From a67f4134649706d958451ec0966a6e7a97372b33 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Wed, 15 Jul 2026 00:02:03 -0600 Subject: [PATCH 01/13] feat: tier-0 default typed parsing + :hot precompile hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON object roots parsed into non-:hot, interpreter-eligible struct types now materialize through the untyped engine (compiled once, covered by the package workload) and construct through StructUtils' tier-0 field-table interpreter. Measured against master on a request-shaped family: marginal first-parse per new struct family 3.7s -> 0.07s, steady state 51us -> 18us. Everything else — :hot types, custom dicttype/null, non-object roots, choosetype-bearing types (recursively, via StructUtils.interptreesafe, since choosetype functions receive the raw source and are written against lazy values) — keeps the classic lazy descent with identical semantics. The classic fallback sits behind an invokelatest boundary in JIT sessions: with the route decided by a runtime table lookup, a directly-reachable specialized descent gets compiled per type even when never taken — the exact first-call cost the tree route exists to eliminate. Trim builds keep the direct static call (the tree route is compile-time disabled there; typed parsing under --trim is the :hot tier's job). __init__ registers the :hot precompile hook: for each :hot-annotated type defined downstream, parse user-provided samples plus a field-table- synthesized sample (and "{}") and write the result back out, compiling the typed lazy descent and write path into the defining package's image. Co-Authored-By: Claude Fable 5 --- src/JSON.jl | 18 ++++++ src/parse.jl | 135 +++++++++++++++++++++++++++++++++++++++++ test/interp_default.jl | 106 ++++++++++++++++++++++++++++++++ test/runtests.jl | 1 + 4 files changed, 260 insertions(+) create mode 100644 test/interp_default.jl diff --git a/src/JSON.jl b/src/JSON.jl index c4c2a44..7d2d6ae 100644 --- a/src/JSON.jl +++ b/src/JSON.jl @@ -125,10 +125,28 @@ print(a, indent=nothing) = print(stdout, a, indent) "See [`json`](@ref)." print +function __init__() + # :hot-annotated struct definitions in downstream packages fire this hook + # during their precompilation, compiling the typed parse/write paths for + # each annotated type into that package's image + StructUtils.register_hot_hook!(_hot_json_hook) + return nothing +end + +# exercises the untyped engine plus the tier-0 typed default (engine → field +# table interpreter), so a downstream user's first typed parse of any +# eligible struct costs a table build instead of compiling a descent +@kwarg struct _EngineWorkload + a::Int = 0 + b::Union{String,Nothing} = nothing + c::Vector{Float64} = Float64[] +end + @compile_workload begin x = JSON.parse("{\"a\": 1, \"b\": null, \"c\": true, \"d\": false, \"e\": \"\", \"f\": [1,null,true], \"g\": {\"key\": \"value\"}}") json = JSON.json(x) isvalidjson(json) + JSON.parse("{\"a\": 1, \"b\": \"x\", \"c\": [1.5], \"z\": null}", _EngineWorkload) end diff --git a/src/parse.jl b/src/parse.jl index 3468ea9..bd28e82 100644 --- a/src/parse.jl +++ b/src/parse.jl @@ -232,12 +232,147 @@ parse(x::LazyValue, ::Type{T}=Any; unknown_fields::Symbol=:ignore) where {T,O} = @inline _parse(x, T, dicttype, null, jsonreadstyle(T, O, null, style, unknown_fields)) +# memoized routing verdict per (target type, style type): the eligibility + +# tree-safety walk is table recursion we don't want on every parse +const _INTERP_ROUTE = Dict{Tuple{DataType,DataType},Bool}() +const _INTERP_ROUTE_LOCK = ReentrantLock() + +function _interproute(style::StructStyle, @nospecialize(T))::Bool + T isa DataType || return false + key = (T, typeof(style)) + lock(_INTERP_ROUTE_LOCK) do + get!(_INTERP_ROUTE, key) do + StructUtils.interpready(style, T) && StructUtils.interptreesafe(style, T) + end + end +end + function _parse(x::LazyValue, ::Type{T}, dicttype::Type{O}, null, style::StructStyle) where {T,O} + if !StructUtils.TRIM_BUILD && T !== Any && O === DEFAULT_OBJECT_TYPE && null === nothing && + !StructUtils.ishot(T) && gettype(x) == JSONTypes.OBJECT && _interproute(style, T) + # tier-0 default: materialize through the untyped engine (compiled + # once, covered by the package workload), then construct through the + # StructUtils interpreter — faster than the specialized lazy descent + # at request-payload sizes, and the per-type compile cost drops to a + # field-table build. `:hot`-annotated types keep the lazy descent; + # custom dicttype/null and non-object roots keep classic semantics. + # The interpreter entry is called directly: routing through the + # `make` dispatcher would leave the (never-taken) specialized-descent + # arm reachable, and the JIT compiles it per type — the exact + # first-call cost this route exists to eliminate. + out = ValueClosure() + pos = applyvalue(out, x, nothing) + getisroot(x) && checkendpos(x, T, pos) + y, _ = StructUtils._interp_make(style, T, out.value::Object{String,Any}) + return y::T + end + if StructUtils.TRIM_BUILD + # trim binaries need the static call graph (and the tree route is + # compile-time disabled above, so this IS the path) + y, pos = StructUtils.make(style, T, x) + getisroot(x) && checkendpos(x, T, pos) + return y + else + # runtime-dispatch boundary: keeps the JIT from eagerly compiling the + # specialized lazy descent for types the gate routes to the + # interpreter — that per-type compile is what the tree route removes + return Base.invokelatest(_parse_classic, x, T, style) + end +end + +function _parse_classic(x::LazyValue, ::Type{T}, style::StructStyle) where {T} y, pos = StructUtils.make(style, T, x) getisroot(x) && checkendpos(x, T, pos) return y end +# ---------------- :hot precompile hook + sample synthesis ---------------- + +# registered with StructUtils from __init__: called for each :hot-annotated +# struct during the *defining package's* precompilation, inside a newly- +# inferred-tagging block — everything parsed here (the typed lazy descent, +# the write path) lands in that package's image +function _hot_json_hook(@nospecialize(T), samples::Tuple) + T isa Type || return nothing + for s in samples + s isa AbstractString || continue + try + x = parse(String(s), T) + json(x) + catch + end + end + s = try + _synthesize_sample(T) + catch + nothing + end + if s !== nothing + try + x = parse(s, T) + json(x) + catch + end + end + try + parse("{}", T) + catch + end + return nothing +end + +# build a minimal valid JSON sample for T from its field table: dummy leaf +# per kind, recursion for nested structs/vectors; CUSTOM-kind fields are +# omitted (defaults/nullability cover them, and "{}" is the fallback) +function _synthesize_sample(@nospecialize(T)) + style = JSONReadStyle{DEFAULT_OBJECT_TYPE}(nothing) + tbl = StructUtils.fieldtable(T, style) + tbl.eligible || return nothing + io = IOBuffer() + Base.print(io, '{') + isfirst = true + for sp in tbl.specs + frag = _synth_value(sp.kind, sp.elkind, sp.ft, sp.elft) + frag === nothing && continue + isfirst || Base.print(io, ',') + isfirst = false + Base.print(io, '"', sp.name, "\":", frag) + end + Base.print(io, '}') + return String(take!(io)) +end + +function _synth_value(kind::Int8, elkind::Int8, @nospecialize(ft), @nospecialize(elft)) + SU = StructUtils + if kind == SU.KIND_STRING || kind == SU.KIND_SYMBOL + return "\"s\"" + elseif kind == SU.KIND_CHAR + return "\"c\"" + elseif SU.KIND_INT64 <= kind <= SU.KIND_UINT128 + return "1" + elseif SU.KIND_FLOAT64 <= kind <= SU.KIND_FLOAT16 + return "1.5" + elseif kind == SU.KIND_BOOL + return "true" + elseif kind == SU.KIND_DATE + return "\"2020-01-02\"" + elseif kind == SU.KIND_DATETIME + return "\"2020-01-02T03:04:05\"" + elseif kind == SU.KIND_TIME + return "\"03:04:05\"" + elseif kind == SU.KIND_UUID + return "\"c8b1cf79-de6a-54ab-a142-682c06a0de6a\"" + elseif kind == SU.KIND_ANY + return "1" + elseif kind == SU.KIND_STRUCT + return _synthesize_sample(ft) + elseif kind == SU.KIND_VECTOR + el = _synth_value(elkind, Int8(0), elft, nothing) + return el === nothing ? nothing : string('[', el, ']') + end + return nothing +end + mutable struct ValueClosure value::Any ValueClosure() = new() diff --git a/test/interp_default.jl b/test/interp_default.jl new file mode 100644 index 0000000..6d928cb --- /dev/null +++ b/test/interp_default.jl @@ -0,0 +1,106 @@ +using Test, JSON, Dates, UUIDs, StructUtils + +# tier-0 default typed parsing: JSON object roots for non-:hot eligible +# structs materialize through the untyped engine and construct through the +# StructUtils field-table interpreter; everything else keeps the classic +# lazy descent. These tests pin parity across the routing boundary. + +@kwarg struct IDTier + name::String + amount::Int = 0 + currency::String = "usd" +end + +@kwarg :hot struct IDHotTier + name::String + amount::Int = 0 + currency::String = "usd" +end + +@kwarg struct IDEvent + name::String + day::Date = Date(0) + at::Union{DateTime,Nothing} = nothing + uid::Union{UUID,Nothing} = nothing + cap::Union{Int,Nothing} = nothing + kind::Symbol = :none &(json=(name="event_kind",),) + venue::Union{IDTier,Nothing} = nothing + tiers::Vector{IDTier} = IDTier[] + note::Any = nothing + score::Union{Float64,Missing} = missing +end + +@nonstruct struct IDPct + v::Float64 +end +JSON.lift(::Type{IDPct}, x) = IDPct(Float64(x)) + +@kwarg struct IDCustom + p::IDPct = IDPct(0.0) +end + +const IDJSON = """ +{"name":"Kickoff","day":"2026-08-01","at":"2026-07-25T23:59:59", + "uid":"c8b1cf79-de6a-54ab-a142-682c06a0de6a","cap":64,"event_kind":"league", + "venue":{"name":"Gym","amount":1}, + "tiers":[{"name":"Early","amount":2500,"currency":"eur"},{"name":"Late"}], + "note":{"k":"v"},"score":null,"unknown":123} +""" + +@testset "tier-0 default typed parse" begin + ev = JSON.parse(IDJSON, IDEvent) + @test ev.name == "Kickoff" + @test ev.day == Date(2026, 8, 1) + @test ev.at == DateTime(2026, 7, 25, 23, 59, 59) + @test ev.uid == UUID("c8b1cf79-de6a-54ab-a142-682c06a0de6a") + @test ev.cap == 64 + @test ev.kind === :league # :json tagkey rename resolved in the field table + @test ev.venue isa IDTier && ev.venue.amount == 1 && ev.venue.currency == "usd" + @test length(ev.tiers) == 2 + @test ev.tiers[1].currency == "eur" && ev.tiers[2].currency == "usd" + @test ev.note isa JSON.Object{String,Any} && ev.note["k"] == "v" + @test ev.score === missing + + # defaults + fresh containers per parse + a = JSON.parse("{\"name\":\"a\"}", IDEvent) + b = JSON.parse("{\"name\":\"b\"}", IDEvent) + @test isempty(a.tiers) && a.tiers !== b.tiers + @test a.venue === nothing && a.cap === nothing && a.kind === :none + + # unknown_fields=:error preserved through the interpreter route + @test_throws ArgumentError JSON.parse("{\"name\":\"x\",\"nope\":1}", IDTier; unknown_fields=:error) + + # :hot types keep the lazy descent, identical results + @test StructUtils.ishot(IDHotTier) + h = JSON.parse("{\"name\":\"h\",\"amount\":3}", IDHotTier) + @test h.name == "h" && h.amount == 3 && h.currency == "usd" + + # custom lift leaf (CUSTOM kind, dynamic arm) + c = JSON.parse("{\"p\": 0.25}", IDCustom) + @test c.p.v == 0.25 + + # custom dicttype and null route through the classic path with their + # classic semantics (note: on the classic path, Any-typed *fields* + # materialize as JSON.Object via the lift fallback regardless of + # dicttype — same as master) + d = JSON.parse(IDJSON, IDEvent; dicttype=Dict{String,Any}) + @test d.note isa JSON.Object{String,Any} && d.note["k"] == "v" + m = JSON.parse("{\"name\":\"x\",\"score\":null}", IDEvent; null=missing) + @test m.score === missing + + # non-object roots keep the classic path + @test JSON.parse("[{\"name\":\"t\"}]", Vector{IDTier})[1].name == "t" + + # sample synthesis produces parseable JSON for eligible types + s = JSON._synthesize_sample(IDEvent) + @test s isa String + sev = JSON.parse(s, IDEvent) + @test sev isa IDEvent && sev.venue isa IDTier && length(sev.tiers) == 1 + + # the hot hook is registered with StructUtils + @test any(h -> h === JSON._hot_json_hook, StructUtils.HOT_HOOKS) + # and runs cleanly under force for both annotated and plain types + StructUtils._hot_precompile!(IDHotTier; force=true) + StructUtils._hot_precompile!(IDEvent, ("{\"name\":\"s\"}",); force=true) + @test true +end diff --git a/test/runtests.jl b/test/runtests.jl index ca1498a..3793fee 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -3,6 +3,7 @@ using JSON, Test, Tar include(joinpath(dirname(pathof(JSON)), "../test/object.jl")) include(joinpath(dirname(pathof(JSON)), "../test/lazy.jl")) include(joinpath(dirname(pathof(JSON)), "../test/parse.jl")) +include(joinpath(dirname(pathof(JSON)), "../test/interp_default.jl")) include(joinpath(dirname(pathof(JSON)), "../test/json.jl")) # Arrow.jl is broken on 32 bit systems for now :( if Sys.WORD_SIZE == 64 From ad7bd21a537d86977dd116a5ea1f48f6b2473cb8 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Wed, 15 Jul 2026 08:14:35 -0600 Subject: [PATCH 02/13] perf: fuse the tier-0 route into a single lazy pass + de-specialize entry glue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tree route (untyped materialize, then interpret) becomes one pass: applyobject drives the field-table interpreter's slots directly, with PtrString field matching (no allocation), per-kind scalar materialization through the interpreter's leaf ladder, unknown keys skipped without materializing, and ANY/CUSTOM subtrees materialized via the existing engine (CUSTOM keeps the 4-arg make with the field's tags). The engine is parameterized by style only and sits behind an @noinline boundary — inlined into the per-type entry, the JIT re-infers the whole engine per target type. The route now requires the default read style exactly; custom styles keep classic semantics wholesale. Entry glue is de-specialized (@nospecialize on invalid/checkendpos/ jsonreadstyle/unknownfielderror): interpolating the target type into error strings drags type-show machinery into every type's inference. This is the same defect class the commit-level bisect identified upstream — 53df2b3 'feat(parse): add unknown_fields keyword (#451)' regressed marginal per-family TTFX 0.70s -> 6.3s on 1.5.1 via its per-type error/validation helpers, and 8154d24 'fix(parse): forward type predicates to inner style (#454)' regressed steady state ~31us -> ~66us on 1.6.0; both fixes here apply upstream independently of the tier work. Measured vs master (request-shaped family): marginal first parse per new struct family 3.7s -> 0.15s (24x), steady 51us -> 5.9us (8.7x). Co-Authored-By: Claude Fable 5 --- src/JSON.jl | 2 +- src/parse.jl | 190 ++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 174 insertions(+), 18 deletions(-) diff --git a/src/JSON.jl b/src/JSON.jl index 7d2d6ae..2860050 100644 --- a/src/JSON.jl +++ b/src/JSON.jl @@ -25,7 +25,7 @@ end @enum Error InvalidJSON UnexpectedEOF ExpectedOpeningObjectChar ExpectedOpeningQuoteChar ExpectedOpeningArrayChar ExpectedClosingArrayChar ExpectedComma ExpectedColon ExpectedNewline InvalidChar InvalidNumber InvalidUTF16 -@noinline function invalid(error, buf, pos::Int, T) +@noinline function invalid(error, buf, pos::Int, @nospecialize(T)) # compute which line the error falls on by counting “\n” bytes up to pos cus = buf isa AbstractString ? codeunits(buf) : buf line_no = count(b -> b == UInt8('\n'), view(cus, 1:pos)) + 1 diff --git a/src/parse.jl b/src/parse.jl index bd28e82..e209253 100644 --- a/src/parse.jl +++ b/src/parse.jl @@ -184,7 +184,7 @@ StructUtils.structlike(st::JSONReadStyle{O,N,S}, ::Type{T}) where {O,N,S<:JSONSt StructUtils.structlike(st::JSONReadStyle{O,N,S}, ::Type{T}) where {O,N,S<:JSONStyle,T<:NamedTuple} = StructUtils.structlike(st.style, T) -function jsonreadstyle(::Type{T}, ::Type{O}, null, style::StructStyle, unknown_fields::Symbol) where {T,O} +function jsonreadstyle(@nospecialize(T), ::Type{O}, null, style::StructStyle, unknown_fields::Symbol) where {O} ignore_unknown_fields = unknown_fields === :ignore ? true : unknown_fields === :error ? false : @@ -195,7 +195,7 @@ function jsonreadstyle(::Type{T}, ::Type{O}, null, style::StructStyle, unknown_f return JSONReadStyle{O}(null, style, ignore_unknown_fields) end -@noinline unknownfielderror(::Type{T}, key) where {T} = +@noinline unknownfielderror(@nospecialize(T), @nospecialize(key)) = ArgumentError("encountered unknown JSON member $(repr(key)) while parsing `$T`") function StructUtils.unknownfield(st::JSONReadStyle, ::Type{T}, key, value) where {T} @@ -247,23 +247,21 @@ function _interproute(style::StructStyle, @nospecialize(T))::Bool end end +const _DEFAULT_READSTYLE = JSONReadStyle{DEFAULT_OBJECT_TYPE,Nothing,StructUtils.DefaultStyle} + function _parse(x::LazyValue, ::Type{T}, dicttype::Type{O}, null, style::StructStyle) where {T,O} - if !StructUtils.TRIM_BUILD && T !== Any && O === DEFAULT_OBJECT_TYPE && null === nothing && + if !StructUtils.TRIM_BUILD && T !== Any && style isa _DEFAULT_READSTYLE && !StructUtils.ishot(T) && gettype(x) == JSONTypes.OBJECT && _interproute(style, T) - # tier-0 default: materialize through the untyped engine (compiled - # once, covered by the package workload), then construct through the - # StructUtils interpreter — faster than the specialized lazy descent - # at request-payload sizes, and the per-type compile cost drops to a - # field-table build. `:hot`-annotated types keep the lazy descent; - # custom dicttype/null and non-object roots keep classic semantics. - # The interpreter entry is called directly: routing through the - # `make` dispatcher would leave the (never-taken) specialized-descent - # arm reachable, and the JIT compiles it per type — the exact - # first-call cost this route exists to eliminate. - out = ValueClosure() - pos = applyvalue(out, x, nothing) + # tier-0 default: a single lazy pass drives the field-table + # interpreter's slots directly — scalar leaves materialize per their + # kind tag, unknown keys are skipped without materializing, and the + # per-type compile cost is a field-table build. `:hot` types keep the + # specialized lazy descent; custom styles/dicttype/null and + # non-object roots keep classic semantics. Called directly (not via + # the `make` dispatcher) so the never-taken specialized-descent arm + # isn't compiled per type. + y, pos = _fused_make(style, T, x) getisroot(x) && checkendpos(x, T, pos) - y, _ = StructUtils._interp_make(style, T, out.value::Object{String,Any}) return y::T end if StructUtils.TRIM_BUILD @@ -286,6 +284,164 @@ function _parse_classic(x::LazyValue, ::Type{T}, style::StructStyle) where {T} return y end +# ---------------- fused tier-0 lazy interpretation ---------------- +# JIT-only (the route is gated off under trim builds): drives the +# StructUtils field-table interpreter's slots directly from applyobject — +# one pass, no intermediate tree. Closures are parameterized by style only, +# never by the target type, so the whole engine compiles once and lives in +# this package's image via the workload. + +struct FusedObjClosure{S<:StructStyle} + style::S + tbl::StructUtils.FieldTable + slots::Vector{Any} +end + +function (f::FusedObjClosure{S})(k::PtrString, v::LazyValue) where {S} + specs = f.tbl.specs + i = 0 + for j = 1:length(specs) + if k == @inbounds(specs[j]).name # PtrString == String: no allocation + i = j + break + end + end + if i == 0 + # unknown key: honor the style hook (unknown_fields=:error throws); + # returning non-Int makes applyobject skip the value unmaterialized + StructUtils.unknownfield(f.style, f.tbl.T, k, v) + return nothing + end + sp = @inbounds specs[i] + if gettype(v) == JSONTypes.NULL + kind = sp.kind + if sp.nullable + f.slots[i] = nothing + elseif sp.missingable + f.slots[i] = missing + elseif kind == StructUtils.KIND_ANY + f.slots[i] = nothing + elseif kind == StructUtils.KIND_CUSTOM + val, _ = StructUtils.make(f.style, sp.ft::Type, nothing, sp.tags) + f.slots[i] = val + else + x, _ = StructUtils.lift(f.style, sp.ft::Type, nothing) + f.slots[i] = x + end + return getpos(v) + 4 + end + val, pos = _fused_value(f.style, sp, v) + f.slots[i] = val + return pos +end + +struct FusedArrClosure{S<:StructStyle} + style::S + elkind::Int8 + elft::Any + arr::Any + name::String +end + +function (f::FusedArrClosure{S})(_, v::LazyValue) where {S} + elk = f.elkind + if gettype(v) == JSONTypes.NULL + # a null element: same semantics the interpreter's element lift has + x, _ = StructUtils.lift(f.style, f.elft::Type, nothing) + push!(f.arr::Vector, x) + return getpos(v) + 4 + end + local val, pos + if elk == StructUtils.KIND_STRUCT && gettype(v) == JSONTypes.OBJECT + val, pos = _fused_make(f.style, f.elft, v) + elseif elk == StructUtils.KIND_ANY + out = ValueClosure() + pos = applyvalue(out, v, nothing) + val = out.value + else + val, pos = _fused_scalar(f.style, elk, f.elft, v, f.name) + end + push!(f.arr::Vector, val) + return pos +end + +function _fused_value(style::StructStyle, sp::StructUtils.FieldSpec, v::LazyValue) + kind = sp.kind + if kind == StructUtils.KIND_STRUCT && gettype(v) == JSONTypes.OBJECT + return _fused_make(style, sp.ft, v) + elseif kind == StructUtils.KIND_VECTOR && gettype(v) == JSONTypes.ARRAY + arr = StructUtils._alloc_vector(sp.elft, 0) + f = FusedArrClosure{typeof(style)}(style, sp.elkind, sp.elft, arr, sp.name) + pos = applyarray(f, v) + pos isa Int || (pos = skip(v)) + return arr, pos + elseif kind == StructUtils.KIND_ANY + out = ValueClosure() + pos = applyvalue(out, v, nothing) + return out.value, pos + elseif kind == StructUtils.KIND_CUSTOM + # materialize, then the 4-arg make with the field's tags — identical + # to the interpreter's CUSTOM arm (lift/choosetype/dateformat) + out = ValueClosure() + pos = applyvalue(out, v, nothing) + val, _ = StructUtils.make(style, sp.ft::Type, out.value, sp.tags) + return val, pos + else + return _fused_scalar(style, kind, sp.ft, v, sp.name) + end +end + +# scalar leaves: parse the base JSON scalar lazily, then produce the +# exact-typed value through the interpreter's kind ladder (ISO dates, int +# widths, symbols, chars, and the JIT lift fallback for odd pairings) +function _fused_scalar(style::StructStyle, kind::Int8, @nospecialize(ft), v::LazyValue, name::String) + t = gettype(v) + if t == JSONTypes.STRING + buf = getbuf(v) + local s, pos + GC.@preserve buf begin + str, pos = parsestring(v) + s = convert(String, str) + end + return StructUtils._liftleaf(style, kind, ft, s, name), pos + elseif t == JSONTypes.NUMBER + num, pos = parsenumber(v) + raw = isint(num) ? num.int : + isfloat(num) ? num.float : + isbigint(num) ? num.bigint : num.bigfloat + return StructUtils._liftleaf(style, kind, ft, raw, name), pos + elseif t == JSONTypes.TRUE + return StructUtils._liftleaf(style, kind, ft, true, name), getpos(v) + 4 + elseif t == JSONTypes.FALSE + return StructUtils._liftleaf(style, kind, ft, false, name), getpos(v) + 5 + else + # aggregate into a scalar-kind field: materialize and let the + # interpreter's leaf ladder (and its lift fallback) decide + out = ValueClosure() + pos = applyvalue(out, v, nothing) + return StructUtils._liftleaf(style, kind, ft, out.value, name), pos + end +end + +# @noinline: this is the boundary between per-type entry glue and the +# compile-once engine — inlined, the JIT re-infers the engine per target type +@noinline function _fused_make(style::StructStyle, @nospecialize(T), v::LazyValue) + tbl = StructUtils.fieldtable(T, style) + if !tbl.eligible + # nested type the interpreter can't build: materialize the subtree + # and let the generic machinery decide (classic semantics) + out = ValueClosure() + pos = applyvalue(out, v, nothing) + val, _ = StructUtils.make(style, T::Type, out.value) + return val, pos + end + slots = Vector{Any}(undef, length(tbl.specs)) + f = FusedObjClosure{typeof(style)}(style, tbl, slots) + pos = applyobject(f, v) + pos isa Int || (pos = skip(v)) + return StructUtils._construct_interp(style, tbl, slots), pos +end + # ---------------- :hot precompile hook + sample synthesis ---------------- # registered with StructUtils from __init__: called for each :hot-annotated @@ -395,7 +551,7 @@ parse!(x::LazyValue, obj::T; # for LazyValue, if x started at the beginning of the JSON input, # then we want to ensure that the entire input was consumed # and error if there are any trailing invalid JSON characters -function checkendpos(x::LazyValue, ::Type{T}, pos::Int) where {T} +function checkendpos(x::LazyValue, @nospecialize(T), pos::Int) buf = getbuf(x) len = getlength(buf) if pos <= len From c42938abd31555c44851b4aaa5da6ae850c00b73 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Tue, 14 Jul 2026 12:32:31 -0600 Subject: [PATCH 03/13] fix: hand-formatted ISO lowers for Date/DateTime/Time string(::TimeType) routes through Dates' DateFormat machinery (dynamic lpad/repeat) under juliac --trim; the fixed-width renderings are equal to the string() forms. Co-Authored-By: Claude Fable 5 --- src/write.jl | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/write.jl b/src/write.jl index 5262175..374f938 100644 --- a/src/write.jl +++ b/src/write.jl @@ -34,6 +34,31 @@ const StringLike = Union{Enum, AbstractChar, VersionNumber, Cstring, Cwstring, U StructUtils.lower(::JSONStyle, ::Missing) = nothing StructUtils.lower(::JSONStyle, x::Symbol) = String(x) StructUtils.lower(::JSONStyle, x::StringLike) = string(x) +# trim-friendly ISO renderings: string(::TimeType) routes through Dates' +# DateFormat machinery (dynamic lpad/repeat) under juliac --trim +StructUtils.lower(::JSONStyle, x::Dates.Date) = _iso_date_string(x) +StructUtils.lower(::JSONStyle, x::Dates.DateTime) = _iso_datetime_string(x) +StructUtils.lower(::JSONStyle, x::Dates.Time) = _iso_time_string(x) + +_two_dig(x::Int)::String = x < 10 ? string('0', x) : string(x) +_three_dig(x::Int)::String = x < 10 ? string("00", x) : x < 100 ? string('0', x) : string(x) +_four_dig(x::Int)::String = x < 10 ? string("000", x) : x < 100 ? string("00", x) : x < 1000 ? string('0', x) : string(x) + +_iso_date_string(d::Dates.Date)::String = + string(_four_dig(Dates.year(d)), '-', _two_dig(Dates.month(d)), '-', _two_dig(Dates.day(d))) + +function _iso_datetime_string(dt::Dates.DateTime)::String + ms = Dates.millisecond(dt) + base = string(_iso_date_string(Dates.Date(dt)), 'T', + _two_dig(Dates.hour(dt)), ':', _two_dig(Dates.minute(dt)), ':', _two_dig(Dates.second(dt))) + return ms == 0 ? base : string(base, '.', _three_dig(ms)) +end + +function _iso_time_string(t::Dates.Time)::String + ms = Dates.millisecond(t) + base = string(_two_dig(Dates.hour(t)), ':', _two_dig(Dates.minute(t)), ':', _two_dig(Dates.second(t))) + return ms == 0 ? base : string(base, '.', _three_dig(ms)) +end StructUtils.lower(::JSONStyle, x::Regex) = x.pattern StructUtils.lower(::JSONStyle, x::Complex) = (re=real(x), im=imag(x)) StructUtils.lower(::JSONStyle, x::AbstractArray{<:Any,0}) = x[1] From 1c079f671e08e82b8927873aa2f2672ee5f77238 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Wed, 15 Jul 2026 08:34:46 -0600 Subject: [PATCH 04/13] =?UTF-8?q?test(trim):=20JuliaC=20harness=20?= =?UTF-8?q?=E2=80=94=20hot-from-LazyValue=20workload=20at=20error=20budget?= =?UTF-8?q?=20zero?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the StructUtils JuliaC harness: the temp env devs this checkout plus the resolved StructUtils (dev checkouts are dev'd; registry copies added), pins Parsers#trim-verifier-fixes until #207 releases, exports JULIAC_DISABLE_PRECOMPILE_WORKLOADS to juliac sessions, and sets the trim_build preference (pruning the tier-0 tree route and its invokelatest boundary out of the typed-parse entry). The workload exercises the :hot specialized lazy descent with heterogeneous scalars — dates, UUID, Symbol, unions, nested structs, vectors — plus untyped parse with isa-narrowing, defaults-only parses, and the required-field error path; runtime asserts run in the produced binary. Verifier-driven fixes along the way: error builders use isa-laddered string extraction with per-branch typeasserts (no repr/show of Symbols or types — also removes the per-target-type show inference the 1.5.1 regression introduced); _typenamestr unwraps UnionAlls by hand (Base's nameof(::UnionAll) recurses through an Any-typed body); the #472 hand-formatted ISO date lowers are cherry-picked (Dates.format's padding is not verifier-resolvable). JSON.json of structs remains follow-up work (the writer's BigFloat arm and array-show typeinfo are pre-existing master reachability), so the workload pins the read path. Co-Authored-By: Claude Fable 5 --- src/JSON.jl | 7 +- src/parse.jl | 28 ++++- test/hot_lazy_trim_safe.jl | 89 ++++++++++++++++ test/runtests.jl | 2 + test/trim_compile_tests.jl | 202 +++++++++++++++++++++++++++++++++++++ 5 files changed, 324 insertions(+), 4 deletions(-) create mode 100644 test/hot_lazy_trim_safe.jl create mode 100644 test/trim_compile_tests.jl diff --git a/src/JSON.jl b/src/JSON.jl index 2860050..1756ac0 100644 --- a/src/JSON.jl +++ b/src/JSON.jl @@ -48,8 +48,13 @@ end snippet = replace(snippet, r"[\b\f\n\r\t]" => " ") # we call @invoke here to avoid --trim verify errors caret = @invoke(repeat(" "::String, (erri + 2)::Integer)) * "^" + # the type renders via an isa ladder with per-branch typeasserts: + # interpolating the type object infers show machinery per target type (a + # large per-type TTFX tax) and is dynamic under --trim now that this + # helper is @nospecialize + tname = _typenamestr(T) msg = """ - invalid JSON at byte position $(pos) (line $line_no) parsing type $T: $error + invalid JSON at byte position $(pos) (line $line_no) parsing type $(tname): $error $snippet$(error == UnexpectedEOF ? " " : "...") $caret """ diff --git a/src/parse.jl b/src/parse.jl index e209253..c8b7d36 100644 --- a/src/parse.jl +++ b/src/parse.jl @@ -188,15 +188,37 @@ function jsonreadstyle(@nospecialize(T), ::Type{O}, null, style::StructStyle, un ignore_unknown_fields = unknown_fields === :ignore ? true : unknown_fields === :error ? false : - throw(ArgumentError("`unknown_fields` must be `:ignore` or `:error`, got `$(repr(unknown_fields))`")) + throw(ArgumentError(string("`unknown_fields` must be `:ignore` or `:error`, got `:", String(unknown_fields), "`"))) if T === Any && !ignore_unknown_fields throw(ArgumentError("`unknown_fields` is only supported when parsing into a target type or existing object")) end return JSONReadStyle{O}(null, style, ignore_unknown_fields) end -@noinline unknownfielderror(@nospecialize(T), @nospecialize(key)) = - ArgumentError("encountered unknown JSON member $(repr(key)) while parsing `$T`") +# isa-laddered string extraction: no per-target-type inference of show +# machinery (the 1.5.1 TTFX regression class), and no dynamic repr-of-Any +# for the trim verifier to reject +@noinline function unknownfielderror(@nospecialize(T), @nospecialize(key)) + # per-branch typeasserts: narrowing on @nospecialize arguments doesn't + # stick, and the verifier needs each call fully concrete + keystr = key isa String ? (key::String) : + key isa PtrString ? convert(String, key::PtrString) : + key isa Symbol ? String(key::Symbol) : + key isa Int ? string(key::Int) : "" + return ArgumentError(string("encountered unknown JSON member \"", keystr::String, + "\" while parsing `", _typenamestr(T), "`")) +end + +function _typenamestr(@nospecialize(T)) + # unwrap UnionAlls by hand: Base's nameof(::UnionAll) recurses through an + # Any-typed body, which the trim verifier can't resolve + body = T + while body isa UnionAll + body = getfield(body::UnionAll, :body) + end + body isa DataType && return String(nameof(body::DataType)::Symbol) + return "" +end function StructUtils.unknownfield(st::JSONReadStyle, ::Type{T}, key, value) where {T} st.ignore_unknown_fields || throw(unknownfielderror(T, key)) diff --git a/test/hot_lazy_trim_safe.jl b/test/hot_lazy_trim_safe.jl new file mode 100644 index 0000000..5a91fa7 --- /dev/null +++ b/test/hot_lazy_trim_safe.jl @@ -0,0 +1,89 @@ +# :hot typed parsing under `juliac --trim=safe`: the specialized lazy +# descent for annotated types, driven from real JSON text. Unlike +# tree-shaped sources (where the hot findfield's field x value-type +# cross-product limits scalar variety), the lazy source is uniform — every +# field branch sees a LazyValue — so heterogeneous scalars (dates, UUIDs, +# symbols) are exercised here. Compiled in an env with the StructUtils +# `trim_build` preference, which prunes the tier-0 tree route and its +# invokelatest boundary out of the typed-parse entry. +using JSON, StructUtils, Dates, UUIDs + +@kwarg :hot struct LTier + name::String + amount::Int = 0 + currency::String = "usd" +end + +@kwarg :hot struct LEvent + name::String + day::Date = Date(0) + at::Union{DateTime,Nothing} = nothing + uid::Union{UUID,Nothing} = nothing + cap::Union{Int,Nothing} = nothing + kind::Symbol = :none + venue::Union{LTier,Nothing} = nothing + tiers::Vector{LTier} = LTier[] + score::Union{Float64,Missing} = missing +end + +const SAMPLE = """ +{"name":"Kickoff","day":"2026-08-01","at":"2026-07-25T23:59:59", + "uid":"c8b1cf79-de6a-54ab-a142-682c06a0de6a","cap":64,"kind":"league", + "venue":{"name":"Gym","amount":1}, + "tiers":[{"name":"Early","amount":2500,"currency":"eur"},{"name":"Late"}], + "score":null,"unknown_extra":[1,2,3]} +""" + +function run_hot_lazy_trim_sample() + evx = JSON.parse(SAMPLE, LEvent) + evx isa LEvent || error("type") + e = evx::LEvent + e.name == "Kickoff" || error("name") + e.day == Date(2026, 8, 1) || error("day") + e.at == DateTime(2026, 7, 25, 23, 59, 59) || error("at") + e.uid == UUID("c8b1cf79-de6a-54ab-a142-682c06a0de6a") || error("uid") + e.cap == 64 || error("cap") + e.kind === :league || error("kind") + v = e.venue + v isa LTier || error("venue") + (v::LTier).amount == 1 || error("venue amount") + length(e.tiers) == 2 || error("tiers") + e.tiers[1].currency == "eur" || error("cur1") + e.tiers[2].currency == "usd" || error("cur2") + e.score === missing || error("score") + # defaults-only parse + m = JSON.parse("{\"name\":\"m\"}", LEvent) + (m::LEvent).cap === nothing || error("m cap") + isempty((m::LEvent).tiers) || error("m tiers") + # untyped parse + isa-narrow (the #472 pattern) + u = JSON.parse(SAMPLE) + if u isa JSON.Object{String,Any} + c = u["cap"] + c isa Int64 || error("untyped cap") + c == 64 || error("untyped cap value") + else + error("untyped root") + end + # TODO(write-side trim): JSON.json of a struct is not yet verifier-clean + # on master — the writer's BigFloat arm reaches string(::BigFloat) MPFR + # internals and an array-show typeinfo invoke_in_world. Write tiering is + # follow-up work; this workload pins the read path. + # required-field error path + threw = false + try + JSON.parse("{\"amount\":1}", LTier) + catch + threw = true + end + threw || error("required") + return nothing +end + +function @main(args::Vector{String})::Cint + _ = args + run_hot_lazy_trim_sample() + Core.println("HOT_LAZY_TRIM_OK") + return 0 +end + +Base.Experimental.entrypoint(main, (Vector{String},)) diff --git a/test/runtests.jl b/test/runtests.jl index 3793fee..a62d259 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -106,3 +106,5 @@ end ] @test JSON.json(x, 2) isa String end + +include("trim_compile_tests.jl") diff --git a/test/trim_compile_tests.jl b/test/trim_compile_tests.jl new file mode 100644 index 0000000..2762d2f --- /dev/null +++ b/test/trim_compile_tests.jl @@ -0,0 +1,202 @@ +using StructUtils +using Test + +const _TRIM_SAFE_ERROR_BUDGET = 0 # JSON typed/untyped parse + write, error budget zero +const _TRIM_SUPPORTED = VERSION >= v"1.12.0-rc1" +const _TRIM_PRE_RELEASE = !isempty(VERSION.prerelease) +const _JULIAC_ENTRYPOINT_EXPR = "using JuliaC; if isdefined(JuliaC, :main); JuliaC.main(ARGS); else JuliaC._main_cli(ARGS); end" + +# Pkg.test() sets JULIA_LOAD_PATH restrictively, which prevents subprocesses +# from finding stdlib packages like Pkg. Remove it so subprocesses get the +# default load path. +function _clean_cmd(cmd::Cmd) + env = Dict{String,String}(k => v for (k, v) in ENV if k != "JULIA_LOAD_PATH") + # juliac sessions must not execute precompile workloads: they would bake + # JIT-only instances into the image for the verifier to reject + env["JULIAC_DISABLE_PRECOMPILE_WORKLOADS"] = "1" + return setenv(cmd, env) +end + +function _trim_use_bundle()::Bool + default = Sys.iswindows() ? "1" : "0" + return get(ENV, "JSON_TRIM_BUNDLE", default) == "1" +end + +function _setup_trim_env() + # JuliaC requires Julia 1.12+ and can't be in [extras] without breaking + # Pkg.test() on older Julia versions. Create a temp project that dev's + # StructUtils from the local checkout and adds JuliaC. + json_path = normpath(joinpath(@__DIR__, "..")) + # use whatever StructUtils this test session resolved: a dev checkout is + # dev'd into the temp env; a registry copy is added normally + su_path = normpath(joinpath(dirname(pathof(StructUtils)), "..")) + su_setup = startswith(su_path, joinpath(homedir(), ".julia", "packages")) ? + "Pkg.add(\"StructUtils\")" : "Pkg.develop(path=$(repr(su_path)))" + env_path = mktempdir() + julia = joinpath(Sys.BINDIR, Base.julia_exename()) + setup_script = joinpath(env_path, "setup.jl") + # TODO: drop once Parsers.jl#207 (trim fixes for the float overflow-widening + # ladder) is released — JSON's number parsing pulls Parsers' float path into + # every workload graph + write(setup_script, """ + import Pkg + $(su_setup) + Pkg.develop(path=$(repr(json_path))) + Pkg.add(url="https://github.com/JuliaData/Parsers.jl", rev="trim-verifier-fixes") + Pkg.add("JuliaC") + """) + println("[trim] setting up temp environment with JuliaC...") + flush(stdout) + exit_code, output, timed_out = _run_command_with_timeout( + _clean_cmd(`$julia --startup-file=no --history-file=no --project=$env_path $setup_script`); + timeout_s = 120.0, log_label = "setup" + ) + rm(setup_script; force = true) + if exit_code != 0 || timed_out + println("[trim] setup FAILED (exit=$exit_code, timed_out=$timed_out)") + println(output) + error("failed to set up trim test environment") + end + println("[trim] temp environment ready") + return env_path +end + +function _run_trim_compile(project_path::String, script_path::String, output_name::String; timeout_s::Float64 = 120.0, bundle_dir::Union{Nothing, String} = nothing) + julia_exe = joinpath(Sys.BINDIR, Base.julia_exename()) + cmd = if bundle_dir === nothing + _clean_cmd(`$julia_exe --startup-file=no --history-file=no --code-coverage=none --project=$project_path -e $(_JULIAC_ENTRYPOINT_EXPR) -- --output-exe $output_name --project=$project_path --experimental --trim=safe $script_path`) + else + _clean_cmd(`$julia_exe --startup-file=no --history-file=no --code-coverage=none --project=$project_path -e $(_JULIAC_ENTRYPOINT_EXPR) -- --output-exe $output_name --bundle $bundle_dir --project=$project_path --experimental --trim=safe $script_path`) + end + return _run_command_with_timeout(cmd; timeout_s = timeout_s, log_label = "compile") +end + +function _run_command_with_timeout(cmd::Cmd; timeout_s::Float64, log_label::String) + output_path = tempname() + out = open(output_path, "w") + exit_code = -1 + timed_out = false + try + proc = run(pipeline(ignorestatus(cmd), stdout = out, stderr = out); wait = false) + timed_out = _wait_process_with_timeout!(proc; timeout_s = timeout_s, log_label = log_label) + exit_code = something(proc.exitcode, -1) + finally + close(out) + end + output = try + read(output_path, String) + catch + "" + finally + rm(output_path; force = true) + end + return exit_code, output, timed_out +end + +function _wait_process_with_timeout!(proc::Base.Process; timeout_s::Float64, log_label::String) + started_at = time() + next_log_at = started_at + 10.0 + timed_out = false + while Base.process_running(proc) + now = time() + if now - started_at >= timeout_s + timed_out = true + try; kill(proc); catch; end + break + end + if now >= next_log_at + elapsed = round(now - started_at; digits = 1) + println("[trim] $(log_label) WAIT $(elapsed)s") + flush(stdout) + next_log_at = now + 10.0 + end + sleep(0.1) + end + try; wait(proc); catch; end + return timed_out +end + +function _parse_trim_verify_totals(output::String) + m = match(r"Trim verify finished with\s+(\d+)\s+errors,\s+(\d+)\s+warnings\.", output) + m === nothing && return nothing + return parse(Int, m.captures[1]), parse(Int, m.captures[2]) +end + +function _trim_executable_timeout_s()::Float64 + default = Sys.iswindows() ? "120.0" : "30.0" + return parse(Float64, get(ENV, "JSON_TRIM_EXE_TIMEOUT_S", default)) +end + +function _run_trim_case(project_path::String, script_file::String, output_name::String) + script_path = joinpath(@__DIR__, script_file) + @test isfile(script_path) + println("[trim] compile START $(script_file)") + start_t = time() + mktempdir() do tmpdir + cd(tmpdir) do + bundle_dir = _trim_use_bundle() ? joinpath(tmpdir, "bundle") : nothing + exit_code, output, timed_out = _run_trim_compile(project_path, script_path, output_name; bundle_dir = bundle_dir) + if timed_out + println("[trim] compile TIMED OUT for $(script_file)") + println(output) + @test false + return + end + totals = _parse_trim_verify_totals(output) + trim_errors, trim_warnings = if totals === nothing + exit_code == 0 ? (0, 0) : error("failed to parse trim verifier summary:\n$output") + else + totals + end + if get(ENV, "JSON_TRIM_PRINT_OUTPUT", "0") == "1" || trim_errors > 0 + println("---- trim compile output ($(script_file)) ----") + println(output) + println("---- end output ----") + end + @test trim_errors <= _TRIM_SAFE_ERROR_BUDGET + @test trim_warnings >= 0 + output_path = Sys.iswindows() ? "$(output_name).exe" : output_name + if trim_errors == 0 + run_path = bundle_dir === nothing ? output_path : joinpath(bundle_dir, "bin", output_path) + @test exit_code == 0 + @test isfile(run_path) + run_timeout_s = _trim_executable_timeout_s() + run_cmd = `$(abspath(run_path))` + run_exit, run_output, run_timed_out = _run_command_with_timeout(run_cmd; timeout_s = run_timeout_s, log_label = "run") + if run_timed_out + println("[trim] executable TIMED OUT for $(script_file)") + println(run_output) + end + if run_exit != 0 + println("---- trim executable output ($(script_file)) ----") + println(run_output) + println("---- end output ----") + end + @test !run_timed_out + @test run_exit == 0 + else + @test exit_code != 0 + end + end + end + println("[trim] compile DONE $(script_file) ($(round(time() - start_t; digits = 2))s)") + return nothing +end + +@testset "Trim compile" begin + if !_TRIM_SUPPORTED + println("[trim] skip Julia < 1.12: JuliaC trim compilation is unavailable") + @test true + elseif _TRIM_PRE_RELEASE + println("[trim] skip prerelease Julia: trim verifier behavior is not stable yet") + @test true + else + project_path = _setup_trim_env() + # trim builds set the trim_build preference: it prunes the tier-0 + # tree route (and its invokelatest classic boundary) out of the + # typed-parse entry, leaving the :hot lazy descent's static graph + write(joinpath(project_path, "LocalPreferences.toml"), + "[StructUtils]\ntrim_build = true\n") + _run_trim_case(project_path, "hot_lazy_trim_safe.jl", "hot_lazy_trim_safe") + end +end From 0f3c44fa34775d0990e8303f2aec4543aa91cc83 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Wed, 15 Jul 2026 11:02:11 -0600 Subject: [PATCH 05/13] refactor: extract tier-0 machinery into src/tier0.jl parse.jl returns to entries + classic + untyped engine; the fused lazy interpreter drive, the route memo, the :hot precompile hook, and the sample synthesizer move to tier0.jl with a file-level architecture note. Comment refresh on the invokelatest boundary. No behavior change (suite and trim harness green before and after). Co-Authored-By: Claude Fable 5 --- src/JSON.jl | 1 + src/parse.jl | 268 ++------------------------------------------------ src/tier0.jl | 272 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 279 insertions(+), 262 deletions(-) create mode 100644 src/tier0.jl diff --git a/src/JSON.jl b/src/JSON.jl index 1756ac0..a048d60 100644 --- a/src/JSON.jl +++ b/src/JSON.jl @@ -92,6 +92,7 @@ end include("lazy.jl") include("parse.jl") +include("tier0.jl") include("write.jl") """ diff --git a/src/parse.jl b/src/parse.jl index c8b7d36..ec21303 100644 --- a/src/parse.jl +++ b/src/parse.jl @@ -254,22 +254,6 @@ parse(x::LazyValue, ::Type{T}=Any; unknown_fields::Symbol=:ignore) where {T,O} = @inline _parse(x, T, dicttype, null, jsonreadstyle(T, O, null, style, unknown_fields)) -# memoized routing verdict per (target type, style type): the eligibility + -# tree-safety walk is table recursion we don't want on every parse -const _INTERP_ROUTE = Dict{Tuple{DataType,DataType},Bool}() -const _INTERP_ROUTE_LOCK = ReentrantLock() - -function _interproute(style::StructStyle, @nospecialize(T))::Bool - T isa DataType || return false - key = (T, typeof(style)) - lock(_INTERP_ROUTE_LOCK) do - get!(_INTERP_ROUTE, key) do - StructUtils.interpready(style, T) && StructUtils.interptreesafe(style, T) - end - end -end - -const _DEFAULT_READSTYLE = JSONReadStyle{DEFAULT_OBJECT_TYPE,Nothing,StructUtils.DefaultStyle} function _parse(x::LazyValue, ::Type{T}, dicttype::Type{O}, null, style::StructStyle) where {T,O} if !StructUtils.TRIM_BUILD && T !== Any && style isa _DEFAULT_READSTYLE && @@ -293,9 +277,12 @@ function _parse(x::LazyValue, ::Type{T}, dicttype::Type{O}, null, style::StructS getisroot(x) && checkendpos(x, T, pos) return y else - # runtime-dispatch boundary: keeps the JIT from eagerly compiling the - # specialized lazy descent for types the gate routes to the - # interpreter — that per-type compile is what the tree route removes + # runtime-dispatch boundary: with the route decided by a runtime + # table lookup, a directly-reachable classic descent would be JIT- + # compiled per target type even when the fused tier-0 route is always + # taken — exactly the first-call cost tier-0 exists to remove. Types + # that genuinely route here (:hot, custom styles/kwargs, ineligible + # shapes) pay one dynamic dispatch per parse. return Base.invokelatest(_parse_classic, x, T, style) end end @@ -306,250 +293,7 @@ function _parse_classic(x::LazyValue, ::Type{T}, style::StructStyle) where {T} return y end -# ---------------- fused tier-0 lazy interpretation ---------------- -# JIT-only (the route is gated off under trim builds): drives the -# StructUtils field-table interpreter's slots directly from applyobject — -# one pass, no intermediate tree. Closures are parameterized by style only, -# never by the target type, so the whole engine compiles once and lives in -# this package's image via the workload. - -struct FusedObjClosure{S<:StructStyle} - style::S - tbl::StructUtils.FieldTable - slots::Vector{Any} -end - -function (f::FusedObjClosure{S})(k::PtrString, v::LazyValue) where {S} - specs = f.tbl.specs - i = 0 - for j = 1:length(specs) - if k == @inbounds(specs[j]).name # PtrString == String: no allocation - i = j - break - end - end - if i == 0 - # unknown key: honor the style hook (unknown_fields=:error throws); - # returning non-Int makes applyobject skip the value unmaterialized - StructUtils.unknownfield(f.style, f.tbl.T, k, v) - return nothing - end - sp = @inbounds specs[i] - if gettype(v) == JSONTypes.NULL - kind = sp.kind - if sp.nullable - f.slots[i] = nothing - elseif sp.missingable - f.slots[i] = missing - elseif kind == StructUtils.KIND_ANY - f.slots[i] = nothing - elseif kind == StructUtils.KIND_CUSTOM - val, _ = StructUtils.make(f.style, sp.ft::Type, nothing, sp.tags) - f.slots[i] = val - else - x, _ = StructUtils.lift(f.style, sp.ft::Type, nothing) - f.slots[i] = x - end - return getpos(v) + 4 - end - val, pos = _fused_value(f.style, sp, v) - f.slots[i] = val - return pos -end - -struct FusedArrClosure{S<:StructStyle} - style::S - elkind::Int8 - elft::Any - arr::Any - name::String -end - -function (f::FusedArrClosure{S})(_, v::LazyValue) where {S} - elk = f.elkind - if gettype(v) == JSONTypes.NULL - # a null element: same semantics the interpreter's element lift has - x, _ = StructUtils.lift(f.style, f.elft::Type, nothing) - push!(f.arr::Vector, x) - return getpos(v) + 4 - end - local val, pos - if elk == StructUtils.KIND_STRUCT && gettype(v) == JSONTypes.OBJECT - val, pos = _fused_make(f.style, f.elft, v) - elseif elk == StructUtils.KIND_ANY - out = ValueClosure() - pos = applyvalue(out, v, nothing) - val = out.value - else - val, pos = _fused_scalar(f.style, elk, f.elft, v, f.name) - end - push!(f.arr::Vector, val) - return pos -end - -function _fused_value(style::StructStyle, sp::StructUtils.FieldSpec, v::LazyValue) - kind = sp.kind - if kind == StructUtils.KIND_STRUCT && gettype(v) == JSONTypes.OBJECT - return _fused_make(style, sp.ft, v) - elseif kind == StructUtils.KIND_VECTOR && gettype(v) == JSONTypes.ARRAY - arr = StructUtils._alloc_vector(sp.elft, 0) - f = FusedArrClosure{typeof(style)}(style, sp.elkind, sp.elft, arr, sp.name) - pos = applyarray(f, v) - pos isa Int || (pos = skip(v)) - return arr, pos - elseif kind == StructUtils.KIND_ANY - out = ValueClosure() - pos = applyvalue(out, v, nothing) - return out.value, pos - elseif kind == StructUtils.KIND_CUSTOM - # materialize, then the 4-arg make with the field's tags — identical - # to the interpreter's CUSTOM arm (lift/choosetype/dateformat) - out = ValueClosure() - pos = applyvalue(out, v, nothing) - val, _ = StructUtils.make(style, sp.ft::Type, out.value, sp.tags) - return val, pos - else - return _fused_scalar(style, kind, sp.ft, v, sp.name) - end -end - -# scalar leaves: parse the base JSON scalar lazily, then produce the -# exact-typed value through the interpreter's kind ladder (ISO dates, int -# widths, symbols, chars, and the JIT lift fallback for odd pairings) -function _fused_scalar(style::StructStyle, kind::Int8, @nospecialize(ft), v::LazyValue, name::String) - t = gettype(v) - if t == JSONTypes.STRING - buf = getbuf(v) - local s, pos - GC.@preserve buf begin - str, pos = parsestring(v) - s = convert(String, str) - end - return StructUtils._liftleaf(style, kind, ft, s, name), pos - elseif t == JSONTypes.NUMBER - num, pos = parsenumber(v) - raw = isint(num) ? num.int : - isfloat(num) ? num.float : - isbigint(num) ? num.bigint : num.bigfloat - return StructUtils._liftleaf(style, kind, ft, raw, name), pos - elseif t == JSONTypes.TRUE - return StructUtils._liftleaf(style, kind, ft, true, name), getpos(v) + 4 - elseif t == JSONTypes.FALSE - return StructUtils._liftleaf(style, kind, ft, false, name), getpos(v) + 5 - else - # aggregate into a scalar-kind field: materialize and let the - # interpreter's leaf ladder (and its lift fallback) decide - out = ValueClosure() - pos = applyvalue(out, v, nothing) - return StructUtils._liftleaf(style, kind, ft, out.value, name), pos - end -end - -# @noinline: this is the boundary between per-type entry glue and the -# compile-once engine — inlined, the JIT re-infers the engine per target type -@noinline function _fused_make(style::StructStyle, @nospecialize(T), v::LazyValue) - tbl = StructUtils.fieldtable(T, style) - if !tbl.eligible - # nested type the interpreter can't build: materialize the subtree - # and let the generic machinery decide (classic semantics) - out = ValueClosure() - pos = applyvalue(out, v, nothing) - val, _ = StructUtils.make(style, T::Type, out.value) - return val, pos - end - slots = Vector{Any}(undef, length(tbl.specs)) - f = FusedObjClosure{typeof(style)}(style, tbl, slots) - pos = applyobject(f, v) - pos isa Int || (pos = skip(v)) - return StructUtils._construct_interp(style, tbl, slots), pos -end - -# ---------------- :hot precompile hook + sample synthesis ---------------- - -# registered with StructUtils from __init__: called for each :hot-annotated -# struct during the *defining package's* precompilation, inside a newly- -# inferred-tagging block — everything parsed here (the typed lazy descent, -# the write path) lands in that package's image -function _hot_json_hook(@nospecialize(T), samples::Tuple) - T isa Type || return nothing - for s in samples - s isa AbstractString || continue - try - x = parse(String(s), T) - json(x) - catch - end - end - s = try - _synthesize_sample(T) - catch - nothing - end - if s !== nothing - try - x = parse(s, T) - json(x) - catch - end - end - try - parse("{}", T) - catch - end - return nothing -end - -# build a minimal valid JSON sample for T from its field table: dummy leaf -# per kind, recursion for nested structs/vectors; CUSTOM-kind fields are -# omitted (defaults/nullability cover them, and "{}" is the fallback) -function _synthesize_sample(@nospecialize(T)) - style = JSONReadStyle{DEFAULT_OBJECT_TYPE}(nothing) - tbl = StructUtils.fieldtable(T, style) - tbl.eligible || return nothing - io = IOBuffer() - Base.print(io, '{') - isfirst = true - for sp in tbl.specs - frag = _synth_value(sp.kind, sp.elkind, sp.ft, sp.elft) - frag === nothing && continue - isfirst || Base.print(io, ',') - isfirst = false - Base.print(io, '"', sp.name, "\":", frag) - end - Base.print(io, '}') - return String(take!(io)) -end -function _synth_value(kind::Int8, elkind::Int8, @nospecialize(ft), @nospecialize(elft)) - SU = StructUtils - if kind == SU.KIND_STRING || kind == SU.KIND_SYMBOL - return "\"s\"" - elseif kind == SU.KIND_CHAR - return "\"c\"" - elseif SU.KIND_INT64 <= kind <= SU.KIND_UINT128 - return "1" - elseif SU.KIND_FLOAT64 <= kind <= SU.KIND_FLOAT16 - return "1.5" - elseif kind == SU.KIND_BOOL - return "true" - elseif kind == SU.KIND_DATE - return "\"2020-01-02\"" - elseif kind == SU.KIND_DATETIME - return "\"2020-01-02T03:04:05\"" - elseif kind == SU.KIND_TIME - return "\"03:04:05\"" - elseif kind == SU.KIND_UUID - return "\"c8b1cf79-de6a-54ab-a142-682c06a0de6a\"" - elseif kind == SU.KIND_ANY - return "1" - elseif kind == SU.KIND_STRUCT - return _synthesize_sample(ft) - elseif kind == SU.KIND_VECTOR - el = _synth_value(elkind, Int8(0), elft, nothing) - return el === nothing ? nothing : string('[', el, ']') - end - return nothing -end mutable struct ValueClosure value::Any diff --git a/src/tier0.jl b/src/tier0.jl new file mode 100644 index 0000000..bf84870 --- /dev/null +++ b/src/tier0.jl @@ -0,0 +1,272 @@ +# Tier-0 typed parsing for JSON: a single lazy pass drives the StructUtils +# field-table interpreter's slots directly from applyobject — no intermediate +# tree, closures parameterized by style only (never the target type), so the +# whole engine compiles once and ships in this package's image via the +# workload. JIT-only: under StructUtils.TRIM_BUILD the route is compile-time +# disabled and typed parsing goes through the specialized hot descent. +# +# Also here: the :hot precompile hook JSON registers with StructUtils (each +# :hot-annotated struct's typed parse/write compiles into its defining +# package's image), plus the field-table-driven sample synthesizer it uses. + +# memoized routing verdict per (target type, style type): the eligibility + +# tree-safety walk is table recursion we don't want on every parse +const _INTERP_ROUTE = Dict{Tuple{DataType,DataType},Bool}() +const _INTERP_ROUTE_LOCK = ReentrantLock() + +function _interproute(style::StructStyle, @nospecialize(T))::Bool + T isa DataType || return false + key = (T, typeof(style)) + lock(_INTERP_ROUTE_LOCK) do + get!(_INTERP_ROUTE, key) do + StructUtils.interpready(style, T) && StructUtils.interptreesafe(style, T) + end + end +end + +const _DEFAULT_READSTYLE = JSONReadStyle{DEFAULT_OBJECT_TYPE,Nothing,StructUtils.DefaultStyle} + +# ---------------- fused tier-0 lazy interpretation ---------------- +# JIT-only (the route is gated off under trim builds): drives the +# StructUtils field-table interpreter's slots directly from applyobject — +# one pass, no intermediate tree. Closures are parameterized by style only, +# never by the target type, so the whole engine compiles once and lives in +# this package's image via the workload. + +struct FusedObjClosure{S<:StructStyle} + style::S + tbl::StructUtils.FieldTable + slots::Vector{Any} +end + +function (f::FusedObjClosure{S})(k::PtrString, v::LazyValue) where {S} + specs = f.tbl.specs + i = 0 + for j = 1:length(specs) + if k == @inbounds(specs[j]).name # PtrString == String: no allocation + i = j + break + end + end + if i == 0 + # unknown key: honor the style hook (unknown_fields=:error throws); + # returning non-Int makes applyobject skip the value unmaterialized + StructUtils.unknownfield(f.style, f.tbl.T, k, v) + return nothing + end + sp = @inbounds specs[i] + if gettype(v) == JSONTypes.NULL + kind = sp.kind + if sp.nullable + f.slots[i] = nothing + elseif sp.missingable + f.slots[i] = missing + elseif kind == StructUtils.KIND_ANY + f.slots[i] = nothing + elseif kind == StructUtils.KIND_CUSTOM + val, _ = StructUtils.make(f.style, sp.ft::Type, nothing, sp.tags) + f.slots[i] = val + else + x, _ = StructUtils.lift(f.style, sp.ft::Type, nothing) + f.slots[i] = x + end + return getpos(v) + 4 + end + val, pos = _fused_value(f.style, sp, v) + f.slots[i] = val + return pos +end + +struct FusedArrClosure{S<:StructStyle} + style::S + elkind::Int8 + elft::Any + arr::Any + name::String +end + +function (f::FusedArrClosure{S})(_, v::LazyValue) where {S} + elk = f.elkind + if gettype(v) == JSONTypes.NULL + # a null element: same semantics the interpreter's element lift has + x, _ = StructUtils.lift(f.style, f.elft::Type, nothing) + push!(f.arr::Vector, x) + return getpos(v) + 4 + end + local val, pos + if elk == StructUtils.KIND_STRUCT && gettype(v) == JSONTypes.OBJECT + val, pos = _fused_make(f.style, f.elft, v) + elseif elk == StructUtils.KIND_ANY + out = ValueClosure() + pos = applyvalue(out, v, nothing) + val = out.value + else + val, pos = _fused_scalar(f.style, elk, f.elft, v, f.name) + end + push!(f.arr::Vector, val) + return pos +end + +function _fused_value(style::StructStyle, sp::StructUtils.FieldSpec, v::LazyValue) + kind = sp.kind + if kind == StructUtils.KIND_STRUCT && gettype(v) == JSONTypes.OBJECT + return _fused_make(style, sp.ft, v) + elseif kind == StructUtils.KIND_VECTOR && gettype(v) == JSONTypes.ARRAY + arr = StructUtils._alloc_vector(sp.elft, 0) + f = FusedArrClosure{typeof(style)}(style, sp.elkind, sp.elft, arr, sp.name) + pos = applyarray(f, v) + pos isa Int || (pos = skip(v)) + return arr, pos + elseif kind == StructUtils.KIND_ANY + out = ValueClosure() + pos = applyvalue(out, v, nothing) + return out.value, pos + elseif kind == StructUtils.KIND_CUSTOM + # materialize, then the 4-arg make with the field's tags — identical + # to the interpreter's CUSTOM arm (lift/choosetype/dateformat) + out = ValueClosure() + pos = applyvalue(out, v, nothing) + val, _ = StructUtils.make(style, sp.ft::Type, out.value, sp.tags) + return val, pos + else + return _fused_scalar(style, kind, sp.ft, v, sp.name) + end +end + +# scalar leaves: parse the base JSON scalar lazily, then produce the +# exact-typed value through the interpreter's kind ladder (ISO dates, int +# widths, symbols, chars, and the JIT lift fallback for odd pairings) +function _fused_scalar(style::StructStyle, kind::Int8, @nospecialize(ft), v::LazyValue, name::String) + t = gettype(v) + if t == JSONTypes.STRING + buf = getbuf(v) + local s, pos + GC.@preserve buf begin + str, pos = parsestring(v) + s = convert(String, str) + end + return StructUtils._liftleaf(style, kind, ft, s, name), pos + elseif t == JSONTypes.NUMBER + num, pos = parsenumber(v) + raw = isint(num) ? num.int : + isfloat(num) ? num.float : + isbigint(num) ? num.bigint : num.bigfloat + return StructUtils._liftleaf(style, kind, ft, raw, name), pos + elseif t == JSONTypes.TRUE + return StructUtils._liftleaf(style, kind, ft, true, name), getpos(v) + 4 + elseif t == JSONTypes.FALSE + return StructUtils._liftleaf(style, kind, ft, false, name), getpos(v) + 5 + else + # aggregate into a scalar-kind field: materialize and let the + # interpreter's leaf ladder (and its lift fallback) decide + out = ValueClosure() + pos = applyvalue(out, v, nothing) + return StructUtils._liftleaf(style, kind, ft, out.value, name), pos + end +end + +# @noinline: this is the boundary between per-type entry glue and the +# compile-once engine — inlined, the JIT re-infers the engine per target type +@noinline function _fused_make(style::StructStyle, @nospecialize(T), v::LazyValue) + tbl = StructUtils.fieldtable(T, style) + if !tbl.eligible + # nested type the interpreter can't build: materialize the subtree + # and let the generic machinery decide (classic semantics) + out = ValueClosure() + pos = applyvalue(out, v, nothing) + val, _ = StructUtils.make(style, T::Type, out.value) + return val, pos + end + slots = Vector{Any}(undef, length(tbl.specs)) + f = FusedObjClosure{typeof(style)}(style, tbl, slots) + pos = applyobject(f, v) + pos isa Int || (pos = skip(v)) + return StructUtils._construct_interp(style, tbl, slots), pos +end + +# ---------------- :hot precompile hook + sample synthesis ---------------- + +# registered with StructUtils from __init__: called for each :hot-annotated +# struct during the *defining package's* precompilation, inside a newly- +# inferred-tagging block — everything parsed here (the typed lazy descent, +# the write path) lands in that package's image +function _hot_json_hook(@nospecialize(T), samples::Tuple) + T isa Type || return nothing + for s in samples + s isa AbstractString || continue + try + x = parse(String(s), T) + json(x) + catch + end + end + s = try + _synthesize_sample(T) + catch + nothing + end + if s !== nothing + try + x = parse(s, T) + json(x) + catch + end + end + try + parse("{}", T) + catch + end + return nothing +end + +# build a minimal valid JSON sample for T from its field table: dummy leaf +# per kind, recursion for nested structs/vectors; CUSTOM-kind fields are +# omitted (defaults/nullability cover them, and "{}" is the fallback) +function _synthesize_sample(@nospecialize(T)) + style = JSONReadStyle{DEFAULT_OBJECT_TYPE}(nothing) + tbl = StructUtils.fieldtable(T, style) + tbl.eligible || return nothing + io = IOBuffer() + Base.print(io, '{') + isfirst = true + for sp in tbl.specs + frag = _synth_value(sp.kind, sp.elkind, sp.ft, sp.elft) + frag === nothing && continue + isfirst || Base.print(io, ',') + isfirst = false + Base.print(io, '"', sp.name, "\":", frag) + end + Base.print(io, '}') + return String(take!(io)) +end + +function _synth_value(kind::Int8, elkind::Int8, @nospecialize(ft), @nospecialize(elft)) + SU = StructUtils + if kind == SU.KIND_STRING || kind == SU.KIND_SYMBOL + return "\"s\"" + elseif kind == SU.KIND_CHAR + return "\"c\"" + elseif SU.KIND_INT64 <= kind <= SU.KIND_UINT128 + return "1" + elseif SU.KIND_FLOAT64 <= kind <= SU.KIND_FLOAT16 + return "1.5" + elseif kind == SU.KIND_BOOL + return "true" + elseif kind == SU.KIND_DATE + return "\"2020-01-02\"" + elseif kind == SU.KIND_DATETIME + return "\"2020-01-02T03:04:05\"" + elseif kind == SU.KIND_TIME + return "\"03:04:05\"" + elseif kind == SU.KIND_UUID + return "\"c8b1cf79-de6a-54ab-a142-682c06a0de6a\"" + elseif kind == SU.KIND_ANY + return "1" + elseif kind == SU.KIND_STRUCT + return _synthesize_sample(ft) + elseif kind == SU.KIND_VECTOR + el = _synth_value(elkind, Int8(0), elft, nothing) + return el === nothing ? nothing : string('[', el, ']') + end + return nothing +end From 8d845016a5e9e3f615ee5481e52e469c7ff4ea40 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Wed, 15 Jul 2026 11:35:18 -0600 Subject: [PATCH 06/13] perf(tier0): size-based auto-tier + lock-free route memo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bulk documents lose to a compiled descent under per-element interpretation (fuzz worst case 12x on an 11KB vector-of-structs doc), so object roots at or above 4KiB take the classic specialized route through the existing boundary — a type that parses bulk documents is worth its one-time compile, which is exactly what every type cost before tier-0. The route memo becomes copy-on-write (one atomic load per read): the previous lock dominated sub-microsecond parses of near-empty documents. Fuzz-corpus effect (226 cases vs JSON 1.4.0 + StructUtils 2.6.2): the big-class catastrophes disappear (worst 0.082 -> 0.78 median class ratio); full/minimal classes hold at 2.7x/2.0x geomean faster. Co-Authored-By: Claude Fable 5 --- src/parse.jl | 3 ++- src/tier0.jl | 33 +++++++++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/parse.jl b/src/parse.jl index ec21303..44f671a 100644 --- a/src/parse.jl +++ b/src/parse.jl @@ -257,7 +257,8 @@ parse(x::LazyValue, ::Type{T}=Any; function _parse(x::LazyValue, ::Type{T}, dicttype::Type{O}, null, style::StructStyle) where {T,O} if !StructUtils.TRIM_BUILD && T !== Any && style isa _DEFAULT_READSTYLE && - !StructUtils.ishot(T) && gettype(x) == JSONTypes.OBJECT && _interproute(style, T) + !StructUtils.ishot(T) && gettype(x) == JSONTypes.OBJECT && + getlength(getbuf(x)) - getpos(x) < _FUSED_MAX_BYTES && _interproute(style, T) # tier-0 default: a single lazy pass drives the field-table # interpreter's slots directly — scalar leaves materialize per their # kind tag, unknown keys are skipped without materializing, and the diff --git a/src/tier0.jl b/src/tier0.jl index bf84870..35c3b8e 100644 --- a/src/tier0.jl +++ b/src/tier0.jl @@ -11,19 +11,44 @@ # memoized routing verdict per (target type, style type): the eligibility + # tree-safety walk is table recursion we don't want on every parse -const _INTERP_ROUTE = Dict{Tuple{DataType,DataType},Bool}() +# copy-on-write: reads are one atomic load + hash lookup (the memo sits on +# every typed parse, including sub-microsecond ones where a lock would +# dominate); writers clone and swap under the lock +mutable struct _RouteMemo + @atomic table::Dict{Tuple{DataType,DataType},Bool} +end +const _INTERP_ROUTE = _RouteMemo(Dict{Tuple{DataType,DataType},Bool}()) const _INTERP_ROUTE_LOCK = ReentrantLock() function _interproute(style::StructStyle, @nospecialize(T))::Bool T isa DataType || return false key = (T, typeof(style)) - lock(_INTERP_ROUTE_LOCK) do - get!(_INTERP_ROUTE, key) do - StructUtils.interpready(style, T) && StructUtils.interptreesafe(style, T) + tbl = @atomic _INTERP_ROUTE.table + r = get(tbl, key, nothing) + r === nothing || return r + verdict = StructUtils.interpready(style, T) && StructUtils.interptreesafe(style, T) + lock(_INTERP_ROUTE_LOCK) + try + old = @atomic _INTERP_ROUTE.table + if !haskey(old, key) + new = copy(old) + new[key] = verdict + @atomic _INTERP_ROUTE.table = new end + finally + unlock(_INTERP_ROUTE_LOCK) end + return verdict end +# documents above this size take the classic specialized descent instead of +# the tier-0 engine: per-element interpretation loses to a compiled descent +# on bulk documents (measured crossover sits in the low kilobytes), and a +# type that parses bulk documents is worth its one-time compile — the same +# cost every type paid before tier-0 existed. `:hot` skips the size check +# entirely by never reaching this route. +const _FUSED_MAX_BYTES = 4096 + const _DEFAULT_READSTYLE = JSONReadStyle{DEFAULT_OBJECT_TYPE,Nothing,StructUtils.DefaultStyle} # ---------------- fused tier-0 lazy interpretation ---------------- From e705efac7fac8048dc7c1acad8dd87f74a28eaa6 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Wed, 15 Jul 2026 12:36:52 -0600 Subject: [PATCH 07/13] feat(tier0): adapt the fused engine and synthesizer to recursive ValueSpecs The lazy drive walks the spec tree (union arms picked by JSON value type, nullable elements handled in the array closure); dict/custom positions materialize their subtree and reuse the interpreter's boxed arms. Sample synthesis recurses the same specs. Co-Authored-By: Claude Fable 5 --- src/tier0.jl | 107 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 64 insertions(+), 43 deletions(-) diff --git a/src/tier0.jl b/src/tier0.jl index 35c3b8e..6c453db 100644 --- a/src/tier0.jl +++ b/src/tier0.jl @@ -80,81 +80,96 @@ function (f::FusedObjClosure{S})(k::PtrString, v::LazyValue) where {S} return nothing end sp = @inbounds specs[i] + vs = sp.spec if gettype(v) == JSONTypes.NULL - kind = sp.kind - if sp.nullable + if vs.nullable f.slots[i] = nothing - elseif sp.missingable + elseif vs.missingable f.slots[i] = missing - elseif kind == StructUtils.KIND_ANY + elseif vs.kind == StructUtils.KIND_ANY f.slots[i] = nothing - elseif kind == StructUtils.KIND_CUSTOM - val, _ = StructUtils.make(f.style, sp.ft::Type, nothing, sp.tags) + elseif vs.kind == StructUtils.KIND_CUSTOM + val, _ = StructUtils.make(f.style, vs.declft::Type, nothing, sp.tags) f.slots[i] = val else - x, _ = StructUtils.lift(f.style, sp.ft::Type, nothing) + x, _ = StructUtils.lift(f.style, vs.ft::Type, nothing) f.slots[i] = x end return getpos(v) + 4 end - val, pos = _fused_value(f.style, sp, v) + val, pos = _fused_field(f.style, sp, v) f.slots[i] = val return pos end +# field-level: CUSTOM carries the field's tags; everything else goes through +# the spec tree +function _fused_field(style::StructStyle, sp::StructUtils.FieldSpec, v::LazyValue) + vs = sp.spec + if vs.kind == StructUtils.KIND_CUSTOM + out = ValueClosure() + pos = applyvalue(out, v, nothing) + val, _ = StructUtils.make(style, vs.declft::Type, out.value, sp.tags) + return val, pos + end + return _fused_spec(style, vs, v, sp.name) +end + struct FusedArrClosure{S<:StructStyle} style::S - elkind::Int8 - elft::Any + el::StructUtils.ValueSpec arr::Any name::String end function (f::FusedArrClosure{S})(_, v::LazyValue) where {S} - elk = f.elkind - if gettype(v) == JSONTypes.NULL - # a null element: same semantics the interpreter's element lift has - x, _ = StructUtils.lift(f.style, f.elft::Type, nothing) - push!(f.arr::Vector, x) - return getpos(v) + 4 - end + el = f.el local val, pos - if elk == StructUtils.KIND_STRUCT && gettype(v) == JSONTypes.OBJECT - val, pos = _fused_make(f.style, f.elft, v) - elseif elk == StructUtils.KIND_ANY - out = ValueClosure() - pos = applyvalue(out, v, nothing) - val = out.value + if gettype(v) == JSONTypes.NULL + if el.nullable + val, pos = nothing, getpos(v) + 4 + elseif el.missingable + val, pos = missing, getpos(v) + 4 + else + x, _ = StructUtils.lift(f.style, el.ft::Type, nothing) + val, pos = x, getpos(v) + 4 + end else - val, pos = _fused_scalar(f.style, elk, f.elft, v, f.name) + val, pos = _fused_spec(f.style, el, v, f.name) end push!(f.arr::Vector, val) return pos end -function _fused_value(style::StructStyle, sp::StructUtils.FieldSpec, v::LazyValue) - kind = sp.kind - if kind == StructUtils.KIND_STRUCT && gettype(v) == JSONTypes.OBJECT - return _fused_make(style, sp.ft, v) - elseif kind == StructUtils.KIND_VECTOR && gettype(v) == JSONTypes.ARRAY - arr = StructUtils._alloc_vector(sp.elft, 0) - f = FusedArrClosure{typeof(style)}(style, sp.elkind, sp.elft, arr, sp.name) +# position-level recursion mirroring StructUtils._spec_value, driven lazily. +# Kinds the lazy drive doesn't specialize for (dicts, union arms decided by +# source shape, generic customs) materialize the subtree and reuse the +# interpreter's boxed arms — correctness first, still no per-type compile. +function _fused_spec(style::StructStyle, vs::StructUtils.ValueSpec, v::LazyValue, name::String) + k = vs.kind + if k == StructUtils.KIND_STRUCT && gettype(v) == JSONTypes.OBJECT + return _fused_make(style, vs.ft, v) + elseif k == StructUtils.KIND_VECTOR && gettype(v) == JSONTypes.ARRAY + el = vs.child::StructUtils.ValueSpec + arr = StructUtils._alloc_vector(el.declft, 0) + f = FusedArrClosure{typeof(style)}(style, el, arr, name) pos = applyarray(f, v) pos isa Int || (pos = skip(v)) return arr, pos - elseif kind == StructUtils.KIND_ANY + elseif k == StructUtils.KIND_UNION2 + arm = gettype(v) == JSONTypes.ARRAY ? (vs.child::StructUtils.ValueSpec) : + (vs.child2::StructUtils.ValueSpec) + return _fused_spec(style, arm, v, name) + elseif k == StructUtils.KIND_ANY out = ValueClosure() pos = applyvalue(out, v, nothing) return out.value, pos - elseif kind == StructUtils.KIND_CUSTOM - # materialize, then the 4-arg make with the field's tags — identical - # to the interpreter's CUSTOM arm (lift/choosetype/dateformat) + elseif k == StructUtils.KIND_DICT || k == StructUtils.KIND_CUSTOM out = ValueClosure() pos = applyvalue(out, v, nothing) - val, _ = StructUtils.make(style, sp.ft::Type, out.value, sp.tags) - return val, pos + return StructUtils._spec_value(style, vs, out.value, name), pos else - return _fused_scalar(style, kind, sp.ft, v, sp.name) + return _fused_scalar(style, k, vs.ft, v, name) end end @@ -206,7 +221,7 @@ end f = FusedObjClosure{typeof(style)}(style, tbl, slots) pos = applyobject(f, v) pos isa Int || (pos = skip(v)) - return StructUtils._construct_interp(style, tbl, slots), pos + return StructUtils._construct_interp(style, tbl, slots, v), pos end # ---------------- :hot precompile hook + sample synthesis ---------------- @@ -255,7 +270,7 @@ function _synthesize_sample(@nospecialize(T)) Base.print(io, '{') isfirst = true for sp in tbl.specs - frag = _synth_value(sp.kind, sp.elkind, sp.ft, sp.elft) + frag = _synth_value(sp.spec) frag === nothing && continue isfirst || Base.print(io, ',') isfirst = false @@ -265,8 +280,9 @@ function _synthesize_sample(@nospecialize(T)) return String(take!(io)) end -function _synth_value(kind::Int8, elkind::Int8, @nospecialize(ft), @nospecialize(elft)) +function _synth_value(vs::StructUtils.ValueSpec) SU = StructUtils + kind = vs.kind if kind == SU.KIND_STRING || kind == SU.KIND_SYMBOL return "\"s\"" elseif kind == SU.KIND_CHAR @@ -288,10 +304,15 @@ function _synth_value(kind::Int8, elkind::Int8, @nospecialize(ft), @nospecialize elseif kind == SU.KIND_ANY return "1" elseif kind == SU.KIND_STRUCT - return _synthesize_sample(ft) + return _synthesize_sample(vs.ft) elseif kind == SU.KIND_VECTOR - el = _synth_value(elkind, Int8(0), elft, nothing) + el = _synth_value(vs.child::StructUtils.ValueSpec) return el === nothing ? nothing : string('[', el, ']') + elseif kind == SU.KIND_DICT + el = _synth_value(vs.child::StructUtils.ValueSpec) + return el === nothing ? nothing : string("{\"k\":", el, '}') + elseif kind == SU.KIND_UNION2 + return _synth_value(vs.child2::StructUtils.ValueSpec) # the scalar arm end return nothing end From d3aff418b7623ed809c59b42142e681275a442c2 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Wed, 15 Jul 2026 19:42:37 -0600 Subject: [PATCH 08/13] feat(parse): fully-fused tier-0 typed parsing for every target type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The size auto-tier (_FUSED_MAX_BYTES), the route memo, and the classic invokelatest fallback are gone: under the default read configuration every non-:hot typed parse drives the field-table interpreter in one lazy pass — struct objects and element vectors stream directly, custom kinds hand the RAW lazy value to the generic machinery (choosetype/lift/make overloads see exactly what the specialized descent hands them — this dissolves the treesafe gate), and every other (kind, shape) pairing materializes its subtree into the interpreter's boxed arms. Root capability: any T parses through rootspec (scalars, containers, tuples, sets, unions, null); struct-from-array fills positionally (FusedPosClosure — classic's lazy applyeach handed Int keys); fixed-size array roots keep the raw-lazy generic route (dimension discovery). JSONText raw capture routes via the custom-make classification. Explicit nulls take the Missing arm first (classic @_peel order). Alias tuples and raw name tags match through _findspec on the fused key path. Custom dicttype/null and custom inner styles take the fully-specialized descent: per-style lazy lifts and trait overrides are invisible to the engine's materializing ladder, so those hooks keep classic semantics at classic cost. :hot and trim builds are unchanged (specialized static descent; the trim harness compiles under trim_build=true). Co-Authored-By: Claude Fable 5 --- src/parse.jl | 44 +++---- src/tier0.jl | 248 ++++++++++++++++++++++--------------- test/interp_default.jl | 18 +-- test/trim_compile_tests.jl | 6 +- 4 files changed, 174 insertions(+), 142 deletions(-) diff --git a/src/parse.jl b/src/parse.jl index 44f671a..c84f083 100644 --- a/src/parse.jl +++ b/src/parse.jl @@ -151,6 +151,11 @@ import StructUtils: StructStyle abstract type JSONStyle <: StructStyle end +# lazy values carry their own lift/descent protocol; the tier-0 interpreter +# must never drive them directly (the fused engine in tier0.jl is the +# interpreter-shaped path for lazy input) +StructUtils.interpsource(::LazyValues) = false + # defining a custom style allows us to pass a non-default dicttype `O` through JSON.parse, # while still delegating custom behavior to an inner StructStyle if one was provided struct JSONReadStyle{O,T,S} <: JSONStyle @@ -256,39 +261,20 @@ parse(x::LazyValue, ::Type{T}=Any; function _parse(x::LazyValue, ::Type{T}, dicttype::Type{O}, null, style::StructStyle) where {T,O} - if !StructUtils.TRIM_BUILD && T !== Any && style isa _DEFAULT_READSTYLE && - !StructUtils.ishot(T) && gettype(x) == JSONTypes.OBJECT && - getlength(getbuf(x)) - getpos(x) < _FUSED_MAX_BYTES && _interproute(style, T) - # tier-0 default: a single lazy pass drives the field-table - # interpreter's slots directly — scalar leaves materialize per their - # kind tag, unknown keys are skipped without materializing, and the - # per-type compile cost is a field-table build. `:hot` types keep the - # specialized lazy descent; custom styles/dicttype/null and - # non-object roots keep classic semantics. Called directly (not via - # the `make` dispatcher) so the never-taken specialized-descent arm - # isn't compiled per type. + if !StructUtils.TRIM_BUILD && !StructUtils.ishot(T) && style isa _FUSED_STYLE + # tier-0 default for every typed parse: one lazy pass drives the + # field-table interpreter — any target type, no per-type compile + # beyond a field-table build. Called directly (not via the `make` + # dispatcher) so the never-taken specialized-descent arm isn't + # compiled per type. y, pos = _fused_make(style, T, x) getisroot(x) && checkendpos(x, T, pos) return y::T end - if StructUtils.TRIM_BUILD - # trim binaries need the static call graph (and the tree route is - # compile-time disabled above, so this IS the path) - y, pos = StructUtils.make(style, T, x) - getisroot(x) && checkendpos(x, T, pos) - return y - else - # runtime-dispatch boundary: with the route decided by a runtime - # table lookup, a directly-reachable classic descent would be JIT- - # compiled per target type even when the fused tier-0 route is always - # taken — exactly the first-call cost tier-0 exists to remove. Types - # that genuinely route here (:hot, custom styles/kwargs, ineligible - # shapes) pay one dynamic dispatch per parse. - return Base.invokelatest(_parse_classic, x, T, style) - end -end - -function _parse_classic(x::LazyValue, ::Type{T}, style::StructStyle) where {T} + # the fully-specialized static descent: trim builds (the verifier needs + # its static call graph), `:hot` types (compiled into their defining + # package's image by the precompile hook), and custom dicttype/null + # (those change materialization semantics) y, pos = StructUtils.make(style, T, x) getisroot(x) && checkendpos(x, T, pos) return y diff --git a/src/tier0.jl b/src/tier0.jl index 6c453db..22edffa 100644 --- a/src/tier0.jl +++ b/src/tier0.jl @@ -2,61 +2,28 @@ # field-table interpreter's slots directly from applyobject — no intermediate # tree, closures parameterized by style only (never the target type), so the # whole engine compiles once and ships in this package's image via the -# workload. JIT-only: under StructUtils.TRIM_BUILD the route is compile-time -# disabled and typed parsing goes through the specialized hot descent. +# workload. Fully capable: every target type routes here under the default +# read configuration — struct objects and element vectors drive the lazy +# tokens directly, custom kinds hand the RAW lazy value to the generic +# machinery (user hooks keep their semantics), and every other (kind, shape) +# pairing materializes its subtree into the interpreter's boxed arms. +# JIT-only: under StructUtils.TRIM_BUILD typed parsing goes through the +# specialized hot descent (the trim verifier needs its static call graph). # # Also here: the :hot precompile hook JSON registers with StructUtils (each # :hot-annotated struct's typed parse/write compiles into its defining # package's image), plus the field-table-driven sample synthesizer it uses. -# memoized routing verdict per (target type, style type): the eligibility + -# tree-safety walk is table recursion we don't want on every parse -# copy-on-write: reads are one atomic load + hash lookup (the memo sits on -# every typed parse, including sub-microsecond ones where a lock would -# dominate); writers clone and swap under the lock -mutable struct _RouteMemo - @atomic table::Dict{Tuple{DataType,DataType},Bool} -end -const _INTERP_ROUTE = _RouteMemo(Dict{Tuple{DataType,DataType},Bool}()) -const _INTERP_ROUTE_LOCK = ReentrantLock() - -function _interproute(style::StructStyle, @nospecialize(T))::Bool - T isa DataType || return false - key = (T, typeof(style)) - tbl = @atomic _INTERP_ROUTE.table - r = get(tbl, key, nothing) - r === nothing || return r - verdict = StructUtils.interpready(style, T) && StructUtils.interptreesafe(style, T) - lock(_INTERP_ROUTE_LOCK) - try - old = @atomic _INTERP_ROUTE.table - if !haskey(old, key) - new = copy(old) - new[key] = verdict - @atomic _INTERP_ROUTE.table = new - end - finally - unlock(_INTERP_ROUTE_LOCK) - end - return verdict -end - -# documents above this size take the classic specialized descent instead of -# the tier-0 engine: per-element interpretation loses to a compiled descent -# on bulk documents (measured crossover sits in the low kilobytes), and a -# type that parses bulk documents is worth its one-time compile — the same -# cost every type paid before tier-0 existed. `:hot` skips the size check -# entirely by never reaching this route. -const _FUSED_MAX_BYTES = 4096 - -const _DEFAULT_READSTYLE = JSONReadStyle{DEFAULT_OBJECT_TYPE,Nothing,StructUtils.DefaultStyle} +# the tier-0 route: the default read configuration only. Custom dicttype/ +# null change materialization semantics, and custom inner styles can carry +# per-style `lift(::MyStyle, ::Type{T}, ::LazyValue)` overloads or trait +# overrides (dictlike/arraylike) that the engine's materializing scalar +# ladder and structural spec tree would silently bypass — those take the +# fully-specialized descent, where every hook sees exactly what classic +# handed it. +const _FUSED_STYLE = JSONReadStyle{DEFAULT_OBJECT_TYPE,Nothing,StructUtils.DefaultStyle} # ---------------- fused tier-0 lazy interpretation ---------------- -# JIT-only (the route is gated off under trim builds): drives the -# StructUtils field-table interpreter's slots directly from applyobject — -# one pass, no intermediate tree. Closures are parameterized by style only, -# never by the target type, so the whole engine compiles once and lives in -# this package's image via the workload. struct FusedObjClosure{S<:StructStyle} style::S @@ -73,44 +40,81 @@ function (f::FusedObjClosure{S})(k::PtrString, v::LazyValue) where {S} break end end + if i == 0 + # miss on declared names: alias tuples and raw name tags register + # extra candidates (only consulted when the table declares any) + for j = 1:length(specs) + if @inbounds(specs[j]).aliases !== nothing + i = StructUtils._findspec(specs, convert(String, k)) + break + end + end + end if i == 0 # unknown key: honor the style hook (unknown_fields=:error throws); # returning non-Int makes applyobject skip the value unmaterialized StructUtils.unknownfield(f.style, f.tbl.T, k, v) return nothing end - sp = @inbounds specs[i] + return _fused_fillslot!(f.style, f.slots, i, @inbounds(specs[i]), v) +end + +# fill slot i for a matched spec (by key or position); returns the next +# lazy position (or nothing to let the applier skip unmaterialized) +function _fused_fillslot!(style::StructStyle, slots::Vector{Any}, i::Int, + sp::StructUtils.FieldSpec, v::LazyValue) vs = sp.spec if gettype(v) == JSONTypes.NULL - if vs.nullable - f.slots[i] = nothing - elseif vs.missingable - f.slots[i] = missing + # classic @_peel order: when a field admits both, an explicit null + # takes the Missing arm first + if vs.missingable + slots[i] = missing + elseif vs.nullable + slots[i] = nothing elseif vs.kind == StructUtils.KIND_ANY - f.slots[i] = nothing + slots[i] = nothing elseif vs.kind == StructUtils.KIND_CUSTOM - val, _ = StructUtils.make(f.style, vs.declft::Type, nothing, sp.tags) - f.slots[i] = val + val, _ = StructUtils.make(style, vs.declft::Type, nothing, sp.tags) + slots[i] = val else - x, _ = StructUtils.lift(f.style, vs.ft::Type, nothing) - f.slots[i] = x + x, _ = StructUtils.lift(style, vs.ft::Type, nothing) + slots[i] = x end return getpos(v) + 4 end - val, pos = _fused_field(f.style, sp, v) - f.slots[i] = val + val, pos = _fused_field(style, sp, v) + slots[i] = val return pos end +# positional struct fill from a JSON array source: classic's lazy applyeach +# handed the struct closures Int keys for arrays, so field order is the +# match (surplus elements go to the style's unknownfield hook) +struct FusedPosClosure{S<:StructStyle} + style::S + tbl::StructUtils.FieldTable + slots::Vector{Any} +end + +function (f::FusedPosClosure{S})(i::Int, v::LazyValue) where {S} + specs = f.tbl.specs + if i > length(specs) + StructUtils.unknownfield(f.style, f.tbl.T, i, v) + return nothing + end + return _fused_fillslot!(f.style, f.slots, i, @inbounds(specs[i]), v) +end + # field-level: CUSTOM carries the field's tags; everything else goes through # the spec tree function _fused_field(style::StructStyle, sp::StructUtils.FieldSpec, v::LazyValue) vs = sp.spec if vs.kind == StructUtils.KIND_CUSTOM - out = ValueClosure() - pos = applyvalue(out, v, nothing) - val, _ = StructUtils.make(style, vs.declft::Type, out.value, sp.tags) - return val, pos + # custom-kind fields (user lift/make targets, choosetype tags, + # abstract declared types) receive the RAW lazy value — user hooks + # see exactly what the specialized descent hands them + val, st = StructUtils.make(style, vs.declft::Type, v, sp.tags) + return val, st isa Int ? st : skip(v) end return _fused_spec(style, vs, v, sp.name) end @@ -126,10 +130,11 @@ function (f::FusedArrClosure{S})(_, v::LazyValue) where {S} el = f.el local val, pos if gettype(v) == JSONTypes.NULL - if el.nullable - val, pos = nothing, getpos(v) + 4 - elseif el.missingable + # classic @_peel order: Missing arm first + if el.missingable val, pos = missing, getpos(v) + 4 + elseif el.nullable + val, pos = nothing, getpos(v) + 4 else x, _ = StructUtils.lift(f.style, el.ft::Type, nothing) val, pos = x, getpos(v) + 4 @@ -142,20 +147,31 @@ function (f::FusedArrClosure{S})(_, v::LazyValue) where {S} end # position-level recursion mirroring StructUtils._spec_value, driven lazily. -# Kinds the lazy drive doesn't specialize for (dicts, union arms decided by -# source shape, generic customs) materialize the subtree and reuse the -# interpreter's boxed arms — correctness first, still no per-type compile. +# Struct objects and element vectors — the overwhelmingly common shapes — +# drive the lazy tokens directly; custom kinds hand the RAW lazy value to +# the generic machinery; every other (kind, shape) pairing materializes its +# subtree and reuses the interpreter's boxed arms — full capability, still +# no per-type compile. function _fused_spec(style::StructStyle, vs::StructUtils.ValueSpec, v::LazyValue, name::String) k = vs.kind - if k == StructUtils.KIND_STRUCT && gettype(v) == JSONTypes.OBJECT - return _fused_make(style, vs.ft, v) - elseif k == StructUtils.KIND_VECTOR && gettype(v) == JSONTypes.ARRAY - el = vs.child::StructUtils.ValueSpec - arr = StructUtils._alloc_vector(el.declft, 0) - f = FusedArrClosure{typeof(style)}(style, el, arr, name) - pos = applyarray(f, v) - pos isa Int || (pos = skip(v)) - return arr, pos + if k == StructUtils.KIND_STRUCT + t = gettype(v) + if t == JSONTypes.OBJECT || t == JSONTypes.ARRAY + tbl = StructUtils.fieldtable(vs.ft::DataType, style) + if tbl.eligible + t == JSONTypes.OBJECT && return _fused_struct(style, tbl, v) + return _fused_struct_positional(style, tbl, v) + end + end + elseif k == StructUtils.KIND_VECTOR + if gettype(v) == JSONTypes.ARRAY + el = vs.child::StructUtils.ValueSpec + arr = StructUtils._alloc_vector(el.declft, 0) + f = FusedArrClosure{typeof(style)}(style, el, arr, name) + pos = applyarray(f, v) + pos isa Int || (pos = skip(v)) + return arr, pos + end elseif k == StructUtils.KIND_UNION2 arm = gettype(v) == JSONTypes.ARRAY ? (vs.child::StructUtils.ValueSpec) : (vs.child2::StructUtils.ValueSpec) @@ -164,19 +180,31 @@ function _fused_spec(style::StructStyle, vs::StructUtils.ValueSpec, v::LazyValue out = ValueClosure() pos = applyvalue(out, v, nothing) return out.value, pos - elseif k == StructUtils.KIND_DICT || k == StructUtils.KIND_CUSTOM - out = ValueClosure() - pos = applyvalue(out, v, nothing) - return StructUtils._spec_value(style, vs, out.value, name), pos - else - return _fused_scalar(style, k, vs.ft, v, name) + elseif k == StructUtils.KIND_CUSTOM + # raw lazy value to the generic machinery — user lift/make/choosetype + # hooks see exactly what the specialized descent hands them + val, st = StructUtils.make(style, vs.declft::Type, v) + return val, st isa Int ? st : skip(v) + elseif !(k == StructUtils.KIND_DICT || k == StructUtils.KIND_TUPLE || + k == StructUtils.KIND_FIXEDARRAY || k == StructUtils.KIND_SETLIKE || + k == StructUtils.KIND_UNSUPPORTED) + # scalar leaf kinds parse straight off the lazy token + return _fused_scalar(style, vs, v, name) end + # dict/tuple/set/fixed-array kinds, unsupported leaves, and shape + # mismatches (struct-from-array, vector-from-object): materialize the + # subtree; the interpreter's boxed arms handle any shape + out = ValueClosure() + pos = applyvalue(out, v, nothing) + return StructUtils._spec_value(style, vs, out.value, name), pos end # scalar leaves: parse the base JSON scalar lazily, then produce the # exact-typed value through the interpreter's kind ladder (ISO dates, int # widths, symbols, chars, and the JIT lift fallback for odd pairings) -function _fused_scalar(style::StructStyle, kind::Int8, @nospecialize(ft), v::LazyValue, name::String) +function _fused_scalar(style::StructStyle, vs::StructUtils.ValueSpec, v::LazyValue, name::String) + kind = vs.kind + ft = vs.ft t = gettype(v) if t == JSONTypes.STRING buf = getbuf(v) @@ -197,26 +225,18 @@ function _fused_scalar(style::StructStyle, kind::Int8, @nospecialize(ft), v::Laz elseif t == JSONTypes.FALSE return StructUtils._liftleaf(style, kind, ft, false, name), getpos(v) + 5 else - # aggregate into a scalar-kind field: materialize and let the - # interpreter's leaf ladder (and its lift fallback) decide + # aggregate token into a scalar-kind field: materialize the subtree; + # the interpreter's boxed arms (lift fallback included) decide out = ValueClosure() pos = applyvalue(out, v, nothing) - return StructUtils._liftleaf(style, kind, ft, out.value, name), pos + return StructUtils._spec_value(style, vs, out.value, name), pos end end -# @noinline: this is the boundary between per-type entry glue and the -# compile-once engine — inlined, the JIT re-infers the engine per target type -@noinline function _fused_make(style::StructStyle, @nospecialize(T), v::LazyValue) - tbl = StructUtils.fieldtable(T, style) - if !tbl.eligible - # nested type the interpreter can't build: materialize the subtree - # and let the generic machinery decide (classic semantics) - out = ValueClosure() - pos = applyvalue(out, v, nothing) - val, _ = StructUtils.make(style, T::Type, out.value) - return val, pos - end +# the object↔struct fast path: one lazy pass drives the field-table slots. +# @noinline: the boundary between per-type entry glue and the compile-once +# engine — inlined, the JIT re-infers the engine per target type +@noinline function _fused_struct(style::StructStyle, tbl::StructUtils.FieldTable, v::LazyValue) slots = Vector{Any}(undef, length(tbl.specs)) f = FusedObjClosure{typeof(style)}(style, tbl, slots) pos = applyobject(f, v) @@ -224,6 +244,32 @@ end return StructUtils._construct_interp(style, tbl, slots, v), pos end +function _fused_struct_positional(style::StructStyle, tbl::StructUtils.FieldTable, v::LazyValue) + slots = Vector{Any}(undef, length(tbl.specs)) + f = FusedPosClosure{typeof(style)}(style, tbl, slots) + pos = applyarray(f, v) + pos isa Int || (pos = skip(v)) + return StructUtils._construct_interp(style, tbl, slots, v), pos +end + +# root entry for ANY target type: the spec tree describes T (built once per +# (target, style type)); custom/unsupported roots hand the raw lazy value to +# the generic machinery — the never-error backstop +@noinline function _fused_make(style::StructStyle, @nospecialize(T), v::LazyValue) + vs = StructUtils.rootspec(T, style) + if vs.kind == StructUtils.KIND_CUSTOM || vs.kind == StructUtils.KIND_UNSUPPORTED || + vs.kind == StructUtils.KIND_FIXEDARRAY + # custom/unsupported shapes and fixed-size arrays (0-dim included: + # dimension discovery needs the raw lazy value) take the generic route + val, st = StructUtils.make(style, T::Type, v) + return val, st isa Int ? st : skip(v) + end + if gettype(v) == JSONTypes.NULL + return StructUtils._spec_nullwrap(style, vs, nothing, "root"), getpos(v) + 4 + end + return _fused_spec(style, vs, v, "root") +end + # ---------------- :hot precompile hook + sample synthesis ---------------- # registered with StructUtils from __init__: called for each :hot-annotated diff --git a/test/interp_default.jl b/test/interp_default.jl index 6d928cb..c65b838 100644 --- a/test/interp_default.jl +++ b/test/interp_default.jl @@ -1,9 +1,9 @@ using Test, JSON, Dates, UUIDs, StructUtils -# tier-0 default typed parsing: JSON object roots for non-:hot eligible -# structs materialize through the untyped engine and construct through the -# StructUtils field-table interpreter; everything else keeps the classic -# lazy descent. These tests pin parity across the routing boundary. +# tier-0 default typed parsing: every non-:hot typed parse under the default +# read configuration drives the StructUtils field-table interpreter in one +# lazy pass; :hot types and custom dicttype/null take the specialized +# descent. These tests pin parity across the routing boundary. @kwarg struct IDTier name::String @@ -79,16 +79,16 @@ const IDJSON = """ c = JSON.parse("{\"p\": 0.25}", IDCustom) @test c.p.v == 0.25 - # custom dicttype and null route through the classic path with their - # classic semantics (note: on the classic path, Any-typed *fields* - # materialize as JSON.Object via the lift fallback regardless of - # dicttype — same as master) + # custom dicttype and null take the specialized descent with their + # existing semantics (note: on that path, Any-typed *fields* materialize + # as JSON.Object via the lift fallback regardless of dicttype — same as + # master) d = JSON.parse(IDJSON, IDEvent; dicttype=Dict{String,Any}) @test d.note isa JSON.Object{String,Any} && d.note["k"] == "v" m = JSON.parse("{\"name\":\"x\",\"score\":null}", IDEvent; null=missing) @test m.score === missing - # non-object roots keep the classic path + # non-object roots drive the interpreter spec tree @test JSON.parse("[{\"name\":\"t\"}]", Vector{IDTier})[1].name == "t" # sample synthesis produces parseable JSON for eligible types diff --git a/test/trim_compile_tests.jl b/test/trim_compile_tests.jl index 2762d2f..6f9a9ef 100644 --- a/test/trim_compile_tests.jl +++ b/test/trim_compile_tests.jl @@ -192,9 +192,9 @@ end @test true else project_path = _setup_trim_env() - # trim builds set the trim_build preference: it prunes the tier-0 - # tree route (and its invokelatest classic boundary) out of the - # typed-parse entry, leaving the :hot lazy descent's static graph + # trim builds set the trim_build preference: it prunes the fused + # tier-0 route out of the typed-parse entry, leaving the specialized + # descent's static graph write(joinpath(project_path, "LocalPreferences.toml"), "[StructUtils]\ntrim_build = true\n") _run_trim_case(project_path, "hot_lazy_trim_safe.jl", "hot_lazy_trim_safe") From c1c43c0b6472a21f5daabcf9adbc71974624974d Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Wed, 15 Jul 2026 20:47:23 -0600 Subject: [PATCH 09/13] fix(parse): restore the runtime-dispatch boundary for non-fused typed parses Deleting the invokelatest boundary during the fused rewiring re-created the dead-arm compilation cost it existed to remove: the specialized make(style, T, x) arm was JIT-compiled for every target type even when the fused route was always taken (fuzz sum-of-first-parse blew up 7.6x vs base). Types that genuinely route there (:hot, custom styles/dicttype/ null) pay one dynamic dispatch per parse; trim builds keep the static call graph in a dedicated compile-time arm. Co-Authored-By: Claude Fable 5 --- src/parse.jl | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/parse.jl b/src/parse.jl index c84f083..04b39d9 100644 --- a/src/parse.jl +++ b/src/parse.jl @@ -261,7 +261,13 @@ parse(x::LazyValue, ::Type{T}=Any; function _parse(x::LazyValue, ::Type{T}, dicttype::Type{O}, null, style::StructStyle) where {T,O} - if !StructUtils.TRIM_BUILD && !StructUtils.ishot(T) && style isa _FUSED_STYLE + if StructUtils.TRIM_BUILD + # trim binaries need the static call graph + y, pos = StructUtils.make(style, T, x) + getisroot(x) && checkendpos(x, T, pos) + return y + end + if !StructUtils.ishot(T) && style isa _FUSED_STYLE # tier-0 default for every typed parse: one lazy pass drives the # field-table interpreter — any target type, no per-type compile # beyond a field-table build. Called directly (not via the `make` @@ -271,10 +277,16 @@ function _parse(x::LazyValue, ::Type{T}, dicttype::Type{O}, null, style::StructS getisroot(x) && checkendpos(x, T, pos) return y::T end - # the fully-specialized static descent: trim builds (the verifier needs - # its static call graph), `:hot` types (compiled into their defining - # package's image by the precompile hook), and custom dicttype/null - # (those change materialization semantics) + # runtime-dispatch boundary: with the route decided at runtime, a + # directly-reachable specialized descent would be JIT-compiled per + # target type even when the fused route is always taken — exactly the + # first-call cost tier-0 exists to remove. Types that genuinely route + # here (`:hot`, custom styles/dicttype/null) pay one dynamic dispatch + # per parse. + return Base.invokelatest(_parse_specialized, x, T, style) +end + +function _parse_specialized(x::LazyValue, ::Type{T}, style::StructStyle) where {T} y, pos = StructUtils.make(style, T, x) getisroot(x) && checkendpos(x, T, pos) return y From 86f69c62d9952ad9d71cf0a2dc25d39b889f2404 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Thu, 16 Jul 2026 10:12:35 -0600 Subject: [PATCH 10/13] fix(parse): static error rendering in the Any make; re-enable custom style tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Any-make fallback interpolated the LazyValue into its error string — show(::LazyValue) materializes and prints a LazyArray through Base's matrix-show machinery, ~40 unresolved verifier errors under --trim from one statement. It now renders position + type through the trim-clean invalid() helper. Re-enable the CustomJSONStyle tests (commented since the old choosetype function hook was removed): per-style lift overloads work through the existing inner-style forwarding, and @choosetype-emitted make overrides dispatch on the read wrapper carrying the custom style as its third parameter (JSONReadStyle{<:Any,<:Any,CustomJSONStyle}) — only traits and lift forward to the inner style, make methods dispatch on the wrapper. Co-Authored-By: Claude Fable 5 --- src/parse.jl | 5 ++++- test/parse.jl | 21 ++++++++++++++------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/parse.jl b/src/parse.jl index 04b39d9..ac8662f 100644 --- a/src/parse.jl +++ b/src/parse.jl @@ -428,7 +428,10 @@ function StructUtils.make(st::StructStyle, ::Type{Any}, x::LazyValues) elseif type == JSONTypes.TRUE || type == JSONTypes.FALSE return StructUtils.lift(st, Bool, x) else - throw(ArgumentError("cannot parse $x")) + # no value interpolation: `show(::LazyValue)` materializes and prints + # a LazyArray through Base's matrix-show machinery — a large dynamic + # fan-out under --trim; `invalid` renders position + type statically + invalid(InvalidJSON, getbuf(x), getpos(x), Any) end end diff --git a/test/parse.jl b/test/parse.jl index a20980a..1a2e082 100644 --- a/test/parse.jl +++ b/test/parse.jl @@ -726,16 +726,23 @@ JSON.lift(::DateMaterializedObjectStyle, ::Type{Date}, x::JSON.Object) = Date(x[ m = Array{Float64,0}(undef) m[1] = 1.0 @test JSON.parse("1.0", Array{Float64,0}) == m - # test custom JSONStyle - # StructUtils.lift(::CustomJSONStyle, ::Type{UUID}, x) = UUID(UInt128(x)) - # @test JSON.parse("340282366920938463463374607431768211455", UUID; style=CustomJSONStyle()) == UUID(typemax(UInt128)) - # @test JSON.parse("{\"id\": 0, \"uuid\": 340282366920938463463374607431768211455}", N; style=CustomJSONStyle()) == N(0, UUID(typemax(UInt128))) + # test custom JSONStyle: per-style lift + @choosetype route through + # the specialized descent, where user hooks see raw lazy values + StructUtils.lift(::CustomJSONStyle, ::Type{UUID}, x) = UUID(UInt128(x)) + @test JSON.parse("340282366920938463463374607431768211455", UUID; style=CustomJSONStyle()) == UUID(typemax(UInt128)) + @test JSON.parse("{\"id\": 0, \"uuid\": 340282366920938463463374607431768211455}", N; style=CustomJSONStyle()) == N(0, UUID(typemax(UInt128))) # tricky unions @test JSON.parse("{\"id\":0}", O) == O(0, nothing) @test JSON.parse("{\"id\":0,\"name\":null}", O) == O(0, missing) - # StructUtils.choosetype(::CustomJSONStyle, ::Type{Union{I,L,Missing,Nothing}}, val) = JSON.gettype(val) == JSON.JSONTypes.NULL ? Missing : hasproperty(val, :fruit) ? I : L - # @test JSON.parse("{\"id\":0,\"name\":{\"id\":1,\"name\":\"jim\",\"fruit\":\"apple\"}}", O; style=CustomJSONStyle()) == O(0, I(1, "jim", apple)) - # @test JSON.parse("{\"id\":0,\"name\":{\"id\":1,\"firstName\":\"jim\",\"rate\":3.14}}", O; style=CustomJSONStyle()) == O(0, L(1, "jim", 3.14)) + # style-scoped make overrides (which @choosetype emits) dispatch on + # the READ WRAPPER carrying the custom style as its third parameter: + # JSON wraps user styles in JSONReadStyle, and only traits/lift are + # forwarded to the inner style + StructUtils.@choosetype JSON.JSONReadStyle{<:Any,<:Any,CustomJSONStyle} Union{I,L,Missing,Nothing} val -> + JSON.gettype(val) == JSON.JSONTypes.NULL ? Missing : + hasproperty(val, :fruit) ? I : L + @test JSON.parse("{\"id\":0,\"name\":{\"id\":1,\"name\":\"jim\",\"fruit\":\"apple\"}}", O; style=CustomJSONStyle()) == O(0, I(1, "jim", apple)) + @test JSON.parse("{\"id\":0,\"name\":{\"id\":1,\"firstName\":\"jim\",\"rate\":3.14}}", O; style=CustomJSONStyle()) == O(0, L(1, "jim", 3.14)) StructUtils.liftkey(::JSON.JSONStyle, ::Type{Point}, x::String) = Point(parse(Int, split(x, "_")[1]), parse(Int, split(x, "_")[2])) @test JSON.parse("{\"1_2\":\"hi\"}", Dict{Point, String}) == Dict(Point(1, 2) => "hi") From 1536d984bb16ae6e7fba3062228f2614466dd069 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Fri, 17 Jul 2026 23:52:30 -0600 Subject: [PATCH 11/13] docs: plain-language comments throughout the typed-parse engine Rewrites tier0.jl's header and the routing comments in parse.jl for a fresh reader: what the engine is (one pass over the lazy JSON driving the field-table interpreter, compiled once), which configurations use it and why the others don't, and why the per-type path sits behind invokelatest (a plain call would compile the whole per-type descent for every parsed type, recreating the first-call latency the engine removes). No references to deleted code paths or session history. Suite green including the trim-compile case. Co-Authored-By: Claude Fable 5 --- src/parse.jl | 20 +++++++------- src/tier0.jl | 74 +++++++++++++++++++++++++++++----------------------- 2 files changed, 50 insertions(+), 44 deletions(-) diff --git a/src/parse.jl b/src/parse.jl index ac8662f..139d2a1 100644 --- a/src/parse.jl +++ b/src/parse.jl @@ -268,21 +268,19 @@ function _parse(x::LazyValue, ::Type{T}, dicttype::Type{O}, null, style::StructS return y end if !StructUtils.ishot(T) && style isa _FUSED_STYLE - # tier-0 default for every typed parse: one lazy pass drives the - # field-table interpreter — any target type, no per-type compile - # beyond a field-table build. Called directly (not via the `make` - # dispatcher) so the never-taken specialized-descent arm isn't - # compiled per type. + # the default: one lazy pass drives the field-table interpreter — + # any target type, and a new type costs a table build, not a compile y, pos = _fused_make(style, T, x) getisroot(x) && checkendpos(x, T, pos) return y::T end - # runtime-dispatch boundary: with the route decided at runtime, a - # directly-reachable specialized descent would be JIT-compiled per - # target type even when the fused route is always taken — exactly the - # first-call cost tier-0 exists to remove. Types that genuinely route - # here (`:hot`, custom styles/dicttype/null) pay one dynamic dispatch - # per parse. + # The per-type path is reached through invokelatest on purpose: the + # route is decided at runtime, and if this call were a plain one the + # compiler would compile the whole per-type descent for EVERY parsed + # type — even the ones that always take the engine above — recreating + # the first-call latency the engine exists to remove. Types that + # genuinely come here (`:hot`, custom styles/dicttype/null) pay one + # dynamic dispatch per parse. return Base.invokelatest(_parse_specialized, x, T, style) end diff --git a/src/tier0.jl b/src/tier0.jl index 22edffa..d69e1dd 100644 --- a/src/tier0.jl +++ b/src/tier0.jl @@ -1,26 +1,32 @@ -# Tier-0 typed parsing for JSON: a single lazy pass drives the StructUtils -# field-table interpreter's slots directly from applyobject — no intermediate -# tree, closures parameterized by style only (never the target type), so the -# whole engine compiles once and ships in this package's image via the -# workload. Fully capable: every target type routes here under the default -# read configuration — struct objects and element vectors drive the lazy -# tokens directly, custom kinds hand the RAW lazy value to the generic -# machinery (user hooks keep their semantics), and every other (kind, shape) -# pairing materializes its subtree into the interpreter's boxed arms. -# JIT-only: under StructUtils.TRIM_BUILD typed parsing goes through the -# specialized hot descent (the trim verifier needs its static call graph). +# The default typed-parse engine: one pass over the lazy JSON drives the +# StructUtils field-table interpreter directly — no intermediate tree. The +# closures below are parameterized by style only, never by the target type, +# so this whole file compiles once (during JSON's own precompilation, via +# the workload) and a user's first typed parse of any struct costs a table +# build instead of a compile. # -# Also here: the :hot precompile hook JSON registers with StructUtils (each -# :hot-annotated struct's typed parse/write compiles into its defining -# package's image), plus the field-table-driven sample synthesizer it uses. +# It handles every target type: struct objects and element vectors stream +# straight off the lazy tokens; a field whose type has its own lift/make/ +# choosetype hooks receives the raw lazy value, so user hooks see exactly +# what they'd see on the per-type path; everything else (dict/tuple/set/ +# multidim-array fields, mismatched shapes) reads its subtree into plain +# Julia values and lets the interpreter's generic handlers finish. +# +# Trimmed binaries skip this file's route entirely (its dispatch decisions +# happen at runtime, which a trimmed binary can't compile for) and use the +# per-type path, whose call targets are all static. +# +# Also here: the precompile hook JSON registers with StructUtils — when a +# downstream package defines a `:hot` struct, this hook parses synthesized +# samples during THAT package's precompilation so the type's specialized +# parse/write code lands in its image. -# the tier-0 route: the default read configuration only. Custom dicttype/ -# null change materialization semantics, and custom inner styles can carry -# per-style `lift(::MyStyle, ::Type{T}, ::LazyValue)` overloads or trait -# overrides (dictlike/arraylike) that the engine's materializing scalar -# ladder and structural spec tree would silently bypass — those take the -# fully-specialized descent, where every hook sees exactly what classic -# handed it. +# Only the default read configuration uses the engine. A custom dicttype or +# null changes what values materialize as, and a custom style can carry +# per-style lazy `lift` methods or trait overrides that the engine would +# silently skip (it reads scalars into plain values before lifting, and +# classifies types structurally) — so those all take the per-type path, +# where every user hook is dispatched normally. const _FUSED_STYLE = JSONReadStyle{DEFAULT_OBJECT_TYPE,Nothing,StructUtils.DefaultStyle} # ---------------- fused tier-0 lazy interpretation ---------------- @@ -65,8 +71,8 @@ function _fused_fillslot!(style::StructStyle, slots::Vector{Any}, i::Int, sp::StructUtils.FieldSpec, v::LazyValue) vs = sp.spec if gettype(v) == JSONTypes.NULL - # classic @_peel order: when a field admits both, an explicit null - # takes the Missing arm first + # an explicit null fills a field that admits both Missing and + # Nothing with `missing` (matching how make resolves such unions) if vs.missingable slots[i] = missing elseif vs.nullable @@ -87,9 +93,9 @@ function _fused_fillslot!(style::StructStyle, slots::Vector{Any}, i::Int, return pos end -# positional struct fill from a JSON array source: classic's lazy applyeach -# handed the struct closures Int keys for arrays, so field order is the -# match (surplus elements go to the style's unknownfield hook) +# structs can also fill from a JSON array: elements map to fields in +# declaration order, and surplus elements go to the style's unknownfield +# hook (ignored by default) struct FusedPosClosure{S<:StructStyle} style::S tbl::StructUtils.FieldTable @@ -130,7 +136,7 @@ function (f::FusedArrClosure{S})(_, v::LazyValue) where {S} el = f.el local val, pos if gettype(v) == JSONTypes.NULL - # classic @_peel order: Missing arm first + # null element: Missing arm first, as in the object closure above if el.missingable val, pos = missing, getpos(v) + 4 elseif el.nullable @@ -150,8 +156,8 @@ end # Struct objects and element vectors — the overwhelmingly common shapes — # drive the lazy tokens directly; custom kinds hand the RAW lazy value to # the generic machinery; every other (kind, shape) pairing materializes its -# subtree and reuses the interpreter's boxed arms — full capability, still -# no per-type compile. +# subtree into plain Julia values and hands them to the interpreter's +# generic handlers — every shape covered, still no per-type compile. function _fused_spec(style::StructStyle, vs::StructUtils.ValueSpec, v::LazyValue, name::String) k = vs.kind if k == StructUtils.KIND_STRUCT @@ -193,15 +199,17 @@ function _fused_spec(style::StructStyle, vs::StructUtils.ValueSpec, v::LazyValue end # dict/tuple/set/fixed-array kinds, unsupported leaves, and shape # mismatches (struct-from-array, vector-from-object): materialize the - # subtree; the interpreter's boxed arms handle any shape + # subtree into plain values; the interpreter's generic handlers cover + # any shape out = ValueClosure() pos = applyvalue(out, v, nothing) return StructUtils._spec_value(style, vs, out.value, name), pos end # scalar leaves: parse the base JSON scalar lazily, then produce the -# exact-typed value through the interpreter's kind ladder (ISO dates, int -# widths, symbols, chars, and the JIT lift fallback for odd pairings) +# exact-typed value through the interpreter's per-kind conversions (ISO +# dates, integer widths, symbols, chars, with a generic lift fallback for +# odd pairings) function _fused_scalar(style::StructStyle, vs::StructUtils.ValueSpec, v::LazyValue, name::String) kind = vs.kind ft = vs.ft @@ -226,7 +234,7 @@ function _fused_scalar(style::StructStyle, vs::StructUtils.ValueSpec, v::LazyVal return StructUtils._liftleaf(style, kind, ft, false, name), getpos(v) + 5 else # aggregate token into a scalar-kind field: materialize the subtree; - # the interpreter's boxed arms (lift fallback included) decide + # the interpreter's generic handlers (lift fallback included) decide out = ValueClosure() pos = applyvalue(out, v, nothing) return StructUtils._spec_value(style, vs, out.value, name), pos From dc6eadbdb94f19059027a6629c5743482607c558 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Sat, 18 Jul 2026 00:04:33 -0600 Subject: [PATCH 12/13] docs: present-tense mechanism for the invokelatest routing comment Co-Authored-By: Claude Fable 5 --- Manifest-v1.12.toml | 88 +++++++++++++++++++++++++++++++++++++++++++++ src/parse.jl | 12 +++---- 2 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 Manifest-v1.12.toml diff --git a/Manifest-v1.12.toml b/Manifest-v1.12.toml new file mode 100644 index 0000000..979f7fe --- /dev/null +++ b/Manifest-v1.12.toml @@ -0,0 +1,88 @@ +# This file is machine-generated - editing it directly is not advised + +julia_version = "1.12.3" +manifest_format = "2.0" +project_hash = "19f7a579e8486201e771cd906646aaa821873ea9" + +[[deps.Dates]] +deps = ["Printf"] +uuid = "ade2ca70-3891-5945-98fb-dc099432e06a" +version = "1.11.0" + +[[deps.JSON]] +deps = ["Dates", "Logging", "Parsers", "PrecompileTools", "StructUtils", "UUIDs", "Unicode"] +path = "." +uuid = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" +version = "1.6.1" + + [deps.JSON.extensions] + JSONArrowExt = ["ArrowTypes"] + + [deps.JSON.weakdeps] + ArrowTypes = "31f734f8-188a-4ce0-8406-c8a06bd891cd" + +[[deps.Logging]] +uuid = "56ddb016-857b-54e1-b83d-db4d58db5568" +version = "1.11.0" + +[[deps.Parsers]] +deps = ["Dates", "PrecompileTools", "UUIDs"] +git-tree-sha1 = "32a4e09c5f29402573d673901778a0e03b0807b9" +uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" +version = "2.8.6" + +[[deps.PrecompileTools]] +deps = ["Preferences"] +git-tree-sha1 = "edbeefc7a4889f528644251bdb5fc9ab5348bc2c" +uuid = "aea7be01-6a6a-4083-8856-8a6e6704d82a" +version = "1.3.4" + +[[deps.Preferences]] +deps = ["TOML"] +git-tree-sha1 = "8b770b60760d4451834fe79dd483e318eee709c4" +uuid = "21216c6a-2e73-6563-6e65-726566657250" +version = "1.5.2" + +[[deps.Printf]] +deps = ["Unicode"] +uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7" +version = "1.11.0" + +[[deps.Random]] +deps = ["SHA"] +uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +version = "1.11.0" + +[[deps.SHA]] +uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" +version = "0.7.0" + +[[deps.StructUtils]] +deps = ["Dates", "UUIDs"] +git-tree-sha1 = "82bee338d650aa515f31866c460cb7e3bcef90b8" +uuid = "ec057cc2-7a8d-4b58-b3b3-92acb9f63b42" +version = "2.8.2" + + [deps.StructUtils.extensions] + StructUtilsMeasurementsExt = ["Measurements"] + StructUtilsStaticArraysCoreExt = ["StaticArraysCore"] + StructUtilsTablesExt = ["Tables"] + + [deps.StructUtils.weakdeps] + Measurements = "eff96d63-e80a-5855-80a2-b1b0885c5ab7" + StaticArraysCore = "1e83bf80-4336-4d27-bf5d-d5a4f845583c" + Tables = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" + +[[deps.TOML]] +deps = ["Dates"] +uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76" +version = "1.0.3" + +[[deps.UUIDs]] +deps = ["Random", "SHA"] +uuid = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" +version = "1.11.0" + +[[deps.Unicode]] +uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" +version = "1.11.0" diff --git a/src/parse.jl b/src/parse.jl index 139d2a1..98d2bd8 100644 --- a/src/parse.jl +++ b/src/parse.jl @@ -274,13 +274,11 @@ function _parse(x::LazyValue, ::Type{T}, dicttype::Type{O}, null, style::StructS getisroot(x) && checkendpos(x, T, pos) return y::T end - # The per-type path is reached through invokelatest on purpose: the - # route is decided at runtime, and if this call were a plain one the - # compiler would compile the whole per-type descent for EVERY parsed - # type — even the ones that always take the engine above — recreating - # the first-call latency the engine exists to remove. Types that - # genuinely come here (`:hot`, custom styles/dicttype/null) pay one - # dynamic dispatch per parse. + # invokelatest keeps the per-type descent out of this function's + # compilation: a plain call here would specialize the whole descent for + # every parsed type, including the ones the engine above handles. Types + # that route here (`:hot`, custom styles/dicttype/null) pay one dynamic + # dispatch per parse. return Base.invokelatest(_parse_specialized, x, T, style) end From 402cb7402b7cb9dcfd61edfd406d95c2dcb4978b Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Sat, 18 Jul 2026 00:32:22 -0600 Subject: [PATCH 13/13] api: consume only StructUtils' public engine surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every StructUtils name the engine touches is now public (construct, makevalue, liftleaf, findspec, allocvector, the table/spec types and kind constants) — no leading-underscore internals. JSONText declares StructUtils.custommake, replacing reflection-based detection of its make override. Co-Authored-By: Claude Fable 5 --- src/parse.jl | 3 +++ src/tier0.jl | 27 +++++++++++++++------------ test/interp_default.jl | 4 ++-- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/parse.jl b/src/parse.jl index 98d2bd8..104ca2e 100644 --- a/src/parse.jl +++ b/src/parse.jl @@ -557,6 +557,9 @@ function StructUtils.lift(style::JSONReadStyle, ::Type{T}, x::LazyValues, tags=( end end +# JSONText fields must reach this make method rather than being field-parsed +StructUtils.custommake(::Type{JSONText}) = true + function StructUtils.make(::StructStyle, ::Type{JSONText}, x::LazyValues) buf = getbuf(x) pos = getpos(x) diff --git a/src/tier0.jl b/src/tier0.jl index d69e1dd..af53c95 100644 --- a/src/tier0.jl +++ b/src/tier0.jl @@ -51,7 +51,7 @@ function (f::FusedObjClosure{S})(k::PtrString, v::LazyValue) where {S} # extra candidates (only consulted when the table declares any) for j = 1:length(specs) if @inbounds(specs[j]).aliases !== nothing - i = StructUtils._findspec(specs, convert(String, k)) + i = StructUtils.findspec(specs, convert(String, k)) break end end @@ -125,6 +125,7 @@ function _fused_field(style::StructStyle, sp::StructUtils.FieldSpec, v::LazyValu return _fused_spec(style, vs, v, sp.name) end +# applyarray closure appending each parsed element to the field vector struct FusedArrClosure{S<:StructStyle} style::S el::StructUtils.ValueSpec @@ -152,7 +153,7 @@ function (f::FusedArrClosure{S})(_, v::LazyValue) where {S} return pos end -# position-level recursion mirroring StructUtils._spec_value, driven lazily. +# position-level recursion mirroring StructUtils.makevalue, driven lazily. # Struct objects and element vectors — the overwhelmingly common shapes — # drive the lazy tokens directly; custom kinds hand the RAW lazy value to # the generic machinery; every other (kind, shape) pairing materializes its @@ -172,7 +173,7 @@ function _fused_spec(style::StructStyle, vs::StructUtils.ValueSpec, v::LazyValue elseif k == StructUtils.KIND_VECTOR if gettype(v) == JSONTypes.ARRAY el = vs.child::StructUtils.ValueSpec - arr = StructUtils._alloc_vector(el.declft, 0) + arr = StructUtils.allocvector(el.declft, 0) f = FusedArrClosure{typeof(style)}(style, el, arr, name) pos = applyarray(f, v) pos isa Int || (pos = skip(v)) @@ -203,7 +204,7 @@ function _fused_spec(style::StructStyle, vs::StructUtils.ValueSpec, v::LazyValue # any shape out = ValueClosure() pos = applyvalue(out, v, nothing) - return StructUtils._spec_value(style, vs, out.value, name), pos + return StructUtils.makevalue(style, vs, out.value, name), pos end # scalar leaves: parse the base JSON scalar lazily, then produce the @@ -221,23 +222,23 @@ function _fused_scalar(style::StructStyle, vs::StructUtils.ValueSpec, v::LazyVal str, pos = parsestring(v) s = convert(String, str) end - return StructUtils._liftleaf(style, kind, ft, s, name), pos + return StructUtils.liftleaf(style, kind, ft, s, name), pos elseif t == JSONTypes.NUMBER num, pos = parsenumber(v) raw = isint(num) ? num.int : isfloat(num) ? num.float : isbigint(num) ? num.bigint : num.bigfloat - return StructUtils._liftleaf(style, kind, ft, raw, name), pos + return StructUtils.liftleaf(style, kind, ft, raw, name), pos elseif t == JSONTypes.TRUE - return StructUtils._liftleaf(style, kind, ft, true, name), getpos(v) + 4 + return StructUtils.liftleaf(style, kind, ft, true, name), getpos(v) + 4 elseif t == JSONTypes.FALSE - return StructUtils._liftleaf(style, kind, ft, false, name), getpos(v) + 5 + return StructUtils.liftleaf(style, kind, ft, false, name), getpos(v) + 5 else # aggregate token into a scalar-kind field: materialize the subtree; # the interpreter's generic handlers (lift fallback included) decide out = ValueClosure() pos = applyvalue(out, v, nothing) - return StructUtils._spec_value(style, vs, out.value, name), pos + return StructUtils.makevalue(style, vs, out.value, name), pos end end @@ -249,15 +250,16 @@ end f = FusedObjClosure{typeof(style)}(style, tbl, slots) pos = applyobject(f, v) pos isa Int || (pos = skip(v)) - return StructUtils._construct_interp(style, tbl, slots, v), pos + return StructUtils.construct(style, tbl, slots, v), pos end +# struct-from-array: fill the slot buffer positionally function _fused_struct_positional(style::StructStyle, tbl::StructUtils.FieldTable, v::LazyValue) slots = Vector{Any}(undef, length(tbl.specs)) f = FusedPosClosure{typeof(style)}(style, tbl, slots) pos = applyarray(f, v) pos isa Int || (pos = skip(v)) - return StructUtils._construct_interp(style, tbl, slots, v), pos + return StructUtils.construct(style, tbl, slots, v), pos end # root entry for ANY target type: the spec tree describes T (built once per @@ -273,7 +275,7 @@ end return val, st isa Int ? st : skip(v) end if gettype(v) == JSONTypes.NULL - return StructUtils._spec_nullwrap(style, vs, nothing, "root"), getpos(v) + 4 + return StructUtils.makevalue(style, vs, nothing, "root"), getpos(v) + 4 end return _fused_spec(style, vs, v, "root") end @@ -334,6 +336,7 @@ function _synthesize_sample(@nospecialize(T)) return String(take!(io)) end +# a minimal JSON fragment satisfying one field spec, or nothing to omit it function _synth_value(vs::StructUtils.ValueSpec) SU = StructUtils kind = vs.kind diff --git a/test/interp_default.jl b/test/interp_default.jl index c65b838..0061b63 100644 --- a/test/interp_default.jl +++ b/test/interp_default.jl @@ -100,7 +100,7 @@ const IDJSON = """ # the hot hook is registered with StructUtils @test any(h -> h === JSON._hot_json_hook, StructUtils.HOT_HOOKS) # and runs cleanly under force for both annotated and plain types - StructUtils._hot_precompile!(IDHotTier; force=true) - StructUtils._hot_precompile!(IDEvent, ("{\"name\":\"s\"}",); force=true) + StructUtils.hot_precompile!(IDHotTier; force=true) + StructUtils.hot_precompile!(IDEvent, ("{\"name\":\"s\"}",); force=true) @test true end