ONNX Runtime PR #32121: Why a CUDA Graph Replayed an Overwritten Pointer Array

Why a CUDA Graph Replayed an Overwritten Pointer Array

An asynchronous copy can be finished for the current run and still depend on its source buffer during a future CUDA Graph replay.

That distinction caused a subtle lifetime bug in ONNX Runtime’s CUDA Execution Provider.

CudaAsyncBuffer placed data such as a kernel’s pointer array in pinned host memory, scheduled a host-to-device copy, and then returned the host buffer through the normal deferred-release path.

That was valid during ordinary execution.

It was not valid when the stream was being captured.

The graph remembered an address, not a private copy

Outside CUDA Graph capture, ONNX Runtime could treat the pinned buffer as temporary.

The runtime scheduled cudaMemcpyAsync, kept the buffer alive until the stream consumed it, and then allowed the allocator to reuse the memory.

The lifetime was tied to one queued copy.

During stream capture, the operation had a different future.

The copy became a node in the captured graph. When that graph was launched again, the node read from the host address recorded during capture.

The bytes at that address therefore needed to remain valid across later graph replays.

Returning the buffer to the allocator did not immediately make the stored address disappear. It made the address available for another allocation.

That created the failure.

Why a decode-only loop could hide the bug

Suppose a decode graph was captured while its pointer array occupied a pinned host allocation.

The graph recorded a host-to-device copy from that address.

ONNX Runtime then returned the host allocation through its usual deferred-release mechanism.

If the application only replayed the same decode workload, nothing might immediately request and overwrite that block. The stale lifetime assumption could remain invisible.

Now insert a differently shaped prefill run.

That run can request a compatible pinned allocation, receive the recycled block, and write a different pointer array into it.

The next decode replay still reads from the original host address.

But the address now contains the prefill run’s bytes.

The graph copies those bytes into device memory, and a kernel such as Concat interprets them as its own array of input pointers.

The graph itself has not changed. The meaning of the memory behind its recorded address has.

That is why the failure can appear only after another inference runs between capture and replay.

The fix changes ownership during capture

ONNX Runtime PR #32121 makes CudaAsyncBuffer::CopyToGpu inspect the stream’s capture status after scheduling the copy.

When the stream is not being captured, the previous behavior remains:

AddDeferredReleaseCPUPtr(...);

The host buffer stays alive until the ordinary asynchronous copy has consumed it and can then return to the allocator.

When capture is active, the buffer follows another path:

RetainBufferForGraphCapture(...);

Ownership moves into provider-scoped storage instead of returning to the allocator.

The captured graph can continue reading the same host address during replay because another run cannot reclaim and overwrite that allocation.

The fix is conservative. The buffer is retained until the provider is destroyed rather than being released at the exact end of an individual graph’s lifetime.

That may retain the allocation longer than strictly necessary, but it restores the required address stability without introducing a new graph-lifetime ownership system.

The plugin path needed the same repair

The first version of the patch covered the built-in CUDA Execution Provider.

ONNX Runtime also has a plugin CUDA EP path with a parallel CudaAsyncBuffer implementation. Under the plugin build configuration, the public CUDA kernel header selects that adapter instead.

The plugin implementation still used the original deferred-release behavior.

That meant the same operators could remain exposed to the same stale host-address problem when built through the plugin boundary.

Review caught the missing parallel path.

The final patch added capture detection and provider-scoped buffer retention to both implementations.

This was not an optional consistency cleanup. Both paths supported the behavior that created the lifetime requirement.

Fixing only one would have made correctness depend on how the CUDA EP was packaged.

What the regression evidence proves

The PR reports a compute-sanitizer failure before the fix in a workload that interleaved prefill with graph-replayed decode.

The failure appeared as an invalid, misaligned global read in a Concat kernel. After the lifetime change, the same reported workload ran cleanly.

The checked-in test covers the new retention container more narrowly. It verifies that the container keeps ownership of a buffer and releases it when the container itself is destroyed.

That proves the added ownership mechanism.

It does not reproduce the complete CUDA sequence:

capture
→ allocator reuse
→ overwrite
→ replay
→ incorrect access

The implementation and reported sanitizer result support the fix. The automated regression boundary remains less complete than the original failure path.

What the patch deliberately leaves unchanged

The change protects the pinned host buffer used as the source of the captured copy.

It does not redesign every allocation involved in CUDA Graph replay.

The PR specifically leaves the destination scratch allocation under ONNX Runtime’s existing graph address-stability assumptions. It also does not claim a measured performance improvement.

The supported conclusion is narrower:

A captured copy continued to depend on a host address after ONNX Runtime had allowed the allocator to reuse it. The fix preserves that host allocation whenever the copy is captured into a graph.

The broader lesson is not limited to CUDA.

A resource’s lifetime belongs to its last future reader, not merely to the call that first appeared to consume it.

During ordinary execution, the last reader may be one queued operation.

During graph capture, the last reader may be a replay that has not happened yet.


Sources

https://github.com/microsoft/onnxruntime/pull/32121

https://github.com/microsoft/onnxruntime/commit/cd13bdd819dcbdb9e0be2e8c38d2698ae2e56097

https://github.com/microsoft/onnxruntime/blob/main/onnxruntime/core/providers/cuda/cuda_kernel.h

https://github.com/microsoft/onnxruntime/blob/main/onnxruntime/core/providers/cuda/plugin/cuda_kernel_adapter.h

https://github.com/microsoft/onnxruntime/blob/main/include/onnxruntime/core/framework/allocator.h

Popular posts from this blog

AMD Is Trying to Turn Kernel Diversity Into One Software Asset

PyTorch #188031 — Why Did Forward Use 64-Bit Indexing While Backward Still Used uint32_t?

NVIDIA CUTLASS #3017 — Why load() and store() Mapped the Same Tile Coordinate to Different Addresses