Repository navigation
radio CUDA: reuse one pinned pixel stage for every image - #1140
Closed
plotarmordev wants to merge 1 commit into
Closed
plotarmordev wants to merge 1 commit into
plotarmordev wants to merge 1 commit into
Conversation
The 1.1.0 temporary-upload path rounds full-size image pixels to a 64 MiB pinned stage. When smaller spares occupy the pool, the completed stage cannot be parked, so the next image frees it with cuMemFreeHost and allocates another while the GPU waits. Reserve one stage with the tower scratch and wait on each copy event before refilling it. This removes per-image pinned allocation and disposal without changing upload bytes or device arithmetic. Exercise reuse and in-flight byte preservation with alternating image payloads.
7 of 8 tasks
Owner
|
Landed on main in 93a215b..35e46c0, with your records under your authorship plus the maintainer fix-ups. Thank you. Measured with the whole group (#1139, #1140, #1143, #1147, #1164, #1169, #1171 and #1170's Flash Next slice) against main on GB10s:
- Synthema, Ash's AI Assistant. |
This was referenced Oct 25, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Branch
r3/vision-pinned-stage: one commit,25b565cc8cb55e5d8764f510054e2f8ce1e04082, on maind1018b31.The GPU numbers were measured with #1057 (the image tower runs once) under both builds; see Receipt.
What this changes
On main, each full-size image the tower encodes allocates a 64 MiB pinned buffer and frees the previous one, and
the frees are long. e9bf05f and cc50aef (both in 1.1.0, neither in 1.0.12) moved
DeviceBuffer.uploadAsyncontocopy_source's temporary stages, andTower.featuresuploads each image's f32 pixels that way. For a 2048 x 1664image that is 13,312 patches × 768 values × 4 = 40,894,464 B, which
asyncChunkrounds up to a 67,108,864 B (64 MiB)stage, the whole 64 MiB spare budget; when the stage completes,
keepSparerefuses it whenever any other spare isheld, and
releaseTrackedfrees it (host/source-history.txthas the ancestry and the source lines). Smallerimages round to smaller stages; only this size was measured. In the trace, the base build makes 20
cuMemHostAlloccalls for the 20 images (629.4 ms) and 29cuMemFreeHostcalls totalling 13,089.5 ms, the longest872.9 ms, about one queued tower; that the free waits for the queued tower is our reading of the durations, not
something the trace shows directly.
This commit gives the tower its own
copy_source.Stage, reserved next to its scratch at init for the largest image(
max_patches * patchWidth() * 4bytes, thefeaturesroom limit; larger images and video are refused beforetake, so the stage never grows). Each imagetakes the stage, which waits on the previous copy's event, copiesits pixels in, and
copyTos the same scratch offset as before; the event is recorded before the cast and the towerkernels. Upload bytes, destinations, kernels and arithmetic are unchanged.
deinitreleases the stage, and theexisting quarantine covers a copy that failed to complete.
What it does not do: the engine thread still waits on the previous copy's event. From stream order
tower(k-1), copy(k), event(k), tower(k)we expect image 2 to wait about one copy and images 3 and 4 about one tower, so the hoststays about two images ahead rather than queuing a whole request (not timed per image). The stage costs 39 MiB of
pinned memory for the life of the process, charged to the copy-source budget, so a load that does not fit is refused
at start. Upload paths other than the tower's keep the temporary stages.
docs/runtime/cuda-copy-sources.mdgets one paragraph saying the tower owns its stage.Test:
zig/src/families/radio/upload_test.zig, "RADIO upload reuses its pinned pixels without freeing betweenimages", drives the real
Tower.featuresthrough a fake driver and stops before device arithmetic (a fakeNotReadylaunch). Four full-size images alternate between two payloads (0.5 and −0.5). It requires one pinned allocation for
all four, no free between images, an event wait before each reuse, the bytes in flight equal to the image that copy
read, and the release after the last event. With the old
uploadAsyncline in place it fails (expected 1, found 2). A variant that writes the next image into the stage beforetakeaborts at the in-flight byte check.Bug report
d1018b31(1.1.1), CUDA backend, with nemotron cuda: a prompt pass with images runs the RADIO tower once, not twice #1057. The upload path dates from 1.1.0(
host/source-history.txt); 1.1.0 itself was not run for this report.nvidia/NVIDIA-Nemotron-3.5-Super-VL-120B-A12B-NVFP4at revision82d93699acd3be9e4617c53360fe5b3f5dbeb1b3.tensorfold-native serve MODEL --vision --vision-image-tokens 13312 --vision-max-images 4 --context 18432 --parallel 1 --prompt-cache-gib 0.bench.py image-ttft(receipts'scripts/): one generated 2048 x 1664 PNG, then four copies of it(3,356 and 13,366-token prompts), a warmup and 3 measured requests each (20 images), greedy, thinking off,
"draft": false, 32 tokens, cooled to 75 °C before every request.Both servers under
nsys profile --trace=cuda --sample=none --cpuctxsw=none, base = main + #1057, this branch =base + this commit (
scripts/host_api.py,scripts/timeline.pyon the SQLite exports):cuMemHostAllocfrom the first image oncuMemFreeHostfrom the first image oncuMemHostAllocwhile loadingtoken_sha256, 4 requests541bca2d…9735ec541bca2d…9735ectoken_sha256, 4 requestsef22b44e…d444c4ef22b44e…d444c4Full hashes:
541bca2d52751d8bb9c44b2af53ce8ff2dfb93745d8cb6bdb740442dad9735ec,ef22b44ea1d731737c2d2a757d671d03b1269563d6bb505944e58608ecd444c4(the same as #1057's receipts).How to reproduce
Receipt
Raw files: https://github.com/plotarmordev/TensorFold/tree/nemotron-receipts/receipts/nemotron/vision-pinned-stage (
README.mdmaps every file;SHA256SUMS).Environment, with the TensorFold commit, MLX or PyTorch version, machine and GPU, checkpoint and revision: this
branch at
25b565ccon maind1018b31. Without nemotron cuda: a prompt pass with images runs the RADIO tower once, not twice #1057 every image runs through the tower twice, so both measuredbuilds carry it: base =
d1018b31+ nemotron cuda: a prompt pass with images runs the RADIO tower once, not twice #1057 (732b0e31), this branch = base + this commit (5a4d65a1). A thirdbuild adds the RADIO fused-glue pull request on top (
f6d8ba5a, "both" below).env/commits.txtshows eachmeasured commit's parent, tree and
git patch-id --stablenext to the submitted ones:5a4d65a1andf6d8ba5acarry
25b565cc's patch-id, every nemotron cuda: a prompt pass with images runs the RADIO tower once, not twice #1057 copy carriese2357b58's. Each built from a detached checkout of thatcommit (
env/PROVENANCE-*.txt):zig build fatbinsandzig build installwith-Dtarget=aarch64-linux-gnu.2.28 -Dcpu=baseline -Doptimize=fast -Dstrip=true -Dsm=121 -Dversion=1.1.1 -Dnvcc=…(zig 0.17.0, nvcc 13.0.88).tensorfold-nativesha256: base5e70f78f…dcef69, thisbranch
7df4fbe7…d56ca8, both34fba744…a29cda(full inenv/and each hold's.sha256). One DGX Spark (GB10),driver 580.173.02, CUDA 13.0,
nvidia/NVIDIA-Nemotron-3.5-Super-VL-120B-A12B-NVFP4@82d93699acd3be9e4617c53360fe5b3f5dbeb1b3,served as in the bug report on loopback with
TF_HEAT_HIGH=85 TF_HEAT_LOW=75, a fresh server andHOMEeach hold,each hold started at a zone of 60 °C or less.
Exactness, from
tools/bench_concurrent.py --alone --serialortoken_shaagainst"draft": false: everyimage-ttftrequest (2048 x 1664 images, one and four) is"draft": false, and every request on every build (base,this branch, both; profiled and unprofiled) gives
541bca2d…for one image andef22b44e…for four.bench.py decodeon "both" runs 11 prompts drafted (count, prose, code, image, multilingual and six languages) plus count with"draft": false; every drafted reply equals"draft": false, and the hashes equal main's. Itsimagepromptcarries one 320 x 208 picture, so that is the one drafted image request that ran, through this stage. The
full-size
image-ttftworkload was not run with drafts;bench_concurrent.py, resumed and concurrent imagerequests were not run.
Decode speed before and after, from
tools/bench_openai.py: not measured. Decode rounds and text prompts do notreach the tower's upload.
Prompt speed before and after:
tools/bench_cache.pysends no images, so TTFT is the server'stime_to_first_tokenfrombench.py image-ttft. This commit alone was timed only under nsys, one hold each (base,then this branch; table above). Unprofiled, it was timed only together with the fused-glue pull request, in the
order base, both, base, both:
The ranges do not overlap (one image 2.286–2.295 s against 2.168–2.183 s). How the unprofiled gain splits between
the two pull requests was not measured. Every four-image request on every build, warmups included, paused once at
the heat gate inside its prompt pass (
heat_wait_s 8.0in each server log); no one-image request paused. The TTFTsabove include that pause. The four-image saving is larger than the tower idle this removes (about 0.15 s a
request); the rest is consistent with the engine thread no longer sitting in
cuMemFreeHostwhile work is queued,so more work is queued when the heat gate pauses, but the trace does not split the two.
Tests run, with pass, fail and skip counts (CPU only, a DGX Spark,
-j1,nice -n 19, fresh source copies), at this tip:zig build test-bolt-load-host -Dbolt-load-filter='RADIO upload' test-cuda-copy-source10/10 steps, 49 of 49;zig build test211/211 steps, 2960 of 2974 passed, 14 skipped, 0 failed;zig build test-kernel-contract test-golden fuzz-cortheon19/19 steps, 76 of 76 tests (kernel contract 46, 9, 3 and 7;fuzz-cortheon11 of 11),test-golden26/26 and 306/319 equal to the frozen answers, 0 unexpected differences;zig fmt --checkon the changed files clean;tools/zig/lean_check.py444 problems, 444 on main, 0 new. Failingreceipts: the new test with the old upload line (
expected 1, found 2), and the early-overwrite variant (abort atthe in-flight byte check).
Not run, and why:
featuresand stage).tf-cuda-test kernel-contract: no kernel changed.cuEventSynchronizedurations were not broken out.Checklist
"draft": false, a resumed prompt equals a fresh one, and concurrent equals solo. (Measured: full-size image replies byte-equal before and after with"draft": false, and drafted equal to"draft": falseon 11 decode prompts, one with a 320 x 208 picture, with this commit in the build. Resumed and concurrent requests with images were not run.)SUMMARYline is in the receipt. (No kernel changed.)