# bench/c_accel — C acceleration spike Spike work for `tarantool-protobuf-04c` (benchmark which Lua↔C boundary wins for protobuf codec work). Not part of the shipping codec; lives under `bench/` because its only purpose is measurement. ## Strategies (per the parent ticket) The acceleration design question is *where* the Lua↔C boundary should sit. Four candidate boundaries: 1. **Pure Lua** (baseline) — current `mode=full` generated code, no C involved. Measured via the existing `bench/bench.lua`. 2. **Per-primitive FFI** — `wire.lua`'s `encode_varint`, `decode_string`, etc. become `ffi.C.` calls. The dispatch loop stays in Lua; only the inner bit-twiddling is in C. 3. **One generic C call per message** — a C module gets the descriptor and the input table once and owns the inner loop. 4. **Hand-written C codec for hello.Person** — upper bound. No dispatch, no descriptor walk. Tells us the ceiling. All four strategies are wired into `spike_bench.lua`: - `prim.c` + `prim_ffi.lua` — Strategy 2 (FFI primitives) - `generic_codec.c` — Strategy 3 (one generic C call, descriptor-walking) - `person_codec.c` — Strategy 4 (hand-written for hello.Person) ## Scope Both `person_codec.c` and `generic_codec.c` implement only the Person fields exercised by `bench/bench.lua`'s payload builder: `name`, `age`, `emails`, `address` (with `street`/`city`/`zip`), `lucky_numbers`. The spike measures perf, not coverage. `generic_codec.c` walks a hand-built `message_desc_t` / `field_desc_t`. A real `ra6` would build these descriptors from the Lua descriptor at `pb.finalize_message` time and pass them through a registered userdata. The generic decode caches per-field stack indices for repeated / packed arrays for the duration of `decode_message`, so each `lua_setfield` of the array root into the result table happens *once*, not per element. That matches `person_codec.c`'s pattern; the naive "lazy lookup per element" version (initial commit) was ~2× slower than hand-written at 100 KB. ## Build and run ```bash make -C bench/c_accel # builds pb_c_person.dylib tarantool bench/c_accel/spike_bench.lua ``` Override the Tarantool include dir if auto-detection fails: ```bash make -C bench/c_accel TT_INC=/path/to/include/tarantool ``` ## Results — 2026-05-18 Apple M-series, Tarantool 3.8.0-entrypoint-49 / LuaJIT 2.1.0-beta3. Throughput msg/s; bandwidth MB/s. ×L columns are speedup vs the pure-Lua baseline. ### Encode | size | bytes | pure-Lua msg/s (MB/s) | S2 FFI msg/s (MB/s) | ×L | S3 gen msg/s (MB/s) | ×L | S4 hand msg/s (MB/s) | ×L | |-------|-------:|----------------------:|--------------------:|-----:|--------------------:|-----:|---------------------:|-----:| | 10B | 10 | 3,159,308 (31.6) | 3,117,936 (31.2) | 0.99 | 10,382,060 (103.8) | 3.29 | 11,981,070 (119.8) | 3.79 | | 100B | 94 | 3,210,840 (301.8) | 2,984,273 (280.5) | 0.93 | 10,017,531 (941.6) | 3.12 | 11,095,085 (1042.9) | 3.46 | | 1KB | 930 | 369,992 (344.1) | 303,955 (282.7) | 0.82 | 1,970,288 (1832.4) | 5.33 | 1,826,351 (1698.5) | 4.94 | | 10KB | 9,634 | 85,025 (819.1) | 50,697 (488.4) | 0.60 | 300,336 (2893.4) | 3.53 | 243,132 (2342.3) | 2.86 | | 100KB | 96,674 | 8,726 (843.6) | 5,458 (527.7) | 0.63 | 30,428 (2941.6) | 3.49 | 24,826 (2400.0) | 2.85 | ### Decode | size | bytes | pure-Lua msg/s (MB/s) | S2 FFI msg/s (MB/s) | ×L | S3 gen msg/s (MB/s) | ×L | S4 hand msg/s (MB/s) | ×L | |-------|-------:|----------------------:|--------------------:|-----:|--------------------:|------:|---------------------:|------:| | 10B | 10 | 3,499,685 (35.0) | 1,003,014 (10.0) | 0.29 | 9,078,941 (90.8) | 2.59 | 8,896,006 (89.0) | 2.54 | | 100B | 94 | 2,964,500 (278.7) | 981,865 (92.3) | 0.33 | 8,728,669 (820.5) | 2.94 | 8,734,387 (821.0) | 2.95 | | 1KB | 930 | 165,113 (153.6) | 64,890 (60.3) | 0.39 | 1,191,611 (1108.2) | 7.22 | 1,258,812 (1170.7) | 7.62 | | 10KB | 9,634 | 22,807 (219.7) | 8,683 (83.7) | 0.38 | 219,809 (2117.6) | 9.64 | 233,209 (2246.7) | 10.23 | | 100KB | 96,674 | 2,344 (226.6) | 870 (84.1) | 0.37 | 25,487 (2463.9) | 10.87 | 26,562 (2567.8) | 11.33 | ### What the numbers say - **The C boundary is cheap; per-primitive FFI is not.** Crossing the C boundary *once* per message wins 3–11×. Crossing it tens of times per message (S2) *loses* — pure-Lua decode is 3× faster than FFI-primitive decode because LuaJIT inlines its own wire helpers but a `ffi.load`'d library's per-call dispatch is several hundred ns. - **S3 ≈ S4** within ±15% at every size, and S3 *beats* S4 on encode at 1 KB+ (the descriptor-walk loop is uniformly branch- predictable; the hand-written codec has more divergent per-field paths). - **C encode plateaus at ~2.5–2.9 GB/s** from 1 KB upward. The bottleneck moves to Lua table reads and output string allocation, not wire formatting. - **C decode degrades much more gracefully than Lua decode.** Pure-Lua decode is per-byte cliff-y (158 k msg/s @ 1KB → 2.3 k @ 100KB); C decode degrades roughly linearly with size, hitting 2.5 GB/s at 100KB. - The cache-the-repeated-array-stack-idx pattern is required: the naive lazy-getfield version was ~2× slower than hand-written at 100 KB. `ra6` must encode this. ### What this means for the architecture (`pf6`) - **Ship `ra6` (generic C runtime, one C call per message).** It's the message-level boundary and S3 lands within noise of the hand-written ceiling. 3–11× over pure Lua at every size. - **Drop `c0i` (codegen-emitted per-message C).** ≤15% headroom over `ra6`, going the wrong way at scale. The codegen complexity isn't justified. - **Drop per-primitive FFI as an architecture.** S2 loses to pure Lua at every size ≥1 KB on encode and at every size on decode. The boundary is too chatty. - The result-table allocation in C still goes through the Lua runtime, so very-small-message C wins are capped (~3× at 10B encode). Worth knowing for `ra6` — the floor is the Lua side of the boundary, not the wire layer.