# 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. **Implement** — `bd update --claim`, write the change. 2. **Test** — `just test`. Fix any breakage before measuring anything. 3. **Benchmark** — `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. 4. **Commit** — single commit per task, including the PERF_LOG.md update. `bd close ` 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 — 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: ```lua 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: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:** - 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['']`) and `emitInlineDecode` adds an elseif arm per extension id that decodes straight into `result._extensions[]`. 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: ```lua 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. ```lua -- 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: ```lua -- 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. ---