Fix ASM split-K semaphore deadlock under CUDA graph capture - #4494
Merged
amd-ruitang3 merged 2 commits intoAug 12, 2026
Conversation
get_semaphore_workspace() caches the split-K atomic counter per
(device, stream). A captured CUDA graph bakes that pointer in and then
replays on a stream other than the capture stream, so the cached counter
can be left non-zero between replays. The a16w16 ASM split-K kernels gate
their reduction phase on the counter, so the reduction never fires and the
waves spin forever -- a wedged queue rather than a fault.
Return a fresh workspace per launch while a capture is in progress.
Allocating under capture also records the zero-fill as a graph node, so
every replay re-establishes the counter==0 entry invariant. The buffer is
retained for the process lifetime because aiter cannot observe when a graph
dies; releasing it would let a later allocation from the same graph private
pool reuse a block the graph still writes to on replay.
Eager launches are unaffected: the stream-keyed cache is still used
whenever no capture is in progress.
Validated on 8x MI355X, Kimi-K3 TP8, DSpark speculative decoding, with the
draft model captured into full CUDA graphs:
- before: server hangs during warmup; rocgdb shows every wave of
bf16gemm_fp32bf16_tn_64x64_splitk_clean parked on a global-memory spin
with the counter reading 2 while waiting for 0.
- after: cudagraph capture 8/8, 0 faults, serves.
- GSM8K 100q 5-shot temp 0: accuracy 0.990 (non-speculative reference
0.99), 0 invalid.
- Throughput at batch 1: 120.9 output tok/s vs 114.7 with the draft run
eagerly; 8K/1K median ITL 29.18 ms vs 30.4-30.8 ms.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yanyuan Qin <yanyuan.qin@amd.com>
Contributor
🏷️ CI GuideRuns automatically on every PR:
Extended tests (opt-in via labels):
|
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes a GPU deadlock that can occur when capturing and replaying CUDA graphs that include the a16w16 ASM split-K GEMM path, by avoiding reuse of a cached split-K semaphore counter during capture.
Changes:
- Allocate a fresh, zero-initialized semaphore workspace for each launch when CUDA graph capture is active.
- Retain capture-allocated workspaces for process lifetime to prevent reuse hazards with graph-private memory pools.
- Keep the existing per-(device, stream)
lru_cachebehavior for non-capture (eager) execution.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Reproduce the observed stale semaphore value in a subprocess and validate two captured split-K launches across repeated graph replay. Assisted-by: OpenAI Codex Signed-off-by: Yanyuan Qin <yanyuan.qin@amd.com>
amd-ruitang3
approved these changes
Aug 12, 2026
amd-ruitang3
added a commit
that referenced
this pull request
Aug 12, 2026
13 tasks
3 tasks
JohnQinAMD
added a commit
to JohnQinAMD/aiter-amd
that referenced
this pull request
Aug 21, 2026
The a16w16 ASM split-K kernels gate their reduction phase on an atomic counter that must be zero when a launch starts. get_semaphore_workspace() zeroes that counter exactly once, where it allocates it. A CUDA graph records kernel launches, not the allocation, so a replay cannot re-establish the invariant: a counter left non-zero stays non-zero, the reduction never fires and the waves spin forever -- a wedged queue rather than a fault. Kimi-K3 TP8 with a graphed speculative draft hangs during warmup on 8x MI355X. Hand out a slot from a fixed per-device ring while a capture is in progress, and record the zero-fill inside the graph so every replay starts from a zero counter. The ring is allocated on the eager path -- hosts warm a shape up eagerly before any graph records it -- so it is ordinary allocator memory rather than graph private-pool memory, which could not safely be freed while any graph that recorded it may still replay. The cost is a constant 64 * 4 KiB = 256 KiB per device plus one memset node per recorded launch, and nothing accumulates across captures. One slot per recorded launch also gives concurrent replays private counters, the separation the stream key provides on the eager path. Recording more split-K launches than the ring has slots shares slots again and warns once; it cannot deadlock, because the zero-fill is still recorded per launch. Kimi-K3 TP8 records 29 of the 64. Eager launches are unaffected: the stream-keyed cache is still used whenever no capture is in progress. This supersedes ROCm#4494, which allocated the workspace inside the capture region and therefore had to retain every one of them in a list that never shrank. Signed-off-by: Yanyuan Qin <yanyuan.qin@amd.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
JohnQinAMD
added a commit
to JohnQinAMD/aiter-amd
that referenced
this pull request
Aug 21, 2026
The a16w16 ASM split-K kernels gate their reduction phase on an atomic counter that must be zero when a launch starts. get_semaphore_workspace() zeroes that counter exactly once, where it allocates it. A CUDA graph records kernel launches, not the allocation, so a replay cannot re-establish the invariant: a counter left non-zero stays non-zero, the reduction never fires and the waves spin forever -- a wedged queue rather than a fault. Kimi-K3 TP8 with a graphed speculative draft hangs during warmup on 8x MI355X. Hand out a slot from a fixed per-device ring while a capture is in progress, and record the zero-fill inside the graph so every replay starts from a zero counter. The ring is allocated on the eager path -- hosts warm a shape up eagerly before any graph records it -- so it is ordinary allocator memory rather than graph private-pool memory, which could not safely be freed while any graph that recorded it may still replay. The cost is a constant 64 * 4 KiB = 256 KiB per device plus one memset node per recorded launch, and nothing accumulates across captures. One slot per recorded launch also gives concurrent replays private counters, the separation the stream key provides on the eager path. Recording more split-K launches than the ring has slots shares slots again and warns once; it cannot deadlock, because the zero-fill is still recorded per launch. Kimi-K3 TP8 records 29 of the 64. Eager launches are unaffected: the stream-keyed cache is still used whenever no capture is in progress. This supersedes ROCm#4494, which allocated the workspace inside the capture region and therefore had to retain every one of them in a list that never shrank. Signed-off-by: Yanyuan Qin <yanyuan.qin@amd.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
JohnQinAMD
added a commit
to JohnQinAMD/aiter-amd
that referenced
this pull request
Aug 21, 2026
The a16w16 ASM split-K kernels gate their reduction phase on an atomic counter that must be zero when a launch starts. get_semaphore_workspace() zeroes that counter exactly once, where it allocates it. A CUDA graph records kernel launches, not the allocation, so a replay cannot re-establish the invariant: a counter left non-zero stays non-zero, the reduction never fires and the waves spin forever -- a wedged queue rather than a fault. Kimi-K3 TP8 with a graphed speculative draft hangs during warmup on 8x MI355X. Hand out a slot from a fixed per-device ring while a capture is in progress, and record the zero-fill inside the graph so every replay starts from a zero counter. The ring is allocated on the eager path -- hosts warm a shape up eagerly before any graph records it -- so it is ordinary allocator memory rather than graph private-pool memory, which could not safely be freed while any graph that recorded it may still replay. The cost is a constant 64 * 4 KiB = 256 KiB per device plus one memset node per recorded launch, and nothing accumulates across captures. One slot per recorded launch also gives concurrent replays private counters, the separation the stream key provides on the eager path. Recording more split-K launches than the ring has slots shares slots again and warns once; it cannot deadlock, because the zero-fill is still recorded per launch. Kimi-K3 TP8 records 29 of the 64. Eager launches are unaffected: the stream-keyed cache is still used whenever no capture is in progress. This supersedes ROCm#4494, which allocated the workspace inside the capture region and therefore had to retain every one of them in a list that never shrank. Signed-off-by: Yanyuan Qin <yanyuan.qin@amd.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
JohnQinAMD
added a commit
to JohnQinAMD/aiter-amd
that referenced
this pull request
Aug 21, 2026
The a16w16 ASM split-K kernels gate their reduction phase on an atomic counter that must be zero when a launch starts. get_semaphore_workspace() zeroes that counter exactly once, where it allocates it. A CUDA graph records kernel launches, not the allocation, so a replay cannot re-establish the invariant: a counter left non-zero stays non-zero, the reduction never fires and the waves spin forever -- a wedged queue rather than a fault. Kimi-K3 TP8 with a graphed speculative draft hangs during warmup on 8x MI355X. Hand out a slot from a fixed per-device ring while a capture is in progress, and record the zero-fill inside the graph so every replay starts from a zero counter. The ring is allocated on the eager path -- hosts warm a shape up eagerly before any graph records it -- so it is ordinary allocator memory rather than graph private-pool memory, which could not safely be freed while any graph that recorded it may still replay. The cost is a constant 64 * 4 KiB = 256 KiB per device plus one memset node per recorded launch, and nothing accumulates across captures. One slot per recorded launch also gives concurrent replays private counters, the separation the stream key provides on the eager path. Recording more split-K launches than the ring has slots shares slots again and warns once; it cannot deadlock, because the zero-fill is still recorded per launch. Kimi-K3 TP8 records 29 of the 64. Eager launches are unaffected: the stream-keyed cache is still used whenever no capture is in progress. This supersedes ROCm#4494, which allocated the workspace inside the capture region and therefore had to retain every one of them in a list that never shrank. Signed-off-by: Yanyuan Qin <yanyuan.qin@amd.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
JohnQinAMD
added a commit
to JohnQinAMD/aiter-amd
that referenced
this pull request
Aug 21, 2026
The a16w16 ASM split-K kernels gate their reduction phase on an atomic counter that must be zero when a launch starts. get_semaphore_workspace() zeroes that counter exactly once, where it allocates it. A CUDA graph records kernel launches, not the allocation, so a replay cannot re-establish the invariant: a counter left non-zero stays non-zero, the reduction never fires and the waves spin forever -- a wedged queue rather than a fault. Kimi-K3 TP8 with a graphed speculative draft hangs during warmup on 8x MI355X. Hand out a slot from a fixed per-device ring while a capture is in progress, and record the zero-fill inside the graph so every replay starts from a zero counter. The ring is allocated on the eager path -- hosts warm a shape up eagerly before any graph records it -- so it is ordinary allocator memory rather than graph private-pool memory, which could not safely be freed while any graph that recorded it may still replay. The cost is a constant 64 * 4 KiB = 256 KiB per device plus one memset node per recorded launch, and nothing accumulates across captures. One slot per recorded launch also gives concurrently replayed launches private counters, the separation the stream key provides on the eager path. That protection is bounded rather than absolute: past the ring's size slots are reused, and two launches sharing one can interleave again if they replay concurrently. Kimi-K3 TP8 records 29 of the 64 and replays serially. Eager launches are unaffected: the stream-keyed cache is still used whenever no capture is in progress. They do now pay a one-time 256 KiB allocation per device, even in a process that never captures a graph. This supersedes ROCm#4494, which allocated the workspace inside the capture region and therefore had to retain every one of them in a list that never shrank. Signed-off-by: Yanyuan Qin <yanyuan.qin@amd.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
JohnQinAMD
added a commit
to JohnQinAMD/aiter-amd
that referenced
this pull request
Aug 21, 2026
The a16w16 ASM split-K kernels gate their reduction phase on an atomic counter that must be zero when a launch starts. get_semaphore_workspace() zeroes that counter exactly once, where it allocates it. A CUDA graph records kernel launches, not the allocation, so a replay cannot re-establish the invariant: a counter left non-zero stays non-zero, the reduction never fires and the waves spin forever -- a wedged queue rather than a fault. Kimi-K3 TP8 with a graphed speculative draft hangs during warmup on 8x MI355X. Hand out a slot from a fixed per-device ring while a capture is in progress, and record the zero-fill inside the graph so every replay starts from a zero counter. The ring is allocated on the eager path -- hosts warm a shape up eagerly before any graph records it -- so it is ordinary allocator memory rather than graph private-pool memory, which could not safely be freed while any graph that recorded it may still replay. Sizing: every recorded ASM a16w16 launch takes a slot, including splitK=None, the tuned_gemm default for untuned bpreshuffled bf16, where the C++ heuristic may still pick split==1 and ignore the buffer. A host capturing tens of batch sizes across tens of layers records thousands of launches, so the ring is 4096 slots -- a fixed 16 MiB per device. Past it slots are reused: sequential launches on one stream stay safe, because the kernel restores the counter and the recorded zero-fill is ordered before its launch, but two graphs replayed concurrently on different streams can then share one counter. A warning fires once per device at that point. The capture check is scoped to the workspace's device, since is_current_stream_capturing() answers about the current device and out.device need not be it. Eager launches are unaffected: the stream-keyed cache is still used whenever no capture is in progress. They do now pay a one-time 16 MiB allocation per device, even in a process that never captures a graph. This supersedes ROCm#4494, which allocated the workspace inside the capture region and therefore had to retain every one of them in a list that never shrank. Signed-off-by: Yanyuan Qin <yanyuan.qin@amd.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
JohnQinAMD
added a commit
to JohnQinAMD/aiter-amd
that referenced
this pull request
Aug 21, 2026
The a16w16 ASM split-K kernels gate their reduction phase on an atomic counter that must be zero when a launch starts. get_semaphore_workspace() zeroes that counter exactly once, where it allocates it. A CUDA graph records kernel launches, not the allocation, so a replay cannot re-establish the invariant: a counter left non-zero stays non-zero, the reduction never fires and the waves spin forever -- a wedged queue rather than a fault. Kimi-K3 TP8 with a graphed speculative draft hangs during warmup on 8x MI355X. Hand out a slot from a fixed per-device ring while a capture is in progress, and record the zero-fill inside the graph so every replay starts from a zero counter. The ring is allocated on the eager path -- hosts warm a shape up eagerly before any graph records it -- so it is ordinary allocator memory rather than graph private-pool memory, which could not safely be freed while any graph that recorded it may still replay. Sizing: every recorded ASM a16w16 launch takes a slot, including splitK=None, the tuned_gemm default for untuned bpreshuffled bf16, where the C++ heuristic may still pick split==1 and ignore the buffer. A host capturing tens of batch sizes across tens of layers records thousands of launches, so the ring is 4096 slots -- a fixed 16 MiB per device. Past it slots are reused: sequential launches on one stream stay safe, because the kernel restores the counter and the recorded zero-fill is ordered before its launch, but two graphs replayed concurrently on different streams can then share one counter. A warning fires once per device at that point. The capture check is scoped to the workspace's device, since is_current_stream_capturing() answers about the current device and out.device need not be it. Eager launches are unaffected: the stream-keyed cache is still used whenever no capture is in progress. They do now pay a one-time 16 MiB allocation per device, even in a process that never captures a graph. This supersedes ROCm#4494, which allocated the workspace inside the capture region and therefore had to retain every one of them in a list that never shrank. Signed-off-by: Yanyuan Qin <yanyuan.qin@amd.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
JohnQinAMD
added a commit
to JohnQinAMD/aiter-amd
that referenced
this pull request
Aug 21, 2026
The a16w16 ASM split-K kernels gate their reduction phase on an atomic counter that must be zero when a launch starts. get_semaphore_workspace() zeroes that counter exactly once, where it allocates it. A CUDA graph records kernel launches, not the allocation, so a replay cannot re-establish the invariant: a counter left non-zero stays non-zero, the reduction never fires and the waves spin forever -- a wedged queue rather than a fault. Kimi-K3 TP8 with a graphed speculative draft hangs during warmup on 8x MI355X. Hand out a slot from a fixed per-device ring while a capture is in progress, and record the zero-fill inside the graph so every replay starts from a zero counter. The ring is allocated on the eager path -- hosts warm a shape up eagerly before any graph records it -- so it is ordinary allocator memory rather than graph private-pool memory, which could not safely be freed while any graph that recorded it may still replay. Sizing: every recorded ASM a16w16 launch takes a slot, including splitK=None, the tuned_gemm default for untuned bpreshuffled bf16, where the C++ heuristic may still pick split==1 and ignore the buffer. A host capturing tens of batch sizes across tens of layers records thousands of launches, so the ring is 4096 slots -- a fixed 16 MiB per device. Past it slots are reused: sequential launches on one stream stay safe, because the kernel restores the counter and the recorded zero-fill is ordered before its launch, but two graphs replayed concurrently on different streams can then share one counter. A warning fires once per device at that point. The capture check is scoped to the workspace's device, since is_current_stream_capturing() answers about the current device and out.device need not be it. Eager launches are unaffected: the stream-keyed cache is still used whenever no capture is in progress. They do now pay a one-time 16 MiB allocation per device, even in a process that never captures a graph. This supersedes ROCm#4494, which allocated the workspace inside the capture region and therefore had to retain every one of them in a list that never shrank. Signed-off-by: Yanyuan Qin <yanyuan.qin@amd.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
JohnQinAMD
added a commit
to JohnQinAMD/aiter-amd
that referenced
this pull request
Aug 21, 2026
The split-K counter must be zero when a launch starts, and aiter zeroes it only where it allocates it. A CUDA graph records launches, not that allocation, so replay starts from whatever the counter held, the reduction never fires and the waves spin. Kimi-K3 TP8 with a graphed draft hangs during warmup on 8x MI355X. Give each recorded launch a slot from a per-device pool allocated on the eager path, and record the zero-fill inside the graph. Exhausting the pool raises rather than reusing a slot, since two graphs sharing a counter deadlock when replayed concurrently. Eager launches are unchanged. Supersedes ROCm#4494, which allocated under capture and therefore had to retain every workspace in a list that never shrank. Signed-off-by: Yanyuan Qin <yanyuan.qin@amd.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
JohnQinAMD
added a commit
to JohnQinAMD/aiter-amd
that referenced
this pull request
Aug 24, 2026
The split-K counter must be zero when a launch starts, and aiter zeroes it only where it allocates it. A CUDA graph records launches, not that allocation, so replay starts from whatever the counter held, the reduction never fires and the waves spin. Kimi-K3 TP8 with a graphed draft hangs during warmup on 8x MI355X. Give each recorded launch a slot from a per-device pool allocated on the eager path, and record the zero-fill inside the graph. Exhausting the pool raises rather than reusing a slot, since two graphs sharing a counter deadlock when replayed concurrently. Eager launches are unchanged. Supersedes ROCm#4494, which allocated under capture and therefore had to retain every workspace in a list that never shrank. Signed-off-by: Yanyuan Qin <yanyuan.qin@amd.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
JohnQinAMD
added a commit
to JohnQinAMD/aiter-amd
that referenced
this pull request
Aug 24, 2026
The split-K counter must be zero when a launch starts, and aiter zeroes it only where it allocates it. A CUDA graph records launches, not that allocation, so replay starts from whatever the counter held, the reduction never fires and the waves spin. Kimi-K3 TP8 with a graphed draft hangs during warmup on 8x MI355X. Give each recorded launch a slot from a per-device pool allocated on the eager path, and record the zero-fill inside the graph. Exhausting the pool raises rather than reusing a slot, since two graphs sharing a counter deadlock when replayed concurrently. Eager launches are unchanged. Supersedes ROCm#4494, which allocated under capture and therefore had to retain every workspace in a list that never shrank. Signed-off-by: Yanyuan Qin <yanyuan.qin@amd.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
JohnQinAMD
added a commit
to JohnQinAMD/aiter-amd
that referenced
this pull request
Aug 24, 2026
The split-K counter must be zero when a launch starts, and aiter zeroes it only where it allocates it. A CUDA graph records launches, not that allocation, so replay starts from whatever the counter held, the reduction never fires and the waves spin. Kimi-K3 TP8 with a graphed draft hangs during warmup on 8x MI355X. Give each recorded launch a slot from a per-device pool allocated on the eager path, and record the zero-fill inside the graph. Exhausting the pool raises rather than reusing a slot, since two graphs sharing a counter deadlock when replayed concurrently. Eager launches are unchanged. Supersedes ROCm#4494, which allocated under capture and therefore had to retain every workspace in a list that never shrank. Signed-off-by: Yanyuan Qin <yanyuan.qin@amd.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
JohnQinAMD
added a commit
to JohnQinAMD/aiter-amd
that referenced
this pull request
Aug 24, 2026
The split-K counter must be zero when a launch starts, and aiter zeroes it only where it allocates it. A CUDA graph records launches, not that allocation, so replay starts from whatever the counter held, the reduction never fires and the waves spin. Kimi-K3 TP8 with a graphed draft hangs during warmup on 8x MI355X. Give each recorded launch a slot from a per-device pool allocated on the eager path, and record the zero-fill inside the graph. Exhausting the pool raises rather than reusing a slot, since two graphs sharing a counter deadlock when replayed concurrently. Eager launches are unchanged. Supersedes ROCm#4494, which allocated under capture and therefore had to retain every workspace in a list that never shrank. Signed-off-by: Yanyuan Qin <yanyuan.qin@amd.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
JohnQinAMD
added a commit
to JohnQinAMD/aiter-amd
that referenced
this pull request
Aug 24, 2026
The split-K counter must be zero when a launch starts, and aiter zeroes it only where it allocates it. A CUDA graph records launches, not that allocation, so replay starts from whatever the counter held, the reduction never fires and the waves spin. Kimi-K3 TP8 with a graphed draft hangs during warmup on 8x MI355X. Give each recorded launch a slot from a per-device pool allocated on the eager path, and record the zero-fill inside the graph. Exhausting the pool raises rather than reusing a slot, since two graphs sharing a counter deadlock when replayed concurrently. Eager launches are unchanged. Supersedes ROCm#4494, which allocated under capture and therefore had to retain every workspace in a list that never shrank. Signed-off-by: Yanyuan Qin <yanyuan.qin@amd.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
JohnQinAMD
added a commit
to JohnQinAMD/aiter-amd
that referenced
this pull request
Aug 24, 2026
The split-K counter must be zero when a launch starts, and aiter zeroes it only where it allocates it. A CUDA graph records launches, not that allocation, so replay starts from whatever the counter held, the reduction never fires and the waves spin. Kimi-K3 TP8 with a graphed draft hangs during warmup on 8x MI355X. Give each recorded launch a slot from a per-device pool allocated on the eager path, and record the zero-fill inside the graph. Exhausting the pool raises rather than reusing a slot, since two graphs sharing a counter deadlock when replayed concurrently. Eager launches are unchanged. Supersedes ROCm#4494, which allocated under capture and therefore had to retain every workspace in a list that never shrank. Signed-off-by: Yanyuan Qin <yanyuan.qin@amd.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
JohnQinAMD
added a commit
to JohnQinAMD/aiter-amd
that referenced
this pull request
Aug 24, 2026
The split-K counter must be zero when a launch starts, and aiter zeroes it only where it allocates it. A CUDA graph records launches, not that allocation, so replay starts from whatever the counter held, the reduction never fires and the waves spin. Kimi-K3 TP8 with a graphed draft hangs during warmup on 8x MI355X. Give each recorded launch a slot from a per-device pool allocated on the eager path, and record the zero-fill inside the graph. Exhausting the pool raises rather than reusing a slot, since two graphs sharing a counter deadlock when replayed concurrently. Eager launches are unchanged. Supersedes ROCm#4494, which allocated under capture and therefore had to retain every workspace in a list that never shrank. Signed-off-by: Yanyuan Qin <yanyuan.qin@amd.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
JohnQinAMD
added a commit
to JohnQinAMD/aiter-amd
that referenced
this pull request
Aug 24, 2026
The split-K counter must be zero when a launch starts, and aiter zeroes it only where it allocates it. A CUDA graph records launches, not that allocation, so replay starts from whatever the counter held, the reduction never fires and the waves spin. Kimi-K3 TP8 with a graphed draft hangs during warmup on 8x MI355X. Give each recorded launch a slot from a per-device pool allocated on the eager path, and record the zero-fill inside the graph. Exhausting the pool raises rather than reusing a slot, since two graphs sharing a counter deadlock when replayed concurrently. Eager launches are unchanged. Supersedes ROCm#4494, which allocated under capture and therefore had to retain every workspace in a list that never shrank. Signed-off-by: Yanyuan Qin <yanyuan.qin@amd.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
JohnQinAMD
added a commit
to JohnQinAMD/aiter-amd
that referenced
this pull request
Aug 24, 2026
The split-K counter must be zero when a launch starts, and aiter zeroes it only where it allocates it. A CUDA graph records launches, not that allocation, so replay starts from whatever the counter held, the reduction never fires and the waves spin. Kimi-K3 TP8 with a graphed draft hangs during warmup on 8x MI355X. Give each recorded launch a slot from a per-device pool allocated on the eager path, and record the zero-fill inside the graph. Exhausting the pool raises rather than reusing a slot, since two graphs sharing a counter deadlock when replayed concurrently. Eager launches are unchanged. Supersedes ROCm#4494, which allocated under capture and therefore had to retain every workspace in a list that never shrank. Signed-off-by: Yanyuan Qin <yanyuan.qin@amd.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
JohnQinAMD
added a commit
to JohnQinAMD/aiter-amd
that referenced
this pull request
Aug 24, 2026
The split-K counter must be zero when a launch starts, and aiter zeroes it only where it allocates it. A CUDA graph records launches, not that allocation, so replay starts from whatever the counter held, the reduction never fires and the waves spin. Kimi-K3 TP8 with a graphed draft hangs during warmup on 8x MI355X. Give each recorded launch a slot from a per-device pool allocated on the eager path, and record the zero-fill inside the graph. Exhausting the pool raises rather than reusing a slot, since two graphs sharing a counter deadlock when replayed concurrently. Eager launches are unchanged. Supersedes ROCm#4494, which allocated under capture and therefore had to retain every workspace in a list that never shrank. Signed-off-by: Yanyuan Qin <yanyuan.qin@amd.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Purpose
What breaks
An ASM a16w16 split-K GEMM recorded into a CUDA graph can spin forever on replay. The device wedges; it does not return a wrong number and it does not recover. Kimi-K3 TP8 with DSpark on 8x MI355X hangs during warmup as soon as the draft is graphed, so the server never becomes ready. The workaround was to bypass split-K or keep the draft eager, at a throughput cost.
What this change touches
aiter/ops/gemm_op_a16w16.pyop_tests/test_gemm_a16w16_graph.pyNo kernel code is modified. The ASM binaries are untouched; the change is entirely in who owns the semaphore workspace.
The FlyDSL split-K HGEMM shares the same host-side defect in
aiter/ops/flydsl/gemm_kernels.py, and it is deliberately not fixed here. It is not reachable in production today -- the family that hangs,small_m, is not dispatched (iter_small_m_registry_configsis commented out atgemm_kernels.py:24, andaiter/configs/holds 0small_mentries against 606 FlyDSL split-K entries), and the family that is dispatched,hgemm, tolerates a dirty counter. Because there is no time pressure there, it is being addressed properly rather than with the same patch: those kernels are generated from the Python DSL, so the counter can be removed outright in favour of an fp32 workspace plus a separate reduce kernel, the waycsrc/opus_gemmalready does it. That work is tracked separately and does not gate this PR.The defect, in code
Split-K spreads one GEMM's K reduction across blocks that coordinate through an atomic counter. The kernel requires the counter to be zero when a launch starts. Blocks then count in, the last one to arrive does the reduction and writes the counter back to zero for the next launch.
The counter is zeroed exactly once, where it is allocated (
aiter/ops/gemm_op_a16w16.py:36):Under graph capture both properties this rests on are lost:
memset. Nothing restores the entry state on replay. (If the very first call ever happens inside capture the memset is recorded -- but the second graph captured on that stream gets the cached tensor and no memset, so the exposure returns.)So a counter left non-zero stays non-zero forever: clearing it was never part of the graph.
Point 1 is what the observed hang is. Point 2 is a lost guarantee rather than an observed failure -- 200 concurrent replays of two graphs captured on one stream produced no corruption in testing, though that run cannot prove the replays actually overlapped on the device. It is recorded because the cache was keyed by stream precisely to prevent it, and capture removes that protection.
How it was found
rocgdbon the hung endpoint: all 132 waves ofbf16gemm_fp32bf16_tn_64x64_splitk_cleanspinning on one counter, which held2while the kernel waited for0. That is what named the invariant.Why CI did not catch it
Three things had to line up, and they explain the shape of the test below.
torch.cuda.graph; none of them is a split-K GEMM, and no split-K test captured. The intersection was empty.rocgdbon a live server found it and CI could not.The new test sits at
op_tests/depth 1, so.github/scripts/split_tests.sh(find op_tests -maxdepth 1 -name 'test_*.py') collects it.The fix
While capture is active, hand out a fresh zero-initialized workspace per launch and keep a reference to it. Eager execution keeps the existing cache:
The allocation happens inside the capture region, so its zero-fill is recorded as a graph node and the entry state is restored on every replay. Each recorded launch also gets a private counter, restoring the separation the stream key used to provide. GEMM math, kernel selection and the eager path are unchanged.
Why not the smaller fix. Recording a
zero_()on the cached workspace is shorter and retains no memory at all. It was built and measured on the FlyDSL twin of this defect, and it does clear the hang. It was not taken because it moves thelru_cachelookup inside the capture region, and the capture stream is always new, so that lookup always misses (measured:lru_cachemisses go 1 -> 2 during capture, versus unchanged for this PR's shape). A miss under capture meanstorch.zerosruns inside the capture region, so the cache ends up holding memory from that graph's private pool, keyed on the capture stream. Any later eager launch on that stream would then use graph-pool memory outside the graph. Returning before the cache is consulted avoids that.Cost. One
memsetnode and one 4096 B workspace (_SEMA_SHAPE = (16, 64)uint32) per recorded split-K launch, summed over every graph the process captures. Only launches that actually take the split-K path count --splitK <= 1returns an empty tensor and never reaches this code.Measured rather than left to the reader, with temporary counting instrumentation that is not part of this change. Kimi-K3 TP8 with DSpark on 8x MI355X,
FULL_AND_PIECEWISE, eight captured graph sizes: 29 workspaces, 0.11 MiB per rank, identical on all eight ranks, against a--gpu-memory-utilization 0.88budget on 288 GB cards. The count is small because few shapes selectsplitK > 1.The list grows only during startup capture. It grows only while capture is active, and in the hosts that reach this kernel capture is a bounded startup phase:
validate_cudagraph_capturing_enabled()before capturing (vllm/compilation/breakable_cudagraph.py,vllm/compilation/cuda_graph.py), which throwsRuntimeError: CUDA graph capturing detected at an inappropriate timeunless the window is open. The window is opened and closed twice, both insidegpu_model_runner.pyduring startup: once around the encoder memory estimate, once aroundcapture_model(). A runtime capture is a hard error, not a silent accumulation. vLLM does reach this kernel --model_executor/layers/utils.py->aiter.tuned_gemm.tgemm->gemm_a16w16_asm-- so the path is reachable, just not repeatable.run_perftestdefaults totestGraph=Falseandcsrc/gemm_a16w16/gemm_a16w16_tune.pynever sets it, so the tuner sweep records no graphs regardless of how many candidates it evaluates.A host that captured repeatedly for the lifetime of the run would grow the list without bound. The mechanism that would do that in the wider ecosystem is inductor's
cudagraph_treesre-recording undertorch.compile(mode="reduce-overhead"), which requires calling AITER's tuned GEMM directly rather than through vLLM or SGLang. No such caller is known today, and neither serving host can enter that state.Tensors allocated during capture come from the graph's private memory pool, so they cannot simply be freed while any graph that recorded them may still replay. AITER has no hook for graph destruction, so it cannot know when that stops being true, and holds them for the process lifetime instead. Under vLLM this costs the literal 4096 B and nothing beyond it: every capture shares one process-global pool (
_global_graph_poolinvllm/platforms/interface.py) that is never released, so a retained reference pins nothing that was not already permanent for the life of the process.How other aiter operators handle capture
Three approaches now exist in tree, and the difference is what each one needs from the graph:
csrc/opus_gemm,aiter/tuned_gemm.py)aiter/dist/device_communicators/custom_all_reduce.py)register_graph_buffers()after it endsThe opus case is the one to compare against, because
_opus_prewarm_capture_workspacedeliberately does the opposite of this PR: it sizes its workspace on the exact streamtorch.cuda.graphwill capture on before capture starts, and no-ops if already capturing. That works because opus only needs its workspace to exist and be registered. Pre-warming cannot fix a per-replay precondition: a pre-warmed workspace is zeroed once, the graph still records nomemset, and replay N still cannot recover a dirty counter. Only something inside the graph can, which is why this fix allocates under capture rather than ahead of it.The opus shape -- each split writing its fp32 partial to a disjoint slice of a
[split_k, M, N]workspace, with a separate reduce kernel summing along the split axis and ordering coming from the stream -- removes the reason the counter exists rather than protecting it. That option is not available here: these kernels ship as precompiledhsa/gfx950/bf16gemm/*.cobinaries, so the semaphore fix is the only one available for the ASM path, not an interim step.Test plan
op_tests/test_gemm_a16w16_graph.pyseeds the capture-stream counter with2-- the valuerocgdbfound -- captures two shipping(M, N, K) = (64, 256, 5120),splitK=13launches, replays four times, and compares both outputs against eager. Seeding rather than racing for the residue makes it deterministic; the resulting entry state, and the hang, are the production ones.The GPU work runs in a subprocess behind a timeout, because the unfixed control wedges the device rather than failing.
Test results
MI355X (gfx950), one idle GPU per run.
3a79aec3b71393a36351e2d0512414d9a1ba78131 passed in 24.39s, including a fresh JIT buildgit diff --checkEnd to end, Kimi-K3 TP8 with DSpark on 8x MI355X:
The captured endpoint scored 99/100 on the frozen GSM8K-100 gate with zero invalid responses, reached 120.9 output tok/s against 114.7 with the eager draft workaround, and logged zero memory faults.
Overlap and limits
The change is limited to who owns the semaphore workspace under capture. No kernel is modified, and the eager path is byte-for-byte the same code as before.
The unfixed control intentionally exercises a deadlock and can stall the selected GPU until killed; it is not a routine shared-runner test.
Tool assistance
Claude Opus 5 (1M context) assisted with the initial semaphore fix and drafting this description. OpenAI Codex assisted with the regression test and validation review.
Update -- 2026-08-13
A note for anyone arriving here from the revert.
This was merged as
c6ce60con 2026-08-12 at 09:44 UTC and reverted by #4709 (518cfbc) at 16:53 UTC the same day, somaincurrently carries neither the fix norop_tests/test_gemm_a16w16_graph.py, and the Kimi-K3 graphed-draft hang is present again.The concern was VRAM growth from
_captured_semaphore_keepalive, which is a fair thing to ask about a list with no eviction. Looking into it more carefully, the growth does seem to be confined to startup in the hosts that reach this kernel; the Cost. section above has been updated with what I found, including vLLM's capture-window check. The original wording there called the repeated-capture case "a real limitation of this approach" without mentioning that no current host can reach that state, which I think reads more alarming than it should. That was my wording to fix rather than anyone's misreading.If there is appetite for a re-land, a version that sidesteps the question altogether might be easier to review: a small ring of workspaces pre-allocated on the eager path, with
zero_()recorded inside the graph -- a fixed 256 KiB per device, no growth, and no graph-pool memory retained. Happy to put that up instead if it would help. Everything above describes the reverted commit as merged, not that version.