~bigbes/tarantool

tarantool-protobuf

ref: 9ee21c091d83431b7490d4ea3960df4b33f5f65c tarantool-protobuf/bench/PERF_LOG.md -rw-r--r-- 7.0 KiB
9ee21c09 — Eugene Blikh codegen: inline 1-byte varint length prefix at every LEN emit site 3 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-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.