Environment
- GPU: AMD MI355X (gfx950)
- ROCm: 7.2.x (rocm7.2.4)
- PyTorch: 2.10.0+rocm7.2.4
- Setup: 8-GPU tensor-parallel LLM inference (TP=8) using PyTorch
torch.cuda.CUDAGraph for decode
Bug Summary
When a device buffer is written via H2D copy on the default stream immediately before graph.replay() — also on the same default stream — the in-graph kernel reads a stale value from the previous step instead of the freshly written value.
This is not a stream-ordering bug (the H2D copy and replay are on the same stream in program order), not a dangling-pointer bug (the captured pointer equals the live buffer address), and not a concurrent-write bug (the buffer is arange both before and after replay). The stale read is localized to the HIP graph replay path itself.
Symptom
Intermittent Memory access fault by GPU node-N MEMORY_VIOLATION during LLM decode (CUDAGraph replay path), non-deterministic, absent in eager mode. Triggered by a small integer buffer (cu_seqlens_q, int32, ~513 elements) that is written correctly each step but read stale inside the graph.
Debug-agent wave dump at fault:
wave_NNNN: pc=0x... <_convert_req_index_to_global_index_kernel> (stopped, reason: MEMORY_VIOLATION)
scalar registers:
s3: 000003e3 ← qo_end = 0x3e3 = 995 (STALE cumulative value from prior prefill step)
s2: 0000008b ← token_id = 139 (loop variable, should be ≤ 256 for decode)
...
vector registers:
v3: [0] 0000772b [1] 0000772b ... ← derived offset ≈ 29 000+, far OOB in token_indices
The buffer should contain arange(0, 257) (decode: 1 token per request). Instead the graph kernel read qo_end = 995, the cumulative value left by the immediately preceding prefill step.
Source location: for token_id in range(qo_start, qo_end) — the stale qo_end drives token_id to ~995, causing an out-of-bounds load in token_indices[token_id].
Evidence: buffer is physically correct; stale is in the graph's read path
We performed three independent probes to localize the stale:
1. Before-replay D2D snapshot (no host sync)
Right before graph.replay(), a GPU→GPU copy (non_blocking=True, same default stream) snapshots cu_seqlens_q into a persistent buffer. A daemon thread later saves it — no synchronization on the hot path.
Result (8 ranks × 3000 steps, warmup + steady-state): buffer is always arange(0, 257), max = 256, zero cumulative rows. The physical device memory is correct before replay.
2. After-replay D2D snapshot (rolling ring, no host sync)
Same approach, taken after replay, rolling ring of 512 steps overwritten every 100 steps.
Result (8 ranks × 512 steps covering the crash step): buffer is always arange(0, 257), max = 256, zero cumulative rows — even at the exact steps preceding the crash.
3. In-graph kernel register dump (ROCm debug agent)
HSA_TOOLS_LIB=librocm-debug-agent.so.2, graph mode preserved (not eager), --save-code-objects. Crash reproduced with the unmodified kernel; wave dump captured at fault.
Result: s3 = 0x3e3 = 995 = stale qo_end from the immediately preceding prefill step (which legitimately wrote cumulative ~988 into the buffer before the current decode step overwrote it with arange). The buffer is provably arange (probes 1 + 2), yet the graph kernel read 995.
Conclusion from probes
| Probe |
Buffer content |
Kernel read |
| Before-replay D2D |
arange (max 256) |
— |
| After-replay D2D |
arange (max 256) |
— |
| Debug-agent register |
— |
995 (stale cumulative) |
Same address (ADDR_PROBE confirmed capture pointer == live pointer at every replay). Same physical memory (D2D probes confirm arange both sides of replay). Different value inside the graph. The stale is 100% in the graph replay's memory read path.
Characteristics
- CUDAGraph required: eager mode runs 145 000+ decode steps with zero violations.
- Multi-GPU TP=8 required: we attempted single-GPU minimal repros (torch copy graph, triton kernel graph, 500-kernel large graph, shared-pool multi-graph, all with external H2D write before replay) — none reproduced. The bug requires the real TP=8 setup with RCCL collectives inside the graph.
- Non-deterministic / flaky: same commit alternates pass/fail; concurrent-scale-dependent (higher batch = more prefill-decode interleaving = higher stale probability).
- One-epoch lag: the stale value is specifically the value written by the immediately preceding prefill step — not the capture-time value, not an arbitrary old value. This suggests the graph's view of the buffer lags by one write epoch.
- In-kernel instrumentation masks the bug (Heisenbug): adding a
tl.store to record the loaded value (and clamping the loop to avoid the crash) causes the bug to disappear — the recompiled/recaptured kernel stops exhibiting the stale. This makes in-kernel probes unreliable for this bug; the debug-agent register dump (which does not modify the kernel binary) is the only reliable in-kernel observable.
- Graph pool: multiple graphs (one per decode batch size) share a single
graph_pool_handle.
H2D write details
The write path:
# CpuGpuBuffer.copy_to_gpu()
self.gpu.copy_(self.cpu, non_blocking=True) # pinned H2D on current stream
Called in prepare_inputs on the default stream, immediately before graph.replay() also on the default stream. Program-order synchronization should guarantee visibility. The forward pass is single-stream synchronous with no side-stream switching.
Ask
- Is this a known HIP-graph limitation on gfx950 (e.g., graph replay does not guarantee observability of H2D writes on the same stream made between replays)?
- Is there a timeline for a fix in HIP runtime or the graph replay path?
- Is there a recommended pattern to force the graph to observe external writes (other than inserting a
cudaStreamSynchronize between the write and replay, which would defeat the performance purpose of CUDAGraph)?
Environment
torch.cuda.CUDAGraphfor decodeBug Summary
When a device buffer is written via H2D copy on the default stream immediately before
graph.replay()— also on the same default stream — the in-graph kernel reads a stale value from the previous step instead of the freshly written value.This is not a stream-ordering bug (the H2D copy and replay are on the same stream in program order), not a dangling-pointer bug (the captured pointer equals the live buffer address), and not a concurrent-write bug (the buffer is arange both before and after replay). The stale read is localized to the HIP graph replay path itself.
Symptom
Intermittent
Memory access fault by GPU node-NMEMORY_VIOLATION during LLM decode (CUDAGraph replay path), non-deterministic, absent in eager mode. Triggered by a small integer buffer (cu_seqlens_q, int32, ~513 elements) that is written correctly each step but read stale inside the graph.Debug-agent wave dump at fault:
The buffer should contain
arange(0, 257)(decode: 1 token per request). Instead the graph kernel readqo_end = 995, the cumulative value left by the immediately preceding prefill step.Source location:
for token_id in range(qo_start, qo_end)— the staleqo_enddrivestoken_idto ~995, causing an out-of-bounds load intoken_indices[token_id].Evidence: buffer is physically correct; stale is in the graph's read path
We performed three independent probes to localize the stale:
1. Before-replay D2D snapshot (no host sync)
Right before
graph.replay(), a GPU→GPU copy (non_blocking=True, same default stream) snapshotscu_seqlens_qinto a persistent buffer. A daemon thread later saves it — no synchronization on the hot path.Result (8 ranks × 3000 steps, warmup + steady-state): buffer is always
arange(0, 257), max = 256, zero cumulative rows. The physical device memory is correct before replay.2. After-replay D2D snapshot (rolling ring, no host sync)
Same approach, taken after replay, rolling ring of 512 steps overwritten every 100 steps.
Result (8 ranks × 512 steps covering the crash step): buffer is always
arange(0, 257), max = 256, zero cumulative rows — even at the exact steps preceding the crash.3. In-graph kernel register dump (ROCm debug agent)
HSA_TOOLS_LIB=librocm-debug-agent.so.2, graph mode preserved (not eager),--save-code-objects. Crash reproduced with the unmodified kernel; wave dump captured at fault.Result:
s3 = 0x3e3 = 995= staleqo_endfrom the immediately preceding prefill step (which legitimately wrote cumulative ~988 into the buffer before the current decode step overwrote it with arange). The buffer is provablyarange(probes 1 + 2), yet the graph kernel read995.Conclusion from probes
Same address (
ADDR_PROBEconfirmed capture pointer == live pointer at every replay). Same physical memory (D2D probes confirm arange both sides of replay). Different value inside the graph. The stale is 100% in the graph replay's memory read path.Characteristics
tl.storeto record the loaded value (and clamping the loop to avoid the crash) causes the bug to disappear — the recompiled/recaptured kernel stops exhibiting the stale. This makes in-kernel probes unreliable for this bug; the debug-agent register dump (which does not modify the kernel binary) is the only reliable in-kernel observable.graph_pool_handle.H2D write details
The write path:
Called in
prepare_inputson the default stream, immediately beforegraph.replay()also on the default stream. Program-order synchronization should guarantee visibility. The forward pass is single-stream synchronous with no side-stream switching.Ask
cudaStreamSynchronizebetween the write and replay, which would defeat the performance purpose of CUDAGraph)?