|
| 1 | +# DSA top-k device kernel — remove the literal selection bounds |
| 2 | + |
| 3 | +**Issue:** [#505](https://github.com/mudler/vllm.cpp/issues/505). |
| 4 | +**Row:** `MODEL-TEXT-deepseek-v4-deepseek-v4-for-causal-lm` (`DeepseekV4ForCausalLM`, ✅). |
| 5 | +**Claim:** `CLAIM-DSA-TOPK-BOUNDS`. |
| 6 | +**Base:** `origin/main` @ `6db04e7bfc886c58c22a089381fbf9277f318ee2`. |
| 7 | +**Pinned oracle:** `${VLLM_SOURCE}` @ `5559679229bc961848b121ccdeaa8fa5d79bec98` (vLLM 0.26.0.dev0). |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## 0. Scope |
| 12 | + |
| 13 | +`DsaTopkKernel` (`src/vt/cuda/cuda_deepseek_v4.cu:624-665` pre-fix) sized two |
| 14 | +thread-local arrays by literal: |
| 15 | + |
| 16 | +```cpp |
| 17 | +bool chosen[512]; // indexed [0, n) where n is the candidate-window length |
| 18 | +int64_t picked[64]; // written [0, topk) |
| 19 | +``` |
| 20 | + |
| 21 | +`topk` is the caller's `index_topk` — **512 on V4-Flash, 1024 on V4-Pro** — so |
| 22 | +`picked[64]` was 8x and 16x too small, and `chosen[512]` overflowed on any window |
| 23 | +wider than 512. The overflow branch is the `n > topk` path. Neither bound was |
| 24 | +asserted and neither derived from the config. |
| 25 | + |
| 26 | +Found while assessing #504 (DeepSeek-V4-Pro), whose `index_topk` of 1024 made the |
| 27 | +mismatch impossible to miss. |
| 28 | + |
| 29 | +## 1. Why it was latent, stated precisely |
| 30 | + |
| 31 | +Not a shipped defect at the time of filing. `dsa_dense = (be.gguf != nullptr)` |
| 32 | +(`deepseek_v4.cpp:668`) forces `is_indexer` false on the real keep-quant GGUF |
| 33 | +path, so the shipped Flash run never calls the indexer — the kernel was exercised |
| 34 | +only at the collapsed synthetic geometry, where `topk` is small by construction. |
| 35 | +Every pre-existing device case ran at `topk=3, nk=5`, which is why the bound was |
| 36 | +invisible to the gate. |
| 37 | + |
| 38 | +It mattered anyway because the real-geometry DSA sparse path is a named residual |
| 39 | +on this row: the moment that residual lands, these literals become a silent |
| 40 | +thread-stack overflow at the real `index_topk` rather than a loud failure. |
| 41 | + |
| 42 | +## 2. Defect proof |
| 43 | + |
| 44 | +The pre-fix kernel body, transcribed verbatim with its literals and driven at the |
| 45 | +real V4-Flash width (`topk=512`, `nk=600`, so `n > topk`), under ASan: |
| 46 | + |
| 47 | +``` |
| 48 | +==3748575==ERROR: AddressSanitizer: stack-buffer-overflow |
| 49 | +WRITE of size 1 at 0x7a20a0e00490 thread T0 |
| 50 | + #0 OldKernelRow old-overflow.cpp:26 |
| 51 | +``` |
| 52 | + |
| 53 | +Line 26 is the `chosen[s] = false` initialization loop. `picked[64]` then takes |
| 54 | +512 writes in the same call. The reproduction is scratch and not committed; the |
| 55 | +committed gate is §4. |
| 56 | + |
| 57 | +## 3. Fix — two passes, no scratch |
| 58 | + |
| 59 | +Replaced the mask-plus-picks approach with a threshold formulation over the same |
| 60 | +total order the host reference sorts by (`DsaTopkSelect`: logit desc, then index |
| 61 | +asc — a total order because candidate indices are distinct): |
| 62 | + |
| 63 | +- **pass 1** walks the order downwards `topk` times to land on the topk-th best |
| 64 | + element, the selection threshold; |
| 65 | +- **pass 2** scans the window once in ascending index order and emits every |
| 66 | + element outranking-or-equal to that threshold. |
| 67 | + |
| 68 | +Exactly `topk` elements satisfy pass 2 under a total order, and they come out |
| 69 | +already in ascending key order, so the `O(topk^2)` emit sort disappears along with |
| 70 | +the buffers. **No per-thread scratch, no bound, no configurable limit.** Cost is |
| 71 | +unchanged at `O(topk*n)` for pass 1 and strictly better overall. |
| 72 | + |
| 73 | +Two defensive additions that are not load-bearing for ordered input: pass 1 stops |
| 74 | +if no strictly-worse element is found, and pass 2 carries a `w < topk` bound. |
| 75 | +Both exist so a NaN row — where every float comparison is false — cannot write |
| 76 | +past the thread's own row into the next one, which is the failure class this issue |
| 77 | +was about. The host reference is naturally immune (it resizes to `topk`), so this |
| 78 | +keeps the two arms equally safe rather than mirroring a weakness. |
| 79 | + |
| 80 | +## 4. Evidence |
| 81 | + |
| 82 | +**Committed gate** — `tests/vllm/models/test_cuda_deepseek_v4.cpp`, three new |
| 83 | +cases, all comparing device output against the independent host reference |
| 84 | +`DsaTopkSelect` (std::stable_sort based — a genuinely separate implementation, so |
| 85 | +the comparison is not a shared-helper tautology): |
| 86 | + |
| 87 | +| case | shape | what it pins | |
| 88 | +|---|---|---| |
| 89 | +| real `index_topk` widths | `(topk,nk)` = (65,80), (512,600), (1024,1200) | just past the old `picked[64]`, then both shipped widths, each with `n > topk`; also asserts no `-1` leaks and strictly ascending emit | |
| 90 | +| tie-heavy rows | topk=128, nk=300, quantized logits | the total order's tie-break, which distinct random logits cannot exercise | |
| 91 | +| offset window | topk=512, nk=900, `ws=137` | the old code indexed its mask by `s - s0` and its picks by absolute `s`, so `s0` interacted with the two bounds differently | |
| 92 | + |
| 93 | +**Local algorithm equivalence** (`algo-check`, scratch, ASan+UBSan): the new |
| 94 | +kernel body transcribed per-row vs an independent transcription of the oracle — |
| 95 | +**0 mismatched entries across 8 named shapes and a 4000-shape randomized sweep** |
| 96 | +(half with coarsely quantized logits to force tie density, randomized offsets and |
| 97 | +widths). Clean under both sanitizers. This derisked the change while the shared |
| 98 | +GPU lock was held by other jobs; it is not a substitute for §4's device run. |
| 99 | + |
| 100 | +**Device arm** — `test_cuda_deepseek_v4` built on `dgx.casa` (GB10, sm_121a) with |
| 101 | +the mandatory gate flags (`-DVLLM_CPP_CUTLASS_DIR=$HOME/cutlass-4.5.0`, |
| 102 | +`-DVLLM_CPP_TRITON=ON`), both arms from separate trees whose kernel identity is |
| 103 | +asserted before the build so a stale tree cannot masquerade as the other arm: |
| 104 | + |
| 105 | +- **RED** (old kernel + new tests): the device faults. |
| 106 | + |
| 107 | + ``` |
| 108 | + terminate called after throwing an instance of 'std::runtime_error' |
| 109 | + what(): vt cuda: cudaStreamDestroy: an illegal memory access was encountered |
| 110 | + test_cuda_deepseek_v4.cpp:214: FATAL ERROR: test case CRASHED: SIGABRT |
| 111 | + [doctest] test cases: 6 | 5 passed | 1 failed | 17 skipped |
| 112 | + [doctest] assertions: 632 | 632 passed | 0 failed | |
| 113 | + [doctest] Status: FAILURE! |
| 114 | + ``` |
| 115 | + |
| 116 | + Line 214 is `TEST_CASE("W7-device DSA top-k select: REAL index_topk widths |
| 117 | + match host BIT-EXACT (#505)")`. Script exit `134` = SIGABRT. The crash aborts |
| 118 | + the process, which is why only 6 cases ran and 17 were skipped. |
| 119 | + |
| 120 | + Note the shape of that summary: **`assertions: 632 | 632 passed | 0 failed`** |
| 121 | + next to `Status: FAILURE!`. A crashed case contributes no failed assertion, so |
| 122 | + an assertions-only reading of this log reports a clean run. The `Status` line |
| 123 | + and the case count are the load-bearing ones. |
| 124 | + |
| 125 | +- **GREEN** (new kernel + new tests): full suite, nothing skipped. |
| 126 | + |
| 127 | + ``` |
| 128 | + [doctest] test cases: 23 | 23 passed | 0 failed | 0 skipped |
| 129 | + [doctest] assertions: 83913 | 83913 passed | 0 failed | |
| 130 | + [doctest] Status: SUCCESS! |
| 131 | + ``` |
| 132 | + |
| 133 | + `--list-test-cases` on the green binary confirms all three new cases are |
| 134 | + present, so the pass is not an absent-test artifact. |
| 135 | + |
| 136 | +Both arms ran under `flock $HOME/gpu.lock` so a concurrent job could not perturb |
| 137 | +them, and each arm asserted its own kernel identity before building — matching the |
| 138 | +**declarations** `bool chosen[512];` / `int64_t picked[64];` rather than the |
| 139 | +tokens, because the fixed kernel's comment cites both by name and a token grep |
| 140 | +reports the fixed tree as the old one. |
| 141 | + |
| 142 | +**Post-merge re-run.** `origin/main` advanced 17 commits (including the Mamba2 SSD |
| 143 | +work) between the RED/GREEN pair and landing, so the device suite was rebuilt and |
| 144 | +re-run from the *merged* tree rather than trusting the pre-merge green: |
| 145 | + |
| 146 | +``` |
| 147 | +[doctest] test cases: 23 | 23 passed | 0 failed | 0 skipped |
| 148 | +[doctest] assertions: 83913 | 83913 passed | 0 failed | |
| 149 | +[doctest] Status: SUCCESS! |
| 150 | +``` |
| 151 | + |
| 152 | +with the mandatory fast path hard-verified in that run's own configure log — |
| 153 | +`CUTLASS found at /home/mudler/cutlass-4.5.0; enabling sm120a NVFP4 cutlass GEMM` |
| 154 | +and `FlashAttention-2 prefill/decode: ENABLED for arch(es) [121a]` — and |
| 155 | +`--list-test-cases` confirming all 3 new cases in the built binary. |
| 156 | + |
| 157 | +Process note: the first attempt at both arms was lost to `client_loop: send |
| 158 | +disconnect: Broken pipe` while queued on the GPU lock. The harness reported the |
| 159 | +ssh as exit 0 while no DONE marker existed — the wrapper exited, the script never |
| 160 | +finished. Both arms were relaunched under `setsid nohup` and gated on their marker |
| 161 | +files rather than on the ssh status. |
| 162 | + |
| 163 | +## 5. Upstream anchor |
| 164 | + |
| 165 | +Unchanged by this fix; recorded because the fix must not drift from it. Upstream |
| 166 | +selection is `ops.top_k_per_row_prefill` |
| 167 | +(`vllm/model_executor/layers/sparse_attn_indexer.py:488-497`), and the candidate |
| 168 | +window is built as `ks = row_start`, |
| 169 | +`ke = row_start + (pos + 1) // COMPRESS_RATIO` |
| 170 | +(`vllm/v1/attention/backends/mla/indexer.py:270-290`) — the full causal prefix in |
| 171 | +**compressed**-key space, with no fixed cap. Our kernel now likewise has no cap. |
| 172 | + |
| 173 | +Note for the real-geometry residual: our synthetic path passes `we[t] = t + 1` |
| 174 | +over *uncompressed* keys (`deepseek_v4.cpp:806-808`), which is consistent at the |
| 175 | +collapsed geometry but is not the upstream contract. Reconcile against that Triton |
| 176 | +kernel, not against our host reference. That work stays out of scope here. |
| 177 | + |
| 178 | +## 6. Stop conditions |
| 179 | + |
| 180 | +- Do **not** reintroduce a configurable maximum `topk`. The formulation has no |
| 181 | + bound; adding one would re-create the class this issue closed. |
| 182 | +- Do **not** make the kernel and the host reference share a selection helper. The |
| 183 | + gate's value is that two independent implementations agree; a shared helper |
| 184 | + would prove only self-consistency. |
| 185 | +- Do **not** widen scope into the real-geometry DSA residual or the |
| 186 | + compressed-key-space candidate window (§5). |
| 187 | + |
| 188 | +## Outcome |
| 189 | + |
| 190 | +**Measured.** The two literal bounds were a real device fault, not a theoretical |
| 191 | +one: at V4-Flash's own `index_topk` of 512 with a 600-wide window, the pre-fix |
| 192 | +kernel takes an illegal memory access on GB10 and aborts the process. The |
| 193 | +threshold rewrite is bit-exact against the independent host reference across both |
| 194 | +shipped widths, tie-heavy rows and offset windows, and the full 23-case device |
| 195 | +suite passes 83913/83913 with nothing skipped. |
| 196 | + |
| 197 | +**What the bound cost, precisely.** `picked[64]` was 8x short for Flash and 16x |
| 198 | +short for Pro; `chosen[512]` overflowed on any window wider than 512. Both were |
| 199 | +invisible to every pre-existing device case because they all ran at `topk=3, |
| 200 | +nk=5` — the gate's shape, not the model's. |
| 201 | + |
| 202 | +**Rejected: asserting the bounds instead of removing them.** The issue itself |
| 203 | +proposed a guard ("assert both bounds… so the kernel refuses rather than |
| 204 | +corrupts"). A refusal would have been honest but would have left the device DSA |
| 205 | +path unable to run the real `index_topk` at all, converting a latent overflow into |
| 206 | +a guaranteed refusal the moment the real-geometry residual lands. The threshold |
| 207 | +formulation needs no bound, so there is nothing left to assert. The `w < topk` |
| 208 | +bound that remains is a NaN backstop, explicitly not a capacity limit. |
| 209 | + |
| 210 | +**Rejected: sharing a selection helper between the kernel and the host |
| 211 | +reference.** It would have removed the duplication and made the CPU test trivial, |
| 212 | +but it would also have made the equivalence gate prove only self-consistency. Two |
| 213 | +independent implementations agreeing is the whole value of this gate, so the |
| 214 | +duplication is deliberate and recorded in §6 as a stop condition. |
| 215 | + |
| 216 | +**Incidental finding worth keeping.** The RED log prints |
| 217 | +`assertions: 632 | 632 passed | 0 failed` beside `Status: FAILURE!`, because a |
| 218 | +crashed test case contributes no failed assertion. Any gate reading that reports |
| 219 | +on assertion counts alone would have called this run clean. |
| 220 | + |
| 221 | +**Not fixed here, deliberately.** The real-geometry DSA sparse path stays a named |
| 222 | +residual, as does the compressed-key-space candidate window (§5) which our |
| 223 | +synthetic path does not yet mirror. This change makes the kernel able to represent |
| 224 | +the real widths; it does not put the real path on it. |
| 225 | + |
| 226 | +## Now |
| 227 | + |
| 228 | +Row unchanged at ✅. The `index_topk`-width limitation on the device DSA top-k |
| 229 | +path is removed; the real-geometry DSA sparse path remains the named residual it |
| 230 | +was. No lifecycle transition, so no `STATUS`/`BENCHMARKS` write is owed. |
0 commit comments