Skip to content

Fused tier-0 typed parsing: one lazy pass for every target type (two-tier make)#473

Open
quinnj wants to merge 10 commits into
masterfrom
claude/two-tier-json
Open

Fused tier-0 typed parsing: one lazy pass for every target type (two-tier make)#473
quinnj wants to merge 10 commits into
masterfrom
claude/two-tier-json

Conversation

@quinnj

@quinnj quinnj commented Jul 16, 2026

Copy link
Copy Markdown
Member

Fused tier-0 typed parsing: one lazy pass for every target type

Companion to JuliaServices/StructUtils.jl#63 (requires it; the branch dev-pins it locally — release coordination needed before merge).

Under the default read configuration, every non-:hot typed JSON.parse(str, T) drives the StructUtils field-table interpreter directly from the lazy tokens — no intermediate tree, no per-type compilation beyond a field-table build:

  • Struct objects and element vectors stream (FusedObjClosure/FusedArrClosure/FusedPosClosure), scalar leaves parse straight off the token into the interpreter's typed leaf ladder; structs also fill positionally from arrays (classic parity).
  • Custom kinds hand the RAW lazy value to the generic machinerychoosetype, lift, and make overloads (e.g. JSONText raw capture) see exactly what the specialized descent hands them. This dissolves the old tree-safety gating.
  • Everything else materializes its subtree into the interpreter's boxed arms — dict/tuple/set/fixed-array kinds and shape mismatches. Never refuses a shape.
  • Routed elsewhere: :hot types (specialized descent, precompiled into the annotating package's image via the registered hook + synthesized samples), custom dicttype/null (materialization semantics), and custom inner styles (per-style lazy lifts and trait overrides are invisible to the engine) — all behind an invokelatest boundary so the never-taken specialized arm is not JIT-compiled per target type (deleting that boundary measured a 7.6× first-parse-sum regression; restoring it landed at 48.1s vs 77.1s for JSON 1.4.0 across the 216-case fuzz corpus).
  • Trim builds keep the static specialized route; the JuliaC harness case runs at a 0-error budget. The Any-make error path renders statically (interpolating a LazyValue dragged Base's matrix-show machinery into trimmed binaries as ~44 verifier errors).
  • The long-commented CustomJSONStyle tests are re-enabled: per-style lift works via inner-style forwarding; @choosetype/make overrides dispatch on the read wrapper (JSONReadStyle{<:Any,<:Any,MyStyle}) — now documented in the tests.

Fuzz vs 1.4.0 (80 families / 216 cases, seeded): steady parse geomean 1.80× faster (request-shaped docs 3.6×), write 1.10×, first-parse sum 1.6× faster, zero errors, value-identical output (23 hash diffs are dict key order — this branch writes AbstractDict keys sorted). Known regressions: bulk-vector docs ~0.64×, direct container roots ~0.56×, sub-µs empty-doc floors — the interpreter-vs-compiled-descent trade; :hot recovers known-bulk types.

🤖 Generated with Claude Code

quinnj and others added 10 commits July 15, 2026 00:02
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 <noreply@anthropic.com>
…ntry glue

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…get zero

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…eSpecs

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
…style tests

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 <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