59a0a3844c
PR Test AMD / cancel-on-close (push) Has been skipped
PR Test NVIDIA ARM / scan (push) Has been skipped
PR Test NVIDIA / cancel-on-close (push) Has been skipped
PR Test AMD / scan (push) Has been skipped
PR Test NVIDIA ARM / cancel-on-close (push) Has been skipped
PR Test NVIDIA / scan (push) Has been skipped
Release Docker Images / build (cu129-torch-2.11.0) (push) Has been skipped
Release Docker Images / build (cu130-torch-2.11.0) (push) Has been skipped
Release PyPI / publish (push) Has been skipped
Scheduler Python Test / test (push) Successful in 27m19s
Docs / build (push) Successful in 28m8s
Scheduler C++ Test / test (push) Successful in 28m19s
Scheduler C++ Test / test-flat (push) Successful in 28m18s
Docs / deploy (push) Has been cancelled
PR Test AMD / finish (push) Has been cancelled
PR Test NVIDIA / finish (push) Has been cancelled
PR Test NVIDIA ARM / finish (push) Has been cancelled
PR Test NVIDIA ARM / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test AMD / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test NVIDIA / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
496 lines
19 KiB
Python
496 lines
19 KiB
Python
# Copyright (c) 2026 LightSeek Foundation
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
# of this software and associated documentation files (the "Software"), to deal
|
|
# in the Software without restriction, including without limitation the rights
|
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
# copies of the Software, and to permit persons to whom the Software is
|
|
# furnished to do so, subject to the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be included in
|
|
# all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
# SOFTWARE.
|
|
|
|
from __future__ import annotations
|
|
|
|
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)
|