From 6fb4b04c2c5551c1947fc354804f0996d4a64cdb Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Fri, 15 May 2026 17:18:14 +0300 Subject: [PATCH] bench: lazy decode/encode scenarios + jit-trace coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds bench/lazy_bench.lua comparing eager decode/encode against decode_lazy:encode across three workloads (passthrough, sparse-read, mutate-then-reencode) at 1KB/10KB/100KB on emails-heavy Person. Output is stderr-only (varies with CPU load; not committed to baseline.json). Findings (LuaJIT 2.1.0-beta3, both modes): - Passthrough re-encode: lazy 1.0–1.5× faster. Untouched views skip field-walk entirely and return their original bytes verbatim. - Sparse :get x2: lazy 0.60–0.77× of eager. Per-segment Lua tables allocated during the index pass cancel the decode-skip savings on this flat shape (no large subtrees to skip_field over). - Mutate-then-reencode: lazy 0.81–1.07× of eager — roughly break-even. Both paths traverse the full byte range; lazy splices, eager re-emits per field. Lazy is a byte-passthrough optimization, not a universal speedup. Use it when you decode, touch few fields, and re-encode — the proxy / router shape. Also extends bench/jit_trace.lua with three lazy hot-path checks: index pass, sparse :get x2, and untouched :encode. All compile with no fatal aborts (19/19 trace-stability checks pass; one harmless side-trace bridge per scenario at lazy.lua's index loop, same pattern as the existing decode_varint bridge). --- bench/jit_trace.lua | 17 ++++++ bench/lazy_bench.lua | 134 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 bench/lazy_bench.lua diff --git a/bench/jit_trace.lua b/bench/jit_trace.lua index 18158ab637a9085a5d158f80937a1ccb811c15fc..3304f0b088f426cebfb81a503c4531a905cbf769 100644 --- a/bench/jit_trace.lua +++ b/bench/jit_trace.lua @@ -228,6 +228,23 @@ for _, mode in ipairs({'full', 'runtime'}) do function() hello.Result_encode(result) end) check(mode .. '/Result_decode (oneof)', function() hello.Result_decode(result_bytes) end) + + -- Lazy hot paths: index build, sparse :get, untouched :encode + -- (passthrough). The mutation/encode path (:set + :encode walking + -- dirty fields) is not gated — it's expected to be slower-and-pairs, + -- not a tight inner loop. Passthrough is what we promised to stay + -- on trace, since untouched :encode just returns _bytes verbatim. + check(mode .. '/Person_decode_lazy (index pass)', + function() hello.Person_decode_lazy(person_bytes) end) + check(mode .. '/Person_decode_lazy + :get x2', + function() + local v = hello.Person_decode_lazy(person_bytes) + local _ = v:get('name'); local _2 = v:get('age') + end) + check(mode .. '/Person_decode_lazy + :encode (passthrough)', + function() + local _ = hello.Person_decode_lazy(person_bytes):encode() + end) end -- Pin the known map limitation: pairs() over a hash compiles to bytecode diff --git a/bench/lazy_bench.lua b/bench/lazy_bench.lua new file mode 100644 index 0000000000000000000000000000000000000000..554447064c292f2e4843b39205b74701c3511b19 --- /dev/null +++ b/bench/lazy_bench.lua @@ -0,0 +1,134 @@ +#!/usr/bin/env tarantool +-- Lazy-decode micro-benchmark. +-- +-- Measures the read+mutate+re-encode workload where lazy decode_lazy +-- (zero-copy index + passthrough on untouched fields) is expected to beat +-- eager decode+encode: large messages where the caller only touches a +-- few fields. Three scenarios: +-- +-- 1. passthrough: decode_lazy + :encode (no mutation) +-- The strongest claim. Eager has to materialize the whole table +-- and re-walk every field on encode; lazy returns the original +-- bytes verbatim after a single tag-scanning pass. +-- +-- 2. sparse read: decode_lazy + :get(a) + :get(b) +-- The proxy/router workload — touch a handful of fields out of +-- many. Eager pays for materializing everything, lazy only for +-- the fields read. +-- +-- 3. one-field rewrite: decode_lazy + :get(a) + :set(b, ...) + :encode +-- The mutation passthrough case. Eager re-encodes every field, +-- lazy splices the original bytes for everything but the dirty one. +-- +-- Run: tarantool bench/lazy_bench.lua +-- Output is human-readable on stderr (varies with CPU load; not committed). + +package.path = './runtime/?.lua;./runtime/?/init.lua;' + .. './examples/expected/?.lua;./examples/expected/?/init.lua;' + .. package.path + +local clock = require('clock') + +local SIZES = { + {label = '1KB', target = 1024}, + {label = '10KB', target = 10240}, + {label = '100KB', target = 102400}, +} + +-- Emails-heavy Person — scales repeated strings to hit target size. +-- Index pass cost ≈ decode cost on this shape: every email has its own +-- tag/len scan, so lazy can't skip-jump over large subtrees. Use this +-- shape to honestly bound the lazy-vs-eager comparison on a workload +-- that gives lazy *no* structural advantage from skip_field. +local function build_payload(target) + local per_email = 36 + local fixed_bytes = 80 + local n_emails = math.max(1, math.floor((target - fixed_bytes) / per_email)) + local p = { + name = 'bigbes', age = 42, + address = {street = '1 Main St', city = 'Springfield', zip = 12345}, + lucky_numbers = {7, 13, 21, 42, 99}, + emails = {}, + } + for i = 1, n_emails do + p.emails[i] = string.rep('e', 28) .. string.format('%04d', i) + end + return p +end + +local function iter_count(size_bytes) + if size_bytes < 2000 then return 50000 end + if size_bytes < 20000 then return 5000 end + return 500 +end + +local function time_loop(fn, n) + local t0 = clock.monotonic64() + for _ = 1, n do fn() end + local t1 = clock.monotonic64() + return tonumber(t1 - t0) / 1e9 +end + +local function bench(fn, n) + for _ = 1, math.min(n, 1000) do fn() end -- warmup + local best = math.huge + for _ = 1, 5 do + collectgarbage('collect') + local t = time_loop(fn, n) + if t < best then best = t end + end + return n / best -- msgs/s at best-run +end + +local function compare(label, eager_fn, lazy_fn, n) + local e = bench(eager_fn, n) + local l = bench(lazy_fn, n) + local ratio = l / e + io.stderr:write(string.format( + ' %-40s eager %10.0f msgs/s lazy %10.0f msgs/s %s%.2fx\n', + label, e, l, ratio >= 1 and 'lazy ' or 'lazy ', ratio)) +end + +for _, mode in ipairs({'full', 'runtime'}) do + io.stderr:write('\n== mode: ' .. mode .. ' ==\n') + local hello = require(mode .. '.hello.hello_pb') + + for _, size in ipairs(SIZES) do + io.stderr:write(string.format('\n size: %s\n', size.label)) + local payload = build_payload(size.target) + local bytes = hello.Person_encode(payload) + local n = iter_count(#bytes) + + -- 1. passthrough: decode + re-encode with no mutation + compare('passthrough (decode + reencode)', + function() hello.Person_encode(hello.Person_decode(bytes)) end, + function() return hello.Person_decode_lazy(bytes):encode() end, + n) + + -- 2. sparse read: read 2 top-level fields + compare('sparse read (name + age)', + function() + local t = hello.Person_decode(bytes) + local _ = t.name; local _2 = t.age + end, + function() + local v = hello.Person_decode_lazy(bytes) + local _ = v:get('name'); local _2 = v:get('age') + end, + n) + + -- 3. one-field rewrite: change `name`, keep everything else + compare('rewrite name (decode + set + reencode)', + function() + local t = hello.Person_decode(bytes) + t.name = 'mallory' + local _ = hello.Person_encode(t) + end, + function() + local v = hello.Person_decode_lazy(bytes) + v:set('name', 'mallory') + local _ = v:encode() + end, + n) + end +end