Skip to content

Commit 651bf8d

Browse files
authored
fix(peft): preserve GPT-OSS gate/up order in adapter export (#6244)
Signed-off-by: Chen Cui <chcui@nvidia.com>
1 parent 056b78e commit 651bf8d

2 files changed

Lines changed: 177 additions & 1 deletion

File tree

‎src/megatron/bridge/models/gpt_oss/gpt_oss_bridge.py‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import torch
1818
import torch.nn as nn
1919
from megatron.core.models.gpt.gpt_model import GPTModel
20+
from megatron.core.utils import unwrap_model
2021
from transformers import GptOssForCausalLM
2122

2223
from megatron.bridge.models.conversion.mapping_registry import MegatronMappingRegistry
@@ -26,6 +27,7 @@
2627
QKVMapping,
2728
_align_expert_weight_to_shape,
2829
)
30+
from megatron.bridge.models.conversion.peft_bridge import MegatronModel
2931
from megatron.bridge.models.conversion.quantization_utils import dequantize_mxfp4 as _dequantize_mxfp4
3032
from megatron.bridge.models.conversion.utils import get_module_and_param_from_name
3133
from megatron.bridge.models.gpt_provider import GPTModelProvider
@@ -53,6 +55,32 @@ class GPTOSSBridge(MegatronModelBridge):
5355
>>> provider = bridge.to_megatron_provider()
5456
"""
5557

58+
def _get_fused_adapter_linear_out_slices(
59+
self,
60+
megatron_model: list[MegatronModel],
61+
base_hf_weight_names: list[str],
62+
linear_out_tensor: torch.Tensor,
63+
is_expert: bool = False,
64+
) -> dict[str, torch.Tensor] | None:
65+
"""Put expert LoRA-B output rows in HF's alternating gate/up order."""
66+
if is_expert and len(base_hf_weight_names) == 1 and base_hf_weight_names[0].endswith(".experts.gate_up_proj"):
67+
# Adapter tasks use generic parallel mappings, bypassing the base
68+
# weight's GPT-OSS permutation. Reorder B's output rows, but keep
69+
# factors in [out, rank] form: only the base weight is transposed.
70+
# Materialization concatenates expert-TP shards, each of which has
71+
# its own [gate, up] halves. Permute within each shard before joining.
72+
config = unwrap_model(megatron_model)[0].config
73+
expert_tp_size = config.expert_tensor_parallel_size or 1
74+
shards = torch.chunk(linear_out_tensor, expert_tp_size, dim=0)
75+
return {
76+
base_hf_weight_names[0]: torch.cat(
77+
[GPTOSSMLPGateUpProjMapping._uninterleave(shard) for shard in shards], dim=0
78+
)
79+
}
80+
return super()._get_fused_adapter_linear_out_slices(
81+
megatron_model, base_hf_weight_names, linear_out_tensor, is_expert=is_expert
82+
)
83+
5684
def provider_bridge(self, hf_pretrained: PreTrainedCausalLM) -> GPTModelProvider:
5785
"""Convert HuggingFace config to GPTModelProvider."""
5886
provider = super().provider_bridge(hf_pretrained)
@@ -292,7 +320,8 @@ def group_key(self) -> str:
292320
def _interleave(gate_up_proj):
293321
return torch.cat((gate_up_proj[::2, ...], gate_up_proj[1::2, ...]), dim=0)
294322

295-
def _uninterleave(self, elem):
323+
@staticmethod
324+
def _uninterleave(elem: torch.Tensor) -> torch.Tensor:
296325
gate, up = torch.chunk(elem, 2, dim=0)
297326
output = torch.empty_like(elem)
298327
output[::2, ...] = gate
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
2+
3+
import copy
4+
from types import SimpleNamespace
5+
from unittest.mock import Mock
6+
7+
import pytest
8+
import torch
9+
from transformers import GptOssConfig
10+
from transformers.models.gpt_oss.modeling_gpt_oss import GptOssExperts
11+
12+
from megatron.bridge.models.conversion.model_bridge import MegatronWeightTuple, WeightConversionTask
13+
from megatron.bridge.models.conversion.peft_bridge import AdapterWeight, AdapterWeightConversionTask
14+
from megatron.bridge.models.gpt_oss.gpt_oss_bridge import GPTOSSBridge
15+
16+
17+
@pytest.mark.unit
18+
@pytest.mark.parametrize(
19+
"num_experts,rank,hidden_size,ffn_size,expert_tp_size",
20+
[(2, 3, 4, 3, 1), (3, 2, 5, 5, 1), (5, 4, 3, 2, 1), (3, 4, 6, 4, 2), (2, 3, 6, 6, 3)],
21+
)
22+
@pytest.mark.parametrize("with_megatron_names", [False, True])
23+
def test_gpt_oss_expert_adapter_export_preserves_forward(
24+
monkeypatch, num_experts, rank, hidden_size, ffn_size, expert_tp_size, with_megatron_names
25+
):
26+
"""Export native FC1/FC2 factors, then compare real HF experts with native LoRA math."""
27+
generator = torch.Generator().manual_seed(42)
28+
29+
def random_tensor(*shape):
30+
return torch.randn(*shape, generator=generator) * 0.2
31+
32+
bridge = GPTOSSBridge()
33+
tasks = {}
34+
weights = {}
35+
factors = {}
36+
scale = 2
37+
for layer, in_features, out_features in [
38+
("linear_fc1", hidden_size, 2 * ffn_size),
39+
("linear_fc2", ffn_size, hidden_size),
40+
]:
41+
prefix = f"decoder.layers.0.mlp.experts.{layer}"
42+
in_name = f"{prefix}.adapter.linear_in.weight"
43+
out_name = f"{prefix}.adapter.linear_out.weight"
44+
# Noncontiguous factors also occur when expert tensors are sliced/views.
45+
lora_a = random_tensor(num_experts, in_features, rank).transpose(1, 2)
46+
lora_b = random_tensor(num_experts, rank, out_features).transpose(1, 2)
47+
factors[layer] = (lora_a, lora_b)
48+
if layer == "linear_fc1":
49+
# Transport gathers each shard's [gate, up] rows in rank order.
50+
gate, up = lora_b.chunk(2, dim=1)
51+
materialized_b = torch.cat(
52+
[
53+
torch.cat([gate_shard, up_shard], dim=1)
54+
for gate_shard, up_shard in zip(gate.chunk(expert_tp_size, dim=1), up.chunk(expert_tp_size, dim=1))
55+
],
56+
dim=1,
57+
)
58+
else:
59+
materialized_b = lora_b
60+
tasks[prefix] = [
61+
AdapterWeightConversionTask(
62+
global_base_prefix=prefix,
63+
adapter_key=None,
64+
alpha=scale * rank,
65+
dim=rank,
66+
linear_in_task=WeightConversionTask(param_name=in_name, global_param_name=in_name, mapping=Mock()),
67+
linear_out_task=WeightConversionTask(param_name=out_name, global_param_name=out_name, mapping=Mock()),
68+
)
69+
]
70+
weights[prefix] = AdapterWeight(
71+
global_base_prefix=prefix,
72+
adapter_key=None,
73+
alpha=scale * rank,
74+
dim=rank,
75+
linear_in_weight=MegatronWeightTuple(in_name, lora_a, vp_stage=0),
76+
linear_out_weight=MegatronWeightTuple(out_name, materialized_b, vp_stage=0),
77+
)
78+
79+
# Replace transport/materialization only: use the real registry, export stream,
80+
# projection dispatch, expert stacking, names, and optional source metadata.
81+
monkeypatch.setattr(bridge, "build_adapter_conversion_tasks", lambda *args, **kwargs: tasks)
82+
monkeypatch.setattr(
83+
bridge, "materialize_adapter_weights", lambda batch: [weights[t.global_base_prefix] for t in batch]
84+
)
85+
monkeypatch.setattr(
86+
"megatron.bridge.models.conversion.peft_bridge.parallel_state.get_expert_model_parallel_world_size", lambda: 1
87+
)
88+
exported = list(
89+
bridge.stream_adapter_weights_megatron_to_hf(
90+
[
91+
SimpleNamespace(
92+
config=SimpleNamespace(num_moe_experts=num_experts, expert_tensor_parallel_size=expert_tp_size)
93+
)
94+
],
95+
show_progress=False,
96+
with_megatron_names=with_megatron_names,
97+
)
98+
)
99+
tensors = {weight.param_name: weight.weight for weight in exported}
100+
assert len(tensors) == 4
101+
hf_prefix = "model.layers.0.mlp.experts"
102+
for layer, projection in [("linear_fc1", "gate_up_proj"), ("linear_fc2", "down_proj")]:
103+
torch.testing.assert_close(tensors[f"{hf_prefix}.{projection}.lora_A.weight"], factors[layer][0])
104+
if projection == "down_proj":
105+
torch.testing.assert_close(tensors[f"{hf_prefix}.{projection}.lora_B.weight"], factors[layer][1])
106+
if with_megatron_names:
107+
for side, source in [("A", "linear_in"), ("B", "linear_out")]:
108+
weight = next(w for w in exported if w.param_name == f"{hf_prefix}.{projection}.lora_{side}.weight")
109+
assert weight.megatron_param_names == (
110+
f"decoder.layers.0.mlp.experts.{layer}.adapter.{source}.weight",
111+
)
112+
113+
config = GptOssConfig(hidden_size=hidden_size, intermediate_size=ffn_size, num_local_experts=num_experts)
114+
config._experts_implementation = "eager"
115+
original = GptOssExperts(config).eval()
116+
with torch.no_grad():
117+
for parameter in original.parameters():
118+
parameter.copy_(random_tensor(*parameter.shape))
119+
adapted = copy.deepcopy(original)
120+
with torch.no_grad():
121+
for projection in ["gate_up_proj", "down_proj"]:
122+
lora_a = tensors[f"{hf_prefix}.{projection}.lora_A.weight"]
123+
lora_b = tensors[f"{hf_prefix}.{projection}.lora_B.weight"]
124+
# HF GPT-OSS stores [E, in, out]; exported factors stay [E, r, in]/[E, out, r].
125+
getattr(adapted, projection).add_(scale * torch.bmm(lora_b, lora_a).transpose(1, 2))
126+
127+
x = random_tensor(2 * num_experts, hidden_size)
128+
router_indices = torch.arange(2 * num_experts).remainder(num_experts).unsqueeze(1)
129+
routing_weights = torch.ones(2 * num_experts, 1)
130+
expected = torch.empty_like(x)
131+
fc1_a, fc1_b = factors["linear_fc1"]
132+
fc2_a, fc2_b = factors["linear_fc2"]
133+
for token, expert in enumerate(router_indices[:, 0]):
134+
# Independent oracle: Megatron FC1 emits [all gates, all ups]. Never
135+
# construct expected tensors using the exporter's row permutation.
136+
gate_delta, up_delta = (scale * (x[token] @ fc1_a[expert].T) @ fc1_b[expert].T).chunk(2)
137+
gate = x[token] @ original.gate_up_proj[expert, :, ::2] + original.gate_up_proj_bias[expert, ::2]
138+
up = x[token] @ original.gate_up_proj[expert, :, 1::2] + original.gate_up_proj_bias[expert, 1::2]
139+
gate = (gate + gate_delta).clamp(max=original.limit)
140+
up = (up + up_delta).clamp(min=-original.limit, max=original.limit)
141+
activated = (up + 1) * gate * torch.sigmoid(original.alpha * gate)
142+
expected[token] = (
143+
activated @ original.down_proj[expert]
144+
+ original.down_proj_bias[expert]
145+
+ scale * (activated @ fc2_a[expert].T) @ fc2_b[expert].T
146+
)
147+
torch.testing.assert_close(adapted(x, router_indices, routing_weights), expected, atol=1e-6, rtol=1e-5)

0 commit comments

Comments
 (0)