音乐
暂未播放
vLLM 源码完全拆解(六):Attention Backend 如何统一 FlashAttention 与 FlashInfer
从公共元数据走进后端接口#
前五篇已经从 vLLM 的整体架构 出发,依次追踪了 V1 引擎的一次请求、Scheduler.schedule() 的预算决策、KVCacheManager 的物理 Block 生命周期 和 GPUModelRunner 的输入批处理。上一站停在 CommonAttentionMetadata:不同长度的请求已经被压成一条连续 token 流,query_start_loc 保存请求边界,seq_lens 保存完整上下文长度,block_table 和 slot_mapping 则把逻辑位置落到分页 KV Cache。
但这些字段还不是任何一个高性能 Kernel 的最终参数。FlashAttention 可以直接消费二维 block table、累计 query 长度和有效 KV 长度;FlashInfer 的原生分页接口则习惯使用 CSR 风格的 paged_kv_indptr、paged_kv_indices、paged_kv_last_page_len,还要在执行前完成一次 plan();Triton、ROCm、MLA、Mamba 等路径又有各自的约束。若 GPUModelRunner 直接认识每一种 Kernel,模型、缓存布局、硬件平台和编译模式的组合分支会迅速淹没主流程。
vLLM 因而在模型层与具体算子之间建立了 Attention Backend 抽象。它不是简单的“把函数名放进字典”,而是同时回答四个问题:当前层能选哪个实现、该实现要求什么 KV 布局、怎样把公共批次语义降成 Kernel 专用 metadata、模型 forward 最终调用哪个实现对象。
本文延续系列既有基线,主要阅读 2026 年 9 月 26 日访问的 vLLM main 分支提交 3b4566c5。vLLM 的后端注册表与选择规则更新很快,本文会区分稳定抽象与该提交中的具体实现,避免把今天的优先级误写成永远不变的规则。FlashAttention 最初由 Tri Dao 等人在 2022 年 NeurIPS 论文中提出;FlashInfer 由华盛顿大学等团队在 2023 年开始开源,它们在 vLLM 中扮演的是可替换执行后端,而不是调度器或 KV 分配器。
本篇只走到接口层和元数据层。PagedAttention Kernel 内部怎样安排线程、如何合并访存、怎样分块归约并保持 softmax 数值稳定,将在下一篇单独展开。
为什么注意力需要独立 Backend#
线性层、RMSNorm 等算子的输入形状通常比较规整;服务场景中的注意力却同时依赖模型结构、动态请求状态和物理缓存布局。对一次普通 decoder attention,可写成:
O=softmax(dQKT+M)VQ 是本轮新 token 的 query,K,V 包含此前缓存和本轮新增的 Key/Value,d 是 head dimension,M 编码因果、滑动窗口或其他可见性规则。这个公式没有说明 K,V 在显存中是否连续,也没有说明一个 batch 里每条请求的 query 和 context 各有多长,更没有说明 FP8 缓存如何反量化。服务框架必须把这些运行时事实一起交给 Kernel。
至少六个维度会改变可用实现:
| 维度 | 典型取值 | 对后端的影响 |
|---|---|---|
| 硬件 | NVIDIA Ampere/Hopper/Blackwell、AMD、Intel XPU、CPU | 指令集、TMA、Tensor Core、可用第三方库不同 |
| 数据类型 | FP16、BF16、FP8、NVFP4 KV | Kernel 是否支持、scale 位于 host 还是 device、缓存布局不同 |
| 模型几何 | head size、Q/KV 头数、MHA/GQA/MQA | tile 形状、XQA 可用性、页大小限制不同 |
| 注意力语义 | causal、non-causal、sliding window、sink、cross-attention | mask 与历史保留规则不同 |
| 服务特性 | prefix cache、DCP/PCP、KV Connector、投机解码 | metadata、通信和 query 长度语义不同 |
| 执行模式 | eager、piecewise CUDA Graph、full CUDA Graph | 地址稳定性、launch grid 与 host plan() 是否可捕获不同 |
因此“哪个 Kernel 更快”不是单一排名。某实现可能在 Hopper 的 BF16 decode 上占优,却不支持当前 head size;某实现支持 FP8 KV,却要求 page size 为 64;某实现能跑普通 causal attention,却无法表达多模态 PrefixLM 的双向图像区间。后端选择首先是可行性过滤,其次才是性能优先级。

图源:vLLM Triton Attention Backend Deep Dive Figure 3。横向是 query token,纵向是 query head,内层遍历分页 KV Cache。图中省略了因果 mask 和滑动窗口,但已经揭示接口必须提供的两类信息:query 的逻辑边界,以及 KV 页的物理索引。
独立 Backend 带来的并不是“任意后端都能无代价互换”。它提供的是一份明确契约:上游交付统一语义,下游声明能力和布局约束。若契约不满足,Selector 应在启动阶段拒绝配置,而不是运行到某个请求后才让 Kernel 读错内存。
一张源码地图:从模型层到 Kernel#
先把对象和时间线分开。Attention Backend 相关对象并不是在每个 token step 中全部重建,它们分布在模型加载、KV Cache 初始化和逐步执行三个阶段。
1模型构造阶段2 model definition 创建 Attention layer3 -> get_attn_backend(...)4 -> AttentionBackend class5 -> backend.get_impl_cls()6 -> 每层一个 AttentionImpl7 -> layer 注册进 static_forward_context8
9KV Cache 初始化阶段10 每层导出 KVCacheSpec11 -> Core 形成 KV cache groups12 -> Worker.initialize_attn_backend()13 -> 按 backend + spec + num_heads_q 建 AttentionGroup14 -> backend.get_builder_cls()15 -> 每个 group 创建 MetadataBuilder16 -> 选择共同 kernel block size 并绑定 KV tensor17
18每个引擎 step19 GPUModelRunner 构造 CommonAttentionMetadata20 -> 每个 AttentionGroup 的 builder.build()21 -> layer_name -> backend-specific metadata22 -> set_forward_context(...)23 -> 模型逐层 forward24 -> Attention.forward()25 -> AttentionImpl.forward()26 -> FlashAttention / FlashInfer / Triton 等 Kernel这条链上有四种关键身份:
AttentionBackend是类级能力描述与工厂,回答“支持什么、应创建什么”。它本身不保存某一批请求的状态。AttentionImpl是层级执行对象,保存 head 数、scale、sliding window、量化模式等层参数,并在每次 forward 时调用 Kernel。AttentionMetadataBuilder是 group 级适配器,持有 workspace 和可复用 buffer,把一批公共 metadata 编译为专用执行计划。AttentionMetadata是 step 级产物,描述这一次批次的 query、分页 KV、prefill/decode 路径和图捕获所需状态。
AttentionGroup 把后端、若干 layer name、一个 KVCacheSpec 和对应 builder 绑在一起。模型的层数可能是 32 或 80,但若它们共享后端、缓存规格和 Q 头数,一次 metadata build 的结果可以被多层复用。这样,按请求和页表进行的 Python/CPU 准备成本不会机械乘以层数。
代码入口可按下列顺序定位:
| 目标 | 固定提交中的文件 |
|---|---|
| 选择后端 | vllm/v1/attention/selector.py |
| 抽象接口和公共 metadata | vllm/v1/attention/backend.py |
| 建立 AttentionGroup | vllm/v1/worker/gpu_model_runner.py |
| group 与 block size 工具 | vllm/v1/worker/utils.py |
| 模型层与 Impl 调用 | vllm/model_executor/layers/attention/attention.py |
| FlashAttention 适配 | vllm/v1/attention/backends/flash_attn.py |
| FlashInfer 适配 | vllm/v1/attention/backends/flashinfer.py |
第一次读源码时,不应从两个两千行以上的具体后端开头顺序下读。先沿这张图建立对象生命周期,再分别进入 builder 和 impl,才能判断某段代码发生在初始化、step 前处理还是层内热路径。
Backend Selector:不是按名字做一次分支#
每个 Attention 层在构造时调用 get_attn_backend()。传入的不只是用户选择的名字,还包括该层与本次运行的能力向量:
1self.attn_backend = get_attn_backend(2 head_size=head_size,3 dtype=dtype,4 kv_cache_dtype=kv_cache_dtype,5 use_mla=False,6 has_sink=self.has_sink,7 use_mm_prefix=self.use_mm_prefix,8 use_per_head_quant_scales=use_per_head_quant_scales,9 attn_type=attn_type,10 has_sliding_window=sliding_window is not None,11)Selector 又从全局 VllmConfig 补入 block size、KV Connector、DCP、PCP、自适应投机验证、非因果模式和 batch invariance 等信息,打包成 AttentionSelectorConfig。随后才把用户显式指定的 backend 或自动模式交给当前 platform:
1Attention layer 的局部约束2 +3VllmConfig 的运行级约束4 ↓5AttentionSelectorConfig6 ↓7current_platform.get_attn_backend_cls(...)8 ↓9完全限定类名10 ↓11lazy import -> type[AttentionBackend]把平台判断放到 current_platform 而不是 Selector 主文件,原因是 CUDA、ROCm、XPU 与 CPU 的候选集和优先级根本不同。公共层只负责收集事实;平台层负责判断某个实现能否在当前设备上成立。
自动选择与手工指定语义不同#
自动模式会按平台注册的优先顺序检查候选后端,选取第一个通过配置验证的实现。手工指定 --attention-backend FLASH_ATTN 或 --attention-config.backend FLASH_ATTN 时,框架仍然会检查 dtype、head size、compute capability、block size 和特性组合;不兼容应直接报错,而不是静默换成另一个实现。官方的 Attention Backend Feature Support 页面由注册表和 validate_configuration() 检查自动生成,适合核对具体版本的支持矩阵。
这一区别对性能实验很重要。若用户以为自己固定了 FlashInfer,而框架悄悄退回 FlashAttention,实验结论会失去意义;反过来,自动模式若在每项新特性出现后都要求人工选后端,也无法成为可靠默认值。显式配置采用“验证失败即停止”,自动配置采用“过滤后选最优可用者”,两者分别服务可复现性和易用性。
为什么选择结果要缓存#
_cached_get_attn_backend() 使用函数缓存。同一组选择参数在数十层模型中反复出现时,无需重复探测平台和导入类。缓存键必须包含所有会改变结果的运行级因素;固定提交甚至显式把 _run_kv_cache_dtype 放入键中,因为某些平台选择器从当前配置读取它。若某个影响布局的参数没有进入键,不同层或 draft/target 模型就可能错误共享选择结果。
Backend 还会反过来约束 KV Cache#
选择并不是“缓存已经建好后再挑一个 Kernel”。AttentionBackend 暴露 get_supported_kernel_block_sizes()、supported_kv_cache_layouts() 和 customize_spec()。例如某些 FlashAttention 路径要求页大小是 16 的倍数,某些特化 Kernel 固定要求 64 或 128 token 页;FlashInfer 不同硬件路径支持的 page size 集合也不同。模型层先根据后端定制 KVCacheSpec,Worker 再在同一 KV cache group 的所有后端之间求共同可用的 kernel block size。
因此选择错误不只是慢一点。最严重的后果是生产者按一种布局写 KV,消费者按另一种布局解释相同字节。vLLM 试图在启动阶段通过能力检查、共同 block size 和全模型布局协商消除这种歧义。
AttentionGroup:层、后端与 KV Cache Group 怎样绑定#
上一篇已经出现 kv_cache_group,本篇又出现 AttentionGroup。两者不是同一个概念。
KV cache group 解决存储与生命周期问题:哪些层的 cache spec 可以由同一组 block table、相同页几何和同一种回收语义管理。Attention group 解决执行适配问题:在一个 KV cache group 内,哪些层可以共享同一种 backend metadata。
固定提交中的分组键可以概括为:
Gattn=(Cbackend, Skv, Hqrank)Cbackend 是后端类的完整类名,Skv 是该层的 KVCacheSpec,Hqrank 是当前并行 rank 持有的 query head 数。三个量全部相同的层才进入同一个 AttentionGroup。
为什么还要把 Hqrank 放进键?Builder 可能按 Q 头数预分配 scratch 或 workspace。投机解码 draft head 与 target model 即使使用同一后端和缓存规格,也可能拥有不同 Q 头数;强行共享 builder 会让内部 buffer 尺寸和 Kernel 参数错误。这个字段看似重复,实际是在防止形状合法但语义错误的内存访问。
两层索引结构#
Runner 保存的结构近似:
1attn_groups[kv_cache_group_id][attention_group_id]外层选择哪套 block table 和 slot mapping,内层选择哪种 backend、哪些 layer name 共享 builder。一个普通 Llama 模型可能只有一个 KV cache group,内部也只有一个 AttentionGroup;混合模型则可能出现:
1KV group 0: full attention2 AttentionGroup 0: FlashAttention, layers [0, 2, 4, ...]3
4KV group 1: sliding-window attention5 AttentionGroup 0: FlashAttention, layers [1, 3, 5, ...]6
7KV group 2: recurrent state8 AttentionGroup 0: Mamba backend, layers [...]若同一存储组中部分层因特性差异选到不同 backend,还会继续拆成多个 AttentionGroup。分组不是为了给文章画出漂亮层次,而是为了精确确定“一份 metadata 可以安全给谁用”。
Builder 为什么属于 Group,不属于 Layer#
假设 32 层 decoder 的每层都使用相同 FlashAttention 配置。query_start_loc、seq_lens、block table 和 batch 的 prefill/decode 划分对所有层相同;差别只在每层自己的 Q/K/V、KV cache tensor 和 scale。若每层各自把同一批次 metadata 构造 32 次,CPU 热路径会重复完成相同工作。
因此 AttentionGroup.create_metadata_builders() 只创建 group 级 builder,构建完成后把同一个 metadata 对象登记给组内所有 layer_name:
1for layer_name in attn_group.layer_names:2 attn_metadata_dict[layer_name] = attn_metadata_i当混合 KV group 只有 block table 与 slot mapping 不同时,Runner 还会按 (KVCacheSpec, builder type) 缓存第一次构造结果。若 builder 声明 supports_update_block_table=True,后续 group 可以复用控制计划,只替换页表与逐 token 写入地址。这个优化把“批次结构相同”和“物理 KV 页面不同”拆开,减少重复 CPU 工作。
KV tensor 怎样在模型层出现#
Engine Core 只管理 block ID,不持有真正 K/V。Worker 分配 KV tensor 后,bind_kv_cache() 按 layer name 把对应 tensor view 绑定到 Attention 层。模型 forward 到某层时,get_attention_context(layer_name) 从 forward context 取出三样东西:该层的 backend metadata、绑定的 KV tensor、对应 slot mapping。层内实现因而不需要向 Scheduler 查询任何状态,也不需要知道其他层属于哪个 group。
三层接口:Backend、Impl 与 MetadataBuilder#
三层接口最容易被误读成同一对象的不同名字。实际上它们分别处于静态能力、层内执行和批次编译三个时间尺度。
AttentionBackend:能力声明与类工厂#
抽象类的最小核心近似为:
1class AttentionBackend(ABC):2 @staticmethod3 def get_name() -> str: ...4
5 @staticmethod6 def get_impl_cls() -> type[AttentionImplBase]: ...7
8 @staticmethod9 def get_builder_cls() -> type[AttentionMetadataBuilder]: ...除此之外,它还声明支持的 dtype、KV dtype、head size、block size、attention type、sliding window、sink、多模态 prefix、context parallel 和 KV cache layout。Selector 使用这些声明判定可行性,KV Cache 初始化使用布局与 block size 声明确定物理存储,Runner 使用两个工厂方法创建 Impl 与 Builder。
Backend 通常是类而不是实例,因为这些属性不属于某一层或某一批请求。FlashAttentionBackend.get_name() 返回 FLASH_ATTN,get_impl_cls() 返回 FlashAttentionImpl,get_builder_cls() 返回 FlashAttentionMetadataBuilder。这种关系使注册表只需保存一个后端类路径,就能把整套实现接进框架。
AttentionImpl:每层持有的执行对象#
Attention 层拿到 backend 后创建 Impl,并把本层的 num_heads、num_kv_heads、head size、softmax scale、sliding window、KV dtype、ALiBi 或 sink 等参数传入。它的核心签名是:
1def forward(2 layer,3 query,4 key,5 value,6 kv_cache,7 attn_metadata,8 output,9 ...,10): ...其中 Q/K/V 和输出随层变化,kv_cache 是该层绑定的物理缓存视图,attn_metadata 则可能由同组多层共享。Impl 把通用层参数和当前批次 metadata 拼成真正的 Kernel 调用。
FlashAttention 与 FlashInfer 在固定提交中都把 forward_includes_kv_cache_update 设为 False。这意味着层 forward 先通过统一的 KV update 自定义算子,把本轮 K/V 按 slot_mapping 写入缓存,再调用 attention 实现读取完整缓存。分开写入和读取可以显式建立编译器可见的数据依赖,也允许不同 attention Kernel 共享缓存写入路径。若某个 backend 把写入融合进 attention,它可以把该标志设为 True,由实现自行维护顺序。
AttentionMetadataBuilder:把批次语义编译成执行计划#
Builder 的公共入口是:
1def build(2 common_prefix_len,3 common_attn_metadata,4 fast_build=False,5) -> BackendSpecificMetadata:6 ...它不是纯函数。Builder 可以持有长期 workspace、pinned CPU buffer、CUDA Graph 稳定地址、不同 batch size 的 wrapper,以及已探测的 Kernel 能力。逐 step 的 build() 只改写这些预分配容器的有效区域,并产出轻量 metadata 引用。
抽象类还公开几项影响 Runner 行为的能力:
reorder_batch_threshold:是否要把短 query 的 decode 请求移动到 batch 前部;supports_update_block_table:相同结构的其他 cache group 是否只需替换页表;get_cudagraph_support():支持任意混合 batch、仅统一 query 长度、仅单 token decode,还是完全不支持;build_for_cudagraph_capture():图捕获时是否需要专门构造稳定 metadata;build_for_drafting():投机 draft 的短生命周期计划能否走更快构造路径。
这解释了为何 metadata 构造不是若干 tensor 字段的机械复制。Builder 实际在做一次小型编译:识别批次形态、选择执行分支、准备索引和 workspace,并保证这些决定与 CUDA Graph 及具体 Kernel ABI 一致。
公共元数据怎样变成后端专用计划#
CommonAttentionMetadata 保存的是数学与地址意义上的公共事实。固定提交中的主干字段可以分成四组:
| 组别 | 字段 | 含义 |
|---|---|---|
| query 布局 | query_start_loc、CPU 副本、num_actual_tokens、max_query_len | 扁平 query 中每条请求的边界 |
| context 布局 | seq_lens、max_seq_len | 本轮完成后每条请求可见的完整序列长度 |
| KV 地址 | block_table_tensor、slot_mapping | 历史 KV 页和本轮 KV 写入槽位 |
| 语义与扩展 | causal、positions、is_prefilling、encoder/DCP/MM prefix 字段 | mask、阶段与高级功能 |
对请求 i,query 长度和本轮开始前的 context 长度分别为:
qi=si+1−sici=li−qisi 来自 query_start_loc,li 来自 seq_lens。Backend 可以由同一组公共字段恢复“本轮新算多少”和“此前已有多少”,无需上游硬编码 PREFILL、DECODE 两套对象。

图源:FlashInfer KV-Layout 教程。不同请求的 Q、K、V 按有效 token 连续存放,indptr 的相邻差值恢复各请求长度。vLLM 的 query_start_loc 正是同类前缀和表示,不需要把每条序列 padding 到 batch 中的最长长度。
Runner 怎样为多个 group 构造 metadata#
GPUModelRunner._build_attention_metadata() 先以第一个 cache group 的 block table 和 slot mapping 构造一份公共模板。随后遍历 KV cache group:
- 浅拷贝公共模板;
- 替换本 group 的 encoder length、block table 和 slot mapping;
- 遍历该 group 内的 AttentionGroup;
- 取出对应 builder,调用
build(); - 把返回对象登记到组内每个 layer name。
简化伪代码如下:
1for kv_gid, kv_group in enumerate(kv_cache_groups):2 common = copy(common_base)3 common.block_table_tensor = block_tables[kv_gid]4 common.slot_mapping = slot_mappings[kv_gid]5
6 for attn_group in attn_groups[kv_gid]:7 builder = attn_group.get_metadata_builder()8 metadata = builder.build(common_prefix_len, common)9 for layer_name in attn_group.layer_names:10 per_layer_metadata[layer_name] = metadata模型 forward 期间,set_forward_context() 把这个 layer_name -> metadata 映射挂到线程或编译上下文。Attention.forward() 的 Python 签名仍然只有 Q/K/V;进入统一 attention 自定义算子后,get_attention_context(layer_name) 才取回 metadata 和 KV tensor。这样,模型定义不必把十几个服务字段沿每层函数签名传递,torch.compile 也可以把注意力视为一个具有明确副作用的边界。
为什么保留 CPU 和 GPU 两份长度#
某些 builder 需要在 CPU 上决定分流、调用第三方 plan() 或计算最大长度,Kernel 又需要设备 tensor。每轮把 GPU seq_lens 同步回 CPU 会在热路径上形成硬同步,因此 Runner 尽量同时维护 CPU 上界和 GPU 真值。
异步投机验证会让 CPU 侧长度暂时是“草稿全部接受”的乐观上界,而设备端 query 边界才是实际接受结果。Backend 必须声明自己是否支持这种 device/CPU query length 不一致;不能把上界当精确长度交给需要严格边界的 Kernel。公共 metadata 同时保留两份视图,正是为了让 Builder 明确选择,而不是在内部偷偷触发 D2H 同步。
公共层为何不生成巨大 mask#
若总 query 数为 Q、最长 context 为 L,显式布尔 mask 的规模接近 Q×L。分页长上下文下,这既浪费显存,也会额外消耗写入和读取带宽。公共 metadata 只保存 O(R) 的请求边界、O(Q) 的逐 token 地址和 O(P) 的页表,其中 R 是请求数、P 是实际页表项数。具体 Kernel 根据因果标志、窗口和边界在片上生成访问范围,把结构信息保留到最接近计算的位置。
FlashAttention 路径:变长序列与分页 KV#
FlashAttention Builder 的特点是:vLLM 的公共表示与底层接口已经比较接近。FlashAttentionMetadata 直接保留 query_start_loc、seq_lens、二维 block_table、slot_mapping、最大 query/context 长度和实际 token 数。对普通非级联路径,Impl 取出:
1cu_seqlens_q = attn_metadata.query_start_loc2seqused_k = attn_metadata.seq_lens3max_seqlen_q = attn_metadata.max_query_len4max_seqlen_k = attn_metadata.max_seq_len5block_table = attn_metadata.block_tablecu_seqlens_q 告诉变长 attention 每条 query 在连续 Q tensor 中的边界;seqused_k 给出每条请求实际使用的 KV 长度;block_table 则让 Kernel 按逻辑页读取不连续物理缓存。与上一节公式对应,Kernel 可以为请求 i 处理 qi 个 query,并限制其 Key 范围到 li。
Builder 主要在决定什么#
普通字段复制只是开始。Builder 还会处理:
- 是否启用 cascade attention,把公共前缀与请求私有后缀分开计算;
- DCP 下各 rank 的本地 context 长度,以及 prefill/decode 分段;
- FlashAttention 版本与 ahead-of-time scheduler metadata;
- CUDA Graph 捕获时固定的 split 上限和持久 buffer;
- 多模态 PrefixLM 的双向区间与 R-SWA prefix 长度;
- 动态 causal、sliding window、sink 和量化 KV 所需的附加字段。
因此 FlashAttention metadata 看起来接近公共结构,不代表 Builder 可被删除。公共结构描述“批次是什么”,Builder 还要决定“这一版 FlashAttention 用什么控制路径执行”。
KV Cache 视图必须匹配 Kernel ABI#
固定提交中 FlashAttention 的 KV cache 注释形状为:
1[num_blocks, num_kv_heads, block_size, 2 * head_size]Impl 在 forward 中通过 transpose 和 split 得到 Key/Value view,并修正 size-1 维度的退化 stride。这个细节对使用 TMA 的 FA3/FA4 很关键:张量元素完全相同,不代表 stride 一定满足至少 16 字节的对齐要求。Backend 适配层必须把框架分配的通用 buffer 解释成 Kernel 接受的视图,必要时只改 metadata stride,避免真正复制整块 KV。
若 KV 使用 FP8,缓存底层可能以字节容器保存,Impl 再 view 成平台 FP8 dtype,并把 Q/K/V scale 按请求与 KV head 展开。量化支持因设备和 FlashAttention 版本而异,所以它既出现在 Selector 的能力检查里,也出现在 Impl 的运行时准备里:前者防止不可能的组合启动,后者提供真实 Kernel 参数。
为什么 KV 写入在 Attention 之前#
FlashAttention Backend 声明 forward 不包含 KV update。模型层先执行:
1unified_kv_cache_update(K, V, slot_mapping)2 ↓ 数据依赖3unified_attention_with_output(Q, kv_cache, metadata)本轮 query 在因果 attention 中需要看到本轮已经产生的 K/V,例如 prefill chunk 内第 j 个 query 要看到同一 chunk 中不晚于自己的位置。先按 slot_mapping 把整个 chunk 写入分页 cache,再由 Kernel 用 causal 边界限制可见范围,可以统一 prefill、extend 与 decode。自定义算子返回的 dummy dependency 并不承载数据,它用于阻止编译器错误地把缓存读取重排到写入之前。
版本不是 Backend 名称的一部分#
用户通常选择 FLASH_ATTN,Impl 再根据 compute capability、head size、KV dtype、sink、soft cap 等条件解析 FA2、FA3 或 FA4。这样,上层 Backend 名称保持稳定,版本差异留在实现内部;代价是性能复现实验必须记录实际日志中的 FlashAttention 版本,不能只写“使用 FLASH_ATTN”。
不同版本对 CUDA Graph 的支持也不同。固定提交把 FA3 Builder 视为可支持更一般图模式,而 FA2 在混合 prefill/decode 和特殊 GQA 路径上限制更多。Runner 会综合所有 AttentionGroup 的 AttentionCGSupport,若任一后端不能支持完整图,就降到 piecewise 或 eager,而不是让某一层在重放时使用错误形状。
FlashInfer 路径:Plan/Run、Workspace 与分页布局#
FlashInfer 对分页 KV 的接口更像稀疏矩阵库:页表被压成 CSR 风格的三个数组,prefill 和 decode 由不同 wrapper 执行,wrapper 在 run() 前先调用 plan() 准备调度。vLLM 的 Builder 因而承担更多结构转换。

图源:FlashInfer KV-Layout 教程。每个请求拥有一串物理页号,所有请求的页号拼成 kv_page_indices;kv_page_indptr 给出每条请求的页号区间;kv_last_page_len 记录最后一页的有效 token 数。FlashInfer 只负责按照这些索引计算 attention,不负责分配或回收页面。
从二维 Block Table 到 CSR 页表#
设请求 i 的有效序列长度为 li,page size 为 B,其有效页数为:
ni=⌈Bli⌉所有请求页数的前缀和构成:
p0=0,pi+1=pi+ni这就是 paged_kv_indptr。二维 vLLM block table 中每行前 ni 个有效物理 ID 被拼入一维 paged_kv_indices。最后一页有效长度为:
对应 paged_kv_last_page_len。不能把余数为 0 直接写成 0,因为这表示最后一页是满页,而不是没有有效 token。
固定提交用 NumPy 在 CPU 侧计算页数和 indptr,再用 Triton Kernel 从 GPU 二维 block table 抽取有效页号。原生 FlashInfer wrapper 的 plan() 还会消费 CPU 侧 query/page 指针和长度,因此 Builder 使用预分配 buffer,并在必要时将短小控制数组放到 pinned memory,保持 non-blocking copy 的可能性。
为什么先把 Decode 移到前面#
FlashInferMetadataBuilder 的 reorder_batch_threshold 默认为 1。Runner 在构造输入前可把 query 较短的请求移到 batch 前部;Builder 再将批次拆成:
1[decode requests / tokens][prefill or extend requests / tokens]普通 decode 的 query 长度为 1;某些支持 spec-as-decode 的路径会把阈值提高到“1 + 最大 draft token 数”,让短投机验证仍走 decode Kernel。不能简单用请求状态标签分流,因为 chunked prefill、短 extend 和投机验证可能拥有相近 query 长度,但对 mask 与 Kernel 能力的要求不同。Builder 综合 query length、causal 模式和后端能力进行划分。
FlashInferMetadata 因而不只是一张页表,它显式记录 num_decodes、num_decode_tokens、num_prefills、num_prefill_tokens,并分别持有 prefill 与 decode 对象。模型层拿到的 Q tensor仍是一条连续流,Impl 只需按计数切出前后两个 view。
plan() 与 run() 分别做什么#
以原生分页 prefill wrapper 为例,Builder 先执行近似调用:
1prefill_wrapper.plan(2 qo_indptr=query_offsets,3 paged_kv_indptr=page_offsets,4 paged_kv_indices=page_ids,5 paged_kv_last_page_len=last_page_len,6 num_qo_heads=num_q_heads,7 num_kv_heads=num_kv_heads,8 head_dim_qk=head_dim,9 page_size=page_size,10 causal=causal,11 ...,12)模型执行到每一层时,Impl 再调用:
1prefill_wrapper.run(query, kv_cache_tuple, out=output)plan() 根据当前 batch 的变长结构选择调度并准备索引或 workspace;run() 只接收本层 Q 和 KV cache view,复用该计划。若同一个 AttentionGroup 覆盖 32 层,plan 成本只支付一次,run 执行 32 次。这正是 Builder 与 Impl 分开的直接收益。
decode 原生路径同样有 BatchDecodeWithPagedKVCacheWrapper。为支持 full CUDA Graph,Builder 可以按捕获 batch size 保存不同 wrapper,使 workspace 地址和内部 buffer 在重放间稳定。固定提交还可能在 Hopper/Blackwell 上把 decode 分发到 FlashInfer 暴露的 XQA 或 TensorRT-LLM generation API;这些路径直接使用 GPU block table 和 seq_lens,不一定需要原生 CSR 页号。也就是说,FLASHINFER 本身还是一个二级调度器,内部会根据硬件、页大小、GQA 结构、dtype 和 batch 阶段选择 native、XQA 或 TRT-LLM 路径。
FlashAttention 与 FlashInfer 的本质差异#
| 方面 | FlashAttention Backend | FlashInfer Backend |
|---|---|---|
| 公共页表降级 | 多数路径保留二维 block table | 原生路径转换成 CSR 页表三元组 |
| 混合 batch | 单套 varlen metadata 可覆盖多种请求 | 通常先重排,再拆 prefill/decode wrapper |
| 预执行准备 | Builder 准备 scheduler/cascade 等 metadata | Builder 显式 plan() 并管理 workspace |
| 层内调用 | 直接调用对应 FA varlen/paged 路径 | 对前后两段分别 run(),或调用 XQA/TRT-LLM API |
| 图捕获难点 | 版本、split、动态网格与 metadata 稳定性 | wrapper/plan buffer、batch size 和地址稳定性 |
“统一 Backend 接口”并没有把两者强行做成相同内部结构。它只统一上游交付和下游调用边界,允许每个实现保留最适合自身 Kernel 的数据表示。
同一批混合 Prefill 与 Decode 的完整推演#
用一个小批次把两条后端路径放在同一坐标系中。设 page size 为 16,本轮有三个请求,Runner 已把普通 decode 放到最前:
| 请求 | 本轮含义 | 已计算 token | 本轮 query | 完成后 seq_len | 物理 block IDs |
|---|---|---|---|---|---|
| A | 普通 decode | 127 | 1 | 128 | [7,2,9,4,11,6,3,10] |
| B | 首段 prefill | 0 | 4 | 4 | [5] |
| C | 后续 extend | 32 | 3 | 35 | [1,8,12] |
Runner 先构造公共事实#
本轮扁平 query 长度为 1+4+3=8:
1query_start_loc = [0, 1, 5, 8]2seq_lens = [128, 4, 35]3max_query_len = 44max_seq_len = 1285num_actual_tokens = 8block_table_tensor 是三行二维表,不足最大宽度的尾部使用无效或 null 值填充。slot_mapping 则已经逐 token 算好写入地址。A 的新位置是 127,逻辑页号为 7、页内偏移为 15,对应物理 block 10,因此:
B 的位置 0–3 写到物理 block 5 的槽位 80–83;C 的位置 32–34 位于逻辑页 2,写到物理 block 12 的槽位 192–194。最终:
1slot_mapping = [175, 80, 81, 82, 83, 192, 193, 194]这份公共 metadata 不包含“必须调用 FlashAttention 还是 FlashInfer”的答案。它只承诺请求边界、有效长度和地址正确。
FlashAttention Builder 的结果#
在普通非级联路径中,Builder 基本保留:
1cu_seqlens_q = [0, 1, 5, 8]2seqused_k = [128, 4, 35]3block_table = 三行二维页表4max_q = 4, max_k = 128Impl 可以让一个 varlen/paged 调用处理全部八个 query。Kernel 通过相邻 cu_seqlens_q 的差知道 A、B、C 分别有 1、4、3 个 query,通过 seqused_k 知道其 Key 长度为 128、4、35,并沿各自行的物理 block ID 取历史 K/V。B 和 C 的多 token query 在各自区间内应用因果 mask,不会跨请求互相注意。
FlashInfer Builder 的结果#
Builder 识别 A 为 decode,B、C 为 prefill/extend:
1num_decodes = 12num_decode_tokens = 13num_prefills = 24num_prefill_tokens = 7三条请求的有效页数为 [8,1,3],于是完整 CSR 页表为:
1paged_kv_indptr = [0, 8, 9, 12]2paged_kv_indices = [7,2,9,4,11,6,3,10, 5, 1,8,12]3paged_kv_last_page_len = [16, 4, 3]decode wrapper 只消费 A 对应的前缀切片;prefill wrapper 的 qo_indptr 从原数组切出并减去起点,变成 [0,4,7],对应 B 与 C 的七个 query。Builder 分别 plan 两条路径,Impl forward 再把 Q 和 output 按 token 数切开:
1Q[:1] -> decode run -> output[:1]2Q[1:8] -> prefill run -> output[1:8]两条 run 写回同一个预分配输出 tensor,所以模型后续层仍看到与扁平输入完全相同的 token 顺序。后端内部拆成两个 Kernel 并不改变上游 batch 语义。
这个例子说明了什么#
同一份公共批次在 FlashAttention 中更接近“一次变长调用”,在 FlashInfer 中更接近“一个 decode 计划加一个 prefill 计划”。如果把 FlashInfer 的 CSR 转换塞进 GPUModelRunner,Runner 就必须认识它的 wrapper 生命周期;如果让 FlashAttention 也伪装成相同 CSR/plan 接口,又会增加无意义转换。Backend 层让共同事实保持共同、专用计划保持专用。
CUDA Graph、级联注意力与高级路径#
Backend 抽象的真正压力不在普通单卡 causal attention,而在动态批次与静态执行图、共享前缀和并行分片同时出现时。这里选三个最能暴露接口价值的分支。
CUDA Graph 约束的是控制路径,不只是 Tensor 形状#
CUDA Graph 重放要求设备地址和被捕获的操作图稳定。Runner 可以把真实 token 数 Q padding 到捕获形状 Q′,并复用预分配 input/output buffer,但这还不够:attention backend 内部若根据当前长度创建新 tensor、改变 launch 数量或重新分配 workspace,图仍无法安全重放。
因此 Builder 用 AttentionCGSupport 声明四级能力:
| 级别 | 可安全捕获的批次 |
|---|---|
ALWAYS | 包括混合 prefill/decode 在内的一般批次 |
UNIFORM_BATCH | 所有请求 query 长度一致,例如固定宽度投机验证 |
UNIFORM_SINGLE_TOKEN_DECODE | 仅每请求一个 token 的统一 decode |
NEVER | 不支持该图模式 |
Runner 必须取所有活跃 AttentionGroup 的共同下界。一个模型有 30 层支持 full graph、2 层只支持单 token decode,整次模型执行就不能把混合 prefill/decode 当作 full graph 捕获。图模式是全执行链契约,不能由“多数层”投票。
FlashAttention Builder 会为图捕获预分配 scheduler metadata,并固定允许的 split 上限;FlashInfer Builder 会保留 workspace,并可能按捕获 batch size 创建 decode wrapper。两者都把动态内容写入稳定 buffer,而不是每轮替换对象地址。对这套机制的 CUDA 原理,可参考 CUDA Graph 完全拆解。
Cascade Attention 把共享前缀只算一次#
若一个 batch 的请求共享长度为 Lp 的前缀,可以把每个 query 对公共前缀和私有后缀的 attention 分开:
Op,LSEp=Attn(Q,Kp,Vp)Os,LSEs=Attn(Q,Ks,Vs)再用两部分的 log-sum-exp 权重合并输出。这里 LSE 保存 softmax 分母的对数统计,不能简单把 Op 与 Os 相加。若两段的最大值和归一化因子不同,直接相加会得到错误概率。
Builder 的 use_cascade_attention() 决定当前批次是否值得这样做,common_prefix_len 再进入专用 metadata。收益取决于共享前缀长度、batch size 和额外合并成本;前缀很短时,拆成两次 attention 反而增加 launch 和归约开销。
在本文固定提交里,FlashAttention 路径保留了实际 cascade metadata 与执行分支;FlashInfer 虽包含 MultiLevelCascadeAttentionWrapper 相关代码,但其 use_cascade_attention() 明确返回 False 并标注暂时禁用。读源码时应以最终选择函数为准,不能因为文件里出现 wrapper 就断言功能已经在生产路径启用。
混合 KV 与虚拟 Kernel Block#
KV Cache Manager 的 block size 和 Kernel page size不必完全相同。假设 Manager 为统一多个 cache group 选择 64-token 物理管理块,而某后端 Kernel 只接受 16-token 页;Worker 可以把一个管理块映射成四个虚拟 Kernel block。select_common_block_size() 要求选出的 Kernel size 能被 manager block size整除,并同时满足该 group 内所有 backend。
这层转换不能放到 Engine Core:Core 关心分配和引用计数,不应知道 FlashAttention 或 FlashInfer 的页 ABI。也不能交给每层临时猜测:同一物理 page 的子块展开必须在 block table 和 KV tensor view 中一致。AttentionGroup 初始化阶段先协商共同 Kernel block size,Builder 和缓存 view 随后都使用这一结果。
Context Parallel 与投机解码改变的是 metadata 真值#
DCP 将一条长上下文的 KV 分给多个 rank。每个 rank 的本地 Key 长度可能不同,最后还要结合各 rank 的 attention 输出与 LSE;Backend 必须声明是否能返回正确 LSE、是否支持变长投机 query,以及怎样建立本地长度。PCP 又会把 query/prefill chunk 分片,进一步改变请求行与本地 token 的关系。
投机解码的 draft 数可能在设备上才得到最终接受长度。此时 CPU 的 seq_lens 只是上界,设备 query_start_loc 才是真值。某些 Kernel 能直接读取设备边界,某些 plan() 路径必须依赖 CPU 精确长度。Selector 和 Builder 需要共同阻止不支持的组合,否则错误不会表现为简单 shape mismatch,而会表现为某些草稿 token 读到越界或未来 KV。
如何选择后端,以及怎样定位不兼容与性能问题#
大多数部署应先使用自动选择。只有在做可复现实验、绕开已知 bug、验证特定 dtype/layout,或比较 Kernel 时才固定 backend。当前官方接口支持:
1vllm serve MODEL_NAME --attention-backend FLASH_ATTN也可以使用结构化配置:
1vllm serve MODEL_NAME \2 --attention-config.backend FLASHINFER不同 vLLM 版本的参数和后端枚举会变化,运行前应以对应版本的 --help 与官方支持矩阵为准。不要把其他版本文档中的 backend 名称直接套到固定提交。
不要只问“FlashAttention 还是 FlashInfer 更快”#
可靠比较至少要固定:
- GPU 型号与 compute capability;
- vLLM、FlashAttention、FlashInfer 和 CUDA 版本;
- 模型的 Q/KV 头数、head size 与 sliding-window 结构;
- 权重 dtype、激活 dtype 与 KV cache dtype;
- block/page size 与 KV cache layout;
- 输入长度、输出长度、并发数和 prefill/decode 比例;
- eager、piecewise graph 或 full graph;
- prefix cache、投机解码、DCP/PCP 等开关。
后端性能依赖 workload。长 prefill 与大 decode batch 的算术强度、并行度和页遍历方式不同;某个端到端吞吐数字还混合了调度、H2D、LM head 和采样成本。正确方法是先用端到端指标确认业务目标,再用 profiler 分开 metadata build、KV update、prefill attention 和 decode attention。分析工具链可参考 GPU 推理性能分析完全指南。
启动时报“不支持”时怎样定位#
按下面顺序核对,通常比立即换后端更快找到根因:
- 记录 Selector 收到的
AttentionSelectorConfig,确认 head size、dtype、KV dtype 和显式 block size; - 查看候选 backend 给出的 invalid reasons,区分硬件、单项能力与组合能力失败;
- 检查模型是否混有 full/sliding/MLA/Mamba 等不同 spec;
- 检查 KV cache layout 与所有后端支持布局的交集;
- 检查共同 Kernel block size 是否存在;
- 最后再判断是取消某项高级特性,还是换 backend。
例如“FlashInfer 支持 FP8 KV”和“FlashInfer 支持当前 FP8 KV 配置”不是同一句话。后者还取决于 head size、设备代际、page size、是否使用 sink、DCP 和具体内部 decode 路径。只查 dtype 支持表无法证明整个组合可行。
运行正确但性能差时怎样分层#
| 现象 | 先检查 | 可能原因 |
|---|---|---|
| forward 前有明显 CPU 空洞 | Builder build/plan、H2D、小 tensor 分配 | 每轮同步取长度、workspace 重建、pinned copy 未重叠 |
| prefill 快、decode 慢 | decode Kernel、页表遍历、batch size | 路径未进入 XQA/TRT-LLM、并行度不足、页布局不合适 |
| eager 快、full graph 反而慢 | 捕获形状、padding、固定 launch grid | 捕获 batch 过大、有效工作少、重放了过多空工作 |
| 混合 batch 比分开运行差 | 重排、两条 Kernel launch、资源互补是否成立 | prefill/decode 分流成本超过混批收益 |
| metadata 时间随层数增长 | AttentionGroup 分组与共享情况 | spec 或 Q 头数差异导致无法共享,或错误地逐层 build |
| 只在长上下文抖动 | split 数、workspace、page 数与 DCP | 动态调度变化、额外归约或 CPU plan 成本 |
Profiler 中看到 plan() 很长,不等于 FlashInfer Kernel 本身很慢;看到 attention Kernel 很短,也不等于后端端到端更好。Backend 的开销账本至少包括一次 step 的 Builder 成本和每层的 Impl 成本:
Lg 是该 AttentionGroup 中实际执行的层数,Tbuild 是共享元数据构造,Trun 是每层 attention,Tupdate 是独立 KV 写入等公共操作。Builder 优化主要降低第一项,Kernel 优化主要降低第二项;模型层数越多,第二项占比通常越高,但小 batch decode 中第一项仍可能成为可见的 CPU 间隙。
正确性不变量与源码阅读顺序#
Attention Backend 的错误常常不会立刻抛出异常。错误页号、错误 request row 或错误有效长度都可能保持 tensor 形状合法,却让模型静默读到另一个请求的 KV。沿不变量检查比从某个具体 Kernel 逆向猜测更可靠。
九条必须成立的不变量#
- 每个 layer name 必须映射到与该层 backend 和
KVCacheSpec一致的 metadata。 - 同一 AttentionGroup 内共享 metadata 的层必须拥有相同后端、cache spec 和每 rank 的 Q 头数。
query_start_loc单调不减,首项为 0,最后一个有效值等于真实 query token 数。- 对每个请求,
seq_len - query_len必须等于本轮开始前可读取的 context 长度。 block_table中所有有效物理 ID 必须仍归该请求所有,且与当前 KV tensor layout 使用同一 page 几何。- 每个非 padding
slot_mapping必须落在该请求拥有的可写 page 中。 - 后端重排 batch 时,Q/K/V、output、请求行、采样状态和页表必须使用同一个置换。
- CUDA Graph 重放引用的 workspace、metadata tensor 和 wrapper 内部 buffer 地址必须保持稳定。
- 若 CPU 长度只是乐观上界,依赖精确长度的后端必须使用设备真值或拒绝该配置。
一个最小调试批次#
先关闭投机解码、多模态、KV Connector、DCP/PCP 和量化,只保留一个单 token decode 与一个多 token prefill。每轮记录:
1backend=FLASHINFER2req_ids=[A,B]3query_start=[0,1,5]4seq_lens=[128,4]5block_rows={A:[...], B:[...]}6slot_mapping=[...]7split={decode_reqs:1, prefill_reqs:1}8page_indptr=[0,...]9last_page_len=[...]手算一个跨 page 边界的位置,确认 block table 到 slot 的翻译;再检查 Builder 输出的 decode/prefill 切片是否覆盖全部 token 且不重叠。基础路径正确后,每次只打开一项高级能力。若一次同时启用 FP8 KV、prefix cache、spec decode 和 full graph,布局、长度和图重放错误会呈现非常相似的随机输出异常。
推荐源码阅读顺序#
Attention.__init__():看局部模型参数怎样进入 Selector,Impl 怎样创建;AttentionBackend与AttentionMetadataBuilder:只读抽象字段和能力声明;GPUModelRunner.initialize_attn_backend():看 KV group 内怎样按三元键拆 AttentionGroup;_build_attention_metadata():看公共模板怎样替换各 group 的页表并映射到 layer name;FlashAttentionMetadataBuilder.build():先追普通 non-cascade、non-DCP 路径;FlashAttentionImpl.forward():看 metadata 怎样变成 varlen/paged 调用;FlashInferMetadataBuilder.build():追 batch 重排、CSR 页表、prefill/decode plan;FlashInferImpl.forward():核对 Q/output 切片与 wrapper run;- 最后再打开 CUDA Graph、cascade、DCP、量化和多模态分支。
每读到一个判断,先标注它属于哪一层:Selector 的“能不能用”、Builder 的“这批怎样执行”、Impl 的“这一层怎样调用”。把三类判断混在一起,是阅读这部分源码最常见的失焦方式。
系列进度与下一站#
至此,vLLM V1 的主数据流已经从外部请求走到具体 attention 实现:
| 篇次 | 已完成主题 | 关键源码坐标 |
|---|---|---|
| 一 | 整体架构与模块全景 | Entrypoint、Engine、Worker、Kernel |
| 二 | 请求穿过 V1 引擎 | add_request()、EngineCore.step()、输出提交 |
| 三 | Scheduler 的批次决策 | token budget、waiting/running、抢占 |
| 四 | KV Cache Manager | prefix lookup、block pool、分配与回收 |
| 五 | GPUModelRunner 与 InputBatch | 扁平 token、block table、slot mapping、公共 metadata |
| 六 | Attention Backend | selector、AttentionGroup、builder、Impl 与后端专用 metadata |
本篇最应保留的源码关系有六条:
- Selector 输入的是完整能力向量,不是单个 backend 名称;手工指定也必须通过兼容性验证。
- KV cache group 管存储生命周期,AttentionGroup 管 metadata 共享边界,两者不能混为一层。
- Backend 声明能力并创建类,Builder 按 batch 构造计划,Impl 按层执行 Kernel。
CommonAttentionMetadata保存后端无关真值;FlashAttention 可以较直接消费,FlashInfer 原生路径会降成 CSR 页表并执行 plan/run。- group 级 metadata 共享把批次准备成本从逐层重复中移出,层内仍保留各自 Q/K/V、scale 与 KV tensor。
- CUDA Graph、混合 KV、DCP 与投机解码不是旁支补丁,它们决定长度、布局和地址是否仍满足 Backend 契约。
下一篇继续沿数据面向下进入 PagedAttention Kernel。重点将是 block table 如何变成 GPU 地址、一个 query head 怎样由线程组协作读取非连续 KV page、分块 softmax 如何合并局部最大值与归一化因子,以及 GQA、向量化加载和不同 block size 怎样影响寄存器、共享内存与带宽。接口层已经解释过的 Selector 与 Builder 不再重复,只从 Kernel 实际拿到的参数开始。
参考资料#
- vLLM Attention Backend 抽象源码(固定提交)
- vLLM Attention Backend Selector 源码(固定提交)
- vLLM GPUModelRunner:AttentionGroup 初始化与 metadata 构造(固定提交)
- vLLM Attention 模型层与统一自定义算子(固定提交)
- vLLM FlashAttention Backend 源码(固定提交)
- vLLM FlashInfer Backend 源码(固定提交)
- vLLM Attention Backend Feature Support
- vLLM Triton Attention Backend Deep Dive
- FlashInfer KV-Layout 教程
- FlashInfer:Efficient and Customizable Attention Engine for LLM Inference Serving
- FlashAttention:Fast and Memory-Efficient Exact Attention with IO-Awareness
- FlashAttention-3:Fast and Accurate Attention with Asynchrony and Low-precision
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
分享你的想法,与大家交流讨论
音乐
暂未播放



