Repository navigation
Perf C: per-file, per-phase attribution of the native route's context fold (measurement, not a fix) - #11206
Closed
gunbai-bot[bot] wants to merge 3 commits into
Closed
Perf C: per-file, per-phase attribution of the native route's context fold (measurement, not a fix)#11206gunbai-bot[bot] wants to merge 3 commits into
gunbai-bot[bot] wants to merge 3 commits into
Conversation
…ile and per phase The route's own [native-cost-partition] relay prices context as ONE exclusive row -- 1400.79 s of a 1573.85 s wall on the bounded subject -- which is a number and not an attribution: it cannot say whether the fold is paying a genuine per-file parse or a per-file recompute of something invariant. This is the measurement that makes the row readable, and it is deliberately NOT a fix. WHAT IS SPLIT. v2.compiler.program_assembly's per-file front end becomes one declaration per phase -- program_assembly_phase_tokenize / _parse / _normalize -- each taking the PREVIOUS PHASE'S OUTCOME and binding it, so diagnostic merging and refusal propagation stay inside .dag and the composition program_assembly_read_to_normalized_root_prepared is defined by those same three calls rather than restated beside them. v2.compiler.00_compile's context fold gains the matching seam: native_test_front_end_prepare carries the once-per-fold invariant (the language model and the prepared grammar), native_test_context_absorb is the non-front-end half of the step (the qualified-name read and the source-root index insertion), and native_test_context_from_ingest is retained as the composition every clock-free caller keeps calling. WHY THE PHASES ARE SEPARATE DECLARATIONS. A phase span is a realization fact: .dag is pure and cannot read a clock, so the only honest way to attribute the fold per phase is to let the driver that owns the clock enter each phase and time the call. std.compiler_entry.SourceRootEvalDriver now does exactly that, and prints [native-context-split] per file (path, bytes, tokens, and the four phase spans) and [native-context-partition] once (the phase totals, the execution counts, token and byte totals, and the p50/p95/max per-file span). THE COUNTS ARE BESIDE THE TIMES ON PURPOSE. A span alone cannot separate "this file is large" from "this file pays a per-file constant", which is the whole question the 1.1 s/file arithmetic raises, so the token count per file is printed beside the spans and is read outside every span so it prices none of them. NO ROW IS DOUBLE-COUNTED. The phase rows are their own relay on their own basis -- they partition the context span -- and [native-cost-partition]'s exclusive rows are untouched: context remains one exclusive row of the driver wall, so the parent span still reconciles. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019gufjy7FmMzjTRwAnUgZcZ
…sus mode still calls it run_census enters the composed front end deliberately -- it reports per-file refusals and wants no attribution -- so the composition is a live consumer of the driver, not only of the witnesses, and dropping it from the use list would have failed the emitted crate at compile time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019gufjy7FmMzjTRwAnUgZcZ
…ds emit as Box NativeTestFrontEnd.residue is a Diagnostics, which is an Optional, and the emitter boxes an Optional record field -- so front_end.residue.clone() cloned the Box where the phase takes the option by value. Caught by compiling the EMITTED crate rather than by reading the template: the seam typechecks in .dag either way, and only the emitted Rust knows which fields are boxed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019gufjy7FmMzjTRwAnUgZcZ
This was referenced Sep 12, 2026
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.
Perf item C, deliverable 1. Based on #10940's head (stacked on
session/deep-swift-530; retargets to main when #10940 lands).Result first: the expected defect is ruled out by measurement
The brief said to EXPECT a per-file recompute of something invariant, and to find it or rule it out before any parallelism. It is ruled out, and so is a size-quadratic. What is there instead is a flat per-token constant.
Full closure, 2236 files, from the run's own
[native-context-partition]line (srv1, load ~52, emitted driver inadjudicatemode):front_end_preparetokenizeparsenormalizeabsorb(qualified-name + index insert)Mean 1005 ms/file, p50 422 ms, p95 2943 ms, max 94.4 s (
src/v2/std/compilers/target_model.dag, 55832 tokens). That mean independently reproduces the ~1.1 s/file arithmetic on a different subject — so the per-file cost is subject-independent.The discriminator
Median ns/token by token decile (1 = smallest files):
2748k 3245k 948k 840k 840k 835k 833k 840k 814k 864kDeciles 3–10 are dead flat at ~840k ns/token. A per-file invariant recompute makes this curve fall sharply as files grow; a quadratic in file size makes it rise. Neither happens. Three hypotheses die here:
front_end_prepare_calls=1, 0.14 s total. The language model and prepared grammar are paid once and carried, and the relay proves it rather than asserting it.absorbis 0.40 s of 2246 s (0.018%).list_snoc_itemon roots andsource_root_index_insertare measured out, not argued out.parse_table_for_production_analyzedreusesprepared'sGrammarFirstAnalysis; no per-file recompute.Deciles 1–2 are elevated, and that is fold position, not size: at fixed small size (≤90 tokens), files in the first quarter of the fold cost 247 ms median against 57.6 ms in the last quarter — a 4.3× position effect. I have not fully attributed it (it is consistent with first-touch page-fault billing) and I am not claiming it as a per-file constant.
What is actually there
~840 µs to consume one token, uniformly, 80% of it in parse. A recursive-descent parser is normally well under 1 µs/token. This is a different defect class from the one the brief expected — not an invariant recomputed per file, but a per-token constant inside parse — and this PR does not fix it, because deliverable 1 is measurement.
What I checked and did NOT find: the memo is threaded correctly at the nonterminal site (
parse_table_record_lookup_call→parse_table_lookup→ hit/miss →parse_table_insert→table_after_memo), terminal arms correctly return the input table unchanged, the memo key is (character position, production), andMapis primitive-backed since #11121. "The memo is simply dropped" is not the answer.The next instrument is small and named:
ParseTablealready carriesmemo_hits/memo_misses/memo_lookup_callsandparse_table_memo_statsalready exists, butParseArtifactdoes not carry them out, so nothing prints them. Threading those three counters into the split line distinguishes "a memo that never hits" from "genuine per-token work in the combinators".Second finding, not asked for
file_refusals=212of 2236. Counting the parse/tokenize subset by signature (normalize binds theRejectedand returns in <10 µs while parse has spent seconds): 193 files, 217.4 s = 9.8% of the fold. A tenth of the context fold front-ends files that contribute no root — the v2 front-end corpus frontier (v2.workflow.compile_door_ledger) paid for in full at ~840 µs/token before it refuses. Flagging, not taking.The seam
v2.compiler.program_assembly— the per-file front end becomes one declaration per phase:program_assembly_phase_tokenize(read, lm) -> Outcome<TokenStream>program_assembly_phase_parse(tokens, prepared, residue) -> Outcome<ParseArtifact>program_assembly_phase_normalize(parsed) -> Outcome<NormalizedTree>program_assembly_phase_token_count(tokens) -> IntEach phase takes the PREVIOUS PHASE'S OUTCOME and binds it, so diagnostic merging and refusal propagation stay inside
.dag.program_assembly_read_to_normalized_root_preparedis retained and is now defined by those three calls — entering the phases separately is the same computation, not a second front end beside it.v2.compiler.00_compile— the matching seam:native_test_front_end_prepare()carries the once-per-fold invariant asNativeTestFrontEnd;native_test_context_absorb(state, read, root_outcome)is the non-front-end half of the step;native_test_context_state_empty()/native_test_context_finish(state, ingest).native_test_context_from_ingestis retained as the composition every clock-free caller uses — including the driver's ownrun_census, which wants per-file refusals and no attribution.std.compiler_entry.SourceRootEvalDriver— a phase span is a REALIZATION fact (.dagis pure and cannot read a clock), so the driver that owns the clock enters each phase and times the call. It prints[native-context-split]per file (path, bytes, tokens, four phase spans) and[native-context-partition]once.Counts sit beside times because a span alone cannot separate "this file is large" from "this file pays a per-file constant" — the whole question here. The token count is read OUTSIDE every phase span so it prices none of them.
No double counting
Per eager-raven-113's binding accounting ruling: these rows are their own relay on their own basis (
basis: context_span) and partition the CONTEXT span.[native-cost-partition]'s exclusive rows are untouched —contextremains one exclusive row of the driver wall, so the parent span still reconciles. The four phases partition the per-file span with 0.00 s unattributed; the 1.2% residual is the loop and the per-fileeprintlnand is reported, not absorbed.Deliverable 3
1256is a real closure, not the tree by another name. Independent oracle over the same two roots — seeds = every declaredv2.test.*module (thenative_route_universe_prefix) plusnative_route_live_control_module, edges = theimportheaders each source declares, transitive to fixpoint — gives on the full tree: scanned 5578, closure_modules 2091 in 14 rounds, closure FILES 2089. Genuinely closure-scoped at ~37% of the tree, and the driver's own fold independently reports 2236 files over the same roots.That also explains the previously unexplained gap of 4 between
closure_modulesandclosure_reads:closure_modulescounts module NAMES reached,closure_readscounts FILES ingested, and the difference is closure members that no scanned source declares. The full tree has exactly 2 (gunbc.recurring_failure_mode.roster,v1.compiler.ownership); a parked tree has more because parking removes declaring files.native_lane_ingest_matches_closureis consistent: its "closure module was never ingested" arm is quantified over modules some source DECLARES, so an undeclared closure member is not a refusal.The oracle is stated here as a method rather than committed as an artifact, deliberately: a
.dagfunction undergunbc runcannot read the corpus, so the only available forms would be a second closure implementation in the corpus — a §3 fork ofnative_lane_closure_modules, which already answers this — or a hand-authored script, which §6 presumes scaffold.Deliverable 2
Not in this PR, by instruction: parallelizing before the per-file cost is honest hides the defect. The seam landed here IS the fold-parallel seam —
program_assembly_phase_*is pure in(read, lm, prepared, residue)— so D2 is a realization change over these same functions. The measurement changes its priority in both directions: there is no per-file defect left for parallelism to hide, but it would multiply throughput without touching an ~840 µs/token constant that §6 prices as a cost-shape defect in its own right, and ~10% of the parallel work would still be spent parsing files that refuse.Verification
.dagmodules typecheck under the wholedag+src/v2closureRUSTFLAGS=-D warnings, and the malformed control refuses correctly (tokenize_lex_e1_unrecognized_char)--required-regennamed exactlyv1_compiler_emit_rust.rsas drifted — also the positive control that the emitter edit is live — and the regenerated file is committedsession/deep-swift-530rather than rebasing; both.dagauthorities auto-merged and only the generated projection conflicted, which the merge driver correctly refuses. Regenerated through the declared repair route and confirmed both relays survive in the merged projection (native-context-splitand deep-cat'snative-prepare-split), since a textual merge previously dropped that line with no conflict marker.One caveat on the subject: these numbers are over the FULL closure at this branch head, not the bounded subject, so they are NOT directly comparable to the 1400.79 s bounded figure — same host, different subject. The full closure turns out to be the better subject for D1 anyway: the context fold completes and reports before any per-module prepare, so it needs no reduced claim population to finish.
🤖 Generated with Claude Code
https://claude.ai/code/session_019gufjy7FmMzjTRwAnUgZcZ