最早跑这两步 GRPO,计步器走到了 2,抽查的参数却没动。后来把回答长度放开,终于看到了参数变化,训练进程又在退出时崩了。从训练日志看,两次都跑到了最后一步。

我在复现《Self-Distilled Reasoner》。论文里的学生模型只看题目,教师还能看到参考解答,给学生逐 token 的蒸馏信号。我先用七张 GPU 跑 Qwen3-1.7B 的 GRPO 基线。最先要弄清楚的是,这套环境能不能更新参数,跑完又能不能把卡还回来。论文与公开代码

1. 为什么七张卡只跑两步

1.1 一次更新经过哪些组件

1
2
3
题目 → vLLM 生成一组回答 → 正确性奖励 → 组内优势
→ 训练模型计算损失 → 反向传播 → 优化器更新
→ 关闭引擎与通信组 → 进程退出 → 外部采样显存

推理和训练共用这七张卡。vLLM 生成回答,训练框架算梯度,NCCL 让七个进程交换数据。只跑两步也得经过这三处,所以初始化、生成、更新和退出各有一份记录。

1.2 每个环节该记什么

环节 该留下的记录 这次的判断方式
CUDA 初始化 返回码、实际加载的驱动库 初始化成功,再检查数值运算
分布式通信 各进程的集合通信结果 七个进程均得到预期求和 28
生成与奖励 回答长度、结束原因、奖励和优势 检查是否截断、组内是否有差异
优化器更新 步数、梯度、参数前后采样 两步更新均完成,并观测到参数变化
退出 操作系统实际观测的返回码 七个进程全部为 0
显存归还 退出后的 GPU 进程和显存采样 与各卡启动前的空闲基线比较

题目取自公开数学训练集中的八行数据。为了省时间,我限制了更新次数,生成、算奖励和更新参数仍走 GRPO 训练入口。

2. CUDA 803,进程加载的是哪个驱动库

2.1 四种版本号,容易混成一个

信息 它描述什么
容器镜像标签 镜像的打包版本
PyTorch 的 CUDA 构建版本 框架构建时对应的 CUDA 运行时版本
宿主 NVIDIA 驱动 GPU 驱动栈的宿主部分
进程加载的 libcuda.so 应用实际调用的 CUDA Driver API 用户态库

我先对照宿主的 NVIDIA 驱动和进程映射的 libcuda.so。去掉 LD_LIBRARY_PATH 后,进程仍然加载 570.124.06,cuInit(0) 还是返回 803。换成与这台宿主匹配的 525.125.06,返回值才变成 0。

启动环境 加载的库文件名 cuInit(0)
默认环境 libcuda.so.570.124.06 803
取消 LD_LIBRARY_PATH libcuda.so.570.124.06 803
优先加载匹配的驱动库 libcuda.so.525.125.06 0

2.2 570.124.06 和 525.125.06,两个库怎么比

在分配到 GPU 的 Linux 环境里,用跑训练的 Python 执行下面的代码。每换一次库搜索路径,就新开一个 Python 进程。看 /proc/self/maps,两次实际加载了哪个文件,一眼就能对上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import ctypes
from pathlib import Path

driver = ctypes.CDLL("libcuda.so.1")
driver.cuInit.argtypes = [ctypes.c_uint]
driver.cuInit.restype = ctypes.c_int
result = driver.cuInit(0)

maps = Path("/proc/self/maps").read_text().splitlines()
libraries = {
Path(line.split()[-1]).name
for line in maps if "libcuda.so." in line
}
print("cuInit =", result)
print("loaded =", sorted(libraries))
raise SystemExit(0 if result == 0 else 1)

这段代码只检查初始化返回码和已加载的文件名。确认通过之后,再往上测浮点运算、多卡通信和模型生成,一层一层扩大验证范围。

3. 步数在走,为什么参数没动

3.1 同一道题的奖励都一样时,梯度从哪来

同一道题采样 GG 个回答,正确性奖励记为 r1,…,rGr_1,\ldots,r_G,组内标准差缩放后的优势是

rˉ=1G∑i=1Gri,A^i=ri−rˉstd⁡(r1,…,rG)+ε.\bar r=\frac{1}{G}\sum_{i=1}^{G}r_i, \qquad \hat A_i=\frac{r_i-\bar r}{\operatorname{std}(r_1,\ldots,r_G)+\varepsilon}.

同一道题的八个回答如果拿到一样的奖励,每个奖励减去组内均值都是零。这组回答就没有优势加权的策略梯度。其他题仍可能给出信号。这轮设了 β=0\beta=0,没有额外的 KL 项。如果整批每一组的优势都为零,这一项梯度也就全为零。

3.2 八个回答,一半答对会怎样

下面用样本标准差加 10−410^{-4} 的稳定项,照着锁定的版本复现这段归一化逻辑。

1
2
3
4
5
6
7
8
9
10
from statistics import mean, stdev

for rewards in ([0] * 8, [0] * 4 + [1] * 4):
center = mean(rewards)
scale = stdev(rewards) + 1e-4
advantages = [(reward - center) / scale for reward in rewards]
print([round(value, 4) for value in advantages])

# 全部为零奖励 → 八个优势都是 0
# 一半正确 → 前四个约 -0.9352,后四个约 +0.9352

八个都答错时,GRPO 给不出组内偏好。四个答错、四个答对时,两边的优势符号相反。查参数为什么没动,我会先把同一道题的奖励排在一起,再对照梯度和更新前后的参数。TRL 0.26.0 的优势计算

3.3 把回答上限从 256 调回 16,000

最早一轮,我把回答上限设成了 256 tokens。56 条回答全都顶到了上限,每组奖励都相同,抽查的参数也没变。仅凭这一轮,分不出截断对奖励有多大影响。

后来仍然只跑两步,把回答上限恢复到论文配置的 16,000 tokens。最后通过的那轮,56 条都生成了结束标记(EOS),抽查的参数也有了变化。

参数 这次的值 控制的对象
max_steps 2 优化器更新次数
max_prompt_length 2,048 题目长度上限
max_completion_length 16,000 每条回答的生成上限
num_generations 8 每道题的回答数
num_iterations 2 同一批生成结果用于更新的次数

七张卡每卡一次处理 1 条,累积 8 次,全局批量是 7×1×8=567\times1\times8=56。论文用的是 32。论文训练配置

4. KV 缓存该留多少显存

4.1 显存公式

这次用的是 BF16、未量化的 GQA,K/V 张量的存储量是

MKV=2 Nlayers HKV Dhead L S b.M_{\mathrm{KV}} =2\,N_{\mathrm{layers}}\,H_{\mathrm{KV}}\,D_{\mathrm{head}}\,L\,S\,b.

符号 含义 这次的值
22 Key 和 Value 两份张量 2
NlayersN_{\mathrm{layers}} 层数 28
HKVH_{\mathrm{KV}} KV 头数 8
DheadD_{\mathrm{head}} 每个头的维度 128
LL 单条序列的题目与回答总长度 2048+16000=180482048+16000=18048
SS 同时处理的序列数 4
bb 每个元素的字节数 BF16 为 2

公式里用 KV 头数。GQA 的多个 Query 头共享 K/V,用 Query 头数会高估显存。

4.2 代码复算一遍

1
2
3
4
5
6
7
8
9
10
11
12
13
layers = 28
kv_heads = 8
head_dim = 128
sequence_length = 2048 + 16000
concurrent_sequences = 4
bytes_per_element = 2

kv_bytes = (
2 * layers * kv_heads * head_dim
* sequence_length * concurrent_sequences * bytes_per_element
)
print(kv_bytes) # 8279556096
print(f"{kv_bytes / 1024**3:.2f} GiB") # 7.71 GiB

这次每卡给 KV 缓存设的配额是 8 GiB。上面算出的 7.71 GiB,是一张卡同时跑四条满长序列时的有效 K/V 总量,两者留了一点余量。这个公式只算有效 K/V 张量,模型权重、分配块舍入、临时工作区和训练激活都要另外留空间,整卡的真实占用还是要靠实时采样确认。

4.3 每卡提交八条,同时只算四条

设置 这次的值 影响
每题采样条数 8 组内奖励比较的样本数
每卡提交条数 8 每卡 rollout 数量
max_num_seqs 4 引擎同时处理的序列数,其余排队

排队的回答可以等前面的位置空出来再生成。调小并发,每卡同时需要的 KV 就少了,但跑完同一批回答的耗时也可能变化。

5. 进程退出后,显存回没回去

5.1 看 PyTorch 的数字,还是看驱动的数字

读数 主要统计对象 适合回答的问题
torch.cuda.memory_allocated() PyTorch 分配器统计中的活动张量内存 进程内还有多少已分配内存
torch.cuda.memory_reserved() PyTorch 缓存分配器保留的内存 分配器还保留多少缓存空间
驱动侧 GPU 进程与显存采样 进程的 GPU 上下文及设备占用 进程退出后,设备是否回到基线

empty_cache() 只是把分配器里的空闲缓存释放掉,活动张量的引用该结束的还是要正常结束。用了自定义分配器的话,还要核对这些统计接口覆盖到了哪些内存。PyTorch 显存管理说明

5.2 开着 sleep mode 时,崩在了哪一步

vLLM 的 sleep mode 让推理引擎暂时不用的时候释放或转移一部分显存。这个版本里开启后,权重和 KV 走专用的内存池,退出时模型引用、张量和原生分配器的回收顺序要互相配合。

开着 sleep mode 的几轮训练在销毁内存池时崩了。编译回调还引用着模型,注销回调之后,仍有权重张量活着。剩下的引用来自哪里,我没查清。后来按仓库的 GRPO 配置关掉了 sleep mode。

1
2
3
use_vllm: true
vllm_mode: colocate
vllm_enable_sleep_mode: false

最后跑通的是这组配置。前几轮开着 sleep mode 的崩溃仍记作失败。TRL 默认值 · vLLM 专用内存池入口

5.3 等进程退出,再看一次 GPU

1
2
3
4
5
所有 rank 完成最后一次更新和通信
→ 按当前框架版本关闭引擎、释放训练对象和通信组
→ 监督进程等待每个真实退出码
→ 对同一组设备再次查询计算进程与显存
→ 与开跑前基线比较,写入最终报告

训练函数返回之后,解释器和原生扩展还会继续跑析构逻辑。最终状态要看操作系统观测到的退出码,训练函数内部打印的完成信息不能替代这个检查。

6. 这次的版本组合和实测结果

6.1 版本组合

组件 版本或设置
模型 Qwen3-1.7B
Python/Torch/vLLM 3.10/2.8.0/0.11.0
TRL/Transformers 0.26.0/4.57.1
DeepSpeed/FlashAttention 0.18.2/2.8.3
训练 BF16、LoRA、ZeRO-2、CPUAdam
编译 Inductor 启用,这次关闭 CUDA Graph
推理 共置、TP1、sleep 关闭、KV 每卡 8 GiB

6.2 计算、退出、显存采样放一起看

观察对象 实测结果
生成 56 条回答全部以 EOS 结束,长度 663 至 12,504 tokens
更新 每个进程完成 16 次 micro-step 和两步 CPUAdam,抽查参数发生变化
通信与编译 七个进程的启动求和均为 28,每个进程记录到 29 次 Inductor 编译
退出 七个训练进程及宿主执行退出码均为 0
进程内部(PyTorch 分配器) 每个进程约 11.28 GiB 已分配显存
进程退出后(驱动侧外部采样) 第一次采样即回到各卡启动前的 0 MiB,计算进程列表为空

退出前的 11.28 GiB 是每个进程里 PyTorch 统计的已分配量。等七个进程都退出,再从驱动侧查,七张卡各自回到了开跑前的 0 MiB。

从启动到七张卡空出来,整轮用了约 8 分 37 秒。OPSD 的蒸馏效果和长时间训练的稳定性,要留到后面的实验里看。

7. 排查速查表

现象 下一步读取什么
CUDA 初始化返回 803 新进程的初始化返回码及实际映射的 libcuda.so
导入成功,第一次多卡通信失败 当前加载的通信库、实际卡数和首个集合通信的原始错误
步数正常,参数没有变化 回答结束原因、组内奖励标准差、优势和梯度
长回答显存不足 总序列长度、KV 头数、并发数及训练阶段的额外占用
更新完成,退出时 SIGABRT/SIGSEGV 每个进程的原生调用栈、分配器路径及真实退出信号
进程内仍有显存分配 继续等待真实退出,再采设备进程列表与显存基线