Skip to content

CUDA: prefetch the fixed pages the host writes before a bang (experiment) - #2014

Open
nicolas-abril wants to merge 3 commits into
mainfrom
ai/1992-prefetch
Open

nicolas-abril wants to merge 3 commits into
mainfrom
ai/1992-prefetch

Conversation

@nicolas-abril

@nicolas-abril nicolas-abril commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Refs #1992. An experiment for someone with a CUDA GPU to measure, not for merge. It has never run on a real GPU.

Before each bang, this prefetches the pages whose addresses the runtime already knows: the header, ring row 0's get, put and slot words, and the task's tail block. That's 5 of the 11 or so pages in #1992. It doesn't cover the reused heap blocks or the input vector, so this tests whether prefetching beats faulting per page, not the full possible saving.

How to measure

You need your program from #1992, the CUDA toolkit, bun, and nsys for step 4. Add your program's own arguments before +RTS in steps 3 and 4.

1. Get this branch

git clone https://github.com/bendlang/bend.git bend-near
cd bend-near
git fetch origin pull/2014/head:pr-2014
git checkout pr-2014

2. Build your program

bun bend2/main.ts /path/to/your_program.bend -o near_test

3. Compare prefetch off and on, 5 runs each

The binary prefetches by default, and BEND_NEAR=0 turns it off, so both use the same build. Each run prints one bend: near=... line to stderr when it exits.

rm -f off.txt on.txt
for i in 1 2 3 4 5; do
  BEND_NEAR=0 ./near_test +RTS --gpu on > /dev/null 2>> off.txt
  BEND_NEAR=1 ./near_test +RTS --gpu on > /dev/null 2>> on.txt
done
grep -h 'bend: near' off.txt on.txt

The times are medians over the second half of the bangs, in µs:

Field Meaning
cycle_us bang_us + host_us. This is the number that decides it
bang_us From the start of a bang to its end, including the prefetches in it
host_us From the end of one bang to the start of the next
prefetches_per_bang Expect a bit over 5, because the header is sent again per batch
prefetch_us Average host time of one cuMemPrefetchAsync call (a mean over the whole run)
failed Should be 0. If not, the first error is printed on its own line

The near=off lines should be close to the 1,614 µs #1992 reports for 135M. If near=on isn't lower than near=off by more than the spread between runs, prefetching doesn't help and this PR can be closed.

4. See which pages still fault

Save the script:

cat > pages.py <<'PY'
import re, sqlite3, sys
db, log = sys.argv[1], sys.argv[2]
line = [l for l in open(log) if "bend: near=" in l][-1]
v = dict(re.findall(r"(\w+)=(\S+)", line))
base, ring, stak, heap = int(v["corpus"], 16), int(v["ring_off"]), int(v["stak_off"]), int(v["heap_off"])
bangs = int(v["bangs"]) / 2
def region(off):
    return "header+allocators" if off < ring else "ring" if off < stak else "stack+static" if off < heap else "heap"
def show(name, table, count):
    half = f"(SELECT (MIN(start) + MAX(start)) / 2 FROM {table})"
    try:
        rows = sqlite3.connect(db).execute(f"SELECT address, {count} FROM {table} WHERE start > {half}").fetchall()
    except sqlite3.OperationalError as e:
        print(f"\n{name}: not in this export ({e})")
        return
    pages, kinds = {}, {}
    for addr, n in rows:
        off = addr - base
        if off >= 0:
            page = off & ~4095
            pages[page] = pages.get(page, 0) + n
            kinds[page] = region(off)
    print(f"\n{name}: {sum(pages.values()) / bangs:.1f} faults a bang on {len(pages)} distinct pages (second half of the run)")
    for kind in sorted(set(kinds.values())):
        some = [p for p in pages if kinds[p] == kind]
        print(f"  {kind}: {len(some)} pages, {sum(pages[p] for p in some) / bangs:.1f} faults a bang")
    print("  busiest pages (offset from the corpus, faults a bang):")
    for page in sorted(pages, key=pages.get, reverse=True)[:20]:
        print(f"    {page:>14}  {pages[page] / bangs:6.2f}  {kinds[page]}")
show("GPU faults", "CUDA_UM_GPU_PAGE_FAULT_EVENTS", "numberOfPageFaults")
show("CPU faults", "CUDA_UM_CPU_PAGE_FAULT_EVENTS", "1")
PY

Then profile both ways and print the page lists:

for mode in 0 1; do
  BEND_NEAR=$mode nsys profile --trace=cuda --cuda-um-gpu-page-faults=true --cuda-um-cpu-page-faults=true --force-overwrite=true -o near$mode ./near_test +RTS --gpu on > /dev/null 2> near$mode.log
  nsys export --type=sqlite --force-overwrite=true --output=near$mode.sqlite near$mode.nsys-rep
  echo "=== BEND_NEAR=$mode"
  python3 pages.py near$mode.sqlite near$mode.log
done

Look for how many heap pages are left, and whether a page's faults per bang stay near the same value. A busy heap page means the same block keeps being reused, which a log in the allocator could catch. The ring slot page moves on every bang, so spread-out ring pages are expected with prefetch off.

5. Post back

The output of step 3's grep, the output of step 4, and your GPU, driver and CUDA version.

What was checked

  • Against a fake CUDA library, no GPU: the emitted host C ran 300 bangs on both sides of CUDA_VERSION >= 13000. Each bang made 5 prefetches, all page-aligned, inside the allocation and before the launch. BEND_NEAR=0 made none, a failing call was reported, and stdout was the same either way.
  • pages.py ran on a hand-made database with the columns in NVIDIA's export docs. A newer nsys may name things differently, and if a table is missing the script says so.
  • Not run at all: the kernels, the nsys commands, and the real cuda.h. The stub headers were written from docs, so CUDA 13 could still need a fix. The timing fields read near 0 against the fake, so only real runs can show those values are sensible.
  • Metal and CPU: none of this compiles in there. Test 1819/1819, perf 124/124 on origin/main 017f83fd.
  • The repo gate fails: comp.ts is 66,986 ttok, 986 over its 66k cap, so the CI check will be red. It was exactly at the cap before this. The second commit is only the measuring code and goes away before any merge, but the first (about 192 ttok) would still need to find room.

🤖 Generated with Claude Code

nicolas-abril and others added 2 commits November 2, 2026 19:03
An experiment for #1992, not for merge: comp.ts is 192 ttok over its cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
To remove before any merge. A line at exit gives the medians over the second
half of the bangs, the corpus address and the region offsets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nicolas-abril

Copy link
Copy Markdown
Collaborator Author

@nuxyel this is the experiment for #1992. Could you follow the steps under "How to measure" and post the results here? Step 3 decides whether prefetching helps, and it doesn't need nsys. If a CUDA call doesn't compile or the commands don't work on your setup, tell me what you see, since neither has been run on a real GPU.

@nuxyel

nuxyel commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Thanks for putting this together. I ran it with the program from #1992 (SmolLM2 135M, 8 tokens, plus 360M, 6 tokens).

Setup: RTX 4050 Laptop 6 GB, driver 615.78.08, Core Ultra 7 155H, Linux. PR head 693eb30 and its base 017f83f as a control.

Build: it builds and runs as written against both CUDA 13.4 and 12.9 (the binaries link libnvrtc.so.13 and libnvrtc.so.12), so both sides of the CUDA_VERSION >= 13000 branch compile against the real cuda.h. Every run printed the same tokens, logits and text with BEND_NEAR=0, with BEND_NEAR=1 and on the base build, on both CUDA versions. failed=0 everywhere. prefetches_per_bang is exactly 5.0: every bang in this program is one batch.

Step 3, your loop (BEND_NEAR=0 and 1, +RTS --gpu on, no pinning), CUDA 13.4, on an idle machine: five rounds, the three builds in a shuffled order in each. Median of the five runs, range in parentheses; the first three columns are the bend: near line's own:

cycle_us bang_us host_us wall
135M, BEND_NEAR=0 1,417 (1,402-1,429) 1,056 (1,052-1,066) 361 (350-364) 2.69 s (2.65-2.83)
135M, BEND_NEAR=1 1,176 (1,174-1,179) 761 (759-766) 414 (412-417) 2.35 s (2.34-2.36)
135M, base 017f83f 2.67 s (2.66-2.69)
360M, BEND_NEAR=0 1,824 (1,616-1,840) 1,406 (1,202-1,423) 417 (414-418) 3.95 s (3.88-4.32)
360M, BEND_NEAR=1 1,437 (1,421-1,441) 1,002 (994-1,010) 431 (424-438) 3.73 s (3.61-3.74)
360M, base 017f83f 3.93 s (3.87-3.97)

Every run printed the same tokens as above, failed=0, prefetch_us 5.5-6.1.

  • BEND_NEAR=1 is lower than BEND_NEAR=0 by 241 µs a bang on 135M (17%) and 387 µs on 360M (21%). The spread is 5-30 µs, except one 360M near=off run at 1,616, and even that one is 175 µs above the slowest near=on run. So by your rule the prefetch helps on both models.
  • The gain is in bang_us (295 and 404 µs less). host_us goes up by 53 µs on 135M and 14 µs on 360M; step 4 below has 6-9 more CPU faults a bang with the prefetch.
  • The base build doesn't print the line; its wall time matches near=off's (2.67 against 2.69 s, 3.93 against 3.95 s), so with the prefetch off the PR costs nothing I can measure.
  • Wall time includes loading the weights (0.46 s and 1.48 s) and the prompt. Per generated token, in the last round: 145 ms against 177 ms on 135M, 184 ms against 226 ms on 360M.
  • near=off comes out at 1,417 µs on 135M, not CUDA: about 0.6 ms of every ! goes to page faults on the pages the host wrote since the last bang #1992's 1,614: those numbers came from nsys traces, and these runs have no tracer.

The bend: near lines, grouped by setting, each in run order (up to failed; the base build prints none):

# 135M, BEND_NEAR=0
near=off bangs=1328 cycle_us=1411 bang_us=1056 host_us=355 prefetches_per_bang=0.0 prefetch_us=0.0 failed=0
near=off bangs=1328 cycle_us=1418 bang_us=1054 host_us=364 prefetches_per_bang=0.0 prefetch_us=0.0 failed=0
near=off bangs=1328 cycle_us=1402 bang_us=1052 host_us=350 prefetches_per_bang=0.0 prefetch_us=0.0 failed=0
near=off bangs=1328 cycle_us=1429 bang_us=1066 host_us=363 prefetches_per_bang=0.0 prefetch_us=0.0 failed=0
near=off bangs=1328 cycle_us=1417 bang_us=1056 host_us=361 prefetches_per_bang=0.0 prefetch_us=0.0 failed=0
# 135M, BEND_NEAR=1
near=on bangs=1328 cycle_us=1176 bang_us=759 host_us=417 prefetches_per_bang=5.0 prefetch_us=5.7 failed=0
near=on bangs=1328 cycle_us=1174 bang_us=759 host_us=415 prefetches_per_bang=5.0 prefetch_us=5.6 failed=0
near=on bangs=1328 cycle_us=1178 bang_us=766 host_us=412 prefetches_per_bang=5.0 prefetch_us=5.7 failed=0
near=on bangs=1328 cycle_us=1179 bang_us=766 host_us=413 prefetches_per_bang=5.0 prefetch_us=5.5 failed=0
near=on bangs=1328 cycle_us=1175 bang_us=761 host_us=414 prefetches_per_bang=5.0 prefetch_us=5.8 failed=0
# 360M, BEND_NEAR=0
near=off bangs=1158 cycle_us=1810 bang_us=1395 host_us=415 prefetches_per_bang=0.0 prefetch_us=0.0 failed=0
near=off bangs=1158 cycle_us=1824 bang_us=1406 host_us=418 prefetches_per_bang=0.0 prefetch_us=0.0 failed=0
near=off bangs=1158 cycle_us=1840 bang_us=1423 host_us=417 prefetches_per_bang=0.0 prefetch_us=0.0 failed=0
near=off bangs=1158 cycle_us=1616 bang_us=1202 host_us=414 prefetches_per_bang=0.0 prefetch_us=0.0 failed=0
near=off bangs=1158 cycle_us=1836 bang_us=1418 host_us=418 prefetches_per_bang=0.0 prefetch_us=0.0 failed=0
# 360M, BEND_NEAR=1
near=on bangs=1158 cycle_us=1421 bang_us=997 host_us=424 prefetches_per_bang=5.0 prefetch_us=5.9 failed=0
near=on bangs=1158 cycle_us=1424 bang_us=994 host_us=430 prefetches_per_bang=5.0 prefetch_us=6.1 failed=0
near=on bangs=1158 cycle_us=1440 bang_us=1002 host_us=438 prefetches_per_bang=5.0 prefetch_us=6.0 failed=0
near=on bangs=1158 cycle_us=1437 bang_us=1005 host_us=432 prefetches_per_bang=5.0 prefetch_us=5.9 failed=0
near=on bangs=1158 cycle_us=1441 bang_us=1010 host_us=431 prefetches_per_bang=5.0 prefetch_us=6.0 failed=0

Step 4, page lists, 135M, CUDA 13.4 (two runs each way, second half of the run, faults a bang):

GPU faults ring header heap CPU faults
BEND_NEAR=0 211.4 / 218.3 168.9 / 174.7 2.6 / 2.3 39.9 / 41.3 27.6 / 27.6
BEND_NEAR=1 83.1 / 88.2 0 0 83.1 / 88.2 33.7 / 36.9

The ring and header faults are gone, as intended; one ring page (offset 142737408) alone took 156-161 faults a bang before. But the heap's GPU faults about double, and a third to a half of them land on one page, offset 1139347456, which wasn't among the busiest GPU pages without the prefetch (it faulted about 0.5 a bang on the CPU side). That page sits 4 KB above 1139343360, one of the heap pages that fault either way. CPU faults go up by 6-9 a bang. pages.py ran as written on nsys 2026.3.2, with no missing table. Its full output for the first pair:

=== BEND_NEAR=0

GPU faults: 211.4 faults a bang on 1923 distinct pages (second half of the run)
  header+allocators: 2 pages, 2.6 faults a bang
  heap: 1220 pages, 39.9 faults a bang
  ring: 701 pages, 168.9 faults a bang
  busiest pages (offset from the corpus, faults a bang):
         142737408  155.70  ring
         142606336   11.28  ring
        1137876992    9.78  heap
        1137881088    5.18  heap
        1139343360    4.94  heap
        1137885184    4.80  heap
                 0    2.63  header+allocators
        6098161664    1.61  heap
        5932134400    0.83  heap
         412450816    0.80  heap
        6129295360    0.48  heap
        5932126208    0.39  heap
         421998592    0.37  heap
        1139351552    0.31  heap
         422006784    0.29  heap
        1138147328    0.22  heap
        1139490816    0.22  heap
        1138151424    0.22  heap
        1138155520    0.22  heap
        1138159616    0.22  heap

CPU faults: 27.6 faults a bang on 2194 distinct pages (second half of the run)
  header+allocators: 2 pages, 1.9 faults a bang
  heap: 1487 pages, 19.9 faults a bang
  ring: 701 pages, 5.7 faults a bang
  stack+static: 4 pages, 0.0 faults a bang
  busiest pages (offset from the corpus, faults a bang):
                 0    1.91  header+allocators
         142737408    1.91  ring
         142606336    1.91  ring
        1137881088    1.67  heap
        1137876992    1.43  heap
        6098161664    1.42  heap
        1137885184    0.71  heap
        1139343360    0.49  heap
        6129295360    0.47  heap
        1139347456    0.47  heap
         412450816    0.41  heap
         421998592    0.38  heap
        1139351552    0.36  heap
         422006784    0.31  heap
         412471296    0.25  heap
        1139482624    0.24  heap
        1139494912    0.24  heap
        1139490816    0.24  heap
        1139486720    0.24  heap
        1138159616    0.24  heap
=== BEND_NEAR=1

GPU faults: 83.1 faults a bang on 800 distinct pages (second half of the run)
  heap: 799 pages, 83.1 faults a bang
  ring: 1 pages, 0.0 faults a bang
  busiest pages (offset from the corpus, faults a bang):
        1139347456   31.28  heap
        1137876992   10.52  heap
        6098161664    9.92  heap
        1137881088    7.91  heap
        1137885184    6.70  heap
        1139343360    5.22  heap
        5932134400    0.89  heap
        6129295360    0.49  heap
        5932126208    0.44  heap
         421998592    0.38  heap
        5932130304    0.34  heap
         412450816    0.31  heap
         422006784    0.31  heap
        1139351552    0.29  heap
        1138147328    0.23  heap
         412471296    0.21  heap
         412475392    0.20  heap
         412483584    0.20  heap
         412536832    0.19  heap
         412438528    0.19  heap

CPU faults: 33.7 faults a bang on 2195 distinct pages (second half of the run)
  header+allocators: 2 pages, 2.0 faults a bang
  heap: 1488 pages, 25.7 faults a bang
  ring: 701 pages, 5.9 faults a bang
  stack+static: 4 pages, 0.0 faults a bang
  busiest pages (offset from the corpus, faults a bang):
         142737408    1.98  ring
         142606336    1.98  ring
                 0    1.98  header+allocators
        1137881088    1.73  heap
        1137876992    1.49  heap
        6098161664    1.48  heap
        1137885184    0.74  heap
        1139343360    0.56  heap
        6129295360    0.49  heap
        1139347456    0.49  heap
         412450816    0.47  heap
         412471296    0.45  heap
         421998592    0.44  heap
         412463104    0.44  heap
         412557312    0.42  heap
         412475392    0.42  heap
         412512256    0.42  heap
         412536832    0.42  heap
         412438528    0.41  heap
         412499968    0.41  heap

For what it's worth, on #1854 I measured prefetching only the header page before the launches at +56 µs a bang over the build without the entry check, so the header page alone doesn't pay for itself; the gain here comes with the ring's pages, whose faults are gone in step 4.

The experiment for #1992 again. BEND_NEAR now picks the prefetches by letter:
h the header, r ring row 0's get and put, s the ring slot, t the task's tail
block, and w widens each one to its aligned 64 KB, clamped to the corpus.
Unset or 1 still means all four at 4 KB, and 0 none. The exit line also
reports the last tail block's offset and how often its page moved, so the
fault lists can tell which heap page is the tail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nicolas-abril

nicolas-abril commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator Author

@nuxyel thank you, this is very clear. 17-21% with identical outputs settles that prefetching helps. Before turning this into a PR to merge, I'd like to know which of the prefetches pay for themselves. Your #1854 number suggests the header one may not, and a 4 KB prefetch seems to make the heap page next to it (1139347456) much worse. Can you run one more round?

I pushed e33c525 to this branch. BEND_NEAR now picks the prefetches by letter:

Letter Prefetch
h the header, once per batch
r ring row 0's get and put words
s the ring slot
t the task's tail block
w widens each chosen prefetch to its aligned 64 KB instead of 4 KB

0 means no prefetch. The exit line also has tail_off, the offset of the last bang's tail block, so we can see which heap page is the tail.

All on 135M, CUDA 13.4:

1. Get the new commit and build

git fetch origin pull/2014/head:pr-2014-b
git checkout pr-2014-b
bun bend2/main.ts /path/to/your_program.bend -o near_test

2. Time each setting, 5 rounds

rm -f near.txt
for i in 1 2 3 4 5; do
  for m in 0 hrst h r s t rst hrs hrstw; do
    BEND_NEAR=$m ./near_test +RTS --gpu on > /dev/null 2>> near.txt
  done
done
grep -h 'bend: near' near.txt | sort -s -k2,2

3. Page faults for each setting

This uses the same pages.py as last time (it's in the PR description).

for m in 0 hrst h r s t rst hrs hrstw; do
  BEND_NEAR=$m nsys profile --trace=cuda --cuda-um-gpu-page-faults=true --cuda-um-cpu-page-faults=true --force-overwrite=true -o near_$m ./near_test +RTS --gpu on > /dev/null 2> near_$m.log
  nsys export --type=sqlite --force-overwrite=true --output=near_$m.sqlite near_$m.nsys-rep
  echo "=== BEND_NEAR=$m"
  grep 'bend: near' near_$m.log
  python3 pages.py near_$m.sqlite near_$m.log
done

4. Post back the output of step 2's grep and the whole output of step 3.

What I checked here: both sides of CUDA_VERSION >= 13000 type-check against stub headers. A harness with a fake cuMemPrefetchAsync tested the letter parsing, the 64 KB alignment and the clamping at both ends of the corpus. A bang test still gives the same output on Metal. As before, none of it has run on a real GPU.

@nicolas-abril
nicolas-abril marked this pull request as ready for review November 5, 2026 13:11
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.

2 participants