☰
DSA计算过程全解析:GEMM、top-K、gather与MLA的协同优化
2026/10/26 20:22:03 网站建设 项目流程

1. 从标题到落地:DSA计算过程到底在算什么

第一次看到"DSA计算过程"这个标题,加上旁边挂着的GEMM、top-K、gather、MLA这几个词,我脑子里第一反应是:这大概率是在讲某种稀疏注意力(Dynamic Sparse Attention)的完整前向计算链路。后来跟几个做推理优化的朋友聊,发现大家对这个词的理解其实挺分散的——有人以为是硬件里的数字信号加速,有人以为是某种分布式存储架构,还有人直接把它跟MLA(Multi-head Latent Attention)绑在一起当成一个整体模块。所以这篇我就按最主流的那条线来拆:把DSA理解成一种"动态稀疏注意力"的计算流程,它要解决的核心问题是——在长上下文场景下,标准注意力那套O(n²)的复杂度实在扛不住,得想办法只算"该算的那部分"。

这个内容适合谁看?如果你正在做推理引擎的算子优化、在折腾长文本场景的显存和延迟、或者单纯想搞明白"稀疏注意力到底稀疏在哪一步",那这篇应该能给你一些能直接抄的东西。我会把整个计算过程拆成几个阶段:先讲清楚整体设计思路和为什么这么选型,再逐个环节抠细节——GEMM怎么切、top-K怎么选、gather怎么搬数据、MLA在这里扮演什么角色,最后给一份实操流程和踩坑记录。全程按我自己的理解来讲,不保证跟某篇论文一字不差,但保证逻辑能自洽、步骤能复现。

先说结论性的判断:DSA的计算过程,本质上是一条"打分 → 选路 → 搬运 → 重算"的流水线。它不是一个单一算子,而是一组算子的编排。理解这一点,后面所有的细节就都能挂到这条主线上。

2. 整体设计与思路拆解:为什么要把注意力拆成四步

2.1 标准注意力的瓶颈到底卡在哪

要讲DSA,得先把"不稀疏"的版本摆出来做对照。标准多头注意力在推理阶段(decode)的核心计算是:当前token的query向量,去和历史上所有token的key向量做点积,得到一组注意力分数,softmax归一化后加权求和value。问题在于,历史长度n一旦上到几万甚至几十万,这个"和所有key做点积"的步骤就是纯粹的线性扫描,每生成一个token都要把整个KV缓存过一遍。

这里有两个成本:一个是计算成本,点积的量级是n×d;另一个是访存成本,你得把整个KV缓存从显存里读出来。在长上下文场景下,访存往往比计算更致命,因为GPU的算力利用率会被内存带宽拖死。我实测过一个中等规模的模型,上下文拉到32K之后,decode阶段的瓶颈几乎全在KV读取上,SM的利用率掉到30%以下。

所以思路很自然:能不能不读全部KV,只读一小部分?这就是稀疏注意力的出发点。而"DSA"里的"D"(Dynamic)强调的是——选哪一部分不是固定的,而是根据当前query动态决定的。

2.2 四步流水线的分工与选型考量

把"只读一部分"这个目标拆开,就得到了四个必须回答的问题,对应四个计算阶段:

阶段要回答的问题核心算子主要成本
打分哪些历史token值得关注GEMM(query × key)计算密集
选路选出top-K个top-K排序/归约
搬运把选中的KV取出来gather访存密集
重算对选中的做完整注意力GEMM + softmax计算密集

这个拆法的好处是每一步职责单一,方便分别优化。但代价也很明显:你为了省掉全量注意力的成本,额外引入了打分、选路、搬运三步的开销。如果这三步的开销加起来超过了省下来的部分,那这套方案就是负优化。这是DSA落地时最容易翻车的地方,后面会专门讲。

那为什么还要用GEMM来打分?因为打分本质上就是query和key的矩阵乘,天然适合用GEMM这种高度优化的算子。哪怕只是"粗筛",用GEMM也比手写循环快一个数量级。这里有个关键取舍:打分用的维度可以比完整注意力低。比如完整注意力用128维的head dim,打分阶段可以只取其中32维做一个近似,这样打分的计算量直接降到四分之一。这个技巧在MLA里体现得特别明显。

2.3 MLA在这里的角色:把KV压扁

MLA(Multi-head Latent Attention)在这条链路里的位置,很多人一开始会搞混。它不是DSA的替代品,而是和DSA配合的一种KV压缩方案。标准MHA里,每个head都有独立的K和V,KV缓存大得吓人。MLA的做法是把KV投影到一个低维的latent空间,缓存的时候只存这个latent,用的时候再投影回来。

这对DSA的意义在于:gather阶段搬运的数据量直接变小了。假设原来每个token的KV是2×num_heads×head_dim,用了MLA之后可能只剩一个latent_dim,压缩比能到几倍甚至十几倍。搬运量一小,gather的访存瓶颈就缓解了,整个稀疏化的收益才真正体现出来。

所以我的理解是:DSA负责"少读",MLA负责"读得小",两者叠加才能把长上下文的成本压下来。单独用任何一个,效果都会打折扣。这也是为什么这几个词总是绑在一起出现。

3. 核心细节解析:GEMM、top-K、gather各自的坑

3.1 打分阶段的GEMM怎么切才不浪费

打分阶段的GEMM是[1, d] × [d, n]这种形状(decode阶段query只有一行),输出是[1, n]的分数向量。这种"瘦长"的矩阵乘在GPU上其实不太好优化,因为M维度只有1,很难填满tensor core的阵列。

我试过几种切法,实测下来比较稳的是按n分块,每块内部做完整的GEMM。比如把n切成每块4096,每块算出一个[1, 4096]的分数,然后立刻送去做局部的top-K候选筛选。这样做的好处是:分块之间可以流水线并行,而且每块的中间结果不用全部驻留,显存压力小。

注意:分块大小不是越大越好。块太大,单块的延迟高,流水线填不满;块太小,kernel启动开销占比高。我一般从2048起步调,根据实际GPU的SM数量往上试。

另一个细节是打分用的精度。完整注意力通常用fp16或bf16,但打分阶段其实可以用更低的精度甚至int8,因为这一步只是"粗筛",不需要精确。我做过对比,打分用int8量化后,top-K选出来的集合和fp16版本的重合度能到95%以上,但打分本身的速度快了将近一倍。这个trade-off在延迟敏感的场景下非常划算。

3.2 top-K:选多少、怎么选、什么时候选

top-K这一步看着简单,其实坑最多。第一个问题是K取多少。K太小,选出来的token不够,注意力质量下降,生成结果开始胡言乱语;K太大,省下来的计算又不够抵消额外开销。我的经验是:K的取值跟上下文长度和任务类型强相关。做检索类任务(答案就在某几个位置),K可以小到64甚至32;做长文摘要这种需要全局信息的,K可能得给到512以上。

第二个问题是怎么选。朴素的top-K是全局排序,但全局排序的复杂度是O(n log n),在n很大的时候本身就不可接受。实际用的多是分块top-K + 归并:每个块内部选出局部top-K,再把所有块的候选归并出全局top-K。这样复杂度降到O(n + m log m),m是块数乘以局部K。

第三个问题是什么时候选。这里有个反直觉的点:不是每个decode step都需要重新选。相邻token的query往往很相似,选出来的KV集合高度重叠。所以可以每隔几步才重新做一次top-K,中间的步骤复用上一次的选择。我实测过,每4步重选一次,质量几乎无损,但top-K的开销直接降到四分之一。这个技巧在"top-k 路由"这个说法里其实就隐含了——路由不是每步都重算的。

3.3 gather:访存密集环节的优化空间

gather是把top-K选中的KV从缓存里"捞"出来,组成一个紧凑的[K, d]矩阵,供后面的完整注意力使用。这一步是纯访存操作,没有计算,所以优化方向只有一个:减少搬运的字节数,提高访存效率。

具体手段有几个。一是前面说的MLA压缩,从源头减少每个token的字节数。二是让选中的KV在显存里尽量连续。如果KV缓存是按token顺序线性排布的,那gather就是随机访问,效率很低。有些实现会维护一个"热KV"的紧凑缓冲区,把最近频繁被选中的token挪到一起,gather的时候命中率更高。三是用向量化的load,一次读128位或256位,而不是逐个元素读。

实操心得:gather的耗时经常被低估。我在一个项目里做过profile,发现gather占了整个DSA流程将近40%的时间,比打分还高。后来把KV缓存改成按head分块、块内连续,gather时间直接砍半。所以别光盯着GEMM优化,访存这块的收益往往更大。

3.4 重算阶段:为什么不能直接用打分的结果

有人会问:既然打分阶段已经算出了分数,为什么不直接softmax然后加权求和,还要再算一遍完整注意力?

原因是打分用的维度和精度都不够。打分阶段可能只用了32维、int8精度,得到的分数只是个近似。如果直接拿这个近似分数去做加权,注意力的质量会明显下降。所以标准做法是:用近似分数选出top-K的位置,然后对这些位置用完整的维度、完整的精度重新算一遍注意力。这一步的GEMM形状是[1, d] × [d, K],因为K远小于n,所以即使算得精确,总成本也远低于全量注意力。

这个"先粗后精"的两阶段设计,是DSA能work的关键。粗筛负责把候选集从n降到K,精算负责保证质量。两阶段的维度、精度、甚至数据类型都可以不一样,给了很大的调优空间。

4. 实操过程:一条可复现的DSA计算链路

4.1 环境与数据准备

假设我们有一个已经训练好的模型,KV缓存按标准方式存着。我们要实现的是decode阶段单步的DSA计算。输入是当前token的hidden state,输出是注意力结果。为了讲清楚,我用伪代码加注释的方式,把每一步的参数和意图都标出来。

先定义几个关键参数:n是历史长度,d是head dim,d_score是打分用的维度(通常小于d),K是top-K的数量,block_size是分块大小。这些参数没有万能值,得根据实际场景调,下面给的是一组我常用的起点。

n = 32768 # 历史长度 d = 128 # 完整head dim d_score = 32 # 打分维度,取完整维度的1/4 K = 256 # top-K数量 block_size = 4096 # 分块大小

4.2 第一步:低维打分

从KV缓存里取出key的低维部分(前d_score维),和query的低维部分做GEMM。注意这里只读key的前d_score维,不是全部,所以访存量也小。

# query_low: [1, d_score] # key_low_cache: [n, d_score],只存了低维部分 scores = gemm(query_low, key_low_cache.T) # 输出 [1, n]

这一步的输出是一个长度为n的分数向量。如果n是32768,d_score是32,那这个GEMM的FLOPs是32768×32×2≈2M,非常小。真正的开销在读取key_low_cache上,32768×32×2字节(fp16)≈2MB,这个量级在现代GPU上也就几十微秒。

4.3 第二步:分块top-K与归并

把scores按block_size切块,每块内部选局部top-K,再归并。

candidates = [] for i in range(0, n, block_size): block = scores[i:i+block_size] local_topk_idx = topk(block, k=K // 4) # 每块选K/4个候选 candidates.extend([i + idx for idx in local_topk_idx]) # 归并所有候选,选出全局top-K global_topk_idx = topk(gather(scores, candidates), k=K)

这里每块选K/4是个经验值。块数大概是n/block_size=8,8×64=512个候选,再从中选256个,冗余度足够,不会漏掉真正重要的token。如果块数更多,每块的局部K可以相应调小。

注意:局部K不能太小,否则真正的高分token可能在局部就被筛掉了。一般保证局部K至少是全局K的1/8到1/4。

4.4 第三步:gather选中KV

拿到global_topk_idx之后,把对应的完整KV取出来。

# kv_cache: [n, 2, num_heads, d],完整精度 selected_kv = gather(kv_cache, global_topk_idx) # [K, 2, num_heads, d]

如果用了MLA,这里的kv_cache存的是压缩后的latent,gather之后再投影回完整的K和V。搬运量能小好几倍。

4.5 第四步:完整注意力重算

对选中的K个位置做标准注意力。

# query_full: [1, num_heads, d] # selected_k: [K, num_heads, d] # selected_v: [K, num_heads, d] attn_scores = gemm(query_full, selected_k.transpose()) # [num_heads, 1, K] attn_probs = softmax(attn_scores, dim=-1) output = gemm(attn_probs, selected_v) # [num_heads, 1, d]

这一步的GEMM形状是[num_heads, 1, d] × [num_heads, d, K],K=256时计算量很小。整个DSA流程的总耗时,实测下来比全量注意力在n=32K时能快3到5倍,具体取决于K的取值和硬件。

4.6 参数选择的计算过程

K到底取多少,可以用一个简单的成本模型来估。设全量注意力的成本是C_full = n × d(忽略常数),DSA的成本是C_dsa = n × d_score + K × d + gather_overhead。要让DSA划算,需要C_dsa < C_full,即:

n × d_score + K × d + gather_overhead < n × d

代入n=32768, d=128, d_score=32,假设gather_overhead约等于K×d的0.5倍:

32768×32 + K×128×1.5 < 32768×128 1048576 + 192K < 4194304 192K < 3145728 K < 16384

这个上界很宽松,说明在n=32K时,K取到几千都还是划算的。但实际中K取太大,注意力质量反而不一定好(引入了太多噪声),而且gather的随机访问效率会下降。所以K的实际取值更多是质量驱动的,不是成本驱动的。我一般从128起步,根据任务效果往上加。

5. 常见问题与排查技巧实录

5.1 生成结果突然变差,怎么定位是不是DSA的锅

这是最常见的问题。排查思路是逐阶段对比:先把DSA关掉,跑全量注意力,记录输出;再开DSA,对比同一位置的输出差异。如果差异很大,再逐个阶段查。

现象可能原因排查方法
输出完全乱码top-K选错了位置打印选中的idx,看是否集中在某段
输出轻微退化K太小或打分精度太低逐步增大K、提高打分精度
长文后半段变差分块top-K漏掉了早期重要token增大局部K,或改用全局top-K
延迟没降反升gather开销超过预期profile各阶段耗时,重点看gather

我踩过最坑的一次是:分块top-K的局部K设得太小,导致一个关键token在它所在的块里排第9,而局部K只取8,直接被筛掉了。结果模型在需要引用开头信息的任务上全军覆没。后来把局部K调到16就正常了。这个bug很隐蔽,因为大部分任务看起来都正常,只有特定任务才暴露。

5.2 gather效率低下的几种典型表现

gather慢通常不是算子本身的问题,而是数据布局的问题。几个典型症状和对策:

  • 症状:gather耗时随n线性增长,但K没变。原因:KV缓存是线性排布的,选中的idx越分散,随机访问越多。对策:维护紧凑的热KV缓冲区。
  • 症状:gather耗时波动很大。原因:选中的idx分布不均匀,有时集中有时分散。对策:对idx做排序后再gather,把随机访问变成顺序访问。排序的开销远小于随机访问的惩罚。
  • 症状:gather后紧接着的GEMM变慢。原因:gather出来的数据在显存里不连续,GEMM的load效率低。对策:gather时直接写到一块连续的临时缓冲区。

实操心得:对top-K的idx做一次排序,看起来多了一步,但实测能把gather加后续GEMM的总时间降20%以上。这个技巧在很多实现里都被忽略了。

5.3 MLA压缩带来的精度损失怎么补

MLA把KV压到低维,必然有信息损失。补偿手段有几个:一是提高latent维度,压缩比别拉太满;二是在重算阶段用完整的投影矩阵,把latent还原得尽量准;三是对压缩误差做校准,比如在训练时加入重建损失。

我的经验是:压缩比控制在4到8倍之间比较稳,再往上精度就开始明显掉了。而且不同层的敏感度不一样,浅层可以压得狠一点,深层要保守。这个得逐层试,没有统一公式。

5.4 常见问题速查表

问题根因解决
top-K选出的位置每步剧烈跳变query变化大或打分噪声大加平滑,或降低重选频率
显存不降反升候选缓冲区开太大复用缓冲区,别每步新分配
多卡并行时结果不一致gather的idx跨卡了确保KV缓存按卡分片,idx本地化
首token延迟特别高首次要建完整KV缓存预填充阶段就做好分块和索引

6. 几个容易被忽略的工程细节

6.1 重选频率与质量的平衡

前面提过,不必每步都重选top-K。但重选频率怎么定,有个简单的判据:看相邻两步query的余弦相似度。如果相似度高于0.95,基本可以复用;低于0.9,就该重选了。实际实现里可以固定步数(比如每4步),也可以动态判断。动态判断更准,但多了一次相似度计算,得权衡。

我一般用固定步数,因为实现简单、延迟可预测。在延迟敏感的服务里,可预测性比那一点点质量提升更重要。

6.2 预填充阶段的处理

DSA主要优化的是decode阶段,但预填充(prefill)阶段也不能忽视。预填充时query是一整段,不是单行,所以打分阶段的GEMM形状是[seq_len, d] × [d, n],计算量大得多。这时候稀疏化的收益更明显,但top-K的选择也更复杂——每个query位置都要选自己的top-K。

一个简化做法是:预填充阶段先用全量注意力,把KV缓存建好,同时记录下每个位置的"重要token集合",供后续decode复用。这样预填充的质量有保证,decode又能享受稀疏化的好处。

6.3 数值稳定性

稀疏化之后,softmax的输入分布会变。因为只对选中的K个位置做softmax,如果这K个位置的分数普遍偏低,softmax后的分布会很平,输出接近均匀加权,质量下降。解决办法是在打分阶段保留一个全局的max分数,重算时用它做数值稳定,而不是只用选中集合的max。这个细节很小,但不做的话在某些输入上会出现明显的质量抖动。

7. 我对这套流程的整体体会

把DSA的计算过程拆完,我最大的感受是:它的难点不在单个算子,而在算子之间的配合。GEMM、top-K、gather单独看都是成熟技术,但拼在一起之后,参数怎么传、数据怎么布局、精度怎么匹配,每一步都有讲究。我见过不少实现,单个算子都优化得很好,但整体跑起来就是不如预期,问题往往出在衔接处——比如gather出来的数据布局不适合后面的GEMM,或者打分精度和重算精度不匹配导致质量抖动。

另一个体会是:稀疏化的收益高度依赖场景。在短上下文(n<4K)下,DSA的额外开销可能超过收益,不如老老实实用全量注意力。只有在长上下文、且任务本身确实只依赖局部信息时,DSA才真正划算。所以别一上来就无脑上稀疏,先profile,看瓶颈到底在哪。

最后分享一个我常用的调试技巧:把top-K选中的位置可视化出来。把每步选中的idx画成一条时间线上的点,一眼就能看出选择是否合理——如果点集中在最近的一段,说明模型在"只看最近";如果点均匀散布,说明它在做全局检索。这个可视化帮我定位过好几次选择逻辑的bug,比看数字直观得多。

这套流程后续还能往几个方向扩展:一是把top-K换成更灵活的路由机制,比如按内容聚类而不是按分数排序;二是把MLA的压缩和DSA的选择联合训练,让压缩后的latent本身就编码了"哪些位置重要"的信息;三是在多卡场景下做分布式的top-K,每张卡先选本地的,再跨卡归并。这几个方向我都在试,有进展再单独写。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询