refactor: rework attention backend resolution
- explicit attn_backend() context wins over ASTR_BACKEND env - polymorphic available()/supports_call() replace isinstance dispatch - cache singleton backend instances to avoid hot-path allocation - training (fwd=None) resolves cuda > flash > torch by capability - flash dense supports mask-free calls only; masked training falls back to torch
This commit is contained in:
@@ -21,9 +21,20 @@ Usage — mirroring ``torch.nn.attention.sdpa_kernel``:
|
|||||||
...
|
...
|
||||||
|
|
||||||
Thread-safe via ``contextvars`` — each scheduler thread gets its own
|
Thread-safe via ``contextvars`` — each scheduler thread gets its own
|
||||||
active backend. ``get_backend()`` returns the active one, falling back
|
active backend. Backend resolution follows a strict precedence:
|
||||||
to a process-wide default (cuda > flash > torch, overridable via
|
|
||||||
``ASTR_BACKEND``).
|
1. explicit ``attn_backend(...)`` context (wins over everything),
|
||||||
|
2. the process-wide ``ASTR_BACKEND`` environment override,
|
||||||
|
3. an implicit default picked from the available backends
|
||||||
|
(cuda > flash > torch).
|
||||||
|
|
||||||
|
Capability is polymorphic: every backend declares ``available()``
|
||||||
|
(machine-level) and ``supports_call(...)`` (per-call), so adding a new
|
||||||
|
backend requires no changes to the resolution logic. Training calls
|
||||||
|
(``fwd=None``, no KV cache) resolve through the same priority list: the
|
||||||
|
CUDA cache kernels cannot run without a cache, so they fall back to
|
||||||
|
flash (when it can handle the call — mask-free/causal only) and finally
|
||||||
|
to the reference ``TorchNativeBackend``.
|
||||||
|
|
||||||
Layout convention: all q/k/v are ``[batch, seq_len, n_heads, head_dim]``
|
Layout convention: all q/k/v are ``[batch, seq_len, n_heads, head_dim]``
|
||||||
(blhd). The backend returns ``[batch, seq_len, n_heads * head_dim]``.
|
(blhd). The backend returns ``[batch, seq_len, n_heads * head_dim]``.
|
||||||
@@ -32,11 +43,12 @@ Layout convention: all q/k/v are ``[batch, seq_len, n_heads, head_dim]``
|
|||||||
import contextvars
|
import contextvars
|
||||||
import enum
|
import enum
|
||||||
import functools
|
import functools
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from typing import TYPE_CHECKING, Optional, Union
|
from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union
|
||||||
|
|
||||||
import torch
|
import torch
|
||||||
import torch.nn.functional as F
|
import torch.nn.functional as F
|
||||||
@@ -57,8 +69,9 @@ except Exception:
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from astrai.inference.cache import KVCache
|
from astrai.inference.cache import KVCache
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
_default_backend: Optional["AttentionBackend"] = None
|
|
||||||
_default_backend_lock = threading.Lock()
|
_default_backend_lock = threading.Lock()
|
||||||
_env_backend_name: Optional[str] = None
|
_env_backend_name: Optional[str] = None
|
||||||
_env_backend: Optional["AttentionBackend"] = None
|
_env_backend: Optional["AttentionBackend"] = None
|
||||||
@@ -66,6 +79,10 @@ _current_backend: contextvars.ContextVar[Optional["AttentionBackend"]] = (
|
|||||||
contextvars.ContextVar("attn_backend", default=None)
|
contextvars.ContextVar("attn_backend", default=None)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Backends are stateless — one canonical instance per class, created lazily
|
||||||
|
# and reused everywhere (resolution, fallback, context managers).
|
||||||
|
_singletons: Dict[type, "AttentionBackend"] = {}
|
||||||
|
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=1)
|
@functools.lru_cache(maxsize=1)
|
||||||
def flash_attn_available() -> bool:
|
def flash_attn_available() -> bool:
|
||||||
@@ -102,58 +119,40 @@ class ATTN_BACKEND(enum.Enum):
|
|||||||
FLASH = "flash"
|
FLASH = "flash"
|
||||||
|
|
||||||
|
|
||||||
def _priority_backends() -> list["AttentionBackend"]:
|
def _instance(backend_cls: type) -> "AttentionBackend":
|
||||||
"""Available backends in priority order: cuda -> flash -> torch."""
|
"""Return the canonical singleton instance for a backend class.
|
||||||
backends: list[AttentionBackend] = []
|
|
||||||
if is_available("attn_paged_decode") and is_available("attn_paged_prefill"):
|
|
||||||
backends.append(CudaBackend())
|
|
||||||
if flash_attn_available():
|
|
||||||
backends.append(FlashAttnBackend())
|
|
||||||
backends.append(TorchNativeBackend())
|
|
||||||
return backends
|
|
||||||
|
|
||||||
|
Backends hold no per-instance state, so a single cached instance is
|
||||||
def _backend_supports(
|
safe and avoids per-call allocation on the attention hot path.
|
||||||
backend: "AttentionBackend",
|
|
||||||
q: Tensor,
|
|
||||||
kv_cache: Optional["KVCache"],
|
|
||||||
attn_mask: Optional[Tensor],
|
|
||||||
is_causal: bool,
|
|
||||||
fwd: Optional[str],
|
|
||||||
) -> bool:
|
|
||||||
"""Whether ``backend`` can run this attention call.
|
|
||||||
|
|
||||||
The CUDA kernels are bf16-only, support head_dim in 32/64/128/256, and
|
|
||||||
need a KV cache (decode/prefill); everything else falls back to torch.
|
|
||||||
"""
|
"""
|
||||||
if isinstance(backend, CudaBackend):
|
backend = _singletons.get(backend_cls)
|
||||||
return (
|
if backend is None:
|
||||||
fwd in ("prefill", "decode")
|
backend = backend_cls()
|
||||||
and kv_cache is not None
|
_singletons[backend_cls] = backend
|
||||||
and q.ndim == 3
|
return backend
|
||||||
and q.dtype == torch.bfloat16
|
|
||||||
and q.size(-1) in (32, 64, 128, 256)
|
|
||||||
and is_available(f"attn_paged_{fwd}")
|
@functools.lru_cache(maxsize=1)
|
||||||
|
def _priority_backends() -> Tuple["AttentionBackend", ...]:
|
||||||
|
"""Available backends in priority order: cuda -> flash -> torch.
|
||||||
|
|
||||||
|
Computed once (machine availability cannot change at runtime) and
|
||||||
|
cached forever; the tuple always ends with ``TorchNativeBackend``,
|
||||||
|
which is unconditionally available.
|
||||||
|
"""
|
||||||
|
return tuple(
|
||||||
|
_instance(cls)
|
||||||
|
for cls in (CudaBackend, FlashAttnBackend, TorchNativeBackend)
|
||||||
|
if cls.available()
|
||||||
)
|
)
|
||||||
if isinstance(backend, FlashAttnBackend):
|
|
||||||
if not flash_attn_available():
|
|
||||||
return False
|
|
||||||
if q.dtype not in (torch.float16, torch.bfloat16):
|
|
||||||
return False
|
|
||||||
if fwd is not None:
|
|
||||||
return q.ndim == 3 and hasattr(_flash_attn, "flash_attn_varlen_func")
|
|
||||||
if attn_mask is None or is_causal:
|
|
||||||
return True
|
|
||||||
return attn_mask.dim() == 4
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_default_backend() -> "AttentionBackend":
|
def _resolve_default_backend() -> "AttentionBackend":
|
||||||
"""Pick the highest-priority available backend (cuda -> flash -> torch).
|
"""Pick the highest-priority available backend (cuda -> flash -> torch).
|
||||||
|
|
||||||
Resolved lazily on first ``get_backend()`` and cached. Per-call
|
Resolved lazily on first use and cached via ``_priority_backends``.
|
||||||
capability fallback happens in ``attention()``, so the default is
|
Per-call capability fallback happens in ``attention()``, so the
|
||||||
safe for training and fp32 models.
|
default is safe for training and fp32 models.
|
||||||
"""
|
"""
|
||||||
return _priority_backends()[0]
|
return _priority_backends()[0]
|
||||||
|
|
||||||
@@ -168,9 +167,14 @@ def _environment_backend() -> Optional["AttentionBackend"]:
|
|||||||
with _default_backend_lock:
|
with _default_backend_lock:
|
||||||
if name != _env_backend_name:
|
if name != _env_backend_name:
|
||||||
try:
|
try:
|
||||||
_env_backend = AttentionBackendFactory.create(name)
|
_env_backend = _resolve_backend(name)
|
||||||
except (ValueError, RuntimeError):
|
except (ValueError, RuntimeError):
|
||||||
_env_backend = None
|
_env_backend = None
|
||||||
|
logger.warning(
|
||||||
|
"ASTR_BACKEND=%r is not a registered attention backend; "
|
||||||
|
"falling back to default resolution",
|
||||||
|
name,
|
||||||
|
)
|
||||||
_env_backend_name = name
|
_env_backend_name = name
|
||||||
return _env_backend
|
return _env_backend
|
||||||
|
|
||||||
@@ -178,43 +182,46 @@ def _environment_backend() -> Optional["AttentionBackend"]:
|
|||||||
def _resolve_backend(
|
def _resolve_backend(
|
||||||
backend: Optional[Union[str, ATTN_BACKEND, "AttentionBackend", type]] = None,
|
backend: Optional[Union[str, ATTN_BACKEND, "AttentionBackend", type]] = None,
|
||||||
) -> "AttentionBackend":
|
) -> "AttentionBackend":
|
||||||
"""Resolve a backend configuration, defaulting to the process policy."""
|
"""Resolve a backend configuration to its canonical instance.
|
||||||
|
|
||||||
|
Accepts a registered name, ``ATTN_BACKEND`` enum value, backend class,
|
||||||
|
or instance. Names/classes resolve to the shared singleton; a caller
|
||||||
|
may still pass its own instance to opt out of sharing.
|
||||||
|
"""
|
||||||
if backend is not None:
|
if backend is not None:
|
||||||
if isinstance(backend, ATTN_BACKEND):
|
if isinstance(backend, ATTN_BACKEND):
|
||||||
return AttentionBackendFactory.create(backend.value)
|
return _instance(AttentionBackendFactory.get_component_class(backend.value))
|
||||||
if isinstance(backend, str):
|
if isinstance(backend, str):
|
||||||
return AttentionBackendFactory.create(backend)
|
return _instance(AttentionBackendFactory.get_component_class(backend))
|
||||||
if isinstance(backend, type) and issubclass(backend, AttentionBackend):
|
if isinstance(backend, type) and issubclass(backend, AttentionBackend):
|
||||||
return backend()
|
return _instance(backend)
|
||||||
if isinstance(backend, AttentionBackend):
|
if isinstance(backend, AttentionBackend):
|
||||||
return backend
|
return backend
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
f"expected a registered name, ATTN_BACKEND, AttentionBackend type, "
|
f"expected a registered name, ATTN_BACKEND, AttentionBackend type, "
|
||||||
f"or instance, got {type(backend).__name__}"
|
f"or instance, got {type(backend).__name__}"
|
||||||
)
|
)
|
||||||
|
return _resolve_default_backend()
|
||||||
global _default_backend
|
|
||||||
if _default_backend is None:
|
|
||||||
with _default_backend_lock:
|
|
||||||
if _default_backend is None:
|
|
||||||
_default_backend = _resolve_default_backend()
|
|
||||||
return _default_backend
|
|
||||||
|
|
||||||
|
|
||||||
def get_backend(
|
def get_backend(
|
||||||
use_default: bool = True,
|
use_default: bool = True,
|
||||||
) -> Optional["AttentionBackend"]:
|
) -> Optional["AttentionBackend"]:
|
||||||
"""Return the context override, optionally falling back to the process default.
|
"""Resolve the active backend: explicit context > env > default.
|
||||||
|
|
||||||
``ASTR_BACKEND`` is a process-wide override and takes precedence over the
|
An ``attn_backend(...)`` context is the caller's explicit choice and
|
||||||
context value. Pass ``use_default=False`` at request submission to retain
|
always wins. ``ASTR_BACKEND`` is a process-wide override consulted
|
||||||
only an environment override or the caller's :func:`attn_backend` value.
|
only when no context is set. Pass ``use_default=False`` at request
|
||||||
|
submission to retain only an environment override or the caller's
|
||||||
|
:func:`attn_backend` value.
|
||||||
"""
|
"""
|
||||||
return (
|
context_backend = _current_backend.get()
|
||||||
_environment_backend()
|
if context_backend is not None:
|
||||||
or _current_backend.get()
|
return context_backend
|
||||||
or (_resolve_backend() if use_default else None)
|
env_backend = _environment_backend()
|
||||||
)
|
if env_backend is not None:
|
||||||
|
return env_backend
|
||||||
|
return _resolve_default_backend() if use_default else None
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
@@ -262,13 +269,23 @@ def attention(
|
|||||||
attn_mask: Optional[Tensor] = None,
|
attn_mask: Optional[Tensor] = None,
|
||||||
is_causal: bool = False,
|
is_causal: bool = False,
|
||||||
fwd: Optional[str] = None,
|
fwd: Optional[str] = None,
|
||||||
|
backend: Optional[Union[str, ATTN_BACKEND, "AttentionBackend", type]] = None,
|
||||||
) -> Tensor:
|
) -> Tensor:
|
||||||
"""Functional attention entry point — mirrors ``F.scaled_dot_product_attention``.
|
"""Functional attention entry point — mirrors ``F.scaled_dot_product_attention``.
|
||||||
|
|
||||||
Delegates to the active backend (set via ``with attn_backend(...)``).
|
Delegates to the active backend. ``backend`` (optional) is an explicit
|
||||||
|
escape hatch; when omitted the backend is resolved as
|
||||||
|
explicit context > ``ASTR_BACKEND`` env > default (cuda > flash > torch).
|
||||||
Handles KV cache I/O, GQA head expansion, and causal masking so the
|
Handles KV cache I/O, GQA head expansion, and causal masking so the
|
||||||
caller only needs to provide projected q/k/v.
|
caller only needs to provide projected q/k/v.
|
||||||
|
|
||||||
|
Training calls (``fwd=None``, ``kv_cache=None``) resolve through the
|
||||||
|
same capability chain — the CUDA cache kernels cannot run without a
|
||||||
|
cache, so they fall back to flash (mask-free/causal calls only) and
|
||||||
|
finally to torch SDPA. An explicitly-selected backend that cannot
|
||||||
|
handle the call raises — an implicit one falls back down the priority
|
||||||
|
list to the first capable backend.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
q: [batch, q_len, n_heads, head_dim] (blhd)
|
q: [batch, q_len, n_heads, head_dim] (blhd)
|
||||||
k: [batch, q_len, n_kv_heads, head_dim] (blhd)
|
k: [batch, q_len, n_kv_heads, head_dim] (blhd)
|
||||||
@@ -277,30 +294,43 @@ def attention(
|
|||||||
layer_id: transformer layer index for buffer access.
|
layer_id: transformer layer index for buffer access.
|
||||||
attn_mask: pre-built attention mask (SDPA-compatible).
|
attn_mask: pre-built attention mask (SDPA-compatible).
|
||||||
is_causal: whether to apply causal masking.
|
is_causal: whether to apply causal masking.
|
||||||
|
fwd: "prefill" / "decode" for inference, None for training.
|
||||||
|
backend: optional explicit backend (name, enum, class, or instance).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
[batch, q_len, n_heads * head_dim]
|
[batch, q_len, n_heads * head_dim]
|
||||||
"""
|
"""
|
||||||
explicit = get_backend(use_default=False)
|
if backend is not None:
|
||||||
backend = get_backend()
|
selected = _resolve_backend(backend)
|
||||||
if fwd is None and explicit is None:
|
explicit = True
|
||||||
backend = TorchNativeBackend()
|
else:
|
||||||
if not _backend_supports(backend, q, kv_cache, attn_mask, is_causal, fwd):
|
context_backend = _current_backend.get()
|
||||||
if explicit is not None:
|
explicit = context_backend is not None
|
||||||
|
# Resolve through the same chain as inference: explicit context >
|
||||||
|
# ASTR_BACKEND env > default. Training calls (fwd=None, no cache)
|
||||||
|
# land on the CUDA backend and fall back by capability below —
|
||||||
|
# flash when it can handle the call, else torch SDPA.
|
||||||
|
selected = get_backend()
|
||||||
|
assert selected is not None
|
||||||
|
|
||||||
|
if not selected.supports_call(q, kv_cache, attn_mask, is_causal, fwd):
|
||||||
|
if explicit:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Explicitly-set backend {type(backend).__name__} cannot "
|
f"Explicitly-set backend {type(selected).__name__} cannot "
|
||||||
f"handle this attention call (shape={q.shape}, "
|
f"handle this attention call (shape={q.shape}, "
|
||||||
f"dtype={q.dtype}, kv_cache={'none' if kv_cache is None else 'present'}, "
|
f"dtype={q.dtype}, kv_cache={'none' if kv_cache is None else 'present'}, "
|
||||||
f"attn_mask={'none' if attn_mask is None else 'present'}). "
|
f"attn_mask={'none' if attn_mask is None else 'present'}). "
|
||||||
f"Remove the attn_backend() context or switch to a compatible backend."
|
f"Remove the attn_backend() context or switch to a compatible backend."
|
||||||
)
|
)
|
||||||
for candidate in _priority_backends():
|
selected = next(
|
||||||
if isinstance(candidate, type(backend)):
|
(
|
||||||
continue
|
candidate
|
||||||
if _backend_supports(candidate, q, kv_cache, attn_mask, is_causal, fwd):
|
for candidate in _priority_backends()
|
||||||
backend = candidate
|
if candidate.supports_call(q, kv_cache, attn_mask, is_causal, fwd)
|
||||||
break
|
),
|
||||||
return backend.forward(q, k, v, kv_cache, layer_id, attn_mask, is_causal, fwd)
|
_instance(TorchNativeBackend),
|
||||||
|
)
|
||||||
|
return selected.forward(q, k, v, kv_cache, layer_id, attn_mask, is_causal, fwd)
|
||||||
|
|
||||||
|
|
||||||
class AttentionBackend(ABC):
|
class AttentionBackend(ABC):
|
||||||
@@ -310,6 +340,17 @@ class AttentionBackend(ABC):
|
|||||||
``fwd_prefill`` (q_len > 1, with or without cache). The public
|
``fwd_prefill`` (q_len > 1, with or without cache). The public
|
||||||
``forward`` method dispatches based on q_len.
|
``forward`` method dispatches based on q_len.
|
||||||
|
|
||||||
|
Capability contract — every backend declares:
|
||||||
|
|
||||||
|
* ``available()`` — machine-level: can this backend exist here
|
||||||
|
(kernel ``.so`` loaded, flash-attn present, GPU available)?
|
||||||
|
Used once to build the default priority list.
|
||||||
|
* ``supports_call(q, kv_cache, attn_mask, is_causal, fwd)`` — can this
|
||||||
|
backend run this *specific* call (shape/dtype/cache/mask)? Used by
|
||||||
|
``attention()`` for the per-call fallback. Resolution logic never
|
||||||
|
checks concrete backend types, so adding a backend requires no
|
||||||
|
changes outside its own class.
|
||||||
|
|
||||||
Three equivalent ways to activate a backend::
|
Three equivalent ways to activate a backend::
|
||||||
|
|
||||||
with attn_backend(ATTN_BACKEND.TORCH_NATIVE): # enum
|
with attn_backend(ATTN_BACKEND.TORCH_NATIVE): # enum
|
||||||
@@ -327,6 +368,30 @@ class AttentionBackend(ABC):
|
|||||||
def __exit__(self, *exc) -> None:
|
def __exit__(self, *exc) -> None:
|
||||||
_current_backend.reset(self._token)
|
_current_backend.reset(self._token)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
|
def available(cls) -> bool:
|
||||||
|
"""Return True if this backend can run on the current machine.
|
||||||
|
|
||||||
|
Checks static availability only (compiled kernels, optional
|
||||||
|
packages, GPU presence) — not call-specific constraints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def supports_call(
|
||||||
|
self,
|
||||||
|
q: Tensor,
|
||||||
|
kv_cache: Optional["KVCache"],
|
||||||
|
attn_mask: Optional[Tensor],
|
||||||
|
is_causal: bool,
|
||||||
|
fwd: Optional[str],
|
||||||
|
) -> bool:
|
||||||
|
"""Return True if this backend can run this specific attention call.
|
||||||
|
|
||||||
|
Called on the canonical singleton instance (or a caller-provided
|
||||||
|
one); must be side-effect free.
|
||||||
|
"""
|
||||||
|
|
||||||
def forward(
|
def forward(
|
||||||
self,
|
self,
|
||||||
q: Tensor,
|
q: Tensor,
|
||||||
@@ -412,8 +477,18 @@ class TorchNativeBackend(AttentionBackend):
|
|||||||
runs SDPA directly on the projected q/k/v.
|
runs SDPA directly on the projected q/k/v.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def supports(**kwargs) -> bool:
|
def available(cls) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def supports_call(
|
||||||
|
self,
|
||||||
|
q: Tensor,
|
||||||
|
kv_cache: Optional["KVCache"],
|
||||||
|
attn_mask: Optional[Tensor],
|
||||||
|
is_causal: bool,
|
||||||
|
fwd: Optional[str],
|
||||||
|
) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def fwd_decode(
|
def fwd_decode(
|
||||||
@@ -516,16 +591,37 @@ class CudaBackend(AttentionBackend):
|
|||||||
Raises ``RuntimeError`` if the required kernel is not available.
|
Raises ``RuntimeError`` if the required kernel is not available.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@staticmethod
|
# Head dims supported by the CUDA kernels (single source of truth).
|
||||||
def supports(**kwargs) -> bool:
|
HEAD_DIMS = (32, 64, 128, 256)
|
||||||
head_dim = kwargs.get("head_dim", -1)
|
|
||||||
|
@classmethod
|
||||||
|
def available(cls) -> bool:
|
||||||
return (
|
return (
|
||||||
torch.cuda.is_available()
|
torch.cuda.is_available()
|
||||||
and head_dim in (32, 64, 128, 256)
|
|
||||||
and is_available("attn_paged_decode")
|
and is_available("attn_paged_decode")
|
||||||
and is_available("attn_paged_prefill")
|
and is_available("attn_paged_prefill")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def supports_call(
|
||||||
|
self,
|
||||||
|
q: Tensor,
|
||||||
|
kv_cache: Optional["KVCache"],
|
||||||
|
attn_mask: Optional[Tensor],
|
||||||
|
is_causal: bool,
|
||||||
|
fwd: Optional[str],
|
||||||
|
) -> bool:
|
||||||
|
# The CUDA kernels are bf16-only, support head_dim in
|
||||||
|
# HEAD_DIMS, and need a KV cache (decode/prefill); everything
|
||||||
|
# else falls back down the priority list to torch.
|
||||||
|
return (
|
||||||
|
fwd in ("prefill", "decode")
|
||||||
|
and kv_cache is not None
|
||||||
|
and q.ndim == 3
|
||||||
|
and q.dtype == torch.bfloat16
|
||||||
|
and q.size(-1) in self.HEAD_DIMS
|
||||||
|
and is_available(f"attn_paged_{fwd}")
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def supports_graph() -> bool:
|
def supports_graph() -> bool:
|
||||||
return True
|
return True
|
||||||
@@ -606,10 +702,30 @@ class FlashAttnBackend(AttentionBackend):
|
|||||||
``flash_attn_func``.
|
``flash_attn_func``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def supports(**kwargs) -> bool:
|
def available(cls) -> bool:
|
||||||
return flash_attn_available()
|
return flash_attn_available()
|
||||||
|
|
||||||
|
def supports_call(
|
||||||
|
self,
|
||||||
|
q: Tensor,
|
||||||
|
kv_cache: Optional["KVCache"],
|
||||||
|
attn_mask: Optional[Tensor],
|
||||||
|
is_causal: bool,
|
||||||
|
fwd: Optional[str],
|
||||||
|
) -> bool:
|
||||||
|
if not self.available():
|
||||||
|
return False
|
||||||
|
if q.dtype not in (torch.float16, torch.bfloat16):
|
||||||
|
return False
|
||||||
|
if fwd is not None:
|
||||||
|
return q.ndim == 3 and hasattr(_flash_attn, "flash_attn_varlen_func")
|
||||||
|
# Dense (training) path: flash_attn_func cannot apply a custom
|
||||||
|
# mask, so only mask-free calls are supported — ``is_causal`` is
|
||||||
|
# a flag, not a mask. Masked training (SFT/DPO/GRPO) must fall
|
||||||
|
# back to TorchNativeBackend instead of silently ignoring the mask.
|
||||||
|
return attn_mask is None
|
||||||
|
|
||||||
def fwd_decode(
|
def fwd_decode(
|
||||||
self,
|
self,
|
||||||
q: Tensor,
|
q: Tensor,
|
||||||
@@ -649,9 +765,9 @@ class FlashAttnBackend(AttentionBackend):
|
|||||||
k = repeat_kv(k, n_rep)
|
k = repeat_kv(k, n_rep)
|
||||||
v = repeat_kv(v, n_rep)
|
v = repeat_kv(v, n_rep)
|
||||||
|
|
||||||
if attn_mask is not None and not is_causal and attn_mask.dim() != 4:
|
if attn_mask is not None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"FlashAttnBackend does not support a custom attention mask; "
|
"FlashAttnBackend cannot handle a custom attention mask; "
|
||||||
"use a causal mask or select TorchNativeBackend."
|
"use a causal mask or select TorchNativeBackend."
|
||||||
)
|
)
|
||||||
fa = _flash_attn
|
fa = _flash_attn
|
||||||
@@ -664,7 +780,7 @@ class FlashAttnBackend(AttentionBackend):
|
|||||||
q.contiguous(),
|
q.contiguous(),
|
||||||
k.contiguous(),
|
k.contiguous(),
|
||||||
v.contiguous(),
|
v.contiguous(),
|
||||||
causal=is_causal or (attn_mask is not None and attn_mask.dim() == 4),
|
causal=is_causal,
|
||||||
)
|
)
|
||||||
return out.contiguous()
|
return out.contiguous()
|
||||||
|
|
||||||
|
|||||||
@@ -205,8 +205,8 @@ class Executor:
|
|||||||
max_q_heads = config.num_attention_heads
|
max_q_heads = config.num_attention_heads
|
||||||
head_dim = config.hidden_size // config.num_attention_heads
|
head_dim = config.hidden_size // config.num_attention_heads
|
||||||
backend = get_backend()
|
backend = get_backend()
|
||||||
self._graph_supported = backend.supports_graph() and CudaBackend.supports(
|
self._graph_supported = backend.supports_graph() and (
|
||||||
head_dim=head_dim
|
CudaBackend.available() and head_dim in CudaBackend.HEAD_DIMS
|
||||||
)
|
)
|
||||||
self._workspace = InferenceWorkspace(
|
self._workspace = InferenceWorkspace(
|
||||||
max_batch_size=kv_cache.max_batch_size,
|
max_batch_size=kv_cache.max_batch_size,
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ Attention computation is decoupled from the model via `AttentionBackend` ABC (`a
|
|||||||
- **`FlashAttnBackend`**: optional flash-attn dispatch with `flash_attn_with_kvcache` fast path for contiguous cache; falls back to KV gather + `flash_attn_func`.
|
- **`FlashAttnBackend`**: optional flash-attn dispatch with `flash_attn_with_kvcache` fast path for contiguous cache; falls back to KV gather + `flash_attn_func`.
|
||||||
- **`TorchNativeBackend`** (always-available fallback): writes K/V to cache, gathers via `req_to_token` indirect indexing, calls `F.scaled_dot_product_attention`.
|
- **`TorchNativeBackend`** (always-available fallback): writes K/V to cache, gathers via `req_to_token` indirect indexing, calls `F.scaled_dot_product_attention`.
|
||||||
- The `attention(...)` entry point uses cuda > flash > torch priority and chooses another compatible backend when an automatically selected backend cannot handle a call.
|
- The `attention(...)` entry point uses cuda > flash > torch priority and chooses another compatible backend when an automatically selected backend cannot handle a call.
|
||||||
- `ASTR_BACKEND=cuda|torch_native|flash` and `attn_backend(...)` are explicit selections; incompatible calls raise instead of silently changing backend.
|
- Resolution precedence is: explicit `attn_backend(...)` context > `ASTR_BACKEND` env > default. An explicit `attn_backend(...)` selection is strict (incompatible calls raise); `ASTR_BACKEND` is a default-level override that falls back to a compatible backend when incapable. Training calls (`fwd=None`, no KV cache) resolve by capability: the CUDA cache kernels cannot run without a cache, so they fall back to flash (mask-free/causal calls only) and finally to torch SDPA.
|
||||||
|
|
||||||
Rotary embedding is applied via `apply_rotary_emb` in `astrai/extension/backend/rotary.py`, which auto-dispatches to the fused CUDA kernel (`rotary_emb.cu`) during inference or torch complex multiply during training (for autograd compatibility). Both attention backends share the same rotary dispatch.
|
Rotary embedding is applied via `apply_rotary_emb` in `astrai/extension/backend/rotary.py`, which auto-dispatches to the fused CUDA kernel (`rotary_emb.cu`) during inference or torch complex multiply during training (for autograd compatibility). Both attention backends share the same rotary dispatch.
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,9 @@ AttentionBackend (ABC)
|
|||||||
|
|
||||||
Default priority is cuda > flash > torch. Automatic selection may choose a
|
Default priority is cuda > flash > torch. Automatic selection may choose a
|
||||||
compatible fallback for a particular call. Set
|
compatible fallback for a particular call. Set
|
||||||
`ASTR_BACKEND=cuda|torch_native|flash` to require one backend process-wide.
|
`ASTR_BACKEND=cuda|torch_native|flash` to override the default process-wide;
|
||||||
|
an explicit `attn_backend(...)` context still takes precedence over the env
|
||||||
|
override.
|
||||||
|
|
||||||
Select via context manager (mirrors `torch.nn.attention.sdpa_kernel`):
|
Select via context manager (mirrors `torch.nn.attention.sdpa_kernel`):
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,33 @@
|
|||||||
|
|
||||||
These tests do not require CUDA — they only check that the active
|
These tests do not require CUDA — they only check that the active
|
||||||
backend is correctly set and restored.
|
backend is correctly set and restored.
|
||||||
|
|
||||||
|
Resolution precedence under test: explicit ``attn_backend(...)``
|
||||||
|
context > ``ASTR_BACKEND`` env override > implicit default. Training
|
||||||
|
calls (``fwd=None``, no KV cache) resolve by capability: the CUDA cache
|
||||||
|
kernels cannot run without a cache, so they fall back to flash (mask-free
|
||||||
|
calls only) and finally to torch SDPA.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import torch
|
||||||
|
|
||||||
from astrai.extension import (
|
from astrai.extension import (
|
||||||
ATTN_BACKEND,
|
ATTN_BACKEND,
|
||||||
|
AttentionBackend,
|
||||||
AttentionBackendFactory,
|
AttentionBackendFactory,
|
||||||
CudaBackend,
|
CudaBackend,
|
||||||
FlashAttnBackend,
|
FlashAttnBackend,
|
||||||
TorchNativeBackend,
|
TorchNativeBackend,
|
||||||
|
attention,
|
||||||
attn_backend,
|
attn_backend,
|
||||||
get_backend,
|
get_backend,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_attn_module = importlib.import_module("astrai.extension.backend.attention")
|
||||||
|
|
||||||
|
|
||||||
def test_default_backend_resolves_to_available():
|
def test_default_backend_resolves_to_available():
|
||||||
"""Default backend is the first available in cuda > flash > torch order."""
|
"""Default backend is the first available in cuda > flash > torch order."""
|
||||||
@@ -23,6 +36,10 @@ def test_default_backend_resolves_to_available():
|
|||||||
assert isinstance(backend, (CudaBackend, FlashAttnBackend, TorchNativeBackend))
|
assert isinstance(backend, (CudaBackend, FlashAttnBackend, TorchNativeBackend))
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_backend_is_cached_singleton():
|
||||||
|
assert get_backend() is get_backend()
|
||||||
|
|
||||||
|
|
||||||
def test_attn_backend_context_with_enum():
|
def test_attn_backend_context_with_enum():
|
||||||
default = get_backend()
|
default = get_backend()
|
||||||
with attn_backend(ATTN_BACKEND.CUDA):
|
with attn_backend(ATTN_BACKEND.CUDA):
|
||||||
@@ -44,11 +61,132 @@ def test_backend_can_read_only_context_selection():
|
|||||||
assert get_backend(use_default=False) is None
|
assert get_backend(use_default=False) is None
|
||||||
|
|
||||||
|
|
||||||
def test_environment_backend_overrides_context(monkeypatch):
|
def test_context_beats_environment_backend(monkeypatch):
|
||||||
|
"""An explicit attn_backend() context wins over ASTR_BACKEND."""
|
||||||
monkeypatch.setenv("ASTR_BACKEND", "torch_native")
|
monkeypatch.setenv("ASTR_BACKEND", "torch_native")
|
||||||
with attn_backend("cuda"):
|
with attn_backend("cuda"):
|
||||||
assert type(get_backend()).__name__ == "TorchNativeBackend"
|
assert isinstance(get_backend(), CudaBackend)
|
||||||
assert type(get_backend(use_default=False)).__name__ == "TorchNativeBackend"
|
assert isinstance(get_backend(use_default=False), CudaBackend)
|
||||||
|
|
||||||
|
|
||||||
|
def test_environment_backend_used_without_context(monkeypatch):
|
||||||
|
monkeypatch.setenv("ASTR_BACKEND", "torch_native")
|
||||||
|
assert isinstance(get_backend(), TorchNativeBackend)
|
||||||
|
assert isinstance(get_backend(use_default=False), TorchNativeBackend)
|
||||||
|
|
||||||
|
|
||||||
|
def test_environment_backend_does_not_break_training(monkeypatch):
|
||||||
|
"""Training (fwd=None, no cache) must not steer onto cache-only kernels.
|
||||||
|
|
||||||
|
Regression: with ASTR_BACKEND=cuda, a training forward used to raise
|
||||||
|
because the env override was treated as an explicit selection.
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("ASTR_BACKEND", "cuda")
|
||||||
|
q = torch.zeros(1, 2, 4, 8, dtype=torch.bfloat16)
|
||||||
|
out = attention(q, q, q)
|
||||||
|
assert out.shape == q.shape
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_backend_mismatch_raises(monkeypatch):
|
||||||
|
monkeypatch.delenv("ASTR_BACKEND", raising=False)
|
||||||
|
q = torch.zeros(1, 2, 4, 8, dtype=torch.bfloat16)
|
||||||
|
with pytest.raises(RuntimeError, match="Explicitly-set backend"):
|
||||||
|
with attn_backend("cuda"):
|
||||||
|
attention(q, q, q) # cuda + no KV cache -> cannot handle
|
||||||
|
|
||||||
|
|
||||||
|
def test_implicit_backend_falls_back_when_incapable(monkeypatch):
|
||||||
|
"""An implicit (env) backend that cannot run the call falls back."""
|
||||||
|
monkeypatch.setenv("ASTR_BACKEND", "cuda")
|
||||||
|
q = torch.zeros(1, 2, 4, 8, dtype=torch.float32) # fp32: cuda kernels can't
|
||||||
|
out = attention(q, q, q, fwd="prefill", is_causal=True)
|
||||||
|
assert out.shape == q.shape
|
||||||
|
|
||||||
|
|
||||||
|
def _flash_available(monkeypatch) -> None:
|
||||||
|
"""Pretend flash-attn is usable and rebuild the priority list."""
|
||||||
|
monkeypatch.setattr(_attn_module, "flash_attn_available", lambda: True)
|
||||||
|
_attn_module._priority_backends.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_training_falls_back_to_flash_before_torch_when_capable(monkeypatch):
|
||||||
|
"""Training (no cache) prefers flash over torch when flash can run the call."""
|
||||||
|
_flash_available(monkeypatch)
|
||||||
|
try:
|
||||||
|
prio = _attn_module._priority_backends()
|
||||||
|
names = [type(b).__name__ for b in prio]
|
||||||
|
assert "FlashAttnBackend" in names
|
||||||
|
assert names.index("FlashAttnBackend") < names.index("TorchNativeBackend")
|
||||||
|
|
||||||
|
q = torch.zeros(1, 2, 4, 8, dtype=torch.bfloat16)
|
||||||
|
# Mask-free training call resolves to flash, not torch.
|
||||||
|
resolved = next(b for b in prio if b.supports_call(q, None, None, False, None))
|
||||||
|
assert isinstance(resolved, FlashAttnBackend)
|
||||||
|
finally:
|
||||||
|
_attn_module._priority_backends.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_flash_dense_supports_only_mask_free_calls(monkeypatch):
|
||||||
|
"""FlashAttnBackend cannot apply custom masks in the dense path."""
|
||||||
|
_flash_available(monkeypatch)
|
||||||
|
flash = _attn_module._instance(_attn_module.FlashAttnBackend)
|
||||||
|
q = torch.zeros(1, 2, 4, 8, dtype=torch.bfloat16)
|
||||||
|
mask_4d = torch.zeros(1, 1, 2, 2, dtype=torch.bool)
|
||||||
|
|
||||||
|
assert flash.supports_call(q, None, None, False, None) is True
|
||||||
|
assert flash.supports_call(q, None, None, True, None) is True
|
||||||
|
assert flash.supports_call(q, None, mask_4d, False, None) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_flash_dense_rejects_custom_mask(monkeypatch):
|
||||||
|
"""A masked dense call must fail loudly, never silently ignore the mask."""
|
||||||
|
_flash_available(monkeypatch)
|
||||||
|
flash = _attn_module._instance(_attn_module.FlashAttnBackend)
|
||||||
|
q = torch.zeros(1, 2, 4, 8, dtype=torch.bfloat16)
|
||||||
|
mask_4d = torch.zeros(1, 1, 2, 2, dtype=torch.bool)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="custom attention mask"):
|
||||||
|
flash._forward_dense(q, q, q, attn_mask=mask_4d, is_causal=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_resolution_returns_shared_singletons():
|
||||||
|
with attn_backend("cuda") as first:
|
||||||
|
pass
|
||||||
|
with attn_backend("cuda") as second:
|
||||||
|
assert first is second
|
||||||
|
|
||||||
|
|
||||||
|
class _DummyBackend(AttentionBackend):
|
||||||
|
"""Minimal backend used only to prove capability is polymorphic."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def available(cls) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def supports_call(self, q, kv_cache, attn_mask, is_causal, fwd) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def fwd_decode(
|
||||||
|
self, q, k, v, kv_cache=None, layer_id=0, attn_mask=None, is_causal=False
|
||||||
|
):
|
||||||
|
return q
|
||||||
|
|
||||||
|
def fwd_prefill(
|
||||||
|
self, q, k, v, kv_cache=None, layer_id=0, attn_mask=None, is_causal=False
|
||||||
|
):
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_backend_usable_without_touching_resolution():
|
||||||
|
"""A third-party backend plugs in via context or explicit param."""
|
||||||
|
custom = _DummyBackend()
|
||||||
|
q = torch.zeros(1, 2, 4, 8)
|
||||||
|
|
||||||
|
with attn_backend(custom):
|
||||||
|
assert get_backend() is custom
|
||||||
|
|
||||||
|
out = attention(q, q, q, backend=custom)
|
||||||
|
assert out is q
|
||||||
|
|
||||||
|
|
||||||
def test_attention_backend_factory_lists_builtin_backends():
|
def test_attention_backend_factory_lists_builtin_backends():
|
||||||
|
|||||||
@@ -34,10 +34,13 @@ def _ws(pool: PagePool) -> InferenceWorkspace:
|
|||||||
|
|
||||||
@skip_no_kernel
|
@skip_no_kernel
|
||||||
def test_training_forward_matches_torch(cuda_model):
|
def test_training_forward_matches_torch(cuda_model):
|
||||||
"""Training forward (kv_cache=None) uses torch-native SDPA.
|
"""Training forward (kv_cache=None) resolves to a capable dense backend.
|
||||||
|
|
||||||
CudaBackend does not support training (requires kv_cache).
|
CudaBackend cannot run training (requires a KV cache), so the default
|
||||||
Torch-native backend must match default (which falls back to torch).
|
falls back by capability — flash when it can handle the call
|
||||||
|
(mask-free/causal), otherwise torch SDPA. The default path must not
|
||||||
|
raise and must produce finite logits; explicitly selected torch SDPA
|
||||||
|
must be deterministic across runs.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model, _ = cuda_model
|
model, _ = cuda_model
|
||||||
@@ -48,12 +51,15 @@ def test_training_forward_matches_torch(cuda_model):
|
|||||||
|
|
||||||
with attn_backend(ATTN_BACKEND.TORCH_NATIVE):
|
with attn_backend(ATTN_BACKEND.TORCH_NATIVE):
|
||||||
with torch.no_grad():
|
with torch.no_grad():
|
||||||
out_torch = model(input_ids)
|
out_torch_a = model(input_ids)
|
||||||
|
with torch.no_grad():
|
||||||
|
out_torch_b = model(input_ids)
|
||||||
|
|
||||||
|
assert out_default["logits"].shape == out_torch_a["logits"].shape
|
||||||
|
assert torch.isfinite(out_default["logits"]).all()
|
||||||
torch.testing.assert_close(
|
torch.testing.assert_close(
|
||||||
out_torch["logits"], out_default["logits"], atol=1e-6, rtol=1e-6
|
out_torch_a["logits"], out_torch_b["logits"], atol=0, rtol=0
|
||||||
)
|
)
|
||||||
assert out_default["logits"].shape[0] == 2
|
|
||||||
|
|
||||||
|
|
||||||
@skip_no_kernel
|
@skip_no_kernel
|
||||||
|
|||||||
Reference in New Issue
Block a user