说实话,接到在 B300 平台上做 QSA 优化的任务时,我第一反应是:这不就是量化、稀疏化、激活布局三板斧嘛。但真正跑完一轮之后,想法完全变了——板斧还是那三板斧,打法却从一个人盯着 profile 数据手动试参,变成了让优化流程自己滚起来的 KDA 迭代闭环。这篇文章把整个过程拆开讲,包括我踩过的坑、调过的参数、以及最后为什么放弃纯人工方案。
文章里的 QSA,是我在项目里的叫法,对应三个方向:Quantization(量化)、Sparsity(稀疏化)、Activation layout & fusion(激活布局与算子融合)。KDA 则是 Knowledge Distillation & Adaptation,简单说就是在每次自动调整时用知识蒸馏托底精度、用自适应搜索决定下一步改哪里的迭代方法。适合正在做推理加速、模型部署、或者被“量化掉点、稀疏不加速、只能一次次手调”折磨的工程师参考,新手也可以把它当成一份 QSA 优化的路线图来抄。
1. 先从痛点说起:人工优化在 B300 上为什么越来越吃力
1.1 基线 profiling 里看到的真相
年初我在 B300 平台上跑一个图像分类模型,内部代号就叫 C-1。模型不大,几十层卷积加池化,但部署目标很明确:精度不能掉超过 0.5 个点,端到端时延要从原始的 18.6ms 压到 12ms 以内。第一次拿到的 FP16 baseline 数据是精度 91.2%、P99 时延 18.6ms、单卡吞吐每分钟不到 2000 次。
当时想着直接套 QSA 三板斧:把能量化的层全部量化到 INT8,卷积层做结构化稀疏,激活布局换成带宽更友好的重排格式。结果第一轮就翻车了,全局 INT8 量化精度直接掉到 85.3%,根本没法交货。后面改成逐层分析敏感度,先对不敏感的层量化,再对计算量大的层剪枝,最后调布局,精度终于回到 90.3%,时延也降到了 15.2ms。
但这个过程特别难受,光量化层选型就反复试了七八次,每一次都要“改配置、重新编译、跑验证集、看 profile、再改”,一个人完全靠感觉在推。真正让我下决心换思路的,是一次 profiling 里的细节:我最看好的一个“优化点”,实际加速比只有理论值的 20%,而一个我认为没有任何优化空间的残差分支,反而是整个模型的第二耗时段落。很多优化决策靠直觉做,代价太大了。
1.2 优化方案为什么需要从人工试错转向迭代
人工优化的核心问题不是不勤奋,是搜索空间实在太大。拿 C-1 来算一笔账:每层量化位宽至少有 FP16、INT8、INT4 三档可选,稀疏度有 0%、30%、50% 几档,布局和算子融合排布又有好几种组合。假设每个层平均 4~6 个优化维度,单层组合数轻松上百,几十层的模型就是一个天文数字。
人工方案里,绝大多数时候只敢动那些“看起来划算”的层,比如大卷积、大矩阵乘法;对激活分布奇怪的层、短小算子层,基本是绕着走。这导致两个问题:一是漏掉很多反直觉但实际很有效的组合,二是一旦模型结构换掉,之前积累的经验马上失效,得重新来一遍。
KDA 迭代就是把这一步做成闭环:每次自动生成一组 QSA 配置,跑完性能测试和精度验证后,把收益数据回传给一个自适应搜索策略,让它决定下一轮往哪个方向调。再用知识蒸馏把精度兜住,避免自动调优过程中学生模型越变越差,最后彻底脱离人工。听起来很理想,但真正落地时,每一步都有很多细节问题要处理,下面拆开讲。
2. QSA 优化拆解:量化、稀疏化、激活布局,三个杠杆哪个都不能少
2.1 量化:精度换速度,但要先搞清楚哪些层能换
量化是把模型从 FP16 压到更低位宽,本质是用更少字节的乘加换取更高算力利用率和更低带宽压力。B300 这类加速平台上,INT8 吞吐通常比 FP16 高不少,真实收益能不能吃到,取决于算子的实现和内存带宽有没有成为瓶颈。但量化的难点不在速度,在怎么把精度损失压住。
我踩过的最深一个坑是不分青红皂白全局 INT8。模型里总有一些层激活值分布非常“肥”,离群点很多,直接量化会把绝大多数值的动态范围压到极窄区间里,精度崩掉是必然的。后来改用 per-channel 量化,权重按通道算缩放系数,激活做逐层校准,精度恢复非常明显,但离群点严重的层依旧不行。对于这些层,我最终的做法是保留 FP16 混合精度,或者先做一次“离群点截断”,把超出阈值的激活通过可微方式拉回范围内,实测效果好很多。
校准阶段也很有讲究。校准集必须覆盖真实推理时会遇到的分布,不能只拿几张“好看的图”糊弄。激活范围的确定方式,常见做法有两种:一种是最小化量化前后分布差异,适合比较平缓的激活;另一种是最小化量化误差与信息损失,适合长尾分布明显的层。两种都试过之后,我的结论是不要迷信某个单一方法,最好先在几个中等模型上做小范围对照,再逐步扩大应用范围。BN 层也别忘了,量化前要把 BN 的均值和方差吸收进卷积层,或者用训练后折叠的方式处理,否则量化的缩放系数算出来会偏一截。
2.2 稀疏化:只剪不重要权重还不够,还要剪得让硬件认账
稀疏化相对好理解:把权重矩阵里接近零的值剪掉,换来计算量和访存量的降低。但“剪掉”和“实际加速”之间隔着一条巨大的沟,剪得不好,模型权重稀疏了,硬件算子跑起来还是按稠密矩阵算,收益为零。
B300 上真正能吃到加速的是结构化稀疏。我主要用了 2:4 模式:相邻 4 个权重里保留 2 个,因为硬件 kernel 对这个固定结构可以做压缩存储和向量化取数,跳过零值不会被拆成一个个小的分支指令。非结构化稀疏在训练框架里看起来很漂亮,压缩率高,但到了推理加速平台上,矩阵被压缩成不规则格式后,访存调度和负载均衡都很痛苦,加速收益通常被核间同步和索引寻址吃光。
稀疏化还有一个容易翻车的点,是和量化耦合。剪枝会改变每层权重的分布,剪完再量化,原来校准得到的缩放系数可能要重新算。我在某次迭代里先做了 40% 稀疏,再套之前优化好的 INT8 配置,精度掉得比单独做任何一样都严重,原因就是分布变了,旧的量化阈值对不上了。这个教训让我后来源源不断地提醒自己:QSA 三项不是流水线,是联合作战,每个阶段都要带另一位伙伴的视角。
2.3 激活布局与算子融合:把访存带宽这个隐性瓶颈管起来
前面两项算力侧的优化做足了以后,再往深里挖就得看数据怎么流动。B300 上很多模型的瓶颈不在乘加计算,而在数据从全局内存到片上存储的搬运过程。激活布局和算子融合,就是解决这个“搬运”问题的两个核心手段。
我开始时把所有层保持框架默认的连续布局,后面才逐步改成对加速平台更友好的方向和通道重排格式。重排之后,相邻计算所需的数据在内存里更“抱团”,一次突发传输能喂给算子更多的有效数据,而不是把带宽浪费在碎片跳跃上。这个改动在局部层上效果明显,但它的风险在于重排本身需要转换算子,如果这个转换没有和前后算子融合,反而会额外引入一大段访存流量,得不偿失。
算子融合的核心思路也很直接:把卷积、偏置、激活、池化这种一连串操作合并成一个 kernel 执行,不要每一步都写回中间张量再读出来。减法类比就是快递分拣,原本每个中转站都要把包裹放下再取走,现在改成一条传送带直接送到底,时间省在反复装卸上。实际操作中我会优先融合相邻两个消耗大的算子,然后看 profile 里的数据搬移时间是不是降了,再决定要不要继续往下融。融合数越高不一定越好,因为中间结果的复用率不够时,反而会让寄存器压力变大。
3. 实操记录:从纯人工 QSA 到 KDA 迭代闭环的完整过程
3.1 动工前先做的四件事
正式优化之前,我把工作流理成四个固定动作,这套动作后面 KDA 迭代里每一轮也在复用:
- 固定验证集与精度口径:单独留出一部分内部数据集 A,绝不动用训练集做校准和验证,精度指标固定为 Top-1 分类准确率。
- 跑透 baseline profile:对原始 FP16 模型做逐算子耗时拆分,找出 Top 5 耗时算子,并记录整体时延、吞吐、内存占用作为复现基准。
- 建立可回滚的模型版本管理:原始权重、量化表、稀疏掩码、布局配置都单独存档,保证每一次调整失败都能回到上一个稳定点。
- 设计优化目标:时延目标 12ms 以内,精度不得低于 90.7%,这一条作为 KDA 里 reward 计算的核心基准。
这一步看起来是杂活,但后面至少帮我省了三天时间。因为人一着急就会乱改,没有回滚点就只能从头重新调。
3.2 第一版人工 QSA 的三次尝试
第一轮:全局 INT8 量化。我信心满满地推到全模型上,结果精度 85.3%,直接无法接受,时延是降到了 13.1ms,但代价太大。查过激活分布后确认,问题集中在三个激活值范围特别离谱的层。
第二轮:混合精度量化加卷积层 2:4 稀疏。对敏感层回退 FP16,其余层保持 INT8,并挑出计算量大的几个卷积层做 2:4 稀疏。精度恢复到了 90.3%,时延进一步下降到 14.2ms。这一轮让我意识到,混合精度和稀疏是有配合的:稀疏之后的剩余权重,对量化误差的承受能力会有所不同,需要重新校准缩放系数。
第三轮:调整布局与算子融合。把模型主干中两个最耗时的瓶颈段重排了布局,并合并了“卷积+激活”共三组算子。精度没有明显变化,时延降到了 13.9ms。到这一步,纯靠人工经验调整已经到了天花板,再往下抠细节,每降 0.1ms 都要投入大半天时间。
人工优化统计:用掉约两周,最终精度 91.0%、P99 时延 13.9ms、吞吐相比 baseline 提升约 1.6 倍。
3.3 搭建 KDA 迭代闭环
第三轮之后,我决定把整个优化交给 KDA 流程。KDA 分两层:知识蒸馏负责精度兜底,自适应搜索负责寻找更好的 QSA 配置组合。
知识蒸馏的设计上,我把原始 FP16 模型当作教师,把经过量化、稀疏、布局变换后的模型当作学生。每一轮迭代训练时同时计算两类损失:学生模型与教师模型输出 logits 之间的 KL 散度,以及学生模型与真实标签之间的交叉熵。蒸馏温度设成 4,蒸馏权重设成 0.6,这样既能从教师身上学到软化的类别关联信息,又不会完全丢掉真实标签的硬信息。
自适应搜索部分,我简化为一个“测量-决策-更新”的循环。每次先随机初始化一组 QSA 配置,跑完得到精度和时延,算出一个带约束的 reward,奖励精度高和时延低的情况,惩罚超时延约束的情况。搜索策略用带历史记忆的贝叶斯式选择,每次迭代侧重探索那些“预测收益高但不确定性强”的层,而不是在前几轮已确认收益最大的层上反复贪心。
伪代码如下:
# 简化版 KDA 单轮迭代 for iteration in range(max_iterations): policy = policy_selector.suggest(history) # 每层选 量化位宽/稀疏度/布局 build_model_with_qsa(policy) # 应用 QSA 配置 profile_result = run_profiling(next_opt_model) # 跑 profiling acc = evaluate_accuracy(next_opt_model) # 跑验证集 loss = alpha * kd_kl_loss + (1 - alpha) * ce_loss # 蒸馏+任务损失 reward = acc_ratio * w_acc + speed_ratio * w_speed - latency_constraint_penalty history.append(policy, acc, latency, reward) policy_selector.update(history)第一次跑闭环的时候发现一个有意思的现象:搜索器经常选择“一个层 INT4 量化、同时把相邻层做到 40% 稀疏、再调整布局”这种组合,而我人工试参时根本不会这么排,因为直觉认为 INT4 加上高稀疏会直接把精度干穿。但蒸馏兜底之后,精度实际只掉了 0.1 个点,反而是时延降得特别明显。这让我彻底认可了自动化迭代的价值。
3.4 三轮自动迭代后的收益对比
KDA 迭代一共跑了三轮,每轮大概 30 次自动尝试,耗时约三天。最后一轮收敛结果:精度 91.0%,P99 时延 11.8ms,吞吐约 2800 次/分钟。相比人工优化结果,时延又降了 2.1ms,吞吐再提升约 0.45 倍。
| 方案版本 | 精度 | P99 时延 | 吞吐 | 投入时间 |
|---|---|---|---|---|
| FP16 baseline | 91.2% | 18.6ms | ~2000/min | — |
| 全局 INT8(失败) | 85.3% | 13.1ms | ~2500/min | 1天 |
| 人工混合精度+稀疏 | 90.3% | 14.2ms | ~2300/min | 7天 |
| 人工 QSA 全量 | 91.0% | 13.9ms | ~2600/min | 14天 |
| KDA 第一轮 | 90.6% | 12.5ms | ~2700/min | 1天 |
| KDA 第二轮 | 90.9% | 12.1ms | ~2750/min | +1天 |
| KDA 第三轮 | 91.0% | 11.8ms | ~2800/min | +1天 |
关键不是“自动替代人工”这个说法,而是自动迭代能发现人类经验覆盖不到的联合配置,并且把效率稳定复现下来。人工完成一轮完整实验平均要 2~3 小时,KDA 一轮自动尝试在验证集和 profile 充分缓存后,几分钟就能跑完一轮,效率差距一拉就拉开。
4. 排坑实录:这一轮优化里最典型的五个问题
4.1 量化掉点超过预期,先把激活分布拿出来看
现象:模型量化后精度掉点超过一个点,甚至出现个别类完全无法输出正确结果。
排查路径:优先叠加激活值分布,重点看有没有极端离群值;再确认校准集样本数是否足够覆盖真实场景;最后检查 BN 是否做了折叠。解决办法是逐层响应缩放系数量化、敏感层保留高精度、以及在量化后单独跑小步长蒸馏恢复训练稳定模型参数。
实战心得:离群点这种问题靠“多调几个量化方法”通常没有用,必须回到数据分布层面解决。先画出每层激活直方图,谁离群明显,就把谁加入冷名单,这个冷名单本身就是 KDA 搜索空间里一个非常有效的先验项。
4.2 稀疏化做了但实测没有加速收益
现象:权重压缩率看着不错,但 profile 显示相应算子耗时几乎没变。
原因一:选择的稀疏模式没有被后端 kernel 真正支持,剪完还是稠密存取。原因二:矩阵本身太小,稀疏索引寻址的开销比跳过几个零还贵。原因三:稀疏后权重分布极度不均匀,导致部分计算单元空转等待。
实操建议:动手剪之前先在目标平台上做一个小规模算子验证,确认 2:4 还是 4:8 之类的模式有硬件路径支撑。稀疏度不是越高越好,30%-50% 通常比较安全,超过这个区间很多层的精度代价会非线性暴涨,而加速收益早就进入平台期。
4.3 激活重排后反而变慢
现象:布局调整加上算子融合之后,某一整段时间的时延不降反升。
排查结果:问题出在重排前后没有形成足够大的融合区域。布局转换算子本身耗时不低,如果两边没有大算子把转换带来的数据搬移摊销掉,整体速度就会变慢。
解决方法是调整重排范围,让转换点落在“数据使用次数最多的区域”之前,或者干脆把转换算子合并进相邻的卷积 kernel 里。另一个思路是减少重排次数,保持一种布局一直到特征图尺寸缩到很小以后再切换,实测比每层都换布局高效得多。
4.4 KDA 迭代不收敛或奖励反复震荡
现象:reward 曲线呈现剧烈上下跳,精度在 89% 到 91% 之间弹来弹去。
原因有几个:动作空间离散且太大,搜索器总是选到对精度冲击很大的组合;蒸馏权重过高时学生模型即使精度崩了也会被误导认为不错;探索策略过于激进,导致每次大改都把精度拖垮。
对策:把动作空间按层分组压缩,先对高敏感层禁止尝试非安全组合;降低蒸馏温度,让教师信号更稳定;reward 公式里调高精度惩罚,并对时延增加软约束而不是硬惩罚。改完之后,第二轮迭代曲线明显平滑,第三轮基本固定在 91.0% 附近不再下探。
4.5 工具链统计偏差与版本变动带来的回归
现象:某次升级运行时库后,一个算子耗时突然降了一半,我差点以为又是新优化生效了,结果仔细对比才发现是编译策略变了,算子被自动替换成了更激进的版本,但小 batch 下可能不稳定。
实操建议:每次优化结论都要固定运行时版本,升级后必须重跑回归基准集。profile 工具对自定义算子的统计经常只在 host 侧采样,无法反映设备上的真实流水状态,最好另外通过硬件计数器交叉验证,不要只依赖内核耗时列表。
| 问题 | 判定线索 | 快速处置 |
|---|---|---|
| 量化掉点严重 | 激活分布有长尾,校准集过小 | 逐层校准,敏感层回退高精度 |
| 稀疏化无加速 | 后端 kernel 不支持,矩阵太小 | 先做算子级验证,降低稀疏度 |
| 频繁转换布局变慢 | 转换算子耗时不低 | 延长相同布局区间,融合转换算子 |
| KDA 不收敛 | reward 震荡,精度窗口大 | 压缩搜索空间,提高精度惩罚 |
| 版本更新后回归 | 编译策略变化,时间统计不准 | 统一运行时版本,硬件计数器复核 |
最后说说我在这轮优化里最深的体会。人工优化不是不能做,而是从 profile 数据到试验结论的链路太长,一个人很容易陷进“越调越细、越细越乱”的泥潭。KDA 迭代最有价值的点,是把“我觉得这层要调整量化、那层需要换布局”这种模糊经验,变成一条条有量化指标支撑的决策记录。
还有个小技巧:每次迭代都把稀疏掩码、量化表、布局选择、蒸馏权重这四样东西完整存档,后面做回滚或回归对比真的能救命。这套流程跑完,我现在已经开始把 C-1 的经验往另外一个大模型上迁移了,搜索空间和冷名单可以直接复用,只是把 reward 里的约束目标换一下,迁移成本比想象中低得多。