[LoRA] Fix EP + per-expert MoE LoRA illegal memory access (#23178)

This commit is contained in:
Yanbin Jiang
2026-04-22 14:22:32 -07:00
committed by GitHub
parent b9e33d6a5b
commit 917d2aa1dc
3 changed files with 783 additions and 70 deletions
@@ -0,0 +1,611 @@
"""Unit tests for LoRAMemoryPool's MoE expert-parallel (EP) handling.
Covers the global->local expert-id remapping and per-rank buffer sizing
introduced so that per-expert MoE LoRA buffers stay aligned with the
Triton MoE runner's local-id dispatch under `--ep > 1`.
The tests exercise the class behavior without standing up a full server
or distributed groups: `LoRAMemoryPool` is instantiated via `__new__`
and only the fields the helpers read are populated. This keeps the
tests hermetic (CPU-only, no CUDA, no MoE EP group).
Usage:
python -m pytest test/registered/unit/lora/test_mem_pool_ep_unit.py -v
"""
from sglang.test.ci.ci_register import register_cuda_ci
# CPU-only unit test; no CUDA/distributed dependencies.
register_cuda_ci(est_time=4, suite="stage-b-test-1-gpu-small")
import types
import unittest
import unittest.mock as mock
import torch
from sglang.srt.lora.mem_pool import (
LoRAMemoryPool,
_get_moe_ep_context,
_get_moe_tp_context,
_moe_runner_keeps_global_expert_ids,
)
def _make_pool(
*,
num_experts_global: int,
moe_ep_size: int,
moe_ep_rank: int,
moe_use_local_expert_ids: bool,
) -> LoRAMemoryPool:
"""Construct a minimal LoRAMemoryPool for helper-level tests.
Bypasses `__init__` (which requires a real base model, HF config, and
device allocations) and sets only the fields consulted by the EP
helpers under test.
"""
pool = LoRAMemoryPool.__new__(LoRAMemoryPool)
pool.moe_ep_size = moe_ep_size
pool.moe_ep_rank = moe_ep_rank
pool.moe_use_local_expert_ids = moe_use_local_expert_ids
# Helpers under test in this module don't consult moe_tp_size, but set
# defaults so accidental reads don't AttributeError.
pool.moe_tp_size = 1
pool.moe_tp_rank = 0
if moe_use_local_expert_ids and num_experts_global % moe_ep_size == 0:
pool._num_experts_local = num_experts_global // moe_ep_size
else:
pool._num_experts_local = num_experts_global
return pool
def _make_fake_base_model(num_experts: int) -> torch.nn.Module:
"""Return a `torch.nn.Module` whose `.config` exposes `num_experts`.
Used by `_get_num_experts` / `_get_num_local_experts` which walk the
HF config object. No real weights needed.
"""
model = torch.nn.Linear(4, 4, bias=False)
cfg = types.SimpleNamespace(num_experts=num_experts)
model.config = cfg
return model
class TestNumExpertHelpers(unittest.TestCase):
"""`_get_num_experts` / `_get_num_local_experts` / buffer-dim picker."""
def test_num_experts_read_from_config(self):
model = _make_fake_base_model(num_experts=8)
self.assertEqual(LoRAMemoryPool._get_num_experts(model), 8)
def test_num_local_experts_no_ep(self):
pool = _make_pool(
num_experts_global=8,
moe_ep_size=1,
moe_ep_rank=0,
moe_use_local_expert_ids=False,
)
model = _make_fake_base_model(num_experts=8)
self.assertEqual(pool._get_num_local_experts(model), 8)
def test_num_local_experts_with_ep(self):
pool = _make_pool(
num_experts_global=8,
moe_ep_size=4,
moe_ep_rank=2,
moe_use_local_expert_ids=True,
)
model = _make_fake_base_model(num_experts=8)
self.assertEqual(pool._get_num_local_experts(model), 2)
def test_num_local_experts_with_ep_but_backend_keeps_global_ids(self):
"""FlashInfer-style backends keep global topk_ids, so even under EP
the LoRA buffers must remain globally-keyed.
"""
pool = _make_pool(
num_experts_global=8,
moe_ep_size=4,
moe_ep_rank=2,
moe_use_local_expert_ids=False,
)
model = _make_fake_base_model(num_experts=8)
self.assertEqual(pool._get_num_local_experts(model), 8)
def test_uneven_split_disables_local_mapping(self):
"""Shouldn't happen in practice (base MoE requires even split), but
`__init__` must fold uneven splits into `moe_use_local_expert_ids ==
False` so `_get_num_local_experts` returns the global count and no
remapping happens anywhere downstream.
"""
# Simulate what `LoRAMemoryPool.__init__` would set for an uneven
# split: the divisibility guard there forces the flag to False.
pool = _make_pool(
num_experts_global=7,
moe_ep_size=4,
moe_ep_rank=0,
moe_use_local_expert_ids=False,
)
model = _make_fake_base_model(num_experts=7)
self.assertEqual(pool._get_num_local_experts(model), 7)
class TestGlobalToLocalExpertId(unittest.TestCase):
"""`_global_to_local_expert_id` — the per-rank filter + remap."""
def test_passthrough_without_ep(self):
pool = _make_pool(
num_experts_global=8,
moe_ep_size=1,
moe_ep_rank=0,
moe_use_local_expert_ids=False,
)
for gid in range(8):
self.assertEqual(pool._global_to_local_expert_id(gid), gid)
def test_rank0_of_ep4_owns_first_quarter(self):
pool = _make_pool(
num_experts_global=8,
moe_ep_size=4,
moe_ep_rank=0,
moe_use_local_expert_ids=True,
)
# Owned: 0, 1 -> local 0, 1
self.assertEqual(pool._global_to_local_expert_id(0), 0)
self.assertEqual(pool._global_to_local_expert_id(1), 1)
# Not owned by rank 0.
for gid in (2, 3, 4, 5, 6, 7):
self.assertIsNone(pool._global_to_local_expert_id(gid))
def test_rank2_of_ep4_owns_third_quarter(self):
pool = _make_pool(
num_experts_global=8,
moe_ep_size=4,
moe_ep_rank=2,
moe_use_local_expert_ids=True,
)
# Owned globals 4, 5 -> local 0, 1
self.assertEqual(pool._global_to_local_expert_id(4), 0)
self.assertEqual(pool._global_to_local_expert_id(5), 1)
for gid in (0, 1, 2, 3, 6, 7):
self.assertIsNone(pool._global_to_local_expert_id(gid))
def test_last_rank_owns_last_slice(self):
pool = _make_pool(
num_experts_global=128,
moe_ep_size=4,
moe_ep_rank=3,
moe_use_local_expert_ids=True,
)
# Local 0 <-> global 96, local 31 <-> global 127.
self.assertEqual(pool._global_to_local_expert_id(96), 0)
self.assertEqual(pool._global_to_local_expert_id(127), 31)
self.assertIsNone(pool._global_to_local_expert_id(95))
self.assertIsNone(pool._global_to_local_expert_id(128))
class TestIterLocalExpertWeightsDict(unittest.TestCase):
"""`_iter_local_expert_weights` with dict input (the common case)."""
def test_passthrough_without_ep(self):
pool = _make_pool(
num_experts_global=4,
moe_ep_size=1,
moe_ep_rank=0,
moe_use_local_expert_ids=False,
)
weights = {gid: torch.full((2,), float(gid)) for gid in range(4)}
got = {lid: w.tolist() for lid, w in pool._iter_local_expert_weights(weights)}
self.assertEqual(
got, {0: [0.0, 0.0], 1: [1.0, 1.0], 2: [2.0, 2.0], 3: [3.0, 3.0]}
)
def test_rank0_of_ep4_filters_and_remaps(self):
pool = _make_pool(
num_experts_global=8,
moe_ep_size=4,
moe_ep_rank=0,
moe_use_local_expert_ids=True,
)
weights = {gid: torch.full((2,), float(gid)) for gid in range(8)}
got = {lid: w.tolist() for lid, w in pool._iter_local_expert_weights(weights)}
# Rank 0 sees globals 0,1 remapped to locals 0,1.
self.assertEqual(got, {0: [0.0, 0.0], 1: [1.0, 1.0]})
def test_rank3_of_ep4_filters_and_remaps(self):
pool = _make_pool(
num_experts_global=8,
moe_ep_size=4,
moe_ep_rank=3,
moe_use_local_expert_ids=True,
)
weights = {gid: torch.full((2,), float(gid)) for gid in range(8)}
got = {lid: w.tolist() for lid, w in pool._iter_local_expert_weights(weights)}
# Rank 3 sees globals 6,7 remapped to locals 0,1.
self.assertEqual(got, {0: [6.0, 6.0], 1: [7.0, 7.0]})
def test_sparse_dict_only_yields_owned_experts(self):
"""Adapters may only target a subset of experts. The iterator must
still correctly filter and remap whatever subset is provided.
"""
pool = _make_pool(
num_experts_global=8,
moe_ep_size=4,
moe_ep_rank=2,
moe_use_local_expert_ids=True,
)
# Only globals 1, 4, 5, 7 present in adapter.
weights = {
1: torch.full((2,), 1.0),
4: torch.full((2,), 4.0),
5: torch.full((2,), 5.0),
7: torch.full((2,), 7.0),
}
# Rank 2 owns globals 4, 5 -> locals 0, 1.
got = {lid: w.tolist() for lid, w in pool._iter_local_expert_weights(weights)}
self.assertEqual(got, {0: [4.0, 4.0], 1: [5.0, 5.0]})
def test_no_experts_owned_yields_nothing(self):
"""Rank with no matching experts in a sparse dict yields nothing,
leaves buffer zeroed.
"""
pool = _make_pool(
num_experts_global=8,
moe_ep_size=4,
moe_ep_rank=0,
moe_use_local_expert_ids=True,
)
# Only globals 4, 5 present (owned by rank 2).
weights = {4: torch.full((2,), 4.0), 5: torch.full((2,), 5.0)}
got = list(pool._iter_local_expert_weights(weights))
self.assertEqual(got, [])
class TestIterLocalExpertWeightsTensor(unittest.TestCase):
"""`_iter_local_expert_weights` with 3D tensor input (shared-outer and
packed MoE-LoRA formats)."""
def test_passthrough_without_ep(self):
pool = _make_pool(
num_experts_global=4,
moe_ep_size=1,
moe_ep_rank=0,
moe_use_local_expert_ids=False,
)
# [num_experts, rank, hidden] with values carrying the expert id.
weights = torch.arange(4 * 2 * 3, dtype=torch.float32).reshape(4, 2, 3)
got = [(lid, w.clone()) for lid, w in pool._iter_local_expert_weights(weights)]
self.assertEqual([lid for lid, _ in got], [0, 1, 2, 3])
for lid, w in got:
self.assertTrue(torch.equal(w, weights[lid]))
def test_rank1_of_ep2_sees_upper_half(self):
pool = _make_pool(
num_experts_global=4,
moe_ep_size=2,
moe_ep_rank=1,
moe_use_local_expert_ids=True,
)
weights = torch.arange(4 * 2 * 3, dtype=torch.float32).reshape(4, 2, 3)
got = [(lid, w.clone()) for lid, w in pool._iter_local_expert_weights(weights)]
# Rank 1 of EP=2 with 4 experts owns globals 2, 3 -> locals 0, 1.
self.assertEqual([lid for lid, _ in got], [0, 1])
self.assertTrue(torch.equal(got[0][1], weights[2]))
self.assertTrue(torch.equal(got[1][1], weights[3]))
def test_rank_with_partial_tensor_coverage(self):
"""Defensive: tensor has fewer experts than the expected local slice
(e.g. sparse adapter).
"""
pool = _make_pool(
num_experts_global=8,
moe_ep_size=4,
moe_ep_rank=3,
moe_use_local_expert_ids=True,
)
# Only 6 experts present in the tensor; rank 3 expects global 6,7.
# So it should still yield local 0 mapped to global 6; global 7 is
# beyond the tensor length and must be skipped safely.
weights = torch.arange(6 * 2, dtype=torch.float32).reshape(6, 2)
# Note: this is 2D, not 3D -> should raise (sanity check).
with self.assertRaises(TypeError):
list(pool._iter_local_expert_weights(weights))
class TestModuleLevelHelpers(unittest.TestCase):
"""`_get_moe_ep_context` / `_moe_runner_keeps_global_expert_ids`
must degrade gracefully when the MoE EP group or runner backend is
not yet initialized (e.g. in pure-TP launches or hermetic tests)."""
def test_ep_context_defaults_when_group_uninitialized(self):
# Real process here: the MoE EP group isn't set up in a unit test.
# The helper must return (1, 0) rather than raising.
ep_size, ep_rank = _get_moe_ep_context()
self.assertEqual(ep_size, 1)
self.assertEqual(ep_rank, 0)
def test_tp_context_defaults_when_group_uninitialized(self):
# Mirror of `_get_moe_ep_context` for the MoE TP group: if it isn't
# initialized (hermetic tests, pure-TP launches), fall back to (1, 0).
tp_size, tp_rank = _get_moe_tp_context()
self.assertEqual(tp_size, 1)
self.assertEqual(tp_rank, 0)
def test_keeps_global_expert_ids_defaults_to_false(self):
# Without a specific flashinfer backend selected, default is False.
self.assertFalse(_moe_runner_keeps_global_expert_ids())
class TestPoolInitPicksUpEpContext(unittest.TestCase):
"""`LoRAMemoryPool.__init__` should read EP context from the module-
level helpers and set `moe_use_local_expert_ids` correctly."""
def _new_pool_with_ep(
self,
ep_size: int,
ep_rank: int,
keeps_global: bool,
num_experts: int = 8,
moe_tp_size: int = 1,
moe_tp_rank: int = 0,
tp_size: int = 1,
tp_rank: int = 0,
) -> LoRAMemoryPool:
"""Construct a pool with `__init__` called, but stop before
`init_buffers` — we only care about the EP-context state.
"""
with (
mock.patch(
"sglang.srt.lora.mem_pool._get_moe_ep_context",
return_value=(ep_size, ep_rank),
),
mock.patch(
"sglang.srt.lora.mem_pool._get_moe_tp_context",
return_value=(moe_tp_size, moe_tp_rank),
),
mock.patch(
"sglang.srt.lora.mem_pool._moe_runner_keeps_global_expert_ids",
return_value=keeps_global,
),
mock.patch.object(LoRAMemoryPool, "init_buffers", lambda self, _m: None),
):
hf_cfg = types.SimpleNamespace(
num_hidden_layers=1,
hidden_size=8,
vocab_size=32,
num_experts=num_experts,
)
base_model = torch.nn.Linear(8, 8, bias=False)
base_model.config = hf_cfg
return LoRAMemoryPool(
base_hf_config=hf_cfg,
max_loras_per_batch=1,
dtype=torch.bfloat16,
tp_size=tp_size,
tp_rank=tp_rank,
max_lora_rank=8,
target_modules={"qkv_proj"},
base_model=base_model,
eviction_policy="lru",
lora_added_tokens_size=0,
)
def test_no_ep(self):
pool = self._new_pool_with_ep(ep_size=1, ep_rank=0, keeps_global=False)
self.assertEqual(pool.moe_ep_size, 1)
self.assertEqual(pool.moe_ep_rank, 0)
self.assertFalse(pool.moe_use_local_expert_ids)
def test_ep4_triton_backend(self):
pool = self._new_pool_with_ep(ep_size=4, ep_rank=2, keeps_global=False)
self.assertEqual(pool.moe_ep_size, 4)
self.assertEqual(pool.moe_ep_rank, 2)
self.assertTrue(pool.moe_use_local_expert_ids)
def test_ep4_flashinfer_cutlass_keeps_global(self):
"""FlashInfer CUTLASS keeps global topk_ids, so LoRA buffers stay
globally-keyed even under EP.
"""
pool = self._new_pool_with_ep(ep_size=4, ep_rank=2, keeps_global=True)
self.assertEqual(pool.moe_ep_size, 4)
self.assertEqual(pool.moe_ep_rank, 2)
self.assertFalse(pool.moe_use_local_expert_ids)
def test_ep_with_uneven_split_falls_back_to_global_ids(self):
"""If `num_experts % ep_size != 0` (shouldn't happen in practice,
base MoE requires even split) `__init__` must fall back to
globally-keyed buffers rather than silently truncating the local
slice — otherwise non-zero ranks drop every LoRA weight.
"""
pool = self._new_pool_with_ep(
ep_size=4, ep_rank=1, keeps_global=False, num_experts=7
)
self.assertEqual(pool.moe_ep_size, 4)
self.assertEqual(pool.moe_ep_rank, 1)
self.assertFalse(pool.moe_use_local_expert_ids)
def test_init_captures_moe_tp_context(self):
"""`__init__` must capture moe_tp_size/rank so per-expert MoE LoRA
buffers can be sharded by the MoE-TP group (not the outer attn TP).
Under `--tp N --ep N` the MoE TP group degenerates to size 1.
"""
pool = self._new_pool_with_ep(
ep_size=4,
ep_rank=0,
keeps_global=False,
tp_size=4,
tp_rank=0,
moe_tp_size=1,
moe_tp_rank=0,
)
self.assertEqual(pool.tp_size, 4)
self.assertEqual(pool.moe_tp_size, 1)
self.assertEqual(pool.moe_tp_rank, 0)
def _fake_base_model_with_hidden_dim(num_experts: int) -> torch.nn.Module:
"""Fake base model that implements `get_hidden_dim` for MoE + attention
modules. Matches the signatures `LoRAMemoryPool.get_lora_{A,B}_shape`
call through `sglang.srt.lora.utils.get_hidden_dim`.
"""
class _Model(torch.nn.Module):
def __init__(self):
super().__init__()
self.lin = torch.nn.Linear(4, 4, bias=False)
self.config = types.SimpleNamespace(
num_hidden_layers=1,
hidden_size=64,
num_attention_heads=8,
num_key_value_heads=8,
head_dim=8,
intermediate_size=256,
moe_intermediate_size=192,
vocab_size=32,
num_experts=num_experts,
)
def get_hidden_dim(self, module_name: str, layer_idx: int):
cfg = self.config
if module_name == "qkv_proj":
head = cfg.head_dim
return cfg.hidden_size, head * (
cfg.num_attention_heads + cfg.num_key_value_heads * 2
)
if module_name == "o_proj":
return cfg.head_dim * cfg.num_attention_heads, cfg.hidden_size
if module_name == "gate_up_proj_moe":
return cfg.hidden_size, cfg.moe_intermediate_size * 2
if module_name == "down_proj_moe":
return cfg.moe_intermediate_size, cfg.hidden_size
raise NotImplementedError(module_name)
return _Model()
class TestMoeBufferShardsByMoeTp(unittest.TestCase):
"""Regression: per-expert MoE LoRA buffers must shard by `moe_tp_size`,
not the outer attention `tp_size`.
Under `--tp N --ep N` (e.g. tp=4, ep=4) `moe_tp_size == 1`, so per-
expert weights span the full MoE intermediate dim on every rank; the
corresponding LoRA buffer must match. Before the fix, the buffer was
divided by `tp_size` (= 4) while `FusedMoEWithLoRA.slice_moe_lora_*`
kept the weight full-width, producing a 4x shape-mismatch assert at
load time. Non-MoE modules still shard by the outer `tp_size`.
"""
def _pool(
self,
*,
tp_size: int,
moe_tp_size: int,
num_experts: int = 128,
ep_size: int = 1,
ep_rank: int = 0,
) -> LoRAMemoryPool:
pool = LoRAMemoryPool.__new__(LoRAMemoryPool)
pool.max_loras_per_batch = 2
pool.tp_size = tp_size
pool.tp_rank = 0
pool.moe_ep_size = ep_size
pool.moe_ep_rank = ep_rank
pool.moe_tp_size = moe_tp_size
pool.moe_tp_rank = 0
pool.moe_use_local_expert_ids = ep_size > 1
pool._num_experts_local = (
num_experts // ep_size if pool.moe_use_local_expert_ids else num_experts
)
pool.experts_shared_outer_loras = False
pool.base_hf_config = types.SimpleNamespace(
hidden_size=64,
num_attention_heads=8,
num_key_value_heads=8,
head_dim=8,
intermediate_size=256,
moe_intermediate_size=192,
)
return pool
def test_moe_down_proj_uses_moe_tp_not_attn_tp(self):
"""down_proj_moe is row-parallel: LoRA-A input_dim = moe_inter must
be divided by `moe_tp_size`, NOT `tp_size`. This is the exact shape
that failed at load time on `--tp 4 --ep 4` before the fix.
"""
pool = self._pool(tp_size=4, moe_tp_size=1, num_experts=128, ep_size=4)
model = _fake_base_model_with_hidden_dim(num_experts=128)
num_local = 128 // 4 # 32
# A: input_dim = moe_inter / moe_tp_size = 192 / 1 = 192 (pre-fix: 48).
self.assertEqual(
pool.get_lora_A_shape("down_proj_moe", model, 8, 0),
(2, num_local, 8, 192),
)
# B: output_dim = hidden_size, not row-parallel -> unsharded.
self.assertEqual(
pool.get_lora_B_shape("down_proj_moe", model, 8, 0),
(2, num_local, 64, 8),
)
def test_moe_gate_up_proj_uses_moe_tp_not_attn_tp(self):
"""gate_up_proj_moe is column-parallel: LoRA-B output_dim =
moe_inter*2 must be divided by `moe_tp_size`, not `tp_size`.
"""
pool = self._pool(tp_size=4, moe_tp_size=1, num_experts=128, ep_size=4)
model = _fake_base_model_with_hidden_dim(num_experts=128)
num_local = 128 // 4
# A: input_dim = hidden_size, not row-parallel -> unsharded. Rank
# dim is `max_lora_dim * stacked_multiply` (2 for gate_up).
self.assertEqual(
pool.get_lora_A_shape("gate_up_proj_moe", model, 8, 0),
(2, num_local, 16, 64),
)
# B: output_dim = moe_inter*2 / moe_tp_size = 384 / 1 = 384 (pre-fix: 96).
self.assertEqual(
pool.get_lora_B_shape("gate_up_proj_moe", model, 8, 0),
(2, num_local, 384, 8),
)
def test_moe_tp_gt1_still_shards_moe_dims(self):
"""Under `--tp 8 --ep 4` the MoE TP group has size 2, so per-expert
weights ARE sharded along the MoE inner dim — the LoRA buffer must
follow.
"""
pool = self._pool(tp_size=8, moe_tp_size=2, num_experts=128, ep_size=4)
model = _fake_base_model_with_hidden_dim(num_experts=128)
num_local = 128 // 4
# 192 / 2 = 96
self.assertEqual(
pool.get_lora_A_shape("down_proj_moe", model, 8, 0),
(2, num_local, 8, 96),
)
# 384 / 2 = 192 (B: moe_inter*2 / moe_tp_size).
self.assertEqual(
pool.get_lora_B_shape("gate_up_proj_moe", model, 8, 0),
(2, num_local, 192, 8),
)
# A: input_dim = hidden_size, unaffected by MoE TP.
self.assertEqual(
pool.get_lora_A_shape("gate_up_proj_moe", model, 8, 0),
(2, num_local, 16, 64),
)
def test_non_moe_modules_unaffected_by_moe_tp(self):
"""Non-MoE modules must continue to shard by the outer `tp_size`;
the MoE-TP substitution applies only to `*_moe` modules.
"""
pool = self._pool(tp_size=4, moe_tp_size=1, num_experts=128, ep_size=4)
model = _fake_base_model_with_hidden_dim(num_experts=128)
# o_proj is row-parallel: A input_dim sharded by tp_size, B unsharded.
o_a = pool.get_lora_A_shape("o_proj", model, 8, 0)
o_b = pool.get_lora_B_shape("o_proj", model, 8, 0)
# head_dim*num_heads / tp_size = 64 / 4 = 16; B output = hidden_size = 64.
self.assertEqual(o_a, (2, 8, 16))
self.assertEqual(o_b, (2, 64, 8))
# qkv_proj is column-parallel: A unsharded, B sharded by tp_size.
q_b = pool.get_lora_B_shape("qkv_proj", model, 8, 0)
# head_dim * (heads + 2*kv_heads) / tp_size = 8 * 24 / 4 = 48.
self.assertEqual(q_b, (2, 48, 8))
if __name__ == "__main__":
unittest.main()