计算图任务下发与异构多核调度:Cube/Vector核心的协同优化
我接手第一张要真正落到板子上的计算图时最大的困惑不是算子数学怎么写而是这张图最后到底该交给谁去算。一张图里有矩阵乘、有激活、有逐元素缩放、有 reduce如果全靠人肉判断哪一段该给 Cube、哪一段该给 Vector那这套异构多核编程模型基本就废了。后来我把整条链路从图构建一路跟到核内的流水线上跑了几遍才算摸清楚计算图任务下发这件事的骨架图怎么被拆成任务块、任务块按什么规则映射到Cube 或 Vector 核心、映射完之后靠什么保证依赖不乱、算完的数据又怎么在两级存储之间来回搬。这套逻辑不是某一家的私货任何带矩阵加速单元和向量单元的异构加速器抽象出来都是同一副骨架。这篇内容我想讲清楚三件事第一为什么现代加速器软件栈要把图当成调度的基本单位而不是算子第二从中间表示到最终下发中间经过哪几层转换每一层在做什么取舍第三Cube 和 Vector 这两类核心在流水线上怎么咬合以及我踩过的那些坑长什么样。适合已经能写单算子、但一上手整网就发现性能只有理论值一半的人看也适合刚接触异构编程、想搞清楚为什么我的核函数跑不满的读者。里面的 tiling 参数是我按常见容量假设手算的你套自己平台的规格改一下数字就能用。1. 异构多核编程模型为什么要围绕计算图组织1.1 三类核心的能力边界决定了分工方式先把这个架构里最核心的角色摆清楚。一颗典型的 AI 加速核内部通常不是单一的计算单元而是几类功能完全不同的执行部件挂在同一条指令流下Cube 单元负责高密度乘加本质上是一个大尺寸的矩阵乘加阵列擅长 GEMM、卷积这类计算访存比极高的操作Vector 单元是 SIMD 结构负责逐元素运算、比较选择、超越函数exp、log、rsqrt、reduce 和数据类型转换Scalar 单元处理控制流、循环计数、地址计算和标量分支再加上一个专门干搬运的MTE内存搬移引擎负责在全局内存和各级片上缓冲之间搬数据块。这四类部件的能力边界极其清晰Cube 一旦拿到的不是规整的矩阵块效率立刻掉到个位数Vector 的算力峰值远低于 Cube但它能处理任意形状和不规则索引Scalar 几乎不产生有效算力却决定了整个循环能不能跑起来MTE 不参与计算但它的带宽决定了 Cube 会不会饿死。分工不是设计者的偏好而是物理约束逼出来的结果。你在写单算子的时候可以手工把这四类部件编排好但一张真实网络有几百上千个算子节点手工编排的成本高到不可能维护。这就是异构多核编程模型必须存在的理由它要提供一层抽象让你描述算什么而把谁来算、什么时候算、数据放哪交给编译器和运行时去决定。这层抽象的自然载体就是计算图——节点是算子边是张量图本身携带了完整的依赖关系编译器拿到的信息量足够做全局决策。1.2 从算子级调度换成图级调度省掉的到底是什么我刚接触这套东西的时候有过一个疑问单算子我已经调得很好了每个算子内部 tiling 拉满、流水排好为什么还要多一层图调度答案藏在一个很朴素的数字里——任务下发的固定开销。假设一次 kernel 下发到设备、唤醒对应核心、建立上下文、等待完成、回收状态的固定开销在 3 到 10 微秒量级。单看一个算子这nothing但一张中等规模的子图有两百个算子节点纯下发开销就是 0.6 到 2 毫秒。而这张子图在 Cube 上的有效计算时间可能只有 0.3 毫秒。也就是说超过七成的时间花在了通知核心开始干活这件事上而不是干活本身。这在逐元素算子密集的网络里尤其致命因为每个 elementwise 算子的计算量本来就小开销占比能到 90% 以上。图级调度解决这个问题的路径有两条。一条是算子融合把 MatMul 后面紧跟的 Add、激活、缩放识别成一个融合任务中间结果不进全局内存直接在片上缓冲之间流转一次下发干完四件事。另一条是批量下发把一批无依赖关系的算子打包成一个任务组一次性提交给设备运行时在设备侧解开并按核心并行分发主机侧只付一次通信成本。这两条路径都要求编译器看得见整张图。算子级的视角是局部的你只能优化单个算子的内部效率图级的视角是全局的你能优化算子之间的空隙。而实践中空隙往往比算子本身更大。1.3 一套能落地的三层抽象我把这套编程模型拆成三层写代码和排查问题时都按这个分层去定位效率高很多。层级关注对象典型产物谁负责图表示层算子节点、张量边、形状与布局中间表示、优化后的图图编译器前端任务调度层任务块、依赖、流与事件可下发的任务序列执行引擎核内执行层指令序列、片上缓冲、流水同步二进制或指令流设备侧运行时与核函数图表示层关心的是语义等价变换——常量折叠、死代码消除、布局推导、算子融合这一层做完图的计算量可能已经少了一半。任务调度层关心的是物理映射——这块计算给 Cube 还是 Vector、切成多大、放在哪个队列、和谁有依赖。核内执行层关心的是时序——数据什么时候搬进来、搬几块、和计算怎么重叠。三层之间的接口是不断降维的图表示层的产物是带形状信息的 DAG任务调度层把它压成带依赖标记的线性任务序列核内执行层再把每个任务展开成指令流。任何一层的决策失误都会在下一层以完全不同的形式暴露出来——比如图层的融合边界切错了你在核内怎么调流水都救不回来只能看到 Cube 利用率上不去。2. 计算图拆解从中间表示到可下发的任务块2.1 图上必须携带的元信息少一项就要返工一张能被正确下发的图节点上挂的信息远不止算子类型和输入输出。我整理过一份最小集缺任何一项都会在后面某一层出问题形状与数据类型每个张量的完整 shape、dtype包括动态维度上的取值约束。动态 shape 是下发环节最容易翻车的地方因为 tiling 参数依赖于具体 shape。内存布局不只是 NCHW 还是 NHWC还包括分形格式的具体排布方式比如 16×16 分块后的元素顺序。Cube 对输入布局有硬性要求布局信息丢失会导致编译器插入大量隐式转换。对齐属性起始地址的对齐字节数、每行 stride 的对齐情况。搬运单元通常要求 32 字节或 512 字节对齐不对齐要走慢路径。生命周期张量在图上从哪个节点产生、被哪些节点消费、最后一个消费者是谁。这决定了片上缓冲能不能复用是内存规划的核心输入。量化参数缩放因子和零点的位置逐张量还是逐通道这直接影响 Cube 输出的反量化应该放在 FixPipe 阶段还是 Vector 阶段。我见过最典型的问题是把布局信息丢掉之后重新推导编译器推导出一个语义正确但性能很差的布局然后为了保证正确性插入一堆 transpose把 GEMM 的收益全吃掉了。图和算子之间的契约必须显式写清楚靠猜一定会出事。2.2 融合边界的取舍什么该合什么不能硬合融合看起来是稳赚的实际上有明确的边界。我的判断标准是三条第一融合后的任务块能不能在片上放下。MatMul Add 激活 这种融合中间结果可以留在 L0C 或者 UB 里不落全局内存前提是输出块的大小加上输入块的占用不超容量。一旦超了编译器就得把中间结果溢出到全局内存这时候融合反而比不融合更慢因为多了一次写回再读进来。第二融合后 Cube 和 Vector 的负载是否均衡。一个 Cube 密集的 GEMM 后面跟一个超轻的 BiasAdd融合收益明显但如果后面跟的是一个计算量很大的复杂激活比如带多个超越函数的分段函数Vector 侧的时间可能超过 Cube 侧融合之后整条流水被 Vector 拖住反而不如拆开让两个算子在不同的核上流水。第三有没有规约类操作横在中间。Reduce 会改变张量形状融合进去会让下游算子的 tiling 变得极不规则。我一般把涉及规约的边界当作天然的切割点。注意融合不是越多越好。我曾经为了减少下发次数把连续七个逐元素算子合成一个大 kernel结果核内指令数暴涨指令缓存溢出性能掉了 30%。融合的收益来自减少内存往返不是来自减少算子数量这两个目标是两回事。2.3 任务块粒度切太粗跑不满切太细全是开销任务块的粒度设计是整条链路上最需要手算的一环。粒度有两个维度单个任务块的计算规模由 tiling 参数决定和块的数量决定了并行度和尾块浪费。块切得太粗片上缓冲放不下只能退化成多次搬运流水深度也上不去切得太细每个块的计算量小于搬运开销Cube 就开始空转等数据。经验上单个 Cube 任务块的 K 方向迭代次数最好不要低于 8 次这样搬运延迟才有足够的计算来掩盖。块的数量要考虑核数。假设平台有 16 个计算核输出被切成 1024 个块那每核 64 个块负载非常均匀如果切成 1000 个块每核 62 个还余 8 个这 8 个块会让部分核多跑一轮尾块效应吃掉几个百分点的性能。分块数量尽量取核数的整数倍这是最低成本的优化。切分策略适用场景代价沿 M 方向切输出行数远大于列数或 batch 维度可并行输入 B 矩阵被所有块共享需广播沿 N 方向切权重矩阵列方向可分输入 A 矩阵重复加载M/N 二维切分大尺寸方阵最常用需仔细规划核间数据复用沿 K 方向切单块 K 太大、L1 放不下需要跨核累加引入额外同步3. 任务到核心的映射逻辑3.1 静态绑定还是动态回退这不是风格问题是工程问题任务块该给 Cube 还是 Vector有两种做法。静态绑定是在编译期根据算子类型直接决定GEMM 类一律走 Cube逐元素一律走 Vector生成的下发序列里每条指令的目标核心是硬编码的。动态回退是在运行期根据核心占用情况决定某个算子理论上能走 Cube 但当前 Cube 队列拥堵就退到 Vector 上执行一个降级实现。我一开始觉得动态回退明显更聪明实际用下来发现绝大多数生产场景都选静态绑定。原因很实在确定性。静态绑定下每次执行的时间是可预测的性能分析能定位到具体算子出了问题能复现动态回退会引入执行顺序的不确定性同一次推理跑十遍可能得到十种时间分布线上排查直接变成玄学。静态绑定需要解决的是某些算子在两类核心上都能跑选哪个的问题。我的判断规则很粗暴但有效看计算访存比。一个操作的浮点运算次数除以字节搬运量如果这个比值高于 Cube 的机器平衡点即峰值算力除以峰值带宽就坚决放 Cube低于 Vector 的平衡点就放 Vector落在中间的模糊地带按上下游算子的位置就近安排减少跨核数据交接。3.2 流、队列与依赖别把同步写成时间上的巧合设备侧的任务组织通常有三层结构流stream是最粗的一层同一个流内的任务按提交顺序执行不同流之间可以并发队列挂在流下面负责缓存待执行任务事件event是跨流同步的原语一个流在某个点 set 事件另一个流在这个点 wait。这套机制最常见的误用是用流内的顺序性去替代显式依赖。比如 A 算子在流 1、B 算子在流 2B 需要 A 的输出有人图省事把 B 也放到流 1靠提交顺序保证先后。这样确实不出错但流 1 变成了串行链多核并行度直接归零。正确的做法是把依赖画出来无依赖的算子放在不同流上并发跑有依赖的用事件连接。图编译器的责任是把 DAG 上的边翻译成事件的 set/wait 配对运行时的责任是按流分别推进。判断标准很简单时间线上任何一个时刻只要还有空闲核心就说明依赖表达得太保守了。提示事件是消耗性的还是可重复等待的不同平台实现不一样。如果某个事件被多个下游流等待要确认它支持多次 wait否则会出现第一个等待者拿到之后其余全部超时的诡异现象。这个坑我在两个平台上都遇到过。3.3 核内四级流水Cube 不空转的全部秘密一个 Cube 任务块真正跑起来内部是一条四级流水线在转MTE2把数据块从全局内存搬到 L1 缓冲。MTE1从 L1 再切到 L0A / L0B 这两个更小更快的矩阵缓冲。Cube从 L0A 和 L0B 读矩阵块做乘加结果累加进 L0C。FixPipe把 L0C 的累加结果搬出去可以直接写全局内存也可以直接写进 Vector 用的统一缓冲。四级流水要真正重叠必须做双缓冲——在每个缓冲位上放两份空间计算在用第一份的时候搬运去填第二份。以 K 方向循环为例第 i 轮 Cube 计算第 i 块的同时MTE1 在准备第 i1 块MTE2 在准备第 i2 块。这样才能做到 Cube 单元的占用率接近理论值。这里有个反直觉的点流水深度不是越深越好。每加一级缓冲片上容量就多占一份而容量是硬约束。深度 2 的双缓冲能吃掉的延迟大概是单个搬运周期的量级如果搬运延迟远大于计算时间比如 K 很小而矩阵很大加深流水也救不了因为瓶颈根本不在重叠上而在搬运带宽本身。这种情况应该做的是减少搬运量——比如换更紧凑的数据格式、复用已加载的块——而不是加缓冲。3.4 内存层级与格式转换到底该谁做Cube 的输入要求是分形块格式比如按 16×16 的元素块重新排布。而图上游产生的张量通常是行主序。这个转换谁来承担直接决定了性能。三种做法搬运时顺便转在 MTE 里做布局变换不额外占计算时间、用 Vector 单元单独转占 Vector 算力但灵活、要求上游直接产出目标格式最优但约束最强。我的优先级是第三种优先、第一种兜底、第二种只在动态形状下用。原因是 Vector 转格式是纯开销它不产生任何有效计算却要占用本可以用来做激活的算力。而搬运单元在搬数据的时候天然是逐块处理的顺便调整一下元素落位几乎不增加额外时间。这一点在设计图的时候就要考虑——如果一段子图的所有算子都走 Cube那就让整段的内部张量都用分形格式只在段的入口和出口各做一次转换。4. 动手把 MatMul 激活下发到 Cube 与 Vector4.1 工程准备与目录约定我这套示例用的是一个通用的异构加速器软件栈抽象函数名是自造的但结构和真实框架一一对应。目录这样组织project/ ├── graph/ # 图构建脚本 ├── kernels/ # 自定义核函数 ├── config/ │ └── tiling.json # tiling 参数 └── tools/ # 编译、下发、采样脚本编译和下发命令长这样build_graph --input graph/main.py --output build/model.bin --soc-version v2 run_model --model build/model.bin --input data/input.bin --repeat 20 --profiling--repeat 20是必须的单次执行的时间抖动极大至少跑 20 次取中位数和 95 分位不然你会被首轮冷启动的开销骗到。4.2 图构建与算子注册图构建的表达方式有两种用算子组合搭图或者直接写一个自定义算子挂进图里。前者省事后者可控。示例import graph_builder as gb g gb.Graph(matmul_gelu) x g.input(x, shape(4096, 4096), dtypefloat16) w g.const(w, shape(4096, 4096), dtypefloat16) # MatMul 走 Cube输出走 FixPipe 直接进 UB不落全局内存 mm g.matmul(x, w, transpose_bTrue, corecube, out_bufferub) # 激活挂在下游Vector 消费 UB act g.gelu(mm, corevector, in_placeTrue) g.output(y, act) g.compile(opt_level2, fusionmatmul_epilogue)这里有两个关键参数值得说。corecube和corevector是静态绑定的显式声明编译器会照做如果不写编译器按算子类型自动推断。out_bufferub是融合的关键——它告诉编译器这个输出不要落全局内存直接给下游用编译器据此把 FixPipe 的目标改写成统一缓冲省掉一次完整的 GM 往返。in_placeTrue让激活原地写回进一步省一块缓冲。融合策略的名字matmul_epilogue指的是矩阵乘尾处理融合这是最常见也最划算的一类融合GEMM 的输出块还在 FixPipe 手上就顺手把 bias 加、缩放、激活做完全程不碰全局内存。4.3 Tiling 参数的手算过程这一步不能拍脑袋得按容量倒推。假设片上有这么几个约束L0C累加器256 KB、L0A 和 L0B 各 64 KB、L1 512 KB计算数据类型 fp16累加用 fp32。第一步定 baseM 和 baseN。累加结果放 L0C每个元素 4 字节baseM × baseN × 4 ≤ 256 KB取baseM 128, baseN 128占用 128×128×4 64 KB留了一半余量给尾块和双缓冲。第二步定 baseK。输入块放 L0A / L0B每个元素 2 字节L0A: baseM × baseK × 2 ≤ 64 KB → baseK ≤ 256 L0B: baseK × baseN × 2 ≤ 64 KB → baseK ≤ 256理论上 baseK 可以取到 256但那样 L0A 和 L0B 会被占满一点余量都不剩双缓冲就没地方放了。取baseK 128L0A 和 L0B 各占 32 KB正好留出一半做双缓冲。第三步验算 L1。L1 要同时驻留 A 块和 B 块的双缓冲(128×128×2 128×128×2) × 2 128 KB ≤ 512 KB ✓留出了大量余量可以进一步加大预取深度或者让 L1 承担一部分格式转换的中间存储。第四步算块数和核数匹配。输出 4096×4096按 128×128 切块数 (4096 / 128) × (4096 / 128) 32 × 32 1024 块平台 16 个计算核1024 / 16 64正好整除每个核处理 64 个块尾块效应为零。第五步估算理论时间。每个输出块的 K 方向迭代次数 4096 / 128 32 次每次做 128×128×128 2.1 M 次乘加。单核 64 个块的总乘加次数 64 × 32 × 2.1e6 ≈ 4.29e9 MAC折合 8.59 GFLOP。假设单核 Cube 每周期完成一次 16×16×16 的分形乘加4096 MAC主频 1.8 GHz则单核算力约 14.7 TFLOPS单核时间 8.59 / 14700 ≈ 0.58 毫秒。16 核并行理论总时间也是 0.58 毫秒每核都干满。第六步估融合收益。如果 MatMul 和 Gelu 不融合中间结果要落一次全局内存再读回来4096×4096 的 fp16 张量是 32 MB写一次读一次共 64 MB按 1 TB/s 带宽算是 64 微秒。相比 580 微秒的计算时间占比约 11%。这个数字对大矩阵是值得做但不算致命对小矩阵就完全不同了——如果输出只有 128×4096计算时间降到 18 微秒而中间张量往返的绝对耗时虽然也按比例降了但算子下发的固定开销每个算子 5 微秒左右占比飙到 20% 以上融合就变成必选项。把这几组参数写进配置{ matmul_gelu: { base_m: 128, base_n: 128, base_k: 128, core_num: 16, double_buffer: true, pipeline_depth: 2, l0c_dtype: float32 } }4.4 下发、同步与性能采样图编译完之后下发这一步最容易出问题的不是计算是同步。融合算子内部的 Cube 和 Vector 之间需要一次显式握手FixPipe 把数据写进 UB 之后Vector 才能开始读。这个握手在大多数框架里由编译器自动插入通常是一个核内 barrier但如果你手写核函数就必须自己保证。# 手写融合核时的同步骨架 cube_gemm(l0a, l0b, l0c) # Cube 累加 fixpipe(l0c, ub, wait_flag0) # L0C - UB barrier() # 等 FixPipe 落盘 vector_gelu(ub, ub) # Vector 原地激活 barrier() copy_out(ub, gm_y) # 写回全局内存采样的时候不要只看总时间要按部件看。性能分析的输出通常包含每个部件的活跃周期占比Task: matmul_gelu_0 Total: 612 us cube_active : 548 us (89.5%) vector_active : 121 us (19.8%) mte2_active : 203 us (33.2%) mte1_active : 187 us (30.6%) idle_cycles : 64 us (10.5%)这张表读起来信息量很大。Cube 占用 89.5%说明主计算基本吃满剩下的 10.5% 空转大概率来自尾块或者同步等待Vector 只占 19.8%说明激活的计算被完全掩盖在 Cube 的时间里融合是成功的——如果 Vector 占比超过 Cube那融合就是负收益应该拆开。MTE2 占 33%说明全局内存搬运还有余量可以考虑降低预取深度给 L1 省空间。这份数据是调优的起点而不是终点。我第一次看到 Cube 89% 就以为调完了结果把 baseK 从 128 改到 192 之后降到了 82%——因为 L0A/L0B 被占满双缓冲变成了单缓冲搬运和计算没法重叠了。这个教训很值钱单项指标好看不等于整体最优改动前后必须对比全套指标。5. 踩坑实录常见故障与排查路径5.1 Cube 空转和 Vector 堵死的区分方法这两个症状在总耗时上表现几乎一样都是比预期慢但根因完全相反。Cube 空转的特征是 Cube 活跃率低、MTE 活跃率高。说明计算单元在等数据瓶颈在搬运侧。可能的原因预取深度不够、L1 容量不足导致块被反复换出、数据没对齐走了慢路径、或者上游张量的布局和目标格式不一致触发了运行期转换。Vector 堵死的特征是 Vector 活跃率接近 100%、Cube 活跃率忽高忽低。说明逐元素操作成了瓶颈Cube 反而在等 Vector 腾出缓冲。常见于融合过度、或者把计算量大的激活硬塞进了 GEMM 的尾处理。区分方法很简单看 Cube 活跃率和 MTE 活跃率是否同步波动。同步波动的是搬运问题Cube 掉但 MTE 平稳的是下游阻塞导致的反压。5.2 精度对不上的四类根因精度问题最烦人因为它往往不报错只是结果差一点。我整理过一份排查顺序从概率高到低现象根因验证方法结果偏差在 1e-3 量级随 K 增大而恶化累加器用了 fp16 而非 fp32检查 L0C 的数据类型声明个别元素错得离谱其余正常尾块处理越界或填充值不为零把 M/N/K 改成能被 base 整除的值对比整体偏移一个固定比例量化反缩放的时机错了确认反量化在 FixPipe 还是 Vector 做结果随机错乱、每次不一样双缓冲同步缺失读到半成品关掉双缓冲看是否复现第二条我遇到过两次。Cube 处理尾块时如果 baseM 不能整除 M需要对越界区域做零填充填充逻辑写错的话越界部分的垃圾数据会被累加进结果但因为只影响边缘的少数元素做整体误差统计时会被平均掉看平均值根本发现不了。验证精度必须看逐元素的最大误差不能只看平均值。5.3 性能不达预期时的 roofline 定位性能只有理论值一半的时候不要急着调 tiling先做一次 roofline 判断算出这个算子的计算强度FLOP 除以字节数和平台的机器平衡点比。以刚才的 4096³ GEMM 为例2 × 4096³ 137.4 GFLOP输入输出加起来的字节数是 3 × 4096² × 2 100 MB计算强度约 1374 FLOP/Byte。这个值远高于任何平台的机器平衡点通常是几十到几百 FLOP/Byte所以这个算子是计算受限的理论性能就该接近算力峰值。如果实测只有一半问题一定在核内流水搬运没和计算重叠、尾块浪费、或者 Cube 有气泡。反过来如果是逐元素激活计算强度只有个位数属于带宽受限实测性能应该接近带宽峰值而不是算力峰值。这时候你去优化计算指令数是白费力气该做的是减少数据往返——融合、原地写回、降低数据位宽。心得调优之前先分类比调优本身更重要。我见过有人在带宽受限的算子上死磕指令调度折腾一周没有任何提升最后把 fp32 换成 fp16 直接快了一倍。5.4 死锁与同步错位的现场还原死锁在这个模型里有几种典型形态最容易中招的是事件不配对流 A 在某个点 set 了事件流 B 在这个点 wait但流 A 因为某个条件分支没走到 set 那一行流 B 就永远等下去。表现形式是任务卡住不动看门狗超时日志里没有任何错误信息。排查方法是在下发序列里打点把每个 set 和 wait 都记录下时间戳和任务 ID出现卡顿时看最后一个成功的 wait 是哪个往前找对应的 set 有没有执行到。大部分情况下不是同步原语用错了而是控制流和图结构对不上——比如图里有条件分支编译期按最坏情况生成了同步点但实际执行的分支跳过了它。第二种是缓冲复用的时序错误两个任务共用同一块片上缓冲任务 A 还没写完任务 B 就开始读。这种不会死锁但会得到随机错误的结果比死锁更难查。防御手段是打开运行时的缓冲冲突检测或者在调试版本里给每块缓冲加哨兵值每次读写都校验。6. 关于静态与动态调度边界的再思考我在几个项目里反复调整过静态绑定和动态回退的比例最后稳定下来的配置是主干网络的 Cube 路径全静态边缘的小算子和形状不确定的分支走动态。这个划分不是理论推导出来的是被现场问题逼出来的。主干全静态换来了两个好处性能可以精确预测容量规划能提前做好线上出问题时同一个输入跑两次的时间戳完全一致diff 一下就知道是哪个任务的耗时变了。而边缘部分如果也静态绑定遇到动态 batch 或变长序列就会频繁重编译编译时间比推理时间还长这时候宁可牺牲一点确定性换灵活性。还有一个我后来才意识到的事图的下发策略和硬件的缓存层级必须一起设计。早期我把 L1 当成纯粹的数据缓冲所有块的加载都走全局内存结果 L1 的复用率极低。后来改成按输出块组织数据流——同一个输出块需要的 A 行块和 B 列块在 L1 里驻留整个 K 循环周期——L1 的命中率上去了MTE2 的活跃率从 60% 降到 33%整体快了近两成。这个改动跟调度策略无关纯粹是数据流设计但它对性能的影响比调整度参数大得多。所以如果你手上的模型性能上不去先别怀疑编译器。把四类部件的活跃率拉出来看一眼算一下计算强度判断是算力受限还是带宽受限再对照 tiling 的容量约束复核一遍大部分问题在这一步就能定位。剩下那些真的要改架构的往往是融合边界切错了——而这个错误从性能指标上看起来会伪装成核内流水没排好。