~bigbes/tarantool

tarantool-protobuf

ref: e258695ea1fe3b322d0ab4277bbdeed6d4eabe5e tarantool-protobuf/bench d---------
7b3d6901 — Eugene Blikh 2 months ago
bench: apply mcode arena hardening across all bench scripts (3qu)

Added jit.opt.start('sizemcode=64', 'maxmcode=4096') to all 9 bench
scripts. Same fix 3o2 landed in bench/jit_trace.lua, propagated to the
rest. Without it, the macOS arm64 mcode allocator intermittently fails
to find an executable page in the signed-32-bit offset window and the
bench reports interpreter-only throughput with no diagnostic — visible
as silent regressions. The risk is higher in scripts with larger codegen
footprints than the trace gate.

Files touched: bench.lua, lazy_bench.lua, profile.lua, shapes_bench.lua,
starwing_bench.lua, wire_bench.lua, alloc_probe.lua, map_bench.lua,
packed_bench.lua. All 7 non-interactive scripts run rc=0; profile and
starwing loadfile-check clean.

Also refreshed bench/baseline.json per the issue's step 2. Surprise win:
full-mode decode allocations dropped 0.5-50% as a side-effect of auj/ozn
that was only visible after the snapshot. Most notable: proto2_basic
BenchPayload mid decode 2.313 -> 1.156 KB/op (-50%), Person 1KB decode
0.977 -> 0.953 KB/op. Runtime-mode unchanged — confirms the alloc
reduction is full-mode codegen specific.

Steps 3-4 (refresh COMPARISON.md throughput tables, verify the variance
band shrinks below the documented 5-10% drift) are deferred to a
work.lab.local run — laptop variance is 50%+ on identical state, can't
trust throughput A/Bs locally.
dbbfaf35 — Eugene Blikh 2 months ago
codegen: inline 1-byte varint fast path for packed scalar elements (aah)

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 CHARS[n] lookup. Inline the check + lookup at
codegen time at every packed emit site:

  - Repeated packed scalar (mode=full)
  - Repeated packed enum (after string->int resolve)
  - Proto2 extension packed scalar + enum

For varint scalars (int32/int64/uint32/uint64): fast path triggers when
v is a Lua number in [0, 128). For sint32/sint64: 7-bit zigzag range
-64..63 is inlined with bit ops. For bool: always 1 byte via
CHARS[v and 1 or 0] (no fast/slow split). Fixed-width scalars keep the
wire.encode_<type> call shape — already optimal.

Also pre-sizes the parts accumulator with table_new(#v, 0) instead of
{}; same pattern qwt+2sn used decode-side. Eliminates rehash cascade
as elements push.

Tests: 752/752 pass. JIT trace gate: 37/37.

Bench (work.lab.local, median of 3, c_repeated.Holder packed N elems):
  packed_int32:  +90% / +113% / +106%  (N=10/100/1000)
  packed_sint32: +66% / +236% / +156%
  packed_uint32: +73% / +105% / +102%
  packed_bool:   +44% / +51% / +39%
  packed_int64:  +22% / +21% / +23%  (cdata; gain from table_new only)

Headline hello.Person 1KB +3.6%, proto2 BenchPayload mid +10.5%.

See bench/PERF_LOG.md 2026-05-24 aah entry for the full breakdown
including the sint32 +236% mid-size analysis (three function layers
collapsed into one CHARS lookup).
0b6e1bc5 — Eugene Blikh 2 months ago
bench: add map_bench.lua, close ch2 (intervention regresses)

ch2 hypothesized that replacing `for k, v in pairs(map_value) do` in
the codegen-emitted map encoder with a key-collect + ipairs pattern
would let the inner emit loop stay on a JIT trace. Hand-implemented in
both codegen (inline.go:emitInlineEncodeMap) and runtime
(codec.lua kind=='map' branch); 752/752 tests passed.

Bench (work.lab.local, median of 3, Person.ages_by_nickname encode):

  map size | BEFORE      | AFTER       |  Δ
       1   |  1,282,180  |  1,128,545  | -12%
       3   |    634,880  |    549,761  | -13%
      10   |    215,745  |    196,800  |  -9%
      50   |     48,162  |     45,614  |  -5%
     200   |     11,613  |     11,282  |  -3%

Regression across all sizes. LuaJIT's side-trace machinery was already
JIT-ing the inner body via a side trace from the pairs() ISNEXT abort
point — the body was already on-trace before. The change just adds
wrapper overhead (scratch table alloc, O(N) key-collection, extra hash
lookup per entry).

Reverted the codegen + runtime edits. Keeping bench/map_bench.lua —
useful harness for any future map-encoder work (e.g. x9f deterministic
ordering may revisit this).

See ch2 bd notes for full diagnosis.
035b2adb — Eugene Blikh 2 months ago
codegen: CHARS[_len] lookup replaces string.char(_len) at length-prefix sites (2ri)

Profile attributed 39% of Person_encode's trace share (~28% of total) to a
single line emitting `string.char(_len)` at every inlined length-prefix
site. The `_len` argument is rarely a compile-time constant (lengths come
from user data), so the JIT can't fold the C-function call, and the cost
compounds — the 1KB Person fixture fires ~30 length-prefix sites per
encode.

Replace with a 256-entry lookup table `wire.CHARS` (built once at module
load, byte i -> string.char(i)). Codegen header now emits
`local CHARS = wire.CHARS` alongside the other hot-path localizers; the
single emit site in `emitInlineLenPrefix` swaps `string.char(_len)` for
`CHARS[_len]`. Parity is by construction — both return the same interned
1-byte string.

Tests: 752/752 pass. Bench (work.lab.local, median of 3, hello.Person
encode): 10B +4.7%, 1KB +16.8%, 10KB +21.1%, 100KB +34.2%, proto2 mid
+11.1%. Decode unchanged. See bench/PERF_LOG.md 2026-05-24 2ri entry.

Closes lkz and 86g (ffi.new buffer rewrite paths) — separate hand-spike
of that shape regressed 0.31x-0.77x across all sizes; the perceived
buffering inefficiency wasn't there, and 2ri captured the single hottest
line. Remaining encode-perf headroom is c0i (C-runtime backend).
ed383c92 — Eugene Blikh 2 months ago
codegen: inline proto2 extension writers/readers (qwt) + table.new(N,0) for packed lists (2sn)

qwt closes the proto2_basic.BenchPayload `min` bench's worst data point:
encode 1.89M → 3.30M msgs/s (+75%), decode 1.02M → 1.26M msgs/s (+23%).
Mechanism: collectExtsByExtendee groups all `extend Foo { ... }`
declarations by extendee FullName across input files; emitInlineEncode
emits one dedicated writer per extension instead of dispatching through
pb.codec.encode_field, and emitInlineDecode adds an elseif arm per
extension id straight into result._extensions[full_name]. Dynamic
extensions_list walk preserved for forward compat, gated on
#_elist > N so it pays one int compare when no runtime extension was
registered.

2sn pre-sizes packed-scalar repeated lists via table.new(N, 0) where
the count is recoverable from the LEN payload — exact (lim >> 2 or
lim >> 3) for fixed-width, upper bound (lim) for varint-packed. The
allocation is deferred into the `if wt == 2 then` branch so the
per-element fallback and non-packable types keep the bare-`{}`
alloc. Avoids u39's regression mode because the call cost is paid
once per repeated-field-first-occurrence, not per message decode.

Bench summary for hello.Person full mode: encode +3-5% across sizes,
decode +1-3% across sizes. Tests: 752/752 pure-Lua, 1043/1043 with
PB_ENABLE_C=1, JIT 37/37. PERF_LOG entry covers the rationale and
caveats.
b93adbf0 — Eugene Blikh 2 months ago
codec: emit per-descriptor encode body for monomorphic dispatch (21d)

The runtime-mode encode loop in codec.encode_message iterated
desc.fields and called `writer(data, out)` per field. Each iteration
saw a different closure, making the call site megamorphic from the
JIT's view: trace topology fragmented into 25 stops for Person_encode
vs full mode's 8 (~3x).

compile_encode_body emits a generated function at pb.finalize_message
time with one literal call site per field, all closing over a single
`_u` table upvalue indexed by constant int. TGETI on a stable array
with a literal key specializes on trace just like direct upvalue
access — and dodges LuaJIT's 60-upvalue function limit, which
TestAllTypesProto2 (~140 fields) would otherwise hit.

Trace topology (bench/jit_trace.lua):
  runtime/Person_encode             25 -> 10  (full 8)
  runtime/Person_encode multi-byte  18 ->  7  (full 6)
  runtime/Address_encode             6 ->  5  (full 2)
  runtime/Cardinality required       6 ->  2  (full 2)

Encode throughput (hello.Person, bench/bench.lua):
  10B    10.9 -> 15.5 MB/s  (+42%)
  100B  101.1 -> 144.4 MB/s (+43%)
  1KB   170.5 -> 183.5 MB/s (+8%)
  10KB  355.5 -> 362.3 MB/s (+2%)
  100KB 373.5 -> 407.6 MB/s (+9%)

Decode untouched. Full mode unaffected (its inline _encode doesn't
go through codec.encode_message). 752+1043 tests pass; examples
unchanged.

The issue's secondary goal — runtime within 5% of full on encode at
all sizes — is not met. Residual gap is per-closure call overhead;
closing it would need writer bodies inlined into the generated body
(not just call sites), which is a larger codegen-at-runtime change.

Closes tarantool-protobuf-21d.
Files tarantool-protobuf-h8x (decode_group bimodal trace flake
surfaced during validation; pre-existing on master).
5dd21dea — Eugene Blikh 2 months ago
c_runtime: parity gate — test-c / test-all / bench-c (43t)

Adds the C-acceleration parity gate that bd-43t mandates: every test
asserts against a reference output (golden bytes, txtpb, conformance
result), so passing the same suite under both `PB_ENABLE_C=1` and the
default Lua codec proves Lua ≡ C by transitivity. No separate diff
harness needed.

Justfile recipes:
  - `test-c`  — same luatest suite with PB_ENABLE_C=1 (1043 tests;
                unlocks the c_runtime_* groups via build-c)
  - `test-all` — `test` + `test-c` (parity gate)
  - `bench-c` — bench.lua under PB_ENABLE_C=1; runtime column is
                relabelled `c-runtime`. Also tidies build-c — the stale
                "directory doesn't exist yet" branch goes away now that
                ra6 has landed.

bench.lua: extends package.cpath so `require('pb.c_runtime')` finds
runtime/pb/c_runtime.{so,dylib} when invoked outside the Justfile.
Detects `PB_ENABLE_C=1`, renames `runtime` → `c-runtime` in the
output, and refuses --baseline / --compare (alloc shape differs
between codecs by design — would noise the gate).

README: documents the parity strategy and lists `test-all`,
`conformance-c`, `bench-c` as the dual-codec entry points.

Scope notes: c-generated column dropped (c0i is deferred per
docs/c-accel.md); starwing column already lives in
bench/starwing_bench.lua.

bd-43t
5c541416 — Eugene Blikh 2 months ago
bench(c_accel): drop defensive lua_type checks; refresh numbers

person_codec.c had a per-element lua_type(L,-1)==LUA_TSTRING check
inside the emails loop, plus field-level lua_type checks that
generic_codec.c skipped. At 100KB that's ~2700 extra C calls per
message in the hot path, which made S4 (hand-written) look slower
than S3 (generic) at 1KB+ and led to a wrong "branch prediction on
divergent paths" hypothesis in the original README.

Replaced the per-element check with no check and the field-level
lua_type checks with lua_isnil to match generic_codec.c's semantics.
S3 and S4 are now within +/-5% at every size, which strengthens the
ra6 architecture call (ship the generic one-call codec; codegen-
emitted per-message C buys nothing).

Also added bench/c_accel/compile_flags.txt so clangd resolves
<module.h> and friends -- mirrors runtime/pb/c/compile_flags.txt.
4fd731af — Eugene Blikh 3 months ago
ci: install Go 1.26 toolchain + cmake on builds.srht

Ubuntu noble's `golang` apt package is too old to satisfy go.mod's
toolchain directive — `go build` aborted with "toolchain not available".
Drop the apt package, install the official Go 1.26.3 tarball into
~/.local/go, and update go.mod / bench/go/go.mod accordingly. Also add
cmake + build-essential so `tt rocks install luatest` can build its
`checks` dependency.
fe857ac0 — Eugene Blikh 3 months ago
bench: alloc decomposition probe

Adds bench/alloc_probe.lua, a surgical probe that strips the
encode path apart and measures KB-delta per primitive operation.
Lets us attribute the ~136 B/op encode floor (captured in the
perf-analysis-2026-05-18 memo) to a specific source: result
table, intermediate varint strings, output string, etc.

Runs with GC stopped so allocations accumulate; divides by N to
get bytes-per-op. Uses unique input bytes per iteration where
appropriate to eliminate string-interning noise.

Complements bench/profile.lua (where time goes) with where
*memory* goes — the two together informed which bd perf items
were worth landing (the bytes-saved had to translate to a real
allocation rate change, not just a hot-line attribution).
a0f0ce2d — Eugene Blikh 3 months ago
bench: jit.p profile driver for hot encode/decode paths

Adds bench/profile.lua, a one-shot driver around LuaJIT's
sampling profiler (jit.p). Runs Person_encode and Person_decode
against the 1 KB fixture — the size where decode MB/s halves and
encode MB/s stops climbing — and prints a function-and-line
breakdown of where wall time actually goes.

Used during the M5 perf-investigation arc to produce the
attributions captured in the jit-p-profile-2026-05-18 memo and
referenced by several bd issues (h8v, u39, gcy, 4kj, aah, bgu).

`tarantool bench/profile.lua` runs both passes; `encode` /
`decode` arguments limit to one phase. Sample rate is 4 ms and
the loop runs 200 000 iterations so each phase produces ~500
samples — enough to attribute single-percent line cost.
5d0e723e — Eugene Blikh 3 months ago
bench: starwing cross-runtime comparison harness

Adds bench/starwing_bench.lua, a sibling to bench/bench.lua that
runs the same Person and BenchPayload payloads through Starwing's
lua-protobuf (installed as a rock at .rocks/lib/tarantool/pb.so)
instead of our pure-Lua pb runtime. Same payload shapes, same
iteration counts, same measurement loop — so the two outputs
line up column-for-column.

Loading approach: strip runtime/ off package.path so the `pb`
module name resolves to starwing's C module. We use
package.loadlib to grab `luaopen_pb` from the .so and inject
into package.loaded before require('pb') has a chance to hit the
Lua-path loader; starwing's pb.option('int64_as_number') matches
the regime our bench/bench.lua exercises (bench payloads stay in
the Lua number range, avoiding per-call cdata allocation noise).

bench/starwing/{hello.pb,proto2_basic.pb} are the FileDescriptor
binaries starwing's pb.load() consumes; produced by mainline
protoc --descriptor_set_out from the same .proto sources we
generate Lua from.

Mirrors bench/go/'s role for Go (`bench: Go cross-runtime
comparison harness`, 07eef11) but for the in-Tarantool C-module
alternative — answers "how do we compare to the C-module
incumbent on the same host" rather than "how do we compare to
mainline C++/Go on a different host."
4b91822d — Eugene Blikh 3 months ago
bench/jit_trace: harden mcode arena + jit.off the listener

Two infra fixes for the trace-stability gate, both reproduced on
Tarantool 3.8.0-entrypoint / LuaJIT 2.1.0-beta3 / macOS arm64.

1. Default JIT mcode arena (sizemcode=32K, maxmcode=512K) is too
   small for our hot-path codegen footprint. Roughly 1 in 10 runs
   the gate reports every check as 'stops=0' with no diagnostic —
   indistinguishable from a real JIT topology regression. jit.v
   shows 'failed to allocate mcode memory at hello_pb.lua:890'
   (the packed lucky_numbers varint loop). Calling
   jit.opt.start('sizemcode=64','maxmcode=4096') at the top of
   the gate gives the arena enough headroom to hold both encoder
   and decoder bodies for the proto3 + proto2 fixtures.

2. The trace listener callback itself can become hot enough to
   be JIT-compiled, which then races with the function being
   recorded (recording-while-recording). Reproduced with a fat
   cb that appends event tuples to a table: starts go up but
   stops drop to ~0. Calling jit.off(cb) on the listener
   prevents this. The current cb body happens to dodge this
   because its branches keep call sites polymorphic — but it's
   fragile; any future extension (per-event timing, pc context)
   would re-trigger the bug. Adds the jit.off as defense.

Verified 20/20 consecutive runs now report 37/37 passing (was
intermittently 0/37 before). Also drops a now-stale "(PLAN.M6)"
reference from the header comment.

beads-tarantool-protobuf-3o2
d4dbd2f3 — Eugene Blikh 3 months ago
bench: C-acceleration spike — measure four Lua↔C boundaries

Adds bench/c_accel/ with hello.Person codecs at four boundary
strategies and a harness comparing them across 10B–100KB:

  S1 pure Lua (full mode) — existing baseline
  S2 prim.c + prim_ffi.lua — per-primitive FFI calls
  S3 generic_codec.c — one C call per message, descriptor-walking
  S4 person_codec.c — hand-written, no dispatch, upper bound

Plus ffi_probe.lua, a microbenchmark decomposing FFI call cost.

Headline numbers (×L = speedup vs pure-Lua full mode):

  ENCODE       S2     S3     S4
  10B        0.99   3.29   3.79
  100B       0.93   3.12   3.46
  1KB        0.82   5.33   4.94
  10KB       0.60   3.53   2.86
  100KB      0.63   3.49   2.85

  DECODE       S2     S3     S4
  10B        0.29   2.59   2.54
  100B       0.33   2.94   2.95
  1KB        0.39   7.22   7.62
  10KB       0.38   9.64  10.23
  100KB      0.37  10.87  11.33

S3 lands within ±15% of S4 at every size and beats it on encode
at 1KB+: the descriptor-walking loop is uniformly branch-predictable;
hand-written has more divergent per-field paths.

S2 loses to pure Lua at every size ≥1KB on encode and at every size
on decode. FFI cost decomposition (ffi_probe.lua):

  bare FFI call into ffi.load lib        33 ns
  pointer-returning FFI                  73 ns
  + v_out[0] read + tonumber            118 ns
  ffi.C.memcmp (for comparison)          60 ns
  ffi.cast(const uint8_t*, str)         156 ns
  ffi.string(p, 32)                      23 ns
  pure-Lua varint decode                 75 ns
  read_varint in tight traced loop       59 ns (floor)

Per-call FFI dispatch into a ffi.load'd lib is ~60–75 ns — the
same order of magnitude as pure-Lua varint decode. The "win" from
going to C only materializes when you cross the boundary ONCE per
message, not per primitive.

C encode plateaus at ~2.5–2.9 GB/s from 1KB upward; bottleneck
moves to Lua table reads and output string allocation. C decode
hits 2.5 GB/s at 100KB, while pure-Lua decode hits a per-byte
cliff (158 k msg/s @ 1KB → 2.3 k @ 100KB).

Architecture implication for pf6: the generic C runtime (ra6) gets
the full perf envelope; codegen-emitted per-message C (c0i) earns
≤15% headroom and goes the wrong way at scale. Per-primitive FFI
is a non-starter.

Required impl pattern in any future ra6: cache per-field stack
indices for repeated/packed arrays for the duration of
decode_message — the naive lazy-getfield variant was 2× slower
than hand-written at 100KB (see bench/c_accel/README.md).

beads-tarantool-protobuf-04c
be42fabb — Eugene Blikh 3 months ago
bench: record u39 (table.new pre-sized result) post-mortem

Attempted emitting table_new(0, N) for decode result tables. Tests
passed, JIT passed, but small-payload decode (10B, 100B) regressed
15-18% because the table.new upvalue-call cost exceeds any rehash
savings at that scale, and the small case never reaches the first
rehash anyway. Large-payload decode flat. Confirms drm's claim that
alloc shape is not the bottleneck on the bench corpus.

Code change reverted; only the writeup lands.

beads-tarantool-protobuf-u39
a6eebdb3 — Eugene Blikh 3 months ago
codegen: local counter per repeated field on decode

Replaces `list[#list + 1] = val` with `_n_<fname> = _n_<fname> + 1;
list[_n_<fname>] = val` in every generated M.X_decode repeated-field
append site. One counter local per repeated non-map field, declared at
function entry. Counters survive across loop iterations so out-of-order
wire entries for the same field continue past the existing length
without re-scanning.

Profile attributed 6.2% of hello.Person 1KB decode to the `#list + 1`
re-traversal (26-email Person paid 26 list scans per decode).

Bench (Person full decode, msgs/s, median-of-3 vs post-4kj):
1KB +5.0%, 10KB +5.0%, 100KB +7.1%. Encode flat. Tests 745/745.
JIT 37/37, 0 bridges.

beads-tarantool-protobuf-cch
feecf8e0 — Eugene Blikh 3 months ago
codegen: inline 1-byte tag fast path at decode call sites

Hoists wire.decode_tag's 1-byte fast path into every generated
M.X_decode while-loop, falling back to the helper for multi-byte
tags (field ids > 15). The 1-byte case covers every protobuf field
with id 1..15 and is the dominant decode dispatch in real payloads.

Header now localizes string.byte, bit.band, and bit.rshift so the
inlined ops compile to straight local calls.

Bench (Person full decode, msgs/s, median-of-3 vs proper post-h8v
3-run baseline): 10B +14.7%, 100B +8.9%, 1KB +7.5%, 10KB +7.0%,
100KB +8.5%. Full encode is flat to small (-0.1% to -2.6%) at large
sizes, plausibly from header-upvalue layout. JIT trace gate: 37/37,
all bridges still 0. Tests: 745/745.

Also documented in bench/PERF_LOG.md, including the methodology
note that h8v's earlier numbers used single-run baselines and are
therefore ~3-5% optimistic; medians-of-3 are the standard now.

beads-tarantool-protobuf-4kj
c85386b1 — Eugene Blikh 3 months ago
bench: record gcy (inline nested-message decode) post-mortem

Implemented and benchmarked the inline-nested-decode plan from
tarantool-protobuf-gcy. Test suite and JIT gate both pass, but
median-of-3 bench shows 3-8% regressions on Person 1KB/10KB/100KB
encode AND decode. Profile's "100% interpreter bail at Address_decode
call" turned out to be a vl trace-attribution artifact; LuaJIT was
already handling the call well.

Lesson recorded in bench/PERF_LOG.md so the next person who reads the
profile entry knows the obvious-looking inline transformation does
not deliver here. Code change reverted; only the writeup lands.

beads-tarantool-protobuf-gcy
9ee21c09 — Eugene Blikh 3 months ago
codegen: inline 1-byte varint length prefix at every LEN emit site

Eliminates the wire.encode_varint(#body) call + dispatch for every
length-delimited field in mode=full codegen. Profile flagged the
out[n] = wire.encode_varint(#_b) line as ~33% of hello.Person 1KB
encode time, with another ~17% in encode_varint dispatch — together
~50% of encode time. Lifting the 1-byte fast path (the dominant case
for proto strings and small message bodies) to the call site removes
the function frame entirely for lengths < 128.

Applied at every LEN emit site: singular/repeated message body,
singular/repeated string|bytes, packed scalar bundle, packed enum
bundle, map entry. Map value pieces (emitMapPiece message branch)
left as-is — they sit inside a single slot assignment that would
require a deeper restructure, and maps are not on the current hot
benchmark.

Results (hello.Person full encode, msgs/s): 10B +6.9%, 100B +7.9%,
1KB +25.7%, 10KB +48.1%, 100KB +31.9%. Decode flat (unchanged path).
Runtime mode flat (descriptor dispatch still calls encode_varint).
JIT trace gate: 37/37. Test suite: 745/745.

Bench history saved to bench/PERF_LOG.md with full numbers and the
workflow this iteration follows.

beads-tarantool-protobuf-h8v
07eef11c — Eugene Blikh 3 months ago
bench: Go cross-runtime comparison harness

Adds bench/go/ — single-threaded Go benchmarks against the same proto
schemas and payload sizes as bench/bench.lua, run against
google.golang.org/protobuf v1.36 (reflective apiv2) and
planetscale/vtprotobuf v0.6 (codegen marshalers). Wired through
`just gen-go` + `just bench-go`. *.pb.go is gitignored repo-wide so
the generated outputs are regenerated locally — not committed.

bench/COMPARISON.md documents the full table per fixture / size / op:
MB/s for Lua full + Lua runtime + apiv2 + vtproto, alloc bytes/op and
allocs/op side-by-side, and ratios. proto2 BenchPayload vtproto cases
are intentionally skipped — MarshalVT drops proto2 extensions and
would understate bytes vs apiv2 / Lua.
Next