两步 GRPO 跑完了,进程却在退出时崩了
最早跑这两步 GRPO,计步器走到了 2,抽查的参数却没动。后来把回答长度放开,终于看到了参数变化,训练进程又在退出时崩了。从训练日志看,两次都跑到了最后一步。
我在复现《Self-Distilled Reasoner》。论文里的学生模型只看题目,教师还能看到参考解答,给学生逐 token 的蒸馏信号。我先用七张 GPU 跑 Qwen3-1.7B 的 GRPO 基线。最先要弄清楚的是,这套环境能不能更新参数,跑完又能不能把卡还回来。论文与公开代码
1. 为什么七张卡只跑两步
1.1 一次更新经过哪些组件
1 | 题目 → 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 | import ctypes |
这段代码只检查初始化返回码和已加载的文件名。确认通过之后,再往上测浮点运算、多卡通信和模型生成,一层一层扩大验证范围。
3. 步数在走,为什么参数没动
3.1 同一道题的奖励都一样时,梯度从哪来
同一道题采样 个回答,正确性奖励记为 ,组内标准差缩放后的优势是
同一道题的八个回答如果拿到一样的奖励,每个奖励减去组内均值都是零。这组回答就没有优势加权的策略梯度。其他题仍可能给出信号。这轮设了 ,没有额外的 KL 项。如果整批每一组的优势都为零,这一项梯度也就全为零。
3.2 八个回答,一半答对会怎样
下面用样本标准差加 的稳定项,照着锁定的版本复现这段归一化逻辑。
1 | from statistics import mean, stdev |
八个都答错时,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 次,全局批量是 。论文用的是 32。论文训练配置
4. KV 缓存该留多少显存
4.1 显存公式
这次用的是 BF16、未量化的 GQA,K/V 张量的存储量是
| 符号 | 含义 | 这次的值 |
|---|---|---|
| Key 和 Value 两份张量 | 2 | |
| 层数 | 28 | |
| KV 头数 | 8 | |
| 每个头的维度 | 128 | |
| 单条序列的题目与回答总长度 | ||
| 同时处理的序列数 | 4 | |
| 每个元素的字节数 | BF16 为 2 |
公式里用 KV 头数。GQA 的多个 Query 头共享 K/V,用 Query 头数会高估显存。
4.2 代码复算一遍
1 | layers = 28 |
这次每卡给 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 | use_vllm: true |
最后跑通的是这组配置。前几轮开着 sleep mode 的崩溃仍记作失败。TRL 默认值 · vLLM 专用内存池入口
5.3 等进程退出,再看一次 GPU
1 | 所有 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 | 每个进程的原生调用栈、分配器路径及真实退出信号 |
| 进程内仍有显存分配 | 继续等待真实退出,再采设备进程列表与显存基线 |
