背景:我在 8GB 显存的 RTX 4060 Laptop 上跑一个小型后训练研究的评测管线(Qwen3-0.6B,三个数学基准共 2119 题,greedy 解码,最长 512 新 token)。第一版实现慢到不可用,本文记录完整的归因过程:哪些假设被排除了、真正的瓶颈在哪、为什么"看起来显然"的优化只拿到 1.7 倍收益、最终方案为什么成立。所有数字均为本机实测,测试条件统一为 SFT-2K LoRA 适配器 + GSM8K 子集。
1. 症状:评测 2119 题要一天半
第一版评测实现是最朴素的写法:逐题构造输入、model.generate()单条贪心、写文件。跑起来的实测速度是4-bit 量化权重下单条约 18 秒/题。按 2119 题算,一轮评测 10 小时以上——而我的实验设计里有五轮评测(Base + 两个 SFT + 两个 DPO),评测总时间会超过全部训练时间。
更糟的是我一度用"三个评测进程并行"来救,实测单题速度反而恶化到约 24 秒——三个进程在一张卡上互相踩,总吞吐不升反降。先杀掉并行,回到单进程,从头归因。
2. 归因:三个假设,两个被排除
假设一:有残留进程在抢卡。排除。用nvidia-smi和进程命令行逐个核对 GPU 上的计算进程,确认测试期间独占。
假设二:KV Cache 没开,生成退化了 O(n²)。这是最可疑的一个——因为训练脚本里明确写着model.config.use_cache = False(梯度检查点的要求)。如果这个状态被带进评测加载的模型,每生成一个 token 都要重算整个前缀。实测:在评测加载路径上打印model.config.use_cache,结果为True。假设排除,Cache 正常工作。
假设三(真凶):Windows 显示驱动模型(WDDM)下的内核启动开销 + bitsandbytes 反量化成本。这两个因素单独看都合理,合起来解释了全部现象:
- WDDM 的每次内核启动有额外调度开销,而 HF
generate()的解码循环是逐 token 驱动的——每个 token 都要过一遍"启动采样核 → 启动量化/反量化核 → 启动注意力核"的循环。0.6B 的小模型计算本身只要几毫秒,每步的固定开销占了绝对大头; - 4-bit(bitsandbytes NF4)线性层在每次前向都要做反量化,这既是额外的内核数量,也是随批量线性增长的计算量。
3. 四组对照:把两个变量拆开
控制变量:同一适配器、同一 8 题 GSM8K 子集、同一 greedy 与 512 token 上限,只改"量化与否"和"批量大小":
| 配置 | 总耗时(8 题) | 折算 | 相对提速 |
|---|---|---|---|
| 4-bit + batch 1(原始实现) | 141.8s | 17.7s/题 | 1× |
| 4-bit + batch 8 | 83.5s | 10.4s/题 | 1.7× |
| bf16 + batch 1 | 96.2s | 12.0s/题 | 1.5× |
| bf16 + batch 8(最终方案) | 20.1s | 2.5s/题 | 7× |
这张表里有两个反直觉的地方,值得展开。
反直觉一:4-bit 下批量化几乎没用(只有 1.7 倍)。直觉上批量 8 应该接近 8 倍——每步的固定调度开销被 8 个样本摊薄。但 4-bit 线性层的反量化计算量随批量线性增长,batch 8 时每步的矩阵计算变成了 batch 1 的约 8 倍,把摊薄固定开销省下的时间又吃回去了。实测每步耗时:batch 1 约 63ms → batch 8 约 163ms。固定开销主导的场景下,只有"固定开销小"的方案(bf16 融合内核)才能兑现批量化收益——bf16 batch 8 每步约 39ms,摊薄效应才显现出来。
反直觉二:bf16 比 4-bit 慢不了反而快。评测时把权重从 4-bit 换成未量化 bf16(0.6B 模型仅 1.2GB 显存,8GB 卡毫无压力),单条 batch 1 从 17.7s/题 降到 12.0s/题——原因是 bf16 线性层的内核数更少、没有反量化步骤。"量化 = 更快"是小模型推理里的常见误区:量化省的是显存和大模型的访存,当瓶颈根本不在显存和算力、而在每步固定开销时,量化的额外内核反而是纯负担。
4. 方案落地与协议一致性
最终评测实现:bf16 非量化权重 + batch 8 左填充(left padding)批量贪心解码,批量大小走配置文件,训练侧的 4-bit QLoRA 协议一字未动。
批量贪心有一个必须诚实交代的细节:左填充 + 批处理会引入浮点级数值漂移。填充位参与注意力掩码屏蔽,但反量化与矩阵归约的顺序变了,512 步解码下来贪心路径可能分叉。我的 A/B:同 8 题分别用 batch 1 与 batch 8 跑,7 题输出完全一致、1 题生成路径分叉导致答案翻转。处理方式不是消除差异(消除不了),而是协议一致性:所有参与对比的模型(Base / SFT×2 / DPO×2)使用完全相同的批量评测实现——行级输出可能和"假想的单条评测"不同,但五组数字之间的对比是公平的。这在一个以对比为核心的研究里是底线。
5. 意外收获:小模型上 KV Cache 是负收益
归因过程中顺手跑了一个补充实验:用我自己从零实现的 30M 参数 Decoder Transformer(训练细节见系列第三篇之外的小仓库)测 KV Cache 的收益,结果是负的:
| 模型规模 | 有 Cache | 无 Cache | “加速比” |
|---|---|---|---|
| 3.3M 参数 | 142 tok/s | 474 tok/s | 0.30× |
| 30M 参数 | 65.7 tok/s | 93.4 tok/s | 0.70× |
KV Cache 省的是重复计算,代价是每步额外的缓存读写和缓存管理逻辑。当模型小到"无缓存单步计算 < 缓存管理开销"时,Cache 就是纯亏的——再加上 WDDM 的启动开销放大了每步的固定成本,负收益更明显。**教科书上的"KV Cache 加速自回归生成"是有适用域的,这个域的左边界比多数教程暗示的要靠右得多。**这个数据放在报告里比放在教程里有价值——它提醒读者:性能结论必须带规模和环境。
6. 经验清单
- 先归因,再优化。三个假设排掉两个,剩下的才值得动代码。
use_cache那次排查花了十分钟,救了我一次错误的"修复"。 - 小模型 + Windows(WDDM)场景下,每步固定开销是第一瓶颈,计算量和显存反而次要。
- 量化的收益要看瓶颈在哪:省显存是真的,提速在小模型上不成立,甚至为负。
- 批处理收益 = 摊薄的固定开销 − 随批量增长的计算量,两者谁主导取决于实现(有没有反量化这类随批量线性增长的步骤)。
- 对比实验的公平性来自协议一致,不是来自"和教科书写法一致"。行级浮点差异无法消除,但可以让所有被比较的对象共享它。
- 并行不是万能药:GPU 已饱和时,三个评测进程并行只会互相踩——总吞吐不变,尾延迟变差。
硬件:RTX 4060 Laptop 8GB;软件栈:PyTorch 2.8.0+cu126、transformers 5.6.1、bitsandbytes 0.49。本文的完整评测管线与五组对比数据:https://github.com/fangjianzhi/small-llm-post-training;文中的从零 Transformer 与 KV Cache 实测:https://github.com/fangjianzhi/tiny-transformer-lab
系列文章:①《Qwen3-0.6B 后训练实践:一次被数据否定的预注册假设》③《LLM 数据管线缺陷实录》