Two-tier make: tier-0 field-table interpreter default, :hot opt-in, classic family deleted#63
Open
quinnj wants to merge 14 commits into
Open
Two-tier make: tier-0 field-table interpreter default, :hot opt-in, classic family deleted#63quinnj wants to merge 14 commits into
quinnj wants to merge 14 commits into
Conversation
…e sources
Struct targets built from tree-shaped sources (AbstractDict, Vector{<:Pair})
now construct through a non-specializing, field-table-driven engine: one
compiled instance per (style, source shape), never per target type. First
use of a new struct type costs a table build (sub-ms) instead of compiling
a specialized descent, and the whole engine precompiles into pkgimages via
the package workload.
Construction goes through dispatch-free runtime primitives (jl_new_structv
from boxed slots, jl_alloc_array_1d + memoryref builtins for typed vectors),
with a closed kind universe for leaf lifting including hand-rolled ISO
Date/DateTime/Time parsing. Under the new trim_build compile-time preference
(an environment fact for juliac --trim binaries, not a behavior tier), the
interpreter's JIT-only arms — per-type metadata method consultation, dynamic
lift fallbacks, defaults-thunk re-evaluation, exotic source/key shapes — are
compile-time pruned, leaving a graph the trim verifier resolves with zero
errors and no trim_specialize-style global specialization mode.
Routing preserves exact classic semantics: eligibility requires macro-
registered metadata still matching what fieldtags/fielddefaults resolve for
the style (stateful or more specific overloads keep the classic path), and
the classic path also keeps Dict/Tuple/multidim/Set/UnionAll targets,
mutable/@noarg targets, tuple-alias name tags, and vals-dependent defaults'
3-arg fielddefaults semantics. The public make(::Type{T}, source, style) now
asserts its result to T so interpreter results stay well-typed for callers.
measured (make-from-Dict, 5-field family with nested struct + vector):
first family 0.877s -> 0.247s, marginal family 0.301s -> 0.119s, steady
5.86us -> 3.81us.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@noarg/@kwarg/@defaults/@tags/@nonstruct now emit a register_fieldtable! call carrying defaults (as a thunk, eagerly evaluated to plain data when safe), fieldtags, a nonstruct marker, and a vals-dependent flag (defaults referencing other fields keep the parsed-values fielddefaults semantics). Registration-time data is what makes tier-0 interpretation possible in trimmed binaries, where per-type method consultation is unavailable; under juliac the top-level registration executes in the compile session and the populated registry serializes into the image. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test/interp.jl covers the closed-kind matrix, defaults policies (fresh-empty and thunk defaults never alias, vals-dependent defaults compute from parsed values), and classic-path routing (unregistered types, manual overloads, stateful per-style fieldtags, tuple-alias names, @nonstruct nesting). test/interp_trim_safe.jl is a JuliaC --trim=safe workload compiled in its own env with the trim_build preference (which keys the precompile cache); error budget 0 with runtime asserts in the binary. Trees are built with per-key setindex! because heterogeneous Dict pair-splat constructors are themselves not trim-verifiable. make_trim_safe.jl's Dict-source case moves there: tree-shaped sources route through the interpreter, whose no-JIT graph requires the preference. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…antics refinements
Interpreter refinements surfaced by the JuliaC harness and the JSON suite:
- _interp_make is @nospecializeinfer with an erased source (specializing on
source invited invalidation-recompile stack overflows when extensions
loaded mid-session); the drive ladder gives the verifier concrete
applyeach targets, with a dynamic arm for arbitrary tree shapes under JIT
- trim builds route tree-shaped structlike makes unconditionally to the
interpreter: the runtime eligibility lookup can't constant-fold, and a
reachable classic arm is unresolvable by definition
- KIND_CUSTOM fields pass their resolved tags through the 4-arg make, so
lift/choosetype/dateformat fieldtags keep exact classic semantics
- new treesafe flag + interptreesafe: choosetype functions receive the raw
source value, so formats that materialize an alternate tree for the
interpreter must keep such types (recursively) on their classic path
- the default String lifts for Date/DateTime/Time now use hand-rolled ISO
parsers everywhere: same accepted format as the Dates constructors,
faster, and DateFormat's error paths are not trim-verifiable (this fixes
typed date parsing under --trim generally)
- classify_default reads the size field (generic length on abstract Array
does not devirtualize); registry writes use lock/try/finally (lock-do
closures over @nospecialize captures are unresolvable)
- public make(::Type{T}, source, style) asserts its result to T
- the harness exports JULIAC_DISABLE_PRECOMPILE_WORKLOADS=1 to juliac
sessions: executing workloads there bakes JIT-only instances into the
image for the verifier to reject; the trim workload builds its trees with
per-key setindex! (heterogeneous Dict pair-splat constructors are
themselves unverifiable)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pilation `@kwarg :hot struct ...` (and @noarg/@defaults/@tags, or the standalone `StructUtils.@hot T [samples...]` for non-owned types) emits per-type `make` methods backed by a fully-specialized dispatcher family (the PR #62 generated forms, re-scoped from a global preference to annotated types): literal-unrolled findfield, per-field-type specializing dispatchers, and generated two-component union disambiguation. The emitted entry tree-gates first — eligible tree sources take the tier-0 interpreter (faster at request sizes; the eligibility check lives in the entry because the interpreter's ineligible fallback re-enters make) — while the hot descent owns non-tree sources (format lazy values, NamedTuples, structs) with a statically-resolvable graph: the hot trim workload compiles at 0 verifier errors in the plain no-preference env. Struct definitions annotated :hot also call _hot_precompile!, which fires registered format hooks (StructUtils.register_hot_hook!) inside a PrecompileTools tagging block during downstream package precompilation, so each format's typed parse/write paths for the annotated type land in the defining package's pkgimage. Workload-shape note: with runtime-keyed sources the hot findfield keeps every (field x value-type) arm alive, so heterogeneous-scalar coverage (dates etc.) belongs to format-side workloads where the source value type is uniform (lazy values); the SU trim workload sticks to the proven Int/String/NamedTuple/Vector cross-product. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The unconditional subset of the trim-verifier-fixes branch, extracted so it
can merge to main independently of the two-tier make work (which supersedes
the trim_specialize preference and the globally-gated generated forms):
- generated _unionmake replaces the runtime Base.uniontypes loops in the
make dispatchers: the runtime Vector{Any} of component types made the
recursive make calls dynamic even when the target type constant-folds
- container closures (Dict/Array/FixedArray) assert make's NTuple{2,Any}
return: an Any-valued target makes the return opaque, and destructuring
an opaque value is dynamic dispatch under juliac --trim
- the generated struct applyeach hoists the field value out of the three
source branches so the closure call is a single devirtualizable site
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Without the per-component isa-split it was designed to feed (which stayed gated in PR #62 and is superseded by the tier design), the hoist φ-merges the three source branches into one Any-typed value, making the write closure's argument abstract where the branch-local calls had the concrete field type. Master's shape is the better trim citizen on its own. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
In a specialized descent every scalar field type reaches the format's number/aggregate lift arms statically, and the generic Symbol(x) fallback funnels those values into string(x): MPFR formatting for BigFloat and array-show (typeinfo invoke_in_world) for collections, none of it verifier-resolvable. String-ish inputs get concrete arms; the dynamic fallback survives for JIT sessions and errors loudly in trim builds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… builds The classic makestruct closures (runtime-indexed findfield) are never verifier-resolvable, so any constprop'd call site that folds into them is a trim error by construction — including targets that cannot be annotated, like ad-hoc NamedTuple row types materialized from database queries. Under the trim_build preference the structlike arm now routes tree sources to the interpreter and everything else to the specialized _hot_make3 family, which resolves statically wherever the target type constant-folds. The :hot annotation remains a JIT/precompile-time TTFX feature; trim correctness no longer depends on annotating every type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t emission, cohesion pass No behavior change (suites and both trim harnesses green before and after): - one @_peel macro + one _union2 function replace the four duplicated Missing/Nothing/two-component-union prologues across the classic and hot 3-/4-arg dispatchers; _union2 destructures the Union through its builtin a/b fields, so it needs no generated expansion and no runtime uniontypes vector, folding identically at constant target types (this also removes the last generated-dispatch-on-runtime-T sites the league-easy build flagged) - the :hot option's emitted expressions live in one _hot_exprs builder, shared by the struct macros and the standalone @hot - interpready/interptreesafe moved next to the field-table machinery they read; _liftfail reports kind names instead of numbers - an architecture note at the top of StructUtils.jl documents the three construction mechanisms and their routing in one place Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field specifications go recursive (ValueSpec: vector elements, dict values, and union arms nest as child specs), and unknown shapes degrade to a per-field generic arm (the 4-arg make, exact classic semantics, one dynamic call per such value under JIT) instead of making the whole type ineligible. Newly interpreter-handled: nullable/missing-element vectors, arbitrarily nested vectors, Dict fields (typed keys, recursive values, initialize honored), two-component-union shape dispatch (mirroring the classic arraylike rule including its ambiguity error), Set and other exotic containers via the generic arm, @noarg mutable targets (empty-constructor defaults under JIT, uninit + table defaults under trim), NamedTuple targets (reflection-only), tuple-alias name tags (match candidates in the spec, with the classic raw-fieldname fallback for Symbol keys), and parametric type-param-dependent defaults (registration captures the applicable metadata-method SET so parameterized emissions match; a defaults thunk that throws standalone signals per-construct evaluation through the parameterized 3-arg fielddefaults). Multidim/fixed-size arrays stay classic via the treesafe guard (dimension discovery differs between lazy and materialized sources). Fuzz-corpus eligibility: 58/86 -> 80/86 distinct types; every struct type in the corpus now interprets (the remainder are direct container targets, which route through the dict/array dispatcher arms by design). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@noarg targets have interpreter tables too; the noarg arm sits before the structlike arm's tier gate, so plain make(MutT, dict) still reached the classic makenoarg closures — correct under JIT, but a latent verifier hole for unannotated mutables from tree sources in trim builds. The arm now routes eligible trees to the interpreter and, under trim, everything else to the hot descent, mirroring the structlike arm exactly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ruct family The interpreter (plus the retained container machinery and the hot descent) now covers every make: makestruct/makenoarg/findfield/StructClosure and the treesafe machinery are gone. Dispatcher struct/noarg arms are uniform two-tier: eligible tree sources -> interpreter, everything else -> hot descent; make! noarg ports to _hot_makenoarg. Capability absorbed from classic, with parity fixes surfaced by the gates: - positional Int keys in _findspec (Vector/Tuple -> struct/NamedTuple) - pair-element vectors are dictlike targets / keyed sources everywhere - @atomic fields via _setfield! in _construct_mutable - missing source values are null-like; explicit nulls take the Missing arm first (classic @_peel order) in closure + nullwrap - public per-field fieldtags(style, T, field) consulted when the whole-type form is empty (volatile arm) - surplus positional tuple elements hit the unknownfield hook - _interptable gate hands the built table to _interp_make so volatile tables build once per make (stateful hooks keep call-per-make counts) - types with their own make overloads classify CUSTOM (queried with the concrete Type{T} singleton — Type{<:T} intersects every hot-emitted method through the bottom type); style-level dictlike/arraylike overrides outrank the structural classification; @nonstruct honored via the live structlike trait across pkgimages Compiler landmines fixed along the way: - interpsource folding at dispatcher call sites sent constprop/irinterp into a never-terminating compile: the source-trait verdict now lives inside _interp_root behind @nospecializeinfer, and the JIT dispatcher gate stays a runtime table lookup - _interp_make's fallback arms return the hot descent with Tuple asserts (the make re-entry cycle both loops at runtime and spirals inference) - valuespec's setlike/fixedarray eltype is JIT-only; trim classifies CUSTOM Trim: every harness case now compiles under trim_build=true — the app-level preference every trimmed binary sets (league-easy does) — and all three cases verify at 0 errors. Container-fallback arms _liftfail under trim. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hot closures' field descent bypassed user make overloads — a field whose type carries its own make methods (raw-capture types like JSON's JSONText, @choosetype-emitted union overrides) got field-parsed as a plain struct instead of hitting the hook, unlike the classic closures which always dispatched the declared field type through 4-arg make. The verdict (which fields carry custom make methods) is memoized per type in the RUNTIME world — a generated function's frozen world could never see hooks defined after package load — and rides the closure as an NTuple of Bools. The dispatch itself goes through a @nospecializeinfer trampoline: emitted with a const field type, a raw make call re-triggers the constprop/irinterp compile spiral through the recursive make family. Trim builds emit the plain hot field unconditionally (the methods() reflection behind the verdict is not verifier-safe); custom-make field hooks under trim remain outside the static surface, matching the existing physics constraints. 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.
Two-tier
make: tier-0 field-table interpreter by default,:hotopt-in, classic struct family deletedReplaces the per-type-specializing classic struct construction with a two-tier architecture (design ratified in review; supersedes the
trim_specializePreference approach from #62):@nospecialize/@nospecializeinferthroughout,FieldTable/FieldSpec/ValueSpecmetadata,Memory{Any}slots,jl_new_structv-style construction, a closed leaf ladder (incl. hand-rolled ISO Date/DateTime/Time parsers). Any target type, any tree-shaped source; per-type cost is a table build, not a compile. Covers structs,@noargmutables (incl.@atomicfields), NamedTuples, tuples, dicts, sets, multidim arrays, unions, positional sources, aliases, parametric defaults, and stateful per-style metadata via volatile (rebuild-per-make) tables.:hotopt-in:@kwarg :hot struct ...(also@defaults/@tags/@noarg, or standaloneStructUtils.@hot) keeps a fully specialized descent, compiled at the annotating package's precompile time via a hook registry + PrecompileTools (new dependency). Purely a TTFX/steady-perf feature.trim_build = true); it prunes the JIT-only arms so the static graph verifies. No per-type annotations required for trim correctness. Three JuliaC harness cases run in CI at a 0-error budget.makestruct/makenoarg/findfield/StructClosureand thetreesafemachinery are gone. Container machinery (maketuple/makearray/makedict) is retained and shared.Semantics preserved (each backed by a test that caught the gap)
positional
Vector/Tuple→ struct sources; pair-vectors as dictlike; classic@_peelnull ordering (explicit null →Missingarm first); per-field publicfieldtags(style, T, field);unknownfieldhooks for surplus positional elements; usermakeoverloads and style-leveldictlike/arraylikeoverrides classify CUSTOM (the interpreter never bypasses user hooks); the hot closures now route custom-make field types through 4-argmakedispatch (classic behavior the hot tier previously lacked).Compiler landmines encoded as structure (see commit messages for detail)
@nospecializeinferboundaries.Type{<:T}method queries intersect everywhere S<:Othermethod throughType{Union{}}: queries use the concreteType{T}.Numbers (measured on the JSON integration, 216-case seeded fuzz vs JSON 1.4.0 + StructUtils 2.6.2)
first-parse sum 48.1s vs 77.1s (1.6× faster cold); steady parse geomean 1.80× (request-shaped 3.6×); accepted losses on bulk-vector docs/containers (the interpreter vs a compiled descent —
:hotrecovers those); results value-identical across the corpus.Suite: full tests green including the three trim-compile cases. Downstream: JuliaIO/JSON.jl PR (companion) and a league-easy adoption PR validate end-to-end, including a 29MB trimmed server binary at 0 verifier errors.
🤖 Generated with Claude Code