Skip to content

ggml: asynchronous scheduler memory copies#25319

Draft
JohannesGaessler wants to merge 1 commit into
masterfrom
jg/ggml-backend-async-copy
Draft

ggml: asynchronous scheduler memory copies#25319
JohannesGaessler wants to merge 1 commit into
masterfrom
jg/ggml-backend-async-copy

Conversation

@JohannesGaessler

Copy link
Copy Markdown
Contributor

The ggml backend scheduler works by automatically creating "splits" of a ggml graph that can be per backend based on e.g. weight locations. By necessity some data may need to be copied to a different backend to facilitate this. As of right now these copies are entirely synchronous, #17795 #20793 aimed to improve performance by making these copies partially asynchronous. However, these PRs were eventually reverted due to unintended side effects for come hardware setups. Part of the problem is that the ggml backend interface is incomplete in some places and with Diego on an indefinite break we don't have a ggml-side authority w.r.t. the intended design. This PR is my attempt at finding a consensus among ggml maintainers for how to resolve the incomplete parts and how asynchronous copies should be handled.

ggml Backend API Changes

  • On master any ggml_backend_event_t can be passed to any ggml_backend_t. However, there is no mechanism to assert that the event and the backend are actually compatible; in e.g. the CUDA backend any event is simply cast to cudaEvent_t without any checks for misuse. This PR changes the return types of ggml_backend_event_record and ggml_backend_event_wait to bool to indicate whether the backend could successfully use the event. These changes make it easier to use backends across ggml backend (devices) without risking segfaults.
  • On master ggml_backend_tensor_copy_async automatically enforces synchronization between backend_src and backend_dst. However, this limits the flexibility of this API call. When I implemented -sm tensor Diego suggested to me to separate out the asynchronous copy and the synchronization. For CUDA and HIP this would not at all be a problem since the internal synchronization mechanism is the same as with ggml_backend_event_t. However, I think to remember that @0cc4m once informed me that Vulkan does not have native support for events. So what I did with this PR is to instead replace backend_src and backend_dst with a mandatory backend_copy and an optional backend_wait (if not null it is made to wait for the copy to complete). There is no constraint on the memory locations of the src and dst tensors other that backend_copy needs to be able to access them. The change to ggml_backend_tensor_copy_async is backwards compatible though the only applications are in the ggml backend scheduler and the meta backend anyways.

ggml Backend Scheduler Changes

  • On master all inputs for a split are copied synchronously.
  • With this PR inputs are preferentially copied asynchronously by the backend that uses them.
  • Tensors with GGML_FLAG_INPUT are assumed to be set statically by the user code and thus do not need any synchronization.
  • Tensors from preceding splits that do not have the ability for asynchronous operation in the first place (e.g. CPU) do not need synchronization in the first place, their data can simply be copied.
  • For tensors from preceding splits with asynchronous operation, if possible record an event after those splits are done, wait with the current split for that event, and then make the current split copy the data to itself. If events cannot be used, instead copy the data under the control of the preceding split and make the current split wait for the copy to complete.
  • As on master, only return control to the user code after the data from all tensors with GGML_FLAG_INPUT has been copied. Preferably use events for this, full backend synchronizations otherwise. To be clear: this is currently necessary due to what I would consider a misues of the ggml backend scheduler by the llama.cpp user code. Once control is returned to the llama.cpp user code it will to my understanding start overwriting the data of the inputs without first calling ggml_backend_sched_synchronize - I really think that it should. Long-term the in my opinion correct solution is to buffer ggml graphs so that the user code can start preparing a graph with separate inputs while the previous one(s) are still being executed.

Performance changes

CUDA/HIP performance
GPU Model Split mode Microbatch size Test t/s b9867 t/s a6c683b Speedup
MI60 / MI50 gpt-oss 20B MXFP4 MoE - 512 pp2048 1066.38 1066.51 1.00
MI60 / MI50 gpt-oss 20B MXFP4 MoE - 512 tg128 144.19 145.23 1.01
MI60 / MI50 llama 8B Q4_0 - 512 pp2048 1177.83 1178.18 1.00
MI60 / MI50 llama 8B Q4_0 - 512 tg128 97.25 98.03 1.01
MI60 / MI50 stories 15M Q4_0 - 512 pp2048 73445.18 74880.66 1.02
MI60 / MI50 stories 15M Q4_0 - 512 tg128 1605.43 1755.43 1.09
MI100 gpt-oss 20B MXFP4 MoE - 512 pp2048 3103.03 3111.57 1.00
MI100 gpt-oss 20B MXFP4 MoE - 512 tg128 172.80 175.17 1.01
MI100 llama 8B Q4_0 - 512 pp2048 2754.26 2755.87 1.00
MI100 llama 8B Q4_0 - 512 tg128 127.80 131.81 1.03
MI100 stories 15M Q4_0 - 512 pp2048 135432.90 136809.36 1.01
MI100 stories 15M Q4_0 - 512 tg128 1694.35 1908.61 1.13
P40 gpt-oss 20B MXFP4 MoE - 512 pp2048 1361.84 1361.71 1.00
P40 gpt-oss 20B MXFP4 MoE - 512 tg128 64.83 64.97 1.00
P40 llama 8B Q4_0 - 512 pp2048 962.14 961.70 1.00
P40 llama 8B Q4_0 - 512 tg128 52.18 52.23 1.00
P40 stories 15M Q4_0 - 512 pp2048 75746.76 76234.57 1.01
P40 stories 15M Q4_0 - 512 tg128 1674.67 1717.77 1.03
RTX 3090 gpt-oss 20B MXFP4 MoE - 512 pp2048 5824.05 5842.37 1.00
RTX 3090 gpt-oss 20B MXFP4 MoE - 512 tg128 210.37 212.26 1.01
RTX 3090 llama 8B Q4_0 - 512 pp2048 5262.11 5264.47 1.00
RTX 3090 llama 8B Q4_0 - 512 tg128 154.68 155.19 1.00
RTX 3090 stories 15M Q4_0 - 512 pp2048 167064.49 170455.22 1.02
RTX 3090 stories 15M Q4_0 - 512 tg128 3169.20 3376.42 1.07
RTX 4090 gpt-oss 20B MXFP4 MoE - 512 pp2048 13126.94 13143.68 1.00
RTX 4090 gpt-oss 20B MXFP4 MoE - 512 tg128 264.26 266.26 1.01
RTX 4090 llama 8B Q4_0 - 512 pp2048 12904.81 12945.38 1.00
RTX 4090 llama 8B Q4_0 - 512 tg128 178.67 179.24 1.00
RTX 4090 stories 15M Q4_0 - 512 pp2048 234314.73 245034.09 1.05
RTX 4090 stories 15M Q4_0 - 512 tg128 3929.08 4185.79 1.07
2x RTX 4090 gpt-oss 20B MXFP4 MoE layer 512 pp2048 18559.40 18439.94 0.99
2x RTX 4090 gpt-oss 20B MXFP4 MoE layer 512 tg128 264.46 263.70 1.00
2x RTX 4090 gpt-oss 20B MXFP4 MoE tensor 512 pp2048 10526.73 10438.53 0.99
2x RTX 4090 gpt-oss 20B MXFP4 MoE tensor 512 tg128 318.72 318.81 1.00
2x RTX 4090 llama 8B Q4_0 layer 512 pp2048 19215.72 19165.64 1.00
2x RTX 4090 llama 8B Q4_0 layer 512 tg128 178.44 178.45 1.00
2x RTX 4090 llama 8B Q4_0 tensor 512 pp2048 8147.18 8130.43 1.00
2x RTX 4090 llama 8B Q4_0 tensor 512 tg128 236.01 237.04 1.00
2x RTX 4090 stories 15M Q4_0 layer 512 pp2048 211588.03 225849.37 1.07
2x RTX 4090 stories 15M Q4_0 layer 512 tg128 3612.68 3637.42 1.01
RTX 5090 gpt-oss 20B MXFP4 MoE - 512 pp2048 21011.15 21072.35 1.00
RTX 5090 gpt-oss 20B MXFP4 MoE - 512 tg128 411.96 417.34 1.01
RTX 5090 llama 8B Q4_0 - 512 pp2048 16324.41 16385.46 1.00
RTX 5090 llama 8B Q4_0 - 512 tg128 282.14 284.01 1.01
RTX 5090 stories 15M Q4_0 - 512 pp2048 238656.44 256292.00 1.07
RTX 5090 stories 15M Q4_0 - 512 tg128 4662.74 5065.97 1.09
RX 6800 gpt-oss 20B MXFP4 MoE - 512 pp2048 1223.80 1232.16 1.01
RX 6800 gpt-oss 20B MXFP4 MoE - 512 tg128 102.04 103.63 1.02
RX 6800 llama 8B Q4_0 - 512 pp2048 914.11 919.01 1.01
RX 6800 llama 8B Q4_0 - 512 tg128 75.19 75.77 1.01
RX 6800 stories 15M Q4_0 - 512 pp2048 49584.01 49557.04 1.00
RX 6800 stories 15M Q4_0 - 512 tg128 1071.17 1186.25 1.11
RX 9060 XT gpt-oss 20B MXFP4 MoE - 512 pp2048 2914.98 2909.12 1.00
RX 9060 XT gpt-oss 20B MXFP4 MoE - 512 tg128 84.81 87.83 1.04
RX 9060 XT llama 8B Q4_0 - 512 pp2048 2622.87 2627.40 1.00
RX 9060 XT llama 8B Q4_0 - 512 tg128 60.40 61.36 1.02
RX 9060 XT stories 15M Q4_0 - 512 pp2048 57000.98 58116.89 1.02
RX 9060 XT stories 15M Q4_0 - 512 tg128 847.75 1088.10 1.28
V100-PCIE-32GB gpt-oss 20B MXFP4 MoE - 512 pp2048 2435.60 2438.30 1.00
V100-PCIE-32GB gpt-oss 20B MXFP4 MoE - 512 tg128 150.82 151.97 1.01
V100-PCIE-32GB llama 8B Q4_0 - 512 pp2048 3046.82 3047.02 1.00
V100-PCIE-32GB llama 8B Q4_0 - 512 tg128 127.31 127.80 1.00
V100-PCIE-32GB stories 15M Q4_0 - 512 pp2048 151432.10 156348.42 1.03
V100-PCIE-32GB stories 15M Q4_0 - 512 tg128 1757.81 1826.03 1.04

For actual, real life use the performance impact of the asynchronous copies is very small, a few % at most. Stories 15M provides an effective upper bound on the reduction in overhead, there the speedup can be 10-30%. Though I don't have the setup for testing it I would assume that the impact for audio models would be quite noticeable.

TODOs

  • I do not have a good understanding of ggml backends outside of the CPU and the CUDA/HIP backends. So I would like to ask @ggerganov and @0cc4m to provide feedback regarding whether the ggml backend changes would work for Metal/Vulkan. Also I don't know who to tag from e.g. the SYCL or CANN teams.
  • @aendk please confirm that the speedup you are presumably getting with this PR is equivalent to the one you got with your previous PRs.
  • @DEV-DUFORD @slavap @nathanmp please confirm that this PR does not negatively impact your performance relative to master. For the previous PRs I was able to reproduce some multi GPU performance regressions using 2x RTX 4090 but with this PR the performance was unchanged when I tested it.
  • @am17an please provide feedback regarding your efforts at tensor pre-fetching in ggml: allow prefetching tensor overrides #21067 . I think that with the changes in this PR it should be possible to implement this in the ggml backend scheduler with minimal additional changes. I think all that would be necessary would be to have additional ggml backends running parallel to the regular ones and doing nothing but the weight copies.

Requirements

@github-actions github-actions Bot added ggml changes relating to the ggml tensor library for machine learning CUDA Related to the CUDA backend labels Jul 5, 2026
Comment thread ggml/src/ggml-backend.cpp
Comment on lines +1580 to +1596
for (int split_id = 0; split_id < sched->n_splits; split_id++) {
struct ggml_backend_sched_split * split = &splits[split_id];
int split_backend_id = split->backend_id;
ggml_backend_t split_backend = sched->backends[split_backend_id];

for (int input_id = 0; input_id < split->n_inputs; input_id++) {
struct ggml_tensor * input = split->inputs[input_id];
struct ggml_tensor * input_cpy = tensor_copy(input, split_backend_id, sched->cur_copy);

if (!(input->flags & GGML_TENSOR_FLAG_INPUT)) {
continue;
}

ggml_backend_tensor_copy_async(split_backend, /*backend_wait =*/ NULL, input, input_cpy);
any_input_copies[split_backend_id] = true;
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

During an off-site discussion with @am17an he pointed me at a scenario with > 1 splits / backend where pre-fetching inputs like this will potentially overwrite some of them.

@0cc4m

0cc4m commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Vulkan does have working events since #20518, I believe. But our main issue has always been that specifically how each function is meant to work has never been fully specified, so there may be different assumptions on different backends.

@ggerganov ggerganov self-assigned this Jul 6, 2026
@Alp3r3n

Alp3r3n commented Jul 6, 2026

Copy link
Copy Markdown

Hi @JohannesGaessler,

I pulled this branch and profiled it locally using Nsight Systems to verify the async memory overlap behavior on my architecture.

Environment:

  • GPU: NVIDIA GeForce RTX 3060 Ti 8GB
  • OS/Driver: Ubuntu 24.04.4 LTS / Driver 580.65.06 (CUDA 13.0)
  • Build: GGML_CUDA=ON
  • Model: TinyLlama-1.1B-Chat-v1.0-GGUF (Q4_K_M)

Observations:
Looking at the timeline, the expected overlap between memory transfers and compute kernels is not triggering. Instead, the cudaMemcpyAsync operations are executing sequentially in the gaps between the GraphExec computations.

I've attached the timeline snapshot showing this sequential compute/memory behavior.

Could the CUDA Graphs be forcing a synchronization in this specific setup, or is the model size (1.1B) too small to saturate the compute and allow for overlapping?

image

Best regards,
Alperen

@ORippler

ORippler commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Adding some general comments from my side as @aendk is still out this week:

This PR is my attempt at finding a consensus among ggml maintainers for how to resolve the incomplete parts and how asynchronous copies should be handled.

Given the trouble we had with this previously, I really think we should offer and implement isolated tests a backend can use to validate its implementation against the expected behavior. Happy to assist/contribute here if you want.

On master ggml_backend_tensor_copy_async automatically enforces synchronization between backend_src and backend_dst.

I don't think this is actually true. If backend_dst->iface.cpy_tensor_async != NULL, ggml_backend_tensor_copy_async simply dispatches into it -> it offloads the responsibility for correct implementation solely to backend_dst. CUDA -> CUDA this works fine, but CUDA -> VULKAN is for example ill-specified/depends on VULKAN correctly implementing the logic. This is one of the behavioral gaps I'd like to see us close/resolve.

So what I did with this PR is to instead replace backend_src and backend_dst with a mandatory backend_copy and an optional backend_wait (if not null it is made to wait for the copy to complete)

For tensors from preceding splits with asynchronous operation, if possible record an event after those splits are done, wait with the current split for that event, and then make the current split copy the data to itself. If events cannot be used, instead copy the data under the control of the preceding split and make the current split wait for the copy to complete.

But CUDA -> CUDA copies in your draft still work via enqueuing an event internally that is not exposed via ggml? Note that this is a feature I want to keep.

On second thought, seems we always synchronize input_backend for cross-backend copies in ggml_backend_sched_compute_splits? Then this code-piece was effectively a no-op in the CUDA backend.

Once control is returned to the llama.cpp user code it will to my understanding start overwriting the data of the inputs without first calling ggml_backend_sched_synchronize - I really think that it should. Long-term the in my opinion correct solution is to buffer ggml graphs so that the user code can start preparing a graph with separate inputs while the previous one(s) are still being executed.

Agreed on the need for async/buffered graph prep + execution. AFAIK currently we synchronize after a batch in llama.cpp (e.g. when extracting logits/the sampled token), and across ubatches we synchronize via #20927 only pipeline parallel when a context would be reused (and we would thus trigger the race condition).

@ORippler

ORippler commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Observations:
Looking at the timeline, the expected overlap between memory transfers and compute kernels is not triggering. Instead, the cudaMemcpyAsync operations are executing sequentially in the gaps between the GraphExec computations.

  1. Copy/Compute overlap in single-GPU setting would require changes beyond the scope of this PR (i.e. llama-side of things would have to add double-buffering to prepare a graph for token n+1 while graph n is still being evaluated).
  2. Current implementation is that ggml_backend_tensor_copy_async filed against a cuda backend are processed sequentially.

This is expected to bring perf by removing the unneeded cudaStreamSynchronize calls in your nsys profile between the calls to cudaMemcpyAsync.

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

Labels

CUDA Related to the CUDA backend ggml changes relating to the ggml tensor library for machine learning

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants