~bigbes/tarantool

tarantool-protobuf

ref: 89e781b0905d5409ace43733899e7353619a9e8d tarantool-protobuf/bench/c_accel/README.md -rw-r--r-- 6.1 KiB
89e781b0 — Eugene Blikh test: verify build dispatch after hook fix 2 months ago

#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 FFIwire.lua's encode_varint, decode_string, etc. become ffi.C.<fn> 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

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:

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.