~bigbes/tarantool

tarantool-protobuf

5c5414165838069d5fcfc199bf1ebf9690c7c3c8 — Eugene Blikh 2 months ago 569bc3c
bench(c_accel): drop defensive lua_type checks; refresh numbers

person_codec.c had a per-element lua_type(L,-1)==LUA_TSTRING check
inside the emails loop, plus field-level lua_type checks that
generic_codec.c skipped. At 100KB that's ~2700 extra C calls per
message in the hot path, which made S4 (hand-written) look slower
than S3 (generic) at 1KB+ and led to a wrong "branch prediction on
divergent paths" hypothesis in the original README.

Replaced the per-element check with no check and the field-level
lua_type checks with lua_isnil to match generic_codec.c's semantics.
S3 and S4 are now within +/-5% at every size, which strengthens the
ra6 architecture call (ship the generic one-call codec; codegen-
emitted per-message C buys nothing).

Also added bench/c_accel/compile_flags.txt so clangd resolves
<module.h> and friends -- mirrors runtime/pb/c/compile_flags.txt.
3 files changed, 67 insertions(+), 37 deletions(-)

M bench/c_accel/README.md
A bench/c_accel/compile_flags.txt
M bench/c_accel/person_codec.c
M bench/c_accel/README.md => bench/c_accel/README.md +41 -24
@@ 57,7 57,7 @@ Override the Tarantool include dir if auto-detection fails:
make -C bench/c_accel TT_INC=/path/to/include/tarantool
```

## Results — 2026-05-18
## Results — 2026-05-23

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


@@ 67,45 67,62 @@ pure-Lua baseline.

| 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 |
| 10B   |     10 |    3,008,967 (30.1)   |  3,034,763 (30.3)   | 1.01 | 10,407,993 (104.1)  | 3.46 | 11,633,995 (116.3)   | 3.87 |
| 100B  |     94 |    2,936,858 (276.1)  |  2,888,587 (271.5)  | 0.98 |  9,989,012 (939.0)  | 3.40 | 11,122,852 (1045.5)  | 3.79 |
| 1KB   |    930 |      374,964 (348.7)  |    298,388 (277.5)  | 0.80 |  1,942,426 (1806.5) | 5.18 |  1,992,349 (1852.9)  | 5.31 |
| 10KB  |  9,634 |       73,390 (707.0)  |     49,761 (479.4)  | 0.68 |    297,018 (2861.5) | 4.05 |    287,786 (2772.5)  | 3.92 |
| 100KB | 96,674 |        8,857 (856.2)  |      5,341 (516.4)  | 0.60 |     30,244 (2923.8) | 3.41 |     29,459 (2847.9)  | 3.33 |

### 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 |
| 10B   |     10 |    3,368,932 (33.7)   |    922,203 (9.2)    | 0.27 |  8,162,932 (81.6)   |  2.42 |  8,099,789 (81.0)    |  2.40 |
| 100B  |     94 |    2,801,552 (263.3)  |    915,311 (86.0)   | 0.33 |  7,825,645 (735.6)  |  2.79 |  8,029,549 (754.8)   |  2.87 |
| 1KB   |    930 |      157,212 (146.2)  |     58,902 (54.8)   | 0.37 |  1,049,098 (975.7)  |  6.67 |  1,149,822 (1069.3)  |  7.31 |
| 10KB  |  9,634 |       21,627 (208.4)  |      8,179 (78.8)   | 0.38 |    206,740 (1991.7) |  9.56 |    211,077 (2033.5)  |  9.76 |
| 100KB | 96,674 |        2,242 (216.7)  |        839 (81.1)   | 0.37 |     22,901 (2213.9) | 10.22 |     23,956 (2315.9)  | 10.69 |

### 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
  the C boundary *once* per message wins 2.4–10.7×. 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 ±5% at every size.** The descriptor-walk
  dispatch overhead is in the noise. This is the most important
  result for the `pf6` architecture call: shipping the generic
  one-call codec (`ra6`) lands within noise of the hand-written
  ceiling, so codegen-emitted per-message C (`c0i`) buys nothing.
- **C encode plateaus at ~2.8–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.
  Pure-Lua decode is per-byte cliff-y (157 k msg/s @ 1KB →
  2.2 k @ 100KB); C decode degrades roughly linearly with size,
  hitting 2.3 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.

### History: why S4 once looked slower than S3 at 1KB+

The 2026-05-18 snapshot of these numbers showed S4 encode trailing
S3 by 10–20% at 1 KB+, and we hypothesised it was branch prediction
on the hand-written codec's divergent per-field paths. That was
wrong. The actual cause was that `person_codec.c`'s emails loop did
a defensive `lua_type(L, -1) == LUA_TSTRING` check on each element
before `lua_tolstring`, while `generic_codec.c` skipped it. With
~2,700 emails at 100 KB that's 2,700 extra C calls per message in
the hot path. Replacing the per-element `lua_type` check with no
check (and the field-level type checks with `lua_isnil`, matching
the generic codec's semantics) closed the gap. The lesson generalises:
when comparing two C codecs that look "the same shape", measure
their per-element work, not their dispatch shape — the boundary
crossings to the Lua stack dominate everything else.

### What this means for the architecture (`pf6`)

- **Ship `ra6` (generic C runtime, one C call per message).** It's

A bench/c_accel/compile_flags.txt => bench/c_accel/compile_flags.txt +7 -0
@@ 0,0 1,7 @@
-std=c99
-Wall
-Wextra
-I/opt/homebrew/include/tarantool
-I/opt/homebrew/opt/tarantool/include/tarantool
-I/usr/local/include/tarantool
-I/usr/include/tarantool

M bench/c_accel/person_codec.c => bench/c_accel/person_codec.c +19 -13
@@ 11,6 11,14 @@
 * weight_kg, maps) are intentionally absent. The spike measures the
 * upper bound of C boundary perf for the bench shapes, not full
 * codec coverage.
 *
 * Type-check parity with generic_codec.c: field presence is via
 * lua_isnil, with no per-element lua_type check inside repeated
 * loops. The original (2026-05-18) version did defensive lua_type
 * checks per element, which added ~2700 extra C calls per message
 * at 100KB and made S4 look slower than S3 at 1KB+. Don't add them
 * back without re-measuring; see README for the post-correction
 * numbers.
 */

#include <module.h>


@@ 127,7 135,7 @@ 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) {
	if (!lua_isnil(L, -1)) {
		size_t n;
		const char *s = lua_tolstring(L, -1, &n);
		write_string_field(b, (1 << 3) | 2, s, n);


@@ 135,7 143,7 @@ encode_address_body(buf_t *b, lua_State *L, int t)
	lua_pop(L, 1);

	lua_getfield(L, t, "city");
	if (lua_type(L, -1) == LUA_TSTRING) {
	if (!lua_isnil(L, -1)) {
		size_t n;
		const char *s = lua_tolstring(L, -1, &n);
		write_string_field(b, (2 << 3) | 2, s, n);


@@ 143,7 151,7 @@ encode_address_body(buf_t *b, lua_State *L, int t)
	lua_pop(L, 1);

	lua_getfield(L, t, "zip");
	if (lua_type(L, -1) == LUA_TNUMBER) {
	if (!lua_isnil(L, -1)) {
		write_varint(b, (3 << 3) | 0);
		write_varint(b, (uint64_t)(int64_t)lua_tointeger(L, -1));
	}


@@ 205,7 213,7 @@ Person_encode(lua_State *L)

	/* name (1, string) */
	lua_getfield(L, t, "name");
	if (lua_type(L, -1) == LUA_TSTRING) {
	if (!lua_isnil(L, -1)) {
		size_t n;
		const char *s = lua_tolstring(L, -1, &n);
		write_string_field(&b, (1 << 3) | 2, s, n);


@@ 214,7 222,7 @@ Person_encode(lua_State *L)

	/* age (2, int32) */
	lua_getfield(L, t, "age");
	if (lua_type(L, -1) == LUA_TNUMBER) {
	if (!lua_isnil(L, -1)) {
		write_varint(&b, (2 << 3) | 0);
		write_varint(&b, (uint64_t)(int64_t)lua_tointeger(L, -1));
	}


@@ 222,16 230,14 @@ Person_encode(lua_State *L)

	/* emails (3, repeated string) */
	lua_getfield(L, t, "emails");
	if (lua_type(L, -1) == LUA_TTABLE) {
	if (!lua_isnil(L, -1)) {
		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);
			}
			size_t n;
			const char *s = lua_tolstring(L, -1, &n);
			write_string_field(&b, (3 << 3) | 2, s, n);
			lua_pop(L, 1);
		}
	}


@@ 239,7 245,7 @@ Person_encode(lua_State *L)

	/* address (5, sub-message) */
	lua_getfield(L, t, "address");
	if (lua_type(L, -1) == LUA_TTABLE) {
	if (!lua_isnil(L, -1)) {
		int addr_idx = lua_gettop(L);
		buf_t sub;
		buf_init(&sub);


@@ 253,7 259,7 @@ Person_encode(lua_State *L)

	/* lucky_numbers (7, packed int32) */
	lua_getfield(L, t, "lucky_numbers");
	if (lua_type(L, -1) == LUA_TTABLE) {
	if (!lua_isnil(L, -1)) {
		int idx = lua_gettop(L);
		int n = (int)lua_objlen(L, idx);
		buf_t sub;