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