From f2ce6725846e8a6773d8236d0d442eb473fd270e Mon Sep 17 00:00:00 2001 From: yanbao1217 <215131680+yanbao1217@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:35:16 +0800 Subject: [PATCH 1/5] feat: add FlashInfer attention backend for SM120 decode Register FlashInfer (flashinfer-python >= 0.7.0, XQA kernels) as a paged decode backend through the attention contract layer: - explicit "flashinfer" selection plus an auto slot between FlashAttention-2 and Triton; the SM gate keeps auto away from it on SM8/9/10, so it is only reached where every FlashAttention generation is rejected (SM 12.x) - capabilities declare CUDA graphs and async scheduling; packed prefill stays FA4-only because the adapter leaves the kernel without a prefill entry point - validation rejects unsupported setups explicitly (float16 unvalidated on SM120, page sizes outside the compiled XQA variants, head_dim not divisible by 16); the workspace allocates lazily on first execution so backend selection performs no device allocations - empty batches return early because the kernel host-dispatch SIGFPEs on batch 0; padded block-table columns are clamped non-negative and the kernel bounds reads by seq_lens, so -1/zero/stale padding stays invisible - requires CUDA_HOME with nvcc >= 12.9 and that toolkit's lib directory on LD_LIBRARY_PATH for the just-in-time compiled modules --- docs/flashinfer.md | 97 ++++++++++ pyproject.toml | 1 + tests/test_backend_selection.py | 33 +++- tests/test_flashinfer_attention.py | 216 +++++++++++++++++++++++ vllm_rlt/attention/backend.py | 13 +- vllm_rlt/entrypoints/cli.py | 1 + vllm_rlt/entrypoints/serve.py | 1 + vllm_rlt/kernels/flashinfer_attention.py | 115 ++++++++++++ 8 files changed, 465 insertions(+), 12 deletions(-) create mode 100644 docs/flashinfer.md create mode 100644 tests/test_flashinfer_attention.py create mode 100644 vllm_rlt/kernels/flashinfer_attention.py diff --git a/docs/flashinfer.md b/docs/flashinfer.md new file mode 100644 index 00000000..cc6fc1a1 --- /dev/null +++ b/docs/flashinfer.md @@ -0,0 +1,97 @@ +# FlashInfer Attention Backend + +The `flashinfer` backend runs paged decode attention through FlashInfer's XQA +kernels (`flashinfer-python >= 0.7.0`, `trtllm_batch_decode_with_kv_cache`, +`backend="auto"`). It is the validated attention path for NVIDIA consumer +Blackwell (SM120, e.g. RTX 5090), where the pinned FlashAttention releases +reject paged KV. + +## Scope + +- Decode and per-token prefill, identical semantics to the Triton path, + including CUDA Graph capture and asynchronous scheduling. +- No packed prefill: the engine keeps the FA4-only packed path closed, so + prefill runs per token exactly as with Triton. +- No prefill/decode disaggregation; PD entrypoints reject the backend + explicitly. +- `bfloat16` only. `float16` is rejected at startup: the kernels compute + correctly, but flashinfer-python 0.7.0.post1 crashes the process (host + SIGFPE) while building the f16 JIT module on first in-process use. +- `--block-size` must be one of `16, 32, 64, 128` (compiled XQA page-size + variants) and `head_dim` must be divisible by 16 within `[16, 512]`. + +## Installation and runtime requirements + +```bash +pip install -e ".[flashinfer]" +``` + +The XQA kernels are compiled just in time on first use, which imposes two +environment requirements beyond the package install: + +1. **A CUDA toolkit with `nvcc >= 12.9` reachable via `CUDA_HOME`.** SM120 + kernels are built for the `compute_120f` architecture family, which needs + CUDA 12.9 or newer. Without `nvcc`, startup falls back to comparing against + the CUDA version PyTorch was built with, and SM 12.x devices are rejected + with `SM 12.x requires CUDA >= 12.9`. +2. **That toolkit's `lib` directory on `LD_LIBRARY_PATH`.** The compiled + modules link `libcudart.so.13`; pip-installed toolkits keep it outside the + default loader path. With the pip toolkit layout + (`nvidia-cuda-nvcc`/`nvidia-cuda-runtime`/`nvidia-cuda-cccl`/...), the + unified tree lives under `site-packages/nvidia/cu13` and may need + `lib64/libcudart.so` and `lib64/stubs/libcuda.so` developer symlinks, which + the wheels do not ship. + +The first call on a machine compiles for a few seconds and caches under +`~/.cache/flashinfer///`; later calls, including CUDA +Graph capture warmups, reuse the cache. + +## Running + +```bash +vllm-rlt --model --revision \ + --device cuda --dtype bfloat16 --attention-backend flashinfer +``` + +The adapter holds one 128 MiB zero-initialized workspace for the kernel's +scratch and softmax statistics; it is allocated once at startup and reused, so +graph capture performs no new allocations. Padded block-table columns are +clamped non-negative before the call: the engine produces `-1` padding on the +synchronous path, `0` on the asynchronous path, and stale-but-valid ids in +CUDA Graph replay beyond the current width. The kernel bounds reads by +`seq_lens`, so all three padding states stay invisible. + +## Choosing between Triton and FlashInfer + +SM120 is the only validated target for this backend; on it, FlashInfer decodes +noticeably faster at low concurrency (single-request decode measured ~2x +against the Triton kernel at 256-token contexts) because the XQA kernel +parallelizes a single query row across KV chunks, while the Triton kernel +spawns one program per (row, head). At high concurrency with long contexts the +two are comparable; choose Triton when its determinism properties matter more +and FlashInfer for single-user or low-batch latency. SM9/SM10 devices are +accepted but unvalidated — prefer FlashAttention there until validated +configurations are recorded. + +## Validation status + +Validated on RTX 5090 (SM120, 32 GB) with Ouro-1.4B BF16, `block_size=16`, +fixed four-loop decode: oracle parity against the reference paged attention, +token/exit-depth equality against the Triton backend across synchronous, +asynchronous, and multi-stream execution with static buffers and graph replay, +and end-to-end greedy generation. + +## Accuracy note (GSM8K-87, single greedy pass) + +On the frozen GSM8K-87 protocol (HF baseline 59/87), a fixed four-loop greedy +pass scores 58/87 with FlashInfer versus 61/87 with Triton. 64 of 87 +generations are token-identical between the two backends; the remaining 23 +diverge at scattered positions with unchanged exit depths, and the net +correct-answer flips are 3 against, 0 for. Kernel-level comparison against a +float32 oracle at 352-1024 token contexts shows the Triton kernel is +near-exact (max deviation up to 5e-4) while XQA's split reductions deviate by +up to 4e-3 — within normal BF16 kernel variation, but enough to flip +near-tied greedy argmax decisions during long chain-of-thought decoding. +Triton remains the default backend; treat this note as the expected greedy +variation when opting into FlashInfer on SM120. + diff --git a/pyproject.toml b/pyproject.toml index 93a60a77..b79c36d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ pd = ["nixl==1.4.1"] serve = [] flash-attn = ["flash-attn>=2.8.3"] flash-attn4 = ["flash-attn-4==4.0.0b30"] +flashinfer = ["flashinfer-python>=0.7.0"] triton = [] dev = ["pytest>=8", "ruff>=0.9"] diff --git a/tests/test_backend_selection.py b/tests/test_backend_selection.py index f8624beb..5824d950 100644 --- a/tests/test_backend_selection.py +++ b/tests/test_backend_selection.py @@ -15,9 +15,15 @@ @pytest.fixture def packages(monkeypatch): - from vllm_rlt.kernels import flash_attention - - installed = {"flash_attn", "flash_attn_3.flash_attn_interface", "flash_attn.cute", "triton"} + from vllm_rlt.kernels import flash_attention, flashinfer_attention + + installed = { + "flash_attn", + "flash_attn_3.flash_attn_interface", + "flash_attn.cute", + "triton", + "flashinfer", + } original_import = flash_attention.importlib.import_module def import_module(name): @@ -32,10 +38,17 @@ def import_module(name): flash_attn_with_kvcache=lambda *args, **kwargs: None, flash_attn_varlen_func=lambda *args, **kwargs: None, ) + if name == "flashinfer.decode": + if "flashinfer" not in installed: + raise ImportError("flashinfer is not installed") + return SimpleNamespace( + trtllm_batch_decode_with_kv_cache=lambda *args, **kwargs: None + ) return original_import(name) monkeypatch.setattr(flash_attention.importlib, "import_module", import_module) monkeypatch.setattr(flash_attention, "version", lambda name: "test") + monkeypatch.setattr(flashinfer_attention, "version", lambda name: "test") monkeypatch.setattr(torch.version, "hip", None) return installed @@ -74,7 +87,8 @@ def test_cpu_auto_reports_torch_and_runs_reference_attention(caplog): "flash_attn_2", ), ((10, 0), torch.bfloat16, 128, 16, (), "flash_attn_4"), - ((12, 0), torch.bfloat16, 128, 16, (), "triton"), + ((12, 0), torch.bfloat16, 128, 16, (), "flashinfer"), + ((12, 0), torch.bfloat16, 128, 16, ("flashinfer",), "triton"), ((7, 5), torch.float16, 128, 256, (), "torch"), ], ) @@ -109,12 +123,15 @@ def test_auto_reports_missing_packages_and_configuration(packages, monkeypatch): assert "multiple of 256" in reason and "triton" in reason -@pytest.mark.parametrize("backend", ["flash_attn_2", "triton"]) -def test_explicit_missing_package_never_falls_back(packages, monkeypatch, backend): - monkeypatch.setattr(torch.cuda, "get_device_capability", lambda device: (8, 9)) +@pytest.mark.parametrize( + "backend,sm,block_size", + [("flash_attn_2", (8, 9), 256), ("triton", (8, 9), 256), ("flashinfer", (12, 0), 16)], +) +def test_explicit_missing_package_never_falls_back(packages, monkeypatch, backend, sm, block_size): + monkeypatch.setattr(torch.cuda, "get_device_capability", lambda device: sm) packages.clear() with pytest.raises(ImportError): - create_backend(backend, torch.device("cuda"), torch.bfloat16, 128, 256) + create_backend(backend, torch.device("cuda"), torch.bfloat16, 128, block_size) def test_explicit_flash_incompatibility_is_not_overridden(packages, monkeypatch): diff --git a/tests/test_flashinfer_attention.py b/tests/test_flashinfer_attention.py new file mode 100644 index 00000000..af8300b9 --- /dev/null +++ b/tests/test_flashinfer_attention.py @@ -0,0 +1,216 @@ +"""FlashInfer paged attention: gates, padding states, graphs and mixed depths.""" + +import pytest +import torch + +from tests.helpers import tiny_ouro_config +from vllm_rlt import CacheConfig, ExecutionConfig, ExitConfig, SamplingParams, SchedulerConfig +from vllm_rlt.engine.llm_engine import LLMEngine +from vllm_rlt.kernels.flashinfer_attention import FlashInferPagedAttention +from vllm_rlt.kernels.paged_attention import torch_paged_attention +from vllm_rlt.models import OuroForCausalLM + + +def _cuda_like(monkeypatch, sm): + monkeypatch.setattr(torch.version, "hip", None) + monkeypatch.setattr(torch.cuda, "get_device_capability", lambda device: sm) + + +def test_unsupported_architecture_rejected_before_import(monkeypatch): + from vllm_rlt.kernels import flashinfer_attention + + _cuda_like(monkeypatch, (7, 5)) + + def unexpected_import(name): + pytest.fail(f"unsupported architecture reached dependency import: {name}") + + monkeypatch.setattr(flashinfer_attention.importlib, "import_module", unexpected_import) + with pytest.raises(ValueError, match="not supported on SM75"): + FlashInferPagedAttention(torch.device("cuda"), torch.bfloat16, 128, 16) + + +@pytest.mark.parametrize( + "dtype", [torch.float16, torch.float32, torch.complex64], ids=["fp16", "fp32", "complex"] +) +def test_unvalidated_dtypes_rejected(monkeypatch, dtype): + _cuda_like(monkeypatch, (12, 0)) + with pytest.raises(ValueError): + FlashInferPagedAttention(torch.device("cuda"), dtype, 128, 16) + + +def test_geometry_and_page_size_rejections(monkeypatch): + _cuda_like(monkeypatch, (12, 0)) + with pytest.raises(ValueError, match="divisible by 16"): + FlashInferPagedAttention(torch.device("cuda"), torch.bfloat16, 100, 16) + with pytest.raises(ValueError, match="divisible by 16"): + FlashInferPagedAttention(torch.device("cuda"), torch.bfloat16, 8, 16) + with pytest.raises(ValueError, match="page-size variants"): + FlashInferPagedAttention(torch.device("cuda"), torch.bfloat16, 128, 17) + with pytest.raises(ValueError, match="NVIDIA"): + FlashInferPagedAttention(torch.device("cpu"), torch.bfloat16, 128, 16) + + +def test_missing_package_raises_import_error_without_fallback(monkeypatch): + from importlib.metadata import PackageNotFoundError + + from vllm_rlt.kernels import flashinfer_attention + + _cuda_like(monkeypatch, (12, 0)) + + def missing(name): + raise PackageNotFoundError(name) + + monkeypatch.setattr(flashinfer_attention, "version", missing) + with pytest.raises(ImportError, match="install flashinfer"): + FlashInferPagedAttention(torch.device("cuda"), torch.bfloat16, 128, 16) + + +@pytest.mark.gpu +def test_backend_contract_anchors(): + attention = FlashInferPagedAttention(torch.device("cuda"), torch.bfloat16, 128, 16) + assert attention.info["backend"] == "flashinfer" + assert attention.info["package"] == "flashinfer-python" + assert attention.info["page_size"] == 16 + assert attention.info["kernel"] == "xqa" + assert attention.capabilities.packed_prefill is False + assert attention.capabilities.cuda_graphs is True + assert attention.capabilities.async_scheduling is True + # Absence is the contract: the FA4 packed-prefill gates in the engine and + # model runner key off a missing generation attribute staying missing. + assert not hasattr(attention, "generation") + assert not hasattr(attention, "prefill") + + +@pytest.mark.gpu +@pytest.mark.parametrize("heads", [2, 4]) +def test_ragged_strided_paged_attention_with_zero_length_row(heads): + torch.manual_seed(9) + # Layer slicing leaves a non-contiguous block stride, as in the real cache. + keys = torch.randn(12, 3, 16, 2, 128, device="cuda", dtype=torch.bfloat16)[:, 1] + values = torch.randn(12, 3, 16, 2, 128, device="cuda", dtype=torch.bfloat16)[:, 1] + q = torch.randn(4, heads, 128, device="cuda", dtype=torch.bfloat16) + tables = torch.tensor( + [[7, 2, 9], [3, 0, 0], [1, 8, 6], [0, 0, 0]], device="cuda", dtype=torch.int32 + ) + lengths = torch.tensor([35, 1, 47, 0], device="cuda", dtype=torch.int32) + attention = FlashInferPagedAttention(q.device, torch.bfloat16, 128, 16) + expected = torch_paged_attention(q, keys, values, tables, lengths) + actual = attention(q, keys, values, tables, lengths) + torch.testing.assert_close(actual, expected, atol=0.015, rtol=0.02) + assert torch.isfinite(actual).all() + # Zero-length rows are async padding rows; they must read as exact zeros, + # not small garbage, so downstream masking stays optional. + assert torch.count_nonzero(actual[-1]) == 0 + + +@pytest.mark.gpu +def test_padding_states_are_invisible(): + torch.manual_seed(11) + keys = torch.randn(24, 3, 16, 2, 64, device="cuda", dtype=torch.bfloat16)[:, 1] + values = torch.randn(24, 3, 16, 2, 64, device="cuda", dtype=torch.bfloat16)[:, 1] + q = torch.randn(3, 2, 64, device="cuda", dtype=torch.bfloat16) + lengths = torch.tensor([16, 31, 20], device="cuda", dtype=torch.int32) + attention = FlashInferPagedAttention(q.device, torch.bfloat16, 64, 16) + outputs = [] + for fill in (-1, 0, 19): # sync pad, async pad, stale-but-valid replay id + tables = torch.full((3, 4), fill, device="cuda", dtype=torch.int32) + for row in range(3): + tables[row, :2] = torch.arange(row * 2, row * 2 + 2, device="cuda") + outputs.append(attention(q, keys, values, tables, lengths)) + assert torch.equal(outputs[0], outputs[1]) + assert torch.equal(outputs[0], outputs[2]) + + +@pytest.mark.gpu +def test_flashinfer_decode_same_queries_match_across_batch_sizes(): + generator = torch.Generator(device="cuda").manual_seed(1234) + dtype = torch.bfloat16 + # Ouro geometry and long enough prefixes to exercise any KV split selection. + keys = torch.randn(260, 2, 16, 4, 128, generator=generator, device="cuda", dtype=dtype)[:, 1] + values = torch.randn(260, 2, 16, 4, 128, generator=generator, device="cuda", dtype=dtype)[:, 1] + q = torch.randn(4, 16, 128, generator=generator, device="cuda", dtype=dtype) + tables = torch.arange(260, device="cuda", dtype=torch.int32).view(4, 65) + lengths = torch.tensor([1025, 1029, 1025, 1031], device="cuda", dtype=torch.int32) + attention = FlashInferPagedAttention(q.device, dtype, 128, 16) + together = attention(q, keys, values, tables, lengths) + for rows in ([2, 3], [3, 0], [1]): + separate = attention(q[rows], keys, values, tables[rows], lengths[rows]) + torch.testing.assert_close(separate, together[rows], rtol=0, atol=0) + + +@pytest.mark.gpu +def test_graph_replay_updates_buffers(): + torch.manual_seed(5) + keys = torch.randn(24, 3, 16, 2, 64, device="cuda", dtype=torch.bfloat16)[:, 1] + values = torch.randn(24, 3, 16, 2, 64, device="cuda", dtype=torch.bfloat16)[:, 1] + rows, width = 4, 4 + q = torch.randn(rows, 2, 64, device="cuda", dtype=torch.bfloat16) + static_tables = torch.zeros(rows, width, device="cuda", dtype=torch.int32) + static_lengths = torch.full((rows,), 16, device="cuda", dtype=torch.int32) + attention = FlashInferPagedAttention(q.device, torch.bfloat16, 64, 16) + # Warm JIT modules and workspace outside the capture region. + attention(q, keys, values, static_tables, static_lengths) + torch.cuda.synchronize() + graph = torch.cuda.CUDAGraph() + with torch.cuda.graph(graph): + out = attention(q, keys, values, static_tables, static_lengths) + generator = torch.Generator().manual_seed(21) + for trial in range(3): + for row in range(rows): + start = (row * 3 + trial * 5) % 16 + static_tables[row] = torch.arange(start, start + width, dtype=torch.int32) + static_lengths.copy_( + torch.randint(1, width * 16, (rows,), generator=generator, dtype=torch.int32) + ) + graph.replay() + expected = torch_paged_attention(q, keys, values, static_tables, static_lengths) + torch.testing.assert_close(out, expected, atol=0.015, rtol=0.02) + + +@pytest.mark.gpu +@pytest.mark.parametrize("layout", ["last_exited", "shared"]) +@pytest.mark.parametrize("static", [False, True]) +def test_flashinfer_sync_async_mixed_depths(layout, static): + torch.manual_seed(123) + config = tiny_ouro_config(head_dim=64) + model = OuroForCausalLM(config).to(device="cuda", dtype=torch.bfloat16) + traces = {str(i): [4, 2 + i % 3, 4, 2, 3, 4] for i in range(3)} + outputs = [] + for backend, asynchronous, multi in [ + ("triton", False, False), + ("flashinfer", False, False), + ("flashinfer", True, False), + ("flashinfer", True, True), + ]: + engine = LLMEngine( + model, + cache_config=CacheConfig(num_blocks=48, block_size=16, layout=layout), + scheduler_config=SchedulerConfig( + max_num_seqs=3, max_num_batched_tokens=8, prefill_chunk_size=4 + ), + execution_config=ExecutionConfig( + async_scheduling=asynchronous, + multi_stream=multi, + static_buffers=static, + pad_to_power_of_two=static, + ), + exit_config=ExitConfig("trace", depths_by_request=traces), + attention_backend=backend, + ) + for i in range(3): + engine.add_request( + str(i), + [2, 3, 4, 5] * (i + 1), + SamplingParams(max_tokens=6, min_loops=1, ignore_eos=True), + ) + finished = {} + for _ in range(300): + if not engine.has_unfinished_requests(): + break + for out in engine.step(): + if out.finished: + finished[out.request_id] = (out.token_ids, out.exit_depths) + assert len(finished) == 3 + assert engine.cache_manager.num_used_blocks == 0 + outputs.append(finished) + assert all(value == outputs[0] for value in outputs) diff --git a/vllm_rlt/attention/backend.py b/vllm_rlt/attention/backend.py index 15bde763..2e2005a9 100644 --- a/vllm_rlt/attention/backend.py +++ b/vllm_rlt/attention/backend.py @@ -8,6 +8,7 @@ import torch from vllm_rlt.kernels.flash_attention import FLASH_BACKENDS, FlashPagedAttention +from vllm_rlt.kernels.flashinfer_attention import FlashInferPagedAttention from vllm_rlt.kernels.paged_attention import torch_paged_attention, triton_paged_attention @@ -47,7 +48,8 @@ def generation(self): 3: BackendCapabilities(cuda_graphs=True, async_scheduling=True), 4: BackendCapabilities(packed_prefill=True, cuda_graphs=True, async_scheduling=True), } -_AUTO_PRIORITY = ("flash_attn_4", "flash_attn_3", "flash_attn_2", "triton", "torch") +_FLASHINFER_CAPABILITIES = BackendCapabilities(cuda_graphs=True, async_scheduling=True) +_AUTO_PRIORITY = ("flash_attn_4", "flash_attn_3", "flash_attn_2", "flashinfer", "triton", "torch") def backend_capabilities(implementation) -> BackendCapabilities: @@ -55,7 +57,7 @@ def backend_capabilities(implementation) -> BackendCapabilities: def validate_backend_name(backend: str) -> None: - if backend not in {"auto", "torch", "triton", *FLASH_BACKENDS}: + if backend not in {"auto", "torch", "triton", "flashinfer", *FLASH_BACKENDS}: raise ValueError("unknown attention backend") @@ -70,6 +72,9 @@ def _create_explicit(backend, device, dtype, head_dim, block_size): # Probe the actual module, including its dependency imports, before selection. importlib.import_module("vllm_rlt.kernels.triton_attention") return AttentionBackend(triton_paged_attention, _TRITON_CAPABILITIES, {"backend": backend}) + if backend == "flashinfer": + kernel = FlashInferPagedAttention(device, dtype, head_dim, block_size) + return AttentionBackend(kernel, _FLASHINFER_CAPABILITIES, dict(kernel.info)) if backend in FLASH_BACKENDS: kernel = FlashPagedAttention(device, dtype, head_dim, block_size, backend) return AttentionBackend(kernel, _FLASH_CAPABILITIES[kernel.generation], dict(kernel.info)) @@ -85,7 +90,7 @@ def create_backend( *, required_capabilities: BackendCapabilities = BackendCapabilities(), ) -> AttentionBackend: - """Explicit choices fail without fallback; auto tries FA4/3/2, Triton, then Torch. + """Explicit choices fail without fallback; auto tries FA4/3/2, FlashInfer, Triton, then Torch. Each candidate validates hardware/configuration and imports its implementation. Only compatibility and import failures permit auto to try the next candidate; @@ -118,7 +123,7 @@ def create_backend( f"flash_attn_{implementation.generation}" if candidate in FLASH_BACKENDS else candidate ) reason = ( - "first compatible candidate in FA4 > FA3 > FA2 > Triton > Torch priority" + "first compatible candidate in FA4 > FA3 > FA2 > FlashInfer > Triton > Torch priority" if backend == "auto" else f"explicitly requested {backend}" ) diff --git a/vllm_rlt/entrypoints/cli.py b/vllm_rlt/entrypoints/cli.py index b7c53c82..706baab6 100644 --- a/vllm_rlt/entrypoints/cli.py +++ b/vllm_rlt/entrypoints/cli.py @@ -70,6 +70,7 @@ def main(): "auto", "torch", "triton", + "flashinfer", "flash_attn", "flash_attn_2", "flash_attn_3", diff --git a/vllm_rlt/entrypoints/serve.py b/vllm_rlt/entrypoints/serve.py index 0563433c..3e5e45db 100644 --- a/vllm_rlt/entrypoints/serve.py +++ b/vllm_rlt/entrypoints/serve.py @@ -92,6 +92,7 @@ def main(): "auto", "torch", "triton", + "flashinfer", "flash_attn", "flash_attn_2", "flash_attn_3", diff --git a/vllm_rlt/kernels/flashinfer_attention.py b/vllm_rlt/kernels/flashinfer_attention.py new file mode 100644 index 00000000..2b4cae71 --- /dev/null +++ b/vllm_rlt/kernels/flashinfer_attention.py @@ -0,0 +1,115 @@ +"""FlashInfer XQA paged decode kernels; no packed prefill in this backend. + +Decode uses independent single-query rows, exactly like the Triton path. +SM120 (RTX 50) is the validated target; other supported majors are accepted +but unvalidated. Requires a CUDA toolkit with nvcc >= 12.9 visible through +CUDA_HOME for the just-in-time compiled kernels, and that toolkit's lib +directory on LD_LIBRARY_PATH. +""" + +import importlib +import logging +from importlib.metadata import PackageNotFoundError, version +from types import SimpleNamespace + +import torch + +FLASHINFER_PAGE_SIZES = (16, 32, 64, 128) +FLASHINFER_WORKSPACE_BYTES = 128 * 1024 * 1024 + + +class FlashInferPagedAttention: + def __init__(self, device, dtype, head_dim, block_size): + if device.type != "cuda" or torch.version.hip: + raise ValueError("flashinfer currently requires an NVIDIA CUDA device") + # float16 kernels compute correctly, but flashinfer-python 0.7.0.post1 + # crashes the process (host SIGFPE) while building the f16 JIT module + # on first in-process use; reject instead of shipping that. + if dtype is torch.float16: + raise ValueError( + "the flashinfer backend is not validated with float16; use bfloat16" + ) + if dtype is not torch.bfloat16: + raise ValueError("flashinfer requires bfloat16") + # The XQA JIT module generator rejects anything else at first call; + # validate here so the failure is an explicit startup error instead. + if head_dim % 16 or not 16 <= head_dim <= 512: + raise ValueError( + "flashinfer requires head_dim divisible by 16 and in [16, 512] (XQA kernels)" + ) + # XQA kernels are compiled per page size and only these variants ship. + if block_size not in FLASHINFER_PAGE_SIZES: + raise ValueError( + "flashinfer requires --block-size one of " + f"{FLASHINFER_PAGE_SIZES} (XQA page-size variants)" + ) + major, minor = torch.cuda.get_device_capability(device) + if major not in (9, 10, 12): + raise ValueError(f"flashinfer is not supported on SM{major}{minor}") + try: + package_version = version("flashinfer-python") + self.kernel = getattr( + importlib.import_module("flashinfer.decode"), + "trtllm_batch_decode_with_kv_cache", + ) + except (ImportError, AttributeError, PackageNotFoundError) as error: + raise ImportError( + "install flashinfer with: pip install 'flashinfer-python>=0.7.0' " + "(a CUDA toolkit with nvcc >= 12.9 must be reachable via CUDA_HOME, " + "and its lib directory on LD_LIBRARY_PATH for the JIT modules). " + "No backend fallback was performed." + ) from error + if major != 12: + logging.getLogger(__name__).warning( + "flashinfer attention is validated on SM120 only; SM%d%d is unvalidated", + major, + minor, + ) + # Persistent, pre-zeroed scratch shared by every call. Allocated on the + # first execution rather than here so backend selection stays free of + # device allocations: engines always profile before CUDA-graph capture, + # so the workspace exists by the time any graph is recorded. + self.workspace = None + self.info = dict( + backend="flashinfer", + package="flashinfer-python", + version=package_version, + kernel="xqa", + page_size=block_size, + ) + # Mirrors the capability names of the attention contract so a later + # migration only touches registration, not this kernel adapter. + self.capabilities = SimpleNamespace( + packed_prefill=False, cuda_graphs=True, async_scheduling=True + ) + logging.getLogger(__name__).warning("Attention implementation: %s", self.info) + + def __call__(self, q, key_cache, value_cache, block_tables, context_lengths): + # An empty batch host-crashes (SIGFPE, not an exception) inside the + # FlashInfer dispatch; return the same empty shape the other backends do. + if q.shape[0] == 0: + return torch.empty_like(q) + if self.workspace is None: + self.workspace = torch.zeros( + FLASHINFER_WORKSPACE_BYTES, dtype=torch.uint8, device=q.device + ) + out = torch.empty_like(q) + self.kernel( + q, + (key_cache, value_cache), + self.workspace, + # Padding columns hold -1 (synchronous), 0 (async), or stale valid + # ids (graph replay beyond the current width). The kernel bounds + # reads by seq_lens, and clamped ids keep any full-width access on + # allocated memory regardless. + block_tables=block_tables.clamp_min(0), + seq_lens=context_lengths.to(torch.uint32), + max_seq_len=block_tables.shape[1] * key_cache.shape[1], + bmm1_scale=q.shape[-1] ** -0.5, + out=out, + q_len_per_req=1, + # The engine pool is [blocks, page, heads, dim]; the API default is + # HND and silently misreads NHD data. + kv_layout="NHD", + ) + return out From 0a560976247080eadda2c25abf64ca795c5315a1 Mon Sep 17 00:00:00 2001 From: yanbao1217 <215131680+yanbao1217@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:35:16 +0800 Subject: [PATCH 2/5] bench: parameterize the GSM8K native attention backend The run subcommand accepts --attention-backend (torch/triton/flashinfer, default triton), records it in the run metadata, and passes it through Generator to the engine instead of the hardcoded Triton selection. --- benchmarks/gsm8k.py | 12 +++++++++++- benchmarks/gsm8k_backends.py | 6 ++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/benchmarks/gsm8k.py b/benchmarks/gsm8k.py index 862a6752..226ac1b2 100644 --- a/benchmarks/gsm8k.py +++ b/benchmarks/gsm8k.py @@ -439,6 +439,7 @@ def run(args): output.mkdir(parents=True, exist_ok=False) metadata = { "backend": args.backend, + "attention_backend": args.attention_backend, "protocol_sha256": file_digest(args.protocol), "source": source(), "cuda_visible_devices": os.environ["CUDA_VISIBLE_DEVICES"], @@ -456,7 +457,10 @@ def run(args): tokenizer = AutoTokenizer.from_pretrained(model, local_files_only=True) task = make_task(protocol["task_config"]) start = time.monotonic() - generator = Generator(args.backend, str(model), tokenizer, protocol["max_length"], exit_policy) + generator = Generator( + args.backend, str(model), tokenizer, protocol["max_length"], exit_policy, + attention_backend=args.attention_backend, + ) load_seconds = time.monotonic() - start rows = [] with (output / "samples.jsonl").open("x", buffering=1) as stream: @@ -619,6 +623,12 @@ def main(): p.add_argument("--backend", choices=["transformers", "native"], required=True) p.add_argument("--protocol", required=True) p.add_argument("--output", required=True) + p.add_argument( + "--attention-backend", + choices=["torch", "triton", "flashinfer"], + default="triton", + help="Native engine attention kernel; recorded in the run metadata", + ) p.add_argument("--exit-mode", choices=["ouro", "ouro_delayed"], default="ouro") p.add_argument( "--exit-threshold", diff --git a/benchmarks/gsm8k_backends.py b/benchmarks/gsm8k_backends.py index 4c038aa8..bf0fad81 100644 --- a/benchmarks/gsm8k_backends.py +++ b/benchmarks/gsm8k_backends.py @@ -80,7 +80,9 @@ def _after_model(self, module, inputs, output): class Generator: - def __init__(self, backend, model_path, tokenizer, max_length, exit_policy): + def __init__( + self, backend, model_path, tokenizer, max_length, exit_policy, attention_backend="triton" + ): self.backend, self.tokenizer, self.exit = backend, tokenizer, exit_policy # Full depth, taken from the protocol (the checkpoint's total_ut_steps). loops = exit_policy["max_loops"] @@ -113,7 +115,7 @@ def __init__(self, backend, model_path, tokenizer, max_length, exit_policy): model_path, device="cuda", dtype=torch.bfloat16, - attention_backend="triton", + attention_backend=attention_backend, # last_exited KV keeps one plane per loop. cache_config=CacheConfig(num_blocks=loops * ((max_length + 15) // 16)), scheduler_config=SchedulerConfig(max_num_seqs=1, max_num_batched_tokens=128), From a224349c5ed1c5a32747874c42e91c6aabe6d039 Mon Sep 17 00:00:00 2001 From: yanbao1217 <215131680+yanbao1217@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:35:16 +0800 Subject: [PATCH 3/5] docs: add FlashInfer backend guide with SM120 accuracy note Document installation and the two runtime requirements (CUDA_HOME with nvcc >= 12.9 for JIT kernels, the toolkit lib directory on LD_LIBRARY_PATH), scope and explicit rejections, backend selection guidance, and the GSM8K-87 greedy variation measured on RTX 5090. --- README.md | 6 +++--- docs/README.md | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2ff14ddb..be9e9f7b 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,9 @@ vLLM to be installed. See [Citation](#citation) for the paper's BibTeX entry. - **Depth-aware paged KV cache.** LAST-EXITED and SHARED layouts, automatic CUDA cache sizing, and optional prefix caching, incremental page allocation, priority scheduling, and preemption with CPU state snapshots. -- **Configurable GPU execution.** Triton and FlashAttention backends, asynchronous - scheduling, multiple CUDA streams, reusable buffers, and CUDA Graph capture - of decode recurrent cores. +- **Configurable GPU execution.** Triton, FlashAttention, and FlashInfer backends, + asynchronous scheduling, multiple CUDA streams, reusable buffers, and CUDA Graph + capture of decode recurrent cores. - **Offline and online inference.** A Python API, a command-line interface, and an OpenAI-compatible completions endpoint with streaming, greedy decoding, and seeded top-k/top-p sampling. diff --git a/docs/README.md b/docs/README.md index 3288f99e..9d0fb8ce 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ an environment to receiving the first generated response. The | [Runtime configuration](cdb_runtime.md) | Exit policies, KV layouts, execution options, and CUDA Graphs | | [Asynchronous scheduling](https://github.com/hsliuustc0106/vllm-rlt/pull/30) | CPU/GPU pipelining and single-stream or multi-stream execution | | [FlashAttention](https://github.com/hsliuustc0106/vllm-rlt/pull/30) | FA2/FA3/FA4 installation, hardware selection, and constraints | +| [FlashInfer](flashinfer.md) | XQA decode kernels for SM120: installation, environment, and scope | | [Cache and scheduling features](https://github.com/hsliuustc0106/vllm-rlt/pull/31) | Prefix reuse, incremental KV, priorities, and preemption | | [Prefill/decode disaggregation](https://github.com/hsliuustc0106/vllm-rlt/pull/31) | Single-host GPU worker pools and NIXL transfer | | [HTTP serving](serving.md) | Completions API, streaming, and service lifecycle | @@ -26,7 +27,8 @@ an environment to receiving the first generated response. The Current model support is limited to Ouro-1.4B. CPU execution provides a Torch reference backend. FlashAttention hardware validation is currently documented -for FA4 on B300; FA2/FA3 require validation on their target devices. Disaggregated +for FA4 on B300; FA2/FA3 require validation on their target devices. FlashInfer +decode is validated on RTX 5090 (SM120). Disaggregated serving currently targets multiple GPUs on a single host. `ouro_delayed` reuses the trained Ouro gate with a one-loop delay; it changes From 1a24e527005a5ace2de33b7c1e7f9e17bf9bc566 Mon Sep 17 00:00:00 2001 From: yanbao1217 <215131680+yanbao1217@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:34:38 +0800 Subject: [PATCH 4/5] docs: record GSM8K attention-backend runs in the accuracy guide Document the --attention-backend flag on the native run command, the RTX 5090 Triton/FlashInfer scores on the frozen 87-question case, and the expected single-pass greedy variation between BF16 kernels. --- docs/accuracy.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/accuracy.md b/docs/accuracy.md index f0eccac3..60a2ff29 100644 --- a/docs/accuracy.md +++ b/docs/accuracy.md @@ -164,6 +164,25 @@ counts as `identical_context` when the two runs chose different depths for a pos whose preceding tokens were still identical. These fields are diagnostic; the accuracy gate does not use them. +## Attention-backend runs + +`run --backend native` selects the attention kernel with `--attention-backend` +(`triton` by default, recorded in the run metadata): + +```bash +python -m benchmarks.gsm8k run --backend native --attention-backend flashinfer \ + --protocol /path/to/gsm8k-87-protocol.json --output /path/to/native-flashinfer +``` + +On RTX 5090 (SM120) the frozen default case scored **Triton 61/87 and FlashInfer +58/87** against the stored HF baseline of 59/87: 64 of the 87 generations were +token-identical between the kernels, the divergent ones flipped near-tied greedy +choices at scattered positions, and exit depths never differed. This is the +expected single-pass greedy variation between BF16 kernels with different +accumulation orders — see the [FlashInfer accuracy +note](flashinfer.md#accuracy-note-gsm8k-87-single-greedy-pass) before treating a +one-question delta from a backend swap as a regression. + Dataset: [GSM8K](https://huggingface.co/datasets/openai/gsm8k). The [Ouro evaluation settings](https://arxiv.org/html/2510.25741v5#A3.T16) do not pin the exact harness revision, demonstrations or token limits, so the settings From 549a709ba0b7f875c96bacda0ad510d917c7c0e2 Mon Sep 17 00:00:00 2001 From: yanbao1217 <215131680+yanbao1217@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:05:48 +0800 Subject: [PATCH 5/5] fix: gate automatic FlashInfer selection to SM120 and dedupe capabilities Address review feedback on #112: - auto only considers the flashinfer candidate on SM 12.x, so SM90/SM100 keep their pre-FlashInfer fallback when every FlashAttention generation is unavailable; explicit --attention-backend flashinfer still opts in on other accepted architectures with the unvalidated-architecture warning - the capability declaration now lives only in the attention registration layer; the kernel adapter no longer carries a duplicate - regression rows for the SM90/SM100 fallbacks, an explicit-opt-in test, and the CUDA Graph matrix (replay, eager fallback, abort/cancellation, request reuse) now cover FlashInfer --- tests/test_backend_selection.py | 21 +++++++++++++++++++++ tests/test_cuda_graph.py | 7 ++++--- tests/test_flashinfer_attention.py | 13 ++++++++----- vllm_rlt/attention/backend.py | 22 +++++++++++++++++++++- vllm_rlt/kernels/flashinfer_attention.py | 6 ------ 5 files changed, 54 insertions(+), 15 deletions(-) diff --git a/tests/test_backend_selection.py b/tests/test_backend_selection.py index 5824d950..859b3053 100644 --- a/tests/test_backend_selection.py +++ b/tests/test_backend_selection.py @@ -87,6 +87,15 @@ def test_cpu_auto_reports_torch_and_runs_reference_attention(caplog): "flash_attn_2", ), ((10, 0), torch.bfloat16, 128, 16, (), "flash_attn_4"), + ( + (9, 0), + torch.bfloat16, + 128, + 16, + ("flash_attn.cute", "flash_attn_3.flash_attn_interface"), + "triton", + ), + ((10, 0), torch.bfloat16, 128, 16, ("flash_attn.cute",), "triton"), ((12, 0), torch.bfloat16, 128, 16, (), "flashinfer"), ((12, 0), torch.bfloat16, 128, 16, ("flashinfer",), "triton"), ((7, 5), torch.float16, 128, 256, (), "torch"), @@ -104,6 +113,18 @@ def test_auto_selection_constraints( assert backend_capabilities(selected).packed_prefill +def test_explicit_flashinfer_opt_in_outside_sm120(packages, monkeypatch, caplog): + import logging + + caplog.set_level(logging.WARNING, logger="vllm_rlt.kernels.flashinfer_attention") + monkeypatch.setattr(torch.cuda, "get_device_capability", lambda device: (9, 0)) + selected = create_backend("flashinfer", torch.device("cuda"), torch.bfloat16, 128, 16) + assert selected.info["selected_backend"] == "flashinfer" + assert selected.capabilities.cuda_graphs and selected.capabilities.async_scheduling + assert not selected.capabilities.packed_prefill + assert "unvalidated" in caplog.text + + def test_rocm_auto_uses_triton(packages, monkeypatch): monkeypatch.setattr(torch.version, "hip", "7.0") monkeypatch.setattr( diff --git a/tests/test_cuda_graph.py b/tests/test_cuda_graph.py index fb901c42..85f689b9 100644 --- a/tests/test_cuda_graph.py +++ b/tests/test_cuda_graph.py @@ -21,7 +21,7 @@ def test_cuda_graph_validation(): @pytest.mark.gpu -@pytest.mark.parametrize("backend", ["triton", "flash_attn"]) +@pytest.mark.parametrize("backend", ["triton", "flash_attn", "flashinfer"]) @pytest.mark.parametrize("mode", ["sync", "async", "multi"]) @pytest.mark.parametrize("layout", ["last_exited", "shared"]) @pytest.mark.parametrize("static", [False, True]) @@ -71,7 +71,8 @@ def test_graph_replay_matches_eager_and_reuses_requests(backend, mode, layout, s @pytest.mark.gpu -def test_graph_cache_limit_fallback_and_abort(): +@pytest.mark.parametrize("backend", ["triton", "flashinfer"]) +def test_graph_cache_limit_fallback_and_abort(backend): torch.manual_seed(19) model = OuroForCausalLM(tiny_ouro_config(head_dim=64)).to("cuda", torch.bfloat16) engine = LLMEngine( @@ -87,7 +88,7 @@ def test_graph_cache_limit_fallback_and_abort(): async_scheduling=True, ), exit_config=ExitConfig("ouro_delayed"), - attention_backend="triton", + attention_backend=backend, ) for count in [1, 2, 3]: for i in range(count): diff --git a/tests/test_flashinfer_attention.py b/tests/test_flashinfer_attention.py index af8300b9..920c40ec 100644 --- a/tests/test_flashinfer_attention.py +++ b/tests/test_flashinfer_attention.py @@ -5,6 +5,7 @@ from tests.helpers import tiny_ouro_config from vllm_rlt import CacheConfig, ExecutionConfig, ExitConfig, SamplingParams, SchedulerConfig +from vllm_rlt.attention import BackendCapabilities, create_backend from vllm_rlt.engine.llm_engine import LLMEngine from vllm_rlt.kernels.flashinfer_attention import FlashInferPagedAttention from vllm_rlt.kernels.paged_attention import torch_paged_attention @@ -72,13 +73,15 @@ def test_backend_contract_anchors(): assert attention.info["package"] == "flashinfer-python" assert attention.info["page_size"] == 16 assert attention.info["kernel"] == "xqa" - assert attention.capabilities.packed_prefill is False - assert attention.capabilities.cuda_graphs is True - assert attention.capabilities.async_scheduling is True - # Absence is the contract: the FA4 packed-prefill gates in the engine and - # model runner key off a missing generation attribute staying missing. + # Absence is the contract: capability declarations live only in the + # attention registration layer, and the FA4 packed-prefill gates key off + # a missing generation attribute staying missing. + assert not hasattr(attention, "capabilities") assert not hasattr(attention, "generation") assert not hasattr(attention, "prefill") + wrapper = create_backend("flashinfer", torch.device("cuda"), torch.bfloat16, 128, 16) + assert wrapper.info["selected_backend"] == "flashinfer" + assert wrapper.capabilities == BackendCapabilities(cuda_graphs=True, async_scheduling=True) @pytest.mark.gpu diff --git a/vllm_rlt/attention/backend.py b/vllm_rlt/attention/backend.py index 2e2005a9..a0bdb664 100644 --- a/vllm_rlt/attention/backend.py +++ b/vllm_rlt/attention/backend.py @@ -56,6 +56,18 @@ def backend_capabilities(implementation) -> BackendCapabilities: return implementation.capabilities +def _auto_flashinfer_supported(device) -> bool: + """Gate automatic FlashInfer selection to the validated SM120 target. + + Other architectures the kernel accepts remain reachable through explicit + `--attention-backend flashinfer` opt-in; auto keeps its pre-FlashInfer + fallback behavior everywhere else. + """ + if device.type != "cuda" or torch.version.hip: + return False + return torch.cuda.get_device_capability(device)[0] == 12 + + def validate_backend_name(backend: str) -> None: if backend not in {"auto", "torch", "triton", "flashinfer", *FLASH_BACKENDS}: raise ValueError("unknown attention backend") @@ -90,7 +102,8 @@ def create_backend( *, required_capabilities: BackendCapabilities = BackendCapabilities(), ) -> AttentionBackend: - """Explicit choices fail without fallback; auto tries FA4/3/2, FlashInfer, Triton, then Torch. + """Explicit choices fail without fallback; auto tries FA4/3/2, FlashInfer + (SM120 only), Triton, then Torch. Each candidate validates hardware/configuration and imports its implementation. Only compatibility and import failures permit auto to try the next candidate; @@ -104,6 +117,13 @@ def create_backend( candidates = ("torch",) skipped.append(f"{device.type.upper()} uses Torch reference attention") for candidate in candidates: + if ( + backend == "auto" + and candidate == "flashinfer" + and not _auto_flashinfer_supported(device) + ): + skipped.append("flashinfer: automatic selection is validated on SM120 only") + continue try: implementation = _create_explicit(candidate, device, dtype, head_dim, block_size) missing = [ diff --git a/vllm_rlt/kernels/flashinfer_attention.py b/vllm_rlt/kernels/flashinfer_attention.py index 2b4cae71..73a0a5a4 100644 --- a/vllm_rlt/kernels/flashinfer_attention.py +++ b/vllm_rlt/kernels/flashinfer_attention.py @@ -10,7 +10,6 @@ import importlib import logging from importlib.metadata import PackageNotFoundError, version -from types import SimpleNamespace import torch @@ -77,11 +76,6 @@ def __init__(self, device, dtype, head_dim, block_size): kernel="xqa", page_size=block_size, ) - # Mirrors the capability names of the attention contract so a later - # migration only touches registration, not this kernel adapter. - self.capabilities = SimpleNamespace( - packed_prefill=False, cuda_graphs=True, async_scheduling=True - ) logging.getLogger(__name__).warning("Attention implementation: %s", self.info) def __call__(self, q, key_cache, value_cache, block_tables, context_lengths):