从“同一模型为什么会给出不同答案”到 Bitwise Reproducibility:一次量化大模型推理非确定性的完整排查
本文讨论 大模型推理的 Bitwise Reproducibility(比特级可复现性)。
1. 问题是怎么出现的?
最近在做一个视觉语言模型推理实验时,我遇到了一个一开始很难理解的问题:
代码没有改、模型没有改、数据没有改、随机种子没有改,重新运行一次,少数样本的答案却发生了变化。
例如第一次运行某一道题得到:
A
第二次重新启动程序,仍然是完全相同的输入,却可能得到:
B
更麻烦的是,这并不是大量样本随机乱跳。
绝大多数样本结果完全一样,只有极少数题发生变化。
于是最开始很容易怀疑:
- 是不是随机种子没有固定?
- 是不是 dropout 没关?
- 是不是模型实际上还在 sampling?
- 是不是 CUDA kernel 存在随机性?
- 是不是 FlashAttention 导致了不同?
- 是不是 KV Cache 或长时间运行积累了数值误差?
- 是不是输入图像预处理发生了细微变化?
- 是不是我自己的算法实现存在非确定性?
后面的排查证明:
这些怀疑有些是合理的,但最终真正的问题比这些更深一层。
最终我们定位到:
在当前软硬件环境下,某个 AWQ Marlin 量化线性层,在获得 bitwise-identical 输入时,两次 fresh process 仍然产生了不同的输出。
也就是说,第一次真正的数值分叉不是发生在数据、随机数、注意力计算甚至生成策略,而是发生在:
同一个 Tensor
↓
AwqMarlinLinear
↓
两个不完全相同的 Tensor
之后我们替换量化执行 backend,并最终实现了:
完整测试集
两次独立 fresh process
所有样本
所有实验臂
generated token sequence 完全相同
full sequence SHA256 完全相同
Bitwise Reproducibility: PASS
这篇文章就是完整记录我是怎么一步一步找到它的。
2. 什么叫 Bitwise Reproducibility?
先把概念说清楚。
很多时候我们说一个深度学习实验“可复现”,其实可能包含不同级别。
例如两次实验:
Run 1 Accuracy = 53.4%
Run 2 Accuracy = 53.4%
可以说最终指标一致。
但这并不意味着每一道题答案一致。
进一步要求:
sample 0: A == A
sample 1: C == C
sample 2: B == B
...
叫做输出级复现。
但是 Bitwise Reproducibility 要求更加严格。
对于某个 Tensor:
X(1)=X(2) X^{(1)} = X^{(2)} X(1)=X(2)
并不是指:
∣X(1)−X(2)∣<10−5 |X^{(1)}-X^{(2)}| < 10^{-5} ∣X(1)−X(2)∣<10−5
而是要求内存中每一个 bit 都相同。
如果把 Tensor 原始字节进行 SHA256:
sha256(tensor_bytes_run1)
和:
sha256(tensor_bytes_run2)
那么要求:
SHA256_1 == SHA256_2
这才是真正的:
bitwise identical
术语解释:SHA256 是什么?
SHA256 可以理解为一台非常严格的“文件指纹机”。
给它一段数据,例如:
ABC它会生成一个固定长度的字符串。
数据哪怕只变化 1 bit,SHA256 通常都会完全不同。
所以在判断两个 Tensor 是否真正逐 bit 相同时,SHA256 非常方便。
它比:
torch.allclose()严格得多。
allclose()判断的是“数值是否足够接近”;SHA256 判断的是:底层字节到底是不是完全一样。
3. 为什么这个问题特别难发现?
因为最后模型输出的是一个离散 token。
假设某一道选择题中模型对 A、B 两个选项的 logits 是:
A = 12.3125
B = 12.28125
那么模型选择 A。
但是如果第二次运行发生极小数值变化:
A = 12.28125
B = 12.296875
最终就变成了 B。
虽然底层只变化了:
0.01 ~ 0.03
但最终答案发生了:
A → B
这是典型的:
连续数值误差 → 离散决策翻转
术语解释:Logit 是什么?
大模型预测下一个 token 时,并不是直接输出:
“我选择 A”而是先给词表中的每一个 token 一个分数。
例如:
token A: 12.31 token B: 12.28 token C: 8.21 token D: 7.63这些还没有经过 softmax 的分数就叫 logits。
greedy decoding 会选择:
argmaxizi \operatorname*{argmax}_i z_i iargmaxzi
也就是分数最高的 token。
当 Top-1 和 Top-2 非常接近时,一个非常小的浮点误差就可能改变最终 token。
这也是为什么这类问题经常表现为:
绝大多数题完全稳定
+
少量“边界题”随机翻转
而不是整个模型彻底失控。
4. 第一反应:先排查最常见的随机性来源
面对这种现象,我第一步没有直接怀疑 CUDA kernel。
因为深度学习实验中有大量更加常见的随机性来源。
所以首先把经典方法全部锁死。
5. 常用方法一:固定随机种子
最基础的做法是固定:
import random
import numpy as np
import torch
random.seed(0)
np.random.seed(0)
torch.manual_seed(0)
torch.cuda.manual_seed_all(0)
同时设置:
PYTHONHASHSEED=0
术语解释:随机种子是什么?
计算机通常使用的是“伪随机数”。
给定同一个初始 seed:
seed = 0一个确定的伪随机数生成器应该生成同样的随机序列。
因此如果模型中存在:
- dropout
- sampling
- 随机初始化
- 随机数据增强
固定 seed 往往可以解决重复运行不一致的问题。
但我们这里很快发现:
没有解决。
6. 为什么固定 seed 在这里没用?
因为:
随机性和数值非确定性不是一回事。
假设一个 CUDA kernel 根本没有调用任何 RNG。
但是 GPU 有几千个线程并行执行:
thread 1
thread 2
thread 3
...
如果涉及浮点累加,那么执行顺序稍微不同:
(a+b)+c (a+b)+c (a+b)+c
和:
a+(b+c) a+(b+c) a+(b+c)
理论上在实数域相同。
但 IEEE 浮点数存在舍入,因此计算机里可能:
(a+b)+c≠a+(b+c) (a+b)+c \neq a+(b+c) (a+b)+c=a+(b+c)
例如非常粗略地理解:
第一次:
(a + b) + c
第二次:
a + (b + c)
最后一个 bit 就可能不一样。
这里:
没有随机数。
但仍然:
不 bitwise deterministic。
所以:
固定 seed
只能解决:
RNG 随机性
不能自动解决:
并行浮点运算顺序导致的数值漂移
7. 常用方法二:关闭 Dropout,进入 eval 模式
模型推理必须:
model.eval()
我们还进一步确保:
for parameter in model.parameters():
parameter.requires_grad_(False)
并使用:
with torch.inference_mode():
...
术语解释:Dropout 是什么?
Dropout 是训练神经网络时常用的一种正则化方法。
假设 hidden vector 是:
[1.2, 0.7, -0.3, 2.1]训练时 Dropout 可能随机把部分元素置零:
[1.2, 0, -0.3, 0]下一次可能又变成:
[0, 0.7, -0.3, 2.1]所以推理时必须执行:
model.eval()把 Dropout 关闭。
检查后:
eval mode 正常
dropout 已关闭
问题依旧存在。
8. 常用方法三:关闭随机采样
生成时使用:
do_sample=False
num_beams=1
也就是 greedy decoding。
术语解释:Sampling 和 Greedy Decoding
假设模型预测:
A: 45% B: 40% C: 10% D: 5%如果开启 sampling:
do_sample=True那么模型可能随机抽到 B。
而 greedy decoding:
do_sample=False永远选择概率最高的 A。
所以如果 greedy decoding 仍然发生:
A → B就意味着问题发生在更前面的 logits 计算中。
我们的生成已经是:
do_sample = false
num_beams = 1
因此也不是 sampling 导致的。
9. 常用方法四:PyTorch deterministic algorithms
然后开启:
torch.use_deterministic_algorithms(
True,
warn_only=False,
)
同时:
torch.backends.cudnn.benchmark = False
torch.backends.cudnn.deterministic = True
术语解释:Deterministic Algorithm
一些 GPU 操作存在多个实现。
某些实现:
更快 但运行顺序可能不同某些实现:
稍慢 但保证固定计算路径
torch.use_deterministic_algorithms(True)的作用就是尽量要求 PyTorch:如果某个操作存在 deterministic 实现,就使用 deterministic 实现;
如果某个操作明确知道无法保证 deterministic,可以直接报错。
这是非常重要的一层保护。
但结果:
仍然没有解决。
这时候问题开始变得有意思了。
10. 为什么 deterministic_algorithms 也可能管不到?
因为现代大模型并不是所有运算都一定经过 PyTorch 自带 operator。
例如量化模型经常使用:
第三方 CUDA extension
自定义 CUDA kernel
fused kernel
Triton kernel
PyTorch 的:
torch.use_deterministic_algorithms(True)
不意味着它能够控制世界上所有第三方 kernel。
可以把它理解成:
PyTorch:
“我能保证我自己认识的这些算子走 deterministic 路径。”
但如果某个第三方包说:
“这个矩阵乘法我自己实现。”
那么 PyTorch 并不一定知道其内部发生了什么。
这个认识后来成为整个排查非常重要的转折点。
11. 常用方法五:锁死 cuBLAS
设置:
CUBLAS_WORKSPACE_CONFIG=:4096:8
术语解释:cuBLAS 是什么?
NVIDIA GPU 上大量矩阵计算并不是 PyTorch 自己从零实现的。
NVIDIA 提供了一套高度优化的线性代数库:
cuBLAS
可以理解成:
GPU 版 BLASTransformer 中大量:
Y=XW Y=XW Y=XW
最终可能调用 cuBLAS。
CUBLAS_WORKSPACE_CONFIG是 CUDA 官方提供的一个控制部分确定性行为的环境变量。
于是我们又固定了:
CUBLAS_WORKSPACE_CONFIG=:4096:8
问题仍然存在。
12. 常用方法六:锁死 Attention Backend
Transformer 最核心的操作之一是:
Attention(Q,K,V)=softmax(QK⊤d)V \operatorname{Attention}(Q,K,V)= \operatorname{softmax} \left( \frac{QK^\top}{\sqrt d} \right)V Attention(Q,K,V)=softmax(dQK⊤)V
现代 PyTorch 中常用:
SDPA
Scaled Dot Product Attention
但 SDPA 后面可能选择不同 backend。
例如:
Flash Attention
Memory-Efficient Attention
Math Attention
为了排除这种影响,我们强制:
torch.backends.cuda.enable_flash_sdp(False)
torch.backends.cuda.enable_mem_efficient_sdp(False)
torch.backends.cuda.enable_math_sdp(True)
也就是只允许最普通的:
Math SDPA
术语解释:FlashAttention 是什么?
标准 Attention 会构造:
QK⊤ QK^\top QK⊤
这个矩阵在长序列下非常大。
FlashAttention 通过重新安排 GPU 内存访问和计算顺序,大幅降低显存访问成本,因此非常快。
但是不同 Attention kernel 可能拥有不同的:
- 分块方式
- reduction 顺序
- accumulation 顺序
因此即使数学公式完全一样,浮点结果也可能最后几 bit 不一样。
我最开始确实高度怀疑:
是不是 SDPA 自动选择了不同 kernel?
于是我们做了一个非常关键的实验:
完全关闭 Flash 和 memory-efficient SDPA,只允许 Math backend。
如果这时候问题消失,那么基本可以定位到 Attention backend。
结果:
问题依然存在。
于是:
FlashAttention
Memory Efficient Attention
SDPA 自动选路
都不再是首要嫌疑。
13. 到这里,经典 deterministic 手段基本都用完了
此时我们的运行合同已经非常严格:
seed 固定
Python seed 固定
NumPy seed 固定
CUDA seed 固定
model.eval()
inference_mode()
do_sample=False
num_beams=1
torch deterministic algorithms=True
cuDNN deterministic=True
cuDNN benchmark=False
CUBLAS_WORKSPACE_CONFIG=:4096:8
Flash SDPA=False
Memory Efficient SDPA=False
Math SDPA=True
还有量化相关的 FP32 accumulation 设置。
但两次运行结果:
仍然不同。
这说明不能继续靠“多加几个 deterministic flag”碰运气了。
必须真正找到:
第一个不同的 Tensor 到底在哪里出现。
14. 一个很重要的思路转变:不要再看最终答案
之前我们一直在比较:
最终答案 A / B / C / D
但最终答案距离真正的问题太远。
模型内部可能经过几十层 Transformer。
结构大致是:
Input
↓
Embedding
↓
Layer 0
↓
Layer 1
↓
Layer 2
↓
...
↓
Layer N
↓
LM Head
↓
Logits
↓
Token
只知道最后:
A ≠ B
几乎无法知道问题在哪里。
所以我们重新定义诊断目标:
对模型关键模块安装 forward hook,对每个模块记录 input SHA256 和 output SHA256。
术语解释:Forward Hook 是什么?
PyTorch 可以给一个网络层安装“监听器”。
比如:
layer.register_forward_hook(...)当模型执行:
Layer 0我们可以自动拿到:
输入 Tensor 输出 Tensor而不需要修改模型数学逻辑。
于是就可以画出:
module 1: input same, output same module 2: input same, output same module 3: input same, output DIFFERENT那么:
module 3就成为极其重要的嫌疑点。
15. 定义真正有因果意义的判断标准
这里不能只找:
哪个模块 output 不一样
因为如果上游已经不同,下游自然全都不同。
例如:
Layer 3 output 不同
↓
Layer 4 input 不同
↓
Layer 4 output 不同
不能说 Layer 4 有问题。
真正关键的是:
SAME INPUT → DIFFERENT OUTPUT
也就是:
XA=XB X_A=X_B XA=XB
但是:
f(XA)≠f(XB) f(X_A)\neq f(X_B) f(XA)=f(XB)
这个证据非常强。
因为上游输入已经完全一样。
那么分叉一定发生在:
f()
内部。
16. 先排除“长时间运行状态积累”
在正式 hook 之前,还有一个假设需要处理:
是不是模型跑了很多题后,某种 CUDA state、KV Cache 或 allocator 状态逐渐变化,最后造成漂移?
所以我们专门看:
fresh process 的第一个 sample
如果:
sample 0
在两个全新的 Python process 中 logits 就已经不同,那么:
前面跑了 100 多个 sample 导致状态积累
这个解释就不成立。
实验结果确实如此。
fresh process 的 sample0 已经存在 logits hash 差异。
这一步非常重要。
它把问题从:
长流程状态泄漏
缩小成:
单次 forward 内部就存在数值非确定性
17. 第一次真正抓到“犯罪现场”
随后我们对模型中数百个关键模块进行了追踪。
两次 fresh process:
selected modules: 316 / 316
结果第一次出现差异的位置是:
model.language_model.layers.0.self_attn.q_proj
运行时类型:
AwqMarlinLinear
更重要的是:
input SHA256 完全一致
output SHA256 不一致
输入:
dtype = bfloat16
shape = [1, 135, 3584]
SHA256 run A:
98dbffb23adba3c0cd595dba6670bb089...
SHA256 run B:
98dbffb23adba3c0cd595dba6670bb089...
完全一样。
但输出:
SHA256 run A:
07f18259c7f68968c8b498b4291fa90...
SHA256 run B:
4cd4dc185a8b97a3e789a34e5ba299...
不同。
这是整个排查真正的突破。
18. q_proj 到底是什么?
Transformer Self-Attention 中首先需要把 hidden state 投影成:
Q=XWQ Q=XW_Q Q=XWQ
K=XWK K=XW_K K=XWK
V=XWV V=XW_V V=XWV
其中:
q_proj = Query projection
k_proj = Key projection
v_proj = Value projection
也就是说:
q_proj
本质上是一层线性变换。
普通神经网络中就是:
Q = X @ Wq
但是我们的模型是一个 AWQ 4-bit 量化模型。
因此这里并不是普通的:
torch.nn.Linear
而是:
AwqMarlinLinear
19. AWQ 是什么?
术语解释:AWQ
AWQ 全称通常解释为:
Activation-aware Weight Quantization
大型语言模型的权重通常使用:
FP16 BF16每个参数需要大约 16 bit。
一个 7B 模型如果全部 BF16:
7×109×2 Byte≈14 GB 7\times10^9\times2\text{ Byte} \approx14\text{ GB} 7×109×2 Byte≈14 GB
仅模型权重本身就可能超过一张 12 GB GPU。
因此可以把语言模型的大量权重量化成:
INT4 / 4-bit理论上单个参数占用变成原来的四分之一左右。
AWQ 的特点是:
在量化过程中考虑 activation 对权重重要性的影响,从而尽量降低低 bit 量化造成的模型质量损失。
简单理解:
原权重: BF16 ↓ AWQ 量化权重: INT4推理时再使用专门的 CUDA kernel 高效计算。
20. Marlin 又是什么?
术语解释:Marlin
INT4 权重并不能直接用普通 FP16 Linear 的方式高效计算。
所以需要专门针对:
低 bit 权重 + GPU tensor core优化的矩阵乘法 kernel。
Marlin 就属于这一类高度优化的量化矩阵乘法实现。
它的目标主要是:
更高吞吐 更快推理 更好利用 GPU因此运行时:
Q = X @ Wq实际上可能不是普通 PyTorch matmul,而是由一个专门的 CUDA kernel 完成。
于是之前一个一直解释不通的现象突然可以解释了:
torch.use_deterministic_algorithms(True)
为什么没有把问题解决?
因为真正第一个出现差异的地方是:
AwqMarlinLinear
也就是第三方量化 CUDA kernel。
21. 这时能不能直接下结论“Marlin 都不确定”?
不能。
这一点非常重要。
我们真正证明的是:
在当前 GPU、CUDA、PyTorch、Transformers、GPTQModel 和模型配置组合下,我们使用到的
AwqMarlinLinear路径没有满足跨 fresh process bitwise reproducibility。
不能扩大成:
Marlin 永远不 deterministic。
更不能写成:
AWQ 不可复现。
AWQ 是量化方法。
Marlin 是其中一种执行 backend。
这是两个不同层级。
22. 下一步不是放弃 AWQ,而是只换执行 backend
这是整个解决方案最漂亮的地方。
我们没有:
重新训练模型
重新量化模型
换模型
换 checkpoint
换精度
换数据
而是保留:
同一个 AWQ 4-bit checkpoint
只把执行 kernel:
AwqMarlinLinear
换成另外一个 AWQ backend:
torch_awq
可以理解成:
同一本 4-bit 权重文件
┌──────────── Marlin kernel
权重 ──┤
└──────────── Torch AWQ kernel
模型参数本身没有发生改变。
改变的是:
这些量化权重在 GPU 上具体怎么执行矩阵乘法。
23. 第一轮验证:只测试一个样本
不能一上来就重新跑完整实验。
因为如果 backend 仍然不 deterministic,那么跑完整数据集只是浪费计算。
所以首先继续用之前完全相同的 first-divergence probe。
两次 fresh process:
Run A
Run B
仍然跟踪:
316 个模块
结果变成:
bitwise same outputs: 316
bitwise different outputs: 0
same-input/different-output modules: 0
也就是说:
316 / 316 全部 bitwise identical
第一次真正看到了:
NONE:所有被跟踪模块都 bitwise 一致。
这是非常关键的结果。
因为它构成了一个相当干净的控制变量实验:
原环境
+
Marlin backend
→ SAME INPUT / DIFFERENT OUTPUT
原环境
+
Torch AWQ backend
→ SAME INPUT / SAME OUTPUT
因此问题基本锁定到了:
量化执行 backend
这一层。
24. 为什么一个 sample 还不能宣布成功?
因为:
sample0 PASS
只能证明:
这个输入
没有发生分叉。
完整实验中还可能存在其他:
Tensor shape
序列长度
数据模式
kernel 调度
触发不同执行路径。
因此最终还必须做:
Full-dataset Fresh Process Certification
也就是:
Fresh Process #1
完整数据集
Fresh Process #2
完整数据集
两次之间:
不 resume
不 stitching
不复用 Python process
确保是真正的:
从模型加载开始完全独立
25. 最终认证应该比较什么?
这里又有一个很容易犯的错误:
不能直接要求两个运行目录 SHA256 完全相同。
因为日志里会存在:
generation_seconds
timestamp
GPU memory statistics
运行目录名
完成时间
这些字段本来就应该不同。
例如:
Run A generation_seconds = 0.083
Run B generation_seconds = 0.087
这不叫模型不 deterministic。
因此真正比较的是模型语义输出:
generated_token_ids
generated_token_count
full_sequence_sha256
raw_answer
parsed_answer
is_correct
其中最核心的是:
generated_token_ids
full_sequence_sha256
26. 最终结果
经过两次完全独立的 full-dataset fresh run:
sample identity mismatches: 0
当前主要路径:
strict exact records:
191 / 191
full_sequence_sha256 exact:
191 / 191
另一条受控路径:
strict exact records:
191 / 191
full_sequence_sha256 exact:
191 / 191
全部生成记录:
382 / 382
严格一致。
最终:
strict result-field mismatches: 0
CURRENT_METHOD_BITWISE: PASS
FULL_TWO_ARM_BITWISE: PASS
至此,才真正可以说:
Bitwise Reproducibility PASS
27. 整个排查路径回顾
把整个过程压缩成一条路线,就是:
现象:
同模型 + 同数据 + 同代码
少量答案发生变化
│
▼
怀疑 RNG
│
├─ Python seed
├─ NumPy seed
├─ torch seed
└─ CUDA seed
│
▼
仍然漂移
│
▼
怀疑训练态 / sampling
│
├─ model.eval()
├─ inference_mode()
├─ do_sample=False
└─ num_beams=1
│
▼
仍然漂移
│
▼
怀疑 CUDA / PyTorch 非确定性
│
├─ deterministic_algorithms=True
├─ cuDNN deterministic
└─ CUBLAS_WORKSPACE_CONFIG
│
▼
仍然漂移
│
▼
怀疑 Attention backend
│
├─ Flash SDPA OFF
├─ Mem-efficient SDPA OFF
└─ Math SDPA ONLY
│
▼
仍然漂移
│
▼
观察 fresh process sample0
│
▼
sample0 logits 已不同
│
▼
排除“长流程状态积累”
│
▼
逐层 SHA256 forward tracing
│
▼
寻找:
SAME INPUT → DIFFERENT OUTPUT
│
▼
第一处分叉:
Layer0 q_proj
AwqMarlinLinear
│
▼
只换 AWQ backend
Marlin → Torch AWQ
│
▼
sample0:
316 / 316 modules exact
│
▼
Full dataset × 2 fresh processes
│
▼
382 / 382 exact
│
▼
BITWISE PASS
这才是整个问题真正完整的因果链。
28. 这次排查中最重要的几个经验
28.1 “设置随机种子”不等于“确定性”
这是我认为最容易误解的一点。
很多实验代码写:
torch.manual_seed(42)
然后就宣布:
实验可复现
这是不严谨的。
Seed 主要解决:
随机数序列
而不是:
浮点并行计算确定性
两者必须区分。
29. torch.use_deterministic_algorithms(True) 也不是万能开关
它非常重要。
但它只能控制 PyTorch 能够识别和管理的算子。
现代大模型包含大量:
FlashAttention
Triton
quantized kernels
fused operators
third-party CUDA extensions
因此实际工程里应该记住:
框架级 deterministic ≠ 整个应用级 deterministic
30. 量化模型要特别关注“backend”
以前我更容易把模型理解成:
checkpoint
+
model architecture
这次之后我认为还必须加第三项:
checkpoint
+
model architecture
+
runtime kernel/backend
尤其对于:
GPTQ
AWQ
INT4
INT8
FP8
这样的量化模型。
同一个 checkpoint:
backend A
和:
backend B
可能具有:
- 不同速度;
- 不同显存;
- 不同数值误差;
- 不同 deterministic behavior。
所以正式论文实验不应该只记录:
Model: XXX-7B-AWQ
还应该记录:
quantization backend
31. 最有效的诊断方法不是看最终 Accuracy
如果只比较:
Accuracy
这个 bug 会非常难定位。
甚至会误判成:
方法性能下降了 0.x%
但实际上不是算法发生变化。
真正有效的方法是:
逐层 tracing
+
Tensor SHA256
+
SAME INPUT → DIFFERENT OUTPUT
这个方法非常通用。
面对:
训练漂移
推理漂移
量化误差
GPU nondeterminism
模型部署差异
都可以使用。
32. 为什么“SAME INPUT → DIFFERENT OUTPUT”证据这么强?
假设:
Module A output 不一样
不能说明 A 一定有问题。
因为可能:
A input 本来就不一样
但是如果:
SHA256(X1)=SHA256(X2) \operatorname{SHA256}(X_1)= \operatorname{SHA256}(X_2) SHA256(X1)=SHA256(X2)
同时:
SHA256(f(X1))≠SHA256(f(X2)) \operatorname{SHA256}(f(X_1)) \neq \operatorname{SHA256}(f(X_2)) SHA256(f(X1))=SHA256(f(X2))
那么几乎可以直接把问题缩到:
f
内部。
这是比:
最后答案不一样
强得多的工程证据。
33. 不要把 near-boundary 样本当成“随机坏样本”
之前发生翻转的样本通常具有一个明显特点:
Top-1 logit
和:
Top-2 logit
非常接近。
例如 margin 可能只有:
0.015625
0.03125
0.046875
因此:
底层非常小的 numeric drift
就可以:
改变 argmax
所以当一个模型表现为:
99% 样本完全稳定
1% 样本偶尔翻转
不要马上认为:
这 1% 数据有问题
它们可能只是:
模型决策边界附近的“数值放大器”。
这些样本反而是定位 nondeterminism 非常好的 probe。
34. 一个比较完整的 Deterministic Inference Checklist
以后如果需要做正式、严格的模型复现实验,我会至少检查下面这些内容。
随机数
random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)
torch.cuda.manual_seed_all(SEED)
PYTHONHASHSEED=0
模型状态
model.eval()
with torch.inference_mode():
...
Generation
do_sample=False
num_beams=1
PyTorch
torch.use_deterministic_algorithms(
True,
warn_only=False,
)
cuDNN
torch.backends.cudnn.benchmark = False
torch.backends.cudnn.deterministic = True
cuBLAS
CUBLAS_WORKSPACE_CONFIG=:4096:8
SDPA
严格实验可以考虑:
torch.backends.cuda.enable_flash_sdp(False)
torch.backends.cuda.enable_mem_efficient_sdp(False)
torch.backends.cuda.enable_math_sdp(True)
量化模型
必须额外记录:
Quantization method
bits
group size
backend/kernel
最终验证
不要只比较 accuracy。
至少比较:
generated token ids
必要时进一步:
Tensor raw bytes SHA256
35. 如果这些都做了仍然不稳定,下一步该怎么办?
我的建议已经和以前不同了。
不要继续无止境地尝试:
再加一个 seed
再关一个 flag
再换一个 environment variable
而应该马上进入:
First-Divergence Analysis
具体就是:
Run A
Run B
↓
记录关键模块 input/output
↓
SHA256
↓
从前向后比较
↓
找到第一个:
same input
different output
这是最高效的定位方式之一。
36. 一个通用的排查框架
以后遇到类似问题,可以按照四层来分。
Layer 1:输入确定性
验证:
dataset
tokenization
image preprocessing
prompt
input_ids
pixel_values
是否一致。
Layer 2:框架确定性
验证:
seed
eval
sampling
PyTorch deterministic
cuDNN
cuBLAS
SDPA
Layer 3:第三方 kernel 确定性
重点检查:
AWQ
GPTQ
FlashAttention
Triton
custom CUDA
fused operators
Layer 4:输出确定性
最终验证:
generated token ids
sequence hash
full-dataset fresh-process replay
只有四层全部过关,才是真正比较可靠的:
reproducible inference protocol
37. 这次最大的认识
过去我对“可复现”的理解更接近:
代码一样
seed 一样
Accuracy 一样
≈ 可复现
现在我认为正式实验至少要分清三层:
Metric Reproducibility
↓
Prediction Reproducibility
↓
Bitwise Reproducibility
第一层:
Accuracy 一样
第二层:
每个 sample 答案一样
第三层:
关键 Tensor / generated sequence
逐 bit 一样
如果研究结论依赖非常小的性能差异,那么这三者的区别尤其重要。
假设两个方法只有:
+1%
差距。
如果 baseline 本身重复运行就会:
±1%
漂移,那么这个实验的解释空间会非常危险。
因此:
先证明测量仪器稳定,再比较算法。
在大模型研究中,模型推理程序本身就是我们的“测量仪器”。
38. 最终得到的冻结推理协议
这次最终能够通过 Bitwise Certification 的环境大致为:
Quantized LLM:
AWQ 4-bit
AWQ execution backend:
Torch AWQ
Generation:
greedy decoding
Randomness:
all seeds fixed
PyTorch:
deterministic algorithms enabled
cuDNN:
deterministic enabled
benchmark disabled
cuBLAS:
deterministic workspace configuration
Attention:
Math SDPA only
Execution:
fresh independent processes
在这一冻结软硬件环境中:
单样本逐模块:
316 / 316 bitwise identical
完整数据集两次 fresh replay:
382 / 382 strict exact
result mismatches:
0
最终:
BITWISE REPRODUCIBILITY
PASS
39. 但最后还有一个边界必须强调
我们证明的是:
在当前冻结软硬件环境和指定 backend 下实现了 bitwise reproducibility。
并不是证明:
任意 NVIDIA GPU
任意 CUDA
任意 PyTorch
任意 Transformers
任意 GPTQModel
都一定产生完全相同的 bit。
因为只要变化:
GPU architecture
CUDA version
compiler
kernel implementation
library version
浮点计算路径都有可能变化。
所以严谨的说法应该是:
Bitwise reproducibility is an execution-contract property, not merely a model property.
翻译成人话:
比特级复现不是“这个模型天然拥有的属性”,而是“模型 + 软件栈 + 硬件 + kernel + 推理配置”共同构成的属性。
我认为这是这次排查里最有价值的结论。
40. 总结
这次问题最开始只是:
为什么完全一样的模型,
重新运行以后有几道题答案不一样?
一路排查经历了:
随机种子
→ Dropout
→ Sampling
→ PyTorch deterministic
→ cuDNN
→ cuBLAS
→ SDPA backend
→ fresh process
→ logits
→ module tracing
→ Tensor SHA256
→ first divergence
→ AWQ runtime kernel
→ backend replacement
→ full-dataset certification
最终找到了第一处真正有因果意义的证据:
AwqMarlinLinear
SAME INPUT
→
DIFFERENT OUTPUT
随后只更换量化执行 backend:
Marlin
→
Torch AWQ
得到:
SAME INPUT
→
SAME OUTPUT
最后再通过完整数据集两次 fresh-process replay:
382 / 382 strict exact
0 mismatches
完成 Bitwise Certification。
如果以后再碰到类似问题,我认为最重要的三个原则就是:
第一,不要把“固定 seed”当成“已经 deterministic”。
第二,不要只看最终 Accuracy,要找到 first divergence。
第三,对量化大模型,checkpoint 之外还必须把 runtime backend 当成实验变量。
这次排查真正让我意识到:
在做算法比较之前,先证明运行系统本身是稳定的,本身就是实验设计的一部分。
更多推荐


所有评论(0)