第一次在项目里把模型从GPU迁移到NPU我的第一反应是都是并行计算硬件改改接口不就行了结果一个 3×3 卷积GPU 上跑 200 多微秒换到 NPU 上直接飙到 5 毫秒以上。当时我以为是自己算子写错了后来对照架构手册才发现NPU 根本不是“少了一些核心的 GPU”它的取指、调度、数据搬运、计算阵列的工作方式和你已经习惯的硬件模型差别很大。这篇文章想聊的就是 NPU 架构、算子开发与性能调优这三件事。我会从底层结构讲起拆一个卷积算子的完整实现流程再分享做性能分析时真正有效的几个手段最后把我在实际项目中踩过的坑和排查过程全部列出来。适合刚接触 AI 芯片编程、准备做算子移植或者单纯想搞明白“NPU 为什么这么难调”的人。如果你已经写过几个算子那可以直接跳到第三章和第四章那里有更接近实战的内容。1. 为什么“懂GPU”不代表“会NPU”先理解NPU的底层差异1.1 NPU的核心组成与数据流NPU 最核心的设计目标是在尽量小的功耗和面积里完成高密度、高规则的计算。所以它的内部结构通常由这几块组成可编程控制单元负责取指、译码、任务调度、计算阵列以 MAC 单元为主做乘加运算、片上存储SRAM 或 buffer用来暂存中间数据、数据搬运引擎DMA负责片外内存与片上 buffer 之间的传输以及外部的存储接口通常是 DDR 或更高带宽的存储器。典型的数据流是这样host CPU 把一个算子的描述信息写进 NPU 的指令队列NPU 控制单元解析后先让 DMA 把输入数据从外部 DDR 搬到片上 buffer然后计算阵列从 buffer 读取数据做乘加结果再写回 buffer最后由 DMA 搬回 DDR。整条链路里数据搬运和计算是两个可以并行进行的阶段但多数初学者会把它们按顺序看待导致硬件利用率一直上不去。我建议你在写任何算子之前先去画一张“数据流向图”从外部存储到片上 buffer从片上 buffer 到计算阵列计算完成后又是怎么回写的。这张图画清楚了后面很多优化方案都顺理成章。画不出来就说明架构手册还没吃透千万不要急着写代码。1.2 计算与访存的博弈NPU为什么对“规则访问”有执念GPU 擅长用大量线程去掩盖延迟线程的调度是硬件级并行的所以它容忍相对复杂的分支和随机访问。NPU 则相反它希望计算阵列始终满负荷工作一旦流水线出现气泡性能损失非常明显。因此 NPU 上的算子必须被拆成足够规则的分块每个分块的形状、访问模式、依赖关系最好在设计阶段就固定下来。这就解释了为什么很多在其他平台能跑的算法直接搬到 NPU 上会慢得离谱。比如带动态输出长度的循环或者每次迭代访存地址都变化的逻辑在 GPU 上能靠线程调度“硬扛”在 NPU 上却会让整个计算阵列等数据等指令流水线空转。所以我们在做算子开发时第一件事不是写 kernel而是做“计算重构”把不规则的计算尽量变成规则的张量运算把动态分支尽量上移到 host 侧把访存模式整理成连续地址。很多人忽略这一步直接对着算法写代码后面调优会非常痛苦。我把这个阶段称为“为硬件铺路”它能决定最终性能的上限。2. 算子开发从0到1以卷积算子为例的完整拆解2.1 先做数学分析卷积怎么变成计算阵列喜欢的形式卷积本身是四层左右的嵌套循环如果可以直接在计算阵列里完成没必要生成显式的 im2col 矩阵。但我们需要考虑片上 buffer 尺寸、数据复用关系、输出累加的次数。以 3×3 卷积为例输出特征图的每个点需要计算 9 次乘加而输入数据有通道维度上的天然复用。如果分块策略对了同一块输入数据可以被多个输出通道复用这样外部存储访问次数会大幅下降。做数学分析时最核心的是计算强度一个算子每访问一个字节能完成多少次浮点运算。卷积的计算强度通常为计算强度 ≈ 2 × Kh × Kw × Cin × Cout × OH × OW / 访存量但访存量不是简单地把输入和权重加起来还要考虑重复读取。比如输入 H×W×Cin输出 OH×OW×Cout如果不做任何分块输入可能反复被读取 Cout 次。所以优化的核心目标之一就是通过调整循环顺序和分块大小让数据在片上被多算几次减少重复搬运。我在实际项目里会先用一个简单的 Python 脚本估算不同分块下的访存量选出几个候选策略再去算子里验证。这一步虽然老土但能帮你避开“改一版跑一版”的盲目调优。2.2 数据铺排与片上缓冲最容易出错的环节假设我们的硬件计算阵列一次可以处理 16×16 的输入块片上 buffer 最多能放 256KB。这时候需要决定输入特征图怎么切权重怎么排输出累加器怎么分配。常见做法是把 NCHW 布局转换成 NHWC或者干脆在 host 侧把权重提前重排成计算阵列要求的格式。有一次我接手一个别人写的算子性能一直上不去。查了半天发现输入是按 NCHW 存储的而计算阵列每次读取行时需要跳着读DMA 无法高效突发传输访存带宽被浪费了一半。后来我在初始化阶段增加了一次布局转换把通道维挪到最后一维带宽利用率明显提升。这个经验说明数据布局直接影响 DMA 效率必须当作算子接口的一部分来设计。另外要注意片上 buffer 的分配策略。输入 block、权重 block、输出累加 block 三者共享容量通常输入和权重用一个 buffer输出累加用另一个 buffer。如果输出通道数很大累加器需要的空间会非常可观这时需要把输出通道也切块分多次计算。分块逻辑是整个算子里最容易出 bug 的部分我建议在写 kernel 前先建立一个分块参数表参数含义常见范围Tile_M输出行分块大小16~64Tile_N输入通道分块大小32~128Tile_K卷积窗口展开后的分块大小16~64Buffer_In输入片上缓冲块数2~4用于流水2.3 Kernel实现与主机端调度一份可参考的伪代码对于大多数 NPU算子代码可以分两层host 层负责参数准备、地址计算、任务提交device 层kernel负责实际计算循环。下面是一个简化版本的框架用类 C 语言描述// 分块参数 #define TILE_H 16 #define TILE_W 16 #define TILE_CIN 64 // 卷积 kernel单 tile 处理函数 void conv_kernel( int8_t *in_ptr, // 输入 tensor 基地址 int8_t *wei_ptr, // 权重 tensor 基地址已重排 int32_t *out_ptr, // 输出累加基地址 int in_h, int in_w, int ci, int co, int block_start_h, int block_start_w, int block_start_ci ) { // 1. 将当前 tile 输入从 DDR 搬入片上 buffer // dma_load(in_ptr, sram_in, size_in); // 2. 将当前 tile 权重搬入片上 buffer // dma_load(wei_ptr, sram_wei, size_wei); // 3. 计算循环累加 for (int ci 0; ci TILE_CIN; ci) { for (int kh 0; kh 3; kh) { for (int kw 0; kw 3; kw) { // 对应输入行、列偏移 int in_h_idx block_start_h kh; int in_w_idx block_start_w kw; // 对每个输出通道和输出点做乘加 for (int i 0; i TILE_H; i) { for (int j 0; j TILE_W; j) { int8_t input_val sram_in[... int8_t weight_val sram_wei[... out_local[i][j] input_val * weight_val; } } } } } // 4. 将累加结果写回 DDR // dma_store(out_local, out_ptr, size_out); } // host 层调度 void conv_launch(...) { // 遍历所有输出分块 for (int oh 0; oh out_h; oh TILE_H) { for (int ow 0; ow out_w; ow TILE_W) { for (int co 0; co out_c; co TILE_CO) { conv_kernel(...); } } } }这里省略了地址计算和 buffer 切换逻辑但你应该能看出一个关键点kernel 里循环的主体是数据搬移和累加而不是像传统 C 代码那样把循环关系写得特别抽象。NPU 上真正耗时的是 DMA 等待和流水线同步所以 host 层必须“批量提交”多个 tile让上一个 tile 的计算和下一个 tile 的搬运重叠。我记得第一次只写单 tile 循环时性能和 CPU 差不多原因就是 DMA 等待占据了大量时间。调试的时候先用小尺寸输入比如 8×8×8跑一遍对照 CPU 参考结果确认每个 tile 的起始地址和输出位置没有问题再逐步放大。不要一上来就上全尺寸那样定位问题会很难。3. 性能调优的实战工具箱从Profiling到逐项优化3.1 用Roofline模型判断瓶颈方向拿到一个不达标的算子我建议大家先做一个计算强度估算再对照 NPU 的 Roofline 模型。假设某 NPU 的峰值算力是 30 TOPSINT8DDR 带宽是 50GB/s那么拐点计算强度就是 600 FLOPs/Byte。也就是说如果一个算子的计算强度低于 600它大概率会被访存带宽卡住高于这个值才可能逼近计算峰值。卷积核为 3×3、通道数 128、输出通道 128、特征图 16×16 时计算量约 2 × 3 × 3 × 256 × 128 × 128 ≈ 75.5 MFLOPs。理想访存量如果按输入和权重各读一次约 1MB 左右计算强度约 75 FLOPs/Byte明显低于拐点。这时候你去优化循环展开和指令调度收益会很有限真正该做的是减少搬运次数、加大数据复用。实际做 profiling 时我会先打开工具的硬件计数器看三组关键指标MAC 利用率、DMA 占用率、流水线 stall 周期。MAC 利用率低先看数据搬运是否有瓶颈DMA 占用率高考虑压缩数据或增大分块stall 高多半是同步太多或 buffer 切换策略不合理。3.2 六大优化手段与取舍下面这张表是我在多个算子项目里反复验证过的优化手段按投入产出比排序优化手段解决的问题典型收益风险数据布局转换非连续访存导致 DMA 低效30% ~ 300%需要额外转换时间增大分块大小数据复用不足重复搬运20% ~ 100%片上 buffer 容量受限双缓冲/DMA流水DMA 与计算串行等待20% ~ 60%代码复杂度增加循环重排访存模式和计算顺序不匹配10% ~ 50%可能影响数值顺序算子融合多次搬运中间结果最高到几十倍需要打破算子边界指令级优化流水线气泡和同步开销10% ~ 30%依赖具体硬件细节我自己的经验是优先查数据布局和分块参数这两个问题往往能在半天内解决收益却非常大。双缓冲是最经典但也是让代码最难看的方式如果架构手册里明确支持 DMA 自动传输和多队列一定要利用起来不要让 host 侧频繁等待。算子融合的方法通常和编译器优化 flag 一起考虑比如把相邻的卷积和激活合并成一个 kernel避免中间结果写回 DDR。3.3 一个具体算子的调优过程记录我在模拟项目 X 中写过深度可分离卷积。初次实现时输出 16 个通道每通道 64×64单算子耗时 3.2 毫秒。第一步做 Roofline计算量不大但每个通道都重新读取整张输入图访存量高得惊人。我把输入 tile 分块从 8×8 改成 32×32同时将权重预重排成 K-C-H-W 顺序耗时降到 1.1 毫秒。第二步加双缓冲将输入 tile 的 DMA 搬运和计算任务的执行重叠耗时从 1.1 毫秒降到 0.6 毫秒。第三步我发现激活函数是单独写的产生了一次中间数据回写的开销于是把 ReLU 融合进了卷积 kernel 的输出阶段最终耗时 0.4 毫秒。整个过程用调优日志记录每一步的改动和结果后面回归测试能很快发现问题。性能调优最忌讳一步改多个变量否则出了问题很难回退。3.4 量化对性能的影响绝大部分 NPU 对 INT8 的支持比 FP16 好得多算力通常能翻倍带宽占用也能减少一半。但量化不是直接把数据截断需要考虑激活值的分布和每层的缩放系数。我的做法是在模型导出阶段先做校准集统计确定每个 tensor 的 min/max 或量化尺度同时在算子里使用“左移缩放 截断”的方式避免浮点运算。量化后的一个常见陷阱是溢出。INT8 乘法结果用 INT16 或 INT32 累加但如果累加次数太多INT32 也可能溢出。必要的时候要做“分通道再缩放”或者把累加器输出转为 FP32 后回写。无论怎么调精度对比必须回归不能只看性能指标。4. 实际项目中踩过的坑三条反直觉的排查记录4.1 算子单独跑很快端到端却很慢有段时间模拟项目 X 里单个卷积算子 profiling 显示已经很优秀但整个模型跑起来还是慢。排查链路是这样先用模型级打点统计每个算子耗时发现很多算子耗时极低但算子之间的间隔非常长。再看 host CPU 占用发现每一层推理几乎都在等待和同步。根因是 host 侧提交任务的方式太保守每完成一个算子都要等待中断返回再发下一个。NPU 的启动开销虽然不高但一整个模型里有几十个算子累积起来就是几百毫秒的延迟。解决方法是把不依赖前序结果的算子批量放入一个 task 队列并允许同一个计算流里异步追加任务。如果你的工具链支持 graph/mode尽量一次提交完整模型而不是逐算子启动。后来我们改成批量提交后端到端延迟直接降了 40%。4.2 广播向量写入导致比预期慢一倍有一个归一化算子输入是 H×W 的特征图需要乘一个只和通道有关的标量。我最初把标量广播成和特征图一样大的 tensor再在片上做逐元素乘法结果发现带宽占用高得离谱。单独看每个 DMA 都非常高效但累加起来的真实传输量是必须传输的若干倍。排查时发现NPU 对地址对齐和连续长度有固定要求广播操作如果不走硬件的“标量-向量”指令通路就会被编译器展开成普通乘法DMA 重复读取相同数据。后来改成将标量加载到寄存器或片上常量区并调用硬件提供的 broadcast 模式带宽占用立刻降下来了。这个问题的教训是复杂的数据路径不一定高效优先看架构手册里是否有更直接的硬件指令。4.3 “优化后”结果错误率上升数值稳定性的陷阱某次我把一个卷积从 FP16 改成混合精度重新量化后精度对比完全达标于是信心满满地合入了主干。两天后测试同学发现某些输入图片输出始终不对错误率从 1% 涨到 10%。我一开始怀疑量化系数不对重新采集校准集后仍然复现。后来我逐层打印激活值的绝对值发现其中一层在几个通道上的最大绝对值非常大导致量化尺度被拉大绝大多数通道的有效精度大幅下降。解决办法是采用“per-channel 量化”而不是整张 tensor 共享一个 scale。修复后错误率回落到 0.8%。这个场景在 NLP 或宽度较大的模型中尤其常见不能只盯全局均值要关注分布末端。4.4 动态shape对编译和调度的影响还有一次模型输入尺寸不是固定的图片经预处理后会出现不同的 H 和 W。在 GPU 上这无所谓但在 NPU 上编译器为每个具体 shape 特化了优化策略shape 一变就可能重新编译或触发保守代码路径导致延迟忽高忽低。最有效的做法是限制输入尺寸集合比如按 32 对齐并用 padding 填充到固定最大值。如果模型确实无法避免动态需要在设计算子时用“循环 边界判断”处理但这通常会牺牲性能。所以做工程选型时形状约束应该在模型转化阶段就考虑而不是等算子写完再去优化。5. 给想入局的人项目规划与工具链选择建议5.1 先定算子清单80%性能问题在设计阶段不要等模型突然性能不足时才去逐算子优化而是先统计模型里的算子种类和耗时占比。以我经验卷积、矩阵乘、归一化、逐点激活通常能覆盖 90% 以上的耗时。把这些高频算子列成清单逐个完成数据布局设计和分块方案会比散点式优化有效得多。设计阶段还要考虑算子的组合模式。比如卷积后面接激活函数就应该直接融入卷积 kernel卷积输入来自上采样层时可以考虑先做下采样或通道合并。硬件利用率是算出来的不是调出来的前期方案对了后面基本只是微调。5.2 调优日志与回归测试容易被低估的工程化能力我在项目里有一个固定习惯每次优化都记录四样东西——改动描述、硬件计数器的关键指标、端到端延迟、精度对比结果。调优日志不是给领导看的而是给两个月后的自己看的。因为很多优化看起来有效过一阵子配合新的编译版本反而变差了没有日志很难回退。回归测试至少包含三部分功能正确性和 CPU 参考结果对比、性能基线记录每个算子 P50/P99 延迟、精度评估在完整测试集上跑一遍。如果条件允许把回归测试接到自动构建里每次合入前自动跑。我发现很多性能回退都是在合入后才发现而回归测试能把这个成本降得很低。5.3 硬件抽象层与自动调度不可能完全绕开底层最后说一个趋势判断随着工具链成熟很多人希望“写好算子描述编译器自动生成最佳实现”。自动调度器确实可以处理一部分规律明显的算子但 NPU 本身的异构性、存储层次、DMA 行为、量化限制决定了完全黑盒并不现实。那些性能极限场景里的特殊指令、特殊数据布局仍然需要人去理解硬件。所以在入局时我建议你先确保团队里有至少一个人能把架构手册的数据通路讲清楚同时尽量选择暴露 buffer 和 DMA 控制接口的抽象层而不是把所有东西封装死。不能调 buffer 的框架遇到性能问题时会让你寸步难行。如果只能给一条建议别急着写很多算子先花两周把一款 NPU 的架构手册从头到尾读一遍把数据流、分块、同步机制都画出来再动手。硬件上的认知差会在后面的每一步放大早补早省时间。
阅读完成 · 觉得有帮助?