Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions miles/backends/megatron_utils/bridge_lora_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@

from __future__ import annotations

import logging
from argparse import Namespace
from dataclasses import dataclass

from megatron.core.utils import get_attr_wrapped_model

from miles.utils.hf_config import load_hf_config
from miles.utils.multi_lora import is_multi_lora_enabled
from miles.utils.multi_lora import is_multi_lora_enabled, targets_expert_leaves

from .lora_utils import patch_param_grad_buffer_for_colocate_mode_lora
from .lora_utils import convert_target_modules_to_hf, patch_param_grad_buffer_for_colocate_mode_lora

logger = logging.getLogger(__name__)


@dataclass
Expand Down Expand Up @@ -66,6 +69,46 @@ def _get_model_config_from_wrapped(model):
return get_attr_wrapped_model(model, "config", allow_none=False)


def _validate_multi_lora_moe_support(args: Namespace, provider) -> None:
"""Reject MoE configs the multi-slot grouped-expert adapter cannot serve (checked
post-finalize because they depend on the resolved provider, not the CLI)."""
if not getattr(provider, "num_moe_experts", None):
return
if not targets_expert_leaves(args.target_modules):
logger.info("[multilora] MoE model with no expert leaves in --target-modules; experts stay frozen")
return

# Checked on the provider: --expert-tensor-parallel-size stays None until Megatron resolves it.
expert_tp = getattr(provider, "expert_tensor_parallel_size", 1) or 1
assert expert_tp == 1, (
f"Multi-LoRA on MoE experts requires expert_tensor_parallel_size=1 (resolved to "
f"{expert_tp}); set --expert-tensor-parallel-size 1."
)
assert getattr(provider, "moe_grouped_gemm", False), (
"Multi-LoRA on MoE experts requires moe_grouped_gemm=True (SequentialMLP expert "
"linears are skipped, so the experts would train no adapter)."
)
assert not getattr(provider, "fp8", None) and not getattr(provider, "fp4", None), (
"Multi-LoRA on MoE experts does not support fp8/fp4 experts (quantization padding "
"desynchronizes the dispatched token order)."
)
# sglang only wraps a fused MoE layer when both expert projections are targeted.
served = set(convert_target_modules_to_hf(list(args.target_modules)))
expert_pair = {"gate_proj", "up_proj", "down_proj"}
if served & expert_pair:
assert expert_pair <= served, (
f"Multi-LoRA on MoE experts requires all of {sorted(expert_pair)} in "
f"--target-modules (got {sorted(served & expert_pair)}); a one-sided expert "
f"target is dropped at rollout time."
)
assert not getattr(
provider, "moe_pad_expert_input_to_capacity", False
), "Multi-LoRA on MoE experts does not support --moe-pad-expert-input-to-capacity."
assert not getattr(
provider, "moe_permute_fusion", False
), "Multi-LoRA on MoE experts requires moe_permute_fusion=False."


Comment thread
yushengsu-thu marked this conversation as resolved.
def _setup_lora_model_via_bridge(args: Namespace) -> list:
"""Build Megatron model with LoRA using Megatron-Bridge.

Expand Down Expand Up @@ -105,6 +148,15 @@ def _setup_lora_model_via_bridge(args: Namespace) -> list:
provider.variable_seq_lengths = True
provider.moe_token_dispatcher_type = "alltoall"
provider.moe_router_load_balancing_type = "none"
if is_multi_lora_enabled(args) and targets_expert_leaves(args.target_modules):
# Expert adapters cannot replay the fused permute's row_id_map, and most bridge
# MoE providers default the fusion on — so turn it off rather than refuse to build.
if getattr(provider, "moe_permute_fusion", False):
logger.info(
"[multilora] disabling moe_permute_fusion: expert adapters replay the "
"dispatcher's permutation, which the fused kernel does not expose"
)
provider.moe_permute_fusion = False
if getattr(args, "decoder_first_pipeline_num_layers", None) is not None:
provider.num_layers_in_first_pipeline_stage = args.decoder_first_pipeline_num_layers
if getattr(args, "decoder_last_pipeline_num_layers", None) is not None:
Expand All @@ -114,6 +166,8 @@ def _setup_lora_model_via_bridge(args: Namespace) -> list:
provider.finalize()

if is_multi_lora_enabled(args):
_validate_multi_lora_moe_support(args, provider)

from miles.backends.megatron_utils.multi_lora_utils import create_multi_lora_instance

lora = create_multi_lora_instance(args)
Expand Down
114 changes: 81 additions & 33 deletions miles/backends/megatron_utils/multi_lora_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@
from miles.backends.training_utils.parallel import get_parallel_state
from miles.ray.multi_lora.controller import get_multi_lora_controller
from miles.utils.adapter_config import AdapterRun
from miles.utils.distributed_utils import get_gloo_group

logger = logging.getLogger(__name__)

# Cached by adapter_shard_topology(); the topology is fixed for the run.
_shard_topology: tuple[bool, tuple[tuple[int, int, int], ...]] | None = None


def create_multi_lora_instance(args: Namespace):
"""Create a MultiLoRA instance from training args."""
Expand All @@ -32,6 +36,7 @@ def create_multi_lora_instance(args: Namespace):

lora_cls = LoRA

# exclude_modules was already folded into target_modules during arg validation.
return MultiLoRA(
target_modules=convert_target_modules_to_megatron(args.target_modules, lora_type=lora_cls),
n_adapters=args.multi_lora_n_adapters,
Expand All @@ -43,21 +48,53 @@ def create_multi_lora_instance(args: Namespace):
)


def all_megatron_checkpoints_exist(step_dir: Path, tp_size, pp_size) -> bool:
return all(
(step_dir / f"adapter_megatron_tp{tp}_pp{pp}.pt").exists() for tp in range(tp_size) for pp in range(pp_size)
)
def megatron_shard_name(tp_rank: int, pp_rank: int, ep_rank: int, ep_size: int) -> str:
"""Adapter shard name for one (tp, pp, ep) coordinate; EP ranks hold different local
experts. The ep suffix is omitted at ep_size == 1 so legacy checkpoints stay loadable."""
name = f"adapter_megatron_tp{tp_rank}_pp{pp_rank}"
if ep_size > 1:
name += f"_ep{ep_rank}"
return name + ".pt"


def adapter_shard_topology() -> tuple[bool, tuple[tuple[int, int, int], ...]]:
"""Return ``(this_rank_writes_its_shard, realized (tp, pp, ep) coords)`` via one cached gloo all-gather."""
global _shard_topology
if _shard_topology is not None:
return _shard_topology
parallel_state = get_parallel_state()
coords = (parallel_state.tp.rank, parallel_state.pp.rank, parallel_state.ep.rank)
if not dist.is_initialized():
_shard_topology = (True, (coords,))
return _shard_topology

current_rank = dist.get_rank()
group = get_gloo_group()
gathered: list[object] = [None] * dist.get_world_size(group=group)
dist.all_gather_object(gathered, (coords, current_rank), group=group)
is_writer = current_rank == min(rank for entry_coords, rank in gathered if entry_coords == coords)
_shard_topology = (is_writer, tuple(sorted({entry_coords for entry_coords, _ in gathered})))
return _shard_topology


def all_megatron_checkpoints_exist(step_dir: Path, shard_names) -> bool:
return all((step_dir / name).exists() for name in shard_names)


def find_latest_checkpoint(ckpt_dir: Path) -> tuple[Path | None, int]:
_, coords = adapter_shard_topology()
if not ckpt_dir.exists():
return None, 0

parallel_state = get_parallel_state()
tp_size = parallel_state.tp.size
pp_size = parallel_state.pp.size
tp_rank = parallel_state.tp.rank
pp_rank = parallel_state.pp.rank
ep_size = parallel_state.ep.size
my_coords = (parallel_state.tp.rank, parallel_state.pp.rank, parallel_state.ep.rank)

expected = {megatron_shard_name(*coord, ep_size) for coord in coords}
my_shard = megatron_shard_name(*my_coords, ep_size)
# Legacy pre-expert-adapter layout: no ep suffix; safe for all EP ranks to read (EP-replicated).
legacy = {megatron_shard_name(tp, pp, 0, 1) for tp, pp, _ in coords}
my_legacy = megatron_shard_name(my_coords[0], my_coords[1], 0, 1)

def get_step(d):
return int(d.name.split("_")[1])
Expand All @@ -69,8 +106,11 @@ def get_step(d):
)
for step_dir in step_dirs:
step = get_step(step_dir)
if all_megatron_checkpoints_exist(step_dir, tp_size, pp_size):
return step_dir / f"adapter_megatron_tp{tp_rank}_pp{pp_rank}.pt", step
if all_megatron_checkpoints_exist(step_dir, expected):
return step_dir / my_shard, step
if ep_size > 1 and all_megatron_checkpoints_exist(step_dir, legacy):
logger.info(f"[multilora] resuming from pre-expert-parallel shard layout in {step_dir}")
return step_dir / my_legacy, step

return None, 0

Expand Down Expand Up @@ -116,20 +156,28 @@ def zero_optimizer_state_for_adapter(optimizer, model, idx: int) -> None:


def slice_lora_to_rank(hf_name: str, tensor: torch.Tensor, adapter_rank: int) -> torch.Tensor:
if "lora_A" in hf_name and adapter_rank < tensor.shape[0]:
remainder = tensor[adapter_rank:]
assert remainder.abs().max() == 0, (
f"lora_A padded dims are non-zero: {hf_name}, "
f"max={remainder.abs().max().item():.6e}, shape={tensor.shape}, rank={adapter_rank}"
)
return tensor[:adapter_rank]
if "lora_B" in hf_name and adapter_rank < tensor.shape[1]:
remainder = tensor[:, adapter_rank:]
assert remainder.abs().max() == 0, (
f"lora_B padded dims are non-zero: {hf_name}, "
f"max={remainder.abs().max().item():.6e}, shape={tensor.shape}, rank={adapter_rank}"
)
return tensor[:, :adapter_rank]
"""Trim a max-rank-padded LoRA tensor to ``adapter_rank`` on the rank axis, addressed
from the end so packed grouped-expert exports are not sliced on the expert axis."""
if "lora_A" in hf_name:
rank_dim = tensor.ndim - 2
if adapter_rank < tensor.shape[rank_dim]:
remainder = tensor.narrow(rank_dim, adapter_rank, tensor.shape[rank_dim] - adapter_rank)
assert remainder.abs().max() == 0, (
f"lora_A padded dims are non-zero: {hf_name}, "
f"max={remainder.abs().max().item():.6e}, shape={tensor.shape}, rank={adapter_rank}"
)
return tensor.narrow(rank_dim, 0, adapter_rank)
return tensor
if "lora_B" in hf_name:
rank_dim = tensor.ndim - 1
if adapter_rank < tensor.shape[rank_dim]:
remainder = tensor.narrow(rank_dim, adapter_rank, tensor.shape[rank_dim] - adapter_rank)
assert remainder.abs().max() == 0, (
f"lora_B padded dims are non-zero: {hf_name}, "
f"max={remainder.abs().max().item():.6e}, shape={tensor.shape}, rank={adapter_rank}"
)
return tensor.narrow(rank_dim, 0, adapter_rank)
return tensor
return tensor


Expand All @@ -144,7 +192,7 @@ def save_multi_lora_checkpoints(
Layout (per adapter)::

{adapter.save}/checkpoints/step_{iteration}/
├── adapter_megatron_tp{tp}_pp{pp}.pt ← per-rank shard, fast resume
├── adapter_megatron_tp{tp}_pp{pp}[_ep{ep}].pt ← per-rank shard, fast resume
├── adapter_model.safetensors ← gathered HF, inference / external
└── adapter_config.json ← HF PEFT metadata (r, alpha, ...)
"""
Expand All @@ -158,11 +206,11 @@ def save_multi_lora_checkpoints(
parallel_state = get_parallel_state()
tp_rank = parallel_state.tp.rank
pp_rank = parallel_state.pp.rank
# One writer per (tp, pp) shard: LoRA params are replicated across DP AND
# CP, so gate on the combined dp×cp group. Gating on intra_dp alone left
# every CP rank writing the same shard file and racing the os.replace.
is_dp_cp_rank_0 = parallel_state.intra_dp_cp.rank == 0
is_global_writer = is_dp_cp_rank_0 and tp_rank == 0 and pp_rank == 0
ep_rank = parallel_state.ep.rank
ep_size = parallel_state.ep.size
# Exactly one writer per (tp, pp, ep) shard; see adapter_shard_topology.
is_shard_writer, _ = adapter_shard_topology()
is_global_writer = is_shard_writer and tp_rank == 0 and pp_rank == 0 and ep_rank == 0

target_modules_hf = (
convert_target_modules_to_hf(list(args.target_modules))
Expand All @@ -183,21 +231,21 @@ def save_multi_lora_checkpoints(

final_dir = config.save / "checkpoints" / f"step_{iteration}"
tmp_dir = config.save / "checkpoints" / f"_tmp_step_{iteration}"
if is_dp_cp_rank_0:
if is_shard_writer:
tmp_dir.mkdir(parents=True, exist_ok=True)
if dist.is_initialized():
dist.barrier()

with expose_adapter_slot(model, adapter.slot):
# Megatron checkpoints
if is_dp_cp_rank_0:
if is_shard_writer:
shard: dict[str, torch.Tensor] = {
name: param.data.cpu()
for batch in model
for name, param in batch.named_parameters()
if ".adapter." in name
}
native_path = tmp_dir / f"adapter_megatron_tp{tp_rank}_pp{pp_rank}.pt"
native_path = tmp_dir / megatron_shard_name(tp_rank, pp_rank, ep_rank, ep_size)
torch.save(shard, native_path)
logger.info(f"{log_prefix} saved Megatron shard " f"({len(shard)} tensors) to {native_path}")

Expand Down
34 changes: 34 additions & 0 deletions miles/utils/multi_lora.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,23 @@ def define_new_adapter_metrics(snapshot: dict) -> None:
define_step_key_metric_group(prefix=f"{name}/perf", step_key="rollout/step")


# Leaf module names that can live inside MoE experts (they also name the dense MLP
# projections); the bulk aliases expand to them during target-module resolution.
_EXPERT_LEAF_NAMES = frozenset({"linear_fc1", "linear_fc2", "gate_proj", "up_proj", "down_proj"})
_ALL_MODULE_ALIASES = frozenset({"all", "all-linear", "all_linear"})


def targets_expert_leaves(target_modules: Any) -> bool:
"""Whether ``target_modules`` can put adapters on MoE expert linears."""
if isinstance(target_modules, str):
target_modules = [target_modules]
entries = [str(tm).strip().lower() for tm in (target_modules or [])]
if any(entry in _ALL_MODULE_ALIASES for entry in entries):
return True
# Map each entry (possibly a dotted or wildcard path) to its leaf module name.
return any(entry.split(".")[-1] in _EXPERT_LEAF_NAMES for entry in entries)


def validate_multi_lora_args(args: Any) -> None:
"""Set ``args.multi_lora``, then validate and default the multi-LoRA arg
surface. Called from ``miles_validate_args``; a no-op for normal runs."""
Expand All @@ -68,6 +85,23 @@ def validate_multi_lora_args(args: Any) -> None:
assert args.lora_rank > 0, "--lora-rank must be set when --multi-lora-n-adapters > 0"
assert args.target_modules is not None, "--target-modules must be set when --multi-lora-n-adapters > 0"
assert args.train_backend == "megatron", "Multi-LoRA currently requires --train-backend megatron"
# Adapter routing is only recompute-safe without pipelining; enforce at launch.
assert getattr(args, "pipeline_model_parallel_size", 1) == 1, (
"Multi-LoRA requires --pipeline-model-parallel-size 1: no single rank holds a "
"complete adapter to push to the rollout engines, and a pipelined schedule would "
"recompute activations against a later micro-batch's adapter routing."
)
# Per-slot token spans assume sequence-major contiguous sample packing, which only 'thd' provides.
assert getattr(args, "qkv_format", "thd") == "thd", (
"Multi-LoRA requires --qkv-format thd: per-adapter token spans assume the "
f"micro-batch packs samples contiguously, which bshd does not (got {args.qkv_format!r})."
)
assert not getattr(args, "experts_shared_outer_loras", False), (
"Multi-LoRA does not support --experts-shared-outer-loras; MoE expert adapters "
"use the per-expert layout. Drop the flag (and --sglang-experts-shared-outer-loras)."
)
# Expert-parallel sizes are checked post-finalize in _validate_multi_lora_moe_support:
# --expert-tensor-parallel-size stays None until Megatron's own validate_args resolves it.
assert "muon" not in str(getattr(args, "optimizer", "")).lower(), (
"Multi-LoRA does not support Muon: per-adapter decoupled stepping is only "
"implemented for Adam-family per-slot optimizers"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Adapter shards are keyed by (tp, pp, ep): EP ranks hold different local experts, and
the realized coordinates are not the tp x pp x ep cross product when ETP < TP."""

from miles.backends.megatron_utils.multi_lora_utils import all_megatron_checkpoints_exist, megatron_shard_name


def _names(coords, ep_size):
return {megatron_shard_name(*coord, ep_size) for coord in coords}


def test_shard_name_omits_ep_suffix_without_expert_parallelism():
# Checkpoints written before expert adapters existed must stay loadable.
assert megatron_shard_name(0, 0, 0, ep_size=1) == "adapter_megatron_tp0_pp0.pt"
assert megatron_shard_name(1, 2, 0, ep_size=1) == "adapter_megatron_tp1_pp2.pt"


def test_shard_name_is_unique_per_expert_parallel_rank():
names = {megatron_shard_name(0, 0, ep, ep_size=4) for ep in range(4)}
assert len(names) == 4
assert megatron_shard_name(0, 0, 2, ep_size=4) == "adapter_megatron_tp0_pp0_ep2.pt"


def test_completeness_check_requires_every_realized_shard(tmp_path):
coords = [(0, 0, 0), (0, 0, 1), (0, 0, 2)]
for coord in coords[:2]:
(tmp_path / megatron_shard_name(*coord, 3)).touch()

assert not all_megatron_checkpoints_exist(tmp_path, _names(coords, 3))

(tmp_path / megatron_shard_name(*coords[2], 3)).touch()
assert all_megatron_checkpoints_exist(tmp_path, _names(coords, 3))


def test_completeness_ignores_unrealized_coordinates(tmp_path):
# TP=2, EP=2, ETP=1: only (0,0,0) and (1,0,1) exist; a cross-product check
# would demand four shards and never resume.
coords = [(0, 0, 0), (1, 0, 1)]
for coord in coords:
(tmp_path / megatron_shard_name(*coord, 2)).touch()

assert all_megatron_checkpoints_exist(tmp_path, _names(coords, 2))


def test_completeness_check_with_single_shard(tmp_path):
(tmp_path / "adapter_megatron_tp0_pp0.pt").touch()
assert all_megatron_checkpoints_exist(tmp_path, _names([(0, 0, 0)], 1))
Loading
Loading