chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:32:31 +08:00
commit 59a0a3844c
1103 changed files with 340460 additions and 0 deletions
@@ -0,0 +1,22 @@
# 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.
# Concrete backend modules are imported lazily in sampling.registry to avoid
# circular imports (default.py imports register_backend from registry).
@@ -0,0 +1,285 @@
# 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
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING
import torch
import torch.distributed as dist
if TYPE_CHECKING:
from tokenspeed.runtime.layers.logits_processor import LogitsProcessorOutput
from tokenspeed.runtime.sampling.dp_sampling_config import DpSamplingRuntimeConfig
from tokenspeed.runtime.sampling.sampling_batch_info import SamplingBatchInfo
from tokenspeed.runtime.sampling.sampling_params import SamplingParams
from tokenspeed.runtime.utils.server_args import ServerArgs
DEFAULT_RANDOM_SEED = 48
CUDA_GRAPH_VARIANT_DEFAULT = "default"
SPECULATIVE_ACCEPT_THRESHOLD_SINGLE = 1.0
SPECULATIVE_ACCEPT_THRESHOLD_ACC = 1.0
@dataclass
class SamplingBackendConfig:
enable_nan_detection: bool = False
# Optional logprob features — OFF by default. These are checked at server
# start / graph capture time so the fast path has zero extra compute.
# Enabling any of these enlarges the captured graph footprint.
enable_output_logprobs: bool = False
# Sizing for pre-allocated per-backend buffers (e.g. coin buffers for
# rejection sampling). Required to keep RNG out of the CUDA graph.
max_bs: int = 1
max_draft_tokens_per_req: int = 1
# Sizing for backend-owned per-request state (e.g. token-count buffers
# for penalties in FlashInferFullSamplingBackend). Indexed by req_pool_idx, not
# batch row, so the data survives batch membership changes.
max_req_pool_size: int = 0
vocab_size: int = 0
device: torch.device | None = None
random_seed: int = DEFAULT_RANDOM_SEED
# Attention TP group for sampler-output broadcast (rank 0 wins).
tp_group: tuple[int, ...] | None = None
enable_tp_sync: bool = True
@classmethod
def from_server_args(
cls,
server_args: ServerArgs,
*,
max_bs: int,
max_draft_tokens_per_req: int,
device: str,
random_seed: int = DEFAULT_RANDOM_SEED,
max_req_pool_size: int = 0,
vocab_size: int = 0,
tp_group: tuple[int, ...] | None = None,
) -> SamplingBackendConfig:
return cls(
enable_nan_detection=server_args.enable_nan_detection,
enable_output_logprobs=server_args.enable_output_logprobs,
max_bs=max_bs,
max_draft_tokens_per_req=max(max_draft_tokens_per_req, 1),
max_req_pool_size=max_req_pool_size,
vocab_size=vocab_size,
device=device,
random_seed=random_seed,
tp_group=tp_group,
enable_tp_sync=not server_args.disable_sampling_tp_sync,
)
class SamplingBackend(ABC):
"""Shared contract for single-step sampling and multi-step spec-decode verification.
Both methods return (output_tokens, accept_lengths). For sample(),
accept_lengths is all-ones so the downstream contract matches verify().
Backends that need random state override prepare() to refill per-request
buffers outside of any CUDA graph capture.
Requests asking for params a backend doesn't implement are NOT rejected;
the backend silently applies only what it supports, so all requests go
through the same captured graph.
"""
# Subclasses that hold per-pool-idx state (scalars like temperature /
# top_k, plus large rows like _counts / _logit_bias) flip this to True
# so prepare_step() performs flip detection + _reset_slot. Stateless
# backends (greedy) leave it False and the whole prepare_step call is
# a no-op.
_HAS_POOL_STATE: bool = False
_SUPPORTS_DP_VERIFY: bool = False
def __init__(self, config: SamplingBackendConfig) -> None:
self.config = config
# Sentinel of "which rid currently owns each slot from this backend's
# point of view". rid is just a comparison value here, not a lookup
# key, so this is pool-keyed state (size O(pool_rows) strings), not
# rid-keyed state. A mismatch against the incoming rid is a flip.
if self._HAS_POOL_STATE:
pool_rows = config.max_req_pool_size + 1
self._last_rid_per_slot: list[str | None] = [None] * pool_rows
# Resolved once; None means maybe_broadcast is a no-op.
self._tp_pg = None
self._tp_src_global_rank: int | None = None
if (
config.enable_tp_sync
and config.tp_group is not None
and len(config.tp_group) > 1
):
from tokenspeed.runtime.distributed.process_group_manager import (
process_group_manager as pg_manager,
)
self._tp_pg = pg_manager.get_process_group("nccl", config.tp_group)
self._tp_src_global_rank = config.tp_group[0]
def configure_dp_sampling(self, runtime: DpSamplingRuntimeConfig) -> None:
"""Configure optional DP sampling state.
Stateless or unsupported backends ignore this; DP-capable backends
override it to initialize backend-local communication buffers.
"""
def maybe_broadcast(self, *tensors: torch.Tensor) -> None:
"""Broadcast each tensor from tp_group[0] so all attention-TP ranks
agree. No-op when sync is off or tp_size <= 1. Graph-safe."""
if self._tp_pg is None:
return
for t in tensors:
dist.broadcast(t, src=self._tp_src_global_rank, group=self._tp_pg)
def prepare_step(
self,
request_ids: list[str],
request_pool_indices: list[int],
sampling_params_list: list[SamplingParams],
num_tokens_per_req: int = 1,
) -> None:
"""Called once per step, outside the CUDA graph. Two jobs:
1. Flip detection: a slot's owning rid changed since last step
(first-use and rid-recycling look the same). Delegates to
_reset_slot which scatters all per-slot persistent state
(scalars, counts, bias, generators).
2. Per-step dynamic refill: coin buffers, etc. Delegated to the
subclass via _prepare_step_hook.
Stateless backends (greedy) short-circuit both phases.
"""
if not self._HAS_POOL_STATE:
return
assert (
len(request_ids) == len(request_pool_indices) == len(sampling_params_list)
), (
f"prepare_step expects aligned per-request lists; got "
f"rids={len(request_ids)}, pool_indices={len(request_pool_indices)}, "
f"sp_list={len(sampling_params_list)}"
)
pool_rows = len(self._last_rid_per_slot)
for rid, pool_idx, sp in zip(
request_ids, request_pool_indices, sampling_params_list
):
assert (
0 <= pool_idx < pool_rows
), f"pool_idx {pool_idx} out of range [0, {pool_rows}) for rid={rid}"
if self._last_rid_per_slot[pool_idx] != rid:
self._reset_slot(pool_idx, sp)
self._last_rid_per_slot[pool_idx] = rid
self._prepare_step_hook(
num_tokens_per_req=num_tokens_per_req,
bs=len(request_pool_indices),
request_pool_indices=request_pool_indices,
)
def prepare_capture(self, bs: int, num_tokens_per_req: int = 1) -> None:
"""Per-step refill for the capture/warm-up path. No flip detection;
the backend uses its stub generator for any RNG-fed buffers so the
captured graph sees a fully-written state.
Default: no-op.
"""
self._prepare_step_hook(
num_tokens_per_req=num_tokens_per_req,
bs=bs,
request_pool_indices=None,
)
def cuda_graph_capture_variants(self, num_tokens_per_req: int) -> tuple[str, ...]:
"""Return sampler-specific CUDA graph variants to capture."""
return (CUDA_GRAPH_VARIANT_DEFAULT,)
def prepare_capture_variant(
self,
bs: int,
num_tokens_per_req: int,
variant: str,
) -> None:
if variant != CUDA_GRAPH_VARIANT_DEFAULT:
raise ValueError(f"Unsupported CUDA graph variant: {variant}")
self.prepare_capture(bs=bs, num_tokens_per_req=num_tokens_per_req)
def cuda_graph_replay_variant(self, num_tokens_per_req: int) -> str:
return CUDA_GRAPH_VARIANT_DEFAULT
def _prepare_step_hook(
self,
num_tokens_per_req: int,
bs: int,
request_pool_indices: list[int] | None,
) -> None:
"""Subclass hook for per-step dynamic state (coin buffers, etc).
request_pool_indices=None is the capture path; otherwise the CPU
list from forward_op.request_pool_indices.
Default: no-op."""
def _reset_slot(self, pool_idx: int, sp: SamplingParams) -> None:
"""Scatter all per-slot persistent state for a newly-assigned slot.
Called from prepare_step on flip. Stateful backends override."""
raise NotImplementedError
def reset_capture_state(self) -> None:
"""Clear any per-pool state that warm-up iterations may have dirtied
before CUDA graph capture. Warm-up runs sample()/verify() against
pool row 0 (see CudaGraphWrapper capture path); stateful backends
override this to zero whatever row 0 accumulates. Default: no-op."""
def get_packed_output_d2h(
self,
output_tokens: torch.Tensor,
output_lengths: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor] | None:
"""If the backend wrote both outputs into a single contiguous GPU
buffer, return CPU views obtained from one D2H copy. Otherwise
return None and let the caller fall back to two separate D2Hs."""
return None
@abstractmethod
def sample(
self,
logits_output: LogitsProcessorOutput,
sampling_info: SamplingBatchInfo,
) -> tuple[torch.Tensor, torch.Tensor]: ...
@abstractmethod
def verify(
self,
logits_output: LogitsProcessorOutput,
sampling_info: SamplingBatchInfo,
candidates: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]: ...
@@ -0,0 +1,621 @@
# 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
from typing import TYPE_CHECKING
import torch
from tokenspeed_kernel.ops.sampling import argmax as sampling_argmax
from tokenspeed_kernel.ops.sampling.cuda import (
chain_speculative_sampling_target_only,
fused_topk_topp_prepare,
fused_topk_topp_renorm,
verify_chain_greedy,
)
from tokenspeed_kernel.ops.sampling.flashinfer import (
softmax,
top_k_renorm_prob,
top_k_top_p_sampling_from_probs,
top_p_renorm_prob,
)
from tokenspeed_kernel.ops.sampling.triton import gather_and_expand_scalars
from tokenspeed_kernel.platform import current_platform
from tokenspeed_kernel.torch_compile import get_compiler_backend
# Resolved once at import: the fused top-k + top-p kernel is NVIDIA-only.
# On non-NVIDIA platforms (e.g. ROCm) we fall back to the back-to-back
# flashinfer renorm calls. Defining this at module scope keeps the hot path
# branch-free in the captured graph.
_FUSED_TOPK_TOPP_AVAILABLE = current_platform().is_nvidia
from tokenspeed.runtime.distributed.dp_sampling_comm import DpSamplingComm
from tokenspeed.runtime.sampling.backends.base import (
SPECULATIVE_ACCEPT_THRESHOLD_ACC,
SPECULATIVE_ACCEPT_THRESHOLD_SINGLE,
SamplingBackend,
SamplingBackendConfig,
)
from tokenspeed.runtime.sampling.dp_sampling_config import (
DpSamplingRuntimeConfig,
slice_dp_vocab_mask,
)
from tokenspeed.runtime.sampling.registry import register_backend
from tokenspeed.runtime.sampling.utils import (
coin_eps,
gather_token_logprobs_torch,
)
from tokenspeed.runtime.utils.nvtx import nvtx_range
from tokenspeed.runtime.utils.pdl import pdl_enabled
if TYPE_CHECKING:
from tokenspeed.runtime.layers.logits_processor import LogitsProcessorOutput
from tokenspeed.runtime.sampling.sampling_batch_info import SamplingBatchInfo
from tokenspeed.runtime.sampling.sampling_params import SamplingParams
class FlashInferSamplingBackend(SamplingBackend):
"""Fast backend: fused softmax(temperature) + top_k_top_p_sampling_from_probs
for stochastic single-step sampling; cuda chain kernels (greedy +
rejection) for multi-step verification.
Scope is deliberately narrow — temperature / top_k / top_p only —
keeping the hot path to 2 kernels. Requests asking for min_p, penalties,
or logit_bias are silently ignored; use `flashinfer_full` if any of those
matter for the workload.
"""
_HAS_POOL_STATE = True
_SUPPORTS_DP_VERIFY = True
def __init__(self, config: SamplingBackendConfig) -> None:
super().__init__(config)
self._init_dp_sampling(config)
self._init_shared_buffers(config)
self._init_pool_scalars(config)
# Pre-create the side stream used by fused_topk_topp_renorm. Must
# happen before any CUDA graph capture — cudaStreamCreate is illegal
# inside capture, and verify() runs from the captured graph.
fused_topk_topp_prepare(config.device)
def _init_dp_sampling(self, config: SamplingBackendConfig) -> None:
self._dp_tp_group = config.tp_group
self._dp_tp_size = (
len(self._dp_tp_group) if self._dp_tp_group is not None else 1
)
self._dp_rank = 0
self._dp_comm: DpSamplingComm | None = None
self._dp_comm_vocab_size = 0
if self._dp_tp_size <= 1:
self._dp_max_pad_bs = config.max_bs
self._dp_max_reqs_per_rank = config.max_bs
return
self._dp_max_pad_bs = (
(config.max_bs + self._dp_tp_size - 1) // self._dp_tp_size
) * self._dp_tp_size
self._dp_max_reqs_per_rank = self._dp_max_pad_bs // self._dp_tp_size
def configure_dp_sampling(self, runtime: DpSamplingRuntimeConfig) -> None:
if not runtime.enabled:
return
if (
runtime.vocab_size is None
or runtime.max_bucket_bs is None
or runtime.topology is None
or runtime.device is None
):
raise RuntimeError("enabled DP sampling runtime is incomplete")
topology = runtime.topology
if topology.tp_size != self._dp_tp_size:
raise RuntimeError(
f"DP sampling runtime tp_size={topology.tp_size} "
f"does not match backend tp_size={self._dp_tp_size}"
)
if topology.tp_group != self._dp_tp_group:
raise RuntimeError("DP sampling runtime tp_group does not match backend")
if self._dp_tp_group is None:
raise RuntimeError("dp_sampling requires a tp_group")
self._dp_rank = topology.tp_rank
if runtime.max_bucket_bs > self._dp_max_pad_bs:
raise RuntimeError(
f"DP sampling max_bucket_bs={runtime.max_bucket_bs} exceeds "
f"backend max_pad_bs={self._dp_max_pad_bs}"
)
if runtime.vocab_size % self._dp_tp_size != 0:
raise RuntimeError(
f"DP sampling vocab_size={runtime.vocab_size} must be divisible by "
f"tp_size={self._dp_tp_size}"
)
self._init_dp_verify_buffers(runtime.device)
if runtime.vocab_size == self._dp_comm_vocab_size:
return
if self._dp_comm is not None and self._dp_comm.is_initialized:
raise RuntimeError("Cannot resize DP sampling comm after use")
self._dp_comm_vocab_size = runtime.vocab_size
self._dp_comm = DpSamplingComm(
tp_size=self._dp_tp_size,
rank=self._dp_rank,
group=self._dp_tp_group,
max_pad_bs=self._dp_max_pad_bs,
num_tokens_per_req=runtime.num_tokens_per_req,
vocab_size=runtime.vocab_size,
logits_dtype=None,
device=runtime.device,
)
def _init_dp_verify_buffers(self, device: torch.device | str) -> None:
if self._predict_local_buf is not None:
return
max_n = self.config.max_draft_tokens_per_req
self._predict_local_buf = torch.zeros(
(self._dp_max_reqs_per_rank * max_n,), dtype=torch.int32, device=device
)
self._accept_index_local_buf = torch.zeros(
(self._dp_max_reqs_per_rank * max_n,), dtype=torch.int32, device=device
)
self._accept_length_local_buf = torch.zeros(
(self._dp_max_reqs_per_rank,), dtype=torch.int32, device=device
)
def _init_pool_scalars(self, config: SamplingBackendConfig) -> None:
# Capture warm-up reads row 0 with req_pool_indices zeroed, so row 0
# must carry neutral-sampling values that can't produce nan/inf.
pool_rows = config.max_req_pool_size + 1
self._temperature_pool = torch.ones(
(pool_rows,), dtype=torch.float32, device=config.device
)
self._top_k_pool = torch.ones(
(pool_rows,), dtype=torch.int32, device=config.device
)
self._top_p_pool = torch.ones(
(pool_rows,), dtype=torch.float32, device=config.device
)
self._seed_pool = torch.zeros(
(pool_rows,), dtype=torch.int64, device=config.device
)
# Per-slot CPU-side torch.Generators used to advance speculative
# coin buffers outside the CUDA graph. Seeded on flip from sp.seed.
# Slot 0 is pre-filled with _capture_gen so capture warm-up works
# without any real request having been registered.
#
# Retract-resume note: if a request is retracted and later takes a
# different pool slot on resume, _reset_slot re-seeds a fresh
# Generator from sp.seed. Sampling stays deterministic given the same
# seed, and flashinfer's Philox path (seed + seq_len offset) already
# gives per-step uniqueness independent of the torch.Generator.
self._cpu_generator_per_slot: list[torch.Generator | None] = [None] * pool_rows
self._cpu_generator_per_slot[0] = self._capture_gen
def _reset_slot(self, pool_idx: int, sp: SamplingParams) -> None:
self._temperature_pool[pool_idx].fill_(float(sp.temperature))
self._top_k_pool[pool_idx].fill_(int(sp.top_k))
self._top_p_pool[pool_idx].fill_(float(sp.top_p))
self._seed_pool[pool_idx].fill_(int(sp.seed))
cpu_gen = torch.Generator(device="cpu")
cpu_gen.manual_seed(int(sp.seed))
self._cpu_generator_per_slot[pool_idx] = cpu_gen
def _init_shared_buffers(self, config: SamplingBackendConfig) -> None:
max_pad_bs = self._dp_max_pad_bs
max_n = config.max_draft_tokens_per_req
# Persistent coin buffers. Filled per-request in prepare() outside the
# CUDA graph so verify() only reads from them.
self._coins_buf = torch.zeros(
(max_pad_bs, max_n),
dtype=torch.float32,
device=config.device,
)
self._final_coins_buf = torch.zeros(
(max_pad_bs,), dtype=torch.float32, device=config.device
)
# Stub generator used during CUDA-graph capture/warm-up (no requests yet).
self._capture_gen = torch.Generator(device=config.device)
self._capture_gen.manual_seed(config.random_seed)
# Pre-allocated persistent buffers — no per-step alloc in the hot path.
self._ones_buf = torch.ones(
(max_pad_bs,), dtype=torch.int32, device=config.device
)
# predict + accept_length share one packed backing store.
# Layout: [0, max_bs * max_n) is predict, [max_bs * max_n, total)
# is accept_length.
self._predict_max = max_pad_bs * max_n
self._output_pack_buf = torch.zeros(
(self._predict_max + max_pad_bs,),
dtype=torch.int32,
device=config.device,
)
self._predict_buf = self._output_pack_buf[: self._predict_max]
self._accept_length_buf = self._output_pack_buf[self._predict_max :]
# Flat layout so [:bs * n].view(bs, n) is contiguous for any bs/n.
self._accept_index_buf = torch.zeros(
(max_pad_bs * max_n,),
dtype=torch.int32,
device=config.device,
)
self._predict_local_buf: torch.Tensor | None = None
self._accept_index_local_buf: torch.Tensor | None = None
self._accept_length_local_buf: torch.Tensor | None = None
@torch.compile(dynamic=True, backend=get_compiler_backend())
def _prepare_step_hook(
self,
num_tokens_per_req: int,
bs: int,
request_pool_indices: list[int] | None = None,
) -> None:
"""Refill persistent coin buffers outside the captured graph.
request_pool_indices=None is the capture/warm-up path — uses
_capture_gen for all rows. Otherwise reads per-slot generators
populated via _reset_slot."""
if bs <= 0:
return
n = min(num_tokens_per_req, self.config.max_draft_tokens_per_req)
lo = coin_eps(self._coins_buf.dtype)
if request_pool_indices is None:
self._coins_buf[:bs, :n].uniform_(lo, 1.0, generator=self._capture_gen)
self._final_coins_buf[:bs].uniform_(lo, 1.0, generator=self._capture_gen)
return
cpu_coins = torch.empty((bs, n), dtype=torch.float32, pin_memory=True)
cpu_final = torch.empty((bs,), dtype=torch.float32, pin_memory=True)
for i, pool_idx in enumerate(request_pool_indices):
gen = self._cpu_generator_per_slot[pool_idx]
if gen is None:
raise RuntimeError(
f"sampling slot {pool_idx} was not initialized before "
"coin-buffer refill"
)
cpu_coins[i, :n].uniform_(lo, 1.0, generator=gen)
cpu_final[i].uniform_(lo, 1.0, generator=gen)
self._coins_buf[:bs, :n].copy_(cpu_coins, non_blocking=True)
self._final_coins_buf[:bs].copy_(cpu_final, non_blocking=True)
@nvtx_range("sampling:sample", color="yellow")
def sample(
self,
logits_output: LogitsProcessorOutput,
sampling_info: SamplingBatchInfo,
) -> tuple[torch.Tensor, torch.Tensor]:
logits = logits_output.next_token_logits
# Grammar bitmask apply — captured inside the CUDA graph. Buffer is
# pre-bound by bind_grammar_mask_buf; non-grammar rows stay all-ones.
if sampling_info.vocab_mask is not None:
sampling_info.apply_vocab_mask(
logits=logits, vocab_mask=sampling_info.vocab_mask
)
if sampling_info.is_all_greedy:
batch_next_token_ids = sampling_argmax(logits)
else:
temperatures, top_ks, top_ps, _, seeds, offsets = gather_and_expand_scalars(
sampling_info.req_pool_indices,
temperature=self._temperature_pool,
top_k=self._top_k_pool,
top_p=self._top_p_pool,
seed=self._seed_pool,
offsets=sampling_info.valid_cache_lengths,
enable_pdl=pdl_enabled(),
)
probs = softmax(
logits,
temperature=temperatures.view(-1, 1),
enable_pdl=pdl_enabled(),
)
batch_next_token_ids = top_k_top_p_sampling_from_probs(
probs,
top_ks,
top_ps,
filter_apply_order="joint",
seed=seeds,
offset=offsets,
deterministic=True,
)
sampled = batch_next_token_ids.to(torch.int32)
# TP-rank sync: rank 0 wins.
self.maybe_broadcast(sampled)
if self.config.enable_output_logprobs:
logits_output.next_token_logprobs = gather_token_logprobs_torch(
logits, sampled
)
bs = logits.shape[0]
return sampled, self._ones_buf[:bs]
@nvtx_range("sampling:verify", color="yellow")
def verify(
self,
logits_output: LogitsProcessorOutput,
sampling_info: SamplingBatchInfo,
candidates: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
bs = candidates.shape[0]
num_tokens_per_req = candidates.shape[1]
vocab_mask = sampling_info.vocab_mask
logits_layout_plan = getattr(logits_output, "logits_layout_plan", None)
dp_sampling = logits_layout_plan is not None
if dp_sampling:
if self._dp_comm is None:
raise RuntimeError(
"dp_sampling requires tp_size > 1, a resolved tp_group, "
"and a configured DP comm"
)
dp_comm = self._dp_comm
tp_size = self._dp_tp_size
rank = self._dp_rank
effective_bs = logits_layout_plan.effective_bs
pad_bs = logits_layout_plan.bucket_bs
if effective_bs != bs:
raise RuntimeError(
f"DP sampling effective_bs={effective_bs} must match "
f"candidate batch size {bs}"
)
if (
pad_bs < effective_bs
or pad_bs > self._dp_max_pad_bs
or pad_bs % tp_size != 0
):
raise RuntimeError(
f"invalid DP sampling pad_bs={pad_bs} for effective_bs={effective_bs}, "
f"max_pad_bs={self._dp_max_pad_bs}, tp_size={tp_size}"
)
bs = pad_bs // tp_size
# Shard by request so each request's draft chain stays on one rank.
shard = slice(rank * bs, (rank + 1) * bs)
if pad_bs > effective_bs:
candidates = torch.nn.functional.pad(
candidates, (0, 0, 0, pad_bs - effective_bs)
)[shard]
pool_indices = torch.nn.functional.pad(
sampling_info.req_pool_indices, (0, pad_bs - effective_bs)
)[shard]
else:
candidates = candidates[shard]
pool_indices = sampling_info.req_pool_indices[shard]
vocab_mask = slice_dp_vocab_mask(
vocab_mask,
full_bs=effective_bs,
pad_bs=pad_bs,
num_tokens_per_req=num_tokens_per_req,
shard=shard,
)
coins = self._coins_buf[shard]
final_coins = self._final_coins_buf[shard]
if (
self._predict_local_buf is None
or self._accept_index_local_buf is None
or self._accept_length_local_buf is None
):
raise RuntimeError("DP sampling verify buffers are not initialized")
predict = self._predict_local_buf[: bs * num_tokens_per_req]
accept_index = (
self._accept_index_local_buf[: bs * num_tokens_per_req]
.view(bs, num_tokens_per_req)
.fill_(-1)
)
accept_length = self._accept_length_local_buf[:bs]
else:
pool_indices = sampling_info.req_pool_indices
coins = self._coins_buf
final_coins = self._final_coins_buf
predict = self._predict_buf[: bs * num_tokens_per_req]
accept_index = (
self._accept_index_buf[: bs * num_tokens_per_req]
.view(bs, num_tokens_per_req)
.fill_(-1)
)
accept_length = self._accept_length_buf[:bs]
logits = logits_output.next_token_logits
if dp_sampling:
expected_rows = bs * num_tokens_per_req
if logits.shape[0] != expected_rows:
raise RuntimeError(
f"DP sampling logits rows {logits.shape[0]} != expected "
f"{expected_rows}"
)
# Per-draft-position grammar bitmask: buffer shape
# [bs * num_tokens_per_req, V/32] matches the flat target logits.
if vocab_mask is not None:
sampling_info.apply_vocab_mask(
logits=logits,
vocab_mask=vocab_mask,
)
if sampling_info.is_all_greedy:
target_predict = sampling_argmax(logits).reshape(bs, num_tokens_per_req)
verify_chain_greedy(
predicts=predict,
accept_index=accept_index,
accept_token_num=accept_length,
candidates=candidates,
target_predict=target_predict,
batch_size=bs,
num_draft_tokens=num_tokens_per_req,
enable_pdl=pdl_enabled(),
)
else:
# Each request's N verified positions share one (temp, top_k, top_p)
# tuple; flat [bs*N] per-row knobs match the flat [bs*N, vocab] logits.
n = num_tokens_per_req
temperatures, top_ks, top_ps, _, _, _ = gather_and_expand_scalars(
pool_indices,
temperature=self._temperature_pool,
top_k=self._top_k_pool,
top_p=self._top_p_pool,
n=n,
enable_pdl=pdl_enabled(),
)
target_probs = softmax(
logits,
temperature=temperatures,
enable_pdl=pdl_enabled(),
)
if _FUSED_TOPK_TOPP_AVAILABLE:
# Fused replacement for the back-to-back top_k_renorm_prob +
# top_p_renorm_prob(is_deterministic=True) pair. Sentinel
# K = 1<<30 in top_ks routes per-row through the radix top-p
# only path.
target_probs = fused_topk_topp_renorm(
target_probs,
top_ks,
top_ps,
enable_pdl=pdl_enabled(),
)
else:
target_probs = top_k_renorm_prob(target_probs, top_ks)
target_probs = top_p_renorm_prob(
target_probs, top_ps, is_deterministic=True
)
target_probs = target_probs.reshape(bs, n, -1)
chain_speculative_sampling_target_only(
predicts=predict,
accept_index=accept_index,
accept_token_num=accept_length,
candidates=candidates,
uniform_samples=coins[:bs, :n],
uniform_samples_for_final_sampling=final_coins[:bs],
target_probs=target_probs,
draft_probs=None,
threshold_single=SPECULATIVE_ACCEPT_THRESHOLD_SINGLE,
threshold_acc=SPECULATIVE_ACCEPT_THRESHOLD_ACC,
deterministic=not dp_sampling,
enable_pdl=pdl_enabled(),
)
accept_length += 1
logprobs_local = None
if self.config.enable_output_logprobs and dp_sampling:
# DP verify logits are still sharded by request at this point.
# Compute scalar logprobs for local predictions before gathering
# predictions to full-batch shape; the non-DP writer requires
# matching logits/token row counts.
logprobs_local = gather_token_logprobs_torch(logits, predict).view(
bs, num_tokens_per_req
)
if dp_sampling:
n = num_tokens_per_req
dp_comm.prepare_verify_outputs(logits_output.next_token_logits.dtype)
(
predict_full,
accept_index_full,
accept_length_full,
) = dp_comm.gather_verify_outputs(
predict_local=predict.view(bs, n),
accept_index_local=accept_index,
accept_length_local=accept_length,
pad_bs=pad_bs,
)
predict = predict_full.view(-1)[: effective_bs * n]
accept_index = accept_index_full[:effective_bs]
accept_length = accept_length_full[:effective_bs]
if logprobs_local is not None:
logprobs_full = dp_comm.gather_verify_logprobs(
logprobs_local,
pad_bs=pad_bs,
)
logits_output.next_token_logprobs = logprobs_full.view(-1)[
: effective_bs * n
]
# TP-rank sync: rank 0 wins on the full verify-output triple.
# Load-bearing: flashinfer top_k_renorm_prob has no is_deterministic
# knob and produces non-bit-identical results across ranks (sub-ulp
# FP accumulation order).
# PDL still uses rank-0 outputs to keep ranks aligned. Without PDL,
# fused top-k + top-p is bit-identical across ranks and does not need
# a broadcast.
elif pdl_enabled():
self.maybe_broadcast(predict, accept_index, accept_length)
elif not _FUSED_TOPK_TOPP_AVAILABLE:
self.maybe_broadcast(predict, accept_index, accept_length)
if self.config.enable_output_logprobs and not dp_sampling:
logits_output.next_token_logprobs = gather_token_logprobs_torch(
logits, predict
)
return predict, accept_length
def get_packed_output_d2h(
self,
output_tokens: torch.Tensor,
output_lengths: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor] | None:
"""One D2H of the packed predict+accept_length region.
Only applies when both outputs alias into ``_output_pack_buf`` (the
verify() path). For ``sample()``, ``output_tokens`` is a fresh
argmax/top_k_top_p result and ``output_lengths`` is ``_ones_buf``,
neither of which lives in the pack. We fall back to two D2Hs.
"""
if (
output_tokens.data_ptr() != self._output_pack_buf.data_ptr()
or output_lengths.data_ptr() != self._accept_length_buf.data_ptr()
):
return None
n_t = output_tokens.numel()
n_l = output_lengths.numel()
# Copy the whole [0, predict_max + n_l). The gap [n_t, predict_max)
# is stale padding (max_bs * max_n vs. bs * n) — small enough that
# the saved launch beats the wasted bandwidth.
size = self._predict_max + n_l
cpu_pack = torch.empty(size, dtype=torch.int32, pin_memory=True)
cpu_pack.copy_(self._output_pack_buf[:size], non_blocking=True)
return (
cpu_pack[:n_t].view(output_tokens.shape),
cpu_pack[self._predict_max : self._predict_max + n_l],
)
register_backend("flashinfer", FlashInferSamplingBackend)
@@ -0,0 +1,495 @@
# 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
from typing import TYPE_CHECKING
import torch
from tokenspeed_kernel.ops.sampling.cuda import (
chain_speculative_sampling_target_only,
fused_topk_topp_renorm,
)
from tokenspeed_kernel.ops.sampling.flashinfer import (
min_p_sampling_from_probs,
softmax,
top_k_renorm_prob,
top_p_renorm_prob,
)
from tokenspeed_kernel.ops.sampling.triton import (
gather_and_expand_scalars,
min_p_renorm_prob,
)
from tokenspeed_kernel.torch_compile import get_compiler_backend
from tokenspeed.runtime.sampling.backends.base import (
SPECULATIVE_ACCEPT_THRESHOLD_ACC,
SPECULATIVE_ACCEPT_THRESHOLD_SINGLE,
SamplingBackendConfig,
)
from tokenspeed.runtime.sampling.backends.flashinfer import (
_FUSED_TOPK_TOPP_AVAILABLE,
FlashInferSamplingBackend,
)
from tokenspeed.runtime.sampling.registry import register_backend
from tokenspeed.runtime.utils.nvtx import nvtx_range
from tokenspeed.runtime.utils.pdl import pdl_enabled
if TYPE_CHECKING:
from tokenspeed.runtime.layers.logits_processor import LogitsProcessorOutput
from tokenspeed.runtime.sampling.sampling_batch_info import SamplingBatchInfo
from tokenspeed.runtime.sampling.sampling_params import SamplingParams
class FlashInferFullSamplingBackend(FlashInferSamplingBackend):
"""Superset of `flashinfer` adding min_p, frequency/presence/repetition
penalties, and per-token logit_bias, for both single-step sampling and
multi-step spec-decode verification.
Stochastic path runs the 4-kernel sequence softmax(temperature) →
top_k_renorm → top_p_renorm → min_p_sampling, unconditionally (requests
with min_p == 0 are a no-op through min_p_sampling_from_probs) so the
captured CUDA graph matches the runtime flow.
Layout:
* Per-pool-idx token counts (int32[max_req_pool_size, vocab]) —
accumulated after each sample/verify. Zeroed when a pool slot is
re-assigned to a new rid (see `on_pool_assignment`).
* Per-pool-idx logit bias (bf16[max_req_pool_size, vocab]) — zero by
default, scattered from SamplingParams.logit_bias on pool
assignment. Added to logits per step.
* Per-batch-row bf16 penalty scalars flowing through SamplingBatchInfo.
sample() / verify() apply (in order, BEFORE temperature/softmax):
1. repetition (multiplicative): logits = where(count>0,
where(logits>0, logits/rep, logits*rep), logits)
2. frequency + presence (additive):
logits -= freq_pen * count + pres_pen * (count>0)
3. logit_bias (additive): logits += logit_bias[req_pool_idx]
Post-sample/verify, accumulate accepted tokens into counts.
Out of scope in this iteration: min_new_tokens EOS mask, grammar vocab
mask. Both remain silently-ignored no-ops.
"""
_SUPPORTS_DP_VERIFY = False
def __init__(self, config: SamplingBackendConfig) -> None:
super().__init__(config)
if config.max_req_pool_size <= 0 or config.vocab_size <= 0:
raise ValueError(
"FlashInferFullSamplingBackend requires max_req_pool_size > 0 and "
f"vocab_size > 0; got max_req_pool_size={config.max_req_pool_size}, "
f"vocab_size={config.vocab_size}"
)
# Valid pool indices run 0..max_req_pool_size inclusive.
pool_rows = config.max_req_pool_size + 1
self._counts = torch.zeros(
(pool_rows, config.vocab_size),
dtype=torch.int32,
device=config.device,
)
# bf16 is enough precision for typical client-supplied bias values
# (OpenAI caps |logit_bias| at 100).
self._logit_bias = torch.zeros(
(pool_rows, config.vocab_size),
dtype=torch.bfloat16,
device=config.device,
)
# Per-request penalty scalars + min_p. rep_pen starts at 1.0
# (multiplicative identity); others at 0.0 (additive identity).
self._min_p_pool = torch.zeros(
(pool_rows,), dtype=torch.float32, device=config.device
)
self._freq_pen_pool = torch.zeros(
(pool_rows,), dtype=torch.bfloat16, device=config.device
)
self._pres_pen_pool = torch.zeros(
(pool_rows,), dtype=torch.bfloat16, device=config.device
)
self._rep_pen_pool = torch.full(
(pool_rows,), 1.0, dtype=torch.bfloat16, device=config.device
)
# ------------------------------------------------------------------
# Lifecycle hooks
# ------------------------------------------------------------------
def _reset_slot(self, pool_idx: int, sp: SamplingParams) -> None:
# Scatter scalars inherited from the parent backend (temperature, top_k,
# top_p, seed).
super()._reset_slot(pool_idx, sp)
# Penalty + min_p scalars.
self._min_p_pool[pool_idx].fill_(float(sp.min_p))
self._freq_pen_pool[pool_idx].fill_(float(sp.frequency_penalty))
self._pres_pen_pool[pool_idx].fill_(float(sp.presence_penalty))
self._rep_pen_pool[pool_idx].fill_(float(sp.repetition_penalty))
# Zero the slot's count row (history from the previous occupant is
# no longer applicable).
self._counts[pool_idx].fill_(0)
# Zero + scatter logit_bias for the new rid. Zeroing the whole row
# first rather than diffing because the previous occupant's bias
# keys are unknown here.
self._logit_bias[pool_idx].fill_(0.0)
bias_map = getattr(sp, "logit_bias", None) if sp is not None else None
if bias_map:
vocab = self._logit_bias.shape[1]
raw_ids = [int(tid) for tid in bias_map.keys()]
assert all(0 <= tid < vocab for tid in raw_ids), (
f"logit_bias contains out-of-vocab token id(s); "
f"vocab_size={vocab}, offending={[t for t in raw_ids if not 0 <= t < vocab]}"
)
token_ids = torch.tensor(
raw_ids,
device=self._logit_bias.device,
dtype=torch.long,
)
bias_values = torch.tensor(
list(bias_map.values()),
device=self._logit_bias.device,
dtype=torch.bfloat16,
)
self._logit_bias[pool_idx, token_ids] = bias_values
def reset_capture_state(self) -> None:
# Warm-up iterations route all pool indices to row 0, which
# accumulates sampled tokens into _counts[0]. Zero it so the graph
# captures reads against a clean baseline. _logit_bias[0] is only
# written in on_pool_assignment, so it stays zero across warm-up.
self._counts[0].fill_(0)
# ------------------------------------------------------------------
# Penalty + bias application (shared by sample and verify)
# ------------------------------------------------------------------
@nvtx_range("sampling:penalties", color="yellow")
@torch.compile(dynamic=True, backend=get_compiler_backend())
def _apply_penalties_and_bias(
self,
logits: torch.Tensor,
sampling_info: SamplingBatchInfo,
num_tokens_per_req: int = 1,
) -> torch.Tensor:
"""logits is [bs * num_tokens_per_req, V]. Penalty scalars are gathered
from the pool-indexed buffers. num_tokens_per_req > 1 is the spec-decode
verify() path where per-request scalars are repeat_interleave'd to
align with flat logits.
"""
pool_idx = sampling_info.req_pool_indices
if num_tokens_per_req > 1:
pool_idx = torch.repeat_interleave(pool_idx, num_tokens_per_req, dim=0)
counts = self._counts.index_select(0, pool_idx) # [bs*N, V]
active = counts > 0
counts_f = counts.to(logits.dtype)
active_f = active.to(logits.dtype)
# Gather per-request penalty scalars from the pool. [bs*N] → [bs*N, 1]
# for broadcast against [bs*N, V] logits.
rep = (
self._rep_pen_pool.index_select(0, pool_idx).to(logits.dtype).unsqueeze(-1)
)
freq = (
self._freq_pen_pool.index_select(0, pool_idx).to(logits.dtype).unsqueeze(-1)
)
presence = (
self._pres_pen_pool.index_select(0, pool_idx).to(logits.dtype).unsqueeze(-1)
)
# 1. Repetition (multiplicative). scales is 1.0 where count==0, else
# rep_pen. Apply as logits/scales where logits>0, logits*scales else.
scales = torch.where(active, rep.expand_as(logits), torch.ones_like(logits))
logits = torch.where(logits > 0, logits / scales, logits * scales)
# 2. Frequency + presence (additive). Fused into a single subtract.
logits = logits - freq * counts_f - presence * active_f
# 3. Per-token logit_bias (additive). Rows without a logit_bias are
# all-zero, so the add is a no-op for them.
logits = logits + self._logit_bias.index_select(0, pool_idx)
return logits
@nvtx_range("sampling:accum_counts", color="yellow")
@torch.compile(dynamic=True, backend=get_compiler_backend())
def _accumulate_counts(
self,
pool_idx: torch.Tensor,
tokens: torch.Tensor,
weights: torch.Tensor,
) -> None:
"""Graph-safe in-place scatter: counts[pool_idx, tokens] += weights.
weights is int32; 0 masks invalid rows, 1 accumulates."""
self._counts.index_put_(
(pool_idx, tokens.long()),
weights.to(torch.int32),
accumulate=True,
)
# ------------------------------------------------------------------
# Sample / verify
# ------------------------------------------------------------------
@nvtx_range("sampling:sample", color="yellow")
def sample(
self,
logits_output: LogitsProcessorOutput,
sampling_info: SamplingBatchInfo,
) -> tuple[torch.Tensor, torch.Tensor]:
logits = logits_output.next_token_logits.float()
# Grammar bitmask apply — captured inside the CUDA graph. Buffer is
# pre-bound by bind_grammar_mask_buf; non-grammar rows stay all-ones.
# Applied before raw_logprobs capture so constrained logprobs reflect
# the grammar-masked distribution.
if sampling_info.vocab_mask is not None:
sampling_info.apply_vocab_mask(
logits=logits, vocab_mask=sampling_info.vocab_mask
)
# Raw-distribution logprobs (pre-penalty, pre-temperature) when the
# server flag is on. Gather is done after we know the sampled id.
raw_logprobs = (
torch.log_softmax(logits, dim=-1)
if self.config.enable_output_logprobs
else None
)
logits = self._apply_penalties_and_bias(logits, sampling_info)
temperatures, top_ks, top_ps, min_ps, seeds, offsets = (
gather_and_expand_scalars(
sampling_info.req_pool_indices,
temperature=self._temperature_pool,
top_k=self._top_k_pool,
top_p=self._top_p_pool,
min_p=self._min_p_pool,
seed=self._seed_pool,
offsets=sampling_info.valid_cache_lengths,
enable_pdl=pdl_enabled(),
)
)
probs = softmax(
logits, temperature=temperatures.view(-1, 1), enable_pdl=pdl_enabled()
)
if _FUSED_TOPK_TOPP_AVAILABLE:
# Fused replacement for the back-to-back top_k_renorm_prob +
# top_p_renorm_prob(is_deterministic=True) pair. Sentinel
# K = 1<<30 in top_ks routes per-row through the radix top-p
# only path.
probs = fused_topk_topp_renorm(
probs,
top_ks,
top_ps,
enable_pdl=pdl_enabled(),
)
else:
probs = top_k_renorm_prob(probs, top_ks)
probs = top_p_renorm_prob(probs, top_ps, is_deterministic=True)
batch_next_token_ids = min_p_sampling_from_probs(
probs,
min_ps,
seed=seeds,
offset=offsets,
deterministic=True,
)
sampled = batch_next_token_ids.to(torch.int32)
# TP-rank sync BEFORE _accumulate_counts so per-rank counts stay aligned.
# For fused top-k + top-p, the results are bit-identical across ranks.
# So we don't need to broadcast the results.
if not _FUSED_TOPK_TOPP_AVAILABLE:
self.maybe_broadcast(sampled)
if raw_logprobs is not None:
logits_output.next_token_logprobs = raw_logprobs.gather(
-1, sampled.unsqueeze(-1)
).squeeze(-1)
# Accumulate sampled tokens into counts (greedy path accumulates too
# so mixed later batches see the correct history).
self._accumulate_counts(
sampling_info.req_pool_indices,
sampled,
torch.ones_like(sampled, dtype=torch.int32),
)
bs = logits.shape[0]
return sampled, self._ones_buf[:bs]
@nvtx_range("sampling:verify", color="yellow")
def verify(
self,
logits_output: LogitsProcessorOutput,
sampling_info: SamplingBatchInfo,
candidates: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
bs = candidates.shape[0]
num_tokens_per_req = candidates.shape[1]
predict = self._predict_buf[: bs * num_tokens_per_req]
accept_index = (
self._accept_index_buf[: bs * num_tokens_per_req]
.view(bs, num_tokens_per_req)
.fill_(-1)
)
accept_length = self._accept_length_buf[:bs]
logits = logits_output.next_token_logits.float()
# Per-draft-position grammar bitmask: buffer shape
# [bs * num_tokens_per_req, V/32] matches the flat target logits.
# Applied before raw_logprobs capture so constrained logprobs reflect
# the grammar-masked distribution.
if sampling_info.vocab_mask is not None:
sampling_info.apply_vocab_mask(
logits=logits,
vocab_mask=sampling_info.vocab_mask,
)
# Raw (pre-penalty) logprobs captured before penalty application to
# match sample()'s semantics.
raw_logprobs = (
torch.log_softmax(logits, dim=-1)
if self.config.enable_output_logprobs
else None
)
logits = self._apply_penalties_and_bias(
logits,
sampling_info,
num_tokens_per_req=num_tokens_per_req,
)
temperatures, top_ks, top_ps, min_ps, _, _ = gather_and_expand_scalars(
sampling_info.req_pool_indices,
temperature=self._temperature_pool,
top_k=self._top_k_pool,
top_p=self._top_p_pool,
min_p=self._min_p_pool,
n=num_tokens_per_req,
enable_pdl=pdl_enabled(),
)
target_probs = softmax(
logits, temperature=temperatures.view(-1, 1), enable_pdl=pdl_enabled()
)
if _FUSED_TOPK_TOPP_AVAILABLE:
# Fused replacement for the back-to-back top_k_renorm_prob +
# top_p_renorm_prob(is_deterministic=True) pair. Sentinel
# K = 1<<30 in top_ks routes per-row through the radix top-p
# only path.
target_probs = fused_topk_topp_renorm(
target_probs,
top_ks,
top_ps,
enable_pdl=pdl_enabled(),
)
else:
target_probs = top_k_renorm_prob(target_probs, top_ks)
target_probs = top_p_renorm_prob(
target_probs, top_ps, is_deterministic=True
)
target_probs = min_p_renorm_prob(target_probs, min_ps, enable_pdl=pdl_enabled())
target_probs = target_probs.reshape(bs, num_tokens_per_req, -1)
coins = self._coins_buf[:bs, :num_tokens_per_req]
coins_for_final_sampling = self._final_coins_buf[:bs]
chain_speculative_sampling_target_only(
predicts=predict,
accept_index=accept_index,
accept_token_num=accept_length,
candidates=candidates.to(torch.int32),
uniform_samples=coins,
uniform_samples_for_final_sampling=coins_for_final_sampling,
target_probs=target_probs,
draft_probs=None,
threshold_single=SPECULATIVE_ACCEPT_THRESHOLD_SINGLE,
threshold_acc=SPECULATIVE_ACCEPT_THRESHOLD_ACC,
deterministic=True,
enable_pdl=pdl_enabled(),
)
accept_length += 1
# TP-rank sync BEFORE _accumulate_counts so per-rank counts stay aligned.
# For fused top-k + top-p, the results are bit-identical across ranks.
# So we don't need to broadcast the results.
if not _FUSED_TOPK_TOPP_AVAILABLE:
self.maybe_broadcast(predict, accept_index, accept_length)
# Accumulate accepted tokens into counts. accept_index is [bs, N]
# with -1 in unused slots; clamp to a safe index and mask with a
# weight of 0 so invalid slots are no-ops.
valid = accept_index >= 0 # [bs, N]
safe_positions = accept_index.clamp(min=0).long() # [bs, N]
accepted_tokens = predict.long().gather(0, safe_positions.view(-1))
pool_idx_expanded = (
sampling_info.req_pool_indices.unsqueeze(-1)
.expand(-1, num_tokens_per_req)
.reshape(-1)
)
self._accumulate_counts(
pool_idx_expanded,
accepted_tokens,
valid.reshape(-1).to(torch.int32),
)
if raw_logprobs is not None:
logits_output.next_token_logprobs = raw_logprobs.gather(
-1, predict.unsqueeze(-1)
).squeeze(-1)
return predict, accept_length
register_backend("flashinfer_full", FlashInferFullSamplingBackend)
@@ -0,0 +1,253 @@
# 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
from typing import TYPE_CHECKING
import torch
from tokenspeed_kernel.ops.sampling import argmax as sampling_argmax
from tokenspeed_kernel.ops.sampling.cuda import (
verify_chain_greedy as _verify_chain_greedy_cuda,
)
from tokenspeed_kernel.registry import error_fn
from tokenspeed.runtime.sampling.backends.base import (
SamplingBackend,
SamplingBackendConfig,
)
from tokenspeed.runtime.sampling.registry import register_backend
from tokenspeed.runtime.sampling.utils import gather_token_logprobs_torch
from tokenspeed.runtime.utils.nvtx import nvtx_range
from tokenspeed.runtime.utils.pdl import pdl_enabled
if TYPE_CHECKING:
from tokenspeed.runtime.layers.logits_processor import LogitsProcessorOutput
from tokenspeed.runtime.sampling.sampling_batch_info import SamplingBatchInfo
def _verify_chain_greedy_torch(
predicts: torch.Tensor, # [bs * N] int32, in/out
accept_index: torch.Tensor, # [bs, N] int32, in/out (-1-filled on entry)
accept_token_num: torch.Tensor, # [bs] int32, out
candidates: torch.Tensor, # [bs, N] int32
target_predict: torch.Tensor, # [bs, N] int64 (argmax output)
batch_size: int,
num_draft_tokens: int,
) -> None:
"""Pure-torch equivalent of tokenspeed_kernel.verify_chain_greedy.
Used on non-CUDA devices and when the CUDA kernel is unavailable.
"""
bs = batch_size
n = num_draft_tokens
# For i in 1..n-1: candidates[b, i] accepted iff it equals target_predict[b, i-1].
# Accepted prefix length per row = longest-leading-1s of the match array.
match = candidates[:, 1:] == target_predict[:, :-1].to(
candidates.dtype
) # [bs, n-1]
leading = torch.cumprod(match.to(torch.int32), dim=1) # [bs, n-1]
num_accepted = leading.sum(dim=1).to(torch.int32) # [bs]
# Fill all of `predicts` with target_predict; slots outside the accepted
# prefix are harmless because accept_index keeps them at -1 and callers
# mask on that. Matches the CUDA kernel's observable state.
predicts.copy_(target_predict.reshape(-1).to(torch.int32))
device = candidates.device
pos = torch.arange(n, device=device).unsqueeze(0) # [1, n]
batch_off = torch.arange(bs, device=device).unsqueeze(1) * n # [bs, 1]
flat_idx = (batch_off + pos).to(torch.int32) # [bs, n]
valid = pos <= num_accepted.unsqueeze(1) # [bs, n]
accept_index.copy_(torch.where(valid, flat_idx, torch.full_like(accept_index, -1)))
accept_token_num.copy_(num_accepted)
def _verify_chain_greedy(
predicts: torch.Tensor,
accept_index: torch.Tensor,
accept_token_num: torch.Tensor,
candidates: torch.Tensor,
target_predict: torch.Tensor,
batch_size: int,
num_draft_tokens: int,
enable_pdl: bool = False,
) -> None:
# Prefer the CUDA kernel when available AND the tensors are on CUDA.
if _verify_chain_greedy_cuda is not error_fn and candidates.is_cuda:
_verify_chain_greedy_cuda(
predicts=predicts,
accept_index=accept_index,
accept_token_num=accept_token_num,
candidates=candidates,
target_predict=target_predict,
batch_size=batch_size,
num_draft_tokens=num_draft_tokens,
enable_pdl=enable_pdl,
)
return
_verify_chain_greedy_torch(
predicts=predicts,
accept_index=accept_index,
accept_token_num=accept_token_num,
candidates=candidates,
target_predict=target_predict,
batch_size=batch_size,
num_draft_tokens=num_draft_tokens,
)
class GreedySamplingBackend(SamplingBackend):
"""Greedy-only backend: argmax for single-step, chain-greedy verify for
multi-step verification. No flashinfer / min_p / penalty machinery, no
coin buffers. Verify uses the fused CUDA kernel when available; falls
back to a pure-torch implementation otherwise (CPU, ROCm, etc.).
sampling_info is ignored for single-step (always argmax). verify() also
treats every request as greedy — stochastic verification is not
supported. Intended as the default backend and as a fallback when
flashinfer is unavailable."""
def __init__(self, config: SamplingBackendConfig) -> None:
super().__init__(config)
self._ones_buf = torch.ones(
(config.max_bs,), dtype=torch.int32, device=config.device
)
# Pre-allocated int32 buffer for ``sample``'s argmax output: lets the
# cute_dsl kernel write int32 token ids directly, skipping the
# ``.to(torch.int32)`` cast and its elementwise launch in the
# CUDA-graph-captured hot path.
self._sample_token_buf = torch.empty(
(config.max_bs,), dtype=torch.int32, device=config.device
)
self._predict_buf = torch.zeros(
(config.max_bs * config.max_draft_tokens_per_req,),
dtype=torch.int32,
device=config.device,
)
# Flat layout so [:bs * n].view(bs, n) is contiguous for any bs/n
# (required by maybe_broadcast / NCCL).
self._accept_index_buf = torch.zeros(
(config.max_bs * config.max_draft_tokens_per_req,),
dtype=torch.int32,
device=config.device,
)
self._accept_length_buf = torch.zeros(
(config.max_bs,), dtype=torch.int32, device=config.device
)
@nvtx_range("sampling:sample", color="yellow")
def sample(
self,
logits_output: LogitsProcessorOutput,
sampling_info: SamplingBatchInfo,
) -> tuple[torch.Tensor, torch.Tensor]:
logits = logits_output.next_token_logits
# Grammar bitmask apply — captured inside the CUDA graph. Buffer is
# pre-bound by bind_grammar_mask_buf; non-grammar rows stay all-ones
# so apply is a no-op.
if sampling_info.vocab_mask is not None:
sampling_info.apply_vocab_mask(
logits=logits, vocab_mask=sampling_info.vocab_mask
)
bs = logits.shape[0]
tokens = sampling_argmax(logits, out=self._sample_token_buf[:bs])
# TP-rank sync (rank 0 wins), mirrors FlashInferSamplingBackend.sample.
# All-gathered logits are not bit-identical across ranks, so per-rank
# argmax can diverge; an unsynced token id desyncs batch composition and
# deadlocks a downstream model all-reduce.
self.maybe_broadcast(tokens)
if self.config.enable_output_logprobs:
logits_output.next_token_logprobs = gather_token_logprobs_torch(
logits, tokens
)
return tokens, self._ones_buf[:bs]
@nvtx_range("sampling:verify", color="yellow")
def verify(
self,
logits_output: LogitsProcessorOutput,
sampling_info: SamplingBatchInfo,
candidates: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
bs = candidates.shape[0]
num_tokens_per_req = candidates.shape[1]
predict = self._predict_buf[: bs * num_tokens_per_req]
accept_index = (
self._accept_index_buf[: bs * num_tokens_per_req]
.view(bs, num_tokens_per_req)
.fill_(-1)
)
accept_length = self._accept_length_buf[:bs]
logits = logits_output.next_token_logits
# Per-draft-position grammar bitmask: buffer shape
# [bs * num_tokens_per_req, V/32] matches the flat target logits.
if sampling_info.vocab_mask is not None:
sampling_info.apply_vocab_mask(
logits=logits,
vocab_mask=sampling_info.vocab_mask,
)
target_predict = sampling_argmax(logits).reshape(bs, num_tokens_per_req)
_verify_chain_greedy(
predicts=predict,
accept_index=accept_index,
accept_token_num=accept_length,
candidates=candidates.to(torch.int32),
target_predict=target_predict,
batch_size=bs,
num_draft_tokens=num_tokens_per_req,
enable_pdl=pdl_enabled(),
)
accept_length += 1
# TP-rank sync on the full verify-output triple, mirrors
# FlashInferSamplingBackend.verify. Per-rank argmax / accept-length
# divergence (logits not bit-identical across ranks) desyncs batch
# composition and deadlocks the model all-reduce. Buffers are laid out
# flat so these views are NCCL-contiguous.
self.maybe_broadcast(predict, accept_index, accept_length)
if self.config.enable_output_logprobs:
logits_output.next_token_logprobs = gather_token_logprobs_torch(
logits, predict
)
return predict, accept_length
register_backend("greedy", GreedySamplingBackend)
@@ -0,0 +1,707 @@
# SPDX-License-Identifier: MIT AND Apache-2.0
# SPDX-FileCopyrightText: Copyright (c) 2026 LightSeek Foundation
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
#
# 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.
# TokenSpeed keeps its request-pool state and backend boundary.
from __future__ import annotations
from typing import TYPE_CHECKING
import torch
from tokenspeed_kernel.ops.sampling.cute_dsl import argmax as cute_argmax
from tokenspeed_kernel.ops.sampling.triton import (
_QRITA_PERCENTILE_TO_STD_TABLE,
gumbel_sample_from_pools,
gumbel_sample_from_pools_compact,
gumbel_sample_from_pools_generic,
gumbel_sample_top_k_top_p_from_pools,
gumbel_sample_top_k_top_p_qrita_from_pools,
gumbel_sample_top_p_parallel_from_pools,
selected_token_logprobs,
verify_chain_target_sampled,
)
from tokenspeed.runtime.sampling.backends.base import (
CUDA_GRAPH_VARIANT_DEFAULT,
SamplingBackend,
SamplingBackendConfig,
)
from tokenspeed.runtime.sampling.registry import register_backend
from tokenspeed.runtime.sampling.sampling_params import _SAMPLING_EPS, _TOP_K_DISABLED
from tokenspeed.runtime.sampling.utils import nan_guard_logits
from tokenspeed.runtime.utils.nvtx import nvtx_range
from tokenspeed.runtime.utils.pdl import pdl_enabled
if TYPE_CHECKING:
from tokenspeed.runtime.layers.logits_processor import LogitsProcessorOutput
from tokenspeed.runtime.sampling.sampling_batch_info import SamplingBatchInfo
from tokenspeed.runtime.sampling.sampling_params import SamplingParams
_GUMBEL_BLOCK_SIZE = 1024
_COMPACT_GUMBEL_BLOCK_SIZE = 4096
_COMPACT_GUMBEL_VOCAB_MAX = 32768
_TOP_K_TOP_P_SMALL_BLOCK_SIZE = 1024
_TOP_K_TOP_P_TUNED_VOCAB_MAX = 32768
_TOP_K_TOP_P_PAD = 128
_TOP_P_PARALLEL_SAMPLE_BLOCK_SIZE = 1024
_TOP_P_PARALLEL_SAMPLE_ATTEMPTS = 3
_TOP_P_PARALLEL_VERIFY_ATTEMPTS = 4
_TOP_P_PARALLEL_MAX_ATTEMPTS = max(
_TOP_P_PARALLEL_SAMPLE_ATTEMPTS, _TOP_P_PARALLEL_VERIFY_ATTEMPTS
)
_QRITA_VERIFY_MIN_ROWS = 128
_SAMPLE_ROUTE_GUMBEL_GENERIC = 0
_SAMPLE_ROUTE_GUMBEL_NO_FILTER = 1
_SAMPLE_ROUTE_GUMBEL_TOP_K = 2
_SAMPLE_ROUTE_GUMBEL_TOP_K_TOP_P = 3
_SAMPLE_ROUTE_GUMBEL_TOP_P = 4
CUDA_GRAPH_VARIANT_TRITON_NO_FILTER = "triton_no_filter"
CUDA_GRAPH_VARIANT_TRITON_TOP_K = "triton_top_k"
CUDA_GRAPH_VARIANT_TRITON_TOP_K_TOP_P = "triton_top_k_top_p"
CUDA_GRAPH_VARIANT_TRITON_TOP_P = "triton_top_p"
CUDA_GRAPH_VARIANT_TRITON_VERIFY_NO_FILTER = "triton_verify_no_filter"
_CUDA_GRAPH_VARIANT_SAMPLE_ROUTES = {
CUDA_GRAPH_VARIANT_TRITON_NO_FILTER: _SAMPLE_ROUTE_GUMBEL_NO_FILTER,
CUDA_GRAPH_VARIANT_TRITON_TOP_K: _SAMPLE_ROUTE_GUMBEL_TOP_K,
CUDA_GRAPH_VARIANT_TRITON_TOP_K_TOP_P: _SAMPLE_ROUTE_GUMBEL_TOP_K_TOP_P,
CUDA_GRAPH_VARIANT_TRITON_TOP_P: _SAMPLE_ROUTE_GUMBEL_TOP_P,
CUDA_GRAPH_VARIANT_TRITON_VERIFY_NO_FILTER: _SAMPLE_ROUTE_GUMBEL_NO_FILTER,
}
class TritonSamplingBackend(SamplingBackend):
"""TokenSpeed pool-state backend using Triton Gumbel-Max kernels."""
_HAS_POOL_STATE = True
def __init__(self, config: SamplingBackendConfig) -> None:
super().__init__(config)
self._init_triton_pool_state(config)
self._init_triton_buffers(config)
self._sample_route = _SAMPLE_ROUTE_GUMBEL_GENERIC
self._top_k_top_p_pad = _TOP_K_TOP_P_PAD
def _init_triton_pool_state(self, config: SamplingBackendConfig) -> None:
pool_rows = config.max_req_pool_size + 1
self._temperature_pool = torch.ones(
(pool_rows,), dtype=torch.float32, device=config.device
)
self._top_k_pool = torch.ones(
(pool_rows,), dtype=torch.int32, device=config.device
)
self._top_p_pool = torch.ones(
(pool_rows,), dtype=torch.float32, device=config.device
)
self._seed_pool = torch.zeros(
(pool_rows,), dtype=torch.int64, device=config.device
)
self._ones_buf = torch.ones(
(config.max_bs,), dtype=torch.int32, device=config.device
)
self._predict_buf = torch.zeros(
(config.max_bs * config.max_draft_tokens_per_req,),
dtype=torch.int32,
device=config.device,
)
# Flat layout so [:bs * n].view(bs, n) is contiguous for any bs/n
# (required by maybe_broadcast / NCCL).
self._accept_index_buf = torch.zeros(
(config.max_bs * config.max_draft_tokens_per_req,),
dtype=torch.int32,
device=config.device,
)
self._accept_length_buf = torch.zeros(
(config.max_bs,), dtype=torch.int32, device=config.device
)
def _init_triton_buffers(self, config: SamplingBackendConfig) -> None:
pool_rows = config.max_req_pool_size + 1
self._zero_offsets_pool = torch.zeros(
(pool_rows,), dtype=torch.int64, device=config.device
)
vocab_size = max(int(config.vocab_size), 1)
gumbel_blocks = (vocab_size + _GUMBEL_BLOCK_SIZE - 1) // _GUMBEL_BLOCK_SIZE
self._gumbel_local_ids = torch.empty(
(config.max_bs, gumbel_blocks),
dtype=torch.int32,
device=config.device,
)
self._gumbel_local_scores = torch.empty(
(config.max_bs, gumbel_blocks),
dtype=torch.float32,
device=config.device,
)
self._gumbel_out = torch.empty(
(config.max_bs,), dtype=torch.int32, device=config.device
)
self._req_pool_indices_i32 = torch.empty(
(config.max_bs,), dtype=torch.int32, device=config.device
)
self._gumbel_verify_out = torch.empty(
(config.max_bs * config.max_draft_tokens_per_req,),
dtype=torch.int32,
device=config.device,
)
self._gumbel_verify_local_ids = torch.empty(
(config.max_bs * config.max_draft_tokens_per_req, gumbel_blocks),
dtype=torch.int32,
device=config.device,
)
self._gumbel_verify_local_scores = torch.empty(
(config.max_bs * config.max_draft_tokens_per_req, gumbel_blocks),
dtype=torch.float32,
device=config.device,
)
topk_blocks = (vocab_size + _TOP_K_TOP_P_SMALL_BLOCK_SIZE - 1) // (
_TOP_K_TOP_P_SMALL_BLOCK_SIZE
)
topk_candidates = topk_blocks * _TOP_K_TOP_P_PAD
self._topk_candidate_ids = torch.empty(
(config.max_bs, topk_candidates),
dtype=torch.int32,
device=config.device,
)
self._topk_candidate_logits = torch.empty(
(config.max_bs, topk_candidates),
dtype=torch.float32,
device=config.device,
)
self._topk_verify_candidate_ids = torch.empty(
(config.max_bs * config.max_draft_tokens_per_req, topk_candidates),
dtype=torch.int32,
device=config.device,
)
self._topk_verify_candidate_logits = torch.empty(
(config.max_bs * config.max_draft_tokens_per_req, topk_candidates),
dtype=torch.float32,
device=config.device,
)
max_verify_rows = max(config.max_bs * config.max_draft_tokens_per_req, 1)
num_sms = torch.cuda.get_device_properties(config.device).multi_processor_count
self._qrita_verify_num_programs = min(num_sms, max_verify_rows)
self._qrita_verify_buffer = torch.empty(
(self._qrita_verify_num_programs, vocab_size),
dtype=torch.float32,
device=config.device,
)
self._qrita_percentile_to_std_table = torch.tensor(
_QRITA_PERCENTILE_TO_STD_TABLE,
dtype=torch.float32,
device=config.device,
)
top_p_blocks = (vocab_size + _TOP_P_PARALLEL_SAMPLE_BLOCK_SIZE - 1) // (
_TOP_P_PARALLEL_SAMPLE_BLOCK_SIZE
)
top_p_rows = max(config.max_bs * config.max_draft_tokens_per_req, 1)
self._top_p_local_max = torch.empty(
(top_p_rows, top_p_blocks), dtype=torch.float32, device=config.device
)
self._top_p_local_sum = torch.empty(
(top_p_rows, top_p_blocks), dtype=torch.float32, device=config.device
)
self._top_p_local_argmax = torch.empty(
(top_p_rows, top_p_blocks), dtype=torch.int32, device=config.device
)
self._top_p_local_scores = torch.empty(
(top_p_rows, top_p_blocks, _TOP_P_PARALLEL_MAX_ATTEMPTS),
dtype=torch.float32,
device=config.device,
)
self._top_p_local_logits = torch.empty(
(top_p_rows, top_p_blocks, _TOP_P_PARALLEL_MAX_ATTEMPTS),
dtype=torch.float32,
device=config.device,
)
self._top_p_local_ids = torch.empty(
(top_p_rows, top_p_blocks, _TOP_P_PARALLEL_MAX_ATTEMPTS),
dtype=torch.int32,
device=config.device,
)
self._top_p_row_max = torch.empty(
(top_p_rows,), dtype=torch.float32, device=config.device
)
self._top_p_row_total = torch.empty(
(top_p_rows,), dtype=torch.float32, device=config.device
)
self._top_p_row_argmax = torch.empty(
(top_p_rows,), dtype=torch.int32, device=config.device
)
self._top_p_row_candidate_logits = torch.empty(
(top_p_rows, _TOP_P_PARALLEL_MAX_ATTEMPTS),
dtype=torch.float32,
device=config.device,
)
self._top_p_row_candidate_ids = torch.empty(
(top_p_rows, _TOP_P_PARALLEL_MAX_ATTEMPTS),
dtype=torch.int32,
device=config.device,
)
self._top_p_accepted = torch.empty(
(top_p_rows,), dtype=torch.int32, device=config.device
)
self._selected_logprob_out = torch.empty(
(top_p_rows,), dtype=torch.float32, device=config.device
)
def _req_pool_indices_for_kernels(
self, req_pool_indices: torch.Tensor, rows: int
) -> torch.Tensor:
req_pool_indices = req_pool_indices[:rows]
if req_pool_indices.dtype == torch.int32:
return req_pool_indices
if req_pool_indices.dtype != torch.int64:
raise ValueError(
"Triton sampling requires int32/int64 req_pool_indices, "
f"got {req_pool_indices.dtype}"
)
out = self._req_pool_indices_i32[:rows]
out.copy_(req_pool_indices, non_blocking=True)
return out
def _write_logprob_outputs(
self,
logits_output: LogitsProcessorOutput,
logits: torch.Tensor,
sampled: torch.Tensor,
) -> None:
if not self.config.enable_output_logprobs:
return
rows = logits.shape[0]
selected_out = self._selected_logprob_out[:rows]
logits_output.next_token_logprobs = selected_token_logprobs(
logits, sampled, selected_out
)
@staticmethod
def _select_sample_route(
sampling_params_list: list[SamplingParams],
) -> int:
if len(sampling_params_list) == 0:
return _SAMPLE_ROUTE_GUMBEL_GENERIC
top_ks = [int(sp.top_k) for sp in sampling_params_list]
top_ps = [float(sp.top_p) for sp in sampling_params_list]
all_top_p_one = all(abs(p - 1.0) <= _SAMPLING_EPS for p in top_ps)
all_top_k_disabled = all(k == _TOP_K_DISABLED for k in top_ks)
all_top_k_finite = all(k != _TOP_K_DISABLED for k in top_ks)
if all_top_k_disabled and all_top_p_one:
return _SAMPLE_ROUTE_GUMBEL_NO_FILTER
if all_top_k_disabled:
return _SAMPLE_ROUTE_GUMBEL_TOP_P
if all_top_k_finite:
if all_top_p_one:
return _SAMPLE_ROUTE_GUMBEL_TOP_K
return _SAMPLE_ROUTE_GUMBEL_TOP_K_TOP_P
return _SAMPLE_ROUTE_GUMBEL_GENERIC
def _reset_slot(self, pool_idx: int, sp: SamplingParams) -> None:
self._temperature_pool[pool_idx].fill_(float(sp.temperature))
self._top_k_pool[pool_idx].fill_(int(sp.top_k))
self._top_p_pool[pool_idx].fill_(float(sp.top_p))
self._seed_pool[pool_idx].fill_(int(sp.seed))
def prepare_step(
self,
request_ids: list[str],
request_pool_indices: list[int],
sampling_params_list: list[SamplingParams],
num_tokens_per_req: int = 1,
) -> None:
SamplingBackend.prepare_step(
self,
request_ids=request_ids,
request_pool_indices=request_pool_indices,
sampling_params_list=sampling_params_list,
num_tokens_per_req=num_tokens_per_req,
)
self._sample_route = self._select_sample_route(sampling_params_list)
self._top_k_top_p_pad = self._select_top_k_top_p_pad(sampling_params_list)
@staticmethod
def _select_top_k_top_p_pad(sampling_params_list: list[SamplingParams]) -> int:
finite_top_ks = [
int(sp.top_k)
for sp in sampling_params_list
if int(sp.top_k) != _TOP_K_DISABLED
]
if finite_top_ks and max(finite_top_ks) <= 64:
return 64
return _TOP_K_TOP_P_PAD
def _use_qrita_verify_top_k_route(self, rows: int, vocab_size: int) -> bool:
return (
self._sample_route
in (_SAMPLE_ROUTE_GUMBEL_TOP_K, _SAMPLE_ROUTE_GUMBEL_TOP_K_TOP_P)
and rows >= _QRITA_VERIFY_MIN_ROWS
and vocab_size >= _TOP_K_TOP_P_TUNED_VOCAB_MAX
and (
vocab_size > _TOP_K_TOP_P_TUNED_VOCAB_MAX or self._top_k_top_p_pad > 64
)
)
def prepare_capture(self, bs: int, num_tokens_per_req: int = 1) -> None:
self._sample_route = _SAMPLE_ROUTE_GUMBEL_GENERIC
self._top_k_top_p_pad = _TOP_K_TOP_P_PAD
SamplingBackend.prepare_capture(
self, bs=bs, num_tokens_per_req=num_tokens_per_req
)
def cuda_graph_capture_variants(self, num_tokens_per_req: int) -> tuple[str, ...]:
variants = (
CUDA_GRAPH_VARIANT_DEFAULT,
CUDA_GRAPH_VARIANT_TRITON_NO_FILTER,
CUDA_GRAPH_VARIANT_TRITON_TOP_P,
CUDA_GRAPH_VARIANT_TRITON_TOP_K,
CUDA_GRAPH_VARIANT_TRITON_TOP_K_TOP_P,
)
if num_tokens_per_req <= 1:
return variants
return (*variants, CUDA_GRAPH_VARIANT_TRITON_VERIFY_NO_FILTER)
def prepare_capture_variant(
self,
bs: int,
num_tokens_per_req: int,
variant: str,
) -> None:
sample_route = _CUDA_GRAPH_VARIANT_SAMPLE_ROUTES.get(variant)
if sample_route is not None:
self._sample_route = sample_route
if sample_route in (
_SAMPLE_ROUTE_GUMBEL_TOP_K,
_SAMPLE_ROUTE_GUMBEL_TOP_K_TOP_P,
):
self._top_k_top_p_pad = _TOP_K_TOP_P_PAD
SamplingBackend.prepare_capture(
self,
bs=bs,
num_tokens_per_req=num_tokens_per_req,
)
return
if variant == CUDA_GRAPH_VARIANT_DEFAULT:
self.prepare_capture(bs=bs, num_tokens_per_req=num_tokens_per_req)
return
raise ValueError(f"Unsupported CUDA graph variant: {variant}")
def cuda_graph_replay_variant(self, num_tokens_per_req: int) -> str:
if self._sample_route == _SAMPLE_ROUTE_GUMBEL_NO_FILTER:
if num_tokens_per_req > 1:
return CUDA_GRAPH_VARIANT_TRITON_VERIFY_NO_FILTER
return CUDA_GRAPH_VARIANT_TRITON_NO_FILTER
if self._sample_route == _SAMPLE_ROUTE_GUMBEL_TOP_K:
return CUDA_GRAPH_VARIANT_TRITON_TOP_K
if self._sample_route == _SAMPLE_ROUTE_GUMBEL_TOP_K_TOP_P:
return CUDA_GRAPH_VARIANT_TRITON_TOP_K_TOP_P
if self._sample_route == _SAMPLE_ROUTE_GUMBEL_TOP_P:
return CUDA_GRAPH_VARIANT_TRITON_TOP_P
return CUDA_GRAPH_VARIANT_DEFAULT
def sample(
self,
logits_output: LogitsProcessorOutput,
sampling_info: SamplingBatchInfo,
) -> tuple[torch.Tensor, torch.Tensor]:
logits = nan_guard_logits(
logits_output.next_token_logits, self.config.enable_nan_detection
)
if sampling_info.vocab_mask is not None:
sampling_info.apply_vocab_mask(
logits=logits, vocab_mask=sampling_info.vocab_mask
)
if sampling_info.is_all_greedy:
batch_next_token_ids = cute_argmax(logits)
else:
offsets_pool = (
sampling_info.valid_cache_lengths
if sampling_info.valid_cache_lengths is not None
else self._zero_offsets_pool
)
bs = logits.shape[0]
req_pool_indices = self._req_pool_indices_for_kernels(
sampling_info.req_pool_indices, bs
)
if self._sample_route == _SAMPLE_ROUTE_GUMBEL_NO_FILTER:
if logits.shape[1] <= _COMPACT_GUMBEL_VOCAB_MAX:
batch_next_token_ids = gumbel_sample_from_pools_compact(
logits,
req_pool_indices,
self._temperature_pool,
self._seed_pool,
offsets_pool,
self._gumbel_out[:bs],
block_size=_COMPACT_GUMBEL_BLOCK_SIZE,
)
else:
batch_next_token_ids = gumbel_sample_from_pools(
logits,
req_pool_indices,
self._temperature_pool,
self._seed_pool,
offsets_pool,
self._gumbel_local_ids[:bs],
self._gumbel_local_scores[:bs],
self._gumbel_out[:bs],
)
elif self._sample_route in (
_SAMPLE_ROUTE_GUMBEL_TOP_K,
_SAMPLE_ROUTE_GUMBEL_TOP_K_TOP_P,
):
batch_next_token_ids = gumbel_sample_top_k_top_p_from_pools(
logits,
req_pool_indices,
self._temperature_pool,
self._top_k_pool,
self._top_p_pool,
self._seed_pool,
offsets_pool,
self._topk_candidate_ids[:bs],
self._topk_candidate_logits[:bs],
self._gumbel_out[:bs],
block_size=_TOP_K_TOP_P_SMALL_BLOCK_SIZE,
top_k_pad=self._top_k_top_p_pad,
)
elif self._sample_route == _SAMPLE_ROUTE_GUMBEL_TOP_P:
batch_next_token_ids = gumbel_sample_top_p_parallel_from_pools(
logits,
req_pool_indices,
self._temperature_pool,
self._top_p_pool,
self._seed_pool,
offsets_pool,
self._top_p_local_max[:bs],
self._top_p_local_sum[:bs],
self._top_p_local_argmax[:bs],
self._top_p_local_scores[:bs],
self._top_p_local_logits[:bs],
self._top_p_local_ids[:bs],
self._top_p_row_max[:bs],
self._top_p_row_total[:bs],
self._top_p_row_argmax[:bs],
self._top_p_row_candidate_logits[:bs],
self._top_p_row_candidate_ids[:bs],
self._top_p_accepted[:bs],
self._gumbel_out[:bs],
block_size=_TOP_P_PARALLEL_SAMPLE_BLOCK_SIZE,
num_attempts=_TOP_P_PARALLEL_SAMPLE_ATTEMPTS,
)
else:
batch_next_token_ids = gumbel_sample_from_pools_generic(
logits,
req_pool_indices,
self._temperature_pool,
self._top_k_pool,
self._top_p_pool,
self._seed_pool,
offsets_pool,
self._gumbel_out[:bs],
)
sampled = batch_next_token_ids.to(torch.int32)
self.maybe_broadcast(sampled)
self._write_logprob_outputs(logits_output, logits, sampled)
return sampled, self._ones_buf[: logits.shape[0]]
@nvtx_range("sampling:verify", color="yellow")
def verify(
self,
logits_output: LogitsProcessorOutput,
sampling_info: SamplingBatchInfo,
candidates: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
bs = candidates.shape[0]
num_tokens_per_req = candidates.shape[1]
predict = self._predict_buf[: bs * num_tokens_per_req]
accept_index = (
self._accept_index_buf[: bs * num_tokens_per_req]
.view(bs, num_tokens_per_req)
.fill_(-1)
)
accept_length = self._accept_length_buf[:bs]
logits = nan_guard_logits(
logits_output.next_token_logits, self.config.enable_nan_detection
)
if sampling_info.vocab_mask is not None:
sampling_info.apply_vocab_mask(
logits=logits,
vocab_mask=sampling_info.vocab_mask,
)
if sampling_info.is_all_greedy:
target_sampled = cute_argmax(logits)
verify_chain_target_sampled(
predicts=predict,
accept_index=accept_index,
accept_token_num=accept_length,
candidates=candidates,
target_sampled=target_sampled,
enable_pdl=pdl_enabled(),
)
else:
offsets_pool = (
sampling_info.valid_cache_lengths
if sampling_info.valid_cache_lengths is not None
else self._zero_offsets_pool
)
req_pool_indices = self._req_pool_indices_for_kernels(
sampling_info.req_pool_indices, bs
)
if self._sample_route == _SAMPLE_ROUTE_GUMBEL_NO_FILTER:
if logits.shape[1] <= _COMPACT_GUMBEL_VOCAB_MAX:
target_sampled = gumbel_sample_from_pools_compact(
logits,
req_pool_indices,
self._temperature_pool,
self._seed_pool,
offsets_pool,
self._gumbel_verify_out[: bs * num_tokens_per_req],
block_size=_COMPACT_GUMBEL_BLOCK_SIZE,
num_tokens_per_req=num_tokens_per_req,
)
else:
target_sampled = gumbel_sample_from_pools(
logits,
req_pool_indices,
self._temperature_pool,
self._seed_pool,
offsets_pool,
self._gumbel_verify_local_ids[: bs * num_tokens_per_req],
self._gumbel_verify_local_scores[: bs * num_tokens_per_req],
self._gumbel_verify_out[: bs * num_tokens_per_req],
num_tokens_per_req=num_tokens_per_req,
)
elif self._sample_route in (
_SAMPLE_ROUTE_GUMBEL_TOP_K,
_SAMPLE_ROUTE_GUMBEL_TOP_K_TOP_P,
):
rows = bs * num_tokens_per_req
if self._use_qrita_verify_top_k_route(rows, logits.shape[1]):
target_sampled = gumbel_sample_top_k_top_p_qrita_from_pools(
logits,
req_pool_indices,
self._temperature_pool,
self._top_k_pool,
self._top_p_pool,
self._seed_pool,
offsets_pool,
self._qrita_verify_buffer,
self._qrita_percentile_to_std_table,
self._gumbel_verify_out[:rows],
num_tokens_per_req=num_tokens_per_req,
num_programs=min(self._qrita_verify_num_programs, rows),
)
else:
target_sampled = gumbel_sample_top_k_top_p_from_pools(
logits,
req_pool_indices,
self._temperature_pool,
self._top_k_pool,
self._top_p_pool,
self._seed_pool,
offsets_pool,
self._topk_verify_candidate_ids[:rows],
self._topk_verify_candidate_logits[:rows],
self._gumbel_verify_out[:rows],
block_size=_TOP_K_TOP_P_SMALL_BLOCK_SIZE,
top_k_pad=self._top_k_top_p_pad,
num_tokens_per_req=num_tokens_per_req,
)
elif self._sample_route == _SAMPLE_ROUTE_GUMBEL_TOP_P:
rows = bs * num_tokens_per_req
target_sampled = gumbel_sample_top_p_parallel_from_pools(
logits,
req_pool_indices,
self._temperature_pool,
self._top_p_pool,
self._seed_pool,
offsets_pool,
self._top_p_local_max[:rows],
self._top_p_local_sum[:rows],
self._top_p_local_argmax[:rows],
self._top_p_local_scores[:rows],
self._top_p_local_logits[:rows],
self._top_p_local_ids[:rows],
self._top_p_row_max[:rows],
self._top_p_row_total[:rows],
self._top_p_row_argmax[:rows],
self._top_p_row_candidate_logits[:rows],
self._top_p_row_candidate_ids[:rows],
self._top_p_accepted[:rows],
self._gumbel_verify_out[:rows],
block_size=_TOP_P_PARALLEL_SAMPLE_BLOCK_SIZE,
num_attempts=_TOP_P_PARALLEL_VERIFY_ATTEMPTS,
num_tokens_per_req=num_tokens_per_req,
)
else:
target_sampled = gumbel_sample_from_pools_generic(
logits,
req_pool_indices,
self._temperature_pool,
self._top_k_pool,
self._top_p_pool,
self._seed_pool,
offsets_pool,
self._gumbel_verify_out[: bs * num_tokens_per_req],
num_tokens_per_req=num_tokens_per_req,
)
verify_chain_target_sampled(
predicts=predict,
accept_index=accept_index,
accept_token_num=accept_length,
candidates=candidates,
target_sampled=target_sampled,
enable_pdl=pdl_enabled(),
)
accept_length += 1
# Rank 0 remains the source of truth for attention-TP agreement.
self.maybe_broadcast(predict, accept_index, accept_length)
if self.config.enable_output_logprobs:
self._write_logprob_outputs(
logits_output,
logits,
predict,
)
return predict, accept_length
register_backend("triton", TritonSamplingBackend)
@@ -0,0 +1,572 @@
# SPDX-License-Identifier: MIT AND Apache-2.0
# SPDX-FileCopyrightText: Copyright (c) 2026 LightSeek Foundation
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
#
# 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.
# TokenSpeed keeps pool-owned counts and logit-bias state.
from __future__ import annotations
from typing import TYPE_CHECKING
import torch
from tokenspeed_kernel.ops.sampling.triton import (
accumulate_counts_inplace,
apply_penalties_logit_bias_inplace,
gumbel_sample_from_pools,
gumbel_sample_from_pools_compact,
gumbel_sample_from_pools_generic,
gumbel_sample_min_p_from_pools,
gumbel_sample_min_p_from_pools_parallel,
gumbel_sample_top_k_top_p_from_pools,
gumbel_sample_top_k_top_p_qrita_from_pools,
gumbel_sample_top_p_parallel_from_pools,
verify_chain_target_sampled,
)
from tokenspeed.runtime.sampling.backends.base import (
CUDA_GRAPH_VARIANT_DEFAULT,
SamplingBackend,
SamplingBackendConfig,
)
from tokenspeed.runtime.sampling.backends.triton import (
_COMPACT_GUMBEL_BLOCK_SIZE,
_COMPACT_GUMBEL_VOCAB_MAX,
_SAMPLE_ROUTE_GUMBEL_NO_FILTER,
_SAMPLE_ROUTE_GUMBEL_TOP_K,
_SAMPLE_ROUTE_GUMBEL_TOP_K_TOP_P,
_SAMPLE_ROUTE_GUMBEL_TOP_P,
_TOP_K_TOP_P_PAD,
_TOP_K_TOP_P_SMALL_BLOCK_SIZE,
_TOP_P_PARALLEL_SAMPLE_ATTEMPTS,
_TOP_P_PARALLEL_SAMPLE_BLOCK_SIZE,
_TOP_P_PARALLEL_VERIFY_ATTEMPTS,
TritonSamplingBackend,
)
from tokenspeed.runtime.sampling.registry import register_backend
from tokenspeed.runtime.sampling.utils import nan_guard_logits
from tokenspeed.runtime.utils.nvtx import nvtx_range
from tokenspeed.runtime.utils.pdl import pdl_enabled
if TYPE_CHECKING:
from tokenspeed.runtime.layers.logits_processor import LogitsProcessorOutput
from tokenspeed.runtime.sampling.sampling_batch_info import SamplingBatchInfo
from tokenspeed.runtime.sampling.sampling_params import SamplingParams
CUDA_GRAPH_VARIANT_TRITON_FULL_MIN_P = "triton_full_min_p"
CUDA_GRAPH_VARIANT_TRITON_FULL_TOP_K_TOP_P_MIN_P = "triton_full_top_k_top_p_min_p"
class TritonFullSamplingBackend(TritonSamplingBackend):
"""Full sampling backend with TokenSpeed-owned state and Triton kernels."""
def __init__(self, config: SamplingBackendConfig) -> None:
super().__init__(config)
if config.max_req_pool_size <= 0 or config.vocab_size <= 0:
raise ValueError(
"TritonFullSamplingBackend requires max_req_pool_size > 0 and "
f"vocab_size > 0; got max_req_pool_size={config.max_req_pool_size}, "
f"vocab_size={config.vocab_size}"
)
pool_rows = config.max_req_pool_size + 1
self._counts = torch.zeros(
(pool_rows, config.vocab_size),
dtype=torch.int32,
device=config.device,
)
self._logit_bias = torch.zeros(
(pool_rows, config.vocab_size),
dtype=torch.bfloat16,
device=config.device,
)
self._min_p_pool = torch.zeros(
(pool_rows,), dtype=torch.float32, device=config.device
)
self._freq_pen_pool = torch.zeros(
(pool_rows,), dtype=torch.bfloat16, device=config.device
)
self._pres_pen_pool = torch.zeros(
(pool_rows,), dtype=torch.bfloat16, device=config.device
)
self._rep_pen_pool = torch.full(
(pool_rows,), 1.0, dtype=torch.bfloat16, device=config.device
)
self._min_p_row_max = torch.empty(
(config.max_bs * config.max_draft_tokens_per_req,),
dtype=torch.float32,
device=config.device,
)
self._full_has_min_p = True
def prepare_step(
self,
request_ids: list[str],
request_pool_indices: list[int],
sampling_params_list: list[SamplingParams],
num_tokens_per_req: int = 1,
) -> None:
super().prepare_step(
request_ids=request_ids,
request_pool_indices=request_pool_indices,
sampling_params_list=sampling_params_list,
num_tokens_per_req=num_tokens_per_req,
)
self._full_has_min_p = any(float(sp.min_p) > 0.0 for sp in sampling_params_list)
def prepare_capture(self, bs: int, num_tokens_per_req: int = 1) -> None:
self._full_has_min_p = True
super().prepare_capture(bs=bs, num_tokens_per_req=num_tokens_per_req)
def prepare_capture_variant(
self,
bs: int,
num_tokens_per_req: int,
variant: str,
) -> None:
if variant == CUDA_GRAPH_VARIANT_TRITON_FULL_MIN_P:
self._full_has_min_p = True
self._sample_route = _SAMPLE_ROUTE_GUMBEL_NO_FILTER
SamplingBackend.prepare_capture(
self,
bs=bs,
num_tokens_per_req=num_tokens_per_req,
)
return
if variant == CUDA_GRAPH_VARIANT_TRITON_FULL_TOP_K_TOP_P_MIN_P:
self._full_has_min_p = True
self._sample_route = _SAMPLE_ROUTE_GUMBEL_TOP_K_TOP_P
self._top_k_top_p_pad = _TOP_K_TOP_P_PAD
SamplingBackend.prepare_capture(
self,
bs=bs,
num_tokens_per_req=num_tokens_per_req,
)
return
self._full_has_min_p = variant == CUDA_GRAPH_VARIANT_DEFAULT
super().prepare_capture_variant(
bs=bs,
num_tokens_per_req=num_tokens_per_req,
variant=variant,
)
def cuda_graph_capture_variants(self, num_tokens_per_req: int) -> tuple[str, ...]:
return (
*super().cuda_graph_capture_variants(num_tokens_per_req),
CUDA_GRAPH_VARIANT_TRITON_FULL_MIN_P,
CUDA_GRAPH_VARIANT_TRITON_FULL_TOP_K_TOP_P_MIN_P,
)
def cuda_graph_replay_variant(self, num_tokens_per_req: int) -> str:
if self._full_has_min_p:
if self._sample_route == _SAMPLE_ROUTE_GUMBEL_NO_FILTER:
return CUDA_GRAPH_VARIANT_TRITON_FULL_MIN_P
if self._sample_route in (
_SAMPLE_ROUTE_GUMBEL_TOP_K,
_SAMPLE_ROUTE_GUMBEL_TOP_K_TOP_P,
):
return CUDA_GRAPH_VARIANT_TRITON_FULL_TOP_K_TOP_P_MIN_P
return CUDA_GRAPH_VARIANT_DEFAULT
return super().cuda_graph_replay_variant(num_tokens_per_req)
def _reset_slot(self, pool_idx: int, sp: SamplingParams) -> None:
super()._reset_slot(pool_idx, sp)
self._min_p_pool[pool_idx].fill_(float(sp.min_p))
self._freq_pen_pool[pool_idx].fill_(float(sp.frequency_penalty))
self._pres_pen_pool[pool_idx].fill_(float(sp.presence_penalty))
self._rep_pen_pool[pool_idx].fill_(float(sp.repetition_penalty))
self._counts[pool_idx].fill_(0)
self._logit_bias[pool_idx].fill_(0.0)
bias_map = getattr(sp, "logit_bias", None) if sp is not None else None
if bias_map:
vocab = self._logit_bias.shape[1]
raw_ids = [int(tid) for tid in bias_map.keys()]
assert all(0 <= tid < vocab for tid in raw_ids), (
f"logit_bias contains out-of-vocab token id(s); "
f"vocab_size={vocab}, offending="
f"{[t for t in raw_ids if not 0 <= t < vocab]}"
)
token_ids = torch.tensor(
raw_ids,
device=self._logit_bias.device,
dtype=torch.long,
)
bias_values = torch.tensor(
list(bias_map.values()),
device=self._logit_bias.device,
dtype=torch.bfloat16,
)
self._logit_bias[pool_idx, token_ids] = bias_values
def reset_capture_state(self) -> None:
self._counts[0].fill_(0)
@nvtx_range("sampling:penalties", color="yellow")
def _apply_penalties_and_bias(
self,
logits: torch.Tensor,
req_pool_indices: torch.Tensor,
num_tokens_per_req: int = 1,
) -> torch.Tensor:
return apply_penalties_logit_bias_inplace(
logits,
req_pool_indices,
self._counts,
self._logit_bias,
self._freq_pen_pool,
self._pres_pen_pool,
self._rep_pen_pool,
num_tokens_per_req=num_tokens_per_req,
)
@nvtx_range("sampling:accum_counts", color="yellow")
def _accumulate_counts(
self,
pool_idx: torch.Tensor,
tokens: torch.Tensor,
weights: torch.Tensor,
) -> None:
accumulate_counts_inplace(self._counts, pool_idx, tokens, weights)
def _gumbel_sample_full_logits(
self,
logits: torch.Tensor,
req_pool_indices: torch.Tensor,
offsets_pool: torch.Tensor,
out: torch.Tensor,
*,
num_tokens_per_req: int = 1,
) -> torch.Tensor:
rows = logits.shape[0]
if (
self._full_has_min_p
and self._sample_route == _SAMPLE_ROUTE_GUMBEL_NO_FILTER
):
if logits.shape[1] > _COMPACT_GUMBEL_VOCAB_MAX:
local_ids = (
self._gumbel_local_ids
if num_tokens_per_req == 1
else self._gumbel_verify_local_ids
)
local_scores = (
self._gumbel_local_scores
if num_tokens_per_req == 1
else self._gumbel_verify_local_scores
)
return gumbel_sample_min_p_from_pools_parallel(
logits,
req_pool_indices,
self._temperature_pool,
self._min_p_pool,
self._seed_pool,
offsets_pool,
local_ids[:rows],
local_scores[:rows],
self._min_p_row_max[:rows],
out[:rows],
num_tokens_per_req=num_tokens_per_req,
)
return gumbel_sample_min_p_from_pools(
logits,
req_pool_indices,
self._temperature_pool,
self._min_p_pool,
self._seed_pool,
offsets_pool,
out[:rows],
num_tokens_per_req=num_tokens_per_req,
)
if self._sample_route in (
_SAMPLE_ROUTE_GUMBEL_TOP_K,
_SAMPLE_ROUTE_GUMBEL_TOP_K_TOP_P,
):
if (
not self._full_has_min_p
and num_tokens_per_req > 1
and self._use_qrita_verify_top_k_route(rows, logits.shape[1])
):
return gumbel_sample_top_k_top_p_qrita_from_pools(
logits,
req_pool_indices,
self._temperature_pool,
self._top_k_pool,
self._top_p_pool,
self._seed_pool,
offsets_pool,
self._qrita_verify_buffer,
self._qrita_percentile_to_std_table,
out[:rows],
num_tokens_per_req=num_tokens_per_req,
num_programs=min(self._qrita_verify_num_programs, rows),
)
candidate_ids = (
self._topk_candidate_ids
if num_tokens_per_req == 1
else self._topk_verify_candidate_ids
)
candidate_logits = (
self._topk_candidate_logits
if num_tokens_per_req == 1
else self._topk_verify_candidate_logits
)
return gumbel_sample_top_k_top_p_from_pools(
logits,
req_pool_indices,
self._temperature_pool,
self._top_k_pool,
self._top_p_pool,
self._seed_pool,
offsets_pool,
candidate_ids[:rows],
candidate_logits[:rows],
out[:rows],
min_p_pool=self._min_p_pool if self._full_has_min_p else None,
block_size=_TOP_K_TOP_P_SMALL_BLOCK_SIZE,
top_k_pad=self._top_k_top_p_pad,
num_tokens_per_req=num_tokens_per_req,
)
if not self._full_has_min_p:
if self._sample_route == _SAMPLE_ROUTE_GUMBEL_NO_FILTER:
if logits.shape[1] <= _COMPACT_GUMBEL_VOCAB_MAX:
return gumbel_sample_from_pools_compact(
logits,
req_pool_indices,
self._temperature_pool,
self._seed_pool,
offsets_pool,
out[:rows],
block_size=_COMPACT_GUMBEL_BLOCK_SIZE,
num_tokens_per_req=num_tokens_per_req,
)
local_ids = (
self._gumbel_local_ids
if num_tokens_per_req == 1
else self._gumbel_verify_local_ids
)
local_scores = (
self._gumbel_local_scores
if num_tokens_per_req == 1
else self._gumbel_verify_local_scores
)
return gumbel_sample_from_pools(
logits,
req_pool_indices,
self._temperature_pool,
self._seed_pool,
offsets_pool,
local_ids[:rows],
local_scores[:rows],
out[:rows],
num_tokens_per_req=num_tokens_per_req,
)
if self._sample_route == _SAMPLE_ROUTE_GUMBEL_TOP_P:
return gumbel_sample_top_p_parallel_from_pools(
logits,
req_pool_indices,
self._temperature_pool,
self._top_p_pool,
self._seed_pool,
offsets_pool,
self._top_p_local_max[:rows],
self._top_p_local_sum[:rows],
self._top_p_local_argmax[:rows],
self._top_p_local_scores[:rows],
self._top_p_local_logits[:rows],
self._top_p_local_ids[:rows],
self._top_p_row_max[:rows],
self._top_p_row_total[:rows],
self._top_p_row_argmax[:rows],
self._top_p_row_candidate_logits[:rows],
self._top_p_row_candidate_ids[:rows],
self._top_p_accepted[:rows],
out[:rows],
block_size=_TOP_P_PARALLEL_SAMPLE_BLOCK_SIZE,
num_attempts=(
_TOP_P_PARALLEL_SAMPLE_ATTEMPTS
if num_tokens_per_req == 1
else _TOP_P_PARALLEL_VERIFY_ATTEMPTS
),
num_tokens_per_req=num_tokens_per_req,
)
return gumbel_sample_from_pools_generic(
logits,
req_pool_indices,
self._temperature_pool,
self._top_k_pool,
self._top_p_pool,
self._seed_pool,
offsets_pool,
out[:rows],
min_p_pool=self._min_p_pool,
num_tokens_per_req=num_tokens_per_req,
)
@nvtx_range("sampling:sample", color="yellow")
def sample(
self,
logits_output: LogitsProcessorOutput,
sampling_info: SamplingBatchInfo,
) -> tuple[torch.Tensor, torch.Tensor]:
logits = nan_guard_logits(
logits_output.next_token_logits, self.config.enable_nan_detection
).float()
if sampling_info.vocab_mask is not None:
sampling_info.apply_vocab_mask(
logits=logits, vocab_mask=sampling_info.vocab_mask
)
logits_for_logprobs = (
logits.clone() if self.config.enable_output_logprobs else None
)
req_pool_indices = self._req_pool_indices_for_kernels(
sampling_info.req_pool_indices, logits.shape[0]
)
logits = self._apply_penalties_and_bias(logits, req_pool_indices)
offsets_pool = (
sampling_info.valid_cache_lengths
if sampling_info.valid_cache_lengths is not None
else self._zero_offsets_pool
)
sampled = self._gumbel_sample_full_logits(
logits,
req_pool_indices,
offsets_pool,
self._gumbel_out[: logits.shape[0]],
).to(torch.int32)
self.maybe_broadcast(sampled)
if logits_for_logprobs is not None:
self._write_logprob_outputs(
logits_output,
logits_for_logprobs,
sampled,
)
self._accumulate_counts(
req_pool_indices,
sampled,
torch.ones_like(sampled, dtype=torch.int32),
)
return sampled, self._ones_buf[: logits.shape[0]]
@nvtx_range("sampling:verify", color="yellow")
def verify(
self,
logits_output: LogitsProcessorOutput,
sampling_info: SamplingBatchInfo,
candidates: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
bs = candidates.shape[0]
num_tokens_per_req = candidates.shape[1]
predict = self._predict_buf[: bs * num_tokens_per_req]
accept_index = (
self._accept_index_buf[: bs * num_tokens_per_req]
.view(bs, num_tokens_per_req)
.fill_(-1)
)
accept_length = self._accept_length_buf[:bs]
logits = nan_guard_logits(
logits_output.next_token_logits, self.config.enable_nan_detection
).float()
if sampling_info.vocab_mask is not None:
sampling_info.apply_vocab_mask(
logits=logits,
vocab_mask=sampling_info.vocab_mask,
)
logits_for_logprobs = (
logits.clone() if self.config.enable_output_logprobs else None
)
req_pool_indices = self._req_pool_indices_for_kernels(
sampling_info.req_pool_indices, bs
)
logits = self._apply_penalties_and_bias(
logits,
req_pool_indices,
num_tokens_per_req=num_tokens_per_req,
)
offsets_pool = (
sampling_info.valid_cache_lengths
if sampling_info.valid_cache_lengths is not None
else self._zero_offsets_pool
)
target_sampled = self._gumbel_sample_full_logits(
logits,
req_pool_indices,
offsets_pool,
self._gumbel_verify_out[: bs * num_tokens_per_req],
num_tokens_per_req=num_tokens_per_req,
)
verify_chain_target_sampled(
predicts=predict,
accept_index=accept_index,
accept_token_num=accept_length,
candidates=candidates.to(torch.int32),
target_sampled=target_sampled,
enable_pdl=pdl_enabled(),
)
accept_length += 1
self.maybe_broadcast(predict, accept_index, accept_length)
valid = accept_index >= 0
safe_positions = accept_index.clamp(min=0).long()
accepted_tokens = predict.long().gather(0, safe_positions.view(-1))
pool_idx_expanded = (
req_pool_indices.unsqueeze(-1).expand(-1, num_tokens_per_req).reshape(-1)
)
self._accumulate_counts(
pool_idx_expanded,
accepted_tokens,
valid.reshape(-1).to(torch.int32),
)
if logits_for_logprobs is not None:
self._write_logprob_outputs(
logits_output,
logits_for_logprobs,
predict,
)
return predict, accept_length
register_backend("triton_full", TritonFullSamplingBackend)
@@ -0,0 +1,58 @@
# 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.
import json
from abc import ABC, abstractmethod
from functools import cache
from typing import Any, Self
import dill
import torch
@cache
def _cache_from_str(json_str: str) -> "CustomLogitProcessor":
"""Deserialize a json string to a Callable object.
This function is cached to avoid redundant deserialization.
"""
data = json.loads(json_str)
return dill.loads(bytes.fromhex(data["callable"]))
class CustomLogitProcessor(ABC):
"""Abstract base class for callable functions."""
@abstractmethod
def __call__(
self,
logits: torch.Tensor,
custom_param_list: list[dict[str, Any]] | None = None,
) -> torch.Tensor:
"""Define the callable behavior."""
raise NotImplementedError
def to_str(self) -> str:
"""Serialize the callable function to a JSON-compatible string."""
return json.dumps({"callable": dill.dumps(self).hex()})
@classmethod
def from_str(cls, json_str: str) -> Self:
"""Deserialize a callable function from a JSON string."""
return _cache_from_str(json_str)
@@ -0,0 +1,190 @@
# 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 dataclasses
import torch
@dataclasses.dataclass(frozen=True)
class DpSamplingSupport:
requested: bool
enabled: bool
infra_supports: bool
drafter_available: bool
backend_supports_verify: bool
tp_size: int
tp_group_set: bool
@dataclasses.dataclass(frozen=True)
class DpSamplingTopology:
tp_rank: int
tp_size: int
tp_group: tuple[int, ...] | None
skip_all_gather: bool
tie_word_embeddings: bool = False
@property
def tp_group_set(self) -> bool:
return self.tp_group is not None
@dataclasses.dataclass(frozen=True)
class DpSamplingRuntimeConfig:
enabled: bool = False
vocab_size: int | None = None
max_bucket_bs: int | None = None
min_bs: int | None = None
num_tokens_per_req: int = 1
topology: DpSamplingTopology | None = None
device: torch.device | str | None = None
@dataclasses.dataclass(frozen=True)
class DpSamplingRuntimeLimits:
runtime_vocab_size: int
max_num_seqs: int
data_parallel_size: int
num_tokens_per_req: int
configured_min_bs: int | None
device: torch.device | str
def resolve_dp_sampling_support(
*,
requested: bool,
drafter_available: bool,
backend_supports_verify: bool,
topology: DpSamplingTopology,
) -> DpSamplingSupport:
tp_size = int(topology.tp_size)
tp_group_set = topology.tp_group_set
infra_supports = (
drafter_available and backend_supports_verify and tp_size > 1 and tp_group_set
)
support = DpSamplingSupport(
requested=bool(requested),
enabled=infra_supports and bool(requested),
infra_supports=infra_supports,
drafter_available=drafter_available,
backend_supports_verify=backend_supports_verify,
tp_size=tp_size,
tp_group_set=tp_group_set,
)
if support.requested and not support.infra_supports:
raise RuntimeError(
"--dp-sampling was set but Batch-DP spec-verify "
"preconditions are not met: "
f"drafter={support.drafter_available}, "
f"backend_supports_dp_verify={support.backend_supports_verify}, "
f"tp_size={support.tp_size}, "
f"tp_group_set={support.tp_group_set}"
)
return support
def resolve_dp_sampling_runtime(
*,
support: DpSamplingSupport,
lm_head_rows: int,
topology: DpSamplingTopology,
limits: DpSamplingRuntimeLimits,
) -> DpSamplingRuntimeConfig:
if not support.enabled:
return DpSamplingRuntimeConfig(
num_tokens_per_req=limits.num_tokens_per_req,
topology=topology,
device=limits.device,
)
validate_dp_sampling_lm_head_vocab(
lm_head_rows=lm_head_rows,
vocab_size=limits.runtime_vocab_size,
tp_size=topology.tp_size,
skip_all_gather=topology.skip_all_gather,
tie_word_embeddings=topology.tie_word_embeddings,
)
dp_vocab_size = int(lm_head_rows)
if not topology.skip_all_gather:
dp_vocab_size *= int(topology.tp_size)
dp_vocab_size = (
(dp_vocab_size + int(topology.tp_size) - 1) // int(topology.tp_size)
) * int(topology.tp_size)
max_bs = limits.max_num_seqs // max(limits.data_parallel_size, 1)
max_bucket_bs = (
(max_bs + topology.tp_size - 1) // topology.tp_size
) * topology.tp_size
min_bs = (
int(limits.configured_min_bs)
if limits.configured_min_bs is not None
else 2 * int(topology.tp_size)
)
if min_bs < 1:
raise ValueError("dp_sampling_min_bs must be >= 1")
return DpSamplingRuntimeConfig(
enabled=True,
vocab_size=dp_vocab_size,
max_bucket_bs=max_bucket_bs,
min_bs=min_bs,
num_tokens_per_req=limits.num_tokens_per_req,
topology=topology,
device=limits.device,
)
def slice_dp_vocab_mask(
vocab_mask: torch.Tensor | None,
*,
full_bs: int,
pad_bs: int,
num_tokens_per_req: int,
shard: slice,
) -> torch.Tensor | None:
if vocab_mask is None:
return None
n = num_tokens_per_req
if pad_bs > full_bs:
vocab_mask = torch.nn.functional.pad(
vocab_mask,
(0, 0, 0, (pad_bs - full_bs) * n),
value=-1,
)
return vocab_mask.view(pad_bs, n, -1)[shard].reshape(-1, vocab_mask.shape[-1])
def validate_dp_sampling_lm_head_vocab(
*,
lm_head_rows: int,
vocab_size: int,
tp_size: int,
skip_all_gather: bool,
tie_word_embeddings: bool,
) -> None:
if skip_all_gather and int(lm_head_rows) < int(vocab_size):
raise RuntimeError(
"Batch-DP sampling with skip_all_gather requires a replicated/"
"full-vocab LM head. Got a sharded LM head with "
f"lm_head_rows={lm_head_rows}, vocab_size={vocab_size}, "
f"tp_size={tp_size}, tie_word_embeddings={tie_word_embeddings}. "
"Disable --dp-sampling or use a model path that resolves a "
"replicated LM head."
)
@@ -0,0 +1,119 @@
# 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.
"""Declarative logits layout plans used by sampling."""
from __future__ import annotations
import dataclasses
import torch
from tokenspeed.runtime.distributed.comm_backend import Group
from tokenspeed.runtime.distributed.dp_sampling_comm import DpSamplingComm
@dataclasses.dataclass(frozen=True)
class LogitsLayoutPlan:
effective_bs: int
bucket_bs: int
tp_size: int
num_tokens_per_req: int
class LogitsLayoutExecutor:
"""Executes sampling-provided logits layout plans."""
def __init__(
self,
*,
tp_rank: int,
tp_size: int,
tp_group: Group,
max_bucket_bs: int,
num_tokens_per_req: int,
vocab_size: int,
device: torch.device | str,
) -> None:
self._tp_rank = tp_rank
self._tp_size = tp_size
self._num_tokens_per_req = num_tokens_per_req
self._comm = DpSamplingComm(
tp_size=tp_size,
rank=tp_rank,
group=tp_group,
max_pad_bs=max_bucket_bs,
num_tokens_per_req=num_tokens_per_req,
vocab_size=vocab_size,
logits_dtype=None,
device=device,
)
def slice_hidden_states(
self,
hidden_states: torch.Tensor,
plan: LogitsLayoutPlan,
) -> torch.Tensor:
n = self._tokens_per_req(plan)
rows = hidden_states.shape[0]
if rows % n != 0:
raise ValueError(f"hidden_states have {rows} rows, not divisible by N={n}")
bs = rows // n
if bs != plan.effective_bs:
raise ValueError(
f"hidden_states imply effective_bs={bs}, but logits layout plan has "
f"effective_bs={plan.effective_bs}"
)
pad_rows = (plan.bucket_bs - plan.effective_bs) * n
if pad_rows > 0:
hidden_states = torch.nn.functional.pad(hidden_states, (0, 0, 0, pad_rows))
reqs_per_rank = plan.bucket_bs // self._tp_size
start = self._tp_rank * reqs_per_rank * n
return hidden_states[start : start + reqs_per_rank * n]
def swap_batch_vocab(
self,
local_logits: torch.Tensor,
plan: LogitsLayoutPlan,
) -> torch.Tensor:
n = self._tokens_per_req(plan)
rows = local_logits.shape[0]
if rows % n != 0:
raise ValueError(f"local logits have {rows} rows, not divisible by N={n}")
bs = rows // n
if bs != plan.effective_bs:
raise ValueError(
f"local logits imply effective_bs={bs}, but logits layout plan has "
f"effective_bs={plan.effective_bs}"
)
pad_rows = (plan.bucket_bs - plan.effective_bs) * n
if pad_rows > 0:
local_logits = torch.nn.functional.pad(local_logits, (0, 0, 0, pad_rows))
return self._comm.swap_batch_vocab(local_logits, pad_bs=plan.bucket_bs)
def _tokens_per_req(self, plan: LogitsLayoutPlan) -> int:
if (
plan.tp_size != self._tp_size
or plan.num_tokens_per_req != self._num_tokens_per_req
or plan.bucket_bs < plan.effective_bs
or plan.bucket_bs % self._tp_size != 0
):
raise RuntimeError("invalid DP logits layout plan")
return plan.num_tokens_per_req
@@ -0,0 +1,93 @@
# 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
from typing import TYPE_CHECKING
from tokenspeed_kernel.platform import current_platform
from tokenspeed.runtime.sampling.backends.base import (
DEFAULT_RANDOM_SEED,
SamplingBackend,
SamplingBackendConfig,
)
if TYPE_CHECKING:
from tokenspeed.runtime.utils.server_args import ServerArgs
_BACKEND_REGISTRY: dict[str, type[SamplingBackend]] = {}
def _get_default_backend_name() -> str:
if current_platform().is_nvidia:
return "flashinfer"
return "greedy"
def _resolve_backend_name(server_args: ServerArgs) -> str:
return server_args.sampling_backend or _get_default_backend_name()
def register_backend(name: str, cls: type[SamplingBackend]) -> None:
_BACKEND_REGISTRY[name] = cls
def create_sampling_backend(
server_args: ServerArgs,
*,
max_bs: int,
max_draft_tokens_per_req: int,
device: str,
random_seed: int = DEFAULT_RANDOM_SEED,
max_req_pool_size: int = 0,
vocab_size: int = 0,
tp_group: tuple[int, ...] | None = None,
) -> SamplingBackend:
# Trigger concrete-backend registration on first use.
from tokenspeed.runtime.sampling.backends import flashinfer as _fi # noqa: F401
from tokenspeed.runtime.sampling.backends import ( # noqa: F401
flashinfer_full as _ff,
)
from tokenspeed.runtime.sampling.backends import greedy as _g # noqa: F401
from tokenspeed.runtime.sampling.backends import triton as _t # noqa: F401
from tokenspeed.runtime.sampling.backends import triton_full as _tf # noqa: F401
name = _resolve_backend_name(server_args)
if name not in _BACKEND_REGISTRY:
raise ValueError(
f"Unknown sampling backend: {name!r}. "
f"Available: {list(_BACKEND_REGISTRY)}"
)
cls = _BACKEND_REGISTRY[name]
return cls(
SamplingBackendConfig.from_server_args(
server_args,
max_bs=max_bs,
max_draft_tokens_per_req=max_draft_tokens_per_req,
device=device,
random_seed=random_seed,
max_req_pool_size=max_req_pool_size,
vocab_size=vocab_size,
tp_group=tp_group,
)
)
+140
View File
@@ -0,0 +1,140 @@
# 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 dataclasses
import threading
from collections.abc import Callable
from typing import TYPE_CHECKING
import torch
from tokenspeed.runtime.utils import get_colorful_logger
logger = get_colorful_logger(__name__)
if TYPE_CHECKING:
from tokenspeed.runtime.engine.schedule_batch import ScheduleBatch
@dataclasses.dataclass
class SamplingBatchInfo:
# Basic batched sampling params. Disaggregated decode populates these via
# from_schedule_batch. The standard hot path leaves them None; sampling
# backends gather params from their own pool-indexed buffers.
temperatures: torch.Tensor | None = None
top_ps: torch.Tensor | None = None
top_ks: torch.Tensor | None = None
min_ps: torch.Tensor | None = None
# Whether all requests use greedy sampling
is_all_greedy: bool = False
# Masking tensors for grammar-guided structured outputs
vocab_size: int = 0
grammars: list | None = None
vocab_mask: torch.Tensor | None = None
# Backend-specific in-place fn ``(logits, vocab_mask) -> None``,
# bound by ``capturable_grammar.bind_grammar_mask_buf`` so the
# captured sampler can apply the bitmask without branching on
# backend.
apply_vocab_mask: Callable[[torch.Tensor, torch.Tensor], None] | None = None
# An event used for overlap schedule
sampling_info_done: threading.Event | None = None
# int64[bs] — req_pool_idx per batch row. Sampling backends gather
# their pool-indexed scalar buffers (temperature / top_k / top_p /
# seeds / penalties / logit_bias / counts) against this index.
req_pool_indices: torch.Tensor | None = None
# int32[pool_rows] — RuntimeStates.valid_cache_lengths, read-only
# reference. Sampling backends derive the per-request Philox offset
# from `valid_cache_lengths.index_select(0, req_pool_indices)`;
# carrying the reference rather than the gathered view keeps the
# index_select inside the captured graph.
valid_cache_lengths: torch.Tensor | None = None
# Device
device: str = "cuda"
def __getitem__(self, s: slice) -> SamplingBatchInfo:
"""Row-slice batch-indexed fields; pool/scalar fields pass through.
Used by hybrid-batch samplers (MIXED + spec-dec) that apply
different sampler ops to a prefix vs suffix of rows. Only ``slice``
is supported — int indexing would yield 0-dim tensors and break
downstream gathers.
``is_all_greedy`` is inherited from the parent; when ``top_ks`` is
populated the slice refines it from the sliced tensor (one GPU
sync, only on the disagg slice path).
"""
if not isinstance(s, slice):
raise TypeError(
f"SamplingBatchInfo only supports slice indexing, got {type(s).__name__}"
)
def _slice(t):
return t[s] if t is not None else None
return dataclasses.replace(
self,
temperatures=_slice(self.temperatures),
top_ps=_slice(self.top_ps),
top_ks=_slice(self.top_ks),
min_ps=_slice(self.min_ps),
is_all_greedy=self.is_all_greedy,
req_pool_indices=_slice(self.req_pool_indices),
vocab_mask=_slice(self.vocab_mask),
grammars=_slice(self.grammars),
)
@classmethod
def from_schedule_batch(
cls, batch: ScheduleBatch, vocab_size: int
) -> SamplingBatchInfo:
reqs = batch.reqs
device = batch.device
temperatures = torch.tensor(
[r.sampling_params.temperature for r in reqs], dtype=torch.float
).to(device, non_blocking=True)
top_ps = torch.tensor(
[r.sampling_params.top_p for r in reqs], dtype=torch.float
).to(device, non_blocking=True)
top_ks = torch.tensor(
[r.sampling_params.top_k for r in reqs], dtype=torch.int32
).to(device, non_blocking=True)
min_ps = torch.tensor(
[r.sampling_params.min_p for r in reqs], dtype=torch.float
).to(device, non_blocking=True)
ret = cls(
temperatures=temperatures,
top_ps=top_ps,
top_ks=top_ks,
min_ps=min_ps,
is_all_greedy=all(r.sampling_params.top_k <= 1 for r in reqs),
vocab_size=vocab_size,
device=device,
)
return ret
+243
View File
@@ -0,0 +1,243 @@
# 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.
"""Sampling parameters for text generation."""
import zlib
from typing import Any
_SAMPLING_EPS = 1e-6
# Sentinel for "top_k is disabled" (sample from whole vocab). We rewrite
# top_k=-1 (API convention) to this value so downstream code can pass it
# unchanged to top_k kernels that expect a positive cutoff.
_TOP_K_DISABLED = 1 << 30
# Upper bound the fused top-k + top-p kernel sorts in its on-chip top-K
# branch. Requests with a finite top_k above this would silently fall through
# to the top-p-only branch, so reject them at request time. Must stay in sync
# with K_TOPK_MAX in fused_topk_topp.h.
_TOP_K_FUSED_MAX = 128
class SamplingParams:
"""
The sampling parameters.
See docs/backend/sampling_params.md or
https://docs.tokenspeed.ai/backend/sampling_params.html
for the documentation.
"""
def __init__(
self,
max_new_tokens: int | None = None,
stop: str | list[str] | None = None,
stop_token_ids: list[int] | None = None,
temperature: float = 1.0,
top_p: float = 1.0,
top_k: int = -1,
min_p: float = 0.0,
frequency_penalty: float = 0.0,
presence_penalty: float = 0.0,
repetition_penalty: float = 1.0,
min_new_tokens: int = 0,
json_schema: str | None = None,
regex: str | None = None,
ebnf: str | None = None,
structural_tag: str | None = None,
ignore_eos: bool = False,
skip_special_tokens: bool = True,
spaces_between_special_tokens: bool = True,
no_stop_trim: bool = False,
thinking_budget: int | None = None,
custom_params: dict[str, Any] | None = None,
stream_interval: int | None = None,
logit_bias: dict[str, float] | None = None,
seed: int | None = None,
# vLLM-style output logprobs. None = off; 0 = the sampled (generated)
# token's logprob at each output position. Other values are rejected by
# verify().
logprobs: int | None = None,
# OpenAI-compat: `n` is a request-level fanout (number of choices)
# that the serving layer forwards on every sampling_params dict.
# TokenSpeed does not multiplex a single request into n completions,
# so accept and ignore.
n: int = 1,
) -> None:
self.max_new_tokens = max_new_tokens
self.stop_strs = stop
if stop_token_ids:
self.stop_token_ids = set(stop_token_ids)
else:
self.stop_token_ids = None
self.temperature = temperature
self.top_p = top_p
self.top_k = top_k
self.min_p = min_p
self.frequency_penalty = frequency_penalty
self.presence_penalty = presence_penalty
self.repetition_penalty = repetition_penalty
self.min_new_tokens = min_new_tokens
self.regex = regex
self.json_schema = json_schema
self.ebnf = ebnf
self.structural_tag = structural_tag
self.ignore_eos = ignore_eos
self.skip_special_tokens = skip_special_tokens
self.spaces_between_special_tokens = spaces_between_special_tokens
self.no_stop_trim = no_stop_trim
self.custom_params = custom_params
self.thinking_budget = thinking_budget
self.stream_interval = stream_interval
self.logit_bias = logit_bias
self.seed = seed
self.logprobs = logprobs
# Process some special cases
if self.temperature < _SAMPLING_EPS:
# top_k = 1 means greedy sampling
self.temperature = 1.0
self.top_k = 1
if self.top_k == -1:
self.top_k = _TOP_K_DISABLED
def verify(self, vocab_size: int) -> None:
if self.temperature < 0.0:
raise ValueError(
f"temperature must be non-negative, got {self.temperature}."
)
if not 0.0 < self.top_p <= 1.0:
raise ValueError(f"top_p must be in (0, 1], got {self.top_p}.")
if not 0.0 <= self.min_p <= 1.0:
raise ValueError(f"min_p must be in [0, 1], got {self.min_p}.")
if self.top_k < -1 or self.top_k == 0:
raise ValueError(
f"top_k must be -1 (disable), or at least 1, " f"got {self.top_k}."
)
if self.top_k != _TOP_K_DISABLED and self.top_k >= _TOP_K_FUSED_MAX:
raise ValueError(
f"top_k must be < {_TOP_K_FUSED_MAX} (fused kernel limit) "
f"or -1 (disable), got {self.top_k}."
)
if not -2.0 <= self.frequency_penalty <= 2.0:
raise ValueError(
"frequency_penalty must be in [-2, 2], got "
f"{self.frequency_penalty}."
)
if not -2.0 <= self.presence_penalty <= 2.0:
raise ValueError(
"presence_penalty must be in [-2, 2], got " f"{self.presence_penalty}."
)
if not 0.0 <= self.repetition_penalty <= 2.0:
raise ValueError(
"repetition_penalty must be in (0, 2], got "
f"{self.repetition_penalty}."
)
if not 0 <= self.min_new_tokens:
raise ValueError(
f"min_new_tokens must be in (0, max_new_tokens], got "
f"{self.min_new_tokens}."
)
if self.max_new_tokens is not None:
if self.max_new_tokens < 0:
raise ValueError(
f"max_new_tokens must be at least 0, got {self.max_new_tokens}."
)
if not self.min_new_tokens <= self.max_new_tokens:
raise ValueError(
f"min_new_tokens must be in (0, max_new_tokens({self.max_new_tokens})], got "
f"{self.min_new_tokens}."
)
if self.logit_bias is not None:
for token_id in self.logit_bias:
if not 0 <= int(token_id) < vocab_size:
raise ValueError(
f"logit_bias must has keys in [0, {vocab_size - 1}], got "
f"{token_id}."
)
if self.logprobs is not None and self.logprobs != 0:
# Only the sampled token's logprob (logprobs=0) is materialized;
# top-k (>0) and full-vocab (-1) output logprobs are not supported.
raise ValueError(
f"logprobs={self.logprobs} is not supported; use logprobs=0 "
"(the sampled token's logprob)."
)
grammars = [
self.json_schema,
self.regex,
self.ebnf,
] # since mutually exclusive, only one can be set
if sum(x is not None for x in grammars) > 1:
raise ValueError("Only one of regex, json_schema, or ebnf can be set.")
def requested_features(self) -> "set[str]":
"""Return the set of backend-facing feature names this request needs.
`temperature`, `top_k`, `top_p`, `min_p` each appear only when the
corresponding field is not at its neutral default. Used by
SamplingBackend.register() to reject requests asking for features
the active backend does not implement."""
out: set[str] = set()
if abs(self.temperature - 1.0) > _SAMPLING_EPS:
out.add("temperature")
# top_k=_TOP_K_DISABLED and top_k=1 (greedy short-circuit from __init__) are neutral.
if self.top_k != _TOP_K_DISABLED and self.top_k != 1:
out.add("top_k")
if self.top_p < 1.0:
out.add("top_p")
if self.min_p > 0.0:
out.add("min_p")
if self.frequency_penalty != 0.0:
out.add("frequency_penalty")
if self.presence_penalty != 0.0:
out.add("presence_penalty")
if self.repetition_penalty != 1.0:
out.add("repetition_penalty")
if self.logit_bias:
out.add("logit_bias")
return out
def resolve_seed(self, rid: str) -> None:
"""If the caller didn't supply a seed, derive one deterministically
from rid. Called at the single request-materialization point so all
TP/DP ranks agree on the seed."""
if self.seed is None:
self.seed = zlib.crc32(rid.encode("utf-8")) & 0xFFFFFFFF
def normalize(self, tokenizer) -> None:
# Process stop strings
if self.stop_strs is None:
self.stop_strs = []
self.stop_str_max_len = 0
else:
if isinstance(self.stop_strs, str):
self.stop_strs = [self.stop_strs]
stop_str_max_len = 0
for stop_str in self.stop_strs:
if tokenizer is not None:
stop_str_ids = tokenizer.encode(stop_str, add_special_tokens=False)
stop_str_max_len = max(stop_str_max_len, len(stop_str_ids))
else:
stop_str_max_len = max(stop_str_max_len, len(stop_str))
self.stop_str_max_len = stop_str_max_len
@@ -0,0 +1,86 @@
# 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 torch
from tokenspeed_kernel.torch_compile import get_compiler_backend
from tokenspeed.runtime.utils import crash_on_warnings, get_colorful_logger
logger = get_colorful_logger(__name__)
# Smallest positive value per dtype, used as the lower bound for `uniform_`
# draws that feed rejection-sampling kernels. A coin of exact 0 silently
# accepts a zero-probability draft in `chain_speculative_sampling_target_only`
# (the kernel condition `coin <= target_prob / threshold_acc` reduces to
# `0 <= 0`), so the coin must be strictly positive.
COIN_EPS = {
torch.float32: torch.finfo(torch.float32).tiny,
torch.bfloat16: torch.finfo(torch.bfloat16).tiny,
}
def coin_eps(dtype: torch.dtype) -> float:
"""Lower bound for uniform coin draws of the given dtype. See COIN_EPS."""
return COIN_EPS[dtype]
def nan_guard_logits(
logits: torch.Tensor,
enable_nan_detection: bool,
) -> torch.Tensor:
"""Replace NaNs with -1e5 and optionally crash; no-op when detection is disabled."""
if not enable_nan_detection:
return logits
if not torch.any(torch.isnan(logits)):
return logits
logger.warning("Detected errors during sampling! NaN in the logits.")
logits = torch.where(torch.isnan(logits), torch.full_like(logits, -1e5), logits)
if crash_on_warnings():
raise ValueError("Detected errors during sampling! NaN in the logits.")
return logits
@torch.compile(dynamic=True, backend=get_compiler_backend())
def gather_token_logprobs_torch(
logits: torch.Tensor,
tokens: torch.Tensor,
) -> torch.Tensor:
"""Per-row log_softmax(logits)[tokens]. Fuses cast + online softmax + gather
into one Triton kernel sequence so the full [B, V] log_softmax matrix is
never materialized."""
raw_logprobs = torch.log_softmax(logits.float(), dim=-1)
return raw_logprobs.gather(-1, tokens.unsqueeze(-1)).squeeze(-1)
@torch.compile(dynamic=True, backend=get_compiler_backend())
def top_p_normalize_probs_torch(
probs: torch.Tensor,
top_ps: torch.Tensor,
) -> torch.Tensor:
"""Pure-torch nucleus renorm — used by the prefill-logprob path."""
probs_sort, probs_idx = probs.sort(dim=-1, descending=True)
probs_sum = torch.cumsum(probs_sort, dim=-1)
probs_sort[(probs_sum - probs_sort) > top_ps.view(-1, 1)] = 0.0
probs_sort.div_(probs_sort.sum(dim=-1, keepdim=True))
return torch.zeros_like(probs_sort).scatter_(-1, probs_idx, probs_sort)