[Perf][Kernel] Add sampled filtering for persistent top-k (#56346)
Signed-off-by: mgoin <mgoin64@gmail.com> Co-authored-by: OpenAI Codex <noreply@openai.com>
Misha Goin committed
Sep 15, 2026 at 17:15 UTC
c6fa1f05d1bc627171c3a58c05f23a1c41202ac1
5 files changed
+556
-35
benchmarks/kernels/benchmark_persistent_topk.py
new
+134
@@ -0,0 +1,134 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Benchmark sparse-indexer decode top-k on variable-length FP32 rows.
4
+
5
+Run on a reserved GPU, for example:
6
+ chg run -- .venv/bin/python benchmarks/kernels/benchmark_persistent_topk.py
7
+
8
+Use --capacity to emulate graphs captured at a larger maximum context length.
9
+Run the same command on the base and candidate builds to compare JSON results.
10
+"""
11
+
12
+import argparse
13
+import itertools
14
+import json
15
+import statistics
16
+from pathlib import Path
17
+
18
+import torch
19
+from flashinfer.testing import bench_gpu_time_with_cupti
20
+
21
+import vllm._custom_ops # noqa: F401
22
+from vllm.platforms import current_platform
23
+
24
+
25
+def benchmark(rows: int, width: int, k: int, args) -> dict:
26
+ capacity = args.capacity or width
27
+ if capacity < width:
28
+ raise ValueError("Capacity must be at least the valid-length upper bound")
29
+ torch.manual_seed(args.seed)
30
+ logits = torch.randn(rows, capacity, device="cuda", dtype=torch.float32)
31
+ if args.full_length:
32
+ lengths = torch.full((rows,), width, device="cuda", dtype=torch.int32)
33
+ else:
34
+ lengths = torch.randint(
35
+ int(width * 0.8), width + 1, (rows,), device="cuda", dtype=torch.int32
36
+ )
37
+ invalid = torch.arange(capacity, device="cuda")[None] >= lengths[:, None]
38
+ logits.masked_fill_(invalid, float("nan"))
39
+ out = torch.empty((rows, k), device="cuda", dtype=torch.int32)
40
+ workspace = torch.empty(1024 * 1024, device="cuda", dtype=torch.uint8)
41
+ cooperative = (
42
+ args.backend == "auto"
43
+ and rows <= 64
44
+ and capacity % 4 == 0
45
+ and current_platform.has_device_capability(90)
46
+ and not current_platform.is_device_capability_family(120)
47
+ )
48
+ op = torch.ops._C.cooperative_topk if cooperative else torch.ops._C.persistent_topk
49
+
50
+ def run():
51
+ op(logits, lengths, out, workspace, k, capacity)
52
+
53
+ run()
54
+ valid = torch.arange(k, device="cuda")[None] < lengths[:, None]
55
+ assert torch.all(out[~valid] == -1)
56
+ assert torch.all(((out >= 0) & (out < lengths[:, None])) == valid)
57
+ sorted_indices = out.sort(dim=1).values
58
+ assert torch.all(
59
+ (sorted_indices[:, 1:] != sorted_indices[:, :-1])
60
+ | (sorted_indices[:, 1:] == -1)
61
+ )
62
+ selected = logits.gather(1, out.clamp_min(0).long()).masked_fill(
63
+ ~valid, -float("inf")
64
+ )
65
+ reference = logits.masked_fill(invalid, -float("inf")).topk(k, dim=1).values
66
+ torch.testing.assert_close(
67
+ selected.sort(dim=1, descending=True).values, reference, atol=0, rtol=0
68
+ )
69
+ trials = [
70
+ statistics.median(
71
+ bench_gpu_time_with_cupti(
72
+ run,
73
+ use_cuda_graph=True,
74
+ cold_l2_cache=not args.warm,
75
+ dry_run_iters=5,
76
+ repeat_iters=args.repeat,
77
+ )
78
+ )
79
+ * 1000
80
+ for _ in range(args.trials)
81
+ ]
82
+ us = statistics.median(trials)
83
+ return dict(
84
+ rows=rows,
85
+ width=width,
86
+ capacity=capacity,
87
+ k=k,
88
+ backend="cooperative" if cooperative else "persistent",
89
+ us=us,
90
+ trials_us=trials,
91
+ # Minimum useful traffic: one valid-score read and one index write.
92
+ effective_gbps=(int(lengths.sum()) * 4 + out.numel() * 4) / (us * 1000),
93
+ )
94
+
95
+
96
+def main():
97
+ parser = argparse.ArgumentParser(description=__doc__)
98
+ parser.add_argument("--rows", type=int, nargs="+", default=[1, 32, 128, 256, 1024])
99
+ parser.add_argument("--widths", type=int, nargs="+", default=[8192, 65536, 163840])
100
+ parser.add_argument("--top-k", type=int, nargs="+", default=[512, 2048])
101
+ parser.add_argument("--capacity", type=int)
102
+ parser.add_argument(
103
+ "--full-length", action="store_true", help="Use exactly --widths valid scores"
104
+ )
105
+ parser.add_argument("--backend", choices=["auto", "persistent"], default="auto")
106
+ parser.add_argument(
107
+ "--warm", action="store_true", help="Keep L2 warm between replays"
108
+ )
109
+ parser.add_argument("--repeat", type=int, default=60)
110
+ parser.add_argument("--trials", type=int, default=3)
111
+ parser.add_argument("--seed", type=int, default=123)
112
+ parser.add_argument("--output", type=Path)
113
+ args = parser.parse_args()
114
+ metadata = dict(
115
+ gpu=str(torch.cuda.get_device_properties(0)),
116
+ torch=torch.__version__,
117
+ cuda=torch.version.cuda,
118
+ args=vars(args),
119
+ )
120
+ print(json.dumps(metadata, default=str))
121
+ results = []
122
+ for rows, width, k in itertools.product(args.rows, args.widths, args.top_k):
123
+ result = benchmark(rows, width, k, args)
124
+ results.append(result)
125
+ print(json.dumps(result), flush=True)
126
+ if args.output:
127
+ args.output.write_text(
128
+ json.dumps(dict(metadata=metadata, results=results), indent=2, default=str)
129
+ + "\n"
130
+ )
131
+
132
+
133
+if __name__ == "__main__":
134
+ main()
csrc/libtorch_stable/persistent_topk.cuh
+52
-34
@@ -1030,41 +1030,34 @@ constexpr uint32_t FILTERED_TOPK_SMEM_INPUT_SIZE =
1030
constexpr size_t FILTERED_TOPK_SMEM_DYNAMIC =
1031
sizeof(int) * 2 * FILTERED_TOPK_SMEM_INPUT_SIZE; // 128KB
1032
1033
-/*!
1034
- * \brief Filtered Top-K kernel for ragged sequences.
1035
- *
1036
- * \tparam DType Data type (float, half, nv_bfloat16)
1037
- * \tparam IdType Index type (int32_t)
1038
- * \tparam VEC_SIZE Vector size for input loads (1, 2, 4, or 8)
1039
- */
1040
-template <typename DType, typename IdType, int VEC_SIZE, uint32_t MAX_K = 2048,
1041
- bool UsePredicatedShortLoads = false>
1042
-__global__ void __launch_bounds__(FILTERED_TOPK_BLOCK_THREADS)
1043
- FilteredTopKUnifiedKernel(const DType* __restrict__ input,
1044
- IdType* __restrict__ output,
1045
- const IdType* __restrict__ lengths,
1046
- uint32_t num_rows, uint32_t top_k,
1047
- uint32_t max_len) {
1033
+template <uint32_t MAX_K>
1034
+struct FilteredTopKStorage {
1035
+ alignas(128) int histogram[2][256 + 128];
1036
+ alignas(128) int counter;
1037
+ alignas(128) int threshold_bin;
1038
+ alignas(128) int num_input[2];
1039
+ alignas(128) int indices[MAX_K];
1040
+ int last_remain;
1041
+};
1042
+
1043
+// With CheckOverflow, return false before using a truncated stash so the caller
1044
+// can retry with exact full-row selection.
1045
+template <typename DType, typename IdType, int VEC_SIZE, uint32_t MAX_K,
1046
+ bool UsePredicatedShortLoads, bool CheckOverflow = false>
1047
+__device__ __forceinline__ bool filtered_topk_row(
1048
+ const DType* score, IdType* dst, int length, uint32_t top_k,
1049
+ FilteredTopKStorage<MAX_K>& storage) {
1050
constexpr uint32_t BLOCK_SIZE = FILTERED_TOPK_BLOCK_THREADS;
1051
constexpr int RADIX = 256;
1052
constexpr int SMEM_INPUT_SIZE = FILTERED_TOPK_SMEM_INPUT_SIZE;
1051
-
1052
- const uint32_t bid = blockIdx.x;
1053
const int tx = threadIdx.x;
1054
1055
- if (bid >= num_rows) return;
1056
-
1057
- const int length =
1058
- (lengths != nullptr) ? lengths[bid] : static_cast<int>(max_len);
1059
- const DType* score = input + bid * max_len;
1060
- IdType* dst = output + bid * top_k;
1061
-
1055
// Trivial case: length <= top_k
1056
if (length <= static_cast<int>(top_k)) {
1057
for (int i = tx; i < static_cast<int>(top_k); i += BLOCK_SIZE) {
1058
dst[i] = (i < length) ? static_cast<IdType>(i) : static_cast<IdType>(-1);
1059
}
1067
- return;
1060
+ return true;
1061
}
1062
1063
// Short path
@@ -1077,16 +1070,14 @@ __global__ void __launch_bounds__(FILTERED_TOPK_BLOCK_THREADS)
1070
hist4096::histogram_4096_topk<MAX_K, 12, 8>(score, dst, length,
1071
_smem_reg);
1072
}
1080
- return;
1073
+ return true;
1074
}
1075
1083
- // Static shared memory
1084
- alignas(128) __shared__ int s_histogram_buf[2][RADIX + 128];
1085
- alignas(128) __shared__ int s_counter;
1086
- alignas(128) __shared__ int s_threshold_bin_id;
1087
- alignas(128) __shared__ int s_num_input[2];
1088
- alignas(128) __shared__ int s_indices[MAX_K];
1089
-
1076
+ auto& s_histogram_buf = storage.histogram;
1077
+ auto& s_counter = storage.counter;
1078
+ auto& s_threshold_bin_id = storage.threshold_bin;
1079
+ auto& s_num_input = storage.num_input;
1080
+ auto& s_indices = storage.indices;
1081
auto& s_histogram = s_histogram_buf[0];
1082
1083
// Dynamic shared memory for input double buffer
@@ -1213,10 +1204,13 @@ __global__ void __launch_bounds__(FILTERED_TOPK_BLOCK_THREADS)
1204
// Stage 2: refine with 8bit radix passes
1205
#pragma unroll
1206
for (int round = 0; round < NUM_ROUNDS; ++round) {
1216
- __shared__ int s_last_remain;
1207
+ auto& s_last_remain = storage.last_remain;
1208
const auto r_idx = round % 2;
1209
1210
const auto _raw_num_input = s_num_input[r_idx];
1211
+ if constexpr (CheckOverflow) {
1212
+ if (_raw_num_input > SMEM_INPUT_SIZE) return false;
1213
+ }
1214
const auto num_input =
1215
(_raw_num_input < SMEM_INPUT_SIZE) ? _raw_num_input : SMEM_INPUT_SIZE;
1216
@@ -1284,6 +1278,30 @@ __global__ void __launch_bounds__(FILTERED_TOPK_BLOCK_THREADS)
1278
const int idx = s_indices[base];
1279
dst[base] = static_cast<IdType>(idx);
1280
}
1281
+ return true;
1282
+}
1283
+
1284
+/*!
1285
+ * \brief Filtered Top-K kernel for ragged sequences.
1286
+ *
1287
+ * \tparam DType Data type (float, half, nv_bfloat16)
1288
+ * \tparam IdType Index type (int32_t)
1289
+ * \tparam VEC_SIZE Vector size for input loads (1, 2, 4, or 8)
1290
+ */
1291
+template <typename DType, typename IdType, int VEC_SIZE, uint32_t MAX_K = 2048,
1292
+ bool UsePredicatedShortLoads = false>
1293
+__global__ void __launch_bounds__(FILTERED_TOPK_BLOCK_THREADS)
1294
+ FilteredTopKUnifiedKernel(const DType* __restrict__ input,
1295
+ IdType* __restrict__ output,
1296
+ const IdType* __restrict__ lengths,
1297
+ uint32_t num_rows, uint32_t top_k,
1298
+ uint32_t max_len) {
1299
+ const uint32_t bid = blockIdx.x;
1300
+ if (bid >= num_rows) return;
1301
+ const int length = lengths ? lengths[bid] : static_cast<int>(max_len);
1302
+ __shared__ FilteredTopKStorage<MAX_K> storage;
1303
+ filtered_topk_row<DType, IdType, VEC_SIZE, MAX_K, UsePredicatedShortLoads>(
1304
+ input + bid * max_len, output + bid * top_k, length, top_k, storage);
1305
}
1306
1307
// Helper to compute GCD for VEC_SIZE selection
csrc/libtorch_stable/sampled_topk.cuh
new
+253
@@ -0,0 +1,253 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+// Sampled top-k for long FP32 sparse-indexer rows, inspired by DeepSelect:
5
+// https://github.com/deepseek-ai/DeepSelect
6
+// A sample estimates a cutoff, then survivors are compacted in shared memory
7
+// for exact FP32 selection. Too few or too many candidates trigger an exact
8
+// full-row fallback.
9
+
10
+#pragma once
11
+
12
+#include <cub/cub.cuh>
13
+#include <cuda_runtime.h>
14
+#include <cstdint>
15
+
16
+#include "persistent_topk.cuh"
17
+
18
+namespace vllm::sampled_topk {
19
+
20
+constexpr int kThreads = 1024;
21
+// Conservative crossover bounds from the B300 batch/length sweep.
22
+template <int K>
23
+constexpr int kMinSampledLength = K == 512 ? 98304 : 65536;
24
+// Target half the buffer to leave room for sampling error.
25
+constexpr int kSample = 4096;
26
+constexpr int kCapacity = 8192;
27
+
28
+struct Storage {
29
+ uint2 candidates[kCapacity];
30
+ int histogram[2048];
31
+ typename cub::BlockScan<int, kThreads>::TempStorage scan;
32
+ int count;
33
+ int remaining;
34
+ int bin;
35
+ uint32_t prefix;
36
+};
37
+
38
+__device__ __forceinline__ uint32_t ordered(float value) {
39
+ const uint32_t bits = __float_as_uint(value);
40
+ return (bits & 0x80000000u) ? ~bits : (bits | 0x80000000u);
41
+}
42
+
43
+__device__ __forceinline__ uint32_t coarse(uint32_t key) {
44
+ const uint32_t bits = (key & 0x80000000u) ? (key ^ 0x80000000u) : ~key;
45
+ return topk_histogram_4096::extract_coarse_bin_N<11>(__uint_as_float(bits));
46
+}
47
+
48
+// Select the bin containing the rank and update the rank within that bin.
49
+template <int Bins>
50
+__device__ void select_bin(Storage& s) {
51
+ constexpr int kItems = (Bins + kThreads - 1) / kThreads;
52
+ int sums[kItems];
53
+#pragma unroll
54
+ for (int j = 0; j < kItems; ++j) {
55
+ const int bin = Bins - 1 - (threadIdx.x * kItems + j);
56
+ sums[j] = bin >= 0 ? s.histogram[bin] : 0;
57
+ }
58
+ cub::BlockScan<int, kThreads>(s.scan).InclusiveSum(sums, sums);
59
+ const int remaining = s.remaining;
60
+ __syncthreads();
61
+#pragma unroll
62
+ for (int j = 0; j < kItems; ++j) {
63
+ const int bin = Bins - 1 - (threadIdx.x * kItems + j);
64
+ if (bin >= 0 && sums[j] >= remaining &&
65
+ sums[j] - s.histogram[bin] < remaining) {
66
+ s.bin = bin;
67
+ s.remaining = remaining - (sums[j] - s.histogram[bin]);
68
+ }
69
+ }
70
+ __syncthreads();
71
+}
72
+
73
+// Find a coarse bin, then refine its full FP32 keys one byte at a time.
74
+// Read shared candidates normally, or the full row for exact fallback.
75
+template <bool Buffered>
76
+__device__ uint32_t threshold(Storage& s, const float* row, int count,
77
+ int rank) {
78
+ const int tid = threadIdx.x;
79
+ if (tid == 0) {
80
+ s.remaining = rank;
81
+ s.prefix = 0;
82
+ }
83
+ for (int i = tid; i < 2048; i += kThreads) s.histogram[i] = 0;
84
+ __syncthreads();
85
+ for (int i = tid; i < count; i += kThreads) {
86
+ const uint32_t key = Buffered ? s.candidates[i].y : ordered(row[i]);
87
+ atomicAdd(&s.histogram[coarse(key)], 1);
88
+ }
89
+ __syncthreads();
90
+ select_bin<2048>(s);
91
+ const int coarse_bin = s.bin;
92
+ uint32_t mask = 0;
93
+ for (int shift = 24; shift >= 0; shift -= 8) {
94
+ if (tid < 256) s.histogram[tid] = 0;
95
+ __syncthreads();
96
+ const uint32_t prefix = s.prefix;
97
+ for (int i = tid; i < count; i += kThreads) {
98
+ const uint32_t key = Buffered ? s.candidates[i].y : ordered(row[i]);
99
+ if (coarse(key) == coarse_bin && (key & mask) == prefix) {
100
+ atomicAdd(&s.histogram[(key >> shift) & 255], 1);
101
+ }
102
+ }
103
+ __syncthreads();
104
+ select_bin<256>(s);
105
+ if (tid == 0) s.prefix = prefix | (static_cast<uint32_t>(s.bin) << shift);
106
+ mask |= 255u << shift;
107
+ __syncthreads();
108
+ }
109
+ return s.prefix;
110
+}
111
+
112
+__device__ __forceinline__ int reserve(bool hit, int* counter) {
113
+ const uint32_t mask = __ballot_sync(0xffffffffu, hit);
114
+ if (mask == 0) return 0;
115
+ const int lane = threadIdx.x & 31;
116
+ int base = 0;
117
+ if (lane == 0) base = atomicAdd(counter, __popc(mask));
118
+ base = __shfl_sync(0xffffffffu, base, 0);
119
+ return base + __popc(mask & ((1u << lane) - 1));
120
+}
121
+
122
+template <bool Buffered>
123
+__device__ void emit(Storage& s, const float* row, int32_t* dst, int count,
124
+ uint32_t cutoff) {
125
+ const int tid = threadIdx.x;
126
+ if (tid == 0) s.count = 0;
127
+ __syncthreads();
128
+ for (int base = 0; base < count; base += kThreads) {
129
+ const int i = base + tid;
130
+ const uint2 pair = i < count ? (Buffered ? s.candidates[i]
131
+ : make_uint2(i, ordered(row[i])))
132
+ : make_uint2(0, 0);
133
+ bool keep = i < count && pair.y > cutoff;
134
+ if (i < count && pair.y == cutoff) {
135
+ keep = atomicSub(&s.remaining, 1) > 0;
136
+ }
137
+ const int offset = reserve(keep, &s.count);
138
+ if (keep) dst[offset] = pair.x;
139
+ }
140
+}
141
+
142
+template <int K>
143
+__global__ void __launch_bounds__(kThreads)
144
+ sampled_topk_kernel(const float* __restrict__ input,
145
+ const int32_t* __restrict__ lengths,
146
+ int32_t* __restrict__ output, int64_t stride,
147
+ int max_length) {
148
+ extern __shared__ __align__(16) unsigned char smem[];
149
+ auto& s = *reinterpret_cast<Storage*>(smem);
150
+ const int tid = threadIdx.x;
151
+ const int length = max(0, min(lengths[blockIdx.x], max_length));
152
+ const float* row = input + blockIdx.x * stride;
153
+ int32_t* dst = output + blockIdx.x * K;
154
+ // Graph allocations can hold rows much shorter than the host length bound.
155
+ if (length < kMinSampledLength<K>) {
156
+ __shared__ filtered_topk::FilteredTopKStorage<K> fallback_storage;
157
+ bool selected;
158
+ if ((stride & 3) == 0 && (reinterpret_cast<uintptr_t>(row) & 15) == 0) {
159
+ selected =
160
+ filtered_topk::filtered_topk_row<float, int32_t, 4, K, false, true>(
161
+ row, dst, length, K, fallback_storage);
162
+ } else {
163
+ selected =
164
+ filtered_topk::filtered_topk_row<float, int32_t, 1, K, true, true>(
165
+ row, dst, length, K, fallback_storage);
166
+ }
167
+ if (!selected) {
168
+ const uint32_t exact_cutoff = threshold<false>(s, row, length, K);
169
+ emit<false>(s, row, dst, length, exact_cutoff);
170
+ }
171
+ return;
172
+ }
173
+ if (tid == 0) s.remaining = max(1, (kCapacity / 2) * kSample / length);
174
+ for (int i = tid; i < 2048; i += kThreads) s.histogram[i] = 0;
175
+ __syncthreads();
176
+ // Sample contiguous warps spread across the row to keep loads coalesced.
177
+ for (int i = tid; i < kSample; i += kThreads) {
178
+ const int index = (i / 32) * (length / (kSample / 32)) + i % 32;
179
+ atomicAdd(&s.histogram[coarse(ordered(row[index]))], 1);
180
+ }
181
+ __syncthreads();
182
+ select_bin<2048>(s);
183
+ const uint16_t half_key = s.bin << 5;
184
+ const uint16_t half_bits =
185
+ (half_key & 0x8000u) ? (half_key ^ 0x8000u) : ~half_key;
186
+ const uint32_t cutoff = ordered(__half2float(__ushort_as_half(half_bits)));
187
+ if (tid == 0) s.count = 0;
188
+ __syncthreads();
189
+ constexpr int kItems = 16;
190
+ // Compact survivors with one reservation per warp per tile. Keep counting
191
+ // beyond capacity so overflow triggers fallback instead of using a subset.
192
+ for (int base = 0; base < length; base += kItems * kThreads) {
193
+ uint32_t keys[kItems];
194
+ uint32_t hit_mask = 0;
195
+ if ((reinterpret_cast<uintptr_t>(row) & 15) == 0 &&
196
+ base + kItems * kThreads <= length) {
197
+#pragma unroll
198
+ for (int j = 0; j < kItems / 4; ++j) {
199
+ const float4 values =
200
+ reinterpret_cast<const float4*>(row + base)[tid + j * kThreads];
201
+ keys[4 * j] = ordered(values.x);
202
+ keys[4 * j + 1] = ordered(values.y);
203
+ keys[4 * j + 2] = ordered(values.z);
204
+ keys[4 * j + 3] = ordered(values.w);
205
+ }
206
+ } else {
207
+#pragma unroll
208
+ for (int j = 0; j < kItems; ++j) {
209
+ const int i = base + tid * 4 + (j / 4) * kThreads * 4 + j % 4;
210
+ keys[j] = i < length ? ordered(row[i]) : 0;
211
+ }
212
+ }
213
+#pragma unroll
214
+ for (int j = 0; j < kItems; ++j) {
215
+ const int i = base + tid * 4 + (j / 4) * kThreads * 4 + j % 4;
216
+ if (i < length && keys[j] >= cutoff) hit_mask |= 1u << j;
217
+ }
218
+ const int lane = tid & 31;
219
+ const int hits = __popc(hit_mask);
220
+ int inclusive = hits;
221
+#pragma unroll
222
+ for (int delta = 1; delta < 32; delta *= 2) {
223
+ const int previous = __shfl_up_sync(0xffffffffu, inclusive, delta);
224
+ if (lane >= delta) inclusive += previous;
225
+ }
226
+ const int total = __shfl_sync(0xffffffffu, inclusive, 31);
227
+ int warp_base = 0;
228
+ if (lane == 31 && total != 0) warp_base = atomicAdd(&s.count, total);
229
+ warp_base = __shfl_sync(0xffffffffu, warp_base, 31);
230
+ int offset = warp_base + inclusive - hits;
231
+#pragma unroll
232
+ for (int j = 0; j < kItems; ++j) {
233
+ if (hit_mask & (1u << j)) {
234
+ if (offset < kCapacity) {
235
+ s.candidates[offset] = make_uint2(
236
+ base + tid * 4 + (j / 4) * kThreads * 4 + j % 4, keys[j]);
237
+ }
238
+ ++offset;
239
+ }
240
+ }
241
+ }
242
+ __syncthreads();
243
+ const int count = s.count;
244
+ if (count >= K && count <= kCapacity) {
245
+ const uint32_t exact_cutoff = threshold<true>(s, row, count, K);
246
+ emit<true>(s, row, dst, count, exact_cutoff);
247
+ } else {
248
+ const uint32_t exact_cutoff = threshold<false>(s, row, length, K);
249
+ emit<false>(s, row, dst, length, exact_cutoff);
250
+ }
251
+}
252
+
253
+} // namespace vllm::sampled_topk
csrc/libtorch_stable/topk.cu
+17
-1
@@ -9,6 +9,7 @@
9
10
#ifndef USE_ROCM
11
#include "persistent_topk.cuh"
12
+ #include "sampled_topk.cuh"
13
#endif
14
15
namespace {
@@ -36,7 +37,22 @@ void launch_persistent_topk(const torch::stable::Tensor& logits,
37
max_smem_per_block = device_prop->sharedMemPerBlockOptin;
38
}
39
39
- if (num_rows > 32 && max_smem_per_block >= 128 * 1024) {
40
+ // Allow static fallback storage in addition to the 128 KiB dynamic buffer.
41
+ if (num_rows > 64 &&
42
+ max_seq_len >= vllm::sampled_topk::kMinSampledLength<TopK> &&
43
+ max_smem_per_block >= 144 * 1024) {
44
+ auto kernel = vllm::sampled_topk::sampled_topk_kernel<TopK>;
45
+ constexpr size_t smem_size =
46
+ vllm::filtered_topk::FILTERED_TOPK_SMEM_DYNAMIC;
47
+ cudaError_t status = cudaFuncSetAttribute(
48
+ kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, smem_size);
49
+ STD_TORCH_CHECK(status == cudaSuccess,
50
+ "sampled_topk smem failed: ", cudaGetErrorString(status));
51
+ kernel<<<num_rows, vllm::sampled_topk::kThreads, smem_size, stream>>>(
52
+ logits.const_data_ptr<float>(), lengths.const_data_ptr<int32_t>(),
53
+ output.mutable_data_ptr<int32_t>(), stride,
54
+ static_cast<int>(std::min(max_seq_len, logits.size(1))));
55
+ } else if (num_rows > 32 && max_smem_per_block >= 128 * 1024) {
56
cudaError_t status =
57
vllm::FilteredTopKRaggedTransform<float, int32_t, TopK>(
58
logits.const_data_ptr<float>(), output.mutable_data_ptr<int32_t>(),
tests/kernels/test_top_k_per_row.py
+100
@@ -932,6 +932,106 @@ def test_deepseek_workspace_topk(
932
)
933
934
935
+@pytest.mark.skipif(not _has_device_capability(80), reason="Requires SM80+")
936
+@pytest.mark.parametrize("rows", [65, 128])
937
+@pytest.mark.parametrize("top_k", [512, 1024, 2048])
938
+@pytest.mark.parametrize(
939
+ "distribution", ["random", "10LSBits", "ascending", "constant", "sampled_peaks"]
940
+)
941
+@torch.inference_mode()
942
+def test_persistent_topk_sampled_graph(
943
+ rows: int, top_k: int, distribution: str
944
+) -> None:
945
+ """Sampling and both fallbacks preserve exact values across graph replays."""
946
+ if torch.cuda.get_device_properties(0).shared_memory_per_block_optin < 144 * 1024:
947
+ pytest.skip("Sampled top-k requires at least 144 KiB of shared memory")
948
+ set_random_seed(42)
949
+ min_sampled_length = 98304 if top_k == 512 else 65536
950
+ width = min_sampled_length + 3
951
+ lengths = torch.full((rows,), width, dtype=torch.int32, device="cuda")
952
+ logits = create_random_logits(
953
+ torch.zeros_like(lengths),
954
+ lengths,
955
+ torch.float32,
956
+ 42,
957
+ False,
958
+ "10LSBits" if distribution == "10LSBits" else "random",
959
+ )
960
+ if distribution == "ascending":
961
+ logits.copy_(torch.arange(width, device="cuda", dtype=torch.float32))
962
+ elif distribution == "constant":
963
+ logits.fill_(1.0)
964
+ elif distribution == "sampled_peaks":
965
+ # A biased sample leaves fewer than k survivors and must fall back.
966
+ logits.zero_()
967
+ sample = torch.arange(4096, device="cuda")
968
+ positions = (sample // 32) * (width // 128) + sample % 32
969
+ logits[:, positions] = 1 + sample.float() / 4096
970
+ original = logits.clone()
971
+ indices = torch.empty((rows, top_k), dtype=torch.int32, device="cuda")
972
+ workspace = torch.empty(RADIX_TOPK_WORKSPACE_SIZE, dtype=torch.uint8, device="cuda")
973
+
974
+ def run() -> None:
975
+ torch.ops._C.persistent_topk(
976
+ logits,
977
+ lengths.view(-1, 4 if rows == 128 else 1),
978
+ indices,
979
+ workspace,
980
+ top_k,
981
+ width,
982
+ )
983
+
984
+ run()
985
+ graph = torch.cuda.CUDAGraph()
986
+ with torch.cuda.graph(graph):
987
+ run()
988
+ positions = torch.arange(width, device="cuda")
989
+ slots = torch.arange(top_k, device="cuda")
990
+ for step in range(3):
991
+ bounds = torch.tensor(
992
+ [
993
+ -1,
994
+ 0,
995
+ 1,
996
+ top_k - 1,
997
+ top_k,
998
+ 32769,
999
+ min_sampled_length - 1,
1000
+ min_sampled_length,
1001
+ width,
1002
+ width + 17,
1003
+ ],
1004
+ dtype=torch.int32,
1005
+ device="cuda",
1006
+ )
1007
+ lengths.copy_(
1008
+ bounds[(torch.arange(rows, device="cuda") + step) % bounds.numel()]
1009
+ )
1010
+ logits.copy_(original if step != 1 else original.flip(1))
1011
+ logits.masked_fill_(positions[None] >= lengths[:, None], float("nan"))
1012
+ indices.fill_(-2)
1013
+ graph.replay()
1014
+ valid = slots[None] < lengths.clamp(max=top_k)[:, None]
1015
+ assert torch.all(indices[~valid] == -1)
1016
+ assert torch.all(((indices >= 0) & (indices < lengths[:, None])) == valid)
1017
+ ordered_indices = indices.sort(dim=1).values
1018
+ assert torch.all(
1019
+ (ordered_indices[:, 1:] != ordered_indices[:, :-1])
1020
+ | (ordered_indices[:, 1:] == -1)
1021
+ )
1022
+ selected = logits.gather(1, indices.clamp_min(0).long())
1023
+ selected.masked_fill_(~valid, -float("inf"))
1024
+ expected = logits.masked_fill(
1025
+ positions[None] >= lengths[:, None], -float("inf")
1026
+ )
1027
+ torch.testing.assert_close(
1028
+ selected.sort(dim=1, descending=True).values,
1029
+ expected.topk(top_k, dim=1).values,
1030
+ atol=0,
1031
+ rtol=0,
1032
+ )
1033
+
1034
+
1035
def run_large_context_topk_test(
1036
batch_size: int,
1037
seq_lens: list[int],