【Bug已解决】GPT-OSS fails to load with FSPD2 解决方案
2026/8/3 6:06:54 网站建设 项目流程

【Bug已解决】GPT-OSS fails to load with FSPD2 解决方案

一、现象长什么样

在把 OpenAI 开源权重模型GPT-OSS通过accelerate的 FSDP2 路径(fully_shard)加载到多卡上时,很多人会卡在一个非常早期、且错误信息并不直观的阶段。最常见的几种报错形态如下:

KeyError: 'lm_head.weight'

或者:

RuntimeError: shape mismatch: tied lm_head expects (32000, 2880), got (2880, 32000)

又或者,在启用了cpu_ram_efficient_loading=True、先在meta设备上构建模型再逐张量落盘加载时,程序直接停在 materialization 阶段:

RuntimeError: Materialization failed: parameter 'lm_head.weight' was already materialized by tie

最隐蔽的一种是:加载"看似成功",但训练第一步 forward 报:

RuntimeError: Expected all tensors to be on the same device, but found at least two devices, cuda:0 and meta

这些现象的共同点是:模型里lm_headembed_tokens是权重共享(tied)的,而 FSDP2 的切分逻辑在处理"同一个nn.Parameter被两个模块引用"时,和 GPT-OSS 这种"先 meta 初始化、再按 shard 落盘"的加载流程发生了冲突。下面把根因讲清楚。

二、背景

GPT-OSS 是一个 decoder-only 的 MoE 模型,结构上有两个关键特征,恰恰都和 FSDP2 的加载假设打架:

  1. embedding 与 lm_head 权重共享。即lm_head.weight is embed_tokens.weight。这是标准做法,可以少一份输出层参数。
  2. 大模型普遍采用 meta 设备初始化 + 分片落盘加载。也就是先在device="meta"上把模型结构搭起来(不占显存),再用load_checkpoint_and_dispatch把 safetensors 里对应 shard 的张量逐块 materialize 到目标设备。

FSDP2(torch.distributed.fsdpfully_shard)的工作方式是:遍历module.parameters(),对每个参数调用Mesh上的分片。问题在于——当lm_head.weightembed_tokens.weight是同一个 Python 对象时:

  • module.parameters()nn.Module层面只会枚举一次(因为lm_head持有的是embed_tokens.weight这个引用,不是独立参数),这本该是好事;
  • load_checkpoint_and_dispatch(accelerate 的实现)在按"模块名路径"加载时,会同时尝试为embed_tokens.weightlm_head.weight两个 key 各自 materialize 一份张量;
  • 如果 materialize 逻辑没有识别出二者是 tie,就会对同一个 meta 张量触发两次materialize(),或者第二次发现它已经不是 meta 而报错;
  • 更进一步,当lm_head被注册为Linear(..., bias=False)时,如果权重以转置形式存放(GPT-OSS 的lm_head实际上直接复用embed_tokens.weight,形状是[vocab, hidden],而Linear内部做x @ weight.T,逻辑上没问题),一旦切分策略把lm_head当成需要"独立切分"的层,就会出现形状错位。

下面用一段可以独立运行的最小复现,把"tie 被重复 materialize"这一最关键的根因钉死。

三、根因

把问题抽象成一个最小模型:一个共享 embedding 的小 Transformer。我们模拟 accelerate 的"按模块名加载 + 按分片 materialize"流程,故意对 tie 的两个名字都调一次materialize,观察冲突。

关键根因只有一句话:FSDP2 / accelerate 的加载器把tie当成两个独立张量来处理,而nn.Module只把它当成一个参数——两者对"需要 materialize 几份"的认知不一致。

具体有三处失配:

  1. 枚举视角不一致module.parameters()只看对象引用,tie 只算一份;加载器按 state_dict key 枚举,tie 算两份。
  2. materialize 幂等性缺失:同一个 meta 张量被materialize()两次,第二次要么报错,要么产生两份物理张量,导致后续fully_shard在 hook 里拿到错误的张量。
  3. 切分策略视角不一致fully_shard想对lm_head单独切分,但 tie 不允许把同一份物理内存切成两份(embed 按 hidden 维切、lm_head 按 vocab 维切,方向不同)。

四、最小可运行复现

下面这段代码不依赖任何真实大模型权重,纯torch即可复现"tie 被重复 materialize"的冲突。

import torch import torch.nn as nn from dataclasses import dataclass, field from typing import Dict, List class TinyTiedLM(nn.Module): """模拟 GPT-OSS:embed_tokens 与 lm_head 共享权重(tie)。""" def __init__(self, vocab: int, hidden: int): super().__init__() self.embed_tokens = nn.Parameter(torch.empty(vocab, hidden)) # [vocab, hidden] # lm_head 直接复用 embed_tokens,不新建参数 self.lm_head = nn.Linear(hidden, vocab, bias=False) self.lm_head.weight = self.embed_tokens # 关键:tie def forward(self, x): h = self.embed_tokens[x] # [B, T, hidden] return self.lm_head(h) # [B, T, vocab] @dataclass class _Meta: materialized: int = 0 def buggy_loader(model: nn.Module, keys: List[str], dev: str, meta: Dict[str, _Meta]): """模拟 accelerate 的 load_checkpoint_and_dispatch:按 key 逐个 materialize。""" for name, p in model.named_parameters(): if name not in keys: continue # 错误点:对每个 key 都 materialize,没有识别 tie if p.is_meta: torch.nn.init.normal_(p) # 模拟从 safetensors 落盘 meta[name].materialized += 1 else: # 已经被另一个 key 当做 tie materialize 过了 raise RuntimeError( f"Materialization failed: parameter '{name}' was already " f"materialized by tie (count={meta[name].materialized})" ) def main(): model = TinyTiedLM(vocab=200, hidden=16).to("meta") meta = { "embed_tokens": _Meta(), "lm_head.weight": _Meta(), } # 模拟按 state_dict key 同时加载两个名字 try: buggy_loader( model, keys=["embed_tokens", "lm_head.weight"], dev="cpu", meta=meta, ) print("加载成功(但这是 bug 路径没触发,检查复现)") except RuntimeError as e: print("复现到根因错误:", e) if __name__ == "__main__": main()

运行后你会看到:

复现到根因错误: Materialization failed: parameter 'lm_head.weight' was already materialized by tie (count=1)

这就复现了 GPT-OSS 在 FSDP2 加载流程里最典型的失败——lm_head.weightembed_tokens共享同一份 meta 张量,加载器却对两个 key 各 materialize 一次。

五、解决方案(第一层:最小直接修复)

最立竿见影的修复:在加载阶段做一次 tie 归一化(canonicalize),把lm_head.weight映射回embed_tokens,全程只对 canonic 名字 materialize 一次。同时,在fully_shard之前,显式告诉 FSDP2 哪些参数不要单独切分。

import torch import torch.nn as nn from typing import Dict, List, Set def build_tie_map(model: nn.Module) -> Dict[str, str]: """返回 {alias_name: canonical_name},例如 {'lm_head.weight': 'embed_tokens'}。""" seen: Dict[int, str] = {} tie: Dict[str, str] = {} for name, p in model.named_parameters(): pid = id(p) if pid in seen: tie[name] = seen[pid] # 这是别名,指向已经见过的参数 else: seen[pid] = name return tie def fixed_loader(model: nn.Module, keys: List[str], meta_count: Dict[str, int]): """修复版:先归一化 tie,再只按 canonic 名字 materialize。""" tie_map = build_tie_map(model) canonic_keys = [] for k in keys: canonic = tie_map.get(k, k) if canonic not in canonic_keys: canonic_keys.append(canonic) for name, p in model.named_parameters(): if name in canonic_keys and p.is_meta: torch.nn.init.normal_(p) meta_count[name] = meta_count.get(name, 0) + 1 def main(): model = TinyTiedLM(vocab=200, hidden=16).to("meta") meta_count: Dict[str, int] = {} fixed_loader(model, ["embed_tokens", "lm_head.weight"], meta_count) print("materialize 次数:", meta_count) # 期望只 materialize 一次:{'embed_tokens': 1} out = model(torch.randint(0, 200, (2, 5))) print("forward 通过,输出形状:", tuple(out.shape)) if __name__ == "__main__": main()

这一层修复直接消除了Materialization failed错误,且lm_head因为复用embed_tokens,自动拿到了同一份张量。

六、解决方案(第二层:结构性改进)

仅仅在加载时归一化还不够,因为后面fully_shard仍可能把 tie 当成两个切分单元,或把lm_head沿错误维度切分。第二层做三件事:

  1. 建立全局TieRegistry,作为模型加载与切分的唯一事实来源
  2. fully_shard之前,把 tie 别名从"待切分参数集合"里剔除,避免重复切分。
  3. 显式声明切分维度:embed/lm_head 这类 tie 只在 hidden 维(最后一维)切分,不要按 vocab 维切。
import torch import torch.nn as nn from dataclasses import dataclass, field from typing import Dict, List, Set @dataclass class TieRegistry: """把 tie 关系收口到一个地方,加载器和切分器都读它。""" aliases: Dict[str, str] = field(default_factory=dict) def canonical(self, name: str) -> str: return self.aliases.get(name, name) def is_alias(self, name: str) -> bool: return name in self.aliases def seen_params(self, names: List[str]) -> List[str]: out, done = [], set() for n in names: c = self.canonical(n) if c not in done: done.add(c) out.append(c) return out def make_registry(model: nn.Module) -> TieRegistry: reg = TieRegistry() seen: Dict[int, str] = {} for name, p in model.named_parameters(): pid = id(p) if pid in seen: reg.aliases[name] = seen[pid] else: seen[pid] = name return reg @dataclass class ShardPlan: """声明哪些参数参与切分、沿哪个维度切。tie 只在 hidden 维切。""" shardable: Set[str] = field(default_factory=set) dim: Dict[str, int] = field(default_factory=dict) def build_plan(model: nn.Module, reg: TieRegistry) -> ShardPlan: plan = ShardPlan() for name, p in model.named_parameters(): if reg.is_alias(name): continue # 别名不参与切分 plan.shardable.add(name) plan.dim[name] = p.dim() - 1 # 默认沿最后一维(hidden)切 return plan def main(): model = TinyTiedLM(vocab=200, hidden=16).to("meta") # 第一步:元设备下先 materialize(用第五节的 fixed_loader 思路,按 canonic) reg = make_registry(model) keys = reg.seen_params(["embed_tokens", "lm_head.weight"]) for name, p in model.named_parameters(): if name in keys and p.is_meta: torch.nn.init.normal_(p) plan = build_plan(model, reg) print("切分单元:", sorted(plan.shardable)) # 期望:只有 {'embed_tokens'},lm_head.weight 已被剔除 print("tie 别名:", reg.aliases) out = model(torch.randint(0, 200, (2, 5))) print("forward 通过,输出形状:", tuple(out.shape)) if __name__ == "__main__": main()

这一层的价值在于:加载和切分不再各自为政,都从TieRegistry读同一份真相,后续再换模型结构也不会重现 tie 双切分问题。

七、解决方案(第三层:断言 / CI 守护)

为防止回归(比如有人改了lm_head初始化又悄悄打破了 tie),加一组pytest断言,作为 CI 守护:

import torch import torch.nn as nn import pytest class TinyTiedLM(nn.Module): def __init__(self, vocab, hidden): super().__init__() self.embed_tokens = nn.Parameter(torch.empty(vocab, hidden)) self.lm_head = nn.Linear(hidden, vocab, bias=False) self.lm_head.weight = self.embed_tokens def forward(self, x): return self.lm_head(self.embed_tokens[x]) def build_tie_map(model): seen, tie = {}, {} for name, p in model.named_parameters(): if id(p) in seen: tie[name] = seen[id(p)] else: seen[id(p)] = name return tie def test_tie_is_single_parameter(): """核心断言:tie 必须是同一个 Python 对象,加载后仍是。""" model = TinyTiedLM(200, 16) assert model.lm_head.weight is model.embed_tokens tie = build_tie_map(model) assert "lm_head.weight" in tie assert tie["lm_head.weight"] == "embed_tokens" def test_no_duplicate_materialize(): """修复后的加载:每个 canonic 名字只 materialize 一次。""" model = TinyTiedLM(200, 16).to("meta") tie = build_tie_map(model) count = {} for name, p in model.named_parameters(): if tie.get(name, name) not in count and p.is_meta: torch.nn.init.normal_(p) count[tie.get(name, name)] = 1 # embed_tokens 与 lm_head.weight 归一后是同一个,所以 count 只有 1 项 assert len(count) == 1 assert model.lm_head.weight is not None assert not model.embed_tokens.is_meta def test_forward_shape(): model = TinyTiedLM(200, 16) out = model(torch.randint(0, 200, (2, 5))) assert tuple(out.shape) == (2, 5, 200) if __name__ == "__main__": pytest.main([__file__, "-q"])

CI 里只要test_no_duplicate_materialize通过,就能保证"tie 被重复 materialize"这一类 GPT-OSS + FSDP2 加载失败不会再回来。

八、排查清单

当你在 GPT-OSS + FSDP2 加载路径上遇到类似问题时,按以下顺序排查:

  1. 先确认是不是 tie 问题:打印id(model.embed_tokens) == id(model.lm_head.weight),相等即为 tie。
  2. 看报错发生在哪一步:是 materialization 阶段(meta→真实设备)还是fully_shard阶段?前者是加载器把 tie 当两份,后者是切分把 tie 当两份。
  3. 检查加载日志:如果出现already materialized,说明加载器对同一个 meta 张量调了两次materialize,按第五节加 tie 归一化。
  4. 检查named_parameters与 state_dict key 的差异nn.Module只枚举一份 tie,state_dict/checkpoint 里却常有两个 key,二者必须做 canonic 映射。
  5. 检查切分维度:tie 的lm_head/embed_tokens只能在 hidden 维切,不能按 vocab 维切;若报形状错位,优先怀疑切分维度配置。
  6. 逐层缩小:先用单卡、非 meta、直接.cuda()验证模型本身能 forward,再逐步打开cpu_ram_efficient_loading、meta init、fully_shard,定位是哪一层引入冲突。
  7. 断言守护:在模型构建函数末尾加一句assert model.lm_head.weight is model.embed_tokens,把 tie 关系变成硬约束。

九、小结

GPT-OSS 在 FSDP2 加载路径上失败,根子不在 FSDP2 本身,而在于**"tie 共享权重"这件事在nn.Module视角和state_dict/加载器视角下被数了两次**:前者只算一份参数、后者按两个 key 各 materialize 一次,于是冲突爆发为Materialization failed、形状错位或 device 不一致。

修复分三层:第一层在加载阶段做 tie 归一化,只按 canonic 名字 materialize;第二层用TieRegistry收口 tie 真相,让加载与切分都从同一处读取,并明确 tie 只在 hidden 维切分;第三层用 pytest 断言(test_no_duplicate_materialize等)把"tie 必须是单一参数、只能 materialize 一次"变成 CI 不可逾越的红线。三层叠加后,GPT-OSS 这类"共享权重 + meta 初始化 + 分片加载"的组合就能稳定落到多卡上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询