Skip to content

cuda: stream TurboQuant MoE experts from pinned host memory - #364

Open
jasstrong wants to merge 5 commits into
TheTom:feature/turboquant-kv-cachefrom
jasstrong:pr/tq-moe-paging-rework
Open

jasstrong wants to merge 5 commits into
TheTom:feature/turboquant-kv-cachefrom
jasstrong:pr/tq-moe-paging-rework

Conversation

@jasstrong

Copy link
Copy Markdown

Three patches letting a TurboQuant mixture-of-experts model keep its expert stack
in pinned host memory with the hot experts cached in VRAM, so a model larger than
the card runs without spilling everything to the CPU.

An expert stack is the one weight worth doing this to. A token routes to a
handful of experts out of hundreds, so the traffic is per-expert rather than the
whole tensor, and at that granularity the bus is fast enough that the copies
overlap compute.

1. Route expert addresses through an optional table. The MoE kernels find an
expert by arithmetic on the base pointer, which requires every expert to be
resident and contiguous. The table is built on the device (reading it back would
be illegal during a graph capture) and initially reproduces that layout exactly,
so it is a no-op. The point is that an entry can then be pointed elsewhere.

2. Cache the table per expert tensor, rather than rebuilding it per call. Raw
device memory, never freed early, retired on replacement, released at teardown,
for the same reason as the pre-rotation caches next to it.

3. The residency cache. Per tensor, a slab of n_slots expert-sized slots plus
bookkeeping that stays on the device so the policy runs inside the graph. Each
decode probes the routed experts against the slots, awards a slot to each miss,
copies those in and repoints the table. Anything that loses a slot contest reads
in place from host memory, which is correct if slower.

Measured on an MI210 with a 91 GB TQ3 Qwen3.8-Flash-Next, four expert layers in
host memory:

generation
all experts in VRAM (ceiling) 34.2 t/s
four layers in host, paging off 22.9 t/s
paging on, 16 slots 31.7 t/s
paging on, 64 slots 32.6 t/s
paging on, 128 slots 32.8 t/s

43% over reading in place, and 96% of what the same layers cost when they never
left the card.

Correctness was checked by perplexity rather than by reading the output, because
the failure mode here is a wrong expert, which produces fluent text that is
quietly wrong. On 273 KB of prose, perplexity is 3.3453 for all of: experts in
VRAM, experts in host with paging off, and paging on at 64, 16 and 4 slots. Four
slots against ten routed experts per token forces a collision on essentially
every call, which is the condition that matters: the probe reserves a slot it
hits with an atomicMin so a miss mapping to the same slot cannot claim it and
overwrite the contents mid-call. An earlier version of this without the
reservation passed a read-the-output check and was wrong.

Everything is opt-in: GGML_MOE_HOST_EXPERTS to admit host-resident experts,
GGML_MOE_EXPERT_TABLE for the indirection, GGML_MOE_PAGE_IN with
GGML_MOE_PAGE_SLOTS for the paging. With them unset the kernels take the original
contiguous path and the generated code is unchanged.

One known rough edge: the pool allocator half-succeeds when it cannot get what it
asked for, rather than sizing to available VRAM or failing outright. The slot
count behaves monotonically here (16 through 128 improve smoothly), though on a
different kernel I have seen the direct-mapped modulo make it erratic, so a
mixing hash or set-associativity may be worth it later.

TurboQuant-specific, admitted only for the TQ expert path and only on opt-in, so
no upstream question here.

jas and others added 3 commits September 7, 2026 03:44
The MoE decode kernels find an expert by arithmetic on the base pointer,
base + expert*nb_expert, which requires every expert to be resident and
contiguous. Give them the option of an indirection instead: a per-expert
address table, built on the device so nothing is read back to the host, which
would be illegal inside a graph capture.

Filled here to reproduce the contiguous layout exactly, so behaviour is
unchanged and the table is a no-op. The point is what it makes possible: an
entry can be pointed at a VRAM cache slot, or at mapped host memory for an
expert that is not resident, without the kernels needing to know. That is the
groundwork for keeping an expert stack larger than the card in host memory with
only the hot experts cached.

All three MoE kernels take a USE_TABLE template parameter and the dispatch
picks the variant, so the arithmetic path is unchanged when the table is off.
Opt-in through GGML_MOE_EXPERT_TABLE while it is being brought up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxP6x5bmUDYFvmouceN2mR
The table was rebuilt on every call: a kernel launch and a device allocation per
MoE projection. Cache it per expert tensor instead, keyed on the weight address,
the expert stride and the count.

The buffer is a raw device allocation rather than pool memory and is never freed
early, for the same reason as the pre-rotation caches above it: a captured graph
replays kernels that read this exact address, and the pool frees strictly LIFO
while transient allocations sit on top. Outgrown buffers are retired and released
at context teardown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxP6x5bmUDYFvmouceN2mR
With the address table in place, an expert stack can live in pinned host memory
and be read per expert instead of having to fit in VRAM. This adds the landing
slots and the policy that decides what goes in them.

Per expert tensor: a slab of n_slots expert-sized slots, plus residency
bookkeeping that stays on the device so the policy runs inside the graph rather
than forcing a readback, which a capture would not allow. Each decode probes the
routed experts against the slots, awards a slot to each miss, copies those
experts in, and repoints the table entries at the slots. Hits read from the slot
they already occupy; anything that loses a slot contest reads in place from host
memory, which is correct if slower.

The probe reserves a slot it hits with an atomicMin, so a miss mapping to the
same slot cannot claim it and overwrite the contents mid-call. Without that
reservation a hit and a miss can collide and the hit reads the wrong expert,
which shows up as fluent output that is quietly wrong rather than as a crash.

Paging is skipped for a tensor already resident in VRAM: copying a resident
expert into a slot spends bandwidth and VRAM for nothing. The residency query
runs once per tensor via the pointer attributes.

Opt-in through GGML_MOE_PAGE_IN, with GGML_MOE_PAGE_SLOTS sizing the pool.
Requires the address table, since paging works by repointing entries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxP6x5bmUDYFvmouceN2mR
@jasstrong
jasstrong marked this pull request as ready for review September 7, 2026 05:40
@giveen

giveen commented Sep 7, 2026

Copy link
Copy Markdown

@jasstrong have you checked out --moe-cache auto

@jasstrong

Copy link
Copy Markdown
Author

Thanks, I had not. I have now, and it does not cover this case.

Two hard blockers first. The cache only sees experts assigned to the CPU backend on the regular MUL_MAT_ID path, and these sit in ROCm_Host behind an explicit override, so there is nothing for it to observe. And moe_cache_type_supported() enumerates 22 types from Q1_0 to IQ4_XS with no TQ entry, so TQ3_1S experts are declined regardless of placement.

The deeper difference is that moe-cache caches and this pages. moe-cache wants the canonical experts CPU-resident in full and copies the hot ones into a VRAM pool. That assumes host RAM can hold every expert. On the machine I am running, the experts are about 57.6 GiB across 48 layers, the n-gram table is another 27 GiB, and the box has 60 GiB. Canonical CPU experts do not fit, so that placement is not a slower option, it is not an option. What this PR does instead is keep most experts resident and page the spill: the routed experts are copied into a bounded slot pool, and an expert that loses a slot collision is simply read in place through the address table. Host residency is sized to the spill, currently 8.4 GiB, not to the model.

On teaching moe-cache about TQ rather than landing this: I do not think that is as cheap as it sounds. moe-cache dispatches through mmvq.cuh and holds cached experts in a pooled slab at a uniform per-expert stride. The TQ MoE kernels are 2905 lines across four variants selected per architecture, fp16 MFMA multi-column on CDNA, dp4a on RDNA2 and RDNA3, and a scalar fallback. They are the survivors of 32 commits of measurement on real hardware, including one that deleted the decode paths that turned out slower than what replaced them. Reconciling them with the slab layout means rewriting the innermost addressing of every variant, and that tuning was established empirically rather than by construction, so it would have to be re-established the same way.

Which is an argument for the shape of this PR rather than against it. The address table is exactly the seam that integration needs: once expert addresses come from a table instead of being computed from the tensor base, the same kernels can read an expert from a slot, from host memory, or in principle from a moe-cache slab. I would rather land the indirection and wire it up afterwards than rewrite four tuned kernels against a layout before anything has measured whether the combination wins.

jasstrong and others added 2 commits September 11, 2026 13:12
Three build breaks, none of which show up on HIP.

nvcc treats "declared but never referenced" as an error, and two things were
left behind by the rework:

  - tq_page_in_experts, the original bulk kernel, superseded by
    tq_page_in_misses once the residency plan started marking which experts
    actually need copying. Removed, and the measurement that justifies the
    whole approach (27.6 GB/s for a coalesced copy against 2.7 GB/s for the
    matmul reading host memory in place) moved onto the kernel that replaced it,
    since that is the rationale a reader needs there.
  - n_expert_all, a width computed and never used.

MUSA has no cudaPointerAttributes, cudaPointerGetAttributes, cudaMemoryTypeHost
or cudaMemoryTypeUnregistered, so the non-HIP branch of the host-memory check
did not compile there at all. Paging is only worth anything when the expert
stack is in host memory and that query is how we establish it, so MUSA stays off
rather than guessing. I have no MUSA hardware to test on beyond the build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
b6d7fd5 made the file compile on MUSA by taking the host-memory check as
false there, which had the side effect of disabling paging without saying so.
Make that a decision rather than a consequence.

The feature needs to know the expert stack is in host memory, and the
pointer-attribute query is how that is established. MUSA exposes no equivalent:
upstream carries no mapping for it in vendors/musa.h and no reference to a
musaPointerGetAttributes anywhere, so there is no spelling to copy, and a
guessed symbol would break the build again on hardware that cannot be tested
here. Off by omission, and the comment says as much, along with what to add to
vendors/musa.h to turn it on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jasstrong

Copy link
Copy Markdown
Author

CI on this branch has been red since it opened, for two reasons that never show up on HIP. Fixed in b6d7fd5 and e04fd47.

nvcc

error #177-D: declared but never referenced, twice, and both were genuine leftovers from the rework rather than anything subtle:

  • tq_page_in_experts, the original bulk kernel. tq_page_in_misses superseded it once the residency plan started marking which experts actually need copying, and nothing has called it since. Removed. I moved its comment onto the kernel that replaced it rather than deleting it, because it carries the measurement the whole approach rests on: 27.6 GB/s for a coalesced copy against 2.7 GB/s for the matmul reading the same host memory in place.
  • n_expert_all, a width computed and never used.

MUSA

The non-HIP branch of the host-memory check did not compile there at all: no cudaPointerAttributes, cudaPointerGetAttributes, cudaMemoryTypeHost or cudaMemoryTypeUnregistered.

Paging is only worth anything when the expert stack is in host memory, and that query is how it is established, so the feature is now off on MUSA. I want to be explicit that this is off by omission and not by measurement:

  • vendors/musa.h maps a good deal of the memory API (cudaHostRegister, cudaMemcpy and friends) but nothing for pointer attributes.
  • Upstream llama.cpp has no reference to a musaPointerGetAttributes anywhere, so there is no spelling to copy. A guessed symbol would just break the build again on hardware I cannot test.

Rather than leave that as a silent consequence of the compile fix, the gate now says page_in_on = false on MUSA with the reasoning next to it, and names the four mappings to add to vendors/musa.h to turn it back on. If someone with MUSA headers can confirm those symbols exist, enabling it is a four-line change plus deleting two blocks.

Worth flagging for whoever reviews: I have no MUSA or NVIDIA hardware, so both of these are build-correctness only. CI is the test, not me.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants