正在获取所在地区…
正在读取天气
音乐
暂未播放
DeepInfer 源码拆解(三十八):采样内核的微观账——基数筛选、两级定点采样与残差并行的 TileLang 实现
从采样管线走进采样内核
DeepInfer 源码完全拆解(八):采样、惩罚与确定性校验 讲清楚了采样在引擎里处于什么位置:Sampler.forward() 如何区分预填充与解码两条主路径,driver 与 follower 如何分工,七个采样步骤如何被压进一次 fused_sample 内核调用,以及随机性为何改成了 (序列种子, 采样位置, 用途) 的纯函数映射而不是全局随机流。那一篇回答的是”为什么融合、融合了哪七步、接口长什么样”。
这一篇往下一层,站到内核内部看。一个 vocab_size ≈ 12.9 万 的词表、一个 batch 里最多上千行,每行都要依次完成温度缩放、top-p/top-k 截断、log-softmax、随机采样、与草稿分布的逐元素比较、残差累积、拒绝重采样。如果把这些步骤老老实实写成七八个独立的 kernel,每一步都要把 B×V 的中间结果写回 HBM 再读出来;即使写成一个大 kernel,也很容易退化成”一个 CTA 负责一行、串行扫过整个词表十几遍”的形态,并行度不足,延迟被词表长度线性拉长。
DeepInfer 的做法是让内核与执行策略一起变:
- 排序被换成基数筛选。top-p 需要”按概率降序的第 k 个前缀和”,top-k 需要”按 logit 降序的第 k 个”。标准做法是对整行做降序排序,代价是 O(VlogV) 的比较与大量寄存器/共享内存。内核用 IEEE-754 浮点位的单调映射把两侧需求统一成”整数键上的前缀和”,再用 11 位一轮的基数直方图去二分出阈值——不需要排序,几轮遍历就能确定候选集边界。
- 采样被换成两级定点 CDF。词表太大了,一次计算一遍完整 CDF 再二分,意味着每个线程都要处理 V 项。内核把一行折叠成 2048 个”车道和”,先在第一级用一个随机数选中车道,再在第二级只在选中车道对应的一列候选里精确定位 token。概率被量化成 31 位定点数,累加与比较全是整数运算。
- 残差分布被算成”单趟遍历 + 一次归约”。投机验证被拒绝后要从 max(p−q,0) 归一化后重采样。内核不需要先写出残差向量再扫一遍,它把”排除候选集后重归一化的目标分布”和”带惩罚项的草稿分布”在同一趟读里相减,边减边把概率质量累积进按车道分组的定点累加器,随后直接在这个累加器上做第二级采样。
- 不同阶段之间用 PDL 衔接。log-sum-exp 的预扫描、阈值筛选、采样三个阶段被拆成两次 kernel launch(
fused_sample_tl里两个T.Kernel块),中间靠programmatic dependent launch的 sync/trigger 让后一个 CTA 在前一个还没彻底收尾时就开始读输入,从而把三段的启动延迟叠起来。
这一篇按内核的执行顺序拆:
- 参数与分块怎么由
vocab_size与两个布尔量在编译期特化,以及为什么vocab_size必须进编译期; - 第一段
T.Kernel:在线 log-sum-exp 与对齐交叉熵的归约顺序; lse_workspace为什么只有两个 float,以及 lse 为什么存成 (−log∑,−max) 而不是别的形式;- 可排序整数键与 11 位基数直方图:top-p 与 top-k 如何在同一个树里同时收敛;
- 并列阈值的处理:为什么需要 tie 分组扫描,
cut为什么能被后续阶段直接比较; - 两级定点采样:车道和、拒取法、无分支二分、槽位精定位;
- 投机验证与残差的单趟并行收敛,以及零残差回退;
accept_probs的残差质量从哪来、为什么要在接受后仍计算它;- 残差惩罚常数
DRAFT_PROB_PENALTY:源码注释里可证明的双侧偏差界; - 与周边内核的对照:
random_sampling_tl(非融合版)、sum_smaller_probs_tl、outlier_detection三块小内核在同一个算子家族里各守什么不变量。
编译期特化:哪些进模板,哪些留在运行时
内核的签名只有三个参数:
1@tilelang.jit(...)2def fused_sample_tl(3 vocab_size: int,4 do_sample: bool,5 do_reject_sampling: bool,6) -> Any:7 assert vocab_size >= 32, "fused_sample requires vocab_size >= 32"8 assert not do_reject_sampling or do_sample, "reject sampling requires sampling"其余维度全是动态的:total_q(本轮参与采样的行数)、pool_rows(投机信息池行数)、以及 logits、logprobs、top_p、top_k 各自的 stride。这三个编译期参数之所以不放进张量形状里,原因是它们决定控制流和分块布局:
vocab_size决定LSE_BD、SAMPLE_BD、FILTER_BD三个分块宽度,决定基数直方图的桶数,决定N_LSE_CHUNKS、N_SAMPLE_CHUNKS这些循环上界能否被T.unroll展开;do_sample决定是否生成随机采样的分支,以及是否有sample_positions/seq_seeds的读取;do_reject_sampling决定是否生成第二条”草稿残差”的采样分支。
而 total_q 如果也进模板,就意味着每来一个新 batch 形状都要重新 JIT 编译一次。DeepInfer 的批是动态的,total_q 在运行时波动很大,所以它只能留在动态维度里;相应地,with T.Kernel(total_q, threads=THREADS) 里每个 CTA 处理一行,行的数量不参与编译。
三个分块宽度的选择有一套经验规则:
1if vocab_size >= 8192:2 THREADS, LSE_BD, SAMPLE_BD = 256, 8192, 20483else:4 LSE_BD = tilelang.next_power_of_2(vocab_size)5 THREADS = min(128, LSE_BD)6 SAMPLE_BD = LSE_BD7FILTER_BD = min(LSE_BD, 4096)8TIE_BD = min(FILTER_BD, 256)为什么 log-sum-exp 用 8192 的大块、而采样只用 2048 的小块?T.Kernel 虽然每行一个 CTA,但 T.annotate_min_blocks_per_sm(3) 和 (4) 要求每个 SM 上至少驻留 3~4 个 CTA。CTA 的常驻数量由共享内存和寄存器决定,而第二段 kernel 里需要同时驻留 hist(基数直方图,2048 桶 × uint32 = 8 KB)、topk_hist(计数直方图)、L/R 分块、mask、lane_cdf 等一大批共享内存。SAMPLE_BD 取 2048 而不是 8192,是在”每个线程一次读更多”和”更多 CTA 同时驻留”之间取了后者。
FILTER_BD = min(LSE_BD, 4096) 则是为筛选阶段单独选的小块,代码注释直接写了原因:
1# Keep LSE's reduction tree; smaller filter/sample tiles reduce register use.log-sum-exp 的分块大小决定了它的归约树形状,为了让”本内核算出的 logprob”与”参考实现的交叉熵”逐 bit 对齐,归约树不能随意改;但筛选阶段的并行度更看重寄存器压力,所以它用更小的块。
NUM_STAGES = 2 if vocab_size > LSE_BD else 0 决定是否走软件流水:只有词表比一个分块还大时才需要将”读下一块”和”算上一块”重叠起来;小词表一次就能读完,流水带来的额外共享内存没有回报。
第一段:在线 log-sum-exp 与对齐的归约顺序
第一段 kernel 只做一件事:为每一行算出 (−log∑je(zj−m)t,−m),其中 m=maxjzj、t=1/T,存进 lse_workspace[row, 0..1]。
1with T.Kernel(total_q, threads=THREADS) as row:2 T.annotate_min_blocks_per_sm(3)3 T.pdl_sync()4 inv_temperature = T.alloc_var(T.float, 1.0 / temperatures[row])5 max_v = T.alloc_var(T.float, -1e30)6 sum_v = T.alloc_var(T.float, 0.0)7 for i0 in T.Pipelined(N_LSE_CHUNKS, num_stages=NUM_STAGES):8 ...9 blk_max = T.alloc_reducer((1,), T.float, op="max", replication="all")10 T.fill(blk_max, max_v)11 for j in T.Parallel(LSE_BD):12 blk_max[0] = T.max(blk_max[0], lf0[j])13 T.finalize_reducer(blk_max)14 new_max = T.alloc_var(T.float, blk_max[0])15 blk_sum = T.alloc_reducer((1,), T.float, replication="all")16 T.fill(blk_sum, 0.0)17 for j in T.Parallel(LSE_BD):18 blk_sum[0] += T.exp((lf0[j] - new_max) * inv_temperature)19 if i0 == N_LSE_CHUNKS - 1:20 T.pdl_trigger()21 T.finalize_reducer(blk_sum)22 sum_v = T.exp((max_v - new_max) * inv_temperature) * sum_v + blk_sum[0]23 max_v = new_max24 lse0 = T.alloc_var(T.float, -T.log(sum_v))25 lse1 = T.alloc_var(T.float, -max_v)注意温度缩放被放进了指数里:exp((zj−m)⋅T1),而不是先对 logits 整体做除法再求 softmax。这两者在数学上等价:
softmax(Tz)j=∑iezi/Tezj/T=exp(Tzj−logi∑ezi/T)但实现上,前者把缩放折进在线归约的指数里,只需要在指数内做一次乘法,不会产生一个 B×V 的临时”已缩放 logits”张量。后面所有阶段也都遵循同一约定:任何地方的 logprob 都写成 (z−max)⋅T1−log∑。
lse0 = -\log \sum_j e^{(z_j-m)t} 与 lse1 = -m 这个符号设计值得展开。设目标分布的 log-softmax 为
其中 t=1/T。两段 kernel 之间只传递了两个 float。若存 (log∑,max),第二段每一行仍要在指数或乘法里把最大值加回去。内核直接存成负数,那么第二段只要算
ℓj=(zj+lse1)⋅t+lse0一次加法、一次乘法、一次加法。从代码里能直接看到这个形状——阈值筛选阶段用:
1T.exp((logits_frag[j] + lse1) * inv_temperature + lse0)采样阶段用:
1lf[j] = (lf[j] + lse1) * inv_temperature + lse0这是同一个公式在两处复用。对每一行来说这个公式要被执行 O(V) 次(一遍筛选、一遍采样),两段 kernel 之间的”符号约定”省下的乘法是实打实的。
归约顺序为什么要被锁死
在线 log-sum-exp 的合并公式是:
m←max(m,m′),s←se(m−m′)t+s′这个递归对分块的合并顺序敏感:浮点加法不满足结合律,不同的合并顺序得到不同的最后几位。源码在 NUM_STAGES 分支里显式给 lf0 指定了 fragment 布局:
1if NUM_STAGES:2 # Preserve the original reduction order for bitwise3 # alignment with the reference cross-entropy implementation.4 T.annotate_layout(5 {lf0: T.Fragment((LSE_BD,), forward_fn=lambda i: ((i % (THREADS * 8)) // 8, i // (THREADS * 8) * 8 + i % 8))}6 )这个 forward_fn 把逻辑下标 i 映射成 (thread_id, register_index):每一轮 8 个连续元素落在同一个线程的相邻寄存器里,下 8 个落到下一个线程。也就是说,每个线程持有的是一组长度为 8、跨度为 THREADS * 8 的连续片段。这样每个线程内部按固定步长顺序累加,跨线程再经过 T.finalize_reducer 汇总,整个规约树就与 PyTorch 的参考实现一致。
对采样来说,logprob 的绝对误差通常不会改变采出的 token;但只要引擎把 logprob 回传给用户(logprobs 输出字段),那么”同一条请求在任意 batch 下给出逐 bit 相同的 logprob”就是一项对外承诺。归约顺序被锁死是为了兑现这条承诺,而不是为了数学上的精确性。
最后一块的 pdl_trigger
1if i0 == N_LSE_CHUNKS - 1:2 T.pdl_trigger()T.pdl_sync() 与 T.pdl_trigger() 是一对:前者让本 CTA 等待所有依赖的前序 launch 到达,后者提前向硬件宣告”我即将写完输出、后续 launch 可以开始依赖同步”。第二段 kernel 开头先 T.pdl_sync() 再读 lse_workspace,而第一段选在”最后一次块归约 finalize 之前”就触发,含义是”最后一块计算尚未收敛,但你要的输入数据已经在途了”。硬件会把第二段的一部分启动开销与第一段最后一块的归约重叠。这是 Hopper/Blackwell 上 PDL 的典型用法:提前 1~2 微秒,在几百行的 batch 上就是可见的收益。
阈值筛选:不排序的 top-p 与 top-k
先把要筛的东西讲清楚。Holtzman 等人 2020 年在 ICLR 发表的 The Curious Case of Neural Text Degeneration 指出:贪心解码与 beam search 在开放域生成里容易退化成重复、无聊的文本,原因是模型的概率分布尾部很长——大量低概率 token 合起来占据可观的概率质量。top-p(nucleus sampling)的做法是:把分布按概率降序排列,只保留累计概率首次达到或超过 p 的那个前缀(跨过阈值的 token 本身也保留),再在这个前缀上重归一化后采样。
这一定义背后有一个关于分布形态的经验事实。下图展示同一个上下文下不同位置的下一 token 分布形状差别巨大——有的位置极为尖锐,概率集中在少数 token 上;有的位置相当扁平,大量中等概率 token 分食质量:

(图源:Holtzman et al., The Curious Case of Neural Text Degeneration, ICLR 2020, Figure 5。)
这幅形态图正好解释了内核为什么必须同时支持 top-k 与 top-p:固定 k 在尖锐分布上会放进大量低概率 token,在扁平分布上又会裁掉有意义的中等概率候选;top-p 固定的是质量而非数量,才随着分布形状伸缩。下图进一步把不同解码方法生成文本的 n-gram 频次分布与人类文本对比,纯采样与 nucleus 采样都贴近人类分布,而 beam search 偏离得很远:

(图源:Holtzman et al., ICLR 2020, Figure 7。)
这两张图对内核的含义有两件事:第一,top-p 的本质是”在排序后的前缀上做累积”,而排序本身是最贵的步骤,所以内核用整数键加基数直方图避开排序;第二,排序之后的分布是重归一化的,采样必须在候选集内部按条件概率进行,这也是后文”只保留 sel_f 个并列项”的依据。
设目标分布的 log-softmax 为 ℓi,单调映射到整数键 κi=key(ℓi) 后,按参考实现的语义,top-p 要保留的是”前 k 个累计质量首次严格超过 ptop“的前缀——第 k 个 token 本身被保留。用阈值表述,就是取最大的 θ,使
i: κi≥θ∑eℓi≥ptop并且 ∑κi>θeℓi<ptop。top-k 同理:取最大的 θk,使键 ≥θk 的 token 数量不少于 k。两个条件都只依赖”键降序下的前缀”,所以内核只需要找到一对 (θ,cut)。第二段 kernel 的目标就是找到一个整数键阈值与一个并列截点,使得后面所有阶段都能用一次整数比较完成候选判定的”再判定”:
1if lf[j] < threshold or (lf[j] == threshold and col > cut):2 lf[j] = -inf只要拿到 (threshold, cut),任何一次遍历都能判断某个 token 是否被保留,不需要再回头算累积概率。这就是为什么这一阶段要付出几轮遍历的代价——它换来的是一次性确定、可被后续阶段无限次复用的边界表示。
可排序整数键
top-p 要按概率降序排,top-k 要按logit降序排。softmax 是单调函数,pi>pj⟺zi>zj(温度为正时),所以两者的降序关系完全一致。内核把浮点 logit 映射成一个 u32 整数键,使得:
zi>zj⟺key(zi)>key(zj)1raw = T.reinterpret(T.uint32, x)2return T.Select(raw > SIGN_BIT, ~raw, raw | SIGN_BIT)这是 IEEE-754 浮点数被当作有符号整数比较时的一个经典修正:
- 正数(符号位为 0)的位模式作为无符号整数本身就是单调的,只需翻转符号位(
raw | SIGN_BIT),让它们的键落在 u32 的高半区; - 负数(符号位为 1)的位模式大小与数值大小相反——数值越大,位模式越小,所以需要取反(
~raw);取反后符号位自然变成 0,键落进低半区。
注释写着”Map +0 and -0 to the same key for stable ties”。这个效果来自分支条件的边界:T.Select 判断的是 raw > SIGN_BIT。+0 的 raw 是 0x00000000,不满足大于,走 raw | SIGN_BIT,得到 0x80000000;−0 的 raw 恰好等于 0x80000000,同样不满足严格大于,也走 raw | SIGN_BIT,还是 0x80000000。两者因此落在同一个键上。如果条件写成 >=,−0 就会走取反分支,得到 0x7FFFFFFF,两个零的键被拆开,并列判定会随符号位而抖动。
−0 在 logits 里不常见,但在被 mask 的 -inf 两侧容易出现各种等价的零;把它归一化成一个键后,并列判定就不再因为符号位闪烁而随机。
11 位基数直方图
筛选的核心是一个”前 k 个”的判定问题,可以统一写成:
- top-p:求最小的前缀 k,使前 k−1 项的累计质量 ≤ptop、而前 k 项的累计质量 >ptop(即前 k 个是首次严格跨过阈值的前缀);
- top-k:求 k=top_k 处第 k 大 logit 的键。
两者都对排序后的顺序有要求,但真正需要的其实只是”键的分布”——第 k 大的键是多少、前 k 个键的(分数形式)概率和是多少。于是内核按位从高到低做多次直方图统计:
1RADIX_BITS = 8 if LSE_BD <= 1024 else 112RADIX_BUCKETS = 1 << RADIX_BITS3ROUNDS = T.ceildiv(32, RADIX_BITS)一个 u32 键被切成 ROUNDS 段,每段 RADIX_BITS 位。每一轮:
- 清空
hist(top-p 的双定点累加)与topk_hist(计数); - 扫一遍整行 logits,只统计”前缀与已确定前缀一致”的键:
prefix_match = ((bits ^ desired) & dm) == 0; - 对命中前缀的项,分别往
hist和topk_hist的对应桶里累加; - 对两个直方图做反向累积和(
block_cumsum(..., reverse=True)),得到”键大于等于本桶的项的总质量”; - 在每个桶上检查”从最高桶往下的累计概率首次达到 ptop“或”从最高桶往下的计数首次达到 k“——找到的那一段位就写进
desired,并把剩余需要的质量/数量记进ps_s/ks_s,供下一轮使用; dm追加掩码,下一轮只在确定的这一位段内继续细分。
RADIX_BITS 的取值有一个精确的权衡:
1# Small LSE tiles use 256 buckets to reduce histogram clearing/scanning;2# larger tiles use 2048 buckets to save one radix pass.- 桶越多,每一轮需要扫描的桶越多,清空与累积和的开销随桶数线性增长;
- 桶越多,每轮能确定的位数越多,总轮数
ROUNDS = ceil(32 / RADIX_BITS)越少。
键是 u32,因此 8 位一轮要 4 轮,11 位一轮只要 3 轮(最后一轮只用到剩余的 10 位)。而 LSE_BD 在 vocab_size 很大时是 8192,直方图扫描的成本相对整行扫两次 logits 的成本并不显著,所以选 11 位。小词表(LSE_BD <= 1024)时整行本来就很小,扫描桶反而成了主要成本,于是退回 8 位。
为什么用反向累积和?因为”保留前 k 个”是保留键最大的那些。反向累积和 hist[bucket] 表示”键 ≥ 本桶下界的项总和”,它直接对应”从高到低保留了多少”。第 5 步的比较条件 hist[bucket] >= ps_cur 是”累计保留质量已经达到目标”,prev < ps_cur(prev = hist[bucket+1])是”上一格还没达到”。两个条件一起保证了唯一确定一个桶:这轮选择的桶是”跨过目标的那一格”,而 ps_cur - prev 就是”下一轮还要在更低位继续找的剩余质量”。
T.atomic_add 被用于往直方图桶里累加:一个 CTA 内多个线程可能命中同一个桶。这里没有使用 T.reduce_sum,因为直方图是散射写入,桶与桶之间的累加无法用规约表达。
双量同树:为什么 top-p 和 top-k 能在一棵树上收敛
当 top_p_active and top_k_active 同时成立时,find_threshold(True, True, ...) 在一轮直方图里同时维护两个量:
hist:按分数形式累积概率,定点编码为uint32,scale 为 231;topk_hist:计数,uint32。
两者共享同一个”前缀”路径(desired、dm),因为在键空间里两个条件说的是同一件事:保留前 k 个最大的键。top-p 的 k 由概率和质量决定,top-k 的 k 由计数和参数决定,但两者都在”键降序”这个共同序上定义。
所以”先 top-p 后 top-k”的真实语义是:
候选集={i:key(zi)≥keyp}∩{i:key(zi)≥keyk}={i:key(zi)≥max(keyp,keyk)}由于键的排序与 logit 排序一致,最终阈值就是两个阈值里更高的那个。树结构天然解决了”谁的截断更紧”:desired 在逐位细化时,只要有一个条件没达到,就会继续细分到更低位的位段。逐位收敛的最大值就是最终阈值。
并列截点又受两个条件共同约束——既不能保留超过 top-p 允许的并列项数量,也不能保留超过 top-k 允许的并列项数量。sel 的公式正是这两个约束的交:ks_s 是 top-k 剩下还能保留的个数,ps_s / ps_th 是 top-p 剩下还能保留的”以阈值概率计的份数”,两者取小后上取整,得到”最多还能保留几个等于阈值的 token”。后续所有阶段一致地使用”col > cut 即剔除”。
注意 softmax 的浮点舍入:内核在键空间里做的是”logit 的精确比较”,而参考实现是在已经经 softmax 变成概率之后再比较累积和。由于 softmax 严格单调(不考虑舍入),两者在只开 top-p 时选取的边界一致;但在并列键上,概率可能因为指数运算的舍入而不完全相等,导致边界在极少的情况下多退一步或少退一步。这也是为什么测试里同时跑了多组温度与 vocab_size,而不是只测一组。
还有一个更结构性的差异:top-k 与 top-p 同时开启时,内核在同一棵键树上分别求两个阈值、再取更严格的那个,ptop 的质量口径是完整词表的 1;而把 top-k 与 top-p 作为两个先后 warper 串联的实现(HF 的
LogitsProcessorList、vLLM 的apply_top_k_top_p_pytorch、llama.cpp 的采样器链)会先按 top-k 裁掉尾部,再在重新归一化的 top-k 子集上计算 top-p,因此同样参数下候选集可能更窄。两套语义都成立,但跨框架对照采样结果时必须先确认属于哪一种。内核注释里”与参考实现一致”指的是单截断路径与归约顺序,不覆盖合取语义。
并列阈值:分组扫描
找到了 threshold 之后,整行里可能有大量 token 的键恰好等于 threshold——比如 logits 被结构化输出的 mask 大量置成同一个 -inf,或者模型在某个位置给出多个完全相等的 logit。ps_s[0] 与 ks_s[0] 记录的是”达到阈值后还保留多少”:
ps_th 是阈值处的概率 threshold_p 按同一个 31 位定点 scale 编码后的整数:
1threshold = sortable_to_f32(encoded)2threshold_p = T.exp((threshold + lse1) * inv_temperature + lse0)接着 sel_f 表示”只保留 sel 个等于阈值的项”。扫描是分块进行的:
- 若当前块之前累计的 tie 数量已经达到
sel_f,本块的 ties 全部丢弃(连同严格小于阈值的项一起置-inf); - 否则先数本块的 ties:若加完后仍未达到
sel_f,保留全部 ties、把严格小于阈值的项置-inf; - 若这个块跨过了
sel_f(cnt > sel_f),说明边界就落在这个块里,且最多发生一次。此时只对这个块做分组扫描:每个 256 项组计数 → 组计数的前缀和 → 锁定”第sel_f个 tie”落在哪个组 → 在组内再前缀和定位第几个元素 → 得到cut; - 之后的块里,所有 tie 都被置成
-inf(col > cut),同时把剩余的有效项概率质量累加进blk_mass。
为什么只做一次分组扫描?因为 sel_f 是确定的、而块是按顺序处理的,“跨过 sel_f”的块唯一。分组扫描成本是 O(TIE_BD) 的两次 cumsum,不随整行长度增长。整体复杂度仍是 O(V) 一次遍历加一次局部扫描。
blk_mass 是排除后剩余候选的质量和,最后:
1mass = T.alloc_var(T.float, blk_mass[0])2cut = cut_s[0]3lse0 -= T.log(mass)把 lse0 减去 log(mass),等于把归一化因子从”全词表的 log∑“换成”候选集内部的 log∑“。之后采样阶段用的 lf[j] = (lf[j] + lse1) * t + lse0 就自动落在重归一化后的候选分布上,不需要再单独算一次候选集的归一化。
有一个精度细节:blk_mass 被累加成浮点,而直方图用的 31 位定点质量。两者之间的取舍是”候选集质量需要参与一次 log 运算(浮点)“与”排序判定需要可靠的前缀和(整数)“。threshold 处的概率 threshold_p 会带来另一层舍入:当 threshold_p 极小(远距离尾部)时,p_s/pth 会很大,sel_f 被截到 vocab_size;当 threshold_p <= 0 时(下溢为 0),sel_f 设为 0,即”一个 tie 都不保留”,把边界推到严格不等号。
两级定点采样:把词表折叠成 2048 个车道
拿到候选集后,常规的采样实现是”算完整 CDF 再二分”。词表大小 12.9 万时,单行 CDF 需要 12.9 万个加法与一次二分。如果用一个 CTA 处理一行,让 256 个线程各算一个前缀和片段,那么跨线程的排序与合并本身就是一项显式成本,而且需要写入 12.9 万个值(每行 516 KB 的共享内存,根本放不下)。
DeepInfer 的方法是把词表在”列”方向折叠:
acc[k]=m∑pk+m⋅BD,k=0,…,BD−1其中 BD = SAMPLE_BD = 2048,这意味着 token 下标按 2048 分列、每列对应”跨步 2048 抽取出的约 64 个元素”(12.9 万 / 2048 向上取整为 64)。累加器 sample_acc_frag 是每个线程持有的一组 2048 个定点的”车道和”:
1for j in T.Parallel(SAMPLE_BD):2 pf[j] = T.Select(i0 * SAMPLE_BD + j < vocab_size, T.exp(lf[j]), 0.0)3...4if do_sample:5 accumulate(sample_cfg, pf, sample_acc_frag)accumulate 把概率由浮点转成定点再累加:
1for k in T.Parallel(cfg.bd):2 acc_frag[k] += float_to_fixed(cfg.scale_dtype, cfg.scale_bits, prob_frag[k])scale_dtype = uint32、scale_bits = 31,所以每个概率被量化到 2−31 的网格。
定点数与”零概率永不采中”
为什么不用浮点 CDF?参考实现使用过浮点前缀和,但有一个隐蔽的问题:浮点累加在长前缀下误差会逐步放大,而”样本落在哪个区间”是一个不连续的选择——误差跨过阈值时,采出的 token 就变了。定点数把这个问题变成可分析的量化误差:
- 每个概率向下截断到 2−31 的网格,量化误差在 [−2−31,0) 之间;
- V 项累计误差落在 [−V⋅2−31,0),V=129280 时最多约 −6×10−5;
- 所有 pi<2−31 的项都量化成 0,累积区间长度为 0,永远不会被选中。
第二条让”小于量化底线的 token 永不采出”成为硬性保证,而不只是”几乎不会”。这与测试里”采样结果的概率必须大于 0”的断言一致。float_to_fixed 的实现对 uint64 有专门路径:
1bits = T.reinterpret(x, "uint32")2exp = T.cast((bits >> 23) & 0xFF, T.int32)3mant = T.cast((bits & 0x7FFFFF) | 0x800000, dtype)4top = mant << T.cast(scale_bits - 23, dtype)5rshift = 127 - exp6return T.if_then_else(rshift < 64, top >> T.cast(rshift, dtype), T.cast(0, dtype))把 float32 直接按位拆成阶码与尾数,尾数补上隐含位 1 后左移到 64 位空间,再按阶码右移,从而避免先转浮点再乘以 231 时可能产生的第二重舍入。rshift >= 64 的情形(指数很小、下溢)返回 0,这就是”极小概率量化成 0”的来源。scale_dtype 是 uint32 时走的是简单乘法路径(x * 2^{scale_bits} 再截断),因为此时不存在 scale_bits - 23 > 63 的移位越界问题,不需要按位拆。
第一级:拒取法与无分支二分
第一级的输入是 lane_cdf = cumsum(acc_frag)(反向不需要,这里用正向),输出是”随机数 r 落在哪个车道 k0,以及它在车道内的余量 r2”。
随机数怎么来:
1total = T.max(rt_s[1], 1)2limit = (~T.cast(0, SCALE_DTYPE)) // total * total3rnd = T.alloc_var(SCALE_DTYPE, limit)4while rnd >= limit:5 rnd = T.rng_rand()6r_s[0] = rnd % total三段:
limit是 ⌊(232−1)/total⌋⋅total,即 232−1 向下取整到total的倍数。这是拒取法(rejection sampling)的经典做法:均匀随机数范围是 [0,232),取模后落在 [0,total),但 total 不能整除 232,导致一部分余数区间被多采到一点点。把大于limit的样本拒绝并重抽,得到的模值就是严格均匀的。while循环的期望迭代次数小于 2。- 为什么需要严格均匀?因为采样的”正确性”(与目标分布一致)依赖于均匀随机数。定点采样对随机数偏差的容忍度比浮点低:浮点误差可以被吸收,但整数模偏差在固定点网格上会成为真实的概率偏移。
T.rng_init(seed_val, verify_seq)出现在这个之前。随机数流由(seed, seq)初始化,其中seq = sample_positions[row] * 3 + case。这是 第八篇 讲过的三路分流:case 0 是正常采样、case 1 是残差重采样、case 2 是投机接受判定。每一个需要随机数的环节都只消耗自己的那条流,互不干扰。
定位车道用的是一个无分支二分:
1search_threads = min(cfg.threads, BD)2slots = BD // search_threads3for lane in T.Parallel(search_threads):4 slot = 05 for level in T.unroll(slots.bit_length() - 1):6 step = 1 << (slots.bit_length() - 2 - level)7 probe = (slot + step - 1) * search_threads + lane8 if lane_cdf[probe] <= r:9 slot += step10 k = slot * search_threads + lane11 prev = T.if_then_else(k > 0, lane_cdf[k - 1], 0)12 if prev <= r and lane_cdf[k] > r:13 offset_s[0] = k14 rt_s[0] = r - prev每个线程负责一条单调子序列 lane_cdf[lane], lane_cdf[lane + T], lane_cdf[lane + 2T], ...,在这条子序列上做二分。由于整条 CDF 是单调的,任意线程的子序列也单调。各线程找到”本子序列中第一个 ≥r 的位置”后,再检查一次相邻项,只有唯一一个线程会同时满足 prev <= r && lane_cdf[k] > r。
这里的选择比较有意思:一般做法是单线程串行二分 2048 项(11 次),或者整块 CTA 用 one-hot 前缀和。这个实现用了 256 个线程、每线程 8 项的子序列二分(3 次),总代价是 3 次读取(每次读 search_threads 个并行地址),不用同步、不用归约,也没有分支发散——循环体是固定次数的 if 更新。
slots & (slots - 1) == 0 是一个断言:子序列长度必须是 2 的幂,这样 slots.bit_length() - 1 恰好是二分步数。assert BD % search_threads == 0 保证均匀分配。
第二级:槽位精定位
第一级给出车道 k_0 与余量 r2=r−lane_cdf[k0−1]。第二级只需要在”车道 k0 的 64 个槽位”中定位:
1for m in T.Parallel(N_SLOTS):2 col = offset + m * BD3 slot_cdf[m] = T.if_then_else(4 col < vocab_size,5 float_to_fixed(cfg.scale_dtype, cfg.scale_bits, slot_probs[m]),6 0,7 )8T.cumsum(slot_cdf, dim=0)9for m in T.Parallel(N_SLOTS):10 col = offset + m * BD11 prev = T.if_then_else(m > 0, slot_cdf[m - 1], 0)12 cum = slot_cdf[m]13 if col < vocab_size and prev <= r2 and cum > r2:14 sel_s[0] = colslot_probs[m] 是 probs[row, k_0 + m*BD],即”车道 k0 的那一列”。这是精确到 token 的概率,不是车道和——所以第二级需要在采样阶段重新从 logprobs 里取出这些值:
1if col < vocab_size:2 slot_probs[m] = T.exp(logprobs[row, col])3else:4 slot_probs[m] = 0.0logprobs 在采样阶段被写入(logprobs[row, i0 * SAMPLE_BD + j] = lf[j]),所以候选集合的重归一化已经存在列方向上了。取一列 = 64 次跨步访问,对每个 CTA 的 256 个线程而言并行度足够。
T.cumsum(slot_cdf, dim=0) 对 64 项做前缀和,然后每个槽位各自检查一次区间。sel_s 只被一个线程写入(唯一匹配),所以不需要原子。第二阶段并不做二分:每个线程检查自己的槽位,因为只有 64 项,T.cumsum 一次就把前缀和算完了。
两级采样的总复杂度:第一级每个线程 3 次读、共 3×256 个地址;第二级 64 项 cumsum + 64 次检查。整行的采样成本与 V 无关,只取决于 BD=2048 与 N_SLOTS=64。
为什么”第一级用定点、第二级用浮点”
sample_acc_frag 是定点累加,而 slot_probs 是浮点概率。原因在于两级承担的不同需求:
- 第一级需要全局单调、可精确可复现的比较。一个跨 64 个槽位的和,如果每次用浮点重算都可能有最后一位的波动,那么”哪个车道”就不稳定,结果会随 batch 或编译配置变化。
- 第二级只需要在一列 64 项内做前缀和,误差来源单一、数量级小,浮点足以保证正确性;而且
float_to_fixed也在这时把浮点转回定点,所以最终比较仍然在定点网格上。
也就是说,“定点”是采样核心的数学域,浮点只是装载与重归一化过程中的表达形式。这个设计让”同一请求任何 batch 下采出同一 token”成为一条编译期可以推理的性质,而不是需要统计验证的性质。
投机验证与残差:单趟遍历同时完成两件事
当 do_reject_sampling=True 时,采样阶段多了一部分工作:对 is_verify 标记为真的行,需要
- 判定草稿 token 是否被接受;
- 计算残差分布 max(p−q,0) 并重归一化(用于被拒绝时重采样)。
表面上是”先算分布、再归一化、再采样”三步,实际内核把这三件事都压在同一趟读里完成。
在拆内核细节之前,先看标准投机解码算法的直观形态。Leviathan 等人 ICML 2023 的论文用下图说明一次投机迭代如何展开:每一行是一次迭代,草稿模型连续给出建议,目标模型一次并行打分,绿色是被接受的 token、红色是被拒绝的、蓝色是拒绝后按残差分布重采样得到的修正:

(图源:Leviathan et al., Fast Inference from Transformers via Speculative Decoding, ICML 2023, Figure 1。)
这张图有两个细节直接对应内核里的代码结构:其一,接受与拒绝是按位置逐项判定的,被接受的前缀之后一旦出现拒绝,后续草稿全部作废——这正是内核里”每一行取一个 draft_tid”的由来;其二,修正 token 来自残差分布的重采样,而残差分布只在”目标分布扣掉草稿分布”之后的质量上定义,所以内核必须让目标分布与草稿分布在同一趟读里相减。更关键的是,图里所说的”目标分布”在这里指的是截断并重归一化后的分布:被 top-p/top-k 排除的 token 在目标分布里概率为 0,它们不该被接受,也不该在残差里被采回来。源码用 (threshold, cut) 这个边界把这件事表达得很干净——一次边界判定,接受分支与残差分支共用。
接受判定
1if is_reject_sampling:2 draft_tid = T.cast(last_next_n_token_ids[draft_row], T.int32)3 accepted_s[0] = False4 if draft_tid >= 0:5 draft_lp = last_nextn_token_logprobs[draft_row, draft_tid]6 draft_logit = logits[row, draft_tid]7 target_lp = (draft_logit + lse1) * inv_temperature + lse08 if (top_p_active or top_k_active) and (9 draft_logit < threshold or (draft_logit == threshold and draft_tid > cut)10 ):11 target_lp = -inf12 accept_lp = T.min(target_lp - draft_lp, 0)13 verify_seq = sample_positions[row] * 3 + 214 T.rng_init(seed_val, verify_seq)15 accepted_s[0] = T.rng_rand_float(32, "uniform") < T.exp(accept_lp)16 accept_result_verify[row] = accepted_s[0]有几个细节需要逐条解释:
draft_tid >= 0 的提前返回。草稿 token 为 -1 表示”这一轮没有草稿”(在变长验证中,某些行没有对应的草稿位置)。此时直接拒绝,不参与判定。这与 第五篇 讲的变长验证一致:不是每一行都有草稿。
target_lp 在阈值判定中被置成 -inf。这一点很重要:如果被截断后的候选集里没有草稿 token,那么草稿 token 在目标分布里的概率是 0,它无论如何都不该被接受——即便原始 logit 很高。因为”目标分布”在这里的定义是截断并重归一化后的分布,不是原始 softmax。
残差路径则不是”直接套阈值”,而是用 (threshold, cut) 这个边界再判一次”是否落在候选集内”:采样阶段里每个被截断的项 lf[j] 被置成 -inf,pf[j] = exp(-inf) = 0,所以 max(pf - α·draft, 0) 对被截断的 token 恒为 0。边界一次算好,两个分支复用——这正是前文反复强调”阈值要与采样阶段共用同一份 logits”的原因。
accept_lp = min(target_lp - draft_lp, 0)。草稿概率用 log 存,所以目标/草稿比值 q/p 的对数是 logq−logp=target_lp−draft_lp;封顶 0 后就是 min(1,q/p)。draft_lp 是草稿模型自己的 logprob,取自 last_nextn_token_logprobs——即草稿头的输出分布。
accept_lp.exp() 与均匀数比较。这是”以概率 a 接受”的标准写法。随机数用的是 case=2 流,与正在进行的采样(可能还没做)和残差重采样都不同流。
残差并行收敛
用统一的记号:设目标分布为 q、草稿分布为 p(源码 draft_lf 的 p 对应草稿),被拒绝后应从 norm(max(q−p,0)) 重采样。最直接的做法是先把残差写成一个 V 维向量,再像普通采样那样走两级 CDF。但这样要多写多读一遍 V 项,而且第二级采样本来就是”取一列”,残差也取一列就行。
内核的做法是:在原本用来写 logprobs 的同一趟遍历里,同时把残差概率累进 sample_acc_frag:
1for reject in T.unroll(2 if do_reject_sampling else 1, explicit=True):2 if is_reject_sampling == (reject != 0):3 for i0 in T.Pipelined(N_SAMPLE_CHUNKS, num_stages=3 if NUM_STAGES else 0):4 ...5 for j in T.Parallel(SAMPLE_BD):6 pf[j] = T.Select(i0 * SAMPLE_BD + j < vocab_size, T.exp(lf[j]), 0.0)7 for j in T.Parallel(SAMPLE_BD):8 logprobs[row, i0 * SAMPLE_BD + j] = lf[j]9 if reject != 0:10 ...11 for j in T.Parallel(SAMPLE_BD):12 pf[j] = T.Select(13 i0 * SAMPLE_BD + j < vocab_size,14 T.max(pf[j] - T.exp(draft_lf[j] - DRAFT_PROB_PENALTY), 0.0),15 0.0,16 )17 if do_sample:18 accumulate(sample_cfg, pf, sample_acc_frag)for reject in T.unroll(2 if do_reject_sampling else 1, explicit=True) 是一个按行分流的技巧:每一行要么走普通采样(reject == 0 分支),要么走残差路径(reject == 1 分支),两者的代码几乎相同,区别只在 pf[j] 的计算和是否读取草稿分布 draft_lf。T.unroll + explicit=True 让编译器把两个分支展开成两段(未流水化时可能被合并成同一段遍历),运行时由 is_reject_sampling == (reject != 0) 的谓词决定哪些行进入哪一段。这样残差行不需要额外一趟 kernel,而普通行不会为残差计算付任何代价。
这里 accumulate 累加的是未归一化的相对质量:
其中 α=e−DRAFT_PROB_PENALTY。代码里并没有显式除以归一化因子 Z=∑imax(qi−αpi,0)。那第二级采样怎么”归一化”的?答案在 sample_offset:
1total = T.max(rt_s[1], 1)2...3r_s[0] = rnd % totalrt_s[1] 就是 lane_cdf[BD-1],若候选集归一化到 1,它就是 1;若残差只累加了 Z<1 的相对质量,那么 total 就是 Z,随机数对 total 取模,相当于从 [0,Z) 均匀取数,再与未归一化的 CDF 比较——自动完成了重归一化。这是一步很巧的替换:
等价于在归一化分布上采样。同样的技巧也用于 lane_cdf[k] > r 的边界判定:边界比较的尺度与累加器一致,不需要显式除法。
而 allow_zero=True 时,若 total 恰好为 0(残差质量全为 0,例如草稿分布处处 ≥ 目标),则 total = max(0, 1) = 1、limit = (2^{32}-1)//1*1 = 2^{32}-1,所以循环条件 rnd >= limit 几乎立即为假,rnd % 1 = 0,sample_select 选不出任何槽位、最终返回 0。真正兜底的是后面的 rt_s_rej[1] == 0 判定:
1if rt_s_rej[1] == 0:2 max_prob_tid = T.alloc_reducer((1,), T.int32, op="min", replication="all")3 T.fill(max_prob_tid, vocab_size)4 for i0 in T.serial(N_SAMPLE_CHUNKS):5 fallback_logits = T.alloc_fragment((SAMPLE_BD,), T.float)6 T.copy(logits[row, i0 * SAMPLE_BD], fallback_logits)7 for j in T.Parallel(SAMPLE_BD):8 col = i0 * SAMPLE_BD + j9 if col < vocab_size and fallback_logits[j] == max_v:10 max_prob_tid[0] = T.min(max_prob_tid[0], col)11 T.finalize_reducer(max_prob_tid)12 idx_rej = max_prob_tid[0]rt_s[1] 在 allow_zero 时由 sample_offset 以 lane_cdf[BD-1] 的形式返回——真实的未归一化总质量。当它等于 0 时,内核回退到”取最大 logit 的最小下标”(op="min" 保证并列时取最左边的一个)。也就是说,零残差时的回退是确定性的 argmax,而不是”随便采一个”。这与第八篇讲的”同输入同输出”性质一致。
再补一处:allow_zero 时的 total 计算 T.max(rt_s[1], 1) 是”避免除以零”的守卫——但 rnd % 1 = 0,所以所有车道都会被”选中”,rt_s[0] 为 0,最后全靠 rt_s_rej[1] == 0 分支的结果。这属于”先让程序跑通、再在调用方处纠正”的写法;关键是纠正路径本身是确定性的。
accept_probs:残差总质量即接受概率
1if accepted_s[0]:2 residual_mass = T.alloc_reducer((1,), sample_cfg.scale_dtype, replication="all")3 T.fill(residual_mass, 0)4 for j in T.Parallel(SAMPLE_BD):5 residual_mass[0] += sample_acc_frag[j]6 T.finalize_reducer(residual_mass)7 if T.get_thread_binding() == 0:8 accept_probs[row] = T.max(1.0 - T.cast(residual_mass[0], T.float) / sample_cfg.scale, 0.0)accept_probs 回传到上游的是 1−∑imax(qi−αpi,0) 的定点近似。从数学上,若忽略惩罚项 α,则 1−∑imax(qi−pi,0)=∑imin(pi,qi),这正是随机采样接受概率。它被上层用来做投机深度控制。
一个容易误读的点:这段只在 accepted_s[0] 为真时计算,即”草稿被接受”之后。这看起来很反直觉——接受率不应该由是否接受这次来决定。实际上 accept_probs 在这里不是”这一次的接受结果”,而是”这一行在当前分布下的期望接受概率”,它对所有验证行都要计算,而 residual_mass 恰好是已经算好的残差总质量。accepted_s[0] 只是控制流上的分支:拒绝的那一支已经有了 rt_s_rej[1](同样表示总质量),也能直接算出 1 - 残差质量。所以两个分支都产出 accept_probs,只是数据来源不同。源码里拒绝分支的写法:
1accept_probs[row] = T.max(1.0 - T.cast(rt_s_rej[1], T.float) / sample_cfg.scale, 0.0)注意 sample_cfg.scale = 2^{scale_bits} = 2^{31},所以 residual_mass / scale 是”残差质量占全概率的比例”。
DRAFT_PROB_PENALTY:源码注释里的双侧界
前面多次出现 T.exp(draft_lf[j] - DRAFT_PROB_PENALTY),其中
1DRAFT_PROB_PENALTY = 5e-5 # 防止 reject sampling 采不出东西这是一个很小的常数——α=e−5×10−5≈0.99995,只会把残差里的草稿概率降低约 5×10−5 的相对量。但它带来的后果是:最终的接受/重采样分布不再是精确的目标分布。tilelang_ops/common.py 的模块注释给出了完整的证明,这里按它的记号复述。
设 p 为草稿分布、q 为目标分布,α=e−ϵ(ϵ=PENALTY)。实际的拒绝重采样权重为
ri∝max(qi−αpi,0)注意源码里的 p 是 draft、q 是 target,和很多论文里的记号相反;这里保持源码注释的约定。令 si=max(qi−pi,0)、Δ=∑isi,则
ri=si+di,0≤di≤ϵpi,D=i∑di≤ϵ其中 di 是惩罚项带来的额外残差:在 qi>pi 的区域,di=(1−α)pi=ϵpi;在 qi≤pi 的区域,si=0,di=max(qi−αpi,0)≤ϵqi。
最终输出 token j 的概率为
Aj=min(pj,qj)+ΔΔ+Dsj+dj而理想的目标分布是 qj=min(pj,qj)+sj。两式相减:
Aj−qj=Δ+DΔdj−Dsj上界:若 qj≥pj,则 dj≤ϵpj≤ϵqj;若 qj<pj,则 sj=0 且 dj≤ϵqj。因此 Aj−qj≤dj≤ϵqj,即
Aj≤(1+ϵ)qj下界:qj−Aj=Δ+DDsj−Δdj≤Δ+DDsj≤Δ+DDΔ≤1+ϵϵ,即
Aj≥qj−1+ϵϵ合并得到源码注释里的结论:
qj−1+ϵϵ≤Aj≤(1+ϵ)qj其中 ϵ=1−e−PENALTY,当 PENALTY = 5e-5 时 ϵ≈5×10−5。
这条界的意义在于:工程实现允许对拒绝采样做一次极小的扰动,但必须能以可证明的方式约束它。如果没有惩罚项,残差是 max(q−p,0);当草稿分布 p 被系统性高估时,残差会被低估,重采样分布就偏离 q。更直接的问题是,在 q 与 p 高度接近的 token 上,残差可能全部量化成 0,于是拒绝时”没有东西可采”。惩罚项把残差整体抬高一个 ϵ 的量级,确保”总有分量可采”,代价是分布被抬离 q 一个 ϵ/(1+ϵ) 的下界、并被抬高于 q 一个 ϵ 的上界。
对采样质量而言,5×10−5 的量级远低于任何”人类可感知”的差别;对测试而言,它提供了一个可对账的 total_eps——第八篇的测试正是用 1−e−PENALTY 作为分布偏差的容差。这是把”工程近似”与”统计正确性”连接起来的标准做法:不是宣称实现精确,而是给出可证明的误差范围。
周边三块小内核:同一个算子家族的三条不变量
采样内核只负责”选 token”。在同一个 tilelang_ops 家族里,还有几块针对采样相关的特殊需求而存在的小内核,它们共同撑起了对外接口的完整性。它们放在这里对照,是因为它们解释了”内核边界”在哪里被切开、为什么切开。
random_sampling_tl:非融合采样路径与参考基线
random_sampling_tl(vocab_size, is_nextn_predict) 处理的是”已经算出 probs 张量”的场景,不包含温度缩放、top-p/top-k 与 log-softmax。它的结构与 fused_sample 的采样阶段几乎一样:
accumulate把每一块概率折叠成车道和;sample_offset取第一级的车道与余量;sample_select在选中车道的一列中精定位。
它与融合版的差别在”输入是概率还是 logits”和”是否带验证分支”。主前向路径不再调用它:fused_sample_ref 需要一条独立于融合内核的可执行路径来做逐行对照,测试也需要用它验证定点 CDF 的分布性质;此外它还充当 benchmark 的基线,用来衡量”融合”相对于”先算 probs 再采样”到底省了多少。这三件事决定了它必须保留一份可执行的实现,而不是只留在文档里。
sample_case = 1 if is_nextn_predict else 0 区分了”目标采样”与”残差采样”的随机流,这与融合内核里 sample_positions * 3 + case 的三路编码对齐:单独调 random_sampling 时没有 case=2 的接受判定,因为它不参与验证。
sum_smaller_probs_tl:把”升序 CDF 值”变成一个标量
1if val < logprob_limit or (val == logprob_limit and col <= sample_result):2 thread_sum[0] += T.exp(val)sum_smaller_probs 的定义是:把一行的 token 按概率升序排列,求”直到采样 token(含)“的概率和。它不输出 token id,而是输出一个标量——采样 token 在升序 CDF 上的累计概率值(不是位置,也不是排名)。
这个量由 sampler.py 里的 DEEPINFER_COMPUTE_SUM_SMALLER_PROBS 控制,默认关闭;开启后随 RequestOutput 回传给上层。它的用途是给采样结果附加一个”这条 token 在分布里的分位位置”式的标量:例如离线分析可以据此判断一次采样是落在头部还是尾部,回放工具可以把它作为确定性校验的附加指纹。它不是生成 token 的必需数据,而是可观测性接口的一部分。
内核的并列规则与阈值筛选共用同一套比较语义:val < limit or (val == limit and col <= sample_result) 表示”概率小于它,或概率相等但下标不大于它”。这等价于把并列 token 按原始下标排序后求前缀和——原始下标是唯一、稳定的排序键,不依赖浮点相等。sum_smaller_probs 因此与融合内核的阈值判定使用同一套并列规则,避免两套实现给出不一致的”升序位置”。
detect_outlier_and_abort:把静默的坏值变成可定位的 abort
1if T.isnan(value) or value <= -max_abs or value >= max_abs:2 T.evaluate(T.call_extern("int32", "printf", "outlier detected: id=%ld, value=%.9g, offset=%d\n", ...))3 T.device_assert(False, no_stack_info=True)outlier_detection.py 把 MAX_ABS 定在 1011:这不是一个”业务阈值”,而是用来拦住异常值——NaN、无穷,或者数量级已经超出任何正常激活范围的死值。它扫描任意形状的扁平张量,遇到坏值就打印 id(调用方传入的运行期标识)、fp32 数值和扁平偏移,然后让设备断言失败。
它守的是采样的输入前提。采样内核第一段里已经有一道守卫:
1T.device_assert(T.isfinite(sum_v) and sum_v > 0, msg="Invalid logits: contains NaN or +inf, or all entries are -inf")但这条守卫的粒度是”整行”:它只能告诉你”这一行的 logits 不合法”,不会告诉你是哪一层、哪个请求、哪个偏移出了问题。更常见的麻烦是部分位置出现 NaN,而不是整行全坏——此时 sum_v 可能仍是有限值(NaN 参与 max / 求和时行为取决于归约路径),softmax 被污染,采样随机数比较退化成全 false,最终静默地落在一个固定的 fallback token 上。生产上这类故障最难查:不崩、不报错,只是输出分布悄悄漂移。
detect_outlier_and_abort 的价值就在这里:它把”静默的坏值”变成一条带 id 和偏移的 printf,然后显式 abort。id 是调用方给的运行期标识(例如请求序号、层号或步号),用来在日志里指认”是哪一份张量的哪一个位置”;偏移则是扁平数组里的下标,配合形状就能反推出高维坐标。断言失败后 CUDA 上下文不可继续使用,进程需要退出——这是有意的选择:宁可让一个副本下线,也不要让它继续吐出看起来正常、实则可复现性已丧失的 token。
它与采样内核里的 device assert 构成了两层守卫:内层保证不会用坏 logits 产出 token;外层的诊断工具帮你定位坏值的来源。两道守卫都不保护”数值精度”——采样阶段对 logits 的量化是另一套问题,见后文。
工程边界与容易踩的坑
动态形状与 JIT 开销
fused_sample_tl(vocab_size, do_sample, do_reject_sampling) 只有三个参数,但 total_q 是动态的。这意味着同一个 (vocab_size, True, True) 编译出来的内核可以服务任意批大小,不需要按批大小重新编译。代价是 T.Kernel(total_q) 的 CTA 数量在运行时才确定,编译器无法做”按批大小调整分块”的优化。
这也解释了为什么采样阶段要把 vocab_size 编译进模板:不同模型的词表不同(常见的 8192、32768、129280),而同一个模型在整个服务生命周期里词表固定。按词表编译只发生一次,之后所有请求复用同一份二进制。
PDL 的语义边界
T.pdl_sync() / T.pdl_trigger() 在 Hopper 与 Blackwell 上有硬件支持;在更老的架构上,tilelang lower 成普通的 kernel 边界同步(没有重叠),功能正确但收益消失。代码依赖 PDL 的地方有三处:两段 kernel 之间、kv_index_cat 等周边内核的输入准备、以及 rgb_pad_to_patches 的图片预处理。PDL 不是正确性前提,而是延迟优化;如果某平台不支持,内核仍然能跑,只是两段之间的启动延迟没法叠起来。
定点量化的硬上界
采样阶段的定点是 31 位。词表越大,累计误差越大:
errmax≈V⋅2−31V=65,536 时约 3×10−5;V=129,280 时约 6×10−5;V=1,000,000(百万级词表)时约 4.7×10−4。这个误差是分布层面的,不是单个 token 的——它影响的是”采样分布与目标分布的接近程度”。
若将来出现更大的词表,要么增加定点位宽(uint64 路径已在 float_to_fixed 中实现,scale_bits 最高可取 63 位),要么把定点改为”定点 + 残差补偿”。但 uint64 定点会把累加器的寄存器压力翻倍,这又是 sample_acc_frag 在 BD=2048 下能否驻留的关键。这是一个典型的”精度—占用”折中。
T.unroll 的双分支与代码膨胀
1for reject in T.unroll(2 if do_reject_sampling else 1, explicit=True):2 if is_reject_sampling == (reject != 0):3 for i0 in T.Pipelined(N_SAMPLE_CHUNKS, num_stages=3 if NUM_STAGES else 0):两个分支展开后,每个分支都包含完整的 N_SAMPLE_CHUNKS 循环与流水化。若 N_SAMPLE_CHUNKS = 64、SAMPLE_BD = 2048,整个 kernel 的指令数会相当可观。这是为了”同一趟遍历里同时处理普通行与残差行”的代价。好在 T.Pipelined 与分块让每段循环体积可控,而 is_reject_sampling 的谓词让每个分支最多只有一半的行进入循环体。
并列与浮点相等的边界
lf[j] == threshold 是浮点相等比较。threshold 是从 u32 键反解出浮点值的:
1def sortable_to_f32(e: T.uint32) -> T.float:2 return T.reinterpret(T.float, e ^ (T.cast(T.cast(~e, T.int32) >> 31, T.uint32) | SIGN_BIT))它是 f32_to_sortable 的逆映射:对键做同样的符号翻转,还原出原始浮点。由于键是从原始 logit 精确映射出来的,反解得到的是完全相同的浮点值,所以 == 比较是精确的——只要 logits 本身没有被中途改写。这就是为什么采样阶段的所有 lf[j] 都来自重新读的 logits 或经 (lf + lse1)*t + lse0 计算——只有在”筛选与采样读同一份 logits”时,并列比较才可靠。如果中途有别的 kernel 改写了 logits,阈值比较就会失效。生产路径里没有这类改写,但这是调用方必须遵守的约定。
极端值:+inf、-inf、并行空行
f32_to_sortable里正无穷的raw是0x7F800000,翻转符号位后键为0xFF800000;负无穷的raw是0xFF800000,取反后键为0x007FFFFF。SORTABLE_POS_INF与SORTABLE_NEG_INF正是这两个值。desired在细化过程中被T.clamp(desired_s[0], SORTABLE_NEG_INF, SORTABLE_POS_INF)夹住——这一段对全 -inf 行尤其重要:如果一整行的 logits 都是 -inf,第一段 kernel 的device_assert会先失败,根本走不到这里。但如果只有一部分是 -inf、其余很小,threshold可能正好落在 -inf 键上,此时threshold_p = exp(-inf) = 0,代码把sel_f置为 0,即”不保留任何 -inf”。T.Kernel(total_q)是”每行一个 CTA”,不存在空行;但pool_rows(投机信息池行数)可以大于total_q,残差阶段从mtp_info_read_rows[row]取到的draft_row是池里的真实行号,可能覆盖整个池。sample_positions为 0 是合法的(序列的第一步),三路分流得到seq = 0/1/2,与 seed 组合后仍是一个有效的哈希输入。
与 lse_workspace 的生命周期
lse_workspace 是 [total_q, 2] 的临时张量,由调用方分配。它只活在两段 kernel 之间(同一调用内的两次 launch),不会被跨步持有。这与引擎里其他”借用缓冲”的约束一致——比如 SchedulerOutputs 里的 clone_blocks 是调度器内部 vector 的零视图,必须在下次 schedule() 前消费完。lse_workspace 更安全一些,因为它在内核内部的两次 launch 之间传递,调用方看到的只是一个会被覆写的张量。
与站内其他拆解的连接
- DeepInfer 源码完全拆解(八):采样、惩罚与确定性校验 从管线视角讲采样,本篇从内核视角补上执行细节;两篇合在一起才能看清”端到端”的采样。
- DeepInfer 源码拆解(五):投机解码——DSpark 变长验证、DFlash 草稿头与置信度调度 讲的是上游:草稿从哪里来、
mtp_info怎么组织。本篇里的draft_row、last_nextn_token_logprobs都是那篇讲过结构的对象。 - DeepInfer 源码拆解(三十六):选行与采样输入 讲的是”哪些行进入采样”:预填充取末行、prompt logprobs 逐行、投机验证逐行。本篇的
total_q正是那篇选行逻辑的输出。 - 批不变性为什么能被做到:采样层的哈希随机数与定点采样,加上 Batch-invariant 推理完全拆解 里的归约算子级别处理,构成两条互补的路径。
- vLLM 源码完全拆解(九):Sampler 里的采样在 Python 侧走 logits processors 链、逐 op 调用;对照它能看清”把采样压进一个 kernel”在工程上改变了哪些边界。
小结
fused_sample_tl 是 DeepInfer 采样路径的真正核心。它把一件看起来”逻辑上必须排序、必须多遍扫描”的事,改写成了以整数键和定点 CDF为骨架的流水:
- 用在线 log-sum-exp 一次算出 (−log∑je(zj−m)t,−m),只存两个 float,后续所有阶段复用同一公式;
- 用浮点位到 u32 的单调映射,把 top-p 与 top-k 统一为”整数键上的前缀和”问题,用 11 位基数直方图逐位收敛阈值,避免排序;
- 用 tie 分组扫描确定并列边界,用”重归一化的 lse”取代”候选集再算一次归一化”;
- 把词表折叠成 2048 个车道,定点累加 + 拒取法 + 无分支二分选车道,再在 64 个槽位里精定位 token;
- 在残差分支里,用同一趟读与一次归约同构地完成接受判定、残差计算与重采样,零质量时回退到确定性 argmax;
- 用
DRAFT_PROB_PENALTY对一个无法避免的近似给出严格的双侧界,使”工程近似”有可证明偏差范围,而不只是经验上的”差不多”。
下一篇回到采样上游的 sampler_utils:logits 如何按块计算、prompt logprobs 如何切分回填、force_topk 如何压缩候选,以及这些路径与本文的定点采样内核怎样对接。图像链路背景可以先参考 DeepInfer 源码拆解(九):视觉编码器 VE。
参考资料
- DeepInfer 官方仓库(DeepSeek 推理引擎)
- DeepInfer 采样内核
fused_sample.py - DeepInfer 两级定点采样
random_sampling.py - DeepInfer 残差惩罚常数与证明
common.py - Fast Inference from Transformers via Speculative Decoding(Leviathan et al., ICML 2023)
- Accelerating Large Language Model Decoding with Speculative Sampling(Chen et al., 2023)
- PyTorch
torch.multinomial文档(对照全局 RNG 的行为) - TileLang 官方仓库(本文内核的 DSL)
- NVIDIA Hopper 架构中的 Programmatic Dependent Launch
- The Pile / Llama 等模型的 top-p 采样语义:Hugging Face
TopPLogitsWarper
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
分享你的想法,与大家交流讨论
正在获取所在地区…
正在读取天气
音乐
暂未播放



