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.
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.
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).
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.
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."
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
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
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
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
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
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
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
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.
bench,codec: pin proto2 paths, kill pairs() on extension hot path
JIT trace gate gains 12 proto2-specific checks (required encode/decode,
groups singular + repeated, extension encode/decode). The extension-encode
check fired NYI on bytecode 72 (ISNEXT) — pairs() over the new
extensions_by_full_name hash is the same trace-abort that gates map
encode. Fix: register_extension now also appends to extensions_list,
an array view. codec.encode_message, text.emit_message and
json.encode_message all switch to ipairs over the list. Hash tables
stay around for O(1) lookups in decode (extensions_by_id) and bracket-
name resolution (extensions_by_full_name).
Inline (full-mode) codegen learns to walk extensions too: before this
commit the inline encoder skipped t._extensions entirely (only the
runtime codec walked it), so a generated _encode silently dropped any
extension data set on the message. Add the array-walk after the field
loop and a decode_extension dispatch in the unknown-tag branch — the
inline path now matches runtime byte-for-byte.
bench/bench.lua parameterizes over a FIXTURES list so the baseline can
cover both hello.Person and a new proto2_basic.BenchPayload fixture
(required + group + extension + repeated). baseline.json restructured
under "schemas": [{schema, results}, …]; compare walks both. Fresh
numbers committed.
740/740 luatest cases pass; JIT gate 37/37; conformance 2806 binary+JSON
and 434 text-format, both 0 failures.
bench: per-helper wire bench + shape-variety bench + multi-byte jit gates
Three additions so future perf regressions are visible at the right
granularity, not just averaged out by the single Person shape in
bench/bench.lua.
bench/wire_bench.lua (new). Microbenches every wire helper in
isolation — encode/decode for varint at 1/2/3/5 bytes, zigzag, fixed
widths, float/double, LEN, tag, skip_field, utf8 validator. Used when
tuning wire.lua to confirm a change moved the helper-level ns/op as
expected (e.g. encode_varint(150) 529 ns -> 68 ns from the 2-byte
fast path). Run via `make bench-wire`. No baseline, no regression
gate — this is a manual inspection tool.
bench/shapes_bench.lua (new). Runs encode + decode against a handful
of fundamentally different Person / Event / Result shapes —
scalar-heavy, packed-int (100 and 1000 elements), nested-friends
(10 and 100), maps (scalar-valued and message-valued), oneof, and
WKT-heavy. Each shape dials one knob up so cost attribution stays
clean. Shows alloc B/op alongside throughput. Run via
`make bench-shapes`. Caught the packed-int / multi-byte-varint
opportunity that bench/bench.lua (all 1-byte varints, fixed
shape) doesn't surface.
bench/jit_trace.lua + 4 gates. Two new fixtures per mode that
specifically exercise encode_varint_slow's 2/3-byte Lua-number paths
and decode_string's multi-byte LEN fallback (200-byte name + packed
ints in [150..500000]). If a future change pushes encode_varint_slow
past LuaJIT's inline budget the gate fires instead of the regression
landing silently in shapes_bench. Total gate count: 19 -> 23.
Makefile gains `bench-wire` and `bench-shapes` phony targets.
bench: lazy decode/encode scenarios + jit-trace coverage
Adds bench/lazy_bench.lua comparing eager decode/encode against
decode_lazy:encode across three workloads (passthrough, sparse-read,
mutate-then-reencode) at 1KB/10KB/100KB on emails-heavy Person.
Output is stderr-only (varies with CPU load; not committed to
baseline.json).
Findings (LuaJIT 2.1.0-beta3, both modes):
- Passthrough re-encode: lazy 1.0–1.5× faster. Untouched views skip
field-walk entirely and return their original bytes verbatim.
- Sparse :get x2: lazy 0.60–0.77× of eager. Per-segment Lua tables
allocated during the index pass cancel the decode-skip savings on
this flat shape (no large subtrees to skip_field over).
- Mutate-then-reencode: lazy 0.81–1.07× of eager — roughly
break-even. Both paths traverse the full byte range; lazy
splices, eager re-emits per field.
Lazy is a byte-passthrough optimization, not a universal speedup. Use
it when you decode, touch few fields, and re-encode — the proxy /
router shape.
Also extends bench/jit_trace.lua with three lazy hot-path checks:
index pass, sparse :get x2, and untouched :encode. All compile with
no fatal aborts (19/19 trace-stability checks pass; one harmless
side-trace bridge per scenario at lazy.lua's index loop, same pattern
as the existing decode_varint bridge).
M6: trace stability gate + two fixes
Add `make jit-trace` (`bench/jit_trace.lua`) — a standalone tarantool
script that attaches a `jit.attach('trace')` listener over each hot
encode/decode path and asserts no aborts in our source files fall into
the fatal set (NYI bytecode, blacklisting, persistent type instability).
Runs outside luatest because on macOS arm64 the test framework exhausts
JIT mcode pages before the test body runs, masking real abort reasons.
Two fixes shipped to make all 13 scenarios pass:
- `decode_varint` grew a 1-byte fast path. Before, calling it from a
hot decode loop pulled an inner `while true do` into the caller's
root trace, which got blacklisted after enough retries.
- `pb.finalize_message` now precomputes `desc.oneofs_list` (array
form) and the runtime-mode codec iterates it with ipairs instead
of `pairs(desc.oneofs)`. `pairs()` over a hash-keyed table compiles
to bytecode ISNEXT, which is NYI in LuaJIT 2.1.
The gate also reports interpreter-bridge counts as a benchmark-quality
metric. Decoders show 0-4 bridges per run depending on JIT timing —
caused by side traces returning from inlined `decode_varint` calls,
which LuaJIT 2.1 can't stitch back cleanly. Small per-call overhead on
the multi-byte slow path, structural to the engine.
Scope caveat: map fields encode via `pairs()` and remain off-trace —
pinned by the gate's last scenario so we notice if upstream lifts the
restriction.
Initial commit: protoc-gen-tarantool plugin + pb runtime
A protoc plugin (Go) and a pure-Lua + LuaJIT-FFI runtime that give
Tarantool a complete proto3 + gRPC stack. Two codegen modes (full
inline / runtime descriptor), 226-test luatest suite, 18-fixture
mainline-protoc interop corpus, JSON codec, well-known types,
gRPC client/server factories, runtime .proto parser, microbench
harness with allocation regression gate.
Covers PLAN.md M1-M5. Module is `pb` (not `protobuf`) to avoid
colliding with Tarantool's built-in encode-only `protobuf` module.