Skip to content

cuda: stage synchronous downloads through a thread's pinned buffer - #2103

Merged
ashhart merged 5 commits into
ashhart:mainfrom
shuuul:fix/gb10-pageable-download
Nov 5, 2026
Merged

ashhart merged 5 commits into
ashhart:mainfrom
shuuul:fix/gb10-pageable-download

Conversation

@shuuul

@shuuul shuuul commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Relates to #2102.

What

DeviceBuffer.download now stages through a per-thread pinned bounce only on an integrated device; discrete cards keep their direct DtoH copy and their speed. The engine's CU_DEVICE_ATTRIBUTE_INTEGRATED reading gates it (cached per thread and driver). A new cuda/download_stage.zig owns the bounce, which counts in memory.usage and is released with the thread's buffer. downloadAsync is unchanged and still requires pinned memory, as its doc says.

The smoke test downloads 6 MiB through two pieces. A fake-driver test covers both paths: integrated stages into pinned memory and the pageable caller gets the bytes; discrete calls {{SECRET_kr7wjfjl}} with the caller's slice and allocates nothing.

Why

On GB10 (driver 580.178.04, ATS) a synchronous DtoH copy straight into pageable host memory can return success while some pages keep their old contents and the bytes land elsewhere. The downstream zspark fork hit it 10 times in 60 rank-round runs, corrupting an allocator's metadata; pinning the compare buffers gave 0/60, staging gave 0/100. Standalone loops and 200 unmodified tf-cuda-test smoke runs here never miss, so the issue has no small public reproducer; #2102 records what we have.

This is the defensive fix, not a claim that TensorFold's pointers are wrong.

Cost (idle GB10, 2,000 copies for the small sizes, direct against staged)

bytes direct staged
4 5.90 us 6.51 us
2,048 4.71 us 5.08 us
32,768 5.33 us 6.91 us
303,104 10.59 us 20.97 us
4 MiB 84.74 us 212.36 us
64 MiB 1,354.59 us 4,095.36 us

These are the integrated-device numbers; a discrete card takes the direct column unchanged. The 303,104-byte row is a logits row; the large rows are checks/test paths, not decoding.

Verified

  • zig build test-runtime-host: 282/282 tests pass, including both fake-driver paths.
  • zig build -Dnvcc=/usr/local/cuda/bin/nvcc compile-cuda-tests, then tf-cuda-test smoke on a GB10: all pass, including the 6 MiB case.
  • python3 tools/zig/lean_check.py: 425 problems, the same as main; the new comments are one line each.
  • 200 consecutive smoke runs pass.

Review resolution

For the A/B on a GB10: replies should be byte-equal, decode unchanged, and the logits-row copy is the 303,104-byte row above.

@ashhart

ashhart commented Nov 4, 2026

Copy link
Copy Markdown
Owner

Thanks @shuuul, and sorry for the wait. A careful write-up of a fault that's hard to pin. Your three questions: stage only where the device is integrated: the engine already reads CU_DEVICE_ATTRIBUTE_INTEGRATED, so no new probe is needed, and discrete cards keep their direct copies and their speed. Count the bounce in memory.usage (or take it from copy_source's pinned spares), since it's pinned memory the budget doesn't see and it stays for the thread's life. And there's no conflict with #2043: #2113 keeps states on the device and never downloads. One small thing besides: on our main the new comments in download_stage.zig and memory.zig run over several lines, so lean reads 432; one line each, please. With those, we'll A/B it on a GB10: replies byte-equal, decode level, and the logits-row copy time.

@shuuul
shuuul force-pushed the fix/gb10-pageable-download branch from 5ec11e9 to b76aa63 Compare November 5, 2026 04:13
@shuuul
shuuul marked this pull request as ready for review November 5, 2026 04:14
@shuuul

shuuul commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

All four are in: staging is gated on CU_DEVICE_ATTRIBUTE_INTEGRATED (cached per thread/driver), the bounce counts in memory.usage and is released by drop, the comments are one line each (lean 425), and the fake-driver tests now cover both the integrated and discrete paths. zig build test-runtime-host is 282/282; tf-cuda-test smoke passes on a GB10 including the 6 MiB case. Marked ready for review.

@ashhart

ashhart commented Nov 5, 2026

Copy link
Copy Markdown
Owner

Thanks @shuuul, all four asks read as done. A note so this doesn't confuse anyone: we'd opened #2200 for the same issue without noticing this PR. I've closed it, because yours is the better fix and came first. This one's in review now; I'll come back to you with the result.

@ashhart

ashhart commented Nov 5, 2026

Copy link
Copy Markdown
Owner

@shuuul, one thing CI found on b76aa6373: zig and zig-windows crash in zig build test, outside test-runtime-host. download now calls stage.integrated, and cuCtxGetDevice at download_stage.zig:20 faults in host tests whose fake driver tables never set it. For example:

  • tests/qwen4/cuda_draw_launch_test
  • projection_f32_host_tests
  • w4a8_exception_gate_host
  • families/qwen3_5/cuda_pocket_test
  • quant_ref_host_tests

Those fakes need cuCtxGetDevice and cuDeviceGetAttribute set, and they should answer discrete so each test keeps its direct copy. Please run the whole zig build test before pushing. The rest of the review will follow on the new head.

@shuuul

shuuul commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. I'll set cuCtxGetDevice and cuDeviceGetAttribute in the fake driver tables named there, answering discrete so each test keeps its direct copy, and run the whole zig build test before pushing the new head. I'll address the rest of the review on that head.

@shuuul

shuuul commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 0d5cd20. Every fake driver table that reaches download now sets cuCtxGetDevice and cuDeviceGetAttribute and answers device 0 / attribute 0, so the probe takes the discrete path and the direct copy: the five you named, plus the DeepSeek load mock, the H3 runtime fake, Flash Next's pocket test, contract_rows_host, contract_verify_host and contract_q35_attn_host (the last one also crashed in zig build test here). zig build test exits 0 on this head. The rest of the review can follow on it.

@ashhart

ashhart commented Nov 5, 2026

Copy link
Copy Markdown
Owner

Thanks @shuuul. That's every fake table, and answering discrete keeps each test on its old direct copy. One small thing: integrated() caches its reading per thread by the driver's address, and the integrated test ends with drop() but not forget(). A later test whose fake driver lands at the same stack address would reuse "integrated" and call cuMemHostAlloc on a table that doesn't set it. Could you add defer stage.forget(); to both download tests? The GB10 run is queued on our side.

@shuuul

shuuul commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

Done in 2d87813: both download tests now defer stage.forget() after defer stage.drop(), so the cached reading does not outlive the fake driver's stack address. zig build test-runtime-host passes.

@ashhart

ashhart commented Nov 5, 2026

Copy link
Copy Markdown
Owner

Thanks @shuuul, that's the one line. It's queued for after the 1.2.3 cut, with our GB10 run.

@ashhart

ashhart commented Nov 5, 2026

Copy link
Copy Markdown
Owner

Our GB10 run, main against this PR, Qwen3.8-27B: verdict GREEN on the head before your one-line forget() fix. 32K prompts and decode after them are within 0.1%, the 1- and 4-stream sweeps within 0.3%, and there are no token differences across runs in the 20 prompt rows I checked. A level result is what we hoped for. Thanks @shuuul: it's queued for after the 1.2.3 cut.

@ashhart
ashhart force-pushed the fix/gb10-pageable-download branch from 2d87813 to 73008e5 Compare November 5, 2026 17:42
On GB10 (driver 580.178.04, ATS) a synchronous cuMemcpyDtoH_v2 straight into
pageable host memory can return success while some pages keep their old
contents and the bytes land elsewhere; zspark's rank-round test met it 10
times in 60 runs, corrupting its allocator's metadata. Pin a bounce buffer
per thread (at most 4 MiB a piece) and copy on from it with the CPU.

The cost on an idle GB10 (2,000 copies, direct against staged): 4 B 5.90 vs
6.51 us, 32 KiB 5.33 vs 6.91, a 303,104 B logits row 10.59 vs 20.97, 4 MiB
84.74 vs 212.36, 64 MiB 1,354.59 vs 4,095.36.

The smoke test now downloads 6 MiB through two pieces.
The fake writes only pinned destinations; the test asserts the copy's
destination is the thread's pinned buffer and the pageable caller gets the
bytes, so a regression to the direct copy fails without needing the fault.
Discrete cards keep their direct DtoH copies and their speed. The bounce's
pinned bytes now count in memory.usage and are released with it. Comments
are one line each; tests cover both the integrated and discrete paths.
download now asks cuCtxGetDevice and cuDeviceGetAttribute whether the
device is integrated. The fake driver tables that only set the entries
their test used left those two undefined, so the probe jumped into
garbage. Each now answers device 0 and attribute 0, which keeps the
direct copy path every host test expects.

zig build test passes.
The integrated test cached its reading by the fake driver's address;
a later test whose fake lands there would reuse it and call
cuMemHostAlloc on a table that does not set it. Both tests clear it.
@ashhart
ashhart force-pushed the fix/gb10-pageable-download branch from 73008e5 to 5e0abe0 Compare November 5, 2026 18:22
@ashhart
ashhart merged commit 1d89100 into ashhart:main Nov 5, 2026
0 of 7 checks passed
@ashhart

ashhart commented Nov 5, 2026

Copy link
Copy Markdown
Owner

Thank you @shuuul. This is merged into main with your commits under your own name, so you're listed as a TensorFold contributor. It ships in the next release, credited to you in the changelog and the release notes.

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