|
| 1 | +# Next perf cycle — brainstorm and lever triage (post-#490 arc) — 2026-08-29 |
| 2 | + |
| 3 | +Scheduled nightly cycle. Two objectives were set: (1) a controls-in-band |
| 4 | +confirmation round for the #490 arc's estimate-met bar, and (2) a |
| 5 | +brainstorm/spec for the next cycle. **Objective 1 was not run** — the |
| 6 | +regime preconditions failed and were still failing at the end of the |
| 7 | +window. Objective 2 was done as a load-independent static analysis, and |
| 8 | +its central finding is that the lever list inherited from the arc is |
| 9 | +**stale attribution** that must be re-traced before anything is built. |
| 10 | + |
| 11 | +Worktree: `blove/perf-cycle-2026-08-29` off `origin/main` @ `bac186da` |
| 12 | +(#529). No engine code changed in this document's commit. |
| 13 | + |
| 14 | +## Objective 1 — ABORTED, with the regime evidence |
| 15 | + |
| 16 | +Preconditions from the cycle definition: `lsof -i :4173` empty and 1-min |
| 17 | +load < 5. Both failed at fire time and never recovered: |
| 18 | + |
| 19 | +| Check | At fire (22:00) | At end of window (22:02) | |
| 20 | +| ------------ | ----------------------------------------------------- | ------------------------ | |
| 21 | +| 1-min load | **6.37** (5/15-min: 9.55 / 8.46) | **6.25** (8.70 / 8.25) | |
| 22 | +| Swap | **8775M used of 9216M**, 440M free | unchanged | |
| 23 | +| Port 4173 | held by another `Claude` PID 24077 (3 CLOSED sockets) | — | |
| 24 | +| Top consumer | `Virtualization.VirtualMachine.xpc` at **258% CPU** | still running | |
| 25 | + |
| 26 | +A VM pinning ~2.6 cores, a parallel Codex session, a running eslint, and |
| 27 | +exhausted swap on a 10-core Mac. Per the standing rule the port holder was |
| 28 | +**not** killed. No bench was run, no trace was captured, and **no bar is |
| 29 | +claimed**. |
| 30 | + |
| 31 | +**The #490 arc's verdict is therefore unchanged and still estimate-met:** |
| 32 | +50k filter-metadata ~124.1 loaded → **~116 fit-estimate against the ≤120 |
| 33 | +primary bar**, keystroke warm p50 51.2 loaded against the ≤130 bar. The |
| 34 | +confirmation round is still owed. No addendum was written to |
| 35 | +`2026-08-29-dense-flat-cooperative-candidate-results.md`, because writing |
| 36 | +one without the measurement is exactly the thing the cycle forbids. |
| 37 | + |
| 38 | +**Carry-forward:** the confirmation round is the first item of the next |
| 39 | +cycle that fires on a quiet machine. It is cheap (~15 min) and it is the |
| 40 | +only thing standing between "estimate-met" and "met". |
| 41 | + |
| 42 | +## Objective 2 — lever triage |
| 43 | + |
| 44 | +### The headline finding: the inherited lever list is pre-M2 attribution |
| 45 | + |
| 46 | +The three levers on record — snapshot HAMT reads ~10%, sort-key carry fill |
| 47 | +~8%, residual runner overhead ~15% — come from the **post-M1 trace of a |
| 48 | +127 ms settle window**. Since that trace, two things landed that changed |
| 49 | +the profile's shape: |
| 50 | + |
| 51 | +- **#518** amortized the shared slice clock to a 32-unit stride (main's own |
| 52 | + 50k filter went ~141.6 → ~133 in the same regime), and |
| 53 | +- **M2** (`17385023`/`28fa68f5`/`04fe0a2a`) re-denominated the identity |
| 54 | + lane's build unit from one row to one slot-vector chunk — cutting the |
| 55 | + 50k sweep from ~196 scheduler hops to ~50. |
| 56 | + |
| 57 | +Both edits attack the _runner overhead_ term specifically. The window is |
| 58 | +now ~124 ms, and the shares inside it have not been re-measured. **Any |
| 59 | +percentage quoted from the arc doc is a percentage of a different |
| 60 | +window.** Re-tracing is a gate, not a formality. |
| 61 | + |
| 62 | +### Static confirmation that one inherited share is mis-attributed |
| 63 | + |
| 64 | +Reading the flat snapshot's read path (`visible-index.ts`), every |
| 65 | +`root.rows.get(...)` on a flat root is **viewport-sized, not row-count |
| 66 | +sized**: |
| 67 | + |
| 68 | +- `visible-index.ts:220` (`publicRowAt`) — one get per requested row. |
| 69 | +- `visible-index.ts:236` (`range`) — one get per row in `[start, end)`. |
| 70 | +- `visible-index.ts:267` (`ɵslotOfRowId`) — one get per call. |
| 71 | + |
| 72 | +A 50k filter settle asks for a viewport (tens of rows) per commit, not |
| 73 | +50 000. Sweeping every `.rows.get(` site in `packages/` (24 hits, none in |
| 74 | +tests) finds no per-row HAMT read on the identity lane's build path at |
| 75 | +all: `sweepChunk` → `carryRecord` touches `filterVerdict`, |
| 76 | +`fillSortKeysFromPrevious`, a transient tree insert and a membership bit — |
| 77 | +no store get. The two candidate-lane gets |
| 78 | +(`flat-cooperative-candidate.ts:350`, `:438`) are on the **replay** and |
| 79 | +**evaluate-lane** paths, neither of which the warm identity lane runs. |
| 80 | + |
| 81 | +So "snapshot `rows.get` ~10%" is unlikely to survive re-tracing as a |
| 82 | +snapshot-read cost. The re-trace's first job is to find out what that |
| 83 | +10% actually is now. **Do not build a dense read path against it.** |
| 84 | + |
| 85 | +For the record, if a dense read path _is_ ever wanted, the substrate is |
| 86 | +already there and already invariant-pinned: `root.recordsBySlot` is a |
| 87 | +per-revision `SlotVector<RowRecord>` on every `RevisionRoot`, and |
| 88 | +`__tests__/records-by-slot.test.ts` pins |
| 89 | +`slotVectorGet(recordsBySlot, record.slot) === record` (identity) for |
| 90 | +every record in `rows`. Turning a `rows.get(rowId)` into a |
| 91 | +`slotVectorGet(recordsBySlot, slot)` is two array indexes against a trie |
| 92 | +descent. It is a _cheap_ change whenever a site is shown to be hot. No |
| 93 | +site has been shown to be hot. |
| 94 | + |
| 95 | +### Lever A (the one concrete, real candidate): the double WeakMap get per survivor |
| 96 | + |
| 97 | +This one is load-independent and readable straight off the source. |
| 98 | + |
| 99 | +On the warm keystroke path the candidate takes the identity lane |
| 100 | +(`operation === "set-query"`, captured plan flat) and adoption fires, |
| 101 | +because a keystroke is a filter-only change: |
| 102 | +`flat-cooperative-candidate.ts:95` → |
| 103 | +`adoptEvaluationCache(next, captured)` makes both plans share **one** |
| 104 | +evaluation-cache WeakMap. |
| 105 | + |
| 106 | +Then, per surviving row, `carryRecord` does: |
| 107 | + |
| 108 | +1. `filterVerdict(plan, record)` — which internally does |
| 109 | + `#evaluationCache.get(input.row)`, finds the entry, fails the |
| 110 | + `cached.verdictPlan === compiled` guard (the entry was written by the |
| 111 | + _previous_ plan, so this plan must run its own filters — correct and |
| 112 | + deliberate), **discards the entry**, and evaluates the predicate. |
| 113 | +2. `fillSortKeysFromPrevious(nextPlan, prevPlan, record)` — two |
| 114 | + `instanceof CompiledQueryPlan` re-validations, then |
| 115 | + `next.#evaluationCache.get(input.row)` **on the same key, into the same |
| 116 | + map**, hits `existing !== undefined` and returns `existing.sortKeys` |
| 117 | + immediately. Under adoption this function carries nothing and evaluates |
| 118 | + nothing; it is a lookup wrapped in revalidation. |
| 119 | + |
| 120 | +That is **two WeakMap gets on the same key plus two `instanceof` checks |
| 121 | +plus two call frames, per survivor, per commit** — at 50k survivors and 5 |
| 122 | +warm commits, 500 000 redundant lookups. |
| 123 | + |
| 124 | +**Proposed shape:** a single fused reader used only when adoption has |
| 125 | +fired — the candidate already knows this at line 95 and can hoist it to a |
| 126 | +boolean. One `#evaluationCache.get(row)` yields both the entry to check |
| 127 | +the verdict guard against _and_ `entry.sortKeys`, with no second lookup |
| 128 | +and no re-validation. Semantics are unchanged: the verdict is still |
| 129 | +recomputed under the new plan (the `verdictPlan` guard still fails), and |
| 130 | +the keys still come from the same entry `fillSortKeysFromPrevious` would |
| 131 | +have read. |
| 132 | + |
| 133 | +**Pre-registered prediction (must be falsifiable before it is built):** if |
| 134 | +the sort-key carry term is still ~8% of a ~124 ms window (~10 ms) and this |
| 135 | +removes roughly one of the two lookups plus the revalidation, the |
| 136 | +expected win is **~3–5 ms on 50k filter-metadata and proportionally more |
| 137 | +on keystroke warm p50** (which is dominated by survivor count). If a |
| 138 | +paired A/B measures flat, the harness is lying or the term has already |
| 139 | +moved — per the bench-A/B rule, a flat response across the variable is a |
| 140 | +harness signal, not a result. |
| 141 | + |
| 142 | +**Gate:** the re-trace must first show the carry-fill term is still a |
| 143 | +material share of the _current_ window. If M2 already absorbed it, this |
| 144 | +lever is dead and should be dropped without ceremony. |
| 145 | + |
| 146 | +### Lever B (columnar verdict cache): REJECT — and now on a ceiling argument, not a regime excuse |
| 147 | + |
| 148 | +The cycle asked whether the columnar verdict cache preserved at |
| 149 | +`73f1ae24` (`blove/filter-fast-path`) has a case now that warm keystroke |
| 150 | +commits run the cooperative identity lane, and required a warm-path budget |
| 151 | +analysis to prove the saving _before_ any rebuild. The budget analysis |
| 152 | +proves the opposite, and it does not need a fit regime to do it: |
| 153 | + |
| 154 | +- The columnar verdict cache exists to make **filter predicate |
| 155 | + evaluation** cheaper. That is the only term it can touch. |
| 156 | +- The post-arc trace puts compiled-query `filterVerdict` at **~1% of the |
| 157 | + settle window**. The pre-arc `evaluate` term it replaced was ~13%; the |
| 158 | + compiled predicate already collapsed it. |
| 159 | +- A cache cannot save more than the term it caches. The upper bound on |
| 160 | + this lever is therefore **~1% ≈ 1 ms of a 124 ms window**, before |
| 161 | + paying for a mutable columnar store with commit-side clears — a real |
| 162 | + ongoing maintenance cost on every commit, which is precisely the cost |
| 163 | + the two previous reverts were unwilling to carry. |
| 164 | +- This is consistent with, and now explains, the two prior measurements: |
| 165 | + the Amendment J A/B measured **flat** (50k filter-metadata 108.6 vs |
| 166 | + 107.3 pooled; filter-text 104.3 vs 108.4 — both inside the ~8.3 ms |
| 167 | + frame-quantization bin, with TanStack controls at **49.7–58.0, in |
| 168 | + band**, so that round was _fit_ and the flat reading is trustworthy). |
| 169 | + It was reverted as "cold-store-invisible" twice; the sharper statement |
| 170 | + is that its target term is now ~1% and there is no version of the warm |
| 171 | + path where it pays for itself. |
| 172 | + |
| 173 | +**Decision: closed, not deferred.** Recommend deleting the preserved |
| 174 | +branch's claim on the roadmap rather than carrying it forward a third |
| 175 | +time. Reopening requires a trace showing filter-predicate evaluation back |
| 176 | +above ~10% of a settle window — which would itself mean something else |
| 177 | +regressed. |
| 178 | + |
| 179 | +### Lever C (runner overhead ~15%): re-measure before touching |
| 180 | + |
| 181 | +M2 and #518 both targeted this directly and neither has been traced |
| 182 | +since. It is the term most likely to have already moved. No proposal |
| 183 | +until the re-trace. |
| 184 | + |
| 185 | +## What the next cycle should do, in order |
| 186 | + |
| 187 | +1. **Confirmation round** (objective 1, unchanged): controls-in-band |
| 188 | + check, then the honest met/missed verdict against ≤120 / ≤130, as an |
| 189 | + addendum to the arc results doc. Cheap, and it closes an open bar. |
| 190 | +2. **Re-trace** one `PLAYWRIGHT_PERF_TRACE=1` 50k filter-metadata run, |
| 191 | + `analyze-cdp.mjs --window=settle`, **shares only**. Publish the |
| 192 | + post-M2 share table beside the post-M1 one so the drift is visible. |
| 193 | +3. **Then** decide on Lever A against the _current_ shares, with the |
| 194 | + pre-registered prediction above as the falsifier. |
| 195 | +4. Lever B needs no further work — it is closed. |
| 196 | + |
| 197 | +Nothing in steps 2–4 should be started before step 1, because a share |
| 198 | +table measured on a loaded machine and a bar measured on a quiet one are |
| 199 | +the same 15-minute setup cost. |
| 200 | + |
| 201 | +## Honesty ledger for this run |
| 202 | + |
| 203 | +- No bench executed. No trace captured. No absolute or relative |
| 204 | + performance number in this document was measured tonight; every figure |
| 205 | + is cited from a prior run with its own regime disclosed. |
| 206 | +- The static findings (call-site sweep, adoption/lookup analysis, the |
| 207 | + `filterVerdict` ceiling argument) are load-independent and stand on the |
| 208 | + source at `bac186da`. |
| 209 | +- The one substantive claim that would change the roadmap — that the |
| 210 | + inherited "~10% snapshot HAMT reads" lever is mis-attributed — is |
| 211 | + offered as a _prediction the re-trace can refute_, not as a finding. |
0 commit comments