~bigbes/tarantool

tarantool-protobuf

ref: 55b5386f999fbd6e3abe6d5a0297dd8ac753b988 tarantool-protobuf/examples/expected/full/c_nested/c_nested_pb.lua -rw-r--r-- 32.8 KiB
117c6ef9 — Eugene Blikh 2 months ago
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.
b7e97d6a — Eugene Blikh 2 months ago
codegen: inline 2-byte tag + 2-byte LEN fast paths at decode dispatch (auj)

emitInlineDecode now inlines both the 1-byte tag fast path (field IDs 1..15,
existing) and the 2-byte tag fast path (field IDs 16..4095) directly. The
fallback to wire.decode_tag only fires for field IDs >= 4096. Same shape
applied to the string/bytes LEN prefix in emitInlineStringBytesScalar and
emitInlineStringBytesRepeated, extending the existing 1-byte LEN inline
with a 2-byte branch — covers lengths 0..16383 without crossing a function
frame.

Why this matters: the original auj bridge ('multi-byte varint' side trace
tr9 off Person_decode's tr5 at pc=55) was the tag fast path's 2+ byte
guard exiting to a side trace that couldn't stitch back through
wire.decode_tag's frame return. Inlining the 2-byte case literally keeps
the side trace inside the parent's own frame.

Throughput on the multi-byte-varint Person fixture (full mode, no
PB_ENABLE_C, 5-run median):
  small (1-byte tags+lens+vars)            14.0 -> 14.2 MB/s   (noise)
  big   (2-byte LEN + multi-byte packed)   31.6 -> 34.7 MB/s   (+10%)

Bridge rate at full/Person_decode multi-byte varint:
  before (this session)  0/50  (already self-resolved after kot/21d/2ri/aah)
  after                  4/100 (residual now in the packed-varint loop,
                                migrated from the tag site; eliminating
                                fully requires per-scalar-type 2-byte
                                inline in the packed-loop emitter)

Suites: test 766/766, test-c 1057/1057, examples all green.
ff1ce60f — Eugene Blikh 2 months ago
c_runtime: decode_unsafe(plan, buf) entrypoint, skip_utf8 gate on dec_ctx (kyt)

Before this change, M.<Msg>_decode_unsafe stayed on the inline Lua path even
when PB_ENABLE_C=1, because c_runtime.decode validated UTF-8 unconditionally.
Now both safe and unsafe decoders dispatch to the C runtime; the unsafe path
calls c_runtime.decode_unsafe, which sets dec_ctx.skip_utf8 and gates the
is_valid_utf8 call on every PB_KIND_STRING payload across the decode tree.

pb.decode_unsafe in init.lua mirrors pb.decode: tries desc.c_plan first, falls
back to the pure-Lua decode_msg_unsafe. Full-mode codegen emits the same
pb.c_runtime check at the unsafe prologue.

Perf on string-heavy Person (418B, 16 emails + 16 nicknames):
  Lua  unsafe  142 MB/s
  C    safe    364 MB/s
  C    unsafe  455 MB/s   (+25% over C-safe, 3.2x over Lua-unsafe)

Suites: test 766/766, test-c 1057/1057, examples all green.
53840866 — Eugene Blikh 2 months ago
codegen: emit <Msg>_decode_unsafe for trusted-source decoding (6bb)

Full-mode codegen now emits a sister <Msg>_decode_unsafe(buf) alongside
<Msg>_decode that drops the per-string utf8_len check (singular,
repeated, map keys/values, extensions, and the >=128-byte fallback all
route through wire.decode_bytes). Sub-messages recurse into their own
_decode_unsafe so nested strings also bypass; WKTs continue to call the
normal pb.wkt.<Name>_decode (no _unsafe twin, no string-validation hot
path). C runtime dispatch is skipped because it validates today (kyt).

Use this when re-decoding bytes from a trusted producer — your own
encoder over typed RPC, JSON/text round-trips, in-process pipelines —
where the spec-required utf8.len check on every string is duplicate
work. Microbench on a string-heavy 1KB Person (26 emails) shows
~20% throughput vs _decode; conformance suite still passes (3240/3240)
because _decode itself is unchanged.

Runtime mode does not yet expose _decode_unsafe (compiled f._reader
closures capture handler.decode by value, so a runtime swap wouldn't
reach them); tracked in 58u.
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).
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.
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)
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).
6e7835a2 — Eugene Blikh 2 months ago
c_runtime: encode/decode singular sub-messages (ra6 3d)

Refactor encode_lua/decode_lua into encode_body/decode_body so the
field-walk loop is callable recursively, then dispatch the MESSAGE
kind into a per-side sub-handler. Repeated and map fields still skip
at the field-walk level — 3e (jc9) and 3h (asz) land them next.

encode_submessage_field force-establishes the parent enc_buf's
heap_idx via a no-op ebuf_grow before recursing. Without that the
final ebuf_reserve on the parent could land its new userdata above
sub-encode's leaked stack slots, making the closing lua_settop drop
the parent's heap.

decode_submessage_field bounds the inner read by temporarily
shrinking c->len to the sub-message end offset; the wire-prim
helpers already bounds-check against c->len, so a malformed inner
payload can't over-read into the outer message's bytes.

New fixture test/proto/c_nested.proto carries a 5-level singular
chain (L1->L2->L3->L4->L5) for the depth test. The two existing
"skip message" tests are renamed to "skip repeated and map" — sub-
messages now encode and decode end-to-end.

bd-hwe