Iterative log of optimization work on tarantool-protobuf encode/decode hot paths. Each entry captures: what changed, why, measured before/after, and any caveats.
For each beads task on the optimization track:
bd update <id> --claim, write the change.just test. Fix any breakage before measuring anything.just bench (and just jit-trace if the change touches hot
wire paths). Capture full output, compute deltas vs. previous entry's
"after" numbers, and append a new entry to this file.bd close <id> afterwards.bd ready in the
perf bucket) and repeat from step 1.Notes:
just bench on a quiet machine; numbers can swing 5-10% from
background noise on macOS. Re-run if a number looks suspicious.BenchPayload schemas are tracked
separately because optimizations rarely move all three uniformly.full (inlined codegen) and runtime (descriptor dispatch) modes.min / mid. Pins proto2 extension and
default-value paths.Pre-optimization snapshot. Tarantool 3.7.0-0-g1f1ec9fdf, LuaJIT 2.1.0-beta3, macOS arm64.
| mode | size | msgs/s | MB/s | alloc B/op |
|---|---|---|---|---|
| full | 10B | 2,250,858 | 22.5 | 136 |
| full | 100B | 2,215,919 | 208.3 | 136 |
| full | 1KB | 240,381 | 223.6 | 1368 |
| full | 10KB | 43,240 | 416.6 | 8540 |
| full | 100KB | 4,920 | 475.7 | 131605 |
| runtime | 10B | 1,098,666 | 11.0 | 136 |
| runtime | 100B | 1,089,811 | 102.4 | 136 |
| runtime | 1KB | 186,459 | 173.4 | 1368 |
| runtime | 10KB | 39,600 | 381.5 | 8540 |
| runtime | 100KB | 4,433 | 428.5 | 131605 |
| mode | size | msgs/s | MB/s | alloc B/op |
|---|---|---|---|---|
| full | 10B | 2,476,811 | 24.8 | 112 |
| full | 100B | 2,091,875 | 196.6 | 112 |
| full | 1KB | 109,479 | 101.8 | 1000 |
| full | 10KB | 14,961 | 144.1 | 4840 |
| full | 100KB | 1,461 | 141.3 | 33512 |
| runtime | 10B | 2,103,514 | 21.0 | 112 |
| runtime | 100B | 1,843,624 | 173.3 | 112 |
| runtime | 1KB | 103,503 | 96.3 | 1000 |
| runtime | 10KB | 13,710 | 132.1 | 4840 |
| runtime | 100KB | 1,336 | 129.1 | 33512 |
| mode | size | enc msgs/s | enc MB/s | dec msgs/s | dec MB/s |
|---|---|---|---|---|---|
| full | min | 1,485,112 | 7.4 | 846,439 | 4.2 |
| full | mid | 181,413 | 110.5 | 73,650 | 44.9 |
| runtime | min | 1,163,210 | 5.8 | 982,154 | 4.9 |
| runtime | mid | 162,481 | 99.0 | 73,952 | 45.0 |
Task: [tarantool-protobuf-4kj] Decoder: generated tag/length fast path
for full-mode decode. Profile attributed ~21% of hello.Person 1KB decode
time to wire.decode_tag dispatch.
Change: In cmd/protoc-gen-tarantool/internal/gen/inline.go, the
generated M.X_decode while-loop now decodes the 1-byte tag form inline
before falling back to wire.decode_tag for multi-byte. A header tweak
in gen.go localizes string.byte, bit.band, and bit.rshift at the
top of every generated file so each call inside the loop becomes a
straight-line local-call.
-- Before:
local id, wt
id, wt, pos = wire.decode_tag(buf, pos)
-- After:
local id, wt
local _b = string_byte(buf, pos)
if _b ~= nil and _b < 0x80 then
wt = band(_b, 7)
if wt >= 6 then error("illegal wire type " .. wt, 0) end
id = rshift(_b, 3)
if id == 0 then error("illegal field number 0", 0) end
pos = pos + 1
else
id, wt, pos = wire.decode_tag(buf, pos)
end
Field numbers 1..15 always encode as a single byte; that's the overwhelmingly dominant case in real payloads, including every Person field in the bench corpus.
Tests: 745/745 pass. JIT trace gate: 37/37, all bridges still 0.
Bench (Person full, msgs/s, median-of-3 vs proper post-h8v median-of-3 baseline):
| size | dir | post-h8v | 4kj | Δ |
|---|---|---|---|---|
| 10B | dec | 2,247,620 | 2,579,347 | +14.7% |
| 100B | dec | 1,949,565 | 2,122,601 | +8.9% |
| 1KB | dec | 102,175 | 109,792 | +7.5% |
| 10KB | dec | 14,100 | 15,084 | +7.0% |
| 100KB | dec | 1,393 | 1,512 | +8.5% |
| 10B | enc | 2,291,108 | 2,272,701 | -0.8% |
| 100B | enc | 2,250,757 | 2,313,101 | +2.8% |
| 1KB | enc | 293,677 | 293,367 | -0.1% |
| 10KB | enc | 60,269 | 59,390 | -1.5% |
| 100KB | enc | 6,746 | 6,570 | -2.6% |
Methodology note (lesson from gcy): the post-h8v "single-run" baseline I'd captured for h8v was a peak run (the bench is noisier than I'd expected on macOS arm64). For 4kj I re-baselined post-h8v with a 3-run median before comparing, which made the decode win obvious and exposed the encode -1 to -3% as small/within-noise. Going forward, medians-of-3 are the comparison standard; PERF_LOG entries earlier than this one used single-run baselines (h8v's numbers are likely tilted ~3-5% optimistic).
Bench (proto2_basic.BenchPayload full): roughly flat — proto2 mid encode/decode within ±1% of post-h8v. Expected: BenchPayload doesn't have a tight decode loop the way Person 1KB does.
Bench (runtime mode): the runtime mode wrappers don't see the
inlined fast path — they delegate to pb.codec.decode. Runtime decode
1KB measured at 97,285 vs an earlier baseline 103,503, but that baseline
was single-run and inconsistent with the variance I've seen since.
Treating this as noise; no semantic change is plausible for runtime
mode here (only file-header upvalues added, three locals never
referenced by runtime wrappers).
Caveats / leftovers:
wire.decode_tag call.
Generated protobuf rarely uses high field numbers, but extensions
often do; the fallback keeps them correct.Person_encode even though that
function doesn't use them. Not currently worth optimizing.wire.decode_len is still a function call. Inlining its byte-read
half can come next, but the substring it produces is unavoidable.Commit: see git history for SHA.
Task: [tarantool-protobuf-gcy] Decoder: inline nested-message decode at
the call site. Profile flagged result.address = M.Address_decode(payload)
at hello_pb.lua:1031 as a 100% interpreter bail in the vl trace; thesis
was that replacing the call with the inlined Address decode body would
eliminate the bail and lift Person decode by 20–40%.
Change attempted: In cmd/protoc-gen-tarantool/internal/gen/inline.go,
added inlineCandidateForDecode (singular, non-group, non-WKT message
fields whose target has no further message subfields) and
emitInlineNestedDecode, which emits Address's decode loop inline in
Person_decode using a do/end scope to shadow the outer result. The
length prefix was read in-place (no wire.decode_len substring alloc).
Tests: 745/745 pass. JIT trace gate: 37/37 (after a flaky first-run 0/37 caused by the documented macOS arm64 mcode alloc issue).
Bench (hello.Person — full, msgs/s, median of 3 runs):
| size | dir | h8v baseline | after gcy | Δ |
|---|---|---|---|---|
| 1KB | enc | 302,117 | 292,680 | -3.0% |
| 1KB | dec | 110,115 | 104,182 | -5.3% |
| 10KB | enc | 64,052 | 59,543 | -6.4% |
| 10KB | dec | 15,086 | 14,113 | -6.6% |
| 100KB | enc | 6,487 | 6,661 | +2.7% |
| 100KB | dec | 1,519 | 1,396 | -8.1% |
Outcome: reverted. Tests passed but the change is a net regression at every size that exercises the inlined nested decode (Person 1KB/10KB have Address embedded; 100KB grows the Address body but is dominated by emails). Encode also regressed because Person_encode's trace had to account for a larger Person_decode (shared mcode arena / instruction cache pressure on macOS arm64, or LuaJIT abandoning some inlining of Person_encode under the new pressure).
Why the profile claim didn't translate:
vl (and jit.p count) reported Address_decode as 100% Interpreted
for the parent line, but in the live benchmark LuaJIT was already
inlining the small Address_decode body into Person_decode's trace
when entering. The "bail" was a tooling artifact of how
jit.attach('trace') attributes side traces, not a sustained
interpreter fallback. The benchmark numbers refute the trace
interpretation.do/end scope with shadowed result may
introduce extra upvalue references that LuaJIT 2.1 doesn't optimize
as well as a plain function call into a trace it has already
inlined.What would actually help here: the real decode bottleneck on
hello.Person (per profile recap) is still decode_string (utf8
validation = ~16% of decode time), decode_tag (21%), and
list[#list+1] = val at the email append loop (6%). Those are the next
targets — see entries on 6bb (skip_utf8_validation), 4kj
(generated tag/length fast path), cch (local counter for repeated
append).
Beads: issue closed with --reason referencing this entry; not
reopened. Inlining nested decode bodies can come back if a workload
shows that the nested call site is the hot trace boundary, but the
candidate restriction + bench coupling needs to be rethought first
(measure each call-site in isolation, not just at the trace level).
Commit: none — change reverted before commit. PERF_LOG entry is the only artifact.
Task: [tarantool-protobuf-h8v] Encoder: codegen-time inline FFI writes (mode=full) — first slice. Full FFI-buffer rewrite is still future work; this attacks the single hottest line identified by jit.p profiling.
Change: In cmd/protoc-gen-tarantool/internal/gen/inline.go, every
length-prefixed emit site (singular/repeated message, singular/repeated
string|bytes, packed scalar bundle, packed enum bundle, map entry) now
inlines the 1-byte varint fast path:
-- Before:
n = n + 1; out[n] = wire.encode_varint(#_b)
-- After:
local _len = #_b
if _len < 128 then
n = n + 1; out[n] = string.char(_len)
else
n = n + 1; out[n] = wire.encode_varint(_len)
end
Eliminates the function call + dispatch for every length prefix under 128
bytes — which is the dominant case for proto strings, message bodies, and
packed scalar bundles. Profile flagged this as ~33% of encode time
(out[n] = wire.encode_varint(#_b)) + another ~17% in encode_varint
dispatch.
Tests: 745/745 pass. JIT trace gate: 37/37 (no regressions).
Bench (hello.Person — full encode, msgs/s and Δ vs baseline):
| size | before | after | Δ |
|---|---|---|---|
| 10B | 2,250,858 | 2,405,176 | +6.9% |
| 100B | 2,215,919 | 2,389,715 | +7.9% |
| 1KB | 240,381 | 302,117 | +25.7% |
| 10KB | 43,240 | 64,052 | +48.1% |
| 100KB | 4,920 | 6,487 | +31.9% |
Bench (hello.Person — full decode): flat (±1%). Expected; decode path unchanged.
Bench (runtime mode): flat (±2%). Expected; only mode=full codegen
touched, runtime mode dispatches through pb.codec.encode_field which
still calls into wire.encode_varint for length prefixes.
Bench (proto2_basic.BenchPayload — full):
| size | dir | before | after | Δ |
|---|---|---|---|---|
| min | enc | 1,485,112 | 1,502,031 | +1.1% |
| mid | enc | 181,413 | 203,832 | +12.4% |
| min | dec | 846,439 | 897,014 | +6.0% |
| mid | dec | 73,650 | 71,496 | -2.9% |
mid decode -2.9% is within run-to-run noise (proto2 mid has no string
fields touched by this change; the wider distribution at 73K msgs/s
swings ±5%).
Alloc/op: unchanged (table-of-strings model preserved). Future h8v slices targeting the per-field table writes themselves would move this.
Caveats / leftovers:
100KB Person bench has email-string + name-string lengths above 128,
so it pays the slow path for those — but message-body lengths there
are still mostly < 128 because they wrap individual nested messages.
Net result is still +32%.table.concat(entry) then inlined length prefix.
Per-piece wire.encode_len(...) inside map values (emitMapPiece
message branch) was not rewritten — the call sits inside a single
out[idx] = ... slot assignment and untangling it would require
separating value-build from value-write. Map fields are not on the
current hot benchmark; deferred.pb.codec.encode_field) does
not benefit. The corresponding follow-up is 21d (codec dispatch
fragmenting traces); independent route to similar wins.Commit: see git history for SHA.