已合并
add flash mla with kvcache #112
yangyibin创建于 17 天前
add flash mla with kvcache #112
已合并
共 6 个文件变更+5734-0
| @@ -0,0 +1,201 @@ | |||
| 1 | +# Flash MLA with KV Cache | ||
| 2 | + | ||
| 3 | +基于 CANNBot-DSL 实现的 Flash MLA 推理算子,使用 paged KV cache 完成注意力计算: | ||
| 4 | + | ||
| 5 | +$$ | ||
| 6 | +O = \operatorname{softmax}(QK^T \cdot \text{softmax\_scale} + M)V | ||
| 7 | +$$ | ||
| 8 | + | ||
| 9 | +## 支持范围 | ||
| 10 | + | ||
| 11 | +| 特性与约束 | 说明 | | ||
| 12 | +| :--- | :--- | | ||
| 13 | +| 数据类型 | `float16`、`bfloat16`,Q 与 KV cache 类型一致 | | ||
| 14 | +| Query | `TND`,shape 为 `[T, N, 576]`,`N` 支持 64 或 96 | | ||
| 15 | +| KV cache | `PA_NZ` 或 `PA_BBND`,page size 固定为 128,KV head 数固定为 1 | | ||
| 16 | +| 输出布局 | `NTD`,Attention 输出 shape 为 `[N, T, 512]` | | ||
| 17 | +| Mask | `mask_mode=0`(无 mask)或 `mask_mode=3`(right-down causal) | | ||
| 18 | +| Value head dim | 512 | | ||
| 19 | + | ||
| 20 | +实现由三个 Python 文件组成: | ||
| 21 | + | ||
| 22 | +- `flash_mla_with_kvcache.py`:主算子和 host launch。 | ||
| 23 | +- `flash_mla_with_kvcache_metadata.py`:生成设备侧调度 metadata。 | ||
| 24 | +- `checker.py`:公开接口参数校验。 | ||
| 25 | + | ||
| 26 | +## 快速开始 | ||
| 27 | + | ||
| 28 | +在已加载 CANN 环境并将当前 sample 目录加入 `PYTHONPATH` 后,可运行下面的 | ||
| 29 | +`TND + PA_BBND` 示例。它包含一个 batch、2 个 Query token 和 129 个 KV token: | ||
| 30 | + | ||
| 31 | +```python | ||
| 32 | +import math | ||
| 33 | + | ||
| 34 | +import torch | ||
| 35 | +import torch_npu | ||
| 36 | + | ||
| 37 | +from flash_mla_with_kvcache import flash_mla_with_kvcache | ||
| 38 | +from flash_mla_with_kvcache_metadata import flash_mla_with_kvcache_metadata | ||
| 39 | + | ||
| 40 | +dtype = torch.bfloat16 | ||
| 41 | +num_heads_q = 64 | ||
| 42 | +num_heads_kv = 1 | ||
| 43 | +query_tokens = 2 | ||
| 44 | +kv_tokens = 129 | ||
| 45 | +page_size = 128 | ||
| 46 | +page_count = 2 | ||
| 47 | + | ||
| 48 | +q = torch.randn( | ||
| 49 | + query_tokens, num_heads_q, 576, dtype=dtype, device="npu" | ||
| 50 | +) | ||
| 51 | +k_cache = torch.randn( | ||
| 52 | + page_count, page_size, num_heads_kv, 576, | ||
| 53 | + dtype=dtype, | ||
| 54 | + device="npu", | ||
| 55 | +) | ||
| 56 | +block_table = torch.tensor([[0, 1]], dtype=torch.int32, device="npu") | ||
| 57 | +cache_seqlens = torch.tensor([kv_tokens], dtype=torch.int32, device="npu") | ||
| 58 | +cu_seqlens_q = torch.tensor([0, query_tokens], dtype=torch.int32, device="npu") | ||
| 59 | + | ||
| 60 | +metadata = flash_mla_with_kvcache_metadata( | ||
| 61 | + cache_seqlens, | ||
| 62 | + num_heads_q, | ||
| 63 | + num_heads_kv, | ||
| 64 | + cu_seqlens_q=cu_seqlens_q, | ||
| 65 | + max_seqlen_q=query_tokens, | ||
| 66 | + max_seqlen_kv=kv_tokens, | ||
| 67 | + layout_q="TND", | ||
| 68 | +) | ||
| 69 | + | ||
| 70 | +attention_out, softmax_lse = flash_mla_with_kvcache( | ||
| 71 | + q, | ||
| 72 | + k_cache, | ||
| 73 | + block_table=block_table, | ||
| 74 | + cache_seqlens=cache_seqlens, | ||
| 75 | + cu_seqlens_q=cu_seqlens_q, | ||
| 76 | + metadata=metadata, | ||
| 77 | + softmax_scale=1.0 / math.sqrt(576), | ||
| 78 | + layout_q="TND", | ||
| 79 | + layout_kv="PA_BBND", | ||
| 80 | + layout_out="NTD", | ||
| 81 | + return_softmax_lse=True, | ||
| 82 | +) | ||
| 83 | +torch.npu.synchronize() | ||
| 84 | +``` | ||
| 85 | + | ||
| 86 | +调用顺序固定为:先生成 metadata,再将其与同一组序列信息传给主算子。调度配置不变时,metadata 可以复用。 | ||
| 87 | + | ||
| 88 | +metadata 调度和主算子启动均使用当前 stream 的有效 AIC/AIV 核数,支持 stream 控核;主算子的 block 数和 workspace 随有效 AIC 核数缩放。生成与使用 metadata 时须保持相同的设备与核数配置,修改 stream 核数配额后应重新生成 metadata。此路径不将设备侧 metadata 回读到 host,支持 ACLGraph capture。 | ||
| 89 | + | ||
| 90 | +## Metadata 接口 | ||
| 91 | + | ||
| 92 | +```python | ||
| 93 | +flash_mla_with_kvcache_metadata( | ||
| 94 | + cache_seqlens, | ||
| 95 | + num_heads_q: int, | ||
| 96 | + num_heads_kv: int, | ||
| 97 | + cu_seqlens_q=None, | ||
| 98 | + seqused_q=None, | ||
| 99 | + max_seqlen_q=-1, | ||
| 100 | + max_seqlen_kv=-1, | ||
| 101 | + head_dim_qk=576, | ||
| 102 | + head_dim_v=512, | ||
| 103 | + mask_mode=0, | ||
| 104 | + layout_q="BSND", | ||
| 105 | +) | ||
| 106 | +``` | ||
| 107 | + | ||
| 108 | +| 参数 | 说明 | | ||
| 109 | +| :--- | :--- | | ||
| 110 | +| `cache_seqlens` | 必选,NPU 上连续的一维 `int32` Tensor,shape 为 `[B]`,表示每个 batch 的有效 KV 长度 | | ||
| 111 | +| `num_heads_q` | Query head 数;配合当前主算子使用时为 64 或 96 | | ||
| 112 | +| `num_heads_kv` | KV head 数,当前固定为 1 | | ||
| 113 | +| `cu_seqlens_q` | `TND` 必选,NPU 上连续的一维 `int32` Tensor,shape 为 `[B+1]` | | ||
| 114 | +| `seqused_q` | 可选,NPU 上连续的一维 `int32` Tensor,shape 为 `[B]`,表示每个 batch 实际使用的 Query 数 | | ||
| 115 | +| `max_seqlen_q` | 最大 Query 长度,`-1` 表示未指定;也可传非负整数供调度使用 | | ||
| 116 | +| `max_seqlen_kv` | 最大 KV 长度,`-1` 表示未指定;也可传非负整数供调度使用 | | ||
| 117 | +| `head_dim_qk` | Q/K head dim,当前固定为 576 | | ||
| 118 | +| `head_dim_v` | Value head dim,当前固定为 512 | | ||
| 119 | +| `mask_mode` | 0 表示无 mask,3 表示 right-down causal | | ||
| 120 | +| `layout_q` | metadata 支持 `TND`、`BSND`、`BNSD`;当前主算子使用 `TND` | | ||
| 121 | +| 返回值 | NPU 上的一维 `int32` metadata Tensor,供主算子直接使用 | | ||
| 122 | + | ||
| 123 | +## 主算子接口 | ||
| 124 | + | ||
| 125 | +```python | ||
| 126 | +flash_mla_with_kvcache( | ||
| 127 | + q, | ||
| 128 | + k_cache, | ||
| 129 | + block_table=None, | ||
| 130 | + cache_seqlens=None, | ||
| 131 | + cu_seqlens_q=None, | ||
| 132 | + seqused_q=None, | ||
| 133 | + attn_mask=None, | ||
| 134 | + metadata=None, | ||
| 135 | + head_dim_v=512, | ||
| 136 | + softmax_scale=1.0, | ||
| 137 | + mask_mode=0, | ||
| 138 | + max_seqlen_q=-1, | ||
| 139 | + max_seqlen_kv=-1, | ||
| 140 | + layout_q="BSND", | ||
| 141 | + layout_kv="PA_BBND", | ||
| 142 | + layout_out="BSND", | ||
| 143 | + return_softmax_lse=False, | ||
| 144 | +) | ||
| 145 | +``` | ||
| 146 | + | ||
| 147 | +| 参数 | 说明 | | ||
| 148 | +| :--- | :--- | | ||
| 149 | +| `q` | 必选,`float16` 或 `bfloat16`,当前为 `TND [T, N, 576]` | | ||
| 150 | +| `k_cache` | 必选,与 Q 同 dtype;`PA_BBND [blocks, 128, 1, 576]` 或 `PA_NZ [blocks, 1, 36, 128, 16]` | | ||
| 151 | +| `block_table` | 必选,`int32 [B, max_pages]`,记录各 batch 使用的物理 page 编号 | | ||
| 152 | +| `cache_seqlens` | 必选,`int32 [B]`,每个 batch 的有效 KV 长度 | | ||
| 153 | +| `cu_seqlens_q` | `TND` 必选,`int32 [B+1]` Query 前缀和 | | ||
| 154 | +| `seqused_q` | 可选,`int32 [B]`,每个 batch 实际参与计算的 Query 数 | | ||
| 155 | +| `attn_mask` | `mask_mode=3` 时必选,shape 为 `[2048, 2048]`、dtype 为 `int8`;`mask_mode=0` 时必须为 `None` | | ||
| 156 | +| `metadata` | 必选,由 `flash_mla_with_kvcache_metadata()` 生成的一维 `int32` Tensor | | ||
| 157 | +| `head_dim_v` | 当前固定为 512 | | ||
| 158 | +| `softmax_scale` | Softmax 缩放系数,默认值和传入 `None` 时均为 `1.0` | | ||
| 159 | +| `mask_mode` | 0 表示无 mask,3 表示 right-down causal | | ||
| 160 | +| `max_seqlen_q` | 当前主算子要求为 `-1` 或 `None` | | ||
| 161 | +| `max_seqlen_kv` | 当前主算子要求为 `-1` 或 `None` | | ||
| 162 | +| `layout_q` | 当前必须为 `TND` | | ||
| 163 | +| `layout_kv` | `PA_NZ` 或 `PA_BBND` | | ||
| 164 | +| `layout_out` | 当前必须为 `NTD` | | ||
| 165 | +| `return_softmax_lse` | 是否返回 Softmax LSE | | ||
| 166 | + | ||
| 167 | +返回 `(attention_out, softmax_lse)`: | ||
| 168 | + | ||
| 169 | +- `attention_out`:shape 为 `[N, T, 512]`,dtype 与 Q 一致。 | ||
| 170 | +- `softmax_lse`:启用时 shape 为 `[N, T]`、dtype 为 `float32`;关闭时返回空的 `float32` Tensor。 | ||
| 171 | + | ||
| 172 | +注意:虽然函数签名保留了 `BSND` 默认值,当前 checker 开放的主算子组合为 `layout_q="TND"` 和 `layout_out="NTD"`,调用时应显式传入。 | ||
| 173 | + | ||
| 174 | +## 性能对比 | ||
| 175 | + | ||
| 176 | +下图为 CANNBot-DSL 与 Flash Attn With Kvcache(CANN built-in ASC)在 8 个代表性 case 上的 kernel device time。 | ||
| 177 | +使用 msprof 采集 `Task Duration`,每个实现执行两组、每组 1 次 warmup 和 | ||
| 178 | +20 次正式采样;丢弃 warmup 后,从 40 个样本中取最小值。首次编译和 host | ||
| 179 | +开销不计入结果。加速比定义为 `Flash Attn With Kvcache / CANNBot-DSL`,大于 1 表示 | ||
| 180 | +CANNBot-DSL 更快。 | ||
| 181 | + | ||
| 182 | + | ||
| 183 | + | ||
| 184 | +测试环境为 CANN 9.2.0 两种实现使用同一 | ||
| 185 | +设备、相同输入配置和相同重复次数。 | ||
| 186 | + | ||
| 187 | +## 功能测试 | ||
| 188 | + | ||
| 189 | +在仓库根目录执行: | ||
| 190 | + | ||
| 191 | +```bash | ||
| 192 | +python -m pytest test/flash_mla_with_kvcache/test_flash_mla_with_kvcache.py -v | ||
| 193 | +``` | ||
| 194 | + | ||
| 195 | +ACLGraph 模式: | ||
| 196 | + | ||
| 197 | +```bash | ||
| 198 | +python -m pytest test/flash_mla_with_kvcache/test_flash_mla_with_kvcache.py -v -o mode=aclgraph | ||
| 199 | +``` | ||
| 200 | + | ||
| 201 | +测试覆盖两种 KV layout、两种数据类型、普通与 causal attention、ragged/empty batch、尾块和非连续外层 stride。 | ||
| @@ -0,0 +1,397 @@ | |||
| 1 | +# Copyright (c) 2026 Huawei Technologies Co., Ltd. | ||
| 2 | +# This program is free software, you can redistribute it and/or modify it under the terms and conditions of | ||
| 3 | +# CANN Open Software License Agreement Version 2.0 (the "License"). | ||
| 4 | +# Please refer to the License for details. You may not use this file except in compliance with the License. | ||
| 5 | +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, | ||
| 6 | +# INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. | ||
| 7 | +# See LICENSE in the root of the software repository for the full text of the License. | ||
| 8 | + | ||
| 9 | +"""Host-side validation for Flash MLA with KV cache.""" | ||
| 10 | + | ||
| 11 | +from dataclasses import dataclass | ||
| 12 | + | ||
| 13 | +import torch | ||
| 14 | + | ||
| 15 | + | ||
| 16 | +HEAD_DIM_QK = 576 | ||
| 17 | +HEAD_DIM_V = 512 | ||
| 18 | +MASK_TEMPLATE_SIZE = 2048 | ||
| 19 | +KV_BLOCK_SIZE = 128 | ||
| 20 | +PA_NZ_D0 = 16 | ||
| 21 | +PA_NZ_D1 = HEAD_DIM_QK // PA_NZ_D0 | ||
| 22 | +MAX_BATCH_SIZE = 65536 | ||
| 23 | +UNSPECIFIED_SEQLEN = -1 | ||
| 24 | +NO_MASK_MODE = 0 | ||
| 25 | +CAUSAL_MASK_MODE = 3 | ||
| 26 | +SUPPORTED_Q_LAYOUTS = ("TND",) | ||
| 27 | +SUPPORTED_KV_LAYOUTS = ("PA_NZ", "PA_BBND") | ||
| 28 | +SUPPORTED_HEAD_COUNTS = (64, 96) | ||
| 29 | +SUPPORTED_MASK_MODES = (NO_MASK_MODE, CAUSAL_MASK_MODE) | ||
| 30 | +SUPPORTED_DTYPES = (torch.float16, torch.bfloat16) | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +@dataclass(frozen=True, slots=True) | ||
| 34 | +class CheckedInputs: | ||
| 35 | + """Normalized values and dimensions needed by the host launcher.""" | ||
| 36 | + | ||
| 37 | + layout_q: str | ||
| 38 | + layout_kv: str | ||
| 39 | + layout_out: str | ||
| 40 | + batch: int | ||
| 41 | + total_q: int | ||
| 42 | + num_heads_q: int | ||
| 43 | + logical_rows: int | ||
| 44 | + block_size: int | ||
| 45 | + | ||
| 46 | + | ||
| 47 | +@dataclass(frozen=True, slots=True) | ||
| 48 | +class InputRelations: | ||
| 49 | + """Dimensions established by relationships between public inputs.""" | ||
| 50 | + | ||
| 51 | + batch: int | ||
| 52 | + | ||
| 53 | + | ||
| 54 | +@dataclass(frozen=True, slots=True) | ||
| 55 | +class FeatureSelection: | ||
| 56 | + """Normalized values derived from the supported feature set.""" | ||
| 57 | + | ||
| 58 | + layout_q: str | ||
| 59 | + layout_kv: str | ||
| 60 | + layout_out: str | ||
| 61 | + total_q: int | ||
| 62 | + num_heads_q: int | ||
| 63 | + block_size: int | ||
| 64 | + | ||
| 65 | + | ||
| 66 | +def _check_dtype(tensor, name, allowed): | ||
| 67 | + display_name = name[0].upper() + name[1:] | ||
| 68 | + if tensor.dtype not in allowed: | ||
| 69 | + if allowed == SUPPORTED_DTYPES: | ||
| 70 | + raise TypeError( | ||
| 71 | + f"{display_name} must be float16 or bfloat16; got {tensor.dtype}" | ||
| 72 | + ) | ||
| 73 | + raise TypeError(f"{display_name} must be {allowed}; got {tensor.dtype}") | ||
| 74 | + | ||
| 75 | + | ||
| 76 | +def _require_inner_contiguous(tensor, name): | ||
| 77 | + """Accept outer strides while keeping each innermost row DMA-compatible.""" | ||
| 78 | + display_name = name[0].upper() + name[1:] | ||
| 79 | + if tensor.dim() > 0 and tensor.stride(-1) != 1: | ||
| 80 | + raise ValueError(f"{display_name} must be contiguous in its last dimension") | ||
| 81 | + | ||
| 82 | + | ||
| 83 | +def _first_noncontiguous_dim(tensor): | ||
| 84 | + expected_stride = 1 | ||
| 85 | + for dim in range(tensor.dim() - 1, -1, -1): | ||
| 86 | + if tensor.stride(dim) != expected_stride: | ||
| 87 | + return dim | ||
| 88 | + expected_stride *= tensor.shape[dim] | ||
| 89 | + return -1 | ||
| 90 | + | ||
| 91 | + | ||
| 92 | +def _normalize_layout(value, allowed, name): | ||
| 93 | + display_name = name[0].upper() + name[1:] | ||
| 94 | + if value not in allowed: | ||
| 95 | + raise ValueError( | ||
| 96 | + f"{display_name} must be one of {sorted(allowed)}; got {value!r}" | ||
| 97 | + ) | ||
| 98 | + return value | ||
| 99 | + | ||
| 100 | + | ||
| 101 | +def _check_required_inputs(*, metadata, block_table, cache_seqlens): | ||
| 102 | + """Check that unconditional public inputs are present.""" | ||
| 103 | + if metadata is None: | ||
| 104 | + raise ValueError("Metadata is required") | ||
| 105 | + if cache_seqlens is None or block_table is None: | ||
| 106 | + raise ValueError("Cache_seqlens and block_table are required") | ||
| 107 | + | ||
| 108 | + | ||
| 109 | +def _check_single_arguments( | ||
| 110 | + *, | ||
| 111 | + q, | ||
| 112 | + k_cache, | ||
| 113 | + block_table, | ||
| 114 | + cache_seqlens, | ||
| 115 | + cu_seqlens_q, | ||
| 116 | + seqused_q, | ||
| 117 | + attn_mask, | ||
| 118 | + metadata, | ||
| 119 | + head_dim_v, | ||
| 120 | + mask_mode, | ||
| 121 | + max_seqlen_q, | ||
| 122 | + max_seqlen_kv, | ||
| 123 | + layout_q, | ||
| 124 | + layout_kv, | ||
| 125 | + layout_out, | ||
| 126 | + return_softmax_lse, | ||
| 127 | +): | ||
| 128 | + """Check constraints that can be decided from one argument alone.""" | ||
| 129 | + max_seqlen_q = ( | ||
| 130 | + UNSPECIFIED_SEQLEN if max_seqlen_q is None else max_seqlen_q | ||
| 131 | + ) | ||
| 132 | + max_seqlen_kv = ( | ||
| 133 | + UNSPECIFIED_SEQLEN if max_seqlen_kv is None else max_seqlen_kv | ||
| 134 | + ) | ||
| 135 | + if type(max_seqlen_q) is not int: | ||
| 136 | + raise TypeError("Max_seqlen_q must be an int") | ||
| 137 | + if type(max_seqlen_kv) is not int: | ||
| 138 | + raise TypeError("Max_seqlen_kv must be an int") | ||
| 139 | + if max_seqlen_q != UNSPECIFIED_SEQLEN: | ||
| 140 | + raise ValueError( | ||
| 141 | + f"Max_seqlen_q must be {UNSPECIFIED_SEQLEN} or omitted" | ||
| 142 | + ) | ||
| 143 | + if max_seqlen_kv != UNSPECIFIED_SEQLEN: | ||
| 144 | + raise ValueError( | ||
| 145 | + f"Max_seqlen_kv must be {UNSPECIFIED_SEQLEN} or omitted" | ||
| 146 | + ) | ||
| 147 | + if type(head_dim_v) is not int or head_dim_v != HEAD_DIM_V: | ||
| 148 | + raise ValueError(f"Head_dim_v must be {HEAD_DIM_V}; got {head_dim_v}") | ||
| 149 | + if not isinstance(return_softmax_lse, bool): | ||
| 150 | + raise TypeError("Return_softmax_lse must be bool") | ||
| 151 | + if type(mask_mode) is not int: | ||
| 152 | + raise TypeError("Mask_mode must be an int") | ||
| 153 | + | ||
| 154 | + for value, name in ( | ||
| 155 | + (layout_q, "layout_q"), | ||
| 156 | + (layout_kv, "layout_kv"), | ||
| 157 | + (layout_out, "layout_out"), | ||
| 158 | + ): | ||
| 159 | + if value is not None and not isinstance(value, str): | ||
| 160 | + display_name = name[0].upper() + name[1:] | ||
| 161 | + raise ValueError(f"{display_name} must be a string; got {value!r}") | ||
| 162 | + | ||
| 163 | + _check_dtype(q, "q", SUPPORTED_DTYPES) | ||
| 164 | + _check_dtype(k_cache, "k_cache", SUPPORTED_DTYPES) | ||
| 165 | + if block_table.dtype != torch.int32: | ||
| 166 | + raise TypeError("Block_table must be int32") | ||
| 167 | + if cache_seqlens.dtype != torch.int32: | ||
| 168 | + raise TypeError("Cache_seqlens must be int32") | ||
| 169 | + if cache_seqlens.dim() != 1: | ||
| 170 | + raise ValueError("Cache_seqlens must be one-dimensional") | ||
| 171 | + if cu_seqlens_q is not None and cu_seqlens_q.dtype != torch.int32: | ||
| 172 | + raise TypeError("Cu_seqlens_q must be int32") | ||
| 173 | + if seqused_q is not None and seqused_q.dtype != torch.int32: | ||
| 174 | + raise TypeError("Seqused_q must be int32") | ||
| 175 | + if attn_mask is not None: | ||
| 176 | + mask_shape = (MASK_TEMPLATE_SIZE, MASK_TEMPLATE_SIZE) | ||
| 177 | + if tuple(attn_mask.shape) != mask_shape: | ||
| 178 | + raise ValueError(f"Attn_mask must be {mask_shape}") | ||
| 179 | + if attn_mask.dtype != torch.int8: | ||
| 180 | + raise TypeError("Attn_mask must be int8") | ||
| 181 | + | ||
| 182 | + if not isinstance(metadata, torch.Tensor): | ||
| 183 | + raise ValueError( | ||
| 184 | + "Metadata tile_table must be replaced by one flat int32 tensor" | ||
| 185 | + ) | ||
| 186 | + if metadata.dtype != torch.int32 or metadata.dim() != 1: | ||
| 187 | + raise ValueError("Metadata must be one flat int32 tensor") | ||
| 188 | + if metadata.numel() <= 0: | ||
| 189 | + raise ValueError("Metadata first dimension must be greater than 0") | ||
| 190 | + | ||
| 191 | + for tensor, name in ( | ||
| 192 | + (q, "q"), | ||
| 193 | + (block_table, "block_table"), | ||
| 194 | + (cache_seqlens, "cache_seqlens"), | ||
| 195 | + (metadata, "metadata"), | ||
| 196 | + (cu_seqlens_q, "cu_seqlens_q"), | ||
| 197 | + (seqused_q, "seqused_q"), | ||
| 198 | + (attn_mask, "attn_mask"), | ||
| 199 | + ): | ||
| 200 | + if tensor is not None: | ||
| 201 | + _require_inner_contiguous(tensor, name) | ||
| 202 | + | ||
| 203 | + | ||
| 204 | +def _check_cross_arguments( | ||
| 205 | + *, | ||
| 206 | + q, | ||
| 207 | + k_cache, | ||
| 208 | + block_table, | ||
| 209 | + cache_seqlens, | ||
| 210 | + cu_seqlens_q, | ||
| 211 | + seqused_q, | ||
| 212 | + attn_mask, | ||
| 213 | + mask_mode, | ||
| 214 | + layout_q, | ||
| 215 | +): | ||
| 216 | + """Check relationships between two or more public inputs.""" | ||
| 217 | + if k_cache.dtype != q.dtype: | ||
| 218 | + raise TypeError("Q and k_cache must have the same dtype") | ||
| 219 | + if ( | ||
| 220 | + mask_mode in SUPPORTED_MASK_MODES | ||
| 221 | + and (mask_mode == NO_MASK_MODE) != (attn_mask is None) | ||
| 222 | + ): | ||
| 223 | + raise ValueError( | ||
| 224 | + f"Mask_mode = {NO_MASK_MODE} forbids attn_mask; " | ||
| 225 | + f"mask_mode = {CAUSAL_MASK_MODE} requires it" | ||
| 226 | + ) | ||
| 227 | + | ||
| 228 | + batch = cache_seqlens.shape[0] | ||
| 229 | + if not 0 < batch < MAX_BATCH_SIZE: | ||
| 230 | + raise ValueError(f"Batch size must be in (0, {MAX_BATCH_SIZE})") | ||
| 231 | + if block_table.dim() != 2 or block_table.shape[0] != batch: | ||
| 232 | + raise ValueError("Block_table must have shape (B, max_pages)") | ||
| 233 | + if block_table.shape[1] <= 0: | ||
| 234 | + raise ValueError("Block_table max_pages must be positive") | ||
| 235 | + if layout_q == "TND": | ||
| 236 | + if cu_seqlens_q is None or cu_seqlens_q.shape != (batch + 1,): | ||
| 237 | + raise ValueError("TND requires cu_seqlens_q with shape (B+1,)") | ||
| 238 | + if seqused_q is not None and seqused_q.shape != (batch,): | ||
| 239 | + raise ValueError("Seqused_q must have shape (B,)") | ||
| 240 | + return InputRelations(batch=batch) | ||
| 241 | + | ||
| 242 | + | ||
| 243 | +def _check_supported_features( | ||
| 244 | + *, | ||
| 245 | + q, | ||
| 246 | + k_cache, | ||
| 247 | + layout_q, | ||
| 248 | + layout_kv, | ||
| 249 | + layout_out, | ||
| 250 | + mask_mode, | ||
| 251 | +): | ||
| 252 | + """Check limits imposed by the currently implemented feature set.""" | ||
| 253 | + layout_q = _normalize_layout(layout_q, SUPPORTED_Q_LAYOUTS, "layout_q") | ||
| 254 | + layout_kv = _normalize_layout(layout_kv, SUPPORTED_KV_LAYOUTS, "layout_kv") | ||
| 255 | + layout_out = "BSND" if layout_out is None else _normalize_layout( | ||
| 256 | + layout_out, ("NTD",), "layout_out" | ||
| 257 | + ) | ||
| 258 | + if layout_out != "NTD": | ||
| 259 | + raise ValueError("Layout_out must be NTD") | ||
| 260 | + if mask_mode not in SUPPORTED_MASK_MODES: | ||
| 261 | + raise ValueError( | ||
| 262 | + f"Mask_mode must be {NO_MASK_MODE} or {CAUSAL_MASK_MODE}; " | ||
| 263 | + f"got {mask_mode}" | ||
| 264 | + ) | ||
| 265 | + | ||
| 266 | + if q.dim() != 3 or q.shape[2] != HEAD_DIM_QK: | ||
| 267 | + raise ValueError(f"TND q must have shape (T, N, {HEAD_DIM_QK})") | ||
| 268 | + if q.stride(1) != HEAD_DIM_QK: | ||
| 269 | + raise ValueError( | ||
| 270 | + "TND q supports non-contiguous storage only between tokens; " | ||
| 271 | + "heads inside each token must be contiguous" | ||
| 272 | + ) | ||
| 273 | + total_q, num_heads_q = q.shape[0], q.shape[1] | ||
| 274 | + if total_q <= 0: | ||
| 275 | + raise ValueError("TND q token count must be positive") | ||
| 276 | + if num_heads_q not in SUPPORTED_HEAD_COUNTS: | ||
| 277 | + supported_heads = " or ".join(map(str, SUPPORTED_HEAD_COUNTS)) | ||
| 278 | + raise ValueError(f"Q head count must be {supported_heads}") | ||
| 279 | + | ||
| 280 | + noncontiguous_dim = _first_noncontiguous_dim(k_cache) | ||
| 281 | + max_noncontiguous_dim = 0 if layout_kv == "PA_BBND" else 1 | ||
| 282 | + if noncontiguous_dim > max_noncontiguous_dim: | ||
| 283 | + allowed = ( | ||
| 284 | + "dimension 0" if max_noncontiguous_dim == 0 else "dimensions 0 or 1" | ||
| 285 | + ) | ||
| 286 | + raise ValueError( | ||
| 287 | + f"{layout_kv} k_cache supports non-contiguous storage only in " | ||
| 288 | + f"{allowed}; got dimension {noncontiguous_dim}" | ||
| 289 | + ) | ||
| 290 | + | ||
| 291 | + if layout_kv == "PA_NZ": | ||
| 292 | + if ( | ||
| 293 | + k_cache.dim() != 5 | ||
| 294 | + or k_cache.shape[1] != 1 | ||
| 295 | + or k_cache.shape[2] != PA_NZ_D1 | ||
| 296 | + or k_cache.shape[4] != PA_NZ_D0 | ||
| 297 | + ): | ||
| 298 | + raise ValueError( | ||
| 299 | + "PA_NZ k_cache must be " | ||
| 300 | + f"(blocks, 1, {PA_NZ_D1}, block, {PA_NZ_D0})" | ||
| 301 | + ) | ||
| 302 | + block_size = k_cache.shape[3] | ||
| 303 | + else: | ||
| 304 | + if k_cache.dim() != 4 or k_cache.shape[2:] != (1, HEAD_DIM_QK): | ||
| 305 | + raise ValueError( | ||
| 306 | + "PA_BBND k_cache must be " | ||
| 307 | + f"(blocks, block, 1, {HEAD_DIM_QK})" | ||
| 308 | + ) | ||
| 309 | + block_size = k_cache.shape[1] | ||
| 310 | + if k_cache.shape[0] <= 0: | ||
| 311 | + raise ValueError("K_cache block count must be positive") | ||
| 312 | + if block_size != KV_BLOCK_SIZE: | ||
| 313 | + raise ValueError(f"Paged KV block size must be {KV_BLOCK_SIZE}") | ||
| 314 | + | ||
| 315 | + return FeatureSelection( | ||
| 316 | + layout_q=layout_q, | ||
| 317 | + layout_kv=layout_kv, | ||
| 318 | + layout_out=layout_out, | ||
| 319 | + total_q=total_q, | ||
| 320 | + num_heads_q=num_heads_q, | ||
| 321 | + block_size=block_size, | ||
| 322 | + ) | ||
| 323 | + | ||
| 324 | + | ||
| 325 | +def check_flash_mla_inputs( | ||
| 326 | + *, | ||
| 327 | + q, | ||
| 328 | + k_cache, | ||
| 329 | + block_table, | ||
| 330 | + cache_seqlens, | ||
| 331 | + cu_seqlens_q, | ||
| 332 | + seqused_q, | ||
| 333 | + attn_mask, | ||
| 334 | + metadata, | ||
| 335 | + head_dim_v, | ||
| 336 | + mask_mode, | ||
| 337 | + max_seqlen_q, | ||
| 338 | + max_seqlen_kv, | ||
| 339 | + layout_q, | ||
| 340 | + layout_kv, | ||
| 341 | + layout_out, | ||
| 342 | + return_softmax_lse, | ||
| 343 | +): | ||
| 344 | + """Validate inputs by category and derive host launch metadata.""" | ||
| 345 | + _check_required_inputs( | ||
| 346 | + metadata=metadata, | ||
| 347 | + block_table=block_table, | ||
| 348 | + cache_seqlens=cache_seqlens, | ||
| 349 | + ) | ||
| 350 | + _check_single_arguments( | ||
| 351 | + q=q, | ||
| 352 | + k_cache=k_cache, | ||
| 353 | + block_table=block_table, | ||
| 354 | + cache_seqlens=cache_seqlens, | ||
| 355 | + cu_seqlens_q=cu_seqlens_q, | ||
| 356 | + seqused_q=seqused_q, | ||
| 357 | + attn_mask=attn_mask, | ||
| 358 | + metadata=metadata, | ||
| 359 | + head_dim_v=head_dim_v, | ||
| 360 | + mask_mode=mask_mode, | ||
| 361 | + max_seqlen_q=max_seqlen_q, | ||
| 362 | + max_seqlen_kv=max_seqlen_kv, | ||
| 363 | + layout_q=layout_q, | ||
| 364 | + layout_kv=layout_kv, | ||
| 365 | + layout_out=layout_out, | ||
| 366 | + return_softmax_lse=return_softmax_lse, | ||
| 367 | + ) | ||
| 368 | + relations = _check_cross_arguments( | ||
| 369 | + q=q, | ||
| 370 | + k_cache=k_cache, | ||
| 371 | + block_table=block_table, | ||
| 372 | + cache_seqlens=cache_seqlens, | ||
| 373 | + cu_seqlens_q=cu_seqlens_q, | ||
| 374 | + seqused_q=seqused_q, | ||
| 375 | + attn_mask=attn_mask, | ||
| 376 | + mask_mode=mask_mode, | ||
| 377 | + layout_q=layout_q, | ||
| 378 | + ) | ||
| 379 | + features = _check_supported_features( | ||
| 380 | + q=q, | ||
| 381 | + k_cache=k_cache, | ||
| 382 | + layout_q=layout_q, | ||
| 383 | + layout_kv=layout_kv, | ||
| 384 | + layout_out=layout_out, | ||
| 385 | + mask_mode=mask_mode, | ||
| 386 | + ) | ||
| 387 | + | ||
| 388 | + return CheckedInputs( | ||
| 389 | + layout_q=features.layout_q, | ||
| 390 | + layout_kv=features.layout_kv, | ||
| 391 | + layout_out=features.layout_out, | ||
| 392 | + batch=relations.batch, | ||
| 393 | + total_q=features.total_q, | ||
| 394 | + num_heads_q=features.num_heads_q, | ||
| 395 | + logical_rows=features.total_q * features.num_heads_q, | ||
| 396 | + block_size=features.block_size, | ||
| 397 | + ) | ||
| @@ -0,0 +1,527 @@ | |||
| 1 | +# Copyright (c) 2026 Huawei Technologies Co., Ltd. | ||
| 2 | +# This program is free software, you can redistribute it and/or modify it under the terms and conditions of | ||
| 3 | +# CANN Open Software License Agreement Version 2.0 (the "License"). | ||
| 4 | +# Please refer to the License for details. You may not use this file except in compliance with the License. | ||
| 5 | +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, | ||
| 6 | +# INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. | ||
| 7 | +# See LICENSE in the root of the software repository for the full text of the License. | ||
| 8 | + | ||
| 9 | +"""Representative NPU accuracy coverage for Flash MLA with paged KV cache. | ||
| 10 | + | ||
| 11 | +This is a compact functional subset of the historical 204-case regression | ||
| 12 | +suite, restricted to the public mainline interface. Cases deliberately cross | ||
| 13 | +multiple behaviors instead of repeating geometry: dtype, both supported KV | ||
| 14 | +layouts, tails, ragged/empty batches, seqused padding, causal masking, | ||
| 15 | +FlashDecode and metadata. | ||
| 16 | +Every end-to-end case runs metadata followed by the main operator and compares | ||
| 17 | +against an independent CPU reference. | ||
| 18 | +""" | ||
| 19 | + | ||
| 20 | +import importlib | ||
| 21 | +import math | ||
| 22 | +from pathlib import Path | ||
| 23 | +import sys | ||
| 24 | + | ||
| 25 | +import pytest | ||
| 26 | +import torch | ||
| 27 | + | ||
| 28 | +REPO_ROOT = Path(__file__).resolve().parents[2] | ||
| 29 | +SAMPLES = REPO_ROOT / "samples" | ||
| 30 | +OP_DIR = SAMPLES / "flash_mla_with_kvcache" | ||
| 31 | +sys.path.insert(0, str(OP_DIR)) | ||
| 32 | + | ||
| 33 | +flash_mla_module = importlib.import_module("flash_mla_with_kvcache") | ||
| 34 | +metadata_module = importlib.import_module("flash_mla_with_kvcache_metadata") | ||
| 35 | + | ||
| 36 | + | ||
| 37 | +@pytest.fixture(autouse=True) | ||
| 38 | +def _automatic_buffer_sync(monkeypatch): | ||
| 39 | + """Exercise the sample with compiler-generated Buffer synchronization.""" | ||
| 40 | + monkeypatch.setenv("CANNBOTDSL_AUTO_BUFFER_SYNC", "1") | ||
| 41 | + | ||
| 42 | + | ||
| 43 | +DEFAULT_RTOL = 0.005 | ||
| 44 | +BFLOAT16_RTOL = 0.0078125 | ||
| 45 | +DEFAULT_ATOL = 0.000025 | ||
| 46 | +BFLOAT16_ATOL = BFLOAT16_RTOL | ||
| 47 | +FAIL_RATIO = 0.005 | ||
| 48 | +MAX_RELATIVE_ERROR = 10.0 | ||
| 49 | +RELATIVE_FLOOR = (1.0 / (1 << 14)) / 0.005 | ||
| 50 | +RELATIVE_EPSILON = 2e-9 | ||
| 51 | +FULL_D_FAILURE_RATIO = 0.8 | ||
| 52 | +LSE_ATOL = 0.1 | ||
| 53 | + | ||
| 54 | + | ||
| 55 | +def _torch_tolerances(dtype): | ||
| 56 | + if dtype == torch.float16: | ||
| 57 | + return DEFAULT_ATOL, DEFAULT_RTOL | ||
| 58 | + if dtype == torch.bfloat16: | ||
| 59 | + return BFLOAT16_ATOL, BFLOAT16_RTOL | ||
| 60 | + raise ValueError(f"unsupported precision dtype: {dtype}") | ||
| 61 | + | ||
| 62 | + | ||
| 63 | +def _compare_stats(actual, golden, atol, rtol): | ||
| 64 | + if actual.shape != golden.shape: | ||
| 65 | + return {"shape_ok": False, "fail_ratio": 1.0, "max_rel": 0.0, | ||
| 66 | + "full_d_failure": False} | ||
| 67 | + actual = actual.float() | ||
| 68 | + golden = golden.float() | ||
| 69 | + difference = (actual - golden).abs() | ||
| 70 | + exact = actual == golden | ||
| 71 | + absolute_pass = exact | (difference <= atol) | ||
| 72 | + relative_error = difference / ( | ||
| 73 | + golden.abs().clamp(min=RELATIVE_FLOOR) + RELATIVE_EPSILON | ||
| 74 | + ) | ||
| 75 | + bad = ~absolute_pass & ~(exact | (relative_error <= rtol)) | ||
| 76 | + fail_ratio = float(bad.sum()) / difference.numel() | ||
| 77 | + needs_relative = ~absolute_pass | ||
| 78 | + max_relative = ( | ||
| 79 | + relative_error[needs_relative].max().item() | ||
| 80 | + if needs_relative.any() | ||
| 81 | + else 0.0 | ||
| 82 | + ) | ||
| 83 | + bad_rows = bad.reshape(-1, actual.shape[-1]) | ||
| 84 | + row_failure_ratio = bad_rows.float().mean(dim=1) | ||
| 85 | + full_d_failure = bool( | ||
| 86 | + bad_rows.all(dim=1).any() | ||
| 87 | + or (row_failure_ratio > FULL_D_FAILURE_RATIO).any() | ||
| 88 | + ) | ||
| 89 | + return { | ||
| 90 | + "shape_ok": True, | ||
| 91 | + "fail_ratio": fail_ratio, | ||
| 92 | + "max_rel": max_relative, | ||
| 93 | + "full_d_failure": full_d_failure, | ||
| 94 | + } | ||
| 95 | + | ||
| 96 | + | ||
| 97 | +def _precision_pass(stats): | ||
| 98 | + return ( | ||
| 99 | + stats["shape_ok"] | ||
| 100 | + and not stats["full_d_failure"] | ||