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