Files
AstrAI/tests/extension/test_backend.py
T
ViperEkura a29bdfae46 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
2026-08-23 14:47:02 +08:00

226 lines
7.5 KiB
Python

"""Backend selection and context-manager switching tests.
These tests do not require CUDA — they only check that the active
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 torch
from astrai.extension import (
ATTN_BACKEND,
AttentionBackend,
AttentionBackendFactory,
CudaBackend,
FlashAttnBackend,
TorchNativeBackend,
attention,
attn_backend,
get_backend,
)
_attn_module = importlib.import_module("astrai.extension.backend.attention")
def test_default_backend_resolves_to_available():
"""Default backend is the first available in cuda > flash > torch order."""
backend = get_backend()
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():
default = get_backend()
with attn_backend(ATTN_BACKEND.CUDA):
assert isinstance(get_backend(), CudaBackend)
assert get_backend() is default
def test_attn_backend_context_with_registered_name():
default = get_backend()
with attn_backend("cuda"):
assert isinstance(get_backend(), CudaBackend)
assert get_backend() is default
def test_backend_can_read_only_context_selection():
assert get_backend(use_default=False) is None
with attn_backend("cuda") as backend:
assert get_backend(use_default=False) is backend
assert get_backend(use_default=False) is None
def test_context_beats_environment_backend(monkeypatch):
"""An explicit attn_backend() context wins over ASTR_BACKEND."""
monkeypatch.setenv("ASTR_BACKEND", "torch_native")
with attn_backend("cuda"):
assert isinstance(get_backend(), CudaBackend)
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():
assert AttentionBackendFactory.list_registered() == [
"cuda",
"flash",
"torch_native",
]
def test_attn_backend_rejects_unknown_registered_name():
with pytest.raises(ValueError, match="Unknown component: 'unknown'"):
with attn_backend("unknown"):
pass
def test_attn_backend_context_with_class():
default = get_backend()
with attn_backend(CudaBackend):
assert isinstance(get_backend(), CudaBackend)
assert get_backend() is default
def test_attn_backend_context_with_instance():
custom = CudaBackend()
default = get_backend()
with attn_backend(custom):
assert get_backend() is custom
assert get_backend() is default
def test_cudabackend_is_context_manager():
default = get_backend()
with CudaBackend():
assert isinstance(get_backend(), CudaBackend)
assert get_backend() is default