Skip to content

FP8 LoRA 训练在整块包装的模型上梯度丢失,权重保持初始值 #1780

Open

Description

@aiimagestudio

现象

--fp8_models 做 LoRA 训练时,循环能正常跑完,loss 也有正常数值,但 LoRA 权重从未更新。

在 Fibo-2.0-dev 上跑完 12800 step、每 500 step 存一次,得到 26 个检查点。这些文件字节完全相同。其中 364 个 lora_B 的最大绝对值是 0,lora_A 仍是 PEFT 的初始值。拿到 ComfyUI 里 strength 1.0 的出图和底模是同一张。

官方 examples/fibo/model_training/lora/Fibo-2.0-dev-T2I.sh 没有开 --fp8_models,所以示例路径碰不到。16GB 显卡放不下 BF16 的 Fibo DiT,才会走文档里的 FP8 存储。

原因

diffsynth/core/vram/layers.py 里 AutoWrappedModule.cast_to 在存储 dtype 和计算 dtype 不同时执行:

return copy.deepcopy(module).to(dtype=dtype, device=device)

FP8 训练的配置是 offload / onload / preparing 为 float8_e4m3fn,computation 为 bfloat16。每次 forward 都会整块 deepcopy。

Fibo 的显存映射把整个 FiboTransformerBlock 和 FiboPerceiverResampler 包成 AutoWrappedModule,递归在这里停止,里面的 nn.Linear 不会再包成 AutoWrappedLinear。PEFT 把 LoRA 注入到 .module 内部。反传更新的是这份复制品,原参数的 grad 是 None,优化器不会改它们。存盘读到的就是初始 LoRA。

--find_unused_parameters 会让这些没有梯度的参数不报错,所以训练看起来是成功的。

影响范围

不是所有模型。Qwen-Image、Z-Image、FLUX、FLUX.2、FLUX.3 的 DiT 按每个 Linear 包成 AutoWrappedLinear,LoRA 加在类型转换外面。官方 Qwen FP8 LoRA 示例走的是这条,不受影响。

会丢梯度的是「整块包装 + FP8」:

  • FiboTransformerBlock、FiboPerceiverResampler:AutoWrappedModule,整块 deepcopy。
  • Wan 主 DiT 的 DiTBlock:AutoWrappedNonRecurseModule。它会共享子模块里的 AutoTorchModule,但 PEFT 的 LoRA 层本身不是 AutoTorchModule,仍会被复制。下面的小例子把 grad 打成了 None。
  • Vace 的 DiTBlock、Wan S2V 的 block 也是整块 AutoWrappedModule。

docs/zh/Training/FP8_Precision.md 写的是:冻结权重可以用 FP8 存放,LoRA 的梯度仍然更新原参数。现在这条对整块包装的模型不成立。

最小复现

不必下载完整权重。存储用 float16、计算用 float32,走的是同一个 cast_to 分支。

import torch
import torch.nn as nn
from peft import LoraConfig, inject_adapter_in_model
from diffsynth.core.vram.layers import AutoWrappedModule

class Block(nn.Module):
    def __init__(self):
        super().__init__()
        self.to_q = nn.Linear(8, 8, bias=False)
    def forward(self, x):
        return self.to_q(x)

block = Block()
for p in block.parameters():
    p.requires_grad = False
block.to(dtype=torch.float16)
kw = dict(
    offload_dtype=torch.float16, offload_device="cpu",
    onload_dtype=torch.float16, onload_device="cpu",
    preparing_dtype=torch.float16, preparing_device="cpu",
    computation_dtype=torch.float32, computation_device="cpu",
)
wrapped = AutoWrappedModule(block, **kw, name="block")
wrapped = inject_adapter_in_model(
    LoraConfig(r=2, lora_alpha=2, target_modules=["to_q"]), wrapped
)
for p in wrapped.parameters():
    if p.requires_grad:
        p.data = p.data.float()
y = wrapped(torch.randn(2, 8))
y.float().pow(2).mean().backward()
for n, p in wrapped.named_parameters():
    if "lora_B" in n:
        print(n, None if p.grad is None else float(p.grad.abs().max()))

结果是 lora_B 仍为 0,grad 为 None。

Wan 主 DiT 的包装同样会丢。先把 to_q 包成 AutoWrappedLinear,再把 block 包成 AutoWrappedNonRecurseModule,然后同样注入 LoRA,lora_B.grad 还是 None。

修复方向

cast_to 保持现在的复制和 dtype 转换。复制完成之后,把副本里 requires_grad=True 的参数重新指回原模块上的 Parameter。冻结权重继续用转换后的计算精度副本。没有可训练参数时,行为和现在一样,推理不受影响。

AutoWrappedNonRecurseModule.cast_to 重写了这个函数,需要同样处理,否则 Wan 主 DiT 还是接不上梯度。

Activity

  1. added a commit that references this issue on Oct 22, 2026
    052a8f1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions