【原创】海光Z100 DCU适配vLLM实录(二):W4A16大M Prefill结构重写、GDN融合与长上下文HIP Attention

上一篇结束时,海光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_MBLOCK_NBLOCK_Knum_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=4num_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会决定下一阶段真正值得优化的对象。

相关推荐
IJCAST5 小时前
IJCAST最新一期已经发布
人工智能·深度学习·神经网络
chunmiao30326 小时前
OpenAI 官宣断供 Cursor,AI 编程迎来第一次模型断供
人工智能·深度学习
147API7 小时前
评测分数不理想,什么时候值得补蒸馏数据
人工智能·深度学习·机器学习
β添砖java10 小时前
深度学习26转置卷积
人工智能·深度学习
JoannaJuanCV10 小时前
VLM学习-SFT(监督微调)
深度学习·学习·机器学习·大模型·视觉大模型·vlm·视觉编码器
玩转单片机与嵌入式11 小时前
深入浅出TinyML 21:INT8量化改变了模型中的哪些数据?
stm32·深度学习·tinyml
满怀冰雪11 小时前
23-PaddleClas 数据集、配置文件与训练参数详解
人工智能·python·深度学习·paddlepaddle
手写码匠11 小时前
华为云Flexus+DeepSeek征文|华为云MaaS DeepSeek推理服务 × Flexus云服务器 × Dify一键部署:性能评测实战
人工智能·深度学习·算法·aigc
十三画者12 小时前
【文献分享】ExoFILT:基于深度学习的外泌事件单颗粒追踪数据分类器
人工智能·深度学习·机器学习·数据挖掘·数据分析