🌐 ν•œκ΅­μ–΄λ‘œ 읽기

08 β€” Bootstrap

Where we are

Chapter 07 closes at a824a175 ("Check for stack overflow."), 2023-07-18 15:04:09 β€” the compiler's own source has been squeezed, module by module, into the narrow dialect it can read itself: no type classes, no deriving, no fixity declarations, a hand-rolled Map, Int in place of Integer. That last commit replaces the C runtime's fixed-size interpreter stack array with a malloc'd one, sized by a new -K flag. Three minutes and twenty seconds later, 456ce17c lands. The ladder those two commits and everything before them were built for β€” GHC compiles the compiler, the compiler compiles itself, the C runtime runs that self-compile, and a byte-for-byte cmp checks the two results agree β€” is aux/bootstrapping-a-compiler.md's subject in full; read it first. This chapter narrates what happens the moment that ladder gets make targets of its own and someone actually climbs it.

What happened

The range a824a175..298c9824 is 28 commits, all by Lennart Augustsson, spread across two and a half calendar days: nineteen commits on 07-18 (15:07–23:19), four on 07-19 (13:11–19:29), and five on 07-20 (11:20–14:58).

456ce17c: the milestone

456ce17c ("Add bootstrap test."), 15:07:29, adds one new Makefile target β€” four lines (456ce17c:Makefile lines 66–69, quoted whole):

bootcombtest:	$(BIN)/uhs uhs.comb
	$(BIN)/uhs -ilib -isrc -omain-uhs.comb  MicroHs.Main
	$(BIN)/eval -H10000000 -K1000000 -ruhs.comb --  -ilib -isrc -omain-comb.comb MicroHs.Main
	cmp main-uhs.comb main-comb.comb

Line one has the GHC-built bin/uhs compile MicroHs.Main β€” its own compiler's entry point, the whole src/MicroHs/lib tree it imports β€” straight to main-uhs.comb. Line two hands uhs.comb (a separate, already-built target: bin/uhs compiling itself to a combinator file) to bin/eval, the C runtime, and tells it to run that combinator file as a program, with the same compiler arguments (-ilib -isrc -omain-comb.comb MicroHs.Main) tacked on after the --. uhs.comb is uhs, translated into the combinator terms eval already knows how to run β€” chapter 02's .comb format, unchanged in shape since then. So line two is the interpreted compiler compiling the same source a second time, writing main-comb.comb. Line three is the oracle: plain byte equality, no special-casing. If a native, GHC-built compiler and that same compiler, translated into combinators and run by the C interpreter, produce identical output compiling their own source, the ladder holds. The full treatment of why cmp (rather than some semantic check) is the right oracle, and of bootboottest, the parallel "local lib" rung, is the aux doc's; this chapter's job is watching bootcombtest actually run.

fc7a2ad7 / f06bacb7 / 1876f4c1: the reality of a compiler-compiling-compiler workload

Running a compiler on nothing but a few short test files, the way every prior chapter's Try-it does, and running it on itself β€” 30 .hs files under src/ and lib/, 73,856 bytes combined at this commit (counted with git ls-tree -r -l 456ce17c -- src lib) β€” are different workloads, and the difference shows up within the hour.

fc7a2ad7 ("Add comments."), 15:36:42, is mostly what its title says: short /* ... */ annotations next to eval.c's combinator cases (/* K x y = * x */ and the like). But three of its added lines, sitting right above the IO_BIND case, are not decoration β€” they're a TODO note written against a real, anticipated problem (fc7a2ad7:src/runtime/eval.c lines 977–979, quoted whole):

      /* XXX could use associativity to avoid deep evalio recursion. */
      /* (m >>= g) >>= h    ===  m >>= (\ x -> g x >>= h) */
      /* BIND (BIND m g) h  ===  BIND m (\ x -> BIND (g x) h) == BIND m (C' BIND g h)*/

Thirty-five minutes later, f06bacb7 ("Commented out non-working alternative."), 16:12:11, tries a fix β€” in the library, not the runtime. lib/System/IO.hs's hGetContents reads a file handle one character at a time, and its recursive case is an ordinary do-bound >>=: each character read nests one more IO.>>= around the rest of the file (f06bacb7:lib/System/IO.hs lines 126–135, quoted whole):

-- Strict hGetContents
hGetContents :: Handle -> IO String
hGetContents h = do
  c <- P.primHGetChar h
  case c == negate 1 of
    False ->
      do { cs <- hGetContents h; return (chr c:cs) }
      -- This should use less stack, but it doesn't work. :(
      --return (chr c : P.primPerformIO (hGetContents h))
    True  -> return ""

This is exactly the site the TODO comment above was worried about: src/MicroHs/Compile.hs's readFilePath calls IO.hGetContents to read every module the compiler compiles, so a source file of N characters builds a chain of N nested >>=s before any of them run. The commented- out line tries routing around that with P.primPerformIO instead of a recursive do-bind; the comment says plainly it doesn't work, and the attempt is left in place, dead, rather than deleted.

Twenty-one seconds after that, 1876f4c1 ("Use associativity of IO.>>= to avoid deep evalio recursion."), 16:12:32, fixes the actual mechanism β€” in the runtime, where the TODO comment said it belonged. Before this commit, evalio's IO_BIND case handles m >>= g by calling evalio recursively on m β€” so for a left-nested chain (m >>= g) >>= h, evaluating the outer bind means a nested C function call to evaluate the inner one, one native stack frame per bind in the chain. A hand-read of one character produces one bind; a source file produces as many nested binds as it has characters, and that recursion runs on the C process's own call stack, not the interpreter's own -K-sized NODEPTR stack a824a175 just made growable β€” a different stack, with no such flag. 1876f4c1's fix uses the algebraic law fc7a2ad7's TODO comment already named β€” (m >>= g) >>= h === m >>= (\x -> g x >>= h) β€” to rewrite the term instead of recursing into it, and loops back to the top of the evaluator via goto top rather than making a nested call (1876f4c1:src/runtime/eval.c lines 980–998, quoted whole):

    case IO_BIND:
      CHECKIO(2);
      {
        /* Use associativity to avoid deep evalio recursion. */
        /* (m >>= g) >>= h      ===  m >>= (\ x -> g x >>= h) */
        /* BIND ((BIND m) g) h  ===  BIND m (\ x -> BIND (g x) h) == (BIND m) (((C' BIND) g) h)*/
        NODEPTR bm;
        NODEPTR bmg = evali(ARG(TOP(1)));
        GCCHECKSAVE(bmg, 4);
        if (TAG(bmg) == AP && TAG(bm = indir(FUN(bmg))) == AP && TAG(indir(FUN(bm))) == IO_BIND) {
          NODEPTR g = ARG(bmg);
          NODEPTR h = ARG(TOP(2));
          n = new_ap(bm, new_ap(new_ap(new_ap(combCC, combIOBIND), g), h));
          POP(3);
          goto top;
        }
      }

      x = evalio(ARG(TOP(1)));  /* first argument, unwrapped */

Precision matters here: this is not a report of >>= producing wrong answers, and nothing about it changes what any bind computes β€” every m >>= g still ends up running g's continuation with m's result, in order. What changes is how many native stack frames evaluating a left-nested chain of binds costs: unboundedly many before this commit (one per bind, however deep the chain), a constant number after it, because the interpreter loop now iterates instead of recursing whenever it can. The bug this closes is a stack-depth risk under a character-at-a-time file reader run against real source files β€” a compiler compiling itself is the first workload in this project's history that produces IO chains long enough to make that risk concrete.

5d58461f: bigger ints, honestly

Six minutes and forty-eight seconds after the associativity fix, 5d58461f ("Use bigger ints."), 16:19:20, is not about the Haskell Int type user programs see β€” that had already narrowed to machine width back in chapter 06/07's diet. It widens the runtime's own bookkeeping counters from int to int64_t: num_reductions, num_gc, num_marked, max_num_marked, start_size, and several heap-indexing loop variables in init_nodes, scan, gc, and parse_top (5d58461f:src/runtime/eval.c lines 121–122, quoted):

int64_t num_reductions = 0;
int64_t num_gc = 0;

A test program's reduction and GC counts fit comfortably in 32 bits; a compiler compiling several thousand lines of its own source does not stay comfortably inside that margin for long. Nothing user-visible about program semantics changes β€” only how far the runtime's own statistics can count before silently wrapping.

1a84b483: give up when the heap is down to 2%

6ca78e8a ("Cleanup"), 23:16:14, and eight seconds later 1a84b483 ("Give up when heap is down to 2%"), 23:16:22, land at the end of the same long evening. The GC's gc() function already tracked how many cells survived each mark-and-sweep pass; this commit adds one check right after that count is known (1a84b483:src/runtime/eval.c lines 334–338, quoted whole):

  if (num_marked > max_num_marked)
    max_num_marked = num_marked;
  int64_t free = heap_size - heap_start - num_marked;
  if (free < heap_size / 50)
    ERR("heap exhausted");

heap_size / 50 is exactly 2% of the heap. This is not a smarter collector or a bigger one β€” the heap size is still whatever -H says it is β€” it's an honest bail-out: once a GC pass leaves fewer than 2% of the heap free, the runtime stops and reports "heap exhausted" instead of continuing to collect ever more often for ever less benefit, or running out mid-allocation with no diagnostic at all. It is the runtime meeting its own limit, for the first time, under a workload big enough to find it β€” an honest ceiling stated in the same commit that discovers it matters.

ee853660 / 04a4bbe4 / 8ad7094c: performance work, only after a real workload

Two days later, on 07-20, ee853660 ("Specialize the Map to StringMap."), 11:20:05, deletes src/MicroHs/Map.hs β€” the general Map k v chapter 07 built with an explicit equality argument on every call β€” and replaces every use of it in Compile.hs, Desugar.hs, Main.hs, and Translate.hs with src/MicroHs/StringMap.hs, a version specialized to String keys with eqString baked in rather than passed at each call site: ee853660:src/MicroHs/Compile.hs line 79 reads case M.lookup nm ch of where the previous commit's same line read case M.lookup eqIdent nm ch of. Twenty-five minutes later, 04a4bbe4 ("Sort in frequency order."), 11:45:47, reorders two lookup tables that are both searched linearly, string by string, on every combinator or primop name the compiler ever reads or writes: eval.c's primops[], which parse() scans on every .comb-file token, and Translate.hs's primTable. Both diffs add a comment naming the same reasoning (04a4bbe4:src/runtime/eval.c lines 169–190 and 04a4bbe4:src/MicroHs/Translate.hs lines 36–51, quoted whole, ... marking the primop entries omitted after each combinator table):

/* We use linear search in this, because almost all lookups
 * are among the combinators.
 */
struct {
  char *name;
  enum node_tag tag;
  NODEPTR node;
} primops[] = {
  /* combinators */
  /* sorted by frequency in a typical program */
  { "B", B },
  { "O", O },
  { "K", K },
  { "C'", CC },
  { "C", C },
  { "T", T },
  { "S'", SS },
  { "P", P },
  { "I", I },
  { "S", S },
  { "Y", Y },
  { "B'", BB },
  ...
-- Use linear search in this table.
-- 99% of the hits are among the combinators.
primTable :: [(String, Any)]
primTable = [
  ("B", primitive "B"),
  ("O", primitive "O"),
  ("K", primitive "K"),
  ("C'", primitive "C'"),
  ("C", primitive "C"),
  ("T", primitive "T"),
  ("S'", primitive "S'"),
  ("P", primitive "P"),
  ("I", primitive "I"),
  ("S", primitive "S"),
  ("Y", primitive "Y"),
  ("B'", primitive "B'"),
  ...

Both tables move B and O to the front and push S, Y, and B' β€” the combinators chapter 01's bracket abstraction produces least often in practice β€” toward the back, on the strength of a one-line comment about typical frequency, not a cited measurement. Between these two, at 16:50:16 on 07-18 (the same evening as the recursion fix, not the same day as the reordering), 8ad7094c ("Add profiling option.") adds a PROF variable to the Makefile, wired into bootuhs's build and link steps: GHC's own -prof -fprof-auto cost-center profiler, applied to the "local lib" GHC build of the compiler. Built and run this chapter's own Try-it below, that profiler's report is dominated by Text.ParserComb.(>>=), Data.List.(++), and Text.String.eqString β€” Haskell-level cost centers inside the compiler's own parser and library, measured on bootuhs, a native GHC binary. It has nothing to say about which combinator names β€” S, K, B, O β€” turn up most often in a .comb file, which is what 04a4bbe4's tables are sorted by; that's a property of the C runtime eval and the translator Translate.hs, neither of which the GHC profiler ever touches. Checked directly rather than assumed: no commit message, code comment, or file in this range connects the two. They read as two independent performance interventions from the same 48-hour window β€” one instruments the compiler's own Haskell, the other hand-tunes a linear search in a completely different program β€” not a pipeline where one feeds the other.

db418344: type parsing, parse-only

db418344 ("Add type parsing."), 18:46:43, gives Parse.hs a real type syntax for the first time since chapter 06 flattened type Type = Expr. A new EType distinguishes type variables, constructor applications, and unification variables, and ETypeScheme adds forall (db418344:src/MicroHs/Parse.hs lines 99–107, quoted whole):

-- No higher order type variables (yet)
data EType
  = TVar Ident
  | TConstr Ident [EType]
  | TUVar Int
  --Xderiving (Show)

data ETypeScheme = ETypeScheme [Ident] EType
  --Xderiving (Show)

Sign :: Ident -> ETypeScheme -> EDef now carries a real parsed type scheme instead of an untyped Expr alias. But Desugar.hs, unchanged in this respect by this commit, still turns every Sign into nothing (db418344:src/MicroHs/Desugar.hs line 151, quoted): Sign _ _ -> []. Types are syntax the parser understands and the EDef AST can carry, and nothing downstream reads them yet β€” no checker, no inference, not even a warning. The same parse-only posture chapter 03 gave the compiler's earliest type signatures, one step further along; what those parsed types eventually get used for is chapter 09's subject, not this one's.

298c9824: uhs becomes mhs β€” what actually renames

The range's last commit, 298c9824 ("Rename uhs to mhs."), 14:58:46, touches exactly two files: Makefile and tests/Makefile. No file is git mv'd, no module is renamed β€” MicroHs.Main was already called that. What changes is a new MHS=mhs Makefile variable substituted everywhere the literal string uhs had named a build artifact: the binary ($(BIN)/uhs becomes $(BIN)/$(MHS)), its GHC-built, local-library sibling (bootuhs becomes boot$(MHS)), the self-compiled combinator file (uhs.comb becomes $(MHS).comb), the phony test target (uhstest becomes $(MHS)test), and, in tests/Makefile, the UHS variable itself is renamed to MHS=../bin/mhs. The project earns its name β€” MicroHs β€” in its build vocabulary first. It doesn't earn it everywhere at once: a grep -rn uhs over src//lib//ghc/ at this commit still turns up one hit, src/MicroHs/Main.hs's own usage message, unchanged by this commit (298c9824:src/MicroHs/Main.hs line 23, quoted): error "Usage: uhs [-v] [-r] [-iPATH] [-oFILE] ModuleName". The compiler renamed by this commit still calls itself uhs to its own users' faces.

Undisclosed extras

Two commits carry more than their titles say. 787d8842 ("Add an (unused) Data.Map"), 14:48:47, does add the promised file β€” 177 lines, a real balanced 2-3 tree implementation, built but wired into no call site (the Makefile only compiles it as part of bootuhs, never imports it from anywhere that matters) β€” but the same commit also adds a leIdent helper to Parse.hs and moves StringMap.hs's type signatures into a new trailing block alongside a fresh commented-out Data.Map-based alternative implementation, neither mentioned in the title. 8e2f7109 ("Change test targets."), 16:25:51 on 07-18, mostly does what it says β€” renaming and adding alltest/everytest targets β€” but its bootcombtest diff also silently drops -K1000000 from the eval invocation 456ce17c had added 1 hour 18 minutes earlier (8e2f7109:Makefile line 67, quoted): $(BIN)/eval -H10000000 -ruhs.comb -- -ilib -isrc -omain-comb.comb MicroHs.Main β€” no -K1000000, and no mention of it in the commit title β€” leaving bootcombtest to run on eval's built-in default of 10,000 interpreter-stack cells from then on β€” a hundredfold cut in an explicit safety margin, undisclosed. On the other side of the ledger: acf40ed4 ("Remove junk"), 13:11:39 on 07-19, deletes A.hs and Test.hs β€” the exact two files 59b8b1ab added, undisclosed, under the unrelated title "Fix name" back in chapter 07's range. Two weeks later, the same project removes them under a title that says plainly what the diff does.

Why it matters

456ce17c isn't a feature landing; it's a fixpoint closing. Once bootcombtest exists and passes, the compiler's own speed stops being a side concern and becomes the benchmark that matters most: every commit that makes the runtime or the compiler faster makes the next bootstrap run faster too, and the bootstrap run is now the heaviest, most representative workload in the whole project's test suite. That's visible in the commit order itself. Every performance intervention in this chapter β€” the int64_t widening, the 2%-heap bailout, StringMap, the frequency-sorted lookup tables β€” comes after 456ce17c, several of them within hours, none of them before. The runtime had gone 54 days, from this repository's first commit (ccb31388, 2023-05-25) to a824a175, without a single commit tuning its counters, its GC threshold, its data structures, or its lookup order β€” earlier commits titled "optimiser" (ae2a1403, d2956ee8, and others) are a different concern entirely, code-quality passes over Exp.hs's bracket-abstraction output, aux/bracket-abstraction.md's subject, not runtime engineering. The self-compile is what finally gave the runtime a workload worth engineering for, and the project engineered for it immediately once it existed, not before.

Notebook margin

  • The three fixes most directly forced by running a real self-compile β€” 1876f4c1's associativity rewrite, 5d58461f's wider counters, 1a84b483's heap bailout β€” land 7 hours 3 minutes 50 seconds apart at the outside (1876f4c1 to 1a84b483), and the tightest pair (f06bacb7 to 1876f4c1) is 21 seconds.
  • The StringMap/frequency-order/profiling cluster (8ad7094c, ee853660, 04a4bbe4) spans 42 hours 55 minutes 31 seconds end to end β€” still inside two days of 456ce17c, even though the last two land on a different calendar day than the first.
  • d8d8856b ("Add version string to compiler binary."), 11:53:35 on 07-20, gives every .comb file a five-byte header ("v1.0\n") and adds a checkversion check to eval's main that reads and matches those bytes before parsing the rest of the file, one ERR("version mismatch") away from running a .comb file built by an incompatible compiler version. 787d8842 ("Add an (unused) Data.Map"), the same afternoon, is the honest kind of speculative code this project allows: built, compiled, never called, and its title says so.

Try it

Anchor: 456ce17c ("Add bootstrap test."), 2023-07-18 15:07:29 β€” the commit that adds the bootcombtest target this exercise runs. Convention (see 00-overview.md):

git worktree add /tmp/mhs-456ce17c 456ce17c
cd /tmp/mhs-456ce17c

Build both binaries the target needs, exactly as written, no patch needed on this project's flake toolchain (GHC 9.8.4):

mkdir -p bin
nix develop $MICROHS -c make bin/uhs
nix develop $MICROHS -c make bin/eval
[...]
[12 of 12] Linking bin/uhs
gcc -Wall -O3 src/runtime/eval.c -o bin/eval

(The same -Wx-partial warnings on head/tail every earlier chapter's Build notes already flag appear during the bin/uhs build; omitted.)

Now the money shot β€” the whole ladder, run as bootcombtest itself runs it, timed step by step:

nix develop $MICROHS -c bash -c 'time bin/uhs -ilib -isrc -ouhs.comb MicroHs.Main'
real	0m0.964s
nix develop $MICROHS -c bash -c 'time bin/uhs -ilib -isrc -omain-uhs.comb MicroHs.Main'
real	0m0.942s
nix develop $MICROHS -c bash -c 'time bin/eval -H10000000 -K1000000 -ruhs.comb -- -ilib -isrc -omain-comb.comb MicroHs.Main'
real	0m21.273s
nix develop $MICROHS -c cmp main-uhs.comb main-comb.comb

cmp prints nothing and exits 0: main-uhs.comb and main-comb.comb β€” 68,799 bytes each β€” are byte-identical. The GHC-built bin/uhs compiled its own source; that same source, translated into combinators and interpreted by bin/eval, compiled it again; both runs produced the exact same output. That's bootcombtest, in full, actually running, at the commit that adds it β€” no stack overflow, no patch. (I also re-ran the whole ladder at this range's last commit, 298c9824, with bin/mhs in place of bin/uhs: main-mhs.comb and main-comb.comb agreed there too, 75,052 bytes each, in 20.715s for the interpreted step β€” so whatever native-stack risk 1876f4c1 closed, it isn't one this environment's default 8 MB ulimit -s happens to trigger for this particular self-compile; the fix is real and the mechanism above is read directly from the diff, but I did not manage to reproduce the crash it prevents.)

Exercise 1: time the eval-run self-compile against the GHC-built one

The three time runs above already answer this, on this machine: the GHC-built bin/uhs compiles MicroHs.Main in well under a second (0.942s–0.964s, run to run); bin/eval interpreting the combinator translation of that same compiler, doing the identical compile, takes 21.273s β€” a 22.6Γ— slowdown (21.273 Γ· 0.942): native code versus a graph-reduction interpreter walking the same combinator terms, on this compiler's own source for the first time rather than on a short test program the way every earlier chapter's time target measures it. The same three-step timing at 298c9824, two days and 27 commits of source growth later (main-uhs.comb 68,799 bytes here versus main-mhs.comb 75,052 bytes there, roughly 9% more program), gives 0.926s / 0.890s / 20.715s β€” a slightly better throughput per byte (75,052 Γ· 20.715 β‰ˆ 3,624 B/s interpreted, versus 68,799 Γ· 21.273 β‰ˆ 3,234 B/s here), consistent with β€” though this single before/after pair can't isolate exactly which of this range's performance commits earns it β€” ee853660/04a4bbe4's lookup-table work paying for itself rather than being pure overhead.

Exercise 2: corrupt one byte of uhs.comb

uhs.comb's text format names every combinator with a $-prefixed token; find one and flip a single byte:

(python3 isn't in the devShell's buildInputs either β€” like diff in chapter 01, it's whatever the host system provides on the PATH nix develop hands you.)

nix develop $MICROHS -c python3 -c "
data = bytearray(open('uhs.comb','rb').read())
assert data[53:54] == b'C'
data[53] = ord('Z')
open('uhs-corrupt.comb','wb').write(bytes(data))
"

Byte offset 53 (0-indexed) sits inside a \$C token near the start of the file; the edit above turns it into \$Z , a combinator name that does not exist. Ask eval to run it, the same way bootcombtest's middle line does:

nix develop $MICROHS -c bin/eval -H10000000 -K1000000 -ruhs-corrupt.comb -- -ilib -isrc -ocorrupt-out.comb MicroHs.Main
eval: bad primop Z
ERR: no primop

Exit code 1. parse()'s primop table lookup (the same linear search 04a4bbe4 later reorders) finds no entry named Z, prints exactly the diagnostic its fprintf/ERR calls promise, and the whole run aborts before a single reduction happens β€” one corrupted byte, caught at parse time, not miscompiled silently into the next bootstrap.

Exercise 3: the profiling flag and the frequency ordering β€” checked, not assumed

8ad7094c's PROF flag doesn't exist yet at the 456ce17c anchor, and neither does 04a4bbe4's reordered primops[] table; this exercise needs both, so it uses a second worktree at the commit where they last appear together, 04a4bbe4 itself:

git worktree add /tmp/mhs-04a4bbe4 04a4bbe4
cd /tmp/mhs-04a4bbe4
mkdir -p bin
nix develop $MICROHS -c make PROF="-prof -fprof-auto" bin/bootuhs
[...]
ghc -prof -fprof-auto -hide-all-packages -o bin/bootuhs ghc-boot/*.o ghc-boot/*/*.o
nix develop $MICROHS -c bin/bootuhs -ilib -isrc -omain-boot.comb +RTS -p -RTS MicroHs.Main

This writes bootuhs.prof, GHC's own cost-center report (trimmed to the top entries):

COST CENTRE      MODULE            SRC                                      %time %alloc

>>=.\.xss        Text.ParserComb   src/Text/ParserComb.hs:80:15-69           18.8    7.4
++               Data.List         lib/Data/List.hs:(22,1)-(25,26)           10.1   23.9
>>=.\            Text.ParserComb   src/Text/ParserComb.hs:(78,5)-(82,68)      9.9   17.0
eqString         Text.String       lib/Text/String.hs:(101,1)-(110,44)        8.7   12.9
runP             Text.ParserComb   src/Text/ParserComb.hs:(66,1)-(68,12)      6.3    0.0
foldr.rec        Data.List         lib/Data/List.hs:(57,5)-(60,30)            4.2    2.8
...

Read this list straight: it's dominated by Text.ParserComb's >>=, Data.List.(++), and Text.String.eqString β€” Haskell functions inside the compiler's own parser and prelude, measured on bootuhs, a native GHC binary running the "local lib" build. Not one combinator name β€” S, K, B, O, the names 04a4bbe4's primops[]/primTable reorder β€” appears anywhere in this report, because they belong to a different program entirely: eval.c's interpreter and Translate.hs's translator, neither of which bootuhs's GHC profiler ever runs. The "connect the profiling flag to the frequency ordering" instinct doesn't survive this run: they're two separate performance interventions, aimed at two separate programs, that happen to land in the same 48-hour window. What this report does support independently is 8f361a0a's "Fix stupid eqString" from not quite two days earlier β€” eqString alone accounts for 8.7% of this run's time and 12.9% of its allocation, exactly the kind of hot-path cost a workload this size makes visible for the first time.

Clean up both worktrees:

git worktree remove --force /tmp/mhs-456ce17c
git worktree remove --force /tmp/mhs-04a4bbe4