从 Qwen3 数学到 CUDA Kernel:0.6B 推理运行时的完整实现与优化

6425 字
32 分钟
从 Qwen3 数学到 CUDA Kernel:0.6B 推理运行时的完整实现与优化

这是一篇可以从头顺读的项目教程,不是三篇彼此跳转的优化记录。我们从“一个 token ID 如何变成下一个 token”开始,给每个 tensor 标 shape,给每个公式解释符号,再逐步回答:

  1. Qwen3-0.6B 的一层到底算了什么?
  2. prefill 和 decode 为什么是两个不同的性能问题?
  3. 为什么 Q/K Norm、RoPE、KV 写入应该融合?
  4. Paged KV Cache 的逻辑位置怎样翻译为显存地址?
  5. online softmax 为什么不需要保存完整 attention score?
  6. 短 context 和长 context 为什么必须分派不同 CUDA kernel?
  7. CUDA Graph、Tensor Core 和 W8A16 分别解决什么,不能解决什么?
  8. “比 vLLM 快”与“W8A16 带来的加速”为什么是两个不同的比值?

文章对应的完整代码、测试和 83 页 XeLaTeX 学习文档都在 kimikimiki/qwen-cuda-runtime

项目边界:Qwen3-0.6B、batch size 1、greedy decode、BF16 主路径、NVIDIA RTX 3060 Laptop GPU(SM86)。这是一个可读、可改、可验证的专用运行时,不是通用在线服务框架。

从 token ID 到 CUDA kernel 的全局阅读地图
从 token ID 到 CUDA kernel 的全局阅读地图

上图给出本文的唯一主线:先建立模型数学,再沿真实数据流定位瓶颈,最后让 kernel、缓存和 调度共同消除瓶颈。 如果跳过前两步,写出的 CUDA 很可能更复杂却更慢。

0. 先统一符号、shape 和计量单位#

后文反复使用这些符号:

符号含义本项目数值
BBbatch size,同时处理的请求数1
SSprefill 的 prompt token 数128 到 8192
TT当前 decode context 长度随生成增长
LLdecoder layer 数28
ddhidden size1024
dffd_{ff}MLP intermediate size3072
HqH_qquery head 数16
HkvH_{kv}key/value head 数8
DD每个 attention head 的维度128
VVvocabulary size151936
PPKV cache 每页 token 数16

shape 写成 [B,S,d][B,S,d] 时,表示有 B×S×dB\times S\times d 个元素。BF16 每个元素 2 byte,因此 它的存储量为

bytes([B,S,d],BF16)=2BSd.\operatorname{bytes}([B,S,d],\mathrm{BF16})=2BSd.

矩阵乘统一写成

Y=XWT,Y=XW^{\mathsf T},

其中 XRM×KX\in\mathbb{R}^{M\times K}WRN×KW\in\mathbb{R}^{N\times K}YRM×NY\in\mathbb{R}^{M\times N}。逐元素展开是

ym,n=k=0K1xm,kwn,k.y_{m,n}=\sum_{k=0}^{K-1}x_{m,k}w_{n,k}.

mm 表示输入行,nn 表示输出通道,kk 是被归约的输入通道。这个展开式非常重要: 它同时决定 GEMM 的数学、线程如何切分 KK、权重怎样连续读取,以及 W8A16 在哪里乘 scale。

0.1 FP32、BF16 和 INT8 各自负责什么#

本项目的主路径是 BF16 输入/输出,归约中需要更大动态范围的位置使用 FP32:

  • RMSNorm 的平方和使用 FP32;
  • attention score、最大值、指数和与输出累加使用 FP32;
  • W8A16 的 INT8 权重先乘 FP32 scale,再累加到 FP32;
  • 最终激活或 logits 再转换为 BF16。

INT8 不是“把所有 tensor 强制变成整数”。W8A16 表示权重 WW 是 8 bit,激活 XX 仍是 16 bit。量化只在通过质量门控的 LM head 启用。

1. Qwen3-0.6B 从 token 到 logits 的完整主干#

Qwen3-0.6B 是 decoder-only Transformer。输入不是汉字,而是 tokenizer 产生的整数 token IDs:

idsZB×S.\mathrm{ids}\in\mathbb{Z}^{B\times S}.

Embedding 矩阵

ERV×d=R151936×1024E\in\mathbb{R}^{V\times d} =\mathbb{R}^{151936\times1024}

执行查表,而不是矩阵乘。第 bb 个样本、第 ss 个位置的 hidden vector 为

Xb,s,:=Eidsb,s,:.X_{b,s,:}=E_{\mathrm{ids}_{b,s},:}.

之后 XRB×S×1024X\in\mathbb{R}^{B\times S\times1024} 连续通过 28 个 decoder layer、final RMSNorm 和 tied LM head,得到

logitsRB×S×151936.\mathrm{logits}\in\mathbb{R}^{B\times S\times151936}.

生成下一个 token 时只需要最后一个位置:

tnext=argmax0v<VlogitsB1,S1,v.t_{\mathrm{next}} =\arg\max_{0\le v<V}\mathrm{logits}_{B-1,S-1,v}.

Qwen3-0.6B 的宏观结构与 shape
Qwen3-0.6B 的宏观结构与 shape

“tied LM head”表示输出权重与 embedding 权重共享:

Wlm=E.W_{lm}=E.

它减少参数量,但不意味着 decode 时不读这块权重。每产生一个 token,仍要用一个 10241024 维向量与 151936151936 行权重做点积。

1.1 参数量从 shape 逐项算出来#

单个 layer 的主要矩阵是:

矩阵shape参数量
Q projection[2048,1024][2048,1024]2,097,152
K projection[1024,1024][1024,1024]1,048,576
V projection[1024,1024][1024,1024]1,048,576
O projection[1024,2048][1024,2048]2,097,152
gate projection[3072,1024][3072,1024]3,145,728
up projection[3072,1024][3072,1024]3,145,728
down projection[1024,3072][1024,3072]3,145,728

因为 HqD=16×128=2048H_qD=16\times128=2048,Q 的宽度是 2048;因为 HkvD=8×128=1024H_{kv}D=8\times128=1024,K/V 的宽度各是 1024。加上 norm 权重后,每层 15,730,944 个参数;28 层与 tied embedding 合计 596,049,920 个参数。

BF16 参数占用约

596049920×2220=1136.875 MiB.\frac{596049920\times2}{2^{20}}=1136.875\ \mathrm{MiB}.

这个结果建立了第一个工程约束:6 GiB 显存可以容纳模型,但长 KV cache、CUDA Graph 静态 buffer、workspace 和框架开销仍需严格规划。

2. 逐步走完一个 Decoder Layer#

设第 \ell 层输入为 X()RB×S×dX^{(\ell)}\in\mathbb{R}^{B\times S\times d}。 一层包含 attention 子层和 MLP 子层,每个子层前有 RMSNorm,后面有 residual。

单个 Qwen3 decoder layer 的完整数据流
单个 Qwen3 decoder layer 的完整数据流

2.1 Input RMSNorm#

对 hidden row x=(x0,,xd1)x=(x_0,\ldots,x_{d-1}),RMSNorm 定义为

rms(x)=1di=0d1xi2+ϵ,\operatorname{rms}(x) =\sqrt{\frac{1}{d}\sum_{i=0}^{d-1}x_i^2+\epsilon},RMSNorm(x)i=xirms(x)γi.\operatorname{RMSNorm}(x)_i =\frac{x_i}{\operatorname{rms}(x)}\gamma_i.

γi\gamma_i 是可学习缩放,ϵ=106\epsilon=10^{-6} 防止分母为零。它没有减均值,所以不同于 LayerNorm。CUDA 实现需要先把 1024 个平方归约成一个 FP32 标量,再把标量广播给整行。

X^=RMSNorm(X()).\hat X=\operatorname{RMSNorm}(X^{(\ell)}).

2.2 Packed QKV projection#

数学上有三次线性层:

Q=X^WQT,K=X^WKT,V=X^WVT.Q=\hat XW_Q^{\mathsf T},\quad K=\hat XW_K^{\mathsf T},\quad V=\hat XW_V^{\mathsf T}.

运行时把权重按输出维拼接:

WQKV=[WQWKWV]R4096×1024,W_{QKV} = \begin{bmatrix} W_Q\\W_K\\W_V \end{bmatrix} \in\mathbb{R}^{4096\times1024},

一次线性层得到 [QKV][Q\mid K\mid V]。这样不是减少 FLOP,而是减少 GEMM 调度和权重描述开销, 并让后续 split 只是 view。

2.3 Q/K Head RMSNorm#

Qwen3 对每个 Q/K head 的 128 维向量再次归一化。对 head 向量 qhq_h

qˉh=qhD1r=0D1qh,r2+ϵγQ.\bar q_h = \frac{q_h} {\sqrt{D^{-1}\sum_{r=0}^{D-1}q_{h,r}^2+\epsilon}} \odot\gamma_Q.

rr 是 head 内维度,\odot 表示逐元素乘。K 同理,但 V 不做该归一化。

2.4 RoPE 将位置编码成二维旋转#

对位置 pp 和维度对 ii,角频率为

ωi=θ2i/D,θ=106,\omega_i=\theta^{-2i/D},\qquad \theta=10^6,

相位为

ϕp,i=pωi.\phi_{p,i}=p\omega_i.

把向量的两个半区配对,令 a=qia=q_ib=qi+D/2b=q_{i+D/2}

[qiqi+D/2]=[cosϕp,isinϕp,isinϕp,icosϕp,i][ab].\begin{bmatrix} q'_i\\q'_{i+D/2} \end{bmatrix} = \begin{bmatrix} \cos\phi_{p,i}&-\sin\phi_{p,i}\\ \sin\phi_{p,i}& \cos\phi_{p,i} \end{bmatrix} \begin{bmatrix}a\\b\end{bmatrix}.

K 做同样旋转。RoPE 不改变向量长度,但让 Q/K 点积包含相对位置信息。

2.5 GQA:16 个 Q head 共享 8 个 KV head#

每个 KV head 服务

G=HqHkv=168=2G=\frac{H_q}{H_{kv}}=\frac{16}{8}=2

个 query head。query head hh 使用

g(h)=hGg(h)=\left\lfloor\frac{h}{G}\right\rfloor

号 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 bb、query head hh、query 位置 tt 和历史位置 jj

sb,h,t,j=r=0D1Qb,h,t,rKb,g(h),j,rD.s_{b,h,t,j} = \frac{ \sum_{r=0}^{D-1} Q'_{b,h,t,r} K'_{b,g(h),j,r} }{\sqrt D}.

ss 是 score。causal mask 要求 jtj\le t。稳定 softmax 为

m=max0jtsj,m=\max_{0\le j\le t}s_j,pj=exp(sjm)k=0texp(skm).p_j=\frac{\exp(s_j-m)} {\sum_{k=0}^{t}\exp(s_k-m)}.

head 输出为

ob,h,t,r=j=0tpjVb,g(h),j,r.o_{b,h,t,r} =\sum_{j=0}^{t}p_jV_{b,g(h),j,r}.

拼接 16 个 query head 后宽度为 2048,再经过 O projection:

A=OWOTRB×S×1024.A=OW_O^{\mathsf T}\in\mathbb{R}^{B\times S\times1024}.

第一条 residual:

R=X()+A.R=X^{(\ell)}+A.

2.7 SwiGLU MLP#

先做 post-attention RMSNorm:

R^=RMSNorm(R).\hat R=\operatorname{RMSNorm}(R).

gate 与 up projection 可以打包为一次 GEMM:

[GU]=R^WGUT,[G\mid U]=\hat RW_{GU}^{\mathsf T},

其中 G,URB×S×3072G,U\in\mathbb{R}^{B\times S\times3072}。SiLU 定义为

SiLU(z)=zσ(z)=z1+exp(z).\operatorname{SiLU}(z)=z\sigma(z)=\frac{z}{1+\exp(-z)}.

SwiGLU 输出为

Z=SiLU(G)U.Z=\operatorname{SiLU}(G)\odot U.

down projection 和第二条 residual 得到下一层输入:

X(+1)=R+ZWDT.X^{(\ell+1)}=R+ZW_D^{\mathsf T}.

到这里,模型数学已经闭环。下面才讨论为什么运行时不能按这些公式逐个调用通用算子。

3. Prefill 与 Decode 是两种不同 workload#

Prefill、Decode 与 KV cache 的时间线
Prefill、Decode 与 KV cache 的时间线

3.1 Prefill#

prefill 一次处理 SS 个 prompt token。线性层中的

M=BSM=BS

通常较大,因此是有充分行复用的 GEMM。同一权重 tile 被许多输入行复用,Tensor Core 更容易 发挥吞吐。

attention 需要处理整个 causal 三角区域,score 数量近似 S2/2S^2/2。若显式存储完整 score, 显存和带宽会按 O(S2)O(S^2) 增长,所以 production prefill 后端使用 fused scaled dot-product attention。

3.2 Decode#

decode 每步只输入一个新 token:

M=B=1.M=B=1.

线性层退化为 GEMV。权重几乎没有跨输入行复用,每步都要重新流过显存。attention 只计算一个 query,却必须读取长度 TT 的全部历史 K/V,所以成本随 context 线性增长。

3.3 为什么必须保存 KV Cache#

如果不缓存,第 tt 步会重新为前 tt 个 token 计算 K/V;生成 NN 步的重复工作近似 O(N2)O(N^2)。缓存后,每层只追加当前 token 的 K/V,历史部分直接读取。

单层 BF16 KV cache 字节数为

byteslayer=2BTHkvD2.\operatorname{bytes}_{layer} =2\cdot B\cdot T\cdot H_{kv}\cdot D\cdot2.

第一个 2 表示 K 和 V,最后一个 2 表示 BF16 每元素 2 byte。代入 B=1B=1Hkv=8H_{kv}=8D=128D=128

byteslayer=4096T.\operatorname{bytes}_{layer}=4096T.

28 层合计

bytesall=114688T.\operatorname{bytes}_{all}=114688T.

因此 T=8192T=8192 时约 896 MiB。decode 每步又要读取这些历史 K/V,这解释了为什么 prompt 越长, attention 占完整请求的比例越高。

4. 先画性能上界,再写 CUDA#

Roofline 用算术强度

I=FLOPbytes transferredI=\frac{\text{FLOP}}{\text{bytes transferred}}

判断 kernel 更可能受计算吞吐还是显存带宽限制。可达性能上界为

Pmin(Ppeak,IBWmem).P\le\min(P_{\mathrm{peak}},I\cdot BW_{\mathrm{mem}}).

PpeakP_{\mathrm{peak}} 是计算峰值,BWmemBW_{\mathrm{mem}} 是显存带宽。

4.1 Decode GEMV 的权重墙#

M=1M=1 的 BF16 线性层,每个权重参与一次乘加,产生约 2 FLOP,同时至少读取 2 byte:

IGEMV1 FLOP/byte.I_{\mathrm{GEMV}}\approx1\ \mathrm{FLOP/byte}.

这是低算术强度 workload。单纯改成 Tensor Core 指令不会消除权重读取下界。

28 层主要矩阵的 BF16 权重流量约 840 MiB/token;tied LM head 另有 296.75 MiB/token。 短 context 时 LM head 是显著的固定带宽成本,长 context 时 KV 读取逐渐追上并超过它。

4.2 Naive 实现的五类浪费#

  1. RMSNorm、RoPE、cache append 分开,反复读写 Q/K。
  2. 两个 Q head 共享同一 KV head,但 kernel 仍重复读取 K/V。
  3. 短 context 分得太碎,长 context 又没有足够 CTA。
  4. 每层、每 token 重复 Python、dispatcher 和 kernel launch。
  5. M=1M=1 的带宽瓶颈误判为“Tensor Core 用得不够”。

项目的优化顺序据此确定:先消除冗余数据移动与 launch,再做 shape dispatch,最后只在确有收益且 通过质量门控的位置量化。

5. 软件架构:模型、算子、调度与存储解耦#

项目从 Python API 到 CUDA kernel 的分层
项目从 Python API 到 CUDA kernel 的分层

仓库按单向依赖分层:

职责
qwen_cuda_runtime/model表达 Qwen 数学和权重装载
qwen_cuda_runtime/runtimeKV cache、graph runner、生成状态
qwen_cuda_runtime/opstorch.library schema、fake implementation、Python 包装
csrc/bindings参数检查、当前 CUDA stream、PyTorch 注册
csrc/kernels纯 CUDA 数据通路
tests逐算子、集成、质量和回归测试
benchmarkmicrobenchmark 与完整请求 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,可以把

ri=xi+air_i=x_i+a_i

与下一次 RMSNorm 放在同一个 kernel:第一次读取时计算 rir_i 并累加 ri2r_i^2,第二次从寄存器 或重读后的连续地址写 norm 输出。收益是少一次大 tensor 中间写回和一次 launch;代价是 kernel 接口更专用,且浮点运算顺序必须用 reference 测试约束。

Packed SwiGLU 同理。gate/up 已在相邻地址中,kernel 对同一 index 读取 gi,uig_i,u_i,写

zi=gi1+exp(gi)ui.z_i=\frac{g_i}{1+\exp(-g_i)}u_i.

down projection 不融合,因为它是大矩阵乘;强行并入 elementwise kernel 会失去 cuBLAS 对 GEMM 的成熟调度。

7. 深度融合:Q/K Norm、RoPE 与 Cache Append#

融合前,一份 Q/K 至少经历 Norm 读写、RoPE 读写;K 还要再次读取后写 cache。对 batch-one decode,这些小 kernel 的数学量不大,launch 与全局内存往返不可忽略。

一个 warp 完成 head norm、RoPE 与 K cache append
一个 warp 完成 head norm、RoPE 与 K cache append

7.1 为什么一个 warp 正好处理一个 head#

D=128D=128,一个 warp 有 32 lane。让 lane ll 负责

{l,l+32,l+64,l+96}.\{l,l+32,l+64,l+96\}.

于是每 lane 连续处理 4 个值,全 warp 恰好覆盖 128 维。每 lane 先累加 4 个平方,warp shuffle 合并成 norm;随后所有 lane 使用同一个 inverse RMS。

RoPE 的 half-rotation 配对是 (r,r+64)(r,r+64)。lane ll 同时持有 lll+64l+64,以及 l+32l+32l+96l+96,不必通过 shared memory 交换。这个映射不是“warp 越多越快”,而是由 D=128D=128 与 RoPE 配对关系共同推导出来。

7.2 逻辑 token 到物理 cache 地址#

令当前位置为 pp

=pP,o=pmodP.\ell=\left\lfloor\frac{p}{P}\right\rfloor,\qquad o=p\bmod P.

\ell 是逻辑页,oo 是页内位置。block table 给出物理页:

q=block_table[b,].q=\operatorname{block\_table}[b,\ell].

融合 kernel 在得到 rotated K 后直接写物理页 qq,避免中间 tensor 再走一次 cache append kernel。Q 只返回给当前 attention,K 同时返回给 prefill attention 并持久化到 cache。

8. Paged KV Cache:逻辑连续,物理分页#

Paged KV 的逻辑页到物理页地址翻译
Paged KV 的逻辑页到物理页地址翻译

连续 KV cache 为每个请求预留最大长度会浪费空间;增长时重新分配又会复制历史数据并破坏延迟。 PagedAttention 借鉴虚拟内存:模型看到连续 token 位置,物理存储由固定大小 page 组成。

项目 K layout 为

[Nb,Hkv,D/x,P,x],[N_b,H_{kv},D/x,P,x],

V layout 为

[Nb,Hkv,D,P].[N_b,H_{kv},D,P].

NbN_b 是物理 page 数;xx 是向量化宽度。K 多出的 xx 维让相邻 lane 读取同一 token 的多个 dimension 时能形成 packed、合并访存。K 的线性地址是

offsetK=(((qHkv+j)Dx+rx)P+o)x+(rmodx).\operatorname{offset}_K = \left( \left( \left( qH_{kv}+j \right)\frac{D}{x} +\left\lfloor\frac{r}{x}\right\rfloor \right)P+o \right)x+(r\bmod x).

jj 是 KV head,rr 是 head 内维度。V 的地址更直接:

offsetV=((qHkv+j)D+r)P+o.\operatorname{offset}_V =\left((qH_{kv}+j)D+r\right)P+o.

reset 不清空全部 K/V,只把 context length、block table 有效范围和 allocator 状态复位。 旧字节不会被读取,因为有效长度定义了边界。这比每次 cudaMemset 数百 MiB 更合理。

当前项目为 batch-one 预建 page table;它展示了地址翻译、追加和读取的核心机制,但没有实现 vLLM 的多请求动态 page allocator、prefix sharing、抢占和连续批处理。这是必须写清的边界。

9. Online Softmax:不保存完整 score#

朴素 attention 先写全部 sjs_j,再读回求 softmax,最后再次读概率和 V。对长 context,这个 中间数组既占显存又增加带宽。

online softmax 只维护三个状态:

mn=max0j<nsj,m_n=\max_{0\le j<n}s_j,n=j=0n1esjmn,\ell_n=\sum_{j=0}^{n-1}e^{s_j-m_n},on=j=0n1esjmnvj.o_n=\sum_{j=0}^{n-1}e^{s_j-m_n}v_j.

加入新 score sns_n 时,新最大值为

mn+1=max(mn,sn).m_{n+1}=\max(m_n,s_n).

旧状态基于 mnm_n,必须重标定到新基准 mn+1m_{n+1}

α=emnmn+1,β=esnmn+1.\alpha=e^{m_n-m_{n+1}},\qquad \beta=e^{s_n-m_{n+1}}.

于是

n+1=αn+β,\ell_{n+1}=\alpha\ell_n+\beta,on+1=αon+βvn.o_{n+1}=\alpha o_n+\beta v_n.

最终输出是

Attention(q,K,V)=oTT.\operatorname{Attention}(q,K,V)=\frac{o_T}{\ell_T}.

Online softmax 的状态更新与分区合并
Online softmax 的状态更新与分区合并

9.1 为什么分区结果仍能精确合并#

长 context 被切成 partition。第 pp 个 partition 输出 (mp,p,op)(m_p,\ell_p,o_p)。全局最大值

M=maxpmp.M=\max_p m_p.

把每个局部状态重标定到 MM

L=ppempM,L=\sum_p\ell_pe^{m_p-M},O=popempM.O=\sum_po_pe^{m_p-M}.

最终结果 O/LO/L。这不是近似,也不是省略 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 分担 D=128D=128 的分量并用 warp primitive 归约 score;这里的标量版本用于 展示数学与代码状态的一一对应。

10. Native PagedAttention:短、长 context 双路径#

一个固定 launch 配置无法同时适合 T=128T=128T=8192T=8192

  • 短 context 分成很多 partition,会让 scratch 和第二阶段 reduction 的固定开销占主导;
  • 长 context 只用一个 CTA,会让 28 层串行扫描大量 K/V,GPU 并行度不足。

短上下文与长上下文 attention 的 shape dispatch
短上下文与长上下文 attention 的 shape dispatch

10.1 短路径:grouped single CTA#

grid 映射为 (b,kv head)(b,\text{kv head}),一个 CTA 同时处理共享该 KV head 的两个 Q head。K/V 从 paged cache 读取一次,两个 query 分别维护 online-softmax 状态。

适用条件是 context 较短,单 CTA 可以在可接受时间内扫描完。收益是:

  1. 避免 partition scratch;
  2. 避免第二次 reduction launch;
  3. GQA 的 K/V 复用发生在同一 CTA;
  4. 固定 launch 数更少。

代价是 context 增长后单 CTA 工作量过大,SM 间并行不足。

10.2 长路径:64-token partition 加 reduction#

第一阶段 grid 映射为 (b,kv head,partition)(b,\text{kv head},\text{partition})。每个 warp 处理 64 个历史 token, 输出 (mp,p,op)(m_p,\ell_p,o_p) 到预分配 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 固定为 [B,1,][B,1,\cdots],适合 capture 一次、replay 多次。

Prefill、首 token 与双 CUDA Graph decode 循环
Prefill、首 token 与双 CUDA Graph decode 循环

项目分别捕获 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 为什么有效#

对于 C=ABC=AB,naive kernel 每个 thread 计算一个 Cm,nC_{m,n},在 KK 维循环中反复从 global memory 读取 A/B。相邻输出会重复读取相同元素。

工业 GEMM 把数据复用分成层次:

  1. CTA tile:global memory 到 shared memory;
  2. warp tile:shared memory 到寄存器;
  3. MMA tile:warp 用 Tensor Core 累加;
  4. epilogue:缩放、bias、激活和向量化写回。

GEMM 分层 tile、K 维流水与 W8A16 的适用位置
GEMM 分层 tile、K 维流水与 W8A16 的适用位置

12.1 K 维双缓冲#

KK 分成多个 KtileK_{tile}。stage 0 计算时,stage 1 预取下一块:

load(k+1)mma(k).\text{load}(k+1)\parallel\text{mma}(k).

双缓冲隐藏 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 精度路径的取舍#

输入常见累加适用与代价
FP8FP16/FP32更小流量,需硬件、scale 与严格质量门控
INT8INT32 或反量化 FP32权重压缩明显,量化元数据和离群值影响误差
FP16FP32Tensor Core 常用,范围小于 BF16
BF16FP32大模型常用,指数范围接近 FP32
TF32FP32FP32 API 的 Tensor Core 折中,不等于完整 FP32 乘法精度
FP32FP32更高精度与流量,吞吐低于低精度 Tensor Core
FP64FP64面向科学计算,消费级 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 权重

WlmR151936×1024W_{lm}\in\mathbb{R}^{151936\times1024}

的 BF16 大小约 296.75 MiB。按 128 个值一组做 symmetric per-row group quantization:

sj,g=max0r<128wj,128g+r127,s_{j,g} = \frac{\max_{0\le r<128}|w_{j,128g+r}|}{127},qj,128g+r=clip(round(wj,128g+rsj,g),127,127).q_{j,128g+r} = \operatorname{clip} \left( \operatorname{round} \left(\frac{w_{j,128g+r}}{s_{j,g}}\right), -127,127 \right).

jj 是输出词表行,gg 是该行第几个 128-value group。反量化点积为

yjg=0K/1281sj,g(r=0127qj,128g+rx128g+r).y_j \approx \sum_{g=0}^{K/128-1} s_{j,g} \left( \sum_{r=0}^{127} q_{j,128g+r}x_{128g+r} \right).

量化后权重加 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:

vector=32g+lane.\operatorname{vector}=32g+\operatorname{lane}.

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 unroll
for (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。 M=1M=1 时它的核心收益来自权重字节减少,而不是更高理论 TOPS。

13.2 为什么只量化 LM head#

全层 W8A16 实验的 teacher-forced top-1 agreement 只有 0.9625,未通过质量门槛;LM-head-only 模式达到 0.99375,因此正式路径只保留后者。量化范围是由质量证据决定的,不是由“8 bit 更快” 决定的。

14. 测试必须从函数正确性走到完整请求#

验证分四层:

  1. 算子正确性:与 PyTorch reference 比较误差;
  2. 模型正确性:固定 prompt 比较 token IDs;
  3. 质量门控:teacher-forced logits 与 top-1 agreement;
  4. 性能:microbenchmark 与同 workload 端到端 benchmark。

14.1 环境与方法#

项目配置
GPUNVIDIA RTX 3060 Laptop GPU,SM86,6 GiB
PyTorch2.11.0+cu130
CUDA runtime13.0
NVCC13.2
模型Qwen3-0.6B,本地 BF16 checkpoint
batch1
samplinggreedy,ignore EOS
output128 token
prompt128、1024、4096、8192 token
统计每个 shape 预热后,同步运行取中位数

benchmark 前检查 nvidia-smi,确认没有其他计算进程占用 GPU。计时覆盖同一离线请求的 prefill 与 128-token decode,不包含模型加载、tokenize 和 detokenize。

完整请求输出吞吐定义为

throughputrequest=128tprefill+tdecode.\operatorname{throughput}_{request} = \frac{128} {t_{\mathrm{prefill}}+t_{\mathrm{decode}}}.

它不是多请求服务的 aggregate throughput,也不是只算 decode loop 的 token/s。

14.2 端到端结果#

Prompt本项目 BF16LM-head W8A16vLLM 0.23 BF16
128191.85 token/s211.75 token/s160.77 token/s
1024159.93 token/s173.22 token/s143.58 token/s
409696.32 token/s100.64 token/s101.50 token/s
819259.08 token/s60.64 token/s66.10 token/s

完整请求吞吐与三个比较比值
完整请求吞吐与三个比较比值

必须定义三个不同的比值:

RBF/vLLM=本项目 BF16vLLM BF16,R_{\mathrm{BF/vLLM}} =\frac{\text{本项目 BF16}}{\text{vLLM BF16}},RW8/BF=本项目 W8A16本项目 BF16,R_{\mathrm{W8/BF}} =\frac{\text{本项目 W8A16}}{\text{本项目 BF16}},RW8/vLLM=本项目 W8A16vLLM BF16.R_{\mathrm{W8/vLLM}} =\frac{\text{本项目 W8A16}}{\text{vLLM BF16}}.
PromptBF16/vLLMW8A16/BF16W8A16/vLLM
1281.193x1.104x1.317x
10241.114x1.083x1.206x
40960.949x1.045x0.992x
81920.894x1.026x0.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 读取随 TT 增长。短 prompt 时压缩 296.75 MiB LM head 占比高,端到端收益明显。长 prompt 时每步接近 896 MiB KV 读取,LM head 占比下降,所以 W8A16 microbenchmark 的约 1.95x 不会等比例变成完整请求加速。

这也是为什么必须同时报告 microbenchmark 和端到端数据。

14.4 算子与质量结果#

W8A16 microbenchmark:

ProjectionBF16W8A16SpeedupMean abs error
QKV0.05632 ms0.03443 ms1.636x0.005398
O0.06246 ms0.03789 ms1.649x0.005392
gate/up0.07322 ms0.03174 ms2.307x0.005011
down0.06912 ms0.04045 ms1.709x0.005124
LM head0.99072 ms0.50688 ms1.954x0.005298

LM-head-only 质量门控使用 5 个 prompt、每个 32 个 teacher-forced token:

指标结果
first-token top-1 agreement1.00000
teacher-forced top-1 agreement0.99375
first-token mean absolute logit error0.016998
first-token max absolute logit error0.127930

这些指标说明该测试集上 token 决策高度一致,但不等价于“所有任务绝对无损”。改变模型、量化范围、 group size 或采样方式后必须重做质量验证。

14.5 Native attention 的优化链#

native 后端 decode token/s 从初版到 dual graph:

Prompt初版双 Graph 后
128112.61122.36
1024100.22100.90
409672.0172.37
819257.1556.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. 如何复现#

构建扩展:

Terminal window
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

运行测试:

Terminal window
python -m pytest tests -q

运行完整请求 benchmark:

Terminal window
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. 这套实现解决了什么,还没有解决什么#

已经解决:

  1. 用可替换分层把模型数学、CUDA 算子、KV 存储和生成调度解耦;
  2. 针对 Qwen3-0.6B 的 QK Norm/RoPE/GQA 语义设计专用数据通路;
  3. 实现可读的 Paged KV Cache 与 native short/long PagedAttention;
  4. 用 online softmax 避免 O(T)O(T) score 中间数组;
  5. 用静态显存池和 CUDA Graph 降低 decode 分配/launch 开销;
  6. 用质量门控筛掉全层 W8A16,只保留有效的 LM-head W8A16;
  7. 用完整请求、microbenchmark、token/quality 四层证据约束性能结论。

没有解决:

  1. 多请求 continuous batching 与动态 page allocator;
  2. prefix cache、抢占、请求调度和分布式 tensor parallel;
  3. native attention 全 shape 超过生产 vLLM;
  4. FP8/INT8 Tensor Core GEMM 的通用高性能实现;
  5. 对其他 Qwen3 尺寸、其他 GPU 和 sampling 策略的自动调优。

这些边界并不削弱项目深度。相反,能从公式推导瓶颈、选择专用优化、删除失败路径、解释 benchmark 边界,才是 CUDA 工程从“能运行”走到“结论可信”的关键。

参考资料#

  1. Qwen3 Technical Report
  2. Hugging Face Qwen3 model documentation
  3. Attention Is All You Need
  4. RoFormer: Enhanced Transformer with Rotary Position Embedding
  5. FlashAttention
  6. FlashAttention-2
  7. PagedAttention / vLLM paper
  8. NVIDIA CUDA C++ Programming Guide
  9. NVIDIA Ampere GPU Architecture Tuning Guide
  10. PyTorch Custom C++ and CUDA Operators
  11. OneFlow Softmax CUDA 优化
  12. dlog:CUDA 高性能计算库的分层架构
  13. 项目源码与完整实验记录

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
Profile Image of the Author
橙子派
你好,我是橙子派。
公告
橙子派的博客!
音乐
封面

音乐

暂未播放

0:000:00
暂无歌词
分类
标签
站点统计
文章
9
分类
3
标签
25
总字数
44,074
运行时长
0
最后活动
0 天前
站点信息
构建平台
Local
博客版本
chengzipai v6.13.3
文章许可
None

文章目录