From 6e89cf9978343e9c7ead0bad1afcda886b18a3b6 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Mon, 18 May 2026 23:22:56 +0300 Subject: [PATCH] docs: C acceleration architecture note (pf6 deliverable) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the architecture chosen for the C-accelerated encode/decode path, based on the bench/c_accel/ spike results (commit d4dbd2f). Closes the umbrella decision task pf6; the breakdown of follow-on implementation work lives in Beads under bd-ra6 and its twelve sub-issues (3a–3l). Key decisions captured: - Ship S3 (generic C runtime, descriptor-walking, one C call per message) as bd-ra6. Within ±15% of hand-written codegen in the spike, and beats it at scale on encode. - Defer S4 (codegen-emitted C, bd-c0i) with explicit revival criteria — ≤15% headroom doesn't earn codegen + maintenance + distribution complexity. - Drop S2 (per-primitive FFI) — structurally worse than pure Lua; the FFI boundary cost (~60-75 ns/call) is the same order of magnitude as the pure-Lua varint helpers it would replace. User-facing contract: - PB_ENABLE_C=1 env var is the only activation switch, default off. require('pb') returns the same Lua surface in both modes; zero behavior change for existing installs. - No Lua-level toggle. Single knob, evaluated once at module load. Parity verification: - No separate Lua-vs-C diff harness. Existing test suites (luatest, conformance, interop fixtures) run twice in CI — once with PB_ENABLE_C unset and once with PB_ENABLE_C=1. Parity is guaranteed by transitivity through the reference outputs. beads-tarantool-protobuf-pf6 --- docs/c-accel.md | 241 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 docs/c-accel.md diff --git a/docs/c-accel.md b/docs/c-accel.md new file mode 100644 index 0000000000000000000000000000000000000000..e65bf1d28cf44f624d3f65b8d879da13b6b25eea --- /dev/null +++ b/docs/c-accel.md @@ -0,0 +1,241 @@ +# C acceleration — architecture + +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. + +## Background + +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](../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. + +## The spike result + +`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](../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 | + +- **S2 (per-primitive FFI)**: loses to pure Lua at every size ≥ 1 KB + on encode and at *every* size on decode. The `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. +- **S3 (one generic C call per message, descriptor-walking)** lands + within ±15% of S4 (hand-written) and beats it at scale on + encode. +- **S4 (codegen-emitted C per message)** has ≤15% headroom over S3 + and goes the wrong way for large messages, where its more + divergent per-field paths cost branch predictability. + +## Decision + +1. **Ship S3.** Implement a generic C runtime (`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. +2. **Drop S2 entirely.** Per-primitive FFI is structurally worse + than pure Lua and is not a viable architecture. +3. **Defer S4 (codegen C, `bd-c0i`).** ≤15% headroom doesn't earn + the codegen + maintenance + distribution complexity. Revival + criteria below. + +## User-facing contract + +The C path is **disabled by default**. Existing installs get zero +behavior change. + +- `require('pb')` returns the same Lua module table as today. +- The C runtime is loaded only when `PB_ENABLE_C=1` is set in the + environment at module load time. +- When enabled, `pb.encode` / `pb.decode` and generated + `M._encode` / `M._decode` dispatch into C. When the C + module is absent or `PB_ENABLE_C` is unset, the pure-Lua path + runs unchanged. +- No Lua-level toggle (no `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. +- 64-bit integers stay LuaJIT `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. + +## Architecture sketch + +### Descriptor compilation (`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: + +- Per-field records (field number, wire type, kind tag, presence + tracking offset, default-value index) +- Pre-encoded tag bytes (the same trick `mode=full` uses today) +- `luaL_ref` slots for cached field-name strings (so each + `lua_setfield` call avoids re-interning) +- Sub-descriptor pointers for nested messages and map entries +- Oneof grouping metadata (parallel array, matches the + `desc.oneofs_list` pattern from `runtime/pb/codec.lua`) +- Extension range hooks for proto2 +- WKT override pointers — if `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. + +### Encode (`bd-3b`–`bd-3i`) + +One C entry point per message type: + +```c +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: + +```lua +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`). + +### Decode + +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. + +### WKT and the override hook (`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. + +## Parity verification (`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) +- Interop fixtures (`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`. + +## Build and packaging (`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). +- Rockspec optionally builds the C module when a C compiler is + detected; the install never fails on a host without a + compiler. +- `runtime/pb/init.lua` does `pcall(require, 'pb.c_runtime')` at + load time, conditional on `os.getenv('PB_ENABLE_C') == '1'`. +- CI builds the C module on every supported platform; CI also + runs one job with the C module *absent* to confirm the pure-Lua + install works without a compiler. + +## When `c0i` (codegen C) gets revived + +Trigger: 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: + +- Wide messages with dozens of `optional` fields (more + presence-tracking dispatch per call) +- Heavy oneof use where the C plan has to maintain a "which-one" + state per oneof group +- Maps with non-trivial value types +- Deeply nested message hierarchies (5+ levels) where the + recursive descriptor walk traverses many plan tables + +When the trigger fires, write 2–3 paragraphs documenting the +shape and numbers, re-open `bd-c0i`, scope it narrowly: + +- Plugin emits a `Foo_encode_c` / `Foo_decode_c` for the + specific opted-in messages +- Registered as `desc.encode` / `desc.decode` overrides; same + mechanism as WKT +- No new "mode" flag, no fork of the codegen path + +If 6 months pass after `ra6` ships and no such trigger fires, +close `c0i` as "not justified." + +## Out of scope + +- Per-primitive FFI (`S2`) — spike showed it's structurally worse + than pure Lua. Not pursued. +- Replacing the pure-Lua path. `mode=full` and `mode=runtime` + remain the default for everyone who doesn't set `PB_ENABLE_C=1`. +- Cross-process plan caching. Plans are per-process, rebuilt at + module load from descriptors that already live in memory. +- Vinyl or fiber-aware encoder buffers (`bd-3e6`); orthogonal, + separately scoped. + +## References + +- Spike code and full results: [bench/c_accel/](../bench/c_accel/) +- Memos: `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-18` +- Issue cluster: `bd-pf6` (this doc), `bd-ra6` (impl), + `bd-c0i` (deferred codegen), `bd-wky` (build), `bd-47e` + (compat), `bd-z7x` (C-side strategy), `bd-43t` (parity)