From a2090c193e89c96b4a3cddd57ebfd289fe9ea5b0 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Fri, 10 Jul 2026 21:17:51 -0600 Subject: [PATCH 1/5] fix: make the struct machinery resolvable under juliac --trim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four changes that together take a typed parse+write workload from ~330 verifier errors to zero (with JSON.jl's error-path fixes), measured via juliac --trim=safe: - findfield / TupleClosure: @generated with literal field indices and field types. The runtime `i` closure made `fieldtype(T, i)` abstract, funneling every `make` call through one generic instance where every trait branch and recursive call is dynamic. - make dispatchers: `::Type{T} where T` instead of bare `T::Type`, which julia deliberately doesn't specialize — findfield could hand them constant types and still land in the unspecialized instance. - union disambiguation: @generated _unionmake unrolls the arraylike-vs-scalar check with literal component types (Base.uniontypes builds a runtime Vector{Any}, making the recursive make calls dynamic). The rule only ever matches two-component unions, so others return nothing and fall through as before. - @generated struct applyeach: explicit isa-split of the closure call for small-Union fields (e.g. Union{Nothing,String} optional-field structs) — inference doesn't reliably union-split these call sites, leaving dynamic dispatch. A final else branch keeps custom lowers that change the value's type correct. No semantic changes; full suite green (incl. the trim workload testset). --- src/StructUtils.jl | 236 +++++++++++++++++++++++++-------------------- 1 file changed, 130 insertions(+), 106 deletions(-) diff --git a/src/StructUtils.jl b/src/StructUtils.jl index cfdcab2..498dd66 100644 --- a/src/StructUtils.jl +++ b/src/StructUtils.jl @@ -600,12 +600,27 @@ function applyeach(st::StructStyle, f, x::T) where {T} end for i = 1:N fname = Meta.quot(fieldname(T, i)) + # for small-Union fields, split the closure call per component with an + # explicit isa chain: inference doesn't reliably union-split these call + # sites on its own, leaving dynamic dispatch that `--trim` can't resolve. + # `lower` can change the value's type (custom lowerings), so the final + # `else` keeps those correct — it's dead code for identity lowers. + callex = :(f(key, val)) + ft = fieldtype(T, i) + comps = ft isa Union ? Base.uniontypes(ft) : nothing + if comps !== nothing && length(comps) <= 4 && all(c -> isconcretetype(c) || c === Nothing, comps) + for c in reverse(comps) + callex = Expr(:if, :(val isa $c), :(f(key, val)), callex) + end + end push!(ex.args, quote ftags = fieldtags(st, T, $fname) if !haskey(ftags, :ignore) || !ftags.ignore fname = get(ftags, :name, $fname) ret = if isdefined(x, $i) - f(lowerkey(st, fname), lower(st, getfield(x, $i), ftags)) + key = lowerkey(st, fname) + val = lower(st, getfield(x, $i), ftags) + $callex elseif haskey(defs, $fname) # this branch should be really rare because we should # have applied a field default in the struct constructor @@ -846,7 +861,10 @@ end @inline abstractcollectionpassthrough(style::StructStyle, ::Type{T}, source) where {T} = isabstracttype(T) && source isa T && (dictlike(style, T) || arraylike(style, T)) -function make(style::StructStyle, T::Type, source, tags) +# `::Type{T} where T` (not bare `T::Type`, which julia deliberately doesn't specialize): +# this dispatcher must specialize per target type so its trait branches fold — otherwise +# one generic instance with everything dynamic ends up in `--trim` binaries +function make(style::StructStyle, ::Type{T}, source, tags) where {T} if haskey(tags, :choosetype) return make(style, tags.choosetype(source), source, _delete(tags, :choosetype)) end @@ -868,33 +886,8 @@ function make(style::StructStyle, T::Type, source, tags) # we can disambiguate by checking if source is arraylike; # only applies when there's exactly one arraylike and one non-arraylike member if T isa Union - types = Base.uniontypes(T) - arr_type = nothing - scalar_type = nothing - ambiguous = false - for t in types - if arraylike(style, t) - # more than one arraylike type means we can't disambiguate - if arr_type !== nothing - ambiguous = true - break - end - arr_type = t - else - if scalar_type !== nothing - ambiguous = true - break - end - scalar_type = t - end - end - if !ambiguous && arr_type !== nothing && scalar_type !== nothing - if arraylike(style, source) - return make(style, arr_type, source, tags) - else - return make(style, scalar_type, source, tags) - end - end + r = _unionmake(style, T, source, tags) + r !== nothing && return r end end if T <: Tuple || dictlike(style, T) || arraylike(style, T) || noarg(style, T) || structlike(style, T) @@ -904,7 +897,47 @@ function make(style::StructStyle, T::Type, source, tags) end end -function make(style::StructStyle, T::Type, source) +# unrolls the arraylike-vs-scalar union disambiguation with literal component types +# (`Base.uniontypes` builds a runtime Vector{Any}, which makes the recursive `make` +# calls dynamic — type-unstable and unresolvable under `--trim`). The rule can only +# ever match two-component unions ("exactly one arraylike and one non-arraylike"), +# so anything else returns `nothing` (ambiguous) and the caller falls through. +# The `arraylike` trait calls stay in the emitted code — they are style-dependent — +# but fold at inference given the literal types. +@generated function _unionmake(style::StructStyle, ::Type{T}, source, tags) where {T} + types = Base.uniontypes(T) + length(types) == 2 || return :(return nothing) + A, B = types + return quote + a_arr = arraylike(style, $A) + b_arr = arraylike(style, $B) + if a_arr && !b_arr + return arraylike(style, source) ? make(style, $A, source, tags) : make(style, $B, source, tags) + elseif b_arr && !a_arr + return arraylike(style, source) ? make(style, $B, source, tags) : make(style, $A, source, tags) + end + return nothing + end +end + +@generated function _unionmake(style::StructStyle, ::Type{T}, source) where {T} + types = Base.uniontypes(T) + length(types) == 2 || return :(return nothing) + A, B = types + return quote + a_arr = arraylike(style, $A) + b_arr = arraylike(style, $B) + if a_arr && !b_arr + return arraylike(style, source) ? make(style, $A, source) : make(style, $B, source) + elseif b_arr && !a_arr + return arraylike(style, source) ? make(style, $B, source) : make(style, $A, source) + end + return nothing + end +end + +# see the 4-arg method: must specialize per target type (`::Type{T}`, not `T::Type`) +function make(style::StructStyle, ::Type{T}, source) where {T} if abstractcollectionpassthrough(style, T, source) return source, defaultstate(style) end @@ -923,37 +956,10 @@ function make(style::StructStyle, T::Type, source) return make(style, Base.nonnothingtype(T), source) end end - # for Union types like Union{T, Vector{T}} (after Nothing/Missing have been peeled), - # we can disambiguate by checking if source is arraylike; - # only applies when there's exactly one arraylike and one non-arraylike member + # see _unionmake: unrolled two-component union disambiguation if T isa Union - types = Base.uniontypes(T) - arr_type = nothing - scalar_type = nothing - ambiguous = false - for t in types - if arraylike(style, t) - # more than one arraylike type means we can't disambiguate - if arr_type !== nothing - ambiguous = true - break - end - arr_type = t - else - if scalar_type !== nothing - ambiguous = true - break - end - scalar_type = t - end - end - if !ambiguous && arr_type !== nothing && scalar_type !== nothing - if arraylike(style, source) - return make(style, arr_type, source) - else - return make(style, scalar_type, source) - end - end + r = _unionmake(style, T, source) + r !== nothing && return r end end if T <: Tuple @@ -995,27 +1001,36 @@ struct TupleClosure{T,A,S} i::Ptr{Int} end -function (f::TupleClosure{T,A,S})(k, v) where {T,A,S} - st = _foreach(T) do i - if typeof(k) == Int - if k == i - intval, intst = make(f.style, fieldtype(T, i), v) - @inbounds f.vals[i] = intval - return EarlyReturn(_MatchedState(intst)) - end - else - j = unsafe_load(f.i) - if j == i - unsafe_store!(f.i, i + 1) - elseval, elsest = make(f.style, fieldtype(T, i), v) - @inbounds f.vals[i] = elseval - return EarlyReturn(_MatchedState(elsest)) +# generated for the same reason as `findfield`: literal field indices/types keep the +# `make` calls concrete (type-stable, and resolvable under `--trim`) +@generated function _tupleclosure(f::TupleClosure{T}, k, v) where {T} + ex = Expr(:block) + push!(ex.args, :(Base.@_inline_meta)) + for i = 1:fieldcount(T) + ft = fieldtype(T, i) + push!(ex.args, quote + if typeof(k) == Int + if k == $i + intval, intst = make(f.style, $ft, v) + @inbounds f.vals[$i] = intval + return intst + end + else + if unsafe_load(f.i) == $i + unsafe_store!(f.i, $i + 1) + elseval, elsest = make(f.style, $ft, v) + @inbounds f.vals[$i] = elseval + return elsest + end end - end + end) end - return st isa _MatchedState ? st.value : unknownfield(f.style, T, k, v) + push!(ex.args, :(return unknownfield(f.style, T, k, v))) + return ex end +(f::TupleClosure{T,A,S})(k, v) where {T,A,S} = _tupleclosure(f, k, v) + function maketuple(style, ::Type{T}, source) where {T} vals = mem(fieldcount(T)) ref = Ref(1) @@ -1134,37 +1149,46 @@ end setval!(vals::T, x, i) where {T} = _setfield!(vals, i, x) -function findfield(::Type{T}, k, v, f) where {T} - st = _foreach(T) do i - if typeof(k) == Symbol - fn = f.fsyms[i] - ftags = f.ftags[i] - field = get(ftags, :name, fn) - if keyeq(k, field) || keyeq(k, fn) - symval, symst = make(f.style, fieldtype(T, i), v, ftags) - setval!(f.vals, symval, i) - return EarlyReturn(_MatchedState(symst)) - end - elseif typeof(k) == Int - if k == i - ftags = f.ftags[i] - intval, intst = make(f.style, fieldtype(T, i), v, ftags) - setval!(f.vals, intval, i) - return EarlyReturn(_MatchedState(intst)) - end - else - fn = f.fsyms[i] - fstr = f.fstrs[i] - ftags = f.ftags[i] - field = get(ftags, :name, fstr) - if keyeq(k, field) - strval, strst = make(f.style, fieldtype(T, i), v, ftags) - setval!(f.vals, strval, i) - return EarlyReturn(_MatchedState(strst)) +# generated with literal field indices/types: a runtime `i` makes `fieldtype(T, i)` +# abstract, which funnels every `make` call into the unspecialized `T::Type` method — +# type-unstable and a pile of unresolvable dynamic calls under `--trim` +@generated function findfield(::Type{T}, k, v, f) where {T} + ex = Expr(:block) + push!(ex.args, :(Base.@_inline_meta)) + for i = 1:fieldcount(T) + ft = fieldtype(T, i) + push!(ex.args, quote + if typeof(k) == Symbol + let fn = f.fsyms[$i], ftags = f.ftags[$i] + field = get(ftags, :name, fn) + if keyeq(k, field) || keyeq(k, fn) + symval, symst = make(f.style, $ft, v, ftags) + setval!(f.vals, symval, $i) + return symst + end + end + elseif typeof(k) == Int + if k == $i + let ftags = f.ftags[$i] + intval, intst = make(f.style, $ft, v, ftags) + setval!(f.vals, intval, $i) + return intst + end + end + else + let fstr = f.fstrs[$i], ftags = f.ftags[$i] + field = get(ftags, :name, fstr) + if keyeq(k, field) + strval, strst = make(f.style, $ft, v, ftags) + setval!(f.vals, strval, $i) + return strst + end + end end - end + end) end - return st isa _MatchedState ? st.value : unknownfield(f.style, T, k, v) + push!(ex.args, :(return unknownfield(f.style, T, k, v))) + return ex end (f::StructClosure{T,A,S,FS,FSS,FT})(k, v) where {T,A,S,FS,FSS,FT} = findfield(T, k, v, f) From 0212e6c9805570ad89126538bd408656de62b283 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Sat, 11 Jul 2026 12:41:58 -0600 Subject: [PATCH 2/5] fix: typeassert the union-split closure calls so the optimizer can't tail-merge them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With identical f(key, val) calls in every isa branch, the SSA optimizer merges the branches back into a single call with a phi-union argument — exactly the dynamic call site the split exists to avoid. Asserting val to the component type in each branch makes the calls structurally distinct (and is a no-op at runtime since the isa guard already proved it). --- src/StructUtils.jl | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/StructUtils.jl b/src/StructUtils.jl index 498dd66..c9941ef 100644 --- a/src/StructUtils.jl +++ b/src/StructUtils.jl @@ -610,7 +610,10 @@ function applyeach(st::StructStyle, f, x::T) where {T} comps = ft isa Union ? Base.uniontypes(ft) : nothing if comps !== nothing && length(comps) <= 4 && all(c -> isconcretetype(c) || c === Nothing, comps) for c in reverse(comps) - callex = Expr(:if, :(val isa $c), :(f(key, val)), callex) + # the typeassert matters: with identical `f(key, val)` calls in + # every branch, the optimizer tail-merges them back into a single + # call with a φ-union argument, undoing the split + callex = Expr(:if, :(val isa $c), :(f(key, val::$c)), callex) end end push!(ex.args, quote From c9cc25a3b1b0c09cf13f73b7222155f128c2d16f Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Sat, 11 Jul 2026 12:46:42 -0600 Subject: [PATCH 3/5] fix: hoist the applyeach field value before the union-split call The isa split only wrapped the isdefined branch's closure call; the defaults/nothing branches kept their own unsplit call sites, and the optimizer's view of the merged value still produced a dynamic dispatch. Hoisting the three-way value selection first and making one split call after it covers all sources of the value. --- src/StructUtils.jl | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/StructUtils.jl b/src/StructUtils.jl index c9941ef..2cc1d84 100644 --- a/src/StructUtils.jl +++ b/src/StructUtils.jl @@ -620,17 +620,20 @@ function applyeach(st::StructStyle, f, x::T) where {T} ftags = fieldtags(st, T, $fname) if !haskey(ftags, :ignore) || !ftags.ignore fname = get(ftags, :name, $fname) - ret = if isdefined(x, $i) - key = lowerkey(st, fname) - val = lower(st, getfield(x, $i), ftags) - $callex + # hoist the value out of all three source branches, then make the + # (single) closure call through the isa split — a call left inside + # any one branch keeps its unsplit dynamic dispatch + val = if isdefined(x, $i) + lower(st, getfield(x, $i), ftags) elseif haskey(defs, $fname) # this branch should be really rare because we should # have applied a field default in the struct constructor - f(lowerkey(st, fname), lower(st, defs[$fname], ftags)) + lower(st, defs[$fname], ftags) else - f(lowerkey(st, fname), lower(st, nothing, ftags)) + lower(st, nothing, ftags) end + key = lowerkey(st, fname) + ret = $callex ret isa EarlyReturn && return ret end end) From 1ba17aeb2bd6bb9bffc9cab3411b7227c0fbdd85 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Tue, 14 Jul 2026 12:32:31 -0600 Subject: [PATCH 4/5] fix: assert make() pair returns in the container closures An Any-valued target (Dict{String,Any} values, Vector{Any} elements) makes the make() return type opaque, and destructuring an opaque value is dynamic dispatch under juliac --trim. NTuple{2,Any} typeasserts keep the destructure static without constraining the value types. Co-Authored-By: Claude Fable 5 --- src/StructUtils.jl | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/StructUtils.jl b/src/StructUtils.jl index 2cc1d84..a5d6b35 100644 --- a/src/StructUtils.jl +++ b/src/StructUtils.jl @@ -1053,7 +1053,10 @@ struct DictClosure{T,S} end function (f::DictClosure{T,S})(k, v) where {T,S} - val, st = make(f.style, _valtype(f.dict), v) + # NTuple{2,Any} assert: an Any-valued target (e.g. Dict{String,Any}) makes + # the make() return type opaque, and destructuring an opaque value is + # dynamic dispatch under `juliac --trim` + val, st = make(f.style, _valtype(f.dict), v)::NTuple{2,Any} addkeyval!(f.dict, liftkey(f.style, _keytype(f.dict), k), val) return st end @@ -1071,7 +1074,7 @@ struct ArrayClosure{T,S} end function (f::ArrayClosure{T,S})(_, v) where {T,S} - val, st = make(f.style, eltype(f.arr), v) + val, st = make(f.style, eltype(f.arr), v)::NTuple{2,Any} push!(f.arr, val) return st end @@ -1083,7 +1086,7 @@ struct FixedArrayClosure{A,S} end function (f::FixedArrayClosure{A,S})(_, v) where {A,S} - val, st = make(f.style, eltype(f.arr), v) + val, st = make(f.style, eltype(f.arr), v)::NTuple{2,Any} i = f.idx[] @inbounds f.arr[i] = val f.idx[] = i + 1 From 32329db82d79cdb0b775536bb94446fba0ad99d3 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Tue, 14 Jul 2026 13:01:06 -0600 Subject: [PATCH 5/5] perf: gate the trim specialization behind a compile-time preference Full per-target-type specialization is what the trim verifier needs and what first-call latency pays: on JSON.jl's benchmarks/structs.jl Root workload the specialized forms roughly double time-to-first-parse (~8.5s -> ~16.5s on 1.12.5/M-series), which every library user pays per fresh session while trim builds pay it once at AOT-compile time. The "trim_specialize" Preference (compile-time, so flipping it invalidates precompile caches -- an ENV switch would silently reuse stale images) selects between: - default (false): the single-generic-instance forms from main -- bare `T::Type` make dispatchers, runtime `_foreach` findfield/tuple closures, no small-Union write unrolling. Measured slightly FASTER than current main (~7.2s vs ~8.5s TTFX) because the branch's other changes (@generated _unionmake, hoist/typeassert fixes, NTuple make asserts) stay on. - trim (true): per-type make dispatchers (`::Type{T} where T`), literal-unrolled findfield/tuple closures, isa-split small-Union field writes -- the forms juliac --trim can resolve statically (~20.5s TTFX, paid at build time only). The make dispatcher bodies are written once and @eval'd into whichever signature form the preference selects, so the two modes cannot drift; the findfield/tuple closures differ algorithmically and are kept as two @static arms. The trim testset now writes the preference into its temp environment (the workload budget is only meaningful against the specialized forms). Co-Authored-By: Claude Fable 5 --- Project.toml | 2 + src/StructUtils.jl | 180 ++++++++++++++++++++++++++++--------- test/trim_compile_tests.jl | 3 + 3 files changed, 144 insertions(+), 41 deletions(-) diff --git a/Project.toml b/Project.toml index dea4f40..416deb8 100644 --- a/Project.toml +++ b/Project.toml @@ -4,6 +4,7 @@ version = "2.8.2" [deps] Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" +Preferences = "21216c6a-2e73-6563-6e65-726566657250" UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" [weakdeps] @@ -17,6 +18,7 @@ StructUtilsStaticArraysCoreExt = ["StaticArraysCore"] StructUtilsTablesExt = ["Tables"] [compat] +Preferences = "1" Measurements = "2" StaticArraysCore = "1" Tables = "1" diff --git a/src/StructUtils.jl b/src/StructUtils.jl index a5d6b35..1760fd0 100644 --- a/src/StructUtils.jl +++ b/src/StructUtils.jl @@ -1,5 +1,22 @@ module StructUtils +using Preferences + +# Compile-time preference ("trim_specialize"): `juliac --trim` builds opt into +# fully-specialized struct machinery — per-target-type `make` dispatchers, +# literal-unrolled `findfield`/tuple closures, and isa-split small-Union field +# writes — which the trim verifier can resolve statically. The default keeps +# the single-generic-instance forms: on a large nested-struct workload +# (JSON.jl benchmarks/structs.jl Root) full specialization roughly doubles +# first-call compile time, which library users pay on every fresh session, +# while trim builds pay it once at AOT-compile time. +# +# Enable from the build project with: +# Preferences.set_preferences!(StructUtils, "trim_specialize" => true) +# (a Preference, not an ENV switch, so flipping it correctly invalidates the +# precompile cache) +const TRIM_SPECIALIZE = @load_preference("trim_specialize", false)::Bool + using Dates, UUIDs export @noarg, @defaults, @tags, @kwarg, @nonstruct, Selectors @@ -608,7 +625,7 @@ function applyeach(st::StructStyle, f, x::T) where {T} callex = :(f(key, val)) ft = fieldtype(T, i) comps = ft isa Union ? Base.uniontypes(ft) : nothing - if comps !== nothing && length(comps) <= 4 && all(c -> isconcretetype(c) || c === Nothing, comps) + if TRIM_SPECIALIZE && comps !== nothing && length(comps) <= 4 && all(c -> isconcretetype(c) || c === Nothing, comps) for c in reverse(comps) # the typeassert matters: with identical `f(key, val)` calls in # every branch, the optimizer tail-merges them back into a single @@ -870,7 +887,11 @@ end # `::Type{T} where T` (not bare `T::Type`, which julia deliberately doesn't specialize): # this dispatcher must specialize per target type so its trait branches fold — otherwise # one generic instance with everything dynamic ends up in `--trim` binaries -function make(style::StructStyle, ::Type{T}, source, tags) where {T} +# One shared body, two signature forms (see TRIM_SPECIALIZE): `::Type{T} where T` +# specializes per target type — required for trim verification, but on big nested +# structs it roughly doubles first-call compile time versus the bare `T::Type` +# form, which julia deliberately compiles as a single generic instance. +const _MAKE4_BODY = quote if haskey(tags, :choosetype) return make(style, tags.choosetype(source), source, _delete(tags, :choosetype)) end @@ -902,6 +923,52 @@ function make(style::StructStyle, ::Type{T}, source, tags) where {T} return lift(style, T, source, tags) end end +const _MAKE3_BODY = quote + if abstractcollectionpassthrough(style, T, source) + return source, defaultstate(style) + end + # start with some hard-coded Union cases + if T !== Any + if T >: Missing && T !== Missing + if nulllike(style, source) + return make(style, Missing, source) + else + return make(style, nonmissingtype(T), source) + end + elseif T >: Nothing && T !== Nothing + if nulllike(style, source) + return make(style, Nothing, source) + else + return make(style, Base.nonnothingtype(T), source) + end + end + # see _unionmake: unrolled two-component union disambiguation + if T isa Union + r = _unionmake(style, T, source) + r !== nothing && return r + end + end + if T <: Tuple + return maketuple(style, T, source) + elseif dictlike(style, T) + return makedict(style, T, source) + elseif arraylike(style, T) + return makearray(style, T, source) + elseif noarg(style, T) + return makenoarg(style, T, source) + elseif structlike(style, T) + return makestruct(style, T, source) + else + return lift(style, T, source) + end +end +let sig4 = TRIM_SPECIALIZE ? :(make(style::StructStyle, ::Type{T}, source, tags) where {T}) : + :(make(style::StructStyle, T::Type, source, tags)), + sig3 = TRIM_SPECIALIZE ? :(make(style::StructStyle, ::Type{T}, source) where {T}) : + :(make(style::StructStyle, T::Type, source)) + @eval $(Expr(:function, sig4, _MAKE4_BODY)) + @eval $(Expr(:function, sig3, _MAKE3_BODY)) +end # unrolls the arraylike-vs-scalar union disambiguation with literal component types # (`Base.uniontypes` builds a runtime Vector{Any}, which makes the recursive `make` @@ -943,45 +1010,6 @@ end end # see the 4-arg method: must specialize per target type (`::Type{T}`, not `T::Type`) -function make(style::StructStyle, ::Type{T}, source) where {T} - if abstractcollectionpassthrough(style, T, source) - return source, defaultstate(style) - end - # start with some hard-coded Union cases - if T !== Any - if T >: Missing && T !== Missing - if nulllike(style, source) - return make(style, Missing, source) - else - return make(style, nonmissingtype(T), source) - end - elseif T >: Nothing && T !== Nothing - if nulllike(style, source) - return make(style, Nothing, source) - else - return make(style, Base.nonnothingtype(T), source) - end - end - # see _unionmake: unrolled two-component union disambiguation - if T isa Union - r = _unionmake(style, T, source) - r !== nothing && return r - end - end - if T <: Tuple - return maketuple(style, T, source) - elseif dictlike(style, T) - return makedict(style, T, source) - elseif arraylike(style, T) - return makearray(style, T, source) - elseif noarg(style, T) - return makenoarg(style, T, source) - elseif structlike(style, T) - return makestruct(style, T, source) - else - return lift(style, T, source) - end -end if VERSION < v"1.11" mem(n) = Vector{Any}(undef, n) @@ -1009,6 +1037,8 @@ end # generated for the same reason as `findfield`: literal field indices/types keep the # `make` calls concrete (type-stable, and resolvable under `--trim`) +@static if TRIM_SPECIALIZE + @generated function _tupleclosure(f::TupleClosure{T}, k, v) where {T} ex = Expr(:block) push!(ex.args, :(Base.@_inline_meta)) @@ -1035,8 +1065,34 @@ end return ex end + (f::TupleClosure{T,A,S})(k, v) where {T,A,S} = _tupleclosure(f, k, v) +else + +function (f::TupleClosure{T,A,S})(k, v) where {T,A,S} + st = _foreach(T) do i + if typeof(k) == Int + if k == i + intval, intst = make(f.style, fieldtype(T, i), v) + @inbounds f.vals[i] = intval + return EarlyReturn(_MatchedState(intst)) + end + else + j = unsafe_load(f.i) + if j == i + unsafe_store!(f.i, i + 1) + elseval, elsest = make(f.style, fieldtype(T, i), v) + @inbounds f.vals[i] = elseval + return EarlyReturn(_MatchedState(elsest)) + end + end + end + return st isa _MatchedState ? st.value : unknownfield(f.style, T, k, v) +end + +end # @static if TRIM_SPECIALIZE + function maketuple(style, ::Type{T}, source) where {T} vals = mem(fieldcount(T)) ref = Ref(1) @@ -1161,6 +1217,11 @@ setval!(vals::T, x, i) where {T} = _setfield!(vals, i, x) # generated with literal field indices/types: a runtime `i` makes `fieldtype(T, i)` # abstract, which funnels every `make` call into the unspecialized `T::Type` method — # type-unstable and a pile of unresolvable dynamic calls under `--trim` +@static if TRIM_SPECIALIZE + +# literal field indices and field types: the runtime-`i` closure form below +# makes `fieldtype(T, i)` abstract, funneling every make call through one +# generic instance — unresolvable under --trim @generated function findfield(::Type{T}, k, v, f) where {T} ex = Expr(:block) push!(ex.args, :(Base.@_inline_meta)) @@ -1200,6 +1261,43 @@ setval!(vals::T, x, i) where {T} = _setfield!(vals, i, x) return ex end +else + +function findfield(::Type{T}, k, v, f) where {T} + st = _foreach(T) do i + if typeof(k) == Symbol + fn = f.fsyms[i] + ftags = f.ftags[i] + field = get(ftags, :name, fn) + if keyeq(k, field) || keyeq(k, fn) + symval, symst = make(f.style, fieldtype(T, i), v, ftags) + setval!(f.vals, symval, i) + return EarlyReturn(_MatchedState(symst)) + end + elseif typeof(k) == Int + if k == i + ftags = f.ftags[i] + intval, intst = make(f.style, fieldtype(T, i), v, ftags) + setval!(f.vals, intval, i) + return EarlyReturn(_MatchedState(intst)) + end + else + fn = f.fsyms[i] + fstr = f.fstrs[i] + ftags = f.ftags[i] + field = get(ftags, :name, fstr) + if keyeq(k, field) + strval, strst = make(f.style, fieldtype(T, i), v, ftags) + setval!(f.vals, strval, i) + return EarlyReturn(_MatchedState(strst)) + end + end + end + return st isa _MatchedState ? st.value : unknownfield(f.style, T, k, v) +end + +end # @static if TRIM_SPECIALIZE + (f::StructClosure{T,A,S,FS,FSS,FT})(k, v) where {T,A,S,FS,FSS,FT} = findfield(T, k, v, f) @inline makenoarg(style, ::Type{T}, source) where {T} = makenoarg(style, initialize(style, T, source), source) diff --git a/test/trim_compile_tests.jl b/test/trim_compile_tests.jl index da649d8..d259438 100644 --- a/test/trim_compile_tests.jl +++ b/test/trim_compile_tests.jl @@ -41,6 +41,9 @@ function _setup_trim_env() println(output) error("failed to set up trim test environment") end + # the trim workload must build against the trim-specialized machinery — + # the default (fast-TTFX) forms are intentionally not verifier-resolvable + write(joinpath(env_path, "LocalPreferences.toml"), "[StructUtils]\ntrim_specialize = true\n") println("[trim] temp environment ready") return env_path end