~bigbes/tarantool

tarantool-protobuf

d4dbd2f36353f3a03fd68fcfab563b81c6520a6b — Eugene Blikh 3 months ago be42fab
bench: C-acceleration spike — measure four Lua↔C boundaries

Adds bench/c_accel/ with hello.Person codecs at four boundary
strategies and a harness comparing them across 10B–100KB:

  S1 pure Lua (full mode) — existing baseline
  S2 prim.c + prim_ffi.lua — per-primitive FFI calls
  S3 generic_codec.c — one C call per message, descriptor-walking
  S4 person_codec.c — hand-written, no dispatch, upper bound

Plus ffi_probe.lua, a microbenchmark decomposing FFI call cost.

Headline numbers (×L = speedup vs pure-Lua full mode):

  ENCODE       S2     S3     S4
  10B        0.99   3.29   3.79
  100B       0.93   3.12   3.46
  1KB        0.82   5.33   4.94
  10KB       0.60   3.53   2.86
  100KB      0.63   3.49   2.85

  DECODE       S2     S3     S4
  10B        0.29   2.59   2.54
  100B       0.33   2.94   2.95
  1KB        0.39   7.22   7.62
  10KB       0.38   9.64  10.23
  100KB      0.37  10.87  11.33

S3 lands within ±15% of S4 at every size and beats it on encode
at 1KB+: the descriptor-walking loop is uniformly branch-predictable;
hand-written has more divergent per-field paths.

S2 loses to pure Lua at every size ≥1KB on encode and at every size
on decode. FFI cost decomposition (ffi_probe.lua):

  bare FFI call into ffi.load lib        33 ns
  pointer-returning FFI                  73 ns
  + v_out[0] read + tonumber            118 ns
  ffi.C.memcmp (for comparison)          60 ns
  ffi.cast(const uint8_t*, str)         156 ns
  ffi.string(p, 32)                      23 ns
  pure-Lua varint decode                 75 ns
  read_varint in tight traced loop       59 ns (floor)

Per-call FFI dispatch into a ffi.load'd lib is ~60–75 ns — the
same order of magnitude as pure-Lua varint decode. The "win" from
going to C only materializes when you cross the boundary ONCE per
message, not per primitive.

C encode plateaus at ~2.5–2.9 GB/s from 1KB upward; bottleneck
moves to Lua table reads and output string allocation. C decode
hits 2.5 GB/s at 100KB, while pure-Lua decode hits a per-byte
cliff (158 k msg/s @ 1KB → 2.3 k @ 100KB).

Architecture implication for pf6: the generic C runtime (ra6) gets
the full perf envelope; codegen-emitted per-message C (c0i) earns
≤15% headroom and goes the wrong way at scale. Per-primitive FFI
is a non-starter.

Required impl pattern in any future ra6: cache per-field stack
indices for repeated/packed arrays for the duration of
decode_message — the naive lazy-getfield variant was 2× slower
than hand-written at 100KB (see bench/c_accel/README.md).

beads-tarantool-protobuf-04c
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)