Skip to content

Techniques

This page explains how mojo-flatbuffers encodes and decodes, why those choices exist, and which ideas were not used. It describes the shipped code.

The library is written in Mojo. It does not call the C++ FlatBufferBuilder, flatcc, or the Zig zig-flatbuffers runtime. The builder follows the same byte rules as the Python flatbuffers builder, which matches flatc for the schemas in this repository.

The speed target is a generated table: pack writes bytes, and a view reads fields from those bytes. FlexBuffers is a second format. Its reader returns an owned tree, because a FlexBuffers value names its own type and there is no schema to specialize.

What the other languages actually time

The serializer benchmark has a FlatBuffers row in several languages. Two of the fastest dashboard rows do not build the suite schema. The C flatcc row and the C++ flatbuffers row wrap another payload in a single vector. Their times are a builder microbenchmark, not a Document round trip.

The rows that build the suite tables are different:

Language Library What the timed path does
Zig zig-flatbuffers 0.2.1 Builder.writeTable, then decodeRoot plus a copy into the suite value
Java official FlatBuffers One reused FlatBufferBuilder, then an unpack into objects
C# FlatSharp Generated serialize and parse
Swift, Kotlin, JavaScript, Python official generators Generated pack and unpack

Zig is the fastest of those schema rows on the small Document and Telemetry cells. Its read is a view, and the suite still pays for a copy into the host value. That split is why this library has both a view and unpack.

The builder writes backward

Builder keeps one byte block. head starts at the end. Each place stores the next value at a lower address. A uoffset is then a positive distance from the field to a child that was written earlier.

The block doubles when head runs out of room. The live suffix is copied to the end of the new block with one memcpy, so every offset measured from the end stays valid. Padding bytes are written as zeros. clear sets head back to the end and resets the scratch counts. It keeps the byte block and the vtable lists. The next message overwrites the suffix it uses. Bytes below the new head are not part of the output, so clear does not have to zero them.

finish copies the live suffix into a List[Byte] the caller owns, again with one memcpy. Strings and byte vectors are copied the same way. Scalars are stored and loaded as one little-endian machine word, which matches the wire on x86-64. A scalar vector is reserved once by start_vector; each element is then stored with push_* and does not recompute alignment. A string read checks the length and the trailing NUL, then copies the bytes with String(unsafe_from_utf8=) and does not scan them for UTF-8 again. TableRef reads the vtable header once per table. Reusing the builder avoids allocating the scratch block, the vtable list, or the string map on every message. The string map is rebuilt only when string sharing is on.

Default fields are omitted. add_i32 writes a field only when the value differs from the schema default, unless set_force_defaults is on. That is the same rule as flatc. Optional scalars (= null in the schema) are written whenever they are set, including zero.

Vtables are compared, not hashed

A vtable is two uint16 sizes plus one uint16 offset per field. Trailing absent fields are trimmed. Before the vtable is written, the builder scans the vtables already stored in this buffer and reuses one with the same offsets.

The scan is linear. A hash map of vtable keys would allocate on the hot path, and a message has few distinct shapes. The C++ builder uses the same linear compare. The Python builder hashes the key. The bytes match either way. The test test_vtable_dedup checks the shared-vtable buffer against the Python builder.

String deduplication is optional (set_share_strings). It is off by default. The v2 records mostly use each string once, and a map lookup costs more than it saves on that input. Turn it on when one buffer repeats the same text.

Generated code does not interpret the schema

gld-flatc-mojo reads a schema and writes straight-line Mojo. pack calls create_string, start_vector, and add_i32 directly. The hot path does not walk a field list. That is the flatcc idea: the generator does the schema work once.

There are two front ends, and they produce one model.

Front end Input What it does
.fbs parser Schema text Recursive descent. Resolves names, expands a union into a type slot and a value slot, lays structs out.
.bfbs decoder flatc --binary --schema Reads the reflection.Schema table with the same wire reader.

Both paths sort objects by name and fields by id, then the emitter prints Mojo. test_codegen checks that the two strings are equal for the benchmark schema and for features.fbs. flatc --json reads a buffer this library built for Holder, including the inline Point and Mixed structs.

A negative vtable offset is normal. Shared vtables often sit at a higher address than the tables that use them. The soffset is a signed 32-bit value. Mojo's Int(some_i32_call()) can zero-extend that value when the call is written as one expression. The reader binds the Int32 to a name first, then sign-extends it. Without that, a shared vtable looks out of range.

Views and copies

A view holds a Span and the table position. A scalar accessor adds the vtable offset and loads the little-endian value. It does not allocate.

unpack walks the same accessors and builds String and List values. That copy is the honest cost of keeping the data after the buffer is dropped. The Zig benchmark does the same split: decodeRoot, then a domain copy.

The verifier checks the root offset, the vtable size, and that each present field offset lands inside the object. Generated verify_ functions also check string terminators and vector lengths, and they recurse into nested tables up to a depth of 64.

FlexBuffers widths

The FlexBuffers builder writes forward. Each integer uses the narrowest width that can hold it. 7 is one byte. 300 is two. A float that survives a round trip through float32 is stored as float32. Maps sort keys and share key bytes inside one buffer.

The reader understands the widths, typed vectors, fixed vectors of length 2, 3, and 4, blobs, and nested maps. test_read_gold loads the official gold_flexbuffer_example.bin and checks the map the Python reader returns: bar, foo, bool, mymap, and vec.

Ideas that were not used

Idea Why it is not the default
Build forward, then patch offsets Every parent offset would be rewritten. The backward builder makes offsets positive as it goes.
Hash vtables The linear scan is enough for a handful of shapes, and it does not allocate.
Share strings by default Repeated strings are the exception in the v2 records. The switch exists for buffers that do repeat them.
SIMD string copies Suite strings are short. The copy is already a tight byte loop into the builder block.
A reflective packer Walking a field table at runtime is what the generated code avoids.
A FlexBuffers view type Every access would still branch on the type byte. The owned tree matches how the tests check values.

The conda package pins one compiler

.mojoc files match the compiler that wrote them. Build and host require mojo-compiler 1.1.0. The run requirement is pin_compatible with that compiler, so a later 1.1.x can load the bytecode and a 1.0 compiler cannot.