From 84b8e0cc7d72624bd21799b8b2b61809392d0f2a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 00:46:31 +0000 Subject: [PATCH 01/12] eager: push and pop a frame as two whole words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A continuation frame was four 4-byte fields: pushed through a stack temporary GCC filled field by field and then copied as one 16-byte load, and popped as one 16-byte (or 8-byte) load of what four 4-byte stores had just written. Neither load can be forwarded from the store buffer, so each waited for the stores to retire — and a frame is usually popped a few instructions after it is pushed (a MEMOIZE under a step that answers outright). perf put a fifth of apply()'s samples on the instructions right after those loads. The frame is now two 8-byte words, built in registers, constructed in place and read back at the width it was written. Same fields, same semantics. compile_file, reduce ms (runner-stats), min of 3, base memo policy: source before after 1.26 MB forest file 1439 908 (-37%) compiler.lamb, 66 KB 701 448 (-36%) 5 MB forest file (min of 1) 6845 4560 (-33%) Output sha unchanged on all three (whole and code-only); test.sh passes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018ffv5AybPTpV56D5vuayQr --- .../cpp/eager-graph-nil-mmap-32.hpp | 48 +++++++++++-------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/implementation/cpp/eager-graph-nil-mmap-32.hpp b/implementation/cpp/eager-graph-nil-mmap-32.hpp index 3754a4e..3362a07 100644 --- a/implementation/cpp/eager-graph-nil-mmap-32.hpp +++ b/implementation/cpp/eager-graph-nil-mmap-32.hpp @@ -120,11 +120,21 @@ class EagerGraphNilMmap32 { // this stack the collector's root set as well as the VM's. enum FrameTag : uint32_t { APPLY_TO, COMPUTE_AND_APPLY, MEMOIZE }; + // + // A frame is two 8-byte words, written whole and read back whole, because + // it is usually popped within a few instructions of being pushed: a load + // that the store buffer cannot answer from one earlier store (a 16-byte + // Frame read back from its four 4-byte fields, say) waits for those stores + // to retire instead, and that stall was a fifth of the whole loop. struct Frame { - FrameTag tag; - uint32_t arg1; - uint32_t arg2; // 0 on an APPLY_TO frame, which mark() ignores - uint32_t meta; // MEMOIZE: low bits of the step counter at push + uint64_t lo, hi; + Frame() = default; + Frame(FrameTag tag, uint32_t arg1, uint32_t arg2, uint32_t meta = 0) + : lo(tag | uint64_t(arg1) << 32), hi(arg2 | uint64_t(meta) << 32) {} + FrameTag tag() const { return FrameTag(uint32_t(lo)); } + uint32_t arg1() const { return lo >> 32; } + uint32_t arg2() const { return uint32_t(hi); } // 0 on an APPLY_TO frame, which mark() ignores + uint32_t meta() const { return hi >> 32; } // MEMOIZE: low bits of the step counter at push }; /** One memo entry: apply(a, b) normalizes to r. a == 0 marks an empty slot. */ @@ -429,8 +439,8 @@ class EagerGraphNilMmap32 { void collect() { for (Tree root : _roots) mark(root); for (const Frame &f : _stack) { // a reduction in progress is live - mark(f.arg1); - mark(f.arg2); + mark(f.arg1()); + mark(f.arg2()); } // Between mark and sweep is the one moment liveness is written on the // nodes themselves, which is what lets the memo be filtered rather than @@ -559,8 +569,8 @@ class EagerGraphNilMmap32 { if (!un.v) { // apply(△(△u')y, b) = apply(apply(u', b), apply(y, b)) const Tree hit = memo_get(a, b); if (hit) { result = hit; goto dispatch; } - _stack.push_back({MEMOIZE, a, b}); - _stack.push_back({COMPUTE_AND_APPLY, un.u, b}); + _stack.emplace_back(MEMOIZE, a, b); + _stack.emplace_back(COMPUTE_AND_APPLY, un.u, b); a = y; goto reduce; } @@ -575,7 +585,7 @@ class EagerGraphNilMmap32 { if (!bn.v) { // b = △d: apply(x, d) const Tree hit = memo_get(a, b); if (hit) { result = hit; goto dispatch; } - _stack.push_back({MEMOIZE, a, b}); + _stack.emplace_back(MEMOIZE, a, b); a = un.v; b = bn.u; goto reduce; @@ -583,8 +593,8 @@ class EagerGraphNilMmap32 { { // b = △de: apply(apply(y, d), e) const Tree hit = memo_get(a, b); if (hit) { result = hit; goto dispatch; } - _stack.push_back({MEMOIZE, a, b}); - _stack.push_back({APPLY_TO, bn.v, 0}); + _stack.emplace_back(MEMOIZE, a, b); + _stack.emplace_back(APPLY_TO, bn.v, 0); a = y; b = bn.u; goto reduce; @@ -597,24 +607,24 @@ class EagerGraphNilMmap32 { while (_stack.size() != base) { const Frame f = _stack.back(); _stack.pop_back(); - if (f.tag == MEMOIZE) { + if (f.tag() == MEMOIZE) { // A tail step (`b = △d`) shares its result with the step it became, // so consecutive MEMOIZE frames all record the same normal form. // Steps that resolved in a handful of rules are cheaper to redo than // to let their entries evict a slower one from the memo. - if ((uint32_t)stats_counters.steps - f.meta >= MEMO_MIN_STEPS) - memo_put(f.arg1, f.arg2, result); + if ((uint32_t)stats_counters.steps - f.meta() >= MEMO_MIN_STEPS) + memo_put(f.arg1(), f.arg2(), result); continue; } - if (f.tag == APPLY_TO) { + if (f.tag() == APPLY_TO) { // `result` is unreachable from any root for exactly as long as it // takes to become `a`, which allocates nothing. a = result; - b = f.arg1; + b = f.arg1(); } else { // COMPUTE_AND_APPLY: apply(apply(fn, arg), result) - _stack.push_back({APPLY_TO, result, 0}); - a = f.arg1; - b = f.arg2; + _stack.emplace_back(APPLY_TO, result, 0); + a = f.arg1(); + b = f.arg2(); } goto reduce; } From e5ca0ea163d084943b2c743294949420a58355b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:11:31 +0000 Subject: [PATCH 02/12] eager: make MEMO_MIN_STEPS filter, and set it by measurement The MEMOIZE frame's `meta` was never set (the pushes left it 0), so the "skip steps that resolved in under 16 rules" filter compared the whole step count against 16 and filtered nothing: every recursing step was memoized. The frames now carry the step count they were pushed at. Switching the filter on at 16, as documented, doubles the steps of compile_file on the 5 MB source (47.6M -> 95.1M): a character-class test is a few rules and is asked of every character, and its hits were the point. Swept on both workloads, on this machine, one run at a time: MEMO_MIN_STEPS replay of the arboretum compile_file reduce ms build's runner sessions 5 MB 1.26 MB 66 KB off (as before) 159.4 s 2637M steps 3891 723 382 2 158.9 s 2628M 3992 785 375 4 152.6 s 2658M 3456 666 367 8 152.4 s 2737M 3642 717 408 16 (3006M steps; 5 MB file: 95.1M steps vs 47.6M) 4 is best or tied on every column. Replay output identical throughout. Memo size was swept the same way and is left budget-scaled (2^23 slots at the default 512 MB budget): 2^24 cut steps (2495M) but not time (154.8 s, and 4356 ms on the 5 MB file); 2^22 cost 2972M steps and 177.7 s; at 2^16 the build's heaviest tests did not finish in 15 min, although that size is 20-30% faster on a single compile_file. Letting the memo grow from 2^16 on evidence (a sampled shadow of a full-size table) kept that gain on single files but not on the build's compile session (4.0 -> 4.75 s, the memo is full-size by its 22nd source), so it is not included. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018ffv5AybPTpV56D5vuayQr --- implementation/cpp/eager-graph-nil-mmap-32.hpp | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/implementation/cpp/eager-graph-nil-mmap-32.hpp b/implementation/cpp/eager-graph-nil-mmap-32.hpp index 3362a07..1027a25 100644 --- a/implementation/cpp/eager-graph-nil-mmap-32.hpp +++ b/implementation/cpp/eager-graph-nil-mmap-32.hpp @@ -95,8 +95,11 @@ class EagerGraphNilMmap32 { // A step that resolved in fewer rule applications than this is cheaper to // redo than to let its entry evict a slower one from the memo (see the - // MEMOIZE pop in apply). - static constexpr uint32_t MEMO_MIN_STEPS = 16; + // MEMOIZE pop in apply). Low, because a step of a few rules can still be + // the one asked for most: a compiler's character-class test is asked of + // every character of its input, and at 16 compile_file of a 5 MB source + // took twice the steps. + static constexpr uint32_t MEMO_MIN_STEPS = 4; struct Node { uint32_t u; @@ -569,7 +572,7 @@ class EagerGraphNilMmap32 { if (!un.v) { // apply(△(△u')y, b) = apply(apply(u', b), apply(y, b)) const Tree hit = memo_get(a, b); if (hit) { result = hit; goto dispatch; } - _stack.emplace_back(MEMOIZE, a, b); + _stack.emplace_back(MEMOIZE, a, b, (uint32_t)stats_counters.steps); _stack.emplace_back(COMPUTE_AND_APPLY, un.u, b); a = y; goto reduce; @@ -585,7 +588,7 @@ class EagerGraphNilMmap32 { if (!bn.v) { // b = △d: apply(x, d) const Tree hit = memo_get(a, b); if (hit) { result = hit; goto dispatch; } - _stack.emplace_back(MEMOIZE, a, b); + _stack.emplace_back(MEMOIZE, a, b, (uint32_t)stats_counters.steps); a = un.v; b = bn.u; goto reduce; @@ -593,7 +596,7 @@ class EagerGraphNilMmap32 { { // b = △de: apply(apply(y, d), e) const Tree hit = memo_get(a, b); if (hit) { result = hit; goto dispatch; } - _stack.emplace_back(MEMOIZE, a, b); + _stack.emplace_back(MEMOIZE, a, b, (uint32_t)stats_counters.steps); _stack.emplace_back(APPLY_TO, bn.v, 0); a = y; b = bn.u; From 3f438b56077da9b7da30bf00b374b4515cba7559 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:21:17 +0000 Subject: [PATCH 03/12] eager: hash with one multiply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every step hashes a pair of indices before it can load anything — the hash-consing slot of the node it builds, the memo slot of the redex it looks up — so the hash's latency is paid in series with the cache miss it leads to. The 64-bit Murmur finalizer is five dependent operations, two of them multiplies; Fibonacci hashing is one multiply, with the high half folded onto the low bits the tables index by. Probe lengths do not suffer (mean slots examined per hash-consing search: 1.26 MB source 2.72 -> 2.68, compiler.lamb 2.56 -> 2.41, three heaviest build tests 2.03 -> 1.96), and neither do memo hits. replay of the arboretum build's runner sessions, back to back: 152.4 s -> 140.8 s (-8%) compile_file reduce ms, 5 interleaved runs (3 for 5 MB), min / median: 1.26 MB file 744 / 823 -> 683 / 778 compiler.lamb 384 / 408 -> 363 / 389 5 MB file 3979 / 4255 -> 3821 / 3836 Replay output identical (3b8f647b617e24b3); benchmark shas unchanged; test.sh passes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018ffv5AybPTpV56D5vuayQr --- implementation/cpp/eager-graph-nil-mmap-32.hpp | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/implementation/cpp/eager-graph-nil-mmap-32.hpp b/implementation/cpp/eager-graph-nil-mmap-32.hpp index 1027a25..df5b4d5 100644 --- a/implementation/cpp/eager-graph-nil-mmap-32.hpp +++ b/implementation/cpp/eager-graph-nil-mmap-32.hpp @@ -198,14 +198,15 @@ class EagerGraphNilMmap32 { return p; } - /** 64-bit finalizer: the pair of indices is the key, and it needs mixing. */ + /** + * The pair of indices is the key, and it needs mixing — but every step hashes + * before it can load anything, so the mixing is on the critical path. One + * multiply (Fibonacci hashing), with the high half, where a product mixes + * best, folded onto the low bits the tables index by. + */ static uint64_t hash(uint32_t u, uint32_t v) { - uint64_t x = (uint64_t(u) << 32) | v; - x ^= x >> 33; - x *= 0xff51afd7ed558ccdULL; - x ^= x >> 33; - x *= 0xc4ceb9fe1a85ec53ULL; - return x ^ (x >> 33); + const uint64_t x = ((uint64_t(u) << 32) | v) * 0x9e3779b97f4a7c15ULL; + return x ^ (x >> 32); } /** A node, from the free list or the high-water mark. Not interned. */ From ccb74734aab36a2933879bb27397cb35d6781ff1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 03:09:47 +0000 Subject: [PATCH 04/12] eager: stop looking up functions that never hit the memo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most memo lookups are for functions whose applications never recur — a loop applied to each suffix of its input, a closure over one position. Counted per function (the `a` of apply(a, b)) on compile_file: source lookups by functions that never hit share of hits 5 MB forest 25.9M 74.9% 0.00% compiler.lamb 3.8M 69.5% 0.01% Each such lookup is a cache miss into a table sized for a module build, and each is followed by a MEMOIZE frame whose write, when the step is done, evicts an entry that might have hit. So a small per-function table (64K one-byte counters, cache-resident) counts consecutive misses, and a function that has missed MEMO_COLD = 64 times in a row is neither looked up nor recorded; one lookup in sixteen for a cold function is still made, and a hit warms it. Reset whenever the memo is re-sized or collected (swept indices are reused by other functions). The memo stays exact: this only decides what is asked of it. Effect on the 5 MB source: 15.9M memo writes -> 1.2M, hits unchanged (6.33M), steps unchanged (47.62M). Threshold swept on the build replay (steps / time): off 2651M / 136.5 s, 32: 2842M / 125.5 s, 64: 2759M / 122.3 s, 127: 2700M / 123.3 s; 64 was also best on single files. replay of the arboretum build's runner sessions, back to back: 136.5 s -> 125.3 s (steps 2651M -> 2711M) compile session alone, 4 interleaved runs: 3.78-4.38 s -> 3.70-3.93 s compile_file reduce ms, interleaved, min / median: 1.26 MB file (5) 628 / 1159 -> 360 / 522 compiler.lamb (5) 343 / 359 -> 296 / 312 5 MB file (3) 3475 / 4113 -> 2272 / 2353 Replay output identical (3b8f647b617e24b3); benchmark shas unchanged; test.sh passes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018ffv5AybPTpV56D5vuayQr --- .../cpp/eager-graph-nil-mmap-32.hpp | 48 +++++++++++++++---- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/implementation/cpp/eager-graph-nil-mmap-32.hpp b/implementation/cpp/eager-graph-nil-mmap-32.hpp index df5b4d5..878ca57 100644 --- a/implementation/cpp/eager-graph-nil-mmap-32.hpp +++ b/implementation/cpp/eager-graph-nil-mmap-32.hpp @@ -101,6 +101,10 @@ class EagerGraphNilMmap32 { // took twice the steps. static constexpr uint32_t MEMO_MIN_STEPS = 4; + // How many lookups in a row may miss for one function before it stops being + // looked up at all (see recall). + static constexpr uint8_t MEMO_COLD = 64; + struct Node { uint32_t u; uint32_t v; @@ -192,6 +196,12 @@ class EagerGraphNilMmap32 { std::vector _memo; size_t _memo_mask = 0; + // Per function (the `a` of apply(a, b), by hash), how many of its lookups in + // a row have missed, up to MEMO_COLD. Small on purpose: it is read on every + // lookup, so it has to stay in cache where the memo cannot. + uint8_t _cold[1 << 16]; + unsigned _cold_tick = 0; + static size_t round_up_pow2(size_t n) { size_t p = MIN_TABLE; while (p < n) p <<= 1; @@ -282,6 +292,29 @@ class EagerGraphNilMmap32 { _memo[hash(a, b) & _memo_mask] = {a, b, r}; } + /** + * What the memo says apply(a, b) is, or 0 — in which case the step is about + * to be reduced, and a MEMOIZE frame is pushed to record it. + * + * Unless `a` has gone cold. Most lookups are for functions that never hit: + * in compile_file of a 5 MB source, three in four are, each of them a cache + * miss into a table sized for a build, plus a write when the step is done + * that evicts something that would have. A function MEMO_COLD lookups in a + * row missed is neither looked up nor recorded; one lookup in sixteen for a + * cold function is made anyway, and a hit warms it again. + */ + Tree recall(Tree a, Tree b) { + uint8_t &cold = _cold[uint32_t(hash(a, 0) >> 48)]; + if (cold == MEMO_COLD && (++_cold_tick & 15)) return 0; + if (const Tree hit = memo_get(a, b)) { + cold = 0; + return hit; + } + cold += cold < MEMO_COLD; + _stack.emplace_back(MEMOIZE, a, b, (uint32_t)stats_counters.steps); + return 0; + } + /** Whether a collection's mark phase found `at` reachable. Indices 0 and 1 * are permanent (padding and the shared leaf), so they count as live. */ bool marked(Tree at) const { @@ -362,6 +395,7 @@ class EagerGraphNilMmap32 { const size_t capacity = std::min(round_up_pow2(_budget / 8), cap); _memo.assign(capacity, Memo{0, 0, 0}); _memo_mask = capacity - 1; + std::fill(std::begin(_cold), std::end(_cold), 0); } /** @@ -458,6 +492,8 @@ class EagerGraphNilMmap32 { if (marked(m.a) && marked(m.b) && marked(m.r)) continue; m = {0, 0, 0}; } + // Swept indices will be reused by other functions. + std::fill(std::begin(_cold), std::end(_cold), 0); sweep(); ++stats_counters.gcs; stats_counters.gc_marked += _live; @@ -571,9 +607,7 @@ class EagerGraphNilMmap32 { goto dispatch; } if (!un.v) { // apply(△(△u')y, b) = apply(apply(u', b), apply(y, b)) - const Tree hit = memo_get(a, b); - if (hit) { result = hit; goto dispatch; } - _stack.emplace_back(MEMOIZE, a, b, (uint32_t)stats_counters.steps); + if (const Tree hit = recall(a, b)) { result = hit; goto dispatch; } _stack.emplace_back(COMPUTE_AND_APPLY, un.u, b); a = y; goto reduce; @@ -587,17 +621,13 @@ class EagerGraphNilMmap32 { goto dispatch; } if (!bn.v) { // b = △d: apply(x, d) - const Tree hit = memo_get(a, b); - if (hit) { result = hit; goto dispatch; } - _stack.emplace_back(MEMOIZE, a, b, (uint32_t)stats_counters.steps); + if (const Tree hit = recall(a, b)) { result = hit; goto dispatch; } a = un.v; b = bn.u; goto reduce; } { // b = △de: apply(apply(y, d), e) - const Tree hit = memo_get(a, b); - if (hit) { result = hit; goto dispatch; } - _stack.emplace_back(MEMOIZE, a, b, (uint32_t)stats_counters.steps); + if (const Tree hit = recall(a, b)) { result = hit; goto dispatch; } _stack.emplace_back(APPLY_TO, bn.v, 0); a = y; b = bn.u; From 0d7885aaa768e770771b644a2b685bcd8086c193 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 03:28:49 +0000 Subject: [PATCH 05/12] eager: tidy the Frame comment Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018ffv5AybPTpV56D5vuayQr --- implementation/cpp/eager-graph-nil-mmap-32.hpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/implementation/cpp/eager-graph-nil-mmap-32.hpp b/implementation/cpp/eager-graph-nil-mmap-32.hpp index 878ca57..9cfb971 100644 --- a/implementation/cpp/eager-graph-nil-mmap-32.hpp +++ b/implementation/cpp/eager-graph-nil-mmap-32.hpp @@ -127,12 +127,11 @@ class EagerGraphNilMmap32 { // this stack the collector's root set as well as the VM's. enum FrameTag : uint32_t { APPLY_TO, COMPUTE_AND_APPLY, MEMOIZE }; - // // A frame is two 8-byte words, written whole and read back whole, because // it is usually popped within a few instructions of being pushed: a load // that the store buffer cannot answer from one earlier store (a 16-byte // Frame read back from its four 4-byte fields, say) waits for those stores - // to retire instead, and that stall was a fifth of the whole loop. + // to retire instead — a stall on nearly every step. struct Frame { uint64_t lo, hi; Frame() = default; From c3c4025660b8fe420f1d8d870eb8710129246831 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 03:49:52 +0000 Subject: [PATCH 06/12] eager: no hash-consing search for a node over the one made last MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A node is made after its children, so nothing can have the node made last as a child yet: a fork or stem over it cannot already exist. That is list()'s argument a cell at a time, and it holds for whatever a reduction builds on what it just built — counted per source, 26% (5 MB file) to 49% (compiler.lamb, the build's heaviest tests) of the nodes a reduction interns. Those skip the search and just take a slot; with no search to wait on, the table write's cache miss overlaps with the steps that follow instead of stalling them. `_newest` is reset by a sweep (the node may have been freed) and by clear(). Exactness checked directly after each benchmark run and the replayed compile session, heavy tests (one collection) and repeated binds: every live node's (u, v) unique and found by the table. replay of the arboretum build's runner sessions, back to back: 121.2 s -> 115.2 s, 116.0 s (steps unchanged, 2711M) compile session alone: 3.04 s -> 2.69 s three heaviest build tests, 2 interleaved runs: 30.4-33.2 s -> 28.8-29.4 s compile_file reduce ms, interleaved, min / median: compiler.lamb (5) 266 / 273 -> 258 / 265 5 MB file (3) 2208 / 2245 -> 2065 / 2234 1.26 MB file (5) 360 / 378 -> 360 / 382 Replay output identical (3b8f647b617e24b3); benchmark shas unchanged; test.sh passes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018ffv5AybPTpV56D5vuayQr --- .../cpp/eager-graph-nil-mmap-32.hpp | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/implementation/cpp/eager-graph-nil-mmap-32.hpp b/implementation/cpp/eager-graph-nil-mmap-32.hpp index 9cfb971..f9a97f1 100644 --- a/implementation/cpp/eager-graph-nil-mmap-32.hpp +++ b/implementation/cpp/eager-graph-nil-mmap-32.hpp @@ -153,6 +153,7 @@ class EagerGraphNilMmap32 { Node *_arena; uint32_t _head; // one past the highest node ever allocated Tree _free = 0; // head of the free list, chained through v + Tree _newest = ~0u; // the node alloc() made last, if no collection since size_t _live = 0; // what the last collection found, for sizing the next one size_t _budget; // collect once _head reaches this @@ -183,7 +184,8 @@ class EagerGraphNilMmap32 { // list() skips searches because of it, and deciding that two trees are equal // by comparing their indices is sound only because of it. So no node is ever // made by alloc() alone: it is made by a search that came up empty, or where - // no search could have found one (list()). + // no search could have found one (list(), and intern() over the node made + // last). std::vector _interned; size_t _interned_count = 0; size_t _interned_mask = 0; @@ -228,7 +230,7 @@ class EagerGraphNilMmap32 { result = _head++; } _arena[result] = {u, v}; - return result; + return _newest = result; } /** Put a live node in the hash-consing table, which must have room for it. */ @@ -269,8 +271,22 @@ class EagerGraphNilMmap32 { } } - /** The node for this shape: the one that already exists, or a new one. */ + /** + * The node for this shape: the one that already exists, or a new one. + * + * No search when a child is the node made last: a node is made after its + * children, so nothing can have that one as a child yet (list()'s argument, + * a cell at a time). That is a quarter to a half of all the nodes a + * reduction builds — whatever it builds on what it just built — and without + * a search to wait on, the table write overlaps with the steps after it. + */ Tree intern(uint32_t u, uint32_t v) { + if (u == _newest || v == _newest) { + const Tree fresh = alloc(u, v); + insert_interned(fresh); + reserve_interned(0); + return fresh; + } const size_t i = slot(u, v); if (_interned[i]) return _interned[i]; const Tree fresh = alloc(u, v); @@ -347,6 +363,7 @@ class EagerGraphNilMmap32 { */ void sweep() { _free = 0; + _newest = ~0u; _live = 0; // Downwards, so the list comes out in ascending order and allocation walks // the arena forwards rather than backwards. @@ -375,6 +392,7 @@ class EagerGraphNilMmap32 { _arena[1] = {0, 0}; // the shared leaf _head = 2; _free = 0; + _newest = ~0u; _live = 0; } From da4fc786a9cd1ee7d11065e80776dfae2bd17757 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:16:40 +0000 Subject: [PATCH 07/12] eager: say why pages stay small MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transparent huge pages (MADV_HUGEPAGE on the arena and both tables) were measured and are not taken. Back to back they win — the build replay of its recorded runner sessions 142.2 -> 125.0 s, 12% — because every large region here is read at random and 4 KiB pages leave the TLB covering a few MB of hundreds. Started from cold memory they lose, and on this VM that is the usual start: it reports free 2 MiB blocks to its host (virtio-balloon free page reporting, order 9), the host drops them, and a huge-page fault is served from exactly those blocks, while 4 KiB faults still find warm fragments. First touch of 96 MB, alone: 4 KiB pages 49-70 ms; huge pages 16-18 ms right after another huge-page process exited, 342 ms otherwise. So a run's cost hung on what ran in the seconds before it; 6 of 15 bare starts stalled 0.2-0.65 s (none at 4 KiB pages: 113-131 ms). compile_file, load + bind + reduce ms, median of 3, each run after 4 s idle: huge pages -> 4 KiB 5 MB file 4498 -> 3243 1.26 MB file 1226 -> 694 compiler.lamb 930 -> 534 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018ffv5AybPTpV56D5vuayQr --- implementation/cpp/eager-graph-nil-mmap-32.hpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/implementation/cpp/eager-graph-nil-mmap-32.hpp b/implementation/cpp/eager-graph-nil-mmap-32.hpp index f9a97f1..9e56b01 100644 --- a/implementation/cpp/eager-graph-nil-mmap-32.hpp +++ b/implementation/cpp/eager-graph-nil-mmap-32.hpp @@ -379,6 +379,11 @@ class EagerGraphNilMmap32 { } } + // Small pages, on purpose. Huge ones (MADV_HUGEPAGE) spare the TLB, and ran + // 8-20% faster back to back; but a VM that returns freed memory to its host + // returns it in whole 2 MiB blocks — the ones a huge-page fault needs — so a + // run that started after a pause touched its memory at a seventh of the + // speed, and took up to twice as long. void map_arena() { int flags = MAP_PRIVATE | MAP_ANONYMOUS; #ifdef MAP_NORESERVE From f51a7bbd6cde750329701cfb1f5433a93e73aa00 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:06:15 +0000 Subject: [PATCH 08/12] eager: grow both tables in place Both tables are now mapped once, at the most they can reach (2^32 slots for the hash-consing table, MAX_MEMO for the memo), and re-laid in the memory they already have, as the arena always was. Growing a vector-backed table mapped a fresh copy and filled it: every page faulted in again, with the old copy still held. rebuild_interned() reads the arena, not the old table, so nothing needs that copy. It is also what lets the memo grow in place (next commit). Peak RSS: compile_file of the 5 MB file 474 -> 444 MB, 1.26 MB 205 -> 188 MB, compiler.lamb 151 -> 137 MB; the build's compile session 413 -> 344 MB (minor faults 125K -> 89K), its test session 1258 -> 1134 MB (425K -> 293K). Time, compile_file load + bind + reduce ms, median of 3 cold (4 s idle before each) / 4 back to back: 5 MB file 3243 -> 3118 / 3174 -> 3156 1.26 MB file 694 -> 671 / 695 -> 677 compiler.lamb 534 -> 499 / 507 -> 515 The build's test session (the replay's long runner), median of 3 interleaved: 137.1 -> 138.4 s, within its noise. Outputs unchanged (5 MB sha 92c528b6bcf22b16 whole and code-only; replay 3b8f647b617e24b3); test.sh and the C++ suite (test.cpp) pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018ffv5AybPTpV56D5vuayQr --- .../cpp/eager-graph-nil-mmap-32.hpp | 67 ++++++++++++------- 1 file changed, 44 insertions(+), 23 deletions(-) diff --git a/implementation/cpp/eager-graph-nil-mmap-32.hpp b/implementation/cpp/eager-graph-nil-mmap-32.hpp index 9e56b01..d893094 100644 --- a/implementation/cpp/eager-graph-nil-mmap-32.hpp +++ b/implementation/cpp/eager-graph-nil-mmap-32.hpp @@ -74,6 +74,25 @@ // the index it was across a collection — which is what lets every Tree a caller // is holding survive one without being registered anywhere. +// Anonymous memory, reserved rather than committed: a page is only committed +// the first time it is touched, so a region can be mapped at the most it will +// ever need and cost what is used of it. +// +// Small pages, on purpose. Huge ones (MADV_HUGEPAGE) spare the TLB, and ran +// 8-20% faster right after a run that used them; but a VM that returns freed +// memory to its host returns whole 2 MiB blocks — the ones a huge-page fault +// needs — so a run that started after a pause touched its memory at a seventh +// of the speed, and took up to twice as long. +static void *map_pages(size_t bytes) { + int flags = MAP_PRIVATE | MAP_ANONYMOUS; +#ifdef MAP_NORESERVE + flags |= MAP_NORESERVE; +#endif + void *mem = mmap(nullptr, bytes, PROT_READ | PROT_WRITE, flags, -1, 0); + if (mem == MAP_FAILED) throw std::runtime_error("mmap failed to reserve memory"); + return mem; +} + class EagerGraphNilMmap32 { public: using Tree = uint32_t; @@ -93,6 +112,10 @@ class EagerGraphNilMmap32 { static constexpr size_t MAX_MEMO = size_t(1) << 24; static constexpr size_t MIN_MEMO_CAP = size_t(1) << 21; + // The most slots the hash-consing table can reach: under reserve_interned's + // 0.7 load factor, 2^31 nodes take 2^32. + static constexpr size_t MAX_INTERNED = ARENA_NODES * 2; + // A step that resolved in fewer rule applications than this is cheaper to // redo than to let its entry evict a slower one from the memo (see the // MEMOIZE pop in apply). Low, because a step of a few rules can still be @@ -186,7 +209,13 @@ class EagerGraphNilMmap32 { // made by alloc() alone: it is made by a search that came up empty, or where // no search could have found one (list(), and intern() over the node made // last). - std::vector _interned; + // + // Both tables are mapped once, at the most they can reach, and re-laid in + // place: rebuild_interned() reads the arena rather than the old table, so + // nothing needs the old copy. Memory already touched costs a memset to + // re-lay; a fresh mapping costs a fault per page all over again — on a VM, a + // host round trip per page — and holds both copies at once while it grows. + Tree *const _interned = static_cast(map_pages(MAX_INTERNED * sizeof(Tree))); size_t _interned_count = 0; size_t _interned_mask = 0; @@ -194,7 +223,7 @@ class EagerGraphNilMmap32 { // replacing what was there. Bounded on purpose — this is the one table whose // natural size is the number of distinct redexes rather than the number of // live nodes, and losing an entry costs time, not correctness. - std::vector _memo; + Memo *const _memo = static_cast(map_pages(MAX_MEMO * sizeof(Memo))); size_t _memo_mask = 0; // Per function (the `a` of apply(a, b), by hash), how many of its lookups in @@ -244,7 +273,7 @@ class EagerGraphNilMmap32 { /** Re-lay the hash-consing table at `capacity`, from the arena's live nodes. */ void rebuild_interned(size_t capacity) { - _interned.assign(capacity, 0); + std::fill_n(_interned, capacity, 0); _interned_mask = capacity - 1; _interned_count = 0; // A swept node is {0, next-free}: the leaf is the only live node whose left @@ -379,20 +408,8 @@ class EagerGraphNilMmap32 { } } - // Small pages, on purpose. Huge ones (MADV_HUGEPAGE) spare the TLB, and ran - // 8-20% faster back to back; but a VM that returns freed memory to its host - // returns it in whole 2 MiB blocks — the ones a huge-page fault needs — so a - // run that started after a pause touched its memory at a seventh of the - // speed, and took up to twice as long. void map_arena() { - int flags = MAP_PRIVATE | MAP_ANONYMOUS; -#ifdef MAP_NORESERVE - flags |= MAP_NORESERVE; -#endif - void *mem = mmap(nullptr, ARENA_BYTES, PROT_READ | PROT_WRITE, flags, -1, 0); - if (mem == MAP_FAILED) - throw std::runtime_error("mmap failed to reserve arena"); - _arena = static_cast(mem); + _arena = static_cast(map_pages(ARENA_BYTES)); _arena[0] = {0, 0}; // index 0 reserved: 0 is the null child sentinel _arena[1] = {0, 0}; // the shared leaf _head = 2; @@ -415,7 +432,7 @@ class EagerGraphNilMmap32 { void size_memo() { const size_t cap = _budget < ARENA_NODES ? MAX_MEMO : MIN_MEMO_CAP; const size_t capacity = std::min(round_up_pow2(_budget / 8), cap); - _memo.assign(capacity, Memo{0, 0, 0}); + std::fill_n(_memo, capacity, Memo{0, 0, 0}); _memo_mask = capacity - 1; std::fill(std::begin(_cold), std::end(_cold), 0); } @@ -457,7 +474,11 @@ class EagerGraphNilMmap32 { size_memo(); } - ~EagerGraphNilMmap32() { munmap(_arena, ARENA_BYTES); } + ~EagerGraphNilMmap32() { + munmap(_arena, ARENA_BYTES); + munmap(_interned, MAX_INTERNED * sizeof(Tree)); + munmap(_memo, MAX_MEMO * sizeof(Memo)); + } EagerGraphNilMmap32(const EagerGraphNilMmap32 &) = delete; EagerGraphNilMmap32 &operator=(const EagerGraphNilMmap32 &) = delete; @@ -509,10 +530,10 @@ class EagerGraphNilMmap32 { // nodes is about to be swept must go — the index may be reused. What the // filter keeps is reduction work; re-earning it was the old cost of every // collection. - for (Memo &m : _memo) { - if (!m.a) continue; - if (marked(m.a) && marked(m.b) && marked(m.r)) continue; - m = {0, 0, 0}; + for (Memo *m = _memo; m <= _memo + _memo_mask; ++m) { + if (!m->a) continue; + if (marked(m->a) && marked(m->b) && marked(m->r)) continue; + *m = {0, 0, 0}; } // Swept indices will be reused by other functions. std::fill(std::begin(_cold), std::end(_cold), 0); @@ -541,7 +562,7 @@ class EagerGraphNilMmap32 { std::string stats() { return std::to_string(allocated()) + " nodes in arena, " + std::to_string(_interned_count) + " shared, " + - std::to_string(_memo.size()) + " memo slots"; + std::to_string(_memo_mask + 1) + " memo slots"; } Tree leaf() { return 1; } From 2f49d48900853668bfe93a0f7732e9b386f28044 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:06:15 +0000 Subject: [PATCH 09/12] eager: start the memo small and grow it with its use size_memo() laid the memo out at its bound up front: 2^23 slots at the default budget, 96 MB written before the first request, which was most of a runner's start-up and all of it paid again by every process however little it went on to reduce. The memo now starts at MIN_TABLE slots and doubles in place once it has taken as many entries as it has slots, up to the same bound. Doubling keeps every entry: at twice the size an entry hashes to its own slot or to the one `half` above it, in the half being added. Start plus loading compile_file.dag: 114-163 -> 34-41 ms. Peak RSS: compile_file of the 5 MB file 444 -> 360 MB, 1.26 MB 188 -> 95 MB, compiler.lamb 137 -> 53 MB; the build's compile session 344 -> 296 MB. compile_file, load + bind + reduce ms, median of 3 cold (4 s idle before each) / 4 back to back: 5 MB file 3118 -> 3047 / 3156 -> 2902 1.26 MB file 671 -> 595 / 677 -> 579 compiler.lamb 499 -> 346 / 515 -> 356 The build's three heaviest tests, which do fill the memo to its bound: min of 3 interleaved 34.25 -> 34.16 s, steps 525.9M -> 529.4M (the memo is smaller while it grows). Replay of the build's sessions, two rounds: 148.8 / 144.7 -> 148.2 / 146.9 s. Outputs unchanged (5 MB sha 92c528b6bcf22b16 whole and code-only; replay 3b8f647b617e24b3); test.sh and the C++ suite pass, and an audited build of this commit's successor (every memo hit and entry checked against a Merkle hash of its nodes, entries checked to sit in their slot after growing) ran the whole replay at a 64 MB budget and the heaviest tests at 16 MB (959 collections) without a finding. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018ffv5AybPTpV56D5vuayQr --- .../cpp/eager-graph-nil-mmap-32.hpp | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/implementation/cpp/eager-graph-nil-mmap-32.hpp b/implementation/cpp/eager-graph-nil-mmap-32.hpp index d893094..d1ea2ad 100644 --- a/implementation/cpp/eager-graph-nil-mmap-32.hpp +++ b/implementation/cpp/eager-graph-nil-mmap-32.hpp @@ -225,6 +225,8 @@ class EagerGraphNilMmap32 { // live nodes, and losing an entry costs time, not correctness. Memo *const _memo = static_cast(map_pages(MAX_MEMO * sizeof(Memo))); size_t _memo_mask = 0; + size_t _memo_cap = 0; // the bound (see size_memo) + size_t _memo_puts = 0; // since it last grew (see memo_put) // Per function (the `a` of apply(a, b), by hash), how many of its lookups in // a row have missed, up to MEMO_COLD. Small on purpose: it is read on every @@ -331,9 +333,29 @@ class EagerGraphNilMmap32 { return 0; // no tree is index 0, so 0 is "miss" } + /** Record an entry, and double the memo once it has taken as many entries + * as it has slots, until it reaches its bound. */ void memo_put(uint32_t a, uint32_t b, uint32_t r) { ++stats_counters.memo_puts; _memo[hash(a, b) & _memo_mask] = {a, b, r}; + if (++_memo_puts > _memo_mask && _memo_mask + 1 < _memo_cap) grow_memo(); + } + + /** + * Double the memo in place, keeping its entries: at twice the size, an entry + * hashes to the slot it is in or to the one `half` above it, in the half being + * added — so no entry moves onto another. + */ + void grow_memo() { + const size_t half = _memo_mask + 1; + std::fill_n(_memo + half, half, Memo{0, 0, 0}); + _memo_mask = 2 * half - 1; + for (Memo *m = _memo; m < _memo + half; ++m) + if (m->a && (hash(m->a, m->b) & half)) { + m[half] = *m; + *m = {0, 0, 0}; + } + _memo_puts = 0; } /** @@ -419,21 +441,26 @@ class EagerGraphNilMmap32 { } /** - * Size the memo to the budget, since it is the rest of what a collection - * bounds: a slot per eight nodes of arena, which at 12 bytes a slot is - * comparable to what the nodes cost — a long-lived module build is exactly + * Empty the memo, and bound it by the budget, since it is the rest of what a + * collection bounds: a slot per eight nodes of arena, which at 12 bytes a slot + * is comparable to what the nodes cost — a long-lived module build is exactly * the workload where remembering more reductions beats touching less memory. * Capped, because a cache stops paying for itself well before it is the size * of the thing it is caching, and direct-mapped, so the capacity is exactly * what it occupies. An unbudgeted evaluator (a one-shot benchmark run that * never calls set_budget) keeps the small cap: it exits before a large memo * pays for its footprint. + * + * The bound is reached by growing (memo_put), not laid out up front: laying + * a memo is a write to every slot — 96 MB at the default budget, which was + * most of what starting a runner cost — and a small request never fills one. */ void size_memo() { const size_t cap = _budget < ARENA_NODES ? MAX_MEMO : MIN_MEMO_CAP; - const size_t capacity = std::min(round_up_pow2(_budget / 8), cap); - std::fill_n(_memo, capacity, Memo{0, 0, 0}); - _memo_mask = capacity - 1; + _memo_cap = std::min(round_up_pow2(_budget / 8), cap); + std::fill_n(_memo, MIN_TABLE, Memo{0, 0, 0}); + _memo_mask = MIN_TABLE - 1; + _memo_puts = 0; std::fill(std::begin(_cold), std::end(_cold), 0); } From 3367ce279603600a29a763790ebd75d6189d333e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:06:15 +0000 Subject: [PATCH 10/12] eager: count a cold function's skipped lookups on its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recall() made one lookup in sixteen for a cold function by a single counter shared by all of them, so a function looked up between the same even number of other cold lookups each time kept landing on the same residue: it could go unsampled for good, never be recorded, never hit, never warm up again. Now the count is the function's own — its byte of _cold, past MEMO_COLD — so every sixteenth of its own lookups is made. One member less. Checked with a scratch harness that makes a function cold, then looks it up on one fixed argument each round between k lookups of other cold functions, completing each sampled miss the way apply() does: with the shared counter it never hit again in 8, 12, 14 and 15 of 16 starting phases for k = 1, 3, 7, 15; with its own, in none. What a cold function pays once it does need the memo stays linear: a recursion that calls itself twice on one argument (2^n steps without the memo) takes, for n = 1000 / 2000, 21367 / 42367 steps with every lookup made, 43186 / 96034 before this commit, 46861 / 89855 after. Time: compile_file load + bind + reduce ms, median of 3 cold / 4 back to back: 5 MB 3047 -> 3056 / 2902 -> 3054, 1.26 MB 595 -> 637 / 579 -> 542, compiler.lamb 346 -> 338 / 356 -> 362 — within their noise. Where the memo matters most it costs a little: the build's three heaviest tests 34.16 -> 35.43 s (min of 3; steps 529.4M -> 532.8M), its test session 138.4 s two commits back and 139.3 s here (median of 3), replay steps 2709.8M -> 2720.9M. Outputs unchanged (5 MB sha 92c528b6bcf22b16 whole and code-only; replay 3b8f647b617e24b3); test.sh passes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018ffv5AybPTpV56D5vuayQr --- implementation/cpp/eager-graph-nil-mmap-32.hpp | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/implementation/cpp/eager-graph-nil-mmap-32.hpp b/implementation/cpp/eager-graph-nil-mmap-32.hpp index d1ea2ad..0d7b2a0 100644 --- a/implementation/cpp/eager-graph-nil-mmap-32.hpp +++ b/implementation/cpp/eager-graph-nil-mmap-32.hpp @@ -229,10 +229,10 @@ class EagerGraphNilMmap32 { size_t _memo_puts = 0; // since it last grew (see memo_put) // Per function (the `a` of apply(a, b), by hash), how many of its lookups in - // a row have missed, up to MEMO_COLD. Small on purpose: it is read on every + // a row have missed, up to MEMO_COLD, and past it how many it has skipped + // since one was last made (see recall). Small on purpose: it is read on every // lookup, so it has to stay in cache where the memo cannot. uint8_t _cold[1 << 16]; - unsigned _cold_tick = 0; static size_t round_up_pow2(size_t n) { size_t p = MIN_TABLE; @@ -366,17 +366,23 @@ class EagerGraphNilMmap32 { * in compile_file of a 5 MB source, three in four are, each of them a cache * miss into a table sized for a build, plus a write when the step is done * that evicts something that would have. A function MEMO_COLD lookups in a - * row missed is neither looked up nor recorded; one lookup in sixteen for a - * cold function is made anyway, and a hit warms it again. + * row missed is neither looked up nor recorded, except every sixteenth of its + * own lookups, and a hit warms it again. Its own, counted in its own byte: + * with one count for all cold lookups, a function looked up between the same + * even number of others each time keeps landing on the same residue, and can + * stay unsampled for good. What is left is the lookups a cold function skips + * once it needs the memo after all — linear, not exponential: a recursion + * that calls itself twice on one argument takes about twice the steps it + * does with every lookup made. */ Tree recall(Tree a, Tree b) { uint8_t &cold = _cold[uint32_t(hash(a, 0) >> 48)]; - if (cold == MEMO_COLD && (++_cold_tick & 15)) return 0; + if (cold >= MEMO_COLD && ++cold < MEMO_COLD + 16) return 0; if (const Tree hit = memo_get(a, b)) { cold = 0; return hit; } - cold += cold < MEMO_COLD; + cold = std::min(cold + 1, int(MEMO_COLD)); // a sampled miss counts afresh _stack.emplace_back(MEMOIZE, a, b, (uint32_t)stats_counters.steps); return 0; } From d855c085c6d3ce87649dca460260b3865e940874 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:06:15 +0000 Subject: [PATCH 11/12] eager: say which steps the memo leaves out, and which bits the hash drops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header still said every step that recurses is memoized; it now names the two exceptions, MEMO_MIN_STEPS and recall()'s cold functions. hash() now says what its fold leaves out: bit 32 + j of the product depends on bits 0..j of u only, so in a table of 2^k slots (u, v) and (u + 2^k, v) always share a slot — rare in the hash-consing table (sized to the live nodes; probes average 2.3-2.8 slots a search on the benchmark sources), a standing eviction pair in the memo. It stays, measured: folding a 128-bit product instead keeps every bit and took 0.15% fewer steps on the build's three heaviest tests (530.1M against 530.9M), but 35.9 s against 35.4 s. Comments only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018ffv5AybPTpV56D5vuayQr --- implementation/cpp/eager-graph-nil-mmap-32.hpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/implementation/cpp/eager-graph-nil-mmap-32.hpp b/implementation/cpp/eager-graph-nil-mmap-32.hpp index 0d7b2a0..66f9bcc 100644 --- a/implementation/cpp/eager-graph-nil-mmap-32.hpp +++ b/implementation/cpp/eager-graph-nil-mmap-32.hpp @@ -62,7 +62,9 @@ // redex that recurs can be recognized: `_memo` maps the operands of a step // to the normal form it reached, so a repeated one is a lookup. Only the // steps that recurse are memoized — the rules that answer outright are -// cheaper to redo than to remember. +// cheaper to redo than to remember — and of those, not the ones that took +// fewer than MEMO_MIN_STEPS rules, nor those of a function whose lookups +// keep missing (recall). // // Neither alone does anything: hash-consing without the memo still re-derives // every occurrence (it just writes the answers on top of each other), and the @@ -245,6 +247,16 @@ class EagerGraphNilMmap32 { * before it can load anything, so the mixing is on the critical path. One * multiply (Fibonacci hashing), with the high half, where a product mixes * best, folded onto the low bits the tables index by. + * + * Which leaves the bits of `u` at and above a table's index width out of the + * index: bit 32 + j of the product depends on bits 0..j of u only, so in a + * table of 2^k slots (u, v) and (u + 2^k, v) always share a slot. The + * hash-consing table is sized to the live nodes, and indices outrun it only + * by what collections have freed (probes measure 2.3-2.8 slots a search); + * the memo is smaller than the arena by design, and there such pairs evict + * each other. Folding a 128-bit product instead keeps every bit, and took + * 0.15% fewer steps on the build's three heaviest tests — in 35.9 s against + * this hash's 35.4 s. */ static uint64_t hash(uint32_t u, uint32_t v) { const uint64_t x = ((uint64_t(u) << 32) | v) * 0x9e3779b97f4a7c15ULL; From e6fa4f8a2ec70da84755676508c5b16e55927165 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:08:29 +0000 Subject: [PATCH 12/12] eager: keep what apply() reaches now and then out of line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collector, the hash-consing table's re-lay and the memo's growth were all inlined into apply(), which made it 1,558 instructions long — and which of its small helpers the compiler still inlined then hung on any edit to the file: a variant of a later change (marshalling long strings as runs) that happened to leave intern() out of line took 1,070M instructions on the benchmark below; that change, on top of this one, takes 924M. collect(), rebuild_interned() and grow_memo() are now [[gnu::noinline]]; apply() is 910 instructions, and what a change to the hot path costs can be measured again. Instructions (cachegrind, compile_file of lambada's compiler.lamb with the base compiler, 9.67M steps): 937.1M -> 919.2M. Time, min / median of 3 interleaved runs, no change beyond noise: the build's compile session (61M steps) 3.71 / 3.80 -> 3.78 / 3.80 s loading the build's library (158M steps) 10.01 / 10.38 -> 10.00 / 10.07 s its three heaviest tests (2.56G steps) 136.5 / 140.0 -> 134.2 / 135.4 s Outputs unchanged (replay 5556b28b64d74f28 and 6856a56e08e6ffec). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018ffv5AybPTpV56D5vuayQr --- implementation/cpp/eager-graph-nil-mmap-32.hpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/implementation/cpp/eager-graph-nil-mmap-32.hpp b/implementation/cpp/eager-graph-nil-mmap-32.hpp index 66f9bcc..7dcc811 100644 --- a/implementation/cpp/eager-graph-nil-mmap-32.hpp +++ b/implementation/cpp/eager-graph-nil-mmap-32.hpp @@ -285,8 +285,12 @@ class EagerGraphNilMmap32 { ++_interned_count; } - /** Re-lay the hash-consing table at `capacity`, from the arena's live nodes. */ - void rebuild_interned(size_t capacity) { + /** Re-lay the hash-consing table at `capacity`, from the arena's live nodes. + * Out of line, as is everything else apply() reaches only now and then — + * grow_memo, collect: inlined, they made apply() twice the size, and which of + * its helpers the compiler inlined then hung on any edit to it, for as much as + * 13% more instructions a step. */ + [[gnu::noinline]] void rebuild_interned(size_t capacity) { std::fill_n(_interned, capacity, 0); _interned_mask = capacity - 1; _interned_count = 0; @@ -358,7 +362,7 @@ class EagerGraphNilMmap32 { * hashes to the slot it is in or to the one `half` above it, in the half being * added — so no entry moves onto another. */ - void grow_memo() { + [[gnu::noinline]] void grow_memo() { const size_t half = _memo_mask + 1; std::fill_n(_memo + half, half, Memo{0, 0, 0}); _memo_mask = 2 * half - 1; @@ -562,7 +566,7 @@ class EagerGraphNilMmap32 { * The memo is dropped outright: its keys are node indices too, and it is a * cache, so re-earning its entries is the cheaper correctness. */ - void collect() { + [[gnu::noinline]] void collect() { for (Tree root : _roots) mark(root); for (const Frame &f : _stack) { // a reduction in progress is live mark(f.arg1());