本文讨论 大模型推理的 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 会选择:

argmax⁡izi \operatorname*{argmax}_i z_i iargmax​zi​

也就是分数最高的 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 版 BLAS

Transformer 中大量:

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(d​QK⊤​)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 当成实验变量。

这次排查真正让我意识到:

在做算法比较之前,先证明运行系统本身是稳定的,本身就是实验设计的一部分。

更多推荐