Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:32:31 +08:00

622 lines
25 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 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)