Target audience: anyone writing or reading the attention call site in a pipeline, adding support for a new attention shape (sliding window, MLA, cross-attn variants), or porting a frontend to a new GPU family.
What this doc is for: the contract —
SiteSpec,AttentionSpec, theAttentionBackendprotocol, the slot / pointer model, and the lifecycle. What this doc is not: the kernel inventory. For that see../kernel_catalog.md.Source:
flash_rt/hardware/backend.py— protocol + base class +SiteSpec/AttentionSpec. Concrete backends live inflash_rt/hardware/{thor,rtx}/attn_backend*.py.
Every model in FlashRT has 1–4 distinct attention shapes (for example, GROOT has SigLIP vision, Qwen3 backbone, DiT self-attention, DiT cross-attention). Each shape has its own preferred kernel, and each hardware target has its own preferred kernel for the same shape:
| Shape | Thor SM110 | RTX SM120 / SM89 |
|---|---|---|
| SigLIP vision (per view, MHA, HD=72) | fmha_strided_full (CUTLASS) |
fa2.fwd_fp16 |
| Paligemma encoder (GQA 8Q/1KV, HD=256) | attention_qkv_fp16 (cuBLAS-decomposed) |
fa2.fwd_fp16 |
| Pi0.5 decoder (state-masked cross) | attention_qkv_fp16_state_masked |
fa2.fwd_fp16 + manual mask |
| GROOT DiT MHA (HD=128) | attention_mha_fp16 (cuBLAS) |
fa2.fwd_fp16 |
If the pipeline calls these kernels directly, the pipeline source is hardware-forked. With AttentionBackend it is not — pipelines call backend.run("encoder", layer_idx, q_seq=...) and the backend instance decides which kernel fires.
@dataclass
class SiteSpec:
num_layers: int # how many layers reuse this shape
num_q_heads: int # MHA: == num_kv_heads; GQA: > num_kv_heads
num_kv_heads: int
head_dim: int
max_q_seq: int # slot allocation upper bound
max_kv_seq: int | None = None # None == self-attn (mirrors max_q_seq)
batch_axis: int = 1 # >1 for batched SigLIP across views
sliding_window: int | None = None # SWA window (Gemma 3 / Pi0.6)
causal: bool = False # reserved for future decoder-only paths
extra: dict = {} # backend-specific hints, escape hatchA site is "all the layers in this model that share this attention shape". Layer count, head count, head dim, sequence-length budgets, and optional features (SWA, causal) live here. The pipeline declares one SiteSpec per shape; the backend pre-allocates Q/K/V slots sized per (num_layers, max_q_seq, max_kv_seq, num_q_heads, head_dim).
The extra dict is an escape hatch for backend-specific keys — for instance, Pi0's decoder uses extra={"kernel": "state_masked"} to opt into the row-0 state-token mask.
spec = (AttentionSpec()
.add_site("siglip", num_layers=27, num_q_heads=16, num_kv_heads=16,
head_dim=72, max_q_seq=256, batch_axis=2)
.add_site("encoder", num_layers=18, num_q_heads=8, num_kv_heads=1,
head_dim=256, max_q_seq=968)
.add_site("decoder", num_layers=18, num_q_heads=8, num_kv_heads=1,
head_dim=256, max_q_seq=10, max_kv_seq=978,
extra={"kernel": "state_masked"}))The frontend builds the spec in a make_attention_spec(...) static method on the pipeline class — this keeps shape knowledge with the model and decouples it from the backend's allocation logic.
The frontend then asks the hardware module for a backend instance:
from flash_rt.hardware import make_attention_backend
backend = make_attention_backend(arch="thor", spec=spec) # → ThorFlashAttnBackend
# or
backend = make_attention_backend(arch="rtx_sm120", spec=spec) # → RtxFlashAttnBackendmake_attention_backend is an internal helper that resolves (arch, model_kind) to a concrete class and returns a fully-allocated instance. The pipeline then injects the backend as a constructor argument.
class AttentionBackend(Protocol):
def sites(self) -> tuple[str, ...]: ...
def get_slot_ptrs(self, site: str, layer_idx: int) -> dict[str, int]: ...
def run(self, site: str, layer_idx: int, q_seq: int, *,
kv_seq: int | None = None, stream: int = 0,
state_nk: int | None = None) -> int: ...
def head_dim(self, site: str) -> int: ...
def num_q_heads(self, site: str) -> int: ...
def num_kv_heads(self, site: str) -> int: ...Three things matter, the rest are accessors.
Returns raw device pointers for the per-layer Q / K / V (and possibly O) buffers. The pipeline calls this once per (site, layer_idx) at construction and caches the dict. Pointers are stable across CUDA Graph capture and replay — that is the whole reason this protocol exists rather than passing torch tensors around.
The dict always contains keys "Q", "K", "V". Some backends include "O" (Thor aliases O with Q for memory savings; RTX returns the FA2 output pointer at run() time and does not pre-publish "O").
Executes attention for one (site, layer) and returns a device pointer to the output. The contract:
q_seqis the active Q rows for this call. May be smaller thanSiteSpec.max_q_seq. Rows pastq_seqare ignored. This is how variable-length prompts work without re-capture.kv_seq=Nonemeans self-attention; otherwise cross-attention with explicit KV length.streamis the CUDA stream pointer (0 = default). Backends launch on this stream.state_nkis honored only whenSiteSpec.extra["kernel"] == "state_masked"(Pi0 decoder). The first Q row attends only to the firststate_nkKV positions; remaining rows attend over the fullkv_seq.- The returned pointer is stable across CUDA Graph capture + replay for the same
(site, layer_idx).
The pipeline sees the same call sequence on every hardware target. Only the kernel fired by run() changes.
Backends are allowed to differ on who allocates the buffers:
- Backend-owned (RTX): the backend allocates Q / K / V as torch tensors in
__init__. The pipeline writes Q / K / V into the published pointers. Modern vendored FA2 backends also own stable output tensors; legacy pipflash_attn_funcfallback paths keep a torch output reference so the caching allocator doesn't reassign across capture / replay. - Pipeline-owned (Thor): the pipeline allocates its own Q (which doubles as the output buffer post-attn), plus per-layer K / V cache as part of the weights dict. The backend is a thin wrapper around
fvk.attention_qkv_fp16and takes pointers atrun()time.
A pipeline written to the protocol does not care which storage model fires — it always reads pointers via get_slot_ptrs and lets run() return the output pointer. This is the key portability property.
- One backend per pipeline. The backend is constructed once (typically before the pipeline, then injected), lives as long as the pipeline does, and is destroyed with it. Sharing a backend across pipelines is not supported.
- Single-threaded w.r.t. CUDA. Cross-Python-thread calls into the same backend are undefined.
- No reentry during graph capture. During
cuda.graph.capture,run()may be called many times (once per(site, layer)), but all calls run on the captured stream. After capture, the runtime path isgraph.replay()— the backend'srun()is not called per inference.
| Backend | Arch | Used by | Backing kernel(s) |
|---|---|---|---|
ThorFlashAttnBackend |
thor (SM110) | Pi0.5, Pi0 | fmha_strided_full (SigLIP) + attention_qkv_fp16 / attention_qkv_fp16_state_masked (encoder / decoder) |
ThorGrootAttnBackend |
thor (SM110) | GROOT N1.6 | fmha_strided_full (SigLIP) + attention_qkv_fp16 (Qwen3) + attention_mha_fp16 (DiT) |
RtxFlashAttnBackend |
rtx_sm120 / rtx_sm89 | Pi0.5, Pi0 | fa2.fwd_fp16 / fa2.fwd_bf16 for every site |
RtxGrootAttnBackend |
rtx_sm120 / rtx_sm89 | GROOT N1.6 | fa2.fwd_* for SigLIP / Qwen3 / DiT self / DiT cross |
Pi0-FAST is the explicit exception — it does not use this protocol, because its hardware differences are in GEMM dispatch rather than attention shape. See frontends/torch/pi0fast.py for the single-file SM-fork pattern it uses instead.
When porting to a new arch (e.g. RDNA, AMD MI300):
- Implement the protocol. Subclass
AttentionBackendBase(gives you the accessor defaults) and implementget_slot_ptrsandrun. - Decide your storage model. Backend-owned is easier to reason about. Pipeline-owned saves memory if you can alias buffers, but requires the pipeline to allocate.
- Wire it into
make_attention_backend. Add a row mapping(arch, model_kind) → YourBackend. - Run the conformance test.
tests/test_thor_attn_backend.pyandtests/test_thor_groot_attn_backend.pyexercise the protocol end-to-end against captured-graph replay. Same tests run against the new backend by parameterizing thearchfixture.
The protocol does not assume CUDA. A backend that wraps hipblaslt or a CPU reference path is legal.
When adding a new model with an attention shape no existing backend handles (e.g. sliding-window for Gemma 4, MLA for DeepSeek V3+):
- Extend
SiteSpecif necessary. Sliding window is already supported (sliding_window: int | None). Genuinely new features (latent attention, CSA, HCA) need a new field — keep it optional. - Implement the kernel. Add a
.cutocsrc/attention/, expose via pybind, document inkernel_catalog.md. - Update the backend. The backend's
run()becomes amatch sitedispatch — add a branch for the new shape and call your new kernel. - Conformance test. Extend
tests/test_*_attn_backend.pywith a new site and assert pointer stability + output match against a reference.
The model pipeline does not change. It always calls backend.run(...). The new kernel only changes what happens inside the backend.
Every guarantee below must hold for the captured-graph fast path to work:
get_slot_ptrs(site, layer_idx)returns the same dict-of-ints every call within a backend's lifetime, for the same(site, layer_idx).- The pointer values themselves are stable across
graph.capture()andgraph.replay(). - The output pointer returned by
run(site, layer_idx, ...)is valid until the nextrun(same_site, same_layer_idx)call, or end-of-backend lifetime, whichever comes first. (Backends that re-use slots — Thor — return the same pointer every call; backends that allocate per call — RTX FA2 — hold a reference so the caching allocator doesn't reassign.) - Pipelines must not cache output pointers across
run()calls at the same site / layer. Re-read after everyrun(). (In practice, both shipped backends return the same pointer for the same(site, layer), but this is implementation, not contract.)
If any of these break, captured graphs will silently read / write bogus memory after replay. The conformance tests check 1 and 2; 3 and 4 are pipeline discipline.
Three concerns sit outside this protocol — handled at other layers today, available as extension surfaces if a workload needs them:
- KV cache management. Backends allocate static per-layer K / V slots sized at construction; Pi0-FAST writes into these slots during AR decode. Richer KV management (paging, eviction, cross-request sharing) is a separate concern, addressable by adding a KV-policy layer on top of the slot pointers — the protocol's pointer-stability guarantee is the foundation it would build on.
- Quantization of attention. Today's backends consume fp16 / bf16 Q / K / V; FP8 stops at the GEMM input boundary on either side of attention. FP8 attention (FlashInfer / SGLang-style) is a possible future direction — adding it would extend, not break, the protocol.
- Cross-layer fusion. Each
run()is one layer; cross-layer fusion (e.g. attention output + post-attn norm) lives in the pipeline via theresidual_add_rms_norm_fp8_*kernel family, outside the protocol surface.
| What | File |
|---|---|
Protocol, SiteSpec, AttentionSpec, base class |
flash_rt/hardware/backend.py |
| Thor Pi0/Pi0.5 backend | flash_rt/hardware/thor/attn_backend.py |
| Thor GROOT backend | flash_rt/hardware/thor/attn_backend_groot.py |
| RTX Pi0/Pi0.5 backend | flash_rt/hardware/rtx/attn_backend.py |
| RTX GROOT backend | flash_rt/hardware/rtx/attn_backend_groot.py |
| Pipeline call site (Pi0.5, all hardware) | flash_rt/models/pi05/pipeline.py |
make_attention_backend resolver |
flash_rt/hardware/__init__.py |
| Conformance tests | tests/test_thor_attn_backend.py, tests/test_thor_groot_attn_backend.py |