789 lines
37 KiB
Diff
789 lines
37 KiB
Diff
From 5ff40ff3254e92df316c73efa58bba20a12a7d35 Mon Sep 17 00:00:00 2001
|
|
From: halt95 <halt95@users.noreply.github.com>
|
|
Date: Wed, 23 Sep 2026 08:31:18 -0400
|
|
Subject: [PATCH 042/103] topk: deterministic placement and tie membership for
|
|
the large-path radix_topk
|
|
|
|
Above RADIX_THRESHOLD a row is split across a group of CTAs. The pivot was
|
|
already order-independent; cross-CTA placement, intra-CTA placement and tie
|
|
membership were arrival-order races. They are now deterministic behind
|
|
MERLIN_TOPK_DET_LARGE (default on).
|
|
---
|
|
csrc/libtorch_stable/persistent_topk.cuh | 326 ++++++++++++++++++++++-
|
|
csrc/libtorch_stable/topk.cu | 68 ++++-
|
|
tests/kernels/test_top_k_per_row.py | 238 ++++++++++++++++-
|
|
3 files changed, 617 insertions(+), 15 deletions(-)
|
|
|
|
diff --git a/csrc/libtorch_stable/persistent_topk.cuh b/csrc/libtorch_stable/persistent_topk.cuh
|
|
index c5d27d9..8dd3f7a 100644
|
|
--- a/csrc/libtorch_stable/persistent_topk.cuh
|
|
+++ b/csrc/libtorch_stable/persistent_topk.cuh
|
|
@@ -147,6 +147,11 @@ struct PersistentTopKParams {
|
|
// nullptr; the kernel only ever atomicAdds into it, never reads back.
|
|
bool exact_medium;
|
|
unsigned int* exact_counters;
|
|
+ // 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.
|
|
+ bool det_large;
|
|
};
|
|
|
|
// ============================================================================
|
|
@@ -468,7 +473,16 @@ enum TopKExactCounter : int {
|
|
kCtrMediumOverflow = 1, // rows where a 4,096-slot guard would drop
|
|
kCtrMediumExactFallback = 2, // rows that took the exact restart
|
|
kCtrMediumExactFault = 3, // exact restart could not complete
|
|
- kNumTopKExactCounters = 4,
|
|
+ // Large path (multi-CTA radix_topk, seq_len > RADIX_THRESHOLD).
|
|
+ // Slots 4-7 are only ever touched by the deterministic emission, so they
|
|
+ // stay 0 in the MERLIN_TOPK_DET_LARGE=0 control arm: `[9] == 1 && [4] == 0`
|
|
+ // while large rows are known to have run means the host-side capacity gate
|
|
+ // (2 * ctas_per_group <= RADIX) turned the switch off for that launch.
|
|
+ kCtrLargeDetRows = 4, // large rows emitted deterministically
|
|
+ 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,
|
|
};
|
|
|
|
// Exact, bounded-memory re-selection for a single medium-path row.
|
|
@@ -1074,6 +1088,300 @@ __device__ __forceinline__ void wait_ge(int* ptr, int target_val,
|
|
// coordinated radix select via global-memory histograms and barriers.
|
|
// ============================================================================
|
|
|
|
+// ============================================================================
|
|
+// Deterministic placement and tie membership for the large path
|
|
+//
|
|
+// `radix_topk`'s pivot is already an exact, order-independent function of the
|
|
+// row: every quantity feeding it is an integer histogram add and a
|
|
+// single-threaded descending bin walk. Three things after the
|
|
+// pivot are not:
|
|
+//
|
|
+// (a) cross-CTA placement — a CTA's base offset into `row_output` is its
|
|
+// arrival order at `state->output_counter`;
|
|
+// (b) intra-CTA placement — a slot inside that base is arrival order at a
|
|
+// shared counter;
|
|
+// (c) cross-CTA tie membership — which pivot-equal elements survive the
|
|
+// `pos < TopK` cut is a race across CTAs.
|
|
+//
|
|
+// (a) and (b) change only the permutation of the selected list, but sparse
|
|
+// attention consumes that list as an ordered gather, so a permutation forks
|
|
+// the hidden state. (c) changes the set. All three are replaced here by a
|
|
+// rank derived from (cta_in_group, element index):
|
|
+//
|
|
+// * each CTA counts its own `> pivot` and `== pivot` elements out of the
|
|
+// chunk already resident in shared memory (no new global reads);
|
|
+// * the counts are published into `RadixRowState::histogram[radix_iter % 3]`
|
|
+// — the slot round 3 has just finished reading — so the workspace
|
|
+// contract is unchanged;
|
|
+// * 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
|
|
+// (`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;
|
|
+// * 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.
|
|
+//
|
|
+// Cost per row: one extra inter-CTA barrier, two extra ballots and one
|
|
+// 32-lane shuffle scan per 1,024-element tile, 2 global stores and
|
|
+// 2*ctas_per_group global loads. No second pass over the row in global
|
|
+// memory: the pivot-equal count that the tie cut needs is produced by the same
|
|
+// shared-memory pass v2.0.1 already runs to count `> pivot`.
|
|
+// ============================================================================
|
|
+template <int TopK>
|
|
+__device__ __noinline__ void radix_emit_deterministic(
|
|
+ int32_t* __restrict__ row_output, uint32_t ordered_pivot,
|
|
+ uint32_t equal_needed, uint32_t my_chunk_start, uint32_t actual_chunk_size,
|
|
+ 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 int* counters) {
|
|
+ constexpr uint32_t kWarpsPerBlock = kThreadsPerBlock / 32u;
|
|
+ // Staging for the published per-CTA counts. The host gates the switch on
|
|
+ // 2 * ctas_per_group <= RADIX, so these two halves never collide.
|
|
+ constexpr uint32_t kEqStage = RADIX / 2u;
|
|
+
|
|
+ // ---- per-CTA counts, out of the resident chunk -------------------------
|
|
+ if (tx < 2u) suffix_sum[tx] = 0u;
|
|
+ __syncthreads();
|
|
+
|
|
+ uint32_t my_gt = 0u;
|
|
+ uint32_t my_eq = 0u;
|
|
+ for (uint32_t i = tx; i < actual_chunk_size; i += kThreadsPerBlock) {
|
|
+ const uint32_t ord = shared_ordered[i];
|
|
+ if (ord > ordered_pivot) {
|
|
+ my_gt++;
|
|
+ } else if (ord == ordered_pivot) {
|
|
+ my_eq++;
|
|
+ }
|
|
+ }
|
|
+#pragma unroll
|
|
+ for (int off = 16; off > 0; off >>= 1) {
|
|
+ my_gt += __shfl_down_sync(0xffffffffu, my_gt, off);
|
|
+ my_eq += __shfl_down_sync(0xffffffffu, my_eq, off);
|
|
+ }
|
|
+ if ((tx & 31u) == 0u) {
|
|
+ // Integer counts: the atomic's arrival order cannot change the sum, so
|
|
+ // this is a reduction, not a placement.
|
|
+ if (my_gt > 0u) atomicAdd(&suffix_sum[0], my_gt);
|
|
+ if (my_eq > 0u) atomicAdd(&suffix_sum[1], my_eq);
|
|
+ }
|
|
+ __syncthreads();
|
|
+ const uint32_t local_gt = suffix_sum[0];
|
|
+ const uint32_t local_eq = suffix_sum[1];
|
|
+
|
|
+ // ---- barrier X1: the histogram slot is free ----------------------------
|
|
+ // Round 3's `current_hist` is state->histogram[(radix_iter*4+3) % 3], i.e.
|
|
+ // histogram[radix_iter % 3] (4r+3 == r mod 3). Every CTA copies it into
|
|
+ // shared memory AFTER round 3's own barrier and there is no inter-CTA
|
|
+ // barrier between that copy and here, so a fast CTA could otherwise
|
|
+ // overwrite the slot while a slow CTA is still reading it. This barrier is
|
|
+ // the one the slot-reuse argument requires.
|
|
+ if (tx == 0) {
|
|
+ red_release(&state->arrival_counter, 1);
|
|
+ }
|
|
+ wait_ge(&state->arrival_counter,
|
|
+ (barrier_phase + 1) * static_cast<int>(ctas_per_group), tx);
|
|
+ barrier_phase++;
|
|
+ __syncthreads();
|
|
+
|
|
+ uint32_t* cta_counts = state->histogram[radix_iter % 3u];
|
|
+ if (tx == 0) {
|
|
+ cta_counts[cta_in_group] = local_gt;
|
|
+ cta_counts[ctas_per_group + cta_in_group] = local_eq;
|
|
+ }
|
|
+
|
|
+ // ---- barrier X2: the counts are published ------------------------------
|
|
+ // red_release issues `fence.acq_rel.gpu` before the arrival add and wait_ge
|
|
+ // reads with `ld.global.acquire.gpu`, so the two plain stores above are
|
|
+ // visible at device scope to every CTA that leaves this barrier. This is
|
|
+ // the same release/acquire pairing the shipped kernel already relies on for
|
|
+ // CTA-0's plain-store zeroing of `next_hist`.
|
|
+ if (tx == 0) {
|
|
+ red_release(&state->arrival_counter, 1);
|
|
+ }
|
|
+ wait_ge(&state->arrival_counter,
|
|
+ (barrier_phase + 1) * static_cast<int>(ctas_per_group), tx);
|
|
+ barrier_phase++;
|
|
+ __syncthreads();
|
|
+
|
|
+ // ---- bases and the tie cut ---------------------------------------------
|
|
+ for (uint32_t c = tx; c < ctas_per_group; c += kThreadsPerBlock) {
|
|
+ local_histogram[c] = cta_counts[c];
|
|
+ local_histogram[kEqStage + c] = cta_counts[ctas_per_group + c];
|
|
+ }
|
|
+ __syncthreads();
|
|
+
|
|
+ if (tx == 0) {
|
|
+ uint32_t gt_base = 0u;
|
|
+ uint32_t gt_total = 0u;
|
|
+ for (uint32_t c = 0; c < ctas_per_group; ++c) {
|
|
+ const uint32_t g = local_histogram[c];
|
|
+ if (c < cta_in_group) gt_base += g;
|
|
+ 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;
|
|
+ }
|
|
+ const uint32_t eq_total = eq_above;
|
|
+
|
|
+ uint32_t eq_keep_base = 0u;
|
|
+ for (uint32_t c = 0; c < cta_in_group; ++c) {
|
|
+ eq_keep_base += local_histogram[kEqStage + c];
|
|
+ }
|
|
+ const uint32_t my_keep = local_histogram[kEqStage + cta_in_group];
|
|
+
|
|
+ // Invariants, all three derived from the medium-path restart design and re-derivable
|
|
+ // here: the radix walk guarantees #{key > pivot} == TopK - equal_needed
|
|
+ // and equal_needed <= #{key == pivot}, and the two together make the
|
|
+ // emission a bijection onto [0, TopK). Every CTA evaluates this from the
|
|
+ // same published integers, so the verdict is group-uniform and needs no
|
|
+ // further communication.
|
|
+ uint32_t fault = 0u;
|
|
+ if (gt_total + equal_needed != static_cast<uint32_t>(TopK)) fault = 1u;
|
|
+ if (equal_needed > eq_total) fault = 1u;
|
|
+ if (my_keep > local_eq) fault = 1u;
|
|
+
|
|
+ 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] = local_eq - my_keep;
|
|
+ suffix_sum[5] = fault;
|
|
+
|
|
+ if (counters != nullptr && cta_in_group == 0u) {
|
|
+ atomicAdd(&counters[kCtrLargeDetRows], 1u);
|
|
+ if (eq_total > equal_needed) {
|
|
+ atomicAdd(&counters[kCtrLargeTiesCut], 1u);
|
|
+ atomicAdd(&counters[kCtrLargeTiesDropped], eq_total - equal_needed);
|
|
+ }
|
|
+ if (fault != 0u) atomicAdd(&counters[kCtrLargeFault], 1u);
|
|
+ }
|
|
+ }
|
|
+ __syncthreads();
|
|
+
|
|
+ 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 row_fault = suffix_sum[5];
|
|
+
|
|
+ // ---- emission: rank by index, no atomics -------------------------------
|
|
+ if (row_fault == 0u) {
|
|
+ // Scratch for the tile scan. The staged per-CTA counts above are dead by
|
|
+ // now (thread 0 consumed them before the barrier), so local_histogram is
|
|
+ // free again; 33 slots each at 0 and at 64 stay inside RADIX.
|
|
+ uint32_t* warp_gt = local_histogram;
|
|
+ uint32_t* warp_eq = local_histogram + 64;
|
|
+ const uint32_t lane = tx & 31u;
|
|
+ const uint32_t warp = tx >> 5;
|
|
+ uint32_t gt_run = 0u;
|
|
+ uint32_t eq_run = 0u;
|
|
+
|
|
+ // Block-uniform trip count: every thread runs every tile, so the ballots
|
|
+ // and the barriers inside are reached by the whole block.
|
|
+ for (uint32_t base = 0u; base < actual_chunk_size;
|
|
+ base += kThreadsPerBlock) {
|
|
+ const uint32_t i = base + tx;
|
|
+ const bool in_range = (i < actual_chunk_size);
|
|
+ const uint32_t ord = in_range ? shared_ordered[i] : 0u;
|
|
+ const bool f_gt = in_range && (ord > ordered_pivot);
|
|
+ const bool f_eq = in_range && (ord == ordered_pivot);
|
|
+
|
|
+ const uint32_t bgt = __ballot_sync(0xffffffffu, f_gt);
|
|
+ const uint32_t beq = __ballot_sync(0xffffffffu, f_eq);
|
|
+ const uint32_t lane_mask = (1u << lane) - 1u;
|
|
+ const uint32_t gt_in_warp = static_cast<uint32_t>(__popc(bgt & lane_mask));
|
|
+ const uint32_t eq_in_warp = static_cast<uint32_t>(__popc(beq & lane_mask));
|
|
+ if (lane == 0u) {
|
|
+ warp_gt[warp] = static_cast<uint32_t>(__popc(bgt));
|
|
+ warp_eq[warp] = static_cast<uint32_t>(__popc(beq));
|
|
+ }
|
|
+ __syncthreads();
|
|
+
|
|
+ if (tx < 32u) {
|
|
+ const uint32_t cg = (tx < kWarpsPerBlock) ? warp_gt[tx] : 0u;
|
|
+ const uint32_t ce = (tx < kWarpsPerBlock) ? warp_eq[tx] : 0u;
|
|
+ uint32_t sg = cg;
|
|
+ uint32_t se = ce;
|
|
+#pragma unroll
|
|
+ for (int off = 1; off < 32; off <<= 1) {
|
|
+ const uint32_t ng = __shfl_up_sync(0xffffffffu, sg, off);
|
|
+ const uint32_t ne = __shfl_up_sync(0xffffffffu, se, off);
|
|
+ if (tx >= static_cast<uint32_t>(off)) {
|
|
+ sg += ng;
|
|
+ se += ne;
|
|
+ }
|
|
+ }
|
|
+ warp_gt[tx] = sg - cg; // inclusive -> exclusive
|
|
+ warp_eq[tx] = se - ce;
|
|
+ if (tx == 31u) {
|
|
+ warp_gt[32] = sg; // tile totals (lanes >= kWarpsPerBlock add 0)
|
|
+ warp_eq[32] = se;
|
|
+ }
|
|
+ }
|
|
+ __syncthreads();
|
|
+
|
|
+ const uint32_t gt_rank = gt_run + warp_gt[warp] + gt_in_warp;
|
|
+ const uint32_t eq_rank = eq_run + warp_eq[warp] + eq_in_warp;
|
|
+ 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 =
|
|
+ (eq_rank > cut_rank) ? (eq_rank - cut_rank) : 0u;
|
|
+ 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);
|
|
+ } else if (counters != nullptr) {
|
|
+ // Unreachable while the three invariants above hold; bounded store
|
|
+ // plus a counter rather than an out-of-range write.
|
|
+ atomicAdd(&counters[kCtrLargeFault], 1u);
|
|
+ }
|
|
+ }
|
|
+
|
|
+ __syncthreads(); // before warp_gt / warp_eq are rewritten
|
|
+ gt_run += tile_gt;
|
|
+ eq_run += tile_eq;
|
|
+ }
|
|
+ // No thread may start the next row's chunk load — which writes
|
|
+ // shared_ordered — until every warp has finished reading it here.
|
|
+ __syncthreads();
|
|
+ } else {
|
|
+ // ---- fail loud ------------------------------------------------------
|
|
+ // The predicate is group-uniform (same published integers, same fixed
|
|
+ // summation order in every CTA), so no CTA of this group emitted anything
|
|
+ // and CTA 0 can fill the row alone. -1 is the kernel's own padding value
|
|
+ // and the consumer rejects it, so a faulted row attends nothing instead of
|
|
+ // quietly attending the wrong blocks.
|
|
+ if (cta_in_group == 0u) {
|
|
+ for (uint32_t i = tx; i < static_cast<uint32_t>(TopK);
|
|
+ i += kThreadsPerBlock) {
|
|
+ row_output[i] = -1;
|
|
+ }
|
|
+ }
|
|
+ __threadfence();
|
|
+ __syncthreads();
|
|
+#if MERLIN_TOPK_EXACT_TRAP
|
|
+ __trap();
|
|
+#endif
|
|
+ }
|
|
+}
|
|
+
|
|
// ============================================================================
|
|
// Multi-CTA cooperative RadixTopK for a single large row.
|
|
// Adapted from https://github.com/flashinfer-ai/flashinfer/pull/2215
|
|
@@ -1087,7 +1395,8 @@ __device__ void radix_topk(const float* __restrict__ row_input,
|
|
uint32_t* shared_scalars, 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) {
|
|
+ uint32_t radix_iter, uint32_t tx, bool det_large,
|
|
+ unsigned int* counters) {
|
|
const uint32_t my_chunk_end = (my_chunk_start + chunk_size < seq_len)
|
|
? my_chunk_start + chunk_size
|
|
: seq_len;
|
|
@@ -1241,6 +1550,16 @@ __device__ void radix_topk(const float* __restrict__ row_input,
|
|
// -- Count local > pivot elements --
|
|
const uint32_t ordered_pivot = shared_scalars[0];
|
|
|
|
+ // Deterministic placement + tie membership. Everything below this
|
|
+ // branch is v2.0.1 verbatim and is what MERLIN_TOPK_DET_LARGE=0 runs.
|
|
+ if (det_large) {
|
|
+ radix_emit_deterministic<TopK>(
|
|
+ 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);
|
|
+ return;
|
|
+ }
|
|
+
|
|
if (tx == 0) suffix_sum[0] = 0;
|
|
__syncthreads();
|
|
|
|
@@ -1389,7 +1708,8 @@ __global__ void __launch_bounds__(kThreadsPerBlock, 2)
|
|
radix_topk<TopK, VEC_SIZE>(
|
|
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);
|
|
+ cta_in_group, ctas_per_group, barrier_phase, radix_iter, tx,
|
|
+ params.det_large, params.exact_counters);
|
|
radix_iter++;
|
|
}
|
|
}
|
|
diff --git a/csrc/libtorch_stable/topk.cu b/csrc/libtorch_stable/topk.cu
|
|
index 3419344..001c782 100644
|
|
--- a/csrc/libtorch_stable/topk.cu
|
|
+++ b/csrc/libtorch_stable/topk.cu
|
|
@@ -4,6 +4,7 @@
|
|
#include <cuda_runtime.h>
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
+#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <mutex>
|
|
|
|
@@ -36,6 +37,24 @@ bool exact_medium_enabled() {
|
|
return enabled;
|
|
}
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
+// MERLIN_TOPK_DET_LARGE switch.
|
|
+//
|
|
+// Same contract as the medium-path switch: resolved once per process, so it is a
|
|
+// launch constant and a cudagraph captures one value. Default ON on this
|
|
+// branch. MERLIN_TOPK_DET_LARGE=0 is the A/B control arm and runs the
|
|
+// v2.0.1 `radix_topk` tail verbatim -- the deterministic emission sits behind
|
|
+// one `if (det_large)` in front of it and touches nothing when off, including
|
|
+// the large-path counters, which is what makes the control arm the real baseline.
|
|
+// ---------------------------------------------------------------------------
|
|
+bool det_large_enabled() {
|
|
+ static const bool enabled = [] {
|
|
+ const char* raw = std::getenv("MERLIN_TOPK_DET_LARGE");
|
|
+ return (raw == nullptr || raw[0] == '\0') ? true : (std::atoi(raw) != 0);
|
|
+ }();
|
|
+ return enabled;
|
|
+}
|
|
+
|
|
// 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.
|
|
@@ -340,6 +359,30 @@ void launch_persistent_topk(const torch::stable::Tensor& logits,
|
|
params.ctas_per_group = ctas_per_group;
|
|
params.max_seq_len = static_cast<uint32_t>(max_seq_len);
|
|
params.exact_medium = exact_medium_enabled();
|
|
+ // The per-CTA selected / pivot-equal counts are staged in ONE
|
|
+ // 256-slot RadixRowState histogram row (the slot round 3 has just finished
|
|
+ // reading), which holds 2 * ctas_per_group uint32s. That is the whole
|
|
+ // reason the workspace contract is unchanged, and it is also the only
|
|
+ // limit: above 128 CTAs per group the deterministic path cannot be
|
|
+ // expressed there, so the launch degrades to the v2.0.1 tail rather than
|
|
+ // failing. Unreachable at any shape this kernel serves (ctas_per_group is
|
|
+ // 2-3 across our 135K-262K band and <= 16 at the smallest chunk size), but
|
|
+ // a silent loss of determinism is exactly what this branch exists to stop,
|
|
+ // so say it once.
|
|
+ const bool det_large_fits =
|
|
+ 2u * ctas_per_group <= static_cast<uint32_t>(P::RADIX);
|
|
+ params.det_large = det_large_enabled() && det_large_fits;
|
|
+ if (det_large_enabled() && !det_large_fits) {
|
|
+ static std::atomic<bool> det_large_warned{false};
|
|
+ if (!det_large_warned.exchange(true)) {
|
|
+ std::fprintf(stderr,
|
|
+ "[vllm] MERLIN_TOPK_DET_LARGE is on but ctas_per_group=%u "
|
|
+ "needs %u of %d staging slots; the large path falls back "
|
|
+ "to nondeterministic placement at this shape (counter [4] "
|
|
+ "stays 0).\n",
|
|
+ ctas_per_group, 2u * ctas_per_group, P::RADIX);
|
|
+ }
|
|
+ }
|
|
// 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.
|
|
@@ -444,12 +487,17 @@ void persistent_topk(const torch::stable::Tensor& logits,
|
|
// counters. This is also the explicit pre-capture init path: call it once per
|
|
// device before any cudagraph capture.
|
|
//
|
|
-// `out` is an int32 CUDA tensor with at least 5 elements:
|
|
+// `out` is an int32 CUDA tensor with at least 10 elements:
|
|
// [0] medium-path rows entered (denominator)
|
|
// [1] rows where a 4,096-slot guard would drop a candidate
|
|
// [2] rows that took the exact restart (0 in the control arm)
|
|
// [3] exact restarts that could not complete (must stay 0)
|
|
-// [4] 1 if MERLIN_TOPK_EXACT_MEDIUM is on, else 0
|
|
+// [4] large-path rows emitted deterministically (0 in the control arm)
|
|
+// [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
|
|
//
|
|
// COHERENCY -- stated precisely, because the earlier wording overclaimed.
|
|
// With `sync == 0` the snapshot is ordered ONLY against work already enqueued
|
|
@@ -484,9 +532,9 @@ 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 + 1,
|
|
+ STD_TORCH_CHECK(out.numel() >= vllm::persistent::kNumTopKExactCounters + 2,
|
|
"out must hold at least ",
|
|
- vllm::persistent::kNumTopKExactCounters + 1, " elements");
|
|
+ vllm::persistent::kNumTopKExactCounters + 2, " elements");
|
|
STD_TORCH_CHECK(
|
|
reset == 0 || sync != 0,
|
|
"persistent_topk_exact_counters: reset requires sync != 0, otherwise the "
|
|
@@ -531,10 +579,16 @@ 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.
|
|
- static const int32_t enabled = exact_medium_enabled() ? 1 : 0;
|
|
+ // 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.
|
|
+ static const int32_t enabled[2] = {exact_medium_enabled() ? 1 : 0,
|
|
+ det_large_enabled() ? 1 : 0};
|
|
STD_TORCH_CHECK(
|
|
- cudaMemcpyAsync(dst + vllm::persistent::kNumTopKExactCounters, &enabled,
|
|
- sizeof(int32_t), cudaMemcpyHostToDevice,
|
|
+ cudaMemcpyAsync(dst + vllm::persistent::kNumTopKExactCounters, enabled,
|
|
+ 2 * sizeof(int32_t), cudaMemcpyHostToDevice,
|
|
stream) == cudaSuccess,
|
|
"exact counter flag write failed");
|
|
|
|
diff --git a/tests/kernels/test_top_k_per_row.py b/tests/kernels/test_top_k_per_row.py
|
|
index b0564e8..daf8ff6 100644
|
|
--- a/tests/kernels/test_top_k_per_row.py
|
|
+++ b/tests/kernels/test_top_k_per_row.py
|
|
@@ -1849,11 +1849,29 @@ def test_sparse_indexer_topk_backend_resolution() -> None:
|
|
# 3. two runs return bit-identical index tensors, in ascending index order.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
-_EXACT_COUNTER_SLOTS = 5
|
|
+# 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].
|
|
+_SLOT_MEDIUM_ROWS = 0
|
|
+_SLOT_MEDIUM_OVERFLOW = 1
|
|
+_SLOT_MEDIUM_FALLBACK = 2
|
|
+_SLOT_MEDIUM_FAULT = 3
|
|
+_SLOT_LARGE_ROWS = 4
|
|
+_SLOT_LARGE_TIES_CUT = 5
|
|
+_SLOT_LARGE_TIES_DROPPED = 6
|
|
+_SLOT_LARGE_FAULT = 7
|
|
+_SLOT_EXACT_MEDIUM_ON = 8
|
|
+_SLOT_DET_LARGE_ON = 9
|
|
+_EXACT_COUNTER_SLOTS = 10
|
|
|
|
|
|
def _read_exact_counters() -> list[int] | None:
|
|
- """Cumulative [rows, overflow, fallbacks, faults, enabled], or None."""
|
|
+ """Cumulative counters + the two switch flags, or 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.
|
|
+ """
|
|
op = getattr(torch.ops._C, "persistent_topk_exact_counters", None)
|
|
if op is None:
|
|
return None
|
|
@@ -1899,7 +1917,7 @@ def test_persistent_topk_medium_overflow_is_exact(top_k: int, seq_len: int) -> N
|
|
"no persistent_topk_exact_counters op, so the exact path cannot be "
|
|
"shown to have run. A skip here does not count as a pass."
|
|
)
|
|
- if before[4] == 0:
|
|
+ if before[_SLOT_EXACT_MEDIUM_ON] == 0:
|
|
pytest.skip(
|
|
"MERLIN_TOPK_EXACT_MEDIUM=0: control arm. Exactness is not claimed "
|
|
"here, but a qualification run must also execute the arm with the "
|
|
@@ -1969,7 +1987,7 @@ def test_persistent_topk_medium_overflow_is_bit_reproducible() -> None:
|
|
"no persistent_topk_exact_counters op, so the exact path cannot be "
|
|
"shown to have run. A skip here does not count as a pass."
|
|
)
|
|
- if counters[4] == 0:
|
|
+ if counters[_SLOT_EXACT_MEDIUM_ON] == 0:
|
|
pytest.skip(
|
|
"MERLIN_TOPK_EXACT_MEDIUM=0: control arm. Emission order is not "
|
|
"claimed here; a qualification run must also execute the arm with "
|
|
@@ -2020,7 +2038,7 @@ def test_persistent_topk_medium_overflow_tie_keeps_highest_indices() -> None:
|
|
"QUALIFICATION FAILURE if seen on the branch build: no "
|
|
"persistent_topk_exact_counters op. A skip is not a pass."
|
|
)
|
|
- if before[4] == 0:
|
|
+ if before[_SLOT_EXACT_MEDIUM_ON] == 0:
|
|
pytest.skip(
|
|
"MERLIN_TOPK_EXACT_MEDIUM=0: control arm, the tie rule is not "
|
|
"claimed here."
|
|
@@ -2150,3 +2168,213 @@ def test_topk_exact_counters_reader_log_line(monkeypatch) -> None:
|
|
if getattr(torch.ops._C, "persistent_topk_exact_counters_slots", None) is None:
|
|
monkeypatch.setenv("MERLIN_TOPK_COUNTER_SLOTS", "7")
|
|
assert topk_counters.slot_count() == 7
|
|
+
|
|
+
|
|
+# ---------------------------------------------------------------------------
|
|
+# Large-path (multi-CTA radix_topk) deterministic placement and ties
|
|
+#
|
|
+# Above RADIX_THRESHOLD = 32,768 compressed columns a row is split across a
|
|
+# group of CTAs. In v2.0.1 the pivot is exact and order-independent, but
|
|
+# everything after it is a race: a CTA's base offset into the row is its
|
|
+# arrival order at `state->output_counter`, its slot inside that base is
|
|
+# arrival order at a shared counter, and which pivot-equal elements survive the
|
|
+# `pos < TopK` cut is decided by whoever gets there first. The first two
|
|
+# permute the selected list — which sparse attention consumes as an ordered
|
|
+# gather, so they fork the hidden state — and the third changes the set.
|
|
+#
|
|
+# The row below is built so all three are visible at once:
|
|
+# * 200 elements strictly above the pivot, scattered over the whole row,
|
|
+# * 700 elements exactly AT the pivot, also scattered, so the tie group spans
|
|
+# every CTA chunk and several index high-byte buckets,
|
|
+# * top_k = 512, so exactly 312 of the 700 must survive and 388 must be cut.
|
|
+#
|
|
+# `torch.topk`'s own index choice inside a 700-way tie is arbitrary, so it
|
|
+# cannot supply the expectation. The reference is computed from the shipped
|
|
+# rule instead — most-recent-wins, i.e. the 312 HIGHEST indices — and
|
|
+# torch.topk is used for the weaker check it can make, the value multiset.
|
|
+# ---------------------------------------------------------------------------
|
|
+
|
|
+LARGEDET_SEQ_LEN = 40960 # > RADIX_THRESHOLD (32768): the multi-CTA radix path
|
|
+LARGEDET_TOP_K = 512
|
|
+LARGEDET_ABOVE = 200 # elements strictly greater than the pivot
|
|
+LARGEDET_TIE = 700 # elements exactly equal to the pivot
|
|
+LARGEDET_KEPT = LARGEDET_TOP_K - LARGEDET_ABOVE # 312 of the 700 survive
|
|
+LARGEDET_DROPPED = LARGEDET_TIE - LARGEDET_KEPT # 388 are cut
|
|
+
|
|
+
|
|
+def _build_largedet_tie_row(num_rows: int) -> tuple[torch.Tensor, torch.Tensor]:
|
|
+ """Identical rows with a deliberate multi-CTA tie at the cutoff.
|
|
+
|
|
+ Returns (logits, expected_indices) where expected_indices is the ascending
|
|
+ int64 index list the deterministic kernel must return for every row.
|
|
+ """
|
|
+ gen = torch.Generator(device="cpu").manual_seed(19)
|
|
+ perm = torch.randperm(LARGEDET_SEQ_LEN, generator=gen, device="cpu")
|
|
+ above_idx = perm[:LARGEDET_ABOVE]
|
|
+ tie_idx = perm[LARGEDET_ABOVE : LARGEDET_ABOVE + LARGEDET_TIE]
|
|
+
|
|
+ # Filler stays in [0, 0.5) so nothing outside the two planted groups can
|
|
+ # reach the pivot; the planted values are exact binary fractions, so the
|
|
+ # 700 tie elements are bit-identical as float32 and really do tie.
|
|
+ row = torch.rand(
|
|
+ LARGEDET_SEQ_LEN, generator=gen, dtype=torch.float32, device="cpu"
|
|
+ ) * 0.5
|
|
+ row[tie_idx] = 2.0
|
|
+ row[above_idx] = 3.0
|
|
+
|
|
+ logits = row.to("cuda").unsqueeze(0).repeat(num_rows, 1).contiguous()
|
|
+
|
|
+ kept_tie = torch.sort(tie_idx).values[-LARGEDET_KEPT:]
|
|
+ expected = torch.sort(torch.cat([above_idx, kept_tie])).values.to(torch.int64)
|
|
+ assert expected.numel() == LARGEDET_TOP_K
|
|
+ return logits, expected
|
|
+
|
|
+
|
|
+def _run_largedet_row(logits: torch.Tensor) -> torch.Tensor:
|
|
+ num_rows, seq_len = logits.shape
|
|
+ lengths = torch.full((num_rows,), seq_len, dtype=torch.int32, device="cuda")
|
|
+ indices = torch.empty((num_rows, LARGEDET_TOP_K), dtype=torch.int32, device="cuda")
|
|
+ _run_topk_backend(
|
|
+ "persistent_topk", logits, lengths, indices, LARGEDET_TOP_K, seq_len
|
|
+ )
|
|
+ torch.accelerator.synchronize()
|
|
+ return indices
|
|
+
|
|
+
|
|
+def _largedet_counters_or_skip(require_on: bool) -> list[int]:
|
|
+ counters = _read_exact_counters()
|
|
+ if counters is None:
|
|
+ pytest.skip(
|
|
+ "QUALIFICATION FAILURE if seen on the branch build: this build has "
|
|
+ "no persistent_topk_exact_counters op, so the large path cannot be "
|
|
+ "shown to have run deterministically. A skip is not a pass."
|
|
+ )
|
|
+ if require_on and counters[_SLOT_DET_LARGE_ON] == 0:
|
|
+ pytest.skip(
|
|
+ "MERLIN_TOPK_DET_LARGE=0: control arm. Determinism is not claimed "
|
|
+ "here; a qualification run must also execute the arm with the "
|
|
+ "switch on."
|
|
+ )
|
|
+ if not require_on and counters[_SLOT_DET_LARGE_ON] != 0:
|
|
+ pytest.skip(
|
|
+ "MERLIN_TOPK_DET_LARGE=1: this is the control-arm test and must be "
|
|
+ "run a second time with the switch off. A qualification run needs "
|
|
+ "BOTH arms."
|
|
+ )
|
|
+ return counters
|
|
+
|
|
+
|
|
+@pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA")
|
|
+@torch.inference_mode()
|
|
+def test_persistent_topk_large_tie_keeps_highest_indices() -> None:
|
|
+ """Multi-CTA tie at the cutoff: most-recent-wins, ascending, reproducible."""
|
|
+ torch.set_default_device("cuda:0")
|
|
+ num_rows = 8 # <= 32 keeps the dispatch on persistent_topk
|
|
+
|
|
+ before = _largedet_counters_or_skip(require_on=True)
|
|
+
|
|
+ logits, expected = _build_largedet_tie_row(num_rows)
|
|
+ first = _run_largedet_row(logits).clone()
|
|
+ mid = _read_exact_counters()
|
|
+ second = _run_largedet_row(logits)
|
|
+ after = _read_exact_counters()
|
|
+
|
|
+ # 1. The instrument fired, with EXACT deltas. A lower bound would hide
|
|
+ # double counting, and a green assertion below means nothing unless the
|
|
+ # deterministic path is what produced it.
|
|
+ assert mid[_SLOT_LARGE_ROWS] - before[_SLOT_LARGE_ROWS] == num_rows, (
|
|
+ "the deterministic large path did not run on every row: "
|
|
+ f"{mid[_SLOT_LARGE_ROWS] - before[_SLOT_LARGE_ROWS]} of {num_rows}"
|
|
+ )
|
|
+ assert mid[_SLOT_LARGE_TIES_CUT] - before[_SLOT_LARGE_TIES_CUT] == num_rows, (
|
|
+ "the tie cut did not fire; this row is built so it must"
|
|
+ )
|
|
+ assert (
|
|
+ mid[_SLOT_LARGE_TIES_DROPPED] - before[_SLOT_LARGE_TIES_DROPPED]
|
|
+ == num_rows * LARGEDET_DROPPED
|
|
+ ), "the number of discarded pivot-equal elements is not the expected 388/row"
|
|
+ assert after[_SLOT_LARGE_FAULT] - before[_SLOT_LARGE_FAULT] == 0, (
|
|
+ "a large-path invariant fired; the output below is not meaningful"
|
|
+ )
|
|
+
|
|
+ ref_values = torch.topk(logits[0], LARGEDET_TOP_K).values.sort().values
|
|
+
|
|
+ for row in range(num_rows):
|
|
+ got = first[row].to(torch.int64)
|
|
+
|
|
+ # 2. Ascending in index. This is what makes the Python-side
|
|
+ # MERLIN_QSA_SORT a no-op on these rows rather than a correction.
|
|
+ assert bool((got[1:] - got[:-1] > 0).all()), (
|
|
+ f"row {row}: large-path output is not ascending in index"
|
|
+ )
|
|
+
|
|
+ # 3. The set is the analytic one: everything above the pivot, plus the
|
|
+ # 312 HIGHEST-indexed of the 700 pivot-equal elements.
|
|
+ assert torch.equal(got.cpu(), expected), (
|
|
+ f"row {row}: selected set is not most-recent-wins at the cutoff"
|
|
+ )
|
|
+
|
|
+ # 4. The weaker check torch.topk can make: same value multiset.
|
|
+ got_values = logits[row][got].sort().values
|
|
+ assert torch.equal(got_values, ref_values), (
|
|
+ f"row {row}: selected values are not torch.topk's values"
|
|
+ )
|
|
+
|
|
+ # 5. Bit identity across two launches on identical input -- the property
|
|
+ # that separates this path from the arrival-ordered one at 135K.
|
|
+ assert torch.equal(first, second), (
|
|
+ "two identical launches returned different index tensors"
|
|
+ )
|
|
+
|
|
+
|
|
+@pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA")
|
|
+@torch.inference_mode()
|
|
+def test_persistent_topk_large_control_arm_is_untouched() -> None:
|
|
+ """MERLIN_TOPK_DET_LARGE=0 must be the real v2.0.1 baseline.
|
|
+
|
|
+ Run this with the switch OFF. Two things are asserted and one deliberately
|
|
+ is not:
|
|
+
|
|
+ * the large-path counters do not move at all -- the deterministic path adds no
|
|
+ instruction to the control arm, so an A/B comparison measures the
|
|
+ kernel change and not an added atomic (as on the medium path: a
|
|
+ control arm that quietly runs a different algorithm is not a control);
|
|
+ * the returned VALUES are still exactly torch.topk's, because v2.0.1's
|
|
+ pivot is already exact -- only placement and tie membership are racy;
|
|
+ * the index SET and the emission ORDER are NOT asserted, because their
|
|
+ being unreproducible is precisely the defect the large-path change fixes. Asserting
|
|
+ either way here would be a test that cannot fail, or a flake.
|
|
+ """
|
|
+ torch.set_default_device("cuda:0")
|
|
+ num_rows = 8
|
|
+
|
|
+ before = _largedet_counters_or_skip(require_on=False)
|
|
+
|
|
+ logits, _ = _build_largedet_tie_row(num_rows)
|
|
+ out = _run_largedet_row(logits)
|
|
+ after = _read_exact_counters()
|
|
+
|
|
+ for slot, name in (
|
|
+ (_SLOT_LARGE_ROWS, "rows"),
|
|
+ (_SLOT_LARGE_TIES_CUT, "ties_cut"),
|
|
+ (_SLOT_LARGE_TIES_DROPPED, "ties_dropped"),
|
|
+ (_SLOT_LARGE_FAULT, "faults"),
|
|
+ ):
|
|
+ assert after[slot] - before[slot] == 0, (
|
|
+ f"control arm touched the large-path {name} counter; it is not the "
|
|
+ "v2.0.1 baseline"
|
|
+ )
|
|
+
|
|
+ ref_values = torch.topk(logits[0], LARGEDET_TOP_K).values.sort().values
|
|
+ for row in range(num_rows):
|
|
+ got = out[row].to(torch.int64)
|
|
+ assert int(got.min()) >= 0 and int(got.max()) < LARGEDET_SEQ_LEN, (
|
|
+ f"row {row}: control arm returned an out-of-range index"
|
|
+ )
|
|
+ assert got.unique().numel() == LARGEDET_TOP_K, (
|
|
+ f"row {row}: control arm returned a duplicate index"
|
|
+ )
|
|
+ got_values = logits[row][got].sort().values
|
|
+ assert torch.equal(got_values, ref_values), (
|
|
+ f"row {row}: control-arm values are not torch.topk's values"
|
|
+ )
|
|
--
|
|
2.43.0
|
|
|