@@ 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
@@ 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;