[diffusion] Per-section LoRA adapters on fused linear layers (#34933)

This commit is contained in:
Andy Ye
2026-08-17 23:40:45 -07:00
committed by GitHub
parent 5eb117f6ca
commit 772018fe36
5 changed files with 520 additions and 36 deletions
@@ -34,6 +34,8 @@ def _build_cosmos3_param_names_mapping(gated_mlp: bool = True) -> dict:
unchanged.
"""
mapping = {
# PEFT saves adapter keys as <module>.lora_{A,B}.default.weight.
r"^(.*\.lora_[AB])\.default$": r"\1",
# Inherited from text pretraining; unused at diffusion inference.
r"^lm_head\.weight$": "",
r"^norm\.weight$": "",
@@ -11,6 +11,7 @@ import os
from sglang.multimodal_gen.runtime.pipelines_core.composed_pipeline_base import (
ComposedPipelineBase,
)
from sglang.multimodal_gen.runtime.pipelines_core.lora_pipeline import LoRAPipeline
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.cosmos3 import (
Cosmos3DecodingStage,
Cosmos3DenoisingStage,
@@ -25,7 +26,7 @@ from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
class Cosmos3Pipeline(ComposedPipelineBase):
class Cosmos3Pipeline(LoRAPipeline, ComposedPipelineBase):
"""Cosmos3 diffusion pipeline shared by T2V, I2V, and T2I.
Text is tokenized and embedded directly inside the transformer; there is
@@ -55,6 +55,60 @@ def _swap_peft_swiglu_fc1_lora_b(
return torch.cat([gate, value], dim=0)
def stack_or_compose_fused_lora(
a_list: list[torch.Tensor],
b_list: list[torch.Tensor],
adapter_alpha: int | None,
) -> tuple[torch.Tensor, torch.Tensor, int | None]:
"""Equal sections stay 3D-stacked; GQA sections become one 2D pair."""
if len({t.shape for t in a_list}) == 1 and len({t.shape for t in b_list}) == 1:
return torch.stack(a_list), torch.stack(b_list), None
ranks = [a.shape[0] for a in a_list]
outs = [b.shape[0] for b in b_list]
a_2d = torch.cat(a_list, dim=0)
b_2d = b_list[0].new_zeros(sum(outs), sum(ranks))
row = col = 0
for a, b, rank, out in zip(a_list, b_list, ranks, outs):
scale = 1.0 if adapter_alpha is None else adapter_alpha / rank
b_2d[row : row + out, col : col + rank] = (
b if scale == 1.0 else (b.float() * scale).to(dtype=b.dtype)
)
row += out
col += rank
return a_2d, b_2d, a_2d.shape[0]
def _store_fused_lora_groups(
adapter: dict[str, torch.Tensor],
to_merge_params: dict[Hashable, dict[Any, Any]],
adapter_alpha: int | None,
device: torch.device | str,
) -> None:
"""Write deferred fused lora_A/B groups into the adapter dict."""
for a_key, a_parts in list(to_merge_params.items()):
if not str(a_key).endswith(".lora_A"):
continue
base = str(a_key)[: -len(".lora_A")]
b_key = f"{base}.lora_B"
b_parts = to_merge_params.get(b_key)
n = max(a_parts) + 1
if (
b_parts is None
or set(a_parts) != set(range(n))
or set(b_parts) != set(range(n))
):
continue
a, b, fused_alpha = stack_or_compose_fused_lora(
[a_parts[i] for i in range(n)],
[b_parts[i] for i in range(n)],
adapter_alpha,
)
adapter[str(a_key)] = a.to(device)
adapter[b_key] = b.to(device)
if fused_alpha is not None:
adapter[f"{base}.alpha"] = torch.tensor(float(fused_alpha), device=device)
class LoRAPipeline(ComposedPipelineBase):
"""
Pipeline that supports injecting LoRA adapters into the diffusion transformer.
@@ -780,6 +834,9 @@ class LoRAPipeline(ComposedPipelineBase):
# see param mapping in HunyuanVideoArchConfig
if merge_index is not None:
to_merge_params[target_name][merge_index] = weight
# A/B of one fused layer must be laid out together (GQA B cannot stack).
if target_name.endswith((".lora_A", ".lora_B")):
continue
if len(to_merge_params[target_name]) == num_params_to_merge:
sorted_tensors = [
to_merge_params[target_name][i]
@@ -798,6 +855,13 @@ class LoRAPipeline(ComposedPipelineBase):
)
self.lora_adapters[lora_nickname][target_name] = weight.to(self.device)
_store_fused_lora_groups(
self.lora_adapters[lora_nickname],
to_merge_params,
adapter_lora_alpha,
self.device,
)
self.loaded_adapter_paths[lora_nickname] = lora_path
self.loaded_adapter_alphas[lora_nickname] = adapter_lora_alpha
logger.info("Rank %d: loaded LoRA adapter %s", rank, lora_path)
@@ -58,7 +58,10 @@ from sglang.multimodal_gen.runtime.managers.memory_managers.layerwise_offload im
is_layerwise_offloaded_module,
)
from sglang.multimodal_gen.runtime.pipelines.diffusers_pipeline import DiffusersPipeline
from sglang.multimodal_gen.runtime.pipelines_core.lora_pipeline import LoRAPipeline
from sglang.multimodal_gen.runtime.pipelines_core.lora_pipeline import (
LoRAPipeline,
stack_or_compose_fused_lora,
)
from sglang.multimodal_gen.runtime.utils.hf_diffusers_utils import maybe_download_model
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
from sglang.srt.weight_sync.tensor_bucket import (
@@ -215,11 +218,14 @@ def _build_module_weight_name_mapper(module: torch.nn.Module):
if not mapping_fns:
return None
def map_name(name: str) -> str:
def map_name(name: str) -> tuple[str, Any]:
mapped_name = name
merge_index = None
for mapping_fn in mapping_fns:
mapped_name = mapping_fn(mapped_name)[0]
return mapped_name
mapped_name, index, _ = mapping_fn(mapped_name)
if index is not None:
merge_index = index
return mapped_name, merge_index
return map_name
@@ -236,23 +242,24 @@ def _resolve_lora_ipc_layer_dict_key(
layer_prefix: str,
layer_dict: dict,
module: torch.nn.Module,
) -> tuple[Any | None, str]:
) -> tuple[Any | None, str, int | None]:
"""Map training-side LoRA layer prefix to lora_layers key (Layer 2)."""
layer = layer_dict.get(layer_prefix)
if layer is not None:
return layer, layer_prefix
return layer, layer_prefix, None
map_name = _build_module_weight_name_mapper(module)
if map_name is None:
return None, layer_prefix
return None, layer_prefix, None
mapped = _strip_param_weight_suffix(map_name(f"{layer_prefix}.weight"))
mapped_name, merge_index = map_name(f"{layer_prefix}.weight")
mapped = _strip_param_weight_suffix(mapped_name)
if mapped != layer_prefix:
layer = layer_dict.get(mapped)
if layer is not None:
return layer, mapped
return layer, mapped, merge_index
return None, layer_prefix
return None, layer_prefix, None
def _iter_module_weight_updates(
@@ -268,7 +275,7 @@ def _iter_module_weight_updates(
yield name, loaded_weight
continue
mapped_name = map_name(name) if map_name is not None else name
mapped_name = map_name(name)[0] if map_name is not None else name
if mapped_name in model_params:
yield mapped_name, loaded_weight
continue
@@ -628,35 +635,72 @@ class WeightsUpdater:
updated = 0
skipped = 0
unknown_layers: list[str] = []
with lora_pipeline._temporarily_disable_offload(target=target_module):
for layer_name, (lora_a, lora_b) in pairs.items():
layer, _resolved_key = _resolve_lora_ipc_layer_dict_key(
layer_name, layer_dict, dit_module
# Honor lora_merge_mode: merged and unmerged evaluation differ bitwise.
merge_mode = lora_pipeline._resolve_lora_merge_mode(None, None)
merge_weights = lora_pipeline._should_merge_lora_for_layers(
target_module, layer_dict, merge_mode
)
plain_pairs: list[tuple[torch.Tensor, torch.Tensor, Any]] = []
fused_sections: dict[Any, dict[int, tuple[torch.Tensor, torch.Tensor]]] = {}
for layer_name, (lora_a, lora_b) in pairs.items():
layer, _resolved_key, merge_index = _resolve_lora_ipc_layer_dict_key(
layer_name, layer_dict, dit_module
)
if layer is None:
logger.warning(
"Unknown LoRA layer name %s for target %s; skipping",
layer_name,
target_module,
)
if layer is None:
logger.warning(
"Unknown LoRA layer name %s for target %s; skipping",
layer_name,
target_module,
)
unknown_layers.append(layer_name)
skipped += 1
continue
inferred_rank = int(lora_a.shape[0])
alpha = lora_alpha if lora_alpha is not None else inferred_rank
if lora_rank is not None and lora_rank != inferred_rank:
logger.warning(
"LoRA rank mismatch for %s: payload=%d request=%d; using payload rank",
layer_name,
inferred_rank,
lora_rank,
)
unknown_layers.append(layer_name)
skipped += 1
continue
inferred_rank = int(lora_a.shape[-2])
if lora_rank is not None and lora_rank != inferred_rank:
logger.warning(
"LoRA rank mismatch for %s: payload=%d request=%d; using payload rank",
layer_name,
inferred_rank,
lora_rank,
)
if merge_index is None:
plain_pairs.append((lora_a, lora_b, layer))
else:
# Per-section pairs of one fused layer compose into one adapter.
fused_sections.setdefault(layer, {})[merge_index] = (lora_a, lora_b)
with lora_pipeline._temporarily_disable_offload(target=target_module):
for lora_a, lora_b, layer in plain_pairs:
inferred_rank = int(lora_a.shape[-2])
layer.lora_rank = inferred_rank
layer.lora_alpha = alpha
layer.lora_alpha = (
lora_alpha if lora_alpha is not None else inferred_rank
)
layer.set_lora_weights(
lora_a, lora_b, merge_weights=True, clear_existing=True
lora_a, lora_b, merge_weights=merge_weights, clear_existing=True
)
updated += 1
for layer, layer_sections in fused_sections.items():
indices = sorted(layer_sections)
lora_a, lora_b, fused_alpha = stack_or_compose_fused_lora(
[layer_sections[i][0] for i in indices],
[layer_sections[i][1] for i in indices],
lora_alpha,
)
if fused_alpha is not None:
# Composed pairs fold the scale; alpha == rank keeps it neutral.
layer.lora_rank = fused_alpha
layer.lora_alpha = fused_alpha
else:
inferred_rank = int(lora_a.shape[-2])
layer.lora_rank = inferred_rank
layer.lora_alpha = (
lora_alpha if lora_alpha is not None else inferred_rank
)
layer.set_lora_weights(
lora_a, lora_b, merge_weights=merge_weights, clear_existing=True
)
updated += len(layer_sections)
gc.collect()
torch.cuda.empty_cache()
@@ -0,0 +1,373 @@
# SPDX-License-Identifier: Apache-2.0
"""Fused-layer LoRA groups: stack-vs-compose decision, loader wiring, IPC resolution."""
from collections import defaultdict
from types import SimpleNamespace
from unittest.mock import patch
import torch
import torch.nn.functional as F
from sglang.multimodal_gen.runtime.layers.lora.linear import (
MergedColumnParallelLinearWithLoRA,
)
from sglang.multimodal_gen.runtime.pipelines_core.lora_pipeline import (
LoRAPipeline,
stack_or_compose_fused_lora,
)
from sglang.multimodal_gen.runtime.post_training.weights_updater import (
_resolve_lora_ipc_layer_dict_key,
)
_TP_RANK_PATCH = "sglang.multimodal_gen.runtime.layers.lora.linear.get_tp_rank"
_LOCAL_DEVICE_PATCH = (
"sglang.multimodal_gen.runtime.layers.lora.linear.get_local_torch_device"
)
# GQA-like fused layout: unequal q/k/v sections.
OUTPUT_SIZES = [8, 2, 2]
IN_DIM = 4
def _make_ab_lists(ranks: list[int]):
a_list, b_list = [], []
for index, rank in enumerate(ranks):
torch.manual_seed(100 + index * 10 + rank)
a_list.append(torch.randn(rank, IN_DIM))
b_list.append(torch.randn(OUTPUT_SIZES[index], rank))
return a_list, b_list
def _reference_delta(x, a_list, b_list, adapter_alpha):
out = torch.zeros(*x.shape[:-1], sum(OUTPUT_SIZES))
row = 0
for a, b in zip(a_list, b_list):
scale = 1.0 if adapter_alpha is None else adapter_alpha / a.shape[0]
out[..., row : row + b.shape[0]] = (x @ a.T @ b.T) * scale
row += b.shape[0]
return out
def test_compose_unequal_sections_matches_reference():
a_list, b_list = _make_ab_lists([2, 3, 1])
a_2d, b_2d, fused_alpha = stack_or_compose_fused_lora(a_list, b_list, 4)
assert a_2d.shape == (6, IN_DIM)
assert b_2d.shape == (sum(OUTPUT_SIZES), 6)
assert fused_alpha == 6
x = torch.randn(5, IN_DIM)
torch.testing.assert_close(
x @ a_2d.T @ b_2d.T,
_reference_delta(x, a_list, b_list, 4),
rtol=1e-5,
atol=1e-5,
)
def test_stack_kept_for_equal_sections():
torch.manual_seed(0)
a_list = [torch.randn(2, IN_DIM) for _ in range(2)]
b_list = [torch.randn(4, 2) for _ in range(2)]
a, b, fused_alpha = stack_or_compose_fused_lora(a_list, b_list, 4)
assert a.shape == (2, 2, IN_DIM)
assert b.shape == (2, 4, 2)
assert fused_alpha is None
torch.testing.assert_close(a[0], a_list[0])
torch.testing.assert_close(b[1], b_list[1])
class _FakeMergedLinear(torch.nn.Module):
def __init__(
self,
output_sizes: list[int],
in_dim: int,
weight: torch.Tensor | None = None,
output_partition_sizes: list[int] | None = None,
):
super().__init__()
self.output_sizes = output_sizes
self.output_partition_sizes = (
output_partition_sizes
if output_partition_sizes is not None
else output_sizes
)
if weight is None:
weight = torch.randn(sum(output_sizes), in_dim)
self.weight = torch.nn.Parameter(weight)
self.bias = None
self.skip_bias_add = False
self.gather_output = False
self.quant_method = SimpleNamespace(
apply=lambda layer, x, bias=None: F.linear(x, layer.weight, bias)
)
def _make_layer() -> MergedColumnParallelLinearWithLoRA:
torch.manual_seed(0)
return MergedColumnParallelLinearWithLoRA(_FakeMergedLinear(OUTPUT_SIZES, IN_DIM))
def _set_composed(layer, a_list, b_list, adapter_alpha, merge_weights=False):
a_2d, b_2d, fused_alpha = stack_or_compose_fused_lora(a_list, b_list, adapter_alpha)
layer.lora_rank = fused_alpha
layer.lora_alpha = fused_alpha
layer.set_lora_weights(a_2d, b_2d, merge_weights=merge_weights)
def test_unmerged_forward_applies_composed_pair():
layer = _make_layer()
base_weight = layer.base_layer.weight.detach().clone()
a_list, b_list = _make_ab_lists([2, 3, 1])
_set_composed(layer, a_list, b_list, 4)
assert not layer.merged
assert not layer.disable_lora
x = torch.randn(5, IN_DIM)
with patch(_TP_RANK_PATCH, return_value=0):
out, _ = layer.forward(x)
expected = x @ base_weight.T + _reference_delta(x, a_list, b_list, 4)
torch.testing.assert_close(out, expected, rtol=1e-5, atol=1e-5)
def test_merge_writes_section_rows_and_unmerge_restores():
layer = _make_layer()
base_weight = layer.base_layer.weight.detach().clone()
a_list, b_list = _make_ab_lists([2, 2, 2])
with (
patch(_TP_RANK_PATCH, return_value=0),
patch(_LOCAL_DEVICE_PATCH, return_value=torch.device("cpu")),
):
_set_composed(layer, a_list, b_list, 4, merge_weights=True)
assert layer.merged
merged = layer.base_layer.weight.detach().cpu()
row = 0
for a, b in zip(a_list, b_list):
expected_rows = base_weight[row : row + b.shape[0]] + (4 / a.shape[0]) * (b @ a)
torch.testing.assert_close(
merged[row : row + b.shape[0]], expected_rows, rtol=1e-5, atol=1e-5
)
row += b.shape[0]
with patch(_TP_RANK_PATCH, return_value=0):
layer.unmerge_lora_weights()
torch.testing.assert_close(layer.base_layer.weight.detach().cpu(), base_weight)
def test_composed_pair_shards_correctly_under_mock_tp():
tp_size = 2
a_list, b_list = _make_ab_lists([2, 3, 1])
a_2d, b_2d, fused_alpha = stack_or_compose_fused_lora(a_list, b_list, 4)
torch.manual_seed(0)
full_weight = torch.randn(sum(OUTPUT_SIZES), IN_DIM)
x = torch.randn(5, IN_DIM)
expected = x @ full_weight.T + _reference_delta(x, a_list, b_list, 4)
part_sizes = [size // tp_size for size in OUTPUT_SIZES]
rank_outputs = []
for tp_rank in range(tp_size):
local_rows = []
for index, (size, part) in enumerate(zip(OUTPUT_SIZES, part_sizes)):
row = sum(OUTPUT_SIZES[:index]) + tp_rank * part
local_rows.append(full_weight[row : row + part])
layer = MergedColumnParallelLinearWithLoRA(
_FakeMergedLinear(
OUTPUT_SIZES,
IN_DIM,
weight=torch.cat(local_rows, dim=0),
output_partition_sizes=part_sizes,
)
)
layer.lora_rank = fused_alpha
layer.lora_alpha = fused_alpha
layer.set_lora_weights(a_2d, b_2d, merge_weights=False)
with patch(_TP_RANK_PATCH, return_value=tp_rank):
out, _ = layer.forward(x)
rank_outputs.append(out)
# All-gather equivalent: stitch each section's rank slices back together.
stitched = []
for index, part in enumerate(part_sizes):
local_col = sum(part_sizes[:index])
stitched.append(
torch.cat(
[out[..., local_col : local_col + part] for out in rank_outputs],
dim=-1,
)
)
torch.testing.assert_close(
torch.cat(stitched, dim=-1), expected, rtol=1e-5, atol=1e-5
)
def test_ipc_key_resolution_returns_fused_merge_index():
module = torch.nn.Module()
module.param_names_mapping = {
r"^attn\.q\.(.*)$": (r"attn.to_qkv.\1", 0, 3),
r"^attn\.k\.(.*)$": (r"attn.to_qkv.\1", 1, 3),
r"^attn\.v\.(.*)$": (r"attn.to_qkv.\1", 2, 3),
r"^proj\.(.*)$": r"out_proj.\1",
}
sentinel = object()
layer_dict = {"attn.to_qkv": sentinel, "out_proj": sentinel}
layer, key, merge_index = _resolve_lora_ipc_layer_dict_key(
"attn.k", layer_dict, module
)
assert layer is sentinel
assert key == "attn.to_qkv"
assert merge_index == 1
layer, key, merge_index = _resolve_lora_ipc_layer_dict_key(
"proj", layer_dict, module
)
assert layer is sentinel
assert key == "out_proj"
assert merge_index is None
layer, key, merge_index = _resolve_lora_ipc_layer_dict_key(
"attn.to_qkv", layer_dict, module
)
assert layer is sentinel
assert merge_index is None
class _TestLoRAPipeline(LoRAPipeline):
def create_pipeline_stages(self, server_args):
return None
_QKV_MAPPING = {
r"^attn\.q\.(.*)$": (r"attn.to_qkv.\1", 0, 3),
r"^attn\.k\.(.*)$": (r"attn.to_qkv.\1", 1, 3),
r"^attn\.v\.(.*)$": (r"attn.to_qkv.\1", 2, 3),
r"^mlp\.gate\.(.*)$": (r"mlp.gate_up.\1", 0, 2),
r"^mlp\.up\.(.*)$": (r"mlp.gate_up.\1", 1, 2),
}
def _make_loader_pipeline() -> _TestLoRAPipeline:
pipeline = object.__new__(_TestLoRAPipeline)
pipeline.lora_adapters = defaultdict(dict)
pipeline.loaded_adapter_paths = {}
pipeline.loaded_adapter_alphas = {}
pipeline.device = "cpu"
pipeline.modules = {"transformer": torch.nn.Module()}
pipeline.server_args = SimpleNamespace(
lora_path=None,
lora_weight_name=None,
pipeline_config=SimpleNamespace(
dit_config=SimpleNamespace(
arch_config=SimpleNamespace(
param_names_mapping=_QKV_MAPPING,
lora_param_names_mapping={
r"^(.*\.lora_[AB])\.default$": r"\1",
},
)
)
),
)
return pipeline
def _load_adapter(pipeline, state_dict, lora_alpha=None):
loader_mod = "sglang.multimodal_gen.runtime.pipelines_core.lora_pipeline"
with (
patch(f"{loader_mod}.maybe_download_lora", return_value="/adapter"),
patch(f"{loader_mod}.load_file", return_value=state_dict),
patch(f"{loader_mod}.dist.is_initialized", return_value=False),
):
pipeline.load_lora_adapter("/adapter", "adapter", rank=0, lora_alpha=lora_alpha)
def _qkv_state_dict(rank=2):
torch.manual_seed(3)
state = {}
for name, rows in (("q", 8), ("k", 2), ("v", 2)):
state[f"attn.{name}.lora_A.default.weight"] = torch.randn(rank, IN_DIM)
state[f"attn.{name}.lora_B.default.weight"] = torch.randn(rows, rank)
return state
def test_loader_composes_unequal_fused_sections():
pipeline = _make_loader_pipeline()
_load_adapter(pipeline, _qkv_state_dict(), lora_alpha=4)
adapter = pipeline.lora_adapters["adapter"]
assert adapter["attn.to_qkv.lora_A"].shape == (6, IN_DIM)
assert adapter["attn.to_qkv.lora_B"].shape == (sum(OUTPUT_SIZES), 6)
assert adapter["attn.to_qkv.alpha"].item() == 6.0
assert set(adapter) == {
"attn.to_qkv.lora_A",
"attn.to_qkv.lora_B",
"attn.to_qkv.alpha",
}
def test_loader_stacks_equal_fused_sections():
pipeline = _make_loader_pipeline()
state_dict = {
"mlp.gate.lora_A.weight": torch.randn(2, IN_DIM),
"mlp.gate.lora_B.weight": torch.randn(4, 2),
"mlp.up.lora_A.weight": torch.randn(2, IN_DIM),
"mlp.up.lora_B.weight": torch.randn(4, 2),
}
_load_adapter(pipeline, state_dict)
adapter = pipeline.lora_adapters["adapter"]
assert adapter["mlp.gate_up.lora_A"].shape == (2, 2, IN_DIM)
assert adapter["mlp.gate_up.lora_B"].shape == (2, 4, 2)
assert "mlp.gate_up.alpha" not in adapter
def test_loader_drops_incomplete_fused_groups():
pipeline = _make_loader_pipeline()
state_dict = _qkv_state_dict()
for key in list(state_dict):
if ".k." in key:
del state_dict[key]
_load_adapter(pipeline, state_dict)
assert pipeline.lora_adapters["adapter"] == {}
def test_apply_composed_adapter_end_to_end():
layer = _make_layer()
base_weight = layer.base_layer.weight.detach().clone()
pipeline = _make_loader_pipeline()
rank = 2
adapter_alpha = 4
state_dict = _qkv_state_dict(rank=rank)
_load_adapter(pipeline, state_dict, lora_alpha=adapter_alpha)
strength = 2.0
with patch(
"sglang.multimodal_gen.runtime.pipelines_core.lora_pipeline.dist.get_rank",
return_value=0,
):
applied = pipeline._apply_lora_to_layers(
{"attn.to_qkv": layer},
["adapter"],
["/adapter"],
rank=0,
strengths=[strength],
merge_weights=False,
)
assert applied == 1
a_list = [
state_dict[f"attn.{name}.lora_A.default.weight"] for name in ("q", "k", "v")
]
b_list = [
state_dict[f"attn.{name}.lora_B.default.weight"] for name in ("q", "k", "v")
]
x = torch.randn(3, IN_DIM)
with patch(_TP_RANK_PATCH, return_value=0):
out, _ = layer.forward(x)
expected = (
x @ base_weight.T
+ _reference_delta(x, a_list, b_list, adapter_alpha) * strength
)
torch.testing.assert_close(out, expected, rtol=1e-5, atol=1e-5)