首页 / 资讯中心 / 文章详情

从算子到引擎:NVDLA硬件流水线与学习路线解析

从算子到引擎:NVDLA硬件流水线与学习路线解析 ★ FEATURED ARTICLE
第一次翻开 NVDLA 的源码时我的第一反应是去 hw 目录下找 Conv2d 对应的 Verilog 模块。结果翻了一整个下午看到的全是 vmod 生成的例化文件、大片大片的 MAC_RAMA 宏以及我完全没想到的 CBA、CSB 这些名字。后来我才明白学 NVDLA 的正确姿势不是找一个算子模块而是先理解一个算子怎么被拆成硬件任务又怎么在引擎之间流动。这篇内容就是按这个思路整理出来的从 CNN 算子的硬件视角一路讲到 NVDLA 的硬件流水线如何组织执行最后附上我踩过坑之后总结的学习路线。它适合两类人一类是做软件框架或算法想往芯片和体系结构方向走的另一类是已经拿到 NVDLA 源码但不知道从哪下手的初学者。1. 先搞清楚算子进入硬件之前到底翻译成了什么1.1 软件里的算子是一次调用硬件里的算子是半条任务链在 PyTorch 里一行self.conv nn.Conv2d(...)就把卷积封装完了forward 时底层是一个 CUDA kernel 调用硬件细节全部被藏起来。但在专用加速器里Conv2d 这个东西根本不存在存在的只是一个物理引擎、一组内存访问序列、一堆寄存器配置和若干内存描述符。一个卷积实际操作起来是这样一个过程读输入、读权重、做乘累加、写中间结果、等待依赖、释放信号然后再由下一个引擎接手。我刚开始看 NVDLA 时犯的错误就是一直用软件思维找卷积函数结果全程都在找空气。后来我把视角切换成数据流把算子当成一段有输入有输出的任务任务之间通过依赖关系串联。这一步转过来之后后面所有内容都顺了。建议你在接触任何硬件加速器之前先把这个观念转变做完不然看啥都像天书。1.2 CNN 里的原子操作硬件只认四类算法工程师眼里 CNN 有几十种算子但在 NVDLA 这种专用硬件里真正被落地的动作很少。我整理了一张对照表你可以直接拿去做思维锚点软件层算子硬件实现方式典型硬件单元卷积、全连接乘累加阵列MAC Array卷积引擎激活ReLU、ReLU6 等限幅器或查表近似SDP 引擎池化最大、平均滑动窗口比较、累加PDP 引擎归一化BN、LRN通道维度缩放、偏移统计SDP、CDP 引擎为什么要这样收敛因为硬件擅长把一个固定动作做到极致却非常怕任意函数。比如激活函数软件里直接调用torch.relu就好了硬件里实现一个 ReLU 就是跟 0 比较一下然后截断成本极低如果网络里使用了自定义激活硬件就只能通过查表或者分段线性近似来模拟面积和精度都会受影响。NVDLA 因此把每个引擎的任务范围设计得很窄换来的是面积效率和高频率。1.3 NVDLA 选择引擎化拆分而不是指令化执行通用 CPU 靠程序计数器一条一条取指令然后送到执行单元。NVDLA 不是这个玩法它更像一条工厂流水线每个引擎是一个车间车间之间用显式依赖关系交接没有一个中央调度器在逐条指挥。这种设计的直接推论是一个完整的 CNN 在硬件上不是一个算子接一个算子地执行而是多个引擎尽可能并行地处理各自手里的活。例如卷积引擎在算这一层的时候SDP 可能正在处理上一层留下来的中间结果PDP 可能正在做更早那层的池化。算子之间的依赖越多并行程度越低依赖越少流水线越能转起来。读 NVDLA 架构图时别把注意力放在哪个寄存器控制什么先盯着引擎之间的数据连线把流水线关系理清这一步比什么都重要。2. 卷积这个最耗人的算子在 NVDLA 里是怎么被拆墙的2.1 从三重循环到乘累加阵列卷积在软件里本质是三重循环遍历输出位置、遍历输入通道、遍历卷积核窗口。算法库比如 cudnn 常用 im2col 把卷积转成矩阵乘法然后用 GEMM 来加速。但 NVDLA 作为可综合的专用加速器并没有在硬件里做完整的 im2col 缓冲而是直接把滑动窗口扫描逻辑硬做进去输入特征图被灌进片上 SRAM权重留在权重缓冲里MAC 阵列一遍遍做乘累加。这里的关键是权重共享。一个 3x3 卷积核会在整张特征图上反复使用所以权重可以从片上缓冲反复读取而输入数据以广播的方式到达多个 MAC 单元。这样权重访存被压缩到极低瓶颈主要落在输入特征图的带宽上。全连接层可以看作 1x1 卷积或 GEMMNVDLA 的卷积引擎也能映射但效率取决于分块策略后面讲到 tiling 时你会有更直观的感受。2.2 整张图进不去引擎分块是第一道工序很多人第一次看 NVDLA 会问为什么不把整张 feature map 一次性搬进片上 SRAM然后一口气算完算一笔账就明白了。假设输入是 56x56x256FP16 格式每个元素 2 字节。那么光是输入就是 56 乘以 56 乘以 256 再乘以 2约 1.5MB。如果输出也是 56x56x256中间累加器再占一块权重再占一块片内 SRAM 很快就不够用了。NVDLA 的小配置片上存储只有几百 KB 到 1MB 左右整图方案根本不成立。所以编译器必须做分块tiling把输入和输出切成若干个小立方体硬件一次只处理其中一个处理完再切下一个。切分不是简单地按行列切因为卷积有 padding 和 kernel 半径相邻 tile 之间会有重叠区域这部分数据会被重复读取。我们叫它 halo 开销。分块切得太大片上放不下切得太小重复读边界数据的开销直线上升。这个权衡是编译器最重要的优化之一也是读 NVDLA 软件栈时最值得反复琢磨的部分。2.3 权重驻留还是输出驻留两种数据流思路硬件加速器设计里有个经典话题数据在计算阵列里怎么流动。两种主流方案是权重驻留和输出驻留。权重驻留的意思是权重一旦从内存读进片上缓冲就一直在那里待着反复被用来和不同输入做乘累加输入数据流经计算阵列。这种方式适合小卷积核、通道数比较大的层因为权重复用率极高。输出驻留则是每个 PE 内部的累加器守住一个输出位置把不同通道和不同核的数据不断送进来累加。这种方式适合需要大量累加、避免反复写回中间结果的场景。NVDLA 的卷积引擎整体更偏权重驻留的思路这也是为什么它对 3x3、1x1 这类小卷积核特别友好。理解这个点之后你再看 NVDLA 卷积引擎内部那堆 PE 和权重缓存就不会觉得它们只是随机堆出来的模块而是能在脑中把数据流路径画出来权重从缓存广播出去输入从 SRAM 依次流入累加结果在 PE 里聚合成输出。2.4 一个 3x3 卷积的硬件分解示例为了把前面这些概念落到地上我手动拆一个最简单的例子参数是我随便取的只为展示流动关系。假设输入是 16x16x16卷积核是 3x3x16x16也就是输出 16 个通道padding 为 1。第一步准备输入 strip。因为卷积核半径是 1每处理一个输出位置需要读取周围一圈邻居所以实际读入 SRAM 的输入块要扩大到 18x18x16 这类带边界扩展的尺寸编译器会把这个 extra 开销纳入规划。第二步加载权重。3x3x16x16 总共 2304 个权重不大一次性放入权重缓冲后续所有输出位置几乎都能复用。第三步MAC 阵列开始扫描。每个输出像素需要做 3x3x16 也就是 144 次乘累加16x16 个输出位置一共要做 36864 次乘累加。硬件并不是像 CPU 那样循环执行而是用一堆 MAC 单元同时算多个位置。第四步把 16x16x16 的中间结果写回片上缓冲并释放一个依赖信号通知下一个引擎可以开始读取处理。到这里卷积引擎的任务结束SDP 或 PDP 接手。这个示例告诉我们一个卷积在硬件上不是被执行的而是被分解调度的。软件里一个算子可能在硬件里变成几十上百个小任务每个任务都由依赖关系串起来。3. NVDLA 的引擎分工把架构图读成流水线3.1 四个引擎一个 DMA各管一摊NVDLA 的核心处理单元可以简化为一张小表引擎全称职责主要工作CUBE卷积引擎卷积、全连接、1x1 卷积本质是乘累加SDP单数据处理器激活、缩放、偏移、量化、查表PDP平面数据处理器最大池化、平均池化CDP跨通道数据处理器跨通道归一化比如 LRNBDMA桥接 DMA数据搬运、内存拷贝读架构图时候有个技巧先把这张表记熟然后顺着数据连线走一遍典型执行链。比如 Conv 算完数据从 CUBE 出来顺着内存或直接片上缓冲进入 SDP做完 ReLU 之后进 PDP 做池化整个过程 BDMA 负责搬运权重和特征图。很多初学者一上来就扎进某一个引擎的内部微结构结果把自己绕晕。我更建议先从数据从哪来、到哪去这个层面读起把引擎之间的连接关系刻在脑子里再去逐块看内部实现。3.2 没有程序计数器算子靠依赖关系串起来前面提过NVDLA 没有一个中央调度器逐条喂指令那引擎之间怎么知道谁先谁后答案是依赖计数和信号量机制。每个操作在被触发之前会声明它需要等待哪些事件一个操作完成后会释放信号给后续操作。这套机制在硬件里通常实现为一个简单的计数器或门控逻辑类似接力棒。前面的引擎跑完把接力棒交出去后面的引擎收到接力棒才启动。编译器在生成 loadable 时会把网络里算子的先后关系翻译成这种依赖图。我最初以为 CPU 需要在每个算子结束后都去读寄存器、配置下一个算子后来发现完全不是。CPU 只在最开始把描述符地址和依赖关系配置好之后各个引擎自己按图执行。理解这一点对写 runtime 尤其重要你不需要每个节点都干预你要做的只是把图建对、把地址写对然后等一个最终完成信号。3.3 中间结果放哪片上复用比 DRAM 往返重要得多既然有多个引擎接力一个很自然的问题是中间结果写到哪里如果每次都写回 DRAM下一次引擎再读出来带宽就彻底爆炸。正确做法是让中间结果尽量留在片内 SRAM下一个引擎直接从相同地址读取。比如 Conv 的输出 buffer可以直接变成 SDP 的输入 buffer只要地址计算不冲突就不需要经过 DRAM。这个优化是由编译器和 runtime 共同完成的尤其是地址分配逻辑要保证上一个任务写入的数据不会被另一个任务提前覆盖。NVDLA 的编程模型允许这种片上环回这也是很多网络能在有限带宽下跑出较高吞吐的原因。你如果自己写 runtime一定要关注 buffer 生命周期别在一个任务还没被消费完时就把它的内存覆盖掉这类 bug 非常难查因为现象往往是偶发性错误结果。4. 把一条算子链变成硬件执行序列4.1 编译器产出的 Loadable不只装了权重NVDLA 的软件链路由 compiler 和 runtime 两半构成。compiler 接收 Caffe 或 ONNX 这类框架模型经过拓扑排序、算子映射、分块、依赖生成之后产出一个叫 loadable 的二进制包。这个包里除了权重和偏置还包括操作描述符、张量描述符、依赖信息和控制配置。如果简化看一个操作描述符大致长这样仅示意不是官方二进制格式{ op_type: CONV, input_tensor: {address: 0x10000, shape: [16, 16, 16]}, weight_tensor: {address: 0x20000, shape: [16, 3, 3, 16]}, output_tensor: {address: 0x30000, shape: [16, 16, 16]}, conv_param: {stride: [1, 1], padding: [1, 1]}, dependencies: {wait: [], release: [conv_done]} }你电脑里跑一个网络时PyTorch 不会生成这种描述符但 NVDLA 会把网络里每一个算子变成这样一个描述符再通过依赖字段把它们连成图。认识 loadable 的内部结构是理解 NVDLA 软硬分界最直接的一步。我建议你编译一个最简单的两层 CNN然后 dump 出 loadable 文件对照着读收获会非常大。4.2 runtime 只做四件事runtime 拿到 loadable 后的工作其实非常克制分配内存把输入、输出、权重、描述符放到约定地址下发描述符地址把每个操作描述符在内存中的地址写到对应引擎的寄存器触发启动写触发位让各引擎开始工作等待完成通过中断或轮询完成状态然后取走输出。手动配置寄存器有个特别容易踩的坑必须先写描述符地址再写触发位。如果你先触发引擎可能从一个无效地址去读描述符轻则死等重则跑出完全不可控的结果。这种时序错误在真实芯片上非常难复现因为每次内存布局可能有细微差别所以我建议你在 runtime 里把这步做成固定顺序不要优化掉。4.3 最小执行链Conv 加 ReLU 加 MaxPool把前面内容串起来跑一个 Conv 加 ReLU 加 MaxPool 的最小链路实际执行序列大概是这样的runtime 加载 loadable并把输入图像放到指定内存地址CUBE 引擎读取描述符开始卷积运算把中间结果写进片上输出 buffer卷积完成后释放一个依赖信号SDP 引擎收到信号后读取同一份中间结果逐元素比较大小做 ReLU然后写回SDP 完成后释放第二个信号PDP 引擎启动用滑动窗口选出最大值得到池化输出runtime 检测最终完成事件读取输出地址得到最终结果。你会注意到整个过程 CPU 只参与了开始和结束中间没有任何一次逐节点干预。这也是专用加速器和 GPGPU 编程模型最大的区别。如果你习惯写 CUDA会天然觉得每个 kernel 都要由 host 端 launch但 NVDLA 这类异步任务图风格更接近一次配置硬件自治。5. 流水线跑起来之后真正决定性能的三件事5.1 依赖关系越少多引擎并行越充分NVDLA 有好几个引擎理论上可以同时工作。但能不能同时取决于任务之间的依赖图。假如有一个分支网络两个分支互不依赖那么编译器就可以把它们排成并行任务让 SDP 处理分支 A 的同时CUBE 正在算分支 B 的卷积。反过来说如果是一个串行链比如 Conv 接 ReLU 接 Conv后一个卷积只能等前面的全部完成这时多引擎并不能帮你提速流水线再丰满也只能干等。所以性能优化很多时候不是调硬件而是调依赖。我在看一些开源实现时发现有些性能差距不是引擎不够快而是编译器把原本可以并行的任务串行化了。你在做算子调度时多想想这个任务真的需要等前面那个吗往往能挤出不少吞吐。5.2 双缓冲把内存等待藏进计算流水线最怕的就是引擎等数据。等数据的周期里MAC 阵列是空的算力全部浪费。NVDLA 内部很典型的一种缓解手段是双缓冲也叫乒乓缓冲。一组缓冲里数据正在被引擎消费另一组缓冲同时被 DMA 从 DRAM 灌入下一份数据两边交替使用。这样做的代价是片上 SRAM 占用翻倍但收益是内存延迟被隐藏。对一个卷积任务来说如果只有单缓冲那 DMA 搬运输入的时候 MAC 就得歇着双缓冲之后只要数据灌入速度追得上计算速度引擎就能一直转。NVDLA 的输入和权重缓冲在很多配置下都使用了类似思路只不过具体深度和分组要看配置。我会建议你做性能分析时先看引擎利用率如果发现某个引擎长期低于 50%优先怀疑数据供给跟没跟上而不是怀疑计算单元不够强。5.3 背压不是报错是硬件在自我保护当下游引擎处理不过来时上游引擎的输出 buffer 会慢慢填满最终触发反压上游必须停在那里等直到下游腾出空间。这种现象在仿真里经常被误当成 bug有人一看波形里某个 ready 信号拉低就开始查错误。实际上这是完全正常的流水线行为体现的是下游忙上游等。真正要关注的是反压发生的频度和位置。如果某个环节频繁反压说明上下游吞吐不匹配可能需要对分块大小或引擎配置做调整。反压机制本身是保护机制不是故障机制。这点我想强调一下因为我见过好几个初学者在调试时对着一个 stall 信号看了一整天最后发现它只是在等 SDP根本不是出错。5.4 带宽账一次卷积到底要搬多少数据很多人低估了内存带宽对加速器性能的影响。回到之前 56x56x256 的例子输入约 1.5MB输出也约 1.5MB权重 3x3x256x256 在 FP16 下约 1.1MB。如果外加一次 SDP 中间结果写读总 DRAM 流量轻松超过 5MB。这些数据都要走内存总线带宽不够时无论 MAC 阵列算得多快引擎都只能等数据。这也是为什么编译器拼命做片上复用如果中间结果能留在 SRAM5MB 流量可能被压到 2MB 以内。学习 NVDLA 时我建议你养成一个习惯拿到网络先算一笔带宽账估算每层最少访问多少次内存再看分块能不能进一步压缩。很多所谓硬件是不是有 bug的问题最后算下来都是带宽不够导致的根本不是逻辑错误。6. 我的 NVDLA 学习路线图半年实测的路径6.1 阶段一Primer 数据流图打底别碰 RTL我强烈建议第一周不要碰任何 Verilog 文件。NVDLA 官方有一份《NVDLA Primer》把架构和数据流讲得比较清楚。你要做的就是找到里面的 block diagram把它抄下来手动标出每个引擎的输入输出、SRAM 位置和依赖关系。为什么先不碰 RTL因为 NVDLA 的 RTL 大量由脚本生成很多模块是 vmod 之类的模板例化直接读会让你面对一堆宏定义和重复代码极其劝退。我知道有人就是死磕 RTL磕了一个月只搞明白了寄存器名架构层面还是一团浆糊。先让数据流图成为你的主干记忆之后再读 RTL 时你才有地图可以对照。6.2 阶段二用软件栈反向验证架构理解第二到三周把重心放到 nvdla-sw 的 compiler 和 runtime 上。我的做法是编译一个极小的 CNN 模型比如只有两三个卷积层然后 dump 出 loadable逐字段对照官方软件文档去看。你会在这一步发现一个非常重要的现象软件层面的一个 Conv 节点在 loadable 里很可能被拆成了多个硬件操作。这就是 tiling 在产物里的直接体现。看到这个现象的那一瞬间你对算子到硬件任务的理解会从抽象概念变成具体事实。我很多次原来如此的顿悟都发生在这个阶段而不是发生在翻 RTL 的时候。6.3 阶段三功能仿真加少量 RTL 巡检第三到四周可以开始跑功能仿真。NVDLA 自带 C model 和回归测试先把工具链跑通用现成用例验证自己对某个引擎的猜测。比如改一个 ReLU 配置观察输出怎么变化这比读代码高效得多。如果确实需要看 RTL我建议以巡检而不是通读的方式进行。比如你想弄懂 SDP 的激活函数选择逻辑就定向打开 SDP 的那一小段代码看配置位和状态机关系看完就撤。千万不要试图从头到尾把卷积引擎读完生成代码的可读性太差性价比极低。大规模仿真比如用 Verilator 跑完整模型时间成本非常高建议提前脚本化编译流程并保存中间产物避免反复重编。6.4 资源清单与避坑备注最后整理一份我实际用过且觉得有用的清单官方《NVDLA Primer》和 OpenASIC 相关文档前端时间反复读nvdla/hw 和 nvdla/sw 两个仓库软件栈优先读 sw一个极简 Caffe 模型用来生成 loadable结构越简单越好官方回归测试用例改一个配置跑一遍验证你的架构理解是否正确不要一上来读 Verilog尤其别碰 vmod 生成文件不要随便更换非官方分支NVDLA 不同小配置的寄存器地址和 buffer 大小都不同换配置就要重编整个软件栈我踩过最大的坑就是把 NVDLA 当成硬件算子库来学结果看 RTL 一个月收效甚微。后来按软件栈入手从编译器生成的 loadable 反推架构一周就打通了。先数据流、后代码这是我唯一想强调的经验。
阅读完成 · 觉得有帮助?
咨询建站