☰
基于CANN的推理加速实战:从图编译到算子改写全程解析
2026/10/17 4:03:10 网站建设 项目流程

去年底接了一个推理加速任务,问题听起来不算难:某内部推理服务要跑一个代号为 K3 的模型(就是标题里提到的 Kimi K3,这里先说明一下,K3 只是项目内部代号,不代表任何具体产品),目标是让它能在国产化推理环境上稳住长文本场景的时延。结果第一版直接推上线,P99 延迟高得离谱,并发稍微一上去就把资源耗光。后来从算子画像、图编译、内存编排到 APEX 算子改写,走了一整套基于 CANN(异构计算架构)的调优流程,愣是把端到端吞吐拉到了原来的 2.8 倍左右。这篇文章把完整思路和踩坑过程写下来,适合正在做推理加速或国产算力适配的工程师,也适合刚接触 CANN、想知道“这玩意到底能优化出什么效果”的同学。先说结论:性能问题很多时候不是模型结构不行,而是底层执行引擎没把算力榨干。

1. 先别急着调算子:基线数据到底怎么测的

1.1 为什么我把“测基线”看得比调优更重

我看到不少团队一上来就翻算子列表,看到哪个算子不顺眼就改哪个,最终数据搞得很热闹,模型却越调越乱。我的习惯正相反:任何优化动作落地之前,先把“现状”量化成一张表。没有基线,你根本不知道 2.8 倍是真实收益还是幻觉。

拿 K3 这次的业务来说,线上核心场景是几千 token 长度的上下文推理,每轮请求包含提示词处理和增量生成两段。我先按真实业务的输入分布构造了一个测试集,注意这里的关键点是:不要只用固定长度、固定 batch 的合成数据“自嗨”。长文本场景下,长度分布、并发数量、显式 batch 大小都会严重影响算子耗时,用纯固定 shape 测出来的优化结论,换到线上基本不具备参考价值。

我当时的测试方案大概是这样:

测试项配置
单请求输入长度均匀分布在 512~4096 token
推理 batch首包 batch=8,续包 batch=1
并发路数8 个独立请求线程
精度FP16(业务允许)
评价指标端到端吞吐(token/s)、P99 时延

基线跑出来的结果,端到端吞吐只有优化完成后的三分之一左右。最有意思的是,热点排序表里排第一的不是 GEMM,而是一堆不起眼的数据搬运类算子,这说明模型本身算力需求没那么恐怖,瓶颈转移到了引擎层。

1.2 第一张热点算子画像表

我用 Profiling 工具抓了完整推理链路的总耗时,然后按算子类型做累加,拿到这么一张表(数据做了脱敏,但分布结构是真实的):

算子类别耗时占比说明
数据搬运 / 格式转换37%包括 Layout 转换、内存拷贝、拼接
通用矩阵乘28%Attention 里的 QKV 投影等
逐元素算子(激活、归一化)22%这类算子看似轻量,数量极多
通信 / 同步8%多核等待、同步开销
其他5%罕见分支

看到数据搬运占了三成以上,我心里就踏实了——这意味着不用动模型结构,只要优化底层执行计划,就能得到巨大回报。接下来所有优化动作,基本都是围绕这张表格里的占比大头展开的。

2. 从CANN图编译下手:一次离线优化吃掉大半收益

2.1 ATC转换前后的执行管线,到底多做了什么

CANN 体系下,模型不会直接被处理器一条条执行高级算子,而是需要先把训练框架导出的模型转换成离线模型,再在设备上运行。这个“转换”过程,业内通常叫 ATC(模型转换工具),但这名字容易让人误以为它只是格式翻译器。实际上,它在图编译阶段干了很多执行期无法完成的脏活累活。

最典型的几个动作:

  • 合并小算子。像Scale -> Add -> Relu -> Cast这一串逐元素算子,如果在图上逐个下发,每次都要做一次独立调度和内存申请,开销非常大;ATC 阶段可以识别出这种连续子图,合并成单个融合算子,显著减少设备侧调度次数。
  • 固定 Layout。模型原始图里,每个算子各自有习惯的数据排布。ATC 会分析全图,插入必要的转换算子和格式后缀,让相邻算子之间尽量复用同一种数据格式,避免每个算子边界都做一次数据重排。
  • 静态 Shape 推理。如果输入是固定维度,ATC 能在编译期推导出每个中间张量的形状和内存大小。这一点很关键:设备在运行时不需要反复做形状推断和分配。

我用一个生活化类比来解释这件事:原本的执行方式像每次做饭都现买菜、现切菜、现问“今天几个人吃”,非常灵活,但每一步都有等待;ATC 相当于先把菜单固定好、食材预处理完,切菜顺序也编排好了,执行时就只剩按步骤下锅,效率自然高。

2.2 融合规则不是全开就好,得看场景取舍

ATC 通常自带一堆融合规则,默认配置对大多数场景是合理的。但我在 K3 项目里发现,有些融合在长文本推理下反而是负优化。比如某些算子融合会引入更大的中间缓冲,如果输入序列特别长,中间缓冲占用的显存会显著增长,甚至触发显存碎片整理,得不偿失。

我采用的思路是:先跑一遍默认配置拿到基线,再通过配置参数有选择地关闭个别高显存融合规则,对比前后端到端时延。这个阶段我用过的配置项包括:

# 示意:开启自定义融合规则,按场景调整 --fusion_switch_file=fusion_rules.json --op_precision_mode=high_precision_for_first_token

fusion_rules.json里可以控制某类算子是否参与融合。我最终保留了三类关键融合:

  • Attention 内的 QKV 拼接与拆分的融合;
  • 激活函数与归一化的融合;
  • 长序列场景下的分段 softmax 融合。

关掉低收益融合后,显存占用降了约 18%,同时吞吐又提升了一截。这里要提醒:别迷信“规则越多越好”,融合规则是有显存代价的,长序列场景下尤其要实测比对。

3. 最容易被忽略的一层:内存布局与Cache命中

3.1 格式转换里的隐藏开销

很多人在性能报告里看到“数据搬运占 30%”,第一反应是“带宽不够”,实际上大部分搬运开销来自格式转换(Layout 转换)。深度学习框架里存在多种数据排布方式:NCHW、NHWC、NC1HWC0、ND 等,不同硬件对某种排布有更友好的计算效率。模型导入后,ATC 会尽量统一 Layout,但有时两个算子无法共享同一排布,就只能在边界插入转换算子。

K3 模型初版图里,这类转换算子数量相当惊人。我数了一下,一条 4096 token 的推理链路里居然出现了 120 多次转换操作,其中大部分是低效的重复转换。比如一段数据先转成某种加速格式,下个算子又转回原始格式,再下个算子再转,完全是在来回搬运。

解决思路不是手动删算子——那种操作在计算图里往往不安全——而是从源头改导入配置,强制让模型入口数据格式与中间层主流格式保持一致,再配合 ATC 的 Format 优化,把这些重复转换整体消除。这轮改动之后,数据搬运类算子耗时直接降了 20% 以上。

3.2 静态内存池与显存复用策略

CANN 运行时默认的内存管理相对保守,每次访问都会走内存池申请和释放,这在动态场景下没问题,但推理服务追求的是极端稳定和低抖动。

我做了两件事:

  • 开启静态内存池预分配。根据 Profiling 中的峰值显存记录,给推理进程预留一块足够大的缓冲,运行时不再动态申请,彻底消除每次请求的内存分配抖动。
  • 复用中间张量缓冲区。对于生命周期不重叠的算子,让它们共享同一块显存区域。这种复用策略需要开发者手动确认“两个算子不会同时存活”,我从热点图里挑出 12 组中间张量做了合并,显存峰值又降了约 10%。

这里有个容易踩的坑:静态内存池虽然稳定,但如果预分配过大,会挤压同一张卡上其他任务的显存空间;预分配过小,跑到长序列时反而会触发重新分配。我的经验是预留“最大峰值 + 20%”作为安全余量,并通过压测脚本反复验证。

4. 算子级调优:K3里几个关键算子的手动改写

4.1 自定义算子为什么是我最后才用的手段

图编译和内存优化把大头都吃掉了,这时候再往下压,就绕不开一些关键算子本身了。但我不建议一上来就写自定义算子——它需要做边界对齐、性能验证、精度对齐,维护成本极高。只有当 Profiling 明确显示某个算子在热点里反复出现,且融合引擎无法自动优化时,才是值得动手的信号。

K3 项目里有三个位置满足这个条件,我挑一个最典型的说:

  • Softmax内部的计算;
  • 激活后的ScaleAdd组合;
  • 长序列场景下的Attention Score计算。

其中ScaleAdd组合是最容易手动融合的类型。它的计算本质很简单:对输入张量做缩放后加上另一个张量,但框架默认实现会分配两个临时张量,分两次写回内存。手动融合后,只需要一次读取、一次计算、一次写回。

4.2 一个 ScaleAdd 融合的伪代码示例

下面给一个裁剪过的示意版本,核心是去掉中间分配:

def fused_scale_add(x, A, scale, bias): # 原实现:y = x * scale + bias,需要额外分配tmp # 融合实现:直接就地计算,避免一次内存往返 for i in range(x.numel()): # 这里故意用标量循环描述语义,真实实现请用向量指令 x.data[i] = x.data[i] * scale + bias return x

真实实现不能像上面这样写循环,要用框架提供的向量指令或目标设备 API 做分块处理。但核心思想不变:减少中间张量的分配与拷贝,能直接转化为性能提升。

这轮算子改写,配合上一节的内存复用,让端到端时延又缩掉 12% 左右。注意,这个收益是建立在前面所有优化之上的,单独做算子改写效果会差很多。一个常用的优化顺序建议是:先图编译、再内存、最后算子。

4.3 动态Shape场景的编译选择

K3 是长文本模型,输入长度每轮都在变化。这意味着同一份算子代码可能在运行期面对几十种不同的 Shape。CANN 的图编译里,动态 Shape 模式会引入额外开销——每次遇到新 Shape 都可能触发重新编译。

我的处理办法是给序列长度做分档,把动态 Shape 范围限制在有限集合内。比如 512、1024、2048、4096 四档,每档单独做图编译,运行时按实际长度落入最近档位。代价是显存会稍微放大,但换来的是编译抖动消失,P99 时延稳了很多。

提示:分档时记得给每个档位留余量,比如长度 4096 的档位按 4608 去编译,防止边界溢出。

5. 实测中踩过的坑:本地快、上了服务就变慢

5.1 算子下发瓶颈:CPU 侧同步等待

优化完图编译和算子后,单线程推理速度已经很亮眼了。但一接上并发服务,吞吐立刻掉回优化前水平,第一反应是“显存带宽不够”,可 Profiling 显示设备利用率并不高,倒是 CPU 侧出现了明显的同步等待。

排查链路是这样的:

  1. 压测并发 8 路,观察设备利用率和 CPU 占用;
  2. 发现 CPU 占用率接近 100%,设备利用率只有 60%;
  3. 抓取线程栈,确认推理线程在算子下发接口处大量等待;
  4. 定位到原因是流同步操作太多,每执行几个算子就做一次全局同步。

解决方案是把多个独立算子的执行流合并,减少同步点,同时把纯推理逻辑与前后处理逻辑放到不同线程,避免预处理阻塞推理调用。这轮调整后,并发场景下的吞吐才真正追平单线程的预期。个人体会:图编译优化的是设备侧效率,而服务侧性能瓶颈往往在 CPU 调度和同步策略同步优化。

5.2 首次推理慢与算子缓存预热

另一个坑是“服务刚启动时特别慢”。当时压测脚本第一轮请求耗时是后续请求的 5 倍以上,第一次怀疑是冷启动加载模型,预加载之后仍然慢,这才发现是算子缓存未命中。

CANN 运行时会缓存部分编译产物,但缓存文件默认可能没有开启,或者缓存目录没有写入权限,导致每次进程启动都要重新编译算子。解决方案分两步:

  • 显式指定算子缓存目录,并确认该目录是持久化存储;
  • 服务启动后加一个“预热请求”,让第一轮推理触发所有关键算子编译,把缓存写热,后续请求直接命中。

这个点的收益其实很大,它不改变稳态性能,但对在线服务的扩容、重启场景影响巨大。每次发布新版本时,预热逻辑能帮我们省掉至少十几秒的首包等待。

5.3 半精度溢出引发的“黑屏式”失败

K3 本身允许 FP16 推理,但 FP16 的动态范围比 FP32 小很多。长文本场景下注意力分数容易变得很大,再经过 softmax 之前如果做缩放,就可能溢出,不表现为崩溃,而是表现为输出质量突然变差。

排查过程比较曲折:一开始怀疑是显存被污染,后来逐个算子做精度对比,才锁定注意力分数计算节点。解决办法是在该节点前插入一个 rescale 操作,或者开启更高精度的累加模式,用极小的性能代价换回稳定精度。

这里也提醒一下:调优到后期,一定要配一个精度测试集。每次改动算子后,拿同一批输入跑一遍输出,与 FP32 基线做 cosine 相似度对比。我给自己定的标准是:核心 logits 相似度不低于 0.999,低于这个值就视为不可接受。

6. 性能复现Checklist与进一步优化的方向

6.1 复现配置清单

很多读者拿到调优文章,最想知道的是“我该怎么在机器上复现”。我把整个优化过程收敛成一份可执行的清单,尽量简化但不省略关键项:

步骤关键动作建议参数 / 操作
1确认运行环境CANN 工具链版本齐全,设备型号唯一
2导出模型FP16 权重,输入序列分档编译
3生成离线模型开启融合规则,关闭低收益融合项
4启动推理服务开启算子缓存,静态内存池
5预热启动后跑一轮全档位预请求
6压测8 路并发,记录 P99 与吞吐
7精度校验与 FP32 基线对比余弦相似度

这个清单也是我后来做 A/B 验证的通用模板。每次改完配置,跑一遍轮遍历,性能数据直接和上一轮对比,完全靠数据说话。表格里第三行和第四行的参数最容易被忽略,建议提前确认是否生效。

6.2 还能压榨的空间:多卡并行与量化感知训练

K3 推理跑通之后,我们并没有立刻收工。后续方向上还有两条明显的路可走:

  • 多卡并行。长文本模型天然适合按序列维度切分,多卡流水并行可以让总吞吐继续涨,但要注意跨卡通信开销,小 batch 场景下通信占比会升高,不一定值得。
  • 量化感知训练。当前是 FP16 精度,如果换成 INT8 量化,矩阵乘算子的计算强度更高,显存占用也能降下来。前提是精度损失可控,建议用一小部分数据做量化校准,先把精度曲线拉出来再做决定。

我给团队的预期是,这两条路合起来还能再带来 30%~50% 的收益,但需要投入额外的工程资源,性价比需要根据业务体量权衡。

最后聊一个我这次项目里印象最深的事:性能提升多少,有时候反而不如一张可复现的清单重要。中间有两次我们都觉得“优化到头了”,结果回头一看,前一轮的配置根本没提交到正式环境,实际跑的基线一直少了几行参数。把图编译配置、算子缓存开关、预热脚本全部整理成配置文件和自动化任务之后,后续每次调优都变得特别顺畅。如果你也在做类似的推理加速,建议从一开始就养成记录配置、复现对比的习惯——这比任何一次性的性能数字都值钱。

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

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

立即咨询