This document records the architecture chosen for the C-accelerated
encode/decode path. It closes the umbrella task
tarantool-protobuf-pf6; the breakdown of follow-on work lives in
the Beads tracker under bd-ra6 and its sub-issues.
The pure-Lua runtime is competitive at small payload sizes but
hits a per-byte cliff on decode for messages above ~1 KB (see
bench/PERF_LOG.md and the
perf-analysis-2026-05-18 memory). Profiling attributed the cost
to per-field Lua dispatch and table/string work, not the wire
format itself. Crossing into C once per top-level message lets C
own the inner loop and amortizes any boundary cost.
bench/c_accel/ measured four boundary placements across 10 B –
100 KB on hello.Person. Summary speedup vs pure-Lua full mode
(full numbers in bench/c_accel/README.md):
| 10 B | 100 B | 1 KB | 10 KB | 100 KB | |
|---|---|---|---|---|---|
| S2 enc | 0.99 | 0.93 | 0.82 | 0.60 | 0.63 |
| S2 dec | 0.29 | 0.33 | 0.39 | 0.38 | 0.37 |
| S3 enc | 3.29 | 3.12 | 5.33 | 3.53 | 3.49 |
| S3 dec | 2.59 | 2.94 | 7.22 | 9.64 | 10.87 |
| S4 enc | 3.79 | 3.46 | 4.94 | 2.86 | 2.85 |
| S4 dec | 2.54 | 2.95 | 7.62 | 10.23 | 11.33 |
ffi.load-library
call cost (~60–75 ns) is the same order of magnitude as a
pure-Lua varint decode (~75 ns), so swapping doesn't save
anything; it just moves dispatch from bytecode to FFI.
ffi.cast on Lua strings is an additional ~156 ns per decode.pb.c_runtime,
bd-ra6) that walks compiled descriptor plans at the C level
and crosses the Lua↔C boundary exactly once per top-level
encode or decode.bd-c0i). ≤15% headroom doesn't earn
the codegen + maintenance + distribution complexity. Revival
criteria below.The C path is disabled by default. Existing installs get zero behavior change.
require('pb') returns the same Lua module table as today.PB_ENABLE_C=1 is set in the
environment at module load time.pb.encode / pb.decode and generated
M.<Type>_encode / M.<Type>_decode dispatch into C. When the C
module is absent or PB_ENABLE_C is unset, the pure-Lua path
runs unchanged.pb.use_c_runtime = true). The single
knob is the env var, evaluated once at module load. Keeping the
switch out-of-band prevents accidental API surface and keeps
inadvertent activation impossible.int64_t / uint64_t cdata in both
modes (bd-3l). WKT shapes stay identical (bd-3k). Unknown
fields keep round-tripping (bd-3j). Errors land as the same
Lua error types.The C path must be byte-equal to the Lua path on every encoded output and decode result — see "Parity verification" below.
bd-3a)When pb.finalize_message(desc) is called and PB_ENABLE_C=1,
the descriptor is compiled into a C-side plan userdata
allocated once and stashed on desc.c_plan. The plan carries:
mode=full uses today)luaL_ref slots for cached field-name strings (so each
lua_setfield call avoids re-interning)desc.oneofs_list pattern from runtime/pb/codec.lua)desc.encode / desc.decode are
set, the plan calls them instead of walking fields.The plan is not serialized or shared between processes. It is
rebuilt at module load time from the descriptor tables that
runtime/pb/{dynamic,fileset,wkt}.lua and the generated _pb.lua
already produce. No new descriptor source, no protocol change.
bd-3b–bd-3i)One C entry point per message type:
int Person_encode_c(lua_State *L); /* arg1 = table, returns string */
Generated Lua wrappers (in mode=full and mode=runtime) check
desc.c_plan and call the C function when present:
function M.Person_encode(t)
if desc.c_plan then return pb.c_runtime.encode(desc.c_plan, t) end
-- existing pure-Lua body
end
C-side encode loop walks the plan, reads each field via
lua_getfield with cached refs, dispatches on kind (scalar,
string, packed scalar, repeated string, sub-message, repeated
sub-message, map, oneof, group, extension), and writes into a
4 KB stack-backed buffer that promotes to malloc on overflow
(the pattern validated in bench/c_accel/person_codec.c).
Sub-messages use a separate sub-buffer (or backpatching, see
bd-3d for the trade); packed and repeated use cached per-field
stack indices for the duration of the call — the spike showed
that the naive lazy-getfield pattern is 2× slower than the cached
one at 100 KB (bd-3f).
Same shape, mirrored. One C entry point, descriptor-walk in C,
pre-sized result table via lua_createtable(0, n_fields),
per-field repeated-array stack indices cached for the duration
of the call.
bd-3k)WKT types (google.protobuf.Timestamp etc.) keep their hand-
rolled desc.encode / desc.decode functions. When the C plan
sees those set, it passes the field bytes straight to them. No
WKT changes; the same composition mechanism that makes WKT plug
in today.
This is also the extension point if codegen C (c0i) is ever
revived: emit a Foo_encode_c / Foo_decode_c per opted-in
message, register as desc.encode / desc.decode, done. Same
mechanism, narrower scope.
bd-43t)No separate Lua-vs-C diff harness. Instead, every existing test
suite runs in CI twice: once with PB_ENABLE_C unset (pure
Lua, today's baseline) and once with PB_ENABLE_C=1. Both must
pass. Suites in scope:
just test (luatest groups, ~639 tests, both modes already)just conformance (Google's proto3 + proto2 conformance, all
paths)test/interop/fixtures/*.txtpb + .bin)This works because every test asserts against a reference output (golden byte string, txtpb fixture, conformance result), so Lua == reference and C == reference implies Lua == C by transitivity. No new test infrastructure required.
The sourcehut build manifest gains one extra job invoking the
same recipes with PB_ENABLE_C=1.
bd-wky)just build-c — compile the C module into the project tree
(mirrors the bench/c_accel/Makefile pattern; auto-detects
Tarantool's include directory, supports macOS bundles + Linux
shared objects).runtime/pb/init.lua does pcall(require, 'pb.c_runtime') at
load time, conditional on os.getenv('PB_ENABLE_C') == '1'.c0i (codegen C) gets revivedTrigger: a measured real-workload shape where ra6's per-field
descriptor dispatch costs ≥25% over hand-written for that shape,
demonstrated with a microbenchmark, and the affected workload
is on a hot path for a real user. Shapes most likely to surface
this:
optional fields (more
presence-tracking dispatch per call)When the trigger fires, write 2–3 paragraphs documenting the
shape and numbers, re-open bd-c0i, scope it narrowly:
Foo_encode_c / Foo_decode_c for the
specific opted-in messagesdesc.encode / desc.decode overrides; same
mechanism as WKTIf 6 months pass after ra6 ships and no such trigger fires,
close c0i as "not justified."
S2) — spike showed it's structurally worse
than pure Lua. Not pursued.mode=full and mode=runtime
remain the default for everyone who doesn't set PB_ENABLE_C=1.bd-3e6); orthogonal,
separately scoped.c-accel-spike-04c-final-2026-05-18,
c-accel-spike-04c-phase-a-2026-05-18,
c-accel-spike-04c-phase-b-2026-05-18,
luajit-ffi-boundary-cost-2026-05-18bd-pf6 (this doc), bd-ra6 (impl),
bd-c0i (deferred codegen), bd-wky (build), bd-47e
(compat), bd-z7x (C-side strategy), bd-43t (parity)