Issue #727: Lowering a package does the same work three to five times: a tenth of today’s cost

Open and answer this issue on GitHub.

ready · opened 2026-10-19 · updated 2026-10-20 · 1 comment

How it came up

CI’s slowest tests (sysroot 94 s, language 87 s, map_trust 71 s) all lower the tools package (crates/nikaia-std/src/tools, 209 files, 94,500 lines, 3.5 MB of Rust). A cold nikaia lower-std (NIKAIA_CACHE_DIR empty, NIKAIA_LOWER_THREADS=1) takes 58.6 s of CPU, about 1,600 lines a second; a front end usually checks 50-200 k lines a second.

Where the time goes

perf record --call-graph dwarf of a cold lower-std at d4f932d, 8,570 samples. The figures are shares of all samples, inclusive.

A. The same analysis runs several times (about 45 %)

Analysis Runs Total Once would cost
check::walked (the whole checker): ledger inference (check_for_the_ledger), the file’s check, and again in the emitter (Emitter::with_contracts -> check::propagation) 3x 50 % 17 %
contracts::keep::buffer_plans (in walked three times, in keep::infer, and in with_contracts) 5x 21 % 4 %
nonnegative_locals / nn_every (bounds::proven and Emitter::function_body) 2x 12 % 6 %
Ledger::clone in emit_std_items_against_at, once per file 209x 3 % 0

B. Method lookups scan every function of the ledger (about 24 %, mostly inside A). For each method call these functions walk ledger.functions (about 5,000 keys) with key.ends_with("::name"), which is where memcmp (21 % of all samples) comes from:

Function Share (memcmp + self)
keeping_method_key (keep.nika) 10.4 %
owned_by_every_method (buffers.nika) 7.5 %
ledger_candidates (check_values.nika) 4.3 %
candidate_keys / keeps_at_in (keeps.nika) 1.8 %

d4f932d already did this for view_changes.rs: -1 s.

C. Clones. Ty::clone takes 5.7 %, mostly in cleanup_within and collect_copies. FnContract::clone takes 3 %. malloc and free take about 12 % in all.

D. InternerContext::resolve takes 4.5 %: a DashMap with a lock, read by every check.

Goal

A cold lower of the tools package in a tenth of 58.6 s of CPU, with byte-identical package.rs.

To build

  1. The benchmark. Add benches/lowering/ (in Nikaia or as a lower.sh modelled on benches/brc/brc.sh). It does a cold lower of crates/rs2nika/convert (5,000 lines; callgrind instruction count) and of the tools package. It checks that the output is byte-identical to the committed one and prints a table: instructions, plus the NIKAIA_LOWER_TIMES phases for orientation. Count, never time (CLAUDE.md). This VM has no hardware counters, so callgrind is the count.
  2. Run each analysis once. The checker’s result for a unit is computed once and reused by the file’s check and by Emitter::with_contracts. First establish whether the ledger pass’s check sees the final ledger. If it does not, re-check only the units whose callees’ contracts changed, and say so in the commit. Store buffer_plans and nonnegative_locals once per function. Share the ledger instead of cloning it per file. Test: sysroot (package byte-identical, one thread equals every core) plus the touched tests.
  3. Method index. Build one map from a method’s name (the key’s last :: segment) to its ledger keys, once per ledger, so the lookups never go stale. Use it in the four functions of B (and in described_key).
  4. Interner reads without a lock while checking: a frozen snapshot, or RodeoReader.
  5. Clones on the hot paths (cleanup_within, collect_copies, FnContract in BTreeMap clones): borrow instead of clone, guided by a new profile.
  6. Profile again and repeat until the goal is reached. Record the table in benches/lowering/README.md.

Comments

keywan-ghadami, 2026-10-20

State after eight agent rounds (versions 0.0.1099–0.0.1120), counted with benches/lowering/lower.sh (cold nikaia lower --no-cache crates/rs2nika/convert/src/main.nika, release, output byte-identical):

16 410 M → 3 097 M Ir (−81 %). The goal (a tenth, ~1 640 M) is not reached yet.

Built: method index; each analysis once (emitter reuses the check’s facts, Needs read off items, program read once, trust join reuses the ledger pass); solver without arena/bound copies, ranked sorts, repeat questions answered from the book; line/column by binary search; ledger pass skips report-only walks; keep plans reused when the library is std’s; text-tiers worklist; result sources stop when unchanged; CheckResolved.contract as a view. Lowering defects found on the way: #733, #735 (fixed), and one checker gap filed separately.

Current profile (inclusive, 3 096 M): check::walked 1 111 M (ledger walk 390 M); infer_package_noted 735 M; bounds::proven 577 M; parse 467 M; keep::buffer_plans 217 M (twice, different ledgers). malloc/free ~27 % of self.

What is left is no longer local, and needs a decision:

  1. Merge the ledger walk and the check walk (both walk the same file with different computes settings): an architecture change.
  2. A faster allocator (mimalloc): a new dependency.
  3. The interner and the parser live in the winnow-grammar git dependency (InternerContext::resolve 97 M over a DashMap; parsing ~2 500 Ir per byte).
  4. lg_verify re-checks certificates the search just made (60 M): skipping it is a trust decision.

Generated by Claude Code

Back to Open Issues