Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Three requests reach one GPU step together. R0 processes 3 new tokens after a 3-token prefix, R1 processes 1 after a 1-token prefix, and R2 processes 4 after a 5-token prefix. Their 8 query rows fit in one compact buffer. After appending the new keys and values, their KV lengths are 6, 2, and 9, scattered across physical pages. Attention still has to return one correct state per row without attending to a future token.
Before naming an API, predict the contract. The kernel needs query boundaries, a page list plus tail length for each history, and a work assignment that can split a long history without losing softmax normalization. Those pieces explain FlashInfer: a library and kernel generator for attention, matrix multiplication, mixture-of-experts operations, sampling, and cache updates.[1] The serving engine still owns allocation and admission.
FlashAttention supplies the tiled softmax, and paged KV caching supplies the logical-to-physical mapping. Keep those two mechanisms separate: avoiding a score matrix and avoiding cache relocation save different memory traffic. The Python exercises here run on CPU and check indexing and attention arithmetic. They don't execute FlashInfer kernels or measure GPU speed.
Who ships it, who calls it
Start with ownership, because a kernel benchmark and a serving guarantee belong to different layers.
The API walkthrough is pinned to FlashInfer 0.6.18, released August 29, 2026, at commit 69ff11fc4954396d98326656dc85debd2223f637, checked September 2, 2026.[2][3] The paper benchmarks later in the lesson used v0.2. They aren't measurements of this release.
FlashInfer began in summer 2023 with researchers from the University of Washington, Carnegie Mellon University, and OctoAI.[4] The MLSys 2025 paper lists Zihao Ye, Lequn Chen, Ruihang Lai, Wuwei Lin, Yineng Zhang, Stephanie Wang, Tianqi Chen, Baris Kasikci, Vinod Grover, Arvind Krishnamurthy, and Luis Ceze, with affiliations spanning UW, NVIDIA, Perplexity, CMU, and independent research.[5] The cited contribution snapshot documents NVIDIA participation alongside the FlashInfer community, with public GitHub Actions and an NVIDIA-internal GPU matrix on GitLab.[6]
| Field | Documented project fact |
|---|---|
| Origin | UW, CMU, and OctoAI researchers started the project in 2023; the launch post names that initial team.[4] |
| Stewardship | The cited contribution snapshot documents public review and CI plus an additional NVIDIA GPU test path.[6] |
| Founding contributors | Zihao Ye, Lequn Chen, Ruihang Lai, and the paper's compiler, systems, and GPU collaborators form the documented research lineage.[5] |
| Source license | Apache-2.0 for FlashInfer's core source.[7] Bundled CUDA components and dependencies can retain BSD, MIT, or other notices. |
| Commercial boundary | NVIDIA participates in maintenance and CI, but FlashInfer remains a public kernel library rather than a model or hosted inference product.[6] |
| Asset boundary | Tutorials may download gated or separately licensed checkpoints. FlashInfer's Apache license doesn't grant rights to those models or datasets. |
The repository names SGLang, vLLM, TensorRT-LLM, Text Generation Inference, MLC-LLM, LightLLM, lorax, and ScaleLLM as adopters or integrations.[1] Adoption doesn't mean every framework enables every operator. Check the framework's attention backend, FlashInfer release, CUDA version, and model feature matrix before treating a project-level capability as a service guarantee.
Why inference attention becomes irregular
FlashAttention gives exact attention a streaming recipe: load tiles, update online softmax state, and avoid writing a full score matrix to global memory. In serving, the pressure moves to the batch around that kernel. The transformer equation stays regular, but requests arrive and finish independently, sequence lengths change every step, and the key-value (KV) cache comes from a shared page pool.
Start by counting two axes. R0 and R2 are doing chunked prefill, also called append attention. R1 is in the decode loop. Here, every reported KV length includes the current query tokens' keys and values, which the caller has already appended. A ragged query buffer stores the 8 query rows back-to-back. What tells a kernel where one request ends and the next begins? An index-pointer array qo_indptr:
| request | phase | query rows | qo_indptr interval | logical KV length |
|---|---|---|---|---|
| R0 | prefill | 3 | [0, 3) | 6 |
| R1 | decode | 1 | [3, 4) | 2 |
| R2 | prefill | 4 | [4, 8) | 9 |
The query tensor is compact. Now predict what the cache needs: a way to name physical pages and a way to mark the occupied part of the last page. Page size makes allocation independent of exact sequence length, much like virtual memory maps a process address to a physical frame.[8] FlashInfer's length contract is page_size * (len(page_indices) - 1) + last_page_len, with last_page_len in 1 .. page_size.[9]
| request | page indices | last_page_len | KV length check |
|---|---|---|---|
| R0 | [7, 2] | 2 | |
| R1 | [11] | 2 | |
| R2 | [3, 14, 6] | 1 |
Packed metadata for the whole batch is kv_indptr = [0, 2, 3, 6], kv_indices = [7, 2, 11, 3, 14, 6], and kv_last_page_len = [2, 2, 1]. Read the two pointer arrays in different coordinate systems: qo_indptr counts query rows, while kv_indptr counts page IDs. Confusing those units can still produce in-bounds pointers with the wrong sequence.
A cooperative thread array (CTA) is CUDA's name for a thread block: threads that share on-chip memory and run one tile of work. Long histories may benefit from several CTAs and a later merge, but these tiny lengths are indexing examples, not evidence that nine tokens should be split. Actual launches also depend on query tiles, heads, backend, and hardware.
Why does a serving kernel need qo_indptr instead of a padded [batch, max_q, heads, dim] tensor?
Answer
qo_indptr preserves each request's true row range, so the kernel skips padding work and can load a compact ragged buffer. In exchange, every row carries an indirect request boundary.
Can you rebuild both pointer arrays before invoking attention? The script below does so from the same page lists. If a later figure or kernel dump disagrees with qo_indptr = [0, 3, 4, 8] or KV lengths [6, 2, 9], stop there: metadata is wrong before any attention math runs.
1PAGE_SIZE = 4
2pages = {0: [7, 2], 1: [11], 2: [3, 14, 6]}
3last_page_len = {0: 2, 1: 2, 2: 1}
4
5def kv_len(page_ids: list[int], tail: int) -> int:
6 if not page_ids or any(type(p) is not int or p < 0 for p in page_ids):
7 raise ValueError("this example requires nonempty, nonnegative page IDs")
8 if type(tail) is not int or not 1 <= tail <= PAGE_SIZE:
9 raise ValueError("tail must occupy 1..PAGE_SIZE slots")
10 return PAGE_SIZE * (len(page_ids) - 1) + tail
11
12qo_indptr = [0]
13for query_len in (3, 1, 4):
14 qo_indptr.append(qo_indptr[-1] + query_len)
15
16kv_indptr = [0]
17for request in (0, 1, 2):
18 kv_indptr.append(kv_indptr[-1] + len(pages[request]))
19
20lengths = [kv_len(pages[request], last_page_len[request]) for request in (0, 1, 2)]
21assert qo_indptr == [0, 3, 4, 8]
22assert kv_indptr == [0, 2, 3, 6]
23assert lengths == [6, 2, 9]
24for bad_pages, bad_tail in (([], 1), ([3], 0), ([3], 5), ([-1], 1)):
25 try:
26 kv_len(bad_pages, bad_tail)
27 except ValueError:
28 pass
29 else:
30 raise AssertionError("invalid page metadata was accepted")
31print("qo_indptr", qo_indptr)
32print("kv_indptr", kv_indptr)
33print("kv_len", lengths)1qo_indptr [0, 3, 4, 8]
2kv_indptr [0, 2, 3, 6]
3kv_len [6, 2, 9]
Layouts are an API, not an implementation detail
FlashInfer documents several logical layouts because no single physical arrangement wins every phase. Ask what each phase wants to keep contiguous. Ragged storage packs tokens without padding; paged storage breaks a sequence into fixed-size pages; block-sparse row (BSR) metadata views that page table as a sparse matrix. Its CSR-style indptr / indices arrays tell the kernel which blocks to load. Multi-head latent attention (MLA) can store compressed latent states instead of ordinary K/V heads.[9]
The same choice extends to precision. Selected backends accept 8-bit floating point (FP8) or FP4 representations, with format-specific scales and packing. Those aren't drop-in replacements for FP16: scale granularity, cache layout, supported GPU, and dequantization path belong to the selected operator's contract. The example below stays in FP16 rather than assuming every wrapper supports every quantized cache.
The kernel needs three kinds of information:
- Payload: query, key, value, or latent tensors (in FP16, BF16, FP8, or sub-byte quantized formats).
- Shape metadata: head counts, head dimensions, page size, and request lengths.
- Indirection: row pointers, page indices, last-page lengths, and optional sparse masks.
Payload buffers can remain stable while metadata changes every scheduling step. A serving engine can recycle pages without asking a kernel to understand allocator policy. A kernel can specialize memory access around a known page size without owning the allocator.
Ragged prefill and paged decode
Prefill consumes many new tokens per request. A ragged layout keeps those tokens contiguous, so a batch prefill wrapper can traverse row ranges with one set of offsets. Decode usually consumes one token per live request and reads a long history. A paged layout avoids copying each history into a newly padded tensor.
Our batch is mixed: R0 and R2 have several query rows, R1 has one. A paged-prefill wrapper can represent all three through qo_indptr; a dedicated decode wrapper assumes one query per request. FlashInfer also has other mixed-phase paths, but those aren't interchangeable APIs. Choose a named wrapper and verify its signature before passing scheduler metadata.[1]
Causality uses logical positions, not page numbers
R2 has five earlier tokens and four new ones. Its first query is at logical position 5, so it can attend to positions 0 through 5, not the whole nine-token cache. For local query index , query length , and total KV length , the allowed key positions satisfy . This is a bottom-right-aligned causal mask. A top-left triangle would expose too little prefix; no mask would expose future tokens.
Run the indexing check before any GPU call. It also shows why sorting the page IDs would change the sequence:
1page_ids, page_size, q_len, kv_len = [3, 14, 6], 4, 4, 9
2addresses = [(page_ids[j // page_size], j % page_size) for j in range(kv_len)]
3mask = [[j <= kv_len - q_len + i for j in range(kv_len)] for i in range(q_len)]
4assert addresses[5] == (14, 1)
5assert addresses[-1] == (6, 0)
6assert [sum(row) for row in mask] == [6, 7, 8, 9]
7assert [p for p, _ in addresses] != sorted(p for p, _ in addresses)
8print("logical token 5 -> page, slot", addresses[5])
9for row in mask:
10 print("".join("1" if visible else "." for visible in row))1logical token 5 -> page, slot (14, 1)
2111111...
31111111..
411111111.
5111111111BSR as a memory-access contract
BSR metadata doesn't mean the data are mathematically sparse in every model. It says the kernel may load fixed-size blocks through an index table. If R0 references pages [7, 2], the GPU can gather those pages without a defragmentation copy. If a prefix is shared, several request rows can point at the same physical pages while their suffix pages diverge.
That indirection adds pointer arithmetic and less predictable memory access. It pays off when avoiding copies and padding saves more work than extra gathers cost. Tiny sequences can lose to a simpler contiguous kernel. Use the layout only when its saved movement outweighs its indexing cost; this is a workload decision, not a universal speedup.
Core mechanism: schedule work around state
Suppose one query's KV history is split across two CTAs. What can each worker discard while still letting a parent recover exact attention? Normalization has to survive. An attention kernel normally looks like a matrix multiplication followed by a softmax and a value multiplication, but FlashAttention already showed that the full score matrix is unnecessary. FlashInfer makes the output vector plus a log-sum-exp statistic the composable unit across workers, devices, and shared prefixes.[5][10]
If two workers process disjoint key ranges for the same query row and head, each returns an output vector and a base-2 log-sum-exp . For ordinary scaled logits , that statistic is , not . The conversion changes the stored normalization base, not the attention distribution. FlashInfer's public merge_state expects base-2 statistics; don't pass a natural-log LSE without converting it by division by .[11]
Before reading the recurrence, keep one invariant in mind: every partial output is normalized over its own key range, and its statistic records that range's total weight. The merge must restore the weighting between ranges.
The max subtraction keeps the powers of two in a safe range. The operator is associative in exact arithmetic, so a tree can merge many chunks without replaying their key-value tiles.[10] Floating-point reduction order can still change the result. Overlapping chunks double-count keys; mismatched masks, positions, or score scales combine different attention problems.
![Worker A has base-2 LSE 2 and output [1,0], corresponding to normalization mass 4. Worker B has base-2 LSE 1 and output [0,1], corresponding to mass 2. Combining disjoint key ranges gives weights two thirds and one third, output [2/3,1/3], and LSE approximately 2.585.](/cdn/content-image/projects/deep-dive-flashinfer/illustrations/_generated/attention_state_merge_dark.png?v=e06f7b792d2a)
A worked split-KV example
Take one query row and two key chunks. Chunk A has and output . Chunk B has and output .
- Pick .
- Compute weights and .
- Sum weights .
- Combine outputs: .
- Keep for a parent merge.
The merge doesn't need the original scores. Split-KV workers can run on separate pages or sequence ranges, then publish a small state record. Matching state precision, base, and masking rules makes the merged value mathematically equivalent to one larger softmax.
1import math
2
3def merge_state(
4 o_a: list[float], b_a: float, o_b: list[float], b_b: float
5) -> tuple[list[float], float]:
6 """CPU reference; -inf with a zero vector represents an empty key set."""
7 if not o_a or len(o_a) != len(o_b):
8 raise ValueError("state vectors must have the same positive dimension")
9 if not all(math.isfinite(x) for x in o_a + o_b):
10 raise ValueError("state vectors must be finite")
11 if any(math.isnan(b) or b == math.inf for b in (b_a, b_b)):
12 raise ValueError("LSE must be finite or negative infinity")
13 if any(b == -math.inf and any(o) for o, b in ((o_a, b_a), (o_b, b_b))):
14 raise ValueError("empty states must use a zero vector")
15 if b_a == -math.inf:
16 return list(o_b), b_b
17 if b_b == -math.inf:
18 return list(o_a), b_a
19 shift = max(b_a, b_b)
20 w_a = 2 ** (b_a - shift)
21 w_b = 2 ** (b_b - shift)
22 z = w_a + w_b
23 merged = [(w_a * x + w_b * y) / z for x, y in zip(o_a, o_b, strict=True)]
24 return merged, shift + math.log2(z)
25
26output, log_sum_exp = merge_state([1.0, 0.0], 2.0, [0.0, 1.0], 1.0)
27assert abs(output[0] - 2 / 3) < 1e-12
28assert abs(output[1] - 1 / 3) < 1e-12
29assert abs(log_sum_exp - (2 + math.log2(1.5))) < 1e-12
30assert merge_state([0., 0.], -math.inf, output, log_sum_exp) == (output, log_sum_exp)
31assert merge_state([0., 0.], -math.inf, [0., 0.], -math.inf) == ([0., 0.], -math.inf)
32print([round(value, 4) for value in output], round(log_sum_exp, 4))1[0.6667, 0.3333] 2.585An empty or fully masked chunk has no normalized output. This reference defines it as a zero vector with and handles it explicitly. Blindly evaluating produces NaN. The pinned public CUDA merge kernel uses the finite-state recurrence directly, so our empty-state branch isn't a claim that this API accepts arbitrary empty sentinels.[11] Check the producing kernel's masked-state convention before passing its output to a merge.
Now compare split attention with a direct stable softmax, including large logits and a chunk with no visible keys. This continues the preceding cell. The random numbers are synthetic arithmetic inputs, not model outputs or a performance benchmark.
1import random
2
3def direct_state(scores, values):
4 if not scores:
5 return [0.0, 0.0], -math.inf
6 shift = max(scores)
7 weights = [math.exp(s - shift) for s in scores]
8 denominator = sum(weights)
9 output = [sum(w * v[d] for w, v in zip(weights, values, strict=True))
10 / denominator for d in range(2)]
11 return output, (shift + math.log(denominator)) / math.log(2)
12
13rng = random.Random(7)
14cases = 0
15for length in (1, 2, 6, 9, 33):
16 for offset in (-1000.0, 0.0, 1000.0):
17 scores = [offset + rng.uniform(-4, 4) for _ in range(length)]
18 values = [[rng.uniform(-1, 1), rng.uniform(-1, 1)] for _ in scores]
19 expected_o, expected_b = direct_state(scores, values)
20 for cut in range(length + 1):
21 left = direct_state(scores[:cut], values[:cut])
22 right = direct_state(scores[cut:], values[cut:])
23 actual_o, actual_b = merge_state(*left, *right)
24 assert max(abs(a - b) for a, b in zip(actual_o, expected_o)) < 1e-10
25 assert abs(actual_b - expected_b) < 1e-10
26 cases += 1
27
28# Overlap counts a key twice. It is not a valid partition.
29scores, values = [0.0, 0.0], [[1.0, 0.0], [0.0, 1.0]]
30whole = direct_state(scores, values)
31duplicate = merge_state(*whole, *direct_state(scores[:1], values[:1]))
32assert abs(whole[0][0] - 0.5) < 1e-12
33assert abs(duplicate[0][0] - 2 / 3) < 1e-12
34print("split/direct checks:", cases)
35print("overlapping chunks change 0.5000 to", f"{duplicate[0][0]:.4f}")1split/direct checks: 168
2overlapping chunks change 0.5000 to 0.6667What must a split-KV worker return so another worker can combine its result without seeing raw scores?
Answer
It must return an output vector and a log-sum-exp normalization statistic for the same query row. The merge uses the largest statistic as a shift, combines weighted outputs, and carries a new statistic upward.
Plan then run: inspector-executor for GPU kernels
The state merge solves how to combine work. A second question is when to decide where that work goes. FlashInfer wrappers separate a metadata pass (plan) from the hot data pass (run). The plan computes offsets, request-to-tile assignments, temporary-buffer sizes, and choices such as whether to split KV work. The run consumes that plan with query and cache tensors.
Ask which work should happen once per batch and which should happen on every layer launch. The paper describes this split as an inspector-executor pattern: inspect irregular metadata on the CPU, then execute a stable GPU kernel, including under CUDA Graphs, which want the same launch shape and pointer layout each replay.[5]

Metadata values can change while allocated tensor shapes stay fixed. Reuse a plan across compatible layer calls for the same batch specification, not blindly across decode steps whose sequence lengths have grown. The pinned prefill plan performs host-side work and device copies; it can't run inside CUDA Graph capture or torch.compile.[12] Compatible run calls can be captured. Graph-mode wrappers reserve metadata buffers, require a fixed batch size, and bound total query rows and page-index capacity. Stable buffer addresses don't make stale buffer contents correct.
It also creates an explicit lifetime contract:
| object | created by | consumed by | invalidated when |
|---|---|---|---|
| page indices | allocator/scheduler | plan and run | logical-to-physical mapping changes; refresh before use |
| plan workspace | plan | run | incompatible lengths, shapes, mask settings, or buffer replacement |
| partial states | run kernel | merge kernel | current query step ends |
| output tensor | wrapper or caller | model block | owner reuses it after dependent work completes |
The ordinary batch-decode wrapper doesn't take qo_indptr, because each request contributes one query row. Our paged-prefill wrapper does. In v0.6.18 the keyword is head_dim_qk, and paged metadata names start with paged_kv_. The earlier shorthand head_dim / kv_indptr would raise a keyword error for this wrapper.[12]
The following is a GPU-only smoke check, source-checked against v0.6.18 but not executed for this review. It needs a compatible NVIDIA GPU, CUDA-enabled PyTorch, and flashinfer-python==0.6.18; JIT dependencies and artifacts must match the chosen CUDA build. It allocates synthetic Q/K/V directly, so there are no model weights or downloads of checkpoints. The 128 MiB scratch allocation follows the wrapper's documented recommendation, not a measured minimum.
1import torch
2import flashinfer
3from importlib.metadata import version
4
5assert version("flashinfer-python") == "0.6.18"
6assert torch.cuda.is_available(), "requires NVIDIA CUDA; CPU reference is above"
7torch.manual_seed(7)
8device = "cuda:0"
9workspace = torch.empty(128 * 1024 * 1024, dtype=torch.uint8, device=device)
10wrapper = flashinfer.BatchPrefillWithPagedKVCacheWrapper(
11 workspace, kv_layout="NHD", backend="fa2"
12)
13index = lambda values: torch.tensor(values, dtype=torch.int32, device=device)
14q = torch.randn(8, 32, 128, dtype=torch.float16, device=device)
15paged_kv_cache = torch.randn(15, 2, 4, 8, 128, dtype=torch.float16, device=device)
16wrapper.plan(
17 qo_indptr=index([0, 3, 4, 8]),
18 paged_kv_indptr=index([0, 2, 3, 6]),
19 paged_kv_indices=index([7, 2, 11, 3, 14, 6]),
20 paged_kv_last_page_len=index([2, 2, 1]),
21 num_qo_heads=32,
22 num_kv_heads=8,
23 head_dim_qk=128,
24 page_size=4,
25 causal=True,
26 q_data_type=torch.float16,
27 kv_data_type=torch.float16,
28)
29output = wrapper.run(q, paged_kv_cache)
30assert output.shape == q.shape and torch.isfinite(output).all()
31
32# Independent dense reference: gather logical pages and repeat GQA KV heads.
33for row_start, row_end, ids, length in (
34 (0, 3, [7, 2], 6), (3, 4, [11], 2), (4, 8, [3, 14, 6], 9)
35):
36 k = paged_kv_cache[ids, 0].reshape(-1, 8, 128)[:length].float()
37 v = paged_kv_cache[ids, 1].reshape(-1, 8, 128)[:length].float()
38 k, v = k.repeat_interleave(4, dim=1), v.repeat_interleave(4, dim=1)
39 query = q[row_start:row_end].float()
40 scores = torch.einsum("qhd,khd->hqk", query, k) / (128 ** 0.5)
41 q_len = row_end - row_start
42 visible = torch.arange(length, device=device)[None, :] <= (
43 length - q_len + torch.arange(q_len, device=device)[:, None]
44 )
45 probabilities = scores.masked_fill(~visible[None], -torch.inf).softmax(-1)
46 expected = torch.einsum("hqk,khd->qhd", probabilities, v)
47 torch.testing.assert_close(output[row_start:row_end].float(), expected,
48 atol=2e-3, rtol=2e-3)
49torch.cuda.synchronize()
50print("paged causal GQA reference passed:", tuple(output.shape))Here NHD means each K or V page has shape [page_size, kv_heads, head_dim]. The 32 query heads share 8 KV heads in groups of four. Those are grouped-query attention (GQA) semantics, not four copies of each physical cache allocation. The dense comparison repeats heads only in the reference. Its tolerance is a starting check for these random FP16 inputs, not an accuracy budget for every model.
Refresh page mappings and lengths through the public planning path when the batch changes. Some integrations can update compatible fixed-address buffers without rebuilding all work assignments, but that is backend-specific, not a general permission to mutate private wrapper fields. Replanning metadata doesn't normally require recompiling a kernel or recapturing a compatible graph. Conversely, fixed_split_size can change the number of launched CTAs as KV lengths grow; v0.6.18 explicitly warns that fixed splitting doesn't guarantee CUDA Graph compatibility.[12]
Load balancing and split-KV decisions
If one request has 128K cached tokens and seven requests have 128 tokens, assigning only one CTA per request can leave the GPU underfilled or dominated by the long request. Head and query tiling also contribute parallelism, so request count alone doesn't determine utilization. FlashInfer can partition long KV ranges across CTAs, compute partial states, and merge them. The [6, 2, 9] example teaches indexing; it doesn't justify a particular split threshold.
The scheduler must choose a split policy. A tiny chunk raises merge overhead and metadata traffic. A large chunk raises tail latency because one CTA owns too much work. Treat occupancy as a constraint, not the objective: enough resident warps can hide memory stalls, but chasing the highest occupancy can sacrifice tile reuse or register headroom. The current mix of decode lengths, page locality, head dimension, and concurrent streams needs balanced work.
What split-KV changes
| choice | likely benefit | cost or risk |
|---|---|---|
| no split for short histories | low metadata overhead | long request can become a tail |
| split long histories | better CTA balance | partial output and merge workspace |
| split plus CUDA graph | stable launch path | graph shape must stay compatible |
| dynamic plan each step | follows request churn | plan CPU/GPU work and synchronization |
The paper describes a load-balanced scheduler designed to cope with dynamic user requests while remaining compatible with CUDA Graphs.[5] That claim is paper-era and workload-specific. Measure inter-token latency (ITL), tail latency, and memory traffic on the exact model and GPU rather than copying a chunk threshold from a benchmark.
Why isn't assigning one request to one CTA always fair?
Answer
Request lengths can differ by orders of magnitude. A long KV history keeps one CTA busy after short requests finish. Splitting that history into chunks lets several CTAs contribute, but requires partial states and a merge step.
Backends and JIT compilation
FlashInfer isn't one monolithic CUDA kernel. Its Python and C++ APIs select among implementations such as FlashAttention-2/3, cuDNN, CUTLASS, TensorRT-LLM, and generated kernels. The choice depends on architecture, operation, dtype, layout, and optional features.[1]
For one attention call, think in a composition chain: a layout reader follows indirection, a backend microkernel computes tiles and online state, and a merge or output transform finishes the result when work was split. Backend label alone leaves that chain unspecified. Record layout, head grouping, dtype, split policy, and selected implementation together when comparing runs.
The just-in-time (JIT) path matters when a supported specialization isn't covered by a precompiled binary, or when a developer implements a custom attention variant. A template specializes code, compiles it, and caches the resulting module. JIT doesn't make an arbitrary head dimension, cache layout, or unsupported GPU valid by itself. Precompiled flashinfer-cubin and JIT-cache packages reduce first-use latency when their CUDA and architecture match.
The boundary is practical:
| deployment mode | startup behavior | best fit |
|---|---|---|
| precompiled cubin | load matching binary | known GPU fleet, predictable startup |
| JIT module | compile on first use, then cache | custom shape or attention variant |
| backend dispatch | select cuDNN/CUTLASS/TRT-LLM/FA | reuse mature vendor path |
| separate dense reference | correctness and diagnosis, usually slower | compare outputs; not an automatic CPU fallback |
JIT can make a feature possible, but it moves compiler compatibility into your release. Pin CUDA, compiler, driver, and cache location. Warm kernels before exposing production traffic. Log which backend was selected, so a silent fallback doesn't look like a model regression.
The repository exposes module status, cache management, and API logging commands. API logging can capture calls and system information for diagnosis, but tensor-dumping modes may write sensitive prompts and outputs to disk. Treat diagnostics as production data handling, not as a free debug switch.
Where FlashInfer sits in a serving stack
FlashInfer owns kernel-level execution. It doesn't decide admission, tenant quotas, request cancellation, model weights, or page allocation. A stack such as vLLM or SGLang owns those policies, then passes shape and indirection metadata into FlashInfer. TensorRT-LLM can supply one backend implementation, but backend availability isn't the same as serving-engine integration.
| layer | responsibility | FlashInfer boundary |
|---|---|---|
| API gateway | auth, rate limit, cancellation | outside |
| serving scheduler | continuous batching, priority, deadlines | supplies batch metadata |
| KV allocator | physical page ownership and reuse | supplies page table |
| FlashInfer wrapper | plan offsets, choose kernel, run attention | core |
| model block | projections, residuals, logits | calls attention output |
| sampler | top-k/top-p, stop rules | separate FlashInfer operators can help |
The allocator boundary is a source of bugs. Reusing pages while a CUDA stream still reads them can corrupt another request. A disagreement between the page table and kv_last_page_len can expose uninitialized values in the last tile. Reusing a plan after page indices change may produce numerically plausible output for the wrong sequence.
A release checklist at the boundary
- Keep page-index, length, and workspace lifetimes tied to one stream or explicit event.
- Assert page size, head dimensions, dtype, and device at wrapper entry.
- Compare incremental decode against full-prefix attention on short randomized cases.
- Record selected backend, split-KV flag, and workspace size in a trace.
- Stress request finish, cancellation, and page reuse while CUDA Graph capture is enabled.
What evidence would distinguish a page-table lifetime bug from a numerical precision issue?
Answer
Run a short deterministic case with page reuse and stream synchronization checks. If outputs change when page ownership or execution order changes, inspect metadata lifetimes first. If outputs stay stable but differ gradually with dtype or accumulator precision, compare the merge and softmax states.
Strengths
One vocabulary for many inference phases
The same project covers prefill, decode, append, mixed prefill/decode, MLA, sparse patterns, sampling, quantization, and communication. A serving engine can keep a stable integration surface while the selected kernel changes.
Explicit composability
Ragged and paged layouts, plan/run wrappers, split-KV state, and cascade attention expose the metadata that a scheduler already has. The pieces compose instead of requiring one opaque end-to-end graph.
Customization without abandoning fast paths
JIT templates let an unusual attention variant stay close to the established wrapper contract. Mature backend implementations can handle common shapes while generated code covers new combinations.
Hardware breadth with caveats
The release spans several NVIDIA generations, but support is operator-specific. v0.6.18 adds Rubin paths and removes SM75 and single-request FA2 kernels from prebuilt JIT-cache wheels: those APIs may still require first-use compilation even with a cache package installed.[2] A project-level GPU support label doesn't guarantee that the chosen attention backend, dtype, or graph mode works on that GPU.
Weaknesses and failure modes
Metadata complexity moves upward
The engine must now maintain indptr, page indices, last-page lengths, workspaces, events, and plan validity. PagedAttention makes memory efficient, but correctness depends on allocator and kernel agreeing on the mapping.[8]
JIT is a release dependency
First-use compilation can create startup spikes or fail because a driver, CUDA toolkit, compiler, or architecture is missing. A warm cache can go stale after a version change. Precompiled wheels reduce risk but narrow supported combinations.
Kernel breadth raises test burden
Every backend, dtype, head grouping, page layout, causal mode, and architecture multiplies the test matrix. A green unit test on one GPU doesn't prove a mixed-fleet deployment.
GPU specificity limits portability
FlashInfer targets NVIDIA CUDA architectures rather than CPU, AMD, or Apple inference backends. Porting the API idea is possible, but the CUDA kernels, backend dispatch, and cache packages are hardware-specific.
A kernel library isn't a training stack
FlashInfer's main focus is inference kernels and serving operators.[3] The attention walkthrough here checks forward computation only. It doesn't establish an autograd contract, and FlashInfer doesn't replace a distributed training framework, optimizer, or checkpoint system. Verify any backward path separately before using an operator during training.
What the paper actually measured
The FlashInfer paper is an MLSys 2025 paper, posted as arXiv:2501.01005. The evaluation used FlashInfer v0.2, CUDA 12.4, and PyTorch 2.4.0 on A100 40GB SXM and H100 80GB SXM GPUs, with FP16 storage and compute.[5] The abstract's ranges are useful evidence that layout-aware kernels can matter, not universal service-level SLOs.
The end-to-end SGLang experiment used ShareGPT plus a synthetic Variable workload with sequence lengths uniformly distributed from 512 to 2,048 tokens. It measured time-to-first-token (TTFT) and ITL under online serving, adjusting request rate to keep P99 TTFT below 200 ms.[5] Those workload and admission details belong beside the percentage, because changing them changes the question the benchmark answers.
Read every number with its denominator:
| paper-era result | what it supports | what it doesn't support |
|---|---|---|
| 29% to 69% lower ITL vs compiler backends | SGLang v0.3.4 with FlashInfer vs its Triton v3.0 backend on Llama 3.1 8B/70B serving traces | same gain on another model, GPU, or scheduler |
| 28% to 30% lower long-context latency | Streaming-LLM with a fused rotary positional embedding (RoPE)-attention kernel vs unfused kernels on Vicuna-13B / MT-Bench | a fixed latency budget for all context lengths |
| About 14% to 17% lower ITL for parallel generation | MLC-Engine composable formats at parallel width on Llama 3.1 8B/70B ShareGPT | the same percentage increase in throughput or gain at another concurrency |
For a production decision, replay your trace with the same prompt length distribution, output length, batch policy, quantization, CUDA graph mode, and error budget. Compare p50 and p99 time-to-first-token, ITL, tokens per second, GPU memory, compile time, and correctness against a reference implementation. Freeze a warm steady-state window, change one variable, rerun the same trace, then verify output and tail metrics. That measure-change-verify loop makes a kernel comparison an experiment rather than a before-and-after anecdote.
Correctness is part of benchmark coverage
A fast path is useful only across shapes it computes correctly. Your harness should compare each selected backend with a trusted reference on the same ragged and paged inputs. Cover prefill, decode, and mixed batches; short histories, non-full last pages, shared prefixes, split and unsplit KV ranges; the MHA, GQA, and MLA head layouts your model uses; and every dtype, mask, device, and fallback path you intend to ship. Include metadata dtypes in that matrix: current FlashInfer docs require indptr, page-index, and tail-length arrays to use int32; an int64 index can fail before numeric comparison.[9] Compare outputs and attention-state statistics under an explicit tolerance, fail on NaN or Inf, and report unsupported shapes separately. One passing dense case is a smoke test, not coverage.
Diagnose the path before tuning the kernel
When a result regresses, first locate the time. Use Nsight Systems to inspect CPU planning, metadata copies, stream dependencies, kernel launch gaps, and overlap; use Nsight Compute to inspect the selected kernel's memory workload, arithmetic intensity, occupancy limits, and warp stalls.[13][14] Correlate that trace with the recorded backend, layout, split-KV choice, merge work, and p50/p99 TTFT or ITL. A memory-bound trace points first to page access, layout, or precision; a compute-bound trace points to tile or backend choice; a launch gap points to planning or scheduler work. High GPU utilization can still hide a bad tail, a synchronizing plan, or a silent fallback. Tune the boundary that the trace identifies, then rerun the same correctness matrix.
A small code-reading route
Use the local repository as a map rather than trying to read every kernel first:
- Start with
flashinfer/decode.pyand findBatchDecodeWithPagedKVCacheWrapper.planand.run. - Follow plan metadata into
csrc/batch_decode.cu, where offsets and optional split-KV buffers become kernel parameters. - Read
flashinfer/cascade.pyandinclude/flashinfer/attention/cascade.cuhto see how attention states compose, including the base-2 merge. - Read
flashinfer/page.pyandcsrc/page.cufor page append and slot mapping. - Compare a Python wrapper test with its reference-correctness test before reading a specialized backend.
The productive question at each layer is: what shape and lifetime does this function assume, and who owns the next buffer? That question finds more bugs than memorizing kernel names.
What an integration review must defend
Use the three-request batch to explain the boundary before proposing a faster kernel:
- Indexing: Reconstruct both pointer arrays, locate R2 token 5 at page 14 slot 1, and derive its four causal row lengths as 6, 7, 8, and 9.
- Numerics: Match split attention to direct softmax with base-2 LSE, disjoint key ranges, empty-state handling, and an explicit floating-point tolerance.
- Integration: Distinguish refreshing metadata, planning work, compiling a specialization, and capturing a graph. Record which changed before claiming a cache or graph is reusable.
- Measurement: Keep the v0.2 paper results separate from v0.6.18, and report a correctness matrix and serving trace before generalizing a speedup.
Follow-up questions
R2's page IDs stay [3, 14, 6], but its last-page length grows from 1 to 2. Can an unchanged page list justify replaying old length metadata?
Answer
No. The logical KV length grew from 9 to 10 even though the page list stayed fixed. Update the tail length and any length-dependent plan or mask through the supported wrapper path before running; a stable allocation isn't a stable attention problem.
A shared prefix is included in both partial states before merging. Why can the output stay finite yet still be wrong?
Answer
The merge adds normalization mass from both states, so shared keys are counted twice. In the equal-logit example, duplicating the first value changes its weight from 1/2 to 2/3. Partition logical key positions, even when several requests share physical pages.