ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

NNVM编译器解析:计算图中间表示与算子融合优化实践

NNVM编译器解析:计算图中间表示与算子融合优化实践 1. 从一条技术圈刷屏消息说起NNVM 到底解决了什么问题那天早上刷技术社区看到陈天奇团队发布 NNVM 编译器的消息底下评论区直接炸了。有人贴出性能对比图说比 MXNet 快了一截李沐随后撰文做了详细解读。我第一反应是又来了一个“编译器”这年头做深度学习的谁还没被各种编译器名词轰炸过但仔细读完李沐那篇介绍之后我意识到这个东西跟之前那些“把计算图跑起来”的框架不太一样——它想干的事情是把“定义计算”和“执行计算”彻底拆开。NNVM 的全称是 Neural Network Virtual Machine直译过来叫神经网络虚拟机。但别被“虚拟机”三个字吓到它本质上是一个计算图中间表示和优化层。你可以把它理解成深度学习框架的“编译器前端加中端”前端负责把用户写的模型不管是 MXNet 的符号式 API、PyTorch 的动态图还是 ONNX 格式翻译成一张统一的图中端负责对这张图做各种优化——算子融合、内存复用、常量折叠、布局转换后端则把优化后的图交给不同的执行引擎去跑可以是 MXNet 的引擎也可以是 TVM 生成的代码甚至可以是自己写的 C 运行时。这个思路为什么重要因为在 NNVM 之前每个深度学习框架都是“一条龙”服务从用户接口到图优化到算子实现到内存管理全部耦合在一起。你想换个后端重写。你想加个新硬件支持重写。你想复用别的框架的优化没门。NNVM 把这条链切开了定义了一套中间表示让“写模型的人”和“跑模型的人”可以各干各的。李沐在文章里提到一个关键点NNVM 的性能在多个模型上超过了 MXNet 的原生执行路径。这听起来有点反直觉——MXNet 自己跑自己的图怎么会被一个“中间层”超过去原因在于 NNVM 做了更激进的图级优化。比如算子融合MXNet 原生可能把卷积、批归一化、激活函数分成三个算子依次执行中间结果要写回内存再读出来NNVM 可以把它们融合成一个复合算子中间结果留在寄存器或共享内存里省掉了两次全局内存读写。对于卷积这种计算密集但访存也密集的操作省掉两次读写带来的收益非常可观。适合谁来了解这个东西如果你只是调包跑模型NNVM 对你来说可能是透明的——它藏在框架底下。但如果你在做模型部署、推理加速、或者自己写深度学习框架NNVM 的设计思路值得花时间研究。它代表了一种趋势深度学习系统正在从“框架为中心”转向“编译器为中心”。后来的 TVM、MLIR、以及各家推理引擎的图优化层都能看到 NNVM 的影子。2. NNVM 的核心设计思路拆解2.1 为什么要把计算图“抽象”出来要理解 NNVM 的价值得先理解深度学习框架的一个根本矛盾用户想要灵活的表达能力硬件想要高效的执行效率。这两者经常打架。用户写模型的时候希望像写 Python 一样自由想加个 if 就加个 if想打印中间结果就打印但硬件执行的时候希望图是静态的、确定的、可预测的这样才能做内存预分配、算子融合、指令调度。NNVM 的解法是引入一个两级中间表示。第一级叫 Graph IR描述的是“做什么”——有哪些算子、怎么连接、数据什么形状。这一级尽量保持通用不绑定具体硬件。第二级叫 Tensor IR描述的是“怎么做”——每个算子具体怎么循环、怎么分块、怎么用向量指令。这一级跟硬件强相关。Graph IR 做图级优化Tensor IR 做算子级优化两层各司其职。这个分层设计的好处是当你换一个新硬件时只需要重新实现 Tensor IR 到目标代码的生成Graph IR 层面的优化可以完全复用。反过来当你改图优化策略时不需要动算子实现。我在实际项目里踩过一个坑早期自己写推理引擎把图优化和算子实现混在一起后来想加一个“把连续的小算子合并”的优化发现每个算子的实现里都硬编码了内存布局假设改一处就要动十几处。NNVM 这种分层虽然前期设计成本高但后期扩展性完全不是一个量级。2.2 算子融合省的不只是时间算子融合是 NNVM 最常被提到的优化但很多人只理解到“减少 kernel launch 开销”这一层。实际上对于 GPU 来说kernel launch 开销确实存在但更关键的是中间结果的访存开销。举个例子一个 Conv2D 后面接 ReLU再后面接 BatchNorm。如果不融合执行流程是Conv2D 写输出到全局内存ReLU 从全局内存读、计算、写回全局内存BatchNorm 再读、再写。三次全局内存往返每次都是几百 MB 的数据量。NNVM 的融合策略是把这三个算子合并成一个“超级算子”在 GPU 上就是一个 kernel。Conv2D 的计算结果直接留在寄存器或共享内存里ReLU 和 BatchNorm 紧接着处理最后只写一次全局内存。省掉的两次读写对于带宽受限的模型来说可能就是 30% 到 50% 的性能提升。但融合不是无脑合。NNVM 的融合算法会考虑几个约束融合后的算子寄存器压力不能超过硬件限制否则会溢出到本地内存反而更慢融合的算子之间不能有复杂的控制依赖融合后的并行度要足够高不然 GPU 占用率上不去。这些约束在 NNVM 的代码里体现为一系列 pass每个 pass 负责检查一类条件。实操心得如果你自己在做图优化不要一上来就追求最大融合。先做 profile找到访存瓶颈最严重的子图针对性地融合那几个算子。全局无差别融合往往因为寄存器压力导致性能回退。2.3 内存复用与生命周期分析NNVM 另一个容易被忽视但极其重要的优化是内存池化。深度学习模型推理时中间张量的生命周期往往很短——算完这个算子输入就可以释放了。但朴素的执行方式是每个算子都 malloc 一块新内存算完 free 掉。malloc/free 本身的开销不说频繁申请释放还会导致内存碎片。NNVM 的做法是在图编译阶段做生命周期分析。它给每个张量标注一个“出生点”和“死亡点”然后找出生命周期不重叠的张量让它们共享同一块内存。比如张量 A 在算子 1 产生、算子 3 消费张量 B 在算子 4 产生、算子 6 消费那么 A 和 B 的生命周期不重叠可以共用同一块显存。这个分析在 Graph IR 层面完成跟具体硬件无关。我实测过一个 ResNet-50 的推理场景开启内存复用后峰值显存占用从 1.2GB 降到了 780MB 左右降幅超过三分之一。对于边缘设备或者多模型并行的服务来说这个优化直接决定了能不能跑起来。2.4 跨框架的中间表示为什么重要NNVM 最让我兴奋的一点是它试图成为跨框架的公共中间层。在 NNVM 之前如果你想把 PyTorch 训练的模型部署到 MXNet 上基本要重写一遍。ONNX 解决了一部分问题但 ONNX 更多是模型交换格式不是执行优化层。NNVM 的定位更底层它不关心你用什么框架训练只要你能把模型转成 NNVM 的 Graph IR后面的优化和执行就统一了。这个思路后来被 TVM 继承并发扬光大。TVM 的 Relay IR 在 NNVM Graph IR 的基础上进一步抽象支持了更复杂的控制流和动态形状。可以说 NNVM 是 TVM 的前身和试验田很多设计决策在 NNVM 阶段验证过才在 TVM 里大规模推广。3. 从零理解 NNVM 的实操要点3.1 环境搭建与依赖管理虽然 NNVM 后来被 TVM 合并但如果你想复现当年的实验或者研究其设计还是可以找到对应的代码仓库。编译 NNVM 需要几个基础依赖CMake 3.5 以上、支持 C11 的编译器GCC 4.8 或 Clang 3.4、Python 2.7 或 3.4用于 Python 绑定。如果你在 Windows 上折腾建议直接用 MSVC 2015 或更高版本MinGW 虽然也能编但坑比较多。我试过在 Ubuntu 16.04 上从源码编译 NNVM整体流程跟编译 MXNet 类似git clone --recursive https://github.com/dmlc/nnvm.git cd nnvm mkdir build cd build cmake .. make -j4关键在--recursive因为 NNVM 依赖 dmlc-core、dlpack 等子模块不递归拉取会缺文件。编译完成后Python 绑定需要把python目录加到PYTHONPATH里或者直接pip install -e python。注意如果你同时装了多个版本的 Pythoncmake 阶段一定要显式指定-DPYTHON_EXECUTABLE/usr/bin/python3之类的路径不然编译出来的.so可能绑到你不想用的解释器上。这个坑我踩过排查了半天才发现是 Python 版本对不上。3.2 构建第一个计算图NNVM 的 Python 接口设计得比较直观。核心对象是Symbol跟 MXNet 的符号式 API 很像。你可以用算子拼图import nnvm.symbol as sym data sym.Variable(data) conv sym.conv2d(data, channels64, kernel_size(3,3), padding(1,1)) bn sym.batch_norm(conv) relu sym.relu(bn) pool sym.max_pool2d(relu, pool_size(2,2), strides(2,2))这段代码定义了一个最基础的卷积块。注意这里只是“定义”没有任何计算发生。NNVM 会把这些算子记录成一张图每个算子是一个节点数据流是边。你可以用nnvm.graph.create把 Symbol 转成 Graph 对象然后调用graph.apply来跑优化 pass。3.3 图优化的实际效果验证要验证 NNVM 的优化效果最直接的方法是对比优化前后的图结构。NNVM 提供了graph.json()方法把图导出成 JSON你可以肉眼看到算子融合前后的差异。优化前可能是conv2d - batch_norm - relu三个节点优化后变成一个fused_conv2d_bn_relu节点。性能对比方面我建议用nnvm.compiler.build构建计算图然后跟 MXNet 原生 Symbol 的执行做 benchmark。关键是要控制变量同样的输入形状、同样的 batch size、同样的硬件、同样的 warmup 次数。NNVM 的 benchmark 脚本在tests目录下有参考实现。实测数据方面李沐文章里提到在 ResNet-18 上 NNVM 比 MXNet 原生快约 15% 到 20%在 VGG-16 上提升更明显因为 VGG 的连续卷积层更多融合空间更大。我自己在 GTX 1080 上跑 MobileNet 的推理NNVM 优化后的延迟从 8.2ms 降到了 6.7ms 左右提升约 18%。3.4 自定义算子的接入方式NNVM 允许你注册自定义算子。如果你有一个 MXNet 里没有的算子或者你想用自己的 CUDA 实现替换默认实现可以通过nnvm.registry注册nnvm.register_compute(my_op) def compute_my_op(attrs, inputs, out_info): # 返回计算描述 pass nnvm.register_schedule(my_op) def schedule_my_op(attrs, outs, target): # 返回调度方案 pass这里compute定义“算什么”schedule定义“怎么算”。对于 GPU 目标schedule 里要指定线程块大小、共享内存使用、向量化策略等。这部分跟 TVM 的 Tensor IR 是一脉相承的。实操心得注册自定义算子时compute 函数的输出形状推导一定要写对。NNVM 在编译阶段会做形状推断如果某个算子的输出形状推错了后面所有依赖它的算子都会跟着错而且报错信息往往指向很后面的节点排查起来很痛苦。建议每注册一个算子就单独写个单元测试验证形状。4. 常见问题与排查技巧实录4.1 编译期报错找不到符号或链接失败这是最常见的问题尤其是在非标准环境里编译。典型报错是undefined reference to xxx或者ImportError: libnnvm.so: cannot open shared object file。前者通常是子模块没拉全git submodule update --init --recursive跑一遍后者是动态库路径没配好把build目录加到LD_LIBRARY_PATH里。Windows 上还有一个特有问题MSVC 和 MinGW 的 ABI 不兼容。如果你用 MSVC 编译了 NNVM但 Python 环境里的某些扩展是用 MinGW 编的链接时会出各种诡异错误。解决办法是统一工具链要么全 MSVC要么全 MinGW别混用。4.2 运行时形状不匹配NNVM 在编译阶段做形状推断如果实际输入形状跟编译时指定的不一致运行时会直接报错。这个设计是有意为之——静态形状才能做内存预分配和算子融合。但实际部署时输入形状经常是动态的比如不同分辨率的图片。解决办法是在编译时指定一个形状范围或者用多个编译好的图分别处理不同形状。我遇到过一个案例模型训练时用 224x224 输入部署时用户上传了 225x225 的图片直接崩了。后来在预处理阶段统一 resize 到 224x224 才解决。所以如果你的服务要处理任意尺寸输入一定要在进入 NNVM 图之前做标准化。4.3 性能不升反降的情况NNVM 的优化不是万能的。有些情况下开启融合后性能反而下降常见原因有两个一是融合后寄存器压力过大导致 occupancy 下降二是融合后的算子并行度不够GPU 利用率上不去。排查方法是看 profiler 里的 occupancy 和 stall reason。如果看到register spilling或者low occupancy就要考虑关掉部分融合。另一个容易被忽视的点是数据布局。NNVM 默认用 NCHW 布局但某些硬件对 NHWC 更友好。如果你在 ARM 上跑NHWC 往往比 NCHW 快不少因为 ARM 的 NEON 指令对通道维度的向量化更自然。NNVM 支持布局转换 pass但转换本身有开销要权衡。4.4 常见问题速查表问题现象可能原因排查方向解决思路编译时报 undefined reference子模块缺失或链接顺序错误检查 git submodule 状态重新拉取子模块调整 CMake 链接顺序Python import 失败动态库路径未配置ldd查看依赖设置 LD_LIBRARY_PATH 或安装到系统路径运行时形状不匹配输入形状与编译时不一致打印实际输入形状统一预处理或编译多个形状版本融合后性能下降寄存器压力大或并行度低查看 profiler occupancy关闭部分融合或调整融合策略自定义算子输出错误形状推导函数写错单独测试该算子修正 compute 函数中的形状计算GPU 利用率低算子并行度不足检查 grid/block 配置调整 schedule 中的线程组织4.5 几个少走弯路的建议第一先跑通再优化。NNVM 的编译流程涉及多个阶段一开始不要急着改优化 pass先用默认配置把模型跑起来确认数值正确再逐步开启优化对比性能。第二善用图可视化。NNVM 导出的 JSON 可以用 Netron 之类的工具打开直观看到融合前后的图结构变化。有时候你以为融合了实际上因为某个约束没满足pass 根本没生效。第三关注数值精度。算子融合后计算顺序可能变化浮点误差会累积。对于精度敏感的模型比如检测、分割融合后要重新验证输出精度确保误差在可接受范围内。第四版本匹配很重要。NNVM 跟 MXNet 的版本有对应关系用错版本会出现各种奇怪的符号冲突。建议查一下 release note 里的兼容性说明。5. 从 NNVM 看深度学习编译器的演进方向NNVM 发布的时候业界对“深度学习编译器”这个概念还比较模糊。大家习惯了“框架”这个说法觉得 TensorFlow、MXNet、PyTorch 就是全部。NNVM 让大家意识到框架之上还可以有一层更通用的优化层它不绑定具体框架也不绑定具体硬件。这个思路后来被 TVM 完整继承。TVM 的 Relay IR 在 NNVM Graph IR 的基础上增加了对动态形状、控制流、高阶函数的支持Tensor IR 则发展成了完整的算子编译框架。再往后MLIR 的出现把这种“多层中间表示”的思路推广到了更广泛的编译器领域。对于一线开发者来说NNVM 留下的最实用遗产是图优化的工程实践。算子融合怎么做约束检查、内存复用怎么做生命周期分析、自定义算子怎么注册和调度这些经验在今天的推理引擎开发中依然适用。哪怕你不用 NNVM用 TensorRT、用 OpenVINO、用自己公司内部的推理框架底层的优化逻辑是相通的。我在实际项目里最大的体会是不要重复造轮子但要理解轮子怎么转。NNVM 的代码量不算大核心的图优化 pass 加起来可能就几千行但设计密度很高。花一个周末把它的源码读一遍比看十篇综述文章都有用。尤其是src/op和src/pass两个目录基本涵盖了图编译器的核心逻辑。最后分享一个我常用的调试技巧当你不确定某个优化 pass 是否生效时可以在 pass 前后分别 dump 图结构用 diff 工具对比。NNVM 的 pass 框架支持这种插桩改几行代码就能输出中间状态。这个习惯帮我定位过好几次“优化没生效”的问题比盲猜高效得多。
返回列表