Reaktor FlexBuffer — High-Performance Binary Serialization
reaktor-flexbuffer is Reaktor's Kotlin Multiplatform implementation of Google's
FlexBuffers wire format. It provides two deliberately different capabilities:
- a generated, exact-layout
FlexCodertier built for maximum runtime speed; and - a keyed
kotlinx.serializationfallback built for model coverage and compatibility.
The distinction is the most important fact in this document. The generated path is the performance product. The raw serializer path is the coverage path. They share the same wire format and memory layer, but they do not have the same runtime cost or the same schema guarantees.
This page is the source-backed performance and adoption guide for Reaktor, BestBuds, Manna, JVM servers, Android, iOS, browser and Worker JavaScript, desktop, C++, and future native or WebAssembly boundaries. Refreshed 4 October 2026: the October performance and stability review is the current measurement ledger; the July matrices below remain historical. The complete From Bytes to Mutable Memory book is design input for wire understanding and a proposed mutable runtime, not a list of implemented Reaktor features.
1. Executive view
The current result (3 October 2026)
- Two-fork JVM JMH generated encode/decode costs are
0.487 / 0.276 µs(UserProfile),3.476 / 1.715 µs(ApiResponse), and0.376 / 0.165 µs(TimeSeries). Full decode allocates result models, strings and collections. - The current campaign includes actual Chrome and physical S23 Ultra / iPhone 14 Pro runs. Generated decode performs well; Chrome UserProfile is roughly tied with kotlinx JSON and browser Unicode/StringFlood decode loses in the smoke suite.
- JSON encoding wins several object-heavy cells, particularly in the browser. Native browser JSON works on a different object representation and is reported separately from Kotlin-model reconstruction.
- kotlinx ProtoBuf is smaller in all four wire fixtures. Packed Google Java Protobuf is also measured on the host, including model-conversion costs.
- Caller-owned buffers reduce output allocation; UserProfile caller-owned JVM encoding is slower in this run. A removed copy is not an automatic speedup.
- Physical Android bulk-view experiments show substantial wins at 4,096 elements,
up to
47.31xfor float encode. Production Android bulk actuals still return false and use scalar fallback. Thresholds and release APKs remain to verify. - Five dedicated stability probes produced four failures. Correctness, lifetime, cache concurrency and schema validation precede production rollout.
- Codec timings do not measure FFI hops or a mutable-memory engine. The native hello remains a proof; typed versioned FFI and mutable execution memory are designs.
See the complete tables and downloadable evidence for protocols, allocations, wire sizes, caveats and optimization priorities. Sections 6–9 retain the July campaign and integration evidence; their rankings apply to their own fixtures and schedules.
What is implemented, and what is not
| Capability | Current state | Operational meaning |
|---|---|---|
Generated @Struct coders | Implemented and measured; required-field/layout rejection defect open | Experimental peak-performance tier for version-locked models |
| Generated zero-copy accessors | Implemented and tested | Best for reading a few fields without materializing the model |
| Serializer API acceleration | Implemented | Existing serializer<T>() calls can route to a registered coder |
| Raw kotlinx Flex encoder/decoder | Implemented, including sealed polymorphism; null/default defect open | Coverage path requiring semantic regression checks |
| Caller-owned output buffer | Implemented | Avoids the final ByteArray copy while the builder remains owned and alive |
| C++ byte compatibility | Verified with byte/checksum harnesses | Kotlin and C++ can exchange FlexBuffers bytes |
| BestBuds complex-model benchmark | Implemented in modules/app/src/jvmTest | Measures the real sealed messaging model through raw Flex versus JSON |
| BestBuds production model transport | Not migrated | REST, WebSocket, and caches remain on their existing formats |
| Native bridge proof | Implemented | C++ hello map crosses JNI/cinterop and is decoded in Kotlin |
| Versioned typed FFI protocol | Not implemented | The hello proof is not yet a production RPC contract |
| Manna FlexBuffer integration | Not implemented | Manna remains JSON/GraphQL/Worker-oriented today |
| Exact-layout schema fingerprint | Not implemented | Positional decode must not become the persistence default yet |
The dependency reaches many Reaktor and BestBuds targets, but dependency reach is
not adoption. No production @Struct model call site was found outside the
FlexBuffer module's fixtures and profilers. BestBuds' only non-test FlexBuffer use
is its developer-screen native hello verification. Reaktor service routers,
BestBuds REST/WebSocket/cache paths, Manna, and Reaktor Desktop MCP remain JSON.
The architectural call
FlexBuffers should be Reaktor's fast self-describing byte substrate, not its universal answer to every serialization problem.
Use it when the dominant costs are parsing, allocation, repeated bridge calls, or materializing fields that the consumer will not read. Keep JSON where human inspectability, public APIs, browser-native string/object encoding, or third-party interop is the dominant requirement. Benchmark generated FlatBuffers when both endpoints already require and share a schema; it is expected to remain the encode ceiling for that case.
2. The big-picture runtime model
The high-performance design removes work at four layers:
model
|
| compile time: KSP fixes keys, field positions, types,
| key-blob-relative offsets, and decode geometry
v
generated FlexCoder
|
| runtime: direct builder calls; no descriptor walk or per-field key lookup
v
FlexBuffersBuilder
|
| platform kernels: direct stores, UTF-8 specialization, bulk primitive transfer
v
one contiguous byte buffer
|
| optional boundary: network, storage, JNI, cinterop, JSI, worker, process
v
generated decodeAt / zero-copy accessor / keyed fallback
There are two separate notions of zero-copy:
- zero-copy output handoff means
encodeToBufferreturns a view of the builder-owned backing array instead of copying into a newByteArray; - zero-copy field access means a generated accessor reads fields directly from the encoded bytes instead of constructing the complete data class.
Generated full decode is not allocation-free. It avoids navigation-container allocation, but it still allocates the requested result objects, strings, and materialized collections. That is the correct semantic boundary.
3. Five access tiers and when to choose each
| Tier | API shape | Best use | Main cost or risk |
|---|---|---|---|
| 1. Generated accessor | bytes.asUserProfile().username | Partial reads, routing, filtering, headers, indexes | Buffer lifetime remains visible; repeated collection-wrapper construction must be avoided |
| 2. Direct generated coder | FlexBuffers.decode(UserProfileFlexCoder, bytes) | Lowest-overhead full model materialization | Exact-layout and jointly deployed schema required |
| 3. Registered serializer | FlexBuffers.decode(UserProfile.serializer(), bytes) | Drop-in acceleration at existing serializer call sites | Startup registration must be correct and complete |
| 4. Raw serializer Flex | Same serializer API without a registered coder | Sealed classes, unsupported shapes, compatibility work | Descriptor and keyed navigation overhead |
| 5. JSON | Json.decodeFromString(...) | Public APIs, logs, debugging, browser-native string/object encode | Text conversion, parse, larger numeric payloads, full materialization |
A practical decision tree
Is the payload public, third-party, or primarily human-inspected?
yes -> JSON
no -> Is a mandatory shared schema already part of deployment?
yes -> benchmark FlatBuffers and generated FlexCoder
no -> Is the payload ephemeral and both ends jointly deployed?
yes -> generated FlexCoder + fingerprint when available
no -> raw/keyed Flex or another versioned compatible format
Does the consumer need only a few fields?
yes -> generated accessor
Can the next layer consume bytes before a builder is reused?
yes -> caller-owned builder / encodeToBuffer
Public API examples
Direct generated calls avoid reified lookup and serializer dispatch:
val bytes = FlexBuffers.encode(UserProfileFlexCoder, profile)
val decoded = FlexBuffers.decode(UserProfileFlexCoder, bytes)
The serializer route becomes the same fast path after registration:
UserProfileFlexCoder.register()
val bytes = FlexBuffers.encode(UserProfile.serializer(), profile)
val decoded = FlexBuffers.decode(UserProfile.serializer(), bytes)
A configured module registrar registers the complete generated set once:
ReaktorFlexbufferCoders.register()
The caller-owned path removes the final output copy. The final line below is
pseudocode: the repository does not yet provide one universal ReadBuffer
transport adapter.
val builder = FlexBuffersBuilder(16 * 1024, FlexBuffersBuilder.SHARE_KEYS)
val view = FlexBuffers.encodeToBuffer(UserProfile.serializer(), profile, builder)
// A concrete adapter must consume the view synchronously before clear/reuse.
transportAdapter.consumeReadBuffer(view)
4. Complete optimization ledger
This section records every retained runtime or code-generation change from the campaign. Rejected experiments are recorded separately so they are not accidentally reintroduced.
4.0 Direct memory and container foundations
The later optimizations work because the base reader and writer already avoid several abstraction layers:
| Foundation | Runtime effect |
|---|---|
Platform ld8/16/32/64 and st8/16/32/64 functions | Direct little-endian scalar access without a virtual buffer call per value |
| Signed and unsigned width readers | Sign extension and offset/unsigned interpretation are separate, so a narrow negative is not zero-extended |
| Two-comparison width dispatch | Selects 1, 2, 4, or 8-byte access without a long branch chain |
ByteArray-backed Reference, Map, and Vector | Removes the old hot-path ReadBuffer interface dispatch |
| Precomputed vector type-array base | Avoids recomputing packed-type geometry on every element |
| Lazily cached map key-vector geometry | Positional generated reads do not pay for key-vector navigation; keyed search computes it once |
| Typed-vector mode as a final field | Avoids subclass/virtual dispatch for typed element reads |
Direct builder-owned ByteArray | Stores go straight to the growing output array |
Parallel primitive ValueStack arrays | Avoids allocating one object for every pending builder value |
| Reused scratch values | Avoids temporary result objects while closing vectors and maps |
| One wide zero-padding store | Reduces per-byte alignment padding work |
The platform scalar implementations are intentionally different:
- JVM and Android use unaligned
Unsafeloads/stores when the runtime exposes them, with correct portable fallbacks. - Kotlin/Native uses unaligned Native memory intrinsics.
- Kotlin/JS composes 1/2/4-byte integers without
Long; floating conversion uses shared typed-array views.
These are unchecked hot-path primitives. Structural validation must first prove buffer bounds, depth, and root/container integrity. A separate fingerprint or version-locked out-of-band contract must prove the expected generated layout. Structural validity alone does not establish schema compatibility.
4.1 Generated root and dispatch geometry
| Optimization | Removed work | Why it matters |
|---|---|---|
encodeRoot specialization | Nullable root-key resolution | Every generated root encode starts with the known sentinel key offset |
decode(bytes) specialization | Root Reference and Map navigation | Generated positional decode enters the byte array directly |
decode(bytes, limit) | Copying an array-backed slice | ReadBuffer slices can decode from their actual trailer position |
Explicit encode(coder, value) and decode(coder, bytes) | Reified lookup and serializer discovery | Latency-critical code can call the generated object directly |
| Serializer-name registry routing | Full AbstractEncoder/AbstractDecoder fallback | Existing kotlinx call sites can gain generated speed without changing call shape |
| Direct nested coder calls | Interface and registry lookup for nested structs | KSP emits calls to the concrete nested coder object |
| Root type geometry helpers | Generic root wrapper construction | FlexRead.rootEnd and rootByteWidth read the trailer directly |
Root-kind guard for rootMap | Interpretation of a non-map root | Accessors keep direct entry while checking the packed root kind; this is not full structural validation |
The serializer-routed path is 1.005x the direct generated path geometrically in
the final JVM headline matrix. In other words, registration makes serializer
routing essentially free at the measured resolution.
4.2 Compile-time keys and map layout
Generated maps no longer perform ordinary dynamic key work.
- KSP reads the serialized wire name of every property.
- It sorts the names by unsigned UTF-8 byte order, the order used by C/C++
strcmp. - It emits one null-terminated key blob and compile-time relative starts.
- The builder writes that blob once per type per output buffer.
- Every field uses
keyBase + constant, with no hash lookup or absolute-offset array allocation. - The first map writes a key vector; later maps of that type reuse it.
- The generated coder emits all fields, including nulls, so positional geometry stays fixed.
Retained changes:
| Optimization or correction | Runtime/correctness effect |
|---|---|
keyBlockBase plus compile-time starts | Removes a per-encode IntArray and one offset-array load per field |
| Shared key blob | Removes repeated UTF-8 key encoding |
| Shared key vector | Removes repeated key-vector writes and width scans for repeated maps |
| Pre-sorted generated fields | Removes runtime map sorting |
| Correct relative element index | Fixes nested vector/map width geometry when the stack slice does not start at zero |
| Correct typed-vector range | Checks start + length, not length as an absolute end |
| Unsigned UTF-8 sort and compare | Matches the wire specification and C++ for non-ASCII names |
Property-level @SerialName | Keeps Kotlin identifiers independent from wire names |
Class-level @SerialName | Makes serializer registry routing use the real descriptor name |
| Duplicate wire-name compile failure | Prevents two properties from producing one ambiguous map slot |
The key-block design analytically removes repeated work that the upstream C++ builder retains, so repeated-map shapes are an expected advantage. The current Kotlin/C++ harness does not isolate key-block geometry in a matched A/B, and the unique-string results must not be presented as proof of that specific cause.
4.3 Generated scalar decode
The generated decoder knows each declared scalar type. It therefore uses direct inline readers:
inlineIntinlineLonginlineFloatinlineDouble
Those readers calculate end + index * byteWidth and load the value with the
known signed or floating interpretation. They do not reload the packed type byte
and branch through generic dispatch for each scalar.
KSP also:
- uses the compile-time property count to locate the packed type array;
- hoists child vector ends, widths, and type bases into locals;
- enters nested generated coders through
decodeAt; - reconstructs constructor argument order independently from wire-key order; and
- generates Kotlin-safe identifiers for keyword or backtick-only source names.
This is exact-layout decode. It assumes the same serialized layout that the generated encoder emitted. That assumption is the source of both its speed and its schema-evolution limitation.
Generated accessors use the same fixed layout without constructing the data class. The accessor is a value-class façade over the map bytes, scalar properties read by known position, and collection properties expose lazy vector/map views. Callers should hoist a collection view out of a loop: repeatedly evaluating the property can repeatedly reconstruct the lightweight wrapper even though it does not copy the underlying payload.
4.4 Primitive vector width selection
FlexBuffers may store integer vectors in 1, 2, 4, or 8-byte elements. The width scan now:
- includes the vector-size prefix in the required width;
- knows each primitive type's maximum possible width;
- returns immediately when that terminal width is reached;
- handles
ShortArraydirectly instead of converting it toIntArray; - handles
UByteArray,UShortArray,UIntArray, andULongArraydirectly; and - preserves scalar widening/sign extension for compact vectors whose wire width differs from the destination type.
Early termination is particularly useful for large arrays containing an early wide value. It avoids scanning thousands of later elements after the answer is already fixed.
Signed scalars and signed integer vectors use the smallest width that preserves
two's-complement sign extension, matching C++ WidthI. A value such as -5
therefore occupies one byte rather than eight. Positive signed values whose high
bit would look negative, such as 128 through 255, correctly require another
byte. This improves wire size while retaining cross-language parity, and it is
why reader-first deployment is mandatory for the signed-width upgrade.
4.5 Platform bulk primitive transfer
Natural-width primitive vectors can be copied as contiguous bytes. Compact vectors cannot: each element still needs widening or sign extension.
| Platform | Natural-width policy | Reason |
|---|---|---|
| JVM/HotSpot | Bulk encode for Short, Int, Float, Double; bulk decode for Float, Double; Long encode and integer decode remain scalar | Matched JMH showed C2 scalar loops beat Unsafe.copyMemory for the reverted cells |
| Android/ART | Scalar for all primitive kernels today | Bulk actuals return false; physical ByteBuffer A/B supports a proposed replacement, still unimplemented |
| Kotlin/Native/Apple | Pinned memcpy for all compatible primitive arrays | Native primitive arrays are contiguous and Apple targets are little-endian |
| Kotlin/JS | Typed-array transfer for Short, Int, Float, Double; Long remains scalar | Kotlin/JS LongArray is not a byte-compatible BigInt64Array |
The implementation always has a scalar little-endian fallback. A platform kernel
returns false when the reinterpretation is unavailable or unsafe.
Bounds checks use division:
elementCount <= (byteSize - offset) / elementByteWidth
instead of multiplying elementCount * elementByteWidth. This prevents integer
overflow and, on Kotlin/JS, avoids lowering a hot range check through
Long/BigInt. Under the same Node schedule, that JS change moved TimeSeries
generated encode from 18.362 to 17.008 us/op and raw Flex encode from
23.211 to 20.479 us/op.
4.6 Primitive collection materialization
Generated read-only List<Int>, List<Long>, List<Float>, and List<Double>
fields now materialize into primitive arrays wrapped by specialized
AbstractList implementations.
This avoids storing every primitive as a separate boxed object in an
ArrayList. It improves:
- decode construction cost;
- memory locality;
- allocation volume; and
- primitive traversal through specialized
forEachValuemethods.
Mutable collections and sets still use their required ordinary collection semantics. The optimization does not silently return a read-only wrapper where mutability was promised.
4.7 String encoding and decoding
Strings are target-specific enough that one universal implementation loses.
JVM
- Compact LATIN-1 strings expose a byte backing array when supported.
- Pure ASCII copies that array directly into the output.
- LATIN-1 high bytes expand to two-byte UTF-8 without a UTF-16 intermediate.
- UTF-8 length scans eight backing bytes per iteration and counts high bits.
- Standard
decodeToStringis retained because HotSpot already optimizes it well.
The matched three-fork A/B measured User encode about 5.5% faster and API encode
neutral for the word-at-a-time length scan.
Android
- The portable UTF-8 encoder remains the write path.
- Compact LATIN-1 length scanning is retained after a fully hot API 36 ART
diagnostic measured
7.83 usversus15.21 usfor the portable length loop in the full caller-owned User encode. - Cold/interpreted ART samples were discarded.
That ART scanner diagnostic is recorded in the performance audit, not as a separate retained machine-readable result file.
JavaScript
TextDecoderreads UTF-8 from aUint8Arrayview.TextEncoder.encodeIntowrites a known-length string directly into the output view.- The builder reuses the length it already computed instead of running a second independent encode strategy.
Kotlin/Native
- ASCII encode/decode uses tight direct loops.
- Non-ASCII decode falls back to Foundation
NSStringover pinned bytes. - Known-length ASCII writes avoid the generic encoder.
- The design avoids a Kotlin closure in the hottest ASCII loop.
The tradeoff is code size and platform complexity. It is justified because string conversion dominates object-heavy payloads and each runtime exposes a different optimized primitive.
4.8 Key lookup
Map lookup has two paths:
- ASCII query: compare characters directly to unsigned wire bytes with no allocation;
- non-ASCII query: encode the query once, then reuse the bytes across all binary search probes.
The old non-ASCII path could create a scratch array and re-encode code points for
every probe. The new path changes one lookup from repeated O(log n) encoding
work to one encoding plus byte comparisons.
Map construction and lookup now both use unsigned UTF-8 ordering. This is a
correctness requirement as well as a performance property: signed Kotlin
Byte ordering is not C string ordering.
4.9 Raw kotlinx decoder hot path
The raw fallback is still important because real models may be sealed, contextual, nullable, or outside the current KSP-supported subset.
Its retained optimizations and fixes are:
| Change | Effect |
|---|---|
| Descriptor field-index cache | Maps descriptor index to sorted map position once |
Direct currentMapIndex scalar reads | Avoids a Reference allocation and another binary search |
Direct currentVectorIndex scalar reads | Avoids a Reference wrapper for list elements |
| Direct class/list/map structure entry | Opens the pending map/vector child without materializing a generic reference first |
| Registered nested-coder dispatch | Lets supported nested values escape into generated decode |
| Bulk primitive-array decode | Materializes typed vectors directly |
| Equal-count key verification | Prevents a renamed field from being silently decoded by position |
| Unsigned UTF-8 descriptor ordering | Matches actual map ordering for Unicode wire names |
| Sealed/open structure handling | Correctly reads the kotlinx {type,value} polymorphic envelope |
The sealed-polymorphism correction was found by the BestBuds integration test. It now covers sealed values at the root, as a class field, in a list, and as a map value, including both data-class and data-object leaves.
Treating open-polymorphic descriptors as maps fixes structural navigation; it
does not provide subtype registration. FlexEncoderV2/FlexDecoderV2 currently
use EmptySerializersModule, and the public FlexBuffers API has no module
parameter. Externally registered open polymorphism and custom contextual modules
are therefore unsupported until that API changes. The BestBuds regression proves
the sealed hierarchy handled by its built-in serializer, not arbitrary open
polymorphism.
4.10 Builder ownership, pooling, and copies
The standard encode API returns a fresh ByteArray, so it must copy the final
payload out of the reusable builder. That copy is useful ownership isolation and
cannot be described as free.
encodeToBuffer and buildToBuffer expose the builder-owned view for callers
that can obey a shorter lifetime. The view is invalid after clear or reuse.
Pooling policy:
- the builder pool has 16 CAS-claimed slots;
- each builder starts with 16 KiB, enough for the common headline fixtures without growth;
- a fully warmed 16-slot pool has about 256 KiB of initial buffer capacity before retained growth; slots are allocated lazily;
- contention may allocate a temporary builder instead of blocking; and
- a full pool drops an extra builder for garbage collection.
Encoder/decoder instance pools are platform-specific:
| Platform | Pool primitive |
|---|---|
| JVM and Android | Per-thread ThreadLocal slot |
| JavaScript | Plain module-local slot |
| Kotlin/Native | One CAS-claimed AtomicReference slot |
The Native CAS is a correctness fix. The previous volatile check-then-clear could loan one mutable encoder or decoder to two threads.
The ordinary ReadBuffer.toByteArray copy now respects an
ArrayReadBuffer's offset and limit. This keeps slice correctness while allowing
the generated decoder to avoid that copy entirely when it can consume the slice
in place.
4.11 Reader and safety hardening
Not every retained change was a speedup. Some protect the assumptions required by the fast path:
Blob.get(size)is rejected; the valid range is0 until size.- Invalid numeric strings return the documented zero value instead of throwing.
- Signed typed integers retain their low bits correctly when exposed through unsigned arrays.
ByteArray.toFlexMap()validates that the packed root type is actually a map.- Generated exact-layout decode retains an unchecked trusted-buffer entry after structural safety and exact-layout compatibility have been established.
- Raw float/double bits, NaNs, infinities, signed zero, empty vectors, compact widths, keyed vectors, and unsigned arrays have cross-platform coverage.
- The public root-map helper checks the minimum trailer and packed root kind
only; it does not run full structural validation. Untrusted input must pass
FlexBuffers.isValid(or an equivalent boundary validator) before accessor use, and exact-layout compatibility must still be proven separately.
The performance rule is validate structure once, prove layout separately, then
use the trusted internal path. FlexBufferValidator can establish structural
ranges, depth, and types; it does not prove generated field order. Until layout
fingerprints exist, untrusted bytes must remain on raw/keyed decode unless a
version-locked out-of-band contract establishes the exact layout.
4.12 Registration and incremental code-generation correctness
Registration work does not dominate a hot call, but an incomplete registry can silently move a production call onto the slow path.
The processor now:
- gives every generated coder an idempotent
register()method; - can emit one configured module registrar at KSP
finish(); - sorts registrations deterministically;
- makes that registrar aggregating and records all originating files;
- tracks symbols across KSP rounds instead of generating from only the dirty round;
- refuses serial-name collisions between different coders; and
- uses class-level
@SerialNamefor serializer routing.
The former top-level registerGeneratedFlexCoders() facade was removed because
incremental processing could generate an incomplete facade. Use the configured
module object or an individual coder's register() method.
5. Rejected optimizations
Rejected code is not left in the production path.
| Experiment | Result | Decision |
|---|---|---|
| JVM/Android per-thread builder pool | 2.6-5% slower | Keep the 16-slot CAS builder pool |
| Special-case materialized primitive wrappers during re-encode | Ordinary encode regressed 1.1-3.6%; decoded-model encode regressed 1.3-1.9% | Reverted; retained only as a regression benchmark |
| JVM bulk integer materialization | C2 scalar loop faster, including 1.97x for Short decode and 1.55x for Int decode at 4,096 elements | Keep scalar decode |
| JVM bulk Long stores | Scalar encode 1.32x faster at 4,096 elements | Keep scalar Long encode |
| Android primitive bulk transfer inferred from HotSpot | ART lacks the required object-to-object Unsafe copy; physical public-ByteBuffer A/B is available | Measure public bulk views per type/size; release-APK verification remains open |
These results are a reminder that fewer source lines or a native-looking
memcpy is not automatically faster. The retained policy is cell-specific and
evidence-based.
5.1 Memory and code-size tradeoffs
Runtime speed has priority, but the design does not treat memory as free:
| Choice | Performance benefit | Memory/code tradeoff |
|---|---|---|
| 16 pooled 16 KiB builders | Avoids allocation and common buffer growth | About 256 KiB initial capacity when every lazy slot is warm; larger arrays may be retained |
| Generated coder per model | Removes descriptor/key/type work | More generated source, bytecode/native code, and constant key blobs |
| Primitive-backed read-only lists | Avoids boxed ArrayList elements | Returned primitive array and one wrapper still allocate |
| Caller-owned output | Removes final payload copy | Extends mutable builder lifetime and raises use-after-reuse risk |
| Accessor reads | Avoids full model materialization | Keeps the entire backing buffer alive while any accessor/view remains reachable |
| JS typed-array view | Avoids element conversion | Retains the underlying ArrayBuffer; transferable ownership detaches the sender |
| Native owned result handle | Can avoid a Kotlin copy | Native memory remains outstanding until the exact handle is released |
| Shared keys/key vectors | Reduces repeated wire bytes and work | Builder caches retain key metadata until clear/reuse |
The correct memory metrics are not only B/op. Also track peak live bytes,
pooled retained capacity, native-owned bytes, accessor-held buffers, and bundle
or binary size.
The JMH fat jar is roughly 61 MiB because the module's broad
api(:reaktor-core) dependency pulls unrelated framework stacks. That is a
benchmark/build packaging problem, not evidence that every production
FlexBuffer call consumes 61 MiB at runtime. Benchmark models and runners should
move out of production commonMain, and the dependency surface should narrow.
6. Historical July JVM evidence
The tables in this section describe the 11–13 July campaign. For the current host, actual browser and physical mobile measurements, use the October review. The schedules differ; do not treat cross-campaign movements as a paired optimization A/B.
6.1 Headline matrix
Configuration: JMH 1.37, Temurin JDK 25.0.2, three forks, three 400 ms warmups,
five 400 ms measurements, one thread, fixed 1 GiB heap, GC profiler. Values are
us/op; B/op.
| Case | Direct generated | Caller-owned | Serializer-routed | Raw Flex | JSON | ProtoBuf |
|---|---|---|---|---|---|---|
| UserProfile | 0.824; 3,256 | 0.795; 2,408 | 0.832; 3,264 | 1.699; 4,328 | 1.740; 4,576 | 2.127; 12,744 |
| ApiResponse | 5.218; 23,624 | 5.093; 16,496 | 5.263; 23,624 | 15.371; 29,856 | 21.071; 64,280 | 15.317; 101,112 |
| TimeSeries | 0.534; 8,648 | 0.451; 4,288 | 0.533; 8,648 | 5.158; 26,384 | 43.007; 103,825 | 13.307; 43,496 |
Geometric mean versus direct generated:
| Path | Runtime ratio | Allocation geometric mean |
|---|---|---|
| Caller-owned | 0.926x | 5,543 B/op |
| Direct generated | 1.000x | 8,729 B/op |
| Serializer-routed generated | 1.005x | 8,737 B/op |
| Raw serializer Flex | 3.884x | 15,051 B/op |
| ProtoBuf | 5.734x | 38,270 B/op |
| JSON | 8.818x | 31,258 B/op |
The caller-owned TimeSeries path halves allocation from 8,648 to 4,288 B/op
because the final byte-array copy is a large share of this primitive payload.
6.2 What the baseline-to-final numbers can and cannot prove
The original headline JMH methods returned semantic checksums. The corrected methods return the decoded object to the blackhole. The old TimeSeries checksum traversed its arrays inside the timed region.
Therefore, the campaign's original-to-final headline movement combines harness correction with runtime optimization and must not be quoted as a pure codec A/B. Source and version scope:
- the final cross-format matrix for absolute ranking;
- matched primitive-vector A/Bs for primitive claims;
- matched string-scanner A/Bs for string claims; and
- broad corpus runs for directional ranking, not one-percent deltas.
6.3 The 26-model corpus
The corpus covers nested objects, maps, lists, chat, configuration, telemetry, notifications, commerce, media, search, fitness, finance, graph snapshots, games, IoT, education, logistics, market data, and social graph deltas.
| Direction | Competitor/generated geometric mean | Generated wins |
|---|---|---|
| Encode | Raw Flex 2.151x | 26/26 |
| Encode | JSON 2.128x | 26/26 |
| Encode | ProtoBuf 2.088x | 26/26 |
| Decode | Raw Flex 4.245x | 26/26 |
| Decode | JSON 9.199x | 26/26 |
| Decode | ProtoBuf 6.558x | 26/26 |
These corpus schedules are intentionally shorter: one fork, two warmups, and three 200 ms measurements. They establish the ranking. Changes around one percent are noise until repeated under a longer matched schedule.
6.4 Primitive-vector matched A/B
Representative 4,096-element JVM encode results:
| Vector | Baseline | Final | Speedup |
|---|---|---|---|
| Natural Short | 1.688 us | 0.132 us | 12.77x |
| Natural Int | 2.247 us | 0.241 us | 9.33x |
| Natural Long | 0.308 us | 0.306 us | 1.01x |
| Natural Float | 0.592 us | 0.281 us | 2.11x |
| Natural Double | 0.604 us | 0.415 us | 1.46x |
| Compact Short | 1.462 us | 0.132 us | 11.08x |
| Compact Float | 0.602 us | 0.254 us | 2.37x |
| Compact Double | 0.602 us | 0.262 us | 2.30x |
Across all 20 encode cells, the point-estimate geometric speedup is 2.51x,
with 18/20 wins. Decode/materialize improves 1.07x geometrically because the
returned primitive arrays already dominate allocation.
6.5 Generated accessor evidence
Equivalent full-field accessor work in the expanded JVM report:
| Case | Generated accessor | Generic keyed access | Generated advantage |
|---|---|---|---|
| UserProfile | 0.0748 us | 0.2922 us | 3.91x |
| ApiResponse | 0.5804 us | 2.5970 us | 4.47x |
| TimeSeries | 1.0074 us | 1.0320 us | effectively tied |
TimeSeries is tied because both paths spend most of the operation traversing the
same numeric values. Do not compare these rows with the historical
flexAccessorOnly method: that method intentionally reads fewer fields.
6.6 2026-07-13 re-verification and adversarial suite
The full JMH headline was re-run on 2026-07-13 under the identical schedule:
allocation columns matched byte-for-byte and every within-run ratio held
(absolutes drifted 5-15% with session thermals — exactly the cross-session
variance §15 warns about). The pass added an 11-case adversarial + realistic
suite (AdversarialBenchmarkModels.kt, bench/AdversarialBench.kt) that runs
on JVM, Node, Android instrumentation, and the physical-iPhone bench app.
What it confirmed: generated decode won every cell on every platform — up to 9x over raw Flex on an 80-field map (binary search vs positional), 19x on a 96 KB metrics payload (boxed primitives vs bulk), and 96x over JSON on the same payload's JVM round trip.
What it caught that the 26-model corpus never exposed:
- Non-ASCII string encode loses to kotlinx JSON everywhere (JVM 3.7x, V8 3.7x, ART 1.7x, iOS 1.6x slower): the flex path pays a UTF-8 length-precompute pass plus an encode pass, JSON streams once. On the S23 the length pre-scan alone measured equal to the entire encode pass.
- iOS non-ASCII decode is the single measured decode loss (2.1x slower
than JSON): per-string
usePinned+NSString+ UTF-16 conversion. - The Kotlin/JS builder's Long funnel is broader than the headline suggested: generated encode trails JSON by 2.6-4.1x on every object/string shape on V8.
- Deliberate width-bombing (one
Long.MAX_VALUEforcing 8-wide vectors) costs ~25% extra encode versus raw on JVM and produces smaller wire bytes — the price of optimal width selection, accepted.
Full data: reaktor-flexbuffer/PERFORMANCE_VERIFICATION_2026-07-13.md.
7. Platform-specific evidence and policy
7.1 JVM and Reaktor Server
JVM is the strongest measured target:
- JIT inlines direct generated calls well;
- HotSpot gives selective
Unsafe.copyMemorywins; - compact-string access removes ASCII copies and scans;
- caller-owned buffers materially reduce allocation; and
- generated decode is consistently ahead of JSON and ProtoBuf.
For Reaktor Server and k3s services, the highest-value use is a binary internal contract at high-frequency, jointly deployed boundaries: actor mailboxes, graph snapshot replication, telemetry batches, or native compute calls. Public HTTP APIs should not change format merely because a microbenchmark is faster.
The current Ktor/Spring/service surfaces remain JSON unless a specific binary transport is explicitly negotiated.
7.2 Android/ART
The API 36 arm64 emulator instrumentation suite passed 64/64 tests. Values below
are us/op. The scored debug instrumentation benchmark used a 10,000-operation
unmeasured prime, followed by 500 warmups and three 5,000-operation runs; it
reports the minimum of the three runs.
| Case | Generated encode | Generated decode | Generated RT | Serializer RT | Raw Flex RT | JSON RT |
|---|---|---|---|---|---|---|
| UserProfile | 8.44 | 5.75 | 14.19 | 13.03 | 20.56 | 22.01 |
| ChatThread | 24.24 | 8.76 | 33.00 | 32.43 | 65.20 | 117.89 |
| ApiResponse | 62.47 | 27.04 | 89.51 | 86.87 | 158.54 | 255.64 |
| TimeSeries | 10.92 | 6.79 | 17.71 | 16.65 | 51.55 | 349.86 |
Both direct and serializer-routed generated round trips beat raw Flex and JSON in every case. Direct-versus-routed differences are benchmark noise at this resolution. JSON encode alone remains competitive or faster for some object/string-heavy shapes; generated decode recovers the total.
Do not infer physical-device behavior from this table. It does not include:
- release/minified compilation;
- phone thermals;
- OEM ART changes;
- big.LITTLE core placement; or
- app-level contention.
Physical-device results (2026-07-13, Samsung S23 Ultra / SD 8 Gen 2 / Android 16, debug instrumentation APK, same steady-state schedule):
| Case | Generated encode | Generated decode | Generated RT | Raw Flex RT | JSON RT |
|---|---|---|---|---|---|
| UserProfile | 21.81 | 16.32 | 38.13 | 57.03 | 58.53 |
| ChatThread | 63.17 | 24.99 | 88.17 | 177.17 | 302.90 |
| ApiResponse | 170.25 | 61.47 | 231.73 | 412.24 | 630.72 |
| TimeSeries | 26.48 | 16.16 | 42.64 | 156.54 | 979.23 |
Absolutes are 2.4-2.7x the emulator's; every ranking holds, including JSON's isolated encode win on UserProfile. On-device MicroBench puts ART string materialization at ~300-500 ns per string (~10x the JVM floor) — string decode dominates object-heavy payloads on Android.
The July physical bulk-transfer A/B (PrimitiveBulkAbBenchmark) informed a proposed
kernel policy. It did not implement production kernels:
| Width | 256-elem enc/dec | 4,096-elem enc/dec |
|---|---|---|
| Short | bulk 4x SLOWER | 2.4x / 2.7x faster |
| Int | ~parity | 19.1x / 17.7x |
| Long | ~parity | 12.1x / 11.7x |
| Float | 2.7x / 3.0x | 46.2x / 47.7x |
| Double | 2.7x / 2.8x | 31.0x / 32.0x |
ART's scalar float/double path costs ~26 ns/element; java.nio little-endian
typed views route to native bulk memcpy. Two more physical-ART facts:
sun.misc.Unsafe.copyMemory(Object,...) does not exist on this device, and
String.value/coder reflection fails (StringAccess.ON == false) — the JVM
Latin-1 encode fast path silently never engages on Android, and the measured
UTF-8 length pre-scan costs as much as the encode pass itself. A/B'ing the ART
compact-string ISO_8859_1 constructor against decodeToString for short
ASCII was neutral (0.88-1.08x) — that hypothesis is closed.
Proposed policy, not shipped: benchmark and implement ByteBuffer bulk kernels on Android for
Int/Long/Float/Double at n >= ~64 (Float/Double also at n >= ~32); keep Short
scalar below ~1,024. Adopt single-pass string encode (drop the length
pre-scan) instead of porting the reflection fast path. Release-APK
(testBuildType=release) instrumentation remains the one unmeasured Android
configuration. The October A/B
also improves large Short vectors; thresholds must be established per type and
direction. PrimitiveArrayCopy.android.kt still returns false for every bulk actual.
7.3 Kotlin/Native and iOS
The release iosSimulatorArm64 executable ran eight 100,000-operation batches
per cell; round one was discarded and rounds two through eight were medianed.
| Case | Generated encode | Generated decode |
|---|---|---|
| UserProfile | 2.493 us | 1.165 us |
| ApiResponse | 22.129 us | 11.400 us |
| ChatThread | 8.225 us | 3.783 us |
| TimeSeries | 3.331 us | 0.276 us |
TimeSeries decode is legitimately small because two pinned bulk copies fill the returned primitive arrays. Object/string-heavy payloads still pay Kotlin/Native allocation and string conversion.
Simulator results establish code-generation and allocator behavior on the host CPU. They do not establish device thermals or sustained mobile throughput.
Physical-device results (2026-07-13, iPhone 14 Pro / A16 / iOS 26.5, release
iosArm64 bench executable in a signed app, identical methodology):
| Case | Generated encode | Generated decode | vs simulator |
|---|---|---|---|
| UserProfile | 3.016 us | 1.137 us | 1.21x / 0.98x |
| ChatThread | 10.612 us | 4.585 us | 1.29x / 1.21x |
| ApiResponse | 28.858 us | 14.797 us | 1.30x / 1.30x |
| TimeSeries | 3.831 us | 0.300 us | 1.15x / 1.09x |
Rounds were thermally flat; wire bytes matched the audit exactly. The
simulator matrix is therefore a reliable proxy at a 1.1-1.3x scale factor for
this device class. The adversarial suite added one iOS-specific finding: on
non-ASCII content, generated decode runs 2.1x slower than kotlinx JSON (the
per-string pin + NSString + UTF-16 path) — the pin-once/CFString rework in
the roadmap is what fixes it.
Native-specific operating rules:
- pin for the shortest possible interval;
- perform one bulk operation per vector, not a callback per element;
- define buffer ownership explicitly at C boundaries;
- free native-owned memory exactly once; and
- do not retain a pointer into a Kotlin array after unpinning.
7.4 JavaScript, browser, Node, and Cloudflare Workers
The historical July production Kotlin/JS library ran on Node 22.18.0 / V8 12.4. The October Chrome measurements are actual browser evidence with different decode rankings and native-JSON baselines. The July Node cells below used a 2,000-operation warmup and seven 5,000-operation measured batches.
| Case/direction | Generated | Raw Flex | JSON | ProtoBuf |
|---|---|---|---|---|
| User encode | 14.944 | 19.332 | 4.905 | 20.585 |
| User decode | 2.316 | 5.105 | 7.501 | 11.432 |
| API encode | 82.516 | 134.211 | 51.164 | 119.251 |
| API decode | 10.570 | 38.491 | 55.950 | 85.724 |
| TimeSeries encode | 17.008 | 20.479 | 32.583 | 191.388 |
| TimeSeries decode | 4.028 | 32.877 | 91.812 | 122.302 |
Generated decode wins every cell. The measured kotlinx JSON encoder wins string/object-heavy encode, although this campaign did not isolate the runtime mechanism responsible. Generated Flex wins numeric TimeSeries encode.
Wire sizes in bytes, ordered generated/raw Flex/JSON UTF-8/ProtoBuf:
- User:
833 / 833 / 710 / 473 - API:
7,111 / 7,489 / 8,770 / 5,822 - TimeSeries:
4,340 / 4,340 / 5,807 / 4,157
Node is evidence about production Kotlin/JS on V8, not proof of identical Cloudflare Workers performance. A Worker deployment adds isolate startup, bindings, network copies, and platform-specific limits. Measure the actual Worker before setting a budget.
JavaScript-specific guidance:
- use
Uint8Array/ArrayBufferboundaries rather than arrays of numbers; - avoid per-byte Kotlin loops when converting
ByteArray; - transfer
ArrayBufferownership between Web Workers when possible; - keep 32-bit offsets and metadata out of Kotlin
Long; - use JSON encode where V8 already wins and binary decode/transport where it removes larger downstream costs; and
- normalize
Floatthrough binary32 in cross-codec verification.
7.5 C++ comparison
The normalized adversarial harness compiles the vendored C++ reference with
clang++ -O3 -DNDEBUG -mcpu=native. Kotlin and C++ consume byte-identical
fixtures, read the same fields, and produce the same checksums.
| Case | Kotlin | C++ | Kotlin/C++ |
|---|---|---|---|
| Tiny key decode | 0.175 us | 0.071 us | 2.5x |
| Tiny index decode | 0.063 us | 0.011 us | 5.9x |
| Tiny three-key partial read | 0.175 us | 0.030 us | 5.8x |
| 256 sparse misses | 2.945 us | 2.619 us | 1.1x |
| String table scan | 2.668 us | 0.923 us | 2.9x |
| TimeSeries numeric scan | 1.047 us | 1.010 us | 1.0x |
| 64 random wide-map keys | 4.698 us | 4.406 us | 1.1x |
| 64 sequential indexes | 0.133 us | 0.119 us | 1.1x |
| Unique-string encode, sharing | 64.117 us | 126.929 us | 0.5x |
| Unique-string encode, no sharing | 47.417 us | 57.810 us | 0.8x |
C++ remains the tiny-read floor. Kotlin approaches parity on large numeric and wide-map access and wins the two measured unique-string builder cases.
Do not claim that Kotlin is universally faster than C++. The useful result is that crossing to C++ merely to parse a large FlexBuffer is unlikely to pay for itself; native work should be justified by a real compute kernel, platform API, or already-native producer/consumer.
8. FlatBuffers versus FlexBuffers
The C++ comparison builds semantically equivalent FlatBuffer and FlexBuffer representations for 13 fixtures. All partial and full checksums match.
- FlatBuffers faster encode: 13/13,
6.33xgeometric mean. - FlatBuffers faster partial access: 12/13,
1.17xgeometric mean. - FlatBuffers faster full access: 12/13,
1.13xgeometric mean. - FlatBuffers smaller as raw bytes: 8/13.
- FlatBuffers smaller after an 8-byte schema/version id: 8/13.
- FlatBuffers smaller after including the actual schema: 0/13.
This is not a failure of FlexBuffers. It describes the price of self-description. Charging the full FlatBuffer schema to every payload is an illustrative schema-inclusive distribution comparison, not normal production transport: shared schemas are usually deployed once and amortized across many payloads.
| Requirement | Prefer |
|---|---|
| Mandatory shared schema, maximum encode speed | Generated FlatBuffers |
| Self-describing data, dynamic inspection, schema-less native producer | FlexBuffers |
| Public/debug/human-readable contract | JSON |
| Stable compact schema contract with broad ecosystem | ProtoBuf or another negotiated schema format |
| Partial reads of trusted Reaktor payload | Generated Flex accessor |
9. BestBuds: real complex-model integration
BestBuds now has one JVM integration benchmark around a representative deep, production-hot messaging serialization graph:
MessagingResponse.Messages
-> Chat
-> EntityData
-> 100 ChatMessage
-> nullable reply fields
-> List<MessageReaction>
-> MessageKind
-> ModerationStatus
-> contextual Instant
The fixture contains:
- 100 messages, representing five 20-message older-message pages;
- 246 reactions;
- 33 replies;
- every message kind;
- every moderation state;
- nullable fields;
- Unicode and emoji; and
- the sealed service response envelope.
It uses the exact same MessagingResponse.serializer() for:
- Reaktor's configured kotlinx JSON instance; and
FlexBuffers.encode(serializer, value)/FlexBuffers.decode(serializer, bytes).
The messaging models have no generated FlexCoder, so this is deliberately a
raw fallback comparison, not a generated-tier showcase.
Median result from the passing full BestBuds JVM test suite:
| Operation | JSON | Raw Flex | Interpretation |
|---|---|---|---|
| Encode | 70.85 us | 89.00 us | Flex is 25.6% slower; JSON latency is 20.4% lower |
| Decode | 147.00 us | 87.08 us | Flex is 1.69x faster |
| Round trip | 213.08 us | 172.91 us | Flex is 1.23x faster |
| Wire size | 57,441 B | 40,183 B | Flex is 30.0% smaller |
The test performs correctness round trips before timing, warms each path,
alternates order, takes the median of five samples, writes to a volatile sink,
and emits a machine-readable BESTBUDS_SERIALIZATION_PERF line. It is
diagnostic, not a CI superiority gate.
BestBuds adoption opportunities
| Surface | Current state | Potential benefit | Gate before adoption |
|---|---|---|---|
| Chat WebSocket frames | Existing transport remains non-Flex | Smaller frames and faster mobile decode | Binary negotiation, JS byte-copy removal, schema/version envelope |
| Whole-Chat SQLite cache | Existing cache model remains unchanged | Potentially faster load and smaller BLOB | Real FlexSerializer, fingerprint/keyed fallback, record migration, and accessor-aware storage APIs |
| Android/iOS native calls | C++ hello proof exists | One byte-buffer crossing instead of many JNI/cinterop calls | Stable C ABI, ownership, typed errors |
| Notification batches | Existing service path remains unchanged | Batch decode and fewer allocations | Measure real payload; preserve external provider JSON |
| Telemetry/event batches | Existing ingest remains unchanged | Primitive-vector and batch wins | Backpressure, sampling, schema id, server support |
| Media/search manifests | Existing API remains unchanged | Partial accessor reads | Measure string-heavy encode; JSON may remain better |
The WebSocket path is the strongest immediate product experiment:
- the lower Reaktor Ktor and PartyServer abstractions already accept binary frames;
- current BestBuds chat sends text JSON;
- incoming text is trial-decoded as several possible frame models, so the same text may be parsed repeatedly; and
- the Worker-side PartyServer currently allocates and loops over every byte when
converting between Kotlin
ByteArrayand JavaScript typed arrays.
A useful experiment must first replace those byte-by-byte conversions with verified typed-array bulk transfer. Then use one tagged binary envelope so a receiver identifies the frame kind and decodes exactly once. Mobile and Worker versions require text/binary negotiation and JSON fallback during rollout.
The SQLite layer is structurally capable of choosing TEXT or BLOB by
serializer type, but FlexSerializer is still unfinished. Whole-Chat cache
migration therefore requires a real binary serializer, record/version metadata,
mixed old/new reads, rollback tests, and fingerprint fallback before switching
storage columns.
The immediate lesson is not “replace all BestBuds JSON.” It is:
- the raw binary fallback already improves the expensive decode/round-trip side;
- a supported flattened generated transport DTO may improve it further, but the current sealed/contextual model is not directly supported and needs its own benchmark;
- persistence must wait for schema negotiation; and
- browser/Worker conversion code must avoid per-byte copies or it can consume the codec win.
10. Manna: where FlexBuffers could matter
Manna currently has no production reaktor-flexbuffer call site. Its browser,
Worker, GraphQL, and knowledge-graph surfaces remain JSON-oriented.
That makes the following recommendations opportunities, not shipped claims.
High-value candidate: knowledge-graph snapshot
Manna's checked-in generated BestBuds knowledge-graph source is 749,090 bytes:
about 749 kB decimal or 732 KiB. The unified-graph path performs a GraphQL JSON
read, Worker JSON.parse, transformation, JSON response, browser text/JSON
parse, and full node/edge projection.
A browser-delivered binary companion with dense node, edge, count, and chapter data could:
- reduce parse and materialization time;
- keep numeric node/edge arrays contiguous;
- let the UI read summary/header fields without expanding the entire graph;
- transfer an
ArrayBufferinto a Web Worker; - cache one binary artifact in R2 or browser storage; and
- decode selected views through accessors.
JSON should remain the authoring/debug/export representation. A build step could produce the binary derivative and verify semantic checksums against JSON. Benchmark compressed transfer size and the complete fetch-to-mount path, not only codec time. If the browser immediately recreates the full JavaScript object graph, eager materialization may erase much of the accessor/parse win.
Boundaries that should remain JSON by default
- public GraphQL results;
- MCP tool responses intended for model/human inspection;
- configuration edited by humans;
- third-party APIs; and
- low-frequency data where transport/parse cost is not material.
Use a negotiated binary side channel for graph snapshots or bulk arrays rather than forcing FlexBuffers through a text-first interface.
11. Reaktor-wide target map
| Reaktor target or subsystem | Best-fit FlexBuffer role | Status |
|---|---|---|
reaktorServer / k3s JVM | Internal high-rate messages, snapshots, telemetry batches | Candidate; public/service routes remain JSON |
| Android app targets | Fast binary decode, JNI payloads, offline cache after versioning | Codec ready; product migration not done |
| iOS app targets | cinterop payloads, native-produced vectors, fast decode | Codec and hello proof ready; typed protocol not done |
| Browser workbench | Graph snapshots, worker transfer, partial inspection | Candidate; JS encode tradeoffs require measurement |
| Cloudflare Workers | Binary service binding/DO/queue side channel | Candidate; Node results are not Worker proof |
reaktorDesktop | Large graph snapshots, traces, local tool/native results | Candidate; current MCP/control paths remain JSON |
| Actors and mailboxes | Jointly deployed ephemeral envelopes | Strong fit after fingerprint/version envelope |
| Bus/queues/workflows | High-throughput internal frames | Fit only with rolling-upgrade compatibility |
| ObjectStore/persistence | Compact BLOB; future accessor-aware reads | Blocked on serializer, fingerprint, record migration, and storage/accessor API work |
| Telemetry/analytics | Numeric batches and event envelopes | Strong workload fit; external observability protocols still apply |
reaktor-ffi | Stable one-buffer call/result/error frame | Core intended role; protocol still experimental |
| Reaktor Arena / Unreal/C++ | Native simulation state, commands, observations | Future candidate; benchmark against schema-known FlatBuffers |
| Manna | Knowledge-graph snapshot and worker transfer | Opportunity; no shipped integration |
| BestBuds | Messaging, cache, native, telemetry | Raw benchmark and hello proof only today |
| WebAssembly | Linear-memory offset/length buffers | No wasmJs or wasmWasi target exists today |
12. High-performance FFI design
:::tip Full architecture FlexBuffer-side mechanics include (copies, ownership, envelopes, budgets). The complete FFI architecture — typed actor cells, switches and trunks, cross-language Flow with credit-based backpressure, the C ABI for React Native/Flutter/Python, and gRPC/kotlinx.rpc integration — is specified in Reaktor FFI — Switchboard. :::
12.1 Why byte protocols help FFI
FFI cost is rarely only the codec. It includes:
- boundary transitions;
- object conversion;
- string conversion;
- pinning;
- native allocation;
- copying;
- callbacks;
- exception translation; and
- ownership bookkeeping.
A single byte envelope turns many per-field calls into one boundary crossing:
bad:
JNI call(id)
JNI call(name)
JNI call(tags[0])
JNI call(tags[1])
...
better:
encode one request
cross boundary once
native reads only needed fields
return one result/error buffer
FlexBuffers is useful here because C++, Kotlin, JavaScript, and future languages can inspect the same self-describing bytes without generating a schema-specific binding for every experimental call.
12.2 Current native proof
Today, NativeFlexBuffer.cpp builds:
{
"message": "Hello from FlexBuffer C++",
"answer": 42
}
Android:
builder.GetBuffer()is copied into the returnedstd::vector<uint8_t>;- JNI allocates a
jbyteArray; SetByteArrayRegionperforms the second post-builder copy;- Kotlin decodes them.
iOS:
builder.GetBuffer()is copied into the returnedstd::vector<uint8_t>;- C++ copies that vector into a
malloc-ownedReaktorByteBuffer; - cinterop
readBytescopies it again into a KotlinByteArray; - Kotlin calls
Reaktor_FreeByteBufferinfinally; - Kotlin decodes the copy.
This proves compatibility and ownership cleanup. It is not zero-copy and is not a typed RPC protocol.
The current copy ledger is explicit:
- Android performs two semantic post-builder copies: builder vector to returned
vector, then returned vector to
jbyteArray. - Darwin performs three: builder vector to returned vector, vector to
malloc, thenreadBytesinto Kotlin memory. - Worker/WebSocket helpers currently allocate and perform per-byte loops in both directions.
Removing a bridge copy or byte loop may produce a larger end-to-end gain than another scalar-reader micro-optimization.
The separate reaktor-ffi module also contains an Android positional Flex
payload and Hermes/JSI proof. Its typed argument<T>() path is unfinished,
every ByteBufferTransport method is still TODO, its sync/async result model is
a scalar prototype, and its module registry is mutable startup state. The
transport is a nonfunctional skeleton around useful integration proofs, not a
versioned bytes-in/bytes-out production runtime.
12.3 Required production envelope
A production FFI frame should include at least:
| Field | Purpose |
|---|---|
| protocol magic | Reject unrelated/corrupt bytes quickly |
| protocol version | Negotiate envelope behavior |
| layout/schema fingerprint | Gate exact-layout access |
| module and operation id | Dispatch without string-heavy repeated lookup |
| request/correlation id | Match async responses and traces |
| frame kind and sequence | Distinguish request, result, next, complete, error, and cancel frames |
| deadline/cancellation | Stop native or asynchronous work coherently |
| flags | Payload semantics such as compression, streaming, and compatibility mode; never allocator ownership |
| payload | FlexBuffer body |
| error code/category | Stable error transport across language boundaries |
| optional trace context | Connect native timing to Reaktor telemetry |
The fingerprint must cover serialized names, field types, nullability, nesting, relevant defaults, and positional order. On a mismatch:
- fall back to keyed compatible decode where supported;
- negotiate another protocol version; or
- fail with a typed compatibility error.
Never continue positional decode after a fingerprint mismatch.
12.4 Ownership contract
Every ABI function must answer:
- who allocated the buffer?
- who frees it?
- may the receiver retain it?
- is it mutable?
- how long is it valid?
- what alignment is guaranteed?
- can the call return an empty buffer?
- how is allocation failure represented?
A practical C ABI keeps borrowed request memory and owned result memory as different types. ABI compatibility is negotiated outside the payload:
typedef struct {
const uint8_t* data;
size_t size;
} ReaktorBytes;
typedef struct {
const uint8_t* data;
size_t size;
uint64_t handle;
} ReaktorOwnedBuffer;
uint32_t reaktor_ffi_abi_version(void);
ReaktorStatus reaktor_call(
ReaktorBytes request,
ReaktorOwnedBuffer* response
);
void reaktor_buffer_release(uint64_t handle);
The exact ABI is still a design decision. Before non-test calls, the caller must check the ABI version or capability surface; the protocol version inside the bytes cannot protect against a mismatched C struct or exported function.
Keep three independent compatibility values: the C ABI version, the byte protocol version, and the generated schema/layout fingerprint. A match at one layer says nothing about the other two.
The contract must additionally define:
- the zero-initialized response state;
- response contents after every failure status;
- whether an empty result has a null pointer, zero size, or both;
- whether releasing an unknown or already released handle is idempotent or an error;
- whether release is thread-affine; and
- whether a successful result may be retained across later calls.
Allocator ownership is an out-of-band ABI property. Untrusted wire flags must never decide whether or how native memory is freed.
12.5 Platform boundary guidance
JNI / Android
- Cross once per logical request, never once per field.
- Prefer a direct buffer or an explicitly measured
jbyteArraycopy depending on payload size and lifetime. - Cache class/method IDs outside the hot call.
- Do not retain a direct pointer after the Java/Kotlin owner can move or release it.
- Convert errors into stable status codes plus an optional FlexBuffer detail, not arbitrary C++ exceptions through JNI.
- Benchmark ART on physical release devices.
cinterop / Apple
- Use a stable C ABI even when the implementation is C++ or Swift.
- Pin Kotlin memory only for the duration of a synchronous native read.
- For native-owned output, copy once or wrap with a lifetime object that always calls the paired release function.
- Avoid Objective-C dictionaries and per-field bridging in the hot path.
- Keep autorelease/NSString work outside numeric kernels.
No general Swift-facing typed invocation API exists today. A future Swift/ObjC
wrapper may copy into Data, or use Data(bytesNoCopy:) only when an owning
object retains the native handle and supplies the exact release callback. Every
lazy accessor or view must retain that owner; otherwise a nominal zero-copy path
becomes use-after-free.
JSI / Hermes and browser JavaScript
- Expose
ArrayBuffer/Uint8Array, not JavaScript arrays of boxed numbers. - Avoid byte-by-byte Kotlin conversion helpers.
- Use external/host buffers only with a proven lifetime contract.
- Keep offsets and lengths as JavaScript-safe 32-bit numbers when the protocol allows it.
- In browser Web Workers, transfer an
ArrayBufferrather than clone it when ownership can move; transfer detaches the sender's buffer. This does not describe Cloudflare Worker isolates or Hermes/JSI generally. - Avoid recursively converting an entire FlexBuffer into JSI arrays and objects; expose an immutable buffer/HostObject or convert only demanded fields.
JVM native/server libraries
- A future adapter may expose builder storage to
ByteBufferor a native memory segment; no such universalReadBufferadapter exists today. Implement and measure that adapter before claiming a zero-copy JVM handoff. - Once such an adapter exists, avoid a copy merely to satisfy a layer that can consume the original slice.
- Do not expose pooled mutable storage beyond its lifetime.
C++ / Unreal / native engines
- Use accessors for commands or observations that read only selected fields.
- Batch world/entity updates instead of issuing one bridge call per entity.
- If the schema is mandatory and stable, compare generated FlatBuffers before choosing FlexBuffers.
- Keep the Kotlin graph/runtime authoritative unless profiling identifies a specific native compute kernel.
WebAssembly
No Reaktor Wasm target exists today; the current web target is Kotlin/JS IR. When a concrete browser or WASI consumer justifies one, use linear-memory offset/length pairs and typed-array views. A view becomes invalid after memory growth or allocator reuse, so the ABI needs the same explicit ownership and lifetime rules as native pointers. Do not add Wasm solely to claim another target.
12.6 FFI performance budget
Measure the complete boundary:
encode
+ output copy
+ bridge transition
+ input copy/wrap
+ validation
+ native work
+ result encode
+ return copy/wrap
+ decode or accessor reads
Useful metrics:
- total call latency and p50/p95/p99;
- encode/decode/accessor latency separately;
- bytes and copies per call;
- allocations per side;
- boundary transitions per logical operation;
- validation/fingerprint misses;
- fallback-decode rate;
- native-owned bytes outstanding;
- pool misses and retained builder capacity; and
- result/error ratio.
A codec microbenchmark is necessary, but an FFI decision must use end-to-end numbers.
13. Wire, compatibility, and security rules
Signed integer upgrade
The encoder now writes negative integers at C++-compatible minimal signed widths.
New readers decode old wide-negative buffers. Old Reaktor readers may misread new
narrow negatives because they zero-extended T_INT.
Rolling upgrade order:
- deploy new readers;
- verify old payloads;
- deploy new writers;
- retire old readers; and
- optionally compact/re-encode persisted data afterward.
Existing persisted buffers do not need an eager rewrite merely to be read by the new reader.
Trusted versus untrusted bytes
The following table is the required boundary contract. Current empty-map validator, null/default and generated layout defects must be fixed before claiming the contract is satisfied. Raw/keyed decoding is not a blanket correctness guarantee.
| Boundary | Required behavior |
|---|---|
| External network, file, user input | Validate structure; remain on raw/keyed decode unless exact layout is independently proven |
| Internal buffer after structural validation | Generated path only after fingerprint match or a version-locked out-of-band layout contract |
| Persistence across versions | Check protocol and layout fingerprint |
| FFI result from third-party/native plugin | Treat as untrusted until validated |
| Same-process jointly compiled generated call | Exact-layout path acceptable |
The JVM/Android memory layer intentionally uses unchecked direct loads where available. Structural validation and schema/layout trust are therefore separate, mandatory parts of the API architecture.
Remaining format-parity blockers
The October reproducible failures are immediate blockers: equal buffer-backed maps must hash equally; valid empty maps must validate; explicit null must not become a default; missing required fields and incompatible layouts must reject instead of reading the wrong field. These defects remain unfixed by this documentation refresh.
- full public support for fixed typed vectors;
- deprecated string-vector compatibility;
- additional upstream indirect scalar, reuse, mutation, verifier, Unicode, and golden-binary tests;
- complete KSP handling or compile-time rejection for
@Transient, defaults, unsigned fields, and unsupported nested collection/map shapes; and - a schema fingerprint with compatible keyed fallback.
Additional implementation risks that must remain visible:
- unsupported KSP shapes still reach generated runtime TODO/error branches in some cases instead of failing compilation;
- the raw decoder's shared field-index cache uses unsynchronized cold writes and should be prewarmed or made concurrency-safe before heavily concurrent first use;
- registry writes and clears are startup-only and must not overlap reads;
- pooled builders may retain a giant backing array after an exceptional payload;
- generated access depends on both complete structural ingress validation and independent exact-layout proof; and
- no end-to-end production FFI latency/allocation benchmark exists yet.
14. Adoption playbook
Phase 0: prove the workload
- Capture a representative production fixture.
- Separate encode, decode, round trip, partial read, and size.
- Compare JSON, raw Flex, generated Flex, and a schema-known codec where relevant.
- Verify semantic equality before timing.
- Measure the actual target runtime.
Phase 1: add a versioned DTO
- Keep domain models independent from wire-layout promises.
- Create a transport DTO whose serialized names are deliberate.
- Add
@Structonly to shapes supported by the generator. - Configure the module registrar.
- Test class/property
@SerialName, nulls, collections, Unicode, and numerical edge values.
Phase 2: deploy generated bytes on an ephemeral internal path
- Start with a jointly deployed producer/consumer.
- Add protocol version and fingerprint.
- Validate structure at ingress and check the fingerprint before generated positional access.
- Keep a keyed or JSON fallback during rollout.
- Instrument fallback and decode failures.
Phase 3: remove copies
- Use a caller-owned builder only after profiling shows the final copy matters.
- Pass the view directly to the next synchronous consumer.
- Make builder lifetime explicit in code review.
- For JS/FFI, fix any byte-by-byte conversion before claiming a win.
Phase 4: consider persistence
Do this only after:
- layout fingerprinting;
- reader-first upgrade support;
- migration fixtures across released schemas;
- corrupt/truncated input testing;
- rollback behavior; and
- operational tooling to inspect or convert stored blobs.
15. Benchmark discipline
Every scored setup should:
- verify equality or a semantic checksum before timing;
- keep checksum traversal outside the timed region;
- return the decoded value to a blackhole;
- clear and assert the generated registry for raw-fallback measurements;
- warm each implementation and direction independently;
- alternate or randomize paired ordering;
- report allocation as well as time;
- use UTF-8 byte length for JSON wire size;
- distinguish debug/emulator/simulator from release physical hardware;
- run scored suites sequentially on a cool machine; and
- retain machine-readable artifacts.
The October campaign now retains machine-readable JMH, cross-platform, Chrome, bulk-transfer, wire-size and profiling evidence in the published ledger. Its median and adversarial harnesses still have less rigor than JMH. The following reproducibility gaps refer to the historical July campaign.
Some July evidence is less durable than the JVM JMH artifacts. The final Node table, the JS pre/post range-check comparison, the Native score table, and the ART string-scanner diagnostic are recorded in the performance audit rather than separate machine-readable result files. The Native timing runner relies on the separate cross-platform suites for correctness rather than asserting equality inside every timed cell. Treat this as a reproducibility gap to close, not as a reason to discard the current ranking.
Avoid:
- timing setup or fixture generation;
- mixing partial reads with full materialization in one score;
- comparing C++ and Kotlin on different work;
- reporting the best single noisy iteration;
- interpreting a one-fork one-percent movement as a regression;
- benchmarking an Android case before ART finishes background compilation; and
- running JMH forks while Gradle, simulators, or native linkers contend for the same machine.
16. Reproduction and evidence
From /Users/ovd/dev/reaktor:
# Compiler/code-generation correctness
./gradlew :reaktor-compiler:test
# Cross-platform codec correctness
./gradlew :reaktor-flexbuffer:jvmTest
./gradlew :reaktor-flexbuffer:jsNodeTest
./gradlew :reaktor-flexbuffer:iosSimulatorArm64Test
./gradlew :reaktor-flexbuffer:testDebugUnitTest
# Build and run configured JVM JMH
./gradlew :reaktor-flexbuffer:jvmBenchmarkBenchmarkJar
./gradlew :reaktor-flexbuffer:jvmBenchmarkAllocationStats
# Android/ART connected target
./gradlew :reaktor-flexbuffer:connectedDebugAndroidTest
# Production Kotlin/JS runner
./gradlew :reaktor-flexbuffer:compileProductionLibraryKotlinJs
REAKTOR_JS_BENCH_WARMUP_ITERATIONS=2000 \
REAKTOR_JS_BENCH_ITERATIONS=5000 \
REAKTOR_JS_BENCH_WARMUP_ROUNDS=1 \
REAKTOR_JS_BENCH_ROUNDS=7 \
node --input-type=module -e \
"import('./reaktor-flexbuffer/build/compileSync/js/main/productionLibrary/kotlin/reaktor-reaktor-flexbuffer.mjs').then(m => m.runJsFlameChart())"
# C++ semantic verification and scored run
clang++ -O3 -DNDEBUG -mcpu=native -std=c++17 \
-I .github_modules/flatbuffers/include \
reaktor-flexbuffer/cpp/bench/flatbuffers_vs_flexbuffers.cpp \
-o reaktor-flexbuffer/build/tmp/flatbuffers_vs_flexbuffers
reaktor-flexbuffer/build/tmp/flatbuffers_vs_flexbuffers --verify-only
reaktor-flexbuffer/build/tmp/flatbuffers_vs_flexbuffers \
--warmup 1000 --iters 10000 --runs 7
Current review evidence: reaktor-context/flexbuffer-review-2026-10-03/, with a
module mirror in reaktor-flexbuffer/PERFORMANCE_REVIEW_2026-10-03.md and the
published JSON ledger.
The October harness commands and device packaging belong to the retained review
overlay; the commands above describe the module's standard benchmark entry points.
Historical July local evidence:
reaktor-flexbuffer/PERFORMANCE_AUDIT.md
reaktor-flexbuffer/build/reports/benchmarks/reaktor-flexbuffer-jmh-baseline.json
reaktor-flexbuffer/build/reports/benchmarks/full-2026-07-11/
reaktor-flexbuffer/src/jvmBenchmark/
reaktor-flexbuffer/src/commonTest/
reaktor-flexbuffer/src/jvmTest/
reaktor-flexbuffer/cpp/bench/
BestBuds integration:
cd /Users/ovd/dev/bestbuds
./gradlew :app:jvmTest --no-daemon --console=plain
The machine-readable BestBuds line appears in:
modules/app/build/test-results/jvmTest/
TEST-ai.bestbuds.app.SerializationPerformanceIntegrationTest.xml
17. Remaining roadmap
Refreshed 4 October. The current review plan owns the measured priorities and qualifications:
- Fix all four reproduced correctness defects, cold cache concurrency and pooled retention. Add explicit wire/model regressions and reject unsupported KSP shapes.
- Generate fingerprints, required/default/null semantics and keyed fallback before rolling upgrades, persistence or independently deployed FFI. Generate count/element traversal to avoid repeated view wrappers. Use a compiler plugin where KSP cannot implement the required specialization, with incremental-build and size checks.
- Implement physical-device-backed Android ByteBuffer kernels; measure type/direction thresholds and release-APK end-to-end behavior. Keep compact widths and fallback.
- Address JS object encode with 32-bit metadata and typed-array boundaries; measure single-pass UTF-8 and pin-once/native string work rather than assuming a win.
- Integrate explicit buffer ownership with the FFI frame design, then benchmark actual loopback/JNI/cinterop/JSI hops, queueing, cancellation and tails.
- Benchmark C++/SIMD for bounded large batches or scan/filter kernels, including conversion, crossing and lifetime costs. Partial native reads are not full decode.
- Prototype the book's mutable-memory design separately from immutable FlexBuffers wire snapshots. Establish traversal, mutation, footprint and concurrency baselines before adopting cells/slabs/handles.
- Run ephemeral BestBuds DTO and Manna snapshot experiments only after the correctness gate. Keep fallback/evolution, real consumer semantics and actual target measurements.
- Complete upstream parity and benchmark packaging cleanup; cap retention and measure sharing, generated code size, cold startup, allocations and p95/p99 alongside speed.
The July speculative optimization queue remains in the historical module audit; neither it nor this roadmap is a claim that the proposed work is implemented.
18. Source map
| Concern | Primary source |
|---|---|
| Public encode/decode APIs | reaktor-flexbuffer/.../core/FlexBuffers.kt |
| Coder contract and registry | reaktor-flexbuffer/.../core/FlexCoder.kt |
| Serializer fallback | reaktor-flexbuffer/.../core/FlexEncoderV2.kt, FlexDecoderV2.kt |
| Builder and key blocks | reaktor-flexbuffer/.../flatbuffers/FlexBuffersBuilder.kt |
| Reader views | reaktor-flexbuffer/.../flatbuffers/FlexBuffers.kt |
| Generated direct reads | reaktor-flexbuffer/.../flatbuffers/FlexRead.kt |
| Primitive transfer | PrimitiveArrayCopy.kt plus platform actuals |
| UTF-8 specialization | FastDecode.kt plus platform actuals |
| Builder pool | core/FlexBufferPool.kt |
| Encoder/decoder pool | core/PerPlatformPool.kt plus platform actuals |
| Primitive-backed lists | core/FlexCollections.kt |
| KSP generation | reaktor-compiler/.../FlexCoderProcessor.kt |
| JVM JMH | src/jvmBenchmark/... |
| Cross-platform harness | src/commonTest/.../CrossPlatformBenchmark.kt |
| C++ comparison | cpp/bench/ |
| Native hello proof | cpp/common/NativeFlexBuffer.*, Darwin/Android bridges |
| BestBuds integration | modules/app/src/jvmTest/.../SerializationPerformanceIntegrationTest.kt |
19. Rules to keep
- Generated coders are the fast tier; raw serializer Flex is the coverage tier.
- Register coders once during single-threaded startup.
- Validate untrusted structure and independently prove exact layout before unchecked generated access.
- Keep exact-layout payloads ephemeral until fingerprint fallback exists.
- Use accessors when the consumer needs only part of the object.
- Use caller-owned builders only with an explicit lifetime.
- Cross an FFI once per logical operation, not once per field.
- Keep JSON for public, human, and browser-native string/object use cases where it remains the better engineering choice.
- Benchmark FlatBuffers when a schema is already mandatory.
- Make every performance claim target-, direction-, workload-, and methodology-specific.
Related docs
- October performance and stability review — current measurements, failures and priorities.
- From Bytes to Mutable Memory — the full supplied book, source listings and proposed mutable-memory capstone.
- Reaktor FFI — Switchboard — the canonical FFI document this format underpins: current code, typed cells, trunks, cross-language Flow, C ABI, Hermes runtime, gRPC/kotlinx.rpc integration, and roadmap.
- Performance and Observability — profiling, telemetry, budgets, and production evidence.
- Reaktor Core — platform features, adapters, dispatchers, atomics, and serialization foundations. runtime tradeoffs.
- Reaktor Server — service and backend boundaries where binary transport must remain an explicit decision.
- Reaktor DB — persistence and sync, where schema evolution is mandatory.
- FlexBuffer Improvement Plan — historical redirect to this implementation ledger.