~bigbes/tarantool

tarantool-protobuf

ref: e3f067416da55e4390d1a9b3e1f410ad7a48d22f tarantool-protobuf/bench/PERF_LOG.md -rw-r--r-- 33.0 KiB
e3f06741 — Eugene Blikh runtime: pb.decode_unsafe + M.<Name>_decode_unsafe in runtime mode (58u) 2 months ago

#Performance optimization log

Iterative log of optimization work on tarantool-protobuf encode/decode hot paths. Each entry captures: what changed, why, measured before/after, and any caveats.

#Workflow

For each beads task on the optimization track:

  1. Implementbd update <id> --claim, write the change.
  2. Testjust test. Fix any breakage before measuring anything.
  3. Benchmarkjust 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.
  4. Commit — single commit per task, including the PERF_LOG.md update. bd close <id> afterwards.
  5. Next — pick the next P1 (or whatever's at the top of bd ready in the perf bucket) and repeat from step 1.

Notes:

  • "Before" for each entry equals the previous entry's "After" — single contiguous history, no per-task baselines.
  • Always run just bench on a quiet machine; numbers can swing 5-10% from background noise on macOS. Re-run if a number looks suspicious.
  • If a change regresses a workload (any cell drops >5%), document it honestly. A regression that buys 2× somewhere else is a tradeoff worth recording, not a failure to hide.
  • Decoder, encoder, and the proto2 BenchPayload schemas are tracked separately because optimizations rarely move all three uniformly.

#Schemas tracked

  • hello.Person at 10B / 100B / 1KB / 10KB / 100KB. Real-world-shaped: string fields, nested messages, repeated emails. Both full (inlined codegen) and runtime (descriptor dispatch) modes.
  • proto2_basic.BenchPayload at min / mid. Pins proto2 extension and default-value paths.

#Baseline — 2026-05-18

Pre-optimization snapshot. Tarantool 3.7.0-0-g1f1ec9fdf, LuaJIT 2.1.0-beta3, macOS arm64.

#hello.Person — encode

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

#hello.Person — decode

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

#proto2_basic.BenchPayload

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

#2026-05-24 — aah: inline 1-byte varint fast path for packed scalar elements

Task: [tarantool-protobuf-aah] Encoder: codegen-emitted packed-scalar tight loops. Per-element wire.encode_<type>(v) calls in packed-repeated fields paid a full function-call boundary even though encode_varint's small-positive-int hot path is a single string.char(n) (or — post-2ri — a CHARS[n] lookup). Inlining the check + lookup at codegen time removes the call entirely for the dominant small-value case.

Change. New helper emitPackedVarintElem in inline.go emits the per-element write with a type-conditional fast path:

-- int32 / int64 / uint32 / uint64:
local _e = v[_i]
if type(_e) == 'number' and _e >= 0 and _e < 128 then
    m = m + 1; parts[m] = CHARS[_e]
else
    m = m + 1; parts[m] = wire.encode_<type>(_e)
end

-- sint32 / sint64 (zigzag inlined; -64 <= v <= 63 → 7-bit zigzag):
local _e = v[_i]
if type(_e) == 'number' and _e >= -64 and _e <= 63 then
    m = m + 1; parts[m] = CHARS[bit.bxor(bit.lshift(_e, 1), bit.arshift(_e, 31))]
else
    m = m + 1; parts[m] = wire.encode_<type>(_e)
end

-- bool (always 1 byte, no fast/slow split):
m = m + 1; parts[m] = CHARS[(v[_i]) and 1 or 0]

-- fixed-width scalars (fixed32/64/sfixed32/64/float/double): unchanged
m = m + 1; parts[m] = wire.encode_<type>(v[_i])

Applied at every packed emit site:

  1. Repeated packed scalar (mode=full, emitInlineEncodeRepeated).
  2. Repeated packed enum (after string→int resolve, treated as int32).
  3. Proto2 extension packed scalar (emitInlineEncodeExtensionRepeated).
  4. Proto2 extension packed enum.

Also pre-sizes the parts accumulator: local parts, m = table_new(#v, 0), 0 instead of local parts, m = {}, 0. Same pattern qwt+2sn used for the decode-side packed result table — eliminates rehashes as elements push.

Tests: 752/752 pass. JIT trace gate: 37/37 (no regressions).

Bench (work.lab.local, median of 3 trials, c_repeated.Holder packed field × N elements, msgs/s):

field N BEFORE AFTER Δ
packed_int32 10 1,230,747 2,340,246 +90%
packed_int32 100 337,800 719,580 +113%
packed_int32 1000 48,827 100,661 +106%
packed_sint32 10 1,237,886 2,048,630 +66%
packed_sint32 100 130,687 439,797 +236%
packed_sint32 1000 18,511 47,453 +156%
packed_uint32 10 1,094,003 1,890,418 +73%
packed_uint32 100 329,853 676,333 +105%
packed_uint32 1000 48,415 97,969 +102%
packed_bool 10 1,176,470 1,689,881 +44%
packed_bool 100 393,085 593,550 +51%
packed_bool 1000 62,660 87,037 +39%
packed_int64* 10 415,289 507,112 +22%
packed_int64* 100 56,831 68,695 +21%
packed_int64* 1000 5,981 7,331 +23%

* packed_int64 payloads use int64_t cdata elements; the type(_e) == 'number' check fails, so the fast path is skipped and wire.encode_int64 is still called. The +22% there comes from the table_new(#v, 0) pre-sizing alone — cdata varints still go through the FFI boundary tax that drm/[[luajit_cdata_perf_patterns]] document.

Bench (work.lab.local, median of 3 trials, headline hello.Person — encode-only deltas vs the 2ri AFTER medians):

size post-2ri post-aah Δ
10B 4,624,460 4,367,697 ±noise
100B 4,520,564 4,486,250 ±noise
1KB 489,681 507,275 +3.6%
10KB 108,144 105,391 ±noise
100KB 8,579 8,529 ±noise
proto2_basic mid 480,281 530,580 +10.5%

The Person fixture has 5 packed lucky_numbers — too few elements for aah to dominate. proto2 BenchPayload mid carries a heavier packed mix and picks up the bigger lift. The headline gains are real but small; packed_bench.lua is where this change shows its true magnitude.

Reading the table. sint32 mid-size (+236%) is the biggest single win because encode_sint32 previously dispatched through three function layers (encode_sint32zigzag_encode32encode_varintstring.char). aah collapses all of that into a 7-bit-zigzag check plus a CHARS lookup. int32/uint32 land at +90-115% (one layer skipped). bool at +39-51% (the loop overhead becomes a larger relative share at this throughput).

Notes / caveats:

  • The fast path uses type(_e) == 'number' — Lua numbers only. LuaJIT trace-specializes on the hot type so the check is a single guard. Any non-number element (cdata, string, nil) falls through to wire.encode_<type>, preserving every prior behavior including error messages.
  • For sint32/sint64 the 32-bit zigzag is safe in the fast-path range (-64..63 fits in int8). The fallback wire.encode_sint64 handles larger values + cdata correctly.
  • The packed enum path inherits the same fast path after string→int resolve, since post-resolve values are always Lua numbers from the enum lookup table.
  • Repeated unpacked scalars (the else branch at the end of emitInlineEncodeRepeated) emit one tag per element via a different shape and are out of scope here.

Commit: see the aah commit immediately following this entry.


#2026-05-24 — 2ri: CHARS[_len] lookup table replaces string.char(_len) at length-prefix sites

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.

  1. runtime/pb/wire.lua — add M.CHARS = CHARS where CHARS[i] is string.char(i) for i = 0..255 (built once at module load).
  2. 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, …).
  3. cmd/protoc-gen-tarantool/internal/gen/inline.go:208emitInlineLenPrefix 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:

  • The change is parity-safe by construction: 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).
  • This subsumes part of what lkz / 86g (ffi.new buffer rewrite) were meant to address. A separate hand-spike of the full FFI buffer rewrite regressed across all sizes (0.31×-0.77×) — the 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.
  • Memory: 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.


#2026-05-24 — qwt + 2sn: inline proto2 extension writers/readers and table.new(N, 0) for packed lists

Tasks:

  • [tarantool-protobuf-qwt] Codegen: emit per-extension writers for proto2 extensions (skip pb.codec.encode_field dispatch).
  • [tarantool-protobuf-2sn] Decoder: emit 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:

  • The dynamic 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.
  • 2sn intentionally skips non-packed and string/bytes lists. Pre-sizing those would require a tag-occurrence pre-scan; for Person's 26-emails case the per-message scan cost likely exceeds the rehash savings. Filed as future work in 2sn's body.
  • Runtime mode (descriptor-driven dispatch via 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.


#2026-05-18 — u39: table.new(0, N) for result tables (REVERTED)

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.


#2026-05-18 — cch: local counter for repeated-field append

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.


#2026-05-18 — 4kj: inline 1-byte tag fast path at decode call sites

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:

  • Multi-byte tags (field ids > 15) still pay the wire.decode_tag call. Generated protobuf rarely uses high field numbers, but extensions often do; the fallback keeps them correct.
  • Encode regressed slightly at 10KB+. Plausible cause: three extra file-level upvalues (band/rshift/string_byte) shift LuaJIT's function-prologue layout for 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.
  • The richer "order-prediction" form of this task — emit a literal tag-byte equality check per declared field — is deferred. It would double-dispatch (literal-match + id-match fallback) and the simple inline already captures ~half the available gain.

Commit: see git history for SHA.


#2026-05-18 — gcy: inline nested-message decode at the call site (REVERTED)

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:

  1. 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.
  2. Person_decode trace went from N stops to 22 stops after the inline. Larger root traces compile more slowly, are more sensitive to side-trace stitching limits, and produce more mcode — exactly the failure mode CLAUDE.md's "Keep hot wire helpers small" warning describes, applied at the call-site instead of the helper.
  3. The inlined-body's 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.


#2026-05-18 — h8v: inline 1-byte varint length prefix at codegen sites

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:

  • The 1-byte ceiling at 128 bytes matches the proto3 varint boundary; the 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%.
  • Map encode still uses 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.
  • Runtime mode (descriptor dispatch via 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.