Skip to content

[Bug] Megatron MoE LoRA with merge_lora=False fails the weight sync on Qwen3.6-35B MoE #1852

Description

@casper-hansen

Training a fused-MoE model (Qwen/Qwen3.6-35B-A3B, arch qwen3_5_moe) with the Megatron backend + LoRA + merge_lora=False fails on the first weight sync to vLLM (step 0). megatron-bridge exports the MoE expert LoRA as per-layer 3D tensors keyed ...mlp.experts.down_proj / ...mlp.experts.gate_up_proj, but vLLM 0.23.0's fused-MoE LoRA loader expects the flat PEFT layout under the single fused experts target module, so add_lora rejects the adapter.

merge_lora=True (the default, full merged-weight sync) avoids the LoRA-serving path and is unaffected — so this only blocks the un-merged / adapter-served mode, which is exactly what on-policy RL with a live LoRA (and multi-adapter serving) needs. This blocks on-policy RL for any 3D-fused-MoE arch (Qwen3.5/3.6-MoE today, and Kimi-K2.x-MoE, the motivation here).

Environment

  • SkyRL 0a18dd72783a2a1400c8d36929fa8742cea9395d (main); vLLM 0.23.0, torch 2.11.0+cu128, transformer-engine 2.11.0, megatron-core 0.19.0, megatron-bridge 0.6.0
  • Model Qwen/Qwen3.6-35B-A3B (qwen3_5_moe: MoE text tower + GatedDeltaNet)
  • 1 node × 8× H200 (sm_90); megatron, colocated, TP=4 / EP=8 / ETP=1; LoRA rank 32; _SKYRL_USE_NEW_INFERENCE=1 (vLLM started with --enable-lora); FLA_TILELANG=1 (Hopper)

Reproduction

Derived from examples/train/megatron/run_megatron_dapo_qwen3.6_35b_a3b_lora.sh, scaled to one 8-GPU node with tiny batches. The only behavioral change vs. the upstream example is ...lora_config.merge_lora=False (the example defaults to True).

bash examples/train/algorithms/dapo/prepare_dapo_data.sh

export _SKYRL_USE_NEW_INFERENCE=1   # start vLLM with --enable-lora for merge_lora=False
export FLA_TILELANG=1               # Hopper: TileLang GDN backward (fla #640)

uv run --no-sync --extra megatron -m examples.train.algorithms.dapo.main_dapo \
  data.train_data="['$HOME/data/dapo/dapo-math-17k-cleaned.parquet']" \
  data.val_data="['$HOME/data/dapo/aime-2024-cleaned.parquet']" \
  trainer.policy.model.path=Qwen/Qwen3.6-35B-A3B \
  trainer.strategy=megatron trainer.placement.colocate_all=true \
  trainer.placement.policy_num_nodes=1 trainer.placement.policy_num_gpus_per_node=8 \
  generator.inference_engine.num_engines=1 generator.inference_engine.tensor_parallel_size=8 \
  trainer.policy.megatron_config.tensor_model_parallel_size=4 \
  trainer.policy.megatron_config.expert_model_parallel_size=8 \
  trainer.policy.megatron_config.expert_tensor_parallel_size=1 \
  trainer.policy.language_model_only=True generator.inference_engine.language_model_only=True \
  trainer.policy.model.lora.rank=32 trainer.policy.model.lora.alpha=32 \
  trainer.policy.megatron_config.lora_config.merge_lora=False \
  trainer.train_batch_size=8 trainer.policy_mini_batch_size=8 generator.n_samples_per_prompt=4 \
  trainer.max_prompt_length=1024 generator.sampling_params.max_generate_length=1024 \
  trainer.epochs=1 trainer.eval_before_train=false trainer.logger=console \
  generator.inference_engine.backend=vllm generator.inference_engine.gpu_memory_utilization=0.7 \
  environment.env_class=aime

It loads, generates, trains step 0, then dies in sync_weights (~22 s).

Stack trace (fails at step 0, sync_weights)

File ".../skyrl/train/trainer.py", line 260, in train
    await self.dispatch.save_weights_for_sampler()
File ".../skyrl/backends/skyrl_train/workers/worker_dispatch.py", line 583, in save_weights_for_sampler
    self._broadcast_to_inference_engines(self._inference_engine_client, model_id=model_id)
File ".../skyrl/backends/skyrl_train/workers/worker_dispatch.py", line 535, in _broadcast_to_inference_engines
    ray.get(...)
ray.exceptions.RayTaskError: ray::MegatronPolicyWorkerBase.broadcast_to_inference_engines()
File ".../skyrl/backends/skyrl_train/workers/megatron/megatron_worker.py", line 1296, in broadcast_to_inference_engines
    await self._save_lora_adapters_and_sync(lora_sync_path, inference_engine_client, lora_name=lora_name)
File ".../skyrl/backends/skyrl_train/workers/megatron/megatron_worker.py", line 1262, in _save_lora_adapters_and_sync
    await inference_engine_client.load_lora_adapter(lora_name, lora_sync_path)
File ".../skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py", line 1252, in load_lora_adapter
    results = await asyncio.gather(*[_load_on_server(url) for url in self.server_urls])
File ".../skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py", line 1249, in _load_on_server
    raise_for_status(resp, body)
File ".../skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py", line 1399, in raise_for_status
    raise aiohttp.ClientResponseError(
aiohttp.client_exceptions.ClientResponseError: 500, message="Call to add_lora method failed:
  Worker failed with error 'While loading /tmp/skyrl_lora_sync, expected target modules in
  {'down_proj', 'o_proj', 'k_proj', 'conv1d', 'v_proj', 'shared_expert_gate', 'out_proj',
   'experts', 'in_proj_z', 'in_proj_a', 'up_proj', 'q_proj', 'gate_proj', 'in_proj_b',
   'in_proj_qkv', 'gate'}
  but received
  ['language_model.model.layers.0.mlp.experts.down_proj',
   'language_model.model.layers.0.mlp.experts.gate_up_proj',
   ...  (every decoder layer 0..47, both .experts.down_proj and .experts.gate_up_proj) ...].
  Please verify that the loaded LoRA module is correct',
  please check the stack trace above for the root cause",
  url='http://<ip>:8000/skyrl/v1/load_lora_adapter'

Root cause

The export keys the fused experts per layer, per projection as 3D (num_experts-leading) tensors: ...layers.N.mlp.experts.down_proj and ...layers.N.mlp.experts.gate_up_proj. vLLM 0.23.0's fused-MoE LoRA loader (FusedMoE3DWithLoRA / LoRAModelManager._stack_moe_lora_weights, reached because Qwen3_5* sets is_3d_moe_weight=True) instead expects the flat layout under the fused experts module — down_proj → experts (w2), gate_up_proj → experts.base_layer (w13), with lora_A=(rank*E, in), lora_B=(out, rank*E). The exported names aren't in vLLM's allowed target-module set, so add_lora rejects the whole adapter.

Suggested fix

I confirmed the following fixes it, but unsure if this type of patch fits well into SkyRL project and would like maintainers comments @erictang000 @SumanthRH

diff --git a/skyrl/backends/skyrl_train/workers/megatron/megatron_worker.py b/skyrl/backends/skyrl_train/workers/megatron/megatron_worker.py
index 75261d47..a7be56a1 100644
--- a/skyrl/backends/skyrl_train/workers/megatron/megatron_worker.py
+++ b/skyrl/backends/skyrl_train/workers/megatron/megatron_worker.py
@@ -86,6 +86,49 @@ from skyrl.backends.skyrl_train.workers.megatron.model_bridges import (
 )
 
 
+def _convert_moe_experts_lora_to_vllm(
+    adapter_state: Dict[str, "torch.Tensor"],
+) -> Dict[str, "torch.Tensor"]:
+    """Rewrite fused-MoE expert LoRA tensors into the layout vLLM expects.
+
+    Megatron-Bridge exports the fused experts as 3D (num_experts-leading)
+    tensors under two keys per layer::
+
+        ...mlp.experts.gate_up_proj.lora_{A,B}.weight   # w13
+        ...mlp.experts.down_proj.lora_{A,B}.weight       # w2
+
+    with shapes ``lora_A=(E, rank, in)`` and ``lora_B=(E, out, rank)``.
+
+    vLLM's fused-MoE LoRA loader for a 3D-MoE model
+    (``FusedMoE3DWithLoRA`` / ``LoRAModelManager._stack_moe_lora_weights``)
+    instead consumes the flat PEFT layout, keyed by the FusedMoE module name
+    itself for ``down_proj`` (w2) and ``<module>.base_layer`` for the packed
+    ``gate_up_proj`` (w13), with shapes ``lora_A=(rank*E, in)`` and
+    ``lora_B=(out, rank*E)``.  It then reconstructs the per-expert tensors via
+    ``reshape(E, -1, in)`` (lora_A) and ``reshape(out, -1, E).permute(2,0,1)``
+    (lora_B); this transform is the exact inverse, verified to round-trip.
+
+    Non-expert tensors (attention, shared_expert) are passed through unchanged.
+    """
+    converted: Dict[str, "torch.Tensor"] = {}
+    for key, tensor in adapter_state.items():
+        is_gate_up = ".mlp.experts.gate_up_proj." in key
+        is_down = ".mlp.experts.down_proj." in key
+        if (is_gate_up or is_down) and tensor.ndim == 3:
+            if key.endswith(".lora_A.weight"):
+                # (E, rank, in) -> (rank*E [expert-major], in)
+                tensor = tensor.reshape(-1, tensor.shape[-1]).contiguous()
+            elif key.endswith(".lora_B.weight"):
+                # (E, out, rank) -> (out, rank*E [expert-minor])
+                tensor = tensor.permute(1, 2, 0).contiguous().reshape(tensor.shape[1], -1)
+            if is_gate_up:
+                key = key.replace(".mlp.experts.gate_up_proj.", ".mlp.experts.base_layer.")
+            else:
+                key = key.replace(".mlp.experts.down_proj.", ".mlp.experts.")
+        converted[key] = tensor
+    return converted
+
+
 class MegatronWeightExtractor(WeightExtractor):
     """Extracts weights from Megatron model-parallel models.
 
@@ -1238,7 +1281,21 @@ class MegatronPolicyWorkerBase(MegatronWorker, PolicyWorkerBase):
         if torch.distributed.get_rank() == 0:
             os.makedirs(lora_sync_path, exist_ok=True)
 
-            target_modules = infer_target_modules_from_adapter_weights(adapter_state.keys())
+            # The bridge exports the fused-MoE expert LoRA as 3D
+            # (num_experts-leading) tensors keyed `...mlp.experts.gate_up_proj`
+            # / `...mlp.experts.down_proj`. vLLM's fused-MoE LoRA loader
+            # (FusedMoE3DWithLoRA / _stack_moe_lora_weights) instead expects the
+            # flat PEFT layout keyed `...mlp.experts.base_layer` (gate_up_proj /
+            # w13) and `...mlp.experts` (down_proj / w2), with lora_A shaped
+            # (rank*num_experts, in) and lora_B shaped (out, rank*num_experts).
+            # Rewrite the expert tensors so merge_lora=False on-policy sync is
+            # accepted (otherwise load_lora_adapter rejects `experts.down_proj`).
+            adapter_state = _convert_moe_experts_lora_to_vllm(adapter_state)
+
+            target_modules = sorted(
+                set(infer_target_modules_from_adapter_weights(adapter_state.keys()))
+                - {"base_layer"}
+            )
             base_model_name_or_path = str(
                 getattr(self.bridge.hf_pretrained, "model_name_or_path", "")
                 or getattr(self.bridge.hf_pretrained, "name_or_path", "")

Activity

  1. self-assigned this
    on Jul 1, 2026
  2. erictang000 commented on Jul 1, 2026

    @erictang000
    Collaborator

    hey @casper-hansen, thanks for flagging, verified that I can repro the issue

    the fix makes sense to me, can you help open up a PR? and maybe we can put the _convert_moe_experts_lora_to_vllm into megatron_utils.py instead of megatron_worker?

  3. added a commit that references this issue on Jul 2, 2026
    a479991
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

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