Files
vLLM-4x-RTX-3090/patches/0044-topk-determinism-toggles-for-greedy-repeatability.patch
MOVZX 777e910dd0 Initial commit
Signed-off-by: MOVZX <movzx@yahoo.com>
2026-10-15 01:01:52 +07:00

2044 lines
104 KiB
Diff

From fd7899111c69153b283e55fc7b8915aa2cf68def Mon Sep 17 00:00:00 2001
From: halt95 <halt95@users.noreply.github.com>
Date: Wed, 23 Sep 2026 08:35:40 -0400
Subject: [PATCH 044/103] topk: determinism toggles for greedy repeatability
- MERLIN_FULL_K: deterministic Marlin MoE split-K (after unmerged upstream #46639).
- MERLIN_QSA_SORT: canonical order for the QSA selected blocks.
- MERLIN_TIE_DET: deterministic tie-break at the persistent_topk cutoff.
- MERLIN_TIE_RECENT: cutoff ties keep the highest indices.
- Decode candidate truncation is detected; one tie direction across the
medium path; the kernel switches are registered.
---
.../moe/marlin_moe_wna16/kernel.h | 3 +-
.../moe/marlin_moe_wna16/marlin_template.h | 12 +-
.../moe/marlin_moe_wna16/ops.cu | 40 +-
csrc/libtorch_stable/ops.h | 4 +
csrc/libtorch_stable/persistent_topk.cuh | 392 +++++++++++++++---
csrc/libtorch_stable/topk.cu | 183 +++++++-
csrc/libtorch_stable/torch_bindings.cpp | 3 +
tests/kernels/test_top_k_per_row.py | 234 ++++++++++-
tests/test_envs.py | 199 +++++++--
vllm/envs.py | 151 +++++--
.../qwen4_exp/nvidia/ops/qsa_indexer.py | 41 ++
.../qwen4_exp/nvidia/ops/topk_counters.py | 43 +-
12 files changed, 1141 insertions(+), 164 deletions(-)
diff --git a/csrc/libtorch_stable/moe/marlin_moe_wna16/kernel.h b/csrc/libtorch_stable/moe/marlin_moe_wna16/kernel.h
index 25c57f3..15f8018 100644
--- a/csrc/libtorch_stable/moe/marlin_moe_wna16/kernel.h
+++ b/csrc/libtorch_stable/moe/marlin_moe_wna16/kernel.h
@@ -20,7 +20,8 @@
const int32_t *__restrict__ num_tokens_past_padded_ptr, \
const float *__restrict__ topk_weights_ptr, int top_k, \
bool mul_topk_weights, int prob_m, int prob_n, int prob_k, int *locks, \
- bool has_bias, bool use_atomic_add, bool use_fp32_reduce
+ bool has_bias, bool use_atomic_add, bool use_fp32_reduce, \
+ bool use_full_k
namespace MARLIN_NAMESPACE_NAME {
template <const vllm::ScalarTypeId a_type_id, // A ScalarType id
diff --git a/csrc/libtorch_stable/moe/marlin_moe_wna16/marlin_template.h b/csrc/libtorch_stable/moe/marlin_moe_wna16/marlin_template.h
index aac9bd1..d552679 100644
--- a/csrc/libtorch_stable/moe/marlin_moe_wna16/marlin_template.h
+++ b/csrc/libtorch_stable/moe/marlin_moe_wna16/marlin_template.h
@@ -74,7 +74,8 @@ __global__ void Marlin(
int prob_k, // reduction dimension k
int* locks, // extra global storage for barrier synchronization
bool use_atomic_add, // whether to use atomic add to reduce
- bool use_fp32_reduce // whether to use fp32 global reduce
+ bool use_fp32_reduce, // whether to use fp32 global reduce
+ bool use_full_k // whether to reduce over full K per block
) {}
} // namespace MARLIN_NAMESPACE_NAME
@@ -250,7 +251,8 @@ __global__ void Marlin(
int* locks, // extra global storage for barrier synchronization
bool has_bias,
bool use_atomic_add, // whether to use atomic add to reduce
- bool use_fp32_reduce // whether to use fp32 global reduce
+ bool use_fp32_reduce, // whether to use fp32 global reduce
+ bool use_full_k // whether to reduce over full K per block
) {
// Each threadblock processes one "stripe" of the B matrix with (roughly) the
// same size, which might involve multiple column "slices" (of width 16 *
@@ -370,11 +372,13 @@ __global__ void Marlin(
// see https://github.com/vllm-project/vllm/pull/24722 for more details
if (global_mn_tiles > gridDim.x) {
part2_mn_tiles = global_mn_tiles % gridDim.x;
- if (part2_mn_tiles * 3 <= gridDim.x) part2_mn_tiles += gridDim.x;
+ if (!use_full_k && part2_mn_tiles * 3 <= gridDim.x)
+ part2_mn_tiles += gridDim.x;
part1_mn_iters = (global_mn_tiles - part2_mn_tiles) / gridDim.x;
}
- int iters = div_ceil(k_tiles * part2_mn_tiles, gridDim.x);
+ int iters =
+ use_full_k ? k_tiles : div_ceil(k_tiles * part2_mn_tiles, gridDim.x);
if constexpr (group_blocks != -1) {
if (group_blocks >= thread_k_blocks) {
diff --git a/csrc/libtorch_stable/moe/marlin_moe_wna16/ops.cu b/csrc/libtorch_stable/moe/marlin_moe_wna16/ops.cu
index 48de07d..2c6ad76 100644
--- a/csrc/libtorch_stable/moe/marlin_moe_wna16/ops.cu
+++ b/csrc/libtorch_stable/moe/marlin_moe_wna16/ops.cu
@@ -33,6 +33,35 @@
#include <torch/headeronly/util/Exception.h>
#include "libtorch_stable/torch_utils.h"
+#include <cstdlib>
+
+namespace {
+// -------------------------------------------------------------------------
+// MERLIN_FULL_K: the CUDA half of the unmerged upstream PR #46639
+// (`use_full_k`), which makes the Marlin MoE split-K reduction order
+// independent of how many blocks the launch was split across.
+//
+// Our own toggle rather than upstream's VLLM_BATCH_INVARIANT, because that
+// one aborts engine startup on GDN models (upstream #42960) and so can never
+// reach this kernel on Flash-Next.
+//
+// Resolved ONCE per process, not per launch, so it is a launch constant in
+// exactly the sense topk.cu's exact_medium_enabled() is: a cudagraph bakes in
+// the value that was live at capture time and every replay agrees with it.
+// Default OFF -- enabling it by default is a separate release decision.
+//
+// This std::getenv is the SINGLE source of truth. vllm/envs.py carries a raw
+// passthrough entry for validate_environ and the compile-factor hash only;
+// nothing in Python may parse this name a second time.
+// -------------------------------------------------------------------------
+bool merlin_full_k() {
+ static const bool enabled = [] {
+ const char* raw = std::getenv("MERLIN_FULL_K");
+ return (raw != nullptr && raw[0] != '\0') ? (std::atoi(raw) != 0) : false;
+ }();
+ return enabled;
+}
+} // namespace
#define STATIC_ASSERT_SCALAR_TYPE_VALID(scalar_t) \
static_assert(std::is_same<scalar_t, half>::value || \
@@ -183,7 +212,8 @@ exec_config_t determine_exec_config(
const vllm::ScalarType& c_type, const vllm::ScalarType& s_type, int prob_m,
int prob_n, int prob_k, int num_experts, int top_k, int thread_m_blocks,
bool m_block_size_8, int num_bits, int group_size, bool has_zp,
- bool is_zp_float, bool is_a_8bit, int stages, int max_shared_mem, int sms) {
+ bool is_zp_float, bool is_a_8bit, int stages, int max_shared_mem, int sms,
+ bool use_full_k) {
exec_config_t exec_cfg = exec_config_t{1, thread_config_t{-1, -1, -1}};
thread_config_t* thread_configs = thread_m_blocks > 1
? large_batch_thread_configs
@@ -228,7 +258,8 @@ exec_config_t determine_exec_config(
else
allow_count = max(min(allow_count, 2), 1);
- if (prob_n / th_config.thread_n * prob_m * top_k * 4 < sms * allow_count) {
+ if (!use_full_k &&
+ prob_n / th_config.thread_n * prob_m * top_k * 4 < sms * allow_count) {
allow_count =
max(prob_n / th_config.thread_n * prob_m * top_k * 4 / sms, 1);
}
@@ -254,6 +285,7 @@ void marlin_mm(const void* A, const void* B, void* C, void* C_tmp, void* b_bias,
int group_size, int dev, cudaStream_t stream, int thread_k,
int thread_n, int sms, int blocks_per_sm, bool use_atomic_add,
bool use_fp32_reduce, bool is_zp_float) {
+ const bool use_full_k = merlin_full_k();
int thread_m_blocks = div_ceil(moe_block_size, 16);
bool m_block_size_8 = moe_block_size == 8;
bool is_a_8bit = a_type.size_bits() == 8;
@@ -331,7 +363,7 @@ void marlin_mm(const void* A, const void* B, void* C, void* C_tmp, void* b_bias,
exec_cfg = determine_exec_config(
a_type, b_type, c_type, s_type, prob_m, prob_n, prob_k, num_experts,
top_k, thread_m_blocks, m_block_size_8, num_bits, group_size, has_zp,
- is_zp_float, is_a_8bit, stages, max_shared_mem, sms);
+ is_zp_float, is_a_8bit, stages, max_shared_mem, sms, use_full_k);
thread_tfg = exec_cfg.tb_cfg;
}
@@ -383,7 +415,7 @@ void marlin_mm(const void* A, const void* B, void* C, void* C_tmp, void* b_bias,
A_ptr, B_ptr, C_ptr, C_tmp_ptr, bias_ptr, a_s_ptr, b_s_ptr, g_s_ptr, zp_ptr,
sorted_token_ids_ptr, expert_ids_ptr, num_tokens_past_padded_ptr,
topk_weights_ptr, top_k, mul_topk_weights, prob_m, prob_n, prob_k, locks,
- has_bias, use_atomic_add, use_fp32_reduce);
+ has_bias, use_atomic_add, use_fp32_reduce, use_full_k);
// clang-format on
}
diff --git a/csrc/libtorch_stable/ops.h b/csrc/libtorch_stable/ops.h
index ea81216..1156801 100644
--- a/csrc/libtorch_stable/ops.h
+++ b/csrc/libtorch_stable/ops.h
@@ -478,6 +478,10 @@ void persistent_topk(const torch::stable::Tensor& logits,
void persistent_topk_exact_counters(torch::stable::Tensor& out, int64_t reset,
int64_t sync);
+// The slot count of THIS build's counter array, so the Python
+// reader never has to guess it from an environment variable.
+int64_t persistent_topk_exact_counters_slots();
+
#ifdef VLLM_ENABLE_COOPERATIVE_TOPK
void cooperative_topk(const torch::stable::Tensor& logits,
const torch::stable::Tensor& lengths,
diff --git a/csrc/libtorch_stable/persistent_topk.cuh b/csrc/libtorch_stable/persistent_topk.cuh
index fcbcc7b..f8ce859 100644
--- a/csrc/libtorch_stable/persistent_topk.cuh
+++ b/csrc/libtorch_stable/persistent_topk.cuh
@@ -54,6 +54,19 @@ __device__ __forceinline__ auto convert_to_uint32_v2(float x) -> uint32_t {
return (bits & 0x80000000u) ? ~bits : (bits | 0x80000000u);
}
+// THE definition of the effective tie direction, called by
+// both the host (to report it) and the device (to obey it), so the reported
+// policy and the executed policy cannot drift.
+//
+// `MERLIN_TIE_DET=0` is the shipped default and leaves the buffered paths on
+// their original racy atomicAdd rule, which has no direction; the medium-path exact
+// restart is deterministic whatever happens, so it keeps the most-recent-wins
+// direction the medium-path restart shipped and was measured with.
+__host__ __device__ __forceinline__ bool effective_tie_recent(bool det_ties,
+ bool tie_recent) {
+ return det_ties ? tie_recent : true;
+}
+
__device__ __forceinline__ auto convert_to_uint8(float x) -> uint8_t {
__half h = __float2half_rn(x);
uint16_t bits = __half_as_ushort(h);
@@ -147,10 +160,27 @@ struct PersistentTopKParams {
// nullptr; the kernel only ever atomicAdds into it, never reads back.
bool exact_medium;
unsigned long long* exact_counters;
+ // MERLIN_TIE_DET: deterministic tie-break at the k-th cutoff.
+ // Resolved once on the host, like exact_medium above, so it is a launch
+ // constant and is baked into a cudagraph at capture.
+ bool det_ties;
+ // MERLIN_TIE_RECENT: ranks ties by DESCENDING element index, so
+ // the surviving blocks are the most recent rather than the oldest. Both
+ // rules are deterministic; they differ in which tied blocks survive, and
+ // measurably in draft acceptance at depth. Only read when det_ties is set.
+ bool tie_recent;
+ // MERLIN_TOPK_EXACT_DECODE: on an overflowing
+ // DECODE row, take the same exact restart the medium path takes instead of
+ // ranking a truncated candidate set. Detection and the [10] counter are
+ // unconditional; only the repair is gated. Default OFF -- the repair costs
+ // four full-row rescans and the overflow incidence on real traffic has
+ // never been measured, which is what the counter is for.
+ bool exact_decode;
// MERLIN_TOPK_DET_LARGE, ANDed on the host with the capacity gate
- // 2 * ctas_per_group <= RADIX (the per-CTA counts are staged in one 256-slot
- // RadixRowState histogram row). Resolved once per process, so it is a
- // launch constant and a cudagraph captures one value.
+ // 4 * ctas_per_group <= RADIX (the per-CTA counts and the two fault-verdict
+ // regions are staged in one 256-slot RadixRowState histogram row). Resolved
+ // once per process, so it is a launch constant and a cudagraph captures one
+ // value.
bool det_large;
// TEST ONLY. -1 (the default) disables it; any
// other value names the `cta_in_group` whose CTA-LOCAL fault predicate is
@@ -177,10 +207,17 @@ __device__ __forceinline__ uint32_t decode_bin(float x) {
return key >> 5;
}
+// Returns TRUE when this row overflowed its candidate buffer AND the caller
+// asked for the exact repair, in which case nothing has been emitted that the
+// restart will not overwrite and the caller must run it.
+// The restart itself lives with the medium path further down and is called
+// from the dispatch site, so this function needs no forward declaration of it
+// -- some host compilers reject one for a `__device__` template.
template <int TopK>
-__device__ __noinline__ void histogram_2048_topk(
+__device__ __noinline__ bool histogram_2048_topk(
const float* __restrict__ logits, int32_t* __restrict__ output_indices,
- int32_t seq_len) {
+ int32_t seq_len, bool det_ties, bool tie_recent,
+ unsigned long long* __restrict__ counters, bool exact_decode) {
extern __shared__ int decode_smem[];
const int tx = threadIdx.x;
const int lane = tx & 31;
@@ -193,7 +230,18 @@ __device__ __noinline__ void histogram_2048_topk(
constexpr int MAX_ITEMS_PER_THREAD =
(HIST2048_THRESHOLD + kThreadsPerBlock - 1) / kThreadsPerBlock;
- enum : int { sTHR = 0, sOUT = 1, sREF = 2, sFIN = 3, sBUF0 = 4, sBUF1 = 5 };
+ // sOVF claims one of the two scalars that were already
+ // being zeroed by the `tx < 8` loop below and never used, so the decode
+ // shared-memory budget is unchanged.
+ enum : int {
+ sTHR = 0,
+ sOUT = 1,
+ sREF = 2,
+ sFIN = 3,
+ sBUF0 = 4,
+ sBUF1 = 5,
+ sOVF = 6
+ };
// ---- Initialize scalars (prevents stale data from prior rows) ----
if (tx < 8) {
@@ -322,7 +370,7 @@ __device__ __noinline__ void histogram_2048_topk(
__syncthreads();
int remaining_k = TopK - decode_smem[SBASE + sOUT];
- if (remaining_k <= 0) return;
+ if (remaining_k <= 0) return false;
// If all buffered elements fit, output them all (common for short seqs)
const int raw_buf0 = decode_smem[SBASE + sBUF0];
@@ -333,7 +381,7 @@ __device__ __noinline__ void histogram_2048_topk(
output_indices[base + i] = bufs[0][i];
}
__syncthreads();
- return;
+ return false;
}
// ---- Phase 3: Deferred refinement (rare path) ----
@@ -372,6 +420,42 @@ __device__ __noinline__ void histogram_2048_topk(
const int dst = src ^ 1;
const int raw_buf = decode_smem[SBASE + sBUF0 + src];
+
+ // ---- DECODE TRUNCATION, DETECTED ------------------
+ //
+ // `DBUF` is 3,708 and the Phase-2 append guard drops everything past it,
+ // but the atomicAdd that hands out slots sits OUTSIDE that guard -- so
+ // `raw_buf` keeps counting and a post-hoc test here is exact, exactly as
+ // the medium path's M2 is. Dropped candidates are invisible to
+ // refinement AND to the deterministic tie ranking further down: ranking
+ // orders the survivors, it cannot recall a winner that was never
+ // buffered. The failing class: an 8,192-element row inside one coarse bin
+ // with one element larger at FP32 but in the same half-precision bin; a
+ // legal append order drops that winner and the row is silently wrong.
+ //
+ // The historical zero-overflow measurements cover a workload, not a
+ // capacity. Until this counter has been read on the real traffic, the
+ // determinism claim for the decode path is conditional on it reading 0.
+ if (__builtin_expect(raw_buf > DBUF, 0)) {
+ if (tx == 0 && decode_smem[SBASE + sOVF] == 0) {
+ decode_smem[SBASE + sOVF] = 1; // once per row, not once per pass
+ if (counters != nullptr) atomicAdd(&counters[kCtrDecodeOverflow], 1ull);
+ }
+ if (exact_decode) {
+ // Hand the row back for the same repair the medium path takes:
+ // throw the buffered state away and re-select from scratch with a
+ // full-row radix rescan. The restart writes all TopK slots (or
+ // fills the row with -1 and faults), so Phase 2's `> threshold`
+ // emissions are overwritten rather than double-counted. `raw_buf`
+ // is block-uniform and `exact_decode` is a launch constant, so this
+ // return is taken by the whole block -- which is what makes it safe
+ // to leave a function that still owns the shared-memory window.
+ __syncthreads();
+ return true;
+ }
+ __syncthreads();
+ }
+
const int num_buffered = (raw_buf < DBUF) ? raw_buf : DBUF;
compute_suffix_sum();
@@ -385,6 +469,9 @@ __device__ __noinline__ void histogram_2048_topk(
__syncthreads();
const int ref_thr = decode_smem[SBASE + sREF];
+ // Read before any thread decrements sFIN, so every thread agrees on
+ // how many of the tied candidates survive the cutoff.
+ const int fin_total = decode_smem[SBASE + sFIN];
remaining_k -= refine[0][ref_thr + 1];
const int bit_offset = 24 - pass * 8;
@@ -416,8 +503,35 @@ __device__ __noinline__ void histogram_2048_topk(
output_indices[pos] = idx;
} else if (bin == ref_thr) {
if (pass == 3) {
- const int slot = atomicAdd(&decode_smem[SBASE + sFIN], -1);
- if (slot > 0) output_indices[TopK - slot] = idx;
+ if (det_ties) {
+ // Every candidate that reaches here has an identical 32-bit value,
+ // and the racy branch below hands out slots by atomicAdd -- first
+ // warp to arrive wins, so WHICH tied block survives the cutoff is
+ // a race. Rank by element index instead and place at a position
+ // derived from that rank, so the surviving set is arrival-order
+ // independent. No atomics on this path.
+ //
+ // O(t^2) over the exactly-tied set t. Exact 32-bit ties are rare
+ // (19 occurrences across 12 measured requests, one element each), so
+ // t is small in practice -- but that is measurement at one shape,
+ // not a bound.
+ int rank = 0;
+ for (int j = 0; j < num_buffered; ++j) {
+ const int jdx = bufs[src][j];
+ const bool ahead = tie_recent ? (jdx > idx) : (jdx < idx);
+ if (!ahead) continue;
+ const uint32_t jf = convert_to_uint32_v2(logits[jdx]);
+ if (((jf >> bit_offset) & 0xFF) == static_cast<uint32_t>(ref_thr)) {
+ ++rank;
+ }
+ }
+ if (rank < fin_total) {
+ output_indices[TopK - fin_total + rank] = idx;
+ }
+ } else {
+ const int slot = atomicAdd(&decode_smem[SBASE + sFIN], -1);
+ if (slot > 0) output_indices[TopK - slot] = idx;
+ }
} else {
const int bp = atomicAdd(&decode_smem[SBASE + sBUF0 + dst], 1);
if (__builtin_expect(bp < DBUF, 1)) {
@@ -430,6 +544,7 @@ __device__ __noinline__ void histogram_2048_topk(
}
__syncthreads();
}
+ return false;
}
// ============================================================================
@@ -457,10 +572,28 @@ __device__ __noinline__ void histogram_2048_topk(
// selected keys in output order, so that would re-introduce exactly the
// nondeterminism MERLIN_QSA_SORT / MERLIN_TIE_DET / MERLIN_TIE_RECENT exist
// to remove. Ours emits in **ascending index order** via a block scan (no
-// atomics anywhere on the placement path) and cuts ties at the k-th value by
-// **most-recent wins** (the highest element indices are kept), matching the
-// rule measured on the reference host (+3.4 % tokens/step at 12K over
-// oldest-wins).
+// atomics anywhere on the placement path).
+//
+// TIE DIRECTION IS ONE POLICY, DECIDED CENTRALLY. The medium-path restart
+// hard-coded most-recent-wins here while the buffered decode/medium paths took
+// their direction from `tie_recent`, so a medium row that grew from 4,096 to
+// 4,097 candidates silently changed tie direction without the configuration
+// changing. The direction is now a parameter and the ONE place that resolves
+// it is `effective_tie_recent()` in topk.cu:
+//
+// det_ties ? tie_recent : true
+//
+// i.e. `MERLIN_TIE_DET=1 MERLIN_TIE_RECENT=0` is oldest-wins on the whole
+// medium path (buffered AND restart), and with `MERLIN_TIE_DET=0` the restart
+// keeps its shipped most-recent-wins default (the direction that was
+// measured at +3.4 % tokens/step at 12K over oldest-wins).
+//
+// The deterministic large path takes the SAME value.
+// `radix_emit_deterministic` receives `effective_tie_recent(det_ties,
+// tie_recent)` from the dispatch site and mirrors both halves of its cut --
+// the cross-CTA walk and the within-chunk window -- so all five paths
+// (decode-buffered, decode-restart, medium-buffered, medium-restart, large)
+// obey one decision and the host's policy line prints it for all five.
// ============================================================================
// Abort the kernel when the exact fallback cannot complete, instead of
@@ -483,6 +616,23 @@ __device__ __noinline__ void histogram_2048_topk(
// rows and wraps after 66,053, and a delta taken across that boundary is a
// wrong number that looks like a measurement. `atomicAdd(unsigned long long*)`
// is native on sm_86, so the width costs nothing on the increment path.
+//
+// SLOT MAP (the tie-determinism + large-path integration, 11 slots). The tie change numbered its decode
+// counter AROUND the large-path block rather than after the medium-path one, so the merged layout is
+// the union of the two branches with nothing renumbered and no external probe
+// silently re-reading a row count as a switch -- the failure a merged layout
+// must avoid.
+//
+// [0] medium rows [1] medium overflow [2] medium exact fallback
+// [3] medium exact fault
+// [4]-[7] large path (rows_large, ties_cut, ties_dropped, faults_large)
+// [8] MERLIN_TOPK_EXACT_MEDIUM [9] MERLIN_TOPK_DET_LARGE
+// [10] decode overflow
+//
+// Slots 8-9 are written by the HOST after the device copy; the device never
+// touches them, and they are slots INSIDE the array, not two elements appended
+// after it. A stale five- or ten-element reader buffer is rejected outright by
+// the length check in the reader op, which is the fail-loud direction.
enum TopKExactCounter : int {
kCtrMediumRows = 0, // denominator: medium-path rows entered
kCtrMediumOverflow = 1, // rows where a 4,096-slot guard would drop
@@ -497,7 +647,11 @@ enum TopKExactCounter : int {
kCtrLargeTiesCut = 5, // rows where the pivot-equal group was cut
kCtrLargeTiesDropped = 6, // pivot-equal elements discarded by those cuts
kCtrLargeFault = 7, // large-path invariant violation
- kNumTopKExactCounters = 8,
+ kSlotSwitchMedium = 8, // host-written flag, NOT a device counter
+ kSlotSwitchLarge = 9, // host-written flag, NOT a device counter
+ kCtrDecodeOverflow = 10, // decode rows whose buffered-candidate count
+ // exceeded DBUF before deterministic ranking
+ kNumTopKExactCounters = 11, // total slots: device buffer AND reader output
};
// Exact, bounded-memory re-selection for a single medium-path row.
@@ -506,10 +660,17 @@ enum TopKExactCounter : int {
// row, so correctness does not depend on how many values share a coarse bin.
// Entered only after an overflow has been detected, which is 0.1-0.2 % of
// medium rows at 38K-51K prompt tokens.
+//
+// `tie_recent` is the EFFECTIVE tie direction for this launch, resolved once
+// on the host (see the header comment above): true keeps the highest element
+// indices among the pivot-equal group, false keeps the lowest. It is a launch
+// constant and block-uniform, so every walk and the emission predicate below
+// agree on it without a barrier.
template <int TopK>
__device__ __noinline__ void medium_exact_restart(
const float* __restrict__ scores, int32_t* __restrict__ output,
- uint32_t length, unsigned long long* __restrict__ counters) {
+ uint32_t length, unsigned long long* __restrict__ counters,
+ bool tie_recent) {
static_assert(kThreadsPerBlock >= RADIX,
"the single-thread bin walk assumes RADIX <= blockDim.x");
@@ -521,7 +682,8 @@ __device__ __noinline__ void medium_exact_restart(
uint32_t prefix; // ordered-uint radix prefix, grows 8 bits per round
uint32_t remaining; // how many of the pivot-equal group must be kept
uint32_t tie_total; // how many elements have key == pivot in total
- uint32_t tie_cut; // lowest element index kept among the pivot-equal
+ uint32_t tie_cut; // pivot-equal cut: lowest kept index under
+ // most-recent-wins, highest kept under oldest-wins
uint32_t fault; // set by the walks; block-wide, read after a barrier
};
@@ -541,7 +703,11 @@ __device__ __noinline__ void medium_exact_restart(
smem->prefix = 0;
smem->remaining = static_cast<uint32_t>(TopK);
smem->tie_total = 0;
- smem->tie_cut = 0;
+ // The cut is a bound on the kept index range, so its identity element is
+ // direction-dependent. When tie_total == equal_count the walks below are
+ // skipped entirely and this value is what the emission predicate reads:
+ // `idx >= 0` keeps the whole group, and so does `idx <= 0xFFFFFFFF`.
+ smem->tie_cut = tie_recent ? 0u : 0xFFFFFFFFu;
// The index-space tie walk below is two 8-bit rounds, so it resolves
// indices below 65,536. The medium path serves seq_len <= 32,768, but
// fail loudly rather than silently mis-cut if that ever changes.
@@ -626,11 +792,18 @@ __device__ __noinline__ void medium_exact_restart(
// the end, but wasted work on the fault path and harder to debug. The
// condition is block-uniform (shared, read after the barrier above).
if (smem->fault == 0u) {
- // ---- tie cut at the k-th value: most-recent wins --------------------
+ // ---- tie cut at the k-th value --------------------------------------
//
// Keep the `equal_count` HIGHEST element indices among the pivot-equal
- // group. Finding the cut index turns the tie rule into a per-element
- // predicate, which is what lets the emission below be atomic-free.
+ // group when `tie_recent`, the `equal_count` LOWEST when not. Finding the
+ // cut index turns the tie rule into a per-element predicate, which is what
+ // lets the emission below be atomic-free.
+ //
+ // The two index walks below are the only code that has
+ // to know the direction; they differ from each other only in which end of
+ // the index-bin array they start from, so `count_above` means "population
+ // on the far side of the walk" in both directions and the crossing test is
+ // unchanged.
//
// Exact 32-bit ties at the cutoff are rare (19 occurrences across
// 12 requests, one element each), so the common case is tie_total ==
@@ -651,9 +824,12 @@ __device__ __noinline__ void medium_exact_restart(
}
__syncthreads();
if (tx == 0) {
+ const int first_bin = tie_recent ? RADIX - 1 : 0;
+ const int bin_step = tie_recent ? -1 : 1;
uint32_t count_above = 0;
bool found = false;
- for (int bin = RADIX - 1; bin >= 0; --bin) {
+ for (int n = 0; n < RADIX; ++n) {
+ const int bin = first_bin + n * bin_step;
const uint32_t count = smem->histogram[bin];
if (count_above + count >= need) {
smem->tie_cut = static_cast<uint32_t>(bin); // high byte, for now
@@ -684,9 +860,12 @@ __device__ __noinline__ void medium_exact_restart(
}
__syncthreads();
if (tx == 0) {
+ const int first_bin = tie_recent ? RADIX - 1 : 0;
+ const int bin_step = tie_recent ? -1 : 1;
uint32_t count_above = 0;
bool found = false;
- for (int bin = RADIX - 1; bin >= 0; --bin) {
+ for (int n = 0; n < RADIX; ++n) {
+ const int bin = first_bin + n * bin_step;
const uint32_t count = smem->histogram[bin];
if (count_above + count >= need) {
smem->tie_cut = (high_byte << 8) | static_cast<uint32_t>(bin);
@@ -711,7 +890,9 @@ __device__ __noinline__ void medium_exact_restart(
// ---- emission: ascending index order, no atomics ---------------------
//
- // selected(i) = ordered(i) > pivot || (ordered(i) == pivot && i >= tie_cut)
+ // selected(i) = ordered(i) > pivot ||
+ // (ordered(i) == pivot && (tie_recent ? i >= tie_cut
+ // : i <= tie_cut))
//
// is a pure function of the element, so the output slot is a prefix sum
// over the row rather than an arrival rank. Tiles run in ascending base
@@ -726,8 +907,8 @@ __device__ __noinline__ void medium_exact_restart(
uint32_t flag = 0;
if (idx < length) {
const uint32_t ordered = convert_to_uint32_v2(scores[idx]);
- flag =
- (ordered > pivot || (ordered == pivot && idx >= tie_cut)) ? 1u : 0u;
+ const bool keep_tie = tie_recent ? (idx >= tie_cut) : (idx <= tie_cut);
+ flag = (ordered > pivot || (ordered == pivot && keep_tie)) ? 1u : 0u;
}
uint32_t rank = 0;
uint32_t total = 0;
@@ -805,7 +986,8 @@ template <int TopK>
__device__ __noinline__ void histogram_256_topk(
const float* __restrict__ logits, int* __restrict__ output_indices,
int logits_offset, int seq_len, bool exact_medium,
- unsigned long long* __restrict__ counters) {
+ unsigned long long* __restrict__ counters, bool det_ties,
+ bool tie_recent) {
// No default arguments on purpose: a new call site must state whether it
// wants the exact path, rather than silently inheriting the broken one.
// All shared state lives in dynamic shared memory to avoid static
@@ -973,8 +1155,14 @@ __device__ __noinline__ void histogram_256_topk(
atomicAdd(&counters[kCtrMediumExactFallback], 1ull);
}
__syncthreads(); // no thread may clobber medium_smem before this
+ // The restart takes the SAME effective direction as
+ // the buffered branch below, so crossing the 4,096-candidate capacity
+ // no longer flips the tie policy. `det_ties == false` means the
+ // buffered branch is the racy atomicAdd rule with no direction at all,
+ // and the restart then keeps the medium path's shipped most-recent-wins.
medium_exact_restart<TopK>(logits + logits_offset, output_indices,
- static_cast<uint32_t>(seq_len), counters);
+ static_cast<uint32_t>(seq_len), counters,
+ effective_tie_recent(det_ties, tie_recent));
return;
}
__syncthreads();
@@ -1017,6 +1205,9 @@ __device__ __noinline__ void histogram_256_topk(
}
__syncthreads();
+ // Read before any thread decrements shared_final_k; see the decode
+ // path's fin_total for the reason.
+ const int fin_total_m = shared_final_k;
for (int i = thread_id; i < num_buffered; i += kThreadsPerBlock) {
const int idx = buffered_indices[src_buffer][i];
const float logit_value = logits[idx + logits_offset];
@@ -1027,9 +1218,35 @@ __device__ __noinline__ void histogram_256_topk(
output_indices[output_pos] = idx;
} else if (bin == threshold_bin) {
if (pass == 3) {
- const int slot = atomicAdd(&shared_final_k, -1);
- if (slot > 0) {
- output_indices[TopK - slot] = idx;
+ if (det_ties) {
+ // Same rule as the decode path: every candidate here is bit-equal,
+ // so rank by element index rather than by warp arrival order.
+ //
+ // NOTE the medium-path restart interaction: when exact_medium is on AND this row
+ // overflowed the candidate buffer, medium_exact_restart() has
+ // already returned and this branch is never reached. Since the fix,
+ // that path is handed THIS same `tie_recent`, so the
+ // medium path has one tie direction either side of the capacity
+ // boundary.
+ int rank = 0;
+ for (int j = 0; j < num_buffered; ++j) {
+ const int jdx = buffered_indices[src_buffer][j];
+ const bool ahead = tie_recent ? (jdx > idx) : (jdx < idx);
+ if (!ahead) continue;
+ const uint32_t jf =
+ convert_to_uint32_v2(logits[jdx + logits_offset]);
+ if (static_cast<int>((jf >> bit_offset) & 0xFF) == threshold_bin) {
+ ++rank;
+ }
+ }
+ if (rank < fin_total_m) {
+ output_indices[TopK - fin_total_m + rank] = idx;
+ }
+ } else {
+ const int slot = atomicAdd(&shared_final_k, -1);
+ if (slot > 0) {
+ output_indices[TopK - slot] = idx;
+ }
}
} else {
const int buffer_pos =
@@ -1166,12 +1383,16 @@ __device__ __forceinline__ void wait_ge(int* ptr, int target_val,
// * every CTA reads all 2*ctas_per_group counts and derives its own base by
// an exclusive prefix over `cta_in_group`, in one fixed order, so all
// CTAs agree without further communication;
-// * the tie cut keeps the HIGHEST global indices (most-recent-wins, the
-// `MERLIN_TIE_RECENT` policy, measured at +3.4 % tokens/step
-// over oldest-wins). Chunks are handed out in ascending index order
+// * the tie cut keeps the HIGHEST global indices under most-recent-wins --
+// the `MERLIN_TIE_RECENT` policy, measured at +3.4 %
+// tokens/step over oldest-wins, and the shipped default -- or the LOWEST
+// under oldest-wins. Chunks are handed out in ascending index order
// (`my_chunk_start = cta_in_group * chunk_size`), so "the highest
// `equal_needed` pivot-equal indices" is exactly "walk CTAs from the top
-// down", which the per-CTA equal counts already answer;
+// down" and the oldest-wins rule is the same walk mirrored; the per-CTA
+// equal counts already answer both. The direction is the launch constant
+// `effective_tie_recent(det_ties, tie_recent)`, shared with the medium
+// path;
// * emission is a prefix sum over ascending element index — no atomic
// anywhere on the placement path — so the row is strictly ascending in
// index and bit-reproducible.
@@ -1198,7 +1419,7 @@ __device__ __noinline__ void radix_emit_deterministic(
uint32_t* local_histogram, uint32_t* suffix_sum, uint32_t* shared_ordered,
RadixRowState* state, uint32_t cta_in_group, uint32_t ctas_per_group,
int& barrier_phase, uint32_t radix_iter, uint32_t tx,
- unsigned long long* counters, int fault_inject_cta) {
+ unsigned long long* counters, int fault_inject_cta, bool tie_recent) {
constexpr uint32_t kWarpsPerBlock = kThreadsPerBlock / 32u;
// Staging for the published per-CTA counts. The host gates the switch on
// 4 * ctas_per_group <= RADIX, so these regions never collide.
@@ -1216,6 +1437,8 @@ __device__ __noinline__ void radix_emit_deterministic(
// [0] gt count [1] eq count [2] gt_base [3] eq_keep_base [4] cut_rank
// [5] group fault verdict [6] this CTA's emission-bound violation
// [7] group emission-bound verdict [8] ties dropped on this row
+ // [9] my_keep (the pivot-equal window width; with [4] it spells the cut
+ // in BOTH tie directions -- see the tie_recent note above)
// ---- per-CTA counts, out of the resident chunk -------------------------
if (tx < 2u) suffix_sum[tx] = 0u;
if (tx == 6u) suffix_sum[6] = 0u;
@@ -1301,18 +1524,38 @@ __device__ __noinline__ void radix_emit_deterministic(
gt_total += g;
}
- // Most-recent-wins: walk CTAs from the top down, handing each one as many
- // of the `equal_needed` slots as the CTAs above it left unclaimed. keep[]
- // is written over eq[] in place (eq[c] is read before keep[c] is stored).
- uint32_t eq_above = 0u;
- for (int c = static_cast<int>(ctas_per_group) - 1; c >= 0; --c) {
- const uint32_t e = local_histogram[kEqStage + c];
- const uint32_t want =
- (eq_above >= equal_needed) ? 0u : (equal_needed - eq_above);
- local_histogram[kEqStage + c] = (want < e) ? want : e;
- eq_above += e;
+ // Tie-determinism + large-path integration: the walk direction IS the tie
+ // policy. Most-recent-wins walks CTAs from the top down, handing each one
+ // as many of the `equal_needed` slots as the CTAs above it left unclaimed;
+ // oldest-wins is the same walk mirrored, from the bottom up. Chunks are
+ // handed out in ascending index order (`my_chunk_start = cta_in_group *
+ // chunk_size`), so "the highest `equal_needed` pivot-equal indices" is
+ // exactly "the top CTAs first" and the lowest is exactly the reverse.
+ // `tie_recent` is the EFFECTIVE direction, resolved once on the host by
+ // `effective_tie_recent(det_ties, tie_recent)` -- the same value the
+ // medium restart takes and the same value the host's policy line prints,
+ // so the three paths cannot drift. It is a launch constant and this walk
+ // is single-threaded, so the branch costs nothing that matters. keep[] is
+ // written over eq[] in place (eq[c] is read before keep[c] is stored).
+ uint32_t eq_seen = 0u;
+ if (tie_recent) {
+ for (int c = static_cast<int>(ctas_per_group) - 1; c >= 0; --c) {
+ const uint32_t e = local_histogram[kEqStage + c];
+ const uint32_t want =
+ (eq_seen >= equal_needed) ? 0u : (equal_needed - eq_seen);
+ local_histogram[kEqStage + c] = (want < e) ? want : e;
+ eq_seen += e;
+ }
+ } else {
+ for (uint32_t c = 0; c < ctas_per_group; ++c) {
+ const uint32_t e = local_histogram[kEqStage + c];
+ const uint32_t want =
+ (eq_seen >= equal_needed) ? 0u : (equal_needed - eq_seen);
+ local_histogram[kEqStage + c] = (want < e) ? want : e;
+ eq_seen += e;
+ }
}
- const uint32_t eq_total = eq_above;
+ const uint32_t eq_total = eq_seen;
uint32_t eq_keep_base = 0u;
for (uint32_t c = 0; c < cta_in_group; ++c) {
@@ -1354,9 +1597,15 @@ __device__ __noinline__ void radix_emit_deterministic(
suffix_sum[2] = gt_base;
suffix_sum[3] = eq_keep_base;
- // Keep the local pivot-equal elements whose ascending rank within this
- // chunk is >= cut_rank, i.e. the `my_keep` highest-indexed of them.
- suffix_sum[4] = (my_keep <= local_eq) ? (local_eq - my_keep) : 0u;
+ // The kept pivot-equal elements of this chunk are the `my_keep` whose
+ // ascending rank within the chunk lies in [cut_rank, cut_rank + my_keep).
+ // Most-recent-wins puts that window at the TOP of the chunk
+ // (cut_rank = local_eq - my_keep, so the upper bound is vacuous and the
+ // emission predicate is byte-for-byte the rule the large path shipped); oldest-wins
+ // puts it at the BOTTOM (cut_rank = 0, and the upper bound is what binds).
+ suffix_sum[4] =
+ tie_recent ? ((my_keep <= local_eq) ? (local_eq - my_keep) : 0u) : 0u;
+ suffix_sum[9] = my_keep;
suffix_sum[8] = (eq_total > equal_needed) ? (eq_total - equal_needed) : 0u;
// Publish this CTA's verdict. Own word, so no pre-zeroing is needed.
@@ -1396,6 +1645,7 @@ __device__ __noinline__ void radix_emit_deterministic(
const uint32_t gt_base = suffix_sum[2];
const uint32_t eq_keep_base = suffix_sum[3];
const uint32_t cut_rank = suffix_sum[4];
+ const uint32_t keep_width = suffix_sum[9];
const uint32_t row_fault = suffix_sum[5];
// ---- emission: rank by index, no atomics -------------------------------
@@ -1459,11 +1709,20 @@ __device__ __noinline__ void radix_emit_deterministic(
const uint32_t tile_gt = warp_gt[32];
const uint32_t tile_eq = warp_eq[32];
- if (f_gt || (f_eq && eq_rank >= cut_rank)) {
- // Selected elements before this one, in ascending index order:
- // all `> pivot` ones, plus the pivot-equal ones past the cut.
- const uint32_t kept_eq_before =
+ if (f_gt || (f_eq && eq_rank >= cut_rank &&
+ eq_rank - cut_rank < keep_width)) {
+ // Selected elements before this one, in ascending index order: all
+ // `> pivot` ones, plus the pivot-equal ones already inside the cut
+ // window. The clamp to `keep_width` is what makes the expression work
+ // in BOTH directions: under most-recent-wins the window ends at the
+ // last pivot-equal element of the chunk, so `eq_rank - cut_rank` can
+ // never reach `keep_width` and the clamp is dead; under oldest-wins
+ // the window ends early and every pivot-equal element after it must
+ // stop contributing, or a later `> pivot` element is placed past the
+ // end of the row.
+ uint32_t kept_eq_before =
(eq_rank > cut_rank) ? (eq_rank - cut_rank) : 0u;
+ if (kept_eq_before > keep_width) kept_eq_before = keep_width;
const uint32_t slot = gt_base + eq_keep_base + gt_rank + kept_eq_before;
if (slot < static_cast<uint32_t>(TopK)) {
row_output[slot] = static_cast<int32_t>(my_chunk_start + i);
@@ -1554,7 +1813,8 @@ __device__ void radix_topk(const float* __restrict__ row_input,
RadixRowState* state, uint32_t cta_in_group,
uint32_t ctas_per_group, int& barrier_phase,
uint32_t radix_iter, uint32_t tx, bool det_large,
- unsigned long long* counters, int fault_inject_cta) {
+ unsigned long long* counters, int fault_inject_cta,
+ bool tie_recent) {
const uint32_t my_chunk_end = (my_chunk_start + chunk_size < seq_len)
? my_chunk_start + chunk_size
: seq_len;
@@ -1715,7 +1975,7 @@ __device__ void radix_topk(const float* __restrict__ row_input,
row_output, ordered_pivot, shared_scalars[1], my_chunk_start,
actual_chunk_size, local_histogram, suffix_sum, shared_ordered, state,
cta_in_group, ctas_per_group, barrier_phase, radix_iter, tx, counters,
- fault_inject_cta);
+ fault_inject_cta, tie_recent);
return;
}
@@ -1854,10 +2114,23 @@ __global__ void __launch_bounds__(kThreadsPerBlock, 2)
row_output[i] = (i < seq_len) ? static_cast<int32_t>(i) : -1;
}
} else if (seq_len <= static_cast<uint32_t>(HIST2048_THRESHOLD)) {
- histogram_2048_topk<TopK>(row_input, row_output, seq_len);
+ // An overflowing decode row comes back here for
+ // the same exact restart the medium path takes. The return is
+ // block-uniform and nothing has been emitted that the restart does
+ // not overwrite. With MERLIN_TOPK_EXACT_DECODE off this is always
+ // false and the decode path is byte-identical to the medium-path restart's.
+ const bool decode_needs_exact = histogram_2048_topk<TopK>(
+ row_input, row_output, seq_len, params.det_ties,
+ params.tie_recent, params.exact_counters, params.exact_decode);
+ if (decode_needs_exact) {
+ medium_exact_restart<TopK>(
+ row_input, row_output, seq_len, params.exact_counters,
+ effective_tie_recent(params.det_ties, params.tie_recent));
+ }
} else {
histogram_256_topk<TopK>(row_input, row_output, 0, seq_len,
- params.exact_medium, params.exact_counters);
+ params.exact_medium, params.exact_counters,
+ params.det_ties, params.tie_recent);
}
}
continue;
@@ -1868,7 +2141,8 @@ __global__ void __launch_bounds__(kThreadsPerBlock, 2)
row_input, row_output, seq_len, my_chunk_start, chunk_size,
local_histogram, suffix_sum, shared_scalars, shared_ordered, state,
cta_in_group, ctas_per_group, barrier_phase, radix_iter, tx,
- params.det_large, params.exact_counters, params.fault_inject_cta);
+ params.det_large, params.exact_counters, params.fault_inject_cta,
+ effective_tie_recent(params.det_ties, params.tie_recent));
radix_iter++;
}
}
diff --git a/csrc/libtorch_stable/topk.cu b/csrc/libtorch_stable/topk.cu
index 07d166a..2d2c81d 100644
--- a/csrc/libtorch_stable/topk.cu
+++ b/csrc/libtorch_stable/topk.cu
@@ -29,6 +29,41 @@ namespace {
// behaviour exactly (the counters still record, so the control arm reports the
// overflow incidence it is the control for).
// ---------------------------------------------------------------------------
+// -------------------------------------------------------------------------
+// MERLIN_TIE_DET: deterministic tie-break at the k-th cutoff in
+// persistent_topk. Resolved once per process, exactly like
+// exact_medium_enabled() below, so it is a launch constant that a cudagraph
+// bakes in at capture.
+//
+// Our own toggle rather than upstream's VLLM_BATCH_INVARIANT, which aborts
+// engine startup on GDN models (upstream #42960) and so can never reach this
+// kernel on Flash-Next. Default OFF -- promotion is a release decision.
+//
+// This std::getenv is the SINGLE source of truth; vllm/envs.py carries a raw
+// passthrough for validate_environ and the compile-factor hash only.
+// -------------------------------------------------------------------------
+// MERLIN_TIE_RECENT -- the DIRECTION of the deterministic tie-break above.
+// Measured at 12k tokens on the reference host: oldest-wins costs -7.9 % decode,
+// most-recent-wins is +1.5 %. Both deterministic. Also the direction
+// medium_exact_restart() already implements, so setting this alongside
+// MERLIN_TIE_DET makes the medium path agree with itself on overflowing
+// rows. Only read when MERLIN_TIE_DET is set. Default OFF.
+bool tie_recent_enabled() {
+ static const bool enabled = [] {
+ const char* raw = std::getenv("MERLIN_TIE_RECENT");
+ return (raw != nullptr && raw[0] != '\0') ? (std::atoi(raw) != 0) : false;
+ }();
+ return enabled;
+}
+
+bool det_ties_enabled() {
+ static const bool enabled = [] {
+ const char* raw = std::getenv("MERLIN_TIE_DET");
+ return (raw != nullptr && raw[0] != '\0') ? (std::atoi(raw) != 0) : false;
+ }();
+ return enabled;
+}
+
bool exact_medium_enabled() {
static const bool enabled = [] {
const char* raw = std::getenv("MERLIN_TOPK_EXACT_MEDIUM");
@@ -55,6 +90,23 @@ bool det_large_enabled() {
return enabled;
}
+// MERLIN_TOPK_EXACT_DECODE: repair an overflowing decode
+// row with the same exact restart the medium path uses. Resolved once per
+// process like the others, so it is a launch constant a cudagraph bakes in.
+//
+// Default OFF, unlike MERLIN_TOPK_EXACT_MEDIUM. The medium path's overflow
+// incidence was measured (0.1-0.2 % of medium rows at 38-51K) before that
+// switch defaulted on; the decode path's has never been measured at all,
+// which is precisely what the [10] counter now exists to establish. Detection
+// is unconditional and free; the repair waits on a number.
+bool exact_decode_enabled() {
+ static const bool enabled = [] {
+ const char* raw = std::getenv("MERLIN_TOPK_EXACT_DECODE");
+ return (raw != nullptr && raw[0] != '\0') ? (std::atoi(raw) != 0) : false;
+ }();
+ return enabled;
+}
+
// ---------------------------------------------------------------------------
// MERLIN_TOPK_LARGE_FAULT_INJECT. TEST ONLY.
//
@@ -79,6 +131,50 @@ int large_fault_inject_cta() {
return cta;
}
+// ---------------------------------------------------------------------------
+// Say the effective tie policy out loud, once.
+//
+// "TIE_DET=1 TIE_RECENT=0" used to mean three different things on the three
+// paths, and nothing printed the difference. One stderr line at the first
+// launch of the process naming the policy the kernel will actually apply on
+// EVERY path. It is a plain fprintf on the host: no CUDA call, so it is safe
+// from a capturing thread, and the `static` initialiser makes it exactly once
+// per process regardless of how many workers share the log.
+//
+// The line covers all FIVE paths. The large
+// path is no longer a fixed rule: `radix_emit_deterministic` takes the same
+// `effective_tie_recent()` value as the medium restart, so `large=` reports
+// the real direction when MERLIN_TOPK_DET_LARGE is on and the inherited
+// v2.0.1 atomic-arrival race when it is off. The flag printed here is the
+// ENV value; a launch whose ctas_per_group overflows the staging row degrades
+// to atomic-arrival for that launch and leaves counter [4] at 0, which is the
+// separate warning below.
+// ---------------------------------------------------------------------------
+void report_tie_policy_once() {
+ static const bool reported = [] {
+ const bool det = det_ties_enabled();
+ const bool recent =
+ vllm::persistent::effective_tie_recent(det, tie_recent_enabled());
+ const char* dir = recent ? "most-recent-wins" : "oldest-wins";
+ std::fprintf(
+ stderr,
+ "[merlin] persistent_topk tie policy: MERLIN_TIE_DET=%d "
+ "MERLIN_TIE_RECENT=%d MERLIN_TOPK_EXACT_MEDIUM=%d "
+ "MERLIN_TOPK_EXACT_DECODE=%d MERLIN_TOPK_DET_LARGE=%d -> "
+ "decode-buffered=%s decode-restart=%s medium-buffered=%s "
+ "medium-restart=%s large=%s\n",
+ det ? 1 : 0, tie_recent_enabled() ? 1 : 0,
+ exact_medium_enabled() ? 1 : 0, exact_decode_enabled() ? 1 : 0,
+ det_large_enabled() ? 1 : 0, det ? dir : "atomic-arrival",
+ exact_decode_enabled() ? dir : "n/a (detect-only; counter [10])",
+ det ? dir : "atomic-arrival",
+ exact_medium_enabled() ? dir : "n/a (exact restart disabled)",
+ det_large_enabled() ? dir : "atomic-arrival (non-deterministic)");
+ return true;
+ }();
+ (void)reported;
+}
+
// Process-lifetime device counters, one block per device. The kernel only
// ever atomicAdds into them, so the launch path needs no host sync and the
// values accumulate across cudagraph replays.
@@ -421,6 +517,10 @@ void launch_persistent_topk(const torch::stable::Tensor& logits,
params.fault_inject_cta);
}
}
+ params.det_ties = det_ties_enabled();
+ params.tie_recent = tie_recent_enabled();
+ params.exact_decode = exact_decode_enabled();
+ report_tie_policy_once();
// Allocate only off the capture path; in the ordinary engine lifecycle the
// eager warmup lands here first, long before capture. The explicit,
// quiescent-phase entry point is the reader op.
@@ -526,9 +626,10 @@ void persistent_topk(const torch::stable::Tensor& logits,
// device before any cudagraph capture.
//
// PROTOCOL VERSION 2 -- BREAKING. `out` is an **int64**
-// CUDA tensor with at least 10 elements; int32 is REJECTED, not silently
-// accepted. The device counters are 64-bit unsigned and are copied D2D
-// without conversion, so the reader's element width must match the device's.
+// CUDA tensor with EXACTLY kNumTopKExactCounters (11) elements; int32, and any
+// other length, are REJECTED rather than silently accepted. The device counters are 64-bit unsigned
+// and are copied D2D without conversion, so the reader's element width must
+// match the device's.
//
// Why the break rather than a reinterpreting reader: the counters were u32
// copied into a SIGNED int32 tensor. At 65,536 columns with k=512 an
@@ -543,11 +644,20 @@ void persistent_topk(const torch::stable::Tensor& logits,
// so the instrument costs the same, and a 64-bit slot cannot be driven to the
// signed boundary by any run this kernel will ever see.
//
+// The layout is the UNION of the tie-determinism and large-path slot
+// maps -- the large path's ten slots plus the decode restart's [10] decode_overflow, eleven in all,
+// with nothing renumbered. The two switch flags are slots INSIDE the array at
+// [8] and [9], not two elements appended after it, so a probe sized to the
+// old ten- or five-element layout is rejected by the length check below rather
+// than being filled with the wrong meanings. Ask the build for its own answer
+// with `torch.ops._C.persistent_topk_exact_counters_slots()`.
+//
// Everything that reads these counters must move together: the Python reader
-// `vllm/models/qwen4_exp/nvidia/ops/topk_counters.py` now allocates int64, and
-// the medium-path tests allocate int64. An int32 caller gets a loud error rather
-// than half a number. The OPERATOR SCHEMA is unchanged (dtype is not part of
-// it), so a stale caller fails at the check, not at the binding.
+// `vllm/models/qwen4_exp/nvidia/ops/topk_counters.py` allocates int64 and
+// eleven slots, and the medium- and large-path tests do too. An int32 caller gets a loud
+// error rather than half a number. The OPERATOR SCHEMA is unchanged (dtype
+// and length are not part of it), so a stale caller fails at the check, not at
+// the binding.
//
// Slots:
// [0] medium-path rows entered (denominator)
@@ -558,8 +668,11 @@ void persistent_topk(const torch::stable::Tensor& logits,
// [5] large-path rows whose pivot-equal group was cut at the k-th value
// [6] large-path pivot-equal elements discarded by those cuts
// [7] large-path invariant violations (must stay 0)
-// [8] 1 if MERLIN_TOPK_EXACT_MEDIUM is on, else 0
-// [9] 1 if MERLIN_TOPK_DET_LARGE is on, else 0
+// [8] 1 if MERLIN_TOPK_EXACT_MEDIUM is on, else 0 (host-written)
+// [9] 1 if MERLIN_TOPK_DET_LARGE is on, else 0 (host-written)
+// [10] decode rows whose buffered candidates exceeded DBUF (3,708)
+// before deterministic ranking -- MUST be read before any decode-path
+// determinism claim
//
// COHERENCY -- stated precisely, because the earlier wording overclaimed.
// With `sync == 0` the snapshot is ordered ONLY against work already enqueued
@@ -587,7 +700,8 @@ void persistent_topk_exact_counters(torch::stable::Tensor& out, int64_t reset,
out.scalar_type() == torch::headeronly::ScalarType::Long,
"out must be int64: the exact-restart counters are 64-bit as of the "
"large-path counter protocol change, and an int32 buffer would be filled "
- "from half of each counter. Allocate torch.zeros(10, dtype=torch.int64)."
+ "from half of each counter. Allocate "
+ "torch.zeros(persistent_topk_exact_counters_slots(), dtype=torch.int64)."
" (int32 was protocol version 1 and is deliberately rejected.)");
// Element count alone is not enough. A CUDA scalar expanded
// to ten elements (stride 0) satisfies numel but owns one element's storage,
@@ -598,9 +712,19 @@ void persistent_topk_exact_counters(torch::stable::Tensor& out, int64_t reset,
STD_TORCH_CHECK(out.is_contiguous(),
"out must be contiguous (expanded or strided views would be "
"written out of bounds or into the wrong elements)");
- STD_TORCH_CHECK(out.numel() >= vllm::persistent::kNumTopKExactCounters + 2,
- "out must hold at least ",
- vllm::persistent::kNumTopKExactCounters + 2, " elements");
+ // EXACTLY the build's slot count, not "at least". `>=` accepted an
+ // 11-element buffer on the ten-slot large-path build and quietly left the extra
+ // element reading 0 -- a caller compiled against one layout got a plausible
+ // answer from a build with another, which is the failure mode the
+ // self-describing `..._slots` op exists to prevent. Ask the build, size the
+ // buffer to what it says, and a mismatch is an error rather than a wrong
+ // number.
+ STD_TORCH_CHECK(out.numel() == vllm::persistent::kNumTopKExactCounters,
+ "out must hold exactly ",
+ vllm::persistent::kNumTopKExactCounters,
+ " elements (this build's persistent_topk_exact_counters_"
+ "slots(); the switch flags are slots inside the array, not "
+ "appended after it), got ", out.numel());
STD_TORCH_CHECK(
reset == 0 || sync != 0,
"persistent_topk_exact_counters: reset requires sync != 0, otherwise the "
@@ -645,15 +769,20 @@ void persistent_topk_exact_counters(torch::stable::Tensor& out, int64_t reset,
}
// Static storage: the source of an async H2D copy must outlive the copy.
- // Two flags: [kNumTopKExactCounters] = the medium-path switch,
- // [kNumTopKExactCounters + 1] = the large-path switch. The large
- // flag reports the ENV value, not the per-launch effective value: a launch
- // whose ctas_per_group overflowed the staging row still reports 1 here and
- // leaves counter [4] at 0, which is how that degradation is diagnosed.
+ // Written AFTER the device copy, which has already zeroed both slots: they
+ // are slots [8] and [9] INSIDE the array and the device never touches them.
+ // The large flag reports the ENV value, not the per-launch effective value:
+ // a launch whose ctas_per_group overflowed the staging row still reports 1
+ // here and leaves counter [4] at 0, which is how that degradation is
+ // diagnosed. kSlotSwitchLarge == kSlotSwitchMedium + 1, so one copy does
+ // both.
+ static_assert(vllm::persistent::kSlotSwitchLarge ==
+ vllm::persistent::kSlotSwitchMedium + 1,
+ "the two host-written flags must stay adjacent for this copy");
static const int64_t enabled[2] = {exact_medium_enabled() ? 1 : 0,
det_large_enabled() ? 1 : 0};
STD_TORCH_CHECK(
- cudaMemcpyAsync(dst + vllm::persistent::kNumTopKExactCounters, enabled,
+ cudaMemcpyAsync(dst + vllm::persistent::kSlotSwitchMedium, enabled,
2 * sizeof(int64_t), cudaMemcpyHostToDevice,
stream) == cudaSuccess,
"exact counter flag write failed");
@@ -670,3 +799,19 @@ void persistent_topk_exact_counters(torch::stable::Tensor& out, int64_t reset,
STD_TORCH_CHECK(false, "persistent_topk is not supported on ROCm");
#endif
}
+
+// How many int32 slots THIS build's counter array carries.
+//
+// The Python reader used to guess from MERLIN_TOPK_COUNTER_SLOTS (default 5)
+// and name the slots by that length. The decode restart makes the array 11 and the large path makes
+// it 10, so a guess is now wrong on at least one branch, and a reader that
+// allocates the wrong length is rejected by the length check above -- the
+// instrument goes dark exactly when it starts mattering. Ask the build
+// instead. No CUDA call, no allocation, safe from anywhere.
+int64_t persistent_topk_exact_counters_slots() {
+#ifndef USE_ROCM
+ return static_cast<int64_t>(vllm::persistent::kNumTopKExactCounters);
+#else
+ return 0;
+#endif
+}
diff --git a/csrc/libtorch_stable/torch_bindings.cpp b/csrc/libtorch_stable/torch_bindings.cpp
index 44c5f95..53f4577 100644
--- a/csrc/libtorch_stable/torch_bindings.cpp
+++ b/csrc/libtorch_stable/torch_bindings.cpp
@@ -590,6 +590,7 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C, ops) {
// Read (and optionally reset) the medium-path exact-restart counters.
ops.def(
"persistent_topk_exact_counters(Tensor! out, int reset, int sync) -> ()");
+ ops.def("persistent_topk_exact_counters_slots() -> int");
#ifdef VLLM_ENABLE_COOPERATIVE_TOPK
ops.def(
@@ -866,6 +867,8 @@ STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, ops) {
ops.impl("persistent_topk", TORCH_BOX(&persistent_topk));
ops.impl("persistent_topk_exact_counters",
TORCH_BOX(&persistent_topk_exact_counters));
+ ops.impl("persistent_topk_exact_counters_slots",
+ TORCH_BOX(&persistent_topk_exact_counters_slots));
#ifdef VLLM_ENABLE_COOPERATIVE_TOPK
ops.impl("cooperative_topk", TORCH_BOX(&cooperative_topk));
#endif
diff --git a/tests/kernels/test_top_k_per_row.py b/tests/kernels/test_top_k_per_row.py
index 9c775b0..fa6ee84 100644
--- a/tests/kernels/test_top_k_per_row.py
+++ b/tests/kernels/test_top_k_per_row.py
@@ -1081,7 +1081,7 @@ def run_large_context_topk_test(
Torch: {torch_vals.sort(descending=True)[0][:10]}"""
-@pytest.mark.skipif(not _has_device_capability(90), reason="This test requires SM90+")
+@pytest.mark.skipif(not _has_device_capability(90), reason="cooperative_topk is sm_90+ only")
@pytest.mark.parametrize("top_k", [512, 1024, 2048])
def test_cooperative_topk_cs2(top_k: int) -> None:
"""The 64-row dispatch uses the two-CTA cooperative kernel.
@@ -1861,8 +1861,13 @@ def test_sparse_indexer_topk_backend_resolution() -> None:
# ---------------------------------------------------------------------------
# Slot map of torch.ops._C.persistent_topk_exact_counters (see
-# csrc/libtorch_stable/topk.cu). The large path appended four counters and a
-# second switch flag, so the medium-path "enabled" flag moved from [4] to [8].
+# csrc/libtorch_stable/topk.cu). Eleven slots, the
+# union of the tie-determinism and large-path determinism layouts with nothing renumbered -- the large path appended four
+# large-path counters and a second switch flag (so the medium-path "enabled" flag
+# moved from [4] to [8]) and the tie-determinism change numbered its decode counter AROUND that
+# block at [10]. Slots 8-9 are `kSlotSwitchMedium` / `kSlotSwitchLarge` in
+# the kernel: host-written flags, not device counters.
+_EXACT_COUNTER_SLOTS = 11
_SLOT_MEDIUM_ROWS = 0
_SLOT_MEDIUM_OVERFLOW = 1
_SLOT_MEDIUM_FALLBACK = 2
@@ -1873,7 +1878,7 @@ _SLOT_LARGE_TIES_DROPPED = 6
_SLOT_LARGE_FAULT = 7
_SLOT_EXACT_MEDIUM_ON = 8
_SLOT_DET_LARGE_ON = 9
-_EXACT_COUNTER_SLOTS = 10
+_SLOT_DECODE_OVERFLOW = 10
def _read_exact_counters() -> list[int] | None:
@@ -1882,6 +1887,7 @@ def _read_exact_counters() -> list[int] | None:
[0..3] medium: rows, overflow, exact fallbacks, faults.
[4..7] large: deterministic rows, tie cuts, elements cut, faults.
[8] MERLIN_TOPK_EXACT_MEDIUM, [9] MERLIN_TOPK_DET_LARGE.
+ [10] decode overflow.
"""
op = getattr(torch.ops._C, "persistent_topk_exact_counters", None)
if op is None:
@@ -2022,9 +2028,10 @@ def test_persistent_topk_medium_overflow_is_bit_reproducible() -> None:
@pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA")
+@pytest.mark.parametrize("want_recent", [True, False])
@torch.inference_mode()
-def test_persistent_topk_medium_overflow_tie_keeps_highest_indices() -> None:
- """Most-recent-wins at the cutoff, with an analytically known answer.
+def test_persistent_topk_medium_overflow_tie_direction(want_recent: bool) -> None:
+ """The exact restart's tie cut, in BOTH directions, analytically known.
Random float32 values essentially never tie at the k-th place, so the
exactness tests above never exercise the tie rule at all -- they pass
@@ -2037,14 +2044,39 @@ def test_persistent_topk_medium_overflow_tie_keeps_highest_indices() -> None:
index-radix tie rounds actually run,
* the cut spans several high-byte index buckets and lands mid-bucket.
- Under most-recent-wins the answer is forced: the top_k HIGHEST indices,
- emitted ascending. `torch.topk` cannot supply this expectation -- with all
+ Both directions are forced answers: the top_k HIGHEST indices under
+ most-recent-wins, the top_k LOWEST under oldest-wins, emitted ascending
+ either way. `torch.topk` cannot supply either expectation -- with all
values equal its index choice is arbitrary -- so the reference is computed
from the rule itself.
+
+ The direction is a launch constant resolved once per
+ process from `MERLIN_TIE_DET` / `MERLIN_TIE_RECENT`, so the arm that does
+ not match this worker's environment cannot be exercised here and skips
+ LOUDLY naming the environment that would run it. Both arms must be run,
+ in two fresh worker processes -- a green run of one arm is half the gate.
"""
torch.set_default_device("cuda:0")
num_rows, seq_len, width, top_k = 4, 12345, 12352, 2048
+ # effective_tie_recent(det_ties, tie_recent) in persistent_topk.cuh, in
+ # Python. Keep the two in step: this is the reported policy, and the
+ # kernel's launcher prints the same resolution to stderr at first use.
+ det_ties = bool(int(os.environ.get("MERLIN_TIE_DET", "0") or "0"))
+ tie_recent = bool(int(os.environ.get("MERLIN_TIE_RECENT", "0") or "0"))
+ effective_recent = tie_recent if det_ties else True
+ if effective_recent != want_recent:
+ pytest.skip(
+ f"this worker resolves the tie direction to "
+ f"{'most-recent' if effective_recent else 'oldest'}-wins "
+ f"(MERLIN_TIE_DET={int(det_ties)} "
+ f"MERLIN_TIE_RECENT={int(tie_recent)}); run the "
+ f"{'most-recent' if want_recent else 'oldest'}-wins arm in a fresh "
+ f"process with "
+ + ("MERLIN_TIE_DET=0 (or =1 with MERLIN_TIE_RECENT=1)"
+ if want_recent else "MERLIN_TIE_DET=1 MERLIN_TIE_RECENT=0")
+ )
+
before = _read_exact_counters()
if before is None:
pytest.skip(
@@ -2068,17 +2100,134 @@ def test_persistent_topk_medium_overflow_tie_keeps_highest_indices() -> None:
assert after[2] - before[2] == num_rows, "the exact restart did not run"
assert after[3] == before[3], "the exact fallback reported a fault"
- # The cut must genuinely exercise both tie rounds, or the test is weaker
- # than it looks: several high-byte buckets, and not on a bucket boundary.
- cut = seq_len - top_k
- assert (cut >> 8) != ((seq_len - 1) >> 8), "tie cut spans only one bucket"
- assert cut % 256 != 0, "tie cut sits on a bucket boundary"
+ # The cut must genuinely exercise both index-radix rounds, or the test is
+ # weaker than it looks. Each direction has its own cut index and its own
+ # strength, stated separately rather than asserted as if they were one.
+ if want_recent:
+ # Highest top_k indices: cut at seq_len - top_k, several high-byte
+ # buckets away from the top and landing mid-bucket.
+ lo, hi = seq_len - top_k, seq_len
+ cut = lo
+ assert (cut >> 8) != ((seq_len - 1) >> 8), "tie cut spans only one bucket"
+ assert cut % 256 != 0, "tie cut sits on a bucket boundary"
+ else:
+ # Lowest top_k indices: cut at top_k - 1. top_k is a whole number of
+ # 256-index buckets, so this one necessarily lands on the LAST slot of
+ # its bucket -- which makes round B walk all 256 low bins before it
+ # crosses, the opposite corner from the arm above.
+ lo, hi = 0, top_k
+ cut = hi - 1
+ assert (cut >> 8) != 0, "tie cut spans only one bucket"
+ assert cut % 256 == 255, "the oldest-wins cut should end its bucket"
+ expected = torch.arange(lo, hi, dtype=torch.int32, device="cuda")
+ rule = "most-recent-wins" if want_recent else "oldest-wins"
+ for row in range(num_rows):
+ assert torch.equal(indices[row], expected), (
+ f"row {row}: {rule} should select indices "
+ f"[{lo}, {hi}) in ascending order; got "
+ f"{indices[row][:4].tolist()}..{indices[row][-4:].tolist()}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# DECODE candidate truncation, the adversarial class.
+#
+# The decode path buffers the coarse threshold bin's candidates into a
+# 3,708-slot shared buffer (`DBUF`) and refines only what it retained.
+# MERLIN_TIE_DET ranks the survivors deterministically; it cannot recall a
+# winner that was never buffered. The historical zero-overflow measurements
+# establish workload coverage, not a capacity proof.
+# ---------------------------------------------------------------------------
+
- expected = torch.arange(cut, seq_len, dtype=torch.int32, device="cuda")
+@pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA")
+@torch.inference_mode()
+def test_persistent_topk_decode_overflow_is_detected() -> None:
+ """The failing control for the decode-overflow counter.
+
+ The adversarial row: 8,192 elements that all land in ONE coarse
+ half-precision bin, so the coarse pass separates nothing and every element
+ becomes a buffered candidate -- 8,192 of them into 3,708 slots. Within
+ that bin three FP32 levels, all rounding to the same half:
+
+ * indices [0, 511) the highest value -> the 511 true leaders,
+ * index seq_len - 1 the middle value -> the 512th true winner,
+ * everything else the lowest value.
+
+ Element order does the rest: the 512th winner sits at the END of the row,
+ so it is appended after capacity is gone and is dropped before any ranking
+ runs. The returned set then holds the 511 leaders plus an arbitrary
+ element of the bulk.
+
+ This test asserts the COUNTER, not the repair: it is the control that
+ fails on a build without the detection (there is no slot 10 to read, and
+ the op rejects the 11-element buffer), and it is what makes the decode
+ path's determinism claim conditional on a measured number rather than on
+ an unmeasured assumption. Exactness is asserted only when the repair is
+ actually enabled, since MERLIN_TOPK_EXACT_DECODE is a launch constant this
+ process cannot change.
+ """
+ torch.set_default_device("cuda:0")
+ num_rows, seq_len, top_k = 4, 8192, 512
+ leaders = top_k - 1 # 511; the 512th winner is the tail element
+
+ before = _read_exact_counters()
+ if before is None:
+ pytest.skip(
+ "QUALIFICATION FAILURE if seen on the branch build: no "
+ "persistent_topk_exact_counters op. A skip is not a pass."
+ )
+
+ # Three FP32 levels a few ULPs apart. Half-precision spacing near 1.5 is
+ # 2**-10, about 8,000x these gaps, so all three round to the same half and
+ # therefore to the same coarse bin -- asserted below rather than assumed.
+ base = np.float32(1.5)
+ mid = np.nextafter(np.nextafter(base, np.float32(np.inf)), np.float32(np.inf))
+ high = np.nextafter(np.nextafter(mid, np.float32(np.inf)), np.float32(np.inf))
+ assert base < mid < high
+ assert (
+ np.float16(base) == np.float16(mid) == np.float16(high)
+ ), "the three levels must share one half-precision bin or the row is not adversarial"
+
+ logits = torch.full((num_rows, seq_len), float(base), dtype=torch.float32)
+ logits[:, :leaders] = float(high)
+ logits[:, seq_len - 1] = float(mid)
+ lengths = torch.full((num_rows,), seq_len, dtype=torch.int32)
+ indices = torch.empty((num_rows, top_k), dtype=torch.int32)
+ _run_topk_backend("persistent_topk", logits, lengths, indices, top_k, seq_len)
+ torch.accelerator.synchronize()
+
+ after = _read_exact_counters()
+ fired = after[_SLOT_DECODE_OVERFLOW] - before[_SLOT_DECODE_OVERFLOW]
+ assert fired == num_rows, (
+ f"the decode candidate buffer overflowed on {num_rows} rows by "
+ f"construction (8,192 candidates into 3,708 slots) but the counter "
+ f"moved by {fired}; the truncation is happening unseen"
+ )
+
+ # Whatever the arm, the row must still be a well-formed selection.
+ for row in range(num_rows):
+ got = indices[row].tolist()
+ assert len(set(got)) == top_k, f"row {row}: duplicate indices"
+ assert all(0 <= i < seq_len for i in got), f"row {row}: index out of range"
+
+ if not bool(int(os.environ.get("MERLIN_TOPK_EXACT_DECODE", "0") or "0")):
+ pytest.skip(
+ "MERLIN_TOPK_EXACT_DECODE=0 (the default): detection asserted "
+ "above, the repair is not enabled in this process, so exactness "
+ "is NOT claimed for this row. Run this test again in a fresh "
+ "process with MERLIN_TOPK_EXACT_DECODE=1 for the repaired arm -- "
+ "a green detect-only run is half the gate."
+ )
+
+ # Repaired arm: the exact answer is forced and contains the tail winner.
+ expected = torch.tensor(
+ list(range(leaders)) + [seq_len - 1], dtype=torch.int32, device="cuda"
+ )
for row in range(num_rows):
assert torch.equal(indices[row], expected), (
- f"row {row}: most-recent-wins should select indices "
- f"[{cut}, {seq_len}) in ascending order; got "
+ f"row {row}: the exact decode restart should select "
+ f"[0, {leaders}) plus {seq_len - 1}; got "
f"{indices[row][:4].tolist()}..{indices[row][-4:].tolist()}"
)
@@ -2111,6 +2260,16 @@ def test_persistent_topk_exact_counters_rejects_unsafe_output() -> None:
ok = torch.zeros(_EXACT_COUNTER_SLOTS, dtype=torch.int64, device="cuda")
op(ok, 0, 1) # the control: this layout must be accepted
+ slots_op = getattr(torch.ops._C, "persistent_topk_exact_counters_slots", None)
+ assert slots_op is not None, (
+ "the build must describe its own counter layout; a guessed length is "
+ "how a stale probe reads the wrong slots"
+ )
+ assert int(slots_op()) == _EXACT_COUNTER_SLOTS, (
+ f"build reports {int(slots_op())} slots, test expects "
+ f"{_EXACT_COUNTER_SLOTS}"
+ )
+
wrong_dtype = torch.zeros(_EXACT_COUNTER_SLOTS, dtype=torch.int32, device="cuda")
with pytest.raises(Exception):
op(wrong_dtype, 0, 1)
@@ -2131,6 +2290,20 @@ def test_persistent_topk_exact_counters_rejects_unsafe_output() -> None:
with pytest.raises(Exception):
op(too_small, 0, 1)
+ # Too LARGE is rejected too. The check used to be `>=`, so a buffer sized
+ # for one layout was accepted by a build with another and the surplus
+ # slots read 0 -- a plausible answer from the wrong protocol.
+ too_large = torch.zeros(_EXACT_COUNTER_SLOTS + 1, dtype=torch.int64, device="cuda")
+ with pytest.raises(Exception):
+ op(too_large, 0, 1)
+
+ # This case matters: a stale medium-path five-element probe must
+ # be REJECTED, not filled with the wrong meanings. It is the same length
+ # check as above, asserted at the length a real stale caller would use.
+ medium_sized = torch.zeros(5, dtype=torch.int64, device="cuda")
+ with pytest.raises(Exception):
+ op(medium_sized, 0, 1)
+
with pytest.raises(Exception):
op(ok, 1, 0) # reset without sync is not a complete handoff
@@ -2159,24 +2332,49 @@ def test_topk_exact_counters_reader_log_line(monkeypatch) -> None:
monkeypatch.setenv("MERLIN_TOPK_COUNTER_SLOTS", str(_EXACT_COUNTER_SLOTS))
assert envs.MERLIN_TOPK_EXACT_COUNTERS_LOG == 1
+ # The build now exposes the slot count, so the env fallback above is
+ # dead on this branch -- assert the build is what answers, because a
+ # reader that guessed a stale length would be rejected by the op.
+ assert topk_counters.slot_count() == _EXACT_COUNTER_SLOTS, (
+ "the build and the test disagree about the counter layout"
+ )
counters = topk_counters.read()
+ # The dict is in SLOT order, which is NOT the line order: the line puts the
+ # five medium-path fields first (see `_LINE_ORDER`), the array puts the large path's
+ # large-path block at 4-7 and the two switch flags at 8-9. Asserting the
+ # first five dict keys against the medium-path names is what fails on any build
+ # carrying the large-path block, so assert the whole layout instead.
assert list(counters) == [
"rows",
"overflow",
"fallbacks",
"faults",
+ "rows_large",
+ "ties_cut",
+ "ties_dropped",
+ "faults_large",
"switch",
- ], f"unexpected slot names: {list(counters)}"
+ "switch_large",
+ "decode_overflow",
+ ], f"unexpected slot layout: {list(counters)}"
assert counters == dict(zip(counters, direct, strict=True)), (
"the reader disagrees with a direct op call"
)
line = topk_counters.log_line("PP0_TP0")
+ # The documented medium-path prefix is asserted verbatim -- the smoke keys on it
+ # -- and any later slot may follow as `name=<int>`.
assert re.fullmatch(
r"topk-exact-counters rank=PP0_TP0 rows=\d+ overflow=\d+ "
- r"fallbacks=\d+ faults=\d+ switch=\d+",
+ r"fallbacks=\d+ faults=\d+ switch=\d+(?: [a-z_0-9]+=\d+)*",
line,
), f"log line does not match the documented format: {line!r}"
+ assert " decode_overflow=" in line, (
+ f"the decode-overflow counter is not in the line: {line!r}"
+ )
+ assert " switch_large=" in line, (
+ f"the large-path switch flag is not in the line: {line!r}"
+ )
# The cadence gate must also be able to emit -- a reader that never fires
# from maybe_log() is the same defect one level up.
diff --git a/tests/test_envs.py b/tests/test_envs.py
index 891954b..4e8513e 100644
--- a/tests/test_envs.py
+++ b/tests/test_envs.py
@@ -2,6 +2,7 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import os
+import re
from pathlib import Path
from unittest.mock import patch
@@ -603,6 +604,8 @@ class TestVllmMaxNSequences:
# rests on not changing the value between capture and replay in one process.
# These tests check the AOT side, which is the side this file can check.
#
+# The determinism toggles, and the gate that keeps unaudited knobs out.
+#
# Each row is (name, value to set, value the getter must then return). The
# expected value matters: a getter that ignored the environment would still
# satisfy a bare membership check, so every row proves the entry is live.
@@ -646,17 +649,35 @@ _FORK_IGNORED = [
("VLLM_E2_MAMBA_SLOT_OWNERSHIP", " RAISE ", "raise"),
]
-# Not in this repository at all: a reference-host-side patch stack. Named in
-# the TODO block in envs.py and deliberately unregistered until someone reads
-# their real read sites and classifies both identities.
-_FORK_UNAUDITED = [
- "MERLIN_FULL_K",
- "MERLIN_QSA_SORT",
- "MERLIN_TIE_DET",
- "MERLIN_TIE_RECENT",
- "MERLIN_PIN_BSM",
+# Marker fence around the unaudited-knob block in envs.py. The gate below scans
+# BETWEEN these, never the whole file -- see its docstring for why.
+_UNAUDITED_START = "# --8<-- [start:unaudited-host-side-knobs]"
+_UNAUDITED_END = "# --8<-- [end:unaudited-host-side-knobs]"
+
+# All four are compile/capture factors and must be hashed. Three are raw
+# passthroughs of a value that C++ parses; MERLIN_QSA_SORT is parsed here.
+_TIEDET_HASHED = [
+ ("MERLIN_FULL_K", "1", "1"),
+ ("MERLIN_TIE_DET", "1", "1"),
+ ("MERLIN_TIE_RECENT", "1", "1"),
+ ("MERLIN_QSA_SORT", "1", True),
]
+# The persistent_topk kernel switches. Raw passthroughs
+# of values C++ parses, hashed on the same conservative rule as the four above.
+# They are NOT in _TIEDET_HASHED because "unset reads falsy" says nothing useful
+# about them -- MERLIN_TOPK_EXACT_MEDIUM's C++ default is ON, so asserting it
+# under test_tiedet_toggle_defaults_off would be a test that cannot fail in the
+# direction that matters.
+_TIEDET_KERNEL_SWITCHES = [
+ ("MERLIN_TOPK_EXACT_MEDIUM", "0", "0"),
+ ("MERLIN_TOPK_EXACT_DECODE", "1", "1"),
+ ("MERLIN_TOPK_DET_LARGE", "1", "1"),
+]
+
+# Named in the envs.py fenced block, deliberately NOT registered.
+_TIEDET_UNAUDITED = ["MERLIN_PIN_BSM"]
+
def _fresh_env_value(name: str):
if hasattr(envs.__getattr__, "cache_clear"):
@@ -678,6 +699,43 @@ def test_fork_knob_is_a_cache_factor(
assert factors[name] == expected
+def _unaudited_names(source: str) -> set[str]:
+ """The knob names listed between the two fence markers.
+
+ Identifiers, not a substring scan: `"MERLIN_FULL_K" in block` is true for a
+ fence whose prose merely mentions the name, which is the same class of
+ defect one level down. Raises if the fence is missing or inverted, so
+ deleting a marker fails the gate instead of silently emptying it.
+ """
+ start = source.find(_UNAUDITED_START)
+ end = source.find(_UNAUDITED_END)
+ assert start != -1, f"missing marker {_UNAUDITED_START!r} in envs.py"
+ assert end != -1, f"missing marker {_UNAUDITED_END!r} in envs.py"
+ assert start < end, "unaudited-knob markers are inverted in envs.py"
+ block = source[start + len(_UNAUDITED_START) : end]
+ return set(re.findall(r"\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b", block))
+
+
+def _envs_source() -> str:
+ return Path(envs.__file__).read_text(encoding="utf-8")
+
+
+@pytest.mark.parametrize(
+ ("name", "value", "expected"), _TIEDET_HASHED + _TIEDET_KERNEL_SWITCHES
+)
+def test_tiedet_toggle_is_a_cache_factor(
+ name: str, value: str, expected, monkeypatch: pytest.MonkeyPatch
+):
+ """A knob that can change a compiled or captured artifact must be hashed,
+ and the hashed value must follow the environment."""
+ assert name in environment_variables
+ monkeypatch.setenv(name, value)
+ assert _fresh_env_value(name) == expected
+ factors = envs.compile_factors()
+ assert name in factors
+ assert factors[name] == expected
+
+
@pytest.mark.parametrize(("name", "value", "expected"), _FORK_IGNORED)
def test_fork_runtime_knob_is_not_a_cache_factor(
name: str, value: str, expected, monkeypatch: pytest.MonkeyPatch
@@ -691,41 +749,128 @@ def test_fork_runtime_knob_is_not_a_cache_factor(
assert name not in envs.compile_factors()
-@pytest.mark.parametrize("name", _FORK_UNAUDITED)
+@pytest.mark.parametrize(("name", "value", "expected"), _TIEDET_HASHED)
+def test_tiedet_toggle_defaults_off(
+ name: str, value: str, expected, monkeypatch: pytest.MonkeyPatch
+):
+ """Promotion is a release decision. Unset, every toggle must read as off."""
+ monkeypatch.delenv(name, raising=False)
+ assert not _fresh_env_value(name)
+
+
+@pytest.mark.parametrize(
+ "name",
+ [n for n, _, _ in _TIEDET_HASHED + _TIEDET_KERNEL_SWITCHES] + _TIEDET_UNAUDITED,
+)
def test_unaudited_host_side_knob_stays_listed_until_it_is_classified(name: str):
- """The TODO block in envs.py is a gate, not a note.
+ """The fenced block in envs.py is a gate, not a note.
Registering one of these without classifying it -- which means deleting it
from that block once its AOT and capture identity are written down against
its real read site -- is the exact failure the fork's env rule
exists to prevent, so it fails here.
+
+ The scan is scoped to the fenced block, not to the whole file. Scanning the
+ whole file is the defect of an earlier version: a legitimately
+ registered knob NECESSARILY appears in envs.py -- as its declaration and as
+ its `environment_variables` key -- so `name in source` is True for every
+ registered knob and the `assert not listed` branch could never pass. The
+ gate could only ever be satisfied by leaving the knob unregistered, which
+ inverts what it was written to enforce.
"""
- source = Path(envs.__file__).read_text(encoding="utf-8")
- # Scan ONLY the delimited block. Scanning the whole file can never pass
- # once a knob is legitimately registered, because the registry entry
- # contains the knob's own name -- the gate would block the very act it
- # exists to sequence.
- start = source.index("# --8<-- [start:fork-unaudited-knobs]")
- end = source.index("# --8<-- [end:fork-unaudited-knobs]")
- assert start < end, "the unaudited-knob block markers are inverted"
- block = source[start:end]
- listed = name in block
+ listed = name in _unaudited_names(_envs_source())
if name in environment_variables:
assert not listed, (
- f"{name} is registered but still named in the envs.py TODO block: "
- "classify its AOT and capture identity from its real read site and "
- "remove it from that block"
+ f"{name} is registered but still named in the envs.py unaudited "
+ "block: classify its AOT and capture identity from its real read "
+ "site and remove it from that block"
)
else:
assert listed, (
- f"{name} is neither registered nor named in the envs.py TODO block; "
- "every fork knob must be accounted for"
+ f"{name} is neither registered nor named in the envs.py unaudited "
+ "block; every fork knob must be accounted for"
)
+def test_unaudited_gate_discriminates_both_worlds():
+ """Control for the gate above: it must be able to FAIL.
+
+ A membership test that reports the same thing in the registered and the
+ unregistered world measures nothing -- which is precisely how the previous
+ whole-file version passed unnoticed. So assert the scan reports differently on
+ a source that lists a knob inside the fence and one that names it only
+ outside, and assert the real envs.py is in the state the gate claims.
+ """
+ listed = "\n".join(
+ [_UNAUDITED_START, " # MERLIN_EXAMPLE", _UNAUDITED_END, ""]
+ )
+ registered = "\n".join(
+ [
+ " # MERLIN_EXAMPLE was classified and removed from the fence.",
+ _UNAUDITED_START,
+ _UNAUDITED_END,
+ " MERLIN_EXAMPLE: str | None = None",
+ ' "MERLIN_EXAMPLE": lambda: os.getenv("MERLIN_EXAMPLE", None),',
+ "",
+ ]
+ )
+ assert "MERLIN_EXAMPLE" in _unaudited_names(listed)
+ assert "MERLIN_EXAMPLE" not in _unaudited_names(registered)
+ # The whole-file scan the defective version used cannot tell them apart,
+ # and neither can a substring scan of the fence.
+ assert "MERLIN_EXAMPLE" in listed and "MERLIN_EXAMPLE" in registered
+
+ # And the live file really is in both states at once, one knob each way.
+ names = _unaudited_names(_envs_source())
+ assert "MERLIN_FULL_K" in _envs_source()
+ assert "MERLIN_FULL_K" not in names
+ assert "MERLIN_PIN_BSM" in names
+
+ # A missing fence must fail loudly rather than scan nothing.
+ with pytest.raises(AssertionError):
+ _unaudited_names("no markers here")
+
+
+def test_exact_trap_is_a_build_define_not_an_env_var():
+ """MERLIN_TOPK_EXACT_TRAP must stay unregistered, for a stated reason.
+
+ The kernel switches must be registered. This
+ one is not a switch: persistent_topk.cuh consumes it with `#ifndef` at
+ build time and nothing anywhere calls getenv on it, so an envs.py entry
+ would advertise a runtime knob that does not exist. Pin both halves --
+ absent from the registry, and present in the header as a preprocessor
+ define -- so a later "tidy-up" that registers it has to argue with this.
+ """
+ assert "MERLIN_TOPK_EXACT_TRAP" not in environment_variables
+ header = (
+ Path(envs.__file__).parents[1]
+ / "csrc"
+ / "libtorch_stable"
+ / "persistent_topk.cuh"
+ )
+ if not header.is_file(): # source tree not beside an installed wheel
+ return
+ text = header.read_text(encoding="utf-8")
+ assert "#ifndef MERLIN_TOPK_EXACT_TRAP" in text
+ assert "getenv(\"MERLIN_TOPK_EXACT_TRAP\"" not in text
+
+
+def test_tiedet_toggles_are_not_ignored_factors():
+ """Negative control for the table above: a toggle silently added to
+ ignored_factors would keep every test green while dropping it from the
+ cache key."""
+ monkeypatch_free_factors = envs.compile_factors()
+ for name, _, _ in _TIEDET_HASHED + _TIEDET_KERNEL_SWITCHES:
+ assert name in environment_variables, name
+ assert name in monkeypatch_free_factors, name
+ for name in _TIEDET_UNAUDITED:
+ assert name not in environment_variables, name
+ assert name not in monkeypatch_free_factors, name
+
+
def test_fork_cache_factor_lists_are_disjoint_and_registered():
"""Negative control for the two tables above."""
- hashed = {n for n, _, _ in _FORK_HASHED}
+ hashed = {n for n, _, _ in _FORK_HASHED} | {n for n, _, _ in _TIEDET_HASHED}
ignored = {n for n, _, _ in _FORK_IGNORED}
assert not hashed & ignored
for name in hashed | ignored:
diff --git a/vllm/envs.py b/vllm/envs.py
index 5923dc5..960d92b 100755
--- a/vllm/envs.py
+++ b/vllm/envs.py
@@ -455,32 +455,12 @@ if TYPE_CHECKING:
# an identity, it does not choose one. Ignored factor.
VLLM_E2_EXPECTED_CUDAGRAPH_MODE: str = ""
#
- # --8<-- [start:fork-unaudited-knobs]
- # --- TODO: knobs that are NOT in this tree at all ----------
- # The tie-determinism toggles and the BSM pin live in a separate
- # patch stack, not in v2.0.1 and not in this tree:
- #
- # MERLIN_FULL_K MERLIN_QSA_SORT MERLIN_TIE_DET MERLIN_TIE_RECENT
- # MERLIN_PIN_BSM
- #
- # They are therefore UNAUDITED: nothing in this repository shows what they
- # change, and this file will not classify code it cannot read. The rule they
- # fall under is the standing one -- an os.environ (or std::getenv) knob that
- # changes a compiled or captured artifact is not in the cache key unless it
- # is registered here. From what is known of them (Marlin split-K, a
- # persistent_topk ordering change and a cutoff tie-break) they are very
- # likely capture factors and possibly AOT factors, which is exactly why a
- # guess is not good enough.
- #
- # WHEN THE PATCHES LAND, for each name: register it below, label both
- # identities from its actual read site, hash it unless exclusion is proven,
- # and delete it from this block. tests/test_envs.py holds a test that fails
- # if a name is registered while still listed here, so the classification
- # cannot be skipped silently. That test reads ONLY the delimited block
- # below/above -- scanning the whole file could never pass, because a
- # registry entry necessarily contains its own name.
- # ----------------------------------------------------------------------
- # --8<-- [end:fork-unaudited-knobs]
+ # The unaudited-knob fence that stood here listed the
+ # tie-determinism toggles as not-yet-in-tree. They landed with the
+ # v2.2 kernel port: the four are registered and
+ # classified in the "determinism toggles" block below, and the
+ # one knob still unclassified sits in that block's own fence, which
+ # tests/test_envs.py reads as a set of identifiers.
VLLM_LOG_MODEL_INSPECTION: bool = False
VLLM_DEBUG_MFU_METRICS: bool = False
VLLM_WEIGHT_OFFLOADING_DISABLE_PIN_MEMORY: bool = False
@@ -507,6 +487,112 @@ if TYPE_CHECKING:
VLLM_NIC_SELECTION_VARS: str = ""
VLLM_ENABLE_HPC_OPS: bool = False
MERLIN_TOPK_EXACT_COUNTERS_LOG: int = 0
+ #
+ # --- determinism toggles -----------------------------------------
+ # The three nondeterminism mechanisms (FULL_K, QSA_SORT, TIE_DET/TIE_RECENT).
+ # Every one of them is
+ # OFF by default: promotion is the maintainer's decision.
+ #
+ # Why these are registered at all: validate_environ() only inspects names
+ # beginning with VLLM_, so a MERLIN_ name never trips the unknown-variable
+ # warning. Registration here exists for ONE reason -- compile_factors() can
+ # only hash names it knows, and compile_factors() is a deny-list, so
+ # registering a name hashes it unless ignored_factors says otherwise. All
+ # four are deliberately absent from ignored_factors.
+ #
+ # CAPTURE: yes, all four. Each resolves once per process and the resolved
+ # value then reaches the GPU as a kernel launch argument, a launch
+ # configuration, or an extra op in the traced sequence -- so a cudagraph
+ # bakes in whatever was live at capture time. Flipping one between boots
+ # changes the captured graph, which is exactly what a cache key is for.
+ # AOT: not established for the three CUDA toggles (the Marlin and topk
+ # kernels are compiled into the extension at build time, not by the AOT
+ # compiler); established for MERLIN_QSA_SORT, which adds torch ops to the
+ # traced graph. Hashed conservatively throughout, the same rule
+ # already applied to MERLIN_TOPK_EXACT_MEDIUM.
+ #
+ # NOT A PYTHON READ SITE (FULL_K / TIE_DET / TIE_RECENT): each is resolved
+ # once by a std::getenv in C++ -- merlin_full_k() in
+ # csrc/libtorch_stable/moe/marlin_moe_wna16/ops.cu and merlin_det_ties() /
+ # merlin_tie_recent() in csrc/libtorch_stable/topk.cu -- and that is the
+ # single source of truth. The entries below are raw passthroughs so nothing
+ # can mistake them for a second parser; do not use them to decide
+ # behaviour. A raw passthrough hashes "0" and "00" differently even though
+ # the C++ treats them alike: over-invalidation, which is the safe direction.
+ #
+ # Marlin MoE split-K reduction order; the CUDA half of unmerged upstream
+ # PR #46639 (`use_full_k`).
+ MERLIN_FULL_K: str | None = None
+ # persistent_topk tie-break at the k-th cutoff, and its direction.
+ MERLIN_TIE_DET: str | None = None
+ MERLIN_TIE_RECENT: str | None = None
+ #
+ # A REAL Python read site, unlike the three above: parsed here and read
+ # once at import by models/qwen4_exp/nvidia/ops/qsa_indexer.py, which adds
+ # a torch.sort + two torch.where ops to the QSA selection path.
+ MERLIN_QSA_SORT: bool = False
+ #
+ # --- persistent_topk kernel switches ------------------------------
+ # Same classification as the three raw passthroughs above and registered
+ # for the same single reason: compile_factors() is a deny-list, so a name
+ # it does not know cannot be hashed, and a Ray deployment that propagates
+ # registered variables can otherwise drop an explicitly-disabled switch
+ # and leave a worker on the default. Each is resolved ONCE per process by
+ # a std::getenv in csrc/libtorch_stable/topk.cu -- that is the single
+ # source of truth -- and the resolved value reaches the GPU as a kernel
+ # launch argument, so a cudagraph bakes in whatever was live at capture.
+ # CAPTURE: yes, all three. AOT: not established (these kernels are built
+ # into the extension, not by the AOT compiler); hashed conservatively
+ # anyway, same rule as above. None is in ignored_factors.
+ #
+ # Raw passthroughs, so "unset" reads as None here. That is NOT the same as
+ # "off" for the C++: MERLIN_TOPK_EXACT_MEDIUM defaults ON. Read the getter
+ # in topk.cu, not this default, for the shipped behaviour.
+ #
+ # Take the exact restart on an overflowing MEDIUM row:
+ # MERLIN_TOPK_EXACT_MEDIUM. Declared ONCE, in the cross-change knob
+ # block above (same raw passthrough, same hashing); the 0.30 tree already
+ # carried that entry, so it is not repeated here.
+ # The same restart on an overflowing DECODE row.
+ MERLIN_TOPK_EXACT_DECODE: str | None = None
+ # Deterministic placement and tie membership on the LARGE path.
+ # Registered here ahead of its code, exactly as the four toggles above
+ # were, so the full tree is complete; its read site is
+ # det_large_enabled() in topk.cu on the large path. Setting it without that
+ # code does nothing, which is why it is a passthrough and not a parsed
+ # bool that would imply this tree can act on it.
+ MERLIN_TOPK_DET_LARGE: str | None = None
+ #
+ # NOT REGISTERED, deliberately: MERLIN_TOPK_EXACT_TRAP. It is a
+ # preprocessor define consumed by `#ifndef` in persistent_topk.cuh at
+ # BUILD time (-D), not a process environment variable -- nothing in the
+ # tree calls getenv on it. Registering it would advertise a runtime knob
+ # that does not exist. Its default (trap ON) is an open maintainer decision,
+ # not something to settle by
+ # quietly making it settable.
+ #
+ # --- UNAUDITED: reference-host knobs with no read site in this tree ----------
+ # A knob named inside the fence below is one this repository cannot
+ # classify, because the code that reads it is not in it. Registering one
+ # without first deleting it from the fence -- which means writing down its
+ # AOT and capture identity against its real read site -- is the exact
+ # failure the fork's env rule exists to prevent, and
+ # tests/test_envs.py::test_unaudited_host_side_knob_stays_listed_until_it_is_classified
+ # fails on it.
+ #
+ # The fence holds NOTHING BUT the names, one per line. Prose belongs out
+ # here: the gate reads the fence as a set of identifiers, and a knob name
+ # mentioned in passing inside it would otherwise read as a listing.
+ #
+ # The one entry, MERLIN_PIN_BSM, pins Marlin's block_size_m to 64 (Python,
+ # no rebuild). It is an ALTERNATIVE to the split-K toggle above, not a
+ # complement: on its own it fixed only the 74/102-token cells and left
+ # 1050 upward broken, while use_full_k fixed all of them with block_size_m
+ # still varying. The tie-determinism change promotes use_full_k and keeps this one off, so its
+ # read site was never brought across and it stays unclassified.
+ # --8<-- [start:unaudited-host-side-knobs]
+ # MERLIN_PIN_BSM
+ # --8<-- [end:unaudited-host-side-knobs]
def get_default_cache_root():
@@ -2467,6 +2553,19 @@ environment_variables: dict[str, Callable[[], Any]] = {
"MERLIN_TOPK_EXACT_COUNTERS_LOG": lambda: int(
os.getenv("MERLIN_TOPK_EXACT_COUNTERS_LOG", "0")
),
+ # Determinism toggles. See the declaration block above for the AOT /
+ # capture classification and for why three of these are raw passthroughs.
+ # All four are absent from ignored_factors on purpose, so all four are
+ # hashed into the compile cache key.
+ "MERLIN_FULL_K": lambda: os.getenv("MERLIN_FULL_K", None),
+ "MERLIN_TIE_DET": lambda: os.getenv("MERLIN_TIE_DET", None),
+ "MERLIN_TIE_RECENT": lambda: os.getenv("MERLIN_TIE_RECENT", None),
+ "MERLIN_QSA_SORT": lambda: bool(int(os.getenv("MERLIN_QSA_SORT", "0"))),
+ # persistent_topk kernel switches. Raw passthroughs
+ # of values C++ parses; see the declaration block above.
+ # MERLIN_TOPK_EXACT_MEDIUM is registered once, earlier in this dict.
+ "MERLIN_TOPK_EXACT_DECODE": lambda: os.getenv("MERLIN_TOPK_EXACT_DECODE", None),
+ "MERLIN_TOPK_DET_LARGE": lambda: os.getenv("MERLIN_TOPK_DET_LARGE", None),
}
diff --git a/vllm/models/qwen4_exp/nvidia/ops/qsa_indexer.py b/vllm/models/qwen4_exp/nvidia/ops/qsa_indexer.py
index a46f915..555e445 100644
--- a/vllm/models/qwen4_exp/nvidia/ops/qsa_indexer.py
+++ b/vllm/models/qwen4_exp/nvidia/ops/qsa_indexer.py
@@ -23,6 +23,46 @@ _TOPK_WORKSPACE_BYTES = 1024 * 1024
_DECODE_BLOCK_N = 64
+# ---------------------------------------------------------------------------
+# MERLIN_QSA_SORT: canonicalise the ORDER of the selected block indices.
+#
+# persistent_topk assigns output slots by atomicAdd, so two identical requests
+# get the same selected SET in a different ORDER (measured on the reference host: matching
+# sorted hashes, differing raw hashes). Sparse attention then accumulates over
+# those blocks in whatever order it was handed, and fp32 addition is not
+# associative, so the arithmetic moves. Sorting is semantically a no-op -- the
+# same blocks are attended either way -- and makes the accumulation order
+# canonical.
+#
+# Padding (negative sentinels) is pushed to the END so the valid prefix is
+# preserved: `_expand_qsa_indices_kernel` truncates by POSITION
+# (`complete_blocks = min(visible_blocks, BLOCK_TOPK)`), and position is never
+# compared against a score anywhere in the chain, so there is no relevance-rank
+# contract for the sort to break.
+#
+# Branch-free and sync-free on purpose. `.any()` / `.item()` raise
+# cudaErrorStreamCaptureUnsupported inside graph capture; that cost one boot
+# on the reference host and is why this version has no Python-visible branch on data.
+#
+# Resolved once at import so it is a launch constant: a cudagraph captures the
+# op sequence that was live at capture time. Default OFF -- promotion is a release
+# decision, which is the maintainer's.
+# ---------------------------------------------------------------------------
+_QSA_SORT = envs.MERLIN_QSA_SORT
+
+
+def _qsa_canonicalise(block_indices: torch.Tensor) -> None:
+ """Sort selected block indices ascending, padding last. In place, shape preserved."""
+ if not _QSA_SORT:
+ return
+ big = torch.iinfo(block_indices.dtype).max
+ tmp = torch.where(
+ block_indices < 0, torch.full_like(block_indices, big), block_indices
+ )
+ tmp = torch.sort(tmp, dim=-1).values
+ block_indices.copy_(torch.where(tmp == big, torch.full_like(tmp, -1), tmp))
+
+
@triton.jit
def _qsa_mqa_paged_uniform_kernel(
q_ptr,
@@ -565,6 +605,7 @@ def _topk(
block_topk,
logits.shape[1],
)
+ _qsa_canonicalise(block_indices)
def qsa_select_paged_decode(
diff --git a/vllm/models/qwen4_exp/nvidia/ops/topk_counters.py b/vllm/models/qwen4_exp/nvidia/ops/topk_counters.py
index 08ffab8..d7db4d7 100644
--- a/vllm/models/qwen4_exp/nvidia/ops/topk_counters.py
+++ b/vllm/models/qwen4_exp/nvidia/ops/topk_counters.py
@@ -17,22 +17,40 @@ of each counter. An older build whose op still demands int32 will therefore
raise here; that is the intended signal, not a regression to paper over.
SLOT LAYOUT is build-dependent, so nothing here hard-codes five. `slot_count()`
-asks the build first (a `..._slots` op, should a later build expose one) and
-otherwise reads `MERLIN_TOPK_COUNTER_SLOTS` (default 5); names come from a
-table keyed by that length:
+asks the build first -- the tie-determinism change added the `..._slots` op this was written to look
+for, so on a build that has it the env variable is never consulted -- and
+otherwise reads `MERLIN_TOPK_COUNTER_SLOTS` (default 5). The op demands
+EXACTLY that many elements, so a guessed length is an error, not a short read;
+names come from a table keyed by that length:
5 rows overflow fallbacks faults switch
10 rows overflow fallbacks faults rows_large ties_cut ties_dropped
faults_large switch switch_large
+ 11 the ten above, plus decode_overflow
An unknown length degrades to `slot<i>` names rather than mislabelling.
+WHY 11 AND NOT 10. The tie-determinism change numbered its one new counter around the large-path block
+instead of after the medium-path one, so the integration renumbers nothing: slots 4-7 are
+the large-path counters, 8-9 the two switch flags, 10 the decode overflow.
+On the integrated build every one of the eleven is live; on a single-branch
+build the other branch's slots are structurally zero and the line still
+reports them by name -- they are the slots they will hold, not a
+mislabelling. `decode_overflow` is the number that has to be read before any
+claim that the DECODE path is deterministic: it counts rows whose buffered
+candidates exceeded the 3,708-slot capacity before the tie ranking ran, which
+ranking cannot repair.
+
HOW THE SMOKE READS IT. Set `MERLIN_TOPK_EXACT_COUNTERS_LOG=<seconds>` on the
engine; each GPU worker then emits, at most that often and once more at
shutdown, one line into the ordinary engine log:
topk-exact-counters rank=PP0_TP1 rows=... overflow=... fallbacks=...
- faults=... switch=...
+ faults=... switch=... [rows_large=... ... decode_overflow=...]
+
+The first five fields are the medium-path line and keep their order on every build;
+any further slot is appended after them, so an 11-slot build reads as the medium-path
+line plus the four large-path fields, `switch_large` and `decode_overflow`.
Counts are cumulative per worker process, so the smoke takes the LATEST line
per rank -- `grep topk-exact-counters <engine log> | ...` keyed on the `rank=`
@@ -73,7 +91,14 @@ _LARGEDET_NAMES = (
"switch",
"switch_large",
)
-_SLOT_NAMES: dict[int, tuple[str, ...]] = {5: _MEDIUM_NAMES, 10: _LARGEDET_NAMES}
+# The decode-overflow counter comes after the large-path block; see the module
+# docstring for why it is slot 10 and not slot 4.
+_TIEDET_NAMES = _LARGEDET_NAMES + ("decode_overflow",)
+_SLOT_NAMES: dict[int, tuple[str, ...]] = {
+ 5: _MEDIUM_NAMES,
+ 10: _LARGEDET_NAMES,
+ 11: _TIEDET_NAMES,
+}
# The medium-path line contract. Any further name is appended after these, in slot
# order, so a 10-slot build still starts with exactly the medium-path line.
@@ -92,7 +117,13 @@ def _get_op(name: str):
def slot_count() -> int:
- """How many int64 slots this build's counter array carries."""
+ """How many int64 slots this build's counter array carries.
+
+ The build is asked first and the environment is only a fallback for builds
+ older than the slot-count op: a guessed length that does not match the extension is
+ rejected by the op's own length check, so the instrument would go dark
+ exactly when the layout changed -- which is when it matters most.
+ """
query = _get_op(_OP_NAME + "_slots")
if query is not None:
return int(query())
--
2.43.0