59a0a3844c
PR Test AMD / cancel-on-close (push) Has been skipped
PR Test NVIDIA ARM / scan (push) Has been skipped
PR Test NVIDIA / cancel-on-close (push) Has been skipped
PR Test AMD / scan (push) Has been skipped
PR Test NVIDIA ARM / cancel-on-close (push) Has been skipped
PR Test NVIDIA / scan (push) Has been skipped
Release Docker Images / build (cu129-torch-2.11.0) (push) Has been skipped
Release Docker Images / build (cu130-torch-2.11.0) (push) Has been skipped
Release PyPI / publish (push) Has been skipped
Scheduler Python Test / test (push) Successful in 27m19s
Docs / build (push) Successful in 28m8s
Scheduler C++ Test / test (push) Successful in 28m19s
Scheduler C++ Test / test-flat (push) Successful in 28m18s
Docs / deploy (push) Has been cancelled
PR Test AMD / finish (push) Has been cancelled
PR Test NVIDIA / finish (push) Has been cancelled
PR Test NVIDIA ARM / finish (push) Has been cancelled
PR Test NVIDIA ARM / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test AMD / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test NVIDIA / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
366 lines
14 KiB
Python
366 lines
14 KiB
Python
# Copyright (c) 2026 LightSeek Foundation
|
||
#
|
||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||
# of this software and associated documentation files (the "Software"), to deal
|
||
# in the Software without restriction, including without limitation the rights
|
||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||
# copies of the Software, and to permit persons to whom the Software is
|
||
# furnished to do so, subject to the following conditions:
|
||
#
|
||
# The above copyright notice and this permission notice shall be included in
|
||
# all copies or substantial portions of the Software.
|
||
#
|
||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||
# SOFTWARE.
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
import torch
|
||
from tokenspeed_kernel.ops.sampling.cuda import (
|
||
fused_topk_topp_renorm,
|
||
fused_topk_topp_workspace_size,
|
||
)
|
||
from tokenspeed_kernel.ops.sampling.triton import (
|
||
gather_and_expand_scalars,
|
||
min_p_renorm_prob,
|
||
)
|
||
from tokenspeed_kernel.platform import current_platform
|
||
|
||
# Sentinel matching tokenspeed.runtime.sampling.sampling_params._TOP_K_DISABLED.
|
||
_TOP_K_DISABLED = 1 << 30
|
||
|
||
# The fused top-k + top-p kernel ships only as a CUDA build; on ROCm the
|
||
# Python entry point resolves to a RuntimeError stub. Gate the tests instead
|
||
# of failing loudly on AMD CI.
|
||
requires_nvidia = pytest.mark.skipif(
|
||
not current_platform().is_nvidia,
|
||
reason="fused_topk_topp kernel is NVIDIA-only",
|
||
)
|
||
|
||
|
||
def _make_pools(pool_rows: int, device: str):
|
||
temp = torch.linspace(0.5, 1.5, pool_rows, device=device, dtype=torch.float32)
|
||
top_k = torch.arange(1, pool_rows + 1, device=device, dtype=torch.int32)
|
||
top_p = torch.linspace(0.5, 1.0, pool_rows, device=device, dtype=torch.float32)
|
||
min_p = torch.linspace(0.0, 0.2, pool_rows, device=device, dtype=torch.float32)
|
||
seed = torch.arange(100, 100 + pool_rows, device=device, dtype=torch.int64)
|
||
offsets = torch.arange(0, pool_rows, device=device, dtype=torch.int32) * 7
|
||
return temp, top_k, top_p, min_p, seed, offsets
|
||
|
||
|
||
def _reference(index, pool, n: int):
|
||
"""index_select + repeat_interleave reference."""
|
||
idx = index.long()
|
||
return pool.index_select(0, idx).repeat_interleave(n, dim=0)
|
||
|
||
|
||
def _min_p_reference(probs: torch.Tensor, min_p: torch.Tensor) -> torch.Tensor:
|
||
max_probs = probs.max(dim=-1, keepdim=True).values
|
||
out = torch.where(
|
||
probs >= min_p.to(probs.dtype).view(-1, 1) * max_probs,
|
||
probs,
|
||
torch.zeros_like(probs),
|
||
)
|
||
return out / out.sum(dim=-1, keepdim=True)
|
||
|
||
|
||
@pytest.mark.parametrize("bs", [1, 4, 7])
|
||
@pytest.mark.parametrize("n", [1, 4, 8])
|
||
def test_gather_full(bs: int, n: int, device: str) -> None:
|
||
pool_rows = 32
|
||
torch.manual_seed(0)
|
||
temp_p, top_k_p, top_p_p, min_p_p, seed_p, offsets_p = _make_pools(
|
||
pool_rows, device
|
||
)
|
||
index = torch.randint(0, pool_rows, (bs,), device=device, dtype=torch.int32)
|
||
|
||
temps, top_ks, top_ps, min_ps, seeds, offsets = gather_and_expand_scalars(
|
||
index,
|
||
temperature=temp_p,
|
||
top_k=top_k_p,
|
||
top_p=top_p_p,
|
||
min_p=min_p_p,
|
||
seed=seed_p,
|
||
offsets=offsets_p,
|
||
n=n,
|
||
)
|
||
|
||
torch.testing.assert_close(temps, _reference(index, temp_p, n))
|
||
torch.testing.assert_close(top_ks, _reference(index, top_k_p, n))
|
||
torch.testing.assert_close(top_ps, _reference(index, top_p_p, n))
|
||
torch.testing.assert_close(min_ps, _reference(index, min_p_p, n))
|
||
torch.testing.assert_close(seeds, _reference(index, seed_p, n))
|
||
torch.testing.assert_close(offsets, _reference(index, offsets_p, n).to(torch.int64))
|
||
|
||
|
||
@pytest.mark.parametrize("n", [1, 5])
|
||
def test_gather_no_min_p_no_seed(n: int, device: str) -> None:
|
||
"""Verify path: drop min_p, seed, and offsets."""
|
||
pool_rows = 16
|
||
temp_p, top_k_p, top_p_p, _, _, _ = _make_pools(pool_rows, device)
|
||
index = torch.arange(8, device=device, dtype=torch.int32) % pool_rows
|
||
|
||
temps, top_ks, top_ps, min_ps, seeds, offsets = gather_and_expand_scalars(
|
||
index,
|
||
temperature=temp_p,
|
||
top_k=top_k_p,
|
||
top_p=top_p_p,
|
||
n=n,
|
||
)
|
||
|
||
assert min_ps is None
|
||
assert seeds is None
|
||
assert offsets is None
|
||
torch.testing.assert_close(temps, _reference(index, temp_p, n))
|
||
torch.testing.assert_close(top_ks, _reference(index, top_k_p, n))
|
||
torch.testing.assert_close(top_ps, _reference(index, top_p_p, n))
|
||
|
||
|
||
def test_gather_sample_basic(device: str) -> None:
|
||
"""flashinfer.py sample(): seed + offsets, no min_p, n=1."""
|
||
pool_rows = 16
|
||
temp_p, top_k_p, top_p_p, _, seed_p, offsets_p = _make_pools(pool_rows, device)
|
||
index = torch.tensor([3, 1, 0, 2], device=device, dtype=torch.int32)
|
||
|
||
temps, top_ks, top_ps, min_ps, seeds, offsets = gather_and_expand_scalars(
|
||
index,
|
||
temperature=temp_p,
|
||
top_k=top_k_p,
|
||
top_p=top_p_p,
|
||
seed=seed_p,
|
||
offsets=offsets_p,
|
||
n=1,
|
||
)
|
||
|
||
assert min_ps is None
|
||
assert seeds is not None
|
||
assert offsets is not None
|
||
torch.testing.assert_close(temps, _reference(index, temp_p, 1))
|
||
torch.testing.assert_close(seeds, _reference(index, seed_p, 1))
|
||
torch.testing.assert_close(offsets, _reference(index, offsets_p, 1).to(torch.int64))
|
||
assert offsets.dtype == torch.int64
|
||
|
||
|
||
def test_gather_min_p_only(device: str) -> None:
|
||
"""flashinfer_full.py verify(): min_p yes, seed no, offsets no."""
|
||
pool_rows = 16
|
||
temp_p, top_k_p, top_p_p, min_p_p, _, _ = _make_pools(pool_rows, device)
|
||
index = torch.tensor([0, 5, 3], device=device, dtype=torch.int32)
|
||
|
||
temps, top_ks, top_ps, min_ps, seeds, offsets = gather_and_expand_scalars(
|
||
index,
|
||
temperature=temp_p,
|
||
top_k=top_k_p,
|
||
top_p=top_p_p,
|
||
min_p=min_p_p,
|
||
n=4,
|
||
)
|
||
|
||
assert seeds is None
|
||
assert offsets is None
|
||
assert min_ps is not None
|
||
torch.testing.assert_close(min_ps, _reference(index, min_p_p, 4))
|
||
|
||
|
||
def _ref_topk_topp(
|
||
probs: torch.Tensor, top_ks: torch.Tensor, top_ps: torch.Tensor
|
||
) -> torch.Tensor:
|
||
"""Pure-torch baseline mirroring flashinfer's ``top_k_renorm_prob`` followed
|
||
by ``top_p_renorm_prob(is_deterministic=True)``. K >= V is treated as no
|
||
top-k cutoff (matches both the flashinfer clamp and the K = 1<<30 sentinel).
|
||
"""
|
||
bs, V = probs.shape
|
||
out = probs.clone()
|
||
for i in range(bs):
|
||
k = min(int(top_ks[i].item()), V)
|
||
if k < V:
|
||
kth = torch.topk(out[i], k, sorted=False).values.min()
|
||
out[i] = torch.where(out[i] >= kth, out[i], torch.zeros_like(out[i]))
|
||
s = out[i].sum()
|
||
if s > 0:
|
||
out[i] = out[i] / s
|
||
sorted_vals, _ = torch.sort(out[i], descending=True)
|
||
cs = torch.cumsum(sorted_vals, 0)
|
||
p = float(top_ps[i].item())
|
||
# Smallest prefix with cumulative mass >= p. Clamp to V to absorb
|
||
# fp32 rounding when p = 1.0 (cumsum's last value can fall a ulp
|
||
# short of 1.0 and would otherwise push keep past the end).
|
||
keep = min((cs < p).sum().item() + 1, V)
|
||
thresh = sorted_vals[keep - 1]
|
||
out[i] = torch.where(out[i] >= thresh, out[i], torch.zeros_like(out[i]))
|
||
s = out[i].sum()
|
||
if s > 0:
|
||
out[i] = out[i] / s
|
||
return out
|
||
|
||
|
||
@requires_nvidia
|
||
@pytest.mark.parametrize(
|
||
"ks,ps,tag",
|
||
[
|
||
# Mode 3.1: top-K only (P=1.0).
|
||
([1, 16, 64, 128, 1, 64, 16, 128], [1.0] * 8, "topk-only"),
|
||
# Mode 3.2: top-P only (K sentinel → radix path).
|
||
(
|
||
[_TOP_K_DISABLED] * 8,
|
||
[0.5, 0.7, 0.9, 0.95, 0.99, 0.5, 0.9, 0.8],
|
||
"topp-only",
|
||
),
|
||
# Mode 3.3: top-K + top-P together.
|
||
(
|
||
[64, 64, 32, 128, 16, 8, 64, 128],
|
||
[0.9, 0.5, 0.8, 0.7, 0.95, 0.6, 0.9, 0.99],
|
||
"topk+topp",
|
||
),
|
||
# Mixed batch: different rows take different paths in one launch.
|
||
(
|
||
[64, _TOP_K_DISABLED, 1, 128, 32, _TOP_K_DISABLED, 16, 8],
|
||
[0.9, 0.9, 1.0, 0.7, 0.95, 0.5, 0.8, 0.99],
|
||
"mixed",
|
||
),
|
||
],
|
||
)
|
||
def test_fused_topk_topp_matches_pipeline(
|
||
device: str, ks: list[int], ps: list[float], tag: str
|
||
) -> None:
|
||
if not torch.cuda.is_available():
|
||
pytest.skip("CUDA GPU is required for fused_topk_topp_renorm test")
|
||
torch.manual_seed(0)
|
||
bs, V = len(ks), 8192
|
||
logits = torch.randn(bs, V, device=device, dtype=torch.float32) * 3.0
|
||
probs = torch.softmax(logits, dim=-1)
|
||
top_ks = torch.tensor(ks, dtype=torch.int32, device=device)
|
||
top_ps = torch.tensor(ps, dtype=torch.float32, device=device)
|
||
|
||
ref = _ref_topk_topp(probs, top_ks, top_ps)
|
||
ours = fused_topk_topp_renorm(probs.clone(), top_ks, top_ps)
|
||
|
||
# Each kept row should renormalize to 1 within fp32 ulp tolerance.
|
||
torch.testing.assert_close(
|
||
ours.sum(dim=-1), torch.ones(bs, device=device), atol=1e-5, rtol=1e-5
|
||
)
|
||
# The fused kernel must produce the same kept set on every row. Allow
|
||
# a single position of disagreement to absorb ties at the top-p cutoff
|
||
# (cumulative-sum rounding can include/exclude the boundary by 1 entry).
|
||
pos_ref = ref > 0
|
||
pos_ours = ours > 0
|
||
pos_diff = (pos_ref != pos_ours).sum(dim=-1).max().item()
|
||
assert pos_diff <= 1, f"[{tag}] kept-position mismatch up to {pos_diff} per row"
|
||
# Renormalized values: sub-ulp accumulation order differs by row scale,
|
||
# so 1e-5 is the right tolerance (matches the per-row sum bound).
|
||
torch.testing.assert_close(ours, ref, atol=1e-5, rtol=1e-4)
|
||
|
||
|
||
@requires_nvidia
|
||
def test_fused_topk_topp_workspace_size_grows_with_batch(device: str) -> None:
|
||
"""Workspace size must grow monotonically with batch and vocab so callers
|
||
can pre-allocate a buffer sized for ``max_bs × vocab``."""
|
||
|
||
if not torch.cuda.is_available():
|
||
pytest.skip("CUDA GPU is required for fused_topk_topp_workspace_size test")
|
||
V = 8192
|
||
small = fused_topk_topp_workspace_size(1, V)
|
||
large = fused_topk_topp_workspace_size(64, V)
|
||
assert small > 0
|
||
assert large > small
|
||
|
||
|
||
@requires_nvidia
|
||
def test_fused_topk_topp_external_workspace(device: str) -> None:
|
||
"""Pre-allocated workspace path must produce the same result as the
|
||
auto-allocated one, so the runtime can hoist the alloc out of the hot
|
||
path."""
|
||
|
||
if not torch.cuda.is_available():
|
||
pytest.skip("CUDA GPU is required for fused_topk_topp_external_workspace test")
|
||
torch.manual_seed(1)
|
||
bs, V = 4, 8192
|
||
probs = torch.softmax(
|
||
torch.randn(bs, V, device=device, dtype=torch.float32) * 2.5, dim=-1
|
||
)
|
||
top_ks = torch.tensor(
|
||
[32, _TOP_K_DISABLED, 64, 128], dtype=torch.int32, device=device
|
||
)
|
||
top_ps = torch.tensor([0.9, 0.85, 0.95, 0.8], dtype=torch.float32, device=device)
|
||
|
||
auto = fused_topk_topp_renorm(probs, top_ks, top_ps)
|
||
ws = torch.empty(
|
||
fused_topk_topp_workspace_size(bs, V), dtype=torch.uint8, device=device
|
||
)
|
||
manual = fused_topk_topp_renorm(probs, top_ks, top_ps, workspace=ws)
|
||
ws_no_pdl = torch.empty_like(ws)
|
||
no_pdl = fused_topk_topp_renorm(
|
||
probs, top_ks, top_ps, workspace=ws_no_pdl, enable_pdl=False
|
||
)
|
||
torch.testing.assert_close(auto, manual, atol=0.0, rtol=0.0)
|
||
torch.testing.assert_close(auto, no_pdl, atol=0.0, rtol=0.0)
|
||
|
||
|
||
def test_gather_empty_batch(device: str) -> None:
|
||
pool_rows = 16
|
||
temp_p, top_k_p, top_p_p, min_p_p, seed_p, offsets_p = _make_pools(
|
||
pool_rows, device
|
||
)
|
||
index = torch.empty(0, device=device, dtype=torch.int32)
|
||
|
||
temps, top_ks, top_ps, min_ps, seeds, offsets = gather_and_expand_scalars(
|
||
index,
|
||
temperature=temp_p,
|
||
top_k=top_k_p,
|
||
top_p=top_p_p,
|
||
min_p=min_p_p,
|
||
seed=seed_p,
|
||
offsets=offsets_p,
|
||
n=5,
|
||
)
|
||
|
||
assert temps.numel() == 0
|
||
assert top_ks.numel() == 0
|
||
assert top_ps.numel() == 0
|
||
assert min_ps.numel() == 0
|
||
assert seeds.numel() == 0
|
||
assert offsets.numel() == 0
|
||
|
||
|
||
@pytest.mark.parametrize("rows", [1, 3, 5])
|
||
@pytest.mark.parametrize("vocab_size", [17, 257, 1025])
|
||
def test_min_p_renorm_prob(rows: int, vocab_size: int, device: str) -> None:
|
||
torch.manual_seed(rows * 1000 + vocab_size)
|
||
probs = torch.rand((rows, vocab_size), device=device, dtype=torch.float32)
|
||
probs = probs / probs.sum(dim=-1, keepdim=True)
|
||
min_p = torch.linspace(0.0, 0.2, rows, device=device, dtype=torch.float32)
|
||
|
||
out = min_p_renorm_prob(probs, min_p)
|
||
ref = _min_p_reference(probs, min_p)
|
||
|
||
torch.testing.assert_close(out, ref, rtol=1e-5, atol=1e-6)
|
||
torch.testing.assert_close(out.sum(dim=-1), torch.ones(rows, device=device))
|
||
|
||
|
||
def test_min_p_renorm_prob_bf16_min_p(device: str) -> None:
|
||
torch.manual_seed(0)
|
||
probs = torch.rand((4, 513), device=device, dtype=torch.float32)
|
||
probs = probs / probs.sum(dim=-1, keepdim=True)
|
||
min_p = torch.tensor([0.0, 0.01, 0.05, 0.2], device=device, dtype=torch.bfloat16)
|
||
|
||
out = min_p_renorm_prob(probs, min_p)
|
||
ref = _min_p_reference(probs, min_p)
|
||
|
||
torch.testing.assert_close(out, ref, rtol=1e-5, atol=1e-6)
|
||
|
||
|
||
def test_min_p_renorm_prob_empty_batch(device: str) -> None:
|
||
probs = torch.empty((0, 32), device=device, dtype=torch.float32)
|
||
min_p = torch.empty((0,), device=device, dtype=torch.float32)
|
||
|
||
out = min_p_renorm_prob(probs, min_p)
|
||
|
||
assert out.shape == probs.shape
|
||
assert out.dtype == probs.dtype
|