Skip to content

fix(cpu): deal a slow grow's forks to idle threads (#1475) - #1479

Merged
Lorenzobattistela merged 1 commit into
bendlang:mainfrom
viridale:cpu-late-fork-spread
Oct 12, 2026
Merged

Lorenzobattistela merged 1 commit into
bendlang:mainfrom
viridale:cpu-late-fork-spread

Conversation

@viridale

@viridale viridale commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1475.

One commit, 65a7b947, in row_grow and cube_run. The token cut this PR first carried (a7e927ed) is already on main as 75d6f66d, so it was dropped and the fix was rebased onto main. comp.ts is 63,972 ttok, under the 64,000 cap.

The cause

A grow turn gives each row to one thread. The first turn has only the rows the root's fork reached: 2 for a binary tree. A row grows by running each ring's head up to its next fork. So when a tree does real work before each fork, its top levels ran on one or two threads while the rest waited at the turn's barrier. The drain that follows runs each task's subtree whole, so it can't make that back.

What changes

  • row_grow notes when it starts. In a turn with fewer rows than threads, a grow that is still growing after 1 ms makes one last pass. That pass deals its forks through H_CURSOR to row g % CUBE_T instead of within its own row.
  • cube_run repeats the grow turn over the rows the cursor reached until no grow deals, then drains as before. The row bound still covers every task, because a dealt task g lands in a row below g. The Cube comment states this.
  • No dealing once a turn has as many rows as threads. At that point every thread already has a row. Without this gate, a deep fine-grained tree (about 0.1 ms before each fork) went through about 30 extra barrier-bound grow turns: 12% slower and 3x the RSS (table below).
  • Benches never deal. In instrumented builds of all 17 runtime benches, 3 runs each at 16 threads, there were 0 dealing passes. The longest single grow was 0.275 ms (tree-bitonic), so their schedules are unchanged.
  • SEQ is untouched by construction. With one thread, corpus_eval never reaches the cube, and rows < pool_size is never true.
  • The device is untouched. monk_step and the device kernel don't change. row_grow and pool_* are host-only (#if !DEVICE).

#1475's tree

./spread 8 20000000 K --threads T --gpu off: 256 leaves of 20M steps each. M3 Max (12P + 4E), median of 3 runs, main (2343aec) vs this branch. Every K prints the same output on both builds.

Steps before each fork (K) 1 thread 12 threads, main → branch 16 threads, main → branch Speedup at 16
0 5.51 s 0.48 → 0.47 s 0.39 → 0.38 s 14.1x → 14.5x
20,000 5.49 s 0.47 → 0.47 s 0.37 → 0.39 s 14.8x → 14.1x
200,000 5.55 s 0.50 → 0.48 s 0.40 → 0.38 s 13.9x → 14.6x
2,000,000 6.03 s 0.75 → 0.53 s 0.65 → 0.44 s 9.3x → 13.7x
10,000,000 8.23 s 1.85 → 0.78 s 1.77 → 0.71 s 4.6x → 11.6x
20,000,000 10.99 s 3.24 → 1.08 s 3.15 → 0.88 s 3.5x → 12.5x

At 4 threads with K = 20M it is 4.14 → 2.82 s (ideal ≈ 2.75 s). The 1-thread times are the same on both builds.

Deep trees with little work before each fork

These are why the gate exists. Same program, 16 threads, median of 3, time and RSS. "Ungated" is this fix without the rows < pool_size condition.

d, n, k main ungated this branch
16, 1000, 100000 (0.1 ms a fork) 0.51 s, 8 MB 0.57 s, 26 MB 0.50 s, 9 MB
18, 1000, 30000 0.61 s, 11 MB 0.65 s, 34 MB 0.61 s, 13 MB
12, 1000, 1000000 0.43 s, 7 MB 0.35 s, 14 MB 0.32 s, 8 MB
10, 1000, 3000000 0.59 s, 7 MB 0.28 s, 10 MB 0.25 s, 8 MB

Benches (local A/B)

All 17 runtime benches, main vs branch, run alternately, 3 runs each on this Mac:

  • Output: every bench prints the same output in SEQ-CPU, PAR-CPU (--threads 16 --gpu off) and PAR-GPU (--gpu on).
  • RSS: unchanged in every mode.
  • Time:
    • SEQ-CPU is within 1% on 16 of the 17 benches. tree-radix reads 1.05, but its raw runs overlap (main 8.26/9.47/8.41 s, branch 9.57/8.48/8.85 s).
    • PAR-CPU and PAR-GPU scatter in both directions at /usr/bin/time's 10 ms resolution. For example, queens' main build alone ranged 0.69–1.01 s.
    • Since no bench deals, I'd treat the dealing-pass count above as the evidence of an unchanged schedule. These timings aren't precise enough to show that, so the perf gate on the cluster is the real check.

What I ran, and what I couldn't

  • Tests: gates/test.ts's shard script, run locally on this Mac in 4 shards (check, interpret, JS under bun, C), passes 1598 / 1600. The two failures are gfx_clicks and gfx_window. They expect "no display", but this Mac has a desktop session, so a window opens. main's build gives the same output here.
  • Repo gate: gates/repo.ts passes for comp.ts. With ttok 1.0 it flags bendtt.lean (64,192), which this PR doesn't touch; main counts the same.
  • Not run: the cluster gates (I have no access to the minis), Linux and CUDA. Please run test and perf there. The perf gate's PAR-CPU cells are the ones that matter, and the dealing-pass count says they shouldn't move.
  • No test for the new path. The test gate also interprets every test, and the path needs more than 1 ms of grow before it deals. The smallest tree that reliably gets there took the interpreter over 10 minutes. The issue's program reproduces it.
  • The 1 ms check is wall-clock. On a loaded machine, a preempted grow can deal when it otherwise wouldn't. That changes only the schedule, never a result.

Checked after the rebase (65a7b94 on main c25ee78)

Not fixed here

If the work before each fork is a non-flat fork-free call (one that isn't tail-recursive, so it isn't inlined as a spin_N), this PR doesn't help. Example: the issue's spin with its result XORed by 1 after the recursive call. The program gets about 2x at 16 threads on main and on this branch, and that holds even at K = 0. The issue's tail-recursive spin doesn't have this problem. I'll look at it separately.

…rks over the rows, so a call before each fork no longer keeps a fork tree on a few threads (bendlang#1475)

A grow turn gives each row to one thread, and the first turn has only the
rows the root's fork reached: 2 for a binary tree at 16 threads. A row grows
by running each ring's head up to its next fork, so a tree that does real
work before each fork ran its top levels on one or two threads. bendlang#1475's
256-leaf tree got 3.5x from 16 threads instead of 14x.

row_grow now notes when it starts. In a turn with fewer rows than threads,
a grow still growing after 1 ms makes one last pass that deals its forks
through H_CURSOR to row g % CUBE_T, and cube_run repeats the grow turn over
the rows the cursor reached until no grow deals. Once a turn has as many
rows as threads, every thread has a row and no grow deals, so a deep,
fine-grained tree keeps its old schedule. No bench deals: the longest grow
in any bench is 0.275 ms (tree-bitonic).

io_tick moves up to the Row section. comp.ts: 63,761 -> 63,944 ttok.
@Lorenzobattistela Lorenzobattistela self-assigned this Oct 12, 2026
@Lorenzobattistela Lorenzobattistela changed the title On the CPU, a grow that runs past 1 ms with threads idle deals its forks over the rows, so a call before each fork no longer keeps a fork tree on a few threads (#1475) fix(cpu): deal a slow grow's forks to idle threads (#1475) Oct 12, 2026
@Lorenzobattistela
Lorenzobattistela merged commit 74495b3 into bendlang:main Oct 12, 2026
1 check passed
@Lorenzobattistela Lorenzobattistela mentioned this pull request Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

On the CPU, a call before each fork keeps a fork tree on a few threads: 256 leaves get 3.5x from 16 threads instead of 14x

2 participants