Techniques¶
This page explains how mojo-messagepack encodes and decodes. It is a description of the shipped code. Each section names the problem, why this library uses a given method, and what you give up by using it.
The library is written in Mojo. It does not call msgpack-c, mpack, rmp, or
MessagePack-CSharp. The methods below are ports of ideas from those libraries
and from the sibling
serializer-benchmark
clients (C mpack / msgpack-c, Go msgp and shamaton array mode,
MessagePack-CSharp IntKey, glaze-style typed skip).
The timed path is a generated struct (MsgpackDatum) with encode_into and
decode. MsgpackValue (the dynamic tree) is not that path.
Numbers on this page come from the local benches/microbench.mojo on this
host. They are useful for a compile-test loop. They are not an official
cross-language ranking. There is no Mojo MessagePack row in
serializer-benchmark yet.
On the generated Message record, a 2026-09-10 decode pass moved decode
from about 613 ns to about 169 ns. A following one-hour encode pass
moved encode from about 61 ns to about 14 ns on the same local
microbench (three later runs sat at 14–15 ns). That encode change is a
direct buffer cursor: the generated encode_to writes headers, keys, and
small values into w.buf itself instead of calling write_int /
write_fixstr once per field.
Two paths¶
A generated struct is a Mojo type that the CLI gld-msgpackgen-mojo
writes from a JSON Schema. It has three methods:
| Method | Role |
|---|---|
encoded_len |
How many bytes the value will occupy |
encode_to |
Write those bytes into a WireWriter |
decode_from |
Read those bytes from a WireReader |
Object keys are known at generate time. The encoder writes them as constants. The decoder expects the same key order that this library writes. Extra keys are skipped. Missing required keys are an error.
Problem this solves. A general decoder does not know the next field. It
must read a key, look it up, then read a value. That lookup is a hash map or
a long if chain, and the key is often a heap String. A generated decoder
already knows the schema, so it can compare the next bytes to "f_bool" and
move on.
Trade-off. If another program writes the same fields in a different order, the fast path fails and a slower generic loop runs. The bytes are still accepted. You pay the fast-path probe, then the generic walk.
MsgpackValue is an arena: one list of nodes, plus side lists for
strings, raw bytes, and extensions. It can hold any well-formed MessagePack
object without a schema. It allocates more. It is the right tool for unknown
data. It is not the speed target.
Encode¶
Encode means “turn a Mojo value into MessagePack bytes.”
Pre-sized buffer¶
WireWriter holds a List[Byte] and a cursor pos. A store writes
buf[pos] and adds one to pos.
Problem. If the list’s length starts at 0, every store may grow the list. Growing copies the old bytes to a larger allocation. Doing that once per byte is much slower than writing into space that is already there.
What we do. encode calls encoded_len first, then constructs the
writer with that length, not only a reserved capacity. encode_into
reuses a caller list so a loop of encodes does not allocate a new list each
time. That is the same idea as Go vmihailenco/msgpack Encoder.Reset and
C mpack_writer_init with a caller buffer.
Trade-off. encoded_len walks the value twice (once to count, once to
write). For a small Message that extra walk is cheap next to an allocation.
encode_into skips the count when the destination is already large enough.
If the count is slightly high, finish trims the cursor. If it is low, a
mid-write ensure grows the list.
Shortest prefixes¶
MessagePack has several ways to write the integer 1: one byte 0x01, or
0xcc 0x01, or a four-byte form, and so on.
Problem. A decoder must accept every legal form. An encoder that always used the widest form would write larger messages. Larger messages take more time to write and to read.
What we do. Default encode picks the shortest legal prefix. Integers
0…127 are one byte. Strings shorter than 32 bytes use fixstr. Maps and
arrays of at most 15 items use the one-byte header. That is the analog of
CBOR “preferred serialization.”
Trade-off. The encoder has a chain of range tests. Those tests cost a few comparisons. The suite records are small integers and short names, so the first or second branch hits. An identity mode that preserved overlong prefixes is not in v1.
Baked keys and memcpy¶
A generated map must write the field name "f_bool" on the wire. In
MessagePack that is a type byte 0xa6 (fixstr of length 6) plus the six
ASCII bytes.
Problem. Calling write_str("f_bool") on every field walks a String,
computes a header, then copies. Doing that eight times per Message is
pure overhead: the name never changes.
What we do. The generator emits write_fixstr("f_bool".as_bytes()) or,
for names of at most 7 bytes, write_lit(UInt64(…), n) which stores the
header and the name as one integer. Payload strings and bin values still
use memcpy after the header. C mpack_write_cstr and Go msgp do the
same for known keys: the name is data in the program, not something parsed
at run time.
Trade-off. The generated source contains magic integers. They are
correct only for shortest-form keys. A peer that wrote the same name as
str 8 would not match the fast decode path (the generic path still
accepts it). write_lit must not ensure(8) when only 7 bytes remain in
an exact-size buffer: that would grow the list on every encode. The shipped
write_lit ensures n bytes and uses a byte loop if eight bytes would
run past the current length.
Array and integer-key maps¶
MessagePack-CSharp’s fastest path writes structs as arrays or as maps whose
keys are 0, 1, 2, …. Go msgp and shamaton MarshalAsArray do the same.
Field names disappear from the wire.
Problem. String keys are easy to debug and match JSON. They also waste
bytes and time: every record repeats "f_float64".
What we do. Default write is a string-key map. A schema may set
x-msgpack-encoding to array or intkeys. Then generated code writes
an array in property order, or a map with integer keys.
Trade-off. Array encoding is smaller and faster. It is not JSON-compatible and it is positional: a new field in the middle shifts every later index. Integer keys need both sides to share the numbers. That is why the default stays string keys.
Unrolled encode_to (no per-field helpers)¶
Problem. After keys were baked, encode of Message was still about
60 ns. Timing encode_into against a reused encode_to on the same
writer gave the same number. The wrapper was not the cost. Each field
still called write_lit, write_int, or write_fixstr. Those helpers
check length, update w.pos, and return. Eight fields meant many small
calls to write 93 bytes.
What we do. The generator emits one encode_to that keeps a local
cursor p. After w.ensure(512) it stores the map header as one byte,
stores short keys as little-endian UInt64 words, stores fixints as one
byte, and stores a float 64 as 0xcb plus an 8-byte byteswap. A bool
that follows a 7-byte key is fused into that same 8-byte word (0xc2 or
0xc3 in the last byte). Nested lists of floats (telemetry) use the same
float store in a loop. Go msgp generated encoders do this: one
function, no per-field helper.
Trade-off. The generated file is longer and harder to read. A bug in
the cursor (p not written back to w.pos before a helper) would
overwrite bytes. Helpers still run for wide integers, long keys, and
nested structs. encode_into keeps dest at least 512 bytes so those
UInt64 stores do not run past the list. Callers must use the returned
count as the live prefix; decode of the whole list would see leftover
bytes from an earlier larger value.
Unrolled multi-byte stores¶
Floats are eight IEEE 754 bytes after 0xcb. A loop of eight shifts is
correct and short.
Problem. A tiny loop has a counter, a branch, and a shift on every byte. For telemetry (32 floats) that loop is most of encode.
What we do. write_f64 and write_be for 2, 4, and 8 bytes are
unrolled: eight assignments, no loop. read_be is unrolled the same way
on decode.
Trade-off. More source lines. The CPU prefers straight-line stores for this width. We did not unroll 1-byte and 3-byte forms; they are rare on the suite.
Decode¶
Decode means “turn MessagePack bytes into a Mojo value.”
256-way first byte¶
Every MessagePack object starts with one type byte. 0xc2 is false. 0xa6
is a 6-byte string. 0xc1 is unused and is always an error.
Problem. A decoder that does not look at that byte first must try several parsers (“is this an int? a string?”). That is wasted work.
What we do. The reader branches on the first byte, the same way
mpack and msgpack-c do. skip_value uses the same table so it can
jump over an unknown field without building a value. 0xc1 is
KIND_UNUSED on every path.
Trade-off. The function is long. A computed goto or a 256-entry
function table would be even more direct; Mojo 1.0 does not give us that
cleanly, so we use if / elif on integer ranges (0x00…0x7f is
positive fixint).
Expected-order keys, no String for the name¶
Problem. The old generated decoder did this for every field:
- Read the key as a
String(heap allocation). - Compare that string to
"f_bool", then"f_int32", and so on. - Read the value.
Eight keys meant eight allocations before any useful field was stored. That
was most of the 613 ns Message decode.
What we do. After reading the map header, the decoder tries
_decode_expected. That method calls try_eat_fixstr("f_bool".as_bytes()),
which compares the next bytes to 0xa6 plus f_bool and advances the
cursor. No String is built. Then it reads the value. If any key is
missing or in another order, it rewinds and runs the generic loop (which
still allocates keys).
This is glaze’s typed skip-DOM idea, also used in the sibling mojo-json client: the schema is known, so a tree or a hash map is extra work.
Trade-off. The fast path assumes this library’s encode order and
shortest string keys. Interop with Python msgpack.packb of a dict may
use a different key order. Then you pay a failed probe plus the generic
loop. The probe is a handful of byte compares, so that case is still
correct and only slightly slower than the old decoder.
Why strings decode is still expensive¶
The strings suite record is 32 owned String values. Each one is a heap
allocation. Expected-order keys do not help those payloads: the keys are
few, the values are many.
Trade-off. A zero-copy view (StringSpan into the input) would avoid
those allocations. The public field type is still owned String, because
the caller can drop the input buffer. Views are a later item in DESIGN.md.
Floats on "number" fields¶
JSON Schema "type": "number" is Mojo Float64 and is written as
float 64 (0xcb).
Problem. Python msgpack.packb(1) writes an integer. A decoder that
only accepted 0xca / 0xcb would reject that input on f_float64 and
on telemetry values.
What we do. Generated Float64 fields call read_as_f64, which
accepts any integer prefix plus float 32 and float 64.
Trade-off. read_as_f64 has more branches than read_f64. Suite
telemetry is all float 64 after our encoder, so those extra branches
rarely hit on the timed path. They exist for interop.
Ideas that were measured and dropped¶
Each row was implemented, compiled, and timed on
benches/microbench.mojo. Only measured wins stayed.
| Idea | Source | Outcome |
|---|---|---|
| Pre-size writer length | yyjson, glaze, mpack caller buffer | Kept. Reserve-only resized on every byte. |
encode_into reuse |
vmihailenco Reset |
Kept. Timed encode path. |
Baked write_fixstr / write_lit |
Go msgp, mpack known keys |
Kept. Small encode win. |
Expected-order try_eat_fixstr |
glaze, mojo-json | Kept. Largest decode win (613 ns → ~169 ns on Message). |
| Unrolled 2/4/8-byte I/O | msgpack-c / mpack stores | Kept. |
Generated cursor encode_to |
Go msgp |
Kept. Message encode 61 ns → 14 ns. |
Byteswap UInt64 float store |
one store instead of eight | Kept. Telemetry encode ~320 ns → ~40 ns. |
| Fuse 7-byte key + bool | MessagePack-CSharp packed fields | Kept. One UInt64 store. |
encode_into keep 512-byte dest |
mpack / vmihailenco reuse | Kept. Needed so word stores do not grow. |
write_lit ensure(8) always |
guessed for u64 store | Grew an exact-size dest every encode. Now ensures n and falls back to bytes if 8 would run past the end. |
Runtime u64 rebuild of a key on every try_eat_fixstr |
mojo-json word compare | Slower than a 6–10 byte loop. Reverted. The compare is scalar. |
| Always encode structs as arrays | MessagePack-CSharp IntKey, shamaton | Faster and smaller. Not the default. Schema can request it. |
| Wrap msgpack-c | C bench client | Rejected. Would measure someone else's runtime. |
| Input NUL padding | simdjson padded input | MessagePack has no whitespace scan. Copy cost, little gain. Not added. |
| Identity encode (keep overlong prefixes) | CBOR identity | Out of v1. |
What is still expensive¶
strings decode is about 2 µs for 32 heap Strings. The key walk is not
the bound. EmberJson’s JSON path has the same allocation shape.
encode of Message is about 14 ns on the local microbench. strings
encode is about 67 ns because each of the 32 values is still a
String.as_bytes() plus a copy. Array encoding of structs would shrink
the key bytes further and would change the default wire.
A tape (classify the whole buffer, then copy) would help strings and
hurt message, where the schema is already known.
How to read the local numbers¶
The command prints nanoseconds per encode and decode, and the byte size. Run it twice. The first run includes compile. Compare medians of later runs on the same machine.
A faster encode that wrote wider integers would make size larger and could
make decode slower. The shortest-prefix writer is kept because size stayed
93 bytes for the local Message record.