Skip to content

fix: make the struct machinery resolvable under juliac --trim#62

Open
quinnj wants to merge 5 commits into
mainfrom
trim-verifier-fixes
Open

fix: make the struct machinery resolvable under juliac --trim#62
quinnj wants to merge 5 commits into
mainfrom
trim-verifier-fixes

Conversation

@quinnj

@quinnj quinnj commented Jul 11, 2026

Copy link
Copy Markdown
Member

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@generated with literal field indices and types.
The _foreach(T) do i closure compiles a generic f(i::Int64) instance in which fieldtype(T, i) is abstract — so every make(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. make dispatchers: ::Type{T} where T instead of bare T::Type.
Julia deliberately doesn't specialize methods on bare ::Type arguments, 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 TType for method selection); only specialization behavior changes.

3. Union disambiguation → @generated _unionmake.
The arraylike-vs-scalar disambiguation iterated Base.uniontypes(T) (a runtime Vector{Any}), making the recursive make calls 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 returns nothing and falls through exactly as before.

4. @generated struct applyeach: 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 the f(key, val) call site, leaving runtime dispatch. The generated code now binds val and emits an isa chain over the declared components with a final else fallback — custom lowers that change the value's type stay correct (the else branch), while identity lowers get fully static calls (else is dead-code-eliminated).

Validation

  • Full test suite green, including the trim workload testset (7/7).
  • JSON.jl's full suite green against this branch.
  • Parse+write trim workload (materializing parse-to-Any, typed struct parse incl. @kwarg/@defaults + nested/Union fields, typed writes): 0 verifier errors, binary runs.
  • Known remaining dynamic case (unchanged, inherent): writing Dict{String,Any} — heterogeneous values genuinely require runtime dispatch.

🤖 Generated with Claude Code

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).
quinnj and others added 4 commits July 11, 2026 12:41
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant