Liking cljdoc? Tell your friends :D

Performance attempts ledger

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.

How an attempt is measured

Same method as the core's notes in BENCHMARKS.md:

  1. Build the unchanged tree and the candidate as two binaries/class dirs from the same source tree (base and vN).
  2. Alternate the two in one session (same machine, same JIT/heap state), so a drifting machine hits both sides equally. Anything below ~5% is noise unless the two sides differ in every round.
  3. Keep only changes that win in every round. A change that wins in one round and loses in the next is recorded as neutral and reverted.
  4. For the pure-Java codec the probe is a classpath A/B: 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.

Cell: Java pure-Java codec, 136 B encode (0.78x)

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).

Where the time goes

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 encodens/opcost of that part
nothing (base)74.3-
all singular numeric writes45.628.7
both string fields63.410.9
the nested inner message68.16.2
all field tag bytes69.15.2
the Arrays.copyOf result copy76.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.

Attempts (all rejected)

#candidateresultwhy
1unrolled 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.
2same loop, but plain a[i++] = ... stores instead of Unsafe.putByte91-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.
3packed 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.
4primitive mirror arrays on MessageDef (kind[], presence[], repeated[], tags[]) instead of pointer chasing through Field84-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.
5inline 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.
6drop 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.6neutral. 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:

  • HotSpot's inlining ceiling is the binding constraint. A helper inlined into 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.
  • The per-field dispatch is the floor. Removing all of a field's byte writes still leaves 1.06 ns/field of loop and dispatch, and 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.

Cell: Julia, 136 B decode (0.92-0.95x)

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 groupshare
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 path16%
read_field, store_first_or_put, store_bytes_first, pbw_message_put12%
memcpy/memset (string copies, tree init)8%
pbw_message_new_a, arena, pbw_decode entry6%

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.
  • Inlining more of 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.

Cell: Kotlin, 136 B encode (0.80x)

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.

What would actually close these cells

  • Java/Kotlin 136 B encode: per-message-type codegen (a generated straight-line encodeInto per message, i.e. what protoc emits). The binding rejects that by design; it is a product decision, not a tuning session.
  • Julia 136 B decode: something that removes the per-call 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

Keyboard shortcuts
Ctrl+kJump to recent docs
←Move to previous article
→Move to next article
Ctrl+/Jump to the search field
× close