Archive — history, not state. Kept for its reasoning and its evidence; its plan is closed.

Compiler switch report — what each switch changes on a real artifact

Measured 2026-08-05 (night of 2026-08-04) on branch cp/switch-report, base commit 1d65dd7da10a640b17f8017623904795f212ae89 (lane/cp, “Merge p/dedup-census into lane/cp”). Every number below comes from runs made tonight in the worktree .claude/worktrees/night-switchreport; nothing is quoted from plans/one-binary.md or plans/compiler-perf.md.


1. The artifact

The pit-shop/clerk linked closure — the P7 census corpus.

propertyvalue
entrypit-shop/clerk.ce
modules in the linked closure99
source lines59,305
functions in final IR (ship)4,195
corpus identity (sha256 of the sorted path list + shasum -a 256 of each file)9acbaec8b5a40dac243c054e01a9b268bba567a9a316064a1fccd419fc6e22a8
executable hash reported by the shop for the same closureblake2:8b2366df4d031c570c484a051b718628004716f3bd547a0410e20195b84ea482

The 99 locators came from the shop itself:

./pit compile linked_plan pit-shop/clerk.ce      # module_count: 99

and were mapped to the 99 source files listed in plans/night-2026-08-04/corpus.txt (locator pkg/modpkg/mod.cm|.ce). P7 recorded 4,188 functions against 4,195 here; the seven-function drift is ordinary source movement between that census and this tip.

2. Method

The matrix is not run through the shop. A shop realization bakes one compiler profile into the derivation key, so switching passes per row would have measured cache behaviour rather than compiler behaviour. Instead a single harness actor compiles all 99 sources itself, once per row, with the switch record handed straight to streamline:

compiler.mcode_unit_result(src, path, {
  link_mode: "pool",
  streamline_options: {profile: <name>, stage: "unit", passes: {...}}
})

The harness is committed beside this report as plans/night-2026-08-04/switchbench.ce. Reproduce a row with:

cd <worktree>
make                                     # once
echo <config-name> > $SCRATCH/config.txt
cp plans/night-2026-08-04/corpus.txt $SCRATCH/paths.txt
./pit $SCRATCH/switchbench.ce            # writes $SCRATCH/rows/<config>.json

(the harness has the scratch directory as a def SCR constant at the top — point it at your own).

Column definitions, all measured on the final IR:

  • instructionssum over every function of ir_stats.detailed_stats(fn).instr across all 99 units. Labels are excluded, _nop_ rows are counted. This is not the linker’s own program_code_stats counter; that one counts every row and reads ~1.34x higher (see §5).
  • guards — the same walk’s .guard count (the nine census guard ops). Equal, in every row, to guard_reasons.analyze’s census_sites.
  • guard-reason top-5shop_tools/internal/guard_reasons.cm summary.census_by_reason, merged across all 99 units.
  • literal-pool rows — per-module rows: sum length(unit.literals). Linked rows: the program’s stats.literal_rows_after.
  • artifact bytesshoplib::canonical.encode (binary Nota) of the portable mcode payload: per-module, the sum over the 99 pit.mcode.unit@3 records; linked, the sum over the 99 literal-stripped linked units plus the one shared literal group. This is the mcode artifact, not a pressed Mach pool and not a cart. (mach_press refuses a pool-lane unit without an import covering, so pool bytes were not available for a like-for-like row.)
  • compile s / link stime.monotonic inside the harness actor around the 99-unit compile loop and around mcode_link.link_result separately. Process startup, file reads and the census walk are outside both clocks.

Linked rows use P6’s semantic linker with the SHIP_PLAN shape from shop_tools/internal/diff_runner.cm:

{schema: "pit.mcode.link-plan@1", profile: "ship",
 granularity: "program", stripping: {names: false, logs: []}}

Import edges were wired by resolving each unit’s use() claim package-qualified (pkg::modpkg/mod, bare mod<own pkg>/mod) against the 99-locator set. 238 of 373 distinct claims resolved; the remaining 135 are C natives, platform alias modules and fallback-package resolutions and were left unwired. §6 shows why that does not move the result.

3. The matrix

Baseline for the “vs ship” columns is row 2. Total s = compile + link.

#rowinstructionsvs shipguardsvs shipliteral rowsartifact bytesvs shipcompile slink stotal s
1dev (per-module)857,842+0.32%113,678+1.02%8,55257,739,798+0.14%120.8120.8
2ship (per-module) — the shipped form855,147112,5338,55257,660,153104.7104.7
3small (per-module)854,998−0.02%112,5330.00%8,55257,649,072−0.02%95.795.7
4ship + join_types ON852,044−0.36%112,269−0.23%8,54557,487,982−0.30%209.5209.5
5aship − type_checks1,054,404+23.30%166,329+47.80%8,59269,882,875+21.20%129.9129.9
5bship − guardcse897,354+4.94%123,034+9.33%8,56459,541,422+3.26%105.9105.9
5cship − notstone856,676+0.18%112,673+0.12%8,55257,782,150+0.21%121.6121.6
5dship − inline854,998−0.02%112,5330.00%8,55257,649,072−0.02%111.3111.3
5eship − panic_outline946,742+10.71%112,5370.00%8,55264,636,824+12.10%112.2112.2
6ship FULLY LINKED (pit.mcode.program@1)841,997−1.54%111,025−1.34%3,54454,271,643−5.88%104.267.8172.0
7ship linked + join_types ON841,364−1.61%110,951−1.41%3,54354,239,684−5.93%224.369.0293.4

Rows 5a–5e turn exactly one switch off relative to row 2; everything else in the ship profile stays on. Rows 6–7 compile every unit at stage: "unit" first (that is the compile s column) and then run the program linker over all 99 units at once (link s).

Guard-reason top-5

#rowtop five census guard reasons
1devslot_type_unknown_at_join 41,689 · dynamic_read_result 20,806 · param_polymorphic 16,657 · call_result_polymorphic 12,792 · predicate_result_observed 10,448
2shipslot_type_unknown_at_join 41,670 · dynamic_read_result 20,779 · param_polymorphic 16,581 · call_result_polymorphic 12,786 · predicate_result_observed 10,452
3smallslot_type_unknown_at_join 41,666 · dynamic_read_result 20,779 · param_polymorphic 16,580 · call_result_polymorphic 12,792 · predicate_result_observed 10,448
4ship + join_typesslot_type_unknown_at_join 41,412 · dynamic_read_result 21,079 · param_polymorphic 16,391 · call_result_polymorphic 12,691 · predicate_result_observed 10,443
5aship − type_checksslot_type_unknown_at_join 63,080 · type_unknown 26,401 · dynamic_read_result 22,505 · param_polymorphic 19,835 · call_result_polymorphic 14,700
5bship − guardcseslot_type_unknown_at_join 43,480 · dynamic_read_result 22,266 · param_polymorphic 18,897 · call_result_polymorphic 13,876 · predicate_result_observed 10,455
5cship − notstoneslot_type_unknown_at_join 41,670 · dynamic_read_result 20,779 · param_polymorphic 16,589 · call_result_polymorphic 12,786 · predicate_result_observed 10,452
5dship − inlineslot_type_unknown_at_join 41,666 · dynamic_read_result 20,779 · param_polymorphic 16,580 · call_result_polymorphic 12,792 · predicate_result_observed 10,448
5eship − panic_outlineslot_type_unknown_at_join 41,688 · dynamic_read_result 20,902 · param_polymorphic 16,341 · call_result_polymorphic 12,879 · predicate_result_observed 10,452
6ship linkedslot_type_unknown_at_join 41,051 · dynamic_read_result 20,693 · param_polymorphic 16,137 · call_result_polymorphic 12,466 · predicate_result_observed 10,447
7ship linked + join_typesslot_type_unknown_at_join 40,864 · dynamic_read_result 20,917 · param_polymorphic 16,061 · call_result_polymorphic 12,452 · predicate_result_observed 10,439

slot_type_unknown_at_join is the top reason in every row, at 37.0% of ship’s 112,533 census guards. Nothing in the matrix moves it more than 1.9% (row 7, −806 of 41,670), and turning type_checks off raises it by 51%.

Linker’s own counters (rows 6 and 7)

The linker reports its statistics over its own instruction walk, which counts every IR row. Recorded verbatim so this run is comparable to P7’s:

statrow 6 (ship linked)row 7 (+ join_types)
link_instructions_before1,141,2781,138,284
link_instructions_after1,128,1251,127,601
link_instructions_removed13,15310,683
link_guards_before118,081117,807
link_guards_after116,519116,436
link_guards_removed1,5621,371
literal_rows_before8,5498,544
literal_rows_after3,5443,543
literal_rows_coalesced5,0055,001
link_group_count11
finalized_sites00
constant_members / function_members / generic_members0 / 0 / 1,3340 / 0 / 1,334
link_fact_slots00

P7 recorded 1,122,900 instructions and 8,890→3,662 literal rows on this closure; tonight’s 1,128,125 and 8,549→3,544 are the same measurement on a slightly moved tree.

4. Findings

The three profiles are not three profiles. dev, ship and small produce outputs within 0.32% of each other on instructions, 1.02% on guards and 0.14% on bytes. dev — which turns off inline, licm, int_ranges, sroa, forward and switch — is worse than ship on every output axis and slower to compile (120.8 s vs 104.7 s). Whatever dev is buying, it is not compile time on this corpus; the passes it disables (forward, sroa) shrink the IR that every later pass then has to walk, so removing them costs more than they cost. small is exactly ship minus inline, and its output is byte-identical to row 5d, which is how we know the output columns carry no run-to-run noise at all.

Which switches pay:

switchinstructions if OFFbytes if OFFverdict
type_checks+23.30%+21.20%the pass. Also pays for itself in compile time: turning it off made the compile 24% slower, because every later pass then walks 47.8% more guards. Nothing else is close.
panic_outline+10.71%+12.10%strong size win, guard-neutral. It moves 535 panic tails into shared helpers (4,195 → 3,660 functions when off, i.e. 535 helpers exist when on). Compile cost is inside the noise band.
guardcse+4.94%+3.26%free win. 10,501 guards removed (9.33%) for a compile-time delta of +1.1%, which is inside noise.
notstone+0.18%+0.21%marginal but positive and cheap.
inline−0.02%−0.02%buys nothing in size — the output is very slightly smaller with inlining off. Its case has to be made on runtime speed, which this matrix does not measure. It is the only difference between ship and small.

join_types does not clear John’s bar, by a wide margin. Row 4 against row 2: −0.36% instructions, −0.23% guards, −0.30% bytes, for 209.5 s against 104.7 s — exactly 2.00x compile time. The standing ruling is that ~5% program win is worth 2x compile. This is 0.36% for 2.00x, roughly 14x short. On top of the fully linked baseline (row 7 vs row 6) it is worse still: −0.075% instructions and −0.06% bytes for +120 s. The default-off setting in pit-compiler/passflags.cm is correct as it stands, and this corpus does not support flipping it — even though slot_type_unknown_at_join is the largest guard class, join-aware facts clear only 258 of its 41,670 sites (0.62%) at the unit stage.

What link time buys, and it is the best row in the table. Row 6 against row 2, holding the unit-stage compiler identical:

  • literal-pool rows 8,552 → 3,544 (−58.6%, 5,005 rows coalesced into one program group)
  • artifact bytes 57,660,153 → 54,271,643 (−5.88%, 3.39 MB)
  • instructions −1.54% (13,150 rows), guards −1.34% (1,508)
  • cost: +67.8 s on 104.7 s, i.e. 1.65x total compile time

That is a 5.9% artifact win for 1.65x — the only lever in this report that clears the 5%-for-2x bar, and it clears it with room. Most of the byte win is the shared literal pool (5,005 rows coalesced); the link-stage streamline contributes the −1.54% instructions / −1.34% guards on top. The 67.8 s was not broken down between the two phases. Link cost is also a once-per-closure charge rather than a per-unit multiplier, so it does not tax the edit-compile loop the way join_types does.

P6’s import-edge finalization fires on nothing here, and this is not a wiring artifact. finalized_sites is 0 with 1,334 member references seen. The reason is not the 135 unwired native/alias imports: running mcode_link.export_summary directly over all 99 compiled units returns generic for 99 of 99. Zero modules in the shipped clerk closure present the shape the recognizer requires — exactly one return, of a stone(record) built with no non-construction control flow in the module body. Real modules branch while building their export record (platform selection, capability probes, memoization), which sets control_safe false and drops the candidate to generic. The size half of P6 is therefore carried entirely by the literal pool; the direct-reference half is, on this artifact, inert. If direct references are wanted, the recognizer has to be taught the branch-and-freeze shape, not merely handed a better import graph.

Compile cost, in seconds, for the record. ship compiles the 99-unit closure in ~105 s in-actor. Enabling join_types costs +105 s. Linking costs +68 s. Disabling type_checks costs +25 s (a loss on both axes). Everything else moves the clock by less than the measurement noise.

5. Caveats

  1. Wall-time is noisy; output metrics are exact. Two independent runs of the identical configuration measured 104.70 s and 109.98 s for ship (+5.0%), and rows 3 and 5d — semantically the same configuration — measured 95.66 s and 111.27 s (+16.3%) while producing byte-identical outputs. Treat any compile-time delta under about ±16% as noise. Only join_types (+100%), linking (+65%) and type_checks-off (+24%) are outside that band. All output columns (instructions, guards, literal rows, artifact bytes) are deterministic — rows 3 and 5d agree to the byte on all four.
  2. One sample per row. No medians. Given caveat 1 this matters only for the time column.
  3. “Artifact bytes” is canonical mcode, not a cart. It is the binary Nota encoding of the portable units (plus the program literal group when linked). It is a faithful proxy for payload size and it is what the literal pool acts on, but it is not boot/root.cart bytes and it is not pressed Mach pool bytes. mach_press cannot press a link_mode: "pool" unit without an import covering (mach_pool_emit: pool-global VALUES link disrupted), so a pool-bytes column could not be made like-for-like across linked and unlinked rows.
  4. Instruction counts here are lower than the linker’s. 855,147 (per-function detailed_stats) vs the linker’s 1,141,278 for the same program; the linker counts every IR row including labels. Both numbers are reported so this run can be compared either way.
  5. 135 of 373 use() claims were left unwired in the linked rows (C natives, platform alias modules, fallback-package resolutions). This is a floor on import finalization — but see §4: the export recognizer rejects all 99 units regardless, so a complete graph would not have raised finalized_sites above 0.
  6. Row 7 turns join_types on at the unit stage only. The link plan’s profile field is validated against dev|ship|boot only (valid_link_plan in pit-linker/mcode_link.cm), so there is no way to hand the link-stage streamline a join_types: true override without editing the linker or adding a profile. Row 7 is therefore “unit-stage join facts, then the ordinary ship program link”. The link-stage half of join_types is unmeasured.
  7. The switch surface is passflags.resolve options, not the shop. No shop toggle was exercised; the derivation key still bakes one compiler_profile. Nothing here says how these switches behave through a real pit build.
  8. The tree’s known linker-test disruptions were not exercised. tests/mcode_link.cm’s three disrupting tests were left alone as briefed. The linker itself did not disrupt in any row: both linked rows returned a valid pit.mcode.program@1 with zero diagnostics and all 99 units materialized.
  9. Two shop-side oddities were hit while collecting the corpus and are worth someone’s time, but are not part of this report’s claims: a cache-hit ./pit compile linked_plan pit-shop/clerk.ce hung past the 600 s terminal session cap after load_cached_executable (the cold run of the same command completed in 97 s), and a make-spawned pit forge process was still holding .pit/pit.pid after make exited 0, which made the next client refuse with “shop daemon has different build signature”.

6. Raw rows

Everything this report claims is in the directory beside it:

  • rows/<config>.json — the eleven matrix rows verbatim as the harness wrote them, each with the full guard-reason and guard-op histogram and (for linked rows) the linker’s own stats block.
  • rows/_exportprobe.json — the export_summary census ({"generic": 99}) and the repeat ship compile used for the noise estimate in §5.
  • switchbench.ce — the harness that produced every row.
  • exportprobe.ce — the export_summary / repeat-compile probe.
  • corpus.txt — the 99 source paths of the clerk closure, in the order compiled.

Source: plans/archive/night-2026-08-04/switch-report.md