~bigbes/tarantool

tarantool-protobuf

ref: ab214a39023a13ef4c1e534589bdc0d20ff59ce4 tarantool-protobuf/bench/jit_trace.lua -rw-r--r-- 11.6 KiB
ab214a39 — Eugene Blikh codegen: emit strict <Type>_fields / _oneofs constants for lazy view 3 months ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
#!/usr/bin/env tarantool
-- Trace-stability gate (PLAN.md M6).
--
-- For each hot encode/decode path, run a few thousand iterations with
-- a `trace` listener attached and assert that no trace aborts in
-- `runtime/pb/*` or `examples/expected/**/*_pb.lua` fall into the
-- "fatal" set — bytecodes/builtins LuaJIT can't compile, blacklists,
-- persistent type instability. Benign aborts (loop boundaries, retry
-- recording, short warmup traces) are ignored; they are normal JIT
-- bookkeeping and don't mean the hot path fell off the JIT.
--
-- Scope: `pairs()` over a hash compiles to bytecode ISNEXT, which is
-- NYI in the LuaJIT 2.1 fork Tarantool ships. That makes map-field
-- encode/decode (the only place we use `pairs` in the hot path)
-- inherently un-stay-on-trace. The last scenario pins that limitation
-- so we notice if upstream ever lifts it.
--
-- Run as a standalone tarantool script — not via luatest. On macOS
-- arm64, luatest's framework load fills the JIT mcode arena before
-- tests run, so traces in the test body fail with "failed to allocate
-- mcode memory" rather than the real reason we're trying to measure.
--
-- Usage:
--   tarantool bench/jit_trace.lua
-- exit 0 = all checks passed; non-zero = a fatal abort was hit or a
-- hot path failed to compile at all.

package.path = './runtime/?.lua;./runtime/?/init.lua;'
    .. './examples/expected/?.lua;./examples/expected/?/init.lua;'
    .. package.path

jit.on()
local vmdef = require('jit.vmdef')

-- Codes from jit.vmdef.traceerr (1-indexed). We treat these as fatal —
-- they mean the JIT genuinely cannot compile the path, not that it's
-- reorganizing traces. See vmdef.traceerr for the full list.
local FATAL = {
    [5]  = true,  -- blacklisted
    [7]  = true,  -- NYI: bytecode %s
    [11] = true,  -- bad argument type
    [15] = true,  -- NYI: unsupported variant of FastFunc %s
    [16] = true,  -- NYI: return to lower frame
    [18] = true,  -- missing metamethod
    [19] = true,  -- looping index lookup
    [20] = true,  -- NYI: mixed sparse/dense table
    [22] = true,  -- NYI: unsupported C type conversion
    [23] = true,  -- NYI: unsupported C function type
    [26] = true,  -- persistent type instability
}

local function is_our_code(src)
    if type(src) ~= 'string' then return false end
    return src:match('runtime/pb/') ~= nil
        or src:match('examples/expected/') ~= nil
end

local function fmt_reason(code, info)
    local msg = vmdef.traceerr[code] or ('?code=' .. tostring(code))
    return (msg:gsub('%%s', tostring(info or '?')))
end

local jutil = require('jit.util')

local function record(fn, warmup_iters, measured_iters)
    for _ = 1, warmup_iters do fn() end
    jit.flush()
    local fatal, stops = {}, 0
    local start_loc, stop_loc = {}, {}  -- trace_no -> {src, line, pc, parent}
    local cb = function(what, tr, func, pc, code, info)
        if what == 'start' and func then
            local di = debug.getinfo(func, 'S')
            -- For side traces: `code` is the parent trace number,
            -- `info` is the parent's exit index. Root traces have code=nil.
            start_loc[tr] = {
                src = di.short_src, line = di.linedefined, pc = pc,
                parent = code,  -- nil for root, traceno for side trace
            }
        elseif what == 'stop' then
            stops = stops + 1
            if func then
                local di = debug.getinfo(func, 'S')
                stop_loc[tr] = {src = di.short_src, line = di.linedefined}
            end
        elseif what == 'abort' and FATAL[code] then
            local di = func and debug.getinfo(func, 'S') or {short_src = '?'}
            if is_our_code(di.short_src) then
                fatal[#fatal + 1] = {
                    src  = di.short_src,
                    line = di.linedefined,
                    code = code,
                    info = info,
                }
            end
        end
    end
    jit.attach(cb, 'trace')
    for _ = 1, measured_iters do fn() end
    jit.attach(cb)
    -- A "bridge" we care about is a SIDE trace (i.e. a child compiled off
    -- a guard exit of some parent trace) whose own natural exit drops to
    -- the interpreter. Pattern: parent runs hot → guard fails → side
    -- trace covers the divergent code → falls back to VM dispatch instead
    -- of stitching to another trace. Each such bridge costs a few hundred
    -- ns of interp dispatch on every hot iteration.
    --
    -- Pure root traces that end in linktype=interpreter are NOT bridges:
    -- they're short JIT'd snippets entered from interpreter and exit back
    -- to it, with no extra dispatch cost beyond normal interp execution
    -- of the surrounding code.
    local bridges = {}
    for tr = 1, 1024 do
        local info = jutil.traceinfo(tr)
        if not info then break end
        if info.linktype == 'interpreter' and info.link == 0 then
            local s = start_loc[tr] or stop_loc[tr]
            if s and is_our_code(s.src) and s.parent then
                bridges[#bridges + 1] = {
                    tr     = tr,
                    src    = s.src,
                    line   = s.line,
                    pc     = s.pc,
                    parent = s.parent,
                }
            end
        end
    end
    return fatal, stops, bridges
end

local failures = 0
local checks   = 0

local function check(label, fn, opts)
    opts = opts or {}
    local fatal, stops, bridges = record(fn, 2000, 5000)
    checks = checks + 1
    if #fatal > 0 and not opts.expect_fatal_in then
        failures = failures + 1
        io.stderr:write(string.format(
            '  [FAIL] %s — %d fatal abort(s) in our code:\n', label, #fatal))
        local seen = {}
        for _, a in ipairs(fatal) do
            local k = a.src .. ':' .. a.line .. '|' .. a.code
            if not seen[k] then
                seen[k] = true
                io.stderr:write(string.format(
                    '         %s:%d  %s\n',
                    a.src, a.line, fmt_reason(a.code, a.info)))
            end
        end
        return
    end
    if opts.expect_fatal_in then
        local saw = false
        for _, a in ipairs(fatal) do
            if a.src:match(opts.expect_fatal_in) then saw = true; break end
        end
        if not saw then
            failures = failures + 1
            io.stderr:write(string.format(
                '  [FAIL] %s — expected a fatal abort in %q (known limitation), got none\n',
                label, opts.expect_fatal_in))
            return
        end
        io.stderr:write(string.format(
            '  [PIN ] %s — expected NYI present (known LuaJIT 2.1 limitation)\n',
            label))
        return
    end
    if stops == 0 then
        failures = failures + 1
        io.stderr:write(string.format(
            '  [FAIL] %s — no trace was compiled (stops=0)\n', label))
        return
    end
    -- Interpreter bridges (side trace -> interp) are real but their
    -- compilation timing is non-deterministic — across 10 runs you'll
    -- see 0–4 in decoder paths (parent trace bakes in the 1-byte varint
    -- fast path, the multi-byte side trace can't self-loop). They're a
    -- topology metric, not a pass/fail signal — report them for
    -- visibility, don't fail the gate.
    io.stderr:write(string.format(
        '  [ OK ] %s  (stops=%d, bridges=%d)\n', label, stops, #bridges))
    if #bridges > 0 then
        for _, b in ipairs(bridges) do
            io.stderr:write(string.format(
                '         info: bridge tr%d  side-of tr%s  %s:%d  pc=%s\n',
                b.tr, tostring(b.parent), b.src, b.line, tostring(b.pc)))
        end
    end
end

-- ---------------------------------------------------------------------------

io.stderr:write('tarantool-protobuf trace-stability gate ('
    .. (jit.version or '?') .. ')\n')

for _, mode in ipairs({'full', 'runtime'}) do
    local hello = require(mode .. '.hello.hello_pb')

    local addr = {street = '1 Main St', city = 'Springfield', zip = 12345}
    local addr_bytes = hello.Address_encode(addr)

    -- Person with repeated string, packed int32, nested message — no
    -- map fields (see top-of-file scope note).
    local person = {
        name    = 'bigbes', age = 42,
        address = addr,
        lucky_numbers = {7, 13, 21, 42, 99, 144, 233, 377},
        emails  = {'a@b.c', 'd@e.f', 'g@h.i', 'j@k.l'},
        status  = hello.Status.OK,
    }
    local person_bytes = hello.Person_encode(person)

    local result = {value = 'ok'}
    local result_bytes = hello.Result_encode(result)

    check(mode .. '/Address_encode',
        function() hello.Address_encode(addr) end)
    check(mode .. '/Address_decode',
        function() hello.Address_decode(addr_bytes) end)
    check(mode .. '/Person_encode',
        function() hello.Person_encode(person) end)
    check(mode .. '/Person_decode',
        function() hello.Person_decode(person_bytes) end)
    check(mode .. '/Result_encode (oneof)',
        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)

    -- 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
-- ISNEXT, which Tarantool LuaJIT 2.1 can't trace. If this stops triggering,
-- upstream lifted the restriction and our scope claim can broaden.
do
    local hello = require('full.hello.hello_pb')
    local with_map = {
        name = 'bigbes', age = 42,
        ages_by_nickname = {bigbes = 1, eb = 2, blikh = 3},
    }
    check('full/Person_encode with map (known NYI)',
        function() hello.Person_encode(with_map) end,
        {expect_fatal_in = 'hello_pb%.lua'})
end

io.stderr:write(string.format(
    '\n%d/%d checks passed\n', checks - failures, checks))
os.exit(failures > 0 and 1 or 0)