Files

60 lines
3.6 KiB
Markdown

# PureBasic - MsgPack - Performance investigations
Open questions raised by the [results](readme.md#results), with leads to check. \
Nothing below is confirmed yet, update or remove each entry once it is looked into.
Measured on 2026-11-03, see the results' CSVs in `Results/`.
## Strings are slow compared to binary data
x64 with the C backend, PureBasic 6.41, ns per field:
| Payload | Binary write | String write | Binary read | String read |
|:--|--:|--:|--:|--:|
| 1 KB, UTF-8 | 190.6 | 720.9 (3.8x) | 50.2 | 570.0 (11.4x) |
| 1 KB, UTF-16 (2 KB) | - | 526.0 | - | 282.7 |
| 64 KB vs 1 MB | ~5.0 GB/s | ~1.4 GB/s | ~19 GB/s | ~2.1 GB/s |
* UTF-16 is faster than UTF-8 while being twice as big, so the encoding itself costs a lot.
* MessagePack-CSharp reads 1 KB strings in 150 ns, and writes 8 B strings in 13 ns against 60 ns here.
* PureBasic 6.21 is another 2.5 to 4 times slower, see [PureBasic 6.21 vs 6.41](readme.md#purebasic-621-vs-641).
### Leads in `Includes/_Strings.pbi`
* `String.s` is passed by value through `MsgPackWriteString`, `_MsgPackGetEncodedStringSize` and `_MsgPackWriteStringField`,
which may copy it at each call, and would explain why 6.40's string handling helps that much. \
Passing `@String` and its length to the private helpers would avoid it.
* Each write goes over the string at least twice, with `StringByteLength()` then `PokeS()`. \
For `#PB_Unicode` and `#PB_Ascii`, the size could come from `Len()` instead, if it doesn't scan the string.
* `PokeS()` is given `-1` as its length, so it may also look for the null terminator on its own.
* `MsgPackReadString` builds the string with `PeekS()`, then returns it, which may copy it again.
### Leads in the benchmark
* `EncodeStrings()` preallocates `FieldSize * 2 + 5` bytes per field, 64 MB in total, against 32 MB for binaries. \
The allocation is timed, see the next section, and `MsgPackFinalize` then shrinks it by half.
* `ScenarioStringRead()` calls `Len()` on each string, which may scan it on 6.21 and not on 6.41. \
Python's `len()` and .NET's `Length` don't scan.
### Experiments to run
1. Time raw `PokeS()`/`PeekS()` loops with the same sizes, without MsgPack, to split PureBasic's own cost from the include's.
2. Size the strings writer like the binaries one, and compare.
3. Try the `@String` + length helpers, and `Len()` for the fixed-width encodings.
4. Run 6.40 too, to check that the gain comes from 6.40 and not 6.41.
## Buffers are zero-filled
`MsgPackCreate` uses `AllocateMemory()` and `MsgPackGrow` uses `ReAllocateMemory()`, both without `#PB_Memory_NoClear`,
so every buffer is zeroed before being written over.
* That cost is in every write scenario with a preallocated buffer, more so for the big ones.
* With a `GrowthIncrement` of `1`, small writes take ~440 ns against ~12 ns preallocated,
the reallocation and copy probably cost more than the zeroing, but it adds up.
To try: `#PB_Memory_NoClear` on both, as only the written bytes are ever read, then re-run every folder.
## Smaller oddities
* **Maps:** writing them is 8.6 times slower than MessagePack-CSharp and 1.9 times slower than msgpack. \
Each map is written in its own structure, then checked, counted and copied by `MsgPackWriteMapFrom`, see the main readme.
Its 16 keys are strings too, see above.
* **ASM backend:** skipping small fields is 3.2 times slower than with the C backend (57.9 vs 18.3 ns), against 1 to 2 times for the rest.
* **6.21 faster than 6.41:** skip (14.7 vs 18.3 ns) and 1 KB binaries (0.8 to 0.9x). \
Those runs only last 1 to 2 ms, so it may be noise, re-run them with more iterations before looking further.