Is any tree-fold arm worth a default flip? (2026-09-12)
Measurement only. Nothing in src/ or tools/toyvm/ moved for this; the only
new code is tools/fold-ab.js, the harness that produced every number below.
The question this answers is narrow: --tree-fold (Win98, H448) and
--tree-fold --tree-fold-hot=64 (toyvm) are both OFF by default. Should either
be flipped ON? Both features are already built, already correct on their
corpora, and already documented — docs/tree-fold-design-a.md
and docs/toyvm-tree-fold.md. What neither document could
supply is a defensible timing verdict, because every timing table in them
carries its own disclaimer that this box was at load 10-40 when it was taken.
Short answer: flip nothing yet. Nothing is a resolved gain. The toyvm gated
fold is a resolved LOSS on five of six witnesses by the load-robust statistic,
and the mechanism is identified. Win98 --tree-fold is the only arm that never
regressed anywhere, and quake2 is a −1.2% to −1.5% lead it deserves one quiet-box
confirmation for.
What was measured, and the third arm
Build: 41022d0d. Main HEAD at the time (5f1c1df2) does not compile in
isolation — e1333413 committed a $VIRTUAL_HOLE_TABLE reader whose
region.declare is still uncommitted in the main repo's working tree — so the
worktree was reset to the last commit that builds. The Win98 arms therefore
predate 9643d33a (x87 micro-ops in the tree fold); a re-run on that commit may
reach further into mw3's and quake2's float code than what is recorded here.
Load average on this box ran 32 to 244 across the session, with no rep starting below 4 — so there is no quiet-window subset to flag separately, and wall clock is not a measurement of anything. Every number below is USER CPU at fixed work: an exact batch count (Win98) or an exact dispatch count (toyvm), never a time budget.
Three arms, not two:
off baseline flags
on baseline flags + the arm switch
null baseline flags AGAIN, under a different label
null is the control. It is the same binary doing the same work as off, so
every difference it shows is noise by construction. It is the ruler. The
arms are interleaved inside each rep with the order rotated per rep (off on null, on null off, null off on, …), so a warm cache on the first arm or a
thermal ramp on the last cannot masquerade as the feature.
The prescribed verdict rule is |mean(on − off)| > 2 × sd(null − off).
That rule is conservative to the point of being uninformative on a box at load
200, because the paired standard deviation is dominated by additive
interference that hits all three arms. So both statistics are reported: the
paired rule, and the minimum, which is the right robust statistic here —
scheduler interference can only ever add CPU time, so the minimum of N reps is
the closest thing to an interference-free sample, and null_min − off_min is
what that statistic's own noise floor looks like.
node tools/fold-ab.js --target=toyvm --exe=/tmp/demos/1995-c-cma_brw/BRW.EXE \
--work=20m --reps=12 --arm-on='--tree-fold --tree-fold-hot=64' --out=BRW.json
node tools/fold-ab.js --target=win98 --app=quake2_demo --work=150 --reps=8 \
--arm-on='--tree-fold' --base='--quiet-api --batch-size=200000' \
--extra='--args=+set vid_ref soft +map demo1'
--extra= exists because --base is whitespace-split and quake2's args must
be pinned on every single run: lib/apps.js persists config.cfg across
processes, so an unpinned rep inherits the previous one's video driver and is
not the same work as its neighbours.
toyvm arms call runDos() in-process and read guestCpuSecs, the per-slice
getrusage meter bench-dos.js --cpu-time uses; spawning would bury a 0.5s
guest slice under node startup and a wasm module build. Win98 arms shell out to
test/run.js under /usr/bin/time -l and read the child's user seconds.
toyvm: --tree-fold --tree-fold-hot=64, 20M dispatches, 12 reps
Seconds of guest CPU. Positive on−off is a LOSS.
MEDIAN MIN PAIRED MEAN ± SD
prog off on null off on null on-off null-off rule
------- ------- ------- ------- ------- ------- ------- ------------- ------------- ----------
BRW 0.549 0.507 0.595 0.528 0.477 0.520 -0.011 ±0.078 +0.027 ±0.051 unresolved
RUNDEMO 0.178 0.227 0.180 0.146 0.207 0.156 +0.060 ±0.055 +0.005 ±0.059 unresolved
DADEMO3 0.329 0.380 0.348 0.290 0.339 0.286 +0.064 ±0.064 +0.005 ±0.079 unresolved
CATWALK 0.157 0.204 0.172 0.131 0.178 0.130 +0.064 ±0.045 +0.028 ±0.032 unresolved
DTM2 0.221 0.289 0.220 0.183 0.264 0.185 +0.050 ±0.048 -0.021 ±0.056 unresolved
CYCLE 0.384 0.432 0.395 0.332 0.402 0.330 +0.058 ±0.061 -0.007 ±0.046 unresolved
MIN-STATISTIC (interference can only add time)
prog on_min-off_min null_min-off_min reading
------- ---------------- ------------------ ------------------------
BRW -0.051 -0.008 resolved GAIN
RUNDEMO +0.061 +0.010 resolved loss
DADEMO3 +0.049 -0.004 resolved loss
CATWALK +0.047 -0.001 resolved loss
DTM2 +0.081 +0.002 resolved loss
CYCLE +0.070 -0.002 resolved loss
Frame hashes are identical across all three arms on all six programs (BRW
ecef58f7, RUNDEMO 08502c5c, DADEMO3 88b5bd0e, CATWALK 9af00ca9, DTM2 and
CYCLE 38c165c5), and the dispatch count varies by at most 25 out of 20,000,000.
The correctness contract holds. This is a cost question, not a safety one.
The deterministic side, which the load cannot touch
One run per program, --handler-hist on, the gated arm. Counts, so these are
the same numbers at load 2 and at load 244.
prog trees installs folds foldedOps hotPromoted coldSkipped tree entries dispatches removed
------- ------ -------- ----- --------- ----------- ----------- ------------ ------------------
BRW 36 2 275 1410 41 647 566,777 4,479,388 22.397%
RUNDEMO 8 2 18 101 14 118 46,080 230,400 1.152%
DADEMO3 42 2 214 1153 41 353 53,748 352,609 1.763%
CATWALK 4 2 4 19 6 82 526 1,578 0.008%
DTM2 4 2 4 22 21 34 0 0 0.000%
CYCLE 1 2 1 3 20 39 3 6 0.000%
Win98: --tree-fold, 8 reps
Seconds of child-process user CPU. Negative on−off is a GAIN.
MEDIAN MIN PAIRED MEAN ± SD
app work off on null off on null on-off null-off rule
------- --------- ------- ------- ------- ------- ------- ------- ------------- ------------- ----------
mw3 12 batch 15.615 15.555 15.525 14.630 14.790 15.090 -0.068 ±0.208 -0.011 ±0.356 unresolved
quake2 150 batch 5.200 5.135 5.225 5.120 5.060 5.130 -0.078 ±0.078 +0.009 ±0.076 unresolved
app on_min-off_min null_min-off_min median on-off as % reading
------- ---------------- ----------------- -------------------- --------------------------
mw3 +0.160 +0.460 -0.44% unresolvable (null > on)
quake2 -0.060 +0.010 -1.25% suggestive GAIN, not resolved
Deterministic side, one run each with --loopmatch-stats --handler-hist:
app self-loops TREE_FOLD runs iters ops caught retired ops share of guest ops
decoded blocks (folded) the fold stands for
------- ----------- ---------- ------- --------- ----------- ------------ --------------------
mw3 27 5 255 35,863 251,018 10,087,199 2.4%
quake2 162 34 34,401 528,754 8,953,619 148,926,853 5.7%
Declines: mw3 short 15 / long 0 / terminator 3 / unfoldable-op 4; quake2
short 47 / long 0 / terminator 69 / unfoldable-op 12. Quake2's barrier is the
terminator, by a factor of six over unfoldable ops — that, not eligibility
width, is where its next tranche of reach is.
Pictures are byte-identical between arms: png-diff.js reports 0 of 76,800
pixels differ for quake2 and 0 of 307,200 for mw3 at the measured batch
counts.
Per app
BRW is the only program in the whole set where the gated toyvm fold pays for itself, and it pays handsomely: 275 substituted blocks entered 566,777 times, removing 22.4% of all dispatches, and it is the one row whose min goes the right way (−0.051s against a null of −0.008s) — about a 10% gain on a 0.53s baseline. It is also, from the previous session's table, the row the fold was designed around. One program is not a default.
RUNDEMO removes 1.15% of dispatches from 18 substitutions and pays +0.061s on the min against a null of +0.010s. The cost is six times the yield. Note the shape: 8 trees built, 14 blocks promoted hot, 118 skipped cold — the gate is doing its job of not building junk, and the run still ends up slower.
DADEMO3 builds the most trees of any witness (42, 214 substitutions, 1153 folded ops) for 1.76% of dispatches removed, and lands at +0.049s min against −0.004s null. It is the clearest statement that tree count is not yield: five times BRW's substitution effort into a program whose hot code is somewhere else entirely.
CATWALK substitutes four blocks that are entered 526 times in twenty million dispatches — 0.008%, i.e. nothing — and pays +0.047s. Its paired numbers land exactly on the rule's threshold (|+0.064| against 2×0.032), which is the one row where the conservative rule and the min statistic visibly disagree; the min is unambiguous and the mechanism below says which to believe.
DTM2 and CYCLE are the control pair, and they are the cleanest result in the table. DTM2 enters its four trees zero times and CYCLE its single tree three times, so the fold's yield on both is exactly nothing, and both still pay +0.081s and +0.070s on the min against nulls of +0.002s and −0.002s. Two programs where the feature provably did no work and still cost 20-30% of their runtime is the measurement that identifies the cost, because it cannot be anything the trees did.
mw3 is the Win98 app the box could least afford. One batch costs ~1.3s of
user CPU, so the 12-batch fixed work here is ~15.6s and the gameplay window at
batch 888 is out of reach by three orders of magnitude — 888 batches is ~19
minutes of user CPU per run, ~7.6 hours for a 24-run protocol, against a 120s
per-run budget. What the boot window shows is 5 folded blocks catching 2.4% of
retired ops and a −0.44% median that the null arm swamps (null_min is 0.46s
above off_min, larger than the effect). Unresolvable, and honestly so.
quake2 is the best-behaved measurement of the session: 150 batches, 5.2s per run, an arm spread of ±0.1s, and the fold catching 5.7% of retired guest ops across 34 folded blocks entered 34,401 times. Median goes −1.25%, min goes −0.060s against a null of +0.010s, and the sign is the same under both statistics. It fails the prescribed rule (0.078 against a 2×sd of 0.152) but it is the only arm in the whole session pointing consistently at a gain.
What is actually costing the toyvm arm 0.05-0.08s
Look at the loss column, not the programs: +0.047, +0.049, +0.050, +0.058,
+0.060, +0.064 seconds across programs whose totals range from 0.16s to
0.55s and whose yields range from 22.4% of dispatches to literally zero. A cost
that is constant in absolute seconds while everything else varies by 3.5x is not
a property of the trees. It is a fixed per-dispatch tax over a fixed window:
0.05-0.08s over the gate's --tree-fold-warm=10m profiling window is
≈5-8 ns per profiled dispatch, which is the size of the one load/add/store
--block-hits adds — the census the hotness gate profiles with.
docs/toyvm-tree-fold.md anticipated this exactly, and measured blockhits
alone as a control arm for it. What this session adds is the two programs that
turn the argument from an estimate into a subtraction: DTM2 and CYCLE run the
gate, build their trees, enter them 0 and 3 times, and still pay the full toll.
So the gate's economics are: it pays the profiler everywhere and collects only
where a hot block is also foldable. On this six-program set that is 1 program
in 6. The fix is not a bigger --tree-fold-hot; the threshold already declines
the right blocks (647 cold-skipped on BRW, 118 on RUNDEMO). The fix is making
the profiling window cheap or self-limiting — closing it early when the promoted
population stops growing, or sampling instead of counting every dispatch.
Verdicts
| arm | app / program | verdict |
|---|---|---|
toyvm --tree-fold --tree-fold-hot=64 |
BRW | resolved gain (min), ~10% |
| RUNDEMO, DADEMO3, CATWALK, DTM2, CYCLE | resolved loss (min), 20-35% each | |
| all six, prescribed 2×null-sd rule | unresolvable at this load | |
Win98 --tree-fold |
quake2 | unresolvable (suggestive gain, −1.25%) |
| mw3 | unresolvable at this load |
Flip nothing today.
The toyvm gated fold must stay off: five of six witnesses are a resolved loss, and the loss is a profiler tax that the feature pays whether or not it collects. That is a design finding, not a load artifact, and no quiet box will change it — DTM2 pays 0.081s for zero tree entries at any load.
Win98 --tree-fold is the arm to come back to. It is pixel-identical on both
apps, never regressed on either, catches 5.7% of quake2's retired ops, and both
statistics agree on the sign. It needs one thing this box cannot give: a rerun
of tools/fold-ab.js --target=win98 --app=quake2_demo --work=400 --reps=8 at
loadavg under 4. If the −1.25% survives that, flip it; the 400-batch work size
is what the protocol wanted here and what the load would not allow.