Repository navigation
vulkan: staged device-local uploads for cards without Resizable BAR - #1871
Conversation
Without Resizable BAR a discrete card exposes HOST_VISIBLE|DEVICE_LOCAL as a window of about 256 MB: NVIDIA refuses the backend's resident weights past it (0 matrices resident, the tier failing every upload), RADV puts them in system RAM. Staged uploads put resident data in a DEVICE_LOCAL type the host does not map and copy it there from a host staging buffer with vkCmdCopyBuffer. The rule: staged when the host-visible device-local heap is under a quarter of the largest device-local heap (or there is none); COLI_VK_STAGED=1 forces it, 0 forbids it; COLI_VK_HOST_VISIBLE_CAP_MB=N treats the host-visible heap as at most N MiB, so a device with Resizable BAR or unified memory takes the decision a card without it takes. A device that stages nothing keeps the mapped path, its queues and its bits. What moves: the weight pools (dense tensors, the expert tier, COLI_VK_DEV2's), streamed through two 16 MiB staging slots; the tier's tensors are filled in a host image and committed (coli_vk_tensor_commit) by the uploader thread; the MLA KV mirror and the q-prep norm weights go through pending copies recorded at the head of the absorb and q-prep submits; the chain's host-written buffers (frame staging, per-step rows) move to host staging memory instead of the window. A fresh device-local block is zero-filled before its first tensor (#1338's Polaris finding). The uploader takes a transfer-only queue, else a spare queue, else the main queue under a lock that every backend and chain submit takes. The approach follows #1338 by crichalchemist.
tests/vulkan_engines.sh staged: the VK_TEST harness's results bit for bit the same mapped, staged twice and under an emulated 246 MB window that must choose staging on its own (a digest of every result the device returns), then the tier and the chain's ops staged, each run ending with no resident data in host memory. <family>-staged runs a family with COLI_VK_STAGED=1; qwen-staged first runs qwen36's tier and chain under the emulated window with the decision left to the backend. CI: the staged family and the shader family staged in the Vulkan job, qwen-staged and qwen-chain-staged in the engines matrix.
With staged uploads the backend reports at shutdown where the resident data ended up; tests/vulkan_engines.sh staged checks that line after the tier's test as after the chain's.
What a discrete card without Resizable BAR did to the mapped path, the rule that turns staged uploads on, what moves where, how the uploader is synchronized, the zero fill of a fresh block, the lines it prints, what was tested on Lavapipe, the Iris Xe and the 780M, and mapped against staged on the 780M (the same: decode 7.94 against 7.96 tok/s with the tier, 9.91 against 9.96 with the chain). A discrete card without Resizable BAR was not measured. ENVIRONMENT.md: COLI_VK_STAGED, COLI_VK_HOST_VISIBLE_CAP_MB, COLI_VK_TEST_NOFILL.
The section checks each format's numbers, and its twelve tiny steps could end before the uploader landed any expert: with staged uploads a commit is a submit and a fence wait (8.5 ms for the first, 0.6 ms after, on an Iris Xe through Dozen), and 2 of 4 staged runs there failed one of the first formats on 'nothing ran on the device'. COLI_VK_TIER_SYNC=1 for that section only; warm, adapt and partial keep the uploader running free.
A command buffer that would not record or a submit refused failed the uploader for good and took the device down with it. Now it fails that upload only: the uploader waits for what it had sent, the caller frees the tensors and the next upload starts clean, so the matrix stays on the CPU, and an expert whose commit failed stays on the CPU with the tier's budget intact (vk_tier tells a failed copy from a refusal, which still shrinks the plan). A fence wait that fails still loses the device, as every other wait does, now from any thread, and the rest follows: the tier stops at its next step (coli_vk_xb_ready), the chain at its next frame (vkc_begin), rebuilding its state on the CPU. COLI_VK_STAGED_FAULT=<point>[:n] makes the n-th staged upload fail at the staging buffer, the KV mirror's, a device-local block or buffer, a command buffer's recording, a submit, a fence wait or a tier commit after its first matrix. The exit line counts the submits.
tests/vulkan_engines.sh staged-faults: qwen36's matrices one by one, its tier awaited and with the uploader thread free after a warm start, its chain, and colibri's KV mirror, each through COLI_VK_STAGED_FAULT at every point it reaches. Every run: no crash, the fault fired, the CPU's tokens; one matrix fewer on the device where one failed, the tier's budget kept where a commit failed, the state rebuilt where the device was lost mid-run. staged-faults-sanitize: the same under ASan and UBSan. Both in the engines matrix. docs/vulkan.md says what each failure does.
…eepSeek chain Conflict: the usage lines and the dispatcher of tests/vulkan_engines.sh, where both sides added families; the chain families come before the staged entries, and the usage string names them all. The chain's one submit already goes through coli_vk_queue_submit, the backend's lock.
|
@JustVugg Ran it on the RTX 3070 with its launch VBIOS (94.04.3A.00.9D, The rule picks staging on its own, and the card now holds everything: So this card has a transfer-only family and the uploader took it: the queue configuration you listed as not measured.
The control run with Two observations, for what they are worth:
Logs and the runner are in my workspace if you want any of the raw files. Thanks to Claude Code for the help along the way. |
|
Figures for this from a discrete card without Resizable BAR, since the PR's "Not measured" list has none. Staging turns on by itself here and decodes 2.9 times as fast as the mapped path. Setup. # tier arms; mapped adds COLI_VK_STAGED=0, staged leaves it unset
SNAP=$M TOK=$M/tokenizer.json N_NEW=100 COLI_NO_OMP_TUNE=1 NOSTREAM=1 COLI_USAGE=usage.run \
VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/radeon_icd.json COLI_VULKAN=1 COLI_VK_SHADERS=shaders \
COLI_VK_TIER_GB=4 ./qwen36 64 4 prompt.txt
# CPU arm: the first line only. Control: COLI_VK_TIER_GB=0.15 COLI_VK_DENSE=0 COLI_VK_CHAIN=0With the 4 GiB budget the device holds 2416 experts (3.98 GiB) and a 1863.8 MiB trunk, about 5.8 GiB against the 256 MiB window.
One card, one model, one prompt. The sweep results from the same machine are on #1867. |
On a discrete GPU without Resizable BAR, Vulkan put nothing on the card. The host-visible device-local window there is about 256 MB, and the backend placed resident weights in that memory type. An RTX 3070 on its launch VBIOS showed it (reported on #1784):
only 246 of 8192 MB VRAM is host-visible;failed 3004;NVIDIA's driver refuses allocations past the window; RADV instead moves them to system RAM silently, so they are read over PCIe. Every Turing card and every Ampere card on its launch VBIOS is in that situation.
This adds staged device-local uploads: resident data goes to DEVICE_LOCAL memory through a host staging buffer and
vkCmdCopyBuffer. The approach (a device-local arena, the staging copy, a submit lock, the first-touch fill of fresh blocks) comes from #1338 by @crichalchemist, rewritten for the current backend, expert tier and chain.When it stages
COLI_VK_STAGED=1forces staging and0forces the mapped path.COLI_VK_HOST_VISIBLE_CAP_MB=Ncaps the heap size the rule sees, to emulate a card without ReBAR.When the rule says mapped, the queues, memory types and command buffers are exactly as before.
What moves (only when staging)
coli_vk_tensor_commit). On mapped memory the commit does nothing.COLI_VK_DEV2).Readbacks and device-only scratches are unchanged. Each fresh device-local block is zero-filled before first use, #1338's Polaris finding; it does not reproduce on our three devices, but it costs one pass per 256 MB block, so it stays. With staging on, the exit line reports the resident data left in host memory, and the tests check it is 0.
Synchronization. Each device has one uploader with its own command pool, two command buffers and two fences, and does one upload at a time under a mutex. The queue it uses is, in order of preference:
An upload waits on its fence before returning, so the tier hands an expert to the engine thread only after its copy has landed.
When an upload fails
A failure fails that upload, not the run:
COLI_VK_STAGED_FAULT=<point>[:n]injects a failure at each point:stage,pwstage,block,kvbuf,record,submit,waitandcommit.Tests
Same bits: the backend harness's digest of every device result is identical for mapped, staged, staged without the fill, and the 246 MB cap, which picks staging on its own. This holds on Lavapipe, the Intel Iris Xe (Dozen) and the Radeon 780M; a patched copy of the backend before this change gives the same digest.
COLI_VK_STAGED=1on Lavapipe:COLI_VK_DEV2), glm-chain-sanitize 12, glm 34;The new
stagedandstaged-windowfamilies pass.test_vk_tierandtest_vk_chainpass staged on all three devices, with 0 MiB left in host memory.staged-faultsandstaged-faults-sanitize(new): 32 runs over the dense path, the tier (awaited, and with its uploader thread running freely), the chain and the KV mirror. Each must have no crash and no sanitizer report, must show the fault fired, and must give the CPU's tokens. Results: 32/32 on Lavapipe, 32/32 under ASan+UBSan, 32/32 on the 780M.New CI legs:
staged, the shader family staged,qwen-staged,qwen-chain-staged,staged-faultsandstaged-faults-sanitize.Default unchanged: on our devices the rule picks the mapped path, and its bits are the previous build's.
Measured on the Radeon 780M (Qwen3.6-35B-A3B, the docs' method)
Staging costs nothing where it is not needed:
Not measured