news 2026/10/26 9:42:03

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从算子到引擎:NVDLA硬件流水线与学习路线解析

第一次翻开 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 卷积或 GEMM,NVDLA 的卷积引擎也能映射,但效率取决于分块策略,后面讲到 tiling 时你会有更直观的感受。

2.2 整张图进不去引擎:分块是第一道工序

很多人第一次看 NVDLA 会问:为什么不把整张 feature map 一次性搬进片上 SRAM,然后一口气算完?算一笔账就明白了。

假设输入是 56x56x256,FP16 格式,每个元素 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跨通道数据处理器跨通道归一化,比如 LRN
BDMA桥接 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 后的工作其实非常克制:

  1. 分配内存:把输入、输出、权重、描述符放到约定地址;
  2. 下发描述符地址:把每个操作描述符在内存中的地址写到对应引擎的寄存器;
  3. 触发启动:写触发位,让各引擎开始工作;
  4. 等待完成:通过中断或轮询完成状态,然后取走输出。

手动配置寄存器有个特别容易踩的坑:必须先写描述符地址,再写触发位。如果你先触发,引擎可能从一个无效地址去读描述符,轻则死等,重则跑出完全不可控的结果。这种时序错误在真实芯片上非常难复现,因为每次内存布局可能有细微差别,所以我建议你在 runtime 里把这步做成固定顺序,不要优化掉。

4.3 最小执行链:Conv 加 ReLU 加 MaxPool

把前面内容串起来,跑一个 Conv 加 ReLU 加 MaxPool 的最小链路,实际执行序列大概是这样的:

  1. runtime 加载 loadable,并把输入图像放到指定内存地址;
  2. CUBE 引擎读取描述符,开始卷积运算,把中间结果写进片上输出 buffer;
  3. 卷积完成后释放一个依赖信号,SDP 引擎收到信号后,读取同一份中间结果,逐元素比较大小做 ReLU,然后写回;
  4. SDP 完成后释放第二个信号,PDP 引擎启动,用滑动窗口选出最大值,得到池化输出;
  5. 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 阵列算得多快,引擎都只能等数据。

这也是为什么编译器拼命做片上复用:如果中间结果能留在 SRAM,5MB 流量可能被压到 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 反推架构,一周就打通了。先数据流、后代码,这是我唯一想强调的经验。
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 9:42:01

STM32寄存器级中断系统详解:NVIC、EXTI与SysTick实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/26 9:40:31

Versal ACAP全局时钟网络资源解析:从BUFG到时钟区域实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/26 9:39:37

PLC除零异常防护:从故障复现到跨平台安全除法实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/26 9:37:11

零代码AI开发:用自然语言指令实现图片去水印

1. 项目背景与核心思路去年帮朋友处理一批带水印的摄影作品时,我突发奇想:能不能完全不写代码,仅通过指令就完成一个图片去水印工具的开发?这个挑战源于两个现实需求:一是很多非技术背景的内容创作者需要快速去水印工具…

作者头像 李华
网站建设 2026/10/26 9:36:59

电子班牌系统建设全攻略:从硬件选型到实施运维的避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/26 9:36:33

2048微信小程序源码全解析:从配置到核心逻辑与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华