TensorRT-LLM 源码完全拆解(十七):Sampler——从 logits 到 token 的采样管线、分档执行与输出闭环

18682 字
93 分钟
TensorRT-LLM 源码完全拆解(十七):Sampler——从 logits 到 token 的采样管线、分档执行与输出闭环

系列进度与本篇定位

这是「四个推理框架源码系统学习」系列的第四站 TensorRT-LLM,也是本站 TensorRT-LLM 系列的第十七篇。前十六篇的路线是先摸地形、再走执行闭环、再进模型执行、再下到 KV 池的内部账本与读写路径,最后补上分布式、显存预算与 P/D 分离:

到这里,请求怎么进来、怎么被调度、KV 落在哪、一次前向怎么拼出来、显存怎么算预算、两台机器怎么搬 KV,都已经拆过。但整条链路上还剩最后一环没有打开:一次前向算完之后,那张 [num_rows, vocab_size] 的 logits 张量,是怎么变成 new_tokens 里那个整数、并且顺手决定了请求是否该结束的? 这一篇拆的就是 tensorrt_llm/_torch/pyexecutor/sampler/ 这个包——TensorRT-LLM 的采样器。

本篇要回答的问题具体到源码级:

  1. 采样参数:SamplingParams 上几十个字段里,哪些真正影响「选哪个 token」,哪些影响「什么时候停」,哪些只是输出格式?「贪心」这个模式在 TensorRT-LLM 里到底是怎么判定的?
  2. 分档执行:为什么同一个采样器要有 FAST 与 FULL 两个档位?为什么没有「greedy 档」?哪些请求会被挡在 fast 档外面?
  3. 一次采样步的完整路径:setup_sampler_step、sample_async、update_requests 三个入口各自负责什么?logits 是从哪一步开始被「只保留每个请求的最后一个 token」的?
  4. 采样内核:fused_sample_from_logits、flashinfer 链路与 PyTorch 原生实现三套内核分别用在哪里,为什么必须融合?top-k 裁剪在设备端是怎么做的?
  5. 随机与罚项:一个带 seed 的请求,它的随机流如何做到「只依赖自己抽了多少个 token」,而与批的组成无关?重复惩罚、bad words 与 top-p decay 这些「改写 logits」的功能如何接进主线?

采样在推理引擎里的位置:最后一公里

从 logits 到 token:三条约束

Transformer 推理的最后一步是一个形状为 [num_rows, vocab_size] 的浮点张量——LM head 的输出。vocab_size 常见是 12.8 万到 25.6 万,num_rows 是这一步要采样的 token 数(每个请求一个,投机解码时一个请求有多行)。采样器要在这张张量上完成三件事:

  1. 改写:应用重复惩罚、presence/frequency 罚项、bad words、no-repeat-ngram、embedding bias、语法 bitmask——这些都是「在选之前调整分布」;
  2. 裁剪:temperature、top-k、top-p、min-p——把候选集缩到一个子集并重新归一化;
  3. 抽样:在裁剪后的分布上按 RNG 抽一个 token id 出来。

三条约束让这件事并不轻松:

  • 这是延迟关键路径上的一小段计算。每一步解码都要采样一次,采样本身的算术量微不足道(一次 softmax 量级),但它夹在「模型前向」与「下一轮前向的输入组装」之间。任何一次主机与设备之间的同步(D2H sync)都会把重叠调度撕开一个口子——这一点在 PyExecutor 装配线与单步闭环 里已经见过它的代价。
  • 批里的每个请求参数都不一样。同一个 batch 里,请求 A 可能是 temperature=0 的贪心,请求 B 是 top_p=0.9 的随机采样,请求 C 带重复惩罚,请求 D 要输出 logprobs。引擎不能为每种组合准备一个模式,只能把「按行(per-row)的参数」送进内核,让设备端自己判断某一行要不要应用某个过滤器。
  • 请求的结束判定也在这里。EOS、max_tokens、stop words 都要在拿到新 token 之后立刻判定,否则调度器下一轮就会多算一道。

三个入口,三种角色

Sampler 抽象基类(sampler/sampler.py:182)只暴露三个执行入口,PyExecutor 在三个不同时刻调用它们:

setup_sampler_step(scheduled_requests) # 批被调度之后、前向之前:新请求入槽
sample_async(scheduled_requests, model_outputs, ...) -> SampleState
# 前向之后:真正采样,同步返回结果
update_requests(state, resource_manager) # 把结果写回请求对象、判定结束

在重叠调度(overlap scheduler)下,这三个入口被摊到不同的拍上:第 i 步的 sample_async 可以在第 i-1 步的 update_requests 之前跑(这也是 SGLang 重叠调度 那一篇里讨论过的「future token」问题的 TensorRT-LLM 版本)。TorchSampler 为此专门维护一个 _pending_steps 计数:每个序列槽记录「已采样但还没写回请求」的步数,bad-words 这类需要读回历史 token 的功能靠它判断是否必须从设备端现读(sampler.py:592)。

还有一个更激进的入口:sample_in_graph(sampler.py:199)。它把采样放进模型前向的 CUDA 图里执行——前向图捕获时,采样内核作为图的尾巴一起被录进去,重放时不再单独发射。这是本系列第一次在 TensorRT-LLM 里看到「采样也在图里」的设计,后文会展开它带来的全部纪律。

采样参数:哪些动分布,哪些判停止

SamplingParams 的字段地图

tensorrt_llm/sampling_params.py 里的 SamplingParams 是一个六十多个字段的 dataclass。字段多,但可以按「它们在哪一环生效」分成四类,分清楚这一点是读采样器的前提:

类别代表字段生效位置
分布塑形temperature、top_k、top_p、min_p采样内核的过滤与重归一化
动态 top-ptop_p_decay、top_p_min、top_p_reset_ids逐步收紧的运行时 top-p
罚项与封禁repetition_penalty、presence_penalty、frequency_penalty、prompt_ignore_length、bad、bad_token_ids、no_repeat_ngram_size、embedding_bias「改写 logits」,发生在抽样之前
束搜索use_beam_search、n、best_of、beam_width_array、length_penalty、early_stopping、beam_search_diversity_rate独立采样策略
停止判定max_tokens、min_tokens、end_id、stop、stop_token_ids、ignore_eos拿到新 token 之后的结束检查
观测输出logprobs、prompt_logprobs、logprobs_mode、return_context_logits、return_generation_logits采样过程里顺带录制的副产物
结构化约束guided_decoding(json / regex / grammar / structural_tag / json_object)、thinking_token_budget前向之后、采样之前的 bitmask
呈现格式detokenize、skip_special_tokens、exclude_input_from_output只影响文本长什么样,不影响 token

其中有些字段在请求进入引擎之前就被「翻译」过一次:SamplingParams._setup 会用 tokenizer 把 bad 与 stop 这些字符串编码成 token id 列表(_bad_word_ids、_stop_word_ids),因为引擎侧的世界里只有 id,没有字符串。这一点决定了后面所有匹配逻辑都发生在「新 token 的 id 序列」上,而不是文本上。

「贪心」的判定:四种显式情形加一种隐式默认

采样器第一个要回答的问题是:这个请求到底要不要随机抽样? 这不是一个布尔开关,而是从一堆可能为 None 的参数里推出来的:

@staticmethod
def params_imply_explicit_greedy(*, temperature, top_p, top_k, min_p) -> bool:
"""Whether the request carries an explicit greedy control."""
# explicit: top_k == 1, top_p == 0.0, min_p == 1.0, or temperature == 0
return top_k == 1 or top_p == 0.0 or min_p == 1.0 or temperature == 0
@staticmethod
def params_imply_greedy_decoding(*, temperature, top_p, top_k,
use_beam_search, min_p=None, top_p_decay=None) -> bool:
if use_beam_search:
return False
if SamplingParams.params_imply_explicit_greedy(...):
return True
implicitly_greedy = temperature is None and top_p is None and top_k is None
min_p_active = min_p is not None and min_p > 0.0
decay_active = SamplingParams.params_imply_top_p_decay_active(top_p_decay)
return implicitly_greedy and not min_p_active and not decay_active

这段代码里有两个设计决定值得注意:

  • 「什么都没设」等于贪心。temperature、top_p、top_k 全为 None 时按贪心处理。这跟 OpenAI 兼容 API 的直觉相反(那里缺省相当于 temperature=1 的随机采样),但与 TensorRT-LLM 的历史默认一致:SamplingParams 的字段默认值就是 None,引擎把「用户没有表达任何采样意图」读作「要最稳的输出」。要让一个请求变成随机采样,至少要设置 temperature、top_k、top_p 或 min_p 中的任何一个。
  • min_p 与 top-p decay 会推翻隐式贪心。min_p 在 (0, 1) 区间时仍然是在多个 token 里选,所以它是「采样意图」;而 top_p_decay 一旦激活(设置了且 < 1.0),运行时 top-p 会逐步收紧,默认的初始 top-p 是 1.0,这意味着它也必须走采样路径,否则衰减根本无从生效。top_p_decay 等于 1.0 是一个空操作——它是 C++ 侧的默认值,写出来与不写等价。

params_imply_greedy_decoding 是单点真相:采样内核的参数解析(resolve_row_params)、槽位参数登记(SlotSamplingParams.admit)、fast 档资格判定(_can_use_fast_path)全都调用它。好处是「贪心还是采样」这个判断不可能在两条路径上漂移出不同的答案——这是采样器里反复出现的工程纪律。

从请求参数到「槽位参数」:SlotSamplingParams

采样器拿到的是一个批(几十上百个请求),而它的热路径是以序列槽(seq slot)为索引工作的(调度器给每个活跃请求分配一个槽位号,见 调度器内部)。所以参数要落成「按槽位的数组」:

class SlotSamplingParams:
"""TorchSampler's resolved sampling params, indexed by sequence slot."""
def __init__(self, num_slots: int, device):
self.modes = [SamplingMode.GREEDY] * num_slots # host: 分组依据
self.need_processed_logprobs = [False] * num_slots # host
self.need_raw_logprobs = [False] * num_slots # host
self.request_ids = [None] * num_slots # 用途:assert_admitted
# device: (temperature, top_p, min_p) 叠在一个张量里,index_copy_ 一次搬完
self.floats = torch.tensor([[1.0], [1.0], [0.0]], dtype=torch.float32,
device=device).repeat(1, num_slots)
self.top_k = torch.zeros(num_slots, dtype=torch.int32, device=device)

三个细节解释了它为什么长这样:

  1. temperature / top_p / min_p 叠在同一个张量的三行里(floats[0] / [1] / [2]),这样每步的 gather 是一次 index_select(1, slots) 搬三组数,而不是三次拷贝。
  2. host 侧保留 modes 与 logprobs 标志,device 侧保留数值。modes 只被 _group_requests 用来分组(主机上的字典操作),不进内核;数值则必须每步出现在设备上。两侧各取所需,避免把主机列表往设备上重复搬运。
  3. assert_admitted 是一道廉价的一致性闸:它检查「读这个槽位的请求」与「最后登记到这个槽位的请求」是不是同一个。没有这道闸,一个刚被新请求复用的槽位会静默地用上一个请求的参数采样——错得不明显,但结果全错。dummy 请求豁免(它们的 token 会被丢弃)。

首次登记(admit)的时候,参数经由 pinned 主机张量 + index_copy_(..., non_blocking=True) 异步搬到设备,整个过程不产生一次同步。贪心与 beam 请求写入的是中性值(temperature=1.0, top_k=0, top_p=1.0, min_p=0.0),这样即使某一步把整批不加区分地送进融合内核,这些行也只是「什么都不做」——批处理因此不需要按行分流。

整条解析路径可以用一张图概括——从 SamplingParams 的字段集出发,分成四类、只让「分布塑形」那一类参与模式判定,最后落成按槽位的参数数组:

SamplingParams 的解析路径:字段按生效位置分四类,分布塑形参数经单点判据决定模式(贪心/采样/beam),再解析为按槽位的参数数组
SamplingParams 的解析路径:字段按生效位置分四类,分布塑形参数经单点判据决定模式(贪心/采样/beam),再解析为按槽位的参数数组

图:采样参数的解析路径。左侧是「字段分流 → 单点判据 → 模式」的主线,右侧是它在 SlotSamplingParams 与热路径纪律上的落点。(自制图,依据 tensorrt-llm/sampling_params.py 与 sampler/sampling_resolution.py 源码绘制)

resolve_row_params:参数的最终解释

sampling_resolution.py 的 resolve_row_params 把四类参数解析成一个五元组,它同时被 TorchSampler 与 one-model 投机路径使用:

class SamplingMode(IntEnum):
GREEDY = 0
SAMPLING = 1
BEAM = 2
def resolve_row_params(*, temperature, top_k, top_p, min_p,
top_p_decay=None, use_beam_search=False) -> RowSamplingParams:
if use_beam_search:
return RowSamplingParams(SamplingMode.BEAM, 1.0, 0, 1.0, 0.0)
if SamplingParams.params_imply_greedy_decoding(...):
return RowSamplingParams(SamplingMode.GREEDY, 1.0, 0, 1.0, 0.0)
temperature = temperature or 1.0
top_k = top_k or 0
top_p = top_p or 1.0
assert temperature > 0
assert top_k == 0 or top_k > 1
assert top_p > 0
min_p = min_p if min_p is not None and min_p > 0.0 else 0.0
return RowSamplingParams(SamplingMode.SAMPLING, temperature, top_k, top_p, min_p)

这段代码里有三处「看起来多余、实际是设计」的细节:

  • top_k 大于等于词表大小不需要特判。内核把超出范围的 top-k 当作「不裁剪」处理,所以 12 万词表的模型传 top_k=100000 与传 top_k=0 等价,调用方不需要知道词表大小。
  • assert top_k == 0 or top_k > 1:top_k=1 已经被上面判成显式贪心,走不到这里;如果走到这里还等于 1,说明判定逻辑被绕过了,直接断言失败。宁可崩,不要静默产生一个「只保留最大值但走随机路径」的诡异状态。
  • 中性值即禁用:top_k=0(全保留)、top_p=1.0(全保留)、min_p=0.0(不裁剪)。三类过滤器共享同一套「禁用哨兵值」,这让融合内核可以在一行代码里对任意过滤器的禁用状态做统一处理,后文讲内核时会展开。

采样分档:SampleType 与两条执行路径

FAST 与 FULL:为什么采样要分档

sampler_common.py 定义了一个只有两个成员的枚举:

class SampleType(Enum):
"""Sampling tiers, ordered by capability.
- FAST: only temperature / top_p / top_k / min_p. Sampled inside the graph.
- FULL: every sampling feature. Sampled eagerly after the forward.
"""
FAST = auto()
FULL = auto()

它被放在最底层的 sampler_common.py 而不是 sampler.py,原因写在注释里:CUDA Graph 的运行器要以档位为键来缓存图,而它不应该为了拿这个枚举去导入整个采样编排模块(那会拉进一条很重的依赖链,其中有循环依赖)。一个枚举的位置,反映的是模块依赖方向的纪律。

两个档位对应两种完全不同的执行方式:

  • FULL:前向结束之后,主机侧跑完所有采样逻辑(分组、罚项、封禁、beam、logprobs……),采样是独立的 kernel 发射序列。功能全,但每步多出若干次 kernel 发射与主机侧编排开销。
  • FAST:采样被录进模型前向的 CUDA 图里——前向图捕获时,fused_sample_from_logits 作为图的尾巴一起被录下,重放时零额外发射。代价是只能表达「每行一组 temperature / top_k / top_p / min_p」这一件事,任何需要主机侧参与的采样功能都进不了这一档。

为什么没有「argmax(贪心)档」? 这是文档里专门解释过的一个决定:

There is deliberately no argmax tier: greedy sampling is two cheap kernels, so capturing it saves about as much launch overhead as staging for it costs (measured on Qwen2-0.5B it was a wash), and greedy batches take the eager stable-greedy fast path instead.

翻译过来:贪心采样本身就是两个廉价 kernel(argmax 加一次散布),把它录进图省下的发射开销,与「为它准备暂存缓冲区」的成本大致抵消——在 Qwen2-0.5B 上实测两者打平。而贪心批另有一条 eager 快路径(后文 _process_requests 里的 stable greedy path)来处理。别为一个测出来打平的优化增加一条分支——档位设计里最有价值的往往是「为什么没有加这一档」。

谁进了 FAST 档:一组资格谓词

批的档位取「批里最苛刻请求所需的档位」,也就是全体请求都满足 FAST 条件才走 FAST(get_sample_type,sampler.py:849)。资格判定拆成两组谓词:

第一组是「能不能在设备上表达」(_can_use_fast_path):

def _can_use_fast_path(self, requests) -> bool:
if self._use_beam_search:
return False
for req in requests:
slot = req.py_seq_slot
if modes[slot] is not SamplingMode.SAMPLING: # 贪心刻意排除
return False
if self._top_p_decay.uses_decay(slot): # 每步变化的 top_p,不是固定行参数
return False
if req.py_return_log_probs or self._request_rewrites_logits(req):
return False
return True

注意两个反直觉之处:贪心被刻意排除(理由上面刚讲过),stop words 被刻意允许(它们只决定请求何时结束,发生在采样之后,不影响选哪个 token)。

第二组是「会不会算错」(_request_bypasses_in_graph_sampling):

def _request_bypasses_in_graph_sampling(request) -> bool:
"""Whether sampling this request in-graph would skip work it depends on.
The hook runs at the tail of the forward, before anything the executor
does to the logits afterwards: the guided-decoding bitmask applied by
``guided_decoder.execute()``, and the user's own logits processors run
by ``_execute_logit_post_processors``...
"""
return (request.py_guided_decoding_params is not None
or bool(getattr(request, "py_logits_post_processors", None))
or bool(getattr(request, "py_is_draft", False)))

这一组才是真正要命的:图内采样发生在前向的尾巴上,而 guided decoding 的语法 bitmask 与用户自定义 logits processor 发生在前向返回之后。如果让这类请求走 FAST 档,采样读到的将是没被 mask 过的 logits,可能吐出一个语法不允许的 token,而 mask 的结果反被丢弃——错误不但发生,还无声无息。所以判据不是「内核能不能表达」,而是「采样发生的时点是否在所有 logits 改写之后」。draft 请求也被排除:草稿引擎与目标引擎共用同一个采样器,但草稿请求没有注册这两个 hook。

而「改写 logits」的完整清单,在 _request_rewrites_logits 里:

return (request.py_embedding_bias is not None
or bool(getattr(request, "py_bad_words", None))
or bool(getattr(request, "py_no_repeat_ngram_size", None))
or has_occurrence_penalty(request) # repetition / presence / frequency
or bool(request.py_min_length)) # min_length 靠「封掉 EOS」实现

最后一个值得单独说:min_length 在 TensorRT-LLM 里不是「采样后检查长度」,而是prospectively(前瞻地)把 end_id 从 logits 里封掉,直到请求长到足够长(见 TokenBanHandler._add_min_length_bans)。图内采样会跳过这个封禁,让 EOS 提前漏出来——所以带 min_length 的请求必须走 FULL。

分档的执行纪律:暂存、校验与一次性消费

FAST 档的执行被拆成「前向之前暂存、图内采样、前向之后读回」三段,每一段都有自己的纪律:

暂存(stage_in_graph_sampling)。时机很讲究——它发生在引擎已经确定这一拍用哪个批之后,而不是在 CUDA Graph 的键被构建时。理由是:如果在建键时就暂存,那些随后回退到 eager 的步(比如图未命中)会带着「按 padded 批准备的散布索引」去散布未 padding 的 logits。此外,暂存必须按整个 padded 批的宽度进行,因为图每次重放都会为每个 padding 行采样并散布一次;padding 行没有序列槽,它们的落点是专门留出的 scratch 行 dummy_slot_row = max_num_sequences:

self.NEW_TOKENS_SHAPE = (self.max_tokens, self.max_num_sequences + 1, self.max_beam_width)
# 「每个 0..max_num_sequences-1 的索引都属于某个请求」,
# 而重放会为每个 padding 行散布一个 token,所以它们需要一个不属于任何人的槽位。

参数缓冲区只分配一次(_FusedRowParamsBuffers.allocate):

"""Per-row fused-kernel params the in-graph fast tier samples from.
A captured graph holds these addresses, so they are allocated once for the
widest batch and only their leading rows are written. ``floats`` stacks
temperature, top_p and min_p, in the layout of ``SlotSamplingParams.floats``.
"""
# 初始值中性:temperature 1, top_k 0(全保留), top_p 1, min_p 0
# 这样即使重放读到未写入的行,也不会过滤任何东西。

「图捕获会固化地址」是本项目里反复出现的约束(十二篇的 piecewise CUDA Graph 里见过一次)。这里的具体推论是:每步新分配一个张量 = 图指向已被释放的内存,所以这些缓冲只能按最宽批分配一次、原地更新。初始化成中性值是一条防御性设计:重放时多读到的行不会意外引发过滤。

图内采样(sample_in_graph / _fast_sample_in_graph) 的纪律写在基类文档里:只做设备端工作、不做主机同步、不做分配、不依赖重放之间会变化的张量形状。所以每行的参数、seed/offset 必须在图外暂存好,图内只做 fused_sample_from_logits + 散布。

读回与校验(_process_requests 的前半段)。前向结束后,sample_async 会检查「暂存的批」是不是就是刚跑完的批:

staged_matches_batch = (
self._in_graph_staged
and self._in_graph_dest_indices is not None
# 草稿请求复用目标请求的 py_request_id,所以 id 列表本身分辨不了
# 「这个批是 drafter 的批」还是「暂存它的目标批」
and not any(request.py_is_draft for request in live_requests)
and [request.py_request_id for request in live_requests] == self._in_graph_request_ids)

校验分两层:id 列表比对(防「前向被跳过/被替换」),以及 py_is_draft 检查(防草稿批套用目标批的暂存)。校验通过后,读回只有前 _in_graph_live_rows 行(padding 行的 token 被丢进 scratch 行),然后立即消费掉暂存状态:

"""Consumption is one-shot. The id list is not a unique key for a batch -- a draft
request reuses its target's py_request_id and the drafter shares this sampler --
so leaving the state staged would let a later drafter step with the same ids be
served these tokens as draft tokens. The next forward restages."""
self._consume_in_graph_staging()

如果不一次性消费,会出现「同 id 的草稿步把目标批的 token 当自己的草稿 token」这种诡异地静默错误。暂存状态是一种一次性资源,这是图内采样带来的新状态机纪律。

还有一条边界:图内采样只是把 token 采出来,停止判定仍然要跑。所以即便走了 FAST 档,single_step_greedy 也只有在「批里没有任何请求需要 finish-reason kernel」时才为真——否则仍然要走完整路径计算结束原因,不然 stop word 会漏检:

single_step_greedy = not any(
self._request_needs_finish_reason_kernel(request) for request in live_requests)

_request_needs_finish_reason_kernel 的判据是 min_length 或「多 token 的 stop word」——前者依赖主机侧的运行计数(重叠调度下主机历史是滞后的),后者需要滑窗匹配。

采样状态与缓冲区布局

三张核心张量

采样器的持久状态集中在 TorchSampler.Store 里,三张张量撑起整条路径:

new_tokens: (max_tokens, max_num_sequences + 1, max_beam_width) # 最新采样的 token
sampled_log_probs:(max_num_sequences, max_beam_width, max_tokens) # 只给 logprobs / beam 用
log_probs_store: 若干同形状缓冲(indices / ranks / topk_indices / topk_vals)

new_tokens 的形状值得逐维解释:

  • 第一维 max_tokens = max_total_draft_tokens + 1:一个请求一拍最多产生多少个 token。投机解码时草稿长度是 K,一拍验证后最多接受 K+1 个 token,所以要留 K+1 行。非投机场景 K=0,第一维就是 1。
  • 第二维 max_num_sequences + 1:序列槽数加一个 scratch 行。加的那个 1 就是 dummy_slot_row,专门接 CUDA Graph padding 行散布出来的 token。
  • 第三维 max_beam_width:束搜索宽度。非束搜索时是 1。

__init__ 里对形状一致性有一条断言,它把 new_tokens 与 finish-reasons 缓冲绑在一起:

assert (new_tokens_shape[0] == finish_reasons_shape[0]
and new_tokens_shape[1] == finish_reasons_shape[1] + 1
and new_tokens_shape[2] == finish_reasons_shape[2]), \
f"{new_tokens_shape} is not {finish_reasons_shape} plus a scratch slot row"

两个缓冲除了「多一行 scratch」之外必须逐维相等——它们都以序列槽为索引,一旦形状不一致,某个下游的扁平 gather 的 stride 就会算错。

为什么这些张量必须一次分配

SampleState 把「设备侧结果」与「主机侧结果」分开承载:

@dataclass(kw_only=True)
class SampleStateTensors:
new_tokens: torch.Tensor
log_probs: torch.Tensor | None = None
@dataclass(kw_only=True)
class SampleState(Generic[...]):
requests: list[LlmRequest]
device: Optional[...] = None
host: Optional[...] = None
sampler_event: Optional[SamplerEvent] = None
runtime_draft_len: Optional[int] = None

设备侧保留 new_tokens(下一轮前向要用),主机侧是 .tolist() 之后的一份拷贝(update_requests 要用)。两者分开是关键:重叠调度下,主机侧的 D2H 拷贝可以在异步 worker 的侧流上进行,SamplerEvent 就是用来同步这些副流拷贝的句柄。

SampleStateTensorsHostTorch 上还有一个 single_step_greedy 标志,它的注释解释了一个真实的坑:

Describes these host tensors, so it must live here rather than on SampleStateTorch: under pipeline parallelism only this object crosses the ring hand-off, and a receiving rank would otherwise pair the compact buffer with an outer flag left at its default.

流水并行(PP)下,只有这个主机张量对象会被传给环上的下一个 rank;如果把标志放在外层对象上,接收方会用一个「默认的 False」去配一个紧凑布局的缓冲区,导致读回错位。标志必须和它描述的数据活在一起——这是跨 rank 传递状态时的一条通用纪律。

按序列槽 vs 按行:两种索引空间

采样器内部有两个索引空间,分清它们能避免读代码时迷路:

  • 按序列槽(per-slot):长度 max_num_sequences(或 +1),承载一切「跨步持久」的状态——参数(SlotSamplingParams)、种子(_SeedManager)、罚项计数(PenaltyStore)、top-p decay 运行时值、finish reasons、pending steps 计数。这些状态与请求共存亡,不随批的组成变化。
  • 按行(per-row):长度等于这一步 logits 的行数,承载一切「本拍临时」的东西——每行的参数 gather 结果、每行的 seed/offset、每行的日志概率。一个请求如果这一拍产生 N 个 token,就占 N 个连续行。

_PackedStepIndexer / _UnpackedStepIndexer(sampler_features.py)就是两种索引空间之间的翻译器:

class _StepIndexTranslator(ABC):
"""...selecting the memory locations of tokens associated with given sets of requests."""
@staticmethod
def calculate_request_offsets(req_num_steps, pin_memory=False):
# 前缀和右移一位:req_offsets[i] = sum(num_steps[:i])
req_offsets = torch.cumsum(req_num_steps, 0)
req_offsets_rolled[1:] = req_offsets[:-1]
req_offsets_rolled[0] = 0

「packed」指模型输出的扁平行布局(每请求行数不等、紧挨着排),「unpacked」指采样器要散布进去的矩形 (steps, slots, beams) 布局。翻译器的核心是一张 (len(num_steps), max_steps) 的索引表加一个有效掩码——__getitem__ 一次选出某个请求组对应的所有行,掩码过滤掉超出该请求步数的列。设计上是「一次建表、多次查表」,把每个请求的行范围预先算好,避免每步重新推导。

从 logits 到 token:一次采样步的完整流程

三个入口的时序

把三个入口在一步里的位置摆出来:

调度器产出 ScheduledRequests(context_requests + generation_requests)
│
▼
setup_sampler_step(scheduled_requests) # 新请求入槽:参数、种子、罚项、finish reasons
│
▼
模型前向 → logits [num_rows, vocab_size]
│
├─ 若档位是 FAST:图内已采好 token,只需 D2H 拷贝读回
│
▼
sample_async(scheduled_requests, model_outputs, num_context_logits_prefix_sum)
│ 1. _process_requests:选取要采样的行、应用 embedding bias / 罚项 / 封禁
│ 2. 贪心快路径 或 分组采样
│ 3. _unbatch_sampling_results:把结果散布回 (steps, slots, beams)
│ 4. 计算 finish reasons(非 single_step_greedy 时)
▼
update_requests(state, resource_manager) # 写回请求对象、判定结束、推进 decode iter

三个入口的时序与两个索引空间的承载物,可以用一张图对齐:

三个执行入口的时序:setup_sampler_step → 前向 → sample_async → update_requests;下方是按序列槽的持久状态与按行的临时结果两个索引空间
三个执行入口的时序:setup_sampler_step → 前向 → sample_async → update_requests;下方是按序列槽的持久状态与按行的临时结果两个索引空间

图:执行入口时序与内存布局。上排是一次执行器步里三个入口的位置(FAST 档在前向图的尾巴上完成采样),下排是两个索引空间各自的承载物与翻译器。(自制图,依据 sampler/sampler.py 与 sampler/sampler_features.py 源码绘制)

只保留「最后一个 token」:logits 的裁剪

前向产生的 logits 是每个输入 token 一个行。而采样只需要每个请求的最后一个位置——那是「下一个 token」的预测。这一步发生在 LogitsProcessor(_torch/modules/logits_processor.py):

class LogitsProcessor(nn.Module):
@nvtx_range("LogitsProcessor")
def forward(self, hidden_states, lm_head, attn_metadata, return_context_logits=False):
if not return_context_logits:
if attn_metadata is not None:
last_tokens = torch.cumsum(attn_metadata.seq_lens_cuda, dim=0, dtype=torch.long) - 1
hidden_states = hidden_states[last_tokens]
else:
hidden_states = hidden_states[-1]
logits = lm_head(hidden_states)
logits = logits.float()
return logits

三件事在这里发生,每一件都影响采样器的输入形态:

  1. 按 seq_lens_cuda 的前缀和减一取「每请求最后一行」。变长批里,第 i 个请求的最后一个 token 在扁平张量里的位置是 cumsum(seq_lens)[i] - 1。这是一次 gather,把 [total_tokens, hidden] 缩成 [num_requests, hidden]。
  2. lm_head 只对这批薄的行做。如果先对全部 token 做 lm_head,计算量会大出一个量级——把投影推到最后一行之后是省算力的关键。
  3. logits.float():LM head 的输出(bf16/fp16)在这里统一转成 fp32。采样器注释里也印证了这一点:LogitsProcessor forces logits to float32。fp32 是有意的——top-k/top-p/softmax 的数值稳定性依赖足够的精度,而采样这一步的算力代价本来就微不足道。

注意 return_context_logits=True 时不裁剪:那种场景要的是每个上下文位置的 logits(用于 prompt logprobs 或蒸馏),形状完全不同。

context 与 generation 的 logits 如何分区

前向一次可能同时包含 context 请求与 generation 请求。HandleLogits(_torch/pyexecutor/handle_logits.py)负责把这张混合 logits 分发给各请求的「logits 存储」(用于 return_context_logits / return_generation_logits):

for batch_index, llm_req in enumerate(context_requests):
logits_begin = num_context_logits_prefix_sum[batch_index]
logits_end = num_context_logits_prefix_sum[batch_index + 1]
if llm_req.py_return_context_logits:
if llm_req.prepopulated_prompt_len > 0:
logger.warning(f"Because of KV cache reuse, not all context logits could be produced ...")
llm_req.py_result.append_context_logits(logits[logits_begin:logits_end])
...
total_context_logits = num_context_logits_prefix_sum[-1]
for batch_index, llm_req in enumerate(generation_requests):
logits_begin = total_context_logits + batch_index * beam_width
...

num_context_logits_prefix_sum 是由 PyExecutor._sample_async 现算的(py_executor.py:8583),它按「每个 context 请求贡献 context_chunk_size 还是 1 行」累加:

prefix_sum += context_chunk_size if request.py_return_context_logits else 1

也就是说:要 context logits 的请求贡献整段提示的行,不要的只贡献最后一行。这个前缀和同时被 HandleLogits(切分)和 TorchSampler._select_generated_logits(定位生成区起点)用——同一个数字,两处消费。

KV 复用的警告也在这里:如果某个请求的提示被前缀缓存命中(prepopulated_prompt_len > 0),那部分 context logits 根本没算过,引擎只能发一条警告说「给不全」。

_select_generated_logits:把批收窄成要采样的请求

真正的采样只针对 generation 请求。_select_generated_logits 从原始 logits 里切出生成区、按请求分组、生成 SamplingRequestsMetadata:

@dataclass(kw_only=True)
class SamplingRequestsMetadata:
req_num_generated_tokens: torch.Tensor # 每请求本拍产出的 token 数(入向 beam 宽度)
req_num_generated_tokens_output: torch.Tensor # 出向 beam 宽度
req_num_beams: torch.Tensor # 每请求的 beam 数
req_num_steps: torch.Tensor # 每请求的生成步数 = 1 + draft_len
req_offsets: torch.Tensor # 每请求在原始 logits 里的起始偏移

req_num_steps 是「本拍一个请求要产几个 token」,非投机时恒为 1,投机时等于 1 + draft_token_length——这正是 new_tokens 第一维的由来。req_offsets 是 packed 布局里的前缀和起始位置。

三处 logits 改写,顺序不能错

在真正抽样之前,logits 要依次经过三处改写。它们的顺序是强制的:

# 1. embedding bias(logit bias):把用户指定的 token 偏置加进去
apply_embedding_bias(logits_cuda, sampling_requests, sampling_requests_metadata.req_num_steps)
# 2. 罚项:repetition/presence/frequency,原地改写
"""Apply repetition/presence/frequency penalties in place, before the greedy fast
path, so both greedy and grouped-sampling logits are penalized. With beam search
this also re-parents the per-beam counts, which reads the predecessor map the
step's sampling is about to overwrite -- so it has to stay ahead of sampling."""
self._penalty_handler.apply(logits_cuda, sampling_requests, new_tokens=new_tokens_cuda, ...)
# 3. token ban:bad words / no-repeat-ngram / min_length,把某些 token 打成 -inf
self._token_ban_handler.apply_ban_list(logits_cuda, bans, new_tokens_cuda=new_tokens_cuda)

为什么 embedding bias 排在罚项之前?因为罚项对「负无穷 logit」有特殊处理(见后文 occurrence_penalized_logits 的 clamp 逻辑)——如果 bias 先产生了一个 -inf 的封禁,罚项的 clamp 必须知道「这个 -inf 是封禁,不是数值」,不能把它夹回有限值。所以 bias 先写、罚项后读,罚项才能正确识别出「已经被封死的 token」。

罚项排在封禁之前则是因为 beam search:罚项要读上一拍的 predecessor_beams,而这一步的采样马上要覆盖它——「必须在采样覆盖它之前读」。

贪心快路径

改写完成后,如果整批都是贪心且没人要 logprobs,走一条极简路径:

if self._can_use_fast_greedy_path(sampling_requests):
batch_destination_indexer = _UnpackedStepIndexer(...) # 目标散布位置
fast_greedy_sample_kernel(logits_cuda, new_tokens_cuda,
batch_dest_indices_cuda, self.max_beam_width, d2t)

fast_greedy_sample_kernel 就是「argmax + d2t 翻译 + 散布」三合一:

next_tokens = torch.argmax(logits_cuda, dim=-1).to(dtype=new_tokens_cuda.dtype)
if d2t is not None:
next_tokens += d2t[next_tokens] # Eagle3 的草稿词表 → 目标词表
scatter_new_tokens(next_tokens, new_tokens_cuda, batch_dest_indices, max_beam_width)

scatter_new_tokens 把每行的 token 广播到该槽位的所有 beam——因为这条路径每请求只采一个 token,beam 维是广播,不是逐 beam 结果。

分组采样:_group_requests

非贪心路径先把批拆成「一次内核调用能一起处理」的组(_group_requests):

for req_idx, (request, slot) in enumerate(zip(requests, seq_slots)):
slot_params.assert_admitted(request, slot)
mode = modes[slot]
needs_probs = need_processed_logprobs[slot]
if mode is SamplingMode.BEAM:
beam_params = BeamSearchParams.from_request(request)
key = (mode, needs_probs, beam_params.group_key)
else:
key = (mode, needs_probs)
groups[key].req_indices.append(req_idx)

分组键是三元组:采样模式(贪心 / 采样 / beam)、是否需要算概率、以及 beam 搜索额外要求共享的 group_key。同一组共享一次内核调用与一组参数。设计动机是「内核调用次数最少化」——但如果同模式请求在批里不连续,组内的行就不连续,需要一次 index_select 把它们的行聚起来(_sample_batched_by_strategy 里的 contiguous 快判 + 非连续 index_select 分支)。

_sample_fused:融合内核的调用点

采样组的核心调用是 _sample_fused:

def _sample_fused(self, logits_cuda, slots_per_row, *, return_probs):
seeds = self._seed_manager.take_row_seeds(slots_per_row, device=device)
slots_cuda = torch.tensor(slots_per_row, dtype=torch.int64, pin_memory=prefer_pinned()).to(device, non_blocking=True)
temperature, top_k, top_p, min_p = self._slot_params.gather(slots_cuda)
top_p = self._top_p_decay.override_top_p(top_p, slots_cuda)
# 设备端断言,避免主机侧检查带来的每步同步
torch._assert_async(~torch.any(torch.isnan(logits_cuda)))
params = (logits_cuda, temperature, top_k, top_p, min_p)
if return_probs:
tokens, probs = fused_sample_from_logits_with_probs(*params, seed=seeds.seed, offset=seeds.offset)
return tokens, probs, temperature
return fused_sample_from_logits(*params, seed=seeds.seed, offset=seeds.offset), None, temperature

四件事在一处收拢:每行的 seed/offset、每行的四组参数(gather + top-p decay 覆写)、NaN 断言、一次融合内核调用。torch._assert_async 是一个刻意的选择——设备端断言不会强制一次主机同步,而主机侧 if torch.isnan(...).any() 会。在每步都跑的热路径上,同步就是罪。

return_probs 分支返回同一次过滤产生的 token 与概率——注释点明了原因:拒绝采样(投机解码的验收)需要 token 与概率来自同一个分布,否则「接受概率是按一个谁都没从上面采样的分布算的」。

结果的散布与 logprobs 的录制

采样完,_unbatch_sampling_results 把分组的输出按请求索引散布回 (steps, slots, beams)。它有一条防御性断言:

def _dims_canonically_ordered(t): return len(t.dim_order(ambiguity_check=[torch.contiguous_format])) == t.ndim
assert _dims_canonically_ordered(new_tokens_cuda)

「目标张量的维度必须是行主序」——因为后面的 .view(...).scatter_(...) 依赖元素顺序,非连续布局会让 view 出来的线性索引与预期不符。这是「用一个断言换掉一次静默的错位」的又一例。

logprobs 的录制(_process_logprobs)只在有请求要 logprobs 时发生,且要处理 beam 请求(它们的 logprobs 来自 beam history,不走普通 gather)。注释里还留了一条未来的优化设想:用「未屏蔽 token 数 nu 和比值 r = log(max(probs)) - max(logits)」来避免拷贝整个词表的张量,但当前不可行——因为几个内核的 tie-breaking 行为未定义,重建结果可能不一致。「理论上更省的做法」被搁置,理由是数值语义未确定,这种克制值得记住。

采样内核:融合内核、flashinfer 链路与 PyTorch 原生实现

为什么必须融合

采样的算术量小得可怜——一次 softmax 量级。但它的发射负担不小:temperature 缩放、min-p 阈值、top-k 掩码、top-p 累计、重归一化、抽样,每一步在朴素实现里都是独立的 kernel。在解码的每步都跑一遍,六次发射的固定开销会超过计算本身。

更麻烦的是参数组合爆炸。批里可能同时存在「温度 0.7 + top-p 0.9」「温度 1.0 + top-k 50」「纯贪心」三类请求。朴素做法要按「启用了哪些过滤器」给每个请求归类,对每种组合准备一条内核链路——组合数是 2 的过滤器数次方。ops/custom.py 的文档把这一点讲得很清楚:

Unlike the flashinfer chain in :mod:.flashinfer, no filter is resolved away on the host: every parameter is passed as a per-row tensor and neutrality is decided on device. That is what lets one deploy serve a batch mixing filtered and unfiltered requests without a mode per combination — and what lets min_p be added without doubling the mode space.

「禁用在设备端判定」是这个融合内核最重要的设计决定:每个参数都以按行张量的形式传进去,禁用状态用哨兵值表达(top_k 落在 (0, vocab) 之外、top_p >= 1、min_p <= 0),内核在设备上逐行判断「这一行要不要应用这个过滤器」。代价是内核多做几次分支判断,收益是模式空间从指数级塌缩成常数——加一个 min_p 过滤器不再需要把模式翻倍。

三个入口,三个输出组合

融合内核对外只有三个 Python 入口,区别仅在「要它返回什么」:

fused_sample_from_logits tokens -- 目标/草稿采样器
fused_compute_probs_from_logits probs -- 目标分布的概率(供拒绝采样用)
fused_sample_from_logits_with_probs both -- 草稿采样器 + 它的草稿概率
def fused_sample_from_logits(logits, temperatures, top_ks, top_ps, min_ps, *,
seed=None, offset=None) -> torch.Tensor:
"""Sample one token per row. Returns ``[num_rows]`` int32."""
return torch.ops.trtllm.fused_sample_from_logits(
logits, temperatures, top_ks, top_ps, min_ps, seed, offset)

把「同一份过滤、不同输出需求」拆成三个实例化,而不是一个带开关的入口——这样每个内核家族只实例化它真正需要的那一半,不多算。

注释里还有一条关于可编译性的重要说明:

Unlike the flashinfer ops, none of these carries @torch.compiler.disable, and the absence is deliberate rather than an omission: each is a single C++ custom op with a registered fake kernel, so it traces cleanly and has no python chain to break the graph on.

闪存推理链路要 @torch.compiler.disable(因为它是多 kernel 的 Python 编排),而融合版是单个 C++ 自定义算子 + 注册了 fake kernel,能被 torch.compile 干净地 trace。这就是它能进 CUDA Graph 的底层原因——图捕获要求整条链在编译视角下是一个不透明的单体。

分发器:单 CTA 还是多 CTA

文档提到内核内部的 dispatcher 会在「单 CTA」与「多 CTA」两个执行家族之间选。这是按行数与词表大小做的分派:词表 12.8 万,单行 top-k 的全词表扫描在多 CTA 下更快,但行数很少时多 CTA 的启动开销反而吃亏。选择发生在内核内部,调用方无感。

TensorRT-LLM 官方博客里有一张把「设备端正采样配置」讲得最清楚的图,它出自 GVR v2(Self-Sampling and Multi-Thresholding for Faster Exact Top-K)——一套用自采样直方图在设备端做精确 Top-K 的选点核心,同时服务解码(run_varlen)与预填充(run_prefill)两种行接口:

GVR v2 的接入结构:一套 selection core 对上两种行接口——稀疏注意力索引器的 TopK dispatcher 在 decode 与 prefill 两相分别以 varlen 与 compressed-column window 方式调用同一套自采样 + 多阈值选点核心
GVR v2 的接入结构:一套 selection core 对上两种行接口——稀疏注意力索引器的 TopK dispatcher 在 decode 与 prefill 两相分别以 varlen 与 compressed-column window 方式调用同一套自采样 + 多阈值选点核心

图:「一套选点核心、两种行接口」——上方是稀疏注意力索引器到 TopK dispatcher 的调用链,下方是 decode(变长 KV + MTP offset + 压缩量 → 有效前缀)与 prefill(压缩列窗口)两条路径共用同一套自采样、多阈值直方图与「收集 + 精化」流程产出精确索引。(来源:NVIDIA/TensorRT-LLM 官方技术博客 blog29 配套图 docs/source/blogs/media/gvr_v2/integration.svg)

这张图与本篇的关系在于同一个算法家族:采样器的 top-k 与索引器的 exact top-k 都需要「从几十万个浮点数里选出前 k 个」的设备端选择,都要避免「先全排序再截断」的暴力做法,也都以「一步采样得到直方图 / 阈值、再收集与精化」的两阶段结构收尾。区别在于采样器的 top-k 结果用于构造抽样分布,而索引器的 top-k 结果是一张 token 索引表;但对内核作者来说,这两处的工程约束完全一致:选择必须在设备上完成,且不能引入主机同步。

flashinfer 链路:另一条采样实现

ops/flashinfer.py 是闪存推理提供的采样原语,用在与 one-model 投机解码相关的路径上。它的结构与融合内核相反——主机侧把过滤器解析掉:

def resolve_advanced_sampling_filters(advanced_sampling_mode, top_k, top_p):
"""Resolve advanced_sampling_mode to effective (top_k, top_p) tensors.
A filter the mode disables (via ``skips_top_k`` / ``skips_top_p``), or one already
None, becomes None so the downstream op skips that kernel..."""
eff_top_k = None if advanced_sampling_mode.skips_top_k or top_k is None else top_k
eff_top_p = None if advanced_sampling_mode.skips_top_p or top_p is None else top_p
return eff_top_k, eff_top_p

链路本身是多个 kernel 顺序调用:

@torch.compile(options={"max-autotune": True})
def sample_from_logits_op(logits, temperatures, top_k=None, top_p=None, seed=None, offset=None):
if top_k is not None:
top_k = sanitize_top_k(top_k, logits.shape[-1])
logits = top_k_mask_logits_op(logits, top_k)
probs = softmax_op(logits, temperatures)
if top_p is not None:
return top_p_sampling_from_probs_op(probs, top_p, seed=seed, offset=offset)
return sampling_from_probs_op(probs, seed=seed, offset=offset)

这里有一个必须处理的边界(sanitize_top_k):

"""Per ``SamplingParams``, ``top_k == 0`` means "all logits" (top-k disabled),
but the flashinfer top-k kernels (``top_k_mask_logits``) break on a literal
0 — they mask the entire row (all-zero probs). Map any non-positive value
(and any oversized disable sentinel such as ``INT32_MAX``) to ``vocab_size``
(== keep all tokens), leaving genuine top_k values untouched."""
return top_k.clamp(max=vocab_size).masked_fill_(top_k <= 0, vocab_size)

两个采样实现对「禁用」的编码不同:融合内核用哨兵值在设备端判断,闪存的 top_k_mask_logits 却会被字面 0 弄坏(整行被 mask 成全零概率)。所以走闪存链路前必须先把 0 翻译成 vocab_size。这类「半禁用的编码不一致」是接多套内核时最常见的坑。

闪存链路还有一个启动期的问题,注释里写得很有意思:

def warmup_sampling_module() -> None:
"""Build flashinfer's sampling kernels now instead of on first use.
flashinfer ships these kernels as source, so the first sampling call runs
nvcc inline (~90s with a cold cache). Doing that inside the executor loop
stalls the rank past the hang detector's threshold. Cheap no-op once built."""

闪存的采样内核是源码分发、首次调用时 nvcc 现场编译的,冷缓存下要 90 秒。如果放到执行循环里做,整个 rank 会被 hang 检测器判定为卡死。所以启动时预热一次。

PyTorch 原生实现:参考与罚项

ops/vanilla.py 是纯 PyTorch 的实现,两个用途:作为数值参考(对拍融合内核),以及承载那些不值得写 C++ 的算子。

参考实现简短到可以直接读完:

def min_p_renorm_probs(probs, min_p):
"""Keep tokens with prob >= ``min_p`` times the per-row max, then renormalize."""
max_probs = probs.max(dim=-1, keepdim=True).values
thresholds = min_p * max_probs
probs = probs.masked_fill(probs < thresholds, 0.0)
return probs / probs.sum(dim=-1, keepdim=True)
def greedy_search_sampling_batch(logits, *, return_probs=True):
next_tokens = torch.argmax(logits, dim=-1)
...

min_p 的语义在这里一目了然:阈值是「该行最大概率乘以 min_p」,不是绝对值。这一点很多人会记错——min_p 是相对阈值,随每行分布自适应。这也解释了为什么它不能像 top-k/top-p 那样用固定数值编码禁用。

真正有分量的是 occurrence_penalized_logits——重复/存在/频率三种罚项的单一算术真相:

def occurrence_penalized_logits(logits, *, count, seen_for_repetition, seen_for_presence,
repetition, presence, frequency):
"""The occurrence-penalty formula, shared by every backend that applies it.
Single source of truth for the arithmetic, so the eager and CUDA-graph paths
cannot drift apart."""
penalized = logits.float()
# repetition 是乘性的,按 logit 正负分支 —— 除以负 logit 会把它抬高
repeated = torch.where(penalized < 0, penalized * repetition, penalized / repetition)
penalized = torch.where(seen_for_repetition, repeated, penalized)
penalized = penalized - torch.where(seen_for_presence,
presence + frequency * count.to(torch.float32),
penalized.new_zeros(()))
limit = torch.finfo(logits.dtype).max
clamped = penalized.clamp(-limit, limit)
return torch.where(torch.isfinite(logits), clamped, logits.float()).to(logits.dtype)

四行算术里有三处「必须这样写」:

  1. repetition 按符号分支。重复惩罚的参数通常大于 1。如果无条件做 logits / repetition,一个负的 logit 会因为除以大于 1 的数而变大(向零靠),也就是「被抬高」——与「惩罚」的意图相反。所以负 logit 乘、正 logit 除,两个方向都是「压低」。这是罚项实现里最容易出错的一行。
  2. seen_for_repetition 与 seen_for_presence 是两个不同的掩码。文档解释:被 prompt_ignore_length 排除掉的提示前缀 token 仍然算「见过」用于重复惩罚,但不算入存在/频率惩罚。一个掩码盖不住两种语义。
  3. clamp 只夹「算术可能越界的部分」,不夹输入的 -inf。最后一行 torch.where(torch.isfinite(logits), clamped, logits.float()) 是关键:一个来自 embedding bias 的 -inf(用户封禁的 token)必须保持 -inf,否则 clamp 成有限最小值后,这个被封的 token 又会变得可采样。「-inf 是语义,不是数值」。

融合的罚项图

罚项的实际应用走 _apply_occurrence_penalties_impl(torch.compile(fullgraph=True)),注释里记了一个被实测否掉的实现方案:

# Map each logits row to its owning request with a broadcasted range comparison.
# This is O(T * R), but T and R are both small (rows per step x requests) and the
# whole thing fuses into the surrounding elementwise graph, so it measures faster
# than either a searchsorted lookup or a repeat_interleave expansion. Notably
# torch.repeat_interleave must NOT be used here: its output length is
# sum(num_steps), which -- without an explicit host-provided output_size -- torch
# reads back from the device, and that per-step D2H sync destroys the overlap
# between the sampler's host work and the model forward (measured ~20x slower).

repeat_interleave 不带 output_size 会触发一次 D2H 同步去读输出长度,实测慢约 20 倍。所以这里用 O(T×R) 的范围比较来「行 → 请求」映射——理论上更慢的算法,实际上因为没有同步而快得多。这是本系列里「同步成本压倒算术复杂度」的又一实例。

随机数:Philox 种子管理

为什么不能用批级的 torch.Generator

一个带 seed 的请求,用户期望的是:同样的 seed、同样的提示,得到同样的输出。但如果随机数来自一个批级的 torch.Generator,它的内部状态会按「整批这一步抽了多少个数」推进——于是「这个请求落在哪个批里」「批里还有谁」都会改变它的随机流。复现性就没了。

_SeedManager 的文档把问题与解法一起写清楚:

A seeded request’s RNG stream must not depend on which other requests share its batch, so it cannot come from a single batch-wide torch.Generator whose state advances by the batch’s total draw count. Instead every row is sampled with its own Philox (seed, offset), and the kernel keeps the row index out of per-row streams, so a row’s draws depend on that pair alone.

解法是 Philox 计数式 RNG 的 (seed, offset) 二元组:随机流完全由这两个数决定,与行在批里的位置无关。

三套计数器

_SeedManager 维护三样东西:

self._seeds = torch.full((max_num_sequences,), global_seed, dtype=torch.int64) # 按槽位
self._offsets = torch.zeros((max_num_sequences,), dtype=torch.int64) # 按槽位
self._slot_owner: list[Optional[int]] = [None] * max_num_sequences # 槽位当前属主
self._unseeded_offset = UNSEEDED_OFFSET_BASE # 1 << 62
  • 有 seed 的请求:用用户的 seed,offset 按序列槽位计数,槽位换主时 offset 归零。所以它的流只依赖「这个请求自己抽了多少个 token」,与批的组成无关。
  • 没 seed 的请求:用统一的 global_seed = 42(每个 rank 都一样,保证多卡一致),offset 从一个全局共享、只增不减的计数器取,起点是 UNSEEDED_OFFSET_BASE = 1 << 62。

那个 1 << 62 的起点不是随便挑的:

# First offset of the rows that carry no user seed. They all share one seed, which a user
# seed may equal, so they take offsets far beyond any a seeded stream reaches counting up
# from 0.

无 seed 的请求与「seed 恰好等于 42」的用户请求共享同一个 seed 值。如果两边的 offset 都从 0 开始计数,它们会抽到完全相同的随机序列——一个用户显式指定 seed=42,会得到和「没指定 seed」一样的输出,这不合理。把无 seed 的 offset 扔到 2^62 以上,就保证了「各自的流永不重叠」,而不需要为「用户 seed 是不是等于全局 seed」做特判。用取值域隔离代替条件判断。

槽位换主与草稿批

def observe(self, requests) -> None:
"""Seed any slot whose occupant changed.
...Resetting the offset on ownership change is what makes a seeded request start at
the beginning of its stream instead of wherever the slot's previous occupant left off."""
self._batch_is_draft = bool(requests) and requests[0].py_is_draft
if self._batch_is_draft:
return
for request in requests:
if self._slot_owner[seq_slot] != request_id:
self._slot_owner[seq_slot] = request_id
seed = request_random_seed(request)
self._seeds[seq_slot] = self._global_seed if seed is None else seed
self._offsets[seq_slot] = 0

草稿批直接跳过,理由写在注释里:草稿引擎从它自己的 SeqSlotManager 分配槽位,用的编号区间和目标是同一个,所以一个草稿请求可能占着「某个活跃目标请求」的槽位号。如果按属主变化处理,会把那个目标请求的 offset 重置,让它重放一段随机流。草稿行索性全部按「无 seed 行」处理。

take_row_seeds:按行发号

def take_row_seeds(self, slots_per_row, *, device) -> RequestSeeds:
"""Assign each row of one sampling call its ``(seed, offset)``, and advance."""
num_rows = len(slots_per_row)
# Calls without a seeded row are the common case; build them on the device,
# without a per-row loop or a host-to-device copy.
if self._batch_is_draft or not any(map(self._slot_seeded.__getitem__, slots_per_row)):
start = self._unseeded_offset
self._unseeded_offset += num_rows
return RequestSeeds(
seed=torch.full((num_rows,), self._global_seed, dtype=torch.int64, device=device),
offset=torch.arange(start, self._unseeded_offset, dtype=torch.int64, device=device))
# 有 seed 行的慢路径:逐槽位取
for slot in slots_per_row:
if not self._slot_seeded[slot]:
seeds.append(self._global_seed); offsets.append(self._unseeded_offset)
self._unseeded_offset += 1
else:
seeds.append(int(self._seeds[slot])); offsets.append(int(self._offsets[slot]))
self._offsets[slot] += 1

两条路径分得很清:常见情形(整批没有 seed)完全在设备上构造(torch.full + torch.arange),零主机循环、零 H2D 拷贝;只有存在 seeded 行时才走逐行循环,且用 pinned 内存异步搬运。热路径的优化方向永远是「把常见的、简单的情形做成零主机侧工作」。

图内采样的 seed 暂存

FAST 档要在图外把 seed/offset 暂存好。这里有一条容易忽略的纪律:

# Only the live rows consume RNG stream. Padding rows borrow a slot that
# may belong to an active request this batch simply did not schedule;
# advancing it would move that request's Philox offset by an amount that
# depends on concurrent load, breaking the guarantee _SeedManager exists
# for -- that a seeded request's stream depends only on its own draws.
live_slots_per_row = slots_per_row[:live_rows]
row_seeds = self._seed_manager.take_row_seeds(live_slots_per_row, device=params.seed.device)

只为 live 行取号。padding 行会 take_row_seeds 一次但只是丢弃——如果连 padding 行也推进 offset,而这些行借用的槽位可能属于某个「本拍没被调度」的活跃请求,那这个请求的 offset 就会因为「并发负载」而漂移。为 padding 行省下一次取号,其实是保住了整个 seed 机制的核心承诺。

seed 的符号重解释

最后是一个位运算细节(sampler_common.request_random_seed):

"""The C++ ``SamplingConfig.randomSeed`` is ``uint64``, so a user seed in
``[2**63, 2**64)`` (e.g. from ``random.getrandbits(64)``) does not fit the
int64 tensors the torch sampler stores it in. Reinterpret the bit pattern as
signed rather than rejecting or clamping: the RNG consumes the seed as an
opaque bit pattern, so this keeps distinct seeds distinct, and it avoids an
overflow that would abort the whole sampling step rather than one request."""
return seed - (1 << 64) if seed >= (1 << 63) else seed

用户给一个 64 位无符号 seed,int64 存不下。选择是把它重新解释为有符号数(减 2^64),因为 RNG 只把 seed 当不透明的位模式看待——位模式不变,随机流就不变,而且避免了「一个请求的 seed 溢出导致整个采样步中止」。拒绝或截断会让两个不同的 seed 撞在一起;位重解释不会。

罚项与约束:penalties、token ban 与 top-p decay

罚项的工作区:按槽位的词表计数

重复/存在/频率三种罚项都需要知道「每个 token 在这个请求的历史里出现过几次」。PenaltyHandler 维护的是一张 int32[max_num_sequences, vocab_size] 的按槽位计数表:

counts_cuda: int32[num_slots, vocab_size],原地累加
presence_prefix_cuda: bool[num_slots, vocab_size] # prompt_ignore_length 用的前缀存在掩码

按槽位而不是按请求,是「状态不随批重组而搬运」这条纪律的又一次体现。更新走的是 scatter-add:

# accumulate=True sums repeated (slot, token) pairs -> occurrence count.
counts_cuda.index_put_((counted_slots, counted_tokens), ones, accumulate=True)

注意 update_occurrence_workspace 里对「写入源」的选择:它接受的是已定稿(finalized)的 token 对,而不是本拍刚采出来的。原因是重叠调度——本拍的 token 还没写回请求对象,只有上一拍写回的才可信。

罚项与 beam search:重新挂父

束搜索下计数表要按 beam 展开成 int32[num_slots * max_beam_width, vocab_size]。这里的核心操作是re-parent(重新挂父):

def _update_beam_occurrence_counts_impl(counts_cuda, active_cuda, has_previous_token_cuda,
beam_slot_cuda, new_tokens, predecessor_beams,
seq_slots, request_num_beams, max_beam_width):
"""Re-parent every beam onto the beam it continues, then fold in its token.
Beam ``b`` of slot ``s`` takes over the counts of ``predecessor_beams[s, b]``
before this step's token is folded in."""

为什么需要?因为 beam search 每步会重排 beam——beam 3 这一拍的内容可能来自上一拍的 beam 1。与其让每个 beam 重新遍历自己的历史去重算计数(O(长度)),不如让它继承父 beam 的计数,再把本步新吐的一个 token 折进去(O(词表) 的一次 scatter-add)。注释把这条思路写得很清楚:

a beam does not re-walk its history, it inherits its parent beam’s counts and appends the single token it just emitted.

代码里有一处「宁可做无害的重复工作,也不付一次同步」:

"""``armed`` gates per *slot* (``.unsqueeze(1)``), so beams past the current width are
re-parented too, from a clamped and possibly stale parent -- safe because nothing
reads such a row until the width grows to cover it... The gate is device-side, so this
runs unconditionally rather than pay a D2H sync."""
parent = predecessor_beams[seq_slots].to(torch.int64).clamp(0, max_beam_width - 1)

beam_width_array 让请求的 beam 宽度可以逐步增长,那些「还没启用的列」也会被 re-parent,用的是被 clamp 过的、可能是陈旧的父编号。这是安全的,因为在那列真正被启用之前没人读它,而启用时的 re-parent 又会从上一拍合法的父那里重新拿一次。为了让门控留在设备端(不付 D2H 同步),这段「对未启用列的无害操作」被允许执行。

TokenBanHandler:把 token 打成 -inf

token_ban.py 处理三件事:bad words(禁止出现的词)、no-repeat-ngram(禁止重复的 n-gram)、min_length(在足够长之前封掉 EOS)。三者实现方式统一——生成一张「本拍要封哪些 token」的掩码,然后在 logits 上把对应位置打成 -inf:

bans = self._token_ban_handler.generate_ban_list(
sampling_requests, req_num_steps.tolist(), req_num_beams.tolist(), ngram_sizes,
stale_by_one=stale_by_one, pending_steps=pending_steps)
self._token_ban_handler.apply_ban_list(logits_cuda, bans, new_tokens_cuda=new_tokens_cuda)

封禁的生成被单独拉出来(generate_ban_list)而不是直接改 logits,是为了让主机侧的历史匹配与设备侧的写入分开:封禁列表里有字符串匹配的结果(bad words 的 token 序列要在 token 历史里做后缀匹配),这是主机侧工作;而把 -inf 写进 logits 是设备侧工作。

stale_by_one 与 pending_steps 两个参数是重叠调度的产物:

"""Overlap-scheduler stale flags (per request): True when the host token history lags
the device by one token. Only the overlap handler consumes them; computed here as it
needs sampler state. Only the suffix-matching bans (bad words, no-repeat ngram) care:
min_length bans EOS from a length count, never from token values, so a lagging history
cannot mismatch it."""

由于第 i 步的 sample_async 可能跑在第 i-1 步的 update_requests 之前,主机侧看到的 token 历史是滞后一拍的。做后缀匹配的封禁(bad words、no-repeat-ngram)必须知道这一点,否则会匹配到错误的位置。min_length 不受影响(它按长度计数,不看 token 值),所以「只有 min_length 的批」干脆跳过整段计算,也顺带避免了打印一条与它无关的陈旧历史警告。

OverlappedTokenBanHandler 与 SynchronousTokenBanHandler 在构造时二选一:

self._token_ban_handler = (OverlappedTokenBanHandler() if self._track_pending_steps
else SynchronousTokenBanHandler())

只在确实开了重叠调度时才承担这套复杂度。

Top-P Decay:会自己收紧的 top-p

top-p decay 是一个逐步收紧的运行时 top-p,递推式(照抄 TopPDecayStore 文档,与旧 C++ 的 computeToppDecay 内核一致):

runtime_top_p = initial_top_p if token == reset_id
= max(runtime_top_p * top_p_decay, top_p_min) otherwise

直觉:随着生成推进,把采样范围收得越来越紧(更适合需要「先发散后收敛」的场景),碰到某个特定 token(reset id)就重置回初始值。

它的生命周期被拆成四个钩子,每一步都写在同一个类的文档里,这对读代码的人很友好:

1. Admission (setup_for_new_requests): 清掉新入槽位的陈旧成员标记,再重新登记;
同时维护主机侧 _slots 集合(O(1) 早退)与设备侧 is_top_p_decay_slot_cuda 门控镜像。
2. Pre-sample (override_top_p): 用融合 gather 把 decay-active 行的 top_p 换成运行时值。
3. Post-sample (update_after_sample): 用融合 update 就地跑递推式。
4. Finish (retire_slot): 该槽位退出成员集合,早退重新武装。

两个设计点值得注意:

主机集合 + 设备镜像门控的双份记账:

"""Per-slot bool gate (device mirror of ``TopPDecayHandler._slots``). Lets the
fused post-sample update op filter decay-active slots on the GPU,
avoiding a host-side ``.tolist()`` / set intersection each step."""

主机侧的 set 是热路径的 O(1) 早退(没有 decay 请求时整段跳过,不碰设备);设备侧的门控镜像让融合算子能在 GPU 上过滤——避免的是「每步一次 .tolist()」。 is_top_p_decay_slot_cuda 必须初始化为全 False(其它缓冲可以是 empty 因为从不读未登记的槽位,但这个门控本身就是被读的东西)。

准入时拒绝不支持的组合,且是在 request 粒度上:

"""Reject unsupported combinations for a top-p-decay-active request.
Top-p decay is supported only for single-token decode steps without beam search.
Called from validate_request (request admission), so a violating request is failed
individually instead of aborting the whole batch."""

理由很实际:如果在 setup_sampler_step 或采样中途才抛异常,会中止整个执行器的这一拍;在准入时拒绝则只让那一个请求失败。torch.compile 那段还解释了为什么用 max-autotune-no-cudagraphs:

# cudagraphs is unsafe here (the update mutates persistent per-slot state in place
# and the gather's output is consumed outside the compiled region; cudagraph static
# output buffers get overwritten by subsequent replays).

就地修改持久状态 + 输出在编译区外被消费 = 不能上 cudagraph,因为 cudagraph 的静态输出缓冲会被后续重放覆盖。而且编译是惰性的——「第一个 decay-active 请求付这次编译成本(约一秒),不用的负载永不触发」。

采样与投机解码的衔接

一拍多 token:req_num_steps 与 draft 语义

投机解码让一个请求一拍产生多个 token,采样器需要正面对待这个事实。sample_async 的注释把两种调用来源讲清楚了:

# NB: The sampler is either called directly by PyExecutor, for the target model,
# or by a drafter's prepare_draft_tokens(), for the draft model. In the former
# case there are 1 + get_draft_token_length(request) tokens per request. In the
# latter case, there is always only 1 token per request because draft tokens
# are sampled one-by-one.

同一个采样器被两种调用者共用:PyExecutor 调它给目标模型采样(一请求多行),草稿引擎调它给草稿模型采样(一请求一行)。这解释了为什么 _is_draft_batch 里有一条断言:

"""Batches are homogeneous by construction: a drafter builds all-draft batches for its
sample_async/update_requests calls on this shared sampler, and PyExecutor's batches are
all-target. The pending-steps accounting relies on this to skip draft batches wholesale;
assert it so a mixed batch fails loudly instead of silently corrupting the counters."""

批必须同质(全草稿或全目标),因为 _pending_steps 记账靠这个前提来整体跳过草稿批。一个混合批会让计数器静默错乱,所以直接断言。

d2t:草稿词表到目标词表的翻译

Eagle 系列的草稿头在草稿词表(通常是被裁剪的小词表)上出 logits,而目标模型在完整词表上。采样出来的草稿 token 要经过翻译表 d2t 才能变成目标词表的 id:

def apply_d2t(tokens, model_outputs) -> None:
"""Applies draft-to-target token translation table. Modifies tokens in-place."""
if "d2t" in model_outputs:
d2t = model_outputs["d2t"][tokens]
tokens += d2t

d2t 是从 model_outputs 里来的(模型自己产出的)。这条路径有一条明确的限制(_sample_batched_by_strategy):

needs_d2t = "d2t" in model_outputs
if needs_d2t and any(group.mode is not SamplingMode.GREEDY for group in groups):
raise ValueError("d2t does not yet support non-greedy sampling")

d2t 目前只支持贪心——因为一次 d2t[token] += 平移会改变分布,而对平移后的词表重算概率会很麻烦,所以先只做贪心,明确拒绝而不是静默产生错误概率。

概率输出的两种口径

slogprobs_mode(LogprobMode.RAW / PROCESSED)决定要记哪一种对数概率,两者的差别在 _sample_batched_by_strategy 里体现得很清楚:

  • PROCESSED:在过滤之后的分布上取 logprob,需要把「重新归一化的偏移」补回去:
proc_lp_renorm_offset_cuda = (proc_lp_softmax_cuda.gather(dim=1, index=proc_lp_max_logit_indices.unsqueeze(-1))
.log().squeeze(-1) - proc_lp_max_logits_cuda)
  • RAW:不过滤,直接在原始 logits 上取:
batch_raw_logprob_indices_cuda = logits_cuda_indexer[torch.tensor(reqs_indices_needing_raw_logprobs, ...)]

两者都要排除 beam request(它们的 logprobs 走 beam history)。

草稿概率必须与草稿 token 同源

这是投机解码里最要紧的一条,fused_sample_from_logits_with_probs 的文档已经说过:

"""Sample a token AND return the filtered, renormalized distribution.
The rejection path needs both, and needs them to come from the *same* filtering, or
acceptance is computed against a distribution neither side sampled from."""

拒绝采样(rejection sampling)的接受概率是 min(1, p_target(x) / q_draft(x)) 的形式。这里的 q_draft(x) 必须是真正采出 x 的那个分布。如果草稿 token 是从一份过滤(比如带 top-p)来的分布采的,而草稿概率报的是另一份(比如不过滤)分布的值,那么接受率算出来的数与实际发生的事情不对应——统计上就错了,而且错得不明显(通常只是「加速比略低于预期」)。所以采样器把 token 和概率做成同一个内核调用的两个输出,从结构上杜绝漂移。

验收:process_draft_tokens 与回滚账

update_requests 里投机路径的核心是把「接受几个 token」写回请求:

num_accepted = self._two_model_spec_dec.process_draft_tokens(
req, new_tokens_list=new_tokens_list, finish_reasons=finish_reasons)
if (actual_draft_len := get_draft_token_length(req)) > 0:
req.py_num_accepted_draft_tokens = num_accepted
# Pair the acceptance count with the real proposal count of the same step:
# py_draft_tokens is padded to the static max for CUDA graphs, so use the
# pre-padding length the drafter recorded. py_rewind_len keeps the padded
# length (padding occupies KV cache).
effective_len = req.py_draft_tokens_effective_len
req.py_num_draft_tokens_verified = (min(effective_len, actual_draft_len)
if effective_len is not None else actual_draft_len)
req.py_rewind_len = actual_draft_len - num_accepted

三个计数各有明确用途:

  • py_num_accepted_draft_tokens:本拍接受了几个草稿 token;
  • py_num_draft_tokens_verified:本拍真正验证了几个——用未 padding 的 effective_len,因为 py_draft_tokens 为了 CUDA Graph 被 padding 到了静态最大长度;
  • py_rewind_len = actual_draft_len - num_accepted:要被回滚的 KV 长度,这里用的是 padding 后的长度,因为 padding 也占了 KV 缓存空间(对应 TensorRT-LLM 源码完全拆解(十):投机解码 里讲过的分页回滚)。

「验收的分母用未 padding 长度,回滚的分子用 padding 长度」——同一个 actual_draft_len 在两种用途下要配不同的兄弟变量,这是 CUDA Graph 的静态形状与投机解码的动态性碰撞时留下的痕迹。

结束判定也必须延后到全部 token 都写回之后(update_requests 里的 process_draft_tokens 会在中途 finish_by),代码里留了一句很实在的注释:

# req.state can become GENERATION_COMPLETE within this loop iteration
# (e.g. process_draft_tokens -> _handle_stop_criteria -> finish_by),
# so the comparison is valid at runtime; mypy narrowed it away via the
# `continue` at the top of the loop and cannot see the mutating calls.

「验证并接受」这一步是整条投机链路的收口处,它决定了「这一拍净赚了几个 token」,也决定了要回滚多少 KV:

投机解码的验证与接受:草稿 token 与目标模型并行验证,按接受规则决定实际推进多少 token,未通过的草稿需要连同 KV 一起回滚
投机解码的验证与接受:草稿 token 与目标模型并行验证,按接受规则决定实际推进多少 token,未通过的草稿需要连同 KV 一起回滚

图:投机解码的验证与接受。采样器在这一步产出三个计数:py_num_accepted_draft_tokens(接受数)、py_num_draft_tokens_verified(验证数,用未 padding 长度)与 py_rewind_len(回滚长度,用 padding 长度)。(来源:NVIDIA/TensorRT-LLM 官方技术博客 blog02 配套图 docs/source/blogs/media/tech_blog2_verify_and_accept.png)

被拒绝的 token 需要从主机侧也删掉

有一处细节:投机路径下,update_requests 会收集「本拍确定写回」的 token 对,交给罚项工作区:

if actual_draft_len > 0:
num_new_tokens = req.get_num_tokens(DEFAULT_BEAM_IDX) - num_tokens_before
if num_new_tokens > 0:
confirmed_tokens = req.get_tokens(DEFAULT_BEAM_IDX)[-num_new_tokens:]
finalized_token_updates.append((req.py_seq_slot, confirmed_tokens))
...
self._penalty_handler.update_token_counts(finalized_token_updates)

它用「采样前后的 token 数差」反推实际被接受并写回的那部分,只把这一段折进罚项计数。被拒绝的草稿 token 从没进入过 get_tokens() 的历史,自然也进不了计数表——罚项看到的必须是「已经定稿的历史」,而不是「一度被提议过的内容」。

输出闭环:update_requests 与停止判定

三条写回路径

update_requests(sampler.py:1348)把采样结果写回请求对象,并按批的性质分成三条路径,入口处先做一次分流:

# Reuse sample_async's qualification instead of rechecking every request after
# the asynchronous sample completes.
if state.host.single_step_greedy:
self._update_requests_single_beam_single_step(state)
return

三条路径按「批的复杂度」递进:

  1. _update_requests_single_beam_single_step:最常见的贪心 + 单 token 路径。它把主机 token 列表一次性交给 pybind 的批量接口,然后逐请求做三个检查——EOS、长度、单 token stop word:
add_new_tokens_to_requests(requests, new_tokens, DEFAULT_BEAM_IDX)
assert state.host.finish_reasons is None # 这条路径刻意不算设备 finish reason
for request, new_token in zip(requests, new_tokens):
# Order matches handle_stop_criteria: END_ID, then LENGTH, then STOP_WORDS,
# so a terminal EOS at the token limit is reported as END_ID.
if new_token == request.py_end_id:
request.finish_by(FinishReason.END_ID, DEFAULT_BEAM_IDX)
elif (request.max_beam_num_tokens - request.py_orig_prompt_len >= request.py_max_new_tokens
or request.max_beam_num_tokens >= self.max_seq_len):
request.finish_by(FinishReason.LENGTH, DEFAULT_BEAM_IDX)
elif meet_stop_token_criteria(request, new_token, DEFAULT_BEAM_IDX):
request.finish_by(FinishReason.STOP_WORDS, DEFAULT_BEAM_IDX)

判断顺序是有语义的:EOS 优先于长度。一个请求恰好在长度上限吐出 EOS,报的是 END_ID 而不是 LENGTH——这个顺序必须和常规路径的 handle_stop_criteria 一致,否则同一个请求走快慢两条路径会拿到不同的结束原因。断言 finish_reasons is None 则确认这条路径不会去读设备端的结束原因张量。

  1. 批量快路径(batched pybind):_batch_fastpath_eligible 时,把「贪婪 + 无 beam + 无 logprobs + 无草稿 token + 无 stop words」的批合并成一次 add_new_tokens_to_requests 调用,而不是逐请求往返 pybind:
# Fast-path (batched pybind): collapse per-request pybind chatter into one batched
# add_new_tokens_to_requests call. Single-pass eligibility check with early-break;
# falls through when any invariant breaks.
if self._batch_fastpath_eligible and logprobs_state_list is None:
for req in state.requests:
if get_draft_token_length(req) != 0 or req.py_stop_words_list:
fastpath_ok = False
break

「主机与 pybind 的往返次数」在这里第一次显式成为优化对象。eligibility 采用单趟扫描 + 提前退出:只要有一个请求不满足就整批退到常规路径。

  1. 常规路径:逐请求处理 beam、logprobs、投机验收等全部情形。

beam search 的收尾与「聚合请求」

beam 分支里有一段专门处理聚合请求(context-only / disaggregated prefill)的代码,注释写得很细:

# Context-only (disaggregated prefill) requests reach the sampler already flagged
# as finished -- the context server is done with them -- but they are migrating, not
# completing: the generation server decodes the rest. Finalizing here would rewrite
# the beam rows from the beam history, and after a single step only beam 0 has
# history; every other beam is all BEAM_SEARCH_PAD_TOKEN ... The handoff reads the
# per-beam first token back out via getTokens().back() (llmRequest.cpp), which would
# then hand the generation server the prompt tail instead of that beam's token.
if (not req.is_context_only_request and request_finishing
and (beam_history := _maybe_build_beam_history(req_idx)) is not None):
finalize_beam(req, beam_history)
else:
for beam_idx in range(_get_beam_width_out(req)):
add_token(req, new_tokens_list, beam_idx=beam_idx)

一个 P/D 分离的 context 服务端把请求标成「已完成」,但它其实是在迁移而不是结束。 如果这里顺手做 finalize,会用一个「只有 beam 0 有历史」的 beam history 去重写所有 beam 行,交接时读回的就是错误的 token。所以判断条件是「不是 context-only 请求」才 finalize。这一处把 P/D 分离那篇 的控制面约定一路串到了采样器内部。

beam_width_out 的循环也带着理由:只有前 beam_width_out 列是真实 token,其余列被内核垫了 BEAM_SEARCH_PAD_TOKEN 哨兵,把哨兵写进请求的 token 历史会让它对流式消费者与 get_tokens() 的即时读者可见(哪怕 finalize 之后会重写)。

结束判定:三种原因与两套实现

结束原因只有三个有效值,两套实现都要产生同样的判定:

_valid_finish_reasons = {FinishReason.END_ID, FinishReason.LENGTH, FinishReason.STOP_WORDS}
  • 主机侧(快路径):_update_requests_single_beam_single_step 里的三个 if/elif。
  • 设备侧(常规路径):FinishReasonsHandler.write_finish_reasons 在设备上写一张 finish_reasons 张量,再 _copy_to_host 读回。

设备侧的判定还有一处必须小心的地方——多 token stop word 的历史匹配需要「移位后的历史」:

if not single_step_greedy:
# write_finish_reasons shifts stop-word history by the accepted-draft count,
# which update_requests only refreshes after this step. Nothing was speculated
# here -- the tier requires zero draft tokens -- so a count left over from an
# earlier speculative step would shift the comparison and could miss the stop word.
accepted = self._finish_reasons_handler.store.num_accepted_draft_tokens_host
for request in live_requests:
if request.py_stop_words_list:
accepted[request.py_seq_slot] = 0

接受的草稿 token 数会改变 stop word 匹配时的历史偏移量。FAST 档要求零草稿 token,但那个计数可能是上一拍投机留下的——不清零就会用错误的偏移去比对,漏掉 stop word。所以进入 finish-reason kernel 之前手动把相关槽位的计数归零。

_finished_beam_prefix_lengths 是 beam 结束判定的一个高效实现:

"""A request is complete once all of the beams it actually uses have a finish
reason, i.e. once its leading ``py_beam_width`` entries are all set. Rather than
reducing each request's row separately, reduce the whole tensor once and return,
per slot, how many leading beams have finished."""
unfinished = finish_reasons == FinishReason.NOT_FINISHED.value
return (unfinished.cumsum(dim=1) == 0).sum(dim=1).tolist()

「未结束累计和为 0 的位置数」一次给出每个槽位的已结束前缀长度;之后每个请求只要比 prefix_length >= beam_width 即可。用一次整张量的归约替代「逐请求逐 beam 归约」,且对混合 beam 宽度天然正确(宽度之外的列是什么值都不影响)。

罚项计数的收尾

update_requests 的最后一步是把本拍定稿的 token 折进罚项工作区:

self._penalty_handler.update_token_counts(finalized_token_updates)

它必须在「所有请求的 token 都已写回」之后跑,否则计数会缺一段——这也是为什么它出现在整个 for 循环之外,而不是循环里逐请求更新。

推进解码计数

每条路径最后都会做同一件事:

req.py_decoding_iter += 1

它记录了请求走过了多少拍解码,_compute_pending_steps 与 _compute_stale_by_one 都基于它做重叠调度的滞后判断。

与其他框架的采样实现对读

vLLM:Sampler 的 logits 改写链

vLLM V1 的采样器(见 vLLM 源码完全拆解(九):Sampler)走的是一条有序的 logits 处理链:penalties → 温度缩放 → top-k/top-p → 采样。它与 TensorRT-LLM 的差异集中在三点:

  1. 改写与应用分离得更彻底。vLLM 用一组 LogitsProcessor(MinTokensLogitsProcessor、LogitBiasLogitsProcessor、MinPLogitsProcessor 等)在 Python 里串成链,每个处理器改一遍 logits;TensorRT-LLM 则把能融合的都塞进一个 torch.compile(fullgraph=True) 的图,靠 mark_dynamic 处理批变化。
  2. 「禁用」的编码。vLLM 常靠 None / 分支判断在主机侧决定要不要跑某个处理器;TensorRT-LLM 用哨兵值让设备端自己判断(代价是内核多几个分支,收益是不产生模式组合爆炸)。
  3. 停止判定。vLLM 的 stop string 检测在 stop_checker 里对解码后的文本做匹配(见 vLLM 源码完全拆解(十五):停止条件与增量解码);TensorRT-LLM 的 stop word 匹配做在 token id 序列上(meet_stop_token_criteria 的滑窗后缀匹配),更早、更省(不需要先 detokenize)。

一句话概括差异:vLLM 把采样写成一条可读性优先的处理链,TensorRT-LLM 把采样压成一个发射次数优先的融合图。

SGLang:Grammar 与采样的一体化

SGLang(见 SGLang 源码完全拆解(十):Sampling、Grammar 与结构化输出)把 grammar backend 与 sampling backend 分开配置、运行时组合。TensorRT-LLM 的做法不同:结构化输出在采样器之外(guided_decoder.execute() 在前向返回后改 logits,因此这类请求被 _request_bypasses_in_graph_sampling 挡在 FAST 档外)。两者都在回答同一个问题——「语法掩码与采样的先后」,只是 TensorRT-LLM 把顺序约束编码成了分档资格,而 SGLang 编码成后端组合。

llama.cpp:采样器链与后端采样

llama.cpp(见 llama.cpp 源码完全拆解(五):采样管线与语法约束)用一条显式的「采样器链」——每个采样器(top-k、top-p、温度、Mirostat、DRY……)独立挂到链上按序执行,且能选择在 CPU 或后端上跑。它的设计哲学与 TensorRT-LLM 恰好相反:llama.cpp 优先可组合与可扩展(每个采样器是一个独立对象,用户可自选),TensorRT-LLM 优先发射次数与图内可捕获(融合内核 + 分档)。两者的取舍点都是「变化频率」——llama.cpp 面对的是「用户想插任意采样器」的端侧场景,TensorRT-LLM 面对的是「同一批里几十个请求参数各不相同、但每拍都要跑」的服务端场景。

三条实现路线的共同点

三个框架在采样这一步的共识是一样的,而这些共识正是本篇讲的所有「为什么」的根源:

  1. 参数必须按行(per-row)走,因为一个批里的请求参数不同,而引擎不能为每种组合建模式;
  2. 任何主机—设备同步都是禁忌,所以有设备端断言(torch._assert_async)、设备端门控(is_top_p_decay_slot_cuda)、以及「宁可多算也不 repeat_interleave 触发 D2H」这类选择;
  3. 随机数必须按请求而非按批,否则复现性会被批组成污染。

小结

把本篇的事实收拢成一张清单:

  • 采样是最后一公里:一张 [num_rows, vocab_size] 的 logits 要经过「改写 → 裁剪 → 抽样」三步变成 token,而这三步全在延迟关键路径上,一次主机同步就能毁掉重叠调度的收益。
  • 三个入口对应三个时刻:setup_sampler_step 在新请求入槽时准备参数/种子/罚项/结束原因;sample_async 在前向之后采样;update_requests 把结果写回并判定结束。重叠调度下三者错拍执行,_pending_steps 负责记账。
  • 参数分四类:「贪心还是采样」由 params_imply_greedy_decoding 单点判定——显式贪心(top_k=1 / top_p=0 / min_p=1 / temperature=0)加隐式默认(三参数全 None),而 min_p 与激活的 top_p_decay 会推翻隐式默认。
  • 分档是发射开销与功能的权衡:FAST 把采样录进前向 CUDA 图(零额外发射,但只能表达 per-row 的 temperature/top-k/top-p/min-p),FULL 走 eager(全功能)。刻意没有 argmax 档——实测贪心录制与暂存成本抵消。资格判定分两层:内核能不能表达,以及采样时点是否在所有 logits 改写之后(guided decoding 与自定义 logits processor 必须挡住)。
  • FAST 档的三条纪律:按 padded 批宽度暂存(图会为重放每个 padding 行散布 token)、缓冲区按最宽批分配一次(图固化地址)、读回时按 id 列表 + py_is_draft 双重校验并对暂存状态一次性消费。
  • 融合内核的核心决定是「禁用在设备端判定」:每个过滤器以 per-row 张量传入、用哨兵值表达禁用,把模式空间从指数级塌缩成常数(加 min_p 不再翻倍模式)。三个入口按「要 token / 要概率 / 两个都要」拆分,且因为它是单 C++ 算子 + fake kernel,能被 torch.compile 干净 trace——这是它能进图的底层条件。
  • 闪存链路相反:主机侧把过滤器解析掉,且必须处理「top_k=0 会 mask 整行」的边界;其内核源码分发、首次调用 nvcc 编译约 90 秒,所以启动期必须预热,否则会触发 hang 检测器。
  • 罚项的算术有单一真相(occurrence_penalized_logits):repetition 按 logit 符号分支(除负 logit 会抬高)、存在/重复用两个不同掩码、只 clamp 算术可能越界的值而不夹输入 -inf(-inf 是封禁语义,不是数值)。
  • 随机数用 Philox (seed, offset) 二元组:有 seed 的请求按槽位计数、换主归零,其流只依赖自己抽了多少 token;无 seed 的请求共享 global_seed=42 但 offset 起点在 2^62 以上,用取值域隔离保证与「seed 恰好等于 42」的用户请求不撞流;草稿批整批跳过槽位记账(草稿与目标共用槽位号空间);图内采样只为 live 行取号(为 padding 行推进 offset 会让并发负载影响别人的随机流)。
  • top-p decay 用「主机集合 + 设备门控镜像」双份记账,前者是 O(1) 早退,后者避免每步 .tolist();不支持的组合在准入时按请求粒度拒绝,而不是中途抛异常中止整拍。
  • 投机的三个计数各有用处:接受数、验证数(用未 padding 长度,分母不能含 padding)、回滚长度(用 padding 长度,padding 占 KV);草稿 token 与草稿概率必须来自同一次过滤,否则拒绝采样的接受率算的是另一个分布;罚项计数只折进已定稿的历史,被拒绝的草稿 token 从不进入。
  • 结束判定只有三种原因(END_ID / LENGTH / STOP_WORDS),两套实现(主机三个 if / 设备 finish-reason kernel)必须给出同序判定;FAST 档只采 token 不判停止,所以带 min_length 或多 token stop word 的请求仍要走完整路径;beam 的结束用一次「未结束累计和为零」的整张量归约判出已结束前缀长度。
  • 对照三个框架:vLLM 用可读的 logits 处理链、SGLang 用 grammar × sampling 的后端组合、llama.cpp 用可插拔的采样器链,而 TensorRT-LLM 把一切能融的都融进一个图内可捕获的内核并按档位执行。差异的根源是变化频率:端侧要「用户能插任意采样器」,服务端要「每拍几十个不同参数的请求都跑得动」。

一句话概括:采样是推理引擎里最不像「一大块计算」的一块——它的算术量小到可以忽略,难点全在「怎么让几十个参数各异的请求共享最少的发射次数、同时一次主机同步都不发生、还要让有 seed 的请求的随机流与批的组成无关」。TensorRT-LLM 的答案是把参数压成 per-row 张量、把禁用在设备端判定、把采样录进前向图、把随机性交给 Philox 计数式 RNG——每一处取舍都指向同一条纪律:热路径上不要主机同步,也不要按组合分支。

参考资料

  1. TensorRT-LLM 仓库:tensorrt_llm/_torch/pyexecutor/sampler/
  2. TensorRT-LLM 仓库:sampler/sampler.py(采样编排与 TorchSampler)
  3. TensorRT-LLM 仓库:sampler/ops/custom.py(融合采样内核接口)
  4. TensorRT-LLM 仓库:sampler/ops/vanilla.py(PyTorch 原生采样与罚项算术)
  5. TensorRT-LLM 仓库:sampler/ops/flashinfer.py(闪存推理采样原语)
  6. TensorRT-LLM 仓库:sampler/seed_manager.py(Philox 种子管理)
  7. TensorRT-LLM 仓库:sampler/sampling_resolution.py(参数解析与槽位参数)
  8. TensorRT-LLM 仓库:sampler/penalties.py
  9. TensorRT-LLM 仓库:sampler/token_ban.py(bad words / no-repeat-ngram / min_length)
  10. TensorRT-LLM 仓库:sampler/top_p_decay.py
  11. TensorRT-LLM 仓库:sampler/sampler_features.py(步索引、停止判据与异步 D2H)
  12. TensorRT-LLM 仓库:tensorrt_llm/sampling_params.py(SamplingParams 定义与贪心判定)
  13. TensorRT-LLM 仓库:tensorrt_llm/_torch/modules/logits_processor.py(logits 裁剪到每请求最后一行)
  14. TensorRT-LLM 仓库:tensorrt_llm/_torch/pyexecutor/handle_logits.py
  15. TensorRT-LLM 仓库:tensorrt_llm/_torch/pyexecutor/py_executor.py(采样入口的调用时刻)
  16. FlashInfer 仓库(采样与注意力原语)
  17. FlashInfer:为 LLM 服务定制的注意力与采样内核(论文)
  18. vLLM 源码完全拆解(九):Sampler——从 Logits 到 Token 的采样管线与输出回传
  19. SGLang 源码完全拆解(十):Sampling、Grammar 与结构化输出
  20. llama.cpp 源码完全拆解(五):采样管线与语法约束
  21. TensorRT-LLM 源码完全拆解(十):投机解码——one-model 统一 worker、KV 回滚与验收流水线
  22. EAGLE 完全拆解:特征级草稿模型如何把投机解码推到 3 倍以上
  23. PyTorch Philox 随机数生成器文档

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

TensorRT-LLM 源码完全拆解(十七):Sampler——从 logits 到 token 的采样管线、分档执行与输出闭环
https://pinghaoyang.com.cn/articles/insights/posts/tensorrt-llm-sampler-pipeline/
作者
平昊阳
发布于
2026-10-21
许可协议
CC BY-NC-SA 4.0

评论区

访客信息
--°天气加载中

正在获取所在地区…

💨 风力--
💧 湿度--

正在读取天气

Profile Image of the Author
平昊阳
乘长风,破巨浪, 展鸿图于未央!
公告
欢迎来到我的个人博客!欢迎关注交流吖!
更多相关公告,见
「社交-留言」。
音乐
封面

音乐

暂未播放

0:000:00
暂无歌词
站点统计
文章
448
分类
29
标签
451
总字数
6,178,652
运行时长
0 天
最后活动
0 天前

文章目录