[diffusion] cli: support component attention backend overrides (#24320)

This commit is contained in:
Mick
2026-05-05 08:39:27 +08:00
committed by GitHub
parent 078f84d80d
commit 2f7d99b7f7
10 changed files with 444 additions and 43 deletions
@@ -6,8 +6,9 @@
import os
from collections.abc import Generator
from contextlib import contextmanager
from contextvars import ContextVar
from functools import cache
from typing import cast
from typing import NamedTuple, cast
import torch
@@ -63,6 +64,16 @@ def get_env_variable_attn_backend() -> AttentionBackendEnum | None:
forced_attn_backend: AttentionBackendEnum | None = None
class ComponentAttnBackendContext(NamedTuple):
backend: AttentionBackendEnum | None
component_name: str | None
component_attn_backend_context: ContextVar[ComponentAttnBackendContext | None] = (
ContextVar("component_attn_backend_context", default=None)
)
def global_force_attn_backend(attn_backend: AttentionBackendEnum | None) -> None:
"""
Force all attention operations to use a specified backend.
@@ -86,10 +97,25 @@ def get_global_forced_attn_backend() -> AttentionBackendEnum | None:
return forced_attn_backend
def get_component_attn_backend_context() -> ComponentAttnBackendContext | None:
return component_attn_backend_context.get()
def get_component_forced_attn_backend() -> AttentionBackendEnum | None:
context = get_component_attn_backend_context()
return context.backend if context is not None else None
def get_component_attn_backend_name() -> str | None:
context = get_component_attn_backend_context()
return context.component_name if context is not None else None
def get_attn_backend(
head_size: int,
dtype: torch.dtype,
supported_attention_backends: set[AttentionBackendEnum] | None = None,
selected_attention_backend: AttentionBackendEnum | None = None,
) -> type[AttentionBackend]:
if supported_attention_backends is None:
be_tuple = tuple()
@@ -98,7 +124,41 @@ def get_attn_backend(
be_tuple = tuple(
sorted(list(supported_attention_backends), key=lambda b: b.name)
)
return _cached_get_attn_backend(head_size, dtype, be_tuple)
selected_backend = selected_attention_backend or get_global_forced_attn_backend()
if selected_backend is None:
selected_backend = get_component_forced_attn_backend()
if selected_backend is None:
server_args = get_global_server_args()
if server_args.attention_backend is not None:
try:
selected_backend = AttentionBackendEnum[
server_args.attention_backend.upper()
]
except KeyError:
raise ValueError(
f"Invalid attention backend '{server_args.attention_backend}' specified via command line. "
f"Available options are: {[e.name.lower() for e in AttentionBackendEnum]}"
)
component_name = get_component_attn_backend_name()
backend_not_specified = selected_backend is None
attention_backend_cls = _cached_get_attn_backend(
head_size,
dtype,
be_tuple,
selected_backend,
)
if component_name:
backend_name = attention_backend_cls.get_enum().name.lower()
if backend_not_specified:
logger.info_once(
f"Attention backend not specified for {component_name}, "
f"using {backend_name} backend for {component_name}"
)
else:
logger.info_once(f"Using {backend_name} backend for {component_name}")
return attention_backend_cls
@cache
@@ -106,32 +166,11 @@ def _cached_get_attn_backend(
head_size: int,
dtype: torch.dtype,
supported_attention_backends: tuple[AttentionBackendEnum],
selected_backend: AttentionBackendEnum | None,
) -> type[AttentionBackend]:
# Check whether a particular choice of backend was
# previously forced via global_force_attn_backend() or --attention-backend CLI arg.
from sglang.multimodal_gen.runtime.platforms import current_platform
supported_attention_backends = set(supported_attention_backends)
selected_backend = None
backend_by_global_setting: AttentionBackendEnum | None = (
get_global_forced_attn_backend()
)
if backend_by_global_setting is not None:
selected_backend = backend_by_global_setting
else:
# Check the server arguments for a backend override
server_args = get_global_server_args()
if server_args.attention_backend is not None:
try:
selected_backend = AttentionBackendEnum[
server_args.attention_backend.upper()
]
except KeyError:
raise ValueError(
f"Invalid attention backend '{server_args.attention_backend}' specified via command line. "
f"Available options are: {[e.name.lower() for e in AttentionBackendEnum]}"
)
# get device-specific attn_backend
if len(supported_attention_backends) == 0:
@@ -140,14 +179,16 @@ def _cached_get_attn_backend(
elif selected_backend is None and len(supported_attention_backends) == 1:
selected_backend = next(iter(supported_attention_backends))
elif selected_backend is None:
logger.debug(f"Attention backend not specified")
logger.debug("Attention backend not specified")
elif selected_backend not in supported_attention_backends:
supported_attention_backends_str = [
supported_attention_backend.__str__()
for supported_attention_backend in supported_attention_backends
]
logger.debug(
f"Selected attention backend: '{selected_backend}' not in supported attention backends: {supported_attention_backends_str}"
"Selected attention backend: '%s' not in supported attention backends: %s",
selected_backend,
supported_attention_backends_str,
)
selected_backend = None
@@ -161,6 +202,24 @@ def _cached_get_attn_backend(
return cast(type[AttentionBackend], resolve_obj_by_qualname(attention_cls))
@contextmanager
def component_attn_backend_context_manager(
attn_backend: AttentionBackendEnum | None,
component_name: str | None = None,
) -> Generator[None, None, None]:
if attn_backend is None and component_name is None:
yield
return
token = component_attn_backend_context.set(
ComponentAttnBackendContext(attn_backend, component_name)
)
try:
yield
finally:
component_attn_backend_context.reset(token)
@contextmanager
def global_force_attn_backend_context_manager(
attn_backend: AttentionBackendEnum,
@@ -16,6 +16,10 @@ from transformers import AutoImageProcessor, AutoProcessor, AutoTokenizer
from sglang.multimodal_gen.configs.models import ModelConfig
from sglang.multimodal_gen.runtime.distributed import get_local_torch_device
from sglang.multimodal_gen.runtime.layers.attention.selector import (
component_attn_backend_context_manager,
get_component_attn_backend_context,
)
from sglang.multimodal_gen.runtime.loader.utils import (
_normalize_component_type,
component_name_to_loader_cls,
@@ -114,10 +118,26 @@ class ComponentLoader(ABC):
component_model_path,
gpu_mem_before_loading,
)
try:
component = self.load_customized(
component_model_path, server_args, component_name
attn_backend = None
component_attn_name = None
if get_component_attn_backend_context() is None:
attn_backend, matched_backend_key = (
server_args.resolve_component_attention_backend(component_name)
)
component_attn_name = matched_backend_key or component_name
if attn_backend is not None:
logger.info(
"Using %s backend for component: %s",
attn_backend.name.lower(),
matched_backend_key,
)
try:
with component_attn_backend_context_manager(
attn_backend, component_name=component_attn_name
):
component = self.load_customized(
component_model_path, server_args, component_name
)
source = "sgl-diffusion"
except Exception as e:
if "Unsupported model architecture" in str(e):
@@ -130,9 +150,12 @@ class ComponentLoader(ABC):
f"Error while loading customized {component_name}, falling back to native version"
)
# fallback to native version
component = self.load_native(
component_model_path, server_args, transformers_or_diffusers
)
with component_attn_backend_context_manager(
attn_backend, component_name=component_attn_name
):
component = self.load_native(
component_model_path, server_args, transformers_or_diffusers
)
should_offload = self.should_offload(server_args)
target_device = self.target_device(should_offload)
component = component.to(device=target_device)
@@ -18,6 +18,9 @@ from sglang.multimodal_gen.runtime.disaggregation.roles import (
RoleType,
filter_modules_for_role,
)
from sglang.multimodal_gen.runtime.layers.attention.selector import (
component_attn_backend_context_manager,
)
from sglang.multimodal_gen.runtime.loader.component_loaders.component_loader import (
PipelineComponentLoader,
)
@@ -411,13 +414,27 @@ class ComposedPipelineBase(ABC):
component_model_path = self._resolve_component_path(
server_args, module_name, load_module_name
)
module, memory_usage = PipelineComponentLoader.load_component(
component_name=load_module_name,
component_model_path=component_model_path,
transformers_or_diffusers=transformers_or_diffusers,
server_args=server_args,
component_architecture=architecture,
attn_backend, matched_backend_key = (
server_args.resolve_component_attention_backend(
module_name, load_module_name
)
)
if attn_backend is not None:
logger.info(
"Using %s backend for component: %s",
attn_backend.name.lower(),
matched_backend_key,
)
with component_attn_backend_context_manager(
attn_backend, component_name=matched_backend_key or module_name
):
module, memory_usage = PipelineComponentLoader.load_component(
component_name=load_module_name,
component_model_path=component_model_path,
transformers_or_diffusers=transformers_or_diffusers,
server_args=server_args,
component_architecture=architecture,
)
self.memory_usages[load_module_name] = memory_usage
@@ -179,10 +179,11 @@ class DenoisingStage(PipelineStage, RolloutDenoisingMixin):
self.vae = vae
self.pipeline = weakref.ref(pipeline) if pipeline else None
# TODO(will): hack, should use the actual one in dit
selected_attention_backend = self._infer_transformer_attention_backend()
self.attn_backend = get_attn_backend(
head_size=attn_head_size,
dtype=torch.float16,
selected_attention_backend=selected_attention_backend,
)
# cfg
@@ -195,6 +196,26 @@ class DenoisingStage(PipelineStage, RolloutDenoisingMixin):
self._cached_num_steps = None
self._is_warmed_up = False
def _infer_transformer_attention_backend(self) -> AttentionBackendEnum | None:
backends = {
backend
for transformer in (self.transformer, self.transformer_2)
if transformer is not None
for module in transformer.modules()
if isinstance(
(backend := getattr(module, "backend", None)), AttentionBackendEnum
)
}
if not backends:
return None
if len(backends) > 1:
logger.warning(
"Multiple transformer attention backends detected: %s. "
"Using one backend for denoising metadata.",
sorted(backend.name.lower() for backend in backends),
)
return sorted(backends, key=lambda backend: backend.name)[0]
def component_uses(
self, server_args: ServerArgs, stage_name: str | None = None
) -> list[ComponentUse]:
@@ -131,6 +131,9 @@ class ServerArgs(DisaggArgsMixin):
# Attention
attention_backend: str = None
attention_backend_config: addict.Dict | None = None
component_attention_backends: dict[str, str] | str | None = field(
default_factory=dict
)
cache_dit_config: str | dict[str, Any] | None = (
None # cache-dit config for diffusers
)
@@ -470,6 +473,11 @@ class ServerArgs(DisaggArgsMixin):
def _adjust_attention_backend(self):
if self.attention_backend in ["fa3", "fa4"]:
self.attention_backend = "fa"
self.component_attention_backends = (
self._normalize_component_attention_backends(
self.component_attention_backends
)
)
# attention_backend_config
if self.attention_backend_config is None:
@@ -512,6 +520,82 @@ class ServerArgs(DisaggArgsMixin):
return
self._set_default_attention_backend()
@staticmethod
def _normalize_attention_backend_name(backend: str) -> str:
if not isinstance(backend, str):
raise ValueError("Attention backend name must be a string")
normalized = backend.strip().lower()
if normalized in ("fa3", "fa4"):
normalized = "fa"
try:
return AttentionBackendEnum[normalized.upper()].name.lower()
except KeyError:
raise ValueError(
f"Invalid attention backend '{backend}'. "
f"Available options are: {[e.name.lower() for e in AttentionBackendEnum]}"
) from None
@staticmethod
def _parse_component_attention_backend_map(
value: dict[str, str] | str | None,
) -> dict[str, str]:
if value is None or value == "":
return {}
if isinstance(value, dict):
return dict(value)
if not isinstance(value, str):
raise ValueError(
"component_attention_backends must be a dict or a comma-separated component=backend string"
)
try:
parsed = json.loads(value)
if not isinstance(parsed, dict):
raise ValueError
return parsed
except (json.JSONDecodeError, ValueError):
pass
result: dict[str, str] = {}
for pair in value.split(","):
pair = pair.strip()
if not pair:
continue
if "=" not in pair:
raise ValueError(
"component_attention_backends must use component=backend entries"
)
component, backend = pair.split("=", 1)
result[component.strip()] = backend.strip()
return result
@classmethod
def _normalize_component_attention_backends(
cls, value: dict[str, str] | str | None
) -> dict[str, str]:
raw = cls._parse_component_attention_backend_map(value)
normalized: dict[str, str] = {}
for component, backend in raw.items():
if not isinstance(component, str):
raise ValueError("Component attention backend key must be a string")
component_name = component.strip().replace("-", "_")
if not component_name:
raise ValueError("Component attention backend key must not be empty")
normalized[component_name] = cls._normalize_attention_backend_name(backend)
return normalized
def resolve_component_attention_backend(
self, *component_names: str | None
) -> tuple[AttentionBackendEnum | None, str | None]:
for component_name in component_names:
if component_name is None:
continue
key = component_name.replace("-", "_")
backend = self.component_attention_backends.get(key)
if backend is not None:
return AttentionBackendEnum[backend.upper()], key
return None, None
def _adjust_warmup(self):
if self.warmup_resolutions is not None:
self.warmup = True
@@ -808,6 +892,16 @@ class ServerArgs(DisaggArgsMixin):
default=None,
help="Configuration for the attention backend. Can be a JSON string, a path to a JSON/YAML file, or key=value pairs.",
)
parser.add_argument(
"--component-attention-backends",
type=str,
default=None,
help=(
"Per-component attention backend overrides for native pipelines. "
"Use component names from model_index.json, e.g. "
"'text_encoder=torch_sdpa,transformer=fa'."
),
)
parser.add_argument(
"--cache-dit-config",
type=str,
@@ -1267,6 +1361,43 @@ class ServerArgs(DisaggArgsMixin):
component_paths[component] = path
return component_paths, remaining
@staticmethod
def _extract_component_attention_backends(
unknown_args: list[str],
) -> tuple[dict[str, str], list[str]]:
component_attention_backends: dict[str, str] = {}
remaining: list[str] = []
i = 0
while i < len(unknown_args):
arg = unknown_args[i]
key_part = arg.split("=", 1)[0] if "=" in arg else arg
component = None
if key_part.startswith("--component-attention-backends."):
component = key_part[len("--component-attention-backends.") :].replace(
"-", "_"
)
elif key_part.startswith("--component_attention_backends."):
component = key_part[len("--component_attention_backends.") :].replace(
"-", "_"
)
if component is not None:
if "=" in arg:
component_attention_backends[component] = arg.split("=", 1)[1]
elif i + 1 < len(unknown_args) and not unknown_args[i + 1].startswith(
"-"
):
i += 1
component_attention_backends[component] = unknown_args[i]
else:
remaining.append(arg)
i += 1
continue
else:
remaining.append(arg)
i += 1
return component_attention_backends, remaining
@classmethod
def from_cli_args(
cls, args: argparse.Namespace, unknown_args: list[str] | None = None
@@ -1276,6 +1407,9 @@ class ServerArgs(DisaggArgsMixin):
# extract dynamic --<component>-path from unknown args
dynamic_paths, remaining = cls._extract_component_paths(unknown_args)
dynamic_attention_backends, remaining = (
cls._extract_component_attention_backends(remaining)
)
if remaining:
raise SystemExit(f"error: unrecognized arguments: {' '.join(remaining)}")
@@ -1291,6 +1425,12 @@ class ServerArgs(DisaggArgsMixin):
existing = dict(provided_args.get("component_paths") or {})
existing.update(dynamic_paths)
provided_args["component_paths"] = existing
if dynamic_attention_backends:
existing = cls._parse_component_attention_backend_map(
provided_args.get("component_attention_backends")
)
existing.update(dynamic_attention_backends)
provided_args["component_attention_backends"] = existing
return cls.from_dict(provided_args)
@@ -48,6 +48,71 @@ class TestServerArgsPathExpansion(unittest.TestCase):
args.component_paths["vae"], os.path.expanduser("~/fake/local/vae")
)
def test_component_attention_backends_are_normalized(self):
args = self._from_dict_without_model_resolution(
{
"model_path": "/data/my-model",
"component_attention_backends": "text-encoder=torch_sdpa,transformer=fa3",
}
)
self.assertEqual(
args.component_attention_backends,
{"text_encoder": "torch_sdpa", "transformer": "fa"},
)
def test_component_attention_backend_lookup(self):
args = self._from_dict_without_model_resolution(
{
"model_path": "/data/my-model",
"component_attention_backends": {"text_encoder": "torch_sdpa"},
}
)
backend, matched_key = args.resolve_component_attention_backend(
"text_encoder", "transformer"
)
self.assertEqual(backend.name, "TORCH_SDPA")
self.assertEqual(matched_key, "text_encoder")
def test_invalid_component_attention_backend_raises(self):
with self.assertRaises(ValueError):
self._from_dict_without_model_resolution(
{
"model_path": "/data/my-model",
"component_attention_backends": {"text_encoder": "bad_backend"},
}
)
with self.assertRaises(ValueError):
self._from_dict_without_model_resolution(
{
"model_path": "/data/my-model",
"component_attention_backends": "text_encoder",
}
)
def test_dynamic_component_attention_backend_cli_args(self):
parser = FlexibleArgumentParser()
ServerArgs.add_cli_args(parser)
argv = [
"--model-path",
"/fake",
"--component-attention-backends.text-encoder",
"torch_sdpa",
]
with patch.object(sys, "argv", ["sglang"] + argv):
args, unknown_args = parser.parse_known_args(argv)
with patch.object(
PipelineConfig, "from_kwargs", return_value=QwenImagePipelineConfig()
):
server_args = ServerArgs.from_cli_args(args, unknown_args)
self.assertEqual(
server_args.component_attention_backends, {"text_encoder": "torch_sdpa"}
)
class TestOffloadDefaults(unittest.TestCase):
def _from_dict_with_task_type(