diff --git a/Makefile b/Makefile index 7c288d531c43fc16edc85d3f68749dc453cbe818..41b498d8eb84c65313abbd8bd329b096b7d64eed 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,8 @@ space := $(empty) $(empty) LUA_PATH_JOINED := $(subst $(space),;,$(strip $(LUA_PATH_PARTS)));; .PHONY: all build build-doc gen gen-full gen-runtime gen-docs goldens \ - test test-suite bench bench-baseline bench-compare jit-trace clean + test test-suite bench bench-baseline bench-compare \ + bench-wire bench-shapes jit-trace clean all: build gen test @@ -107,6 +108,19 @@ # Fail with exit 1 if any alloc-per-op grows by >5% vs the committed # baseline. Wire into CI to gate PRs. bench-compare: gen tarantool bench/bench.lua --compare + +# Per-helper microbenchmark for the wire layer (encode/decode of every +# primitive + tag + skip + UTF-8). Use when tuning runtime/pb/wire.lua +# to confirm a change moved the helper-level ns/op as expected. +bench-wire: gen + tarantool bench/wire_bench.lua + +# Workload-variety benchmark. Runs encode + decode for several Person +# (and Event / Result) shapes — scalar-heavy, packed-ints, nested +# friends, maps, oneof, WKT — so shape-specific regressions surface +# instead of being averaged out by bench/bench.lua's single shape. +bench-shapes: gen + tarantool bench/shapes_bench.lua # Trace-stability gate: assert every hot encode/decode path JIT-compiles # without fatal aborts (NYI bytecode, blacklisting, persistent type diff --git a/bench/jit_trace.lua b/bench/jit_trace.lua index 3304f0b088f426cebfb81a503c4531a905cbf769..68b6ef38eac033a4975ea95c5761e87cf66dea8e 100644 --- a/bench/jit_trace.lua +++ b/bench/jit_trace.lua @@ -245,6 +245,28 @@ check(mode .. '/Person_decode_lazy + :encode (passthrough)', function() local _ = hello.Person_decode_lazy(person_bytes):encode() end) + + -- Multi-byte varint paths. The default Person fixture has all 1-byte + -- varints (field IDs 1-15, lengths < 128, packed-int values < 128), + -- so it only exercises encode_varint's fast path / decode_int32's + -- inlined 1-byte branch. This fixture forces the 2/3-byte paths: + -- * `name` is a 200-byte string → 2-byte LEN prefix, exercises + -- encode_varint_slow's 2-byte branch and decode_string's + -- 2-byte LEN fallback. + -- * `lucky_numbers` carry multi-byte values → packed payload is + -- varint-of-varints all hitting the slow path. + -- If these regress (e.g. encode_varint_slow grows past inline budget + -- and parent traces stop inlining it), the gate fires. + local big_person = { + name = string.rep('x', 200), + age = 42, + lucky_numbers = {150, 200, 1000, 20000, 50000, 100000, 200000, 500000}, + } + local big_person_bytes = hello.Person_encode(big_person) + check(mode .. '/Person_encode multi-byte varint', + function() hello.Person_encode(big_person) end) + check(mode .. '/Person_decode multi-byte varint', + function() hello.Person_decode(big_person_bytes) end) end -- Pin the known map limitation: pairs() over a hash compiles to bytecode diff --git a/bench/shapes_bench.lua b/bench/shapes_bench.lua new file mode 100644 index 0000000000000000000000000000000000000000..b61b8657ecaf02bb7b37b755de60bfd185952648 --- /dev/null +++ b/bench/shapes_bench.lua @@ -0,0 +1,229 @@ +#!/usr/bin/env tarantool +-- Workload-variety benchmark. +-- +-- bench/bench.lua measures Person at 5 sizes, but the shape is always +-- "name + a bunch of email strings" — so it stresses the string fast +-- paths but says little about scalar-heavy, deeply-nested, or map-heavy +-- traffic. This file fills that gap by running encode + decode against +-- a handful of fundamentally different Person shapes. +-- +-- Each shape is constructed with one knob dialed up and the others kept +-- minimal so the cost attribution stays clean — when "nested-heavy" +-- regresses but "scalar-heavy" doesn't, you know which codegen path +-- changed. +-- +-- No baseline file. Throughput is informational; allocation per op is +-- shown alongside so churn at the GC level is also visible. + +package.path = './runtime/?.lua;./runtime/?/init.lua;' + .. './examples/expected/?.lua;./examples/expected/?/init.lua;' + .. package.path + +local clock = require('clock') + +local MODES = {'full', 'runtime'} + +-- --------------------------------------------------------------------------- +-- Shape definitions. Each returns {label, payload, description}. +-- --------------------------------------------------------------------------- + +local function shape_scalar_heavy() + -- Lots of numeric + bool fields, no strings, no nesting, no maps. + -- Exercises varint / fixed / float encode/decode paths. + return { + label = 'scalar-heavy', + desc = 'age + status + balance + user_id + weight_kg, no strings', + payload = { + age = 42, + status = 1, -- OK + balance = -12345, + user_id = require('ffi').cast('uint64_t', 0xfeedface00000001ULL), + weight_kg = 72.5, + }, + } +end + +local function shape_packed_ints(n) + -- Big packed repeated int32 — single field, N elements, exercises + -- the packed encode/decode hot loop. + local nums = {} + for i = 1, n do nums[i] = i * 7 - 3 end + return { + label = 'packed-int32x' .. n, + desc = n .. ' packed int32s in a single lucky_numbers field', + payload = {lucky_numbers = nums}, + } +end + +local function shape_nested_heavy(n) + -- Repeated Person friends — exercises nested-message encode + the + -- generated _decode recursion + length-prefix path. + local friends = {} + for i = 1, n do + friends[i] = {name = 'friend-' .. i, age = 20 + (i % 50)} + end + return { + label = 'nested-x' .. n, + desc = n .. ' friend Persons, each {name, age}', + payload = {friends = friends}, + } +end + +local function shape_map_heavy(n) + -- map with N entries. Exercises the map-entry decode + -- (pairs-iter on encode, dispatch on decode), key dedup, etc. + local m = {} + for i = 1, n do m['name-' .. string.format('%04d', i)] = i end + return { + label = 'map-strxi32-x' .. n, + desc = n .. ' ages_by_nickname entries', + payload = {ages_by_nickname = m}, + } +end + +local function shape_map_msg(n) + -- map — message-valued map. Stresses both the map + -- entry framing and the nested-message recursion. + local m = {} + for i = 1, n do + m['label-' .. i] = {street = 'St', city = 'City', zip = 10000 + i} + end + return { + label = 'map-strxmsg-x' .. n, + desc = n .. ' addresses_by_label entries (message values)', + payload = {addresses_by_label = m}, + } +end + +local function shape_oneof_text() + -- Result with the `text` oneof branch active. + return { + label = 'oneof-text', + desc = 'Result{id, oneof outcome.text="..."}', + payload = {id = 7, text = 'all good'}, + message = 'Result', + } +end + +local function shape_wkt() + -- Event with all WKT fields populated. Exercises Timestamp / + -- Duration / Wrappers / Struct round-trip. + local datetime = require('datetime') + return { + label = 'wkt-event', + desc = 'Event with Timestamp + Duration + Wrappers + Struct', + payload = { + title = 'ev', + created_at = datetime.new({timestamp = 1700000000, nsec = 123456789}), + duration = {seconds = 60, nanos = 0}, + retry_count = 3, + note = 'hi', + is_admin = true, + payload = {foo = 'bar', n = 42, b = true}, + }, + message = 'Event', + } +end + +local SHAPES = { + shape_scalar_heavy(), + shape_packed_ints(100), + shape_packed_ints(1000), + shape_nested_heavy(10), + shape_nested_heavy(100), + shape_map_heavy(10), + shape_map_heavy(100), + shape_map_msg(50), + shape_oneof_text(), + shape_wkt(), +} + +-- --------------------------------------------------------------------------- +-- Harness +-- --------------------------------------------------------------------------- + +local function summarize(samples) + table.sort(samples) + return samples[math.floor((#samples + 1) / 2)] +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_throughput(fn, n, runs) + for _ = 1, math.min(n, 1000) do fn() end + local samples = {} + for r = 1, runs do + collectgarbage('collect') + samples[r] = time_loop(fn, n) + end + local med = summarize(samples) + return n / med, med +end + +local function bench_alloc(fn, payload_bytes) + local budget = 64 * 1024 * 1024 + local per_iter = math.max(1, payload_bytes) * 2 + local n = math.max(100, math.min(2000, math.floor(budget / per_iter))) + for _ = 1, 100 do fn() end + collectgarbage('collect') + collectgarbage('stop') + local before = collectgarbage('count') + for _ = 1, n do fn() end + local after = collectgarbage('count') + collectgarbage('restart') + collectgarbage('collect') + return (after - before) * 1024 / n +end + +local function iter_for(bytes) + if bytes < 100 then return 100000 end + if bytes < 2000 then return 20000 end + if bytes < 20000 then return 2000 end + return 200 +end + +local function run_shape(mode, shape) + local pb_mod = require(mode .. '.hello.hello_pb') + local msg = shape.message or 'Person' + local encode = pb_mod[msg .. '_encode'] + local decode = pb_mod[msg .. '_decode'] + + local bytes = encode(shape.payload) + local size = #bytes + local iters = iter_for(size) + + local _ = decode(bytes) -- warmup decode + verify it works + + local enc_mps, _ = bench_throughput(function() encode(shape.payload) end, iters, 5) + local enc_bpo = bench_alloc(function() encode(shape.payload) end, size) + local dec_mps, _ = bench_throughput(function() decode(bytes) end, iters, 5) + local dec_bpo = bench_alloc(function() decode(bytes) end, size) + + return size, enc_mps, enc_bpo, dec_mps, dec_bpo +end + +io.stderr:write(string.format('tarantool-protobuf shapes bench (%s)\n\n', _TARANTOOL)) +print(string.format('%-10s %-22s %6s %14s %10s %14s %10s', + 'mode', 'shape', 'bytes', + 'enc msgs/s', 'enc B/op', + 'dec msgs/s', 'dec B/op')) +print(string.rep('-', 92)) + +for _, mode in ipairs(MODES) do + for _, shape in ipairs(SHAPES) do + local ok, size, enc_mps, enc_bpo, dec_mps, dec_bpo = pcall(run_shape, mode, shape) + if ok then + print(string.format('%-10s %-22s %6d %14.0f %10.0f %14.0f %10.0f', + mode, shape.label, size, + enc_mps, enc_bpo, dec_mps, dec_bpo)) + else + io.stderr:write(string.format(' %s/%s FAILED: %s\n', mode, shape.label, tostring(size))) + end + end + print() +end diff --git a/bench/wire_bench.lua b/bench/wire_bench.lua new file mode 100644 index 0000000000000000000000000000000000000000..8d6d93548e45b2696a24d65cd1e85c5236d02c19 --- /dev/null +++ b/bench/wire_bench.lua @@ -0,0 +1,163 @@ +#!/usr/bin/env tarantool +-- Wire-layer microbenchmark. +-- +-- Times each `wire.encode_*` / `wire.decode_*` helper in isolation so +-- a regression at the wire layer is visible independently of message +-- shape. Complements bench/bench.lua, which measures composite encode/ +-- decode throughput on a real message and folds wire-layer changes in +-- with codegen, table allocation, and table.concat costs. +-- +-- Output: one row per (helper, sample) pair with ns/op. Times are +-- median over 5 runs. No baseline / regression gate; this is a manual +-- inspection tool used when tuning wire.lua. +-- +-- Usage: `make bench-wire` or `tarantool bench/wire_bench.lua`. + +package.path = './runtime/?.lua;./runtime/?/init.lua;' .. package.path + +local clock = require('clock') +local ffi = require('ffi') +local wire = require('pb.wire') + +local function timeit(fn, iters) + -- warmup + for _ = 1, math.min(iters, 1000) do fn() end + local samples = {} + for r = 1, 5 do + collectgarbage('collect') + local t0 = clock.monotonic64() + for _ = 1, iters do fn() end + local t1 = clock.monotonic64() + samples[r] = tonumber(t1 - t0) / iters + end + table.sort(samples) + return samples[3] -- median of 5 +end + +local function row(label, fn, iters) + iters = iters or 200000 + print(string.format(' %-44s %8.0f ns/op', label, timeit(fn, iters))) +end + +local function section(name) + print() + print('-- ' .. name .. ' --') +end + +io.stderr:write(string.format('tarantool-protobuf wire bench (%s)\n', _TARANTOOL)) + +-- ========================================================================= +-- Encoders +-- ========================================================================= + +section('Varint encode (Lua-number inputs)') +row('encode_varint(0) [1-byte]', function() wire.encode_varint(0) end) +row('encode_varint(127) [1-byte]', function() wire.encode_varint(127) end) +row('encode_varint(150) [2-byte]', function() wire.encode_varint(150) end) +row('encode_varint(20000) [3-byte]', function() wire.encode_varint(20000) end) +row('encode_varint(1<<28) [5-byte]', function() wire.encode_varint(1 * 2^28) end) + +section('Varint encode (cdata uint64 inputs)') +local u64_small = ffi.cast('uint64_t', 42) +local u64_large = ffi.cast('uint64_t', 0xfeedface00000001ULL) +row('encode_varint(uint64 42)', function() wire.encode_varint(u64_small) end) +row('encode_varint(uint64 large)', function() wire.encode_varint(u64_large) end) + +section('ZigZag encode') +row('zigzag_encode32(42)', function() wire.zigzag_encode32(42) end) +row('zigzag_encode32(-42)', function() wire.zigzag_encode32(-42) end) +row('zigzag_encode64(42)', function() wire.zigzag_encode64(42) end) +row('encode_sint32(-12345)', function() wire.encode_sint32(-12345) end) +row('encode_sint64(-12345)', function() wire.encode_sint64(-12345) end) + +section('Fixed-width encode') +row('encode_fixed32(0xdeadbeef)', function() wire.encode_fixed32(0xdeadbeef) end) +row('encode_fixed64(u64_large)', function() wire.encode_fixed64(u64_large) end) +row('encode_float(3.14)', function() wire.encode_float(3.14) end) +row('encode_double(3.14159265358979)', function() wire.encode_double(3.14159265358979) end) +row('encode_bool(true)', function() wire.encode_bool(true) end) + +section('LEN encode (length-prefixed)') +local s10 = string.rep('a', 10) +local s32 = string.rep('a', 32) +local s127 = string.rep('a', 127) +local s200 = string.rep('a', 200) +local s1k = string.rep('a', 1024) +row('encode_string(10B)', function() wire.encode_string(s10) end) +row('encode_string(32B)', function() wire.encode_string(s32) end) +row('encode_string(127B) [1-byte LEN]', function() wire.encode_string(s127) end) +row('encode_string(200B) [2-byte LEN]', function() wire.encode_string(s200) end) +row('encode_string(1KB) [2-byte LEN]', function() wire.encode_string(s1k) end, 50000) +row('encode_bytes(32B)', function() wire.encode_bytes(s32) end) + +section('Tag encode') +row('encode_tag(1, VARINT) [1-byte]', function() wire.encode_tag(1, wire.WIRE_VARINT) end) +row('encode_tag(15, VARINT) [1-byte]', function() wire.encode_tag(15, wire.WIRE_VARINT) end) +row('encode_tag(16, LEN) [2-byte]', function() wire.encode_tag(16, wire.WIRE_LEN) end) +row('encode_tag(2048, LEN) [3-byte]', function() wire.encode_tag(2048, wire.WIRE_LEN) end) + +-- ========================================================================= +-- Decoders +-- ========================================================================= + +local b1 = string.char(42) +local b2 = string.char(0x96, 0x01) +local b3 = string.char(0xa0, 0x9c, 0x01) +local b_f32 = wire.encode_fixed32(0xdeadbeef) +local b_f64 = wire.encode_fixed64(u64_large) +local b_str10 = wire.encode_string(s10) +local b_str32 = wire.encode_string(s32) +local b_str200 = wire.encode_string(s200) +local b_str1k = wire.encode_string(s1k) +local b_tag1 = wire.encode_tag(1, wire.WIRE_VARINT) +local b_tag16 = wire.encode_tag(16, wire.WIRE_LEN) +local b_bool = string.char(1) + +section('Varint decode') +row('decode_varint(1-byte 42)', function() wire.decode_varint(b1, 1) end) +row('decode_varint(2-byte 150)', function() wire.decode_varint(b2, 1) end) +row('decode_varint(3-byte 20000)', function() wire.decode_varint(b3, 1) end) + +section('Typed varint decode') +row('decode_int32(1-byte)', function() wire.decode_int32(b1, 1) end) +row('decode_int32(2-byte)', function() wire.decode_int32(b2, 1) end) +row('decode_int64(1-byte)', function() wire.decode_int64(b1, 1) end) +row('decode_uint32(1-byte)', function() wire.decode_uint32(b1, 1) end) +row('decode_uint64(1-byte)', function() wire.decode_uint64(b1, 1) end) +row('decode_sint32(1-byte)', function() wire.decode_sint32(b1, 1) end) +row('decode_sint64(1-byte)', function() wire.decode_sint64(b1, 1) end) +row('decode_bool(1)', function() wire.decode_bool(b_bool, 1) end) + +section('Fixed-width decode') +row('decode_fixed32', function() wire.decode_fixed32(b_f32, 1) end) +row('decode_fixed64', function() wire.decode_fixed64(b_f64, 1) end) +row('decode_sfixed32', function() wire.decode_sfixed32(b_f32, 1) end) +row('decode_sfixed64', function() wire.decode_sfixed64(b_f64, 1) end) +row('decode_float', function() wire.decode_float(b_f32, 1) end) +row('decode_double', function() wire.decode_double(b_f64, 1) end) + +section('LEN decode') +row('decode_string(10B)', function() wire.decode_string(b_str10, 1) end) +row('decode_string(32B)', function() wire.decode_string(b_str32, 1) end) +row('decode_string(200B) [2-byte LEN]', function() wire.decode_string(b_str200, 1) end) +row('decode_string(1KB) [2-byte LEN]', function() wire.decode_string(b_str1k, 1) end, 50000) +row('decode_bytes(32B)', function() wire.decode_bytes(b_str32, 1) end) +row('decode_len(32B)', function() wire.decode_len(b_str32, 1) end) + +section('Tag decode') +row('decode_tag(1-byte tag)', function() wire.decode_tag(b_tag1, 1) end) +row('decode_tag(2-byte tag)', function() wire.decode_tag(b_tag16, 1) end) + +section('Skip field') +row('skip_field(VARINT 1-byte)', function() wire.skip_field(b1, 1, wire.WIRE_VARINT) end) +row('skip_field(I64)', function() wire.skip_field(b_f64, 1, wire.WIRE_I64) end) +row('skip_field(LEN 32B)', function() wire.skip_field(b_str32, 1, wire.WIRE_LEN) end) +row('skip_field(I32)', function() wire.skip_field(b_f32, 1, wire.WIRE_I32) end) + +section('UTF-8 validator') +row('is_valid_utf8(10B ASCII)', function() wire.is_valid_utf8(s10) end) +row('is_valid_utf8(32B ASCII)', function() wire.is_valid_utf8(s32) end) +row('is_valid_utf8(200B ASCII)', function() wire.is_valid_utf8(s200) end) +row('is_valid_utf8(1KB ASCII)', function() wire.is_valid_utf8(s1k) end, 50000) +local utf8mix = string.rep('\xe2\x9c\x94', 10) -- 30 bytes of ✔ +row('is_valid_utf8(30B mixed UTF-8)', function() wire.is_valid_utf8(utf8mix) end)