Skip to content

perf: skip data-only analysis, wire InternTable, fix merge_envs (~50% speedup) - #422

Merged
briansrls merged 1 commit into
mainfrom
dark-emu-36
Apr 13, 2026
Merged

briansrls merged 1 commit into
mainfrom
dark-emu-36

Conversation

@briansrls

Copy link
Copy Markdown
Contributor

Summary

First segment of the perf throughline. Self-compile ~60-75s → ~37s (perf ratchet 75s → 55s). Four pieces of work, though the biggest actual win came from profiling, not from a planned optimization: merge_envs was re-interning the entire shared intern_table on every module merge.

Breakdown

Lane A — skip data-only modules (compile.dag)

extract_func_entries and extract_ownership_proofs now pre-filter modules whose items all have body == None. 82 of 143 .dag files are pure type declarations and skip complexity + ownership analysis entirely. Output-preserving — item-level filtering already produced zero results; this eliminates iteration/orchestration overhead.

Lane B PR1 — authored_name_at fallbacks (partial)

authored_name_at() had three paths to node.name. Two are eliminated:

  1. Self-describing kernel spans. The file key <kernel:Name> encodes the name. authored_name_at now extracts it via substring instead of requiring every synthesized name to be pre-registered in source_indices.

  2. Type-synthesis sites use kernel_span. make_container_type, make_map_type, make_callable_type, make_tuple_type (+first/second children), algebra_value_field, algebra_method_field, nominal_type_ref, synthesize_optional_some_variant (value/Some nodes), type-param binding in resolve, build_type_env_unresolved kernels.

ident_span: None and file-not-found paths now return "" for non-kernel spans. A residual fallback to node.name remains for parser-synthesized nodes with name_span: no_span() (~8 sites in 02_parse.dag) — migrates in PR4.

Lane B PR2 — wire InternTable to TypeEnv

Added intern_table: InternTable to TypeEnv. Threaded through the pipeline:

compile_sources → reconcile → typecheck → typecheck_modules → typecheck_module → build_type_env / build_type_env_unresolved

Updated ~14 TypeEnv { ... } construction sites, the test harness (compiler_tests_rust.dag), and hand-written tests (infer_semantics.rs, pipeline.rs) to collect/pass intern tables from parse results.

Unblocks PR3 (registry key migration Map<String, _> → Map<Int, _>).

merge_envs intern_table reuse — the biggest actual perf win

Discovered via running the perf ratchet after PR2 landed. Pipeline went from ~60s baseline to 94s. Profiling showed reconcile was 81% of compile time with a fixed ~1.1s per module regardless of size (even a 3-item module took 1.1s).

Root cause: `merge_envs` was calling `merge_intern_tables(envs |> map(env => env.intern_table))`, which iterates every string in every table via `intern()`. Since PR2 threads a single shared `intern_table` from the frontend through every env, all three envs in a merge (kernel/import/pre_local) have the same table. Merging is pure waste: `O(modules × 2 merges × 3 envs × strings/table)`.

Fix: take the first env's `intern_table` since they're all the same.

Gist pipeline measurements (before → after):

Stage Before After Factor
Reconcile 9.54s 140ms 68×
Per-module build_type_env ~1.1s ~5ms 200×
Total pipeline 11.72s 2.34s 5×
Reconcile % of total 81% 6% no longer the bottleneck

Lane D — profile infrastructure

`compiler_tests_rust.dag` already has `profile_full_pipeline`, `profile_self_compile`, and `profile_reconcile_per_module` tests (all `#[ignore]`'d). They worked as-is and surfaced the merge_envs bug immediately. No new code needed — Lane D complete per the perf plan.

Ratchet bump

`bootstrap.rs` `PERF_RATCHET_SECONDS` 75 → 55. Last measured local run was 37.6s; 55s gives CI ~15s variance headroom. Per the "ratchet only down" invariant.

Key takeaways

  1. Profile first. The biggest win wasn't on the plan. It surfaced immediately when the existing profile tests ran against the PR2 state.
  2. The 20s regression was a feature. Perf ratchet failing pointed straight at `merge_envs`.
  3. Facts Flow Forward. When a single authoritative value is threaded through the pipeline, downstream "merges" are pure waste. The bug was the merge helper not knowing all inputs were Rc-identical.
  4. The plan's estimate was wrong. Lane C (clone elision) was expected to be the big perf win; the real bottleneck was elsewhere. PR3/4/5 + clone elision still have value for the deeper goal (deleting Node.name, stable binding identity), but the urgent perf problem is already resolved.

Still pending (remaining throughline)

  • Lane B PR3: migrate registries `Map<String, _>` → `Map<Int, _>` (~30-60 sites)
  • Lane B PR4: migrate `.name` display reads to `authored_name_at` (~60 sites)
  • Lane B PR5: delete `Node.name` field (~180+ Node literals)
  • Lane C: last-use clone elision (blocked on PR5)
  • PR1 residual: ~8 parser `name_span: no_span()` sites in `02_parse.dag`

Test plan

  • `cargo test --workspace --exclude v2-compiler-tests` — 20 passed
  • `cargo test -p v2-compiler-tests` — 396 passed
  • `cargo test -p v2-compiler-tests strict_compile_diagnostic_count -- --ignored` — 353 (unchanged)
  • `cargo test -p v2-compiler-tests performance_ratchet -- --ignored` — 37.6s (under new 55s budget)
  • `cargo clippy --all-targets -- -D warnings` — clean
  • Fixed-point verified (pass 1 == pass 2 regen)
  • `cargo test -p v2-compiler profile_full_pipeline -- --ignored --nocapture` — reconcile 6% of total
  • `cargo test -p v2-compiler profile_reconcile_per_module -- --ignored --nocapture` — per-module ~5ms

🤖 Generated with Claude Code

@briansrls

briansrls commented Apr 12, 2026 •

Copy link
Copy Markdown
Contributor Author

ChatGPT Review

Grounded in the thesis, the question here is not “did this get faster?” but “did this make performance a consequence of carrying the right facts forward?” The good news is that most of the direction is right: threading InternTable into reconcile/TypeEnv, replacing dummy spans with kernel_span(...), and skipping ownership/CX work for obviously data-only modules all push facts upstream instead of recomputing them later. That matches the thesis’s “separate pass → lattice/binding-carried fact” direction, and it matches the roadmap’s claim that stable binding identity is the prerequisite for the next ownership wins.

I would still leave three blocking comments.

  1. authored_name_at still revives node.name too broadly.

In src/v2/00_core.dag, the new fallback branch is:

  • use source_text_at(...) if the file is in source_indices
  • else, if span.file starts with "<kernel:", decode the name from the file key
  • else, return node.name

The comment says that last branch is only for parser-synthesized span.file == "", but the code is broader than the comment: it fires for any miss in source_indices, including a real cross-module/index-threading bug. That means the semantic proxy the roadmap explicitly calls out as the blocker is still alive; it is just narrower than before. Track 3 still says “InternTable as identity consumer” is pending and “Fix authored_name_at fallback” is blocked because fallback still goes to node.name. This diff does not fully close that blocker. The invariants are explicit that node.name is the escape hatch, boundary data must be sufficient, and missing facts should be surfaced rather than recovered through a proxy.

What I’d ask for: either restrict that fallback to span.file == "" exactly, or better, remove String as the return channel and make this String? / ResolvedName | MissingName, so a miss is structural rather than silently turning back into semantic authority. Right now this is still an invariant leak, not a finished fix.

  1. merge_envs silently picks the first intern_table, which is a hidden contract.

In src/v2/04_env.dag, merge_envs now merges bindings, recursion facts, inductive fields, and source indices, but for intern_table it just takes first_env.intern_table. That may be true on the current happy path, but the function signature still accepts an arbitrary List<TypeEnv>, and the helper itself does nothing to prove that all envs actually share the same interning context.

That is exactly the kind of lossy boundary the invariants warn against: if a boundary fact table collapses distinct upstream facts or drops witnesses needed later, it is already an invariant violation, and the fix belongs at the boundary, not in downstream assumptions. The roadmap also still says InternTable as identity consumer is pending, not solved, which makes this “just trust the first one” choice feel premature as an authority boundary.

What I’d ask for: either actually merge the tables here, or add a fail-closed assertion/check that all merged envs share the same table, or narrow the type so mixed-table merges are impossible. As written, the helper’s contract is stronger than the code proves.

  1. Returning "" from authored_name_at is still sentinel fabrication.

The new code is better in one respect: ident_span == none and empty recovered text no longer default to node.name; they return "". But that is still a sentinel in a plain String channel. The invariants explicitly ban fabrication fallbacks and even call out empty-string fabrication as debt; missing data should be None or a diagnostic, not a valid-looking default string. The modeling guide says the same thing more bluntly: a lookup that returns a default on miss is fabrication, and dispatch should be structural rather than string-based.

Why this matters in practice: any caller that orders args, fills defaults, or keys a map by authored name can now collapse multiple distinct failures onto the same empty key. That is safer than wrong names, but it is still not “zero bugs by construction.”

One non-blocking note:

The “skip data-only analysis” part looks like a good tactical ratchet, but I would describe it that way, not as architectural closure. The thesis says the end state is dissolving separate passes into binding-carried facts, and the roadmap still treats ownership’s big remaining win as blocked on Track 3 identity plus LS-4 borrow modeling. So the speedup is real and welcome, but it is still a tactical cut over a still-open structural gap.

On compositional modeling quality: I did not see a new .dag modeling anti-pattern in the changed .dag files themselves. Replacing ad-hoc zero spans with kernel_span(...) is directionally good. The one caveat is that authored_name_at now decodes kernel identity from a "<kernel:...>" string protocol in SourceSpan.file; that is acceptable as bridge code, but it is still string extraction, so I would keep treating it as temporary rather than letting it become a new authority.

Roadmap alignment: ROADMAP.md is not modified in the diff, so there is no roadmap drift to review directly. Relative to the current roadmap, though, this PR is aligned but incomplete: it moves Track 3 in the right direction, but it does not yet justify marking either “InternTable as identity consumer” or “Fix authored_name_at fallback” done.

The two tests I’d add before merging are: a behavior test proving authored_name_at does not fall back to node.name for a missing non-kernel span.file, and a merge_envs mixed-intern-table test that either merges correctly or fails closed. A scrambled-name regression around whichever emit/helper path consumes authored names would also be valuable, since the invariants explicitly use scrambled-name tests as the diagnostic for name-proxy leaks, and they require behavior-focused tests rather than implementation-shaped ones.

Verdict: good direction, not merge-ready yet. The speedup is thesis-consistent only if identity becomes more exact at the same time; right now the diff still has two silent recovery channels (node.name on source-index miss, "" on name-resolution miss) plus an unchecked “first table wins” assumption in merge_envs.


View conversation

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dd94ca9c4a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

" modules.push(m);\n",
" intern_tables.push(result.intern_table.clone());\n",
" }\n",
" let intern_table = crate::v2_std_core::merge_intern_tables(std::rc::Rc::new(intern_tables));\n",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Drop unused intern-table merge from gist parse timing

In profile_gist_pipeline, the newly added merge_intern_tables(...) value is never consumed (this profile only runs tokenize/parse/resolve and never calls reconcile), but it runs before parse_total is recorded. That means the reported parse stage now includes extra full-table merge work unrelated to parse itself, which skews the profiling output and makes historical perf comparisons for this workflow unreliable.

Useful? React with 👍 / 👎.

@briansrls briansrls left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

codex · gpt-5.4 · dd94ca9c

Review (INVARIANTS: 2, MODELING: 1+/1-, ROADMAP: 0✓/2!)

INVARIANTS — Violations (2)

ROOT CAUSE ANALYSIS

  • src/v2/00_core.dag The upstream gap is that synthesized name-bearing nodes still do not carry a single structural name authority (M2/M5/M8), so authored_name_at reconstructs identity from the copied Node.name string and downstream lookup, pattern, and emit code then branches on that proxy. Define one core synthetic-name authority, use it everywhere synthesized names are created including the remaining 04_infer bridge sites, and let authored_name_at return only source- or synthetic-authoritative names, never node.name.
  • src/v2/04_env.dag The upstream boundary between frontend naming and semantic typing is still missing, so a transit-only parser artifact gets copied into every TypeEnv (M1/M2) and downstream code compensates with guesses like first-env wins and empty_intern_table in emit scopes. Keep InternTable in FrontendResult or a dedicated compile context, then move toward M4 by letting semantic envs consume structural ids or Node references instead of String-plus-table ambient state.

MODELING — Strengths

  • src/v2/04_infer.dag Anonymous record literals now preserve each inferred field's existing span and ident_span, which composes from prior structure instead of fabricating a fresh zero-span name.

MODELING — Improvements

  • src/v2/04_env.dag TypeEnv is most faithful as a product of binding facts; keep interning in a separate compile/frontend layer so the environment stays compositional and stage-correct.

ROADMAP — Incomplete

  • P1: Skip CX/ownership for data-only modules: The new outer module filter in extract_func_entries and extract_ownership_proofs is redundant with the existing item.body filter, so stages 5 and 6 still traverse every module and the roadmap's 8→3 reduction is not achieved.
  • P2: Fix authored_name_at fallbacks: Kernel spans are decoded now, but authored_name_at still reverts to node.name on non-kernel source-index misses, so Node.name deletion is still blocked.

ROADMAP — Unclaimed

  • P3: Wire InternTable to TypeEnv: The diff does thread frontend.intern_table through reconcile, typecheck, and env construction, but ROADMAP.md still shows P3 as pending.

The PR moves performance plumbing and synthetic-name handling forward, but it still falls short of the zero-bugs-by-construction thesis because authored identity is not yet single-authority, parser interning leaks across the semantic boundary, and the claimed data-only stage skip is not actually implemented.

Comment thread src/v2/00_core.dag
} else {
// Remaining fallback for parser-synthesized spans with file "".
// These will migrate to kernel_span in a follow-up PR.
node.name

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Invariant violation: authored_name_at still falls back to node.name when a non-kernel ident span misses source_indices, violating No fallbacks that fabricate and leaving Node.name as a second authority for authored identity.

Comment thread src/v2/04_env.dag
TypeEnv { bindings: merged_bindings, recursive_types: merged_recursive, recursive_type_set: merged_recursive_set, inductive_fields: merged_inductive_fields, source_indices: merged_source_indices }
// All envs in a merge share the same intern_table (threaded from the
// frontend result), so we take the first one rather than re-interning
// every string, which would be O(N*M) per merge and dominate compile time.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Invariant violation: merge_envs resolves duplicated intern_table state by taking envs |> first, violating No duplicate representations and Minimal information per interface.

@briansrls

Copy link
Copy Markdown
Contributor Author

codex · gpt-5.4 · dd94ca9c

Review (INVARIANTS: 2, MODELING: 1+/1-, ROADMAP: 0✓/2!)

INVARIANTS — Violations (2)

ROOT CAUSE ANALYSIS

  • src/v2/00_core.dag The upstream gap is that synthesized name-bearing nodes still do not carry a single structural name authority (M2/M5/M8), so authored_name_at reconstructs identity from the copied Node.name string and downstream lookup, pattern, and emit code then branches on that proxy. Define one core synthetic-name authority, use it everywhere synthesized names are created including the remaining 04_infer bridge sites, and let authored_name_at return only source- or synthetic-authoritative names, never node.name.
  • src/v2/04_env.dag The upstream boundary between frontend naming and semantic typing is still missing, so a transit-only parser artifact gets copied into every TypeEnv (M1/M2) and downstream code compensates with guesses like first-env wins and empty_intern_table in emit scopes. Keep InternTable in FrontendResult or a dedicated compile context, then move toward M4 by letting semantic envs consume structural ids or Node references instead of String-plus-table ambient state.

MODELING — Strengths

  • src/v2/04_infer.dag Anonymous record literals now preserve each inferred field's existing span and ident_span, which composes from prior structure instead of fabricating a fresh zero-span name.

MODELING — Improvements

  • src/v2/04_env.dag TypeEnv is most faithful as a product of binding facts; keep interning in a separate compile/frontend layer so the environment stays compositional and stage-correct.

ROADMAP — Incomplete

  • P1: Skip CX/ownership for data-only modules: The new outer module filter in extract_func_entries and extract_ownership_proofs is redundant with the existing item.body filter, so stages 5 and 6 still traverse every module and the roadmap's 8→3 reduction is not achieved.
  • P2: Fix authored_name_at fallbacks: Kernel spans are decoded now, but authored_name_at still reverts to node.name on non-kernel source-index misses, so Node.name deletion is still blocked.

ROADMAP — Unclaimed

  • P3: Wire InternTable to TypeEnv: The diff does thread frontend.intern_table through reconcile, typecheck, and env construction, but ROADMAP.md still shows P3 as pending.

The PR moves performance plumbing and synthetic-name handling forward, but it still falls short of the zero-bugs-by-construction thesis because authored identity is not yet single-authority, parser interning leaks across the semantic boundary, and the claimed data-only stage skip is not actually implemented.

briansrls added a commit that referenced this pull request Apr 12, 2026
Addresses Codex review on PR #423:

1. Reframe merge_envs as boundary/fact-flow violation (INVARIANTS
   "Facts Flow Forward"), not KF-2/idempotency failure. The fix
   was boundary discipline, not a missing optimizer.

2. Rewrite Rule 4 from arity heuristic ("N inputs → suspect") to
   structural authority test ("does one input carry the fact?").
   Legitimate folds share the N-input shape — arity can't be the
   authority.

3. ROADMAP "Done" items → "PR #422 (pending merge)" since the
   implementation hasn't landed on main yet.

4. KF-2 retained as secondary connection: merge(a,a,a)=a IS
   algebraically true, so KF-2 would also catch it. But primary
   classification is boundary, not optimizer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@briansrls

Copy link
Copy Markdown
Contributor Author

⚠️ ChatGPT reviewer: session expired

The bot's ChatGPT session has expired. Reviews are paused until re-login.

To fix:

  1. SSH into the reviewer host
  2. cd /Users/briansrls/ctrl/scripts/chatgpt-reviewer && docker compose run --rm -p 5900:5900 -p 6080:6080 chatgpt-reviewer --login
  3. Open http://192.168.1.176:6080/vnc.html (password: reviewer)
  4. Log into ChatGPT, then Ctrl+C

The bot will resume automatically on its next cycle.

…tern (~50% self-compile speedup)

Self-compile: ~60-75s → ~37s. Perf ratchet 75s → 55s.

Four pieces of work, executed as the first segment of the perf throughline
(see THESIS / perf-lane-guide). The biggest win came from profiling (not
from a planned optimization): merge_envs was re-interning the entire
shared intern_table on every module merge — O(N*modules*2) pure waste.

compile.dag extract_func_entries and extract_ownership_proofs now pre-filter
modules whose items all have body == None. 82 of 143 .dag files are pure
type declarations and now skip complexity + ownership analysis entirely.
Output-preserving (item-level filtering already produced zero results) —
this eliminates iteration/orchestration overhead.

authored_name_at() previously had three paths that fell back to node.name.
Two changes remove most of them:

1. Kernel spans are now self-describing. authored_name_at extracts the
   name directly from the file key (<kernel:Name> → "Name") via substring,
   so synthesized nodes don't need to be pre-registered in source_indices.

2. Type-synthesis sites now use kernel_span(name: X) instead of
   no_span() / make_span(0,0): make_container_type, make_map_type,
   make_callable_type, make_tuple_type (including "first"/"second" children),
   algebra_value_field, algebra_method_field, nominal_type_ref,
   synthesize_optional_some_variant ("value"/"Some" nodes), type-param
   binding in fn_with_type_params, and build_type_env_unresolved kernels.

The ident_span: None path now returns "" (anonymous nodes). The
file-not-found path returns "" for non-kernel spans, but still falls
back to node.name for parser-synthesized nodes with name_span: no_span().
That residual fallback (~8 sites in 02_parse.dag) will migrate in PR4.

Added intern_table: InternTable field to TypeEnv. Threaded through the
pipeline: compile_sources → reconcile → typecheck → typecheck_modules →
typecheck_module → build_type_env / build_type_env_unresolved. Updated
all ~14 TypeEnv construction sites. Updated test harness
(compiler_tests_rust.dag) and hand-written tests (infer_semantics.rs,
pipeline.rs) to collect/pass intern tables from parse results.

The intern table is now available everywhere inference runs. PR3 will
migrate registries (TypeEnv.bindings, InferScope.locals, etc.) from
Map<String, _> to Map<Int, _> using ident: Int as the key.

Discovered via running the perf ratchet after PR2. Pipeline regressed
from ~60s baseline to 94s. Profiling showed reconcile was 81% of compile
time, with a fixed ~1.1s cost per module regardless of module size
(even 3-item modules took 1.1s).

Root cause: merge_envs was calling merge_intern_tables(envs |> map(env =>
env.intern_table)), which iterates every string in every table via
intern(). Since PR2 threads a single shared intern_table from the
frontend through every TypeEnv, all three envs in a merge
(kernel/import/pre_local) have the same table. Merging is pure waste:
O(modules * 2 merges/module * 3 envs/merge * strings/table).

Fix: take the first env's intern_table since they're all the same.

Measured impact on the gist pipeline:
  Reconcile:         9.54s → 140ms  (68x)
  Per-module env:    ~1.1s → ~5ms   (200x)
  Total pipeline:   11.72s → 2.34s  (5x)
  Share of total:       81% → 6%    (no longer the bottleneck)

compiler_tests_rust.dag already has profile_full_pipeline,
profile_self_compile, and profile_reconcile_per_module tests (all
immediately. No new code needed — this is Lane D complete per the
perf plan.

bootstrap.rs PERF_RATCHET_SECONDS 75 → 55. Last measured dev run
was 37.6s; 55s gives CI ~15s variance headroom. Per the "ratchet
only down" invariant.

- 396 compiler tests pass (cargo test -p v2-compiler-tests)
- 20 hand-written tests pass
- Fixed-point verified (pass 1 == pass 2 regen)
- Diagnostic ratchet unchanged at 353
- cargo clippy --all-targets -- -D warnings clean
- performance_ratchet: 37.6s (under new 55s budget)
- profile_full_pipeline: reconcile now 6% of time (was 81%)

- Lane B PR3: migrate registries Map<String, _> → Map<Int, _> (~30-60 sites)
- Lane B PR4: migrate .name display reads to authored_name_at (~60 sites)
- Lane B PR5: delete Node.name field (~180+ Node literals)
- Lane C: last-use clone elision (blocked on PR5)
- PR1 residual: ~8 parser name_span: no_span() sites in 02_parse.dag

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@briansrls

Copy link
Copy Markdown
Contributor Author

ChatGPT review in progress... (view conversation)

Check back in ~30 minutes for the full review.

@briansrls
briansrls merged commit 61a0bed into main Apr 13, 2026
1 check failed
@briansrls
briansrls deleted the dark-emu-36 branch June 1, 2026 18:41
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