杀进决赛!这 28 支队伍把算子优化卷疯了!
大家好,我是小林。
现在聊 AI,基本都在聊模型本身的能力:谁的跑分更高、谁的上下文更长、谁又发了新版本。
但有件事很少有人提,同样一个模型,同样一块卡,不同的人跑起来,速度能差出一截。
那问题来了,差在哪?
差在模型能力底下的那一层:算子。
这两天正好碰上一件事。中国电信旗下的中电信人工智能科技有限公司,和华为一起办了场「中国电信星辰杯 · 高校 AI 算子开发挑战赛」,决赛刚刚打完。

这场赛事共有177 所高校、539 支队伍报名,一路淘汰下来,进决赛的只剩 28 支。总奖金 70 万,一等奖 20 万,这奖池可以说真香!
选手要干的活,就是围绕昇腾 NPU,开发和优化大模型背后的算子。
这么说还是有点抽象,我直接讲讲决赛里三支队伍做了什么。
• 哈工大深圳的「南工工南」没急着改代码,先给程序做了个性能体检,发现刚启动的那 147 微秒芯片几乎在空转,时间全耗在一步很笨的统计上。他们换了种更快的统计写法,这 147 微秒直接没了。 • 上海交大那支队伍解决的是同一份数据反复搬运的问题。有一块数据在整个计算里要用到两次,原来的写法是用一次就去远处取一次,他们改成取来就留在芯片的缓存里,第二次直接拿来用,省下 2.46 微秒。 • 复旦的「bububu」发现的问题更直接,活分错人了。芯片上有两种干活的部件,专门算矩阵乘法的那个一直闲着,活全堆给了另一个,他们把矩阵乘法还回去之后,这一段的耗时变成了原来的十分之一。对了,还有个细节挺有意思。这次不少队伍是带着 AI Agent 来比赛的,开发、测速、性能分析都交给 Agent 并行跑,自己负责定方向和拍板,有支队伍二十天里迭代了 800 多个版本。

你发现没有,这三件事翻译成大白话,分别是「先量一量时间都花在哪里」「同一份东西别来回取」「活要交给合适的人干」。
这次我们就一起看看,算子到底是个什么东西?那些把算子跑快的招数是怎么想出来的?以及决赛题难在哪里?
看完你会发现,这些招数里不少地方,跟我们平时优化程序的思路是相通的。
────01|算子是什么?────一个大模型跑起来,说到底就是在做计算,海量的乘法和加法。你随便问它一句话,背后都是上万亿次这样的运算,而这些运算最终都要落到 GPU 或者 NPU 这类加速卡上去执行。
这里面最常见、也最占时间的一类,叫矩阵乘法,说白了就是把一大片数字按固定规则两两相乘,再把结果一一累加起来。
可问题是,加速卡那边并没有一个按钮叫「帮我算个矩阵乘」。它认的是一条条具体动作,把哪块数据从显存搬到计算单元手边,哪些计算单元在什么时候做乘加,算完的结果又往哪里写。一次完整的矩阵乘法,要拆成成千上万条这样的动作才能完成。
所以中间必须有人来做翻译,把「矩阵乘法」这个数学概念,翻译成这块卡能一步步执行的动作。
这层翻译的产物,就是算子。
说回你最熟悉的地方。
你用 PyTorch 写过这样的代码吧,torch.matmul(a, b) 做个矩阵乘,torch.softmax(x) 归一化一下。你写的是一行 Python,这一行背后,就有一个翻译好的算子在加速卡上跑。
模型跑一次,这样的算子要被调用成千上万次。矩阵乘、加法、归一化、激活函数轮番上阵,一个接一个把数据过一遍,最后才轮到你看见那段回答。
看到这估计有同学就问了,PyTorch 不是都有现成实现吗?为什么还要人去开发算子?
因为翻译这件事,从来就不止一种译法。算子规定的是「要算什么」,没规定「怎么算」。
比如逐元素加法,要求就是把两组数据对应位置的数字加起来。规则是定死的,可数据一次搬多少、交给哪些计算单元处理、中间结果放哪里,这些全是实现者要安排的事。同一个算子,换种实现方式,在同一块卡上就是能差出一截。
打个做饭的比方。每道工序要完成什么,这是一回事。厨师怎么安排动作、怎么使用灶台,那是另一回事。菜谱一样,手艺不一样,出菜速度就不一样。
框架给你的是一套通用实现,可面对新的硬件、新的数据形状、新的计算组合,里面往往还有不少空间可挖。星辰杯比的就是这个。
算子还可以分成两类。
一种算得重,比如矩阵乘法,数据搬进来以后要做大量乘加,瓶颈卡在算力上。另一种算得很轻,比如加法、归一化、激活函数,数据搬进来算一下就完事,瓶颈卡在搬运上。
第一种叫计算密集型,第二种叫访存密集型。
这个区分很关键。后面所有的优化招数,基本都是冲着这两种瓶颈去的,算力不够就想办法别让计算单元闲着,带宽不够就想办法少搬几趟。
说白了,一次任务花掉的时间,不只是计算本身。计算单元动手之前得先拿到数据,不同任务之间还可能要启动和等待,这些都得算进去。
接下来我们就沿着这两种瓶颈,看看开发者具体能改哪里。
────02|算子优化,具体改哪里?────动手之前,得先搞清楚数据都放在哪里。
加速卡上的存储分两层。
• 一层是显存,容量大,几十上百 GB,模型权重、输入输出都堆在这里,可它在芯片外面,计算单元每次去取数据都得跑一趟远路。 • 另一层叫片上存储,就在芯片内部,紧挨着计算单元,存取飞快,但容量比显存小了好几个数量级。你可以把显存想成一个大仓库,把芯片上这块存储想成手边的工作台。仓库装得多,工作台放不下几样东西,但伸手就够得着。
后面这几招,基本都是围绕这两层存储做文章。
第一招,算子融合先拿一个最简单的计算举例。一批数字,每个先加 1,再乘 2,最后把负数变成 0。
这三步都是典型的访存密集型计算,每一步的运算量都小得可怜,加一下、乘一下而已。
假设把这三步拆成三个独立的计算内核执行。这里的「内核」,你可以理解成实际交给加速卡执行的一段计算程序。
第一段程序读入数据,做加法,把结果写回显存。第二段再把中间结果读出来,乘完以后又写回去。第三段继续读、算、写。
数据读了三趟,也写了三趟。真正的计算没花多少时间,时间全耗在来回搬运上了。
那能不能把这些步骤放在一起?读入一批数据以后,接着把能做的计算一口气做完,最后再写出结果。
读一趟,写一趟,中间全在芯片上解决。这就是算子融合,计算要求一点没变,省掉的是中间结果的读写和内核启动开销。
厨房里也好理解,食材都拿到手边了,顺着能做完的工序就一起做了,不用反复装盘、搬运。当然,哪些步骤能合并,得看数据之间有没有依赖,也得看硬件资源够不够。
第二招,分块复用再回到仓库和工作台。炒一道菜,葱要跑去仓库拿,蒜又跑去拿,下一锅用同样的配料,还得再跑一趟。厨师动作再快,来回取东西的时间也得算进去。
轮到计算密集型算子,这件事就更值得计较了。
拿矩阵乘来说,同一行、同一列的数据,在计算过程中要被反复用到很多次。既然搬一趟的代价省不掉,那就让搬进来的数据在芯片上多待一会,多干几次活再走。
问题是工作台就这么大,整个矩阵根本放不下。
所以得切。把大矩阵切成合适的小块,搬一块进来,把这块能参与的计算全做完,再换下一块。这种切分和分块计算,有个常见的名字叫 Tiling。
你看,复用和切块其实是一件事的两面。正因为数据要被反复用到,才值得把它搬到芯片上。也正因为芯片上放不下,才必须切成块来搬。
这里的「切」很有讲究。块切大了,芯片上的空间放不下。块切小了,能复用的次数变少,循环和搬运的开销又上来了。
第三招,流水和分核数据搬进来了,复用也安排好了,还有一种浪费得盯着,硬件在空转。
第一种空转是等数据。计算单元手里的活干完了,下一批数据还在路上。解法是让搬运和计算交叠起来,算当前这一批的时候,提前把下一批搬进来。
就像你炒这道菜的时候,旁边的人已经在备下一道菜的食材了。锅没停,人也没闲着。
第二种空转是等同伴。加速卡上有很多个能并行工作的计算单元,活得分给它们干,这一步叫分核。
举个极端点的例子。四个计算单元,100 份互不依赖的任务,一个拿走 70 份,另外三个各拿 10 份,后面会发生什么?
三个早早干完,然后一起干等剩下那个。整个算子的耗时,由最慢的那个核说了算。
这个分法看着很假,谁会故意这么干?
可真实情况里的不均,往往不是人为分错的,是数据自己长成那样。序列有长有短,该算的位置这里密那里疏,按最朴素的方式一刀切下去,分到各个核手里的活就是不一样多。所以开发者得结合输入的形状,想办法把活重新打散、匀开。
你看,到这里计算量一点没减少,光是重新安排数据怎么来、活怎么分,就有这么大的优化空间。
最后一关,精度说到优化,经常还会听见「降低精度换速度」这个说法。
这里的精度,说的是每个数字用多少位来存。早些年常用 FP32,一个数占 32 位,后来大家普遍改用 FP16、BF16,现在新硬件连 FP8 都用上了。位数少了,同样的带宽一次能多搬几个数,计算单元一次也能多处理几个,两头都快。
那为什么不干脆一路降到底?因为位数少了,能表示的范围和精细程度也跟着缩水。
举个最常见的坑。一堆数字往一块累加,如果中间结果也用低精度存着,加到后面和已经很大了,这时候再往上加一个很小的数,可能直接就被吃掉了,加了等于没加。几千次累加下来,误差就滚起来了。
所以实际做法通常是混着来。数据搬运和乘法用低精度,省带宽也省算力,但累加这一步用高精度存着,别让小数被吃掉。
可不管怎么混,最后都得把输出跟参考结果比一遍。
跑得快当然好,可结果得先是对的。这条红线要一直带着,不能自己觉得「差不多」就算过了。
所以真动手的时候,这几件事的顺序大概是这样。先测一遍原始实现,看时间花在了哪里。如果计算单元主要在等数据,就优先研究融合、复用这些少搬几趟的办法。如果各个核忙闲不均,就去看分核方式。
每次改完,用相同的输入和环境重新测一遍,记录耗时,也检查结果对不对。
讲了一圈硬件,最后还是回到了程序员最熟悉的那套动作,定位瓶颈、改代码、再验证。
────03|星辰杯的决赛题,难在哪?────前面聊的都是通用机制。回到星辰杯,本届的决赛题是 Sparse FlashAttention 反向算子。
image-20261108132811090
名字挺长,我们拆开看。Attention 是 Transformer 的核心计算,FlashAttention 是它的一种高效实现,Sparse 说明只算其中一部分,反向说明算的是训练时用来更新参数的梯度。
有意思的地方来了,FlashAttention 的原理,前面其实已经替你铺好了。
标准的 Attention 怎么算?输入进来以后,先被变换成三组数据,习惯上叫 Q、K、V。先拿 Q 和 K 乘出一个巨大的注意力分数矩阵,存进显存,再读出来做 softmax,最后跟 V 相乘。序列越长,这个中间矩阵越大,来回读写的代价就越夸张。
FlashAttention 的思路是,把 Q、K、V 切成小块,搬一块到芯片上,在芯片上把分数、softmax、加权求和一口气做完,那个巨大的中间矩阵压根不往显存里存。
你看,这不就是分块加融合的合体吗?只不过用在了一个又具体又关键的计算上。
那反向为什么更难?
正向只用算一个输出,反向要同时算三路梯度,Q 的、K 的、V 的。更麻烦的是,正向为了省显存,把中间的注意力矩阵给扔了,反向偏偏还得用它。怎么办?只能按块重新算一遍。
还有件更要命的事,这三路梯度的累加方向还对不上。沿着一个方向遍历下去,能顺手把其中两路攒齐,剩下那一路的累加方向正好是反的。于是要么老老实实走两趟循环,要么让多个核往同一块地方累加,而后者就得处理写冲突。走哪条路,又是一道选择题。
于是一个反向算子里,同时塞着重算、三路梯度、方向冲突,还有块与块之间的依赖。芯片上那点空间要装的东西一下子多了,切块怎么切、什么时候搬、算完往哪写,每一步都更紧张。
这也是为什么,FlashAttention 的反向在开源社区里是出了名的硬骨头,既是训练里最吃性能的算子之一,也是公认最难写好的之一。
Sparse 又加了一层。
稀疏的意思是,注意力里有大量的块压根不需要算,比如因果掩码挡掉的那部分,直接跳过就行。计算量确实省下来了,可新问题跟着就来了。
还记得前面那个 70、10、10、10 的例子吗?要跳过的块,分布并不均匀。有的核分到的块密密麻麻,有的核分到几块就收工了,干完的只能干等着。
怎么把这些稀疏的块重新打散、尽量均匀地分给各个计算单元,就成了这道题最吃功夫的地方。
所以这道决赛题,相当于把前面讲的全考了一遍。融合要会,分块复用要会,流水和分核要会,最后还有一关,精度得对。
28 支队伍比的就是这个,而且这些东西,选手得一行行自己写出来。
星辰杯用的是 CANN 提供的 Ascend C,它的写法跟我们平时写 CPU 代码不太一样。CPU 上数据进不进缓存,基本不用你操心,硬件自己安排。
但在 Ascend C 里,数据从显存搬到芯片上这件事,要你用接口显式写出来,芯片上那块空间也得自己申请、自己排队复用。
换句话说,前面讲的分块、复用、预取,在这里全是代码里实打实的搬运指令。写得好不好,提交到 CANNjudge 平台上一跑,分数就出来了。
按照官方赛制,线上决赛赛题总分占 70%,现场评分占 30%。提交的代码还要过机审、助教复核和实验环境复现这三道关。
所以选手既要把实现交上去,也要在现场讲清楚自己到底做了什么。评审关心的代码质量和可复现性,跟我们在工程里关心的东西,其实是一回事。
────最后────聊到这里,再回头看开头那三支队伍做的事,应该就不难懂了。
南工工南先跑性能分析再动手,找的是时间到底花在了哪里。上海交大让那块数据留在芯片上不走,用的是第二招的复用。bububu 把矩阵乘还给专门算矩阵的单元,解决的是硬件空转。
说白了,他们面对的就是一份份具体的计算,研究数据怎么搬、任务怎么分、流水怎么排,以及改完以后结果还对不对。
当然也得清醒一点,单个算子跑快了,对整个模型的影响有多大,还要看这段计算原来占了多少时间。
随便算笔账,一段执行过程总共 100 毫秒,其中某个算子占 10 毫秒,优化到 5 毫秒以后,总耗时是 95 毫秒。省多少,取决于你优化的是哪一段。
所以真正值得花力气的,往往就是 Attention 这种既占大头、又难写好的算子。这也是星辰杯把它选成决赛题的原因。
这届比赛的答辩已经结束了,想蹲获奖名单和后续赛事的同学,可以关注中国电信和 AtomGit 的官方消息。说实话,我们平时都在上层用模型,很少往下看。
但偶尔下去看一层,会发现那些让模型跑得飞快的东西,背后没什么魔法,全是在数据搬运和任务分配上,一点一点省出来的。
下次再遇到模型跑得慢,除了看模型和硬件配置,不妨多问一句,时间到底花在哪里了?
