From ff1ce60f7c0d09476ff860eded449af87071fb66 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 24 May 2026 22:48:47 +0300 Subject: [PATCH] c_runtime: decode_unsafe(plan, buf) entrypoint, skip_utf8 gate on dec_ctx (kyt) Before this change, M._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. --- .beads/issues.jsonl | 4 +- .../internal/gen/inline.go | 20 ++--- examples/expected/full/c_int64/c_int64_pb.lua | 4 + .../expected/full/c_nested/c_nested_pb.lua | 20 +++++ .../full/c_repeated/c_repeated_pb.lua | 8 ++ .../full/conformance/conformance_pb.lua | 20 +++++ examples/expected/full/hello/hello_pb.lua | 24 ++++++ .../full/proto2_basic/proto2_basic_pb.lua | 40 +++++++++ .../proto2/test_messages_proto2_pb.lua | 84 +++++++++++++++++++ .../proto3/test_messages_proto3_pb.lua | 20 +++++ .../full/quickstart/quickstart_pb.lua | 4 + runtime/pb/c/c_runtime.c | 43 ++++++++-- runtime/pb/init.lua | 36 +++++--- 13 files changed, 295 insertions(+), 32 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 222a8a50d01c6709ceffb1e3f5389125808db69a..dc9c9d7ca785f98ffa09306c15ee73ebabd1db62 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -48,8 +48,8 @@ {"_type":"issue","id":"tarantool-protobuf-4ql","title":"Encoder: optional caller-owned ffi.cdata ibuf API","description":"Separate API surface, not the default. ibuf-based encoder that writes into a caller-owned ffi.cdata buffer instead of returning a fresh Lua string. Targets hot RPC paths where the caller already owns a reusable buffer (e.g. net.box send path). Independent of the codegen rewrite — different API contract. Earlier attempt parked in stash@{0}; revisit after the codegen-time emission lands so we can compare apples-to-apples.","status":"closed","priority":2,"issue_type":"feature","owner":"bigbes@gmail.com","created_at":"2026-05-17T15:47:06Z","created_by":"Eugene Blikh","updated_at":"2026-05-24T04:30:27Z","closed_at":"2026-05-24T04:30:27Z","close_reason":"Spike (bench/c_accel/ibuf_probe.lua, removed) implemented a hand-coded Person_encode_ibuf mirroring what protoc-gen-tarantool would emit: stable pre-allocated cdata scratch buffer, direct p[i] byte writes, single-pass with backpatched length for nested Address + packed lucky_numbers, ffi.copy(p+i, lua_str, n) for strings. Same-byte correctness verified across 10B/100B/1KB/10KB.\n\nBench (ns/op, Tarantool 3.8.0 / Apple M-series), Person_encode vs ibuf scratch-only:\n\n size Person_encode ibuf scratch ibuf+ffi.string speedup\n 10B 462.8 239.1 273.7 1.69x (win)\n 100B 470.1 243.1 286.3 1.64x (win)\n 1KB 3693.8 4872.6 5141.6 0.72x (loss)\n 10KB 17146 42807 43023 0.40x (loss)\n 100KB 159081 422510 432701 0.37x (loss)\n\nCrossover ~26 emails. Root cause: each email pays an ffi.copy(p+i, lua_str, n) boundary (~50 ns/call). At 26 emails = ~1.3 us pure boundary; at 2800 emails (100KB) = ~140 us pure boundary. Meanwhile Person_encode appends Lua-string refs to an out table (no FFI boundary) and pays ONE bulk table.concat memcpy at the end regardless of count. Per-field boundary work beats per-message bulk work only when field count is very small.\n\nFor 4ql's stated use case (net.box send path), typical Tarantool RPC payloads are \u003e=1 KB — exactly the regression zone (1.4-2.7x slower). Win window (\u003c100B) is too narrow to justify a separate API surface, especially since pb.encode is already 462-470ns at that size — saving 200ns on a sub-microsecond operation is not a meaningful net.box gain.\n\nThree abandoned attempts now (per-byte b:alloc cliff, v2 two-pass bulk reserve, this single-pass backpatch). All hit the same per-field FFI boundary tax. Closing as 'not viable on current LuaJIT'. REVIVAL CRITERIA: a future LuaJIT that can sink FFI calls into traces (so per-field ffi.copy stops paying the boundary cost), OR a use case where caller passes pre-cdata-cast strings (no per-email lua-string -\u003e cdata copy), OR a hand-written C encoder behind the c_runtime FFI surface (different path entirely — see c0i).","labels":["api","encoder","perf"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-86g","title":"Encoder: two-pass with exact size precomputation","description":"Walk fields once to sum byte sizes, allocate the final string at exact size, walk again to write. Eliminates buffer-grow realloc; one lua_pushlstring. Same pattern as vtprotobuf's Size() + MarshalToVT. Stacks with the codegen-time inline writes (depends-on). Expected: additional 30-50% on large-message encode beyond the inline-FFI baseline.","notes":"2026-05-24 — verdict: not viable as a pure-Lua path. Same evidence as lkz close.\n\nHand-spike of the two-pass exact-size+emit shape (ffi.new uint8_t[?],\ninlined per-message sizer + writer, recursive) regressed encode 0.31x-0.77x\nacross all 5 size buckets vs the current `out` table + `table.concat` shape.\nProfile (jit.p) showed 84% of time in the inlined message bodies, not in\nbuffering — the perceived buffering inefficiency simply isn't there.\n\nThe single profile-hottest line (39% of Person_encode share) was replaced\nin 2ri (CHARS[_len] lookup) for +17%-34% encode throughput on 1KB+ payloads.\n\nBeating the pure-Lua ceiling requires escaping it — c0i (C-runtime backend)\nis the remaining path. Closing 86g as superseded.","status":"closed","priority":2,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-17T15:47:05Z","created_by":"Eugene Blikh","updated_at":"2026-05-24T16:53:44Z","closed_at":"2026-05-24T16:53:44Z","labels":["codegen","encoder","perf"],"dependencies":[{"issue_id":"tarantool-protobuf-86g","depends_on_id":"tarantool-protobuf-h8v","type":"blocks","created_at":"2026-05-17T18:47:19Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-drm","title":"Inspect: ~120 B/op fixed encode/decode overhead in Lua hot path","description":"Cross-runtime bench (bench/COMPARISON.md) shows a fixed per-call\nallocation floor on both encode and decode in `mode=full`:\n\n- encode: 136 B/op even at 10 B payload (output is 10 bytes; ~126 B overhead)\n- decode: 112 B/op even at 10 B payload (top-level table = 2 hash-tables)\n\nFor comparison, Go vtproto runs encode at 16 B/op and decode at 8 B/op\non the same 10 B fixture.\n\nInspect:\n 1. What's the source of the 100+ B encode overhead? Suspect candidates:\n - per-call buffer.ibuf workspace allocation in wire.lua\n - Lua string concat building the result\n - GCstr header on the output string itself (~24-32 B)\n 2. Decode floor: 112 B/op. Likely 2x table headers + small alloc for\n the top-level message table.\n 3. For large payloads (100 KB encode = 131605 B/op vs output 96674 B)\n the overhead is ~28 KB. Is that one big realloc tail, or many small?\n\nOutcome: a writeup pointing to specific lines and a recommendation\non whether the M6 ibuf path would actually help.","notes":"Findings from bench/alloc_probe.lua:\n\nENCODE 10B floor = 136 B/op is the `out` Lua table:\n- `local out, n = {}, 0` alone: 64 B (Lua GCtab base)\n- After 5 array entries: 136 B (matches the encode floor exactly)\n- table.concat: 0 B in the bench because output bytes intern\n- Varints: 0 B per call because 1-byte string.char outputs intern\n globally (small string dedup)\n- 2-byte varints DO allocate: ~33 B per fresh value (encode_varint_slow\n path returns a fresh string from string.char + bit.bor)\n\nDECODE 10B floor = 112 B/op is the top-level result table\n{name=..., age=...} — same shape across iters means it'd allocate the\nsame in a real workload.\n\nLARGE PAYLOAD (1KB Person):\n- same input (output interned): 1368 B/op\n- varying age (output unique): 2368 B/op\n- Delta = ~1000 B is the result string for 930 bytes of output (the\n GCstr header + 930 content; the extra ~70 B is presumably padding /\n alignment / the Address's nested concat).\n- So real cost per call has TWO components: (a) the small fixed tables\n for the encoder workspace, (b) the output bytes themselves.\n\nKEY INSIGHT: bench numbers UNDERSTATE real allocation. The bench\niterates the SAME input → output string interns → bench reads only\nthe table cost. Real workloads where every message is unique pay\noutput-size + table-cost.\n\nOPTIMIZATION RANKING:\n1. Highest ROI: encoder workspace table. ~136 B per top-level encode\n + ~136 B per nested message encode (Address adds its own). For a\n 1KB Person we have ~5 nested encoders → ~700 B of tables. Replacing\n the array-of-string-pieces with a single growable `buffer.ibuf` cuts\n this to ~0. This is the M6 ibuf path.\n2. Medium: encode_varint_slow returns a fresh string on each call for\n non-fast-path values. Tag bytes are precomputed as literals; only\n payload varints hit this. Inlining the slow path into wire.lua's\n hot caller (or returning into a passed-in buffer) drops this.\n3. Lowest: result string. Unavoidable for the encoder's API contract\n (returns a string). Only the lazy path avoids it.\n\nDECISION POINT: M6 ibuf path (already prototyped, deferred per\nmemory/decode_perf_deferred.md) is the right intervention.\nConservatively halves encode B/op on small messages, larger savings\non nested-heavy payloads.\nCORRECTION to earlier note.\n\nRecommendation to \"use M6 ibuf path to cut the 136 B/op floor\" was\nwrong. Per memory/tarantool_ibuf_perf.md (verified against current\nrepo state — feature NOT in HEAD):\n\n- Naive per-byte b:alloc(1): ~20× slower realistic, ~36× synthetic\n- Two-pass bulk-reserve: byte-equal correct, ~2× slower at every\n payload size. Sitting in git stash@{0}.\n\nBoth prototypes lose on wall time because:\n (a) Bench harness runs with jit.off, closure dispatch interpreted\n (b) Realistic encoder cost is dominated by per-field closures\n (sizer + writer + emit_tag + pwrite_*), NOT byte writes\n (c) Two-pass adds an extra walk on top\n\nAlso: the 136 B/op encode floor is NOT the throughput bottleneck.\nCross-runtime gap (5× apiv2, 13-20× vtproto) is JIT/dispatch\noverhead per field, not allocator pressure. Cutting 136 B doesn't\nclose that gap.\n\nVIABLE PATHS (neither tried):\n1. Single-pass with backpatched length varints. Walk once. For nested\n msgs: write tag + 1-byte placeholder, recurse, fill in (or memmove\n if final length ≥ 128). Eliminates the size pass (~3 µs at 1 KB).\n2. Codegen-time `_encode_ibuf` per message in protoc-gen-tarantool\n (mode=full). Straight-line ibuf writes, no descriptor walk at\n runtime. Mirrors what pb.encode already does for the table path.\n Probably the only approach that actually closes the throughput gap.\n\nEither has to keep per-field closure count ≤ pb.encode's writers,\notherwise we re-introduce the dispatch cost both stashed prototypes\nfell on.\n2026-05-24 re-verification (post-21d/qwt):\n\nAllocation floor unchanged. Fresh `tarantool bench/alloc_probe.lua`:\n Person_encode 10B (same input) 136.0 B/op\n Person_encode 100B (same input) 136.0 B/op\n Person_decode 10B (same input) 112.0 B/op\n\nDrill-down isolates the source line-by-line — `local out, n = {}, 0` (64 B base) + 5 array entries (8 B each at LuaJIT tab growth = +72 B) = 136 B exactly. Lines: examples/expected/full/hello/hello_pb.lua:1044 (the `out` table) and 1049/1056/1061+ (the `n = n + 1; out[n] = ...` writes that populate it).\n\n21d closed dispatch fragmentation (small-encode +42% throughput per memory/21d-encode-dispatch-codegen-2026-05-24) and the alloc floor did not move — consistent with the CORRECTION note: the 5x cross-runtime throughput gap is not allocator pressure, and naive byte-buffer rewrites already lost in two stashed prototypes.\n\nInvestigation complete. Actionable interventions live in:\n- lkz (single-pass with backpatched length varints into one buffer)\n- 86g (two-pass with exact-size precompute; vtproto's Size+MarshalTo)\n\nBoth depend on closed h8v (codegen FFI direct writes). Closing drm.","status":"closed","priority":2,"issue_type":"bug","owner":"bigbes@gmail.com","created_at":"2026-05-17T15:13:38Z","created_by":"Eugene Blikh","updated_at":"2026-05-24T15:24:55Z","closed_at":"2026-05-24T15:24:55Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"tarantool-protobuf-58u","title":"Runtime-mode decode_unsafe support (compile parallel _reader_unsafe closures)","description":"6bb landed _decode_unsafe in full mode only. Runtime mode (pb.decode(desc, buf) and the runtime-mode codegen wrapper) currently has no unsafe path because compile_readers builds f._reader closures that capture handler.decode by value — a runtime swap of scalar.string.decode wouldn't reach them. Implementation sketch: add M.compile_readers_unsafe(desc) that builds f._reader_unsafe by passing an alternate scalar table where scalar.string = scalar.bytes. Call it from pb.finalize_message alongside compile_readers. Add M.decode_unsafe(desc, buf) as a near-clone of decode_message that uses f._reader_unsafe and routes the map-fallback scalar dispatch through the unsafe scalar table. Then emit M.\u003cName\u003e_decode_unsafe = function(b) return pb.decode_unsafe(M.\u003cName\u003e_descriptor, b) end in gen.go's runtime-mode emitMessageWrappers for API symmetry with full mode.","status":"in_progress","priority":3,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-24T18:20:01Z","created_by":"Eugene Blikh","updated_at":"2026-05-24T18:59:16Z","started_at":"2026-05-24T18:59:16Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"tarantool-protobuf-kyt","title":"C runtime: skip_utf8_validation plan flag (unblock _decode_unsafe + C accel)","description":"6bb's full-mode _decode_unsafe skips the pb.c_runtime dispatch because runtime/pb/c/c_runtime.c calls is_valid_utf8 unconditionally on every string field. Result: when PB_ENABLE_C=1 the safe _decode wins on C but _decode_unsafe runs the inline Lua path and may be slower than C. To unify: add a skip_utf8_validation flag to the decode plan (or expose pb.c_runtime.decode_unsafe(plan, buf)), gate the is_valid_utf8 call on it in c_runtime.c, and wire _decode_unsafe to take the C path when c_runtime is available.","status":"open","priority":3,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-24T18:20:01Z","created_by":"Eugene Blikh","updated_at":"2026-05-24T18:20:01Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"tarantool-protobuf-58u","title":"Runtime-mode decode_unsafe support (compile parallel _reader_unsafe closures)","description":"6bb landed _decode_unsafe in full mode only. Runtime mode (pb.decode(desc, buf) and the runtime-mode codegen wrapper) currently has no unsafe path because compile_readers builds f._reader closures that capture handler.decode by value — a runtime swap of scalar.string.decode wouldn't reach them. Implementation sketch: add M.compile_readers_unsafe(desc) that builds f._reader_unsafe by passing an alternate scalar table where scalar.string = scalar.bytes. Call it from pb.finalize_message alongside compile_readers. Add M.decode_unsafe(desc, buf) as a near-clone of decode_message that uses f._reader_unsafe and routes the map-fallback scalar dispatch through the unsafe scalar table. Then emit M.\u003cName\u003e_decode_unsafe = function(b) return pb.decode_unsafe(M.\u003cName\u003e_descriptor, b) end in gen.go's runtime-mode emitMessageWrappers for API symmetry with full mode.","status":"closed","priority":3,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-24T18:20:01Z","created_by":"Eugene Blikh","updated_at":"2026-05-24T19:11:20Z","started_at":"2026-05-24T18:59:16Z","closed_at":"2026-05-24T19:11:20Z","close_reason":"Runtime mode now exposes pb.decode_unsafe and M.\u003cName\u003e_decode_unsafe. codec.lua adds scalar_unsafe table, parameterizes build_reader/build_repeated_reader/decode_one to accept (scalar_tbl, decode_msg_fn, decode_group_fn), and adds compile_readers_unsafe + decode_message_unsafe + decode_group_unsafe + decode_extension_unsafe as literal clones with the three substitutions documented in codec.lua. Tests parameterized over both modes (14 cases), perf microbench shows ~8% gain in runtime mode (~20% in full mode). Conformance + JIT trace gates still pass. kyt remains open for unifying with C accel.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"tarantool-protobuf-kyt","title":"C runtime: skip_utf8_validation plan flag (unblock _decode_unsafe + C accel)","description":"6bb's full-mode _decode_unsafe skips the pb.c_runtime dispatch because runtime/pb/c/c_runtime.c calls is_valid_utf8 unconditionally on every string field. Result: when PB_ENABLE_C=1 the safe _decode wins on C but _decode_unsafe runs the inline Lua path and may be slower than C. To unify: add a skip_utf8_validation flag to the decode plan (or expose pb.c_runtime.decode_unsafe(plan, buf)), gate the is_valid_utf8 call on it in c_runtime.c, and wire _decode_unsafe to take the C path when c_runtime is available.","status":"closed","priority":3,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-24T18:20:01Z","created_by":"Eugene Blikh","updated_at":"2026-05-24T19:48:24Z","started_at":"2026-05-24T19:41:09Z","closed_at":"2026-05-24T19:48:24Z","close_reason":"C runtime: decode_unsafe(plan, buf) entrypoint added; full-mode codegen _decode_unsafe dispatches via pb.c_runtime.decode_unsafe; pb.decode_unsafe in init.lua routes through C when c_runtime loaded. dec_ctx.skip_utf8 gates is_valid_utf8. Suites: test 766/766, test-c 1057/1057. Perf on string-heavy Person (418B, 16 emails + 16 nicknames): C-unsafe 455 MB/s vs C-safe 364 MB/s (+25%) vs Lua-unsafe 142 MB/s (3.2x).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-h8x","title":"decode_group: 'inner loop in root trace' abort makes trace topology bimodal","description":"decode_group (runtime/pb/codec.lua:1089) has an 'inner loop in root trace' abort condition — the per-tag while-loop is hot enough to be a trace root itself but is reached from another root trace that tries to extend through it.\n\nObserved via bench/jit_trace.lua probe in isolation on full/WithGroup_decode (group):\n Mode A (typical, ~9/10 runs): starts=38, stops=5, aborts=33 (recompile loop)\n Mode B (rare, ~1/10 runs): starts=102, stops=100, aborts=2 (side-trace cascade)\n\nPre-existing on master (probed before/after the compile_encode_body fix landed for tarantool-protobuf-21d). Neither mode breaks the gate (both have stops\u003e0, no FATAL aborts), but the bimodal behavior is unstable and confused 21d's measurements.\n\nFix direction: same idea as 21d — emit a generated per-descriptor decode body (or at minimum, refactor decode_group so the inner while-loop is a separate function the JIT can compile as its own root trace). Mirrors compile_encode_body from the 21d fix.","status":"closed","priority":3,"issue_type":"bug","owner":"bigbes@gmail.com","created_at":"2026-05-24T09:53:50Z","created_by":"Eugene Blikh","updated_at":"2026-05-24T14:18:34Z","closed_at":"2026-05-24T14:18:34Z","close_reason":"wont_fix","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-3qu","title":"bench/*.lua: apply mcode arena hardening + re-snapshot baseline.json if numbers shift","description":"Sibling to 3o2 (closed). The trace gate had an intermittent 'fails silently with no JIT' mode on macOS arm64 caused by the default mcode arena being too small for our codegen footprint. The fix in 3o2 added jit.opt.start('sizemcode=64','maxmcode=4096') to bench/jit_trace.lua and 20/20 runs are now stable.\n\nThe other bench scripts have the same risk and none have the fix:\n bench/bench.lua\n bench/lazy_bench.lua\n bench/profile.lua\n bench/shapes_bench.lua\n bench/starwing_bench.lua\n bench/wire_bench.lua\n bench/alloc_probe.lua\n\nThese scripts have larger codegen footprints than the trace gate (they require more modules, run for longer, and accumulate more traces), so they're MORE likely to hit the same intermittent JIT-fails-silently mode than the gate was. When that happens the script reports throughput that includes interpreter-only iterations — underreporting the real numbers without any diagnostic.\n\nConcrete steps:\n1) Add the jit.opt.start line to each script (same comment block as 3o2, or factor into a tiny bench/_setup.lua included from each).\n2) Re-run bench/bench.lua --baseline to refresh bench/baseline.json.\n3) Re-run bench/bench.lua --print and update the MB/s tables in bench/COMPARISON.md if any number moved \u003e5%.\n4) bench/COMPARISON.md notes 'Numbers will drift run-to-run by 5–10%' — verify that variance band shrinks after the fix.\n\nalloc_probe.lua doesn't need it (allocator counters don't depend on JIT), but adding the line costs nothing and keeps the bench/ scripts uniform.","status":"open","priority":3,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T17:29:35Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T17:29:35Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-auj","title":"Quantify and address Person_decode multi-byte varint side-trace bridge (intermittent ~1/5 runs)","description":"Trace-topology finding from bench/jit_trace.lua. The 'full/Person_decode multi-byte varint' check intermittently reports one bridge — a side trace whose linktype=interpreter, costing interp dispatch per multi-byte tag/length-prefix on the hot decode path.\n\nCaptured output (1–2 runs out of 10):\n\n [ OK ] full/Person_decode multi-byte varint (stops=10, bridges=1)\n info: bridge tr9 side-of tr5 hello_pb.lua:997 pc=55\n\ntr5 is the Person_decode while loop (entry at hello_pb.lua:997). pc=55 falls inside the wire.decode_tag inlined fast path: the guard 'if b \u003c 0x80' fails on a 2+ byte tag, exits to side trace tr9, which contains the multi-byte continuation loop but can't self-link back to the parent — drops to the interpreter to walk the rest of the dispatcher and re-enter on next iteration.\n\nExisting context:\n- wire.lua duplicates the 1-byte varint fast path at every hot decode call site precisely because LuaJIT side traces can't stitch returns from an inlined helper frame. That works for the 1-byte case. The 2+ byte case still calls decode_varint() (the multi-byte fallback) which has its own internal while loop.\n- gcy (inline nested decode) and kot (localize wire.* upvalues) are the structurally related items already filed; they don't cover this specific bridge though.\n\nWhy P3 (not P2):\n- Intermittent (~1/5 runs in the gate). The trace topology is mostly stable.\n- The multi-byte tag path is \u003c 5% of typical RPC payloads (field IDs 1..15 = 1-byte tag, lengths \u003c 128 = 1-byte length). Larger impact would require \u003e127-byte fields or field IDs \u003e= 16.\n- The 'multi-byte varint' fixture in bench/jit_trace.lua (200-byte name + lucky_numbers including 200000, 500000) was added specifically to expose this — and it does, intermittently. The intermittency is the JIT settling on different trace shapes across runs.\n\nConcrete approaches to investigate:\n1) Inline a 2-byte varint fast path inside decode_tag (and decode_string LEN prefix, etc.) — 'if b \u003c 0x80 then ... elseif b2 \u003c 0x80 then ...' — keeping the 3+ byte case in the fallback. Covers field IDs up to 4095 and length prefixes up to 16383, which is almost all real payloads.\n2) Profile-driven: run bench/jit_trace.lua 100x with a fixed seed, collect bridges by location, and decide whether the intermittency rate justifies (1) at all.\n\nAcceptance: 50 consecutive runs of bench/jit_trace.lua report bridges=0 for full/Person_decode multi-byte varint, OR a measured throughput improvement on the bench at the multi-byte-varint fixture.","status":"open","priority":3,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T17:27:36Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T17:27:36Z","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/cmd/protoc-gen-tarantool/internal/gen/inline.go b/cmd/protoc-gen-tarantool/internal/gen/inline.go index dffec140554426beccb7575368be8828009eccf4..ddfd4a2316d14fdb2fb8786a8f33e2f770e74d41 100644 --- a/cmd/protoc-gen-tarantool/internal/gen/inline.go +++ b/cmd/protoc-gen-tarantool/internal/gen/inline.go @@ -815,24 +815,24 @@ func emitInlineDecodeExtensionRepeated(w *writer, ext *protogen.Extension, full // peer (typed RPC against our own encoder, JSON/text round-trips, in-process // pipelines). The unsafe variant drops the utf8_len check at every string site // and routes the >=128-byte fallback through wire.decode_bytes instead of -// wire.decode_string. It also skips the C runtime dispatch because the C -// runtime currently validates unconditionally (see runtime/pb/c/c_runtime.c -// is_valid_utf8 use). (6bb) +// wire.decode_string. Both variants dispatch to the C runtime when available; +// the unsafe path calls c_runtime.decode_unsafe which gates is_valid_utf8 on +// a per-call flag (kyt). (6bb) func emitInlineDecode(w *writer, name string, m *protogen.Message, file *protogen.File, selfPath string, imports map[string]string, prefix string, exts []*protogen.Extension, validateUTF8 bool) { suffix := "_decode" + cEntry := "decode" if !validateUTF8 { suffix = "_decode_unsafe" + cEntry = "decode_unsafe" } emitEmmyWrapperAnnotations(w, name, emmyMessageFullName(m), wrapperDecode) emitLocalizedFunction(w, fmt.Sprintf("function M.%s%s(buf)", name, suffix), func() { w.line(" local _d = M.%s_descriptor", name) - if validateUTF8 { - // C-acceleration: see emitInlineEncode for rationale and lazy-compile. - w.line(" if pb.c_runtime ~= nil then") - w.line(" local _p = _d.c_plan or pb.c_runtime.compile_plan(_d)") - w.line(" return pb.c_runtime.decode(_p, buf)") - w.line(" end") - } + // C-acceleration: see emitInlineEncode for rationale and lazy-compile. + w.line(" if pb.c_runtime ~= nil then") + w.line(" local _p = _d.c_plan or pb.c_runtime.compile_plan(_d)") + w.line(" return pb.c_runtime.%s(_p, buf)", cEntry) + w.line(" end") w.line(" if type(buf) ~= 'string' then") w.line(" error(\"expected string for %s decode, got \" .. type(buf), 0)", m.Desc.FullName()) w.line(" end") diff --git a/examples/expected/full/c_int64/c_int64_pb.lua b/examples/expected/full/c_int64/c_int64_pb.lua index d68d9e9e4061e0c907abb2e52fbc1ee0c90d56b8..6e81761c8beb1ddefca590e0e719030ce222a226 100644 --- a/examples/expected/full/c_int64/c_int64_pb.lua +++ b/examples/expected/full/c_int64/c_int64_pb.lua @@ -165,6 +165,10 @@ end ---@return c_int64.Wide function M.Wide_decode_unsafe(buf) local _d = M.Wide_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for c_int64.Wide decode, got " .. type(buf), 0) end diff --git a/examples/expected/full/c_nested/c_nested_pb.lua b/examples/expected/full/c_nested/c_nested_pb.lua index 3b420e2e344730346ee1c74f7e678d24c43386fe..30156550917eaa44247bf53fffed4e0e93c83a8f 100644 --- a/examples/expected/full/c_nested/c_nested_pb.lua +++ b/examples/expected/full/c_nested/c_nested_pb.lua @@ -203,6 +203,10 @@ end ---@return c_nested.L1 function M.L1_decode_unsafe(buf) local _d = M.L1_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for c_nested.L1 decode, got " .. type(buf), 0) end @@ -360,6 +364,10 @@ end ---@return c_nested.L2 function M.L2_decode_unsafe(buf) local _d = M.L2_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for c_nested.L2 decode, got " .. type(buf), 0) end @@ -517,6 +525,10 @@ end ---@return c_nested.L3 function M.L3_decode_unsafe(buf) local _d = M.L3_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for c_nested.L3 decode, got " .. type(buf), 0) end @@ -674,6 +686,10 @@ end ---@return c_nested.L4 function M.L4_decode_unsafe(buf) local _d = M.L4_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for c_nested.L4 decode, got " .. type(buf), 0) end @@ -809,6 +825,10 @@ end ---@return c_nested.L5 function M.L5_decode_unsafe(buf) local _d = M.L5_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for c_nested.L5 decode, got " .. type(buf), 0) end diff --git a/examples/expected/full/c_repeated/c_repeated_pb.lua b/examples/expected/full/c_repeated/c_repeated_pb.lua index 8ec61fe6fc1bcc8477944efaec008cc12aeb676e..9f1d4aa11d07050d2ddf0eda3c350823e51fa9d7 100644 --- a/examples/expected/full/c_repeated/c_repeated_pb.lua +++ b/examples/expected/full/c_repeated/c_repeated_pb.lua @@ -198,6 +198,10 @@ end ---@return c_repeated.Inner function M.Inner_decode_unsafe(buf) local _d = M.Inner_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for c_repeated.Inner decode, got " .. type(buf), 0) end @@ -892,6 +896,10 @@ function M.Holder_decode_unsafe(buf) local decode_sint32 = wire.decode_sint32 local decode_uint32 = wire.decode_uint32 local _d = M.Holder_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for c_repeated.Holder decode, got " .. type(buf), 0) end diff --git a/examples/expected/full/conformance/conformance_pb.lua b/examples/expected/full/conformance/conformance_pb.lua index 7cc0d7d4cfe8739181a266989cc63adbf29b507c..e12cab3c60363ce3a54adeb1996552f5a7e236a2 100644 --- a/examples/expected/full/conformance/conformance_pb.lua +++ b/examples/expected/full/conformance/conformance_pb.lua @@ -351,6 +351,10 @@ end function M.TestStatus_decode_unsafe(buf) local decode_bytes = wire.decode_bytes local _d = M.TestStatus_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for conformance.TestStatus decode, got " .. type(buf), 0) end @@ -528,6 +532,10 @@ end ---@return conformance.FailureSet function M.FailureSet_decode_unsafe(buf) local _d = M.FailureSet_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for conformance.FailureSet decode, got " .. type(buf), 0) end @@ -872,6 +880,10 @@ function M.ConformanceRequest_decode_unsafe(buf) local decode_varint = wire.decode_varint local varint_to_int32 = wire.varint_to_int32 local _d = M.ConformanceRequest_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for conformance.ConformanceRequest decode, got " .. type(buf), 0) end @@ -1414,6 +1426,10 @@ end function M.ConformanceResponse_decode_unsafe(buf) local decode_bytes = wire.decode_bytes local _d = M.ConformanceResponse_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for conformance.ConformanceResponse decode, got " .. type(buf), 0) end @@ -1734,6 +1750,10 @@ end ---@return conformance.JspbEncodingConfig function M.JspbEncodingConfig_decode_unsafe(buf) local _d = M.JspbEncodingConfig_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for conformance.JspbEncodingConfig decode, got " .. type(buf), 0) end diff --git a/examples/expected/full/hello/hello_pb.lua b/examples/expected/full/hello/hello_pb.lua index e1605166246fbe777a351b95dc19b5eacc3f6b41..fddbb8075023bd700284118bcb86945ca3285ed8 100644 --- a/examples/expected/full/hello/hello_pb.lua +++ b/examples/expected/full/hello/hello_pb.lua @@ -361,6 +361,10 @@ end function M.Result_decode_unsafe(buf) local decode_int32 = wire.decode_int32 local _d = M.Result_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for hello.Result decode, got " .. type(buf), 0) end @@ -537,6 +541,10 @@ end ---@return hello.HelloRequest function M.HelloRequest_decode_unsafe(buf) local _d = M.HelloRequest_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for hello.HelloRequest decode, got " .. type(buf), 0) end @@ -690,6 +698,10 @@ end ---@return hello.HelloReply function M.HelloReply_decode_unsafe(buf) local _d = M.HelloReply_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for hello.HelloReply decode, got " .. type(buf), 0) end @@ -1033,6 +1045,10 @@ end function M.Event_decode_unsafe(buf) local decode_len = wire.decode_len local _d = M.Event_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for hello.Event decode, got " .. type(buf), 0) end @@ -1297,6 +1313,10 @@ end function M.Address_decode_unsafe(buf) local decode_bytes = wire.decode_bytes local _d = M.Address_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for hello.Address decode, got " .. type(buf), 0) end @@ -1839,6 +1859,10 @@ function M.Person_decode_unsafe(buf) local decode_tag = wire.decode_tag local skip_field = wire.skip_field local _d = M.Person_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for hello.Person decode, got " .. type(buf), 0) end diff --git a/examples/expected/full/proto2_basic/proto2_basic_pb.lua b/examples/expected/full/proto2_basic/proto2_basic_pb.lua index fde484f61157cf32a0e9a3fd1e4c85e2e46f8fbc..1b864fa358e931a792920c1b11b6ed5ee5eb4126 100644 --- a/examples/expected/full/proto2_basic/proto2_basic_pb.lua +++ b/examples/expected/full/proto2_basic/proto2_basic_pb.lua @@ -436,6 +436,10 @@ end function M.Defaults_decode_unsafe(buf) local decode_bytes = wire.decode_bytes local _d = M.Defaults_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for proto2_basic.Defaults decode, got " .. type(buf), 0) end @@ -776,6 +780,10 @@ function M.Cardinality_decode_unsafe(buf) local decode_int32 = wire.decode_int32 local decode_len = wire.decode_len local _d = M.Cardinality_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for proto2_basic.Cardinality decode, got " .. type(buf), 0) end @@ -1009,6 +1017,10 @@ end function M.Nested_decode_unsafe(buf) local decode_len = wire.decode_len local _d = M.Nested_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for proto2_basic.Nested decode, got " .. type(buf), 0) end @@ -1155,6 +1167,10 @@ end ---@return proto2_basic.Nested.Inner function M.Nested_Inner_decode_unsafe(buf) local _d = M.Nested_Inner_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for proto2_basic.Nested.Inner decode, got " .. type(buf), 0) end @@ -1305,6 +1321,10 @@ end ---@return proto2_basic.WithGroup function M.WithGroup_decode_unsafe(buf) local _d = M.WithGroup_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for proto2_basic.WithGroup decode, got " .. type(buf), 0) end @@ -1475,6 +1495,10 @@ end ---@return proto2_basic.WithGroup.SingleGroup function M.WithGroup_SingleGroup_decode_unsafe(buf) local _d = M.WithGroup_SingleGroup_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for proto2_basic.WithGroup.SingleGroup decode, got " .. type(buf), 0) end @@ -1625,6 +1649,10 @@ end ---@return proto2_basic.WithGroup.RepGroup function M.WithGroup_RepGroup_decode_unsafe(buf) local _d = M.WithGroup_RepGroup_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for proto2_basic.WithGroup.RepGroup decode, got " .. type(buf), 0) end @@ -1965,6 +1993,10 @@ function M.BenchPayload_decode_unsafe(buf) local decode_int32 = wire.decode_int32 local decode_len = wire.decode_len local _d = M.BenchPayload_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for proto2_basic.BenchPayload decode, got " .. type(buf), 0) end @@ -2209,6 +2241,10 @@ end function M.BenchPayload_Stats_decode_unsafe(buf) local decode_int32 = wire.decode_int32 local _d = M.BenchPayload_Stats_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for proto2_basic.BenchPayload.Stats decode, got " .. type(buf), 0) end @@ -2377,6 +2413,10 @@ end ---@return proto2_basic.BenchPayload.Inner function M.BenchPayload_Inner_decode_unsafe(buf) local _d = M.BenchPayload_Inner_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for proto2_basic.BenchPayload.Inner decode, got " .. type(buf), 0) end diff --git a/examples/expected/full/protobuf_test_messages/proto2/test_messages_proto2_pb.lua b/examples/expected/full/protobuf_test_messages/proto2/test_messages_proto2_pb.lua index 2697a27654a37ac1ef1bd40127b3691c4eaaa0b3..ae738c99e1565b7d76b23645e0cd6c4ff80b86e0 100644 --- a/examples/expected/full/protobuf_test_messages/proto2/test_messages_proto2_pb.lua +++ b/examples/expected/full/protobuf_test_messages/proto2/test_messages_proto2_pb.lua @@ -4665,6 +4665,10 @@ function M.TestAllTypesProto2_decode_unsafe(buf) local skip_field = wire.skip_field local varint_to_int32 = wire.varint_to_int32 local _d = M.TestAllTypesProto2_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto2.TestAllTypesProto2 decode, got " .. type(buf), 0) end @@ -6930,6 +6934,10 @@ end ---@return protobuf_test_messages.proto2.TestAllTypesProto2.NestedMessage function M.TestAllTypesProto2_NestedMessage_decode_unsafe(buf) local _d = M.TestAllTypesProto2_NestedMessage_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto2.TestAllTypesProto2.NestedMessage decode, got " .. type(buf), 0) end @@ -7085,6 +7093,10 @@ end ---@return protobuf_test_messages.proto2.TestAllTypesProto2.Data function M.TestAllTypesProto2_Data_decode_unsafe(buf) local _d = M.TestAllTypesProto2_Data_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto2.TestAllTypesProto2.Data decode, got " .. type(buf), 0) end @@ -7235,6 +7247,10 @@ end ---@return protobuf_test_messages.proto2.TestAllTypesProto2.MultiWordGroupField function M.TestAllTypesProto2_MultiWordGroupField_decode_unsafe(buf) local _d = M.TestAllTypesProto2_MultiWordGroupField_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto2.TestAllTypesProto2.MultiWordGroupField decode, got " .. type(buf), 0) end @@ -7375,6 +7391,10 @@ end ---@return protobuf_test_messages.proto2.ForeignMessageProto2 function M.ForeignMessageProto2_decode_unsafe(buf) local _d = M.ForeignMessageProto2_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto2.ForeignMessageProto2 decode, got " .. type(buf), 0) end @@ -7516,6 +7536,10 @@ end ---@return protobuf_test_messages.proto2.GroupField function M.GroupField_decode_unsafe(buf) local _d = M.GroupField_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto2.GroupField decode, got " .. type(buf), 0) end @@ -7766,6 +7790,10 @@ function M.UnknownToTestAllTypes_decode_unsafe(buf) local decode_int32 = wire.decode_int32 local decode_len = wire.decode_len local _d = M.UnknownToTestAllTypes_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto2.UnknownToTestAllTypes decode, got " .. type(buf), 0) end @@ -7973,6 +8001,10 @@ end ---@return protobuf_test_messages.proto2.UnknownToTestAllTypes.OptionalGroup function M.UnknownToTestAllTypes_OptionalGroup_decode_unsafe(buf) local _d = M.UnknownToTestAllTypes_OptionalGroup_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto2.UnknownToTestAllTypes.OptionalGroup decode, got " .. type(buf), 0) end @@ -8095,6 +8127,10 @@ end ---@return protobuf_test_messages.proto2.NullHypothesisProto2 function M.NullHypothesisProto2_decode_unsafe(buf) local _d = M.NullHypothesisProto2_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto2.NullHypothesisProto2 decode, got " .. type(buf), 0) end @@ -8209,6 +8245,10 @@ end ---@return protobuf_test_messages.proto2.EnumOnlyProto2 function M.EnumOnlyProto2_decode_unsafe(buf) local _d = M.EnumOnlyProto2_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto2.EnumOnlyProto2 decode, got " .. type(buf), 0) end @@ -8349,6 +8389,10 @@ end ---@return protobuf_test_messages.proto2.OneStringProto2 function M.OneStringProto2_decode_unsafe(buf) local _d = M.OneStringProto2_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto2.OneStringProto2 decode, got " .. type(buf), 0) end @@ -8554,6 +8598,10 @@ end function M.ProtoWithKeywords_decode_unsafe(buf) local decode_bytes = wire.decode_bytes local _d = M.ProtoWithKeywords_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto2.ProtoWithKeywords decode, got " .. type(buf), 0) end @@ -9362,6 +9410,10 @@ function M.TestAllRequiredTypesProto2_decode_unsafe(buf) local decode_varint = wire.decode_varint local varint_to_int32 = wire.varint_to_int32 local _d = M.TestAllRequiredTypesProto2_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto2.TestAllRequiredTypesProto2 decode, got " .. type(buf), 0) end @@ -9779,6 +9831,10 @@ end function M.TestAllRequiredTypesProto2_NestedMessage_decode_unsafe(buf) local decode_len = wire.decode_len local _d = M.TestAllRequiredTypesProto2_NestedMessage_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto2.TestAllRequiredTypesProto2.NestedMessage decode, got " .. type(buf), 0) end @@ -9940,6 +9996,10 @@ end ---@return protobuf_test_messages.proto2.TestAllRequiredTypesProto2.Data function M.TestAllRequiredTypesProto2_Data_decode_unsafe(buf) local _d = M.TestAllRequiredTypesProto2_Data_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto2.TestAllRequiredTypesProto2.Data decode, got " .. type(buf), 0) end @@ -10199,6 +10259,10 @@ end function M.TestLargeOneof_decode_unsafe(buf) local decode_len = wire.decode_len local _d = M.TestLargeOneof_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto2.TestLargeOneof decode, got " .. type(buf), 0) end @@ -10377,6 +10441,10 @@ end ---@return protobuf_test_messages.proto2.TestLargeOneof.A1 function M.TestLargeOneof_A1_decode_unsafe(buf) local _d = M.TestLargeOneof_A1_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto2.TestLargeOneof.A1 decode, got " .. type(buf), 0) end @@ -10491,6 +10559,10 @@ end ---@return protobuf_test_messages.proto2.TestLargeOneof.A2 function M.TestLargeOneof_A2_decode_unsafe(buf) local _d = M.TestLargeOneof_A2_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto2.TestLargeOneof.A2 decode, got " .. type(buf), 0) end @@ -10605,6 +10677,10 @@ end ---@return protobuf_test_messages.proto2.TestLargeOneof.A3 function M.TestLargeOneof_A3_decode_unsafe(buf) local _d = M.TestLargeOneof_A3_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto2.TestLargeOneof.A3 decode, got " .. type(buf), 0) end @@ -10719,6 +10795,10 @@ end ---@return protobuf_test_messages.proto2.TestLargeOneof.A4 function M.TestLargeOneof_A4_decode_unsafe(buf) local _d = M.TestLargeOneof_A4_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto2.TestLargeOneof.A4 decode, got " .. type(buf), 0) end @@ -10833,6 +10913,10 @@ end ---@return protobuf_test_messages.proto2.TestLargeOneof.A5 function M.TestLargeOneof_A5_decode_unsafe(buf) local _d = M.TestLargeOneof_A5_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto2.TestLargeOneof.A5 decode, got " .. type(buf), 0) end diff --git a/examples/expected/full/protobuf_test_messages/proto3/test_messages_proto3_pb.lua b/examples/expected/full/protobuf_test_messages/proto3/test_messages_proto3_pb.lua index e3be783f42dabac34263f825abe18dc2dde0434e..3e824f3395307c0aed84cdd623a199c72ec4e9c1 100644 --- a/examples/expected/full/protobuf_test_messages/proto3/test_messages_proto3_pb.lua +++ b/examples/expected/full/protobuf_test_messages/proto3/test_messages_proto3_pb.lua @@ -4857,6 +4857,10 @@ function M.TestAllTypesProto3_decode_unsafe(buf) local skip_field = wire.skip_field local varint_to_int32 = wire.varint_to_int32 local _d = M.TestAllTypesProto3_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto3.TestAllTypesProto3 decode, got " .. type(buf), 0) end @@ -6888,6 +6892,10 @@ end ---@return protobuf_test_messages.proto3.TestAllTypesProto3.NestedMessage function M.TestAllTypesProto3_NestedMessage_decode_unsafe(buf) local _d = M.TestAllTypesProto3_NestedMessage_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto3.TestAllTypesProto3.NestedMessage decode, got " .. type(buf), 0) end @@ -7023,6 +7031,10 @@ end ---@return protobuf_test_messages.proto3.ForeignMessage function M.ForeignMessage_decode_unsafe(buf) local _d = M.ForeignMessage_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto3.ForeignMessage decode, got " .. type(buf), 0) end @@ -7140,6 +7152,10 @@ end ---@return protobuf_test_messages.proto3.NullHypothesisProto3 function M.NullHypothesisProto3_decode_unsafe(buf) local _d = M.NullHypothesisProto3_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto3.NullHypothesisProto3 decode, got " .. type(buf), 0) end @@ -7254,6 +7270,10 @@ end ---@return protobuf_test_messages.proto3.EnumOnlyProto3 function M.EnumOnlyProto3_decode_unsafe(buf) local _d = M.EnumOnlyProto3_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for protobuf_test_messages.proto3.EnumOnlyProto3 decode, got " .. type(buf), 0) end diff --git a/examples/expected/full/quickstart/quickstart_pb.lua b/examples/expected/full/quickstart/quickstart_pb.lua index 3bdb5c01d445c11afd361254bef2980e0528aa4b..d41a1d19047109645fa14fd38727ed9790618a77 100644 --- a/examples/expected/full/quickstart/quickstart_pb.lua +++ b/examples/expected/full/quickstart/quickstart_pb.lua @@ -214,6 +214,10 @@ end function M.User_decode_unsafe(buf) local decode_bytes = wire.decode_bytes local _d = M.User_descriptor + if pb.c_runtime ~= nil then + local _p = _d.c_plan or pb.c_runtime.compile_plan(_d) + return pb.c_runtime.decode_unsafe(_p, buf) + end if type(buf) ~= 'string' then error("expected string for quickstart.User decode, got " .. type(buf), 0) end diff --git a/runtime/pb/c/c_runtime.c b/runtime/pb/c/c_runtime.c index b8523ac0317c73308c4d32c98ab31e75b2d26122..21ceeb32c2ac59235299783146f0f4dbdd0967aa 100644 --- a/runtime/pb/c/c_runtime.c +++ b/runtime/pb/c/c_runtime.c @@ -1987,6 +1987,11 @@ typedef struct dec_ctx { const uint8_t *buf; size_t len; size_t pos; + /* Opt-in skip of is_valid_utf8 on every PB_KIND_STRING payload. + * Set by decode_unsafe_lua for trusted producers (re-decoding our + * own encoder output, in-process typed RPC). Mirrors the Lua + * scalar_unsafe.string = scalar.bytes swap in pb.codec. (kyt) */ + int skip_utf8; } dec_ctx; static uint64_t @@ -2251,7 +2256,8 @@ dec_push_kind(dec_ctx *c, uint8_t kind) uint64_t plen = dec_varint(c); if (c->len - c->pos < plen) luaL_error(c->L, "truncated string/bytes payload"); - if (!is_valid_utf8(c->buf + c->pos, (size_t)plen)) + if (!c->skip_utf8 && + !is_valid_utf8(c->buf + c->pos, (size_t)plen)) luaL_error(c->L, "invalid UTF-8 in string field at offset %d", (int)c->pos); @@ -3066,14 +3072,18 @@ decode_body(dec_ctx *c, pb_plan *plan, int result_idx, } static int -decode_lua(lua_State *L) +decode_impl(lua_State *L, int skip_utf8) { pb_plan *plan = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT); size_t buf_len; const char *buf = luaL_checklstring(L, 2, &buf_len); /* WKT override: desc.decode(buf) consumes the entire body and returns - * whatever Lua representation the override picks (e.g. datetime). */ + * whatever Lua representation the override picks (e.g. datetime). + * The override is opaque — there is no unsafe variant to thread the + * skip_utf8 flag into, so the safe and unsafe paths both call it as + * declared. WKT string payloads (Timestamp/Duration/Value) are + * either ASCII or not user-supplied. */ if (plan->override_decode_ref != LUA_NOREF) { lua_rawgeti(L, LUA_REGISTRYINDEX, plan->override_decode_ref); lua_pushlstring(L, buf, buf_len); @@ -3085,15 +3095,33 @@ decode_lua(lua_State *L) int result_idx = lua_gettop(L); dec_ctx c; - c.L = L; - c.buf = (const uint8_t *)buf; - c.len = buf_len; - c.pos = 0; + c.L = L; + c.buf = (const uint8_t *)buf; + c.len = buf_len; + c.pos = 0; + c.skip_utf8 = skip_utf8; decode_body(&c, plan, result_idx, /* stop_group_id */ 0); return 1; } +static int +decode_lua(lua_State *L) +{ + return decode_impl(L, /* skip_utf8 */ 0); +} + +/* Sister of decode_lua for trusted producers — skips is_valid_utf8 on + * every PB_KIND_STRING payload across the entire decode tree (the flag + * lives on dec_ctx and survives sub-message recursion). Dispatched from + * pb.decode_unsafe and from full-mode codegen's _decode_unsafe + * prologue. (kyt) */ +static int +decode_unsafe_lua(lua_State *L) +{ + return decode_impl(L, /* skip_utf8 */ 1); +} + /* ---------------------------------------------------------------- * * Module entry. * * ---------------------------------------------------------------- */ @@ -3109,6 +3137,7 @@ static const struct luaL_Reg c_runtime_methods[] = { {"plan_sub_plan", plan_sub_plan}, {"encode", encode_lua}, {"decode", decode_lua}, + {"decode_unsafe", decode_unsafe_lua}, {NULL, NULL}, }; diff --git a/runtime/pb/init.lua b/runtime/pb/init.lua index 476f69fcd574ff9a441ec1470bc40b20cea4abf3..692f1b7d844182df86f45dbba2d7530ab723caaa 100644 --- a/runtime/pb/init.lua +++ b/runtime/pb/init.lua @@ -33,14 +33,17 @@ end -- on desc.c_plan and lazily compile it on first call. Lazy compile is -- required because compile_plan eagerly chases sub-message refs and -- codegen forward-declares descriptors (see finalize_message above). -local pb_encode = codec.encode -local pb_decode = codec.decode +local pb_encode = codec.encode +local pb_decode = codec.decode +local pb_decode_unsafe = codec.decode_unsafe if c_runtime ~= nil then - local c_encode = c_runtime.encode - local c_decode = c_runtime.decode - local c_compile = c_runtime.compile_plan - local lua_encode = pb_encode - local lua_decode = pb_decode + local c_encode = c_runtime.encode + local c_decode = c_runtime.decode + local c_decode_unsafe = c_runtime.decode_unsafe + local c_compile = c_runtime.compile_plan + local lua_encode = pb_encode + local lua_decode = pb_decode + local lua_decode_unsafe = pb_decode_unsafe pb_encode = function(desc, t) local plan = desc.c_plan or c_compile(desc) if plan ~= nil then return c_encode(plan, t) end @@ -51,6 +54,11 @@ if c_runtime ~= nil then if plan ~= nil then return c_decode(plan, b) end return lua_decode(desc, b) end + pb_decode_unsafe = function(desc, b) + local plan = desc.c_plan or c_compile(desc) + if plan ~= nil then return c_decode_unsafe(plan, b) end + return lua_decode_unsafe(desc, b) + end end ---@type pb.Module @@ -61,12 +69,14 @@ return { -- Opt-in non-validating decode for trusted producers (re-decoding -- bytes from our own encoder, JSON/text round-trips, in-process -- typed RPC). Skips utf8_len on every string field; sub-message - -- recursion stays on the unsafe path. C runtime dispatch is - -- bypassed because the C path validates unconditionally today - -- (tracked in kyt). Generated runtime-mode wrappers - -- `M._decode_unsafe` forward into this; full-mode codegen - -- inlines a literal sister `_decode_unsafe` body. (6bb, 58u) - decode_unsafe = codec.decode_unsafe, + -- recursion stays on the unsafe path. When the C runtime is loaded + -- this dispatches to c_runtime.decode_unsafe, which gates the + -- is_valid_utf8 call on a dec_ctx.skip_utf8 flag (kyt). Generated + -- runtime-mode wrappers `M._decode_unsafe` forward into this; + -- full-mode codegen inlines a literal sister `_decode_unsafe` body + -- that itself dispatches to c_runtime.decode_unsafe at the prologue. + -- (6bb, 58u, kyt) + decode_unsafe = pb_decode_unsafe, -- Lazy / zero-copy decode view. See runtime/pb/lazy.lua for the -- :get / :has / :which / :iter / :names surface on the returned