fix: make the struct machinery resolvable under juliac --trim#62
Open
quinnj wants to merge 5 commits into
Open
fix: make the struct machinery resolvable under juliac --trim#62quinnj wants to merge 5 commits into
quinnj wants to merge 5 commits into
Conversation
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).
…tail-merge them 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).
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.
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four changes that together take a typed parse+write JSON workload from ~330 trim-verifier errors to zero (in combination with JuliaIO/JSON.jl#472), measured via
juliac --trim=safe --experimental— the resulting native binary builds and runs.The four mechanisms
1.
findfield/TupleClosure→@generatedwith literal field indices and types.The
_foreach(T) do iclosure compiles a genericf(i::Int64)instance in whichfieldtype(T, i)is abstract — so everymake(style, fieldtype(T, i), v, tags)goes dynamic, and the whole make ladder gets pulled into the binary in its unspecialized form (dozens of unresolvable calls). Generating the per-field match logic with literal$i/$ftkeeps eachmakecall concrete. (Same idiom as the existing@generated_construct/applyeach`.)2.
makedispatchers:::Type{T} where Tinstead of bareT::Type.Julia deliberately doesn't specialize methods on bare
::Typearguments, so even constant-typed callers landed in one generic instance whose trait branches (dictlike/arraylike/structlike/…) are all dynamic. Dispatch is unchanged (Type{T} where T≡Typefor method selection); only specialization behavior changes.3. Union disambiguation →
@generated _unionmake.The arraylike-vs-scalar disambiguation iterated
Base.uniontypes(T)(a runtimeVector{Any}), making the recursivemakecalls dynamic even in a specialized instance. The rule can only ever match two-component unions ("exactly one arraylike and one non-arraylike"), so it unrolls with literal component types; anything else returnsnothingand falls through exactly as before.4.
@generatedstructapplyeach: explicit isa-split of the closure call for small-Union fields.For fields like
Union{Nothing,String}/Union{String,Vector{UInt8}}, inference doesn't reliably union-split thef(key, val)call site, leaving runtime dispatch. The generated code now bindsvaland emits anisachain over the declared components with a finalelsefallback — customlowers that change the value's type stay correct (the else branch), while identity lowers get fully static calls (else is dead-code-eliminated).Validation
@kwarg/@defaults+ nested/Union fields, typed writes): 0 verifier errors, binary runs.Dict{String,Any}— heterogeneous values genuinely require runtime dispatch.🤖 Generated with Claude Code