Skip to content

CUDA: the error poll in every flat loop (err_spun) makes ptxas yield on each iteration; tight loops run up to 20x slower #1776

Open

Description

@nuxyel

What you did

bun bend2/main.ts flat.bend -o flat.c      # or `bend flat.bend -o flat.c` with 2.0.37
sed 's/^#define err_spun(H, n) ((++\*(n) & 4095) == 0 \&\& err_seen(H))$/#define err_spun(H, n) (0)/' flat.c > flat_np.c
# build both the way `bend -o` does (clang -DBEND_CUDA=1 ... -std=c11 -O3 ... -lcuda -lnvrtc), then ./x --gpu-build

What happened

Every flat loop Bend compiles for the device (spin_N, opened by WL_SPIN) starts with err_spun(e.mem, &wpoll): a counter and, every 4,096 steps, a load of the heap's error word. In the SASS (read from 2.0.37's build; main emits the same C for it), that loop gets a YIELD and a convergence barrier (BSSY/BSYNC) on every iteration, and it is not unrolled. On a loop with little work per step this dominates: a one-leaf loop of acc * 0.999 + x takes ~70 ns a step on an RTX 4050, against ~3.4 ns for the same loop handwritten in CUDA. With the poll removed from the emitted C, Bend's loop runs as fast as the handwritten one. Splitting the loop keeps the check and most of the speed; a patch is below.

Both builds print 57342550. The tables time the bang alone (IO.now around it, after a warm-up bang), five alternated rounds on an idle machine, min-max. Main 881cb2e (with #1602), RTX 4050 Laptop (sm_89), driver 615.78.08, CUDA 12.9; every build prints the same results, on the GPU and with --gpu off:

program main poll removed ratio
the loop in the file below, 1 leaf (depth 0), 2M steps 136-138 ms 7 ms ~20x
the loop in the file below, 16,384 leaves, 2M steps 129-132 ms 15-16 ms 8.5x
tiled matmul 512x512x4096, 8x4 block a leaf, 50 products 172-177 ms 149-152 ms 1.16x
tiled matmul 512x512x4096, 4x4 block a leaf, 50 products 230-237 ms 210-218 ms 1.09x
16,384 leaves, interleaved reads of one shared Array 112-114 ms 112-115 ms 1.0x
16,384 leaves, 2M reads each of a 32-number Array (whole program) 0.92-0.93 s 0.88-0.91 s ~1.0x

The same loop handwritten in CUDA takes 14.1-14.3 ms for 16,384 threads, so without the poll Bend matches it. Loops that wait on memory hide the poll; loops of arithmetic pay for it.

The cost is the poll's shape, not its load

In a handwritten CUDA kernel with the same loop (one thread / 16,384 threads, ns per step):

poll 1 thread 16,384
none 3.4 7.1
if ((++n & 4095) == 0 && *err != 0) return; in the loop (Bend's shape), volatile load 16.7 30.5
the same with __ldcg or ld.relaxed.gpu 16.7 30.4-30.9
split loop: 4,096 steps with no check, then one check 3.4 7.2

So the check can stay: emitting a spin as an inner loop of up to 4,096 steps with no poll, inside an outer loop that polls once, costs nothing measurable here. On the host err_seen is DEVICE && ..., so CPU builds do not pay.

A fix that keeps the poll

I tried the split shape in bend2/comp.ts (written against e5527d5, applies to 881cb2e unchanged): on the device, each spin_N, each spin segment and the work loop run their body in an inner loop of up to 4,096 steps with no poll, inside an outer loop that polls once. A break leaves the inner loop with steps left, which also ends the outer one; continue stays in the inner loop. The host keeps its macros, so CPU builds do not change.

@@ emit_native (spin_N)
-    ...seg_take(seg), "WL_SPIN"], 1),
-    ...seg_text(seg.lines, 2), "  break;", "  }",
+    ...seg_take(seg), "WL_SPIN2"], 1),
+    ...seg_text(seg.lines, 2), "  break;", "  }", "  WL_SPIN2_END",
@@ compile_book (spin segments)
-      "WL_OPEN", ...seg.spin ? ["WL_SPIN", ...seg.lines, "WL_SPUN"]
+      "WL_OPEN", ...seg.spin ? ["WL_SPIN2", ...seg.lines, "WL_SPUN2"]
@@ after WL_AGAIN
+#if DEVICE
+#define WL_SPIN2     for (u32 wfirst = 1;; wfirst = 0) { \
+  if (!wfirst && err_seen(e.mem)) { return 0; } \
+  u32 wleft = 4096; for (; wleft != 0; wleft -= 1) {
+#define WL_SPIN2_END if (wleft != 0) { break; } }
+#define WL_SPUN2     } if (wleft != 0) { break; } } break;
+#else
+#define WL_SPIN2     WL_SPIN
+#define WL_SPIN2_END
+#define WL_SPUN2     WL_SPUN
+#endif
@@ work_loop (device)
-  WL_SPIN
+  WL_SPIN2
   switch (fid) {
@@ end of work_loop
   }
+  WL_SPIN2_END
+  return 0;
 }

Built with bun bend2/main.ts, every program below prints the same results as main's build, on the GPU and with --gpu off, and a lane-stack overflow inside a bang is still reported. Same machine and rounds:

program main with the split poll removed
flat loop, 1 leaf, 2M steps 136-138 ms 19-20 ms 7 ms
flat loop, 16,384 leaves, 2M steps 129-132 ms 36-37 ms 15-16 ms
tiled matmul 512², 8x4 a leaf, 50 products 172-177 ms 157-161 ms 149-152 ms
tiled matmul 512², 4x4 a leaf, 50 products 230-237 ms 222-227 ms 210-218 ms
interleaved reads of one shared Array 112-114 ms 106-107 ms 112-115 ms
2M reads a leaf of a 32-number Array (whole program) 0.92-0.93 s 0.89-0.90 s 0.88-0.91 s

It keeps most of the gain on the arithmetic loops and is slower than main nowhere. The flat loops do not reach the no-poll time: the split still runs the outer loop and its test every 4,096 steps, and I have not looked further at what nvcc makes of it.

The change adds 17 lines to comp.ts, which is at its token cap; a shorter form is surely possible. I have not opened a pull request; this is to show the shape works.

The file

import Base

def flat_l(k: Nat, +x: F32, acc: F32) -> F32:
  match k:
    case 0n: acc
    case 1n+q: flat_l(q, x, ((acc * 0.999 : F32) + x : F32))

def flat_tree(d: Nat, +k: Nat, +i: U32) -> F32:
  match d:
    case 0n: flat_l(k, U32.to_f32(U32.and(i, 7)), 0.0)
    case 1n++q:
      a b = flat_tree(q, k, (i * 2 : U32)) flat_tree(q, k, ((i * 2 : U32) + 1 : U32))
      (a + b : F32)

def main() -> IO(Unit):
  IO.print(F32.show(flat_tree!(14n, 2000000n, 0)))

bend version

bend 2.0.37, and main 881cb2e (2026-10-19, bun bend2/main.ts), where the tables were measured

uname -sm

Linux x86_64 (RTX 4050 Laptop, driver 615.78.08, CUDA 12.9)

clang --version (the first line)

clang version 23.1.3

Activity

  1. adeerkhan commented on Oct 22, 2026

    @adeerkhan
    Contributor

    There is a PR for the poll: #1820

    It takes the shape your patch takes: the device's native spin runs its body in
    an inner loop of 4096 steps and reads the error word once, after them, so the
    poll's branch leaves the hot path. Two things differ from your patch:

    • it touches only the native spin, not the spin segments or work_loop, so
      the wpoll counter of the dispatcher and the one of a segment cannot
      collide, and the host's C is byte-identical (checked with clang -S);
    • the poll's period does not change, so an error is caught after the same
      number of steps as before (err_spun read the word every 4096 steps too).

    Measured on an idle RTX 5060 (driver 615.71.09, CUDA 13.4), your file with
    five rounds, the three builds alternated, the best of each:

    ms
    main 648
    the PR 332 (1.95x)
    with the poll removed 324

    So the split is 2.5% off a spin that never polls. (On a card at 85 C the same
    pair reads 1233 against 533; the ratio moves with the clock, so the numbers
    above are from an idle card and the alternation shares the drift.)

    A memory-bound loop is not affected: tests/run/stencil3d.bend takes 175 ms
    against 172 ms.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions