From d4dbd2f36353f3a03fd68fcfab563b81c6520a6b Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Mon, 18 May 2026 22:47:02 +0300 Subject: [PATCH] =?UTF-8?q?bench:=20C-acceleration=20spike=20=E2=80=94=20m?= =?UTF-8?q?easure=20four=20Lua=E2=86=94C=20boundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bench/c_accel/.gitignore | 3 + bench/c_accel/Makefile | 50 ++++ bench/c_accel/README.md | 123 ++++++++++ bench/c_accel/ffi_probe.lua | 226 ++++++++++++++++++ bench/c_accel/generic_codec.c | 424 ++++++++++++++++++++++++++++++++++ bench/c_accel/person_codec.c | 397 +++++++++++++++++++++++++++++++ bench/c_accel/prim.c | 121 ++++++++++ bench/c_accel/prim_ffi.lua | 197 ++++++++++++++++ bench/c_accel/spike_bench.lua | 168 ++++++++++++++ 9 files changed, 1709 insertions(+) create mode 100644 bench/c_accel/.gitignore create mode 100644 bench/c_accel/Makefile create mode 100644 bench/c_accel/README.md create mode 100644 bench/c_accel/ffi_probe.lua create mode 100644 bench/c_accel/generic_codec.c create mode 100644 bench/c_accel/person_codec.c create mode 100644 bench/c_accel/prim.c create mode 100644 bench/c_accel/prim_ffi.lua create mode 100644 bench/c_accel/spike_bench.lua diff --git a/bench/c_accel/.gitignore b/bench/c_accel/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..f9edc52733ac8557f01df83b6dd9f3f16aab3810 --- /dev/null +++ b/bench/c_accel/.gitignore @@ -0,0 +1,3 @@ +*.dylib +*.so +*.o diff --git a/bench/c_accel/Makefile b/bench/c_accel/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..c0df6198526a3923c17a8efab606714419887fe8 --- /dev/null +++ b/bench/c_accel/Makefile @@ -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 . 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 ; 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 diff --git a/bench/c_accel/README.md b/bench/c_accel/README.md new file mode 100644 index 0000000000000000000000000000000000000000..a8715c20288b4a3bed86756137d1576403d76730 --- /dev/null +++ b/bench/c_accel/README.md @@ -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.` 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. diff --git a/bench/c_accel/ffi_probe.lua b/bench/c_accel/ffi_probe.lua new file mode 100644 index 0000000000000000000000000000000000000000..38a1d4a2a931cbeeb5a98660099280bbc3bd4201 --- /dev/null +++ b/bench/c_accel/ffi_probe.lua @@ -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 diff --git a/bench/c_accel/generic_codec.c b/bench/c_accel/generic_codec.c new file mode 100644 index 0000000000000000000000000000000000000000..4b1a94c647c5e79011613aeb473205915bb7d683 --- /dev/null +++ b/bench/c_accel/generic_codec.c @@ -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 +#include + +#include +#include +#include + +/* ---------------------------------------------------------------- * + * 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; +} diff --git a/bench/c_accel/person_codec.c b/bench/c_accel/person_codec.c new file mode 100644 index 0000000000000000000000000000000000000000..6a1725915f6c04670ff9943e53d68212d5041fe6 --- /dev/null +++ b/bench/c_accel/person_codec.c @@ -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 +#include + +#include +#include +#include + +/* ---------------------------------------------------------------- * + * 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; +} diff --git a/bench/c_accel/prim.c b/bench/c_accel/prim.c new file mode 100644 index 0000000000000000000000000000000000000000..16ca0b58a85d1baea3ef41809df4db33beede5d6 --- /dev/null +++ b/bench/c_accel/prim.c @@ -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 +#include +#include + +#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; +} diff --git a/bench/c_accel/prim_ffi.lua b/bench/c_accel/prim_ffi.lua new file mode 100644 index 0000000000000000000000000000000000000000..79eb0efe03634656fd7c236e173cadf8dc4ab633 --- /dev/null +++ b/bench/c_accel/prim_ffi.lua @@ -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 diff --git a/bench/c_accel/spike_bench.lua b/bench/c_accel/spike_bench.lua new file mode 100644 index 0000000000000000000000000000000000000000..b3725abad60b00b335bfb080c9dc1d8a917633aa --- /dev/null +++ b/bench/c_accel/spike_bench.lua @@ -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)