【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_head与embed_tokens是权重共享(tied)的,而 FSDP2 的切分逻辑在处理"同一个nn.Parameter被两个模块引用"时,和 GPT-OSS 这种"先 meta 初始化、再按 shard 落盘"的加载流程发生了冲突。下面把根因讲清楚。
二、背景
GPT-OSS 是一个 decoder-only 的 MoE 模型,结构上有两个关键特征,恰恰都和 FSDP2 的加载假设打架:
- embedding 与 lm_head 权重共享。即
lm_head.weight is embed_tokens.weight。这是标准做法,可以少一份输出层参数。 - 大模型普遍采用 meta 设备初始化 + 分片落盘加载。也就是先在
device="meta"上把模型结构搭起来(不占显存),再用load_checkpoint_and_dispatch把 safetensors 里对应 shard 的张量逐块 materialize 到目标设备。
FSDP2(torch.distributed.fsdp的fully_shard)的工作方式是:遍历module.parameters(),对每个参数调用Mesh上的分片。问题在于——当lm_head.weight与embed_tokens.weight是同一个 Python 对象时:
module.parameters()在nn.Module层面只会枚举一次(因为lm_head持有的是embed_tokens.weight这个引用,不是独立参数),这本该是好事;- 但
load_checkpoint_and_dispatch(accelerate 的实现)在按"模块名路径"加载时,会同时尝试为embed_tokens.weight和lm_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 几份"的认知不一致。
具体有三处失配:
- 枚举视角不一致:
module.parameters()只看对象引用,tie 只算一份;加载器按 state_dict key 枚举,tie 算两份。 - materialize 幂等性缺失:同一个 meta 张量被
materialize()两次,第二次要么报错,要么产生两份物理张量,导致后续fully_shard在 hook 里拿到错误的张量。 - 切分策略视角不一致:
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.weight和embed_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沿错误维度切分。第二层做三件事:
- 建立全局
TieRegistry,作为模型加载与切分的唯一事实来源。 - 在
fully_shard之前,把 tie 别名从"待切分参数集合"里剔除,避免重复切分。 - 显式声明切分维度: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 加载路径上遇到类似问题时,按以下顺序排查:
- 先确认是不是 tie 问题:打印
id(model.embed_tokens) == id(model.lm_head.weight),相等即为 tie。 - 看报错发生在哪一步:是 materialization 阶段(meta→真实设备)还是
fully_shard阶段?前者是加载器把 tie 当两份,后者是切分把 tie 当两份。 - 检查加载日志:如果出现
already materialized,说明加载器对同一个 meta 张量调了两次materialize,按第五节加 tie 归一化。 - 检查
named_parameters与 state_dict key 的差异:nn.Module只枚举一份 tie,state_dict/checkpoint 里却常有两个 key,二者必须做 canonic 映射。 - 检查切分维度:tie 的
lm_head/embed_tokens只能在 hidden 维切,不能按 vocab 维切;若报形状错位,优先怀疑切分维度配置。 - 逐层缩小:先用单卡、非 meta、直接
.cuda()验证模型本身能 forward,再逐步打开cpu_ram_efficient_loading、meta init、fully_shard,定位是哪一层引入冲突。 - 断言守护:在模型构建函数末尾加一句
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 初始化 + 分片加载"的组合就能稳定落到多卡上。