~bigbes/tarantool

tarantool-protobuf

ref: ac9b9c1302c14cce6718a4f6e8d7ce84c5b7f1df tarantool-protobuf/runtime/pb d---------
77ccfc15 — Eugene Blikh 3 months ago
lazy: zero-copy decode view (decode_lazy) with passthrough re-encode

Adds pb.decode_lazy(desc, bytes) returning a MessageView/ArrayView/MapView
that indexes the wire bytes in a single pass and decodes individual
fields only on :get / :at access. Nested messages return more lazy
sub-views; WKT descriptors (those carrying desc.decode) are
eager-wrapped so the API stays uniform.

Surface (see runtime/pb/lazy.lua):
  - MessageView: :get / :has / :which / :iter / :names / :set / :encode
  - ArrayView:   :len / :at / :iter / :tolist
  - MapView:     :get / :has / :keys / :iter / :totable

:encode is three-modes: WKT delegates to desc.encode on the materialized
table; untouched views return their original bytes verbatim
(passthrough); mixed views walk fields in id order, splicing clean
segments and re-emitting dirty ones. Sub-MessageView mutations
propagate to parent encode via a flat _sub_msg_views array (walked with
ipairs, so :is_dirty stays on a single JIT trace — pairs over a hash
is NYI in LuaJIT 2.1).

Codegen emits M.<Type>_decode_lazy in both modes as a one-line
delegation to pb.decode_lazy(<desc>, b); no inline expansion.
codec.encode_field is exposed so the lazy passthrough emitter can
splice fresh bytes for a single dirty field without rebuilding the
whole message.

Tests: 40 new lazy_test.lua cases parameterized over both codegen
modes; all 11 interop fixtures round-trip byte-equal through
decode_lazy(b):encode() in both modes. Total: 300/300 luatest, up
from 226.
add2f224 — Eugene Blikh 3 months ago
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.
a33dbecb — Eugene Blikh 3 months ago
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)
46045da1 — Eugene Blikh 3 months ago
codec: precompute per-field readers (runtime decode +10–17%)

Mirror of compile_writers on the decode side: pb.finalize_message now
also calls codec.compile_readers(desc), attaching `f._reader` to each
field whose shape can be specialized — singular scalar/enum/message
and repeated scalar/enum/message (packed and unpacked). Each reader
has signature (buf, pos, wt, result) -> new_pos and bakes in the
field name, decode function, packed-detection, list bookkeeping,
nested-message merge rules, and oneof sibling clearing.

The decode_message hot loop becomes:
  id, wt, pos = decode_tag(buf, pos)
  f = fbi[id]
  if f and f._reader then pos = f._reader(buf, pos, wt, result)
  else /* existing per-kind dispatch — map fields only */ end

Forward declaration `local decode_msg` so reader closures captured at
finalize-time can refer to it; the later `decode_msg = function...`
fills the upvalue.

Effect (bench/bench.lua, hello.Person):
  runtime/100B  decode: 246 → 272 MB/s (+11%)
  runtime/1KB   decode: 122 → 144 MB/s (+17%)
  runtime/10KB  decode: 176 → 200 MB/s (+13%)
  runtime/100KB decode: 180 → 210 MB/s (+17%)
  full mode decode: ~flat (already maximally inlined by codegen)
  encode: unchanged
  alloc/op: unchanged (bench-compare clean)
  bridges across 10 jit-trace runs: 3 → 2

Runtime mode decode is now within ~10–15% of full mode across all
sizes, vs 24–28% gap before this commit.
89cc5008 — Eugene Blikh 3 months ago
codec: specialize writers for repeated fields (+20–80% encode across sizes)

Extends compile_writers to handle repeated scalar (packed and unpacked),
repeated message, and repeated enum (packed and unpacked). Each writer
knows its tag bytes, encoder function, and packed/unpacked shape; the
encode_message loop calls them directly without rediscovering the field
shape on every iteration.

Combined with the singular-field writers (previous commit), this lifts
runtime-mode encode throughput uniformly:

  runtime/10B   encode:  5.0 →  9.1 MB/s  (+82%)
  runtime/100B  encode:   48 →   82 MB/s  (+70%)
  runtime/1KB   encode:   50 →   69 MB/s  (+39%)
  runtime/10KB  encode:   87 →  101 MB/s  (+16%)
  runtime/100KB encode:   90 →  111 MB/s  (+22%)

  full mode encode: +10% on small sizes, +10% on large (small bonus from
                    nested encode_msg calls going through writers too)
  decode: small uplift on full mode 100B (323 → 340 MB/s), noise on rest
  alloc/op: unchanged (bench-compare clean)
  bridges across 10 jit-trace runs: 7 → 3 (-57%; total -89% from baseline)

Falls through to encode_field only for map fields and oneof branches —
both keep their existing dispatch path.
8014163c — Eugene Blikh 3 months ago
codec: precompute per-field writers for singular scalar/enum/message (~+50% small-msg encode)

pb.finalize_message now calls codec.compile_writers(desc), which
attaches `f._writer` to each field whose shape we can specialize:
singular scalar, singular enum, singular message — i.e. not maps,
not repeated, not oneof. Each writer is a monomorphic closure that
knows its tag bytes, encoder function, and default predicate. The
encode_message hot loop calls writer(data, out) per field and only
falls through to encode_field for shapes we haven't specialized.

This eliminates the per-field `encode_field` dispatch chain
(kind/proto_type branch + `is_default_scalar` call), which was the
source of the remaining trace bridges in runtime mode (codec.lua:41
and codec.lua:110 in `make jit-trace` output).

Effect (bench/bench.lua, hello.Person):
  runtime/10B  encode:  5.0 →  8.1 MB/s  (+62%)
  runtime/100B encode: 48.2 → 75.2 MB/s  (+56%)
  runtime/1KB  encode: 49.5 → 56.1 MB/s  (+13%)
  runtime/10KB+ encode: unchanged (dominated by repeated-field
                                   iteration — not yet specialized)
  full mode encode: +10% across small sizes (writer closures also
                    help when codegen calls back into the runtime)
  decode: unchanged
  alloc/op: unchanged (bench-compare clean)
  bridges across 10 jit-trace runs: 7 → 4
9f3bfb8f — Eugene Blikh 3 months ago
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)
aff3ee42 — Eugene Blikh 3 months ago
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.
784dea4d — Eugene Blikh 3 months ago
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.