【大模型LLM学习】推理阶段同一样本LLM输出结果不同的原因
文章目录
0 前言
推理阶段LLM的输出在温度为0时也不一样?!Bug or Feature?
如前文所说,即使是本地部署的大模型,可以发现,无论infer backend是PT、VLLM还是SGLang:
- 在batch size=1时,且温度为0时,且推理完一条再请求下一条时,对于同一个样本无论推理多少次,结果是一样的
- 在batch size>1时,会发现同一个样本,温度为0时,多次调用结果很可能是不一样的(对于qwen3,可以测试qwen3-vl,让它描述图片,可能多次调用结果前一百个token一样,接着就开始有少量不一致了)
- 根据《Understanding and Mitigating Numerical Sources of Nondeterminism in LLM Inference》,仅改变 batch size、GPU 数量或型号,即可导致同一模型在相同输入下生成完全不同的输出(例如 accuracy 波动达 9%,响应长度差 9,000 tokens)

1 rumor
对于,为什么同一个样本,在不同batchsize的情况下,推理结果不一样,有很多传说,并且从有人发现它,到现在已经很久了:
- 《The batch size can affect inference results》,OpenReview
- github上huggingface开发者的深入探究,甚至怀疑是KV-cache的bug:huggingface的探究
- 不同batchsize下,同一个样本推理结果不一样,是因为浮点运算不满足结合率
2 LLM输出不确定性的来源
为什么温度设置为0,topk=1了,不采样了,结果仍然会不一样?
- Thinking Maching Lab的blog,Defeating Nondeterminism in LLM Inference
- TML的batch invariance的实现
- neurips论文修复的实现:权重仍以 FP16/bfloat16 存储,激活用FP32
2.1 浮点运算不满足结合率+多线程并发导致运算顺序不同?
浮点数支持“动态精度”,在存储时采用存尾数和指数的方式
数值 = 尾数(mantissa) × 10^指数(exponent)
在进行加法运算时,同阶的才能直接相加,否则需要对齐后相加。假如系统规定,浮点数尾数保留 3位有效数字,指数为1位数字,那么计算1230 + 23.4,需要
# 转换成浮点格式
1230 = 1.23 × 10³
23.4 = 2.34 × 10¹
# 这两个数的指数不一样(10³ vs 10¹),不能直接相加!所以我们需要把较小的那个数“调整”到相同的指数:
2.34 × 10¹ = 0.0234 × 10³
# 做加法
1.23 × 10³ + 0.0234 × 10³ = 1.2534 × 10³
# 按照系统精度,尾数3位,指数1位
1.2534 × 10³ -> 1.25 × 10³ = 1250
在上面浮点数运算的过程中,最终结果是 1250,而不是精确的 1253.4,也就是说,两个浮点数运算结果有可能有损,两个浮点数的量级相差太大时,较小的那个数可能会被“淹没”或“忽略”。
由于浮点运算精度丢失的问题,浮点运算确实不满足结合率,也就是 ( a + b ) + c ≠ a + ( b + c ) (a+b)+c \neq a+(b+c) (a+b)+c=a+(b+c),例如运行下面的代码可以发现计算结果不一致!
(0.1 + 1e20) - 1e20
>>> 0
0.1 + (1e20 - 1e20)
>>> 0.1
换句话说:如果每次我们以不同的顺序对浮点数进行相加,都可能得到完全不同的结果
例如下面浮点数的运算,有可能得到102个不同的结果
import random
vals = [1e-10, 1e-5, 1e-2, 1]
vals = vals + [-v for v in vals]
results = []
random.seed(42)
for _ in range(10000):
random.shuffle(vals)
results.append(sum(vals))
results = sorted(set(results))
print(f"There are {len(results)} unique results: {results}")
# Output:
# There are 102 unique results: [-8.326672684688674e-17, -7.45931094670027e-17, ..., 8.326672684688674e-17]
到这里,似乎会觉得问题已经水落石出,是浮点运算不满足结合率+运算时使用率不同的顺序导致LLM在温度为0时,同一个样本也能输出不一样的结果。
换句话说:如果每次我们以相同的顺序对浮点数进行相加,得到的结果是确定的,浮点数运算顺序不一致会导致结果不一致
2.2 LLM 的前向传播几乎从不使用原子加
GPU可以用很多个核心来并行执行运算,如果所有核心都需要将计算结果累加到同一个内存位置(比如归约操作reduce_sum,对一个标量求和),就可以使用一种称为 “原子加”(atomic add) 的操作(有时也叫 “fetch-and-add”)。但原子加是非确定性的——累加的顺序完全取决于哪个核心先完成计算,这就会导致多次运行最终结果不一致。
例如,要用 100 个 GPU 核心对一个包含 100 个元素的向量进行加和操作(例如 torch.sum()),累加成一个单一的标量结果。可以使用“原子加”(atomic add),硬件会保证所有 100 次加法操作都会被正确执行,但它不保证这些加法发生的顺序,哪个核心先完成,它的值就先被加进去。由于浮点加法不满足结合律,这种顺序的不确定性导致多次运行得到不同的结果。
因此,很多人认为,GPU 上浮点运算结果不一致,是因为多个线程并发执行时,完成顺序不确定,导致加法顺序不同(“原子加”操作)。由于浮点加法不满足结合律,顺序一变,结果就可能微变,这听起来很合理。
但是,这种原子加操作在LLM的前向传播里面通常不存在。主要是因为两方面的原因:
- batch维度本身就提供了充足的并行性:例如,与其对一个 100 维的向量求和,不如同时对 500 个 100 维的向量分别求和。在这种情况下,每个 GPU 核心可以独立负责一个完整向量的归约,各自的结果写入不同的输出位置,彼此之间无需共享内存,因此完全不需要原子加。
- 现代神经网络库采用了高效且确定性的归约策略:即使确实需要对单个向量做归约(比如 softmax 中的求和),主流框架(如 cuBLAS、PyTorch、Triton)也不依赖原子加。如果要对数据分块归约,要么直接串行处理(因为数据量小,开销可忽略);要么用信号量机制强制线程块按固定顺序累加,从而保证结果完全确定。
** scatter_add(a[b] += c)是少有的需要使用原子加的操作,但是只在反向传播里面会用到,inference不会出现。既然不用原子加,为什么会结果不一样呢?
2.3 前向传播不保证"batch invariance"
batch invariance——模型对单个样本的输出,不应该因为该样本被放入不同大小或内容的 batch 中而改变
按理说,LLM里面大量用到的矩阵运算,不可能因为batch大小不一样结果不同。比如在请求服务里面,不太可能因为除了自己之外的在线人数不一样,导致结果不一致吧?但是运行下面的矩阵运算可以发现,不同batchsize做矩阵乘法,结果真不一致?!
import torch
torch.set_default_device('cuda')
B = 2048
D = 4096
a = torch.linspace(-1000, 1000, B*D).reshape(B, D)
b = torch.linspace(-1000, 1000, D*D).reshape(D, D)
# Doing a matrix vector multiplication by taking
# the first element of the batch
out1 = torch.mm(a[:1], b)
# Doing a matrix matrix multiplication and then taking
# the first element of the batch
out2 = torch.mm(a, b)[:1]
print((out1 - out2).abs().max()) # tensor(1669.2500, device='cuda:0')
# torch.mm(a[:1], b) 和 torch.mm(a, b)[:1] 调用了 cuBLAS 中完全不同的 GEMM 内核(kernel),而这些内核在浮点计算顺序、累加器精度、分块策略上存在本质差异,导致结果不一致。
也就是说,调用网上的大模型api,返回结果是什么,有可能会受到其他人是否在线,是否同时调用这个api,调用顺序以及调用内容是什么的影响。
换句话说:LLM inference之所以同一样本LLM输出结果不同,根本原因在于,服务器负载以及batch size本身就是波动的。
3 让LLM满足"batch invariance"
TML的blog里介绍了对已有的3种kernel的修改方式。要让一个 Transformer 实现具备批不变性,我们必须确保每一个计算内核(kernel)都是批不变的。可以假设所有逐元素操作(pointwise operations)天然就是"batch invariance"的——比如 ReLU、加法、乘法、LayerNorm 中的缩放等,它们对每个元素独立处理,不受 batch 中其他样本影响。
需要考虑修改的是RMSNorm,Matmul和Attention操作,因为它们都包含跨元素的聚合计算(reduction),而聚合顺序可能受 batch size 影响,导致结果不一样。
3.1 RMSNorm

RMSNorm涉及特征feature维度的规约,操作为:
# x: [batch_size, hidden_dim]
# weight: [hidden_dim]
def rms_norm(x, weight):
return x * torch.rsqrt(torch.mean(x ** 2, dim=-1, keepdim=True)) * weight
要实现批不变性,必须保证:对每一个样本(或每个输出元素)的归约操作,其内部的归约顺序是固定的,且不受整个 batch size 的影响。只要归约顺序对同一个样本保持一致,即使算法随数据规模、feature维度调整,仍是批不变的。
例如,假设在对一个向量做归约(比如求和),feature维度为L:
当 L = 100 时,用 warp-level 归约(32 线程一组);
当 L = 10,000 时,改用 block-level + global 归约(先分块归约,再合并);
**只要对同一个 L,归约顺序始终相同,不受batchsize影响,就满足batch invariance
假如使用这样的数据并行策略:让每个GPU core处理batch中的一个样本,core之间互不干扰,每个样本内部归约顺序不受batchsize影响,结果肯定是确定的。
但是,在batchsize比较小的情况下,如果给一个样本分配一个core,会有很多core空闲。如果为了加速计算,使用原子加操作,让多个core为一个样本并行加速计算,就不能满足批不变性了。
一种方式是,在batchsize小、无法完全占满core的情况下,不这么并行,因为 batch 很小时,内核本身执行得就很快,即使效率不高(比如没用满 GPU),整体延迟也不会太差,性能损失可以接受。
3.2 Matmul

M
a
t
m
u
l
=
P
o
i
n
t
w
i
s
e
×
+
R
e
d
u
c
t
i
o
n
Matmul = Pointwise × + Reduction
Matmul=Pointwise×+Reduction
对于
C
[
i
,
j
]
=
Σ
k
A
[
i
,
k
]
∗
B
[
k
,
j
]
C[i,j] = Σₖ A[i,k] * B[k,j]
C[i,j]=ΣkA[i,k]∗B[k,j]:
- 先做逐元素乘法(pointwise): A [ i , k ] ∗ B [ k , j ] A[i,k] * B[k,j] A[i,k]∗B[k,j]
- 再沿 k 维度做归约求和(reduction)
所以,matmul 的非确定性根源和 RMSNorm 一样:归约顺序是否一致。矩阵乘法本质上是“逐元素乘法 + 归约”,若要保证批不变性,就必须确保每个输出元素的归约路径不随 batch size(M/N)变化;而现代 GPU 为追求性能(尤其是 Tensor Core 利用率),常因 M/N 太小而选择不同的kernel和不同的规约策略,从而破坏批不变性。
矩阵乘法(matmul)与 RMSNorm 类似,高效的数据并行的矩阵乘法,可以将输出矩阵划分为二维小块(2D tiles),每个小块分配给一个 GPU 核心(SM),每个核心负责计算属于自己那块的所有点积(dot products),累加
∑
k
K
A
[
i
,
k
]
∗
B
[
k
,
j
]
\sum^{K}_{k} A[i,k] * B[k,j]
∑kKA[i,k]∗B[k,j]。
但与 RMSNorm 不同的是,高效的 matmul 内核还必须满足两个额外约束:高算术强度(arithmetic intensity)——即每字节内存访问要完成足够多的计算;充分利用 Tensor Core(张量核心)——现代 GPU 的专用硬件单元,要求输入以特定 tile 形状(如 16×16 或 64×64)组织。因此,不能像 RMSNorm 那样按单个输出元素划分任务,而必须以二维 tile 为单位进行分块。
将输出
C
(
M
×
N
)
C(M×N)
C(M×N)切成
[
T
M
,
T
N
]
[T_M, T_N]
[TM,TN]的 tiles(如 128×128),如果M 和 N 足够大,能生成足够多的 tiles 来填满 GPU,每个 GPU core(SM)负责一个 tile,整个K维度的归约在该 core 内完成, 不依赖其他 core,天然满足批不变。当 M 或 N 太小时,要提升GPU利用率,就可能用多个core来计算,把
K
K
K切分成
K
1
+
K
2
+
.
.
.
+
K
s
K₁ + K₂ + ... + Kₛ
K1+K2+...+Ks,每个 tile 现在只计算部分和
C
p
a
r
t
i
a
l
=
A
[
:
,
:
K
1
]
@
B
[
:
K
1
,
:
]
C_{partial} = A[:, :K₁] @ B[:K₁, :]
Cpartial=A[:,:K1]@B[:K1,:],最后用原子加把各部分和累加到最终 C,这会违背batch invariance。
要实现矩阵乘法满足batch invariance,TML使用的方案是让不同batchsize的矩阵乘法都用同一种kernel配置,禁用spilt-k,性能大约损失20%。

3.3 Attention

Attention比 RMSNorm 和 MatMul 更难做到批不变,因为它要在两个维度上做归约(feature + sequence),推理优化(如分块预填充、KV 缓存)会改变归约的“边界条件”,导致相同 token 在不同上下文中计算路径不同,破坏结果的确定性。
| 操作 | |
|---|---|
| RMSNorm | 只在 feature 维度(hidden dim)归约 |
| MatMul | 只在 reduction 维度(K)归约 |
| Attention | 先算 Q K T QK^T QKT , 在 sequence length(即 KV 长度)上归约;再算 s o f t m a x ( Q K T ) V softmax(QK^T)V softmax(QKT)V,仍在 sequence length 上归约 |
所以和之前发现的一样,是否开prefill,是否开KV-cache,对结果都会有影响。Chunked Prefill、Prefix Caching、KV Cache等优化,导致同一个 query token在 prefill 阶段(无 cache)和decode 阶段(有 999 个 cache tokens),被送入 attention kernel 的数据布局完全不同。同时,归约长度 = 当前 KV cache 长度 + 新 token 数,这个长度会动态变化。要实现推理结果相同,需要保证无论一次处理多少个请求,也无论每个请求在推理引擎中如何被切分,结果都保持不变。
当前,Attention的并行计算机制,与 RMSNorm 和矩阵乘法类似,默认采用的是数据并行,由于归约操作是沿着 Key/Value 张量进行的,因此这种数据并行策略只能在 Query 张量维度上。
根据推理引擎的具体实现,一个序列可能会被分多次处理(例如在分块预填充(chunked prefill)场景中),也可能一次性全部处理(如果预填充未被拆分)。为了实现“批不变性”,必须确保:对于某个给定的 token,其归约顺序不能依赖于当前与其一同处理的、来自同一序列的其他 token 的数量。也就是说,无论是分多次处理还是一次处理,处理的顺序需要是固定的。例如,在处理序列中的第 1000 个 Query token 时,无论 KV 缓存中包含 0 个 token(预填充阶段)还是 999 个 token(解码阶段),其归约顺序都必须完全一致。如果将 KV-cache的 K/V 值与当前token 的 K/V 值分开进行归约(例如 vLLM 的 Triton 注意力 kernel 就是这样做的),就无法实现顺序固定。
KV Cache和当前生成token的KV分离处理为何破坏批不变性?
假如Block size = 32(每个 GPU block 处理 32 个 KV tokens);
- 场景 A:
KV cache 有 80 个 token,当前 batch 有 48 个新 token → 总共 128;
Cache 部分:需 3 blocks(32+32+16);
新 token 部分:需 2 blocks(32+16);
总共 5 blocks,归约顺序 = [32,32,16,32,16]
- 场景 B:
无 cache,一次性处理 128 个 token;
只需 4 blocks(32×4);
归约顺序 = [32,32,32,32]
*cache 和 non-cache 数据被分开处理,虽然总长度相同,但归约分块方式不同,浮点误差累积路径不同,导致输出不一样
和矩阵乘法不同,LLM 推理中常见的注意力计算确实需要一种沿KV 序列长度维度规约拆分的 kernel,通常称为 Split-KV 或 FlashDecoding。这是因为,如果不沿归约维度(即 KV 序列长度)进行数据并行化,我们就只能在 batch 维度、注意力头维度和“Query 长度”维度上并行。而在注意力的解码阶段(decode stage),Query 长度通常仅为 1,因此除非 batch size 非常大,否则GPU利用率很低,当 KV-cache非常长时,即使只处理一个请求,注意力计算的时间也可能变得很长。
TML采用“固定分片大小”的方式实现Attention的批不变性。核心在于:它让每个局部归约的计算上下文(即每一块数据的处理方式)完全一致,与全局 batch 或序列长度无关。
假设我们设定 固定分片大小 = 256 tokens。
- 场景 A:KV 长度 = 500(比如 decode 阶段,cache 很长)
分片 0:tokens 0–255
分片 1:tokens 256–500(实际 245 个,末尾 padding + mask)
现在要计算第 300 个 token 的 attention output(作为 query):
它需要对全部 500 个 KV 做加权求和;
这个求和被拆成两部分:
先在分片 0 上归约(256 个值)→ partial sum₀
再在分片 1 上归约(245 个值)→ partial sum₁
最终结果 = partial sum₀ + partial sum₁
注意:分片 1 的归约逻辑是“处理最多 256 个元素,其中后 11 个被 mask” —— 这个逻辑是固定的。
- 场景 B:KV 长度 = 1000(另一个请求,更长上下文)
分片 0:0–255
分片 1:256–511
分片 2:512–767
分片 3:768–1000(233 个有效)
现在计算第 300 个 token(仍在分片 1 中):
它的 attention 仍需对全部 1000 个 KV 归约;
但它自己所在的分片(256–511)仍然是 256 个元素,归约方式和场景 A 中的分片 1 完全相同!
*虽然总分片数变了(2 vs 4),但每个分片内部的归约 kernel 是同一个,block size、shared memory layout、warp shuffle pattern 都一样。
4 "batch invariance"对推理延时的影响
在端到端耗时上,延时增加较多,TML表示是因为暂未优化到位

5 "batch invariance"对RL的影响
在 on-policy 强化学习中,有一个核心假设:用于生成数据(采样)的策略(sampler policy)和当前正在训练的策略(trainer policy)是同一个。
这意味着:
- 每次 rollout(轨迹生成)都由最新模型执行;
- 训练时直接用这些数据更新该模型;
- 采样分布和训练分布完全一致 → KL 散度为0
但即使使用同一个模型 checkpoint,在训练时的“采样”和独立推理服务中的“推理”可能因底层计算差异(如 batch size、GPU 内核选择、浮点舍入顺序等),产生微小但非零的输出差异。如果LLM的推理都不能保证确定性,很可能on-policy的RL会变成off-policy的RL,在用旧策略(或略有偏差的策略)生成的数据训练新策略。
因此TML做了一个LLM的RL实验,把如果把这种当成异策略的也加一个重要性采样,和确定性推理不加重要性采样来对比。
关键发现:普通训练不加重要性采样后期可能会崩,但是当 sampler 和 trainer 完全一致时,即使不加重要性采样,训练也能稳定进行——因为根本不存在分布偏移,KL 散度严格为 0,全程平坦

6 其他的一些研究
- 有人基于这个原理做了backdoor攻击发表在S&P上:Your Compiler is Backdooring Your Model: Understanding and Exploiting Compilation Inconsistency Vulnerabilities in Deep Learning Compilers
- neurips 2025 - Understanding and Mitigating Numerical Sources of Nondeterminism in LLM Inference,给出了一些fix的方法(例如混合精度,激活使用FP32)
更多推荐

所有评论(0)