Skip to content

Commit d861819

Browse files
authored
fix(deepseek-v4): DSA top-k kernel could not represent the real index_topk -- remove the literal bounds (#505)
Lands the #505 fix: DsaTopkKernel sized `bool chosen[512]` and `int64_t picked[64]` by literal while index_topk is 512 (V4-Flash) / 1024 (V4-Pro). MEASURED on dgx.casa (GB10, sm_121a): the pre-fix kernel at Flash's own width takes `cudaStreamDestroy: an illegal memory access` and SIGABRTs; after the two-pass threshold rewrite the suite is 23/23 with 83913/83913 assertions and 0 skipped, re-verified from the merged tree with CUTLASS + FlashAttention-2 hard-verified in that run's own configure log. The fix removes the bounds rather than asserting them, so there is no configurable limit left to outgrow, and it drops the O(topk^2) emit sort. FRESH REVIEW: PASS. An independent reviewer reproduced both the defect and the fix on real sm_121a hardware, fuzzed 3,000,081 shapes across three independent implementations (host reference, kernel transcription, and its own O(n^2) rank-count oracle) with ZERO divergence, and ran a 12-row device mutation table. It found no blocking issue and 6 non-blocking findings, addressed in a follow-up. Notably it confirmed the tie-heavy case is uniquely load-bearing: it is the only case that catches a tie-break inversion or a value-only threshold. CI: windows-msvc-cpu and windows-msvc-vulkan are red at the repo-wide BASELINE, not from this change -- the same two jobs fail on #539, #541 and on #511 which already merged, and this PR's own log shows every target building with no `error C####`, failing instead in the release-packaging PowerShell step. Merged on direct developer instruction after the requested review pass. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [Claude Code]
1 parent 8b00f79 commit d861819

3 files changed

Lines changed: 359 additions & 24 deletions

File tree

.agents/specs/dsa-topk-bounds.md

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
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.

src/vt/cuda/cuda_deepseek_v4.cu

Lines changed: 48 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -635,36 +635,60 @@ __global__ void DsaTopkKernel(const float* logits, const int64_t* ws, const int6
635635
for (int64_t s = s0; s < s1; ++s) dst[w++] = s;
636636
return;
637637
}
638-
// Pick `topk` best by (logit desc, index asc); chosen tracked in a local mask.
639-
bool chosen[512]; // nk (candidate window) small in the structural gate
640-
for (int64_t s = 0; s < n; ++s) chosen[s] = false;
641-
int64_t picked[64]; // topk small
642-
for (int j = 0; j < topk; ++j) {
638+
// Pick the `topk` best under the SAME total order the host reference sorts by
639+
// (`DsaTopkSelect`: logit desc, then index asc — a total order because the
640+
// candidate indices are distinct). Two passes, NO per-thread scratch:
641+
//
642+
// pass 1 walks the order downwards `topk` times to land on the topk-th best
643+
// element, which is the selection THRESHOLD;
644+
// pass 2 scans the window once in ascending index order and emits every
645+
// element better-or-equal to that threshold.
646+
//
647+
// Pass 2 emits exactly `topk` entries already in ascending key order, so the
648+
// ascending sort the previous revision needed is gone with the buffers.
649+
//
650+
// This replaces a `bool chosen[512]` + `int64_t picked[64]` pair of literals
651+
// that could not represent the real `index_topk` (512 on V4-Flash, 1024 on
652+
// V4-Pro) and overflowed the thread stack on any window wider than `topk`
653+
// (#505). Cost is unchanged at O(topk*n) for pass 1, and strictly better
654+
// overall: the O(topk^2) emit sort is eliminated.
655+
const int64_t row = static_cast<int64_t>(t) * nk;
656+
// `better(va, a, vb, b)` == "(va, a) outranks (vb, b)".
657+
auto better = [](float va, int64_t a, float vb, int64_t b) -> bool {
658+
return va > vb || (va == vb && a < b);
659+
};
660+
float th_val = 0.0f;
661+
int64_t th_idx = -1;
662+
for (int64_t j = 0; j < topk; ++j) {
663+
float best_val = 0.0f;
643664
int64_t best = -1;
644-
float bestv = -INFINITY;
645665
for (int64_t s = s0; s < s1; ++s) {
646-
if (chosen[s - s0]) continue;
647-
const float v = logits[static_cast<int64_t>(t) * nk + s];
648-
if (best < 0 || v > bestv) { // strict > keeps the SMALLER index on a tie
649-
bestv = v;
666+
const float v = logits[row + s];
667+
// Skip anything at or above the previous step's element, so each step
668+
// descends exactly one rank.
669+
if (th_idx >= 0 && !better(th_val, th_idx, v, s)) continue;
670+
if (best < 0 || better(v, s, best_val, best)) {
671+
best_val = v;
650672
best = s;
651673
}
652674
}
653-
chosen[best - s0] = true;
654-
picked[j] = best;
675+
// n > topk holds here, so a strictly worse element always exists under a
676+
// total order. `best < 0` is therefore unreachable on ordered input; it can
677+
// only arise if the row carries NaN, which makes every comparison false. Stop
678+
// rather than reset the threshold, so pass 2 still emits a bounded prefix.
679+
if (best < 0) break;
680+
th_val = best_val;
681+
th_idx = best;
655682
}
656-
// Emit ascending key order (insertion sort of `topk` picks).
657-
for (int a = 0; a < topk; ++a) {
658-
int64_t mn = picked[a];
659-
int mi = a;
660-
for (int b = a + 1; b < topk; ++b)
661-
if (picked[b] < mn) {
662-
mn = picked[b];
663-
mi = b;
664-
}
665-
picked[mi] = picked[a];
666-
picked[a] = mn;
667-
dst[a] = mn;
683+
if (th_idx < 0) return; // pathological row: leave the -1 padding in place
684+
// Exactly `topk` elements outrank-or-equal the threshold, so `w` lands on topk.
685+
// The `w < topk` bound is not load-bearing for ordered input — it is here so a
686+
// NaN row can never write past this thread's row into the next one, which is
687+
// the failure class #505 was about.
688+
int64_t w = 0;
689+
for (int64_t s = s0; s < s1 && w < topk; ++s) {
690+
const float v = logits[row + s];
691+
if (better(v, s, th_val, th_idx) || (v == th_val && s == th_idx)) dst[w++] = s;
668692
}
669693
}
670694

0 commit comments

Comments
 (0)