This page records every optimization tried against a cell that is below its baseline in BENCHMARKS.md, whether it was kept or rejected, and why. It exists so the same ground is not re-ploughed: several of these attempts look obviously right on paper and measure worse on a real machine.
Read BENCHMARKS.md first for the standard cases, the harness names and the current numbers. This page only adds the experiments.
Same method as the core's notes in BENCHMARKS.md:
base and vN).Ab loads the same
matrix3.desc, builds the 136-byte matrix3 x0 message, warms 2 s, then
reports the best of 5 passes of 200k GenericCodec.encode(m) calls.
-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining confirms whether the
candidate still inlines.BENCHMARKS.md lists protobuf-java 58.3 ns / 136 B against pbwire-java
75.1 ns; the Kotlin binding drives the same codec (Message.encode() ->
GenericCodec.encode), which is why it sits on the same floor (0.80x).
Baseline in the A/B probe: 74.3-82.0 ns per encode (the session drifted later; only in-round comparisons below are meaningful).
Measured by ablation: each variant removes one kind of work from
encodeInto() and the rest stays. Bytes differ in those runs, so they are
measurement only - the real benchmark gates on byte equality first.
| removed from the encode | ns/op | cost of that part |
|---|---|---|
| nothing (base) | 74.3 | - |
| all singular numeric writes | 45.6 | 28.7 |
| both string fields | 63.4 | 10.9 |
the nested inner message | 68.1 | 6.2 |
| all field tag bytes | 69.1 | 5.2 |
the Arrays.copyOf result copy | 76.2 | ~0 (inside the noise floor) |
The remainder, 23.3 ns, is the per-field skeleton alone: 22 iterations of
has[] load, Field load, label/kind/presence tests and dispatch, with no
byte written at all - 1.06 ns per field. protobuf-java has no such loop: its
generated writeTo is straight-line code with compile-time offsets.
| # | candidate | result | why |
|---|---|---|---|
| 1 | unrolled branch-tree varint writer, putVar32U/putVar64U (the C core's shape: test, store, shift, repeat) | 105-128 ns (+35..+55%) | putVar32U grew to 200 bytecode bytes and still inlined, putVar64U to 437 and stopped inlining ("hot method too big"). The call per varint costs more than the loop it removed. |
| 2 | same loop, but plain a[i++] = ... stores instead of Unsafe.putByte | 91-140 ns (+15..+70%) | the Unsafe byte store is at least as cheap as a bounds-checked array store; the int cursor also adds a cast per store. |
| 3 | packed varint: build the 8-byte image with shifts/ors, one Unsafe.putLong, then advance by the computed byte count (2-5 bytes through putInt) | 120-161 ns (+60..+95%) | stays inlined (144/250 bytes) and is still far worse in the real encoder: the packing chain adds register pressure to an already large inlined encodeInto, so the isolated microbenchmark result (0.33 vs 0.41 ns/byte) does not transfer. |
| 4 | primitive mirror arrays on MessageDef (kind[], presence[], repeated[], tags[]) instead of pointer chasing through Field | 84-155 ns (+5..+70%) | four arrays with four bases cost more loads than two loads plus the Field fields the JIT already had in L1. |
| 5 | inline the singular ref path (string/bytes/message) from writeRefVal() into encodeInto() | 85-98 ns (+9..+15%) | removes three call boundaries but enlarges encodeInto, which is already the only reason writeRefVal sometimes fails to inline. |
| 6 | drop the f.repeated branch and the v == 0 && IMPLICIT test (measurement only: the x0 payload has no repeated fields and no zero values) | 76.0 vs 75.6 | neutral. These per-field branches are not the cost, which is why a precomputed action table would not have paid off either - it was dropped before being built. |
Two reusable facts came out of this:
encodeInto() must stay under ~325 bytecode bytes
(FreqInlineSize); at 437 the call site reports "hot method too big" and the
win evaporates. Any unrolled writer has to be measured with
-XX:+PrintInlining, not reasoned about.protobuf-java
spends none of that. That is the "closing it needs per-message codegen" note
in bindings/java/runtime/GenericCodec.java, now with numbers behind it.Official harness (make julia-bench, same session): pbwire-julia 133.2 ns,
protobuf-jl 126.1 ns - a 7.1 ns gap, i.e. 5% of the cell.
Split with a C probe on the same 136-byte payload (best of 5 x 200k
pbw_decode + pbw_message_free): 121 ns, so the C core is the whole
cell and the Julia wrapper around it (one ccall, one Ref, one Message
struct, close) is ~12 ns.
sample on the C probe (8 s, leaf frames, share of all samples):
| frame group | share |
|---|---|
decode_fields (tag read, fnum lookup, action switch) | 40% |
pbw_varint_read (out-of-line call for values over 2 bytes) | 12% |
malloc/free, mostly macOS's xzone free path | 16% |
read_field, store_first_or_put, store_bytes_first, pbw_message_put | 12% |
memcpy/memset (string copies, tree init) | 8% |
pbw_message_new_a, arena, pbw_decode entry | 6% |
malloc + free of one ~600-byte block measures 15.7 ns standalone on
this machine (macOS 27, M4 Pro); the free side dominates and calls
mach_absolute_time inside __xzm_xzone_free_to_freelist_chunk. That is the
"one malloc/free pair per plain call" that
docs/design.md documents, and for a
136-byte message it is ~12% of the decode.
Rejected before building:
pbw_ctx pooling on the Julia side (one context per decoded message,
returned to a pool on close) would remove the malloc/free pair, but
pbw_decode_ctx makes the tree read-only, and the binding's decode
promises a mutable message. Rejected on semantics, not on speed.pbw_varint_read into read_varint (the 3-5 byte
window) covers only two of this payload's nine varints (~2-3% of decode),
while adding code to every inlined copy of read_varint in decode_fields.
Not worth the icache risk for a cell that needs 5%; the 10-byte path is
already unrolled in pbwire_varint_read and its cost is the out-of-line
call, not the shifts.The same codec as the Java cell above (bindings/kotlin/runtime adds DSL
sugar over bindings/java/runtime; Message.encode() ends in
GenericCodec.encode), so attempts 1-6 apply unchanged and were not repeated.
encodeInto per message, i.e. what protoc emits). The
binding rejects that by design; it is a product decision, not a tuning
session.malloc/free
pair from the plain path, which is an ownership change (see
docs/design.md), plus ~12 ns of Julia
wrapper that a ccall-only design cannot avoid.Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |