~bigbes/tarantool

tarantool-protobuf

6fb4b04c2c5551c1947fc354804f0996d4a64cdb — Eugene Blikh 3 months ago 77ccfc1
bench: lazy decode/encode scenarios + jit-trace coverage

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).
2 files changed, 151 insertions(+), 0 deletions(-)

M bench/jit_trace.lua
A bench/lazy_bench.lua
M bench/jit_trace.lua => bench/jit_trace.lua +17 -0
@@ 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

A bench/lazy_bench.lua => bench/lazy_bench.lua +134 -0
@@ 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