Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 94 additions & 6 deletions benches/lua_bench.lua
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,10 @@ local ROUNDS = 5
local function bench(name, iters, fn)
-- Warmup pass: lets JIT compile hot traces and any one-time pools fill
-- before measurement starts. Excluded from timing and memory delta.
local warmup = math.max(3, math.floor(iters / 5))
-- Floor at 50: LuaJIT hotloop default is 56, so fewer iterations leave
-- the bench measuring interpreter mode for the large-payload scenarios
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update the example to match current iteration count.

The comment states "1m has iters=15" but Line 285 shows iters = 60 for the 1m scenario. This mismatch makes the explanation confusing.

📝 Proposed fix
-    -- (1m has iters=15, iters/5=3 → trace never compiles → ~30% noise).
+    -- (1m has iters=60, iters/5=12 → without floor, trace might not compile).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benches/lua_bench.lua` at line 149, Update the explanatory comment that
currently reads "1m has iters=15" to reflect the actual iteration count used in
the benchmark (the `iters = 60` setting for the 1m scenario); locate the comment
near the bench description for large-payload/interpreter mode and change the
text to reference `iters = 60` (or reword to avoid hardcoding the number) so it
matches the `iters` variable defined for the 1m scenario.

-- (1m has iters=15, iters/5=3 → trace never compiles → ~30% noise).
local warmup = math.max(50, math.floor(iters / 5))
Comment on lines +148 to +151
for _ = 1, warmup do fn() end

collectgarbage("collect")
Expand Down Expand Up @@ -220,6 +223,21 @@ local function default_table_access(t)
end
end

local function default_table_modify_top(t)
t.model = "new-model"
t.temperature = 0.0
end

local function default_table_modify_add(t)
t.stream = true
end

local function default_table_modify_nested(t)
if t.messages and qjson.len(t.messages) > 0 then
t.messages[1].content = "modified"
end
end

-- GitHub issues accessors: array of issues, access first issue's fields
local function github_cjson_access(obj)
local _ = obj[1] and obj[1].id
Expand All @@ -239,15 +257,32 @@ local function github_table_access(t)
local _ = t[1] and t[1].user and t[1].user.login
end

local function github_table_modify_top(t)
t[1].title = "modified title"
end

local function github_table_modify_add(t)
if t[1] then
t[1].extra_field = true
end
end
Comment thread
coderabbitai[bot] marked this conversation as resolved.

local function github_table_modify_nested(t)
if t[1] and t[1].user then
t[1].user.login = "modified-user"
end
end

local scenarios = {
{name = "small", iters = 5000, payload = read_file("benches/fixtures/small_api.json")},
{name = "medium", iters = 500, payload = read_file("benches/fixtures/medium_resp.json")},
{name = "github-100k", iters = 100, payload = make_github_issues_payload(100 * 1024),
cjson_access = github_cjson_access, qjson_access = github_qjson_access, table_access = github_table_access},
cjson_access = github_cjson_access, qjson_access = github_qjson_access, table_access = github_table_access,
modify_top = github_table_modify_top, modify_add = github_table_modify_add, modify_nested = github_table_modify_nested},
{name = "100k", iters = 100, payload = make_payload(100 * 1024)},
{name = "200k", iters = 50, payload = make_payload(200 * 1024)},
{name = "500k", iters = 20, payload = make_payload(500 * 1024)},
{name = "1m", iters = 15, payload = make_payload(1024 * 1024)},
{name = "500k", iters = 100, payload = make_payload(500 * 1024)},
{name = "1m", iters = 60, payload = make_payload(1024 * 1024)},
{name = "2m", iters = 20, payload = make_payload(2 * 1024 * 1024)},
{name = "5m", iters = 20, payload = make_payload(5 * 1024 * 1024)},
{name = "10m", iters = 20, payload = make_payload(10 * 1024 * 1024)},
Expand All @@ -269,6 +304,9 @@ for _, s in ipairs(scenarios) do
local cjson_access = s.cjson_access or default_cjson_access
local qjson_access = s.qjson_access or default_qjson_access
local table_access = s.table_access or default_table_access
local modify_top = s.modify_top or default_table_modify_top
local modify_add = s.modify_add or default_table_modify_add
local modify_nested = s.modify_nested or default_table_modify_nested

bench("cjson.decode + access fields", s.iters, function()
local obj = cjson.decode(s.payload)
Expand Down Expand Up @@ -307,7 +345,29 @@ for _, s in ipairs(scenarios) do

bench("qjson.decode + qjson.encode (unmodified)", s.iters, function()
local t = qjson.decode(s.payload)
local _ = qjson.encode(t)
local _enc = qjson.encode(t)
if #_enc < 2 then error("qjson.encode produced too-short result") end
end)

bench("qjson.decode + modify top + encode", s.iters, function()
local t = qjson.decode(s.payload)
modify_top(t)
local _enc = qjson.encode(t)
if #_enc < 2 then error("qjson.encode produced too-short result") end
end)

bench("qjson.decode + add field + encode", s.iters, function()
local t = qjson.decode(s.payload)
modify_add(t)
local _enc = qjson.encode(t)
if #_enc < 2 then error("qjson.encode produced too-short result") end
end)

bench("qjson.decode + modify nested + encode", s.iters, function()
local t = qjson.decode(s.payload)
modify_nested(t)
local _enc = qjson.encode(t)
if #_enc < 2 then error("qjson.encode produced too-short result") end
end)
end

Expand Down Expand Up @@ -384,6 +444,34 @@ do
bench("qjson.decode + qjson.encode (unmodified)", 400, function()
local p = next_p()
local t = qjson.decode(p)
local _ = qjson.encode(t)
local _enc = qjson.encode(t)
if #_enc < 2 then error("qjson.encode produced too-short result") end
end)

next_p = make_cycler(interleaved)
bench("qjson.decode + modify top + encode", 400, function()
local p = next_p()
local t = qjson.decode(p)
default_table_modify_top(t)
local _enc = qjson.encode(t)
if #_enc < 2 then error("qjson.encode produced too-short result") end
end)

next_p = make_cycler(interleaved)
bench("qjson.decode + add field + encode", 400, function()
local p = next_p()
local t = qjson.decode(p)
default_table_modify_add(t)
local _enc = qjson.encode(t)
if #_enc < 2 then error("qjson.encode produced too-short result") end
end)

next_p = make_cycler(interleaved)
bench("qjson.decode + modify nested + encode", 400, function()
local p = next_p()
local t = qjson.decode(p)
default_table_modify_nested(t)
local _enc = qjson.encode(t)
if #_enc < 2 then error("qjson.encode produced too-short result") end
end)
end
145 changes: 98 additions & 47 deletions lua/qjson/table.lua
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ else
_M.empty_array_mt = { __jsontype = "array" }
end

-- Weak side-table for container type hints, avoiding collision with
-- user-visible keys. Maps materialized table → "object" | "array".
local TABLE_TYPE_HINT = setmetatable({}, { __mode = "k" })

-- Box scratch used for one-shot FFI returns. Reused across calls to avoid
-- per-call allocation; safe because the parent Doc / lazy view holds the
-- buffer alive and these are read-and-copy.
Expand Down Expand Up @@ -65,6 +69,8 @@ local function wrap_child(parent_view, src_box)
local own_box = ffi.new("qjson_cursor[1]")
ffi.copy(own_box, src_box, ffi.sizeof("qjson_cursor"))
return {
_parent = parent_view,
_dirty = false,
_doc = parent_view._doc,
_cur_box = own_box, -- keep cdata alive
_cur = own_box[0], -- stable reference into own_box
Expand Down Expand Up @@ -248,10 +254,13 @@ local function materialize_array_contents(view)
end

-- The set of keys reserved by the lazy view bookkeeping; user-supplied JSON
-- keys with these names would collide (minor, deferred). Centralized here so
-- the dirty check and __newindex can share the list.
-- keys with these names would collide (minor, deferred). Centralized so
-- __newindex (cache snapshotting before materialization) and
-- encode_lazy_object_walking (skipping internals while encoding a dirty
-- proxy) share one source of truth.
local INTERNAL_KEYS = {
_doc = true, _cur_box = true, _cur = true, _bs = true, _be = true,
_parent = true, _dirty = true,
}

-- On first write, walk all existing key/value pairs into a plain table,
Expand All @@ -260,10 +269,16 @@ local INTERNAL_KEYS = {
-- Existing rawget-cached entries (e.g. previously returned child proxies)
-- are preserved so callers' references remain valid.
LazyObject.__newindex = function(t, k, v)
-- Mark dirty from this view up to the root.
local cur = t
while cur do
local mt = getmetatable(cur)
if mt ~= LazyObject and mt ~= LazyArray then break end
rawset(cur, "_dirty", true)
cur = rawget(cur, "_parent")
end
Comment thread
membphis marked this conversation as resolved.
local contents = materialize_object_contents(t)
-- Snapshot user-key cache BEFORE nilling internals.
-- Use next() for raw iteration: pairs() invokes __pairs on lazy tables,
-- walking the full JSON via FFI instead of the Lua-side rawget cache.
local cache = {}
local ck, cv = next(t)
while ck ~= nil do
Expand All @@ -272,8 +287,15 @@ LazyObject.__newindex = function(t, k, v)
end
ck, cv = next(t, ck)
end
t._doc, t._cur_box, t._cur, t._bs, t._be = nil, nil, nil, nil, nil
rawset(t, "_parent", nil)
rawset(t, "_dirty", nil)
rawset(t, "_doc", nil)
rawset(t, "_cur_box", nil)
rawset(t, "_cur", nil)
rawset(t, "_bs", nil)
rawset(t, "_be", nil)
setmetatable(t, nil)
TABLE_TYPE_HINT[t] = "object"
for _, kv in ipairs(contents) do
rawset(t, kv[1], cache[kv[1]] or kv[2])
end
Expand All @@ -284,10 +306,16 @@ end
-- switch to empty_array_mt (no lazy machinery), then apply the assignment.
-- Existing rawget-cached entries are preserved so callers' references remain valid.
LazyArray.__newindex = function(t, k, v)
-- Mark dirty from this view up to the root.
local cur = t
while cur do
local mt = getmetatable(cur)
if mt ~= LazyObject and mt ~= LazyArray then break end
rawset(cur, "_dirty", true)
cur = rawget(cur, "_parent")
end
Comment thread
membphis marked this conversation as resolved.
local contents = materialize_array_contents(t)
-- Snapshot integer-key cache BEFORE nilling internals.
-- Use next() for raw iteration: pairs() would invoke __pairs on lazy arrays,
-- walking the full JSON via FFI instead of the Lua-side rawget cache.
local cache = {}
local ck, cv = next(t)
while ck ~= nil do
Expand All @@ -296,8 +324,15 @@ LazyArray.__newindex = function(t, k, v)
end
ck, cv = next(t, ck)
end
t._doc, t._cur_box, t._cur, t._bs, t._be = nil, nil, nil, nil, nil
rawset(t, "_parent", nil)
rawset(t, "_dirty", nil)
rawset(t, "_doc", nil)
rawset(t, "_cur_box", nil)
rawset(t, "_cur", nil)
rawset(t, "_bs", nil)
rawset(t, "_be", nil)
setmetatable(t, _M.empty_array_mt)
TABLE_TYPE_HINT[t] = "array"
for i, x in ipairs(contents) do
rawset(t, i, cache[i] or x)
end
Expand Down Expand Up @@ -328,6 +363,7 @@ function _M.decode(json_str)
error("qjson: root byte-span failed")
end
local view = {
_dirty = false,
_doc = doc,
_cur_box = root_box, -- keep the box alive; _cur is a stable reference
_cur = root_box[0],
Expand Down Expand Up @@ -370,23 +406,42 @@ _M.materialize = materialize
local string_byte = string.byte
local string_format = string.format

-- Minimal JSON string escaper covering the cjson default set.
-- Escape lookup table: byte value → escape sequence string (or nil if safe).
local ESCAPES = {
[0x22] = '\\"',
[0x5C] = '\\\\',
[0x0A] = '\\n',
[0x0D] = '\\r',
[0x09] = '\\t',
[0x08] = '\\b',
[0x0C] = '\\f',
}

-- JSON string escaper with bulk-copy fast path.
-- Scans for bytes that need escaping; copies clean segments via s:sub.
-- For strings with no escapes, returns '"' .. s .. '"' with zero table allocations.
local function encode_string(s)
local out = {'"'}
for i = 1, #s do
local n = #s
local last, i = 1, 1
local out = nil -- lazily create table only when escapes found
while i <= n do
local b = string_byte(s, i)
if b == 0x22 then out[#out+1] = '\\"'
elseif b == 0x5C then out[#out+1] = '\\\\'
elseif b == 0x0A then out[#out+1] = '\\n'
elseif b == 0x0D then out[#out+1] = '\\r'
elseif b == 0x09 then out[#out+1] = '\\t'
elseif b == 0x08 then out[#out+1] = '\\b'
elseif b == 0x0C then out[#out+1] = '\\f'
elseif b < 0x20 then out[#out+1] = string_format('\\u%04x', b)
else out[#out+1] = string.char(b)
local esc = ESCAPES[b]
if esc or b < 0x20 then
if not out then out = {'"'} end
if i > last then out[#out + 1] = s:sub(last, i - 1) end
if esc then
out[#out + 1] = esc
else
out[#out + 1] = string_format('\\u%04x', b)
end
last = i + 1
end
i = i + 1
end
out[#out+1] = '"'
if not out then return '"' .. s .. '"' end
if last <= n then out[#out + 1] = s:sub(last, n) end
out[#out + 1] = '"'
return table.concat(out)
end

Expand All @@ -400,27 +455,6 @@ local function encode_number(n)
return string_format("%.14g", n)
end

-- A lazy subtree is "dirty" if any cached descendant has been materialized
-- (no longer carries Lazy* metatable). Non-cached descendants are guaranteed
-- untouched, so we only need to walk the rawget-cached entries.
local function is_dirty(v)
if type(v) ~= "table" then return false end
local mt = getmetatable(v)
if mt ~= LazyObject and mt ~= LazyArray then
return true -- materialized
end
-- Use next() for raw table iteration: pairs() would invoke __pairs on
-- lazy tables, walking the full JSON via FFI instead of the Lua cache.
local k, child = next(v)
while k ~= nil do
if not INTERNAL_KEYS[k] then
if is_dirty(child) then return true end
end
k, child = next(v, k)
end
return false
end

-- Forward declaration so encode_lazy_object_walking, encode_lazy_array_walking,
-- and encode_array/encode_object can reference encode before its definition is
-- complete (Lua resolves upvalues at call time, but the slot must be declared first).
Expand Down Expand Up @@ -471,7 +505,7 @@ local function encode_lazy_array_walking(t)
end

local function encode_proxy(t)
if not is_dirty(t) then
if not t._dirty then
-- Fast path: no mutations — slice the original buffer bytes.
return t._doc._hold:sub(t._bs + 1, t._be)
end
Expand Down Expand Up @@ -514,6 +548,26 @@ local function encode_object(t)
return "{" .. table.concat(parts, ",") .. "}"
end

-- Dispatch for plain (non-lazy) tables. Separated from the main encode
-- function to keep the lazy-proxy fast path narrow for LuaJIT traces.
local function encode_plain_table(v)
local mt = getmetatable(v)
if mt == _M.empty_array_mt then
return encode_array(v)
end
local hint = TABLE_TYPE_HINT[v]
if hint == "object" then
return encode_object(v)
end
if hint == "array" then
return encode_array(v)
end
if is_array(v) then
return encode_array(v)
end
return encode_object(v)
end

encode = function(v)
if rawequal(v, _M.null) then
return "null"
Expand All @@ -530,10 +584,7 @@ encode = function(v)
if mt == LazyObject or mt == LazyArray then
return encode_proxy(v)
end
if is_array(v) then
return encode_array(v)
end
return encode_object(v)
return encode_plain_table(v)
end
error("qjson.encode: unsupported value type: " .. tv)
end
Expand Down
Loading
Loading