Skip to content

Fix a latent SMEM handoff race in the SM100 DSA indexer backward kernel#426

Open
zkyue wants to merge 1 commit into
NVIDIA:developfrom
zkyue:fix/indexer-bwd-load-warp-mbarrier-arrive
Open

Fix a latent SMEM handoff race in the SM100 DSA indexer backward kernel#426
zkyue wants to merge 1 commit into
NVIDIA:developfrom
zkyue:fix/indexer-bwd-load-warp-mbarrier-arrive

Conversation

@zkyue

@zkyue zkyue commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Before submitting

  • I agree to license this contribution under the terms of LICENSE.txt.
  • I ran pre-commit run and committed any formatting changes. (clang-format n/a — Python only; black 26.3.1 -l 160 passes, file unchanged.)

Affected area

FE OSS kernels or CuTeDSL

Summary

compute-sanitizer --tool racecheck flags RAW hazards on kernel_gemm of
indexer_backward_sm100.py (IndexerBackwardSm100). Every flagged hazard traces to one
defect in _load_warp: all 32 lanes of the load warp store sW / sGradSignal to SMEM,
but only a single elected lane arrives on MBAR_W_LOADED:

# all 32 lanes store:
sGradSignal[pos] = mGradSignal[...]      # ceil(topk/32) stores per lane
sW[idx] = mW[...]                        # ceil(heads/32) stores per lane

cute.arch.fence_view_async_shared()      # proxy fence — does not order threads
with cute.arch.elect_one():
    cute.arch.mbarrier_arrive(mbar + MBAR_W_LOADED)   # one lane only

The compute warpgroup (warps 4–7) does mbarrier_wait(MBAR_W_LOADED) and then reads
sW / sGradSignal with plain shared loads. Per the PTX memory model, mbarrier.arrive
(default .release, .cta scope) releases only the prior memory accesses of the
executing thread, so the stores of the other 31 lanes have no happens-before edge to
the consumer's reads. This is a formal data race.

Fix (2 logical lines): initialize MBAR_W_LOADED with count WARP_SIZE and have all 32
lanes of the load warp arrive. Each lane's release-arrive then orders its own stores
before the consumer's acquire-wait. MBAR_W_LOADED is a one-shot handoff (single arrive
site, single phase-0 wait), so the count change is self-contained.

Why

Evidence that this is a real memory-model violation, not a sanitizer false positive:

  1. Lane-0 exemption. A 1-CTA shape (b=1, s_q=1, s_kv=512, h=64, d=128, topk=128) with
    --racecheck-report hazard --print-limit 0 reports exactly 620 byte-level hazards:
    the recorded writer is always warp-0 lanes 1–31, and the same store instructions
    executed by lane 0 are never flagged. 620 = 4 sGradSignal stores × 31 lanes × 4 B +
    2 sW stores × 31 lanes × 2 B, byte-exact. Racecheck therefore models the mbarrier
    arrive→wait edge and applies it precisely to the arriving thread — matching the PTX
    model, and pinpointing exactly the 31 lanes whose stores are unordered.
  2. No other site is flagged in the tested configurations. The other barriers in kernel_gemm — whole-group
    arrivals (DS_READY / DK_EMPTY / K_LOADED, count = 128) and single-signal
    tcgen05.commit barriers (S_FULL / DK_FULL / K_CONSUMED / DQ_DONE) — report zero
    hazards in the same runs, including at a multi-block shape (b=3, s_q=64, s_kv=2048, topk=512) that
    exercises the full 3-stage sK / 2-stage TMEM pipeline. W_LOADED was the only
    single-elected arrive standing in for whole-warp generic SMEM stores.
  3. Standalone calibration (inlined below, plain CUDA, nvcc -arch=sm_100a):
    • V0, mirroring the kernel (32-lane store → lane-0-only arrive): 124 hazards
      = 31 lanes × 4 B, lane 0 exempt — the same signature as the real kernel.
    • V2 (all 32 lanes arrive, init count 32): 0 hazards.
    • V1 (__syncwarp() then lane-0-only arrive): still 124. This variant is arguably
      correct under the CUDA C++ __syncwarp memory-ordering guarantee, but racecheck does
      not chain the syncwarp edge into the mbarrier release; the all-lane arrive is the
      variant whose ordering is explicit in the PTX memory model and sanitizer-clean, so
      that is what this PR uses.

Why latent. We did not observe corruption on the build we tested (B200, CUDA 13.3,
nvidia-cutlass-dsl 4.6.1): d_index_q / d_weights are bitwise stable across 30 replays
even unpatched, and in the SASS we captured for the topk=128 kernel specialization, ptxas lowers the
release-arrive as an unpredicated MEMBAR.ALL.CTA executed by all lanes, which restores
the ordering as a side effect. That compensation is not contractual — an nvcc 13.3 compile of the same
pattern (calibration V0) contains no MEMBAR before the arrive — so the pattern should
not be relied on across compiler versions. Fixing it also makes kernel_gemm
racecheck-clean, so future real regressions are not buried in baseline noise.

Related issues

None filed; found by running compute-sanitizer over the DSA OSS kernels.

API and compatibility impact

No API change.

  • Numerics: no difference observed on the tested shape/build. d_index_q / d_weights are byte-identical between unpatched
    and patched builds (direct byte comparison of outputs; full-sha256 stable 30/30 replays
    per side at b=3, s_q=64, s_kv=2048, topk=512). d_index_k is accumulated with fp32
    atomics and is run-to-run nondeterministic on both sides (maximum relative deviation from the first replay, over 30
    replays: 4.0e-6 unpatched vs 4.1e-6 patched).
  • Performance: paired nsys kernel-time A/B on B200 (alternating unpatched/patched
    processes, 16 pairs per shape, 200 profiled launches per run, cuda_gpu_kern_sum):
    • b=3, s_q=64, s_kv=2048, topk=512: kernel_gemm −0.014% ± 0.017% (Student-t 95% CI
      on paired deltas) — no measurable change.
    • b=1, s_q=512, s_kv=512, topk=128: kernel_gemm +0.218% ± 0.016% (~+74 ns on a
      34.1 µs launch) — a small cost on this shape. It reproduces with the run
      order swapped (+0.204% ± 0.017%, 8 pairs), while the untouched ScoreGrad kernel
      measured in the same processes stays flat (|Δ| ≤ 0.03%), consistent with a small
      systematic cost rather than measurement noise. Mechanically the patch adds 31 extra
      single-thread arrives on one mbarrier, once per CTA, on the load warp.

Testing

Environment: B200 (SM100), CUDA 13.3, compute-sanitizer 2026.2.0.0, Nsight Systems
2026.3.1, nvidia-cutlass-dsl 4.6.1, torch 2.13, Python package built from this branch
(1.27.0).

racecheck (compute-sanitizer --tool racecheck, kernel driven through
indexer_backward_wrapper; "reports" = default analysis-mode error reports, which
aggregate the underlying per-byte hazards):

shape unpatched patched
b=1, s_q=1, s_kv=512, topk=128 — 1 CTA, --racecheck-report hazard --print-limit 0 620 byte-level hazards 0
b=1, s_q=512, s_kv=512, topk=128 — 512 CTAs, 2 launches 12 reports 0
b=3, s_q=64, s_kv=2048, topk=512 — multi-block 18 reports 0

All unpatched reports point at the sW / sGradSignal stores in _load_warp; no other
site in the kernel is flagged before or after the patch.

pytest (test/python$ pytest fe_api/dsa/ -q, default L0): identical results on
unpatched base and this branch — 26 passed, 4 skipped, 4 failed, with the same 4 failing
cases on both sides (test_DSA_indexer_top_k ×2, test_DSA_sparse_attention_backward ×2
— reference-comparison failures present on unpatched develop@3a9ed3f in our environment,
untouched by this change). All test_DSA_indexer_backward cases pass on both sides.

Numerics and performance: as under "API and compatibility impact".

Calibration repro (mbar_calib.cu) — isolates what racecheck models
// Calibration: does racecheck model mbarrier arrive/wait edges, and for which threads?
// Pattern mirrors upstream indexer_backward_sm100 _load_warp:
//   producer warp0: all 32 lanes STS -> [optional sync] -> lane0-only mbarrier.arrive
//   consumer warp1: mbarrier wait -> LDS
// Variants (compile-time -DVARIANT=):
//   0: no intra-warp sync, lane0-only arrive   (upstream pattern)  -> 124 hazards, lane 0 exempt
//   1: __syncwarp() before lane0-only arrive                       -> 124 hazards
//   2: all-32-lane arrive (init count 32)                          -> 0 hazards
// Build/run: nvcc -arch=sm_100a -DVARIANT=0 mbar_calib.cu && compute-sanitizer --tool racecheck ./a.out
#include <cstdio>
#include <cuda_runtime.h>

#ifndef VARIANT
#define VARIANT 0
#endif

__device__ __forceinline__ void mbar_init(unsigned long long* mbar, unsigned count) {
  unsigned smaddr = (unsigned)__cvta_generic_to_shared(mbar);
  asm volatile("mbarrier.init.shared.b64 [%0], %1;" :: "r"(smaddr), "r"(count));
}
__device__ __forceinline__ void mbar_arrive(unsigned long long* mbar) {
  unsigned smaddr = (unsigned)__cvta_generic_to_shared(mbar);
  asm volatile("{.reg .b64 t; mbarrier.arrive.shared.b64 t, [%0];}" :: "r"(smaddr) : "memory");
}
__device__ __forceinline__ void mbar_wait(unsigned long long* mbar, unsigned phase) {
  unsigned smaddr = (unsigned)__cvta_generic_to_shared(mbar);
  asm volatile(
      "{.reg .pred P;\n"
      "WAIT_LOOP:\n"
      "mbarrier.try_wait.parity.shared.b64 P, [%0], %1;\n"
      "@!P bra WAIT_LOOP;}\n" :: "r"(smaddr), "r"(phase) : "memory");
}

__global__ void producer_consumer(float* out, const float* in) {
  __shared__ float buf[32];
  __shared__ unsigned long long mbar;
  int tid = threadIdx.x;
  int lane = tid % 32;
  int warp = tid / 32;
  if (tid == 0) {
#if VARIANT == 2
    mbar_init(&mbar, 32);
#else
    mbar_init(&mbar, 1);
#endif
  }
  __syncthreads();
  if (warp == 0) {
    buf[lane] = in[lane] * 2.0f;  // all 32 lanes store
#if VARIANT == 0
    if (lane == 0) mbar_arrive(&mbar);
#elif VARIANT == 1
    __syncwarp();
    if (lane == 0) mbar_arrive(&mbar);
#elif VARIANT == 2
    mbar_arrive(&mbar);
#endif
  } else if (warp == 1) {
    mbar_wait(&mbar, 0);
    out[lane] = buf[lane] + 1.0f;  // consumer reads
  }
}

int main() {
  float *d_in, *d_out;
  cudaMalloc(&d_in, 32 * sizeof(float));
  cudaMalloc(&d_out, 32 * sizeof(float));
  cudaMemset(d_in, 0, 32 * sizeof(float));
  producer_consumer<<<1, 64>>>(d_out, d_in);
  cudaError_t e = cudaDeviceSynchronize();
  printf("VARIANT=%d done err=%s\n", VARIANT, cudaGetErrorString(e));
  return 0;
}

Summary by CodeRabbit

  • Bug Fixes
    • Improved synchronization during sparse attention gradient calculations.
    • Resolved potential timing issues when loading shared data, improving kernel correctness and reliability.

In _load_warp all 32 lanes of the load warp store sW / sGradSignal to
SMEM, but only an elected lane arrives on MBAR_W_LOADED. Per the PTX
memory model, mbarrier.arrive (release, cta scope) orders only the
executing thread's prior accesses, so the other 31 lanes' stores have
no happens-before edge to the compute warpgroup's mbarrier_wait and
subsequent reads: a formal data race. Latent in practice: no corruption
observed on the tested B200 / CUDA 13.3 / cutlass-dsl 4.6.1 build,
whose captured SASS (topk=128 specialization) carries an unpredicated
MEMBAR.ALL.CTA before the arrive; that compensation is not contractual.

Fix: initialize MBAR_W_LOADED with count WARP_SIZE and have all 32
lanes arrive, closing the happens-before chain per lane. W_LOADED is a
one-shot handoff (single arrive site, single phase-0 wait), so the
count change is self-contained.

Verified: compute-sanitizer racecheck hazards on kernel_gemm drop to 0
across 1-CTA, 512-CTA and batch=3/topk=512 shapes (previously the only
flagged site in those runs); d_index_q / d_weights byte-identical to
unpatched on the tested shapes; upstream DSA pytest results unchanged.

Signed-off-by: zky <51477259+zkyue@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The SM100 indexer backward kernel changes MBAR_W_LOADED to require warp-wide arrivals and updates _load_warp to signal completion from the whole warp after storing W and grad_signal.

Changes

SM100 barrier synchronization

Layer / File(s) Summary
Warp-wide W-loaded ordering
python/cudnn/deepseek_sparse_attention/indexer_backward/indexer_backward_sm100.py
MBAR_W_LOADED expects self.WARP_SIZE arrivals, while _load_warp uses a whole-warp arrival after shared-memory stores.

Estimated code review effort: 3 (Moderate) | ~15–30 minutes

Suggested reviewers: anerudhan

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: fixing a shared-memory handoff race in the SM100 DSA indexer backward kernel.
Description check ✅ Passed The description follows the template and includes the required sections with detailed summary, rationale, compatibility impact, and testing.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@python/cudnn/deepseek_sparse_attention/indexer_backward/indexer_backward_sm100.py`:
- Around line 544-546: Update the barrier initialization block containing
mbarrier_init for MBAR_W_LOADED and MBAR_DQ_DONE to execute inside
cute.arch.elect_one(), ensuring only one lane performs setup. Keep
cute.arch.sync_threads() after the guarded block.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: e3a74215-9c58-4800-b62a-a1f2551448d7

📥 Commits

Reviewing files that changed from the base of the PR and between 3a9ed3f and 98a5a83.

📒 Files selected for processing (1)
  • python/cudnn/deepseek_sparse_attention/indexer_backward/indexer_backward_sm100.py

Comment on lines +544 to 546
cute.arch.mbarrier_init(mbar + MBAR_W_LOADED, self.WARP_SIZE)
cute.arch.mbarrier_init(mbar + MBAR_DQ_DONE, 1)
cute.arch.sync_threads()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
file='python/cudnn/deepseek_sparse_attention/indexer_backward/indexer_backward_sm100.py'
block="$(sed -n '528,547p' "$file")"
grep -q 'with cute.arch.elect_one()' <<<"$block" || {
  echo "mbarrier initialization block is not elect_one-guarded" >&2
  exit 1
}

Repository: NVIDIA/cudnn-frontend

Length of output: 214


🏁 Script executed:

#!/bin/bash
set -euo pipefail
file='python/cudnn/deepseek_sparse_attention/indexer_backward/indexer_backward_sm100.py'

echo '--- context ---'
sed -n '532,552p' "$file"

echo '--- elect_one usage in repo around mbarrier_init ---'
rg -n -C 2 'mbarrier_init|elect_one\(\)' python/cudnn/deepseek_sparse_attention | sed -n '1,200p'

Repository: NVIDIA/cudnn-frontend

Length of output: 25665


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '532,552p' python/cudnn/deepseek_sparse_attention/indexer_backward/indexer_backward_sm100.py

Repository: NVIDIA/cudnn-frontend

Length of output: 1610


🌐 Web query:

CuTe DSL cute.arch.mbarrier_init elect_one single thread per CTA documentation

💡 Result:

In the NVIDIA CuTe DSL, the cute.arch.elect_one function is a necessary wrapper for operations that must be executed by exactly one thread within a warp for correctness [1][2][3]. When using cute.arch.mbarrier_init, you must wrap the call in a block managed by elect_one. This ensures that the underlying PTX instruction (mbarrier.init.shared.b64) is issued by only one thread, preventing redundant initialization or synchronization errors [2][3][4]. Proper Usage Pattern: with cute.arch.elect_one: cute.arch.mbarrier_init(barrier_ptr, arrival_count) Key Considerations: 1. Single Thread per CTA/Warp: While elect_one selects one thread per warp [2][3], mbarrier_init itself is often described as an operation that must be issued by a single thread per CTA [2][3]. In many kernel implementations, you may see an additional guard (e.g., if warp_idx == 0:) before the elect_one block to ensure only one warp in the CTA performs the initialization [5][6]. 2. Scope: elect_one uses the PTX elect.sync instruction to ensure one thread executes the block while others skip it and reconverge afterward [2][3]. 3. Other Operations: Similar to mbarrier_init, other barrier-related operations such as mbarrier_expect_tx and mbarrier_arrive_and_expect_tx also require wrapping in elect_one [2][7]. 4. Automation: Note that some high-level copy operations (like certain TMA operations) handle single-thread selection implicitly and should not be manually wrapped in elect_one, as this can lead to deadlocks [7]. Always check the specific documentation for the operation being used [8].

Citations:


🌐 Web query:

site:docs.nvidia.com cutlass cute.arch.mbarrier_init elect_one mbarrier_init warp_idx

💡 Result:

In the CUTLASS CuTe DSL, cute.arch.mbarrier_init and cute.arch.elect_one are functions used for hardware-level synchronization [1][2]. cute.arch.elect_one() is a wrapper function that utilizes the PTX elect.sync instruction [1][3]. It selects exactly one thread within a warp to execute the code contained within its context, while all other threads in that warp skip the block and reconverge afterward [1][2]. cute.arch.mbarrier_init is used to initialize an mbarrier in shared memory with a specified thread arrival count [1][4]. Because the underlying PTX mbarrier.init.shared.b64 instruction must be issued by a single thread to ensure correctness, mbarrier_init is required to be wrapped within a cute.arch.elect_one() block [1][2]. Example usage: with cute.arch.elect_one: cute.arch.mbarrier_init(barrier_ptr, arrival_count) warp_idx is a separate function provided by cutlass.cute.arch that returns the index of the warp within a CUDA thread block (CTA) [5][6]. It is not directly related to the required elect_one pattern for barrier initialization, but it is part of the same cute.arch module that provides core hardware introspection functions [7][6]. It is important to note that while some operations (like mbarrier_init and mbarrier_expect_tx) require explicit elect_one wrapping, others (such as certain TMA copy operations) handle this internally [1][8]. Wrapping operations that already handle single-threaded execution internally in elect_one can cause GPU deadlocks [8][9].

Citations:


Guard mbarrier_init with elect_one(). if warp_idx == 0 still runs on all 32 lanes of warp 0, so this barrier setup is not single-threaded. Wrap the initialization block in with cute.arch.elect_one(): and keep sync_threads() afterward.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@python/cudnn/deepseek_sparse_attention/indexer_backward/indexer_backward_sm100.py`
around lines 544 - 546, Update the barrier initialization block containing
mbarrier_init for MBAR_W_LOADED and MBAR_DQ_DONE to execute inside
cute.arch.elect_one(), ensuring only one lane performs setup. Keep
cute.arch.sync_threads() after the guarded block.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant