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-2ri] Codegen: replace string.char(_len) with
CHARS[_len] at length-prefix emit sites. Natural extension of h8v — that
landed the inline 1-byte fast path structure; this kills the C-function
call still living inside it.
Why. Re-ran bench/profile.lua encode against the qwt+2sn baseline.
72% of Person_encode 1KB time was in the message body itself; 39% of that
(≈28% of total) landed on a single line:
n = n + 1; out[n] = string.char(_len)
string.char for a small _len returns an interned 1-byte string but
still pays a C-function call + dispatch per invocation. The 1KB Person
fixture has ~30 length-prefix sites firing per encode (26 emails × 1
length byte + name + address fields + packed lucky_numbers + nested
Address fields), so the cost compounds.
Change.
runtime/pb/wire.lua — add M.CHARS = CHARS where CHARS[i] is
string.char(i) for i = 0..255 (built once at module load).cmd/protoc-gen-tarantool/internal/gen/gen.go — module header now
emits local CHARS = wire.CHARS alongside the other hot-path
localizers (band, rshift, table_new, …).cmd/protoc-gen-tarantool/internal/gen/inline.go:208 —
emitInlineLenPrefix emits out[n] = CHARS[_len] instead of
out[n] = string.char(_len). Single emit site; 259 callers in the
committed fixtures all flip together.Tests: 752/752 pass.
Bench (median of 3 trials each, work.lab.local Linux x86_64 — laptop runs show 50%+ noise on identical state and are unusable for A/B at this granularity; see [bench-on-work-lab-local memory note]):
| schema | size | enc base | enc after | Δ enc | dec base | dec after | Δ dec |
|---|---|---|---|---|---|---|---|
| hello.Person | 10B | 4,416,425 | 4,624,460 | +4.7% | 8,625,690 | 8,692,338 | ±noise |
| hello.Person | 100B | 4,533,829 | 4,520,564 | ±noise | 4,240,721 | 4,405,100 | ±noise |
| hello.Person | 1KB | 419,131 | 489,681 | +16.8% | 308,983 | 307,818 | ±noise |
| hello.Person | 10KB | 89,329 | 108,144 | +21.1% | 43,006 | 42,354 | ±noise |
| hello.Person | 100KB | 6,391 | 8,579 | +34.2% | 4,247 | 4,213 | ±noise |
| proto2_basic.BenchPayload | mid | 432,128 | 480,281 | +11.1% | 199,886 | 199,690 | ±noise |
Reading the table. Gain scales with payload size — bigger messages
have more length-prefix writes (more CHARS[_len] lookups per encode).
10B / 100B near-flat: those payloads touch the length-prefix path 1-2
times so the change is below the noise floor. The 100 KB jump (+34%) is
where this matters: every encoded byte saved on the per-prefix call
amortizes across 26+ emails and the larger string fields.
Decode is genuinely flat — the decoder doesn't emit length prefixes; this is encoder-only.
Notes / caveats:
CHARS[i] returns the
same interned 1-byte string that string.char(i) does. The 752-test
suite confirms this empirically across both full and runtime modes
(runtime-mode wrappers also re-emit the inline fast path).out table +
table.concat shape is already well-tuned in LuaJIT; the remaining
per-line cost was the string.char C-call, and that's what 2ri kills.
See drm notes for the spike data.wire.CHARS is 256 interned 1-byte strings + the table
itself. Built once per process, ~12 KB total — negligible.Commit: see the 2ri commit immediately following this entry.
Tasks:
table_new(N, 0) for repeated-field lists where the element count is recoverable from the packed payload.Changes:
qwt — proto2 extension inline emit. collectExtsByExtendee (gen.go)
walks every input file once and groups all extend Foo { ... }
declarations by extendee FullName. emitInlineMessage now receives the
list of extensions targeting the current message; emitInlineEncode
emits one dedicated writer per extension (same shape as a regular
field's encode block, just sourcing from t._extensions['<full_name>'])
and emitInlineDecode adds an elseif arm per extension id that decodes
straight into result._extensions[<full_name>]. The dynamic
extensions_list walk still runs but is gated on #_elist > N (where
N is the static count) and starts at N+1, so it pays nothing unless
the user has called pb.register_extension at runtime — no codebase
caller does. Falls back to pb.codec.{encode_field,decode_extension}
only for runtime-registered extensions.
2sn — table_new(N, 0) for packed lists. emitInlineDecodeRepeated
defers the list allocation into the if wt == 2 then packed branch and
passes a count estimate to table_new. For fixed-width packed scalars
(fixed32/sfixed32/float → lim >> 2; fixed64/sfixed64/double →
lim >> 3) the estimate is exact; for varint-packed scalars and packed
enums it's an upper bound (lim, since each varint is ≥ 1 byte). The
per-element wt!=2 fallback and all non-packable types
(message/string/bytes) keep the bare-{} allocation — the size hint
would require a per-tag scan that costs more than it saves (the
rationale 2sn explicitly flagged).
Header now local table_new = require('table.new'). Tarantool's
LuaJIT 2.1 fork ships it; no pcall fallback needed (the project
targets Tarantool, same way utf8_len already does).
Why u39 doesn't repeat itself. u39 attempted table.new(0, N) for
the result table on every decode — the call cost was paid for every
message, including small ones where the rehash savings never
materialized. 2sn calls table_new(N, 0) at most once per repeated
field per message, and only on packable types where a meaningful
estimate exists. The wt!=2 fallback never triggers it.
Tests: 752/752 pure-Lua, 1043/1043 with PB_ENABLE_C=1, JIT 37/37 (0 bridges).
Bench (median of 3+ runs, msgs/s; full mode only — runtime mode is the wrapper layer and qwt/2sn don't touch it):
| schema | size | enc base | enc after | Δ enc | dec base | dec after | Δ dec |
|---|---|---|---|---|---|---|---|
| hello.Person | 10B | 2,919,623 | 3,054,881 | +4.6% | 3,388,280 | 3,442,341 | +1.6% |
| hello.Person | 100B | 2,882,467 | 3,004,447 | +4.2% | 2,785,360 | 2,864,878 | +2.9% |
| hello.Person | 1KB | 368,704 | 381,391 | +3.4% | 172,114 | 175,246 | +1.8% |
| hello.Person | 10KB | 76,066 | 78,555 | +3.3% | 23,853 | 24,228 | +1.6% |
| hello.Person | 100KB | 7,859 | 8,743 | +11.2% | 2,538 | 2,549 | +0.4% |
| proto2_basic.BenchPayload | min | 1,889,645 | 3,304,474 | +74.9% | 1,024,176 | 1,255,217 | +22.6% |
| proto2_basic.BenchPayload | mid | 234,041 | 315,092 | +34.6% | 92,791 | 103,610 | +11.7% |
Reading the table. qwt is responsible for the proto2 rows — the
BenchPayload min fixture had been the bench's worst data point at
9.4 MB/s encode; inline writers close that to 16.5 MB/s (+75%). 2sn
contributes most of the +1-3% decode lift on Person sizes that
exercise packed repeated fields (lucky_numbers has 5 packed int32s).
Person 100KB encode +11% is partially measurement noise (the 100KB
fixture has high variance run-to-run).
Notes / caveats:
extensions_list walk is preserved for forward
compatibility but gated so the static case pays a single integer
compare (#_elist > N), not the old per-extension function-pointer
dispatch.pb.encode / pb.decode)
is unchanged — qwt/2sn only touch full-mode codegen. Runtime numbers
in the bench are stable post-change.Commit: see the qwt+2sn commit immediately following this entry.
Task: [tarantool-protobuf-u39] Decoder: emit table.new(0, N) for
result tables. The thesis was that pre-sizing the result table's hash
part to the message's field count would avoid the rehash that
fresh-{} tables pay as fields are added.
Change attempted: Header now require('table.new') with a pcall
fallback so plain-Lua loads still work; emitInlineDecode allocates the
result via table_new(0, len(m.Fields)) instead of {}. 745/745 tests
pass. JIT 37/37.
Bench (Person full decode, msgs/s, median-of-3 vs post-cch):
| size | post-cch | u39 | Δ |
|---|---|---|---|
| 10B | 2,495,633 | 2,111,197 | -15.4% |
| 100B | 2,131,196 | 1,754,540 | -17.7% |
| 1KB | 115,310 | 117,270 | +1.7% |
| 10KB | 15,831 | 15,627 | -1.3% |
| 100KB | 1,620 | 1,617 | -0.2% |
Outcome: reverted. Small-payload decode collapsed. The
table_new(0, N) call (through an upvalue, with arguments) is
several times the cost of a {} literal, and at 10B/100B the decode
loop only populates 2-4 fields — well under the LuaJIT default hash
size where the first rehash would land. The rehash savings never
materialize because the small case doesn't reach them; meanwhile the
extra call cost is paid every decode.
Large payloads (1KB+) saw no meaningful gain either — by then decode
time is dominated by tag decoding, string extraction, and varint
parsing, not table allocation. The drm memory entry already
documented "136 B alloc floor is not the throughput bottleneck"; this
result confirms it from the opposite direction.
Lesson: allocation-shape optimizations are only worth it when the
workload spends real time in allocation. The bench corpus does not
exercise that mode; if a workload ever does (lots of tiny messages, or
messages with very wide field sets), revisit then. Sticking with {}
also keeps generated code compatible with plain Lua (no LuaJIT-only
require) — small platform-portability win.
Commit: none — change reverted. PERF_LOG entry is the only artifact.
Task: [tarantool-protobuf-cch] Decoder: use local counter instead of
#list at repeated-field append. Profile attributed 6.2% of Person 1KB
decode time to list[#list + 1] = val re-traversing the list per
append (26 emails per Person → 26 re-scans).
Change: In cmd/protoc-gen-tarantool/internal/gen/inline.go, the
generated M.X_decode now declares one counter local per repeated
non-map field at function entry:
local _n_emails = 0
local _n_lucky_numbers = 0
-- ...
Each list[#list + 1] = val emit becomes
_n_emails = _n_emails + 1; list[_n_emails] = val. Counters survive
across loop iterations, so out-of-order wire entries for the same field
keep appending past the existing length without re-scanning.
Map fields are skipped (hash-keyed, no array index).
Tests: 745/745. JIT: 37/37, 0 bridges.
Bench (Person full, msgs/s, median-of-3 vs post-4kj baseline):
| size | dir | post-4kj | cch | Δ |
|---|---|---|---|---|
| 1KB | dec | 109,792 | 115,310 | +5.0% |
| 10KB | dec | 15,084 | 15,831 | +5.0% |
| 100KB | dec | 1,512 | 1,620 | +7.1% |
| 10B | dec | 2,579,347 | 2,495,633 | -3.2% |
| 100B | dec | 2,122,601 | 2,131,196 | +0.4% |
| 1KB | enc | 293,367 | 294,691 | +0.5% |
| 10KB | enc | 59,390 | 58,684 | -1.2% |
| 100KB | enc | 6,570 | 6,666 | +1.5% |
Decode wins land at 1KB+ where the repeated-field pattern dominates (Person has 26 emails). At 10B no repeats fire, so the small -3.2% is noise. Encode unchanged path, flat. Profile's "6% of decode time" target translated cleanly into +5–7% on sizes that exercise repeated fields.
Runtime mode: flat. cch only touches mode=full repeated emit;
runtime dispatches through pb.codec which already uses a different
append shape.
Commit: see git history for SHA.
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.