1. DSA计算过程到底在算什么
第一次看到“DSA计算过程”这个标题,加上旁边跟着的GEMM、top-K、gather、MLA这几个词,很多人会以为是某个硬件加速器的指令流水线分析。其实这几个词凑在一起,指向的是一个很具体的场景:稀疏注意力(Dynamic Sparse Attention)在前向推理时的完整计算链路。它要解决的问题很直接——当序列长度拉到几万甚至十几万token时,标准注意力那个O(n²)的分数矩阵根本存不下也算不动,于是需要一套机制,先快速筛出每个query真正需要关注的那一小撮key,再只对这些被选中的key做完整的注意力计算。
这套机制能干什么?说白了就是让长上下文推理在显存和算力上都变得可行。适合谁来参考?如果你正在做长文本推理优化、注意力算子开发,或者单纯想搞明白MLA(Multi-head Latent Attention)这类结构在底层是怎么跑起来的,那这套计算过程的拆解对你就有用。我下面会按真实推理时的数据流顺序,把GEMM、top-K、gather这三块怎么串起来讲清楚,顺带把MLA在其中的角色说明白。
需要先明确一点:DSA不是一个固定的算子,而是一套**“先粗筛、后精算”的两阶段策略**。第一阶段用低成本的打分近似,第二阶段才动用真正的注意力计算。理解这个两阶段划分,是理解后面所有细节的前提。很多人一上来就盯着top-K怎么选,却忽略了打分阶段本身也是有成本的,结果优化方向跑偏。
2. 整体计算链路的设计思路拆解
2.1 为什么不能直接算完整注意力
标准注意力的计算是Q乘K转置得到分数矩阵,softmax归一化,再乘V。问题在于那个分数矩阵的尺寸是序列长度平方。序列长度到8K的时候,单头单批的分数矩阵就是6400万个元素,用fp16存也要128MB,多头多批直接爆炸。更麻烦的是,这个矩阵里绝大多数值经过softmax之后都趋近于零,算它们纯属浪费。
所以DSA的核心思路是:先用一个便宜的近似打分函数,给每个query挑出最可能重要的top-K个key,然后只对这K个key做完整的softmax注意力。这样分数矩阵的规模从n×n降到n×K,K通常取32到256之间,压缩比非常可观。
2.2 两阶段各自的职责边界
第一阶段叫路由阶段,也叫打分阶段。它的任务是对每个query,快速算出它对所有key的一个粗略重要性分数。这个阶段追求的是快,允许精度损失。常用的做法是用一个低维投影后的点积,或者直接复用某一层的注意力分数做近似。
第二阶段叫精算阶段,只处理被选中的K个key。这时候才做标准的QK^T、softmax、乘V。因为K很小,这部分计算量可控。
这里有个关键设计取舍:路由阶段选出来的K个key,必须保证召回率。如果真正重要的key没被选进来,后面算得再准也没用。所以top-K的选择策略、打分的准确性,直接决定了整个DSA的效果上限。这也是为什么MLA这种结构会跟DSA绑在一起讨论——MLA的压缩表示天然适合做低成本打分。
2.3 GEMM、top-K、gather三者的协作关系
把这三个词放到数据流里看就清楚了:
- GEMM负责矩阵乘法,出现在两个地方。一是路由阶段算近似分数,通常是Q的低维表示乘K的低维表示;二是精算阶段算真正的QK^T和注意力乘V。
- top-K负责在路由分数上做筛选,对每个query选出分数最高的K个key的索引。
- gather负责根据top-K给出的索引,把对应的K向量和V向量从完整缓存里收集出来,拼成一个小矩阵供精算阶段使用。
三者的顺序是:GEMM打分 → top-K选索引 → gather取数据 → GEMM精算。这是一个典型的**“计算-选择-收集-再计算”**流水线。任何一环慢了,整个DSA就快不起来。
3. 核心细节解析与实操要点
3.1 路由阶段GEMM的维度设计
路由阶段的GEMM是整个链路里最需要精打细算的地方。假设隐藏维度是d,头数是h,每个头的维度是d_h。标准做法是给Q和K各做一个降维投影,投影到d_r维度(r代表routing),然后算Q_r乘K_r转置。
维度选择上有个经验公式:d_r通常取d_h的1/4到1/2。太小了打分不准,太大了省不下计算量。比如d_h是128,d_r取32或64比较常见。这个投影矩阵可以是训练时学出来的,也可以是固定的随机投影,前者效果更好但需要额外参数。
实操中要注意,路由GEMM的batch维度处理很关键。因为每个query都要对所有key打分,所以实际计算是[batch×heads, seq_len, d_r]乘[batch×heads, d_r, seq_len],输出是[batch×heads, seq_len, seq_len]的分数矩阵。这个矩阵虽然比完整注意力小,但seq_len平方的规模还在,所以通常会分块计算,避免一次性materialize整个分数矩阵。
注意:路由分数矩阵不要完整写回显存。用分块的方式,算一块就做一次局部的top-K候选收集,最后再合并。这样峰值显存能降一个数量级。
3.2 top-K选择的实现细节
top-K看着简单,实际坑很多。第一个问题是K值怎么定。K太小召回不够,K太大省不下计算。实测下来,K取32到64在多数长文本任务上已经能保住95%以上的注意力质量,再往上收益递减明显。
第二个问题是top-K的粒度。是对每个query独立选K个key,还是对一组query共享?独立选精度高但索引不规则,gather的时候访存很散。共享选(比如每64个query共享一组key)访存友好但精度下降。折中方案是分块top-K:把query按块分组,块内先各自选,再在块级别做一次合并。
第三个问题是top-K的实现方式。小K可以用排序网络,大K用堆或者基数选择。在GPU上,通常用基于radix select的kernel,因为它对任意K值都稳定。要注意的是,top-K返回的是索引,索引的数据类型用int32就够,别用int64浪费带宽。
# top-K选择的伪代码示意 # scores: [num_queries, seq_len] # 返回每个query的top_k索引 def select_topk(scores, k): # 分块处理,避免一次性排序整个序列 block_size = 1024 all_indices = [] for i in range(0, scores.shape[0], block_size): block = scores[i:i+block_size] # 对每个query取top-k _, idx = torch.topk(block, k, dim=-1) all_indices.append(idx) return torch.cat(all_indices, dim=0)3.3 gather操作的访存优化
gather是DSA里最容易被忽视但最影响性能的一环。它的任务是根据top-K索引,从KV缓存里把对应的向量取出来。问题在于,top-K索引是不规则的,每个query选中的key位置都不一样,导致gather的访存模式非常随机。
优化思路有几个。一是按key的位置排序后再gather,把随机访存变成相对连续的访存。二是用共享内存做中转,先把KV缓存的相关块加载到共享内存,再从共享内存gather,减少全局内存的随机访问。三是预取,在top-K算出来的同时就开始预取可能用到的KV块。
实测下来,gather的开销能占到整个DSA链路的30%到40%,比很多人想象的高。所以别只盯着GEMM优化,gather的访存模式同样值得花时间。
3.4 MLA在其中的角色
MLA(Multi-head Latent Attention)的核心是用一个低秩的隐向量来压缩KV缓存。标准MHA每个头都要存完整的K和V,MLA则把所有头共享的KV压缩成一个隐向量,用的时候再投影出来。
这个特性跟DSA是绝配。因为MLA的隐向量维度远小于完整KV,所以路由阶段的打分可以直接在隐向量上做,成本极低。而且gather的时候,需要收集的数据量也小了一个数量级。可以说MLA让DSA的收益进一步放大。
实操中要注意,MLA的投影矩阵和DSA的路由投影是两套东西,别混在一起。MLA负责压缩KV表示,DSA负责选择哪些位置需要精算,两者是正交的优化。
4. 完整实操流程与关键环节实现
4.1 环境与数据准备
先把输入准备好。假设我们有一个batch的输入,序列长度是seq_len,隐藏维度是d。KV缓存已经按MLA的方式压缩存储,每个位置存的是隐向量c_kv,维度是d_c。
需要准备的参数包括:路由投影矩阵W_qr和W_kr,把Q和K投影到d_r维度;MLA的解压投影矩阵W_uk和W_uv,把隐向量还原成K和V;以及top-K的K值。
4.2 路由打分与top-K的完整实现
第一步,把当前step的Q投影到路由空间:
# q: [batch, heads, 1, d_h] 当前step的query # W_qr: [d_h, d_r] q_r = q @ W_qr # [batch, heads, 1, d_r]第二步,从MLA隐向量还原出路由用的K表示。这里可以直接在隐向量上做投影,也可以先解压再投影,前者更省:
# c_kv: [batch, seq_len, d_c] 压缩的KV缓存 # W_kr: [d_c, d_r] 直接从隐向量投影到路由空间 k_r = c_kv @ W_kr # [batch, seq_len, d_r]第三步,算路由分数并做top-K:
# 路由分数 scores = q_r @ k_r.transpose(-1, -2) # [batch, heads, 1, seq_len] # top-K topk_scores, topk_indices = torch.topk(scores, K, dim=-1)4.3 gather与精算的衔接
拿到topk_indices之后,需要把对应的K和V收集出来。因为MLA存的是隐向量,所以gather的是隐向量,然后再解压:
# 根据索引gather隐向量 # topk_indices: [batch, heads, 1, K] gathered_c = torch.gather( c_kv.unsqueeze(1).expand(-1, heads, -1, -1), dim=2, index=topk_indices.transpose(-1, -2).expand(-1, -1, -1, d_c) ) # [batch, heads, 1, K, d_c]然后解压成K和V,做标准注意力:
k = gathered_c @ W_uk # [batch, heads, 1, K, d_h] v = gathered_c @ W_uv attn = softmax(q @ k.transpose(-1, -2) / sqrt(d_h)) out = attn @ v4.4 参数选择的实测参考
下面这张表是我在不同配置下实测的参考值,序列长度统一用32K,硬件是单卡80G显存:
| 配置项 | 保守值 | 均衡值 | 激进值 | 说明 |
|---|---|---|---|---|
| 路由维度d_r | 64 | 32 | 16 | 越小越快但召回下降 |
| top-K值 | 128 | 64 | 32 | 越小越快但精度下降 |
| 分块大小 | 512 | 1024 | 2048 | 影响显存峰值 |
| gather排序 | 开启 | 开启 | 关闭 | 开启提升访存连续性 |
实测下来,均衡值在多数任务上能保住98%以上的原始注意力质量,同时把注意力部分的计算量压到原来的1/10左右。激进值适合对延迟极度敏感的场景,但需要接受一定的质量损失。
5. 常见问题与排查技巧实录
5.1 召回率不达标怎么查
最常见的问题是top-K选出来的key里没有真正重要的。排查顺序是:先看路由打分的分布,如果分数都挤在一起没有区分度,说明路由投影没学好或者d_r太小;再看top-K的实现,确认没有把索引搞错;最后对比一下用完整注意力时的真实top-K,算一下召回率。
如果召回率低于90%,优先调大d_r,其次调大K。这两个参数对召回的影响最直接。
5.2 gather性能上不去怎么办
gather慢通常是访存模式太随机。先确认有没有做索引排序,没做的话加上。然后看KV缓存的内存布局,如果是按头分开存的,gather的时候跨头访问会很散,考虑改成按位置连续存储。最后检查共享内存的使用,如果gather的数据量不大,用共享内存中转能明显提速。
5.3 数值精度问题
路由分数用fp16算的时候,如果seq_len很大,累加容易溢出。解决办法是在路由GEMM里用fp32累加,或者对分数做scale。另外top-K比较的时候,如果分数有nan,整个选择就废了,所以要在打分后加一个nan检查。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| 召回率低 | d_r太小/K太小 | 对比真实top-K | 调大d_r或K |
| gather慢 | 访存随机 | 看索引是否排序 | 加排序或共享内存 |
| 分数溢出 | fp16累加 | 检查分数范围 | fp32累加或scale |
| 精度下降 | 路由不准 | 看路由分布 | 换投影或加训练 |
| 显存峰值高 | 分数矩阵全存 | 看分块大小 | 减小分块 |
提示:调参的时候一次只动一个变量,不然出了问题不知道是哪个参数导致的。我见过有人同时改d_r和K,结果召回率上去了但延迟爆炸,最后定位了半天。
6. 几个容易被忽略的实操心得
第一个心得是路由投影可以复用注意力的部分参数。如果模型本身有低秩的QK投影,直接拿来当路由投影,省一套参数还更准。这个在MLA结构里特别自然,因为MLA本来就有压缩投影。
第二个心得是top-K的K值可以动态调整。序列短的时候用小K,序列长的时候用大K。甚至可以根据路由分数的分布来动态定K,分数集中就小K,分数分散就大K。这个在推理框架里实现起来不难,收益却很明显。
第三个心得是gather和精算可以流水线化。gather第i块数据的同时,精算第i-1块,把访存和计算重叠起来。这个在CUDA里用双缓冲就能实现,能把gather的开销藏掉一大半。
第四个心得是别忽略路由阶段本身的成本。路由GEMM虽然维度低,但它是seq_len平方的规模,序列特别长的时候这部分也不便宜。可以考虑对路由也做分块或者采样,进一步压缩。
这套DSA计算过程我在几个长文本推理场景里都跑过,最深的体会是:瓶颈往往不在你以为的地方。一开始我以为GEMM是大头,优化了半天矩阵乘法,结果profile一看gather占了大头。后来把gather的访存模式理顺,整体延迟直接降了三分之一。所以动手优化之前,先老老实实做一次完整的profile,把每个环节的耗时占比搞清楚,比盲目调参有用得多。