293 lines
12 KiB
Python
293 lines
12 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# Adapted from vllm: https://github.com/vllm-project/vllm/blob/v0.7.3/vllm/attention/selector.py
|
|
|
|
import os
|
|
from collections.abc import Generator
|
|
from contextlib import contextmanager
|
|
from contextvars import ContextVar
|
|
from dataclasses import dataclass
|
|
from functools import cache
|
|
from typing import cast
|
|
|
|
import torch
|
|
|
|
import fastvideo.envs as envs
|
|
from fastvideo.attention.backends.abstract import AttentionBackend
|
|
from fastvideo.logger import init_logger
|
|
from fastvideo.platforms import AttentionBackendEnum
|
|
from fastvideo.utils import STR_BACKEND_ENV_VAR, resolve_obj_by_qualname
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
|
|
def backend_name_to_enum(backend_name: str) -> AttentionBackendEnum | None:
|
|
"""
|
|
Convert a string backend name to a _Backend enum value.
|
|
|
|
Returns:
|
|
* _Backend: enum value if backend_name is a valid in-tree type
|
|
* None: otherwise it's an invalid in-tree type or an out-of-tree platform is
|
|
loaded.
|
|
"""
|
|
assert backend_name is not None
|
|
return AttentionBackendEnum[backend_name] if backend_name in AttentionBackendEnum.__members__ else \
|
|
None
|
|
|
|
|
|
def coerce_attn_backend(attn_backend: AttentionBackendEnum | str | None, ) -> AttentionBackendEnum | None:
|
|
"""Normalize an explicit backend selection.
|
|
|
|
Environment-variable parsing remains permissive via
|
|
:func:`backend_name_to_enum`, but typed/config-driven call sites should
|
|
fail fast on typos instead of silently falling back to another backend.
|
|
"""
|
|
if attn_backend is None or isinstance(attn_backend, AttentionBackendEnum):
|
|
return attn_backend
|
|
if not isinstance(attn_backend, str) or not attn_backend.strip():
|
|
raise ValueError("attention backend must be a non-empty string, "
|
|
f"an AttentionBackendEnum, or None; got {attn_backend!r}")
|
|
|
|
backend_name = attn_backend.strip().upper()
|
|
backend = backend_name_to_enum(backend_name)
|
|
if backend is None:
|
|
raise ValueError(f"Unknown attention backend {attn_backend!r}. "
|
|
f"Expected one of {sorted(AttentionBackendEnum.__members__)}")
|
|
return backend
|
|
|
|
|
|
def get_env_variable_attn_backend() -> AttentionBackendEnum | None:
|
|
'''
|
|
Get the backend override specified by the FastVideo attention
|
|
backend environment variable, if one is specified.
|
|
|
|
Returns:
|
|
|
|
* _Backend enum value if an override is specified
|
|
* None otherwise
|
|
'''
|
|
backend_name = os.environ.get(STR_BACKEND_ENV_VAR)
|
|
return (None if backend_name is None else backend_name_to_enum(backend_name))
|
|
|
|
|
|
class _NoRequest:
|
|
"""Type of the "caller expressed no opinion" sentinel.
|
|
|
|
``None`` cannot carry that meaning: it is a real answer — "this component
|
|
resolved to automatic selection" — and a caller passing it wants automatic
|
|
selection, not a fallback to whatever the environment says.
|
|
"""
|
|
__slots__ = ()
|
|
|
|
def __repr__(self) -> str:
|
|
return "<no request>"
|
|
|
|
|
|
NO_REQUEST = _NoRequest()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _BackendScope:
|
|
backend: AttentionBackendEnum | None
|
|
component: str | None
|
|
consult_env: bool
|
|
|
|
|
|
_SCOPE: ContextVar[_BackendScope | None] = ContextVar("fastvideo_attention_backend_scope", default=None)
|
|
|
|
|
|
@contextmanager
|
|
def _component_attention_backend_scope(
|
|
attn_backend: AttentionBackendEnum | str | None,
|
|
component: str | None = None,
|
|
*,
|
|
consult_env: bool = False,
|
|
) -> Generator[None, None, None]:
|
|
"""Carry a component's resolved request from its loader down to its layers.
|
|
|
|
**Transitional plumbing, not a feature.** The decision is resolved once at
|
|
load time and written onto the component's own config
|
|
(``ModelConfig._resolved_attention_backend``); this scope exists only
|
|
because the layer constructors nested inside a model do not yet receive
|
|
that config. They are handed a bare ``supported_attention_backends`` tuple,
|
|
threaded through block constructors up to four levels deep.
|
|
|
|
Deletion path: thread the request alongside that tuple, one model family at
|
|
a time, starting with the families that carry per-role requests (Wan,
|
|
LTX-2, Kandinsky5). When the last family reads its request from its own
|
|
config, this scope, ``_SCOPE`` and ``_BackendScope`` all go away and
|
|
``get_attn_backend`` takes the request as a plain argument.
|
|
|
|
Nothing switches backends at runtime and nothing should: every caller wraps
|
|
*construction*. Process-local, exception-safe, nestable, and part of the
|
|
resolution cache key, so it never needs a cache flush.
|
|
|
|
``_component_attention_backend_scope(None)`` means "automatic selection,
|
|
ignore any process-wide request" (dense teacher/critic roles built
|
|
alongside a quantized student); ``consult_env=True`` re-enables the env
|
|
fallback inside a scope.
|
|
"""
|
|
token = _SCOPE.set(_BackendScope(coerce_attn_backend(attn_backend), component, consult_env))
|
|
try:
|
|
yield
|
|
finally:
|
|
_SCOPE.reset(token)
|
|
|
|
|
|
def _active_component_attention_backend_scope() -> "_BackendScope | None":
|
|
"""The request governing the component being constructed right now, or None."""
|
|
return _SCOPE.get()
|
|
|
|
|
|
def record_resolved_attention_backend(config: object) -> AttentionBackendEnum | None:
|
|
"""Write the request governing construction *right now* onto a component config.
|
|
|
|
Called by each loader while its component is being built, so the decision
|
|
ends up on the object the component keeps and is readable from the component
|
|
after load. The loader is the right place rather than the caller above it:
|
|
a loader may narrow the request for one component (the DMD teacher/critic
|
|
transformers build dense inside a nested scope), and only it knows that.
|
|
"""
|
|
scope = _SCOPE.get()
|
|
resolved = scope.backend if scope is not None else None
|
|
config._resolved_attention_backend = resolved # type: ignore[attr-defined]
|
|
return resolved
|
|
|
|
|
|
def component_attention_backend(component: object) -> AttentionBackendEnum | _NoRequest:
|
|
"""Read back the decision :func:`record_resolved_attention_backend` wrote.
|
|
|
|
Returns ``NO_REQUEST`` unless the component recorded a *concrete* backend,
|
|
so a caller that passes this through only overrides the ambient fallback
|
|
when there is a real decision to override it with.
|
|
|
|
``NO_REQUEST`` rather than ``None`` for the no-decision case is deliberate,
|
|
and cannot be derived from the attribute's presence: ``ModelConfig`` declares
|
|
``_resolved_attention_backend`` as a field defaulting to ``None``, so the
|
|
attribute always exists and a ``getattr`` default can never fire. Worse,
|
|
``record_resolved_attention_backend`` writes ``None`` whenever no scope is
|
|
active, so "resolved to automatic selection" and "never recorded" are the
|
|
same stored value. Neither state should suppress the environment variable at
|
|
a call site that previously honoured it, and collapsing both to
|
|
``NO_REQUEST`` keeps that behavior identical.
|
|
"""
|
|
resolved = getattr(getattr(component, "config", None), "_resolved_attention_backend", None)
|
|
return NO_REQUEST if resolved is None else resolved
|
|
|
|
|
|
def get_attn_backend(
|
|
head_size: int,
|
|
dtype: torch.dtype,
|
|
supported_attention_backends: tuple[AttentionBackendEnum, ...]
|
|
| None = None,
|
|
default_backend: AttentionBackendEnum | None = None,
|
|
*,
|
|
requested: AttentionBackendEnum | None | _NoRequest = NO_REQUEST,
|
|
) -> type[AttentionBackend]:
|
|
"""Resolve the attention backend class for one call site.
|
|
|
|
``requested`` is the decision made for the component this call site belongs
|
|
to — read from ``ModelConfig._resolved_attention_backend``. Passing it means
|
|
the caller knows the answer, so nothing ambient is consulted:
|
|
|
|
* ``requested=SOME_BACKEND`` — use it (subject to the layer's declared
|
|
support), ignoring the construction scope and the environment;
|
|
* ``requested=None`` — that component resolved to *automatic selection*.
|
|
That is an answer, not an absence, so the environment is not consulted
|
|
behind it;
|
|
* ``requested`` omitted (``NO_REQUEST``) — the caller has no opinion; fall
|
|
back to the construction scope, then the environment. This is the path
|
|
every layer built inside a loader still takes.
|
|
"""
|
|
# Resolve every selection-affecting input BEFORE the cache so all of them
|
|
# live in the cache key: no mutation (env, scope) ever needs a cache_clear
|
|
# again, and components with different requests get distinct cache entries
|
|
# (per-component resolution).
|
|
if not isinstance(requested, _NoRequest):
|
|
# Explicit: the component's own decision outranks anything ambient.
|
|
component = None
|
|
env_backend = None
|
|
else:
|
|
scope = _SCOPE.get()
|
|
if scope is not None:
|
|
requested = scope.backend
|
|
component = scope.component
|
|
env_backend = envs.FASTVIDEO_ATTENTION_BACKEND if scope.consult_env else None
|
|
else:
|
|
requested = None
|
|
component = None
|
|
env_backend = envs.FASTVIDEO_ATTENTION_BACKEND
|
|
# The active device is a real selection input, not bookkeeping: the
|
|
# platform's backend resolution runs capability probes against the
|
|
# *current* device (e.g. AttnQatInferBackend's per-arch capability sets
|
|
# decide sm_12x CUTLASS vs sm_100/sm_103 FP4 FA4 vs FlashAttention
|
|
# fallback). Keying on it means a resolution taken for one device is
|
|
# never handed to another.
|
|
device_index = torch.cuda.current_device() if torch.cuda.is_available() else None
|
|
return _cached_get_attn_backend(
|
|
head_size,
|
|
dtype,
|
|
supported_attention_backends,
|
|
default_backend,
|
|
requested=requested,
|
|
env_backend=env_backend,
|
|
component=component,
|
|
device_index=device_index,
|
|
)
|
|
|
|
|
|
@cache
|
|
def _cached_get_attn_backend(
|
|
head_size: int,
|
|
dtype: torch.dtype,
|
|
supported_attention_backends: tuple[AttentionBackendEnum, ...]
|
|
| None = None,
|
|
default_backend: AttentionBackendEnum | None = None,
|
|
*,
|
|
requested: AttentionBackendEnum | None = None,
|
|
env_backend: str | None = None,
|
|
component: str | None = None,
|
|
device_index: int | None = None,
|
|
) -> type[AttentionBackend]:
|
|
# Pure resolution: every selection input arrives as an argument (and is
|
|
# therefore part of the functools cache key). Only get_attn_backend calls
|
|
# this; it performs the scope/env/force reads.
|
|
if not supported_attention_backends:
|
|
raise ValueError("supported_attention_backends is empty")
|
|
# Cache-key-only inputs: component identity separates two components that
|
|
# agree on shape/dtype but not on request; device_index separates
|
|
# resolutions taken under different device capabilities (the platform
|
|
# probes the current device below).
|
|
del component, device_index
|
|
# Precedence: the per-component request (explicit argument, else the
|
|
# construction scope), then the env var (already resolved to None by the
|
|
# wrapper whenever the caller supplied a request), then the layer-declared
|
|
# default. Every one of these arrived as an argument, so all of them are in
|
|
# the cache key.
|
|
selected_backend = requested
|
|
if selected_backend is None and env_backend is not None:
|
|
selected_backend = backend_name_to_enum(env_backend)
|
|
|
|
# Layer-level default (e.g. a checkpoint that requires a specific sparse
|
|
# backend). Lower precedence than the force/scope/env requests, so
|
|
# users can still override it.
|
|
if selected_backend is None and default_backend is not None:
|
|
selected_backend = default_backend
|
|
|
|
# get device-specific attn_backend
|
|
from fastvideo.platforms import current_platform
|
|
|
|
if (selected_backend is not None and selected_backend not in supported_attention_backends):
|
|
fallback_backend = (default_backend if default_backend in supported_attention_backends else None)
|
|
logger.warning(
|
|
"Requested attention backend %s is not supported by this "
|
|
"layer; supported backends are %s. Falling back to %s.",
|
|
selected_backend.name,
|
|
[b.name for b in supported_attention_backends],
|
|
fallback_backend.name if fallback_backend is not None else "automatic selection",
|
|
)
|
|
selected_backend = fallback_backend
|
|
attention_cls = current_platform.get_attn_backend_cls(selected_backend, head_size, dtype)
|
|
if not attention_cls:
|
|
raise ValueError(f"Invalid attention backend for {current_platform.device_name}")
|
|
return cast(type[AttentionBackend], resolve_obj_by_qualname(attention_cls))
|