[Bugfix][HiSparse] Resolve MTP verification rows with a union residency kernel (#59235)
Signed-off-by: Matthew Bonanni <mbonanni@redhat.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Matthew Bonanni committed
Sep 29, 2026 at 23:01 UTC
2798f668608155bc8a8c74cb97e3ddd0d3053085
13 files changed
+694
-431
csrc/libtorch_stable/hisparse_kernels.cu
+396
-201
@@ -30,9 +30,7 @@
30
namespace {
31
32
constexpr int kWarpSize = 32;
33
-// Sentinel in the shared top-k scratch: entry already resolved (hit /
34
-// newest / invalid), no miss handling needed.
35
-constexpr int32_t kTokenDone = -1;
33
+// Empty key in the shared open-addressing table of host rows.
34
constexpr int32_t kHashEmpty = -1;
35
36
bool is_pinned_cpu_tensor(const torch::stable::Tensor& tensor) {
@@ -46,10 +44,15 @@ bool is_pinned_cpu_tensor(const torch::stable::Tensor& tensor) {
44
return attributes.type == cudaMemoryTypeHost;
45
}
46
49
-__device__ __forceinline__ int32_t hash_slot(int32_t key, int32_t hash_size) {
50
- // Knuth multiplicative hash for the open-addressing table.
51
- return static_cast<int32_t>((static_cast<uint32_t>(key) * 2654435761u) %
52
- static_cast<uint32_t>(hash_size));
47
+__device__ __forceinline__ int32_t hash_slot(int32_t key, int size) {
48
+ // Knuth multiplicative hash scaled onto [0, size) by a multiply-high, so
49
+ // the table can take any size without a division.
50
+ return static_cast<int32_t>(
51
+ __umulhi(static_cast<uint32_t>(key) * 2654435761u, size));
52
+}
53
+
54
+__device__ __forceinline__ int next_slot(int h, int size) {
55
+ return h + 1 == size ? 0 : h + 1;
56
}
57
58
// Copy one row of `row_bytes` bytes with a single warp. MLA rows take the
@@ -176,16 +179,117 @@ __device__ __forceinline__ void store_hot_index(
179
}
180
}
181
179
-// One block per batch row.
182
+// A union entry's value holds its first (row, top-k) position until phase 2
183
+// or 3 resolves it to a hot slot tagged with how it was resolved.
184
+constexpr int32_t kEntryHit = 1 << 30;
185
+constexpr int32_t kEntryMiss = 1 << 29;
186
+constexpr int32_t kEntryInvalid = 1 << 28;
187
+constexpr int32_t kEntrySlotMask = 0xFFFF;
188
+constexpr int kResidencyCounters = 5;
189
+// Per-request table entries per referenced top-k position (load <= 1/4
190
+// until the hash_size cap).
191
+constexpr int kHashLoadInverse = 4;
192
+
193
+// Translate a request-relative top-k position to its resident GPU row (-1 when
194
+// the page is not resident) and returns its host row (-1 when not host-backed).
195
+__device__ __forceinline__ int32_t translate_topk_entry(
196
+ const int32_t token_index, const int32_t request_row,
197
+ const int32_t* __restrict__ source_block_table,
198
+ const int32_t* __restrict__ resident_block_table, const int64_t host_rows,
199
+ const int64_t source_bt_stride, const int32_t source_num_reqs,
200
+ const int32_t source_num_blocks, const int32_t source_block_size,
201
+ const int64_t resident_bt_stride, const int32_t resident_num_reqs,
202
+ const int32_t resident_num_blocks, const int32_t resident_block_size,
203
+ const int32_t resident_null_block, int32_t& resident_row) {
204
+ int32_t g = token_index;
205
+ resident_row = -1;
206
+ if (source_block_table != nullptr) {
207
+ const int32_t source_block =
208
+ token_index >= 0 ? token_index / source_block_size : -1;
209
+ if (request_row >= 0 && request_row < source_num_reqs &&
210
+ source_block >= 0 && source_block < source_num_blocks) {
211
+ const int32_t physical_block =
212
+ source_block_table[static_cast<int64_t>(request_row) *
213
+ source_bt_stride +
214
+ source_block];
215
+ g = physical_block > 0 ? physical_block * source_block_size +
216
+ token_index % source_block_size
217
+ : -1;
218
+ } else {
219
+ g = -1;
220
+ }
221
+ if (resident_block_table != nullptr) {
222
+ const int32_t resident_block =
223
+ token_index >= 0 ? token_index / resident_block_size : -1;
224
+ if (request_row >= 0 && request_row < resident_num_reqs &&
225
+ resident_block >= 0 && resident_block < resident_num_blocks) {
226
+ const int32_t physical_block =
227
+ resident_block_table[static_cast<int64_t>(request_row) *
228
+ resident_bt_stride +
229
+ resident_block];
230
+ if (physical_block != resident_null_block && physical_block >= 0) {
231
+ resident_row = physical_block * resident_block_size +
232
+ token_index % resident_block_size;
233
+ }
234
+ }
235
+ }
236
+ }
237
+ return g >= host_rows ? -1 : g;
238
+}
239
+
240
+// Write the hot index of one reference to a resolved union entry and tally it.
241
+__device__ __forceinline__ void write_entry_index(
242
+ const int32_t entry, const int row, const int i, const int32_t top_k,
243
+ int32_t* __restrict__ hot_indices, int32_t* __restrict__ attention_indices,
244
+ const int64_t attention_row_stride, int32_t* __restrict__ miss_mask,
245
+ const int32_t* __restrict__ hot_block_table, const int request_row,
246
+ const int64_t hot_table_stride, const int32_t hot_block_size,
247
+ const int64_t attention_block_stride, const bool first_reference, int& hits,
248
+ int& misses) {
249
+ int32_t* row_out = hot_indices + static_cast<int64_t>(row) * top_k;
250
+ int32_t* row_attention =
251
+ attention_indices != nullptr
252
+ ? attention_indices + static_cast<int64_t>(row) * attention_row_stride
253
+ : nullptr;
254
+ if (entry & kEntryInvalid) {
255
+ store_hot_index(row_out, row_attention, i, -1, hot_block_size,
256
+ attention_block_stride);
257
+ return;
258
+ }
259
+ store_hot_index(row_out, row_attention, i,
260
+ static_cast<int32_t>(get_physical_hot_row(
261
+ hot_block_table, request_row, hot_table_stride,
262
+ hot_block_size, entry & kEntrySlotMask)),
263
+ hot_block_size, attention_block_stride);
264
+ // Only an entry's first reference loads it; later references reuse the
265
+ // slot like a hit.
266
+ if ((entry & kEntryMiss) && first_reference) {
267
+ if (miss_mask != nullptr) {
268
+ miss_mask[static_cast<int64_t>(row) * top_k + i] = 1;
269
+ }
270
+ ++misses;
271
+ } else {
272
+ ++hits;
273
+ }
274
+}
275
+
276
+// One block per request, launched per batch row. A request's rows (several
277
+// with speculative decoding) are contiguous and share its hot-buffer state, so
278
+// the block of its first row resolves the union of their host misses once and
279
+// maps every row onto it; the other blocks exit. The union is bounded by the
280
+// caller's max_union_rows (every decode query's top-k), which
281
+// device_buffer_size covers; a larger union traps. Misses take eviction slots
282
+// in (row, top-k) order of first reference, so the plan is deterministic.
283
//
284
// Shared memory layout (int32 region followed by int16 region):
182
-// s_topk[top_k] top-k global ids; reused as miss scratch
183
-// s_chunk_off[nbc + 1] prefix sums for hit (then miss) compaction
285
+// s_hash_keys[hash_size] open addressing: union of the rows' host ids
286
+// s_hash_vals[hash_size] first position, then slot | kEntry* tag
287
+// s_chunk_off[nbc + 1] prefix sums for hit, then miss, compaction
288
// s_evict_off[nbc + 1] prefix sums for evictable compaction
185
-// s_hash_keys[hash_size] open addressing: global id -> top-k index
186
-// s_counters[3] hits, phase-1 resolved, valid count
289
+// s_counters[5] distinct hits, union size, per-row valid count,
290
+// misses, per-entry hits
291
+// s_done[nbc] positions phase 3 resolved, one word per chunk
292
// s_lru_out[hot_size] int16, compacted slots: [hits fwd | evict bwd]
188
-// s_hash_vals[hash_size] int16 hash values (top-k index)
293
// Valid global ids must be unique within each row.
294
__global__ __launch_bounds__(1024) void hisparse_resolve_residency_kernel(
295
const int32_t* __restrict__ hot_block_table, // [max_rows, hot_blocks]
@@ -218,11 +322,25 @@ __global__ __launch_bounds__(1024) void hisparse_resolve_residency_kernel(
322
const int64_t attention_row_stride, const int64_t valid_count_stride) {
323
const int NUM_WARPS = blockDim.x / kWarpSize;
324
const int num_buffer_chunks = (hot_size + kWarpSize - 1) / kWarpSize;
221
- const int num_token_chunks = (top_k + kWarpSize - 1) / kWarpSize;
325
223
- const int batch_row = blockIdx.x;
326
+ // The first row of each contiguous run of a request's rows leads it. FULL
327
+ // CUDA-graph padding rows map to request 0 after the real rows, forming a
328
+ // second run, but carry no valid top-k: that run returns before phase 2
329
+ // touches the request's shared state.
330
+ const int first_row = blockIdx.x;
331
const int request_row =
225
- request_ids != nullptr ? request_ids[batch_row] : batch_row;
332
+ request_ids != nullptr ? request_ids[first_row] : first_row;
333
+ if (request_ids != nullptr && first_row > 0 &&
334
+ request_ids[first_row - 1] == request_row) {
335
+ return;
336
+ }
337
+ int end_row = first_row + 1;
338
+ if (request_ids != nullptr) {
339
+ while (end_row < static_cast<int>(gridDim.x) &&
340
+ request_ids[end_row] == request_row) {
341
+ ++end_row;
342
+ }
343
+ }
344
const int state_row =
345
request_state_indices != nullptr && request_row >= 0 &&
346
request_row < request_state_count
@@ -230,23 +348,24 @@ __global__ __launch_bounds__(1024) void hisparse_resolve_residency_kernel(
348
: (request_state_indices == nullptr ? request_row : -1);
349
// V2 publishes -1 for CUDA-graph padding rows.
350
if (state_row < 0) {
233
- for (int i = threadIdx.x; i < top_k; i += blockDim.x) {
234
- const int64_t index = static_cast<int64_t>(batch_row) * top_k + i;
235
- hot_indices[index] = -1;
236
- if (attention_indices != nullptr) {
237
- attention_indices[static_cast<int64_t>(batch_row) *
238
- attention_row_stride +
239
- i] = -1;
351
+ for (int row = first_row; row < end_row; ++row) {
352
+ for (int i = threadIdx.x; i < top_k; i += blockDim.x) {
353
+ const int64_t index = static_cast<int64_t>(row) * top_k + i;
354
+ hot_indices[index] = -1;
355
+ if (attention_indices != nullptr) {
356
+ attention_indices[static_cast<int64_t>(row) * attention_row_stride +
357
+ i] = -1;
358
+ }
359
+ if (resolved_global_indices != nullptr) {
360
+ resolved_global_indices[index] = -1;
361
+ }
362
}
241
- if (resolved_global_indices != nullptr) {
242
- resolved_global_indices[index] = -1;
363
+ if (valid_counts != nullptr && threadIdx.x == 0) {
364
+ valid_counts[static_cast<int64_t>(row) * valid_count_stride] = 0;
365
+ }
366
+ if (swap_counts != nullptr && threadIdx.x == 0) {
367
+ swap_counts[row] = 0;
368
}
244
- }
245
- if (valid_counts != nullptr && threadIdx.x == 0) {
246
- valid_counts[static_cast<int64_t>(batch_row) * valid_count_stride] = 0;
247
- }
248
- if (swap_counts != nullptr && threadIdx.x == 0) {
249
- swap_counts[batch_row] = 0;
369
}
370
return;
371
}
@@ -255,136 +374,130 @@ __global__ __launch_bounds__(1024) void hisparse_resolve_residency_kernel(
374
const int lane_id = tid % kWarpSize;
375
const unsigned int lanes_before = ((unsigned int)1 << lane_id) - 1;
376
258
- const int32_t* row_topk =
259
- global_indices + static_cast<int64_t>(batch_row) * input_row_stride;
260
- int32_t* row_out = hot_indices + static_cast<int64_t>(batch_row) * top_k;
261
- int32_t* row_attention =
262
- attention_indices != nullptr
263
- ? attention_indices +
264
- static_cast<int64_t>(batch_row) * attention_row_stride
265
- : nullptr;
266
- int32_t* row_miss = (miss_mask != nullptr)
267
- ? miss_mask + static_cast<int64_t>(batch_row) * top_k
268
- : nullptr;
377
int32_t* row_dgi =
378
device_global_indices + static_cast<int64_t>(state_row) * region_stride;
379
int16_t* row_lru = lru_slots + static_cast<int64_t>(state_row) * hot_size;
380
381
+ // A union within max_union_rows (hash_size - 1) and within the rows' top-k
382
+ // leaves an empty slot, so every probe sequence ends. Use a slice sized to
383
+ // this request's rows so single-row requests initialize a small table.
384
+ const int table_size =
385
+ min(hash_size, kHashLoadInverse * (end_row - first_row) * top_k);
386
+
387
extern __shared__ char smem_raw[];
274
- int32_t* s_topk = reinterpret_cast<int32_t*>(smem_raw);
275
- int32_t* s_chunk_off = s_topk + top_k;
388
+ int32_t* s_hash_keys = reinterpret_cast<int32_t*>(smem_raw);
389
+ int32_t* s_hash_vals = s_hash_keys + hash_size;
390
+ int32_t* s_chunk_off = s_hash_vals + hash_size;
391
int32_t* s_evict_off = s_chunk_off + (num_buffer_chunks + 1);
277
- int32_t* s_hash_keys = s_evict_off + (num_buffer_chunks + 1);
278
- int32_t* s_counters = s_hash_keys + hash_size;
279
- int16_t* s_lru_out = reinterpret_cast<int16_t*>(s_counters + 3);
280
- int16_t* s_hash_vals = s_lru_out + hot_size;
392
+ int32_t* s_counters = s_evict_off + (num_buffer_chunks + 1);
393
+ unsigned int* s_done =
394
+ reinterpret_cast<unsigned int*>(s_counters + kResidencyCounters);
395
+ int16_t* s_lru_out = reinterpret_cast<int16_t*>(s_done + num_buffer_chunks);
396
282
- if (tid < 3) {
397
+ for (int i = tid; i < table_size; i += blockDim.x) {
398
+ s_hash_keys[i] = kHashEmpty;
399
+ s_hash_vals[i] = INT32_MAX;
400
+ }
401
+ for (int i = tid; i < num_buffer_chunks + 1; i += blockDim.x) {
402
+ s_chunk_off[i] = 0;
403
+ s_evict_off[i] = 0;
404
+ }
405
+ if (tid < kResidencyCounters) {
406
s_counters[tid] = 0;
407
}
408
__syncthreads();
409
287
- // Phase 1: translate request-relative positions and resolve resident rows.
288
- for (int i = tid; i < top_k; i += blockDim.x) {
289
- const int32_t token_index = row_topk[i];
290
- int32_t g = token_index;
291
- int32_t resident_row = -1;
292
- if (source_block_table != nullptr) {
293
- const int32_t request_id = request_ids[batch_row];
294
- const int32_t source_block =
295
- token_index >= 0 ? token_index / source_block_size : -1;
296
- if (request_id >= 0 && request_id < source_num_reqs &&
297
- source_block >= 0 && source_block < source_num_blocks) {
298
- const int32_t physical_block =
299
- source_block_table[static_cast<int64_t>(request_id) *
300
- source_bt_stride +
301
- source_block];
302
- g = physical_block > 0 ? physical_block * source_block_size +
303
- token_index % source_block_size
304
- : -1;
305
- } else {
306
- g = -1;
410
+ // Phase 1: translate every row's request-relative positions, resolve
411
+ // resident rows directly, and collect the union of host rows.
412
+ for (int row = first_row; row < end_row; ++row) {
413
+ const int32_t* row_topk =
414
+ global_indices + static_cast<int64_t>(row) * input_row_stride;
415
+ int32_t* row_out = hot_indices + static_cast<int64_t>(row) * top_k;
416
+ int32_t* row_attention =
417
+ attention_indices != nullptr
418
+ ? attention_indices +
419
+ static_cast<int64_t>(row) * attention_row_stride
420
+ : nullptr;
421
+ int32_t* row_miss = miss_mask != nullptr
422
+ ? miss_mask + static_cast<int64_t>(row) * top_k
423
+ : nullptr;
424
+ for (int i = tid; i < top_k; i += blockDim.x) {
425
+ int32_t resident_row;
426
+ const int32_t g = translate_topk_entry(
427
+ row_topk[i], request_row, source_block_table, resident_block_table,
428
+ host_rows, source_bt_stride, source_num_reqs, source_num_blocks,
429
+ source_block_size, resident_bt_stride, resident_num_reqs,
430
+ resident_num_blocks, resident_block_size, resident_null_block,
431
+ resident_row);
432
+ if (resolved_global_indices != nullptr) {
433
+ resolved_global_indices[static_cast<int64_t>(row) * top_k + i] = g;
434
}
308
- if (resident_block_table != nullptr) {
309
- const int32_t resident_block =
310
- token_index >= 0 ? token_index / resident_block_size : -1;
311
- if (request_id >= 0 && request_id < resident_num_reqs &&
312
- resident_block >= 0 && resident_block < resident_num_blocks) {
313
- const int32_t physical_block =
314
- resident_block_table[static_cast<int64_t>(request_id) *
315
- resident_bt_stride +
316
- resident_block];
317
- if (physical_block != resident_null_block && physical_block >= 0) {
318
- resident_row = physical_block * resident_block_size +
319
- token_index % resident_block_size;
435
+ if (resident_row >= 0 || g >= 0) atomicAdd(&s_counters[2], 1);
436
+ if (row_miss != nullptr) row_miss[i] = 0;
437
+ if (resident_row >= 0) {
438
+ store_hot_index(row_out, row_attention, i, resident_row, hot_block_size,
439
+ attention_block_stride);
440
+ } else if (g < 0) {
441
+ store_hot_index(row_out, row_attention, i, -1, hot_block_size,
442
+ attention_block_stride);
443
+ } else {
444
+ int h = hash_slot(g, table_size);
445
+ bool inserted = false;
446
+ for (int probe = 0; probe < table_size; ++probe) {
447
+ const int32_t old = atomicCAS(&s_hash_keys[h], kHashEmpty, g);
448
+ if (old == kHashEmpty) {
449
+ atomicAdd(&s_counters[1], 1);
450
+ inserted = true;
451
+ break;
452
+ }
453
+ if (old == g) {
454
+ inserted = true;
455
+ break;
456
}
457
+ h = next_slot(h, table_size);
458
+ }
459
+ if (inserted) {
460
+ atomicMin(&s_hash_vals[h], (row - first_row) * top_k + i);
461
+ } else {
462
+ // A full table: push the union count past the cap so the check
463
+ // below traps.
464
+ atomicAdd(&s_counters[1], hash_size);
465
}
466
}
467
}
324
- if (g >= host_rows) {
325
- g = -1;
326
- }
327
- if (resolved_global_indices != nullptr) {
328
- resolved_global_indices[static_cast<int64_t>(batch_row) * top_k + i] = g;
329
- }
330
- if (resident_row >= 0 || g >= 0) atomicAdd(&s_counters[2], 1);
331
- if (row_miss != nullptr) row_miss[i] = 0;
332
- if (resident_row >= 0) {
333
- store_hot_index(row_out, row_attention, i, resident_row, hot_block_size,
334
- attention_block_stride);
335
- s_topk[i] = kTokenDone;
336
- atomicAdd(&s_counters[1], 1);
337
- } else if (g < 0) {
338
- store_hot_index(row_out, row_attention, i, -1, hot_block_size,
339
- attention_block_stride);
340
- s_topk[i] = kTokenDone;
341
- atomicAdd(&s_counters[1], 1);
342
- } else {
343
- s_topk[i] = g;
468
+ __syncthreads();
469
+ if (tid == 0) {
470
+ if (valid_counts != nullptr) {
471
+ valid_counts[static_cast<int64_t>(row) * valid_count_stride] =
472
+ s_counters[2];
473
+ }
474
+ s_counters[2] = 0;
475
}
476
+ __syncthreads();
477
}
346
- __syncthreads();
347
- if (valid_counts != nullptr && tid == 0) {
348
- valid_counts[static_cast<int64_t>(batch_row) * valid_count_stride] =
349
- s_counters[2];
478
+ // The table and eviction slots are sized for max_union_rows (hash_size - 1);
479
+ // a larger union would never reach an empty probe slot or run past s_lru_out.
480
+ if (s_counters[1] >= hash_size) {
481
+ if (tid == 0) {
482
+ printf(
483
+ "HiSparse residency: request %d resolves %d host rows, above the "
484
+ "configured bound of %d\n",
485
+ request_row, s_counters[1], hash_size - 1);
486
+ }
487
+ __trap();
488
}
489
// Fully resident rows need only request-relative page translation. Avoid
490
// scanning or rewriting the hot LRU when no selected row can consult it.
353
- if (s_counters[1] == top_k) {
354
- if (tid == 0 && swap_counts != nullptr) {
355
- swap_counts[batch_row] = 0;
491
+ if (s_counters[1] == 0) {
492
+ for (int row = first_row + tid; row < end_row; row += blockDim.x) {
493
+ if (swap_counts != nullptr) swap_counts[row] = 0;
494
}
495
return;
496
}
497
360
- for (int i = tid; i < hash_size; i += blockDim.x) {
361
- s_hash_keys[i] = kHashEmpty;
362
- }
363
- for (int i = tid; i < num_buffer_chunks + 1; i += blockDim.x) {
364
- s_chunk_off[i] = 0;
365
- s_evict_off[i] = 0;
366
- }
367
- __syncthreads();
368
-
369
- for (int i = tid; i < top_k; i += blockDim.x) {
370
- const int32_t g = s_topk[i];
371
- if (g == kTokenDone) {
372
- continue;
373
- }
374
- int slot = hash_slot(g, hash_size);
375
- while (true) {
376
- const int32_t old = atomicCAS(&s_hash_keys[slot], kHashEmpty, g);
377
- if (old == kHashEmpty || old == g) {
378
- s_hash_vals[slot] = static_cast<int16_t>(i);
379
- break;
380
- }
381
- slot = (slot + 1) % hash_size;
382
- }
383
- }
384
- __syncthreads();
385
-
498
// Phase 2: walk hot slots in LRU order, classify hit / evictable, and
387
- // compact them (hits forward, evictables backward) into s_lru_out.
499
+ // compact them (hits forward, evictables backward) into s_lru_out. A hit
500
+ // records its slot on the union entry.
501
const int iters_buffer = (num_buffer_chunks + NUM_WARPS - 1) / NUM_WARPS;
502
int total_hit_count = 0;
503
int total_evict_count = 0;
@@ -397,36 +510,28 @@ __global__ __launch_bounds__(1024) void hisparse_resolve_residency_kernel(
510
const int16_t slot = has_valid_pos ? row_lru[pos] : int16_t(-1);
511
// Corruption tripwire: lru/dgi are long-lived device state; if some
512
// external writer (e.g. stray RDMA into reused VRAM) corrupts a slot
400
- // out of [0, region_stride), treat it as no-hit so it degrades to a re-miss
401
- // instead of an unbounded dgi read (phases 3/5 bound the write side).
513
+ // out of [0, region_stride), treat it as no-hit so it degrades to a
514
+ // re-miss instead of an unbounded dgi read (phase 3 bounds the write
515
+ // side).
516
const int32_t cached_g =
517
(slot >= 0 && slot < region_stride) ? row_dgi[slot] : -1;
518
405
- int found_topk_idx = -1;
519
+ bool is_hit = false;
520
if (cached_g >= 0) {
407
- int h = hash_slot(cached_g, hash_size);
521
+ int h = hash_slot(cached_g, table_size);
522
while (true) {
523
const int32_t k = s_hash_keys[h];
524
if (k == cached_g) {
411
- found_topk_idx = static_cast<int32_t>(s_hash_vals[h]);
525
+ s_hash_vals[h] = kEntryHit | slot;
526
+ is_hit = true;
527
break;
528
}
529
if (k == kHashEmpty) break;
415
- h = (h + 1) % hash_size;
530
+ h = next_slot(h, table_size);
531
}
532
}
418
- const bool is_hit = found_topk_idx >= 0;
533
const bool is_evictable = has_valid_pos && !is_hit;
534
421
- if (is_hit) {
422
- s_topk[found_topk_idx] = kTokenDone;
423
- store_hot_index(row_out, row_attention, found_topk_idx,
424
- static_cast<int32_t>(get_physical_hot_row(
425
- hot_block_table, request_row, hot_table_stride,
426
- hot_block_size, slot)),
427
- hot_block_size, attention_block_stride);
428
- }
429
-
535
int local_hit_off = 0;
536
int local_evict_off = 0;
537
if (has_valid_chunk) {
@@ -465,39 +570,59 @@ __global__ __launch_bounds__(1024) void hisparse_resolve_residency_kernel(
570
}
571
__syncthreads();
572
468
- // Reset prefix sums for the miss compaction (token chunks <= buffer
469
- // chunks because hot_size >= top_k).
470
- for (int i = tid; i < num_token_chunks + 1; i += blockDim.x) {
573
+ for (int i = tid; i < num_buffer_chunks + 1; i += blockDim.x) {
574
s_chunk_off[i] = 0;
575
}
576
__syncthreads();
577
475
- // Phase 3: compact misses, assign them eviction slots (oldest first) and
476
- // record the new ownership in device_global_indices.
477
- const int iters_token = (num_token_chunks + NUM_WARPS - 1) / NUM_WARPS;
578
+ // Phase 3: walk the rows' positions in (row, top-k) order; the first
579
+ // reference to a union entry no slot holds is a miss. Assign misses
580
+ // eviction slots (oldest first) and record the new ownership in
581
+ // device_global_indices. Swaps fill the rows' swap segments in order; the
582
+ // union never exceeds their combined capacity, and position chunks never
583
+ // exceed buffer chunks because hot_size covers every row's top-k.
584
+ const int num_positions = (end_row - first_row) * top_k;
585
+ const int num_position_chunks = (num_positions + kWarpSize - 1) / kWarpSize;
586
+ const int iters_position = (num_position_chunks + NUM_WARPS - 1) / NUM_WARPS;
587
int miss_running_total = 0;
479
- for (int iter = 0; iter < iters_token; iter++) {
588
+ int entry_hits = 0;
589
+ int entry_misses = 0;
590
+ for (int iter = 0; iter < iters_position; iter++) {
591
const int chunk_idx = warp_id + iter * NUM_WARPS;
481
- const bool has_valid_chunk = chunk_idx < num_token_chunks;
482
-
483
- const int i = chunk_idx * kWarpSize + lane_id;
484
- const bool has_valid_token = has_valid_chunk && (i < top_k);
592
+ const bool has_valid_chunk = chunk_idx < num_position_chunks;
593
486
- int32_t g = 0;
594
+ const int position = chunk_idx * kWarpSize + lane_id;
595
+ int32_t g = -1;
596
+ int h = -1;
597
+ int32_t entry = 0;
598
bool is_miss = false;
488
- if (has_valid_token) {
489
- is_miss = s_topk[i] != kTokenDone;
490
- if (is_miss) {
491
- g = s_topk[i];
599
+ if (has_valid_chunk && position < num_positions) {
600
+ const int row = first_row + position / top_k;
601
+ int32_t resident_row;
602
+ g = translate_topk_entry(
603
+ global_indices[static_cast<int64_t>(row) * input_row_stride +
604
+ position % top_k],
605
+ request_row, source_block_table, resident_block_table, host_rows,
606
+ source_bt_stride, source_num_reqs, source_num_blocks,
607
+ source_block_size, resident_bt_stride, resident_num_reqs,
608
+ resident_num_blocks, resident_block_size, resident_null_block,
609
+ resident_row);
610
+ if (resident_row < 0 && g >= 0) {
611
+ h = hash_slot(g, table_size);
612
+ while (s_hash_keys[h] != g) {
613
+ h = next_slot(h, table_size);
614
+ }
615
+ entry = s_hash_vals[h];
616
+ is_miss = entry == position;
617
}
618
}
619
620
int local_miss_off = 0;
621
if (has_valid_chunk) {
497
- const unsigned int miss_mask = __ballot_sync(0xFFFFFFFF, is_miss);
498
- local_miss_off = __popc(miss_mask & lanes_before);
622
+ const unsigned int miss_mask_bits = __ballot_sync(0xFFFFFFFF, is_miss);
623
+ local_miss_off = __popc(miss_mask_bits & lanes_before);
624
if (lane_id == 0) {
500
- s_chunk_off[chunk_idx + 1] = __popc(miss_mask);
625
+ s_chunk_off[chunk_idx + 1] = __popc(miss_mask_bits);
626
}
627
}
628
__syncthreads();
@@ -505,60 +630,112 @@ __global__ __launch_bounds__(1024) void hisparse_resolve_residency_kernel(
630
if (warp_id == 0) {
631
miss_running_total =
632
warp_inclusive_scan(s_chunk_off, lane_id, chunk_idx + 1,
508
- num_token_chunks + 1, miss_running_total);
633
+ num_position_chunks + 1, miss_running_total);
634
+ if (tid == 0) {
635
+ s_counters[3] = miss_running_total;
636
+ }
637
}
638
__syncthreads();
639
640
if (is_miss) {
641
const int m = s_chunk_off[chunk_idx] + local_miss_off;
642
const int16_t evict_slot = s_lru_out[hot_size - 1 - m];
643
+ const int64_t compact_index = static_cast<int64_t>(first_row) * top_k + m;
644
if (evict_slot < 0 || evict_slot >= region_stride) {
645
// Corruption tripwire: an out-of-range slot (corrupted lru state,
646
// see phase 2) must not become a hot-cache/dgi write. Resolve the
518
- // entry as invalid (-1, masked by attention, not in the miss set);
519
- // it re-misses on a later step. Phase 5 re-checks the same
520
- // s_lru_out value, so its copy is skipped consistently.
521
- store_hot_index(row_out, row_attention, i, -1, hot_block_size,
522
- attention_block_stride);
647
+ // entry as invalid (-1, masked by attention); it re-misses on a later
648
+ // step, and its copy is skipped.
649
+ s_hash_vals[h] = kEntryInvalid;
650
if (swap_host_physical_rows != nullptr) {
524
- const int64_t compact_index =
525
- static_cast<int64_t>(batch_row) * top_k + m;
651
swap_host_physical_rows[compact_index] = g;
652
swap_device_physical_rows[compact_index] = -1;
653
}
654
} else {
530
- // Reuse s_topk as compacted miss scratch: m < i always holds (done
531
- // entries are skipped), so writes never overrun pending reads.
532
- s_topk[m] = g;
533
- const int32_t physical_row = static_cast<int32_t>(
534
- get_physical_hot_row(hot_block_table, request_row, hot_table_stride,
535
- hot_block_size, evict_slot));
536
- store_hot_index(row_out, row_attention, i, physical_row, hot_block_size,
537
- attention_block_stride);
655
+ s_hash_vals[h] = kEntryMiss | evict_slot;
656
if (swap_host_physical_rows != nullptr) {
539
- const int64_t compact_index =
540
- static_cast<int64_t>(batch_row) * top_k + m;
657
swap_host_physical_rows[compact_index] = g;
542
- swap_device_physical_rows[compact_index] = physical_row;
658
+ swap_device_physical_rows[compact_index] =
659
+ static_cast<int32_t>(get_physical_hot_row(
660
+ hot_block_table, request_row, hot_table_stride,
661
+ hot_block_size, evict_slot));
662
}
544
- if (row_miss != nullptr) row_miss[i] = 1;
663
row_dgi[evict_slot] = g;
664
}
665
+ entry = s_hash_vals[h];
666
+ }
667
+
668
+ // Hits, first references and references to entries resolved in an
669
+ // earlier chunk write their hot index now; later references to an entry
670
+ // resolved in this same chunk pass (only across rows) are left to phase 4.
671
+ const bool resolved =
672
+ h >= 0 && (entry & (kEntryHit | kEntryMiss | kEntryInvalid));
673
+ if (resolved) {
674
+ write_entry_index(
675
+ entry, first_row + position / top_k, position % top_k, top_k,
676
+ hot_indices, attention_indices, attention_row_stride, miss_mask,
677
+ hot_block_table, request_row, hot_table_stride, hot_block_size,
678
+ attention_block_stride, is_miss, entry_hits, entry_misses);
679
+ }
680
+ if (has_valid_chunk) {
681
+ const unsigned int done_bits = __ballot_sync(0xFFFFFFFF, resolved);
682
+ if (lane_id == 0) {
683
+ s_done[chunk_idx] = done_bits;
684
+ }
685
}
686
}
687
__syncthreads();
688
689
const int total_hits = s_counters[0];
552
- const int total_misses = top_k - total_hits - s_counters[1];
553
- if (swap_counts != nullptr && tid == 0) {
554
- swap_counts[batch_row] = total_misses;
690
+ const int total_misses = s_counters[3];
691
+ if (swap_counts != nullptr) {
692
+ for (int row = first_row + tid; row < end_row; row += blockDim.x) {
693
+ const int remaining = total_misses - (row - first_row) * top_k;
694
+ swap_counts[row] = max(0, min(top_k, remaining));
695
+ }
696
}
556
- if (stats != nullptr && tid == 0) {
557
- atomicAdd(&stats[0], static_cast<unsigned long long>(total_hits));
558
- atomicAdd(&stats[1], static_cast<unsigned long long>(total_misses));
697
+
698
+ // Phase 4: references phase 3 left unresolved (later references across rows
699
+ // to entries loaded in the same chunk pass) map onto the union's slots.
700
+ // Single-row requests resolve everything in phase 3.
701
+ if (end_row - first_row > 1) {
702
+ for (int position = tid; position < num_positions; position += blockDim.x) {
703
+ if (s_done[position / kWarpSize] & (1u << (position % kWarpSize))) {
704
+ continue;
705
+ }
706
+ const int row = first_row + position / top_k;
707
+ int32_t resident_row;
708
+ const int32_t g = translate_topk_entry(
709
+ global_indices[static_cast<int64_t>(row) * input_row_stride +
710
+ position % top_k],
711
+ request_row, source_block_table, resident_block_table, host_rows,
712
+ source_bt_stride, source_num_reqs, source_num_blocks,
713
+ source_block_size, resident_bt_stride, resident_num_reqs,
714
+ resident_num_blocks, resident_block_size, resident_null_block,
715
+ resident_row);
716
+ if (resident_row >= 0 || g < 0) continue;
717
+ int h = hash_slot(g, table_size);
718
+ while (s_hash_keys[h] != g) {
719
+ h = next_slot(h, table_size);
720
+ }
721
+ write_entry_index(
722
+ s_hash_vals[h], row, position % top_k, top_k, hot_indices,
723
+ attention_indices, attention_row_stride, miss_mask, hot_block_table,
724
+ request_row, hot_table_stride, hot_block_size, attention_block_stride,
725
+ false, entry_hits, entry_misses);
726
+ }
727
+ }
728
+ if (stats != nullptr) {
729
+ atomicAdd(&s_counters[4], entry_hits);
730
+ atomicAdd(&s_counters[2], entry_misses);
731
+ __syncthreads();
732
+ if (tid == 0) {
733
+ atomicAdd(&stats[0], static_cast<unsigned long long>(s_counters[4]));
734
+ atomicAdd(&stats[1], static_cast<unsigned long long>(s_counters[2]));
735
+ }
736
}
737
561
- // Phase 4: write back the LRU order: stale evictables at the front,
738
+ // Phase 5: write back the LRU order: stale evictables at the front,
739
// freshly loaded misses next, then hits at MRU.
740
const int total_evictable = hot_size - total_hits;
741
const int remaining_evictable = total_evictable - total_misses;
@@ -783,7 +960,7 @@ void hisparse_resolve_residency(
960
torch::stable::Tensor& device_global_indices,
961
torch::stable::Tensor& lru_slots,
962
std::optional<torch::stable::Tensor> const& request_state_indices,
786
- int64_t region_stride,
963
+ int64_t region_stride, int64_t max_union_rows,
964
std::optional<torch::stable::Tensor> const& miss_mask,
965
std::optional<torch::stable::Tensor> const& stats,
966
std::optional<torch::stable::Tensor> const& attention_indices,
@@ -1034,14 +1211,32 @@ void hisparse_resolve_residency(
1211
}
1212
1213
constexpr int kBlockSize = 1024;
1037
- const int hash_size = 2 * top_k;
1214
+ STD_TORCH_CHECK(max_union_rows > 0 && max_union_rows <= hot_size,
1215
+ "max_union_rows must be in [1, hot_size], got ",
1216
+ max_union_rows);
1217
+ // One spare entry above the union bound keeps every probe sequence finite.
1218
+ const int hash_size = static_cast<int>(max_union_rows) + 1;
1219
const int num_buffer_chunks = (hot_size + kWarpSize - 1) / kWarpSize;
1220
const size_t smem_bytes =
1040
- sizeof(int32_t) * (top_k + 2 * (num_buffer_chunks + 1) + hash_size + 3) +
1041
- sizeof(int16_t) * (hot_size + hash_size);
1221
+ sizeof(int32_t) * (2 * hash_size + 2 * (num_buffer_chunks + 1) +
1222
+ kResidencyCounters + num_buffer_chunks) +
1223
+ sizeof(int16_t) * hot_size;
1224
1225
const torch::stable::accelerator::DeviceGuard device_guard(
1226
hot_cache.get_device_index());
1227
+ int max_smem_bytes = 0;
1228
+ STD_TORCH_CHECK(cudaDeviceGetAttribute(
1229
+ &max_smem_bytes, cudaDevAttrMaxSharedMemoryPerBlockOptin,
1230
+ hot_cache.get_device_index()) == cudaSuccess,
1231
+ "failed to query shared memory per block");
1232
+ STD_TORCH_CHECK(smem_bytes <= static_cast<size_t>(max_smem_bytes),
1233
+ "HiSparse device_buffer_size=", hot_size, " needs ",
1234
+ smem_bytes,
1235
+ " bytes of shared memory to resolve residency, "
1236
+ "but the device allows ",
1237
+ max_smem_bytes,
1238
+ "; lower device_buffer_size or the number of speculative "
1239
+ "tokens.");
1240
const cudaStream_t stream = get_current_cuda_stream();
1241
const int64_t attention_row_stride =
1242
attention_indices.has_value() ? attention_indices.value().stride(0) : 0;
csrc/libtorch_stable/ops.h
+1
-1
@@ -680,7 +680,7 @@ void hisparse_resolve_residency(
680
torch::stable::Tensor& device_global_indices,
681
torch::stable::Tensor& lru_slots,
682
std::optional<torch::stable::Tensor> const& request_state_indices,
683
- int64_t region_stride,
683
+ int64_t region_stride, int64_t max_union_rows,
684
std::optional<torch::stable::Tensor> const& miss_mask,
685
std::optional<torch::stable::Tensor> const& stats,
686
std::optional<torch::stable::Tensor> const& attention_indices,
csrc/libtorch_stable/torch_bindings.cpp
+1
@@ -995,6 +995,7 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C_cache_ops, ops) {
995
" Tensor! lru_slots,"
996
" Tensor? request_state_indices,"
997
" int region_stride,"
998
+ " int max_union_rows,"
999
" Tensor(a!)? miss_mask=None,"
1000
" Tensor(b!)? stats=None,"
1001
" Tensor(c!)? attention_indices=None,"
docs/design/hisparse.md
+4
-4
@@ -130,10 +130,10 @@ path. The resolver consumes the existing graph-stable request mapping from
130
attention metadata; neither the worker nor individual cache handles keep a
131
duplicate mapping.
132
133
-Speculative decoding resolves and consumes each verification step in order.
134
-Each step receives distinct replayable plan rows while sharing the request's
135
-hot-cache state, so a later step cannot reuse a hot row before an earlier step
136
-has consumed it.
133
+Speculative decoding resolves all verification rows of a request in one pass:
134
+one block resolves the union of the rows' top-k against the request's hot-cache
135
+state, so rows that select the same host row share its hot row and no row
136
+evicts a hot row another row of the step still reads.
137
138
## P/D import target
139
tests/v1/attention/test_sparse_mla_backends.py
+228
-106
@@ -68,10 +68,10 @@ from vllm.utils.torch_utils import current_stream
68
from vllm.v1.attention.backends.mla import index_group as index_group_module
69
from vllm.v1.attention.backends.mla.flashattn_mla_sparse import (
70
FlashAttnMLASparseImpl,
71
+ FlashAttnMLASparseMetadataBuilder,
72
)
73
from vllm.v1.attention.backends.mla.flashinfer_mla_sparse import (
74
FlashInferMLASparseImpl,
74
- FlashInferMLASparseMetadataBuilder,
75
FlashInferMLASparseTRTLLMBackend,
76
)
77
from vllm.v1.attention.backends.mla.flashinfer_mla_sparse_sm120 import (
@@ -1880,11 +1880,13 @@ def _make_hisparse_runtime(
1880
block_size: int = 64,
1881
max_swap_rows: int | None = None,
1882
index_group: hisparse_runtime.HiSparseIndexGroup | None = None,
1883
+ max_union_rows: int | None = None,
1884
) -> HiSparseRuntime:
1885
runtime = HiSparseRuntime(
1886
config=ResolvedHiSparseConfig(
1887
top_k=top_k,
1888
device_buffer_size=device_buffer_size,
1889
+ max_union_rows=max_union_rows or device_buffer_size,
1890
),
1891
max_num_reqs=max_num_reqs,
1892
row_width=row_width,
@@ -2015,6 +2017,167 @@ def test_hisparse_maps_speculative_rows_through_request_state():
2017
assert physical.tolist() == [[block_size]] * 4
2018
2019
2020
+@requires_hisparse_ops
2021
+@pytest.mark.parametrize(
2022
+ "num_rows,row_stride,top_k",
2023
+ [(8, 128, 128), (4, 32, 128), (8, 2048, 2048)],
2024
+ ids=["disjoint", "overlap", "long_mtp"],
2025
+)
2026
+def test_hisparse_speculative_rows_resolve_host_misses_consistently(
2027
+ num_rows, row_stride, top_k
2028
+):
2029
+ """Verification rows of one request must agree on its hot-buffer slots.
2030
+
2031
+ Each row resolved residency in its own thread block against the request's
2032
+ shared slot ownership and LRU, so rows missing on different host rows could
2033
+ claim the same free slot. One row's hot index then pointed at a slot holding
2034
+ another row's KV. Overlapping rows must share one slot per host row, and
2035
+ long_mtp's rows span several resolution passes.
2036
+ """
2037
+ device = torch.device(DEVICE_TYPE)
2038
+ block_size, row_width = 64, 64
2039
+ num_blocks = 2 * num_rows * top_k // block_size
2040
+
2041
+ kv_pool = torch.randn(
2042
+ (num_blocks + 1, block_size, row_width), dtype=torch.float32
2043
+ ).pin_memory()
2044
+ flat_pool = kv_pool.reshape(-1, row_width)
2045
+ runtime = _make_hisparse_runtime(
2046
+ top_k=top_k,
2047
+ device_buffer_size=num_rows * top_k,
2048
+ max_num_reqs=1,
2049
+ row_width=row_width,
2050
+ block_size=block_size,
2051
+ max_swap_rows=num_rows,
2052
+ )
2053
+ runtime.bind_source_cache(kv_pool)
2054
+ cache = HiSparseCacheHandle(runtime)
2055
+ cache.all_context_pages_resident = False
2056
+
2057
+ block_table = torch.arange(
2058
+ 1, num_blocks + 1, dtype=torch.int32, device=device
2059
+ ).view(1, num_blocks)
2060
+ req_ids = torch.zeros(num_rows, dtype=torch.int32, device=device)
2061
+ rows = torch.arange(num_rows, dtype=torch.int32, device=device)[:, None]
2062
+ base = torch.arange(top_k, dtype=torch.int32, device=device)[None, :]
2063
+ for step in range(4):
2064
+ runtime.begin_forward()
2065
+ # Rows start row_stride apart (disjoint at row_stride == top_k) and
2066
+ # shift by half a top-k per step: all misses first, then a mix.
2067
+ topk = (rows * row_stride + base + step * (top_k // 2)) % (
2068
+ num_blocks * block_size
2069
+ )
2070
+ hot_indices = cache.swap_in(
2071
+ req_id_per_token=req_ids,
2072
+ block_table=block_table,
2073
+ logical_topk_indices=topk.clone(),
2074
+ block_size=block_size,
2075
+ )
2076
+ torch.accelerator.synchronize()
2077
+
2078
+ global_ref = _triton_convert_reference_impl(
2079
+ req_ids, block_table, topk, block_size, top_k
2080
+ )
2081
+ gathered = runtime.hot.attention_cache.reshape(-1, row_width)[
2082
+ hot_indices.to(torch.long)
2083
+ ].cpu()
2084
+ torch.testing.assert_close(gathered, flat_pool[global_ref.cpu().to(torch.long)])
2085
+
2086
+
2087
+@requires_hisparse_ops
2088
+def test_hisparse_speculative_rows_count_a_shared_load_once():
2089
+ """A host row two verification rows select loads once; the reuse is a hit."""
2090
+ device = torch.device(DEVICE_TYPE)
2091
+ block_size, row_width, top_k = 64, 64, 128
2092
+ num_blocks = top_k // block_size
2093
+ kv_pool = torch.randn(
2094
+ (num_blocks + 1, block_size, row_width), dtype=torch.float32
2095
+ ).pin_memory()
2096
+ runtime = _make_hisparse_runtime(
2097
+ top_k=top_k,
2098
+ device_buffer_size=2 * top_k,
2099
+ max_num_reqs=1,
2100
+ row_width=row_width,
2101
+ block_size=block_size,
2102
+ max_swap_rows=2,
2103
+ )
2104
+ runtime.bind_source_cache(kv_pool)
2105
+ cache = HiSparseCacheHandle(runtime)
2106
+ cache.all_context_pages_resident = False
2107
+ block_table = torch.arange(
2108
+ 1, num_blocks + 1, dtype=torch.int32, device=device
2109
+ ).view(1, num_blocks)
2110
+ topk = torch.arange(top_k, dtype=torch.int32, device=device).repeat(2, 1)
2111
+
2112
+ runtime.begin_forward()
2113
+ cache.swap_in(
2114
+ req_id_per_token=torch.zeros(2, dtype=torch.int32, device=device),
2115
+ block_table=block_table,
2116
+ logical_topk_indices=topk,
2117
+ block_size=block_size,
2118
+ )
2119
+ torch.accelerator.synchronize()
2120
+
2121
+ assert runtime.index_group.swap_stats.cpu().tolist() == [top_k, top_k]
2122
+
2123
+
2124
+@requires_hisparse_ops
2125
+def test_hisparse_union_table_scales_with_union_bound():
2126
+ """Shared memory follows the per-request union bound, not the hot buffer.
2127
+
2128
+ Sizing the table from device_buffer_size needs ~340 KB here and fails.
2129
+ """
2130
+ device = torch.device(DEVICE_TYPE)
2131
+ block_size, row_width, top_k = 64, 64, 128
2132
+ num_blocks = top_k // block_size
2133
+ kv_pool = torch.randn(
2134
+ (num_blocks + 1, block_size, row_width), dtype=torch.float32
2135
+ ).pin_memory()
2136
+ runtime = _make_hisparse_runtime(
2137
+ top_k=top_k,
2138
+ device_buffer_size=32768,
2139
+ max_union_rows=top_k,
2140
+ max_num_reqs=1,
2141
+ row_width=row_width,
2142
+ block_size=block_size,
2143
+ max_swap_rows=1,
2144
+ )
2145
+ runtime.bind_source_cache(kv_pool)
2146
+ cache = HiSparseCacheHandle(runtime)
2147
+ cache.all_context_pages_resident = False
2148
+ req_ids = torch.zeros(1, dtype=torch.int32, device=device)
2149
+ block_table = torch.arange(
2150
+ 1, num_blocks + 1, dtype=torch.int32, device=device
2151
+ ).view(1, num_blocks)
2152
+ topk = torch.arange(top_k, dtype=torch.int32, device=device).view(1, top_k)
2153
+
2154
+ runtime.begin_forward()
2155
+ hot_indices = cache.swap_in(
2156
+ req_id_per_token=req_ids,
2157
+ block_table=block_table,
2158
+ logical_topk_indices=topk,
2159
+ block_size=block_size,
2160
+ )
2161
+ torch.accelerator.synchronize()
2162
+
2163
+ global_ref = _triton_convert_reference_impl(
2164
+ req_ids, block_table, topk, block_size, top_k
2165
+ )
2166
+ gathered = runtime.hot.attention_cache.reshape(-1, row_width)[
2167
+ hot_indices.to(torch.long)
2168
+ ].cpu()
2169
+ torch.testing.assert_close(
2170
+ gathered, kv_pool.reshape(-1, row_width)[global_ref.cpu().to(torch.long)]
2171
+ )
2172
+
2173
+
2174
+@requires_hisparse_ops
2175
+def test_hisparse_rejects_union_bound_beyond_shared_memory():
2176
+ """An oversized union bound fails at construction, not on the first forward."""
2177
+ with pytest.raises(ValueError, match="shared memory"):
2178
+ _make_hisparse_runtime(top_k=2048, device_buffer_size=32768)
2179
+
2180
+
2181
@requires_hisparse_ops
2182
def test_hisparse_resident_rows_bypass_hot_lru():
2183
device = torch.device(DEVICE_TYPE)
@@ -3001,8 +3164,8 @@ def test_hisparse_gather_prefill_cache_prefers_resident_rows():
3164
torch.testing.assert_close(staged_flat[i], expected)
3165
3166
3004
-def test_hisparse_fp8_decode_resolves_steps_then_runs_batched_attention(monkeypatch):
3005
- """FP8 decode must use active dimensions when common metadata is padded."""
3167
+def test_hisparse_fp8_decode_resolves_rows_once_then_runs_batched_attention():
3168
+ """Verification rows resolve in one swap_in; FP8 attention sees them batched."""
3169
device = torch.device("cpu")
3170
num_decodes = 2
3171
query_len = 3
@@ -3013,48 +3176,31 @@ def test_hisparse_fp8_decode_resolves_steps_then_runs_batched_attention(monkeypa
3176
.view(-1, 1)
3177
.expand(-1, 4)
3178
)
3016
- steps: list[int] = []
3179
kernel_shapes: list[torch.Size] = []
3180
3181
def swap_in(req_ids, *, logical_topk_indices, **kwargs): # noqa: ARG001
3020
- steps.append(int(logical_topk_indices[0, 0]))
3021
- output = kwargs["attention_indices_out"]
3022
- if output is not None:
3023
- output.copy_(logical_topk_indices + 10)
3024
- return topk[:num_decodes]
3182
+ return logical_topk_indices + 10
3183
3184
def run_kernel(self, *, q, **kwargs): # noqa: ARG001
3185
kernel_shapes.append(q.shape)
3186
return q[..., :1], None
3187
3030
- runtime = SimpleNamespace(
3031
- hot=SimpleNamespace(attention_cache=torch.empty(1, device=device))
3032
- )
3033
- leader_cache = SimpleNamespace(
3034
- runtime=runtime,
3035
- source_block_table=torch.empty(
3036
- num_decodes, 1, dtype=torch.int32, device=device
3188
+ cache = SimpleNamespace(
3189
+ runtime=SimpleNamespace(
3190
+ hot=SimpleNamespace(attention_cache=torch.empty(1, device=device))
3191
),
3038
- swap_in=MagicMock(side_effect=swap_in),
3039
- )
3040
- follower_cache = SimpleNamespace(
3041
- runtime=runtime,
3192
source_block_table=torch.empty(
3193
num_decodes, 1, dtype=torch.int32, device=device
3194
),
3195
swap_in=MagicMock(side_effect=swap_in),
3196
)
3197
index_group = object.__new__(HiSparseMLAIndexGroup)
3048
- index_group.caches = [leader_cache, follower_cache]
3198
+ index_group.caches = [cache]
3199
index_group.physical_topk_indices = torch.empty(
3200
(num_tokens + 1, 4), dtype=torch.int32, device=device
3201
)
3052
- index_group.valid_topk_counts = torch.empty(
3053
- num_tokens + 1, dtype=torch.int32, device=device
3054
- )
3055
- index_group.request_ids = torch.arange(
3056
- num_decodes, dtype=torch.int32, device=device
3057
- )
3202
+ index_group.row_indices = torch.arange(num_tokens, dtype=torch.int32, device=device)
3203
+ index_group.request_ids = torch.empty_like(index_group.row_indices)
3204
impl = SimpleNamespace(
3205
kv_lora_rank=1,
3206
index_group=index_group,
@@ -3062,84 +3208,83 @@ def test_hisparse_fp8_decode_resolves_steps_then_runs_batched_attention(monkeypa
3208
)
3209
impl._fp8_flash_mla_kernel = MethodType(run_kernel, impl)
3210
metadata = SimpleNamespace(
3065
- num_decodes=num_tokens,
3066
- num_decode_tokens=num_tokens,
3067
- decode_max_query_len=1,
3211
query_start_loc=torch.arange(
3069
- 0,
3070
- num_tokens + 1,
3071
- query_len,
3072
- dtype=torch.int32,
3073
- device=device,
3212
+ 0, num_tokens + 1, query_len, dtype=torch.int32, device=device
3213
),
3075
- block_table=torch.empty(num_decodes, 1, dtype=torch.int32, device=device),
3214
+ req_id_per_token=torch.arange(
3215
+ num_decodes, dtype=torch.int32, device=device
3216
+ ).repeat_interleave(query_len),
3217
block_size=64,
3218
)
3219
3220
output = FlashMLASparseImpl._host_backed_fp8_decode(
3080
- impl,
3081
- q,
3082
- topk,
3083
- metadata,
3084
- SimpleNamespace(),
3085
- num_decodes,
3086
- query_len,
3221
+ impl, q, topk, metadata, SimpleNamespace(), num_decodes
3222
)
3223
3089
- assert steps == [0, 1, 2]
3090
- assert kernel_shapes == [(num_decodes, query_len, 2, 4)]
3091
- assert output.shape == (num_tokens, 2, 1)
3224
+ cache.swap_in.assert_called_once()
3225
torch.testing.assert_close(
3093
- index_group.physical_topk_indices[:num_tokens], topk + 10
3226
+ cache.swap_in.call_args.args[0], metadata.req_id_per_token
3227
)
3228
+ torch.testing.assert_close(
3229
+ cache.swap_in.call_args.kwargs["logical_topk_indices"], topk
3230
+ )
3231
+ assert kernel_shapes == [(num_decodes, query_len, 2, 4)]
3232
+ assert output.shape == (num_tokens, 2, 1)
3233
3096
- follower_indices = index_group.convert_decode_logical_to_physical_topk(
3097
- 1,
3098
- topk,
3099
- metadata,
3100
- return_valid_counts=False,
3101
- num_decodes=num_decodes,
3102
- decode_query_len=query_len,
3234
+
3235
+def test_hisparse_keeps_speculative_rows_as_decodes():
3236
+ """HiSparse limits decodes to verification requests, not short extends.
3237
+
3238
+ FlashAttn sparse MLA used to classify MTP verification as prefill under
3239
+ HiSparse, whose residency-dependent route a FULL CUDA graph freezes at
3240
+ capture.
3241
+ """
3242
+ builder = object.__new__(FlashAttnMLASparseMetadataBuilder)
3243
+ builder.vllm_config = SimpleNamespace(
3244
+ attention_config=SimpleNamespace(hisparse_config=object()),
3245
+ speculative_config=SimpleNamespace(
3246
+ num_speculative_tokens=3, parallel_drafting=False
3247
+ ),
3248
+ parallel_config=SimpleNamespace(decode_context_parallel_size=1),
3249
+ use_v2_model_runner=True,
3250
)
3104
- assert steps == [0, 1, 2, 0, 1, 2]
3105
- torch.testing.assert_close(follower_indices, topk + 10)
3251
3252
+ builder._init_reorder_batch_threshold(256, supports_spec_as_decode=True)
3253
3108
-def test_hisparse_varlen_decode_uses_canonical_request_rows():
3109
- """Graph metadata's query upper bound must preserve packed token rows."""
3110
- num_decodes = 2
3111
- topk = torch.arange(18, dtype=torch.int32).view(9, 2)
3254
+ assert builder.reorder_batch_threshold == 4
3255
3113
- def swap_in(_request_ids, *, logical_topk_indices, **_kwargs):
3114
- return logical_topk_indices + 10
3256
3257
+def test_hisparse_decode_skips_padding_rows():
3258
+ """Padding rows are masked, and the ids stay in the group's persistent buffer.
3259
+
3260
+ The resolver reads them on the HiSparse copy stream, where a compute-stream
3261
+ temporary can already be recycled (CUDA graphs do), scrambling rows' requests.
3262
+ """
3263
+ topk = torch.arange(20, dtype=torch.int32).view(10, 2)
3264
cache = SimpleNamespace(
3117
- source_block_table=torch.zeros((num_decodes, 1), dtype=torch.int32),
3118
- swap_in=MagicMock(side_effect=swap_in),
3265
+ source_block_table=torch.zeros((2, 1), dtype=torch.int32),
3266
+ swap_in=MagicMock(return_value=topk),
3267
)
3268
index_group = object.__new__(HiSparseMLAIndexGroup)
3269
index_group.caches = [cache]
3122
- index_group.physical_topk_indices = torch.empty((10, 2), dtype=torch.int32)
3123
- index_group.valid_topk_counts = torch.empty(10, dtype=torch.int32)
3124
- index_group.request_ids = torch.arange(num_decodes, dtype=torch.int32)
3270
+ index_group.physical_topk_indices = torch.empty((11, 2), dtype=torch.int32)
3271
+ index_group.row_indices = torch.arange(10, dtype=torch.int32)
3272
+ index_group.request_ids = torch.empty_like(index_group.row_indices)
3273
metadata = SimpleNamespace(
3126
- num_decodes=num_decodes,
3127
- num_decode_tokens=topk.shape[0],
3128
- decode_max_query_len=15,
3274
query_start_loc=torch.tensor([0, 8, 9], dtype=torch.int32),
3275
+ req_id_per_token=torch.tensor([0] * 8 + [1, 0], dtype=torch.int32),
3276
block_size=64,
3277
)
3278
3133
- physical_topk = index_group.convert_decode_logical_to_physical_topk(
3134
- 0,
3135
- topk,
3136
- metadata,
3137
- return_valid_counts=False,
3279
+ index_group.convert_logical_to_physical_topk(
3280
+ 0, topk, metadata, block_stride_rows=None, return_valid_counts=False
3281
)
3282
3140
- torch.testing.assert_close(physical_topk, topk + 10)
3141
- request_rows = cache.swap_in.call_args_list[0].args[0]
3142
- torch.testing.assert_close(request_rows, index_group.request_ids)
3283
+ request_ids = cache.swap_in.call_args.args[0]
3284
+ assert request_ids.data_ptr() == index_group.request_ids.data_ptr()
3285
+ torch.testing.assert_close(
3286
+ request_ids, torch.tensor([0] * 8 + [1, -1], dtype=torch.int32)
3287
+ )
3288
3289
3290
def test_flashinfer_hisparse_decode_runs_batched_attention():
@@ -3168,7 +3313,7 @@ def test_flashinfer_hisparse_decode_runs_batched_attention():
3313
)
3314
index_group = object.__new__(HiSparseMLAIndexGroup)
3315
index_group.caches = [cache_handle]
3171
- index_group.convert_decode_logical_to_physical_topk = MethodType(
3316
+ index_group.convert_logical_to_physical_topk = MethodType(
3317
convert_decode, index_group
3318
)
3319
impl = object.__new__(FlashInferMLASparseImpl)
@@ -3200,7 +3345,7 @@ def test_flashattn_hisparse_decode_uses_index_group():
3345
physical = topk.clone()
3346
counts = torch.full((num_tokens,), 4, dtype=torch.int32, device=DEVICE_TYPE)
3347
index_group = object.__new__(HiSparseMLAIndexGroup)
3203
- index_group.convert_decode_logical_to_physical_topk = MagicMock(
3348
+ index_group.convert_logical_to_physical_topk = MagicMock(
3349
return_value=(physical, counts)
3350
)
3351
index_group.physical_kv_cache = MagicMock(
@@ -3223,7 +3368,7 @@ def test_flashattn_hisparse_decode_uses_index_group():
3368
3369
assert output.shape == (num_tokens, 2, 1)
3370
assert lse is None
3226
- index_group.convert_decode_logical_to_physical_topk.assert_called_once()
3371
+ index_group.convert_logical_to_physical_topk.assert_called_once()
3372
impl._run_mqa_kernel.assert_called_once()
3373
3374
@@ -3233,9 +3378,7 @@ def test_flashinfer_sm120_hisparse_decode_uses_index_group():
3378
topk = torch.zeros(num_tokens, 4, dtype=torch.int32, device=DEVICE_TYPE)
3379
physical = topk.clone()
3380
index_group = object.__new__(HiSparseMLAIndexGroup)
3236
- index_group.convert_decode_logical_to_physical_topk = MagicMock(
3237
- return_value=physical
3238
- )
3381
+ index_group.convert_logical_to_physical_topk = MagicMock(return_value=physical)
3382
index_group.physical_kv_cache = MagicMock(
3383
return_value=torch.empty(1, device=DEVICE_TYPE)
3384
)
@@ -3259,7 +3402,7 @@ def test_flashinfer_sm120_hisparse_decode_uses_index_group():
3402
3403
assert output.shape == (num_tokens, 2, 1)
3404
assert lse is None
3262
- index_group.convert_decode_logical_to_physical_topk.assert_called_once()
3405
+ index_group.convert_logical_to_physical_topk.assert_called_once()
3406
impl._run_mqa_kernel.assert_called_once()
3407
3408
@@ -3291,27 +3434,6 @@ def test_hisparse_resident_prefill_uses_attention_block_stride():
3434
assert index_group._convert_once.call_args.kwargs["block_stride_rows"] == 832
3435
3436
3294
-def test_hisparse_shared_sparse_builder_routes_multi_token_chunks_to_prefill():
3295
- """Default sparse backends must route multi-token chunks to prefill."""
3296
- builder = object.__new__(FlashInferMLASparseMetadataBuilder)
3297
- builder.vllm_config = SimpleNamespace(
3298
- attention_config=SimpleNamespace(hisparse_config=object()),
3299
- speculative_config=SimpleNamespace(
3300
- num_speculative_tokens=4,
3301
- parallel_drafting=False,
3302
- ),
3303
- parallel_config=SimpleNamespace(decode_context_parallel_size=1),
3304
- )
3305
-
3306
- builder._init_reorder_batch_threshold(
3307
- 1024,
3308
- supports_spec_as_decode=True,
3309
- supports_dcp_with_varlen=True,
3310
- )
3311
-
3312
- assert builder.reorder_batch_threshold == 1
3313
-
3314
-
3437
def test_hisparse_flashmla_reorders_full_speculative_window_as_decode():
3438
builder = object.__new__(FlashMLASparseMetadataBuilder)
3439
builder.vllm_config = SimpleNamespace(
tests/v1/worker/test_utils.py
+1
@@ -1156,6 +1156,7 @@ def test_hisparse_cache_handles_join_index_groups_during_construction(monkeypatc
1156
resolved = hisparse_runtime_module.ResolvedHiSparseConfig(
1157
top_k=4,
1158
device_buffer_size=8,
1159
+ max_union_rows=8,
1160
)
1161
monkeypatch.setattr(hisparse_runtime_module, "_has_hisparse_ops", lambda: True)
1162
monkeypatch.setattr(
vllm/model_executor/layers/attention/sparse_mla_attention.py
-3
@@ -165,7 +165,6 @@ T = TypeVar("T", bound=SparseMLACommonMetadata)
165
class SparseMLACommonMetadataBuilder(AttentionMetadataBuilder[T]):
166
metadata_cls: type[T]
167
require_uniform_decodes: ClassVar[bool] = False
168
- hisparse_supports_multi_token_decode: ClassVar[bool] = False
168
169
def __init__(
170
self,
@@ -259,8 +258,6 @@ class SparseMLACommonMetadataBuilder(AttentionMetadataBuilder[T]):
258
) -> None:
259
if self.vllm_config.attention_config.hisparse_config is not None:
260
reorder_batch_threshold = 1
262
- if not self.hisparse_supports_multi_token_decode:
263
- supports_spec_as_decode = False
261
super()._init_reorder_batch_threshold(
262
reorder_batch_threshold,
263
supports_spec_as_decode,
vllm/v1/attention/backends/mla/flashattn_mla_sparse.py
+2
-1
@@ -207,10 +207,11 @@ class FlashAttnMLASparseImpl(SparseMLACommonImpl[FlashAttnMLASparseMetadata]):
207
outputs = []
208
if num_decode_tokens:
209
physical_topk, valid_counts = (
210
- index_group.convert_decode_logical_to_physical_topk(
210
+ index_group.convert_logical_to_physical_topk(
211
self.index_group_index,
212
topk_indices[:num_decode_tokens],
213
attn_metadata,
214
+ block_stride_rows=None,
215
return_valid_counts=True,
216
)
217
)
vllm/v1/attention/backends/mla/flashinfer_mla_sparse.py
+2
-2
@@ -273,7 +273,6 @@ class FlashInferMLASparseTRTLLMMetadataBuilder(FlashInferMLASparseMetadataBuilde
273
"""Metadata builder for the SM100 TRT-LLM sparse MLA kernel."""
274
275
_cudagraph_support: ClassVar[AttentionCGSupport] = AttentionCGSupport.ALWAYS
276
- hisparse_supports_multi_token_decode: ClassVar[bool] = True
276
277
def __init__(
278
self,
@@ -502,10 +501,11 @@ class FlashInferMLASparseImpl(SparseMLACommonImpl[FlashInferMLASparseMetadata]):
501
decode_lse: torch.Tensor | None = None
502
if num_decode_tokens > 0:
503
physical_topk, valid_counts = (
505
- index_group.convert_decode_logical_to_physical_topk(
504
+ index_group.convert_logical_to_physical_topk(
505
self.index_group_index,
506
topk_indices[:num_decode_tokens],
507
attn_metadata,
508
+ block_stride_rows=None,
509
return_valid_counts=True,
510
)
511
)
vllm/v1/attention/backends/mla/flashinfer_mla_sparse_sm120.py
+2
-1
@@ -125,10 +125,11 @@ class FlashInferMLASparseSM120Impl(SparseMLACommonImpl[FlashInferMLASparseMetada
125
if num_decode_tokens:
126
topk_indices_physical = cast(
127
torch.Tensor,
128
- index_group.convert_decode_logical_to_physical_topk(
128
+ index_group.convert_logical_to_physical_topk(
129
self.index_group_index,
130
topk_indices[:num_decode_tokens],
131
attn_metadata,
132
+ block_stride_rows=None,
133
return_valid_counts=False,
134
),
135
)
vllm/v1/attention/backends/mla/flashmla_sparse.py
+4
-7
@@ -317,7 +317,6 @@ class FlashMLASparseMetadataBuilder(
317
):
318
_cudagraph_support: ClassVar[AttentionCGSupport] = AttentionCGSupport.UNIFORM_BATCH
319
require_uniform_decodes: ClassVar[bool] = True
320
- hisparse_supports_multi_token_decode: ClassVar[bool] = True
320
metadata_cls = FlashMLASparseMetadata
321
322
def __init__(
@@ -818,10 +817,11 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
817
num_decode_tokens = attn_metadata.num_decode_tokens
818
if num_decode_tokens > 0:
819
decode_topk, decode_lengths = (
821
- index_group.convert_decode_logical_to_physical_topk(
820
+ index_group.convert_logical_to_physical_topk(
821
self.index_group_index,
822
topk_indices[:num_decode_tokens],
823
attn_metadata,
824
+ block_stride_rows=None,
825
return_valid_counts=True,
826
)
827
)
@@ -1027,7 +1027,6 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
1027
attn_metadata,
1028
fp8_metadata.decode.kernel_metadata,
1029
num_decodes,
1030
- fp8_metadata.decode.decode_query_len,
1030
)
1031
# Reshape q: (num_decode_tokens, num_heads, head_dim)
1032
# -> (num_decodes, seq_len, num_heads, head_dim)
@@ -1267,16 +1266,14 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
1266
attn_metadata: FlashMLASparseMetadata,
1267
kernel_metadata: FlashMLASparseMetadata.FP8KernelMetadata,
1268
num_decodes: int,
1270
- decode_query_len: int,
1269
) -> torch.Tensor:
1270
assert isinstance(self.index_group, HiSparseMLAIndexGroup)
1273
- physical_topk = self.index_group.convert_decode_logical_to_physical_topk(
1271
+ physical_topk = self.index_group.convert_logical_to_physical_topk(
1272
self.index_group_index,
1273
topk_indices,
1274
attn_metadata,
1275
+ block_stride_rows=None,
1276
return_valid_counts=False,
1278
- num_decodes=num_decodes,
1279
- decode_query_len=decode_query_len,
1277
)
1278
assert isinstance(physical_topk, torch.Tensor)
1279
q = reshape_query_for_spec_decode(q, num_decodes)
vllm/v1/attention/backends/mla/index_group.py
+16
-104
@@ -39,6 +39,7 @@ class SparseMLAIndexGroup:
39
logical_topk_indices: torch.Tensor
40
physical_topk_indices: torch.Tensor
41
valid_topk_counts: torch.Tensor
42
+ row_indices: torch.Tensor
43
request_ids: torch.Tensor
44
side_stream: torch.Stream
45
logical_topk_ready: torch.Event
@@ -230,12 +231,10 @@ class HiSparseMLAIndexGroup(SparseMLAIndexGroup):
231
*,
232
block_stride_rows: int | None,
233
return_valid_counts: bool,
233
- req_id_per_token: torch.Tensor | None = None,
234
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
235
cache = self.cache(layer_index)
236
num_tokens = logical_topk_indices.shape[0]
237
- if req_id_per_token is None:
238
- req_id_per_token = attn_metadata.req_id_per_token[:num_tokens]
237
+ req_id_per_token = attn_metadata.req_id_per_token[:num_tokens]
238
if num_tokens > self.physical_topk_indices.shape[0]:
239
# Prefill-sized batches do not fit the decode residency workspace.
240
# Non-resident prefills are staged before reaching this path.
@@ -253,113 +252,21 @@ class HiSparseMLAIndexGroup(SparseMLAIndexGroup):
252
)
253
source_block_table = cache.source_block_table
254
assert source_block_table is not None
255
+ # CUDA-graph padding rows past the batch's tokens map to request 0;
256
+ # mark them -1 so residency resolution skips them.
257
+ request_ids = self.request_ids[:num_tokens]
258
+ request_ids.copy_(req_id_per_token)
259
+ request_ids.masked_fill_(
260
+ self.row_indices[:num_tokens] >= attn_metadata.query_start_loc[-1], -1
261
+ )
262
return cache.swap_in(
257
- req_id_per_token,
263
+ request_ids,
264
block_table=source_block_table,
265
logical_topk_indices=logical_topk_indices,
266
block_size=attn_metadata.block_size,
267
return_valid_counts=return_valid_counts,
268
)
269
264
- def convert_decode_logical_to_physical_topk(
265
- self,
266
- layer_index: int,
267
- logical_topk_indices: torch.Tensor,
268
- attn_metadata: Any,
269
- *,
270
- return_valid_counts: bool,
271
- num_decodes: int | None = None,
272
- decode_query_len: int | None = None,
273
- ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
274
- num_tokens = logical_topk_indices.shape[0]
275
- if num_decodes is None:
276
- num_decodes = attn_metadata.num_decodes
277
- if decode_query_len is None:
278
- decode_query_len = attn_metadata.decode_max_query_len
279
- if decode_query_len == 1:
280
- return self.convert_logical_to_physical_topk(
281
- layer_index,
282
- logical_topk_indices,
283
- attn_metadata,
284
- block_stride_rows=None,
285
- return_valid_counts=return_valid_counts,
286
- req_id_per_token=self.request_ids[:num_decodes],
287
- )
288
-
289
- if num_tokens != num_decodes * decode_query_len:
290
- query_start_loc = attn_metadata.query_start_loc[: num_decodes + 1]
291
- physical_topk_indices = self.physical_topk_indices[: num_tokens + 1]
292
- valid_topk_counts = self.valid_topk_counts[: num_tokens + 1]
293
- request_ids = self.request_ids[:num_decodes]
294
- cache = self.cache(layer_index)
295
- source_block_table = cache.source_block_table
296
- assert source_block_table is not None
297
- for step in range(decode_query_len):
298
- token_indices = query_start_loc[:-1] + step
299
- active = (token_indices < query_start_loc[1:]) & (
300
- token_indices < num_tokens
301
- )
302
- output_indices = torch.where(
303
- active, token_indices, torch.full_like(token_indices, num_tokens)
304
- ).long()
305
- token_indices = token_indices.clamp(0, num_tokens - 1).long()
306
- step_topk = logical_topk_indices.index_select(
307
- 0, token_indices
308
- ).masked_fill(~active.unsqueeze(1), -1)
309
- step_result = cache.swap_in(
310
- request_ids,
311
- block_table=source_block_table,
312
- logical_topk_indices=step_topk,
313
- block_size=attn_metadata.block_size,
314
- return_valid_counts=return_valid_counts,
315
- )
316
- if layer_index == 0:
317
- if return_valid_counts:
318
- step_indices, step_counts = step_result
319
- valid_topk_counts.index_copy_(0, output_indices, step_counts)
320
- else:
321
- step_indices = step_result
322
- physical_topk_indices.index_copy_(0, output_indices, step_indices)
323
- physical_topk_indices = physical_topk_indices[:num_tokens]
324
- if return_valid_counts:
325
- return physical_topk_indices, valid_topk_counts[:num_tokens]
326
- return physical_topk_indices
327
-
328
- assert num_tokens == num_decodes * decode_query_len
329
- logical_topk_by_request = logical_topk_indices.view(
330
- num_decodes, decode_query_len, -1
331
- )
332
- physical_topk_by_request = self.physical_topk_indices[:num_tokens].view(
333
- num_decodes, decode_query_len, -1
334
- )
335
- valid_topk_by_request = self.valid_topk_counts[:num_tokens].view(
336
- num_decodes, decode_query_len
337
- )
338
- request_ids = self.request_ids[:num_decodes]
339
- for step in range(decode_query_len):
340
- cache = self.cache(layer_index)
341
- source_block_table = cache.source_block_table
342
- assert source_block_table is not None
343
- cache.swap_in(
344
- request_ids,
345
- block_table=source_block_table,
346
- logical_topk_indices=logical_topk_by_request[:, step],
347
- block_size=attn_metadata.block_size,
348
- return_valid_counts=return_valid_counts,
349
- attention_indices_out=(
350
- physical_topk_by_request[:, step] if layer_index == 0 else None
351
- ),
352
- valid_counts_out=(
353
- valid_topk_by_request[:, step]
354
- if layer_index == 0 and return_valid_counts
355
- else None
356
- ),
357
- )
358
- physical_topk_indices = physical_topk_by_request.view(num_tokens, -1)
359
- if return_valid_counts:
360
- return physical_topk_indices, valid_topk_by_request.view(num_tokens)
361
- return physical_topk_indices
362
-
270
def stage_prefill_rows(
271
self, layer_index: int, kv_cache: torch.Tensor, attn_metadata: Any
272
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
@@ -477,7 +384,12 @@ class SparseMLAIndexGroupBuilder:
384
dtype=torch.int32,
385
device=self.logical_topk_indices.device,
386
),
480
- request_ids=torch.arange(
387
+ row_indices=torch.arange(
388
+ workspace_rows,
389
+ dtype=torch.int32,
390
+ device=self.logical_topk_indices.device,
391
+ ),
392
+ request_ids=torch.empty(
393
workspace_rows,
394
dtype=torch.int32,
395
device=self.logical_topk_indices.device,
vllm/v1/hisparse/runtime.py
+37
-1
@@ -19,7 +19,8 @@ from vllm.distributed import get_tp_group
19
from vllm.forward_context import get_forward_context, in_piecewise_cudagraph
20
from vllm.logger import init_logger
21
from vllm.platforms import current_platform
22
-from vllm.utils.math_utils import round_up
22
+from vllm.utils.math_utils import cdiv, round_up
23
+from vllm.utils.mem_utils import get_max_shared_memory_bytes
24
from vllm.utils.torch_utils import current_stream
25
from vllm.v1.attention.backend import max_decode_query_len
26
from vllm.v1.kv_offload.cpu.shared_offload_region import SharedOffloadRegion
@@ -96,6 +97,9 @@ class PagedCacheView:
97
class ResolvedHiSparseConfig:
98
top_k: int
99
device_buffer_size: int
100
+ # Most host rows one request's decode rows can select in a step: every
101
+ # verification row's top-k.
102
+ max_union_rows: int
103
eager_host_mirror: bool = True
104
105
@classmethod
@@ -151,10 +155,38 @@ class ResolvedHiSparseConfig:
155
return cls(
156
top_k=model_top_k,
157
device_buffer_size=device_buffer_size,
158
+ max_union_rows=min_device_buffer_size,
159
eager_host_mirror=config.eager_host_mirror,
160
)
161
162
163
+def _check_residency_shared_memory(
164
+ device: torch.device, config: ResolvedHiSparseConfig
165
+) -> None:
166
+ """Reject a config whose residency resolver exceeds per-block shared memory."""
167
+ # Mirrors hisparse_resolve_residency's layout: union hash keys and values
168
+ # (plus an empty slot), per-chunk offsets, five counters, done bits, and
169
+ # the int16 compacted LRU.
170
+ hot_size = config.device_buffer_size
171
+ num_chunks = cdiv(hot_size, 32)
172
+ required = 4 * (2 * (config.max_union_rows + 1) + 3 * num_chunks + 7) + 2 * hot_size
173
+ device_index = (
174
+ device.index
175
+ if device.index is not None
176
+ else torch.accelerator.current_device_index()
177
+ )
178
+ available = get_max_shared_memory_bytes(device_index)
179
+ if required > available:
180
+ raise ValueError(
181
+ "HiSparse residency resolution needs "
182
+ f"{required} B of shared memory per block for "
183
+ f"device_buffer_size={config.device_buffer_size} and "
184
+ f"{config.max_union_rows} rows per request "
185
+ "(decode query length x index_topk), but the device allows "
186
+ f"{available} B. Lower device_buffer_size or num_speculative_tokens."
187
+ )
188
+
189
+
190
def check_hisparse_host_memory(pool_bytes: int) -> None:
191
"""Reject a physical host-pool allocation that cannot fit in RAM."""
192
mem = psutil.virtual_memory()
@@ -677,6 +709,9 @@ class HiSparseRuntime:
709
# Logical slots per request. Physical rows come from its ephemeral HMA
710
# block table and need not be contiguous in the shared slab.
711
self.region_stride = config.device_buffer_size
712
+ self.max_union_rows = config.max_union_rows
713
+ if self.device.type == "cuda":
714
+ _check_residency_shared_memory(self.device, config)
715
716
row_bytes = row_width * kv_dtype.itemsize
717
if row_bytes % 16 != 0:
@@ -919,6 +954,7 @@ class HiSparseRuntime:
954
group.lru_slots,
955
request_state_indices,
956
self.region_stride,
957
+ self.max_union_rows,
958
None,
959
group.swap_stats,
960
physical_topk_indices,