这个内测模型帮我把端侧推理速度飙升了37倍!记录Agent工程师的第一次昇腾生态算子优化经历

你好,我是司沐。

写在前面:本文只是笔者作为一个Agent工程师,第一次体验AI跑算子优化的过程记录与感想,并不是严谨的教学或者科普,因为笔者并非此领域的专家,过程中肯定会有一些事实性错误。如果您发现了哪里有问题,欢迎评论区指出与讨论,言辞激烈一些也没有关系,这样反而能帮助新人理解,与加深印象。

本文提到的代码已经同步上传Github仓库:minicpmv-ascend-orangepi。代码纯Vibe,仅供交流学习,也希望能帮上同样想在香橙派AI Pro上跑VLM的小伙伴~

上个月 WAIC 给我们团队送的香橙派 AIpro 8T 终于到货了,它有 8GB 内存,4 核 CPU,板载一块昇腾 310B4 NPU。到货之后,我左看看右看看,想知道这块 NPU 性能到底怎么样,能不能推一个端侧的小VLM,接一些传感器和外设,让它在阳台帮我种番茄。

如你所见,我的本职工作是 Agent 工程师,虽然平时也用过 torch、vllm,但对 NPU 算子与推理优化可以说是几乎一窍不通。模型这一层倒是熟,原始权重下过不少,safetensors、bin、gguf、onnx 的区别清楚,量化版本挑哪个心里有数,各种推理框架都是常用工具,但再往下一层就断了。

模型到底怎么在一块芯片上被执行起来,为什么每出一个新模型都要重新适配一遍,为什么同样的硬件换一套算子性能能差出几十倍,这些问题我一直没有时间弄明白。有时候看别人的优化博客,名词有点眼熟,连起来不知道在讲什么。

正巧,那几天赶上阶跃星辰给我发了新模型 step-explore 的内测权限,说是提升很大。于是我就抱着试试看的想法,把它接进 Claude Code,让它从零开始,试试能不能在这块新生态的昇腾 NPU 上把 MiniCPM-V 4.6 跑起来。

动手之前,我真不觉得这事能成。这种算子优化的任务,我以为只有 A\ 它家的强模型才做得动,其他模型来了都得铩羽而归。何况昇腾这套生态还新,网上能抄的东西不多,适配过的视觉模型更是只有 DeepSeek 去年出的那个OCR模型,所以很多坑得自己一个一个踩。

7 月 27 日早上七点四十开工,中间几次停顿,8 月 1 日凌晨五点多收工。

结果,家人们,它真做到了?!

阶段 解码速度
ollama 跑 4-bit 量化版,纯 CPU 0.15 字每秒
第一版跑上 NPU 1.19
凭直觉合并了一批算子调用 1.19,没动
绕开一个病态的卷积 4.94
去掉最后一处会触发重编译的写法 5.46
给递归步单独开一条路径 5.65
这块板子的物理上限 7.8

最后一行是它自己实测标定出来的天花板,5.65 已经是这块板子物理极限的 72%。生成一百个字,CPU 版要 653 秒,NPU 版 17.7 秒,快了 37 倍。

A single rising staircase chart shows decoding speed increasing from 0.15 字每秒 to 1.19, 4.94, 5.46, and 5.65, with three optimization steps and a nearby upper-limit marker at 7.8.

图 1:解码速度单位为字每秒;5.65 已达到这块板子物理上限 7.8 的约 72%。

到最后那天,网页界面做出来以后,我传了一张我的鱼缸照片上去,问他看到了什么。一秒多的首字延迟后,token以每秒五个的速度往外冒,汩汩的,有种异样的舒畅感。

那一下我是真有点激动。这块板子三天前都不认识这个模型。

经历了这些惊喜后,很难不生出分享的念头。一开始我想把整个流程梳理好,然后把代码po出去,但后来细细一想,像我这样只做Agent,但想了解算子优化这种新领域的工程师应该还不少。如果我也写一篇那样的技术博客,既比不上专业人士的专业度,又让有兴趣的人难看懂。

与其这样,不如就以我这个几乎不懂模型推理优化的人的视角,分享我在AI完成这些任务过程中的震撼,思考,以及带领一些同样不熟悉这个领域,却怀有好奇心的读者,一起走一下全流程,看看模型推理优化写算子,和我们平时做的写Agent、微调模型有什么区别,应该如何理解这个过程。

我们开始吧!

为什么不能拿现成的框架跑

我一开始的设想很省事:华为这么重视昇腾生态,网上应该有开源解决方案吧?慢就慢点,先跑起来。

explore 花了小半天翻代码,回来说这条路是堵的。310B4 这块NPU在生态内有点不上不下,已经停止维护很久了不说,性能也不是非常哇塞,所以很少有人用它跑大模型。

那直接从权重运行呗?香橙派上有带自己的 torch 和 transformer 库的。explore 又摆摆手,说问题不在权重。2.6GB 的 safetensors 没问题,问题在于跑这个模型需要的模型代码,仓库里没带。MiniCPM-V 4.6 的实现被合进了 transformers 5.7,仓库里既没有 modeling 文件也没有 auto_map,trust_remote_code 那条路走不通。

而 transformers 5.7 要求 Python 3.10 以上、torch 2.4 以上。板子上那套昇腾的 torch 适配是出厂预装的,编译的时候就绑死在 Python 3.9 和 torch 2.1,换不了。两头凑不到一起。explore 也看过 MindSpore,那边压根没有这个模型的支持,用它等于全量重写。

explore 选的路是照着权重文件里的 key,把这个模型在板子上重新实现一遍。二十七层视觉、二十四层语言,每一层的算法都得跟官方对上,然后把参数一个不差地灌进去。

我当时没意识到这句话的分量,于是就很甩手掌柜,留下一句:你看着办,尽力完成任务。

explore 对于这条指令,足足跑了5个小时。这五个小时是我最忐忑的时候,因为我也看不懂他在干什么(其实就没看),不知道几个小时之后,他会给我端上来惊喜还是屎山。

后来,我看了它的工作轨迹。explore 在开始前,先是顺手做了另一件事:在板子上另开了一个纯 CPU 环境,装新版 Python 和 transformers,用官方代码把模型正确跑一遍,把中间每一步的结果全存下来。图片预处理出来的张量、分词结果、每一层的输出、最后生成的一百个字,都存成文件。

这套东西成了整个项目的 baseline。它自己那份实现每改一次,就跟这个结果对一次,差在哪儿一眼能看见。后面每一个结论、每一次"这个改动不影响正确性"的说法,靠的都是它。

流程图展示官方代码在 CPU 上生成并保存各层中间结果作为 baseline,后续自己的实现逐层与其对比,以验证每次改动是否保持正确性。

图 2:这里的 baseline 是用于逐层核对正确性的 CPU 参考结果,不是性能对比基准。

我现在觉得这是全程最要紧的决定,而它是在还没写第一行推理代码的时候就想到的。

这也顺带回答了我那个"为什么每个新模型都要单独适配"。要适配的东西落在模型里每一个具体的算法结构上。这个模型的语言部分是混合注意力,二十四层里有十八层是 Gated DeltaNet,这种结构去年才流行起来,昇腾这边的老库没有现成的模型结构可用。结构一新,底下那套跑它的东西就得重新长一遍。

图示从带有“新结构”的模型权重逐层映射到重新实现的各层,再汇合成后端可执行的执行路径,并显示后端缺少现成支持。

图 3:概念示意:当后端没有现成支持时,适配工作需要覆盖模型权重结构中的每一层,并为其建立对应的执行路径。

通顺的乱码

7 月 27 日下午,模型第一次在 NPU 上跑通,输出是一段乱码。

不是那种一看就崩了的乱码。有一点点语法,词之间有一点点关系,中间还夹着几种语言,读起来像一个伪人学说话。它贴给我看的时候,我第一反应是权重哪里算错了。

可所有能查的地方都是好的。参数一个不多一个不少地对上了,视觉那部分跟baseline比对相似度 0.999925,每个环节单独拿出来测都正常。

它找这个 bug 的办法是把范围一层层收紧。先拿 CPU 上那把尺子,看 NPU 这边从第几层开始跟标准答案对不上,锁到语言模型里的 Gated DeltaNet。然后让 CPU 和 NPU 在同一个进程里并排跑这一个模块,每算完一小步就打印数字的最大值,看哪一步的量级突然跳变。

跳的地方是一个补零操作。

这里插一句算子是什么,我自己也是这时候才彻底清楚。模型跑起来的时候,芯片上执行的是一个一个很小的动作,比如两个矩阵相乘,比如取最大值,比如把一段数据后面补几个零凑够长度、。每个动作背后都有一段专门写给这块芯片的程序。一个模型跑一次,就是这些小程序按固定顺序被调用几百上千遍。

补零是最不起眼的那种。Gated DeltaNet 要求序列长度是 64 的倍数,实际长度 91,所以要往后补 37 个零。

结果那块地方补出来的不是零,是没被清理过的内存,绝对值最大到 1e37。不报错,不崩溃,就这么混进后面的运算。原本 0.27 的中间结果变成 21008,原本 4.26 的状态变成 43 亿。数字全崩,模型照常吐字,只是吐出来的话没有意义。

它讲清楚这件事的时候,我第一句话是,这不是最基础的函数吗,怎么会连这个都错。

它的回答大意是,这个函数在英伟达卡上没问题,在这块昇腾芯片配 CANN 7.0 上有问题,而这个组合用的人不多。改法很简单,换成拼接一段确定是零的数据上去。

改完之后,prefill 的输出跟标准答案相似度 0.999855,生成的一百个字逐字一致。

A two-lane debugging diagram compares CPU and NPU results layer by layer, narrows the mismatch to the “补零” operation, shows uninitialized memory contaminating downstream values without an error, and then shows explicit zero concatenation restoring correct output.

图 4:CPU 参考结果作为 baseline;NPU 在“补零”步骤产生未清理过的内存,替换为显式零拼接后,生成的一百个字逐字一致。

看到 explore 做到这一步,我有种很奇妙的感觉:它可能真的能完成任务。

一个函数怎么可能慢一千倍

跑对了,但慢。每秒 1.19 个字。

7 月 28 日我给它派了个活,先跟 ollama 上那个 4-bit 版本比一下速度,再看看算子还有多大提升空间,顺便估一下 500 字输入、200 字输出的时候每个环节要多久。我想知道的就一件事,这东西能不能用。

它先按直觉改了一版。判断是模型每吐一个字要触发大概 300 次算子调用,瓶颈肯定在调用次数上,于是把 Gated DeltaNet 的四个输入投影合成一次矩阵乘,每个字少发 60 次调用。

改完测,1.1906 变成 1.1887。只快了一点点,误差级别。

它自己在报告里写,“我的直觉是错的”。然后它不猜了,写了个脚本把每一处单独掐表。

掐表结果 耗时 占一步 840 毫秒的比例
300 次算子调用的开销总和 12 毫秒 1.4%
18 个线性注意力层 755 毫秒 89%
其中那一个卷积(18 层各一次) 671 毫秒 80%
6 个全注意力层 37 毫秒 4%

单个算子的调用开销只有 27 到 45 微秒,它之前改的方向从根上就不对。真正吃掉时间的是一个卷积,单次 37.30 毫秒。

我看到这儿的疑问是,一个函数怎么可能慢成这样。

它的解释我理解成这样。这些算子都是针对具体硬件、具体数据形状(shape)专门写出来的程序。同一个功能,形状换一个,就可能落到一条没人认真优化过的路上,退化成很笨的实现。这个卷积要分成 6144 组分别算,这块芯片处理这种形状特别吃力。

接下来这一手我看得赏心悦目。explore 先回头看了这一步数学上到底在算什么,然后发现,生成的时候这个卷积的输入宽度只有 5,卷积核宽 4,而且只取最后一个输出。展开来就是 4 个数乘 4 个权重加起来,别的什么都没有。剩下那些复杂度全是通用函数自带的。于是它把这个函数整个绕开,换成一次乘法加一次求和,然后在 CPU 上跟原函数逐元素比对,差异 1e-6,只有浮点舍入的量级。

37.30 毫秒变成 1.86 毫秒,快了 20 倍。整体 1.19 变成 4.94。这中间隔了一个晚上,28 日凌晨三点改完的。

一张算子 profiling 与替换前后对照图:计时柱状图显示卷积耗时远高于 300 次调用开销,右侧将该卷积化简为 4 个数与 4 个权重的乘加并展示整体速度从 1.19 提升到 4.94。

图 5:逐算子计时后发现,真正瓶颈是单次 37.30 毫秒的卷积;将其替换为等价的乘加后,整体速度由 1.19 提升至 4.94,约为 4.2 倍。

我睡醒起来看到这个数字的时候是懵的。前一天晚上我还在琢磨这东西是不是就这样了,一秒钟一个字,拿来看看图凑合,别的干不了。

这就是我那第三个问题的答案里最实的一块。差别不在硬件,在于你调用的那段程序有没有落在被人优化过的路上。落不上,它照样把结果算对,只是慢一千倍,而且不会有任何东西提醒你。

同样的剧本后来又演了两次。做多轮对话的时候,追问一句要等 9 到 10 秒,它先猜是块宽设大了,改完只快 1.2 倍;再去掐表,发现真凶是一行取长度的代码逼着芯片停下来等主机回话,而这一行在 18 个层里各跑一遍,把整个队列排空。改掉之后 9.5 秒降到 1.42 秒。

三次都一样。凭直觉改,零收益或者 1.2 倍;掐完表再改,20 倍、4.2 倍、5.8 倍。

占用才 5%,你怎么好意思说榨干了

7 月 28 日下午,我提了个要求,以后跑分的时候顺手把 CPU 和 NPU 的占用率一起记下来,我想看看到底榨出了多少性能。

数字出来我就懵了。NPU 的核心占用率只有 5% 左右。而它在报告里写的是,单序列解码基本榨干了。

基于自己早期调显卡集群推理的经验,我猜可能是通信带宽受限。于是我发过去一条消息:“那我想知道,为什么NPU占用这么低,你却说性能全榨干了?是带宽限制吗?解释一下”。

它没有嘴硬,也没有绕。上来先说那个表述是错的、容易误导人,然后专门写了一整节来讲清楚。这一节我来回读了三遍,读明白之后我觉得这几天就值了,光这一节就值。

它先实测了两个上限。这块芯片一秒能做 2.48 万亿次浮点运算,一秒能从内存里读进 13.5 GB 数据。两个数一除得到 184,意思是要让计算单元不闲着,每读进 1 个字节就得配上 184 次运算。

再看模型生成文字的时候在干什么。每吐一个字,要把全部权重从内存过一遍。一个 fp16 权重占 2 字节,参与一次乘加,也就是 2 次运算。折下来每读 1 字节只做 1 次运算。

1 和 184。差了 184 倍!

所以哪怕代码写得再完美,计算单元最多只能忙 0.54%,实测 0.34%。而内存带宽那头用掉了 73%。核心不是在偷懒,它是发出取数指令之后卡在那儿干等。至于工具为什么显示 5% 而不是 0.5%,因为那个数统计的是核心处不处在占用状态,一个卡着等内存的核心也算忙。

它还给了个反证,这个反证特别有说服力。

同一块板子,同一份代码 每字节配多少次运算 核心占用率
生成文字(单人) 1 5%
生成文字(8 人合批) 3.07 36%
编码图片 454 86% 到 87%

编码图片这部分远超过 184,属于被算力卡住,所以能跑满。如果是代码把硬件浪费了,它不可能跑满。

结论它拆成两句。对"一个人自己聊天"这种用法,已经贴着这块板子的内存速度上限,继续抠算子只剩一点余量。对"整块板子",算力几乎全空着。

图示单用户生成文字时,内存带宽利用率接近上限,而计算单元大部分时间等待数据,因而NPU核心占用率很低但单用户性能已接近上限。

图 6:图中“1”与“184”表示每读取1字节权重所配的运算次数;实际负载低于机器平衡点,因此低核心占用率不代表内存带宽没有成为瓶颈。

空着的算力可以拿去换别的。八个人同时问问题,权重照样只搬一遍,总吞吐能到 21 字每秒。

漂亮,非常清晰!很直观的展示出了带宽与算力之间的关系。

第一次跑 53 分钟,第二次 49 秒

这块板子上跑推理时,有个我挺困扰的现象:每次改算子之后,第一次跑模型极慢,之后就正常。最夸张的一次,一个新算子跑一次请求花了 53 分钟。

同一件事 第一次 之后
编码一张图 13.3 到 14.5 秒 1.34 秒
首字延迟 138.8 秒 1.12 秒
生成 100 个字 1419 秒 20.2 秒
长 prompt 那次完整请求 53 分钟 49 秒

它给的解释是,这块芯片上的算子遇到没见过的输入形状,第一次执行要现场编译一个专用的小程序出来,一次十几秒起,编完存硬盘,下次直接用。

我听到存硬盘就顺口问了一句,那预热的结果是一个持久化文件吗,是 .om 文件吗。

.om 这个词我是从昇腾文档里瞄到的,听起来类似 .guff 之类的预编译二进制格式。

它说不是。.om 是把整个模型当成一整张图离线编译出来的东西,这个项目没走那条路,因为模型里有递归状态和动态形状,转不过去。落在硬盘上的是一个一个算子各自的二进制,当时一共 3804 个,34MB。文件名是串哈希,由算子类型、输入形状、数据类型和一堆属性算出来。

这句话读懂以后,前面所有奇怪的现象一下全串上了。

形状变了哈希就变,哈希变了就是另一个文件,另一个文件就要重新编译。而用户随便换个问题,输入长度就变了。所以我每问一个新长度的问题,它都要在那儿重编一整套,实测一次 609 秒。

explore 现在也察觉到了这个策略的不合理,于是做了一长串改造,把输入长度补齐到固定档位,把缓存换成预先分配好的固定大小,还有好几处取数据的写法也全改了,因为原来那些写法切出来的长度会跟着问题变。

改动 换个长度提问的等待时间
最初 609 秒
长度补齐到固定档位 78 秒
改掉取末位结果的写法 6.5 秒
缓存尾部不再裁剪 2.3 秒

对比图展示动态形状使不同输入长度生成不同算子哈希并反复现场编译,而固定到预设档位后复用同一编译缓存,等待时间从609秒降至2.3秒。

图 7:右侧的2.3秒表示长度与缓存相关写法全部固定后,换一个输入长度提问时的等待时间。

每一步都拿 CPU 那把尺子重新对过,生成的每个字都跟标准答案一致。

还有个坑更阴。这块板子上内存不够的时候进程是被悄悄杀掉的,没有报错没有堆栈,日志里只剩几行不相干的东西。查到最后发现编译器默认要开 8 个进程并行编译,每个都是当前这个 2GB 进程的完整副本,7.4GB 的板子必死。改成 1 个,再把预编译放进一个不加载模型的干净子进程里一个一个做,子进程被杀也不影响主进程。113 个组合全部编译成功。

不开并发的话还是五点几吗

批处理做完那天,它报了一串数字,同时处理 8 个请求,总吞吐 21 字每秒。

我想起来两年前测试ollama和vllm并发时候的经历,如果开很多并发推理请求,总速度会变多,但每个请求速度会变慢,而且影响首字延迟。(btw,那会ollama的并发做的非常差,20并发以上能和vllm效率差出一个量级)

于是为了确认这点,我问:这个批量推理只对多人同时用有效吗,如果就我一个人用,还是那个五点几?

答案是,对,还是五点几。

explore 还解释了批处理为什么几乎免费。生成一个字的绝大部分时间花在把权重从内存搬进计算单元,搬进来之后真正的计算量小得可怜。八个人的活凑一起算,权重只搬一遍,多出来的那点计算不要钱。

同时处理几个请求 总吞吐 每个人感觉到的速度 首字延迟
1 5.66 5.66 1.19 秒
2 9.97 4.99 2.21 秒
4 15.25 3.81 4.36 秒
8 21.05 2.63 8.68 秒

四行对比图展示同时处理1、2、4、8个请求时,每行只搬运一次权重并分发给多个请求;请求数越多,总吞吐越高,但每个人的速度越低、首字延迟越长。

图 8:图中每一行代表相同硬件和代码下的一种批处理规模,权重只搬运一次,随后被该批次的多个请求共同复用。

这也是为什么线上服务的吞吐量看着吓人,但并不是说多个用户就要多加一份卡。

我拍的一次板,和它推翻自己

有一条我一直觉得别扭。服务端每来一张新图片,都要把 1.1GB 的视觉部分搬进 NPU,编码完再释放。我在网页上换一张图,要等半分钟起步。

早先的结论是这两部分不能同时待在板子上,因为实测峰值内存会顶到 6.9GB,而总共 7.4GB。这个结论写在报告里,有数据支撑。

7 月 31 日下午我跟它说,第一点,视觉这部分肯定要常驻。

它没拿旧结论顶回来,重新测了一遍,然后发现当初那个 6.9GB 的峰值来自算子现场编译时抢的内存。而这些算子现在已经全部预热完了,那个峰值本身不存在了。也就是说,那个结论依赖的前提早就没了,只是没人回头看一眼。两部分可以同时待着,稳态占 4041MB,还剩 609MB。

改完之后,换一张新图的加载从 28 到 120 秒变成 0,编码本身只要 1.17 秒。也就是说我之前每次换图等的那半分钟,九成以上是在搬数据,不是在算。

A two-panel comparison shows the visual tower being loaded into the NPU again for every new image before warmup, versus remaining resident in NPU after warmup so a new image can be encoded without the long loading step.

图 9:左侧表示改造前每次换图都要重新加载视觉部分;右侧表示算子预热完成后的稳态。

它在笔记里写了一句话我很喜欢,大意是当造成某个结论的前提发生变化之后,要回头重新检查那些"我们已经否掉了"的结论。代价它也如实写了,批处理吞吐掉 8%,峰值内存多占 656MB,余量只剩 624MB,并发再往上加会顶到板子上限,所以它在启动时加了条告警。

这几天我到底干了什么

我把三个会话的记录导出来数了一下,数完有点想笑。

消息条数 67 2130
其中"请继续" 38(因为测试版模型不太稳,加上我没有压缩上下文,断了很多次) 0
消息长度中位数 3 个字 巨多
工具调用 0 1190 次
在板子上跑命令 0 797 次
改文件 0 260 次

除了"请继续",我说过的有用的话大概就这些。让它跟 ollama 比一比速度。问这块板子的上限到底是多少。要求跑分的时候把 CPU 和 NPU 占用一起记下来。质疑那个 5%。拍板视觉部分常驻。它想去做量化的时候我拦了一下,说量化先不做,先把流式输出做了,我想先看见字一个一个往外蹦。

也有我判断错的时候。我报过一个 bug,说模型好像每轮对话都会忘记前面说的话。它跑了四组实验,日志显示缓存一直在正常复用,没复现。但它顺手加了几样东西,对话被清掉或者服务重启之后,页面上会跳一条橙色提醒,告诉你这一轮是从零开始的。因为这种两边都不报错的失忆确实是真实存在的一类问题,只是那次不是。

它也漏过我的话。我说"第一点,视觉塔肯定要常驻",后面还跟了一句让它顺便查 bug,它只做了常驻那一半。过了半天我问,现在前端还是必须传图片,你改了吗。它回,没有,我没改,这是我的疏漏,现在就改。

但是整体来看,explore 全程的注意力与理解力始终在线,长期注意力超强,从来没有发现任务遗失在中间,思路也一直很顺。就是有时候偶尔会走一些弯路,忘记自己之前写的代码是什么逻辑(当前这里可能也是因为cc的上下文压缩压的不是时候),不过最终都能自己把自己纠正。

当然,因为不知道阶跃的训练数据里面有没有很多写算子的数据,如果没有很多的话,那explore的表现就真的十分亮眼了,体感上我甚至愿意用它做Claude的平替。

和 ollama 的对比

跑完之后拿 ollama 的 4-bit 量化版做了个横向,同一张图,同一个问题,同样一百个字。

ollama 4-bit,纯 CPU 本项目 NPU fp16
模型体积 1.6GB 2.6GB
预填充 4.67 秒 1.12 秒
解码 0.15 字每秒 4.94
生成 100 个字 653 秒 20.2 秒
CPU 占用均值 73.9% 32.1%

权重体积是对方 1.6 倍、完全没量化的前提下,解码快 33 倍,同时把 CPU 让了出来。这台板子还得留着 CPU 去读传感器。

“写算子”"推理优化"到底是在干什么

这是我这三天最想弄明白的一件事,现在能答上来了。

在这之前,我其实无法想象"推理优化"到底是在干什么。但现在我通过学习explore的行为思路,已经有一个大概的认知了。我把它拆成了五件事:

第一件,把这个模型的数学在目标硬件上重新实现一遍,并且证明它跟原版逐位对得上。

这是最容易被忽略、工作量却最大的一块。框架里那份实现是给 CUDA 写的,换到昇腾上有些算子没有、有些算子行为不一样、有些库根本没编进去。所以它照着权重 key 把二十七层视觉和二十四层语言重写了一遍,然后拿 CPU 参考环境产出的金标张量一层一层对。视觉 0.999925,prefill 0.999855,生成的一百个字逐字一致。

这一步没有任何"性能"可言,但它是后面一切的地基。没有这把尺子,你连"我到底改坏了没有"都不知道。这个项目里最凶的那个 bug,一个补零函数返回垃圾数据,就是靠这把尺子一层层夹出来的。

第二件,找到真正的瓶颈,而不是你以为的那个。

这个项目里发生了三次,每次都是同一个剧本。凭直觉猜一个瓶颈,改完拿到零收益或者 1.2 倍;掐表量一遍,找到真的那个,拿到 20 倍、4.2 倍、5.8 倍。

猜的 收益 量出来的真凶 收益
算子调用次数太多 1.00× 一个 6144 分组的卷积,单次 37 毫秒 20×
块宽设得太大 1.2× 每层一次设备等主机的同步 5.8×
递归状态的临时张量太大 0.93×,更慢了 包装层里三十来个零碎算子 提了 3%

所谓 profile,就是拿 torch.npu.synchronize() 把每个算子、每个层单独夹住计时。听起来朴素得不行,但它是唯一靠谱的办法。这块板子上直觉全是错的,因为从 GPU 那边带过来的经验在这儿不成立。

第三件,针对这块硬件的脾气,把热点重写掉。

这才是狭义上的"写算子"。做法是先看清这一步数学上在算什么,再找一条这块芯片走得顺的路,把同样的数算出来。跟把代码写精巧没什么关系。

那个 37 毫秒的卷积,数学上就是 4 个数的加权和,换成一次乘法一次求和,20 倍。这个项目里还没走到最深那一层,真正的重写是用 AscendC 手写核函数,把好几步融进一个 kernel。想做量化就必须走到那一步,因为板子上这套 torch_npu 里没有融合反量化的矩阵乘,先反量化再相乘反而慢 3.7 倍。

第四件,把形状钉死,让编译出来的东西能复用。

这一条是我之前完全没预料到的,我之前只知道用transformer直推模型会比用推理框架慢很多,因为推理框架推的权重格式是编译过的,有优化。但这块板子上还不太一样:

这块芯片上,每一个算子碰到一个没见过的输入形状,第一次执行都要现场编译一个专用的小程序,一次十几秒起。所以你的代码里只要有一处形状会随着用户的问题长度变化,用户每换一个长度就要重编一整套。实测最惨一次,一个没预热过的组合,一次请求 53 分钟。

优化的内容因此变成了一件很反直觉的事,把所有跟真实长度有关的形状全部消灭掉。长度补齐到固定档位,缓存改成预分配的固定大小,取末位结果不能用切片要用 index_select,构造 mask 不能用变长切片要用比较加 where。全改完,换个长度提问从 609 秒降到 2.3 秒。

第五件,把闲着的资源换成你想要的东西。

前面第四节讲的那个 184 就是干这个用的。先量出这块板子的算力上限和带宽上限,算出机器平衡点,再算出你这个负载的算术强度,两边一比就知道你卡在哪、还剩多少余量、以及该往哪个方向使劲。

单人聊天卡在带宽上,算力空着 99.66%。那就拿空着的算力换吞吐,八个人合批,权重只搬一遍,总吞吐从 5.66 变成 21.05。想换单人的延迟,那是投机解码要干的事,这次没做。想减少搬运的字节数,那是量化,被软件栈挡住了。

这五件事里没有一件是玄学,每一件背后都有一个数字盯着。而且这五件事之间是有顺序的,第一件不做完,后面全是在给一个错误答案加速。

A left-to-right five-step sequence shows推理优化先建立正确性基线,再测量真实瓶颈,随后重写热点、固定输入形状以复用编译结果,最后调度闲置资源。

图 10:五个步骤是有先后依赖的:先证明结果正确,再谈性能;后一步建立在前一步完成的基础上。

至于 explore,说实话,我一开始真的没有寄予任何希望,因为上一代 step-3.7-flash 的代码能力并不出色,能力主要集中在Agent工具调用上了,社区里的伙伴们都说,只要不写代码,step就很好用,比如各种AI自动化,小龙虾,工作流等等场景。

但是没有想到的是,这个新的 step-explore 模型效果好的不像是 step3.x 系列!三天下来,技术路线是它自己选的,那把用来判对错的尺子也是它自己想到要先搭的,那个连报错都没有的底层 bug 是它一层一层比对量出来的,它还三次推翻过自己先前的判断。这些环节我一次都没能给出方案,我只是在旁边问为什么。期间几乎全是代码工作,大量的推倒重来,各种方案对比测试,各种实验验证。我的感受是阶跃模型终于在 Coding 场景站起来了。

尾声

先说学习这件事,这才是我这几天真正被震到的地方。

三天前我连"为什么解码会卡在内存带宽上"都讲不清楚。现在我能跟人讲清楚机器平衡点是怎么算的,为什么低占用率不代表硬件被浪费,为什么这块芯片上换个提问长度就要重新编译一整套东西。而这三天里我一行推理代码都没写。

以前挡着我的是这么一件事。资料其实一直都在,昇腾的文档、roofline 的论文、别人的优化博客,全都搜得到,可那些资料后面全是名词,而我身边没有一个能随时打断、随时追问的人。看两页,问题攒了七八个,没处问,就放下了。这种事我干过很多次。

现在这道障碍基本没了。除掉那些真正人迹罕至的领域,前沿到全世界只有几十个人在做、连模型都没见过多少语料的那种,剩下的绝大部分领域,学不学得会已经只取决于你愿不愿意动手。你可以随时打断它问一句这是什么意思,可以让它把同一件事换三种说法讲到你听懂,还可以让它当场跑个实验证明给你看。我这三天问出去的那些问题,搁在以前,是要占用一个真专家很长时间的。

更要紧的是,它不是给你讲一遍就完了,它是拉着你把这件事真做出来。我学到的每一个概念背后都挂着一个具体的数字和一次具体的失败,这种记忆和看完一篇博客完全是两码事。

再说 explore 这个模型本身。在我感受下来,explore 的手感已经相当逼近 Claude 了,保守估计能暴打sonnet4.6,与opus4.8扳手腕。我原本以为这类活只有 A\ 能干,现在看起来也并非如此。推翻A\暴政,世界属于开源!!!(当然,这个手感只是纯主观感受,且只在当前这个算子优化任务上感受得到,其他任务暂时没有大量体验,可能会与其他人的感受有些出入)

根据手感经验猜一下,从模型的专注力和生成速度上来看,我感觉 step-explore 的总参数量应该在 1.5T 以上,激活参数 40B 左右。很期待发布,不知道估的准不准。

希望本文可以帮到大家,我去研究怎么接外设了~

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐