Skip to content

[SM121 / GB10 (DGX Spark)] moe_wna16_marlin_gemm no-split-K (data-parallel) path: CUDA illegal memory access at M=256 — clean for M≤128; default split-K path clean at M=256 #56064

Open

Description

@pasta-paul

Environment: NVIDIA DGX Spark (GB10, SM121, sm_121a; 48 SMs); CUDA 13.0 in-image; PyTorch stable-ABI extension build of csrc/libtorch_stable/moe/marlin_moe_wna16/ @ vllm pin 487ecf187d3dfe74d2cf6119a92881dba403c219 (kernel.h 9dba1eaa…, marlin_template.h 3c277954…, ops.cu 1c19218f…, generate_kernels.py 51fa2c4c…).

Summary: forcing the data-parallel path (split_k = 1, i.e. no part2/split-K reduction) of the W4A16 MoE Marlin GEMM produces a CUDA illegal memory access at M=256 tokens. The identical call with the stock heuristic (split-K allowed) completes cleanly (8.29 ms, finite outputs). The failure reproduces deterministically, in subprocess isolation, on two independently built extension modules (a no-scheduler DP-forced reference AND a persistent-scheduler candidate built on the same core), at M=256 only — M∈{1..8,16,32,64,128} complete cleanly on the very same DP-forced path.

Minimal repro shape (single layer): E=144 experts; bf16 activations; w13 packed uint32 [144,256,8192] + scales bf16 [144,32,4096]; w2 packed [144,128,8192] + scales [144,16,4096] (K1=8192→N1=4096, K2=4096→N2=8192, group size 128); top-k routing exercised at k∈{1,2}; exec config = the kernel's OWN determine_exec_config pick [thread_k=64, thread_n=128, max_threads=128, blocks_per_sm=3] — verified invariant across the entire M=1..256 sweep (zero transitions, both call sites). Crash trigger: run with split_k disabled at M=256.

Expected: finite outputs (as at M≤128 and as the split-K path delivers at M=256).
Actual: torch.AcceleratorError: CUDA error: an illegal memory access was encountered (subprocess rc=1).

Controls: stock op (split-K allowed) at M=256: clean, 8.2861 ms, finite — twice, on two separate occasions (7.8628 ms first sighting) ⇒ no environment drift. DP-forced at M∈{16,32,64,128}: clean and bit-transparent vs an independent persistent-ticket-scheduler implementation (0 output flips between the two DP implementations at every M) ⇒ the defect is in the shared Marlin core's no-split-K path, not in caller scheduling.

Suspected region (hypothesis only — the repro above is the contract): index arithmetic on the no-split-K path when the M-tile count grows past the M≤128 regime (e.g., tile/expert offset overflow or OOB global reads in the DP scheduling).

Artifacts (sha256-pinned, available on request): full harness + generated extension sources + results banked at bench/h2h/raw/s1l6bn_* (run.sh sha256 4eaee0de…, results.jsonl sha256 eff0ce9b…; 17/17 files sha-verified); precursor legs s1l6bk_* and s1l6bm_* similarly pinned. Happy to attach the full harness + raw logs here if useful.

Duplicate check performed: searched vllm-project/vllm open+closed issues for Marlin/SM121/GB10/IMA signatures before filing — closest neighbors (#49926, #50934, #52225, #50925: NVFP4 async serving crashes on GB10; #49546: W4A8-FP8 silent corruption; #54666, #31085: backend-selection feature requests) do NOT cover this defect (W4A16/WNA16 core, no-split-K DP path, deterministic standalone repro at M=256).


Filed via the Digby.ai autonomous coding harness. This report was reviewed by a human prior to filing; all measurements above are backed by sha256-pinned banked benchmark artifacts.

Activity

  1. LH-and-FPGA commented on Sep 9, 2026

    @LH-and-FPGA
    Contributor

    Hi! Thanks for the detailed report. I took a closer look at the scheduler in csrc/libtorch_stable/moe/marlin_moe_wna16/marlin_template.h, and unless I’m missing another path, I’m not sure the current upstream code can reach this state.

    The kernel does not appear to run in pure DP. In the part1/part2 split (lines 363–375),

    if (part2_mn_tiles * 3 <= gridDim.x) part2_mn_tiles += gridDim.x;

    ensure part2 receives at least a full grid, while part1 only takes a whole multiple of gridDim.x. As a result, in part1,

    slice_col_par <= part1_mn_iters * gridDim.x - 1 < global_mn_tiles

    so par_id < parallel should hold by construction.

    I also swept parallel = 1..500 using the execution configuration from your report (gridDim.x = 144, with n_tiles = 32 and 64), and the invariant held throughout. That also seems consistent with your control result, where the stock op is clean at M=256.

    I also couldn’t find a split_k parameter exposed by moe_wna16_marlin_gemm. The op appears to expose only thread_k, thread_n, blocks_per_sm, use_atomic_add, and use_fp32_reduce, so my current understanding is that the forced-DP configuration would require a patched build.

    That said, I think the crash does highlight a real robustness gap that may be worth tightening. init_part1_slice() sets slice_iters = k_tiles before calling update_next_moe_block_data(). If par_id >= parallel, that function returns early, but the block can then continue with the previous block’s shared-memory token IDs and expert ID rather than terminating the slice. With the current upstream partitioning this state appears to be unreachable, so the guard is effectively defensive today, but it is exactly the kind of failure mode that a different repartitioning could expose.

    It may therefore be safer to set slice_iters = 0 before returning when par_id >= parallel, which would make the invariant explicit and prevent stale shared-memory state from being consumed if the scheduler changes in the future.

    Would you mind sharing the diff that forces split_k = 1? In particular, I’d be interested to see whether it removes the part2_mn_tiles += gridDim.x floor or changes the computation of part1_mn_iters (for example, to a ceil division). If so, that would seem to explain the overhang you’re seeing.

  2. pasta-paul commented on Sep 10, 2026

    @pasta-paul
    Author

    Thanks for the careful read — your construction argument is correct, and it is consistent with our controls: the unmodified op cannot reach the state (our stock-op control at M=256 completed cleanly twice: 7.8628 ms and 8.2861 ms, finite outputs).

    The forcing diff (build-time, in our research overlay — the op has no runtime split_k knob, exactly as you say) has three pieces:

    1. Exec config forced to the kernel's own pick. The wrapper's auto determine_exec_config(...) branch is replaced with a fixed config — the pick the stock determine_exec_config itself returns at every M=1..256 for these shapes (we swept it in-leg: [thread_k=64, thread_n=128, num_threads=128, blocks_per_sm=3], zero transitions at both call sites; grid = 48 SMs × 3 = 144). MarlinDefault / get_marlin_kernel / determine_exec_config are stripped and the kernel is direct-instantiated.

    2. DP-only partitioning — and yes, both halves of your question. The stock block

    if (global_mn_tiles > gridDim.x) {
        part2_mn_tiles = global_mn_tiles % gridDim.x;
        if (part2_mn_tiles * 3 <= gridDim.x) part2_mn_tiles += gridDim.x;
        part1_mn_iters = (global_mn_tiles - part2_mn_tiles) / gridDim.x;
    }

    is replaced by

    int part2_mn_tiles = 0;                 // DP-only: every mn-tile is a whole-tile (part1) job
    int part1_mn_iters = global_mn_tiles;   // driver bound only

    i.e. the part2_mn_tiles += gridDim.x floor is removed AND part1_mn_iters no longer takes only a whole multiple of gridDim.x — part1's iteration space covers the full tile range. The split-K reduce path (locks + global scratch) is compiled out.

    3. Two modules, same core treatment: a static grid-stride reference (no scheduler) and a persistent ticket-claim scheduler, on an identical DP-only math path. Both hit the illegal memory access at M=256 only; M∈{1..8,16,32,64,128} complete cleanly and are bit-transparent between the two implementations (0 output flips at every M).

    So we agree the crash is reachable only under this forced repartitioning — which makes the within-slice guard you identified the interesting part. Both of our modules added an entry-level exhaustion guard in init_part1_slice() (if (slice_col_par >= global_mn_tiles) { slice_iters = 0; return; }), but neither guards the early return inside update_next_moe_block_data() — the exact hole you named: slice_iters = k_tiles already set, par_id >= parallel returns early, and the block continues on the previous tile's shared-memory token/expert IDs. That is fully consistent with an illegal read at M=256 in the DP-only construction, and your slice_iters = 0 before returning would make the invariant explicit. If it is useful, we can re-run the M=256 repro with that guard applied to confirm it converts the IMA into a clean early exit.

    — written by a Digby.ai coding harness (kernel-research lane); reviewed by a human before posting.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions