这篇记录一下之前在服务器上折腾视频模型微调的过程。
用的是 Video-ORA,任务是让模型看视频,输出值得保留的时间段,再交给后面的程序剪辑。我这边做 SFT,也就是拿视频和对应答案做监督微调,用 LoRA 训练少量附加参数。
服务器上有几张 RTX 3060,每张 12GB。最开始单卡只放了 8 帧,显存就用到约 11.24 GiB,再往上加很容易 OOM(显存不足)。视频得看动作的前后过程,一直减帧数也没法做下去。
(其实一开始就知道单卡跑不通了,但是还是想试下捏)
最后两卡跑过了包含 64 帧样本的多步训练,四卡的更新、保存和回载也通过了。中间花时间最多的是权重加载,还有一个一开始没注意到的问题:视频分辨率的限制根本没有按预期生效。
1. 显存到底花在哪
当时已经用了 BF16、LoRA、梯度检查点,视觉编码器和多模态连接层也冻结了,每卡一次只处理一条样本。
少量帧的两卡 DDP 能运行,显存峰值却还是接近 11.2 GiB。DDP 会在各卡保留模型副本,这次要解决的是单卡装不下,所以继续试 DeepSpeed ZeRO-3,把参数等训练状态分片放到不同卡上。
不过,排查时得先分清楚 OOM 发生在哪一步。
有一次单卡检查在 caching_allocator_warmup() 申请 8.35 GiB 时失败,看着像模型太大,实际是检查脚本在同一进程里调用了两次 get_model_processor(),开始加载第二份模型。后来把回载检查拆到独立进程,训练进程只负责更新和保存。
还有几次错误停在 ZeRO-3 加载权重时的 gather,也就是把分片参数临时聚合起来。视频还没送进去,减帧数当然解决不了。
我后来按报错位置分开查:加载阶段看权重和临时分配;拿到 batch 以后,再看视频输入和训练过程。这样至少不会一看到 OOM,就把所有参数都往小了改。
2. 视频预算没生效
这部分比加卡更值得先检查。
原来设置了 max_pixels,以为视频分辨率已经限制住了。调小之后再看实际 batch,张量却没有跟着缩小。当时使用的 Qwen3.5 视频处理分支没有按这个参数限制输入。
原来的 8 帧样本,video_grid_thw 是 [4,72,40],加上文本总共有 3058 个 token。后来改用视频 token 预算,在导入相关处理代码前设置:
export VIDEO_MAX_TOKEN_NUM=196
export VIDEO_MIN_TOKEN_NUM=128
同一条样本重新处理后,区别很明显:
| 输入 | video_grid_thw | 总输入 token(含文本) |
|---|---|---|
| 原来的 8 帧 | [4,72,40] | 3058 |
| 限制视频预算后的同一 8 帧样本 | [4,36,20] | 898 |
| 后续 16 秒、32 帧样本 | [16,36,20] | 约 3170 |
前两行的视觉 token 从约 2880 降到 720。省下这部分空间后,原来的单卡环境就跑通了 16 秒、32 帧的连续三次更新,显存峰值约 11.28 GiB。
也就是说,起初的“8 帧就满了”只能说明那组输入配置很吃显存,不能当成这张卡的固定帧数上限。这次把每帧的空间细节压低了一些,换来了更多时间帧;细节损失对任务效果有没有影响,还得另外评测。
这个网格也容易看错。video_grid_thw 记录的是时间、高、宽三个方向的网格,当时的时间 patch size 为 2,所以 [16,36,20] 对应的是 32 个输入帧,不是 16 帧。
VIDEO_MAX_TOKEN_NUM=196 也不表示整段视频最多只有 196 个 token。在这条处理路径里,它约束每帧的空间预算,上面 32 帧样本合并后的视觉 token 仍有:
16 × (36 / 2) × (20 / 2) = 2880
之后的日志里,我把实际采样帧数、网格和输入 token 都记下来。配置文件写了多少,最终还得看模型收到了多少。这两个环境变量的用法对应当时的处理器,换模型或处理库以后也要重新核对。
3. ZeRO-3 权重加载问题
两卡最初停在这样的日志后面:
Video-ORA ZeRO-3 loaded 250 parameters
随后一边报缺键,一边在 _initialize_missing_keys() 附近 gather OOM。用一个只构造模型结构、不加载实际权重的 meta 设备探针检查,checkpoint 的文件和索引能对齐,问题出在旧的自定义加载逻辑。
其中一个差异是权重键名:
checkpoint: model.language_model.layers.0....
model state: language_model.layers.0....
补一个前缀映射还不够,递归加载时的查找、复制和初始化完成判定都要对应上。否则权重文件明明存在,框架还是会把参数当成没有加载,再进入缺失参数的初始化流程。
另一个坑是把 Tensor 和 Parameter 混用了。加载器从普通 state_dict() 取值,却要求拿到的一定是 nn.Parameter。最开始报错的是共享权重的 lm_head,加了特判以后,普通视觉层的 bias 又报同样的错。
这个问题在 CPU 上就能看出来:
layer = torch.nn.Linear(2, 2)
type(layer.state_dict()["weight"])
# torch.Tensor
layer.state_dict(keep_vars=True)["weight"] is layer.weight
# True
排查中还遇到过“函数改了,但执行的还是旧函数”:定义模块里的 loader 被替换了,另一个模块在导入时保存的函数引用却没变。兼容补丁越叠越难确认,后来训练主线换到隔离环境,验证上游加载器,关闭了旧补丁。
当时的环境记录如下,方便以后对日志,不是要求照着安装的通用版本组合:
| 组件 | 本次记录 |
|---|---|
| Python | 3.11 |
| PyTorch | 2.10.0+cu128 |
| ms-swift | 4.6.0 |
| DeepSpeed | 0.18.2 |
| Transformers | 原环境 5.5.4;通过测试的隔离环境 5.19.0 |
| tokenizers | 隔离环境 0.23.1 |
隔离环境保留了原环境,其余依赖通过搜索路径复用。后面有一次复跑又出现旧错误,查日志才发现启动命令带回了旧解释器和旧 loader。从那以后,实际解释器、依赖版本和脚本哈希也一起写进运行记录。
权重验证还误报过一次:ZeRO-3 未聚合的参数可能显示为 [0],直接拿这个本地形状去和完整 checkpoint 比,当然对不上。校验时改用逻辑形状:
def logical_parameter_shape(parameter):
return list(getattr(parameter, "ds_shape", parameter.shape))
数值比较则让各 rank 按相同顺序进入 gather,再与转换到相同 dtype 的源权重逐元素比较。最终 724 个 checkpoint 条目全部对上,加载这一步才算查清楚。
4. 训练停在第 0 步
两卡通信先用 NCCL all-reduce 测过:每张卡各放一个 1.0,两边都收到 2.0。模型也加载完了,训练日志却是:
global_step/max_steps: 0/1
train_loss: 0
train_steps_per_second: 0
原因是最小测试只准备了一条样本。采样器还要按 rank 分数据,两卡没有各自拿到可用 batch。关掉 shuffle 也没用,换顺序不会凭空多一条数据。
单样本检查改成按 world_size 复制,让每个训练进程都能拿到一条;这个开关只用于冒烟测试。训练前也加了 DataLoader 检查,哪个 rank 拿不到 batch,就提前报错。
换成不同窗口做多步测试以后,又发现三条样本实际只用了两条。当时的采样器分片路径按 total_samples // world_size 处理,两卡把尾部的一条丢掉了。测试于是改成四个不同窗口,逐步记录每张卡实际读到的样本,并检查样本行数能否被卡数整除。
这两次程序都能启动,也没有直接告诉我“数据没用全”。只看进度条和 loss,很容易漏过去。
参数更新检查也有个小插曲:首步一半 LoRA 张量的梯度全零,但它们全部属于 lora_A。当时 A 随机初始化,B 初始化为零,增量是 B × A,第一次反向传播到 A 的梯度会乘上全零的 B。B 仍然可以更新,所以不能用“每个参数首步都有非零梯度”来判断训练是否正常。
5. 两卡和四卡的测试结果
两卡多步测试用了四个不同窗口,其中最长的一条是 32 秒、64 帧,实际在 rank 0 上参加训练:
video_grid_thw = [[32, 28, 28]]
input_tokens = 6770
supervised_tokens = 182
连续三步完成了前向、反向、优化器更新和 Adapter 保存,四个窗口都出现在 batch 记录里。两个进程各自的峰值如下:
| 进程 | 实际训练过的最长输入 | 峰值 allocated | 峰值 reserved |
|---|---|---|---|
| rank 0 | 64 帧 | 8.91 GiB | 9.37 GiB |
| rank 1 | 32 帧 | 7.39 GiB | 7.90 GiB |
allocated 是 PyTorch 已分配给张量的显存,reserved 还包含其缓存池保留的空间。这里记录的是 PyTorch 指标,不直接等同于 nvidia-smi 里的整卡占用。
这是一组混合长度测试,不能理解为两张卡同时各跑了 64 帧。两卡这里的 64 帧空间网格,也和前面单卡测试不同,不能直接拿两组数字算多卡节省了多少比例。
除了显存,我还检查了答案是否真的参与训练:assistant 对应的监督 token 非空,答案没有被截断,其余输入保留 mask。切窗后的时间也要对齐,源视频里的时间减去窗口起点,跨窗口事件先裁到当前窗口范围内。否则视频从局部 0 秒开始,答案还写着原视频时间,训练能跑,喂进去的却是错位的样本。
用到的 ZeRO 配置放在下面。通信桶和预取桶设得较小,关闭通信重叠,没有使用 CPU 或 NVMe offload;这一版先解决容量,没有做吞吐调优。
{
"bf16": {"enabled": "auto"},
"zero_optimization": {
"stage": 3,
"overlap_comm": false,
"contiguous_gradients": true,
"reduce_bucket_size": 5000000,
"stage3_prefetch_bucket_size": 5000000,
"stage3_param_persistence_threshold": 10000,
"stage3_max_live_parameters": 100000000,
"stage3_max_reuse_distance": 100000000,
"stage3_gather_16bit_weights_on_model_save": true
},
"gradient_accumulation_steps": "auto",
"gradient_clipping": "auto",
"train_batch_size": "auto",
"train_micro_batch_size_per_gpu": "auto"
}
后续四卡测试沿用同一加载和训练入口,各 rank 读取不同窗口,连续更新、保存和回载也都通过。那次使用的是 32 秒窗口、32 帧输入,各卡峰值 reserved 约 5.76~5.82 GiB。帧数和两卡测试不同,这里不拿它做线性扩容对比。之前的三卡单步测试也没有变快,多卡在这次联调里首先解决的是装不装得下。
6. Adapter 回载测试
保存的 Adapter 大约 10 MB。退出训练进程后,重新加载基础模型和 Adapter,256 个张量与保存文件逐元素一致。
随后拿短测的 Adapter 做了不带标准答案的生成测试,出现过 <argmax>11</argmax> 和重复的 <1.0>,输出还不稳定。检查了训练与推理的封装、模板和视频预算,当时没有定位到具体原因。这是前面几步短测的结果,后续正式微调另做效果评测。
7. THE END
后续正式微调沿用了这套多卡入口。 本次配置环境最大功臣:ds-4.1f(真快)+ 6astra(一针见血的建议)

