上一篇结束时,海光Z100上的Qwen3.6-27B-AWQ-INT4已经把最影响使用的Decode问题基本处理到可以稳定运行的程度。AWQ是激活感知权重量化(Activation-aware Weight Quantization),它根据模型真实激活分布选择量化尺度,让权重能够以较低比特保存,同时尽量减少精度损失;INT4表示模型权重以4比特整数存储;W4A16表示4比特权重、16比特激活(Weight 4-bit,Activation 16-bit),也就是权重长期保持INT4压缩格式,模型运行时产生的中间激活仍然使用FP16或者BF16。上一篇主要处理单token Decode,因此当时的重点一直放在wave64线程组织、INT4解包、显存读取和GPU图执行上,Qwen3.6-27B的Decode也从早期约18.7tokens/s推进到了完整GPU图路径下约33tokens/s的阶段。
继续往后做以后,优化方向很快发生了变化。Prefill一上来就证明大M矩阵乘和M=1 Decode不是同一个问题,继续扫描Triton参数只能得到有限收益,最后必须改变W4A16本身的执行结构;W4A16压下去以后,GDN因果卷积链和标准Attention马上成为新的热点;长上下文Decode继续增长时,Attention的占比又越来越高,最后发展到单独为gfx906写HIP Attention。独立内核做到约0.52毫秒以后,真正麻烦的事情反而变成正确性和泛化:一个已经在Qwen3.6上连续运行很久的内核,换成GQA8、Head Size 128以后会随机出现上千个NaN,最后才定位到LDS查找表缺少线程同步。Qwen3.8-27B也在这期间暴露了gfx906对BF16支持不足的问题。本文记录的就是这一阶段:从大M Prefill开始,把W4A16、GDN和Attention逐层向下压,并把8K、16K一直推进到131K长上下文。
这里先把后面反复出现的几个指标统一说明。Prefill是预填充阶段,负责处理用户输入的整段Prompt并生成第一个输出token之前的中间状态;Decode是逐token解码阶段,从第一个输出token开始,一个token一个token继续生成。token通常翻译为"词元",是模型内部处理文本的基本单位,一个汉字、一个英文单词或者单词的一部分都可能对应一个或多个token。TTFT是首token延迟(Time To First Token),也就是从请求发出到第一个token出现所花的时间。TP是张量并行(Tensor Parallelism),TP2表示把同一个模型拆到两张GPU共同执行。本文后半段的正式性能数据主要来自真实流式HTTP服务,每次生成64tokens,temperature=0,关闭遇到结束符提前停止,并在测试前后检查Prefix Cache前缀缓存计数;只要发生缓存命中,该次Prefill结果就直接作废。服务启动后的第一次请求还会触发JIT即时编译(Just-In-Time Compilation)和缓存建立,因此冷启动数据不作为正式成绩。
到本文记录结束的位置,Qwen3.6-27B-AWQ-INT4在TP2下已经得到下面这组代表性结果:
| 上下文长度 | Prefill速度 | Decode速度 | TTFT |
|---|---|---|---|
| 8K | 398.8tokens/s | 35.6tokens/s | 约20.5秒 |
| 16K | 379.1tokens/s | 32.2tokens/s | 约43.1秒 |
| 131K | 105.1tokens/s | 13.16tokens/s | 约1246.6秒 |
这些数字并不是从第一篇的18.7tokens/s一路用同一个脚本测出来的,因此本文不会拿首尾数字直接计算一个"累计提升几倍"。随着模型服务方式、上下文长度、GPU图模式和测试脚本变化,性能数字必须放回各自的测试条件中理解。
一、W4A16大M Prefill:INT4权重不必一直保持压缩状态
上一篇主要处理Decode。单请求Decode每一步通常只有一个新token参加线性层计算,因此矩阵乘里的M一般等于1。这里可以先把后文常见的M、N、K说明一下:对于一次线性变换,可以把输入看成一个M×K矩阵,权重对应K×N,最后得到M×N输出;M代表一次同时参与计算的token行数,K是输入特征维度,N是输出特征维度。M=1时,本质上更接近GEMV矩阵向量乘法(General Matrix-Vector Multiplication),模型每生成一个token,都要重新读取一遍大量权重。这种场景通常很依赖HBM高带宽显存(High Bandwidth Memory)的供数速度,所以INT4很有价值:原来16比特的权重压缩到4比特以后,从显存读取的数据量能够大幅减少。Z100对应的gfx906采用wave64,一个Wavefront由64个线程锁步执行,因此上一篇主要围绕wave64线程组织、LDS本地数据共享存储(Local Data Share)、INT4解包和显存访问做M=1快速路径。
Prefill的计算结构完全不同。一轮可能同时处理128、512、2048甚至8192个token,M随之变成几百到几千,此时主要计算也从GEMV转向GEMM通用矩阵乘法(General Matrix Multiplication)。最开始仍然沿着"把Triton W4A16 GEMM继续调快"的路线做,扫描BLOCK_M、BLOCK_N、BLOCK_K、num_warps等参数。BLOCK_M/N/K表示一个GPU程序块每次负责计算的矩阵分块大小,num_warps决定参与这个程序块的并行线程组数量。针对FP16和BF16分别调参以后确实能够得到收益,例如8K Prefill从113.44tokens/s提高到141.67tokens/s,其他长度大约也有25%~48%的改善,但Profiler性能分析器很快表明,这仍然没有触及最主要的问题。
2K Prefill时,W4A16一度占据80%以上的GPU内核时间,8K仍有约67%。原因出在大M下的重复解包:每个M方向的小分块都会重新读取INT4权重,把一个Byte字节中的两个4比特值拆出来,再读取Scale缩放系数和Zero Point零点,把权重恢复成16比特数值参加矩阵乘。M=1时这套成本只发生一次,INT4少读显存的收益很明显;M变成几千以后,同一份权重却被不同M分块反复读取和反复解码,INT4保存下来的显存流量逐渐被重复位操作和格式转换吃掉。
最后采用了一条结构完全不同的路径:**大M Prefill开始计算某个线性投影时,先把当前层INT4权重一次性反量化成临时FP16权重,随后让这一层所有token共同复用,再交给成熟的FP16稠密GEMM。**这里的临时展开只发生在当前层执行期间,并不会在模型加载时把整个27B模型永久转换成FP16。三个主要投影需要的瞬时工作区如下:
| 投影 | 临时FP16工作区 |
|---|---|
| QKV投影 | 83,886,080B,约80MiB |
| Gate/Up投影 | 178,257,920B,约170MiB |
| Down投影 | 89,128,960B,约85MiB |
单Kernel微基准很快证明这种结构在大M下更合适:
| 投影 | M | 原融合W4A16 | 一次反量化+FP16稠密GEMM | 加速 |
|---|---|---|---|---|
| QKV | 128 | 2.456毫秒 | 1.792毫秒 | 1.37倍 |
| QKV | 2048 | 31.723毫秒 | 9.865毫秒 | 3.22倍 |
| QKV | 8192 | 125.677毫秒 | 35.979毫秒 | 3.49倍 |
| Gate/Up | 128 | 4.679毫秒 | 2.425毫秒 | 1.93倍 |
| Gate/Up | 2048 | 66.138毫秒 | 19.167毫秒 | 3.45倍 |
| Gate/Up | 8192 | 262.591毫秒 | 74.362毫秒 | 3.53倍 |
| Down | 128 | 2.645毫秒 | 1.136毫秒 | 2.33倍 |
| Down | 2048 | 33.283毫秒 | 9.913毫秒 | 3.36倍 |
| Down | 8192 | 130.763毫秒 | 36.533毫秒 | 3.58倍 |
M越大,权重一次展开以后被重复利用的次数越多,因此收益也越明显。8K下这条结构路径能够做到大约19~20TFLOPS的有效FP16计算吞吐,已经接近当时gfx906上成熟稠密矩阵乘的水平。
真正上线前还专门检查了HIP Graph。HIP Graph可以理解成AMD GPU上的"计算任务录像":正常情况下CPU要一次次向GPU提交Kernel,Graph则把一串固定执行流程先捕获下来,之后整段回放,从而减少CPU调度和Kernel Launch内核启动的固定开销。上一篇已经遇到过Graph Pool图内存池被长期对象占满的问题,因此这次临时FP16权重必须确认不会在图捕获以后变成每层永久驻留的副本。QKV、Gate/Up和Down分别用M512、M2048、M8192做Changing-input Graph测试,每轮改变Activation激活和Scale,再和重新计算的稠密参考结果逐项比较;捕获、回放和释放以后,输出全部逐位一致,工作区也能够正常回收。随后这条结构路径才成为gfx906上的默认Prefill方案。
放回Qwen3.6-27B完整模型以后,收益远高于前面的Triton参数扫描:
| 输入长度 | 原路径 | 结构重写后 | 提升 |
|---|---|---|---|
| 128 | 154.5tokens/s | 246.1tokens/s | 59.3% |
| 512 | 192.5tokens/s | 425.2tokens/s | 120.9% |
| 2048 | 185.0tokens/s | 475.2tokens/s | 156.8% |
| 8192 | 151.5tokens/s | 297.8tokens/s | 96.5% |
这组提升来自执行方式整体变化:INT4权重从"每个M分块反复解包"变成"当前层只展开一次,后续所有Activation Row激活行共同复用"。后来还尝试过让反量化结果直接生成另一种[K,N]连续布局,部分QKV和Out Projection输出投影的单独Shape确实快一点,Down也有约3.5%收益,但Gate/Up最差会退化约23%;真实27B中2K从558.8下降到538.6tokens/s,回退3.6%,8K只从404.1变成405.6tokens/s,完全落在噪声范围,因此这条路径最终删除。
权重布局也在这一阶段顺手清理。早期为了兼容不同Kernel,会同时保存原始[K,N/8]int32量化权重和给gfx906 Decode快速GEMV准备的[N,K/2]uint8布局。TP2还能勉强承受,TP1单卡32GB显存时就会遇到明显问题:模型INT4权重本身已经占用十几GB,再同时存在大尺寸中间副本很容易OOM,也就是显存不足(Out Of Memory)。后续gfx906上的FP16 W4A16路径逐渐统一到一份长期Packed打包权重,旧布局完成转换以后释放,Prefill需要的FP16稠密权重直接临时从这份INT4权重生成。TP1加载时还改成在CPU上一层一层完成重排,再逐层传回GPU,把加载阶段的显存峰值压下来。
做到这里以后,大M和M=1已经形成两套很清楚的硬件策略:Decode主要受每一步重复读取权重影响,INT4应该尽量保持压缩;Prefill能够让同一层权重服务成百上千个token,一次展开再做高效稠密GEMM更划算。量化格式的价值最终取决于它是否能减少当前硬件真正昂贵的工作。
二、W4A16退到后台以后,GDN数据搬运链成为新热点
W4A16结构路径上线以后,Profiler的时间分布立即发生变化。8K Prefill已经不再由W4A16解包独占,新的GPU时间组成大致变成下面这样:
| 8K Prefill主要项目 | GPU Self时间占比 |
|---|---|
稠密aten::mm矩阵乘 |
35.20% |
| Unified Attention统一注意力 | 31.69% |
scatter_add |
15.74% |
index索引搬运 |
5.78% |
| All-Reduce全归约 | 3.96% |
| GDN递归核心 | 2.91% |
bmm批量矩阵乘 |
2.11% |
| W4反量化 | 0.32% |
这里的GDN是门控增量网络(Gated Delta Network),属于线性注意力结构的一种。标准Attention需要持续读取越来越长的KV Cache,GDN则维护一个递归状态,把历史信息逐步压缩进去。Qwen3.6-27B共有64层,其中48层使用GDN,因此某个辅助操作即使单层只浪费一点时间,重复48次以后也会成为明显热点。
最先处理的是Scatter散射写回。Scatter可以理解成"按照索引把计算结果写回指定位置",原来的通用实现使用scatter_add,遇到多个结果写到相同位置时自动进行累加。但Qwen当前Prefill的不同sequence在扁平token数组中占据互不重叠的区间,同一位置不存在多个结果竞争写入,因此可以直接写回。这个修改先通过不同sequence长度组合以及Changing-input Graph验证,输出和状态全部逐位一致。真实8K Prefill从297.94提高到345.47tokens/s,单项整模收益达到约15.95%。
Scatter去掉以后,旧因果卷积路径里的index→bmm→scatter链仍然很重。这里的index负责从变长输入中按位置取出数据,bmm是批量矩阵乘法(Batch Matrix Multiplication),随后再把结果Scatter回原始token位置。最终把整条变长序列因果卷积重写成一条gfx906 Triton Kernel:每个GPU程序块直接根据query_start_loc找到自己属于哪条sequence、负责哪些token和Channel通道,从原始输入或者上一轮State状态读取卷积窗口,在Kernel内部完成卷积乘加、Bias偏置和SiLU激活,再直接写到最终输出。原来大量中间张量分配和搬运因此一起消失。
真实生产布局是先创建[T,D]连续输入,再转置成[D,T]视图。旧aten::index对这种Stride步长布局很不友好,而新Kernel恰好按照连续Channel方向分块读取,因此单Kernel的差距非常大:
| 输入长度 | 旧因果卷积链 | 融合Kernel | 加速 |
|---|---|---|---|
| 512 | 4.049毫秒 | 0.331毫秒 | 12.24倍 |
| 2K | 13.066毫秒 | 1.060毫秒 | 12.33倍 |
| 8K | 168.503毫秒 | 6.825毫秒 | 24.69倍 |
单Kernel快24.7倍并不等于27B模型整体快24.7倍,因为W4A16、Attention、All-Reduce和其他64层计算仍然存在。放回完整Qwen3.6-27B以后,提升变成更符合Amdahl定律的十几个百分点:
| 输入长度 | 融合前 | 融合后 | 整模提升 |
|---|---|---|---|
| 2K | 506.0tokens/s | 559.2tokens/s | 10.5% |
| 8K | 354.0tokens/s | 406.7tokens/s | 14.9% |
Amdahl定律描述的是一个很实际的上限:某个模块即使无限加速,整个程序最多只能省掉原来属于这个模块的那部分时间。因此看到"Kernel快20倍"时,首先需要确认它原来在完整模型里占多少比例。这个GDN融合路径后来又覆盖多sequence、任意变长输入、不同维度、FP16、BF16、有无初始State、空State Slot状态槽以及Changing-input HIP Graph,最终State可以逐位一致,输出误差控制在FP16舍入量级,真实Decode连续60个token也逐个一致。
GDN融合以后重新做8K Profiler,原来那条搬运链基本被清空:
| 8K Prefill融合后热点 | GPU Self时间占比 |
|---|---|
| 稠密FP16矩阵乘 | 48.46% |
| Unified Attention | 40.67% |
| GDN递归核心 | 4.05% |
| All-Reduce | 4.01% |
| 融合因果卷积 | 1.22% |
原index |
约1.1毫秒,基本退出热点 |
这一次性能分布已经很明确:稠密矩阵乘整体达到约20.1TFLOPS,继续大范围扫描GEMM参数的价值开始很低;GDN卷积链也从两位数占比压到1%左右,Attention自然成为下一块最值得处理的目标。这种优化过程很少存在永久的"最大瓶颈",第一名被压下去以后,第二名会立即成为新的主战场。
三、Attention:从Triton调优到长上下文Decode并行度重构
Attention注意力机制负责让当前token从历史信息中选择最相关的内容。标准Attention会先计算Query和Key之间的相关性,再经过Softmax得到注意力权重,最后对Value加权求和。Query可以理解成当前token提出的"查询",Key类似历史信息的索引,Value保存真正需要取出的历史内容。Key和Value会长期保存在KV Cache键值缓存中,随着上下文增长,Attention需要读取的历史数据也越来越多,因此长上下文特别容易把Attention推成主瓶颈。
当前Qwen3.6标准Attention的Head Size为256,也就是每个注意力头内部处理256维向量,并采用GQA6:1。GQA是分组查询注意力(Grouped Query Attention),这里表示6个Query Head共享一组Key和Value。KV Cache使用FP8保存,FP8是一种8比特浮点格式,能够减少长上下文缓存占用和显存读取。gfx906每个CU计算单元(Compute Unit)只有约64KB LDS共享存储,因此Attention不能简单把Tile分块无限增大。最开始仍然优先调vLLM现有的Triton Unified Attention统一注意力内核,最终比较稳定的Prefill参数是较窄KV Tile、长序列适当增加Query Block,并使用num_warps=4和num_stages=1。单层8K从约752.35毫秒降低到561.26毫秒,但当时W4A16仍占主要时间,完整模型只从约141.9提高到152.1tokens/s,整模收益约7.2%。
W4A16和GDN被压下去以后,GQA线程映射中的浪费开始值得处理。GQA6、BLOCK_M=32时,旧二维映射只能容纳5个完整Query Token,也就是5×6=30个有效Query-Head Row,32行程序块里有2行闲置。改成Flat-GQA扁平GQA映射以后,一个程序块直接处理连续32个真实Query-Head Row,边界可以落在同一token的6个Query Head之间,减少约6.25%的Program程序块数量。单层8K从549.10降低到514.60毫秒,提升约6.7%;真实27B 8K从345.83提高到362.29tokens/s,提升4.76%。
这一阶段还试过不少看起来合理、最终没有保留的方案。把Block Table块表地址提前Scalarize标量化,让16个token不再重复读取同一个物理Block ID,微基准只有0.2%~0.5%收益;把FP8 K/V的Per-tensor Scale张量级缩放折叠进Score Scale和最终Accumulator累加器,和地址标量化一起做时单Kernel能提高约1%~1.5%,但真实8K从405.67变成405.93tokens/s,只有约0.07%;利用Physical Block物理块连续性减少地址计算,在真实2K退化约3%,8K接近9%;把长Prefill Softmax拆成多段并行,完整模型只留下约0.5%,还需要长期增加接近192MiB显存。Z100专用flash_attn包也做过相同Shape A/B,它内部实际上仍然是一套较旧的Triton统一Attention实现,2K从当前35.26毫秒退化到49.05毫秒,慢约39%,8K从514.87增加到734.73毫秒,慢约43%,因此没有替换当前路径。
Decode阶段后来通过一个看起来很普通的参数取得了更明显收益:Softmax Segment分段数量。长上下文Decode不会让一个工作组把整条KV历史从头处理到尾,而是把历史划分成多个Segment分段,每段独立计算局部最大值、指数和以及部分输出,最后再通过一个很小的reduce_segments归约Kernel合并。TP2以后每张卡只剩2个KV Head,而gfx906有60个CU;只分16段时,总并行任务数量太少,大量CU无法同时得到工作。
| Softmax分段数 | Attention主体 | 最终归约 | 总计 |
|---|---|---|---|
| 16 | 2.113毫秒 | 0.064毫秒 | 2.177毫秒 |
| 32 | 1.755毫秒 | 0.065毫秒 | 1.820毫秒 |
| 64 | 1.579毫秒 | 0.066毫秒 | 1.645毫秒 |
| 128 | 1.585毫秒 | 0.064毫秒 | 1.649毫秒 |
64段基本到达平台,继续增加到128没有收益。TP1下64段约2.80毫秒,128段反而退化到3.04毫秒,因此最终默认选择64段。更接近真实单请求的TP2 8K Shape中,单层Attention从1.149毫秒下降到0.730毫秒;完整服务8K Decode从26.33提高到33.11~33.18tokens/s,提升约26%,TP1也从20.7~21.5提高到22.9~23.3tokens/s。
做到这里以后,长上下文Decode的热点已经很集中。TP2 8K每生成一个token大约需要30毫秒,16层标准Attention累计已经接近12毫秒,W4A16线性层约9毫秒,48层GDN核心反而只有很小一部分。继续明显提高长上下文Decode,Attention已经没有办法绕开,于是开始写gfx906专用HIP内核。
四、手写gfx906 HIP Attention:从0.52毫秒到跨模型正确性
这条HIP Attention最开始只针对当前最常用的组合:FP8 KV Cache、Head Size 256、GQA以及分段Softmax。FP8 K/V始终以压缩格式保存在显存中,不提前生成完整FP16副本;LDS中只构造两张256项FP8→FP16查找表,K和V各一张,总共约1KB。GPU每读取一个FP8 Byte,就通过Lookup Table查找表在寄存器里转换成FP16,用完立即丢弃。这样既保留FP8减少显存读取的优势,也不会为长KV Cache常驻一套FP16副本。
Attention核心可以粗略拆成QK和PV两部分。QK是Query-Key点积,用当前Query和历史Key计算注意力分数;PV则拿Softmax得到的Probability概率权重对历史Value做加权汇总。QK阶段让不同GPU线程共同处理Head Dimension,再通过Shuffle线程间数据交换完成归约,并使用gfx906的fdot2指令一次处理两组FP16乘加;Softmax采用Online Softmax在线Softmax,不把整行注意力分数先写回显存;PV阶段同样尽量使用fdot2,最终保持FP32累加提高数值稳定性。
第一版手写内核已经把代表Shape从Triton约1.59毫秒降低到0.67毫秒。后面真正留下来的优化并不多,主要是让编译器完全展开最热的PV循环,并在验证精度以后把指数计算从普通expf换成速度更快的__expf。最终6次GPU Event事件计时分别为0.5281、0.5237、0.5206、0.5163、0.5168和0.5182毫秒,稳态约0.52毫秒,相同条件下约为Triton的3.06倍。
优化过程中出现过不少"看起来更快,实际上不成立"的版本:
| 实验 | 性能/现象 | 最终结论 |
|---|---|---|
| 最终正确HIP内核 | 约0.52毫秒 | 保留 |
PV循环tp+=8 |
约0.471毫秒 | 漏算约7/8 token,删除 |
全局-ffast-math |
速度更高 | 最大输出误差约0.137,删除 |
| LDS缓存V | 约0.65毫秒 | 更慢 |
| Ping-Pong双缓冲 | 约0.60毫秒 | 更慢 |
| 256线程版本 | 约0.62毫秒 | 更慢 |
更激进launch_bounds |
出现寄存器Spill溢出 | 删除 |
Occupancy驻留度表示一个CU能够同时容纳多少活跃Wavefront。理论上更高Occupancy有利于隐藏显存和指令等待,因此一度也尝试增加线程数量、LDS缓存和预取,但最终正确的0.52毫秒版本只用了约1KB LDS,VGPR矢量通用寄存器(Vector General Purpose Register)约120个。性能计数中VALU向量算术单元Busy占比只有三成多,显存Stall停顿也不到1%,继续强行提高Occupancy没有收益。这个内核更接近操作数依赖和指令延迟受限,不能简单归类成"带宽没吃满"或者"算力没吃满"。
接入vLLM时没有重写后面的最终Softmax归约,而是让HIP内核继续输出和原Triton三维Attention相同的Partial Output部分输出、局部最大值和指数和,再复用vLLM已有的reduce_segments。这样改动范围更小,也方便通过环境变量快速回退。HIP Kernel直接提交到vLLM当前HIP Stream执行流中,因此FULL_DECODE_ONLY只捕获Decode的GPU图模式仍然可以正常捕获。接入以后,代表性内核和完整模型的变化如下:
| 测试 | Triton | HIP | 提升 |
|---|---|---|---|
| TP2代表Shape,含归约 | 1.526毫秒 | 0.536毫秒 | 2.85倍 |
| TP1代表Shape,含归约 | 2.681毫秒 | 0.924毫秒 | 2.90倍 |
| TP1 8K Decode | 23.33tokens/s | 28.08tokens/s | 20.4% |
| TP1 16K Decode | 17.01tokens/s | 24.35tokens/s | 43.2% |
| TP2 8K Decode | 32.21tokens/s | 36.99tokens/s | 14.8% |
| TP2 16K Decode | 24.49tokens/s | 33.34tokens/s | 36.1% |
上下文越长,标准Attention在单步Decode中的占比越大,因此16K获得的整模收益明显高于8K。
真正危险的问题发生在内核开始泛化以后。为了避免把它做成只能服务Qwen3.6的特例,后续逐渐加入Head Size 128、更多GQA比例、Prefill、混合Query和BF16 Query。GQA8、Head Size 128、4×4096 Prefill测试中,同一份输入有时完全正常,有时会随机产生1024~2560个NaN。NaN表示"不是一个有效数字"(Not a Number),一旦进入神经网络计算,后续值通常会迅速被污染。相同输入的错误位置每次不同,排查方向因此集中到线程竞态,最终定位到LDS中的FP8查找表构造完成以后缺少一次__syncthreads()线程块同步。一部分Wave已经开始读取查找表时,另一部分Wave负责的数据可能还没有写完。
增加同步以后,原本最容易随机产生NaN的场景连续5次全部正常,FP16的16组集成回归也全部通过。这个问题再次说明GPU Kernel正确性不能只依赖"某个模型连续跑了很多次没出错"。线程调度、Shape和GQA变化都可能改变竞态被触发的概率,因此泛化测试必须主动覆盖不同Head Size、GQA、sequence数量和输入布局。
本文阶段结束时,这条HIP Attention已经模板化支持FP16/BF16 Query、Head Size 128/256以及GQA1~8。Decode下这些GQA比例都可以进入HIP;Prefill则根据实际微基准控制分派范围:
| Prefill类型 | HIP相对Triton表现 | 默认路径 |
|---|---|---|
| GQA6/8代表Shape | 约2.4倍 | HIP |
| GQA4 | 约1.2~1.7倍 | HIP |
| GQA1~3部分Shape | 约0.17~0.9倍 | Triton |
| MHA部分Shape | 明显慢于Triton | Triton |
分派判断依赖gfx906架构、Query数据类型、Head Size、GQA比例、KV量化模式以及Attention功能,而不是模型名称。遇到ALiBi位置偏置、Softcap分数截断、Attention Sink注意力汇聚、滑动窗口、特殊多模态Prefix或者其他没有覆盖的语义,直接回退Triton。这样做的意义在于后续换模型时可以按能力和Shape自动复用,而不需要不断增加if model_name==...一类模型特判。
五、Qwen3.8的BF16问题:局部FP16桥接失败,整模型FP16成为实际方案
Qwen3.8-27B-AWQ-INT4和Qwen3.6-27B的模型规模、64层结构以及主要INT4矩阵Shape都非常接近,一个明显区别是默认Activation数据类型:Qwen3.6使用FP16,Qwen3.8默认BF16。BF16是Brain Floating Point 16脑浮点16位格式,占用空间和FP16相同,但指数范围更大、有效尾数更少。gfx906缺少后续CDNA架构上的原生BF16矩阵计算能力,实际运行时很多BF16运算需要走低效路径,这很快成为Qwen3.8 Prefill远慢于Qwen3.6的主要原因。
这里尝试过两类方案。第一类是在现有BF16模型内部做局部FP16 Bridge桥接,例如只把W4A16某段BF16激活临时转成FP16计算,再转换回来。这类方案在部分矩阵上能够明显提速,但高熵Prompt的Exact Token ID精确token测试没有稳定通过,因此没有保留。第二类更直接:**Qwen3.8启动时显式指定--dtype float16,让整个运行时Activation使用FP16,INT4权重仍然保持4比特存储。**这条路线避开了每层反复进行BF16↔FP16格式转换,也能直接复用已经成熟的gfx906 FP16 W4A16和稠密矩阵乘路径。
在当时同一版本生产路径下,BF16和FP16的差距非常明显:
| Prompt长度 | Qwen3.8 BF16 Prefill/Decode | Qwen3.8 FP16 Prefill/Decode |
|---|---|---|
| 1K | 203.2/28.9tokens/s | 473.8/35.8tokens/s |
| 2K | 180.2/27.6tokens/s | 319.4/33.8tokens/s |
| 4K | 193.2/25.3tokens/s | 443.3/30.4tokens/s |
| 8K | 177.5/21.7tokens/s | 382.8/25.4tokens/s |
| 16K | 151.7/17.0tokens/s | 279.2/19.2tokens/s |
| 32K | 117.4/11.9tokens/s | 182.3/12.8tokens/s |
| 64K | 80.2/7.2tokens/s | 106.6/7.8tokens/s |
这张表属于当时Qwen3.8数据类型问题定位阶段,Attention仍然使用当时的生产路径,因此主要用于观察BF16和FP16本身的差异,不能与后面手写HIP Attention完成后的最终速度直接横向比较。
精度方面也做了单独对照。周期模式的5组2048-token输入连续生成60tokens,FP16与BF16路径5/5逐token一致;高熵随机Prompt中4组有3组一致,1组在第3个token发生Argmax最大概率位置翻转;真实自然文本最终语义保持一致。这里反映的是FP16和BF16本身不同的浮点舍入顺序,而不是W4A16权重被重新量化。综合gfx906的硬件能力和速度差距以后,Qwen3.8最终采用显式FP16运行路径。
手写HIP Attention仍然单独补齐了BF16 Query支持,因为其他默认BF16模型不应该为了使用Attention快速路径就必须整体改成FP16。7组BF16 Head Size、GQA、Prefill和Decode测试中,相比Triton最大输出差约7.6×10^-6,HIP通常快1.5~2.8倍。随后又用默认BF16的Qwen3.5-0.8B-AWQ-INT4做完整服务泛化。这个模型真实配置为24层,其中18层GDN、6层标准Attention,与Qwen3.6的层比例不同:
| Qwen3.5-0.8B TP1 | Triton | 手写HIP | 提升 |
|---|---|---|---|
| 8K Prefill | 919.5tokens/s | 1691.9tokens/s | 84% |
| 8K Decode | 147.8tokens/s | 172.4tokens/s | 17% |
| 16K Prefill | 1118.0tokens/s | 1457.4tokens/s | 30% |
| 16K Decode | 94.4tokens/s | 143.2tokens/s | 52% |
做到这里以后,这条Attention已经具备了跨模型复用的基础。本文截止位置仍然只把已经验证过的能力加入默认分派,低GQA Prefill和额外Attention语义继续走Triton。
六、131K长上下文:真正先撞到的是RPC超时
长上下文系统测试把很多短Prompt下很难观察的问题放大了。最早131K测试在后半段总会退出,显存占用又已经非常高,因此第一判断很容易落到OOM上。继续检查日志才发现,GPU显存并没有发生分配失败,真正触发的是vLLM引擎进程之间的RPC调用超时。RPC是远程过程调用(Remote Procedure Call),vLLM不同进程会通过它提交任务和等待结果;相关Engine执行超时默认只有300秒。
131K Prefill会被Chunked Prefill分块预填充拆成大约16个8192-token Chunk。上下文刚开始时历史KV比较短,单个Chunk执行时间大约95秒;随着已经积累的KV越来越长,后面的Chunk逐渐增长到130秒以上。实际异步调用链的超时等待会覆盖连续执行阶段,后半段相邻两个Chunk的累计等待超过300秒以后,上层就会把请求判断成RPC超时并终止引擎。因此这里真正需要放宽的是Engine RPC执行时限,而不是给KV Cache继续挤显存。
放宽超时以后,早期版本的131K第一次完整跑通,两轮输出能够保持一致,每轮总时间约44.4分钟,平均Prefill只有约49tokens/s。这个成绩很慢,但它第一次给出了真正的131K完整执行链,也把长上下文中的Attention并行不足和每层固定开销全部放大到分钟级。
手写HIP Attention扩展到Prefill和混合Query以后,又重新测试Qwen3.6-27B。TP2、约130K输入、64个输出token、无Prefix Cache命中的两轮结果如下:
| 测试 | Prefill | TTFT | Decode |
|---|---|---|---|
| 第一次冷运行 | 101.9tokens/s | 1285.5秒 | 10.46tokens/s |
| 第二次干净运行 | 105.1tokens/s | 1246.6秒 | 13.16tokens/s |
从早期约49tokens/s提高到105tokens/s以后,131K仍然需要20分钟以上才能出现第一个token,但至少整个链路已经能够稳定完成,而且这105.1tokens/s来自约13万token完整执行后的真实平均速度,不是拿2K或者8K线性外推。
8K和16K也重新用同一套单请求、64个输出token、无Prefix Cache命中的口径复测:
| 输入长度 | TP1 Prefill | TP1 Decode | TP2 Prefill | TP2 Decode |
|---|---|---|---|---|
| 8192 | 240.8tokens/s | 27.79tokens/s | 398.8tokens/s | 35.56tokens/s |
| 16320 | 197.1tokens/s | 22.92tokens/s | 379.1tokens/s | 32.20tokens/s |
TP2并不会在所有场景都得到2倍收益,因为模型每一层还有跨GPU通信。这里最主要的通信操作是All-Reduce全归约:两张GPU分别算出自己的张量分片以后,需要互相交换并求和,让两张卡都拿到下一层需要的完整结果。上一篇实现的gfx906小消息自定义All-Reduce在小尺寸下明显快于通用通信库,但继续把Payload消息尺寸向上扫以后可以看到清楚的交叉点:接近1MB以后,自定义方案很快落后于RCCL。RCCL是ROCm集合通信库(ROCm Collective Communications Library),作用类似NVIDIA平台上的NCCL,针对大消息通信做了更完整的带宽优化。因此当前TP2默认只让约256KB以下的小消息走Z100自定义All-Reduce,更大的消息回到RCCL。
七、这一阶段的落点:从单模型补丁走向按硬件能力分派
回头看这一个阶段,最明显的变化是Prefill终于不再只是Decode之外顺便能跑的功能。W4A16大M结构重写让2K和8K Prefill获得了接近2~2.5倍级别的阶段提升;W4A16退到后台以后,GDN的index→bmm→scatter链又通过融合Kernel整体消失;GDN被压下去以后,Attention成为8K和长上下文的主要热点,先经历Triton参数调整、Flat-GQA和Softmax Segment分段,再发展成专门针对gfx906的HIP实现。整个过程中的主要变化可以压缩成下面这张表:
| 阶段 | 代表变化 | 典型结果 |
|---|---|---|
| W4A16大M结构重写 | INT4每层一次反量化,再复用FP16稠密权重 | 8K 151.5→297.8tokens/s |
| GDN Unique Scatter | 去掉无必要的scatter_add |
8K 297.9→345.5tokens/s |
| GDN因果卷积融合 | 消除index→bmm→scatter数据搬运链 |
8K 354.0→406.7tokens/s |
| Flat-GQA | 消除GQA6线程映射中的空行 | 8K 345.8→362.3tokens/s |
| Decode Softmax 64段 | 增加长KV Attention并行任务数量 | 8K Decode 26.33→33.1tokens/s |
| 手写HIP Attention | FP8查表解码+fdot2+在线Softmax |
代表Kernel约1.59→0.52毫秒 |
| HIP Attention整模 | 替换长上下文标准Attention | TP2 16K Decode 24.49→33.34tokens/s |
| 131K完整链路 | 放宽RPC超时并使用新Attention路径 | Prefill约49→105.1tokens/s |
其中不少单项数据来自不同优化阶段和不同A/B环境,因此这张表用于展示各项技术在各自严格测试中的收益,不应把每一行百分比直接连乘成所谓"总提升"。
这阶段另一个变化是正确性标准越来越接近真实生产环境。固定Shape微基准正确,只能证明这个Shape下数学实现没有明显问题;Changing-input Graph能够继续检查GPU图回放时的数据是否真的会更新;真实Tensor View和Stride可以暴露连续张量测试永远看不到的地址问题;不同GQA和Head Size能够主动触发线程同步竞态;最终的Greedy Token ID贪心token序列对照,则用来确认这些微小误差有没有真正改变模型生成结果。0.471毫秒的错误HIP版本、-ffast-math产生的0.137输出误差,以及GQA8才暴露的__syncthreads()缺失,都说明速度数据只有和正确性测试一起看才有意义。
本文截止位置,gfx906手写Attention已经能够覆盖FP16/BF16 Query、Head Size 128/256、Decode下GQA1~8、Prefill下经过验证的GQA4~8,以及部分混合Query场景;不满足条件的Shape和Attention功能自动回退Triton。Qwen3.5-0.8B完整服务已经证明这条路径可以跨模型复用。131K也已经完整跑通,并得到能够重复的105.1tokens/s Prefill和13.16tokens/s Decode结果。与此同时,32K和64K中间长度、Qwen3.5-4B完整服务泛化以及把当前独立gfx906动态库正式并入vLLM原生ROCm构建体系,在本文记录结束的位置仍然属于后续工作。
这一阶段对低比特推理的理解也比最初更具体。INT4在M=1 Decode里主要负责降低权重流量,应当尽可能保持压缩;到了大M Prefill,同一层权重会被大量token反复使用,一次展开再进入成熟FP16 GEMM更适合gfx906。FP8 KV适合长期压缩保存在显存里,真正进入计算时则通过小型查找表在寄存器中临时还原。数据格式只是模型与硬件之间的一层表示方式,优化最终要围绕这代GPU真正昂贵的访存、指令和调度成本展开。
GPU Kernel的泛化测试同样需要主动制造麻烦。一个Shape连续运行几千次没有错误,不代表换一个GQA、一个Head Size或者一种Stride以后仍然正确;一个模型能输出通顺文字,也不代表底层两条数据类型路径逐token完全一致。越靠近GPU底层,测试越需要从"能运行"逐渐升级到"地址正确、状态正确、Graph回放正确、跨Shape正确、最终token也正确"。做到这里以后,Z100上的vLLM适配已经开始从"为Qwen3.6写几条能跑的补丁",逐渐转向根据gfx906能力、数据类型、量化方式和真实Shape自动选择执行路径。后面的性能瓶颈还会继续变化,而Profiler会决定下一阶段真正值得优化的对象。