engine: compiler optimization pass (C-LEARN -33%, LTM on world3 9.8x, LTM on C-LEARN 101x) - #993
Conversation
`parse_var_with_module_context` built a fresh `DimensionsContext` from its `&[datamodel::Dimension]` argument on every call, and `get_dimensions` resolved each dimension name by scanning that slice, re-canonicalizing every declared dimension name per lookup and then rebuilding the matched `Dimension` (re-interning its every element name) from scratch. Callgrind on C-LEARN: `DimensionsContext::from` was 11% of all instructions (5752 calls), and `canonicalize` -- 25% of the program, its single largest line item -- had `Dimension::from` and that scan as its top two callers. Every production caller already had the project's canonicalized context in hand from the salsa-cached `project_dimensions_context`, or was one line away from it; the datamodel slice was being carried purely so the callee could redo work the caller had already paid for. So the parse chain (`parse_var`/`parse_var_with_module_context`/`parse_equation`/`build_tables`/ `get_dimensions`, plus LTM's `to_flow_ast`/`parse_ltm_equation`) now takes `&DimensionsContext` and resolves a dimension name with one canonicalize and a hash probe. Three call sites that were threading two or three views of the same dimension list (`extract_implicit_var_deps`, `reconstruct_implicit_ variable`, `lower_implicit_var`) lose the redundant one. `db::query::parse_source_variable_impl` deliberately keeps building its own context, from the NARROWED per-variable dimension list rather than the project-wide one: that narrowing is what keeps an unrelated dimension edit from invalidating every cached parse, and a scalar variable's list is empty so it costs nothing. Two lookups on the hot path also stopped interning. `DimensionsContext:: is_dimension_name`/`lookup` and `NamedDimension::get_element_index` built a `CanonicalDimensionName`/`CanonicalElementName` -- taking a shard of the global interner and installing an `Arc` payload -- only to hand it to a hash probe and drop it. They now probe by `&str` through new `Borrow<str>` impls, sound for the same reason `Ident<Canonical>`'s already-present one is: the storage hashes string content, so a `&str` probe hashes identically. One behavior change, in a degenerate model only: where two declared dimensions canonicalize to the same name, `get_dimensions` used to resolve to the first in declaration order while the `DimensionsContext` every later stage consults keeps the last. It now agrees with the context, so a name cannot resolve to one dimension during parsing and another during lowering. Measured (criterion, vs the commit before): bytecode_compile/clearn 408.3 -> 362.7ms (-11.2%), full_pipeline/clearn 444.1 -> 397.0ms (-10.6%), ltm_compile/wrld3 320.0 -> 310.3ms (-3.0%). The new `ltm_compile` group is added here: "compile world3 with LTM on" had no repeatable measurement, and it is ~38x the model's ordinary compile.
`canonicalize`'s non-canonical path ran four unconditional `String`-returning
rewrites over every identifier part -- map `.`, unescape `\\`, collapse
whitespace, lowercase -- so a name whose only defect was its capitalization
still paid four allocations plus a two-way substring searcher for the `\\`
pattern it did not contain. Callgrind put it at roughly 1000-1600
instructions per call, and `canonicalize` is reached once per identifier
occurrence: it was 25% of all instructions on a C-LEARN compile and 22% on a
world3 LTM build, the largest single line item in both.
Each rewrite is now guarded by a cheap "is there anything to do" test and
borrows when there is not, and the last two are fused for an ASCII part so
they write straight into the output buffer. The fusion is deliberately ASCII
only, for two reasons that both fail outside it: ASCII lowercasing is
per-byte and context-free, while `str::to_lowercase` is not in general
(Greek capital sigma lowercases differently at the end of a word), and
U+00A0 -- the one non-ASCII character the whitespace pass recognizes --
cannot occur. The non-ASCII arm keeps the two steps separate and unchanged.
The `canonicalize_slow_path` reference in the test module is untouched, which
turns the existing fast-vs-slow proptest into a genuine differential oracle
for this rewrite. Two things are added on top, because that property's
`\PC{0,100}` generator reaches these branches roughly never: a second
proptest over an alphabet chosen to hit every branch (quotes, periods,
backslashes, both literal escapes, whitespace runs, U+00A0, and the
context-sensitive sigma), and hand-written cases for the step interactions
whose ORDER matters -- `\\n` is an escape only because the backslash
collapse runs first, and a non-escape backslash breaks a whitespace run.
Measured (criterion, cumulative with the previous commit, vs the branch
point): parse_mdl/clearn 32.47 -> 29.01ms (-10.7%), bytecode_compile/clearn
408.3 -> 350.5ms (-14.2%), full_pipeline/clearn 444.1 -> 381.6ms (-14.1%),
ltm_compile/wrld3 320.0 -> 296.3ms (-7.4%).
…nsions per AST node Three changes to the same hot path, measured together because the last one is what makes the first two show up. `canonicalize`'s fast path did three passes: a Unicode `trim`, an `is_ascii` scan, then `is_canonical`'s own scan -- and if the name held ANY non-ASCII character, `is_canonical` demoted the whole string to a per-character `to_lowercase().ne(once(c))` case check. That is exactly the shape of the engine's own generated identifiers: an LTM link score is forty ASCII characters with three U+205A separators in it, and re-canonicalizing one case-checked all forty. It is now one pass over a byte table with a single decode per non-ASCII character, and the case predicate is spelled as two `next()` calls rather than an iterator comparison (the generic `Iterator::eq_by` did not collapse, and was 3.6% of a world3 LTM build's cycles). The scan is conservative in one direction only -- what it rejects falls through to the unchanged `trim` + `is_canonical` pair, so a name it declines is still answered exactly as before. `Expr2Context::get_dimensions` and `Expr3LowerContext::get_dimensions` returned `Option<Vec<Dimension>>`, and every implementation built that Vec by `to_vec`-ing a slice it was already holding. Both are called once per `Var` and `Subscript` node during lowering, and a `Dimension` clone is not cheap: it copies the element vector AND the element->index `HashMap`. On C-LEARN that was 216k dimension-vector clones, showing up as `RawTable::clone`, `drop_in_place<Dimension>` and a slab of the allocator profile. They now return `Option<&[Dimension]>`; no caller needed to change. Finally, several sites re-canonicalized and re-interned a string whose TYPE already proved it canonical -- `CanonicalDimensionName::from_raw(dim.name())` on a `dimensions::Dimension`, or `::from_raw(ident.as_str())` on an `Ident<Canonical>`. The three canonical newtypes are tags over one interned storage, so `From<&Ident<Canonical>>` (and `Dimension::canonical_name()`) make those conversions a refcount bump. A test now pins the property the fused scan exists for, which is invisible to a correctness test: engine-generated names must come back `Cow::Borrowed`. Demoting them to the slow path returns an EQUAL `Cow::Owned`, so only the discriminant shows the regression. Measured (criterion, vs the branch point): bytecode_compile/clearn 408.3 -> 297.5ms (-27.1%), full_pipeline/clearn 444.1 -> 328.4ms (-26.1%), ltm_compile/wrld3 320.0 -> 279.9ms (-12.5%), bytecode_compile/wrld3 -4.2%, parse_mdl/clearn -10.3%. Incremental recompile is unchanged.
…first `model::lower_variable` called `lower_ast(scope, ast.clone())`, and `lower_ast` immediately consumed that clone to build an `Expr1` and then an `Expr2`. The clone existed only to be destroyed: `Expr1` is a different tree with different identifier types, so it is constructed from scratch either way, and the only fields the by-value form actually MOVED were a `String` per `Const` and per identifier -- each of which the caller had just allocated in the clone. So the deep copy bought nothing and cost a `Box` per AST node plus a `String` per identifier, per variable, per lowering. `Expr0::clone` was the single largest allocator in a C-LEARN compile: roughly a million of its 6.4 million allocations, showing up under `HashMap<CanonicalElementName, Expr0>::clone` (the per-element equation map of an arrayed variable, cloned wholesale). `Expr1::from` and `IndexExpr1::from` now take `&Expr0` / `&IndexExpr0` and `lower_ast` takes `&Ast<Expr0>`. The one thing that moves the other way is `Ast::ApplyToAll`/`Ast::Arrayed`'s `Vec<Dimension>`, now cloned per lowering rather than moved -- a few dimensions per variable against a whole equation tree per variable. Measured (criterion, vs the branch point): bytecode_compile/clearn 408.3 -> 287.1ms (-29.7%), full_pipeline/clearn 444.1 -> 318.0ms (-28.4%), ltm_compile/wrld3 320.0 -> 270.7ms (-15.4%), bytecode_compile/wrld3 -5.8%. C-LEARN compile allocations 6.44M -> 5.60M.
…er query `db::analysis::reconstruct_model_variables` parses and lowers every variable in a model, and it was a plain function. The LTM pipeline builds a causal graph once per query that needs polarity or module structure -- `causal_graph_with_modules`, `causal_graph_from_element_edges_with_modules`, `model_ltm_reference_sites`, the pinned-loop path, `compute_link_polarities` -- so on a world3 LTM compile it ran 923 times and accounted for 58% of every instruction the process executed. Nothing between those calls can change its answer: it reads the model's variable set, the per-variable parse memos and the project dimension context, all of them salsa values. It is now a `#[salsa::tracked]` query returning a shared `Arc<HashMap<Ident, Variable>>`, and `CausalGraph::variables` holds that Arc rather than an owned map, so the sharing survives into every graph built from it. Its salsa dependencies are exactly the ones its callers already took by calling it inline, so invalidation granularity is unchanged. A side effect worth naming: the map's iteration order used to differ between two calls in the same process (each `HashMap` seeds its own `RandomState`) and is now fixed for a revision. That is strictly more deterministic -- any output that depended on the old order was already irreproducible run to run, the GH #595 class. Measured (criterion, vs the branch point): ltm_compile/wrld3 320.0 -> 33.7ms, a 9.5x speedup. Non-LTM compiles are untouched (bytecode_compile/clearn stays at -29.7%). The heavy `#[ignore]`d gates were run: oracle_clearn, oracle_wrld3, simulates_clearn vs Ref.vdf, clearn_residual_exactness, both wasm parity suites, clearn_ltm_discovery, clearn_with_ltm_simulates_model_vars_identically, and the pinned-loop C-LEARN gate all pass.
…y element `build_element_context` resolves an arrayed variable's cross-dimension mapping substitutions, and it built a fresh `DimensionsContext` from the project's dimension list to do it -- once per ELEMENT of every arrayed variable. Building one canonicalizes and interns every dimension's every element name, so on C-LEARN that was 2% of the whole import's instructions spent rebuilding a value that does not change. It is memoized on the `ConversionContext` in a `OnceLock`. The memo is invalidated by a new `push_dimension`, which is now the only way `dimensions` grows -- so the cache is correct by construction rather than by relying on the phase ordering (dimension building does finish before any variable is converted, but nothing enforced that). Making it the only path meant hoisting the equivalence-alias materialization out of its `&self.equivalences` loop, since `push_dimension` takes `&mut self`. Measured (criterion, vs the branch point): parse_mdl/clearn 32.47 -> 20.92ms (-35.6%), full_pipeline/clearn 444.1 -> 308.6ms (-30.5%). The win is larger than the rebuild alone because the memo also keeps the subdimension-relationship cache that rides on the context, which every element used to discard.
`reconstruct_single_variable` did its own parse-and-lower: look the name up among the model's explicit variables, and failing that, walk EVERY variable's parse result hunting for a matching implicit one. The LTM link-score emitter calls it once per link, which on C-LEARN is 6,721 times. Since the previous commit, `reconstruct_model_variables` caches a map holding exactly those variables, built by the same construction against the same scope, so this is a lookup in it. That is not only cheaper -- it means the two can no longer disagree about what a name resolves to, which they could while each searched the model its own way. One precedence detail had to be made to match rather than assumed: this function checked explicit variables FIRST, while the map inserted implicit ones over the top. Implicit names carry the reserved `$⁚` prefix so no collision is reachable and no behavior changes today, but the map now uses `or_insert_with` so explicit-wins is the rule in the one place it is decided. Measured: C-LEARN under LTM 2539 -> 2194ms total, with the synthetic-variable stage 854 -> 500ms (-41%) and peak RSS 567 -> 544 MiB. world3 is unchanged (it has 428 links to C-LEARN's 6,721). The heavy `#[ignore]`d LTM gates pass.
SipHash over a short identifier was 4-6% of a large model's compile cycles, spread over every name resolution the compiler does -- one per AST node, per element, per fragment. The maps on that path (the compiler's symbol table and module map, the dimension context and its element index, codegen's name interner, `model_module_map`) now use a `crate::common::IdentMap` alias over `rustc_hash::FxBuildHasher`, which the crate already depends on. Two consequences, both stated on the alias rather than left implicit. The good one: FxHash's seed is fixed, so iteration order is now reproducible across processes -- the direction this crate already wants, since a salsa-cached value built by iterating a map must not differ run to run (GH #595). The cost: a fixed seed means an adversary who chooses the KEYS can force collisions, and the keys here are variable names out of a model file. Every engine entry point today compiles a model on behalf of the person who supplied it -- the CLI, the local MCP and viewer servers, pysimlin, and the browser's wasm bundle -- so a collision attack costs the attacker their own compile. The alias's doc says not to extend it to a map keyed by input from a party other than the one paying for the work. Measured (criterion, vs the branch point): bytecode_compile/clearn 408.3 -> 273.9ms (-32.9%), full_pipeline/clearn 444.1 -> 294.3ms (-33.7%), bytecode_compile/wrld3 -6.2%, ltm_compile/wrld3 -89.8%. All 20 runnable `#[ignore]`d heavy gates pass, the determinism suites included.
Ten `#[ignore]`d integration tests were gated on runtime, not on anything
about what they assert. The `engine-compile-perf` branch cut C-LEARN's
compile by a third and its MDL import by 36%, so that judgement is stale --
and an ignored gate catches nothing until someone remembers to ask for it.
`clearn_ltm_var_count_guardrail`'s own doc comment records a regression that
slipped through for exactly that reason ("this gate is `#[ignore]`d, so
neither pre-commit nor CI caught it").
Now in the default run, with their debug-build cost in parentheses:
`simulates_clearn` (3.8s -- the engine's cross-simulator ground-truth gate
against `Ref.vdf`), `clearn_residual_exactness` (3.8s), `oracle_clearn`
(3.7s) and `oracle_wrld3` (0.1s -- the run-invariance soundness oracles),
`clearn_ltm_var_count_guardrail` (4.2s), `simulates_wrld3_03_wasm` (1.1s),
`corpus_clearn_macros_import` (2.3s), `clearn_unit_error_flood_is_cleared`
(1.9s), `metasd_expansion_tier_full` (2.9s), and
`corpus_sstats_multi_output_materializes` (0.1s).
Cost, measured on a 4-core/4-thread run (`taskset -c 0-3 RUST_TEST_THREADS=4`,
which approximates a CI runner rather than a 32-core developer box):
`cargo test --workspace` 25.4 -> 31.1s, against a 180s cap. Scaled to CI's
~60s baseline that is roughly 74s, so the cap keeps a ~2.4x rail.
The twenty that stay ignored now say why in terms that will not go stale.
Three classes: what is slow is EXECUTION under the non-JIT wasm interpreter,
which no compiler speedup touches (`simulates_clearn_wasm` 34s,
`discovery_clearn_matches_vm_wasm` 287s); the test is a strict subset of one
that now runs by default (`metasd_expansion_tier_heavy`,
`compiles_and_runs_clearn_structural`); or it is a diagnostic dump rather
than an assertion. The rest are still genuinely over budget.
docs/dev/rust.md gains the rule this commit followed: `#[ignore]` for runtime
is a judgement about today's engine, so re-take it after the engine gets
faster -- with the commands to time the ignored set and to check the whole
suite against a CI-shaped core count.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #993 +/- ##
==========================================
+ Coverage 91.70% 91.84% +0.14%
==========================================
Files 244 244
Lines 157154 157340 +186
==========================================
+ Hits 144115 144511 +396
+ Misses 13039 12829 -210 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 915985dfea
ℹ️ 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".
| while b < 128 { | ||
| table[b] = !matches!( | ||
| b as u8, | ||
| b'"' | b'.' | b' ' | b'\n' | b'\r' | b'\t' | b'\\' | b'A'..=b'Z' |
There was a problem hiding this comment.
Route vertical-tab and form-feed names through trimming
When an identifier starts or ends with ASCII vertical tab (U+000B) or form feed (U+000C), this table marks the byte canonical, so is_canonical_needing_no_trim returns true and canonicalize bypasses str::trim. Before this change those characters were stripped, so such datamodel inputs now produce different identifiers and can fail to collide with or resolve references using the trimmed spelling. Mark these two ASCII-whitespace bytes false so the existing slow path preserves the prior behavior.
AGENTS.md reference: AGENTS.md:L97-L97
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, but deliberately not changing it.
The analysis holds: str::trim strips char::is_whitespace, which includes U+000B and U+000C, and the new byte table marks both canonical -- so a name with a leading or trailing vertical tab / form feed is no longer trimmed. (The prior is_canonical was already inconsistent here in its own way: u8::is_ascii_whitespace covers form feed but not vertical tab, so only the trim call was doing the work.)
Declined because no real model has an identifier bounded by a vertical tab or form feed, and the two-byte table change would buy a behavior nobody can reach. Recording it here rather than silently dropping it, so it does not get re-litigated: this is a decision, not an oversight.
| } | ||
|
|
||
| None | ||
| reconstruct_model_variables(db, model, project) |
There was a problem hiding this comment.
Preserve per-variable invalidation for LTM link scores
When LTM is enabled and an unrelated variable's equation changes, this lookup now depends on reconstruct_model_variables, whose tracked result reads and lowers every variable in the model. The changed map invalidates every link_score_equation_text_shaped query that called this helper, including scores whose source and target did not change; on models such as C-LEARN that means regenerating thousands of link scores after a local edit, defeating the query's documented per-involved-variable incrementality. Keep the cached whole-model map for bulk graph consumers, but use a separately tracked per-variable reconstruction for this helper.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch, and it was material -- fixed in f1417af.
Verified rather than reasoned about: I added a body-entry counter to link_score_equation_text_shaped and a test that edits one variable in a two-independent-loop fixture. On the commit you flagged it regenerated 4 of 4 link scores; on the implementation that commit replaced it regenerates fewer. So this was a real regression the PR introduced, not a pre-existing condition.
The fix is the shape you suggested, spelled as a salsa firewall rather than a second reconstruction path: reconstruct_single_variable is now a #[salsa::tracked] query. It still reads the whole-model map and still re-executes on any edit, but its VALUE is one variable, so salsa backdates it whenever that variable is untouched and no reader re-runs -- the same pattern db::query::model_variable_by_name already uses over SourceModel::variables. Keeping one derivation (rather than a separate per-variable reconstruction) preserves the property the original change was for: the whole-model map and the single-name lookup cannot disagree about what a name resolves to.
The cold-build win is untouched -- C-LEARN under LTM stays at ~2.1s -- since the query body is still a hash lookup rather than a parse-and-lower.
Worth noting for anyone reading later: the pin has to count query-body entries, not compare memo pointers. Salsa backdates a re-executed query whose value compares equal, so every link score's memo looks untouched whether its body ran or not, and a pointer check would have gone green through the entire defect.
Review[P2] Rustdoc for
|
…s not whole-model Review catch on PR #993 (r3675119315). Routing `reconstruct_single_variable` straight through the cached whole-model `reconstruct_model_variables` map made every consumer depend on every variable's LOWERED form, because that is what the map holds. `link_score_equation_text_shaped` is tracked per `(from, to, shape)` and documents that "a per-shape link score is recomputed only when the involved variables ... change" -- and that stopped being true: one unrelated equation edit regenerated all of them. On C-LEARN that is 6,721 link scores per keystroke. The lookup is now a `#[salsa::tracked]` query. It still reads the whole map and still re-executes on any edit, but its VALUE is one variable, so salsa backdates it whenever that variable is untouched and no reader re-runs. Same shape and same reason as `db::query::model_variable_by_name` over `SourceModel::variables`. The cold-build win the map lookup bought is untouched -- C-LEARN under LTM stays at ~2.1s -- because the query body is still a hash lookup rather than a parse-and-lower. The regression pin is a body-entry COUNT, not memo identity, and that is load-bearing: salsa backdates a re-executed query whose value compares equal, so every link score's memo looks untouched whether its body ran or not -- a pointer check would have gone green through the whole defect. The new counter lives beside the query. The test asserts "strictly fewer than the cold build" rather than an exact number: WHICH scores touch the edited variable is an emitter property that may legitimately change, while "an edit in one loop must not regenerate an independent loop's scores" is the contract, and an exact pin would fail on every unrelated emitter change and teach nothing. Verified in both directions rather than assumed: the test fails on the previous commit (4 of 4 scores regenerated) and passes on the implementation that commit replaced, so it pins a real regression this change introduced rather than a pre-existing condition.
[P2] Fast-path
|
A profile-driven optimization pass on the compile path, aimed at the two cases that hurt: large models (C-LEARN) and LTM.
Fixes #655
Results
Criterion (
benches/compiler.rs), against the branch point:bytecode_compile/clearnfull_pipeline/clearnparse_mdl/clearnltm_compile/wrld3bytecode_compile/wrld3full_pipeline/wrld3salsa_incremental/*And the case the branch exists for, measured with
examples/ltm_full_benchbecause LTM on C-LEARN is far outside a criterion budget:C-LEARN with LTM: 221.7 s → 2.07 s (107x), peak RSS 1152 → 526 MiB. The synthetic-variable stage alone went 219.3 s → 0.49 s.
ltm_compileis a new benchmark group: "compile world3 with LTM on" had no repeatable measurement, and it is roughly an order of magnitude more expensive than the same model's ordinary compile.What was actually wrong
Every one of these is the same shape — work redone because nobody had counted how often it ran. Commits are ordered so each one's measurement is attributable.
reconstruct_model_variableswas not cached (51cccdb, 11935ae). It parses and lowers every variable in a model, and it was a plain function. The LTM pipeline builds a causal graph once per query that needs polarity or module structure, so on a world3 LTM compile it ran 923 times and was 58% of every instruction the process executed. Nothing between those calls can change its answer. It is now a#[salsa::tracked]query returning a sharedArc<HashMap<..>>, andCausalGraph::variablesholds that Arc so the sharing survives into every graph built from it.reconstruct_single_variable— called once per link score, 6,721 times on C-LEARN — is now a lookup in that map. This is the 9.9x and the 107x.parse_var*rebuilt the dimension context per variable (0dde5da).DimensionsContext::fromwas 11% of all instructions on C-LEARN:parse_var_with_module_contextbuilt a fresh one from its&[datamodel::Dimension]argument on every call, andget_dimensionsresolved each dimension name by scanning that slice, re-canonicalizing every declared name per lookup and then rebuilding the matchedDimensionfrom scratch. Every production caller already had the cachedproject_dimensions_contextin hand or was one line from it. The parse chain now takes&DimensionsContext.canonicalizedid three passes and gave up on non-ASCII (90a66df, d6ae3df). It was 25% of a C-LEARN compile and 22% of a world3 LTM build — the largest single line item in both. Two problems: the fast path ran a Unicodetrim, anis_asciiscan and thenis_canonical's own scan, and one non-ASCII character demoted the whole string to a per-characterto_lowercase()case check. That is exactly the shape of the engine's own generated identifiers — an LTM link score is forty ASCII characters with three U+205A separators in it, and re-canonicalizing one case-checked all forty. It is now a single pass with one decode per non-ASCII character. The slow path also stopped allocating four intermediateStrings per identifier part when usually none of the four rewrites applies.lower_variabledeep-copied an AST purely to destroy it (905d516).lower_ast(scope, ast.clone())immediately consumed that clone to build anExpr1, which is a different tree with different identifier types and is constructed from scratch either way.Expr0::clonewas roughly a million of the compile's 6.4 million allocations.Expr1::fromnow takes&Expr0.get_dimensionshanded back an ownedVecit had just copied (d6ae3df). Both lowering contexts returnedOption<Vec<Dimension>>built byto_vec-ing a slice they were holding, once perVarandSubscriptnode. ADimensionclone copies the element vector and the element→indexHashMap; on C-LEARN that was 216k dimension-vector clones.The MDL importer rebuilt its dimension context per array element (6277f44).
build_element_contextresolves cross-dimension mapping substitutions and built aDimensionsContextto do it, once per element of every arrayed variable. Memoized in aOnceLock; worth −35.8% on import alone.Identifier maps hashed with SipHash (d6ea402). 4–6% of cycles, spread over every name resolution the compiler does.
Review follow-ups
Per-variable invalidation for LTM link scores (f1417af, review r3675119315). Routing
reconstruct_single_variablestraight through the whole-model map made every consumer depend on every variable's lowered form, so one unrelated equation edit regenerated every link score — 6,721 of them on C-LEARN.link_score_equation_text_shapeddocuments the opposite ("recomputed only when the involved variables change"), so this was a real regression the PR introduced.The lookup is now a
#[salsa::tracked]firewall: it still reads the whole map and still re-executes on any edit, but its value is one variable, so salsa backdates it whenever that variable is untouched. Same patternmodel_variable_by_namealready uses overSourceModel::variables. Keeping one derivation rather than adding a second reconstruction path preserves what the original change was for — the map and the single-name lookup cannot disagree about what a name resolves to. The cold-build win is untouched.The pin counts query-body entries rather than comparing memo pointers, and that is load-bearing: salsa backdates a re-executed query whose value compares equal, so a pointer check would have gone green through the whole defect. Verified in both directions — the test fails on the commit that introduced the problem (4 of 4 scores regenerated) and passes on the implementation it replaced.
Vertical tab / form feed trimming (review r3675119302) — correct analysis, deliberately declined.
str::trimstrips U+000B and U+000C and the new byte table marks both canonical, so a name bounded by one is no longer trimmed. No real model has such an identifier, so the two-byte change would buy a behavior nobody can reach. Recorded on the thread as a decision rather than silently dropped.Non-obvious decisions
FxHash is a deliberate security trade, and the reasoning lives on the type.
crate::common::IdentMapisrustc-hash-backed. The upside beyond speed is that a fixed seed makes iteration order reproducible across processes, which is the direction this crate already wants — a salsa-cached value built by iterating a map must not differ run to run (#595). The cost is that an adversary who chooses the keys can force collisions, and the keys here are variable names out of a model file. Every engine entry point today compiles a model on behalf of the person who supplied it (the CLI, the local MCP and viewer servers, pysimlin, the browser's wasm bundle), so a collision attack costs the attacker their own compile. The alias's doc says not to extend it to a map keyed by input from a party other than the one paying for the work. If a hosted service ever compiles models server-side on behalf of other users, that alias has to be revisited.One behavior change, in a degenerate model only. Where two declared dimensions canonicalize to the same name,
get_dimensionsused to resolve to the first in declaration order while theDimensionsContextevery later stage consults keeps the last. It now agrees with the context, so a name cannot resolve to one dimension during parsing and another during lowering.Explicit-wins precedence was made explicit rather than assumed.
reconstruct_single_variablechecked explicit variables before implicit ones; the map it now reads inserted implicit ones over the top. Implicit names carry the reserved$⁚prefix so no collision is reachable and nothing changes today, but the map usesor_insert_withso the rule is decided in one place.Cached-map iteration order is now fixed for a revision where it used to differ between two calls in the same process (each
HashMapseeds its ownRandomState). That is strictly more deterministic; any output that depended on the old order was already irreproducible run to run.Measurement notes
Two things worth recording, because they changed how the work was done:
canonicalize's instructions moved cycles by 0.3%. Callgrind was used only to find structural waste — call counts and who-calls-whom — andperf stat -e cyclesplus criterion decided everything.ltm_compile/wrld3by 4 ms. Swings that size were confirmed against instruction counts, which are layout-immune, rather than chased.Also: the benches and
examples/*use the system allocator, while production native binaries install mimalloc. The ~22% allocator share these profiles show overstates production.Tests un-ignored
Ten
#[ignore]d integration gates were gated on runtime, not on anything about what they assert, and that judgement is now stale. An ignored gate catches nothing until someone remembers to ask for it —clearn_ltm_var_count_guardrail's own doc comment records a regression that slipped through for exactly that reason.Now in the default run:
simulates_clearn(the cross-simulator ground-truth gate againstRef.vdf),clearn_residual_exactness,oracle_clearnandoracle_wrld3(the run-invariance soundness oracles),clearn_ltm_var_count_guardrail,simulates_wrld3_03_wasm,corpus_clearn_macros_import,clearn_unit_error_flood_is_cleared,metasd_expansion_tier_full,corpus_sstats_multi_output_materializes.Cost, measured on
taskset -c 0-3 RUST_TEST_THREADS=4(which approximates a CI runner rather than a 32-core developer box):cargo test --workspace25.4 → 31.1 s against the 180 s cap. Scaled to CI's ~60 s baseline that is roughly 74 s, leaving a ~2.4x rail.The twenty that stay ignored now give reasons that will not go stale: what is slow is execution under the non-JIT wasm interpreter, which no compiler speedup touches (
simulates_clearn_wasm34 s,discovery_clearn_matches_vm_wasm287 s); or the test is a strict subset of one that now runs by default; or it is a diagnostic dump rather than an assertion.docs/dev/rust.mdgains the rule this followed —#[ignore]for runtime is a judgement about today's engine, so re-take it after the engine gets faster — with the commands to time the ignored set and to check the suite against a CI-shaped core count.On #655
All four of its findings, checked against the current tree rather than assumed:
DimensionsContext— fixed, by engine: delete the fragment-compiler address round trip (row C) #991 (ModuleCtxborrows it).intern_name's O(D²) linear scan — fixed; it is a hash lookup.LtmEquation.Not fixed
Ident<Canonical>viasalsa::interned) stays open. The interner is still ~5–6% of compile cycles; this branch cut the map hashing around it, not the sharded-mutex interner itself.canonicalize~4–6%.Verification
cargo test --workspacegreen. All 20 runnable#[ignore]d heavy gates pass, includingsimulates_clearnagainstRef.vdf,clearn_residual_exactness, both invariance oracles, both wasm parity corpora,clearn_with_ltm_simulates_model_vars_identically, and the pinned-loop C-LEARN gate. The seven ignored tests that fail (simulates_delayfixed*,simulates_getdata*,simulates_except_xmile) fail identically on the branch point — they are known-unsupported features, not regressions. Every commit passed the full pre-commit hook.🤖 Generated with Claude Code
https://claude.ai/code/session_01DE767rH3hiqzC5u7uaEfCt