Skip to content

fix(cpu): a grow runs a fork-free task in place when a fork follows it (#1475) - #1526

Merged
Lorenzobattistela merged 2 commits into
bendlang:mainfrom
viridale:grow-call-before-fork
Oct 13, 2026
Merged

Lorenzobattistela merged 2 commits into
bendlang:mainfrom
viridale:grow-call-before-fork

Conversation

@viridale

@viridale viridale commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Follows #1479, which fixed #1475 for flat code and left one case open. If the work before each fork is a non-flat call (one that isn't a tail loop, so it isn't inlined as a spin_N), a fork tree still got about 2x from 16 threads, even when that call does nothing. #1479 put that down to the non-flat leaves, which was wrong: the leaves scale fine. What caps it is the call before the fork.

The cause

./nonflat 8 2000000 0 --threads T --gpu off is #1475's tree with a non-flat spin. At K = 0, the call spin(0, i) before each fork returns at once. M3 Max, median of 3:

tree 1 thread 16 threads, main 16 threads, this branch
non-flat spin at the leaves only 0.93 s 0.09 s 0.09 s
the same, plus spin(0, i) before each fork 0.94 s 0.47 s 0.09 s

Removing the empty call removes the cap. Here is how the call stops the grow:

  • Handoff. Outside sequential code, emit_jump doesn't run a fork-free (nofk) callee inline. It returns it to the caller as a task (!DEVICE && !seq && fid_nofk(fid)), and the continuation K after the call waits on it.
  • The grow defers it. When monk_step is growing (stride != 0), it pushes any ready nofk task back onto a ring for the drain and ends the pass. That's right for a call that a join follows. It's wrong for a call that a fork follows, because the grow never reaches that fork.
  • The drain serializes the subtree. The drain runs the task sequentially, and FID_EXIT keeps it sequential for the K. So the fork after the call, and every level below it, runs on one thread's stack.
  • In 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 #1475's tree: the root's fork deals 2 tasks, and each row's first grow step returns spin, so nothing grows. The drain then holds 2 tasks, each a 128-leaf subtree, on 2 threads, and the other 14 sit idle. Flat spin is inlined, so there's no handoff, and the grow runs it and reaches the fork.

What changes

monk_step runs a ready nofk task in place, inside the grow, when its continuation can fork:

if (stride != 0 && nofk && (DEVICE || k == TERM_HOLE
  || !fid_resw((u32)term_aux(k)) || fid_nofk((u32)term_aux(k)))) {
  ring_push(H, ring_pick(base, stride, cur), r);
  return 2;
}
t      = r;
seq    = false;
stride = nofk ? stride : 0;

Each condition has a reason:

  • fid_resw(k) != 0 picks out a K: a segment with a frame, whose results arrive in registers. A join has no frame, so its resw is 0. Without this check, a leaf that tail-calls a nofk function into a join that can fork would run leaf work inside the grow.
  • !fid_nofk(k) means the code after the call can fork. A task followed by a fork-free K stays deferred, as before.
  • k == TERM_HOLE guards the root. Its term_aux is 0xFFFF, which would index past FID_T. FID_EXIT has the same guard.
  • DEVICE || keeps the GPU schedule exactly as it was. The device never hands off calls.
  • The stride is kept. The fork after the call then deals within the row, as inlined code does. Setting it to 0 would send its kids out through H_CURSOR instead.

The ring-head skip doesn't change. No nofk task whose K can fork reaches a ring head: the grow no longer pushes one, and task_deal pushes only fork kids, which a join follows.

The risk is the same one inlined code already carries. If the K forks in only some branches (x = heavy(d); match d: 0 → x; _ → fork), a grow runs heavy at the leaves too.

comp.ts goes from 63,067 to 63,147 tokens.

Results

M3 Max, --gpu off, median of 3. Both builds print the same output in every row.

non-flat tree, 8 levels, 2M leaf steps 1 thread 4 threads, main → branch 16 threads, main → branch
K = 0 0.94 s 0.47 → 0.24 s 0.48 → 0.09 s
K = 2M 1.87 s 0.93 → 0.48 s 0.94 → 0.19 s

The flat tree from #1475 (spread 8 20000000 K) is unchanged, at K = 0 and K = 20M, on 4 and 16 threads.

Test

tests/run/fork_after_call.bend covers both shapes that reach the new path. An instrumented build confirmed each:

  • spin before spread's fork: reached 254 times at 4 and at 16 threads.
  • pair's join, which tail-calls spin and returns into chain's fork: reached once.

pair's kids call leaf, which can fork, so the grow runs them and reaches the join. Fork-free kids would be left for the drain, and the join would never be reached in the grow. The test prints 417464320 and 2860191008 in the interpreter, under bun, and in C at 1, 4 and 16 threads, on main and on this branch.

Benches (local A/B)

  • Which benches reach the path: in instrumented builds at 4 and 16 threads, only tree-bitonic does, 100 and 242 times. The hits are warp's join, which tail-calls warp_zip and is continued by bsort's K into flow. Before, the drain ran each such flow subtree on one thread.
  • Output: every bench prints its pinned output on the 16-thread CPU path, and spot checks on one thread and with the GPU on printed the same.
  • PAR-CPU, 16 threads, main vs branch, 5 runs each, alternating builds: all 17 are within 2% (0.982 to 1.019). tree-bitonic is 1.004.
  • tree-bitonic, 7 runs each: 0.998 at 4 threads, 0.999 at 1.

What I ran, and what I couldn't

  • Tests: gates/test.ts's shard script, run locally on this Mac in 4 shards, passes 1605 / 1607. 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, 54 / 54. tsc reports no new errors.
  • Not run: the cluster gates (I have no access to the minis), Linux and CUDA. Please run test and perf there.
  • PAR-GPU timing wasn't measured. DEVICE || reduces the device's condition to the old one, so its schedule doesn't change.

bendlang#1475)

A non-flat call before a fork capped scaling. A grow pushed every
fork-free task it met back for the drain, even one whose continuation
forks, so the drain ran that fork and its whole subtree on one thread.
monk_step now runs such a task in place on the host and keeps the row's
stride, so the fork deals within the row, as inlined code does. A task
whose continuation is a join (resw 0), a fork-free frame or the root
still waits for the drain, and the device schedule is unchanged.

bendlang#1475's tree with a non-flat spin (8 levels, 2M leaf steps), 16 threads
on an M3 Max, median of 3: K = 0 0.48 -> 0.09 s, K = 2M 0.94 -> 0.19 s
(1 thread: 0.94 and 1.87 s). The flat tree is unchanged. Of the 17
runtime benches only tree-bitonic reaches the path (100 times at 4
threads, 242 at 16); every output matches its pin, and all 17 are
within 2% of main at 16 threads.
@Lorenzobattistela Lorenzobattistela self-assigned this Oct 13, 2026
@Lorenzobattistela
Lorenzobattistela merged commit 213cd5e into bendlang:main Oct 13, 2026
1 check passed
@wakamex

wakamex commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

on bend-bench this PR screens clean: it emits code that passes every test the base passes, compiles at the same speed, and shows no real slowdown across the CPU and GPU workloads.

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

3 participants