2026/10/31 11:35:15

昇腾TBE算子开发:DSL与TIK路线、Tiling及双缓冲性能优化

昇腾TBE算子开发:DSL与TIK路线、Tiling及双缓冲性能优化 算子开发这件事最让人抓狂的从来不是数学公式推不出来而是逻辑写完了、单测跑通了一上板子发现性能只有理论值的十分之一查半天发现是搬运没对齐。昇腾AI处理器软件栈里的张量加速引擎TBETensor Boost Engine就是专门用来对付这类问题的算子开发框架。简单说它干的事是把你想让芯片算什么翻译成芯片能高效执行的指令流中间那些内存分层、数据搬运、流水排布、多核切分的脏活累活它替你兜掉一大半。这篇东西主要面向三类人刚接手昇腾平台算子开发、被Tiling参数搞得头晕的新手从通用GPU编程模型转过来、想搞清楚达芬奇架构差异的老手以及做模型迁移、发现某些算子跑不动需要自己动手补的中高级工程师。我会把TBE的两条技术路线、算子从原型定义到上板测试的完整链路、Tiling和双缓冲的真实计算过程以及我踩过的那些坑一条一条摊开讲。1. 先搞清楚TBE在整条软件栈里站哪一级1.1 从模型到指令中间隔了多少层很多人一上来就闷头写算子实现结果编译报错一堆看不懂的字段本质是没搞明白自己写的这段代码最终会落到哪一层。昇腾AI处理器软件栈大致可以按越往下越贴近硬件来理解最上面是各类训练和推理框架往下一层是图引擎负责整张计算图的优化、算子融合、内存规划再往下是算子库和算子开发框架也就是TBE所在的位置最底下是运行时和驱动负责把编译好的二进制下发到AI Core上执行。TBE处在一个承上启下的关键位置。往上它要接受图引擎传来的算子描述——输入输出的形状、数据类型、属性参数往下它要产出能在AI Core上跑的二进制。中间这一段的转换正是TBE存在的意义。你可以把它理解成一个翻译官加调度员翻译官负责把数学语义翻译成硬件指令调度员负责安排这些指令按什么顺序、在哪个计算单元、用哪块内存执行。这里有个容易混淆的点TBE不是一个算子库它是一套开发框架。昇腾内置的算子库里有大量已经写好的算子日常调用直接用就行但当你的模型里出现了内置库覆盖不到的算子或者某个算子在你的场景下性能不达标就需要用TBE自己动手写。所以TBE的定位更接近造工具的工具而不是工具本身。理解这个分层还有个实际好处出问题的时候能快速定位是哪一层的锅。比如报错说找不到某个算子的实现那是算子注册和信息库的问题报错说内存越界那是Tiling算错了跑出来结果不对但单算子测试是对的那多半是图优化阶段的融合出了问题。层次清楚了排查方向就不会跑偏。1.2 达芬奇架构决定了TBE必须分层设计要理解TBE为什么长成现在这个样子得先看一眼它服务的硬件。昇腾AI处理器采用的是达芬奇架构一个AI Core内部并不是一块统一的计算资源而是被切成了好几块各司其职的单元Cube单元专门做矩阵乘加算力最猛Vector单元做逐元素运算、激活函数、类型转换这类向量操作Scalar单元做标量控制和地址计算。与之配套的还有一套层级分明的存储最外层是全局内存GM容量大但带宽有限往里是L1 Buffer再往里是Cube专用的L0A、L0B、L0CVector单元则主要依赖统一缓冲区UB。这种计算单元分离加存储分层的设计带来一个很直接的后果数据不会自己跑到计算单元旁边必须显式搬运。而且搬运的单位不是字节是32字节一个block所有地址都得按这个粒度对齐。这就解释了为什么TBE的TIK路线里你会看到密密麻麻的data_move调用和stride参数——这些不是啰嗦是硬件真的需要你把每一趟搬运的起止、跨度、重复次数说清楚。分层设计的另一个后果是同一份计算逻辑放在Cube上跑和放在Vector上跑写法完全不同。矩阵乘这类适合Cube的算子核心是把大矩阵切成能塞进L0A、L0B的小块让Cube单元连续不断地做乘加而像加法、ReLU这种逐元素算子核心是把数据搬进UB凑够一个repeat的长度做向量运算再搬回去。TBE的DSL路线帮你把后者的调度自动化了TIK路线则把前者的控制权完全交给你。1.3 谁会真正用到TBE三类典型场景第一类是自定义算子补齐。你去部署一个从研究侧传过来的模型图解析完发现有个算子内置库里没有比如某种特殊结构的归一化或者自定义的损失函数这时候只能自己写。第二类是性能调优。内置算子为了通用性往往在Tiling上偏保守如果你的场景形状固定、数据量大手写一个针对性Tiling的版本性能提升三到五成很常见。第三类是精度对齐。某些算子在不同精度模式下行为不一致模型精度掉点找不到原因最后定位到某个算子的累加精度上这种情况也得改算子实现。这三类场景对开发者的要求是不一样的。第一类重在能跑通写完能出正确结果就行第二类重在跑得快需要懂Tiling、懂流水、会看profiling数据第三类重在抠细节得把数据类型转换、累加器位宽、舍入模式这些搞明白。我建议刚上手的人先把第一类走通一遍完整经历从原型定义到上板验证的全流程心里有个整体地图再去碰优化。2. DSL和TIK两条路线到底该走哪条2.1 DSL路线用表达式描述算什么让框架决定怎么算TBE DSL这条路线建立在开源深度学习编译器TVM之上核心思路是计算与调度分离。你只需要用Python写清楚输出张量的每个元素是怎么由输入元素算出来的至于这个计算怎么切块、怎么并行、怎么搬数据交给调度器去推导。举个最直白的例子写一个逐元素相加from tbe import tvm from tbe import dsl def add_compute(input_x, input_y, output_z, attrs): shape input_x.shape dtype input_x.dtype data_x tvm.placeholder(shape, namedata_x, dtypedtype) data_y tvm.placeholder(shape, namedata_y, dtypedtype) res tvm.compute(shape, lambda i: data_x[i] data_y[i], nameres) with tvm.target.cce(): sch tvm.create_schedule(res.op) sch[res].parallel(res.axis[0]) return sch这段代码里tvm.compute那一行描述的是语义——第i个输出元素等于两个输入第i个元素之和。parallel那一行是唯一的调度干预告诉编译器沿第0维做多核并行。剩下的切块、对齐、搬运框架会自己生成。写起来确实舒服代码量小可读性高改逻辑也方便。但舒服是有代价的。调度器自动推导出来的方案是按照一套通用启发式规则生成的它不知道你的形状有多大、也不知道你的数据有什么特殊规律。对于形状规则、计算简单的逐元素算子自动方案通常够用可一旦碰上规约、转置、卷积这种访存模式复杂的算子自动生成的代码往往离硬件峰值差得远。而且DSL的调试体验不太好出错时你看到的是一层层lower之后的中间表示很难直接对应回自己写的那几行。我的经验是DSL适合拿来快速验证算法正确性或者写那些结构简单、后续也不太需要极限优化的算子。如果你的目标是先跑通再说这条路省时省力。但如果这个算子将来会成为模型的热点别指望DSL自动生成的版本能让你满意。2.2 TIK路线像写汇编一样亲手安排每一次搬运TIK全称Tensor Iterator Kernel走的是完全相反的思路。它给你一套Python接口但这套接口几乎是一一映射到硬件指令上的声明一块UB上的buffer、从GM往UB搬多少个block、对UB里的数据做一次向量加、再把结果搬回GM。每一步都是你显式指定框架不替你做任何决策。from tbe import tik def add_tik(input_x, input_y, output_z, kernel_nameadd): tik_instance tik.Tik() x_gm tik_instance.Tensor(float16, (4096,), namex_gm, scopetik.scope_gm) y_gm tik_instance.Tensor(float16, (4096,), namey_gm, scopetik.scope_gm) z_gm tik_instance.Tensor(float16, (4096,), namez_gm, scopetik.scope_gm) with tik_instance.for_range(0, 8, block_num8) as blk: x_ub tik_instance.Tensor(float16, (512,), namex_ub, scopetik.scope_ubuf) y_ub tik_instance.Tensor(float16, (512,), namey_ub, scopetik.scope_ubuf) z_ub tik_instance.Tensor(float16, (512,), namez_ub, scopetik.scope_ubuf) tik_instance.data_move(x_ub, x_gm[blk * 512], 0, 1, 32, 0, 0) tik_instance.data_move(y_ub, y_gm[blk * 512], 0, 1, 32, 0, 0) tik_instance.vec_add(4, z_ub, x_ub, y_ub, 1, 8, 8) tik_instance.data_move(z_gm[blk * 512], z_ub, 0, 1, 32, 0, 0) tik_instance.BuildCCE(kernel_namekernel_name, inputs[x_gm, y_gm], outputs[z_gm])这段代码里有几个数字不是随便写的。data_move的第五个参数是burst_len单位是32字节的block512个fp16元素正好是1024字节也就是32个block。vec_add的第一个参数是repeat次数一个repeat处理256字节512个fp16元素是1024字节需要4次repeat。后面那几个8是repeat之间的地址步长单位也是block因为源和目标都是连续排布的步长按最紧凑的方式给。这些参数只要有一个给错要么结果不对要么性能崩。但反过来说正因为每个参数都在你手里你可以做出非常激进的优化把搬运和计算交错起来做双缓冲、把尾块单独拎出来处理、针对特定形状把repeat次数拉满减少循环开销。这些手段在DSL里基本没法精细控制。2.3 选型对照别凭感觉按这张表来维度DSL路线TIK路线编程抽象计算表达式加调度显式搬运与指令编排上手难度低几十行能跑通高需要懂内存分层和指令语义性能上限中等受自动调度限制高可逼近硬件峰值调试友好度较差报错定位到中间表示较好出错位置直观适用算子类型逐元素、简单规约、激活函数卷积、矩阵乘、复杂访存模式形状变化适应性强动态形状友好弱通常需要按形状分档Tiling实际项目里这两条路线不是互斥的。我见过比较务实的做法是先用DSL快速出一个正确版本把整条链路跑通、精度对齐做掉等确认这个算子确实是性能热点之后再用TIK重写一遍两版并存通过配置切换。这样既不耽误进度又给后续优化留了口子。还有一个常被忽略的选型因素团队维护成本。TIK代码和硬件绑得紧将来硬件迭代参数可能要跟着调DSL代码抽象层高迁移成本低。如果这个算子只是临时补丁、长期看会被内置库覆盖那用DSL写完扔那儿更划算。3. 算子开发全流程从契约定义到上板验证3.1 算子原型与信息库接口契约必须先钉死很多人写算子喜欢直接冲实现结果写完发现框架侧传进来的数据类型和自己预期的不一样返工。正确顺序是先定契约再写实现。契约由两部分组成算子原型和算子信息库。算子原型定义的是这个算子对外长什么样——有几个输入、几个输出、有哪些属性、属性的类型和取值范围。这部分通常用框架提供的原型定义接口来写把输入输出按顺序注册进去属性用指定类型声明。原型一旦定下来上层框架在构图时就会按这个契约校验传错参数会直接在构图阶段报错而不是等到运行期才炸。from tbe.common.register import register_op register_op(AddCustom) def add_custom(input_x, input_y, output_z, attrs): pass算子信息库则是给编译器看的说明书一般是一个ini格式的配置文件里面要写清楚每个输入的name、dtype、format输出的对应信息以及这个算子由哪个引擎实现、精度模式怎么选、是否支持动态形状推导。这里最容易出问题的是dtype列表——你得把实际可能出现的所有数据类型组合都列全比如fp16配fp16输出fp16fp32配fp32输出fp32少写一种跑模型时就报算子不支持该数据类型。注意信息库里format字段的填写要和实际内存排布一致。昇腾平台上有ND、NZ、NC1HWC0等多种格式卷积类算子常用NZ逐元素算子一般是ND。格式写错不会立刻报错但会在图编译阶段触发插入转换算子性能莫名其妙掉一截而且很难查。我踩过一次坑信息库里只写了fp16测试用的也是fp16一路顺畅结果线上模型某层被混合精度策略改成了fp32直接报错。后来养成的习惯是只要信息库里写了某种dtype就一定配一条对应的单算子测试用例避免纸上谈兵。3.2 计算逻辑实现先把边界情况想清楚实现阶段最考验功力的是边界处理。形状不整除是最常见的情况总元素数不是tile大小的整数倍最后一块要么补零要么单独走一套逻辑。补零看着省事但如果算子里有规约操作补进去的零会影响结果这时候必须老老实实把尾块单独拿出来算或者在规约时把无效元素排除掉。另一类边界是类型转换。fp16的表示范围有限做累加时如果中间结果一直用fp16存很容易溢出成inf。标准做法是搬进UB之后先转成fp32做累加累加完再转回fp16输出。这个转换不是免费的会多占UB空间、多花指令周期但对于求和、均值、方差这类算子不做转换精度基本没法看。# 累加场景先升精度再累加最后降回输出类型 acc_ub tik_instance.Tensor(float32, (tile_elems,), nameacc_ub, scopetik.scope_ubuf) x_fp32 tik_instance.Tensor(float32, (tile_elems,), namex_fp32, scopetik.scope_ubuf) tik_instance.vec_conv(2, tile_elems, x_fp32, x_ub, 8, 8) tik_instance.vec_add(1, acc_ub, acc_ub, x_fp32, repeat, 8, 8, 8)还有一类容易被忽视的边界是空张量。某些模型在特定配置下会传出元素数为0的张量如果代码里直接按元素数算tile、算循环次数可能算出0次循环或者负值直接段错误。稳妥做法是在入口处判断一下元素数为0直接返回。3.3 编译、单算子测试与精度对齐实现写完只是开始接下来是编译和验证。TBE会把你的Python代码编译成设备侧可执行的二进制这个过程会暴露大量问题接口参数个数不对、buffer大小超过UB容量、指令参数越界、循环边界算错。编译报错信息通常比较具体对着提示改就行难的是编译通过但结果不对。单算子测试是这一阶段的主力工具。它的做法是构造一组输入数据在CPU侧用参考实现算一遍在设备侧跑一遍然后逐元素比对。比对时要注意容差设置浮点运算有舍入误差累加顺序不同结果也会有细微差异所以不能要求逐位相等。一般fp16场景设相对误差1e-3、绝对误差1e-3fp32场景设1e-6量级。容差设太严会误报设太松会漏掉真实错误。我一般会准备三组测试数据第一组是常规的随机数据检查基本正确性第二组包含极值比如最大值、最小值、零、负数检查溢出和边界第三组故意构造不整除的形状比如总元素数是tile大小的1.5倍检查尾块逻辑。三组都过了才敢往模型里集成。精度对齐这块还有个隐性问题不同框架对同一个算子的语义定义可能有细微差异。比如某些归一化算子里的epsilon加在哪个位置、除法用的是倒数还是真除这些细节在单算子测试时如果参考实现和框架行为不一致测试能过但模型精度会掉。稳妥做法是直接拿框架侧的输出来做参考而不是自己另写一份。4. 性能优化实战Tiling、对齐与流水线4.1 Tiling不是一个拍脑袋的数字Tiling的本质是把大张量切成能塞进UB的小块块太大装不下块太小搬运开销占比高。理想值怎么算先看UB的可用容量。假设某型号的UB可用空间是192KB做一个逐元素加法单缓冲下每处理一个元素需要三份空间——两个输入一个输出fp16每份2字节那么单次能处理的元素数是 192×1024 ÷ 3 ÷ 2 ≈ 32768。但实际不能这么满打满算因为还有中间变量、还有对齐填充。如果要做双缓冲空间直接砍半tile元素数降到16384左右。再考虑对齐fp16下32字节对应16个元素tile大小取16的整数倍。综合下来取8192或者16384都是合理区间。参数取值说明UB可用容量192KB因型号和预留空间而异数据类型float16每元素2字节单tile数据份数3份2入1出逐元素加法双缓冲放大系数2搬运与计算重叠理论tile元素数16384192×1024÷3÷2÷2对齐后取值1638416元素对齐正好整除提示上面这个算法是给手动Tiling用的估算起点实际还要看算子里有没有额外的中间buffer。有规约、有转置的算子中间结果往往比输入还占地方得重新算。比手动算更省事的是自动Tiling。框架提供了一套根据算子输入形状和UB约束自动推导切分方案的机制你只要把每个buffer的大小需求描述清楚它会帮你算出合理的切分和循环次数。对于形状变化频繁的场景自动Tiling是首选写起来省心适应性强。缺点是它保守遇到极端形状可能切得不够优。我的策略是先上自动Tiling保证通用性同时用profiling看几个典型形状的耗时如果发现某个高频形状明显拖后腿再针对这个形状注册一套手动Tiling规则两者并存运行时按形状匹配。这样既覆盖长尾又优化了头部。4.2 对齐这件事差一个字节就差一截性能32字节对齐这条规则几乎所有TBE的文档都会提但真正理解它为什么重要的人不多。硬件搬运数据是按block走的一个block 32字节。如果你的起始地址没有按32字节对齐硬件要么搬不了要么得拆成两次搬再拼接效率立刻打折。更麻烦的是这种问题不会报错只会让你的算子慢而且慢得很隐蔽——profiling里看到的只是搬运时间变长看不出具体原因。具体要注意几个地方。一是GM上的张量起始地址如果上游算子输出的形状不是32字节的整数倍下一个算子的输入地址就可能不对齐。这时候要么在Tiling时把每块的大小凑成32字节的整数倍要么在搬运用stride参数把不对齐的部分跳过去单独处理。二是UB内buffer的偏移。你在UB里连续声明好几个buffer如果前一个的大小不是32字节整数倍后一个的起始地址就歪了。我的习惯是声明时就把每个buffer的尺寸向上取整到16个fp16元素也就是32字节宁可浪费一点空间也别让地址错位。三是repeat之间的步长。用向量指令时如果源数据在UB里不是连续排布需要靠stride参数跨过去。stride的单位是block算的时候得把元素数除以16再取整除不尽就得调整buffer布局改成连续排布。这一步算错是最常见的结果对但慢的原因。4.3 双缓冲与多核把搬运时间藏起来一个算子跑得慢profiling打开一看往往不是计算单元忙而是它在等数据。数据从GM搬到UB需要时间如果搬完才开算、算完才搬下一块那计算和搬运是串行的总耗时等于两者之和。双缓冲要解决的就是这个准备两块buffer搬块A的同时算块B算完B的时候A已经搬好了如此交替总耗时趋近于两者中的较大值。实现上双缓冲需要配合事件同步。标准写法是给每块buffer配一对事件搬运前等对应事件、搬运后置位计算前等搬运完成事件、计算后置位。这套逻辑在TIK里有现成的封装但参数得自己算对事件数量和buffer数量要匹配少配一个就会死锁或者读到脏数据。# 双缓冲骨架示意 for i in range(loop_times): cur i % 2 if i 2: tik_instance.wait_event(evt_calc[cur]) tik_instance.data_move(ub[cur], gm[i * tile], 0, 1, burst, 0, 0) tik_instance.set_event(evt_load[cur]) tik_instance.wait_event(evt_load[cur]) tik_instance.vec_add(repeat, out_ub[cur], ub[cur], ub[cur], 1, 8, 8) tik_instance.set_event(evt_calc[cur])注意双缓冲的收益不是线性的。搬运时间远大于计算时间时重叠能带来的提升有限这时候该优化的是搬运本身比如减少搬运次数、增大单次搬运块大小。反过来计算远大于搬运时双缓冲收益也不明显。它最见效的场景是两者量级相当重叠后总耗时能砍掉将近一半。多核并行是另一条路子。一个AI Core的算力有限多个Core同时干活能把吞吐拉上去。做法是把总数据按核数切分每个核处理一段连续的区间。切分时同样要考虑对齐不能让某个核的起始地址歪掉。核数也不是越多越好切得太碎每个核的启动开销和尾块处理成本会吃掉并行收益。经验值是单核处理的数据量不要低于几十KB低于这个量级并行反而变慢。实测过一组数据同样是形状为4096×4096的fp16逐元素加法单缓冲单核跑下来大约是基准值改成双缓冲后耗时降到约六成再叠加上8核并行最终耗时压到基准的两成出头。这个提升幅度在不同算子上会有差异但量级大致如此。5. 那些年踩过的坑问题排查实录5.1 结果不对但测试通过融合引入的坑最让人头疼的一类问题是单算子测试全绿集成到模型里精度就掉。这种情况八成是算子融合惹的祸。图引擎在优化阶段会把相邻的算子合并成一个融合算子如果融合规则写得不对或者你的算子在融合场景下的行为与单独执行时不一致就会出问题。排查思路是从融合边界入手先把融合关掉用纯单算子跑一遍确认结果正确再打开融合看从哪一层开始输出出现偏差。定位到具体融合规则后检查它的输入输出映射是否完整、属性传递有没有遗漏。我遇到过一次融合规则里漏传了一个epsilon属性融合后这个值取了默认的1e-5而实际模型里传的是1e-3精度自然对不上。还有一类是内存复用导致的。图引擎会做内存规划让生命周期不重叠的张量共用一块GM。如果你的算子存在异步操作——比如搬运还没完成就返回了——那这块内存可能已经被下一个算子覆写了。这种问题在单算子测试时不会暴露因为只有它自己在用那块内存。解决办法是把同步点补全确保所有异步操作完成后再返回。5.2 性能断崖式下跌先看对齐再看Tiling性能问题我一般按这个顺序查先看profiling里各个引擎的占用率再看搬运和计算的时间占比最后看是不是有明显的等待空洞。如果发现搬运的带宽利用率极低第一个怀疑对象就是对齐。用工具把每次搬运的起始地址、长度打出来逐个对32取模不整除的就是嫌疑。这种情况在小形状上尤其常见比如某个张量总共就100个元素按32字节切只能切出几个完整block剩下全是尾块怎么搬都慢。如果发现计算单元经常空转那多半是Tiling切得太碎循环次数太多每次循环的启动开销累积起来很可观或者双缓冲没配对导致计算在等搬运。还有一种可能是核数设得太多每个核分到的数据量太少并行收益被启动开销抵消。如果发现整体耗时比理论值高出好几倍检查一下是不是插入了格式转换算子。前面提过信息库里format写错会导致框架自动插入转换这个转换算子本身也要花时间而且往往是在你最不希望有额外开销的地方出现。现象高概率原因排查动作搬运带宽利用率低于三成地址未按32字节对齐打印每次data_move的地址和长度取模计算单元空转多双缓冲未生效或事件配对错误检查wait/set事件是否成对循环次数异常多Tiling切分过碎打印tile大小和循环次数性能随核数增加反而下降单核数据量过小计算单核处理的最小数据量结果偶发错误异步未同步或内存被复用关闭融合后复测补齐同步点5.3 编译期报错八成是契约没对齐编译报错里最常见的是两类一类是接口参数不匹配一类是buffer超限。接口参数不匹配大多是信息库和实现没有对齐。信息库里声明了三个输入实现里只处理了两个或者信息库里写的是ND格式实现里按NZ来搬。这类问题的特点是报错信息指向性较强通常会点出是哪个输入、哪个字段的问题照着改就行。buffer超限是指你申请的总UB空间超过了物理容量。TBE在编译期会做静态检查超了直接报错。这里要注意的是双缓冲会让占用翻倍很多人算的时候按单缓冲算的开了双缓冲就超了。还有一种情况是编译期看不出来、运行期才炸的——动态Tiling时根据实际形状算出的buffer大小超限这种情况得在Tiling函数里做好上限保护超出就降档。5.4 动态形状最容易翻车的地方动态形状是算子开发里的深水区。模型里很多算子的输入形状要到运行期才能确定比如序列长度可变、batch大小可变这时候Tiling就没法在编译期算死了。标准做法是把Tiling逻辑拆出来做成一个能在运行期执行的函数输入是运行时的形状输出是切分方案和循环参数。设备侧的执行代码则通过参数接收这些运行时信息不在代码里写死。听起来简单实际做的时候有两个坑一是Tiling函数的输出要能正确传到设备侧参数传递的顺序和类型必须严格匹配二是设备侧代码里不能有任何依赖编译期形状的常量包括buffer大小和循环边界这些都得在运行期读。我见过一个更隐蔽的坑Tiling函数里用了整数除法形状不能整除时结果向下取整导致最后一块数据没被处理到。单算子测试用的是能整除的形状一路通过换成实际模型的不规则形状尾部数据静默丢失。这类问题不会报错只会让精度缓慢下降查起来极费劲。稳妥做法是Tiling里所有除法都显式处理余数尾块单独走一条分支。5.5 几条掏心窝子的实操建议第一先跑通再优化别一上来就冲着极限性能去。我见过不少人第一版就手写TIK加双缓冲加多核结果卡在精度不对上回头一查是Tiling算错了前面所有优化工作全部作废。正确顺序是先DSL出正确版本验证精度再用TIK重写重写时保持同样的Tiling参数和逻辑只改实现方式这样出问题容易对比定位。第二善用编译期的静态检查。TBE在编译阶段会做大量校验包括地址对齐、buffer越界、参数类型。这些检查虽然有时报错很啰嗦但都是免费的护城河别想着绕过。遇到检查报错就老老实实改代码绕过去的问题迟早会在运行期找回来。第三无论如何都要准备一组脏数据用例。常规的随机测试覆盖不到极值、零、负数、非常规形状而这些恰恰是线上最容易出问题的地方。我的习惯是每个算子至少准备五组数据随机、全零、全最大、含负数、不能整除的形状。这五组过完心里才踏实。第四profiling数据要看趋势不要只看单点。某一次运行慢可能是噪声连续多次都慢才是真问题。看的时候关注三类指标Cube和Vector的占用率、各级搬运的带宽、以及等待事件的耗时。这三个数字能覆盖绝大多数性能问题的方向。第五算子命名和版本管理要有规矩。模型里同一个算子可能同时存在内置版本和自定义版本命名不清晰很容易调错。我一般会在自定义算子名里带上明确的标识和版本号配置文件里也标注清楚避免上线时张冠李戴。写到这里最后分享一个我觉得最值钱的习惯每次调完一个算子把关键的Tiling参数、踩过的坑、以及当时的profiling截图整理成一份短文档存起来。半年后再遇到类似形状的算子翻出来改改就能用比从头推一遍快得多。TBE这套东西细节太多靠脑子记不住靠文档才能滚雪球。