~bigbes/tarantool

tarantool-protobuf

dbbfaf35 — Eugene Blikh 2 months ago
codegen: inline 1-byte varint fast path for packed scalar elements (aah)

Per-element wire.encode_<type>(v) calls in packed-repeated fields paid a
full function-call boundary even though encode_varint's small-positive-int
hot path is a single CHARS[n] lookup. Inline the check + lookup at
codegen time at every packed emit site:

  - Repeated packed scalar (mode=full)
  - Repeated packed enum (after string->int resolve)
  - Proto2 extension packed scalar + enum

For varint scalars (int32/int64/uint32/uint64): fast path triggers when
v is a Lua number in [0, 128). For sint32/sint64: 7-bit zigzag range
-64..63 is inlined with bit ops. For bool: always 1 byte via
CHARS[v and 1 or 0] (no fast/slow split). Fixed-width scalars keep the
wire.encode_<type> call shape — already optimal.

Also pre-sizes the parts accumulator with table_new(#v, 0) instead of
{}; same pattern qwt+2sn used decode-side. Eliminates rehash cascade
as elements push.

Tests: 752/752 pass. JIT trace gate: 37/37.

Bench (work.lab.local, median of 3, c_repeated.Holder packed N elems):
  packed_int32:  +90% / +113% / +106%  (N=10/100/1000)
  packed_sint32: +66% / +236% / +156%
  packed_uint32: +73% / +105% / +102%
  packed_bool:   +44% / +51% / +39%
  packed_int64:  +22% / +21% / +23%  (cdata; gain from table_new only)

Headline hello.Person 1KB +3.6%, proto2 BenchPayload mid +10.5%.

See bench/PERF_LOG.md 2026-05-24 aah entry for the full breakdown
including the sint32 +236% mid-size analysis (three function layers
collapsed into one CHARS lookup).
0b6e1bc5 — Eugene Blikh 2 months ago
bench: add map_bench.lua, close ch2 (intervention regresses)

ch2 hypothesized that replacing `for k, v in pairs(map_value) do` in
the codegen-emitted map encoder with a key-collect + ipairs pattern
would let the inner emit loop stay on a JIT trace. Hand-implemented in
both codegen (inline.go:emitInlineEncodeMap) and runtime
(codec.lua kind=='map' branch); 752/752 tests passed.

Bench (work.lab.local, median of 3, Person.ages_by_nickname encode):

  map size | BEFORE      | AFTER       |  Δ
       1   |  1,282,180  |  1,128,545  | -12%
       3   |    634,880  |    549,761  | -13%
      10   |    215,745  |    196,800  |  -9%
      50   |     48,162  |     45,614  |  -5%
     200   |     11,613  |     11,282  |  -3%

Regression across all sizes. LuaJIT's side-trace machinery was already
JIT-ing the inner body via a side trace from the pairs() ISNEXT abort
point — the body was already on-trace before. The change just adds
wrapper overhead (scratch table alloc, O(N) key-collection, extra hash
lookup per entry).

Reverted the codegen + runtime edits. Keeping bench/map_bench.lua —
useful harness for any future map-encoder work (e.g. x9f deterministic
ordering may revisit this).

See ch2 bd notes for full diagnosis.
035b2adb — Eugene Blikh 2 months ago
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).
2df86386 — Eugene Blikh 2 months ago
beads: close drm (encode/decode B/op floor investigation)
ed383c92 — Eugene Blikh 2 months ago
codegen: inline proto2 extension writers/readers (qwt) + table.new(N,0) for packed lists (2sn)

qwt closes the proto2_basic.BenchPayload `min` bench's worst data point:
encode 1.89M → 3.30M msgs/s (+75%), decode 1.02M → 1.26M msgs/s (+23%).
Mechanism: collectExtsByExtendee groups all `extend Foo { ... }`
declarations by extendee FullName across input files; emitInlineEncode
emits one dedicated writer per extension instead of dispatching through
pb.codec.encode_field, and emitInlineDecode adds an elseif arm per
extension id straight into result._extensions[full_name]. Dynamic
extensions_list walk preserved for forward compat, gated on
#_elist > N so it pays one int compare when no runtime extension was
registered.

2sn pre-sizes packed-scalar repeated lists via table.new(N, 0) where
the count is recoverable from the LEN payload — exact (lim >> 2 or
lim >> 3) for fixed-width, upper bound (lim) for varint-packed. The
allocation is deferred into the `if wt == 2 then` branch so the
per-element fallback and non-packable types keep the bare-`{}`
alloc. Avoids u39's regression mode because the call cost is paid
once per repeated-field-first-occurrence, not per message decode.

Bench summary for hello.Person full mode: encode +3-5% across sizes,
decode +1-3% across sizes. Tests: 752/752 pure-Lua, 1043/1043 with
PB_ENABLE_C=1, JIT 37/37. PERF_LOG entry covers the rationale and
caveats.
578ed4c9 — Eugene Blikh 2 months ago
beads: log 21d closure and h8x creation
b93adbf0 — Eugene Blikh 2 months ago
codec: emit per-descriptor encode body for monomorphic dispatch (21d)

The runtime-mode encode loop in codec.encode_message iterated
desc.fields and called `writer(data, out)` per field. Each iteration
saw a different closure, making the call site megamorphic from the
JIT's view: trace topology fragmented into 25 stops for Person_encode
vs full mode's 8 (~3x).

compile_encode_body emits a generated function at pb.finalize_message
time with one literal call site per field, all closing over a single
`_u` table upvalue indexed by constant int. TGETI on a stable array
with a literal key specializes on trace just like direct upvalue
access — and dodges LuaJIT's 60-upvalue function limit, which
TestAllTypesProto2 (~140 fields) would otherwise hit.

Trace topology (bench/jit_trace.lua):
  runtime/Person_encode             25 -> 10  (full 8)
  runtime/Person_encode multi-byte  18 ->  7  (full 6)
  runtime/Address_encode             6 ->  5  (full 2)
  runtime/Cardinality required       6 ->  2  (full 2)

Encode throughput (hello.Person, bench/bench.lua):
  10B    10.9 -> 15.5 MB/s  (+42%)
  100B  101.1 -> 144.4 MB/s (+43%)
  1KB   170.5 -> 183.5 MB/s (+8%)
  10KB  355.5 -> 362.3 MB/s (+2%)
  100KB 373.5 -> 407.6 MB/s (+9%)

Decode untouched. Full mode unaffected (its inline _encode doesn't
go through codec.encode_message). 752+1043 tests pass; examples
unchanged.

The issue's secondary goal — runtime within 5% of full on encode at
all sizes — is not met. Residual gap is per-closure call overhead;
closing it would need writer bodies inlined into the generated body
(not just call sites), which is a larger codegen-at-runtime change.

Closes tarantool-protobuf-21d.
Files tarantool-protobuf-h8x (decode_group bimodal trace flake
surfaced during validation; pre-existing on master).
0fb62135 — Eugene Blikh 2 months ago
codegen: localize wire.* upvalues per generated message function

Capture each _encode/_decode body, scan for wire.<name> refs, and
rewrite refs that appear >=2 times to bare locals with a
"local X = wire.X" prelude. Single-use refs stay as wire.X — without
the threshold the prelude TGETS outweighed the in-body saving on
sparse small-message decode.

Measured (median-of-3, shapes bench full mode):
- scalar-heavy: enc +38.5%, dec +39.2%
- packed-int32x100: enc +51.1%, dec +38.2%
- map-strxi32-*: enc +5.5%, dec +6-7%
- nested/oneof/wkt: +1-5% (within ~5% variance band)
- bench.lua Person small sizes: neutral

closes kot
5de883e9 — Eugene Blikh 2 months ago
beads: close 4ql (ibuf encoder not viable) + memory note on FFI boundary tax

Hand-coded single-pass backpatched Person_encode_ibuf wins by 1.7x at
10-100B but loses 1.4-2.7x at 1KB-100KB. Crossover at ~26 emails:
per-field ffi.copy boundaries scale linearly while Person_encode pays
one bulk table.concat memcpy regardless of count. Three ibuf attempts
total (per-byte alloc, two-pass reserve, single-pass backpatch), same
root cause each time. Not revivable without LuaJIT FFI sinking or a
cdata-string API contract.
b624a6bf — Eugene Blikh 2 months ago
beads: close bgu (ffi.string decode disproven) + memory note on buffer-reuse trap

Two-round microbench shows ffi.string(scratch + off, len) loses 2-2.7x
to buf:sub even with a pre-allocated stable scratch cdata and ffi.copy
amortized over 26 strings. The per-call pointer-arith cdata crosses
the ffi.string frame and can't be sunk — same root cause as a6n.
48ef4180 — Eugene Blikh 2 months ago
beads: close a6n + memory note on ffi.cast cdata allocation

a6n
b09c7213 — Eugene Blikh 2 months ago
codegen: inline 1-byte LEN fast path for string/bytes decode

Singular and repeated string/bytes fields in generated full-mode _decode
now read the length byte and dispatch in-line instead of calling
wire.decode_string / wire.decode_bytes. For length < 128 (the common
short-string RPC case), the path stays inside the parent JIT trace:
no child-trace stitch, no per-element function frame, utf8_len lookup
hoisted to a generated-file upvalue.

The original a6n design — one ffi.cast(U8CP, buf) at the top of each
_decode — was abandoned: the cdata wrapper is 24 bytes per call and
LuaJIT can't sink the allocation because the pointer local lives
across wire.decode_* call frames. Net regression at small sizes
(+11-16%) overwhelmed the single-byte-read savings.

Bench (hello.Person full-mode decode):

      size    ns/op before   ns/op after   delta
      10B          419            390     -6.9%
      100B         515            478     -7.2%
      1KB         9004           8042    -10.7%
      10KB       64644          56745    -12.2%
      100KB     625638         537750    -14.0%

Zero allocation impact across all sizes. Tests: 1043 pass, 37 JIT
trace gates pass. (a6n)
ee07048d — Eugene Blikh 2 months ago
beads: close 74c

bd-74c
0666a648 — Eugene Blikh 2 months ago
lsp: annotate codec / grpc / json / wkt public surface (74c)

Phase-2 of the LSP / LLM affordance work (phase-1 landed annotations
on init.lua + lazy.lua). Adds ---@param / ---@return on the
public-surface entry points so editors and LLM assistants see typed
signatures on hover.

- codec.lua: encode_message, decode_message, encode_field,
  compile_writers, compile_readers, merge_message.
- grpc.lua: loopback, multiplex, new_stream_pair, wrap_call,
  wrap_server_stream, wrap_server_view.
- json.lua: M.encode, M.decode. New pb.JsonEncodeOpts and
  pb.JsonDecodeOpts @class blocks in _types.lua document the opts
  fields the implementation actually consults (use_proto_names,
  emit_defaults / always_emit_zero_value alias, indent;
  ignore_unknown_fields on decode).
- wkt.lua: register, lookup, any_pack, any_unpack. New
  pb.AnyMessage @class for the {type_url, value} shape.

Also corrects two pre-existing signature lies in _types.lua:

- pb.register is `(desc): pb.Descriptor`, not `(full_name, desc)` —
  the implementation has always derived the key from desc.name and
  all callers pass a single arg.
- pb.any.pack is `(desc, t, type_url_prefix?)`, not `(t, type_url)`.

Pure metadata — `just test` stays at 752 and `just test-c` at 1043.

bd-74c
d7b0364a — Eugene Blikh 2 months ago
beads: close ra6, 43t + drop deferred c0i dep from 43t

ra6 (generic C runtime codec): all 16 sub-tasks (3a-3l) plus rc8 strict-
decode parity bug closed. Umbrella issue closed as scope-complete.

43t (parity gate): closed after Justfile + bench.lua wiring. The c0i
dependency was removed first — c0i is deferred indefinitely per the
04c spike conclusion, so keeping it as a blocker would prevent 43t
from ever closing despite the parity work being complete.

Also added the c-runtime-parity-gate-43t persistent memory recording
the parity strategy and initial perf snapshot vs Lua-runtime.

bd-ra6, bd-43t
5dd21dea — Eugene Blikh 2 months ago
c_runtime: parity gate — test-c / test-all / bench-c (43t)

Adds the C-acceleration parity gate that bd-43t mandates: every test
asserts against a reference output (golden bytes, txtpb, conformance
result), so passing the same suite under both `PB_ENABLE_C=1` and the
default Lua codec proves Lua ≡ C by transitivity. No separate diff
harness needed.

Justfile recipes:
  - `test-c`  — same luatest suite with PB_ENABLE_C=1 (1043 tests;
                unlocks the c_runtime_* groups via build-c)
  - `test-all` — `test` + `test-c` (parity gate)
  - `bench-c` — bench.lua under PB_ENABLE_C=1; runtime column is
                relabelled `c-runtime`. Also tidies build-c — the stale
                "directory doesn't exist yet" branch goes away now that
                ra6 has landed.

bench.lua: extends package.cpath so `require('pb.c_runtime')` finds
runtime/pb/c_runtime.{so,dylib} when invoked outside the Justfile.
Detects `PB_ENABLE_C=1`, renames `runtime` → `c-runtime` in the
output, and refuses --baseline / --compare (alloc shape differs
between codecs by design — would noise the gate).

README: documents the parity strategy and lists `test-all`,
`conformance-c`, `bench-c` as the dual-codec entry points.

Scope notes: c-generated column dropped (c0i is deferred per
docs/c-accel.md); starwing column already lives in
bench/starwing_bench.lua.

bd-43t
5cc94314 — Eugene Blikh 2 months ago
beads: close rc8
11b9ebde — Eugene Blikh 2 months ago
c_runtime: strict-decode parity with pure-Lua codec (rc8)

The C decoder accepted wire types 6/7, field number 0, field numbers
beyond 29 bits, overlong tag varints, invalid UTF-8 in string fields,
and unmatched proto2 SGROUPs — each of which the pure-Lua decoder
already rejected. It also replaced (instead of merged) when a singular
message field appeared more than once on the wire, dropping
sub-message scalars from the prior occurrence.

decode_body: reject wt 6/7 / field 0 / fn > 2^29-1 / overlong tag
varint up front; track egroup_seen so a group body that runs off the
end of the buffer fails loudly. dec_push_kind STRING: port wire.lua's
RFC 3629 validator (utf8.len equivalent) — covers singular, repeated,
oneof, map-key, and map-value via the same code path. Singular message
dispatch in decode_body and decode_extension_into: look up an existing
prev table at result[name] and merge via the new
merge_subresult_into, which ports codec.lua's merge_message (recursive
sub-message, repeated concat, map last-wins per key, skip for WKT
custom-decode).

Regression coverage in test/conformance_test.lua's conformance.core
group: pre-existing tests already pinned the wire-type, tag, UTF-8
(singular/repeated/oneof), and merge cases — those now also exercise
the C path under PB_ENABLE_C=1. Two gaps remained — map-key/value
UTF-8 and proto2 group balancing — both covered now via four new
tests, with proto2 routed through a TestAllTypesProto2 helper.

PB_ENABLE_C=1 just test: 1043/1043 (baseline 1039 + 4 new).
PB_ENABLE_C=1 just conformance-c: 0 unexpected failures (was 77).
just conformance (pure-Lua path): unchanged — no regression.

Closes rc8
1eb062c1 — Eugene Blikh 2 months ago
c_runtime: wire encode/decode dispatch + conformance-c harness

pb.encode / pb.decode lazy-compile desc.c_plan on first call and route
to pb.c_runtime.encode/decode when PB_ENABLE_C=1 loaded the module.
Eager compile at finalize_message time fails on codegen's
forward-declared descriptors — sub-messages don't have .fields yet —
so compilation is deferred until first encode/decode, by which time
the whole module table is populated and sub-plan chase resolves.

Full-mode codegen wrappers (M.<Type>_{encode,decode}) gain the same
lazy-compile prologue, bypassing the inline body when the C runtime
is loaded. Runtime-mode wrappers already call pb.encode and pick up
dispatch centrally.

Harness side: the conformance Docker image now installs tarantool-dev
+ build-essential so the C runtime can be built in-container; a new
`just conformance-c` recipe builds runtime/pb/c_runtime.so inside the
container, runs the suite with PB_ENABLE_C=1, then cleans the .so to
keep the bind mount free of foreign-platform binaries. The runner
script also pre-populates package.loaded.pb (avoids
.rocks/lib/tarantool/pb.so from starwing lua-protobuf masking ours)
and orders package.cpath by jit.os so mixed .dylib/.so trees from
host/container interleavings don't cross-load.

Verified:
- just test                 → 748 pass, 291 C-conditional skipped
- PB_ENABLE_C=1 just test   → 1026/1039 pass; 13 fails are strict-decode
                              gaps in the C decoder (bd-rc8)
- just conformance-c        → 2729/2806 binary suite pass; 77 unexpected
                              failures match the same gap categories
                              (illegal wire-type 6/7, field-num 0/over,
                              overlong tag varint, UTF-8 rejection,
                              message merge for oneof/repeated)

Strict-decode parity tracked in bd-rc8; this commit closes the wiring
half of bd-43t (conformance gate for C paths).
c91a5ee5 — Eugene Blikh 2 months ago
beads: close ra6 3i
Next