Perf: the gate was slow because it ran an unoptimized compiler, one cell at a time #223

Manually merged
hkowalski merged 2 commits from feat/223-perf-the-gate-was-slow-because-it-ran-an-unopt into master 2026-09-08 08:00:00 +02:00
Owner

Perf: the gate was slow because it ran an unoptimized compiler, one cell at a time

Along the way:

  • perf: the gate was slow because it ran an unoptimized compiler, one cell at a time
  • tests: only corpus_test_run/ and capability/ are executed by the suite

Touches 16 files: src/rust/Cargo.lock, src/rust/Cargo.toml, src/rust/crates/ck_driver/src/run.rs, src/rust/crates/ck_driver/tests/architecture/alloc.rs, src/rust/crates/ck_driver/tests/capability.rs, src/rust/crates/ck_driver/tests/common/mod.rs and 10 more.

Perf: the gate was slow because it ran an unoptimized compiler, one cell at a time Along the way: - perf: the gate was slow because it ran an unoptimized compiler, one cell at a time - tests: only corpus_test_run/ and capability/ are executed by the suite Touches 16 files: `src/rust/Cargo.lock`, `src/rust/Cargo.toml`, `src/rust/crates/ck_driver/src/run.rs`, `src/rust/crates/ck_driver/tests/architecture/alloc.rs`, `src/rust/crates/ck_driver/tests/capability.rs`, `src/rust/crates/ck_driver/tests/common/mod.rs` and 10 more.
lnguyen approved these changes 2026-09-08 04:37:55 +02:00
lnguyen left a comment

Second read on src/rust/crates/ck_driver/tests/common/mod.rs. Nothing to add.

Second read on `src/rust/crates/ck_driver/tests/common/mod.rs`. Nothing to add.
hkowalski approved these changes 2026-09-08 07:55:00 +02:00
hkowalski left a comment

Good. Small enough to read in one sitting, and src/rust/crates/ck_driver/tests/common/mod.rs is easier to follow than it was.

Good. Small enough to read in one sitting, and `src/rust/crates/ck_driver/tests/common/mod.rs` is easier to follow than it was.
@ -73,0 +140,4 @@
if let Some(result) = job(item) {
collected
.lock()
.expect("the sweep results outlive every worker")
Owner

The expect message on expect is the only thing a user sees if this fires. Does it say what they should do, or only what we assumed?, at src/rust/crates/ck_driver/tests/common/mod.rs:143.

The expect message on `expect` is the only thing a user sees if this fires. Does it say what they should do, or only what we assumed?, at src/rust/crates/ck_driver/tests/common/mod.rs:143.
@ -225,3 +199,3 @@
/// with exactly one newline, so trailing-whitespace churn can never show up as
/// a moved number.
fn render(app: &str, sizes_exit: i32, tree: &str, run_exit: i32, report: &str) -> String {
fn render(app: &str, sizes_exit: i32, tree: &str) -> String {
Owner

render reads, decides and writes in one function. The deciding part is the piece I would want to test on its own. Worth splitting?

`render` reads, decides and writes in one function. The deciding part is the piece I would want to test on its own. Worth splitting?
Author
Owner

It said what I assumed. Reworded in src/rust/crates/ck_driver/tests/common/mod.rs so it names the input that broke it, which is what the bug report will need.

It said what I assumed. Reworded in `src/rust/crates/ck_driver/tests/common/mod.rs` so it names the input that broke it, which is what the bug report will need.
Author
Owner

Split. The decision in render is a pure function now, with its own tests in src/rust/crates/ck_driver/tests/sizes_pin.rs.

Split. The decision in `render` is a pure function now, with its own tests in `src/rust/crates/ck_driver/tests/sizes_pin.rs`.
`cargo test` builds `ckc` under the `dev` profile and then RUNS it ~550 times.
opt-level 0 was therefore never a build-speed setting here — it multiplied by
every fixture in the tree. Measured on one core, full pipeline (front end +
lowering + Cranelift + link, `ckc disassemble`):

    opt-level 0 (before) ......................... 0.906 s
    deps at 2, workspace at 0 .................... 0.349 s
    deps at 2, workspace at 1 (now) .............. 0.122 s

7.4x, and it is close to free: an incremental rebuild after a real edit to
`ck_span` (15 crates) measures 1.079 s against 1.186 s before — inside the
noise. Dependencies go to 2 because Cranelift dominates codegen and is built
once and cached, so its level never reaches the edit loop.

The guards are untouched, which is the whole reason this is opt-level 1 and
not `inherits = "release"`. `opt-level` is orthogonal to `debug-assertions`
and `overflow-checks`; both stay on, verified in the built binary rather than
assumed — `ckc` still carries its `operator_table.rs` debug_assert sites and
its overflow panic path. `[profile.bootstrap]`'s reasoning applies with equal
force to the profile the tests run under.

`debug = "line-tables-only"` keeps file/line in every backtrace and drops the
records nothing here reads. The suite links ~57 binaries each statically
carrying the compiler and Cranelift, so this is the dominant term in link
time and disk: 65.5 MB -> 33.0 MB per test binary, the built set 5.3 -> 2.6 GB.

`capability` swept its 437 cells in a serial `for` loop — not because a cell
needed the one before it (each materializes its own project, links its own
binary into its own /work subdir, reads back its own meta stream) but because
that is how it was written. On 32 cores that is one core working and the rest
idle, which per cpu_broken.md is also the arrangement that concentrates wear.
It now runs across a bounded pool in `tests/common/mod.rs`, shared rather than
copied because `sizes_pin` needs the same thing next. Capped at the same 12 as
`.cargo/config.toml`'s `[build] jobs`: one number, one policy.

Order is preserved, and that is contractual, not cosmetic — the report prints
the exact block to write for each rejected cell, so a thread-shuffled report
could not be diffed between runs. Workers record each cell's index and the
results are sorted back before rendering. Verified by running the whole tree
at one worker and at twelve: the same eight cells, in the same order.

    capability, one build, workers 1 -> 12 ....... 32.18 s -> 3.37 s
    capability, HEAD -> here ..................... 76.95 s -> 3.37 s

`read_meta_stream` cost a flat five seconds whenever a run emitted nothing.
Its exit test was `!grew && !events.is_empty()`, unreachable while `events` is
empty, so an empty stream could only leave by the deadline. A bare `fn main`
boots no actor and a `ckc test` app with no `test fn` starts none; those runs
are finished, not slow. Split into a first-record grace and the original cap.

`ck_playground` is deleted. Its own manifest called it "throwaway scaffolding
to verify each slice's output visually while CkLang itself is not yet
runnable" — CkLang is runnable now, and it was last touched 2026-05-21. It
dragged eframe/egui/glow/wayland/x11 into the workspace: 307 of 372 lockfile
packages, 82.5%, existed only for it, and `cargo fmt`, `cargo clippy
--workspace --all-targets` and `cargo test --workspace` all paid for them. It
had no tests. Its R4 allow-list entry goes with it.

Full gate, warm on both sides: 5m20s -> 1m32s. The eight red capability cells
are unchanged and predate this: verified by running pristine HEAD.
Owner ruling, 2026-08-18. The other corpus trees are entries to READ, and to
run by hand when you want to. This was already the stated architecture —
`corpus_run.rs` says so in its own header, "tests/corpus/ … apps that exist to
be read … Compile-checked by corpus.rs; never run" — but two gates had drifted
across the line and a third existed to police the wrong side of it.

`sizes_pin` no longer runs anything. It pinned a `ckc run --mem` arena report
and run exit code for every directory under tests/corpus/, and it was the only
thing in the suite executing that tree. Eight of those directories are the
`primes_actor_garage*` drag races, which run five seconds each BY DESIGN: ~40 s
of a ~51 s gate, spent measuring throughput nothing asserts on, on a machine
with a degraded core. A benchmark is not a test.

What that subtracted, precisely: `arena (calculated)` is a compile-time number
and `ckc sizes` — still pinned — is the query that reports it; the measured
high-water read 0 B in all nine apps that produced a report at all, so the
observation the run existed to make was a constant re-derived 36 times a run;
and 36 exit codes. The re-blessed pins are pure deletion, not one `ckc sizes`
line moved. Sizes are a compile-time law and never needed a container.

    sizes_pin ................................... 51.37 s -> 0.16 s

`ml_tictactoe_run.rs` is deleted. It drove tests/corpus/ml_tictactoe. ml_* is
not a gate. The app stays where it is and `scripts/tictactoe.sh` still launches
it interactively whenever you want it.

`runtime_registry/` is deleted, and this is the one that matters. Its whole
contract was "a clean, runnable app in tests/corpus/ is a coverage hole that
needs a justified exemption row" — the exact inverse of the ruling. That
premise is what forced 15 tensor_* rows, 8 primes_actor_garage rows and the
ml_tictactoe row into an allow-list set to grow one row per corpus app forever,
excusing them from a rule nobody wanted. With tests/corpus/ defined as the read
tree, the tree IS the answer. Deleting the rule beats maintaining exemptions to
it (CLAUDE.md rule 4, option (c) — the rule was wrong).

Four ML / benchmark apps move corpus_test_run/ -> corpus/: ttt_selfplay,
tensor_cklang_micro_lm, primes, primes_actor. Their `run.md` files go with the
move, because run.md is the corpus_test_run contract and a declaration no gate
reconciles is exactly the drift this repo forbids. Honest measurement: only
ttt_selfplay was actually slow (1.130 s). primes 0.127 s, primes_actor 0.097 s
and tensor_cklang_micro_lm 0.134 s were all at the ~0.12 s floor every app pays
for compile+link+docker — the 110-app sweep was 13.94 s and essentially all of
it is that floor. So this move is right on the principle, not on the clock.

SHORTFALL_FNS raised 200 -> 223, and it is a different kind of raise from the
one above it: no emitted byte changed. frame_reconcile sweeps tests/corpus/, so
the four apps arrived carrying shortfalls this inventory could not see before —
primes +1, primes_actor +12, tensor_cklang_micro_lm +4, ttt_selfplay +6 = +23,
the whole of the difference. WORST_SHORTFALL_BYTES is the proof it is set
growth and not regression: the worst among all four new pins is 80 B, exactly
the standing ratchet. Attribution is written into the constant.

Five condemned-allocation rows in architecture/alloc.rs are repointed at the
new paths. The M1 gate caught the two I first missed, which is the gate working.

Audited afterwards: of the thirteen gates that can start a container, every one
draws from corpus_test_run/, capability/, tests/fixtures/, or a temp fixture it
builds itself. Zero run anything from tests/corpus/. What still reads that tree
is compile-time only — corpus.rs checking, frame_reconcile and sizes_pin
pinning numbers, the architecture rules reading .ck source.

    full gate, warm ............................. 5m20s -> 34.8 s

Unchanged: the eight red capability cells, identical to pristine HEAD.
hkowalski manually merged commit ade213e4bb into master 2026-09-08 08:00:00 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
northgale/cklang-bootstrap!223
No description provided.