Repository navigation
Asynchronous GPU-to-CPU tensor reads for CPU MoE offloading #31811
KaiFelixBennett
started this conversation in
Ideas
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Asynchronous GPU-to-CPU tensor reads for CPU MoE offloading
Motivation
When experts are kept in system RAM using
--n-cpu-moeor-ot exps=CPU, the scheduler needs to copy tensors from GPU memory to CPU memory before executing CPU splits.Currently, this path performs synchronous tensor copies. For backends such as Vulkan, each individual readback can involve a separate GPU submission and fence wait.
I investigated this overhead using a local profiling patch and found that these synchronization costs can become a significant part of token generation time.
Current implementation
In
ggml/src/ggml-backend.cpp,ggml_backend_sched_copy_input()handles the transfer of inputs to CPU splits.When the destination is the CPU backend, there is no
cpy_tensor_asyncimplementation available. The current path therefore synchronizes the relevant backends and eventually calls:ggml_backend_tensor_copy(input, input_cpy)This results in a synchronous readback for each tensor.
Interestingly, Vulkan already implements
get_tensor_asyncinggml/src/ggml-vulkan/ggml-vulkan.cpp. It records the readback into the current compute context, with the actual memory copy completed during synchronization.Measurements
Tested with Vulkan on both AMD and NVIDIA hardware:
On the R9700, the readback overhead accounted for approximately 15.5% and 18.6% of token generation time with 20 and 40 CPU MoE layers, respectively.
Prototype results
I implemented a prototype that queues asynchronous tensor reads for CPU splits in
ggml_backend_sched_compute_splits()and synchronizes once per source backend.The improvement is visible on both GPUs, despite their very different PCIe configurations.
Correctness
I compared
llama-perplexityresults with and without the prototype using-ncmoe 40 -c 512.The reported perplexity values were identical at all reported positions, including every chunk:
ub=512: 6 chunks, PPL 1.6315 ± 0.06915ub=1: 2 chunks, PPL 1.9163 ± 0.16872This does not constitute a bitwise comparison of logits, but no difference in the reported perplexity results was observed.
Possible implementation approaches
I see two possible directions:
A. Queue asynchronous reads in the scheduler
Use
ggml_backend_tensor_get_async()for inputs that need to be transferred from GPU memory to CPU splits, then synchronize once per source backend before executing the CPU split.This is what my prototype currently does.
B. Extend the backend copy interface
Provide asynchronous copy support for the CPU backend as a destination, potentially through
cpy_tensor_asyncor a public API that could also address the existing TODO inggml_backend_sched_copy_input().Questions for the maintainers
I would appreciate feedback on the preferred direction before preparing a PR.
Test environment
The measurements were performed using
llama-benchwith-ngl 99 -ncmoe N -fa 1 -p 0 -n 64 -r 3, with only one GPU visible per run.Limitations
So far, I have only tested Vulkan as the source backend, on AMD and NVIDIA GPUs.
CUDA, HIP, Metal, SYCL, Linux/RADV and mixed CUDA/Vulkan configurations have not been tested.
There is also a potential edge case with mixed backends: the CPU compute buffer is allocated using the host buffer of the first device. Depending on the device order, this may prevent Vulkan from using its asynchronous readback path and cause it to fall back to staging and immediate synchronization. This is currently a code-based observation, not a measured result.
AI disclosure
Claude (Claude Code) assisted with the research, profiling scripts, scheduler instrumentation and prototype implementation. I ran the benchmarks on my hardware, reviewed the results and made the technical decisions.
All reactions