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", "")
Training a fused-MoE model (
Qwen/Qwen3.6-35B-A3B, archqwen3_5_moe) with the Megatron backend + LoRA +merge_lora=Falsefails 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 fusedexpertstarget module, soadd_lorarejects 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
0a18dd72783a2a1400c8d36929fa8742cea9395d(main); vLLM0.23.0, torch2.11.0+cu128, transformer-engine2.11.0, megatron-core0.19.0, megatron-bridge0.6.0Qwen/Qwen3.6-35B-A3B(qwen3_5_moe: MoE text tower + GatedDeltaNet)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 toTrue).It loads, generates, trains step 0, then dies in
sync_weights(~22 s).Stack trace (fails at step 0,
sync_weights)Root cause
The export keys the fused experts per layer, per projection as 3D (
num_experts-leading) tensors:...layers.N.mlp.experts.down_projand...layers.N.mlp.experts.gate_up_proj. vLLM 0.23.0's fused-MoE LoRA loader (FusedMoE3DWithLoRA/LoRAModelManager._stack_moe_lora_weights, reached becauseQwen3_5*setsis_3d_moe_weight=True) instead expects the flat layout under the fusedexpertsmodule —down_proj→experts(w2),gate_up_proj→experts.base_layer(w13), withlora_A=(rank*E, in),lora_B=(out, rank*E). The exported names aren't in vLLM's allowed target-module set, soadd_lorarejects 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