[Core] Triton kernel for small-batch top-p only masking (#54651)

Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Nick Hill committed Sep 3, 2026 at 07:52 UTC bf95f58d10891293aa6fde4e21f184d12ec0ba34
3 files changed +627 -13
tests/v1/sample/test_topk_topp_sampler.py
+71
@@ -771,6 +771,77 @@ class TestTritonTopkTopp:
771 if finite_in > 0:
772 assert kept > 0, f"Row {i}: no tokens kept"
773
774 + def test_mixed_batch_grammar_rows_get_topp(self):
775 + """Grammar-masked rows in a mixed top-k batch must still get top-p.
776 +
777 + Regression: with the split top-p pipeline active (batch <= 64), a
778 + row with k < vocab but <= k finite logits was covered by neither the
779 + monolithic kernel (standalone top-p disabled) nor the split pipeline
780 + (skips rows with k < vocab), so its top-p was silently dropped.
781 + """
782 + batch_size, vocab_size = 8, 32000
783 + logits = torch.full((batch_size, vocab_size), float("-inf"))
784 + # Rows 0-2: grammar rows (30 finite tokens); k=50 is a no-op for them.
785 + for i in range(3):
786 + idx = torch.randperm(vocab_size, generator=self.generator)[:30]
787 + logits[i, idx] = torch.randn(30, generator=self.generator)
788 + # Rows 3-4: p-only rows (k = vocab); rows 5-7: normal top-k+top-p.
789 + logits[3:5] = torch.randn(2, vocab_size, generator=self.generator)
790 + logits[5:] = torch.randn(3, vocab_size, generator=self.generator)
791 + k = torch.tensor(
792 + [50, 50, 50, vocab_size, vocab_size, 50, 50, 50], dtype=torch.int32
793 + )
794 + p = torch.tensor([0.5, 0.5, 0.5, 0.9, 0.9, 0.9, 0.9, 0.9])
795 +
796 + self._compare_results(logits, k, p)
797 +
798 + # Top-p must actually trim the grammar rows beyond the (no-op) top-k.
799 + from vllm.v1.sample.ops.topk_topp_triton import apply_top_k_top_p_triton
800 +
801 + kept_topk_only = (
802 + apply_top_k_top_p_triton(logits.clone(), k, None)[:3] != float("-inf")
803 + ).sum(-1)
804 + kept_topk_topp = (
805 + apply_top_k_top_p_triton(logits.clone(), k, p)[:3] != float("-inf")
806 + ).sum(-1)
807 + assert (kept_topk_topp < kept_topk_only).all(), (
808 + f"top-p did not trim grammar rows: {kept_topk_only.tolist()} -> "
809 + f"{kept_topk_topp.tolist()}"
810 + )
811 +
812 + def test_topp_tie_break_deterministic(self):
813 + """Boundary-duplicate trimming must be deterministic across calls.
814 +
815 + Regression: the split top-p mask kernel handed out the tie budget in
816 + cross-program atomic order, so which copies of a tied boundary value
817 + survived could vary between identical calls.
818 + """
819 + from vllm.v1.sample.ops.topk_topp_triton import apply_top_k_top_p_triton
820 +
821 + batch_size, vocab_size = 4, 128256
822 + # 1000 tied top tokens; the p=0.5 boundary lands inside the tie
823 + # group (each tied token has mass e/(1000e+1000) ~ 0.073%).
824 + logits = torch.full((batch_size, vocab_size), float("-inf"))
825 + logits[:, :1000] = 1.0
826 + logits[:, 1000:2000] = 0.0
827 + p = torch.full((batch_size,), 0.5, dtype=torch.float32)
828 +
829 + results = [apply_top_k_top_p_triton(logits.clone(), None, p) for _ in range(5)]
830 + for r in results[1:]:
831 + assert torch.equal(results[0], r), "non-deterministic tie break"
832 +
833 + # The split pipeline now keeps the first `numkeep` copies in index
834 + # order, exactly like the monolithic kernel and the PyTorch impl.
835 + kept_idx = (results[0][0] != float("-inf")).nonzero().flatten()
836 + assert kept_idx.min().item() == 0
837 + assert kept_idx.max().item() == len(kept_idx) - 1
838 + pytorch_kept = (
839 + (apply_top_k_top_p_pytorch(logits.clone(), None, p)[0] != float("-inf"))
840 + .sum()
841 + .item()
842 + )
843 + assert len(kept_idx) == pytorch_kept
844 +
845
846 # =============================================================================
847 # FlashInfer top-k/top-p robustness tests
vllm/v1/sample/ops/topk_topp_sampler.py
+4 -12
@@ -352,16 +352,11 @@ def apply_top_k_top_p(
352 if p is None and k is None:
353 return logits
354
355 - if current_platform.is_cpu():
356 - if HAS_TRITON:
357 - return apply_top_k_top_p_triton(logits, k, p)
358 - return apply_top_k_top_p_pytorch(logits, k, p, allow_cpu_sync=True)
359 -
360 - if HAS_TRITON and logits.shape[0] >= 8:
355 + if HAS_TRITON:
356 return apply_top_k_top_p_triton(logits, k, p)
357
363 - # Use pytorch sort implementation for small batch sizes.
364 - return apply_top_k_top_p_pytorch(logits, k, p)
358 + is_cpu = current_platform.is_cpu()
359 + return apply_top_k_top_p_pytorch(logits, k, p, allow_cpu_sync=is_cpu)
360
361
362 def apply_top_k_top_p_pytorch(
@@ -513,7 +508,4 @@ def flashinfer_sample(
508
509
510 def _to_tensor_scalar_tuple(x):
516 - if isinstance(x, torch.Tensor):
517 - return (x, 0)
518 - else:
519 - return (None, x)
511 + return (x, 0) if isinstance(x, torch.Tensor) else (None, x)
vllm/v1/sample/ops/topk_topp_triton.py
+552 -1
@@ -107,6 +107,7 @@ def _topk_topp_kernel(
107 BLOCK_SIZE_TRUNC: tl.constexpr,
108 TOPK_ENABLED: tl.constexpr,
109 TOPP_ENABLED: tl.constexpr,
110 + SPLIT_COVERS_PONLY: tl.constexpr,
111 ):
112 NUM_TILES: tl.constexpr = (VOCAB_SIZE + BLOCK_SIZE - 1) // BLOCK_SIZE
113 pid = tl.program_id(0)
@@ -594,8 +595,16 @@ def _topk_topp_kernel(
595
596 if TOPP_ENABLED and final_pivot == -float("inf"):
597 #### STANDALONE TOP-P SAMPLING ####
598 + # When the split top-p pipeline co-runs (mixed top-k + top-p
599 + # batches), it covers p-only rows (k >= VOCAB_SIZE); skip those
600 + # here. Rows whose top-k was a no-op because they have <= k
601 + # finite logits (e.g. grammar masks) are NOT covered by the
602 + # split pipeline and must get standalone top-p here.
603 + run_standalone = True
604 + if SPLIT_COVERS_PONLY and TOPK_ENABLED:
605 + run_standalone = tl.load(K + row_id) < VOCAB_SIZE
606 p = tl.load(P + row_id)
598 - if p < 1.0:
607 + if run_standalone and p < 1.0:
608 # Zeroth pass: Compute avg and std from a sample block
609 offs = tl.arange(0, BLOCK_SIZE)
610 mask_n = offs < VOCAB_SIZE
@@ -854,6 +863,529 @@ def _topk_topp_kernel(
863 tl.store(LOGITS_ROW + offs_n, logits_blk, mask=mask_n)
864
865
866 +# ---------------------------------------------------------------------------
867 +# Split-row pipeline for small batches.
868 +#
869 +# The monolithic kernel above assigns one program per row, so at small batch
870 +# sizes nearly all SMs idle and every full-vocab sweep is latency-bound
871 +# (~25us each on SM100). For standalone top-p (no top-k truncation) the pivot
872 +# search can hit its iteration cap while sweeping ~the whole vocab per
873 +# iteration, which is slower than the PyTorch sort path.
874 +#
875 +# The pipeline below instead splits each row across S programs and runs the
876 +# pivot search as parallel partial reductions over a fixed number of rounds
877 +# (no host synchronization, CUDA-graph safe). The search state is never
878 +# stored: the per-round combine is a tiny pure function of the stored
879 +# partials, so every program redundantly recomputes the bracket chain from
880 +# them. This is deterministic (same inputs, same code) and far cheaper than
881 +# a vocab sweep, and it saves a kernel launch per combine. The pipeline
882 +# handles only "p-only" rows (p < 1 and no active top-k); rows with an
883 +# active top-k are left to the monolithic kernel, whose truncation path is
884 +# already fast for them.
885 +# ---------------------------------------------------------------------------
886 +
887 +_SPLIT_MAX_BATCH = 64
888 +_SPLIT_MAX_SPLITS = 32 # power of two
889 +_SPLIT_FANOUT = 8
890 +_SPLIT_ROUNDS = 5
891 +
892 +_TRITON_SPLIT_CACHE: dict[torch.device, dict[str, torch.Tensor]] = {}
893 +
894 +
895 +@triton.jit
896 +def _topp_sb_stats_kernel(
897 + LOGITS,
898 + LOGITS_STRIDE_0,
899 + STATS,
900 + K,
901 + P,
902 + HAS_K: tl.constexpr,
903 + VOCAB_SIZE: tl.constexpr,
904 + S: tl.constexpr,
905 + BLOCK: tl.constexpr,
906 +):
907 + # Per-slice online-softmax partials: (max, sum_exp, min, finite_count).
908 + pid = tl.program_id(0)
909 + row = pid // S
910 + slice_id = pid % S
911 + p = tl.load(P + row)
912 + if p >= 1.0:
913 + return
914 + if HAS_K and tl.load(K + row) < VOCAB_SIZE:
915 + return
916 + slice_len = (VOCAB_SIZE + S - 1) // S
917 + start = slice_id * slice_len
918 + end = tl.minimum(start + slice_len, VOCAB_SIZE)
919 + ROW = LOGITS + row.to(tl.int64) * LOGITS_STRIDE_0
920 + offs = tl.arange(0, BLOCK)
921 + m = -float("inf")
922 + exp_sum = 0.0
923 + mn = float("inf")
924 + cnt = tl.zeros((), dtype=tl.float32)
925 + for i in range(start, end, BLOCK):
926 + x = tl.load(ROW + i + offs, mask=i + offs < end, other=-float("inf"))
927 + finite = x > -float("inf")
928 + nm = tl.maximum(m, tl.max(x))
929 + rescale = tl.where(m == -float("inf"), 0.0, tl.exp(m - nm))
930 + e = tl.where(finite, tl.exp(x - nm), 0.0)
931 + exp_sum = exp_sum * rescale + tl.sum(e)
932 + m = nm
933 + mn = tl.minimum(mn, tl.min(tl.where(finite, x, float("inf"))))
934 + cnt += tl.sum(finite.to(tl.float32))
935 + base = (row * S + slice_id) * 4
936 + tl.store(STATS + base + 0, m)
937 + tl.store(STATS + base + 1, exp_sum)
938 + tl.store(STATS + base + 2, mn)
939 + tl.store(STATS + base + 3, cnt)
940 +
941 +
942 +@triton.jit
943 +def _topp_sb_row_stats(STATS, row, S: tl.constexpr):
944 + """Combine per-slice partials into row (max, sum_exp, min, n_finite)."""
945 + sidx = tl.arange(0, S)
946 + sb = STATS + row * S * 4
947 + m_s = tl.load(sb + sidx * 4 + 0)
948 + exp_s = tl.load(sb + sidx * 4 + 1)
949 + mn_s = tl.load(sb + sidx * 4 + 2)
950 + c_s = tl.load(sb + sidx * 4 + 3)
951 + M = tl.max(m_s)
952 + w = tl.where(m_s == -float("inf"), 0.0, tl.exp(m_s - M))
953 + Z = tl.sum(exp_s * w)
954 + return M, Z, tl.min(mn_s), tl.sum(c_s)
955 +
956 +
957 +@triton.jit
958 +def _topp_sb_ladder(lo, hi, F: tl.constexpr):
959 + """Geometric ladder of F pivots inside (lo, hi)."""
960 + fidx = tl.arange(0, F).to(tl.float32)
961 + return lo * tl.exp(tl.log(hi / lo) * (fidx + 1.0) / (F + 1.0))
962 +
963 +
964 +@triton.jit
965 +def _topp_sb_combine(
966 + PART_BASE,
967 + Z,
968 + p,
969 + lo,
970 + hi,
971 + best,
972 + best_mass,
973 + best_minl,
974 + best_nmin,
975 + S: tl.constexpr,
976 + F: tl.constexpr,
977 + IS_LAST: tl.constexpr,
978 +):
979 + """Combine one round's partials and advance the search state.
980 +
981 + Returns (done, nomask, pivot, dup, numdup, numkeep, lo, hi, best,
982 + best_mass, best_minl, best_nmin, sel_fidx, best_sel). `best*` tracks the
983 + tightest fallback: the largest evaluated pivot whose above-mass is >= p,
984 + which guarantees the top-p mass invariant if the search fails to
985 + converge. Pivots are recomputed from (lo, hi) via the same ladder the
986 + sweep used, so partials line up with pivots without storing them.
987 + `sel_fidx` / `best_sel` report which ladder slot produced `dup` /
988 + updated `best` this round (-1 if none), so callers can recover the
989 + per-slice (minl, nmin) partials behind the boundary value for
990 + deterministic duplicate handling.
991 + """
992 + fidx = tl.arange(0, F)
993 + sidx = tl.arange(0, S)
994 + offs2 = sidx[:, None] * (F * 3) + fidx[None, :] * 3
995 + mass_s = tl.load(PART_BASE + offs2 + 0)
996 + minl_s = tl.load(PART_BASE + offs2 + 1)
997 + nmin_s = tl.load(PART_BASE + offs2 + 2)
998 + mass = tl.sum(mass_s, axis=0)
999 + gmin = tl.min(minl_s, axis=0)
1000 + nmin = tl.sum(tl.where(tl.abs(minl_s - gmin[None, :]) < 1e-9, nmin_s, 0.0), axis=0)
1001 + pivs = _topp_sb_ladder(lo, hi, F)
1002 +
1003 + ok = (mass >= p) & (mass - gmin * nmin < p)
1004 + ok_idx = tl.max(tl.where(ok, fidx, -1).to(tl.float32))
1005 + ge = mass >= p
1006 + ge_idx = tl.max(tl.where(ge, fidx, -1).to(tl.float32))
1007 +
1008 + best_sel = -1.0
1009 + if ge_idx >= 0:
1010 + bmask = fidx == ge_idx.to(tl.int32)
1011 + bp = tl.sum(tl.where(bmask, pivs, 0.0))
1012 + if bp > best:
1013 + best = bp
1014 + best_mass = tl.sum(tl.where(bmask, mass, 0.0))
1015 + best_minl = tl.sum(tl.where(bmask, gmin, 0.0))
1016 + best_nmin = tl.sum(tl.where(bmask, nmin, 0.0))
1017 + best_sel = ge_idx
1018 +
1019 + done = 0.0
1020 + nomask = 0.0
1021 + pivot = 0.0
1022 + sel_mass = 0.0
1023 + dup = 1.0
1024 + numdup = 1.0
1025 + numkeep = 1.0
1026 + sel_fidx = -1.0
1027 + if ok_idx >= 0:
1028 + bmask = fidx == ok_idx.to(tl.int32)
1029 + pivot = tl.sum(tl.where(bmask, pivs, 0.0))
1030 + sel_mass = tl.sum(tl.where(bmask, mass, 0.0))
1031 + dup = tl.sum(tl.where(bmask, gmin, 0.0))
1032 + numdup = tl.sum(tl.where(bmask, nmin, 0.0))
1033 + sel_fidx = ok_idx
1034 + done = 1.0
1035 + else:
1036 + new_lo = tl.maximum(lo, tl.max(tl.where(ge, pivs, -float("inf"))))
1037 + new_hi = tl.minimum(hi, tl.min(tl.where(~ge, pivs, float("inf"))))
1038 + if IS_LAST or not (new_hi > new_lo * (1.0 + 1e-7)):
1039 + # Out of rounds or the range collapsed: fall back to the
1040 + # tightest pivot with mass >= p, or keep the whole row if no
1041 + # pivot qualified.
1042 + if best > 0.0:
1043 + pivot = best
1044 + sel_mass = best_mass
1045 + dup = best_minl
1046 + numdup = best_nmin
1047 + else:
1048 + nomask = 1.0
1049 + done = 1.0
1050 + lo = new_lo
1051 + hi = new_hi
1052 + if (done != 0.0) and (nomask == 0.0):
1053 + # Mirror the monolithic guard: a pivot at/above the max logit would
1054 + # mask everything under the strict `>` comparison.
1055 + nomask = tl.where(pivot * Z >= 1.0, 1.0, 0.0)
1056 + # Duplicate (boundary-value) handling, same formula as the
1057 + # monolithic kernel: keep only some of the boundary duplicates.
1058 + numkeep = numdup - tl.cast(tl.maximum(sel_mass - p, 0.0) / dup, tl.uint32).to(
1059 + tl.float32
1060 + )
1061 + numkeep = tl.minimum(tl.maximum(numkeep, 1.0), numdup)
1062 + return (
1063 + done,
1064 + nomask,
1065 + pivot,
1066 + dup,
1067 + numdup,
1068 + numkeep,
1069 + lo,
1070 + hi,
1071 + best,
1072 + best_mass,
1073 + best_minl,
1074 + best_nmin,
1075 + sel_fidx,
1076 + best_sel,
1077 + )
1078 +
1079 +
1080 +@triton.jit
1081 +def _topp_sb_step_kernel(
1082 + LOGITS,
1083 + LOGITS_STRIDE_0,
1084 + STATS,
1085 + PARTS,
1086 + K,
1087 + P,
1088 + ROUND,
1089 + HAS_K: tl.constexpr,
1090 + S: tl.constexpr,
1091 + F: tl.constexpr,
1092 + NUM_ROUNDS: tl.constexpr,
1093 + VOCAB_SIZE: tl.constexpr,
1094 + BLOCK: tl.constexpr,
1095 +):
1096 + pid = tl.program_id(0)
1097 + row = pid // S
1098 + slice_id = pid % S
1099 + p = tl.load(P + row)
1100 + if p >= 1.0:
1101 + return
1102 + if HAS_K and tl.load(K + row) < VOCAB_SIZE:
1103 + return
1104 + M, Z, mn, num_finite = _topp_sb_row_stats(STATS, row, S)
1105 + if (Z <= 0.0) or (num_finite < 1.0):
1106 + return
1107 + max_prob = 1.0 / Z
1108 + min_prob = tl.maximum(tl.exp(mn - M) / Z, 1e-30)
1109 + if max_prob <= min_prob * (1.0 + 1e-6):
1110 + # (Nearly) uniform row: no pivot can differentiate; keep everything.
1111 + return
1112 + # Replay the combine chain for rounds 0..ROUND-1 (deterministic; each
1113 + # is a tiny pure function of the stored partials). Partials from rounds
1114 + # past a row's convergence are stale, so the chain stops at `done`.
1115 + lo = min_prob
1116 + hi = max_prob
1117 + best = -1.0
1118 + best_mass = 0.0
1119 + best_minl = 1.0
1120 + best_nmin = 0.0
1121 + done = 0.0
1122 + for j in range(ROUND):
1123 + if done == 0.0:
1124 + (
1125 + done,
1126 + nomask,
1127 + pivot,
1128 + dup,
1129 + numdup,
1130 + numkeep,
1131 + lo,
1132 + hi,
1133 + best,
1134 + best_mass,
1135 + best_minl,
1136 + best_nmin,
1137 + sel_fidx,
1138 + best_sel,
1139 + ) = _topp_sb_combine(
1140 + PARTS + (row * NUM_ROUNDS + j) * S * F * 3,
1141 + Z,
1142 + p,
1143 + lo,
1144 + hi,
1145 + best,
1146 + best_mass,
1147 + best_minl,
1148 + best_nmin,
1149 + S=S,
1150 + F=F,
1151 + IS_LAST=False,
1152 + )
1153 + if done != 0.0:
1154 + return
1155 + # Sweep this slice against the ladder pivots derived from (lo, hi).
1156 + pivs = _topp_sb_ladder(lo, hi, F)
1157 + slice_len = (VOCAB_SIZE + S - 1) // S
1158 + start = slice_id * slice_len
1159 + end = tl.minimum(start + slice_len, VOCAB_SIZE)
1160 + ROW = LOGITS + row.to(tl.int64) * LOGITS_STRIDE_0
1161 + offs = tl.arange(0, BLOCK)
1162 + fidx = tl.arange(0, F)
1163 + mass = tl.zeros([F], dtype=tl.float32)
1164 + minl = tl.full([F], 1.0, dtype=tl.float32)
1165 + nmin = tl.zeros([F], dtype=tl.float32)
1166 + for i in range(start, end, BLOCK):
1167 + x = tl.load(ROW + i + offs, mask=i + offs < end, other=-float("inf"))
1168 + pr = tl.exp(x - M) / Z
1169 + pr2 = tl.broadcast_to(pr[None, :], (F, BLOCK))
1170 + above = pr2 > pivs[:, None]
1171 + mass += tl.sum(tl.where(above, pr2, 0.0), axis=1)
1172 + cand = tl.where(above, pr2, float("inf"))
1173 + tmin = tl.min(cand, axis=1)
1174 + teq = above & (tl.abs(pr2 - tmin[:, None]) < 1e-9)
1175 + tcnt = tl.sum(teq.to(tl.float32), axis=1)
1176 + is_new = tmin < minl
1177 + is_same = tl.abs(tmin - minl) < 1e-9
1178 + nmin = tl.where(is_new, tcnt, nmin + tl.where(is_same, tcnt, 0.0))
1179 + minl = tl.minimum(minl, tmin)
1180 + pbase = PARTS + ((row * NUM_ROUNDS + ROUND) * S + slice_id) * F * 3
1181 + tl.store(pbase + fidx * 3 + 0, mass)
1182 + tl.store(pbase + fidx * 3 + 1, minl)
1183 + tl.store(pbase + fidx * 3 + 2, nmin)
1184 +
1185 +
1186 +@triton.jit
1187 +def _topp_sb_mask_kernel(
1188 + LOGITS,
1189 + LOGITS_STRIDE_0,
1190 + STATS,
1191 + PARTS,
1192 + K,
1193 + P,
1194 + HAS_K: tl.constexpr,
1195 + MASK_VALUE: tl.constexpr,
1196 + S: tl.constexpr,
1197 + F: tl.constexpr,
1198 + NUM_ROUNDS: tl.constexpr,
1199 + VOCAB_SIZE: tl.constexpr,
1200 + BLOCK: tl.constexpr,
1201 +):
1202 + pid = tl.program_id(0)
1203 + row = pid // S
1204 + slice_id = pid % S
1205 + p = tl.load(P + row)
1206 + if p >= 1.0:
1207 + return
1208 + if HAS_K and tl.load(K + row) < VOCAB_SIZE:
1209 + return
1210 + M, Z, mn, num_finite = _topp_sb_row_stats(STATS, row, S)
1211 + if (Z <= 0.0) or (num_finite < 1.0):
1212 + return
1213 + max_prob = 1.0 / Z
1214 + min_prob = tl.maximum(tl.exp(mn - M) / Z, 1e-30)
1215 + if max_prob <= min_prob * (1.0 + 1e-6):
1216 + return # (nearly) uniform row: keep everything
1217 + # Replay the full combine chain; the last round finalizes with fallback
1218 + # semantics. Rows that converged early stop the chain at that round.
1219 + # `fin` tracks the (round, ladder slot) whose per-slice partials define
1220 + # the boundary value, for deterministic duplicate handling below.
1221 + lo = min_prob
1222 + hi = max_prob
1223 + best = -1.0
1224 + best_mass = 0.0
1225 + best_minl = 1.0
1226 + best_nmin = 0.0
1227 + done = 0.0
1228 + nomask = 0.0
1229 + pivot = 0.0
1230 + dup = 1.0
1231 + numdup = 1.0
1232 + numkeep = 1.0
1233 + fin = -1.0
1234 + best_fin = -1.0
1235 + for j in range(NUM_ROUNDS):
1236 + if done == 0.0:
1237 + (
1238 + done,
1239 + nomask,
1240 + pivot,
1241 + dup,
1242 + numdup,
1243 + numkeep,
1244 + lo,
1245 + hi,
1246 + best,
1247 + best_mass,
1248 + best_minl,
1249 + best_nmin,
1250 + sel_fidx,
1251 + best_sel,
1252 + ) = _topp_sb_combine(
1253 + PARTS + (row * NUM_ROUNDS + j) * S * F * 3,
1254 + Z,
1255 + p,
1256 + lo,
1257 + hi,
1258 + best,
1259 + best_mass,
1260 + best_minl,
1261 + best_nmin,
1262 + S=S,
1263 + F=F,
1264 + IS_LAST=j == NUM_ROUNDS - 1,
1265 + )
1266 + if sel_fidx >= 0.0:
1267 + fin = j * F + sel_fidx
1268 + if best_sel >= 0.0:
1269 + best_fin = j * F + best_sel
1270 + if nomask != 0.0:
1271 + return
1272 + if fin < 0.0:
1273 + fin = best_fin
1274 + logZ = tl.log(Z)
1275 + pivot_logit = tl.log(pivot) + logZ + M
1276 + # numdup/numkeep are exact small integers held in fp32.
1277 + ties = numkeep < numdup
1278 + num_preceding = 0.0
1279 + if ties:
1280 + # Deterministic, index-ordered duplicate budget: per-slice counts of
1281 + # the boundary value come from the finalizing round's partials (a
1282 + # slice holds copies of `dup` iff its minl matches `dup`, with the
1283 + # count in nmin), so each slice knows exactly how many copies were
1284 + # kept by lower-indexed slices.
1285 + fin_round = tl.cast(fin / F, tl.int32)
1286 + fin_slot = tl.cast(fin - tl.cast(fin_round, tl.float32) * F, tl.int32)
1287 + pbase = PARTS + ((row * NUM_ROUNDS + fin_round) * S * F + fin_slot) * 3
1288 + sidx = tl.arange(0, S)
1289 + minl_s = tl.load(pbase + sidx * (F * 3) + 1)
1290 + nmin_s = tl.load(pbase + sidx * (F * 3) + 2)
1291 + dupc_s = tl.where(tl.abs(minl_s - dup) < 1e-9, nmin_s, 0.0)
1292 + num_preceding = tl.sum(tl.where(sidx < slice_id, dupc_s, 0.0))
1293 + slice_len = (VOCAB_SIZE + S - 1) // S
1294 + start = slice_id * slice_len
1295 + end = tl.minimum(start + slice_len, VOCAB_SIZE)
1296 + ROW = LOGITS + row.to(tl.int64) * LOGITS_STRIDE_0
1297 + offs = tl.arange(0, BLOCK)
1298 + for i in range(start, end, BLOCK):
1299 + mask_n = i + offs < end
1300 + x = tl.load(ROW + i + offs, mask=mask_n, other=-float("inf"))
1301 + keep = x > pivot_logit
1302 + if ties:
1303 + # Match the boundary value in probability space with exact
1304 + # equality: `dup` is a probability computed by the step kernels
1305 + # as exp(x - M) / Z, so recomputing it here with the same ops
1306 + # reproduces it bitwise on any platform. (The log/exp round-trip
1307 + # to logit space loses ~1e-6 to cancellation, breaking any
1308 + # absolute tolerance.)
1309 + dmask = tl.exp(x - M) / Z == dup
1310 + cum = tl.cumsum(dmask.to(tl.int32)).to(tl.float32) + num_preceding
1311 + keep_dup = dmask & (cum <= numkeep)
1312 + keep = keep & (~dmask | keep_dup)
1313 + tl.store(ROW + i + offs, tl.where(keep, x, MASK_VALUE), mask=mask_n)
1314 +
1315 +
1316 +def _apply_topp_split(
1317 + logits: torch.Tensor,
1318 + k: torch.Tensor | None,
1319 + p: torch.Tensor,
1320 + mask_value: float,
1321 + num_sm: int,
1322 +) -> None:
1323 + """Split-row top-p pipeline for small batches; masks logits in place."""
1324 + batch_size, vocab_size = logits.shape
1325 + splits = 1
1326 + while splits < _SPLIT_MAX_SPLITS and splits * 2 * batch_size <= num_sm:
1327 + splits *= 2
1328 +
1329 + ws = _TRITON_SPLIT_CACHE.get(logits.device)
1330 + if ws is None:
1331 + ws = {
1332 + "stats": logits.new_empty((_SPLIT_MAX_BATCH * _SPLIT_MAX_SPLITS, 4)),
1333 + "parts": logits.new_empty(
1334 + (_SPLIT_MAX_BATCH, _SPLIT_ROUNDS, _SPLIT_MAX_SPLITS, _SPLIT_FANOUT, 3)
1335 + ),
1336 + }
1337 + _TRITON_SPLIT_CACHE[logits.device] = ws
1338 +
1339 + k_ptr = k if k is not None else logits # dummy pointer when HAS_K=False
1340 + has_k = k is not None
1341 +
1342 + _topp_sb_stats_kernel[(batch_size * splits,)](
1343 + logits,
1344 + logits.stride(0),
1345 + ws["stats"],
1346 + k_ptr,
1347 + p,
1348 + HAS_K=has_k,
1349 + VOCAB_SIZE=vocab_size,
1350 + S=splits,
1351 + BLOCK=8192,
1352 + num_warps=8,
1353 + )
1354 + for round_i in range(_SPLIT_ROUNDS):
1355 + _topp_sb_step_kernel[(batch_size * splits,)](
1356 + logits,
1357 + logits.stride(0),
1358 + ws["stats"],
1359 + ws["parts"],
1360 + k_ptr,
1361 + p,
1362 + round_i,
1363 + HAS_K=has_k,
1364 + S=splits,
1365 + F=_SPLIT_FANOUT,
1366 + NUM_ROUNDS=_SPLIT_ROUNDS,
1367 + VOCAB_SIZE=vocab_size,
1368 + BLOCK=2048,
1369 + num_warps=8,
1370 + )
1371 + _topp_sb_mask_kernel[(batch_size * splits,)](
1372 + logits,
1373 + logits.stride(0),
1374 + ws["stats"],
1375 + ws["parts"],
1376 + k_ptr,
1377 + p,
1378 + HAS_K=has_k,
1379 + MASK_VALUE=mask_value,
1380 + S=splits,
1381 + F=_SPLIT_FANOUT,
1382 + NUM_ROUNDS=_SPLIT_ROUNDS,
1383 + VOCAB_SIZE=vocab_size,
1384 + BLOCK=8192,
1385 + num_warps=8,
1386 + )
1387 +
1388 +
1389 def apply_top_k_top_p_triton(
1390 logits: torch.Tensor,
1391 k: torch.Tensor | None,
@@ -905,6 +1437,21 @@ def apply_top_k_top_p_triton(
1437 p_ptr = logits # Dummy pointer (won't be read)
1438
1439 num_sm = num_compute_units(logits.device.index)
1440 +
1441 + # At small batch sizes the monolithic kernel's standalone top-p path is
1442 + # latency-bound (one program per row, serial vocab sweeps), so use the
1443 + # split-row pipeline for rows without an active top-k. Rows with an
1444 + # active top-k still go through the monolithic kernel (its truncation
1445 + # path is already fast). The monolithic's standalone top-p branch then
1446 + # covers only rows whose top-k is a no-op (k < vocab but <= k finite
1447 + # logits, e.g. grammar masks), which the split pipeline skips.
1448 + use_split = (
1449 + topp_enabled and batch_size <= _SPLIT_MAX_BATCH and logits.device.type == "cuda"
1450 + )
1451 + if use_split and not topk_enabled:
1452 + _apply_topp_split(logits, None, p_ptr, mask_value, num_sm)
1453 + return logits
1454 +
1455 NUM_PROGRAMS = min(num_sm, batch_size)
1456
1457 # Cache per-Triton Program buffer on each device.
@@ -961,8 +1508,11 @@ def apply_top_k_top_p_triton(
1508 BLOCK_SIZE_TRUNC=block_size_trunc,
1509 TOPK_ENABLED=topk_enabled,
1510 TOPP_ENABLED=topp_enabled,
1511 + SPLIT_COVERS_PONLY=use_split,
1512 **launch_kwargs,
1513 )
1514 + if use_split:
1515 + _apply_topp_split(logits, k_ptr, p_ptr, mask_value, num_sm)
1516
1517 return logits
1518
@@ -970,4 +1520,5 @@ def apply_top_k_top_p_triton(
1520 def reset_buffer_cache():
1521 _TRITON_BUFFER_CACHE.clear()
1522 _TRITON_TABLE_CACHE.clear()
1523 + _TRITON_SPLIT_CACHE.clear()
1524 torch.accelerator.empty_cache()