1. 从一行卷积公式说起:为什么需要一张学习地图
很多人第一次接触 NVDLA,是从一份 RTL 代码包或者一张模块框图开始的。打开一看,cmac_a、cmac_b、cacc、cdma、cbuf、csc、cmem、csb、glb、mcif、cvif……几十个缩写扑面而来,每个模块单独看文档都能看懂,但合在一起就不知道谁先谁后、数据怎么流、控制怎么走。更麻烦的是,网上关于 NVDLA 的资料要么是官方文档的翻译,要么是零散的博客片段,很少有人把"从 CNN 算子到硬件流水线"这条链路完整串起来。
我自己当初啃 NVDLA 的时候,走过不少弯路。最开始是直接读cmac_a.v,看了半天乘法阵列,结果发现不知道它在整个流水线里的位置,也不知道它的输入从哪来、输出到哪去。后来才意识到,正确的顺序应该是:先搞清楚 CNN 里到底有哪些算子、每个算子的计算特征是什么,再去看 NVDLA 是怎么把这些算子映射到硬件模块上的,最后才是逐个模块抠 RTL 细节。这个顺序如果搞反了,就会陷入"看得懂每一行代码,但说不清整个系统"的困境。
这篇内容就是把我自己踩过的坑和总结出来的学习路径整理成一张"学习地图"。它适合三类人:一是刚接触 NPU 硬件设计、想系统理解 NVDLA 架构的工程师;二是做 CNN 算法、想了解自己的模型在硬件上到底怎么跑的开发者;三是准备基于 NVDLA 做二次开发或裁剪的团队。核心思路是沿着"算子特征 → 硬件映射 → 流水线调度 → RTL 实现"这条主线走,每一步都解释清楚"为什么这么设计",而不是只告诉你"是什么"。
需要提前说明的是,NVDLA 有 nv_full 和 nv_small 两种配置,前者面向高吞吐场景,后者面向面积受限场景。本文的分析以 nv_full 为主,但核心原理对两者通用,差异点我会在涉及的地方单独标注。另外,NVDLA 的版本演进中有些模块命名和参数有过调整,我以目前公开的主流版本为准,如果你手上的代码包版本不同,对照时注意一下接口信号名的差异。
2. CNN 算子的计算特征:硬件设计的第一性原理
2.1 卷积为什么是 NPU 设计的核心矛盾
CNN 里最核心的算子就是卷积。一个标准卷积层的计算可以写成:输出特征图上每个点,等于输入特征图上对应感受野区域与卷积核的逐元素乘加。用公式表示就是O[n][k][h][w] = ΣΣΣ I[n][c][h+dh][w+dw] * W[k][c][dh][dw],其中 n 是 batch,k 是输出通道,c 是输入通道,dh/dw 是卷积核的空间偏移。
这个公式看起来简单,但它包含了硬件设计中最头疼的几个矛盾。第一是计算量大:一个 3x3 卷积、输入 256 通道、输出 256 通道、特征图 56x56,乘加次数就是 3×3×256×256×56×56 ≈ 18.5 亿次。第二是数据复用机会多但难以充分利用:同一个输入像素会被多个输出通道的卷积核反复使用,同一个权重会被多个空间位置反复使用,但硬件要在有限的时间内把这些复用机会抓住。第三是访存带宽和计算吞吐的失衡:如果每次乘加都要从外部内存读数据,带宽根本扛不住,所以必须在片上做多级缓存。
NVDLA 的整个架构设计,本质上就是在回答一个问题:怎么用有限的片上存储和有限的乘法器阵列,尽可能高效地完成这个三重循环的乘加运算。理解了这一点,后面看任何模块都不会迷失方向。
2.2 除了卷积,还有哪些算子需要硬件支持
实际网络里不只有卷积。以 ResNet、MobileNet 这类典型结构为例,还会遇到以下几类算子,它们的计算特征和卷积差别很大,硬件处理方式也不同:
| 算子类型 | 计算特征 | 硬件处理难点 | NVDLA 对应模块 |
|---|---|---|---|
| 标准卷积 | 三重循环乘加,数据复用高 | 带宽与计算平衡 | CMAC + CBUF |
| 深度卷积 | 每通道独立卷积,通道间无求和 | 并行度低,利用率差 | CMAC(通道分组模式) |
| 全连接 | 矩阵向量乘 | 权重数据量大 | CMAC(1x1 卷积模式) |
| 池化 | 比较或求平均,无乘法 | 需要额外的比较逻辑 | PDP |
| 激活 | 逐元素非线性 | 需要查找表或分段线性 | SDP |
| 归一化 | 逐元素缩放平移 | 需要统计量或参数 | SDP + CDP |
| 逐元素加 | 两路输入逐元素运算 | 需要双路数据对齐 | SDP |
这张表是理解 NVDLA 模块划分的关键。你会发现 NVDLA 把"重计算"的卷积类操作放在 CMAC 里,把"轻计算但需要灵活配置"的逐元素操作放在 SDP 里,把"降采样"的池化单独放在 PDP 里,把跨通道的归一化类操作放在 CDP 里。这种划分不是随意的,而是按照"计算密度"和"数据依赖模式"来切分的。
2.3 算子融合:减少访存的关键手段
如果每个算子都单独执行、单独读写内存,访存开销会大到无法接受。举个例子,一个卷积后面接 BN 再接 ReLU,如果分三步做,就要三次读写特征图。NVDLA 的做法是把这些可以融合的算子串在一条流水线上:卷积的结果先经过 SDP 做 BN 和 ReLU,再写回内存。这样特征图只读写一次,省下了两次往返。
但融合是有条件的。能融合的算子必须满足"逐元素"或"局部"特性,也就是输出只依赖对应的输入点或小邻域,不需要全局信息。卷积、BN、ReLU、逐元素加都满足这个条件,所以可以串起来。而像 Softmax 这种需要全局归约的操作,就没法简单融合,得单独处理。
理解算子融合的边界,对后面理解 NVDLA 的流水线调度至关重要。因为流水线的级数、缓冲的深度、控制信号的握手,全都是围绕"哪些算子能串、串起来后数据怎么对齐"来设计的。
3. NVDLA 的硬件映射:从算子到模块的对应关系
3.1 卷积怎么落到 CMAC 上
CMAC 是 NVDLA 的计算核心,全称是 Convolution MAC。它内部是一组乘法累加单元阵列,nv_full 配置下通常有 64 个 MAC 单元(具体数量取决于配置参数)。但光有乘法器不够,关键是怎么把卷积的三重循环映射到这组阵列上。
NVDLA 采用的策略是通道并行 + 空间并行的混合方案。具体来说,它把输出通道和输入通道的一部分展开到 MAC 阵列上,同时处理多个空间位置。这样做的原因是:如果只做通道并行,空间维度的复用就浪费了;如果只做空间并行,通道维度的权重复用就浪费了。混合方案能在两个维度上都拿到复用收益。
CMAC 内部还分成了 cmac_a 和 cmac_b 两个子模块,这不是简单的复制。cmac_a 主要负责卷积核的乘加,cmac_b 负责处理一些特殊模式,比如深度卷积和 1x1 卷积。为什么要分开?因为深度卷积的通道间没有求和,如果强行用标准卷积的阵列去做,大量乘法器会闲置。分开设计可以让不同算子各取所需,提高整体利用率。
3.2 数据从哪来:CDMA、CBUF 和 CMEM 的分工
计算单元再强,数据供不上也是白搭。NVDLA 的数据供给链路是这样的:外部内存的数据先由 CDMA 搬进来,存到 CBUF 或 CMEM 里,再由 CMAC 读取。
CDMA 是卷积数据搬运器,它负责把输入特征图、权重、以及各种配置数据从外部内存搬到片上。这里有个容易忽略的细节:CDMA 不是简单地做 memcpy,它还要做数据格式转换。比如外部内存里特征图可能是 NCHW 布局,而 CMAC 内部可能按通道分组的方式读取,CDMA 要负责把数据重新排列好。这个转换如果放到计算时做,会拖慢流水线,所以提前在搬运阶段完成。
CBUF 是卷积缓冲,专门存输入特征图。它的容量直接决定了能缓存多大的特征图块。如果特征图太大,CBUF 装不下,就要分块处理,块与块之间会有额外的访存开销。所以 CBUF 的大小是一个重要的设计权衡点:大了面积上去了,小了带宽压力上来了。
CMEM 则是存权重和部分和的地方。权重相对固定,可以提前加载;部分和是卷积累加的中间结果,需要暂存。CMEM 的管理策略直接影响流水线的连续性,如果部分和频繁溢出到外部内存,性能会断崖式下降。
3.3 逐元素操作怎么串进流水线:SDP、PDP、CDP 的角色
卷积算完之后,结果不是直接写回内存,而是先经过 SDP。SDP 全称是 Single Data Processor,它负责处理所有"单输入逐元素"操作,包括 BN 的缩放平移、ReLU 的激活、以及一些简单的逐元素运算。SDP 的设计哲学是"可配置的运算单元",它内部有一组查找表和算术单元,通过配置寄存器来决定执行哪种操作。
PDP 是 Pooling Data Processor,专门做池化。池化和卷积的区别在于它没有乘法,主要是比较(最大池化)或求平均。PDP 单独拿出来做,是因为池化的数据访问模式和卷积不同:卷积需要感受野内的所有点,池化只需要一个窗口内的点,而且窗口之间通常不重叠或少量重叠。用专门的硬件做,比复用 CMAC 更高效。
CDP 是 Channel Data Processor,处理跨通道的操作,比如 LRN(局部响应归一化)和跨通道的缩放。这类操作的特点是输出某个通道的值需要参考同一空间位置上其他通道的值,数据依赖模式又和前面几种不同。
这三个模块加上 CMAC,构成了 NVDLA 的四条主要计算路径。它们可以串联使用,形成一条完整的算子流水线。串联的顺序和配置,由编译器生成的配置信息决定。
3.4 控制与调度:CSB、GLB 和 MCIF 的协同
硬件模块再多,没有统一的控制也是散沙。NVDLA 的控制体系分两层:CSB 是配置空间总线,负责把软件配置的寄存器值分发到各个模块;GLB 是全局控制模块,负责生成各模块的使能、握手、时序控制信号。
CSB 的工作方式值得说一下。它不是简单的寄存器堆,而是带地址译码和广播机制的。软件通过 CSB 写入配置后,CSB 会把配置分发到对应模块的本地寄存器。这样做的好处是各模块可以独立配置、独立启动,支持流水线式的并行执行。
MCIF 是内存接口控制器,负责 NVDLA 和外部内存之间的所有读写请求。它要处理的事情包括:请求的仲裁(多个模块同时要访存怎么办)、地址的映射、以及读写时序的适配。MCIF 的设计质量直接影响 NVDLA 的实际带宽利用率,如果仲裁策略不好,高优先级的数据请求被低优先级的阻塞,流水线就会出现气泡。
4. 硬件流水线的调度逻辑:数据怎么流动起来
4.1 流水线的级数与握手协议
NVDLA 的流水线不是一条简单的直线,而是带分支和反馈的图结构。以一次典型的卷积+BN+ReLU 为例,数据流动路径是:CDMA 从内存读输入特征图 → CBUF 缓存 → CMAC 做卷积 → CMEM 暂存部分和 → SDP 做 BN 和 ReLU → 写回内存。这条路径上有多个模块,每个模块的处理延迟不同,怎么保证数据不错位、不丢失?
答案是握手协议。NVDLA 内部广泛使用 valid-ready 握手:上游模块数据准备好后拉高 valid,下游模块能接收时拉高 ready,两者同时有效时数据才传输。这种协议的好处是天然支持反压:如果下游忙不过来,ready 不拉高,上游就会暂停,不会丢数据。
但握手协议也带来一个问题:如果流水线上某一级经常反压,整个流水线的吞吐就会受限于最慢的那一级。所以 NVDLA 在关键路径上加了 FIFO 缓冲,用来吸收短时的速率不匹配。FIFO 的深度选择是个经验活:太浅起不到缓冲作用,太深浪费面积还增加延迟。
4.2 双缓冲与乒乓操作
卷积计算有个特点:输入数据块和权重数据块需要反复使用。如果每次使用都重新加载,带宽受不了。NVDLA 采用的策略是双缓冲,也就是准备两块缓冲区,一块正在被计算单元读取,另一块同时在加载下一批数据。等当前块算完,两块角色互换,计算不中断。
这种乒乓操作在 CBUF 和 CMEM 的管理上都有体现。实现的关键是地址生成逻辑要能区分"当前块"和"下一块",并且切换时机要精确。如果切换太早,计算单元可能读到还没加载完的数据;切换太晚,计算单元会空等。NVDLA 通过配置寄存器和状态机来控制切换点,具体时机和特征图大小、卷积核大小都有关系。
4.3 部分和的处理:什么时候累加、什么时候写出
卷积的累加不是一次完成的。当输入通道数很大时,CMAC 一次只能处理一部分通道,剩下的通道要分多次累加。这就产生了部分和的概念:第一次算出的结果先存起来,第二次算完加上去,直到所有通道处理完才得到最终结果。
部分和的存储策略直接影响性能。如果部分和一直放在 CMEM 里,容量够的话最好,累加时直接读改写,速度快。但如果 CMEM 装不下,部分和就要溢出到外部内存,每次累加都要往返一次,带宽开销巨大。NVDLA 的编译器会尽量把部分和安排在片上,但遇到超大通道数时也不得不溢出。理解这一点,就能明白为什么有些网络结构在 NVDLA 上跑得快、有些跑得慢。
4.4 流水线气泡从哪来,怎么减少
理想情况下,流水线应该一直满负荷运转。但实际中总会有气泡,也就是某些周期里计算单元没有有效工作。气泡的来源主要有几个:一是数据依赖,比如下一层需要上一层的完整输出才能开始;二是资源冲突,多个模块同时抢同一个内存端口;三是配置切换,换一层网络时寄存器要重新配置,这期间流水线要暂停。
减少气泡的手段包括:算子融合(减少层间依赖)、预取(提前把下一层的数据搬进来)、以及配置预加载(在当前层还在算的时候就配置好下一层)。NVDLA 对这些都有支持,但需要编译器在生成配置时合理安排。这也是为什么 NVDLA 的软件栈(编译器)和硬件同样重要,光有硬件没有好的调度,性能发挥不出来。
5. 逐个模块啃 RTL 的正确姿势
5.1 读 RTL 之前先读什么
直接打开 RTL 文件是最低效的做法。我的经验是,读 RTL 之前先做三件事:第一,把该模块的接口信号列表整理出来,搞清楚每个信号的位宽、方向、含义;第二,找到该模块对应的配置寄存器,理解软件怎么控制它;第三,画一张该模块和上下游模块的连接图,标清楚数据流和控制流。
以 CMAC 为例,读之前你应该知道:它的输入来自 CBUF 和 CMEM,输出到 CMEM 和 SDP,控制信号来自 GLB,配置来自 CSB。有了这个框架,再看 RTL 里的信号就不会迷路。如果一上来就看always @(posedge clk)里的逻辑,很容易陷进去出不来。
5.2 从顶层到底层:自顶向下的阅读顺序
NVDLA 的 RTL 组织是层次化的。顶层是nvdla.v或者类似的 wrapper,下面挂各个子模块。正确的阅读顺序是自顶向下:先看顶层怎么例化子模块、怎么连线的,再看每个子模块的顶层接口,最后才深入到子模块内部的实现。
具体到 CMAC,先看cmac.v的端口和例化,理解它怎么把 cmac_a 和 cmac_b 组合起来;再看cmac_a.v的接口,理解它的输入输出协议;最后才看内部的乘法阵列和累加逻辑。这个顺序能保证你始终知道"当前看的这段逻辑在整个系统里是干什么的"。
5.3 关键模块的 RTL 阅读重点
不同模块的 RTL 阅读重点不一样,我整理了一个对照表:
| 模块 | 阅读重点 | 容易忽略的细节 |
|---|---|---|
| CMAC | 乘法阵列的组织、累加树的结构 | 部分和的位宽和溢出处理 |
| CBUF | 存储体的划分、读写端口仲裁 | 跨时钟域的处理 |
| CDMA | 地址生成、数据重排逻辑 | 边界情况的处理(非对齐地址) |
| SDP | 查找表实现、配置译码 | 饱和截断的时机 |
| PDP | 比较树、窗口滑动逻辑 | 池化窗口边界的填充 |
| CSB | 地址译码、寄存器读写时序 | 写冲突的处理 |
以 CBUF 为例,它的核心是存储体(bank)的划分。为什么要分 bank?因为 CMAC 需要同时读取多个数据,如果只有一个存储体,端口不够用。分 bank 后,不同 bank 可以并行访问,但要注意地址映射,避免多个请求落到同一个 bank 造成冲突。这个细节在 RTL 里体现为 bank 选择逻辑和冲突检测逻辑,是阅读的重点。
5.4 仿真验证:怎么确认自己读懂了
读 RTL 最怕的是"以为自己懂了"。确认的方法是做仿真:给模块喂一组激励,看输出是否符合预期。NVDLA 的代码包里通常带有 testbench,可以从简单的用例开始跑。
我的做法是先用一个小尺寸的卷积(比如 3x3 输入、1 个通道、1 个输出通道),手动算出预期结果,然后跑仿真对比。如果对上了,说明基本逻辑理解了;再逐步加大尺寸、增加通道数,观察部分和的处理、双缓冲的切换是否符合预期。这个过程很枯燥,但比单纯读代码有效得多。遇到对不上的地方,就是理解有偏差的地方,顺着波形往回查,往往能发现之前忽略的细节。
6. 学习路径上的常见误区与实操建议
6.1 不要一上来就抠时序细节
我见过不少人,刚开始学 NVDLA 就一头扎进时序约束和时钟树,结果看了两周还在纠结某个信号的建立时间。这是典型的顺序错误。时序优化是架构理解清楚之后的事,前期应该把精力放在数据流和控制流上。等你知道了数据怎么流、模块怎么配合,再回头看时序,才知道哪些路径是关键路径、为什么要那样约束。
6.2 软件栈和硬件要对照着看
NVDLA 不只是硬件,它有一整套软件栈:编译器把 CNN 模型转成 NVDLA 的配置,运行时驱动把配置加载到硬件。如果只看硬件,你会疑惑"这些配置寄存器到底是谁写的、什么时候写的";如果只看软件,你会疑惑"这些配置到底控制了什么硬件行为"。对照着看,才能形成完整认知。
具体做法是:拿一个简单的网络(比如 LeNet),用编译器生成配置,然后对照配置逐条看它对应哪个模块的哪个寄存器,再去看那个寄存器在 RTL 里怎么用。走通一遍,整个链路就清楚了。
6.3 动手改一个小参数,观察连锁反应
理解一个系统最好的方式是改它。在 NVDLA 上,你可以尝试改一些参数,比如 CBUF 的大小、MAC 阵列的并行度,然后重新综合、跑仿真,观察性能变化。这个过程能让你直观感受到每个参数的作用和代价。
比如把 CBUF 改小,你会发现某些层的性能下降明显,因为特征图装不下要分块,分块带来额外的访存。把 MAC 并行度提高,理论算力上去了,但如果数据供给跟不上,实际性能提升有限,这就是所谓的"带宽墙"。这些体会是看文档看不来的。
6.4 建立自己的笔记体系
NVDLA 的模块多、信号多、配置多,光靠脑子记不住。我从一开始就建了一个笔记体系:每个模块一页,记录接口信号、配置寄存器、数据流路径、阅读时发现的疑点和解答。遇到跨模块的问题,单独开一页记录。时间长了,这本笔记就是自己的 NVDLA 手册,比任何官方文档都贴合自己的理解习惯。
笔记的形式不重要,重要的是持续更新。每次仿真发现新细节、每次读代码有新理解,都记进去。几个月后回头看,能清楚看到自己的理解是怎么一步步深入的。
6.5 几个容易踩的坑
第一个坑是混淆 nv_full 和 nv_small 的配置。两者的模块数量和参数不同,看文档时如果不注意配置,会把参数搞混。建议一开始就确定自己看的是哪个配置,全程保持一致。
第二个坑是忽略位宽和精度。NVDLA 支持 INT8 和 INT16 两种精度,不同精度下数据通路的位宽不同,累加器的位宽也不同。如果看 RTL 时不注意位宽,会疑惑"为什么这里要截断""为什么那里要扩展"。搞清楚精度模式,这些问题就迎刃而解。
第三个坑是把配置顺序当无关紧要。NVDLA 的配置寄存器有依赖关系,某些寄存器必须在另一些之前写,否则硬件行为不确定。这个顺序在软件栈里有体现,读 RTL 时如果看到状态机对配置顺序有要求,不要觉得是多余的限制,那是硬件正确工作的前提。
第四个坑是忽视复位和初始化。仿真时如果不做正确的复位,寄存器初值不确定,行为会随机。实际硬件里复位更重要,复位不干净会导致各种诡异问题。读 RTL 时留意复位逻辑,能帮你理解硬件的初始状态。
7. 从理解到改造:下一步可以做什么
把上面这条路径走通之后,你对 NVDLA 的理解就不再是零散的模块知识,而是一张完整的图。这时候可以做的事情就多了:可以针对特定网络结构做硬件裁剪,去掉用不到的模块省面积;可以修改数据通路支持新的算子;可以优化调度策略提升特定场景的性能。
我自己的体会是,NVDLA 作为一个开源 NPU 参考设计,最大的价值不是它的绝对性能,而是它把"CNN 算子怎么映射到硬件"这件事完整地展示出来了。理解了它,再看其他 NPU 架构,会发现很多设计思路是相通的:数据复用、算子融合、流水线调度、带宽与计算的平衡,这些是共性问题,NVDLA 给出的是一套经过验证的答案。
如果你正在啃 NVDLA,建议不要贪快。一个模块一个模块地过,每个模块都问自己三个问题:它解决什么问题、它怎么解决的、它的代价是什么。三个问题都能答上来,才算真的懂了。这个过程慢,但扎实。等哪天你看到一个新的网络结构,能下意识地判断出它在 NVDLA 上大概会怎么跑、瓶颈可能在哪,那这张学习地图就算真正内化了。