从 Qwen3 数学到 CUDA Kernel:0.6B 推理运行时的完整实现与优化
这是一篇可以从头顺读的项目教程,不是三篇彼此跳转的优化记录。我们从“一个 token ID 如何变成下一个 token”开始,给每个 tensor 标 shape,给每个公式解释符号,再逐步回答:
- Qwen3-0.6B 的一层到底算了什么?
- prefill 和 decode 为什么是两个不同的性能问题?
- 为什么 Q/K Norm、RoPE、KV 写入应该融合?
- Paged KV Cache 的逻辑位置怎样翻译为显存地址?
- online softmax 为什么不需要保存完整 attention score?
- 短 context 和长 context 为什么必须分派不同 CUDA kernel?
- CUDA Graph、Tensor Core 和 W8A16 分别解决什么,不能解决什么?
- “比 vLLM 快”与“W8A16 带来的加速”为什么是两个不同的比值?
文章对应的完整代码、测试和 83 页 XeLaTeX 学习文档都在 kimikimiki/qwen-cuda-runtime。
项目边界:Qwen3-0.6B、batch size 1、greedy decode、BF16 主路径、NVIDIA RTX 3060 Laptop GPU(SM86)。这是一个可读、可改、可验证的专用运行时,不是通用在线服务框架。
上图给出本文的唯一主线:先建立模型数学,再沿真实数据流定位瓶颈,最后让 kernel、缓存和 调度共同消除瓶颈。 如果跳过前两步,写出的 CUDA 很可能更复杂却更慢。
0. 先统一符号、shape 和计量单位
后文反复使用这些符号:
| 符号 | 含义 | 本项目数值 |
|---|---|---|
| batch size,同时处理的请求数 | 1 | |
| prefill 的 prompt token 数 | 128 到 8192 | |
| 当前 decode context 长度 | 随生成增长 | |
| decoder layer 数 | 28 | |
| hidden size | 1024 | |
| MLP intermediate size | 3072 | |
| query head 数 | 16 | |
| key/value head 数 | 8 | |
| 每个 attention head 的维度 | 128 | |
| vocabulary size | 151936 | |
| KV cache 每页 token 数 | 16 |
shape 写成 时,表示有 个元素。BF16 每个元素 2 byte,因此 它的存储量为
矩阵乘统一写成
其中 、、 。逐元素展开是
表示输入行, 表示输出通道, 是被归约的输入通道。这个展开式非常重要: 它同时决定 GEMM 的数学、线程如何切分 、权重怎样连续读取,以及 W8A16 在哪里乘 scale。
0.1 FP32、BF16 和 INT8 各自负责什么
本项目的主路径是 BF16 输入/输出,归约中需要更大动态范围的位置使用 FP32:
- RMSNorm 的平方和使用 FP32;
- attention score、最大值、指数和与输出累加使用 FP32;
- W8A16 的 INT8 权重先乘 FP32 scale,再累加到 FP32;
- 最终激活或 logits 再转换为 BF16。
INT8 不是“把所有 tensor 强制变成整数”。W8A16 表示权重 是 8 bit,激活 仍是 16 bit。量化只在通过质量门控的 LM head 启用。
1. Qwen3-0.6B 从 token 到 logits 的完整主干
Qwen3-0.6B 是 decoder-only Transformer。输入不是汉字,而是 tokenizer 产生的整数 token IDs:
Embedding 矩阵
执行查表,而不是矩阵乘。第 个样本、第 个位置的 hidden vector 为
之后 连续通过 28 个 decoder layer、final RMSNorm 和 tied LM head,得到
生成下一个 token 时只需要最后一个位置:
“tied LM head”表示输出权重与 embedding 权重共享:
它减少参数量,但不意味着 decode 时不读这块权重。每产生一个 token,仍要用一个 维向量与 行权重做点积。
1.1 参数量从 shape 逐项算出来
单个 layer 的主要矩阵是:
| 矩阵 | shape | 参数量 |
|---|---|---|
| Q projection | 2,097,152 | |
| K projection | 1,048,576 | |
| V projection | 1,048,576 | |
| O projection | 2,097,152 | |
| gate projection | 3,145,728 | |
| up projection | 3,145,728 | |
| down projection | 3,145,728 |
因为 ,Q 的宽度是 2048;因为 ,K/V 的宽度各是 1024。加上 norm 权重后,每层 15,730,944 个参数;28 层与 tied embedding 合计 596,049,920 个参数。
BF16 参数占用约
这个结果建立了第一个工程约束:6 GiB 显存可以容纳模型,但长 KV cache、CUDA Graph 静态 buffer、workspace 和框架开销仍需严格规划。
2. 逐步走完一个 Decoder Layer
设第 层输入为 。 一层包含 attention 子层和 MLP 子层,每个子层前有 RMSNorm,后面有 residual。
2.1 Input RMSNorm
对 hidden row ,RMSNorm 定义为
是可学习缩放, 防止分母为零。它没有减均值,所以不同于 LayerNorm。CUDA 实现需要先把 1024 个平方归约成一个 FP32 标量,再把标量广播给整行。
令
2.2 Packed QKV projection
数学上有三次线性层:
运行时把权重按输出维拼接:
一次线性层得到 。这样不是减少 FLOP,而是减少 GEMM 调度和权重描述开销, 并让后续 split 只是 view。
2.3 Q/K Head RMSNorm
Qwen3 对每个 Q/K head 的 128 维向量再次归一化。对 head 向量 :
是 head 内维度, 表示逐元素乘。K 同理,但 V 不做该归一化。
2.4 RoPE 将位置编码成二维旋转
对位置 和维度对 ,角频率为
相位为
把向量的两个半区配对,令 、:
K 做同样旋转。RoPE 不改变向量长度,但让 Q/K 点积包含相对位置信息。
2.5 GQA:16 个 Q head 共享 8 个 KV head
每个 KV head 服务
个 query head。query head 使用
号 KV head。例如 Q head 0、1 都读 KV head 0;Q head 2、3 都读 KV head 1。 这个映射既减少 KV cache 容量,也给 CUDA kernel 提供同一 K/V 被两个 Q head 复用的机会。
2.6 Scaled dot-product attention
对 batch 、query head 、query 位置 和历史位置 :
是 score。causal mask 要求 。稳定 softmax 为
head 输出为
拼接 16 个 query head 后宽度为 2048,再经过 O projection:
第一条 residual:
2.7 SwiGLU MLP
先做 post-attention RMSNorm:
gate 与 up projection 可以打包为一次 GEMM:
其中 。SiLU 定义为
SwiGLU 输出为
down projection 和第二条 residual 得到下一层输入:
到这里,模型数学已经闭环。下面才讨论为什么运行时不能按这些公式逐个调用通用算子。
3. Prefill 与 Decode 是两种不同 workload
3.1 Prefill
prefill 一次处理 个 prompt token。线性层中的
通常较大,因此是有充分行复用的 GEMM。同一权重 tile 被许多输入行复用,Tensor Core 更容易 发挥吞吐。
attention 需要处理整个 causal 三角区域,score 数量近似 。若显式存储完整 score, 显存和带宽会按 增长,所以 production prefill 后端使用 fused scaled dot-product attention。
3.2 Decode
decode 每步只输入一个新 token:
线性层退化为 GEMV。权重几乎没有跨输入行复用,每步都要重新流过显存。attention 只计算一个 query,却必须读取长度 的全部历史 K/V,所以成本随 context 线性增长。
3.3 为什么必须保存 KV Cache
如果不缓存,第 步会重新为前 个 token 计算 K/V;生成 步的重复工作近似 。缓存后,每层只追加当前 token 的 K/V,历史部分直接读取。
单层 BF16 KV cache 字节数为
第一个 2 表示 K 和 V,最后一个 2 表示 BF16 每元素 2 byte。代入 、 、:
28 层合计
因此 时约 896 MiB。decode 每步又要读取这些历史 K/V,这解释了为什么 prompt 越长, attention 占完整请求的比例越高。
4. 先画性能上界,再写 CUDA
Roofline 用算术强度
判断 kernel 更可能受计算吞吐还是显存带宽限制。可达性能上界为
是计算峰值, 是显存带宽。
4.1 Decode GEMV 的权重墙
对 的 BF16 线性层,每个权重参与一次乘加,产生约 2 FLOP,同时至少读取 2 byte:
这是低算术强度 workload。单纯改成 Tensor Core 指令不会消除权重读取下界。
28 层主要矩阵的 BF16 权重流量约 840 MiB/token;tied LM head 另有 296.75 MiB/token。 短 context 时 LM head 是显著的固定带宽成本,长 context 时 KV 读取逐渐追上并超过它。
4.2 Naive 实现的五类浪费
- RMSNorm、RoPE、cache append 分开,反复读写 Q/K。
- 两个 Q head 共享同一 KV head,但 kernel 仍重复读取 K/V。
- 短 context 分得太碎,长 context 又没有足够 CTA。
- 每层、每 token 重复 Python、dispatcher 和 kernel launch。
- 把 的带宽瓶颈误判为“Tensor Core 用得不够”。
项目的优化顺序据此确定:先消除冗余数据移动与 launch,再做 shape dispatch,最后只在确有收益且 通过质量门控的位置量化。
5. 软件架构:模型、算子、调度与存储解耦
仓库按单向依赖分层:
| 层 | 职责 |
|---|---|
qwen_cuda_runtime/model | 表达 Qwen 数学和权重装载 |
qwen_cuda_runtime/runtime | KV cache、graph runner、生成状态 |
qwen_cuda_runtime/ops | torch.library schema、fake implementation、Python 包装 |
csrc/bindings | 参数检查、当前 CUDA stream、PyTorch 注册 |
csrc/kernels | 纯 CUDA 数据通路 |
tests | 逐算子、集成、质量和回归测试 |
benchmark | microbenchmark 与完整请求 benchmark |
这一结构借鉴 CUB 的分层思想:高层描述“做什么”,低层封装“怎样在某个 shape 上做”。一个 attention kernel 可以替换,而模型权重格式和生成 API 不必跟着重写。
5.1 PyTorch dispatcher 的完整调用链
以 RMSNorm 为例:
def rms_norm(x, weight, eps): """调用已注册 CUDA 算子并保持模型层不依赖扩展细节。""" return torch.ops.qwen_cuda.rms_norm(x, weight, eps)schema 描述接口;FakeTensor implementation 只推导输出 metadata;C++ wrapper 检查 device、 dtype、shape、contiguous 和同设备约束,再把当前 PyTorch stream 传给 kernel。kernel 不能偷偷 使用 default stream,否则 eager 可能偶尔正确,CUDA Graph capture 或异步流水会出现竞态。
5.2 为什么不在 hot path 分配显存
KV pages、attention scratch、token history、context length 和 graph 输入输出都在初始化时分配。
decode 循环只覆盖静态 buffer,不反复 cudaMalloc。PyTorch caching allocator 充当显存池,
CUDA Graph 又要求 replay 时地址不变,这两点在此处是一致的设计要求。
6. 基础融合:RMSNorm、Residual 与 SwiGLU
RMSNorm 的平方和需要跨 1024 个元素归约。一个 block 处理一行,每个 thread 读取若干 BF16, 转 FP32 累加,再用 warp shuffle 和少量 shared memory 合并:
/** * 对一个 hidden row 求 FP32 平方和,并返回 warp 内总和。 * 每个 lane 先处理 stride=32 的元素,再通过 shuffle 合并。 */__device__ float warp_sum(float value) { #pragma unroll for (int offset = 16; offset > 0; offset >>= 1) { value += __shfl_down_sync(0xffffffff, value, offset); } return value;}如果上一子层刚产生 residual addition,可以把
与下一次 RMSNorm 放在同一个 kernel:第一次读取时计算 并累加 ,第二次从寄存器 或重读后的连续地址写 norm 输出。收益是少一次大 tensor 中间写回和一次 launch;代价是 kernel 接口更专用,且浮点运算顺序必须用 reference 测试约束。
Packed SwiGLU 同理。gate/up 已在相邻地址中,kernel 对同一 index 读取 ,写
down projection 不融合,因为它是大矩阵乘;强行并入 elementwise kernel 会失去 cuBLAS 对 GEMM 的成熟调度。
7. 深度融合:Q/K Norm、RoPE 与 Cache Append
融合前,一份 Q/K 至少经历 Norm 读写、RoPE 读写;K 还要再次读取后写 cache。对 batch-one decode,这些小 kernel 的数学量不大,launch 与全局内存往返不可忽略。
7.1 为什么一个 warp 正好处理一个 head
,一个 warp 有 32 lane。让 lane 负责
于是每 lane 连续处理 4 个值,全 warp 恰好覆盖 128 维。每 lane 先累加 4 个平方,warp shuffle 合并成 norm;随后所有 lane 使用同一个 inverse RMS。
RoPE 的 half-rotation 配对是 。lane 同时持有 、,以及 、,不必通过 shared memory 交换。这个映射不是“warp 越多越快”,而是由 与 RoPE 配对关系共同推导出来。
7.2 逻辑 token 到物理 cache 地址
令当前位置为 :
是逻辑页, 是页内位置。block table 给出物理页:
融合 kernel 在得到 rotated K 后直接写物理页 ,避免中间 tensor 再走一次 cache append kernel。Q 只返回给当前 attention,K 同时返回给 prefill attention 并持久化到 cache。
8. Paged KV Cache:逻辑连续,物理分页
连续 KV cache 为每个请求预留最大长度会浪费空间;增长时重新分配又会复制历史数据并破坏延迟。 PagedAttention 借鉴虚拟内存:模型看到连续 token 位置,物理存储由固定大小 page 组成。
项目 K layout 为
V layout 为
是物理 page 数; 是向量化宽度。K 多出的 维让相邻 lane 读取同一 token 的多个 dimension 时能形成 packed、合并访存。K 的线性地址是
是 KV head, 是 head 内维度。V 的地址更直接:
reset 不清空全部 K/V,只把 context length、block table 有效范围和 allocator 状态复位。
旧字节不会被读取,因为有效长度定义了边界。这比每次 cudaMemset 数百 MiB 更合理。
当前项目为 batch-one 预建 page table;它展示了地址翻译、追加和读取的核心机制,但没有实现 vLLM 的多请求动态 page allocator、prefix sharing、抢占和连续批处理。这是必须写清的边界。
9. Online Softmax:不保存完整 score
朴素 attention 先写全部 ,再读回求 softmax,最后再次读概率和 V。对长 context,这个 中间数组既占显存又增加带宽。
online softmax 只维护三个状态:
加入新 score 时,新最大值为
旧状态基于 ,必须重标定到新基准 :
于是
最终输出是
9.1 为什么分区结果仍能精确合并
长 context 被切成 partition。第 个 partition 输出 。全局最大值
把每个局部状态重标定到 :
最终结果 。这不是近似,也不是省略 softmax;它只是利用指数平移不改变归一化概率的性质, 把一次长归约变成可并行的两阶段归约。
对应 CUDA 主循环保留 FP32 状态:
/** * 将一个新 score/value 合并进 online-softmax 状态。 * max_value、denom 和 out_accum 始终以同一个指数基准表示。 */__device__ void update_online_state( float score, const float* value, int head_dim, float& max_value, float& denom, float* out_accum) { const float next_max = fmaxf(max_value, score); const float old_scale = expf(max_value - next_max); const float new_scale = expf(score - next_max);
denom = denom * old_scale + new_scale; for (int r = 0; r < head_dim; ++r) { out_accum[r] = out_accum[r] * old_scale + value[r] * new_scale; } max_value = next_max;}真实 kernel 会让 lane 分担 的分量并用 warp primitive 归约 score;这里的标量版本用于 展示数学与代码状态的一一对应。
10. Native PagedAttention:短、长 context 双路径
一个固定 launch 配置无法同时适合 和 :
- 短 context 分成很多 partition,会让 scratch 和第二阶段 reduction 的固定开销占主导;
- 长 context 只用一个 CTA,会让 28 层串行扫描大量 K/V,GPU 并行度不足。
10.1 短路径:grouped single CTA
grid 映射为 ,一个 CTA 同时处理共享该 KV head 的两个 Q head。K/V 从 paged cache 读取一次,两个 query 分别维护 online-softmax 状态。
适用条件是 context 较短,单 CTA 可以在可接受时间内扫描完。收益是:
- 避免 partition scratch;
- 避免第二次 reduction launch;
- GQA 的 K/V 复用发生在同一 CTA;
- 固定 launch 数更少。
代价是 context 增长后单 CTA 工作量过大,SM 间并行不足。
10.2 长路径:64-token partition 加 reduction
第一阶段 grid 映射为 。每个 warp 处理 64 个历史 token, 输出 到预分配 FP32 scratch。第二阶段按前节公式合并 partition。
收益是把 context 维切到多个 CTA 并行;代价是 scratch 写入、额外 launch 和 reduction。 因此 dispatch threshold 必须 benchmark,而不能凭感觉固定。
10.3 Native 与 production 后端的诚实边界
仓库的 native attention 是完整、可运行、可测试的本项目 CUDA 实现,适合学习、调试和继续优化。
production attention 明确复用 vLLM 0.23.0 已注册的 paged_attention_v1。端到端“与
vLLM 持平”的结论使用 production 后端,不能把它写成 native kernel 单独达到同样水平。
这是工程上的正确取舍:底层学习实现负责暴露机制,production 路径负责给出可靠基线。
11. CUDA Graph:优化每 token 的 CPU 调度
即使每个 kernel 已经很快,28 层乘以多算子仍会产生大量 Python、dispatcher 和 launch 开销。 decode shape 固定为 ,适合 capture 一次、replay 多次。
项目分别捕获 native short 和 native long graph。为什么是两个而不是在 graph 内动态分支? 因为 host 在 capture 时不能同步读取 GPU 上的 context length 决定路径;把最坏路径全部捕获又会 让短 context 支付不必要的 scratch/reduction。
graph replay 要求静态地址,因此:
- input token 写入固定
token_buffer; - position 和 context length 保留在 GPU;
- KV pages、block table、scratch 都预分配;
- token history 写入固定地址;
- argmax 输出直接成为下一轮 graph 输入。
生成循环的核心状态转移可写成:
def decode_step(graph, token_buffer, next_token): """覆盖静态输入后 replay;不在热路径创建新 CUDA tensor。""" token_buffer.copy_(next_token) graph.replay() return graph.output_token注意 prefill 最后位置的 logits 已经生成第一个输出 token。若 decode 循环再用 prompt 最后 token 跑一次,就会重复计算并产生 off-by-one。测试必须检查 token 序列,而不只是检查程序没有崩溃。
12. GEMM 与 Tensor Core:分层 tile 为什么有效
对于 ,naive kernel 每个 thread 计算一个 ,在 维循环中反复从 global memory 读取 A/B。相邻输出会重复读取相同元素。
工业 GEMM 把数据复用分成层次:
- CTA tile:global memory 到 shared memory;
- warp tile:shared memory 到寄存器;
- MMA tile:warp 用 Tensor Core 累加;
- epilogue:缩放、bias、激活和向量化写回。
12.1 K 维双缓冲
把 分成多个 。stage 0 计算时,stage 1 预取下一块:
双缓冲隐藏 global-to-shared 延迟,但 shared memory 翻倍,寄存器地址状态增加,可能降低 occupancy。只有计算足够长时,隐藏延迟的收益才覆盖资源代价。
12.2 对齐、向量化和 bank conflict
packed load 要求地址满足对应对齐,例如 16-byte load 的起始地址应 16-byte 对齐;尾部不满足 tile 的 shape 必须 mask 或回退。shared memory 若让一个 warp 的多个 lane 命中同一 bank 的 不同地址,会串行化;layout 常通过 padding 或 swizzle 改变 bank 映射。
occupancy 也不是越高越好。较大 tile 可提高复用,却消耗更多寄存器/shared memory,减少驻留 CTA。正确选择来自 shape-specific benchmark,而不是固定最大 block。
12.3 精度路径的取舍
| 输入 | 常见累加 | 适用与代价 |
|---|---|---|
| FP8 | FP16/FP32 | 更小流量,需硬件、scale 与严格质量门控 |
| INT8 | INT32 或反量化 FP32 | 权重压缩明显,量化元数据和离群值影响误差 |
| FP16 | FP32 | Tensor Core 常用,范围小于 BF16 |
| BF16 | FP32 | 大模型常用,指数范围接近 FP32 |
| TF32 | FP32 | FP32 API 的 Tensor Core 折中,不等于完整 FP32 乘法精度 |
| FP32 | FP32 | 更高精度与流量,吞吐低于低精度 Tensor Core |
| FP64 | FP64 | 面向科学计算,消费级 GPU 吞吐代价很高 |
12.4 为什么自写 WMMA 被删除
本项目曾实现 BF16 WMMA 与 fused SwiGLU。实测 0.11264 ms,而 cuBLAS 加单独 SwiGLU 是 0.07885 ms,只达到 0.70x。原因包括 tile 边界、epilogue、occupancy 和 cuBLAS 成熟调度优势。
“使用 Tensor Core”只是实现事实,不是性能证明。该路径没有达到门槛,因此从正式代码删除,只在 实验记录中保留结论。
13. LM-head W8A16:真正减少固定权重流量
LM head 权重
的 BF16 大小约 296.75 MiB。按 128 个值一组做 symmetric per-row group quantization:
是输出词表行, 是该行第几个 128-value group。反量化点积为
量化后权重加 scale 约 153.01 MiB,接近减半。注意 tied embedding 仍保留 BF16,因为 embedding lookup 与 LM-head GEMV 的访问模式不同,不能为省一份权重而破坏输入查表路径。
13.1 CUDA lane 映射
一个 warp 负责一个输出词表行,一个 block 8 warp。每个 128-value group 中,32 lane 各加载 4 个 packed INT8 和 4 个 BF16:
lane 局部完成 4 次乘加,warp shuffle 合并,lane 0 乘该 group scale 并累加 FP32 total。
/** * 计算一个 128-value group 的局部 W8A16 点积。 * packed_weight 每个 int32 存 4 个 int8;packed_input 存 4 个 bf16。 */float partial = 0.0F;#pragma unrollfor (int item = 0; item < 4; ++item) { partial = fmaf( static_cast<float>(weight_values[item]), __bfloat162float(input_values[item]), partial);}partial = warp_sum(partial);if (lane == 0) { total = fmaf(partial, group_scales[row * groups + group], total);}这个 kernel 使用普通 CUDA core 做反量化与 FP32 FMA,不宣称是 INT8 Tensor Core GEMM。 时它的核心收益来自权重字节减少,而不是更高理论 TOPS。
13.2 为什么只量化 LM head
全层 W8A16 实验的 teacher-forced top-1 agreement 只有 0.9625,未通过质量门槛;LM-head-only 模式达到 0.99375,因此正式路径只保留后者。量化范围是由质量证据决定的,不是由“8 bit 更快” 决定的。
14. 测试必须从函数正确性走到完整请求
验证分四层:
- 算子正确性:与 PyTorch reference 比较误差;
- 模型正确性:固定 prompt 比较 token IDs;
- 质量门控:teacher-forced logits 与 top-1 agreement;
- 性能:microbenchmark 与同 workload 端到端 benchmark。
14.1 环境与方法
| 项目 | 配置 |
|---|---|
| GPU | NVIDIA RTX 3060 Laptop GPU,SM86,6 GiB |
| PyTorch | 2.11.0+cu130 |
| CUDA runtime | 13.0 |
| NVCC | 13.2 |
| 模型 | Qwen3-0.6B,本地 BF16 checkpoint |
| batch | 1 |
| sampling | greedy,ignore EOS |
| output | 128 token |
| prompt | 128、1024、4096、8192 token |
| 统计 | 每个 shape 预热后,同步运行取中位数 |
benchmark 前检查 nvidia-smi,确认没有其他计算进程占用 GPU。计时覆盖同一离线请求的
prefill 与 128-token decode,不包含模型加载、tokenize 和 detokenize。
完整请求输出吞吐定义为
它不是多请求服务的 aggregate throughput,也不是只算 decode loop 的 token/s。
14.2 端到端结果
| Prompt | 本项目 BF16 | LM-head W8A16 | vLLM 0.23 BF16 |
|---|---|---|---|
| 128 | 191.85 token/s | 211.75 token/s | 160.77 token/s |
| 1024 | 159.93 token/s | 173.22 token/s | 143.58 token/s |
| 4096 | 96.32 token/s | 100.64 token/s | 101.50 token/s |
| 8192 | 59.08 token/s | 60.64 token/s | 66.10 token/s |
必须定义三个不同的比值:
| Prompt | BF16/vLLM | W8A16/BF16 | W8A16/vLLM |
|---|---|---|---|
| 128 | 1.193x | 1.104x | 1.317x |
| 1024 | 1.114x | 1.083x | 1.206x |
| 4096 | 0.949x | 1.045x | 0.992x |
| 8192 | 0.894x | 1.026x | 0.917x |
这里 W8A16/BF16 才是启用量化本身带来的实测增量:短 prompt 10.4%,8K prompt 2.6%。 W8A16/vLLM 同时包含本项目其他路径与外部 baseline 的差异,不能把 1.317x 写成 W8 kernel 单独加速 31.7%。
14.3 为什么 context 越长,W8 收益越小
LM head 是固定成本;attention KV 读取随 增长。短 prompt 时压缩 296.75 MiB LM head 占比高,端到端收益明显。长 prompt 时每步接近 896 MiB KV 读取,LM head 占比下降,所以 W8A16 microbenchmark 的约 1.95x 不会等比例变成完整请求加速。
这也是为什么必须同时报告 microbenchmark 和端到端数据。
14.4 算子与质量结果
W8A16 microbenchmark:
| Projection | BF16 | W8A16 | Speedup | Mean abs error |
|---|---|---|---|---|
| QKV | 0.05632 ms | 0.03443 ms | 1.636x | 0.005398 |
| O | 0.06246 ms | 0.03789 ms | 1.649x | 0.005392 |
| gate/up | 0.07322 ms | 0.03174 ms | 2.307x | 0.005011 |
| down | 0.06912 ms | 0.04045 ms | 1.709x | 0.005124 |
| LM head | 0.99072 ms | 0.50688 ms | 1.954x | 0.005298 |
LM-head-only 质量门控使用 5 个 prompt、每个 32 个 teacher-forced token:
| 指标 | 结果 |
|---|---|
| first-token top-1 agreement | 1.00000 |
| teacher-forced top-1 agreement | 0.99375 |
| first-token mean absolute logit error | 0.016998 |
| first-token max absolute logit error | 0.127930 |
这些指标说明该测试集上 token 决策高度一致,但不等价于“所有任务绝对无损”。改变模型、量化范围、 group size 或采样方式后必须重做质量验证。
14.5 Native attention 的优化链
native 后端 decode token/s 从初版到 dual graph:
| Prompt | 初版 | 双 Graph 后 |
|---|---|---|
| 128 | 112.61 | 122.36 |
| 1024 | 100.22 | 100.90 |
| 4096 | 72.01 | 72.37 |
| 8192 | 57.15 | 56.29 |
短 prompt 因 launch 占比高而受益;8K 出现轻微回退,说明 graph 并不自动让所有 shape 更快。 生产路径仍使用 vLLM 注册 attention,native 数据不能替代生产端到端表。
14.6 为什么旧表和新表不能直接相除
旧 baseline 的最大 prompt 是 1024,运行时按 max_model_len=1168 配置;新表测试到
8192,使用 max_model_len=8336。静态 cache、graph buffer、attention dispatch 和
工作集都变化了。因此跨两轮结果的差异不能全部归因于某一个新 kernel。
正确做法是同一 commit、同一配置、同一计时脚本做 A/B;本轮 W8A16/BF16 比值就是这样的 同配置对照。
15. 如何复现
构建扩展:
cmake -S . -B build \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_PREFIX_PATH="$(python -c 'import torch; print(torch.utils.cmake_prefix_path)')"cmake --build build -j运行测试:
python -m pytest tests -q运行完整请求 benchmark:
python benchmark/benchmark_end_to_end.py \ --model /path/to/Qwen3-0.6B \ --prompt-lengths 128 1024 4096 8192 \ --output-length 128不要只截取最快一轮。应固定 GPU 电源与温度条件,先预热,使用 CUDA event 或明确同步边界,保存 原始 JSON,并记录 commit、模型、dtype、backend、最大 context 和依赖版本。
16. 这套实现解决了什么,还没有解决什么
已经解决:
- 用可替换分层把模型数学、CUDA 算子、KV 存储和生成调度解耦;
- 针对 Qwen3-0.6B 的 QK Norm/RoPE/GQA 语义设计专用数据通路;
- 实现可读的 Paged KV Cache 与 native short/long PagedAttention;
- 用 online softmax 避免 score 中间数组;
- 用静态显存池和 CUDA Graph 降低 decode 分配/launch 开销;
- 用质量门控筛掉全层 W8A16,只保留有效的 LM-head W8A16;
- 用完整请求、microbenchmark、token/quality 四层证据约束性能结论。
没有解决:
- 多请求 continuous batching 与动态 page allocator;
- prefix cache、抢占、请求调度和分布式 tensor parallel;
- native attention 全 shape 超过生产 vLLM;
- FP8/INT8 Tensor Core GEMM 的通用高性能实现;
- 对其他 Qwen3 尺寸、其他 GPU 和 sampling 策略的自动调优。
这些边界并不削弱项目深度。相反,能从公式推导瓶颈、选择专用优化、删除失败路径、解释 benchmark 边界,才是 CUDA 工程从“能运行”走到“结论可信”的关键。
参考资料
- Qwen3 Technical Report
- Hugging Face Qwen3 model documentation
- Attention Is All You Need
- RoFormer: Enhanced Transformer with Rotary Position Embedding
- FlashAttention
- FlashAttention-2
- PagedAttention / vLLM paper
- NVIDIA CUDA C++ Programming Guide
- NVIDIA Ampere GPU Architecture Tuning Guide
- PyTorch Custom C++ and CUDA Operators
- OneFlow Softmax CUDA 优化
- dlog:CUDA 高性能计算库的分层架构
- 项目源码与完整实验记录
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!













