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:
2026-08-23 14:47:02 +08:00
parent 10fec8dca1
commit a29bdfae46
6 changed files with 372 additions and 110 deletions
+213 -97
View File
@@ -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", ...]:
if isinstance(backend, FlashAttnBackend): """Available backends in priority order: cuda -> flash -> torch.
if not flash_attn_available():
return False Computed once (machine availability cannot change at runtime) and
if q.dtype not in (torch.float16, torch.bfloat16): cached forever; the tuple always ends with ``TorchNativeBackend``,
return False which is unconditionally available.
if fwd is not None: """
return q.ndim == 3 and hasattr(_flash_attn, "flash_attn_varlen_func") return tuple(
if attn_mask is None or is_causal: _instance(cls)
return True for cls in (CudaBackend, FlashAttnBackend, TorchNativeBackend)
return attn_mask.dim() == 4 if cls.available()
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()
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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.
+3 -1
View File
@@ -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`):
+141 -3
View File
@@ -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():
+12 -6
View File
@@ -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