codegen: int64_as_number opt-in flag for 64-bit decode as Lua number (5y9)
Adds a plugin generator option:
protoc --tarantool_opt=int64_as_number=true ...
mode=full only, default off, error on mode=runtime. When set, 64-bit
scalar decoders (int64/uint64/sint64/fixed64/sfixed64) return a Lua
number for values that fit [-2^53, 2^53] (inclusive — both endpoints
are powers of two and exact as doubles), cdata otherwise. The decoded
type becomes value-dependent under this option; `+`/`-`/`*`/`==`
work transparently on both, so most callers don't have to care.
Wire side: decode_int64_n / decode_uint64_n / decode_sint64_n /
decode_fixed64_n / decode_sfixed64_n added alongside the existing
decoders. Bounds use LL/ULL cdata literals so the threshold compares
compile to plain 64-bit integer compares on a hot trace.
Codegen side: cfg.Int64AsNumber threads to writer.int64AsNumber;
decodeFnSuffix() emits "_n" only when the flag is set and the scalar
is one of the affected kinds. Every wire.decode_<st> emit site picks
it up — singular, repeated, packed, oneof, extension, map value.
The flag is a no-op under PB_ENABLE_C=1: the C runtime decides
number-vs-cdata on its own via luaL_pushint64 (Tarantool's small-fits-
in-double convention). The test gates on PB_ENABLE_C accordingly.
Workload-specific tradeoff measured on c_int64.Wide decode (full mode,
no PB_ENABLE_C, 5 fields per message):
tiny (all 1-byte vars) 2050 -> 1700 ns/op (-17%, cdata avoided)
medium (3-byte vars) 3220 -> 3600 ns/op (+11%, extra cmp+tonumber)
huge (past 2^53) 7575 -> 7750 ns/op (+2%, noise)
Enable when fields are dominated by small IDs/counters/small
timestamps that hit the 1-byte varint path; leave off otherwise.
The plugin flag help documents the tradeoff.
Test fixture: examples/expected/full_n/c_int64/c_int64_pb.lua via
the new gen-int64-as-number Justfile recipe. test/int64_as_number_test.lua
covers byte-identical encoding, small-value Lua number returns, default
cdata returns, 2^53 boundary inclusive, past-2^53 cdata fallback, and
Lua-number-input round-trip.
Suites: test 771/771, test-c 1057/1057 (5 skipped under PB_ENABLE_C=1).
codegen: type-elision for sint64/fixed64/sfixed64 encode (a7l)
For mode=full the field type is statically known, so the codegen can
skip wire.to_int64 / wire.to_uint64's runtime type dispatch by pre-
casting at the call site:
-- before
out[n] = wire.encode_sint64(v) -- calls to_int64(v) inside
out[n] = wire.encode_fixed64(v) -- calls to_uint64(v) inside
-- after
out[n] = wire.encode_sint64_i(INT64(v)) -- direct cdata path
out[n] = wire.encode_fixed64_u(UINT64(v))
out[n] = wire.encode_sfixed64_u(UINT64(v))
Wire-level: added encode_sint64_i / encode_fixed64_u / encode_sfixed64_u
alongside the existing encoders; encode_fixed64 is now a thin wrapper
that calls to_uint64 + encode_fixed64_u so the generic API surface stays
intact. INT64/UINT64 added as file-header upvalues in generated modules.
int32/int64/uint32/uint64 unchanged: those alias to encode_varint, which
only dispatches through to_uint64 on the slow-slow cdata path (large
negatives) — not worth the codegen surface.
Applied at every encode emit site that took a scalar: singular,
required, repeated non-packed, extension singular, extension repeated,
packed slow path, proto2 extension repeated. Map-value path also picks
it up via the shared helper.
c_int64.Wide encode (full mode, 4-run median):
lua-num inputs 2769 -> 2520 ns/op (+9.9% throughput)
cdata inputs 12993 -> 12935 ns/op (unchanged; to_uint64 already
takes the cdata fast path)
Just below the issue's optimistic 10-15% but exactly the case that
matters — Lua-number inputs are the common case for user code on
sint64/fixed64 fields. Suites: test 766/766, test-c 1057/1057.
codegen: CHARS[_len] lookup replaces string.char(_len) at length-prefix sites (2ri)
Profile attributed 39% of Person_encode's trace share (~28% of total) to a
single line emitting `string.char(_len)` at every inlined length-prefix
site. The `_len` argument is rarely a compile-time constant (lengths come
from user data), so the JIT can't fold the C-function call, and the cost
compounds — the 1KB Person fixture fires ~30 length-prefix sites per
encode.
Replace with a 256-entry lookup table `wire.CHARS` (built once at module
load, byte i -> string.char(i)). Codegen header now emits
`local CHARS = wire.CHARS` alongside the other hot-path localizers; the
single emit site in `emitInlineLenPrefix` swaps `string.char(_len)` for
`CHARS[_len]`. Parity is by construction — both return the same interned
1-byte string.
Tests: 752/752 pass. Bench (work.lab.local, median of 3, hello.Person
encode): 10B +4.7%, 1KB +16.8%, 10KB +21.1%, 100KB +34.2%, proto2 mid
+11.1%. Decode unchanged. See bench/PERF_LOG.md 2026-05-24 2ri entry.
Closes lkz and 86g (ffi.new buffer rewrite paths) — separate hand-spike
of that shape regressed 0.31x-0.77x across all sizes; the perceived
buffering inefficiency wasn't there, and 2ri captured the single hottest
line. Remaining encode-perf headroom is c0i (C-runtime backend).
fix(wire): cap the Lua-number varint fast path at 2^51 (x86_64 corruption)
encode_varint had a fast path for Lua numbers in [2^28, 2^53) that emitted
each byte via bit.band(n, 0x7f) / math.floor(n / 128). bit.band routes
through LuaJIT's number->int32 conversion, which on x86_64 uses the
magic-number trick (add 2^52 + 2^51, read the low bits). That is exact only
while n + 2^52 + 2^51 < 2^53, i.e. n < 2^51; above it the addition rounds to
an even double and silently drops low bits, corrupting the varint.
arm64 LuaJIT uses an exact FP->int instruction, so the bug was invisible on
Apple-Silicon dev machines and only surfaced on x86_64 (a 64-bit lease ID in
tarantool-etcd round-tripped 3041234677171912 -> 3041234677171940 over gRPC,
breaking lease lookups). Cap the fast path at 2^51; values in [2^51, 2^53)
now fall through to the exact uint64 cdata loop.
Adds test/wire_varint_test.lua pinning the round-trip at the boundaries.
text: render captured unknown fields, tolerate SGROUP in skip
Two changes close the proto3 text-format conformance suite:
1. `wire.skip_field` learns SGROUP/EGROUP. Wire 3 recurses through
inner tags until a matching EGROUP, with field_id checked against
the SGROUP's id. Callers (codec.lua, lazy.lua, wkt.lua, generated
full-mode `_pb.lua`) now pass the tag's field_id so groups inside
unknown-field skips don't error.
2. `pb.text.encode` walks the captured `_unknown_fields` buffer when
`opts.print_unknown_fields=true` and emits each entry in
TextFormat numeric-field form:
VARINT -> "<id>: <uint64>"
I64/I32 -> "<id>: 0x<hex>"
LEN -> speculative "<id> { <recurse> }"; rolls back to
byte-string form if the inner bytes don't parse as a
sub-message
SGROUP -> "<id> { <recurse> }" through matching EGROUP
`cmd/conformance/core.lua` threads `req.print_unknown_fields` into
`pb.text.encode` so `_Drop` tests drop unknowns and `_Print` tests
render them.
Conformance: text-format suite goes from 2 ✓ / 6 expected fails to
8 ✓ / 0 expected fails. All eight regression tests in
`conformance_test.lua` (one per upstream test, plus the fixed field-1011
tag bytes that were miscomputed earlier) now assert the target output.
codegen+wire: split length-prefix emission + 2-byte varint fast path
Two complementary encode optimizations: codegen + runtime writers no
longer pay a per-field string concat for length-prefixed fields, and
encode_varint_slow now handles 2/3/4-byte values without dropping into
the uint64 cdata path.
Composite bench/bench.lua, full mode:
1KB encode: 229 K -> 332 K msgs/s (1.45x)
10KB encode: 38 K -> 56.5 K (1.49x)
100KB encode: 4.1 K -> 6.7 K (1.64x)
Runtime mode now matches full mode at 10KB+:
1KB encode: 203 K -> 276 K (1.36x)
10KB encode: 35 K -> 55 K (1.56x)
bench/shapes_bench.lua biggest swings (both modes):
packed-int32x1000 enc: 1.7 K -> 13 K (7.0-7.6x)
packed-int32x100 enc: 21 K -> 113 K (5.3x)
scalar-heavy enc: 582 K -> 1.0 M (1.7x)
Per-helper, bench/wire_bench.lua:
encode_varint(150) [2-byte]: 529 ns -> 68 ns (7.8x)
encode_varint(20K) [3-byte]: 841 ns -> 88 ns (9.6x)
encode_tag(16, LEN) [2-byte]: 531 ns -> 78 ns (6.8x)
encode_string(200B) [2-byte L]: 560 ns -> 109 ns (5.1x)
encode_varint(127) [1-byte]: 29 ns -> 28 ns (no regression)
1. Split length-prefix emission. Length-delimited fields (string/bytes
scalars, nested messages, packed scalars/enums) used to emit
`tag → encode_len(body)` where `encode_len(body)` returns
`encode_varint(#body) .. body`. The string concat allocated a copy
of the body per field. Now the codegen and runtime writers emit
three separate `out` slots — `tag`, `varint(#body)`, `body` — and
let `table.concat(out)` join them in one pass at the end of encode.
Applied to inline.go (full-mode codegen) for: singular and repeated
nested messages, singular and repeated string/bytes scalars, packed
scalars, packed enums. Applied to codec.lua build_writer / build_
repeated_writer for the same set. Maps still go through encode_len
pending a separate pass.
2. encode_varint_slow Lua-number fast paths. The outer encode_varint
stays at the tiny `1-byte check + tail call` shape that LuaJIT
inlines into hot traces. The slow function (not inlined into hot
traces, so its body size is unconstrained) now handles non-negative
Lua numbers up to 2^28 directly via bit.rshift / string.char with no
cdata allocation. Values in [2^28, 2^53) emit one byte and recurse
on the smaller residue. Only cdata inputs, negative Lua numbers
(sign-extended to 10-byte varint), and the rare > 2^53 case still
take the uint64 cdata loop. Net effect: every multi-byte varint
encode that fits in a Lua number — including the length prefix for
any string >= 128 bytes, every tag for field IDs >= 16, and every
negative-zigzag sint — drops from ~500 ns to ~70 ns.
497/497 luatest pass. 23/23 jit-trace gates pass (the two new
multi-byte varint gates added in the bench infra commit confirm
encode_varint_slow JIT-compiles cleanly). Conformance suite (binary
+ text) shows 1478 expected passes, 0 unexpected failures.
wire: decode wins — utf8.len validator + fast paths + FFI cast
Five focused decode optimizations, all in wire.lua, landing a 3.9-6.6x
speedup on bench/bench.lua decode and matching wins on lazy / shapes
benches. Per-helper numbers from bench/wire_bench.lua:
is_valid_utf8(32B ASCII): 545 ns -> 36 ns (15x)
is_valid_utf8(1KB ASCII): 16329 ns -> 539 ns (30x)
decode_string(32B): 140 ns -> 80 ns (1.75x)
decode_double: 299 ns -> 226 ns (1.32x)
decode_fixed64: 196 ns -> 184 ns (1.07x)
1. utf8.len swap. Pure-Lua RFC 3629 validator replaced by
`utf8.len(s) ~= nil` (ICU U8_NEXT-backed at src/lua/utf8.c:165).
Source-verified to reject every proto3 case: stray continuation,
overlong, surrogates, truncated, > U+10FFFF, 5-byte+ sequences.
Conformance suite still at 1478/1478 expected passes.
2. decode_string / decode_bytes 1-byte LEN fast path. Strings <=127
bytes (the RPC common case) skip two function-call layers
(decode_string -> decode_len -> decode_varint).
3. decode_float / decode_double via ffi.cast(uint8_t*, buf) instead
of buf:sub. Eliminates the per-call string-slice allocation.
4. decode_double Inf/NaN check via a uint32[2] union split. Bit ops
on Lua numbers don't allocate; the previous uint64 cdata path
produced 3+ intermediates per call.
5. decode_fixed64 reads via the new pb_u64_u_t union (ffi.copy +
read .u). Replaces UINT64(lo) + bit.lshift(UINT64(hi), 32) which
allocated 2-3 cdata per call.
All FFI cdef/union locals hoisted to the top of the file so the
fixed-width decoders all reference the same scratch buffers.
497/497 luatest pass. 19/19 jit-trace gates pass. Conformance
(binary + text) shows zero unexpected failures.
wire: bypass LuaJIT NaN-boxing collision in decode_float/decode_double
Reading a NaN-payload double back through `tonumber(F.d)` runs into
LuaJIT 2.1's NaN-boxed value representation: certain IEEE NaN bit
patterns collide with internal type tags (nil, function, ...) so
`tonumber` yields a non-number and the field is silently dropped
during encoding.
Inspect the raw bit pattern via the uint32_t/uint64_t aliases of the
float/double unions before reaching for the Lua-level value. When the
exponent is all-ones we resolve Inf/NaN ourselves; only normal values
hit `tonumber`.
Closes the conformance suite's DoubleFieldNormalizeSignalingNan and
FloatFieldNormalizeSignalingNan JsonOutput tests, which used bit
patterns (0x7FFBCBA987654321, 0x7FBFFFFF) that hit the collision.
wire: reject invalid UTF-8 in proto3 string fields
Per the proto3 spec, a string field's bytes must form valid UTF-8.
decode_string was aliased to decode_len, so any byte sequence was
accepted and round-tripped. Adds a pure-Lua RFC 3629 validator —
is_valid_utf8 — and routes M.decode_string through it. The bytes
type keeps the raw decode_len path so binary payloads still pass.
The validator covers stray continuation bytes, truncated sequences,
overlong encodings, UTF-16 surrogates (U+D800..U+DFFF), and code
points above U+10FFFF.
Codec, lazy, dynamic, and the generated inline code all consume the
same M.decode_string, so singular / repeated / oneof / map-key /
map-value string fields are all covered.
Drops 5 entries from test/conformance/known_failures.txt
(RejectInvalidUtf8.String.*) and adds 7 regression tests covering
each invalid form plus a positive multi-byte string round-trip and
a bytes-field control.
wire: validate field number and minimal encoding in decode_tag
Three checks added on the multi-byte path:
1. Field number must be > 0 (IllegalZeroFieldNum_Case_0/1/3).
2. Field number must fit in 29 bits per the protobuf spec
(BadTag_FieldNumberSlightlyTooHigh, BadTag_FieldNumberTooHigh).
3. Tag varint must be minimally encoded — a trailing 0 byte with more
than one byte read is overlong (BadTag_OverlongVarint).
The field-number check runs on bit ops over the uint64 cdata returned
by decode_varint rather than after tonumber, otherwise field numbers
above 2^32 alias into the valid range (e.g., fn=2^31+1 was being
recovered as fn=1).
The single-byte fast path picks up the field-zero check directly via
the b >> 3 == 0 condition.
Drops 6 entries from test/conformance/known_failures.txt and adds 4
regression tests pinning each rejection path.
wire: bounds-check skip_field for I32/I64/LEN truncation
skip_field advanced pos by a fixed or length-prefixed amount with no
check against #buf, so a truncated unknown field was silently consumed:
the outer decode loop's `while pos <= len` exited without raising,
making the parser accept payloads it should have rejected.
Validates that the new position never exceeds #buf+1 for WIRE_I64,
WIRE_I32, and both WIRE_LEN fast and slow paths. WIRE_VARINT already
errored correctly via decode_varint's per-byte check.
Drops 15 entries from test/conformance/known_failures.txt
(PrematureEofBeforeUnknownValue.*, PrematureEofInsideUnknownValue.*,
PrematureEofInDelimitedDataForUnknownValue.*).
wire: reject illegal wire types 6/7 in decode_tag
Wire types 6 and 7 are never assigned in the protobuf wire format; only
0 (VARINT), 1 (I64), 2 (LEN), 3/4 (SGROUP/EGROUP, proto2-only), and 5
(I32) are valid. skip_field already rejected them for unknown fields,
but the decode loop in codec.lua and inline.go dispatched to a typed
reader whenever the field ID was known — the reader ignored wt and
called the value-specific decoder anyway, accepting bytes that should
have been a parse error.
Moves the check into decode_tag so every parser path — codec, generated
inline code, lazy, and map sub-fields — rejects 6/7 uniformly.
Drops 24 entries from test/conformance/known_failures.txt.
wire: truncate varints to 32 bits in int32/uint32/sint32/enum decode
decode_int32/uint32/sint32 returned full uint64 values when the wire input
carried bits above bit 31, corrupting re-encode on 51 conformance tests that
exercise overlong or over-range varints. Per the proto3 spec the decoder
must keep only the low 32 bits (and sign-extend for signed types).
Adds wire.varint_to_int32 / varint_to_uint32 and routes every enum-varint
decode site through them: wire.lua typed decoders, codec.lua (5 enum
sites), lazy.lua (3 sites), and the generated code via inline.go (3 sites).
Drops 51 entries from test/conformance/known_failures.txt.
wire: document why encode_varint's fast path stays 1-byte only
Adds a comment explaining the failed experiment with 2/3/4-byte fast
paths: extending the function past the LuaJIT inline budget makes
parent traces stop inlining it, costing ~30% on 1-byte-dominant
workloads (and the bench payload is 1-byte-dominant since most
proto field tags, enum ordinals, and short-string length prefixes
fit in 7 bits). Future maintainers should resist the temptation.
No code change beyond the comment.
wire: encode_varint 1-byte fast path (up to 3.8× encode throughput)
For non-negative Lua numbers under 0x80, return string.char(n) directly
— no `to_uint64` cdata allocation, no `out` table, no table.concat.
Covers the dominant case for typical payloads: length prefixes for
strings <128 bytes, small int field values, enum ordinals, and most
tag bytes when codegen-precomputation isn't available.
Symmetric to the decode_varint 1-byte fast path in 9f3bfb8 but the
payoff is much bigger because the encode path was paying for both
a cdata allocation and a list-builder per call, not just a varint
loop.
Effect (bench/bench.lua, hello.Person):
full/10B encode: 13 → 30 MB/s (2.3×)
full/100B encode: 125 → 278 MB/s (2.2×)
full/1KB encode: 69 → 210 MB/s (3.0×)
full/10KB encode: 98 → 352 MB/s (3.6×)
full/100KB encode: 108 → 398 MB/s (3.7×)
runtime/10B encode: 9 → 16 MB/s (1.8×)
runtime/100B encode: 78 → 151 MB/s (1.9×)
runtime/1KB encode: 68 → 183 MB/s (2.7×)
runtime/10KB encode: 99 → 352 MB/s (3.5×)
runtime/100KB encode: 109 → 411 MB/s (3.8×)
decode: unchanged
alloc/op: unchanged (bench-compare clean)
wire: inline decode_varint 1-byte fast path at all decoders (~2× decode)
decode_tag, decode_len, decode_int32/uint32/int64/uint64/sint32/sint64/
bool, and the VARINT/LEN branches of skip_field each now read the first
byte directly, handle 0..127 in straight-line code, and call into
decode_varint only for multi-byte values. The duplicated 3 lines per
call site are the cost of avoiding LuaJIT 2.1's side-trace-returning-
from-inlined-call limitation: with the fast path inlined, side traces
off the parent decoder's hot guard stay in the caller's own frame and
stitch back cleanly instead of bridging to interpreter dispatch.
Effect (bench/bench.lua, hello.Person across 5 sizes):
full mode decode: 2.1×–2.4× throughput (104→220 .. 14→33 MB/s)
runtime mode decode: 2.0×–2.1× throughput (90→175 .. 12→25 MB/s)
encode: unchanged (only decode paths were touched)
alloc/op: unchanged (bench-compare clean)
bridges: 27 → 7 across 10 jit-trace runs (-74%);
remaining are encoder-side (codec.lua:41/110 in runtime mode)
M6: trace stability gate + two fixes
Add `make jit-trace` (`bench/jit_trace.lua`) — a standalone tarantool
script that attaches a `jit.attach('trace')` listener over each hot
encode/decode path and asserts no aborts in our source files fall into
the fatal set (NYI bytecode, blacklisting, persistent type instability).
Runs outside luatest because on macOS arm64 the test framework exhausts
JIT mcode pages before the test body runs, masking real abort reasons.
Two fixes shipped to make all 13 scenarios pass:
- `decode_varint` grew a 1-byte fast path. Before, calling it from a
hot decode loop pulled an inner `while true do` into the caller's
root trace, which got blacklisted after enough retries.
- `pb.finalize_message` now precomputes `desc.oneofs_list` (array
form) and the runtime-mode codec iterates it with ipairs instead
of `pairs(desc.oneofs)`. `pairs()` over a hash-keyed table compiles
to bytecode ISNEXT, which is NYI in LuaJIT 2.1.
The gate also reports interpreter-bridge counts as a benchmark-quality
metric. Decoders show 0-4 bridges per run depending on JIT timing —
caused by side traces returning from inlined `decode_varint` calls,
which LuaJIT 2.1 can't stitch back cleanly. Small per-call overhead on
the multi-byte slow path, structural to the engine.
Scope caveat: map fields encode via `pairs()` and remain off-trace —
pinned by the gate's last scenario so we notice if upstream lifts the
restriction.
Initial commit: protoc-gen-tarantool plugin + pb runtime
A protoc plugin (Go) and a pure-Lua + LuaJIT-FFI runtime that give
Tarantool a complete proto3 + gRPC stack. Two codegen modes (full
inline / runtime descriptor), 226-test luatest suite, 18-fixture
mainline-protoc interop corpus, JSON codec, well-known types,
gRPC client/server factories, runtime .proto parser, microbench
harness with allocation regression gate.
Covers PLAN.md M1-M5. Module is `pb` (not `protobuf`) to avoid
colliding with Tarantool's built-in encode-only `protobuf` module.