A bench/c_accel/.gitignore => bench/c_accel/.gitignore +3 -0
@@ 0,0 1,3 @@
+*.dylib
+*.so
+*.o
A bench/c_accel/Makefile => bench/c_accel/Makefile +50 -0
@@ 0,0 1,50 @@
+# Makefile for the C-acceleration spike (tarantool-protobuf-04c).
+#
+# Builds Lua C modules loadable from the bench harness with no install step
+# -- the harness sets package.cpath to point here.
+
+# Locate <module.h>. Order: env override, brew prefix, common system dirs.
+TT_INC ?= $(shell \
+ if [ -n "$$TARANTOOL_INCLUDE" ] && [ -f "$$TARANTOOL_INCLUDE/module.h" ]; then \
+ echo "$$TARANTOOL_INCLUDE"; exit 0; \
+ fi; \
+ for d in \
+ $$(brew --prefix tarantool 2>/dev/null)/include/tarantool \
+ /opt/homebrew/include/tarantool \
+ /usr/local/include/tarantool \
+ /usr/include/tarantool; do \
+ if [ -f "$$d/module.h" ]; then echo "$$d"; exit 0; fi; \
+ done)
+
+ifeq ($(TT_INC),)
+$(error Cannot find <module.h>; set TARANTOOL_INCLUDE=/path/to/include/tarantool)
+endif
+
+UNAME_S := $(shell uname -s)
+
+CFLAGS := -O2 -fPIC -Wall -Wextra -std=c99 -I$(TT_INC)
+ifeq ($(UNAME_S),Darwin)
+ LIB_EXT := dylib
+ LDFLAGS := -bundle -undefined dynamic_lookup
+else
+ LIB_EXT := so
+ LDFLAGS := -shared
+endif
+
+MODULES := pb_c_person.$(LIB_EXT) pb_c_generic.$(LIB_EXT) libpb_prim.$(LIB_EXT)
+
+all: $(MODULES)
+
+pb_c_person.$(LIB_EXT): person_codec.c
+ $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $<
+
+pb_c_generic.$(LIB_EXT): generic_codec.c
+ $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $<
+
+libpb_prim.$(LIB_EXT): prim.c
+ $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $<
+
+clean:
+ rm -f *.dylib *.so
+
+.PHONY: all clean
A bench/c_accel/README.md => bench/c_accel/README.md +123 -0
@@ 0,0 1,123 @@
+# 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.<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
+
+```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.
A bench/c_accel/ffi_probe.lua => bench/c_accel/ffi_probe.lua +226 -0
@@ 0,0 1,226 @@
+#!/usr/bin/env tarantool
+-- ffi_probe.lua -- diagnose why S2 (per-primitive FFI) is slow.
+--
+-- Microbenchmarks the individual FFI calls used in prim_ffi.lua to
+-- decompose where decode/encode time actually goes. Also dumps JIT
+-- traces for the hottest call shapes.
+
+local SCRIPT_DIR = (debug.getinfo(1, 'S').source:match('@?(.*/)') or './')
+package.cpath = SCRIPT_DIR .. '?.dylib;' .. SCRIPT_DIR .. '?.so;' .. package.cpath
+
+local ffi = require('ffi')
+local bit = require('bit')
+local clock = require('clock')
+
+ffi.cdef[[
+ typedef struct ibuf_s {
+ uint8_t *data;
+ size_t len;
+ size_t cap;
+ } ibuf_t;
+ void pb_ibuf_init(ibuf_t *b);
+ void pb_ibuf_reset(ibuf_t *b);
+ void pb_write_varint(ibuf_t *b, uint64_t v);
+ void pb_write_bytes(ibuf_t *b, const uint8_t *src, size_t n);
+ void pb_write_string_field(ibuf_t *b, uint32_t tag,
+ const uint8_t *src, size_t n);
+ const uint8_t *pb_read_varint(const uint8_t *p,
+ const uint8_t *end, uint64_t *out);
+]]
+
+local UNAME = io.popen('uname -s'):read('*l')
+local ext = (UNAME == 'Darwin') and '.dylib' or '.so'
+local C = ffi.load(SCRIPT_DIR .. 'libpb_prim' .. ext)
+
+local outbuf = ffi.new('ibuf_t')
+C.pb_ibuf_init(outbuf)
+
+local v_out = ffi.new('uint64_t[1]')
+
+local function time_loop(fn, n)
+ -- Warm: let the JIT trace
+ for _ = 1, 5000 do fn() end
+ collectgarbage('collect')
+ local t0 = clock.monotonic64()
+ for _ = 1, n do fn() end
+ local t1 = clock.monotonic64()
+ return tonumber(t1 - t0) / n -- ns/op
+end
+
+io.write('FFI primitive probe — Tarantool ', _TARANTOOL, '\n\n')
+
+-- ----------------------------------------------------------------
+-- Bench 1: bare FFI call into ffi.load'd lib, simplest signature.
+-- pb_ibuf_reset is void(ibuf_t*). No marshalling beyond pointer pass.
+-- ----------------------------------------------------------------
+local N = 2000000
+
+io.write('=== Bare FFI calls (no return marshalling) ===\n')
+local t = time_loop(function() C.pb_ibuf_reset(outbuf) end, N)
+io.write(string.format(' pb_ibuf_reset(buf): %6.1f ns/call\n', t))
+
+-- One write_varint with constant value (simplest).
+local t = time_loop(function()
+ C.pb_ibuf_reset(outbuf)
+ C.pb_write_varint(outbuf, 42)
+end, N)
+io.write(string.format(' reset + write_varint(42): %6.1f ns/call\n', t))
+
+-- Just write_varint, no reset.
+local t = time_loop(function() C.pb_write_varint(outbuf, 42) end, N)
+io.write(string.format(' write_varint(42) only: %6.1f ns/call (note: drifts buf)\n', t))
+
+-- ----------------------------------------------------------------
+-- Bench 2: FFI call with pointer return + output parameter.
+-- This is the suspected smoking gun. pb_read_varint returns
+-- const uint8_t*. Trace formation around return-by-pointer is the
+-- common LuaJIT FFI pitfall.
+-- ----------------------------------------------------------------
+io.write('\n=== Pointer-return FFI (suspect: pb_read_varint) ===\n')
+
+local buf_str = string.char(0x96, 0x01) -- varint encoding of 150
+local p0 = ffi.cast('const uint8_t*', buf_str)
+local end0 = p0 + #buf_str
+
+local t = time_loop(function()
+ local p = C.pb_read_varint(p0, end0, v_out)
+ -- discard p
+end, N)
+io.write(string.format(' pb_read_varint(p, end, out) [1B]: %6.1f ns/call\n', t))
+
+-- Variant: assign return + read v_out[0] (the realistic decode pattern).
+local t = time_loop(function()
+ local p = C.pb_read_varint(p0, end0, v_out)
+ local v = v_out[0]
+end, N)
+io.write(string.format(' read_varint + v_out[0] read: %6.1f ns/call\n', t))
+
+-- Variant: ditto + tonumber.
+local t = time_loop(function()
+ local p = C.pb_read_varint(p0, end0, v_out)
+ local v = tonumber(v_out[0])
+end, N)
+io.write(string.format(' read_varint + v_out[0] + tonumber: %6.1f ns/call\n', t))
+
+-- ----------------------------------------------------------------
+-- Bench 3: per-call overhead components.
+-- ffi.cast / ffi.string allocate. Quantify.
+-- ----------------------------------------------------------------
+io.write('\n=== Allocation-heavy FFI ops ===\n')
+
+local s32 = string.rep('a', 32)
+
+local t = time_loop(function()
+ local p = ffi.cast('const uint8_t*', s32)
+end, N)
+io.write(string.format(' ffi.cast(const uint8_t*, str): %6.1f ns/call\n', t))
+
+local p_cdata = ffi.cast('const uint8_t*', s32)
+local t = time_loop(function()
+ local s = ffi.string(p_cdata, 32)
+end, N)
+io.write(string.format(' ffi.string(ptr, 32): %6.1f ns/call\n', t))
+
+-- ----------------------------------------------------------------
+-- Bench 4: same op, but call ffi.C symbol vs ffi.load'd lib.
+-- LuaJIT can sometimes inline ffi.C calls (libc symbols loaded via
+-- the process namespace) more aggressively than ffi.load'd libs.
+-- ----------------------------------------------------------------
+io.write('\n=== ffi.C (libc) vs ffi.load (libpb_prim) calls ===\n')
+
+ffi.cdef[[
+ int memcmp(const void *s1, const void *s2, size_t n);
+]]
+
+local b1 = ffi.cast('const uint8_t*', 'aaaaaaaa')
+local b2 = ffi.cast('const uint8_t*', 'aaaaaaaa')
+
+local t = time_loop(function() ffi.C.memcmp(b1, b2, 8) end, N)
+io.write(string.format(' ffi.C.memcmp(p, p, 8): %6.1f ns/call\n', t))
+
+-- ----------------------------------------------------------------
+-- Bench 5: pure-Lua equivalent — a Lua varint decoder.
+-- The baseline that beats us. If pure-Lua varint is ~10 ns,
+-- FFI is paying a real boundary cost per call.
+-- ----------------------------------------------------------------
+io.write('\n=== Pure Lua varint decode (the thing we are losing to) ===\n')
+
+local function lua_decode_varint(s, pos)
+ local v = 0
+ local shift = 0
+ while true do
+ local b = s:byte(pos)
+ pos = pos + 1
+ v = v + (b - (b >= 128 and 128 or 0)) * (2 ^ shift)
+ if b < 128 then break end
+ shift = shift + 7
+ end
+ return v, pos
+end
+
+local s150 = string.char(0x96, 0x01)
+local t = time_loop(function()
+ local v, p = lua_decode_varint(s150, 1)
+end, N)
+io.write(string.format(' lua_decode_varint(s150) — Lua only: %6.1f ns/call\n', t))
+
+-- ----------------------------------------------------------------
+-- Bench 6: jit.dump on the suspect path to see whether a trace
+-- actually compiles for the FFI read_varint loop.
+-- ----------------------------------------------------------------
+io.write('\n=== JIT trace status (read_varint loop) ===\n')
+local jutil = require('jit.util')
+local jv = require('jit.v')
+
+io.write(' Run with TARANTOOL_VERBOSE_JIT=1 to dump traces; below is a 100k-iter loop:\n')
+
+-- Compile-trigger loop
+local function read_varint_loop()
+ for _ = 1, 100 do
+ local p = C.pb_read_varint(p0, end0, v_out)
+ local v = tonumber(v_out[0])
+ end
+end
+
+-- Try to expose trace formation.
+local jv_enabled = false
+local ok = pcall(function()
+ jv.start('-')
+ jv_enabled = true
+end)
+read_varint_loop()
+read_varint_loop()
+if jv_enabled then
+ pcall(function() jv.stop() end)
+end
+
+-- ----------------------------------------------------------------
+-- Bench 7: same FFI primitives, but loop the calls so the trace
+-- has the loop body to compile. If per-call cost falls dramatically
+-- inside a loop, then the issue is trace formation per inner-most
+-- call. If it stays the same, the FFI call itself is slow.
+-- ----------------------------------------------------------------
+io.write('\n=== Tight loop of read_varint (amortize boundary, look for trace) ===\n')
+
+local function run_loop_n(reps)
+ -- pre-warm
+ for _ = 1, 100 do
+ for _ = 1, reps do
+ local p = C.pb_read_varint(p0, end0, v_out)
+ end
+ end
+ collectgarbage('collect')
+ local t0 = clock.monotonic64()
+ for _ = 1, 1000 do
+ for _ = 1, reps do
+ local p = C.pb_read_varint(p0, end0, v_out)
+ end
+ end
+ local t1 = clock.monotonic64()
+ return tonumber(t1 - t0) / (1000 * reps)
+end
+
+for _, reps in ipairs({1, 4, 16, 64, 256}) do
+ io.write(string.format(' read_varint x %3d in loop: %6.1f ns/call\n',
+ reps, run_loop_n(reps)))
+end
A bench/c_accel/generic_codec.c => bench/c_accel/generic_codec.c +424 -0
@@ 0,0 1,424 @@
+/*
+ * generic_codec.c -- one-C-call generic codec, descriptor-walking.
+ *
+ * Strategy 3 of tarantool-protobuf-04c.
+ *
+ * Same boundary as person_codec.c (one C call per top-level
+ * encode/decode), but the inner loop walks a `message_desc_t` and
+ * dispatches per-field on `kind_t`. This is what `ra6` would ship
+ * in production. The gap to person_codec.c (Strategy 4) is the
+ * dispatch overhead of being generic.
+ *
+ * Scope mirrors person_codec.c: only Person fields exercised by
+ * bench/bench.lua. The descriptor tables for Person and Address
+ * are hand-built; a real implementation would build them at
+ * `pb.finalize_message` time from the Lua descriptor.
+ */
+
+#include <module.h>
+#include <lauxlib.h>
+
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+/* ---------------------------------------------------------------- *
+ * buf_t (identical to person_codec.c -- duplicated to keep the *
+ * spike modules independent). *
+ * ---------------------------------------------------------------- */
+
+typedef struct {
+ uint8_t *data;
+ size_t len;
+ size_t cap;
+ uint8_t stack[4096];
+} buf_t;
+
+static inline void
+buf_init(buf_t *b)
+{
+ b->data = b->stack;
+ b->len = 0;
+ b->cap = sizeof(b->stack);
+}
+
+static inline void
+buf_free(buf_t *b)
+{
+ if (b->data != b->stack)
+ free(b->data);
+}
+
+static void
+buf_grow(buf_t *b, size_t need)
+{
+ size_t nc = b->cap ? b->cap * 2 : 64;
+ while (nc < b->len + need)
+ nc *= 2;
+ uint8_t *nd = (uint8_t *)malloc(nc);
+ memcpy(nd, b->data, b->len);
+ if (b->data != b->stack)
+ free(b->data);
+ b->data = nd;
+ b->cap = nc;
+}
+
+static inline void
+buf_reserve(buf_t *b, size_t need)
+{
+ if (b->len + need > b->cap)
+ buf_grow(b, need);
+}
+
+static inline void
+write_varint(buf_t *b, uint64_t v)
+{
+ buf_reserve(b, 10);
+ while (v >= 0x80) {
+ b->data[b->len++] = (uint8_t)(v | 0x80);
+ v >>= 7;
+ }
+ b->data[b->len++] = (uint8_t)v;
+}
+
+static inline void
+write_bytes(buf_t *b, const void *src, size_t n)
+{
+ buf_reserve(b, n);
+ memcpy(b->data + b->len, src, n);
+ b->len += n;
+}
+
+static const uint8_t *
+read_varint(const uint8_t *p, const uint8_t *end, uint64_t *out)
+{
+ uint64_t v = 0;
+ int shift = 0;
+ while (p < end) {
+ uint8_t c = *p++;
+ v |= (uint64_t)(c & 0x7f) << shift;
+ if (!(c & 0x80)) {
+ *out = v;
+ return p;
+ }
+ shift += 7;
+ if (shift >= 64)
+ return NULL;
+ }
+ return NULL;
+}
+
+/* ---------------------------------------------------------------- *
+ * Descriptor model. *
+ * ---------------------------------------------------------------- */
+
+typedef enum {
+ K_INT32 = 0,
+ K_STRING,
+ K_MESSAGE,
+ K_REPEATED_STRING,
+ K_PACKED_INT32,
+} kind_t;
+
+struct message_desc;
+
+typedef struct field_desc {
+ uint32_t tag; /* (field_num << 3) | wire_type */
+ int field_num;
+ const char *name;
+ kind_t kind;
+ const struct message_desc *submsg;
+} field_desc_t;
+
+typedef struct message_desc {
+ const char *name;
+ int n_fields;
+ const field_desc_t *fields;
+} message_desc_t;
+
+/* Address (sub-message used by Person.address) */
+static const field_desc_t address_fields[] = {
+ {(1 << 3) | 2, 1, "street", K_STRING, NULL},
+ {(2 << 3) | 2, 2, "city", K_STRING, NULL},
+ {(3 << 3) | 0, 3, "zip", K_INT32, NULL},
+};
+static const message_desc_t Address_desc = {
+ "Address", 3, address_fields,
+};
+
+/* Person (subset exercised by bench payloads) */
+static const field_desc_t person_fields[] = {
+ {(1 << 3) | 2, 1, "name", K_STRING, NULL},
+ {(2 << 3) | 0, 2, "age", K_INT32, NULL},
+ {(3 << 3) | 2, 3, "emails", K_REPEATED_STRING, NULL},
+ {(5 << 3) | 2, 5, "address", K_MESSAGE, &Address_desc},
+ {(7 << 3) | 2, 7, "lucky_numbers", K_PACKED_INT32, NULL},
+};
+static const message_desc_t Person_desc = {
+ "Person", 5, person_fields,
+};
+
+static int
+find_field_idx(const message_desc_t *md, int field_num)
+{
+ for (int i = 0; i < md->n_fields; i++)
+ if (md->fields[i].field_num == field_num)
+ return i;
+ return -1;
+}
+
+/* Max descriptor fields per message in the spike. Bumped above the
+ * exercise to keep the stack-allocated index arrays in decode_message
+ * safe; a real runtime would size dynamically. */
+#define MAX_FIELDS_PER_MSG 16
+
+/* ---------------------------------------------------------------- *
+ * Generic encode. *
+ * ---------------------------------------------------------------- */
+
+static void
+encode_message(buf_t *b, lua_State *L, int t,
+ const message_desc_t *md)
+{
+ for (int i = 0; i < md->n_fields; i++) {
+ const field_desc_t *fd = &md->fields[i];
+ lua_getfield(L, t, fd->name);
+ if (lua_isnil(L, -1)) {
+ lua_pop(L, 1);
+ continue;
+ }
+ switch (fd->kind) {
+ case K_STRING: {
+ size_t n;
+ const char *s = lua_tolstring(L, -1, &n);
+ write_varint(b, fd->tag);
+ write_varint(b, (uint64_t)n);
+ write_bytes(b, s, n);
+ break;
+ }
+ case K_INT32: {
+ write_varint(b, fd->tag);
+ write_varint(b,
+ (uint64_t)(int64_t)lua_tointeger(L, -1));
+ break;
+ }
+ case K_REPEATED_STRING: {
+ int idx = lua_gettop(L);
+ int n = (int)lua_objlen(L, idx);
+ for (int j = 1; j <= n; j++) {
+ lua_rawgeti(L, idx, j);
+ size_t slen;
+ const char *s =
+ lua_tolstring(L, -1, &slen);
+ write_varint(b, fd->tag);
+ write_varint(b, (uint64_t)slen);
+ write_bytes(b, s, slen);
+ lua_pop(L, 1);
+ }
+ break;
+ }
+ case K_MESSAGE: {
+ int idx = lua_gettop(L);
+ buf_t sub;
+ buf_init(&sub);
+ encode_message(&sub, L, idx, fd->submsg);
+ write_varint(b, fd->tag);
+ write_varint(b, (uint64_t)sub.len);
+ write_bytes(b, sub.data, sub.len);
+ buf_free(&sub);
+ break;
+ }
+ case K_PACKED_INT32: {
+ int idx = lua_gettop(L);
+ int n = (int)lua_objlen(L, idx);
+ buf_t sub;
+ buf_init(&sub);
+ for (int j = 1; j <= n; j++) {
+ lua_rawgeti(L, idx, j);
+ write_varint(&sub,
+ (uint64_t)(int64_t)
+ lua_tointeger(L, -1));
+ lua_pop(L, 1);
+ }
+ write_varint(b, fd->tag);
+ write_varint(b, (uint64_t)sub.len);
+ write_bytes(b, sub.data, sub.len);
+ buf_free(&sub);
+ break;
+ }
+ }
+ lua_pop(L, 1);
+ }
+}
+
+/* ---------------------------------------------------------------- *
+ * Generic decode. *
+ * ---------------------------------------------------------------- */
+
+static const uint8_t *
+decode_message(lua_State *L, const uint8_t *p, const uint8_t *end,
+ const message_desc_t *md);
+
+/* Skip an unknown field given its wire type. Returns new p or NULL. */
+static const uint8_t *
+skip_field(const uint8_t *p, const uint8_t *end, int wt)
+{
+ uint64_t v;
+ switch (wt) {
+ case 0: /* varint */
+ return read_varint(p, end, &v);
+ case 1: /* 64-bit */
+ if (end - p < 8) return NULL;
+ return p + 8;
+ case 2: { /* LEN */
+ p = read_varint(p, end, &v);
+ if (!p || (uint64_t)(end - p) < v) return NULL;
+ return p + v;
+ }
+ case 5: /* 32-bit */
+ if (end - p < 4) return NULL;
+ return p + 4;
+ default:
+ return NULL;
+ }
+}
+
+static const uint8_t *
+decode_message(lua_State *L, const uint8_t *p, const uint8_t *end,
+ const message_desc_t *md)
+{
+ lua_createtable(L, 0, md->n_fields);
+ const int result_idx = lua_gettop(L);
+
+ /* Per-field caches for repeated-array fields. Indexed by field
+ * position in md->fields. arr_stk[i] == 0 means "not yet
+ * created". Setting to result table happens once at the end so
+ * we only pay lua_setfield once per repeated field, not per
+ * element -- matches the hand-written codec's pattern. */
+ int arr_stk[MAX_FIELDS_PER_MSG] = {0};
+ int arr_n[MAX_FIELDS_PER_MSG] = {0};
+
+ while (p < end) {
+ uint64_t tag;
+ p = read_varint(p, end, &tag);
+ if (!p) break;
+ int field_num = (int)(tag >> 3);
+ int wt = (int)(tag & 7);
+ int fi = find_field_idx(md, field_num);
+ if (fi < 0) {
+ p = skip_field(p, end, wt);
+ if (!p) break;
+ continue;
+ }
+ const field_desc_t *fd = &md->fields[fi];
+ switch (fd->kind) {
+ case K_STRING: {
+ uint64_t slen;
+ p = read_varint(p, end, &slen);
+ if (!p || (uint64_t)(end - p) < slen) goto done;
+ lua_pushlstring(L, (const char *)p, (size_t)slen);
+ lua_setfield(L, result_idx, fd->name);
+ p += slen;
+ break;
+ }
+ case K_INT32: {
+ uint64_t v;
+ p = read_varint(p, end, &v);
+ if (!p) goto done;
+ lua_pushinteger(L, (lua_Integer)(int32_t)v);
+ lua_setfield(L, result_idx, fd->name);
+ break;
+ }
+ case K_REPEATED_STRING: {
+ uint64_t slen;
+ p = read_varint(p, end, &slen);
+ if (!p || (uint64_t)(end - p) < slen) goto done;
+ if (arr_stk[fi] == 0) {
+ lua_createtable(L, 4, 0);
+ arr_stk[fi] = lua_gettop(L);
+ }
+ lua_pushlstring(L, (const char *)p, (size_t)slen);
+ lua_rawseti(L, arr_stk[fi], ++arr_n[fi]);
+ p += slen;
+ break;
+ }
+ case K_MESSAGE: {
+ uint64_t slen;
+ p = read_varint(p, end, &slen);
+ if (!p || (uint64_t)(end - p) < slen) goto done;
+ decode_message(L, p, p + slen, fd->submsg);
+ lua_setfield(L, result_idx, fd->name);
+ p += slen;
+ break;
+ }
+ case K_PACKED_INT32: {
+ uint64_t slen;
+ p = read_varint(p, end, &slen);
+ if (!p || (uint64_t)(end - p) < slen) goto done;
+ const uint8_t *fend = p + slen;
+ if (arr_stk[fi] == 0) {
+ lua_createtable(L, 8, 0);
+ arr_stk[fi] = lua_gettop(L);
+ }
+ while (p < fend) {
+ uint64_t v;
+ p = read_varint(p, fend, &v);
+ if (!p) break;
+ lua_pushinteger(L,
+ (lua_Integer)(int32_t)v);
+ lua_rawseti(L, arr_stk[fi], ++arr_n[fi]);
+ }
+ break;
+ }
+ }
+ }
+done:
+ /* Attach any deferred repeated arrays to the result table. */
+ for (int i = 0; i < md->n_fields; i++) {
+ if (arr_stk[i]) {
+ lua_pushvalue(L, arr_stk[i]);
+ lua_setfield(L, result_idx, md->fields[i].name);
+ }
+ }
+ lua_settop(L, result_idx);
+ return p;
+}
+
+/* ---------------------------------------------------------------- *
+ * Lua entry points. *
+ * ---------------------------------------------------------------- */
+
+static int
+Person_encode(lua_State *L)
+{
+ luaL_checktype(L, 1, LUA_TTABLE);
+ buf_t b;
+ buf_init(&b);
+ encode_message(&b, L, 1, &Person_desc);
+ lua_pushlstring(L, (const char *)b.data, b.len);
+ buf_free(&b);
+ return 1;
+}
+
+static int
+Person_decode(lua_State *L)
+{
+ size_t len;
+ const char *buf = luaL_checklstring(L, 1, &len);
+ decode_message(L, (const uint8_t *)buf,
+ (const uint8_t *)buf + len, &Person_desc);
+ return 1;
+}
+
+static const struct luaL_Reg lib[] = {
+ {"Person_encode", Person_encode},
+ {"Person_decode", Person_decode},
+ {NULL, NULL},
+};
+
+LUA_API int
+luaopen_pb_c_generic(lua_State *L)
+{
+ luaL_register(L, "pb_c_generic", lib);
+ return 1;
+}
A bench/c_accel/person_codec.c => bench/c_accel/person_codec.c +397 -0
@@ 0,0 1,397 @@
+/*
+ * person_codec.c -- hand-written C codec for hello.Person.
+ *
+ * Strategy 4 of tarantool-protobuf-04c: upper-bound measurement.
+ *
+ * Scope: ONLY the fields exercised by bench/bench.lua's Person payloads:
+ * name (string, 1), age (int32, 2), emails (repeated string, 3),
+ * address (Address message, 5), lucky_numbers (packed int32, 7).
+ *
+ * Other Person fields (status, friends, avatar, user_id, balance,
+ * weight_kg, maps) are intentionally absent. The spike measures the
+ * upper bound of C boundary perf for the bench shapes, not full
+ * codec coverage.
+ */
+
+#include <module.h>
+#include <lauxlib.h>
+
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+/* ---------------------------------------------------------------- *
+ * Growable byte buffer with a 4 KiB stack-backed initial region. *
+ * ---------------------------------------------------------------- */
+
+typedef struct {
+ uint8_t *data;
+ size_t len;
+ size_t cap;
+ uint8_t stack[4096];
+} buf_t;
+
+static inline void
+buf_init(buf_t *b)
+{
+ b->data = b->stack;
+ b->len = 0;
+ b->cap = sizeof(b->stack);
+}
+
+static inline void
+buf_free(buf_t *b)
+{
+ if (b->data != b->stack)
+ free(b->data);
+}
+
+static void
+buf_grow(buf_t *b, size_t need)
+{
+ size_t nc = b->cap ? b->cap * 2 : 64;
+ while (nc < b->len + need)
+ nc *= 2;
+ uint8_t *nd = (uint8_t *)malloc(nc);
+ memcpy(nd, b->data, b->len);
+ if (b->data != b->stack)
+ free(b->data);
+ b->data = nd;
+ b->cap = nc;
+}
+
+static inline void
+buf_reserve(buf_t *b, size_t need)
+{
+ if (b->len + need > b->cap)
+ buf_grow(b, need);
+}
+
+/* ---------------------------------------------------------------- *
+ * Wire primitives. *
+ * ---------------------------------------------------------------- */
+
+static inline void
+write_varint(buf_t *b, uint64_t v)
+{
+ buf_reserve(b, 10);
+ while (v >= 0x80) {
+ b->data[b->len++] = (uint8_t)(v | 0x80);
+ v >>= 7;
+ }
+ b->data[b->len++] = (uint8_t)v;
+}
+
+static inline void
+write_bytes(buf_t *b, const void *src, size_t n)
+{
+ buf_reserve(b, n);
+ memcpy(b->data + b->len, src, n);
+ b->len += n;
+}
+
+static inline void
+write_string_field(buf_t *b, uint32_t tag, const char *s, size_t n)
+{
+ write_varint(b, tag);
+ write_varint(b, (uint64_t)n);
+ write_bytes(b, s, n);
+}
+
+/* Read a varint. Returns new pointer on success, NULL on truncation. */
+static const uint8_t *
+read_varint(const uint8_t *p, const uint8_t *end, uint64_t *out)
+{
+ uint64_t v = 0;
+ int shift = 0;
+ while (p < end) {
+ uint8_t c = *p++;
+ v |= (uint64_t)(c & 0x7f) << shift;
+ if (!(c & 0x80)) {
+ *out = v;
+ return p;
+ }
+ shift += 7;
+ if (shift >= 64)
+ return NULL;
+ }
+ return NULL;
+}
+
+/* ---------------------------------------------------------------- *
+ * Address encode/decode helpers (sub-message, fields used in *
+ * bench payloads: street/1, city/2, zip/3). *
+ * ---------------------------------------------------------------- */
+
+static void
+encode_address_body(buf_t *b, lua_State *L, int t)
+{
+ lua_getfield(L, t, "street");
+ if (lua_type(L, -1) == LUA_TSTRING) {
+ size_t n;
+ const char *s = lua_tolstring(L, -1, &n);
+ write_string_field(b, (1 << 3) | 2, s, n);
+ }
+ lua_pop(L, 1);
+
+ lua_getfield(L, t, "city");
+ if (lua_type(L, -1) == LUA_TSTRING) {
+ size_t n;
+ const char *s = lua_tolstring(L, -1, &n);
+ write_string_field(b, (2 << 3) | 2, s, n);
+ }
+ lua_pop(L, 1);
+
+ lua_getfield(L, t, "zip");
+ if (lua_type(L, -1) == LUA_TNUMBER) {
+ write_varint(b, (3 << 3) | 0);
+ write_varint(b, (uint64_t)(int64_t)lua_tointeger(L, -1));
+ }
+ lua_pop(L, 1);
+}
+
+static const uint8_t *
+decode_address(lua_State *L, const uint8_t *p, const uint8_t *end)
+{
+ lua_createtable(L, 0, 3);
+ while (p < end) {
+ uint64_t tag;
+ p = read_varint(p, end, &tag);
+ if (!p)
+ return NULL;
+ int field = (int)(tag >> 3);
+ int wt = (int)(tag & 7);
+ if (wt == 2) {
+ uint64_t slen;
+ p = read_varint(p, end, &slen);
+ if (!p || (size_t)(end - p) < slen)
+ return NULL;
+ if (field == 1) {
+ lua_pushlstring(L, (const char *)p, (size_t)slen);
+ lua_setfield(L, -2, "street");
+ } else if (field == 2) {
+ lua_pushlstring(L, (const char *)p, (size_t)slen);
+ lua_setfield(L, -2, "city");
+ }
+ p += slen;
+ } else if (wt == 0) {
+ uint64_t v;
+ p = read_varint(p, end, &v);
+ if (!p)
+ return NULL;
+ if (field == 3) {
+ lua_pushinteger(L, (lua_Integer)(int32_t)v);
+ lua_setfield(L, -2, "zip");
+ }
+ } else {
+ /* Unknown wire types ignored in spike. */
+ return NULL;
+ }
+ }
+ return p;
+}
+
+/* ---------------------------------------------------------------- *
+ * Person_encode(tbl) -> string *
+ * ---------------------------------------------------------------- */
+
+static int
+Person_encode(lua_State *L)
+{
+ luaL_checktype(L, 1, LUA_TTABLE);
+ const int t = 1;
+ buf_t b;
+ buf_init(&b);
+
+ /* name (1, string) */
+ lua_getfield(L, t, "name");
+ if (lua_type(L, -1) == LUA_TSTRING) {
+ size_t n;
+ const char *s = lua_tolstring(L, -1, &n);
+ write_string_field(&b, (1 << 3) | 2, s, n);
+ }
+ lua_pop(L, 1);
+
+ /* age (2, int32) */
+ lua_getfield(L, t, "age");
+ if (lua_type(L, -1) == LUA_TNUMBER) {
+ write_varint(&b, (2 << 3) | 0);
+ write_varint(&b, (uint64_t)(int64_t)lua_tointeger(L, -1));
+ }
+ lua_pop(L, 1);
+
+ /* emails (3, repeated string) */
+ lua_getfield(L, t, "emails");
+ if (lua_type(L, -1) == LUA_TTABLE) {
+ int idx = lua_gettop(L);
+ int n_emails = (int)lua_objlen(L, idx);
+ for (int i = 1; i <= n_emails; i++) {
+ lua_rawgeti(L, idx, i);
+ if (lua_type(L, -1) == LUA_TSTRING) {
+ size_t n;
+ const char *s = lua_tolstring(L, -1, &n);
+ write_string_field(&b, (3 << 3) | 2, s, n);
+ }
+ lua_pop(L, 1);
+ }
+ }
+ lua_pop(L, 1);
+
+ /* address (5, sub-message) */
+ lua_getfield(L, t, "address");
+ if (lua_type(L, -1) == LUA_TTABLE) {
+ int addr_idx = lua_gettop(L);
+ buf_t sub;
+ buf_init(&sub);
+ encode_address_body(&sub, L, addr_idx);
+ write_varint(&b, (5 << 3) | 2);
+ write_varint(&b, (uint64_t)sub.len);
+ write_bytes(&b, sub.data, sub.len);
+ buf_free(&sub);
+ }
+ lua_pop(L, 1);
+
+ /* lucky_numbers (7, packed int32) */
+ lua_getfield(L, t, "lucky_numbers");
+ if (lua_type(L, -1) == LUA_TTABLE) {
+ int idx = lua_gettop(L);
+ int n = (int)lua_objlen(L, idx);
+ buf_t sub;
+ buf_init(&sub);
+ for (int i = 1; i <= n; i++) {
+ lua_rawgeti(L, idx, i);
+ write_varint(&sub, (uint64_t)(int64_t)lua_tointeger(L, -1));
+ lua_pop(L, 1);
+ }
+ write_varint(&b, (7 << 3) | 2);
+ write_varint(&b, (uint64_t)sub.len);
+ write_bytes(&b, sub.data, sub.len);
+ buf_free(&sub);
+ }
+ lua_pop(L, 1);
+
+ lua_pushlstring(L, (const char *)b.data, b.len);
+ buf_free(&b);
+ return 1;
+}
+
+/* ---------------------------------------------------------------- *
+ * Person_decode(string) -> tbl *
+ * ---------------------------------------------------------------- */
+
+static int
+Person_decode(lua_State *L)
+{
+ size_t len;
+ const char *buf = luaL_checklstring(L, 1, &len);
+ const uint8_t *p = (const uint8_t *)buf;
+ const uint8_t *end = p + len;
+
+ lua_createtable(L, 0, 5);
+ const int result_idx = lua_gettop(L);
+
+ /* Lazy arrays: stash stack index of the array table once created. */
+ int emails_stkidx = 0;
+ int n_emails = 0;
+ int lucky_stkidx = 0;
+ int n_lucky = 0;
+
+ while (p < end) {
+ uint64_t tag;
+ p = read_varint(p, end, &tag);
+ if (!p)
+ break;
+ int field = (int)(tag >> 3);
+ int wt = (int)(tag & 7);
+
+ if (wt == 2) {
+ uint64_t slen;
+ p = read_varint(p, end, &slen);
+ if (!p || (size_t)(end - p) < slen)
+ break;
+ const uint8_t *fend = p + slen;
+
+ switch (field) {
+ case 1: /* name */
+ lua_pushlstring(L, (const char *)p, (size_t)slen);
+ lua_setfield(L, result_idx, "name");
+ break;
+ case 3: /* emails (repeated string) */
+ if (emails_stkidx == 0) {
+ lua_createtable(L, 4, 0);
+ emails_stkidx = lua_gettop(L);
+ }
+ lua_pushlstring(L, (const char *)p, (size_t)slen);
+ lua_rawseti(L, emails_stkidx, ++n_emails);
+ break;
+ case 5: /* address (sub-message) */
+ if (decode_address(L, p, fend) == NULL)
+ goto done;
+ lua_setfield(L, result_idx, "address");
+ break;
+ case 7: { /* lucky_numbers (packed int32) */
+ if (lucky_stkidx == 0) {
+ lua_createtable(L, 8, 0);
+ lucky_stkidx = lua_gettop(L);
+ }
+ const uint8_t *q = p;
+ while (q < fend) {
+ uint64_t v;
+ q = read_varint(q, fend, &v);
+ if (!q)
+ break;
+ lua_pushinteger(L,
+ (lua_Integer)(int32_t)v);
+ lua_rawseti(L, lucky_stkidx,
+ ++n_lucky);
+ }
+ break;
+ }
+ default:
+ /* Unknown LEN field -- skip silently. */
+ break;
+ }
+ p = fend;
+ } else if (wt == 0) {
+ uint64_t v;
+ p = read_varint(p, end, &v);
+ if (!p)
+ break;
+ if (field == 2) { /* age */
+ lua_pushinteger(L, (lua_Integer)(int32_t)v);
+ lua_setfield(L, result_idx, "age");
+ }
+ } else {
+ /* Other wire types not exercised by bench payloads. */
+ break;
+ }
+ }
+done:
+ if (emails_stkidx) {
+ lua_pushvalue(L, emails_stkidx);
+ lua_setfield(L, result_idx, "emails");
+ }
+ if (lucky_stkidx) {
+ lua_pushvalue(L, lucky_stkidx);
+ lua_setfield(L, result_idx, "lucky_numbers");
+ }
+ lua_settop(L, result_idx);
+ return 1;
+}
+
+/* ---------------------------------------------------------------- *
+ * Module entry. *
+ * ---------------------------------------------------------------- */
+
+static const struct luaL_Reg lib[] = {
+ {"Person_encode", Person_encode},
+ {"Person_decode", Person_decode},
+ {NULL, NULL},
+};
+
+LUA_API int
+luaopen_pb_c_person(lua_State *L)
+{
+ luaL_register(L, "pb_c_person", lib);
+ return 1;
+}
A bench/c_accel/prim.c => bench/c_accel/prim.c +121 -0
@@ 0,0 1,121 @@
+/*
+ * prim.c -- per-primitive wire helpers exposed via plain C ABI.
+ *
+ * Strategy 2 of tarantool-protobuf-04c. Lua-side dispatch stays in
+ * Lua (read t.name, t.age, ... via t-table accesses), but each
+ * wire-format primitive crosses the FFI boundary.
+ *
+ * Compile as a shared lib loaded by ffi.load() -- not luaopen_*.
+ */
+
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+#if defined(_WIN32)
+#define EXPORT __declspec(dllexport)
+#else
+#define EXPORT __attribute__((visibility("default")))
+#endif
+
+/* ibuf shape mirrors the FFI cdef in prim_ffi.lua. */
+typedef struct ibuf_s {
+ uint8_t *data;
+ size_t len;
+ size_t cap;
+} ibuf_t;
+
+EXPORT void
+pb_ibuf_init(ibuf_t *b)
+{
+ b->cap = 4096;
+ b->data = (uint8_t *)malloc(b->cap);
+ b->len = 0;
+}
+
+EXPORT void
+pb_ibuf_free(ibuf_t *b)
+{
+ free(b->data);
+ b->data = NULL;
+ b->cap = 0;
+ b->len = 0;
+}
+
+EXPORT void
+pb_ibuf_reset(ibuf_t *b)
+{
+ b->len = 0;
+}
+
+static void
+pb_ibuf_grow(ibuf_t *b, size_t need)
+{
+ size_t nc = b->cap;
+ while (nc < b->len + need)
+ nc *= 2;
+ b->data = (uint8_t *)realloc(b->data, nc);
+ b->cap = nc;
+}
+
+static inline void
+pb_ibuf_reserve(ibuf_t *b, size_t need)
+{
+ if (b->len + need > b->cap)
+ pb_ibuf_grow(b, need);
+}
+
+/* ---------------------------------------------------------------- *
+ * Primitives. *
+ * ---------------------------------------------------------------- */
+
+EXPORT void
+pb_write_varint(ibuf_t *b, uint64_t v)
+{
+ pb_ibuf_reserve(b, 10);
+ while (v >= 0x80) {
+ b->data[b->len++] = (uint8_t)(v | 0x80);
+ v >>= 7;
+ }
+ b->data[b->len++] = (uint8_t)v;
+}
+
+EXPORT void
+pb_write_bytes(ibuf_t *b, const uint8_t *src, size_t n)
+{
+ pb_ibuf_reserve(b, n);
+ memcpy(b->data + b->len, src, n);
+ b->len += n;
+}
+
+/* Combined "string field": tag + length + payload. Three primitives
+ * fused into one to lower FFI call count for the most common field
+ * shape; honesty note in README. */
+EXPORT void
+pb_write_string_field(ibuf_t *b, uint32_t tag, const uint8_t *src,
+ size_t n)
+{
+ pb_write_varint(b, tag);
+ pb_write_varint(b, n);
+ pb_write_bytes(b, src, n);
+}
+
+/* Returns a pointer past the varint, or NULL on truncation. */
+EXPORT const uint8_t *
+pb_read_varint(const uint8_t *p, const uint8_t *end, uint64_t *out)
+{
+ uint64_t v = 0;
+ int shift = 0;
+ while (p < end) {
+ uint8_t c = *p++;
+ v |= (uint64_t)(c & 0x7f) << shift;
+ if (!(c & 0x80)) {
+ *out = v;
+ return p;
+ }
+ shift += 7;
+ if (shift >= 64)
+ return NULL;
+ }
+ return NULL;
+}
A bench/c_accel/prim_ffi.lua => bench/c_accel/prim_ffi.lua +197 -0
@@ 0,0 1,197 @@
+-- prim_ffi.lua -- Strategy 2: per-primitive FFI bindings.
+--
+-- Dispatch (per-field branch logic) is in Lua. Each wire primitive
+-- crosses the FFI boundary into prim.c. Used by spike_bench.lua via
+-- require('prim_ffi').
+
+local ffi = require('ffi')
+local bit = require('bit')
+
+ffi.cdef[[
+ typedef struct ibuf_s {
+ uint8_t *data;
+ size_t len;
+ size_t cap;
+ } ibuf_t;
+
+ void pb_ibuf_init(ibuf_t *b);
+ void pb_ibuf_free(ibuf_t *b);
+ void pb_ibuf_reset(ibuf_t *b);
+
+ void pb_write_varint(ibuf_t *b, uint64_t v);
+ void pb_write_bytes(ibuf_t *b, const uint8_t *src, size_t n);
+ void pb_write_string_field(ibuf_t *b, uint32_t tag,
+ const uint8_t *src, size_t n);
+ const uint8_t *pb_read_varint(const uint8_t *p,
+ const uint8_t *end, uint64_t *out);
+]]
+
+local function find_lib()
+ local SCRIPT_DIR = (debug.getinfo(1, 'S').source:match('@?(.*/)') or './')
+ local UNAME = io.popen('uname -s'):read('*l')
+ local ext = (UNAME == 'Darwin') and '.dylib' or '.so'
+ return SCRIPT_DIR .. 'libpb_prim' .. ext
+end
+
+local C = ffi.load(find_lib())
+
+local outbuf = ffi.new('ibuf_t')
+C.pb_ibuf_init(outbuf)
+local subbuf = ffi.new('ibuf_t')
+C.pb_ibuf_init(subbuf)
+local sub2buf = ffi.new('ibuf_t') -- for one extra level of nesting
+C.pb_ibuf_init(sub2buf)
+
+local v_out = ffi.new('uint64_t[1]')
+
+local rshift, band = bit.rshift, bit.band
+
+local M = {}
+
+-- ----------------------------------------------------------------
+-- Address (sub-message used by Person.address) encode/decode helpers
+-- ----------------------------------------------------------------
+
+local function address_encode_into(buf, t)
+ if t.street then
+ C.pb_write_string_field(buf, 0x0A, t.street, #t.street)
+ end
+ if t.city then
+ C.pb_write_string_field(buf, 0x12, t.city, #t.city)
+ end
+ if t.zip then
+ C.pb_write_varint(buf, 0x18)
+ C.pb_write_varint(buf, t.zip)
+ end
+end
+
+-- ----------------------------------------------------------------
+-- Person encode
+-- ----------------------------------------------------------------
+
+function M.Person_encode(t)
+ C.pb_ibuf_reset(outbuf)
+ if t.name then
+ C.pb_write_string_field(outbuf, 0x0A, t.name, #t.name)
+ end
+ if t.age then
+ C.pb_write_varint(outbuf, 0x10)
+ C.pb_write_varint(outbuf, t.age)
+ end
+ if t.emails then
+ local emails = t.emails
+ for i = 1, #emails do
+ local e = emails[i]
+ C.pb_write_string_field(outbuf, 0x1A, e, #e)
+ end
+ end
+ if t.address then
+ C.pb_ibuf_reset(subbuf)
+ address_encode_into(subbuf, t.address)
+ C.pb_write_varint(outbuf, 0x2A)
+ C.pb_write_varint(outbuf, subbuf.len)
+ C.pb_write_bytes(outbuf, subbuf.data, subbuf.len)
+ end
+ if t.lucky_numbers then
+ C.pb_ibuf_reset(subbuf)
+ local lucky = t.lucky_numbers
+ for i = 1, #lucky do
+ C.pb_write_varint(subbuf, lucky[i])
+ end
+ C.pb_write_varint(outbuf, 0x3A)
+ C.pb_write_varint(outbuf, subbuf.len)
+ C.pb_write_bytes(outbuf, subbuf.data, subbuf.len)
+ end
+ return ffi.string(outbuf.data, outbuf.len)
+end
+
+-- ----------------------------------------------------------------
+-- Person decode
+-- ----------------------------------------------------------------
+
+local function decode_address(p, endp)
+ local result = {}
+ while p < endp do
+ p = C.pb_read_varint(p, endp, v_out)
+ if p == nil then break end
+ local tag = tonumber(v_out[0])
+ local field = rshift(tag, 3)
+ local wt = band(tag, 7)
+ if wt == 2 then
+ p = C.pb_read_varint(p, endp, v_out)
+ local slen = tonumber(v_out[0])
+ if field == 1 then
+ result.street = ffi.string(p, slen)
+ elseif field == 2 then
+ result.city = ffi.string(p, slen)
+ end
+ p = p + slen
+ elseif wt == 0 then
+ p = C.pb_read_varint(p, endp, v_out)
+ if field == 3 then
+ result.zip = tonumber(v_out[0])
+ end
+ else
+ break
+ end
+ end
+ return result
+end
+
+function M.Person_decode(s)
+ local p = ffi.cast('const uint8_t*', s)
+ local endp = p + #s
+ local result = {}
+ local emails = nil
+ local n_emails = 0
+ local lucky = nil
+ local n_lucky = 0
+
+ while p < endp do
+ p = C.pb_read_varint(p, endp, v_out)
+ if p == nil then break end
+ local tag = tonumber(v_out[0])
+ local field = rshift(tag, 3)
+ local wt = band(tag, 7)
+ if wt == 2 then
+ p = C.pb_read_varint(p, endp, v_out)
+ local slen = tonumber(v_out[0])
+ if field == 1 then
+ result.name = ffi.string(p, slen)
+ p = p + slen
+ elseif field == 3 then
+ if emails == nil then emails = {} end
+ n_emails = n_emails + 1
+ emails[n_emails] = ffi.string(p, slen)
+ p = p + slen
+ elseif field == 5 then
+ result.address = decode_address(p, p + slen)
+ p = p + slen
+ elseif field == 7 then
+ if lucky == nil then lucky = {} end
+ local fend = p + slen
+ while p < fend do
+ p = C.pb_read_varint(p, fend, v_out)
+ if p == nil then break end
+ n_lucky = n_lucky + 1
+ lucky[n_lucky] = tonumber(v_out[0])
+ end
+ else
+ p = p + slen
+ end
+ elseif wt == 0 then
+ p = C.pb_read_varint(p, endp, v_out)
+ if field == 2 then
+ result.age = tonumber(v_out[0])
+ end
+ else
+ break
+ end
+ end
+
+ if emails then result.emails = emails end
+ if lucky then result.lucky_numbers = lucky end
+ return result
+end
+
+return M
A bench/c_accel/spike_bench.lua => bench/c_accel/spike_bench.lua +168 -0
@@ 0,0 1,168 @@
+#!/usr/bin/env tarantool
+-- Spike harness for tarantool-protobuf-04c.
+--
+-- Compares pure-Lua `full` mode (current baseline) against the
+-- hand-written C codec for hello.Person across 5 payload sizes.
+-- Strategies 2 (per-primitive FFI) and 3 (one generic C call) land
+-- in follow-up sub-issues.
+--
+-- Usage:
+-- make -C bench/c_accel
+-- tarantool bench/c_accel/spike_bench.lua
+
+local SCRIPT_DIR = (debug.getinfo(1, 'S').source:match('@?(.*/)') or './')
+local REPO_ROOT = SCRIPT_DIR .. '../..'
+
+package.path = REPO_ROOT .. '/runtime/?.lua;'
+ .. REPO_ROOT .. '/runtime/?/init.lua;'
+ .. REPO_ROOT .. '/examples/expected/?.lua;'
+ .. REPO_ROOT .. '/examples/expected/?/init.lua;'
+ .. package.path
+package.cpath = SCRIPT_DIR .. '?.dylib;' .. SCRIPT_DIR .. '?.so;' .. package.cpath
+
+local clock = require('clock')
+
+local full = require('full.hello.hello_pb')
+
+local ok, c_person = pcall(require, 'pb_c_person')
+if not ok then
+ io.stderr:write('failed to load pb_c_person: ' .. tostring(c_person) .. '\n')
+ io.stderr:write('run `make -C bench/c_accel` first\n')
+ os.exit(1)
+end
+
+local ok2, c_generic = pcall(require, 'pb_c_generic')
+if not ok2 then
+ io.stderr:write('failed to load pb_c_generic: ' .. tostring(c_generic) .. '\n')
+ io.stderr:write('run `make -C bench/c_accel` first\n')
+ os.exit(1)
+end
+
+local ok3, prim_ffi = pcall(require, 'prim_ffi')
+if not ok3 then
+ io.stderr:write('failed to load prim_ffi: ' .. tostring(prim_ffi) .. '\n')
+ io.stderr:write('run `make -C bench/c_accel` first\n')
+ os.exit(1)
+end
+
+-- Payload builder mirrors bench/bench.lua so numbers are comparable.
+local function build_person_payload(target)
+ if target <= 10 then
+ return {name = 'bigbes', age = 42}
+ end
+ if target <= 100 then
+ return {name = string.rep('a', target - 10), age = 42}
+ end
+ local per_email = 36
+ local fixed_bytes = 80
+ local n_emails = math.max(1,
+ math.floor((target - fixed_bytes) / per_email))
+ local p = {
+ name = 'bigbes', age = 42,
+ address = {street = '1 Main St', city = 'Springfield', zip = 12345},
+ lucky_numbers = {7, 13, 21, 42, 99},
+ emails = {},
+ }
+ for i = 1, n_emails do
+ p.emails[i] = string.rep('e', 28) .. string.format('%04d', i)
+ end
+ return p
+end
+
+local SIZES = {
+ {label = '10B', target = 10},
+ {label = '100B', target = 100},
+ {label = '1KB', target = 1024},
+ {label = '10KB', target = 10240},
+ {label = '100KB', target = 102400},
+}
+
+local function iter_count(size_bytes)
+ if size_bytes < 100 then return 200000 end
+ if size_bytes < 2000 then return 50000 end
+ if size_bytes < 20000 then return 5000 end
+ return 500
+end
+
+local function summarize(samples)
+ table.sort(samples)
+ return samples[math.floor((#samples + 1) / 2)]
+end
+
+local function time_loop(fn, n)
+ local t0 = clock.monotonic64()
+ for _ = 1, n do fn() end
+ local t1 = clock.monotonic64()
+ return tonumber(t1 - t0) / 1e9
+end
+
+local function bench(fn, n, runs)
+ for _ = 1, math.min(n, 1000) do fn() end
+ local samples = {}
+ for r = 1, runs do
+ collectgarbage('collect')
+ samples[r] = time_loop(fn, n)
+ end
+ return summarize(samples) / n -- seconds per op
+end
+
+-- Sanity: every encoder must emit byte-equal output, every decoder must
+-- return a table.
+local function sanity_check()
+ for _, sz in ipairs(SIZES) do
+ local p = build_person_payload(sz.target)
+ local lua_bytes = full.Person_encode(p)
+ for label, mod in pairs({c_person = c_person, c_generic = c_generic, prim_ffi = prim_ffi}) do
+ local bytes = mod.Person_encode(p)
+ if bytes ~= lua_bytes then
+ io.stderr:write(string.format(
+ 'sanity FAIL %s at %s: lua=%d bytes %s=%d bytes\n',
+ label, sz.label, #lua_bytes, label, #bytes))
+ end
+ local back = mod.Person_decode(lua_bytes)
+ if type(back) ~= 'table' then
+ io.stderr:write(label .. ' decode non-table at ' .. sz.label .. '\n')
+ os.exit(2)
+ end
+ end
+ end
+end
+
+sanity_check()
+
+local function fmt_op(t, bytes)
+ return string.format('%8.0f / %7.1f', 1 / t, bytes / t / 1e6)
+end
+
+io.write('hello.Person — pure-Lua (full) vs S2 FFI prims vs S3 generic C vs S4 hand C\n')
+io.write('×L columns = speedup vs pure-Lua baseline\n\n')
+io.write(string.format(
+ '%-6s %7s %18s %18s %5s %18s %5s %18s %5s\n',
+ 'size', 'bytes', 'pure-Lua', 'S2 FFI prim', '×L', 'S3 generic C', '×L', 'S4 hand C', '×L'))
+io.write(string.rep('-', 124) .. '\n')
+
+local runs = 5
+local function run_phase(phase_name, get_fn)
+ io.write(string.format('\n== %s ==\n', phase_name))
+ for _, sz in ipairs(SIZES) do
+ local p = build_person_payload(sz.target)
+ local bytes = full.Person_encode(p)
+ local n = iter_count(#bytes)
+ local t_lua = bench(get_fn(full, p, bytes), n, runs)
+ local t_s2 = bench(get_fn(prim_ffi, p, bytes), n, runs)
+ local t_s3 = bench(get_fn(c_generic, p, bytes), n, runs)
+ local t_s4 = bench(get_fn(c_person, p, bytes), n, runs)
+ io.write(string.format(
+ '%-6s %7d %18s %18s %5.2f %18s %5.2f %18s %5.2f\n',
+ sz.label, #bytes,
+ fmt_op(t_lua, #bytes),
+ fmt_op(t_s2, #bytes), t_lua / t_s2,
+ fmt_op(t_s3, #bytes), t_lua / t_s3,
+ fmt_op(t_s4, #bytes), t_lua / t_s4))
+ end
+end
+
+run_phase('ENCODE', function(mod, p, _) return function() mod.Person_encode(p) end end)
+run_phase('DECODE', function(mod, _, bytes) return function() mod.Person_decode(bytes) end end)
+
+os.exit(0)