Skip to content

Commit a479991

Browse files
casper-hansenerictang000claude
authored
[megatron] Convert fused-MoE expert LoRA to vLLM layout on adapter sync (#1857)
Closes #1852 @erictang000 Megatron-Bridge exports fused-MoE expert LoRA as 3D (num_experts-leading) tensors keyed `...mlp.experts.gate_up_proj` / `...mlp.experts.down_proj`, but vLLM's fused-MoE LoRA loader (FusedMoE3DWithLoRA / _stack_moe_lora_weights) expects the flat PEFT layout keyed `...mlp.experts.base_layer` (w13) and `...mlp.experts` (w2). Without the rewrite, merge_lora=False on-policy sync is rejected because load_lora_adapter cannot resolve `experts.down_proj`. Add `_convert_moe_experts_lora_to_vllm` to megatron_utils and apply it in MegatronPolicyWorkerBase._save_lora_adapters_and_sync before inferring target modules (dropping the synthetic `base_layer` module). --------- Co-authored-by: Eric Tang <erictang000@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bd8b9ea commit a479991

3 files changed

Lines changed: 50 additions & 12 deletions

File tree

‎skyrl/backends/skyrl_train/distributed/megatron/megatron_utils.py‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
# limitations under the License.
2222

2323
import gc
24-
from typing import Any, List, Optional, Union
24+
from typing import Any, Dict, List, Optional, Union
2525

2626
import torch
2727
import torch.nn as nn
@@ -162,6 +162,38 @@ def freeze_moe_router(model_or_models: Union[nn.Module, List[nn.Module]]):
162162
return model_or_models
163163

164164

165+
def _convert_moe_experts_lora_to_vllm(
166+
adapter_state: Dict[str, "torch.Tensor"],
167+
) -> Dict[str, "torch.Tensor"]:
168+
"""Rewrite fused-MoE expert LoRA tensors into the layout vLLM expects.
169+
170+
Megatron-Bridge exports fused experts as 3D tensors keyed
171+
``...mlp.experts.gate_up_proj`` (w13) / ``...mlp.experts.down_proj`` (w2),
172+
with ``lora_A=(E, rank, in)`` and ``lora_B=(E, out, rank)``. vLLM's 3D-MoE
173+
loader (``FusedMoE3DWithLoRA`` / ``_stack_moe_lora_weights``) instead expects
174+
the flat PEFT layout keyed ``...experts.base_layer`` (w13) / ``...experts``
175+
(w2), with ``lora_A=(rank*E, in)`` and ``lora_B=(out, rank*E)``. This is the
176+
exact inverse of vLLM's per-expert reshape. Non-expert tensors pass through.
177+
"""
178+
converted: Dict[str, "torch.Tensor"] = {}
179+
for key, tensor in adapter_state.items():
180+
is_gate_up = ".mlp.experts.gate_up_proj." in key
181+
is_down = ".mlp.experts.down_proj." in key
182+
if (is_gate_up or is_down) and tensor.ndim == 3:
183+
if key.endswith(".lora_A.weight"):
184+
# (E, rank, in) -> (rank*E [expert-major], in)
185+
tensor = tensor.reshape(-1, tensor.shape[-1]).contiguous()
186+
elif key.endswith(".lora_B.weight"):
187+
# (E, out, rank) -> (out, rank*E [expert-minor])
188+
tensor = tensor.permute(1, 2, 0).contiguous().reshape(tensor.shape[1], -1)
189+
if is_gate_up:
190+
key = key.replace(".mlp.experts.gate_up_proj.", ".mlp.experts.base_layer.")
191+
else:
192+
key = key.replace(".mlp.experts.down_proj.", ".mlp.experts.")
193+
converted[key] = tensor
194+
return converted
195+
196+
165197
@torch.no_grad()
166198
def offload_megatron_grads_to_cpu(models):
167199
for model_chunk in models:

‎skyrl/backends/skyrl_train/workers/megatron/megatron_worker.py‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
MegatronStrategy,
2525
)
2626
from skyrl.backends.skyrl_train.distributed.megatron.megatron_utils import (
27+
_convert_moe_experts_lora_to_vllm,
2728
broadcast_object_across_pp_ranks,
2829
freeze_moe_router,
2930
get_model_config,
@@ -1033,12 +1034,10 @@ def forward_backward(
10331034
}
10341035
)
10351036

1036-
# Count microbatches that carry real (non-padding) samples. Token-based batching
1037-
# appends fully-padding microbatches so every DP rank runs the same number of
1038-
# forward passes; those contribute 0 to KL/entropy and to mean metrics but would
1039-
# otherwise inflate the denominators. A real microbatch can still have an all-zero
1040-
# loss_mask (for example, DAPO overlong filtering), so use the iterator's padding
1041-
# count rather than inferring from loss_mask.
1037+
# Count real (non-padding) microbatches. Token-based batching appends padding
1038+
# microbatches so every DP rank runs the same number of forward passes; they must
1039+
# not inflate the KL/entropy denominators. Use the iterator's padding count rather
1040+
# than loss_mask, since a real microbatch can be all-zero (e.g. DAPO overlong filtering).
10421041
num_padding_microbatches = (
10431042
getattr(microbatch_iterator, "num_padding_microbatches", 0) if microbatch_iterator is not None else 0
10441043
)
@@ -1242,7 +1241,15 @@ async def _save_lora_adapters_and_sync(
12421241
if torch.distributed.get_rank() == 0:
12431242
os.makedirs(lora_sync_path, exist_ok=True)
12441243

1245-
target_modules = infer_target_modules_from_adapter_weights(adapter_state.keys())
1244+
# Rewrite fused-MoE expert LoRA into vLLM's flat PEFT layout so
1245+
# merge_lora=False on-policy sync is accepted (otherwise
1246+
# load_lora_adapter rejects `experts.down_proj`). See
1247+
# _convert_moe_experts_lora_to_vllm for the layout details.
1248+
adapter_state = _convert_moe_experts_lora_to_vllm(adapter_state)
1249+
1250+
target_modules = sorted(
1251+
set(infer_target_modules_from_adapter_weights(adapter_state.keys())) - {"base_layer"}
1252+
)
12461253
base_model_name_or_path = str(
12471254
getattr(self.bridge.hf_pretrained, "model_name_or_path", "")
12481255
or getattr(self.bridge.hf_pretrained, "name_or_path", "")

‎tests/train/generators/test_skyrl_gym_generator.py‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -363,10 +363,9 @@ async def test_generate_batched(mock_make, mock_tokenizer, mock_llm, mock_env, g
363363
async def test_generate_batched_metrics_use_truncated_responses(
364364
mock_make, mock_tokenizer, mock_llm, mock_env, generator_cfg, mock_env_cfg
365365
):
366-
"""Rollout metrics must describe the truncated responses that are actually
367-
returned/trained, not the raw engine output. With max_generate_length below
368-
the engine's output length, generate/max_num_tokens must equal the truncated
369-
length, matching response_ids and loss_masks."""
366+
"""Rollout metrics must describe the truncated responses that are trained, not the
367+
raw engine output: with max_generate_length below the engine output, generate/
368+
max_num_tokens must equal the truncated length, matching response_ids/loss_masks."""
370369
generator_cfg.sampling_params.max_generate_length = 2 # < len(MOCK_LLM_OUTPUT_IDS) == 4
371370
mock_make.return_value = mock_env
372371
mock_env.init.return_value = ([{"role": "user", "content": "Initial input"}], {})

0 commit comments

Comments
 (0)