昇腾 910B 微调 Qwen3.8-27B:从 OOM 到 4096 上下文 LoRA

这次想做的事情很简单:给一个 27B 模型做 LoRA,让它学习 SoulChat-R1 中的心理咨询对话方式。

环境是 8 张昇腾 910B,每张约 64 GB HBM。要求也很明确:尽量在 Docker 里操作,不升级共享服务器的宿主驱动;正式训练上下文不能低于 4096;只保存 LoRA adapter,不复制或合并完整基座。

真正动手后才发现,难点不是把 finetuning_type 改成 lora。模型的混合架构、CUDA 导向的可用性检测、冻结层的激活保存,以及训练框架重新设置 checkpoint 的时机,都会影响这条路线能不能跑通。

最终跑通的是 8 卡 FSDP2 + BF16 LoRA + NPU FLA DeltaNet + 全层非重入梯度检查点。正式配置使用 rank 8,适配全注意力的 Q/K/V/O,最大序列长度 4096,完成了 3 个 epoch、2475 个优化步。

但这篇文章不是一份“训练完成,效果起飞”的报告:验证集在较早阶段就达到最低 loss,后续继续训练反而出现了过拟合。工程可行性和模型质量,必须分开验收。

本文记录的是这套具体版本组合上的实测结果,不代表任意昇腾环境都可以直接复制。代码片段用于解释关键修复,不是完整的一键部署脚本。涉及心理健康的数据和模型只能用于授权研究与开发,未经专业评估不能用于临床诊断或治疗。

先把实验边界说清楚

本次使用的基座目录名为 Qwen3.8-27B,下载来源记录为 Qwen/Qwen3.8-27B,通过国内镜像获取。训练前检查了权重索引引用的 18 个 safetensors 分片均存在且非空。这样的检查可以发现缺片,但不能代替逐文件哈希校验;复现时还应固定仓库 revision 和文件校验值。

一个容易混淆的地方是:这份权重使用 Transformers 的 qwen3_5 实现,加载类是 Qwen3_5ForConditionalGeneration。光凭类名就断言模型目录标错了,不够严谨,应该同时核对来源、配置和实际结构。

语言部分包含 64 层:48 层 Gated DeltaNet,16 层全注意力。后者位于第 3、7、11……63 层,按零开始计数。光修好普通 Attention,还覆盖不了它的全部计算。

隔离容器的关键版本如下。这里特意写实测版本,而不是只写一个会变化的 latest 标签。

组件本次验证的版本或配置
硬件8 × Ascend 910B,约 64 GB HBM/卡
宿主驱动25.3.rc1,实验中未升级
CANN9.0.0
PyTorch / torch-npu2.7.1+cpu / 2.7.1.post4
Transformers5.6.0
PEFT / Accelerate0.18.1 / 1.11.0
fla-core / flash-linear-attention0.5.2 / 0.5.2
triton-ascend3.2.1.dev20260530
训练框架LLaMA-Factory 容器内版本,配合进程内适配入口

torch+cpu 后缀,并不意味着没有使用 NPU;这里由 torch-npu 提供 NPU 后端,实际张量设备和运行日志才是依据。

Triton 的包元数据与导入版本也存在不一致现象,运行时 triton.__version__ 报告 3.2.0,并出现低于推荐版本的警告。因此这只能算经过本次测试的实验组合,不能包装成通用的官方兼容矩阵。后续应该固化容器镜像摘要、完整依赖清单以及 LLaMA-Factory 源码提交号。

第一条弯路:16 个 token 能跑,不等于长对话微调成功

早期为了验证链路,曾把序列缩到 16 token。这种测试可以排查启动、模型加载或某些分布式初始化问题,但它不能用来验收心理咨询长对话训练。

cutoff_len=16 的含义是处理后的样本长度上限约为 16 个 token,不是 16K,也不是 batch size。对包含 system、history、用户问题和回答的样本,这个长度可能连有效监督内容都保留不下来。

这次真正要纠正的,是把启动测试当成正式训练交付。短测有它的用处,但只能算启动检查。

后来把验收底线固定为 4096,并区分三件事:

  • 配置上限 4096 只管张量长度上限,超过的照样截断,每条对话未必能完整保留。
  • 动态批次里出现 1552、2280 等长度,只说明样本本身更短,上下文并没有被偷偷调低。
  • 把短样本补齐到 4096,验证的是这个张量长度下的计算与内存压力,有效监督 token 有多少是另一回事。

原始数据在远端已经存在,训练集 6598 条,验证集 300 条,无需重复下载。转换时保留 systemhistory 等对话结构,使用与当前模型兼容的 qwen3_vl_nothink 模板,确认无思考版本的输出不含 <think> 段。

正式复现还应统计应用 chat template 之后的长度分位数、超长比例和有效 labels 数量。4096 是这次的工程下限,覆盖了多少样本的长度还得靠实际 token 统计说话,光看数据集被描述为"长上下文"不算数。

为什么 LoRA 仍然可能把 64 GB HBM 吃满?

LoRA 减少的是可训练参数及其梯度、优化器状态,并不会让基座前向计算消失。

用简化表达式看一次训练的峰值内存:

基座权重及分片临时展开
+ LoRA 参数、梯度和优化器状态
+ 为反向传播保留的激活
+ 算子临时工作区与通信缓冲

在长序列上,后两项可能成为主要问题。冻结了权重,也不等于这个模块不需要参与激活梯度传播:后面的 LoRA 要更新,梯度可能必须经过中间的冻结模块传回更早的可训练位置。

因此几个看似自然的判断都不能直接成立:

  • “只有几百万可训练参数,所以总显存一定很小。”
  • “FSDP2 分到 8 卡,就肯定不会 OOM。”
  • “换成 QLoRA,就能解决所有长序列内存问题。”

FSDP2 主要处理参数等状态的分片,并不自动把每一层的序列激活切给 8 张卡。QLoRA 可以降低量化基座的存储成本,但还需要 NPU 上实际可用的量化算子和训练支持,也不能直接解决激活保存策略错误。本次最终使用的是 BF16 LoRA,不是 QLoRA,也没有实现真正的序列并行。

第二条弯路:把“fast path 不可用”当成全部算子都回退

排查 DeltaNet 时,模型会出现 fast path 不可用的警告。直觉上很容易理解成:少了 causal-conv1d,所以整套高效计算都不能用。

实际读过当前版本的模型实现后,发现这些组件的选择是分开的:因果卷积、DeltaNet 核心计算、归一化不必一起切换。

本次最后保留的是:

模块实际路径
因果深度卷积Transformers 原生卷积、SiLU 和切片
Gated RMSNorm原生实现
DeltaNet 分块计算在 NPU 上执行的 FLA chunk_gated_delta_rule

另一个问题来自可用性检测:当前 Transformers 对 FLA 的检测带有 CUDA 条件,即使容器里已经安装了能够在 NPU 上运行的 FLA,也可能不会自动选择它。

处理方式是在隔离训练进程中显式接入经过验证的函数,不去动宿主驱动。核心逻辑如下:

from fla.ops.gated_delta_rule import chunk_gated_delta_rule
from transformers.models.qwen3_5 import modeling_qwen3_5 as modeling

# 使用原生卷积与归一化,避免走 CUDA 导向的融合实现。
modeling.FusedRMSNormGated = None
modeling.causal_conv1d_fn = None
modeling.causal_conv1d_update = None

def npu_delta(*args, **kwargs):
    if args[0].device.type != "npu":
        raise RuntimeError("Expected NPU tensors")
    return chunk_gated_delta_rule(*args, **kwargs)

modeling.chunk_gated_delta_rule = npu_delta

真实入口还会按 LOCAL_RANK 设置设备,并记录首次调用的设备和 shape。所有 rank 都出现 FLA_NPU_ACTUAL_CALL,比一句笼统的 warning 更能说明实际路径。

这里还有边界:我们验证的是训练中的 chunk 路径,推理的适配仍然要单独验证,训练跑通和推理适配是两回事。

第三条弯路:开了梯度检查点,为什么还是 OOM?

接上 FLA 后,并没有立刻成功。

第一轮仍在 MLP 前向阶段 OOM。继续检查发现,当前 LLaMA-Factory 的自定义 checkpoint 逻辑会根据模块是否有可训练参数决定是否使用检查点,这让一些冻结层绕过了激活重计算机制。

对于只在少数注意力投影中安装 LoRA 的模型,大量 DeltaNet 和 MLP 权重是冻结的,但它们仍可能处在需要梯度传播的路径上。跳过这些层的 checkpoint,激活内存依旧会累积。

第二轮尝试覆盖这个行为,仍然失败了。回溯显示反向重计算还在使用 reentrant checkpoint,并在 MLP 反向重计算时 OOM。只在初始化阶段改一个函数,不足以保证 Trainer 最后使用的是预期配置。

最终修复有两个关键点:

  1. GradientCheckpointingLayer 层统一启用检查点,包括冻结但传递激活梯度的层。
  2. 在第一次实际 Trainer.training_step 中,设置 use_reentrant=False,避免前面的设置又被训练框架覆盖。

关键片段如下,调用时机同样重要:

from functools import partial
from torch.utils.checkpoint import checkpoint
from transformers.modeling_layers import GradientCheckpointingLayer

# 在首个 training_step 中执行一次,而非只在模型构建前执行。
for module in model.modules():
    if isinstance(module, GradientCheckpointingLayer):
        module.gradient_checkpointing = True
        module._gradient_checkpointing_func = partial(
            checkpoint, use_reentrant=False
        )

这里用了内部属性,是绑定具体版本的修复,升级 Transformers 或 LLaMA-Factory 后必须重新检查实现并跑冒烟,不能当成永久稳定 API。

运行时每个 rank 检查到 91 个 checkpoint 层和 92 个 FSDP 模块。这个数字包含模型里的其他模块,和"有 91 个语言层"是两回事;语言层仍是 64 层。

非重入 checkpoint 在所有环境是否都更省显存,本文不下结论。但本次日志和迭代测试明确显示:全层覆盖与运行时强制非重入这组修复,让此前失败的配置通过了测试。

不只检查“能 import”,还要检查数值和真实更新

这次分了几层验收,避免再用一个局部成功替代整个训练成功。

先检查 DeltaNet 的前向与梯度

对相同的 BF16 舍入输入,分别跑 CPU FP32 参考实现和 NPU BF16 实现,比较输出与五组输入梯度。相对 L2 误差如下:

比较对象相对 L2 误差
output0.003616
dq0.003687
dk0.004031
dv0.003795
dg0.003824
dbeta0.004132

这几个值约为 0.36%–0.41%,通过本次设定的 5% 门槛,但有限规模的数值测试替代不了对所有输入的数学证明。

还修正过一个测试自身的问题:FLA 输入布局是 [B, T, H, D]。如果把时间维与 head 维搞反,就算计算完成,也没有验证到想验证的序列长度。

再检查 4096 长度

单算子测试使用 [1, 4096, 48, 128],输出和梯度均有限,峰值 allocated 约 1.39 GiB。注意这是算子测试,不是整模型显存。

整模型则先跑 rank8、仅 Q 的 10 步冒烟,再跑固定补齐到 4096 的压力测试。补齐区域 attention_mask=0labels=-100,不计入监督损失。

最后扩大到 Q/K/V/O,并验证 adapter

仅 Q 不是 LoRA 的唯一或默认正确配置,只是这次先排查可行性时选择的窄范围。扩大到 Q/K/V/O 后仍保持 rank8,避免同时改变覆盖范围和 rank,难以判断开销变化的来源。

扩大后的可训练参数为 5,242,880,约占总参数的 0.0192%。Q/K/V/O 每类命中 16 层,共 64 个投影模块,保存出 128 个 LoRA A/B 张量。

还有一点:这些名字只命中全注意力层,没有给另外 48 层 DeltaNet 的投影加 LoRA。后者使用不同模块名称,不能通过写 q_proj,k_proj,v_proj,o_proj 就声称覆盖了全部语言层。

Q/K/V/O 的 10 步测试完成,训练阶段约 356 秒;固定 4096 压力下训练峰值 allocated 约 32.3 GiB/卡。保存后逐项检查张量有限值,并确认四类投影每一层的 B 权重都非零,证明存在实际更新,而不只是保存了一份初始化 adapter。

测试还跑完了 300 条验证数据,验证 loss 为 4.8649,退出码为 0。这是流程检查结果,模型效果另说。

正式配置:不降上下文,也不截断反向传播

正式训练保留同一套适配入口,去掉冒烟的步数上限与强制 padding,使用真实数据长度动态补齐。下面摘录主要配置,数据注册、模板与分布式配置仍需要配套准备:

stage: sft
finetuning_type: lora
lora_rank: 8
lora_alpha: 16
lora_dropout: 0.05
lora_target: q_proj,k_proj,v_proj,o_proj

template: qwen3_vl_nothink
cutoff_len: 4096
bf16: true
gradient_checkpointing: true
per_device_train_batch_size: 1
gradient_accumulation_steps: 1

learning_rate: 5.0e-5
lr_scheduler_type: cosine
warmup_ratio: 0.03
num_train_epochs: 3.0
max_grad_norm: 1.0

save_only_model: true
save_strategy: steps
save_steps: 100
eval_strategy: steps
eval_steps: 100

8 卡每卡 batch size 为 1,梯度累积为 1,有效全局 batch size 为 8。6598 条训练数据,每个 epoch 825 步,3 个 epoch 共 2475 步。

FSDP2 使用按 Qwen3_5DecoderLayerQwen3_5VisionBlock 包裹的分片策略,开启 reshard_after_forward 和 CPU RAM 高效加载,没有开启参数 CPU offload。视觉塔和多模态 projector 冻结,这次是文本对话适配,不是在训练视觉能力。

这里的梯度检查点会在反向时重算激活,目的是以计算换内存;没有用 detach 切断跨层梯度,也没有采用截断反向传播。当然,输入长度上限仍然是 4096,超过上限的数据截断和反向传播截断不是一回事。

正式运行采用 Docker 后台启动,训练日志写入挂载目录,launcher 使用锁防止重复启动,并在命令结束后记录退出码。SSH 断开不会直接终止这个后台任务;但主机重启、容器退出或进程异常仍然会影响训练。

还有个代价需要提前讲明:save_only_model: true 保存的是 adapter,完整优化器和调度器状态都没了。之后可以加载 adapter 继续训练,但严格恢复原训练状态的无损续训它算不上。

跑了约 26 小时,最好的 checkpoint 却在前面

北京时间 2026 年 9 月 6 日 07:16 启动正式任务。9 月 7 日 09:05 左右,2475 个训练步全部完成。Trainer 记录的累计时间约 25 小时 48 分钟,包含此前插入的周期性评估,不是纯前向反向耗时。

训练中的最大 allocated 记录约为 34,958,087,680 字节,即 32.6 GiB/卡。不要把这个数字和 npu-smi 的设备占用直接混为一谈:后者还可能包含缓存池、运行时等占用。判断是否危险应结合 allocated、reserved、实际算子申请失败及其他进程,而不是只看表面剩余几百 MB。

验证曲线比“最后训练 loss 很低”更值得看:

step验证 loss
1002.2275
5002.1512
8002.2403
11002.3991
16002.6346
20002.7945
24002.8168
2475约 2.819

最后一次训练日志的 loss 约为 1.362,但它只是最近一个日志窗口,不是整个训练集的平均 loss。与此同时,验证 loss 从第 500 步之后总体上升,说明继续拟合训练集没有带来同等的验证收益,呈现明显的过拟合迹象。

这次没有配置自动早停或自动加载最佳 checkpoint,而是保留中间 adapter,完成预定的 3 个 epoch。因此接下来更合理的是优先比较 checkpoint-500 与后续版本,而不是直接上线最后一份。

最低验证 loss 也不等于心理咨询能力已经最好。还需要用固定的留出对话比较连贯性、追问质量、重复与套话、危机情境回应,并做必要的人工专业评估。本文没有做出临床效果结论,也没有证明它优于未微调基座。

为什么进度到 100%,任务还没结束?

2475/2475 表示优化步完成,不表示分布式任务已退出。后面还可能有完整验证、分片状态汇总、adapter 保存、指标写盘和进程清理。

这次最后一个训练步后的评估有 300 条样本、38 个分布式批次,实测用了约 514 秒,也就是 8 分 34 秒。配置同时启用了训练期间评估与 do_eval,实际日志确认:保存模型后,又执行了一遍最终评估。因此给收尾预留十几到二十多分钟,比看到 100% 就直接关闭容器更稳妥。后续可以检查框架工作流,避免末步评估与显式最终评估重复,但不应在正在收尾的任务上临时改逻辑。

截至本文 9 月 7 日 09:18 的日志核查,最终 adapter 已于 09:15 写入顶层输出目录,文件为 20,990,496 字节,额外的最终评估仍在运行,任务尚未确认正常退出。这还算不上完整交付验收。

我的完成检查清单是:优化步数达到目标、进程正常退出且退出码为 0、最终 adapter 文件存在并可读取、关键张量有限、最终指标文件完整。单独出现进度条 100%,或者目录里有旧 checkpoint,都不足以替代这些检查。

如果重新做一次,我会改什么?

第一件事是先统计模板化数据长度与有效监督 token,再选上下文和验收用例。16-token 可以当启动检查,但绝不能当成完成了一轮有意义的长对话训练。

兼容性也要分层验证:包能导入、算子能运行、数值和梯度合理、整模型前后向成功、保存与评估完成,是不同层次的证据,不能从其中一项直接跳到全部成立。

遇到 OOM 则先定位阶段——前向、反向重计算、参数展开和验证阶段,可能有完全不同的内存来源,不要只在 rank、batch size 和 cutoff_len 之间盲目调小。

共享服务器上的升级尽量留在隔离容器里,但 Docker 不是万能隔离层。宿主驱动仍是共同依赖,这次跑通跟新版 CANN 能不能搭配旧驱动,是两回事。

还有一点容易被忽略:验收目标应该从“训练跑完”推进到“选出值得使用的模型”。下一轮可以预先制定早停规则、缩短训练周期或调整学习率,再用留出测试比较;不要根据这次验证曲线反复调参后,还把同一验证集当作独立测试集。

这次最大的收获,与其说是证明“8 张 910B 很能装”,不如说是把几个之前混在一起的问题拆开了:LoRA 不消除激活内存,FSDP2 不自动提供序列并行,CUDA 导向的检测不等于 NPU 算子真的不可用,冻结层也可能需要 checkpoint。

把这些问题拆开,再逐层拿日志和数值说话,才能从“似乎没有办法”,走到一次可以解释、可以检查,也知道自身局限的训练实验。

延伸阅读与复现依据

本文的具体数据来自本次实验的 FLA_NPU_VALIDATION.mdqwen38_fla_entry.pyqwen38_qkvo_formal_20260906.yaml、数值校验日志及正式训练 trainer_log.jsonl。上面的外部文档用于解释概念,不替代本机版本源码与实测结果。为避免暴露共享环境信息,文章省略了服务器地址、用户目录和原始咨询样本。

版权声明: 本文首发于 指尖魔法屋-昇腾 910B 微调 Qwen3.8-27B:从 OOM 到 4096 上下文 LoRAhttps://blog.thinkmoon.cn/post/1038-ascend-910b-qwen38-27b-lora/) 转载或引用必须申明原指尖魔法屋来源及源地址!