做了两年AI硬件加速方面的开发,我慢慢发现一个规律:上手AI加速器的人,十个里有八个不是被电路难倒的,而是被"算子"和"流水线"之间的那座桥搞晕的。你在PyTorch里调一个nn.Conv2d,觉得卷积就是"乘一乘、加一加",可是当你打开NVDLA的架构文档,看到CSC、CMAC、CACC、DBUF这一堆缩写,完全不知道它们和CNN算子怎么对应。这篇东西就是从CNN算子出发、最终看懂NVDLA硬件流水线的一份学习地图,既是我自己的复盘,也给想入门AI加速器的朋友做个参考。NVDLA(NVIDIA Deep Learning Accelerator)是NVIDIA开源的一套深度学习推理加速器IP,硬件代码、架构文档、编译器、驱动全都在GitHub上公开,算是目前少有的、能让人把"算子—硬件—软件栈"三层一次看全的学习样本。
1. 想深入硬件加速,先认清CNN算子有多"难伺候"
1.1 卷积的"国际单位":MAC、FLOPS 与访存量
先把最基础的东西讲透。卷积神经网络里最大头的计算来自卷积层(Convolution Layer),它的本质是无数个乘累加(Multiply-Accumulate,MAC)。一个输出像素点,需要把一个卷积核窗口内的所有输入像素和权重分别相乘再累加。举个具体例子:
假设输入特征图是 56×56×256(高×宽×通道数),要输出 56×56×256,卷积核是 3×3×256。那么每个输出像素需要的乘法次数是 3×3×256 = 2304 次。整个输出平面有 56×56×256 ≈ 80.3 万个输出像素,总乘累加次数大约是 2304 × 80.3万 ≈ 18.5亿次。如果按一个MAC算两次浮点操作(乘一次、加一次),这一层的浮点运算量就是 37 GFLOPs 左右。
这个数字意味着什么?如果你的加速器有 2048 个MAC单元、工作在 1GHz 主频下,那么理论峰值是每秒 2048 × 10^9 = 2.048 T MACs,也就是约 2 TOPS 的算力。18.5亿次 MAC 在这条理论峰值下大约需要 0.9 毫秒。听起来很快,对吧?但真正的麻烦在于:数据从哪来。
这一层的输入数据有多大?56×56×256,每个数按4字节算,大概是 3.2MB。权重呢?256×256×3×3 = 58万多个权重,大概也是 2.4MB。如果每算一个像素就从内存里重新拉一次数据,访存时间会远超计算时间。这就是为什么硬件加速设计的第一课不是"怎么算得快",而是"怎么让数据流得起来"。
1.2 数据重用才是卷积的灵魂
我接触硬件加速之后,最大的认知转变就是:卷积算子之所以在硬件上能有极高的效率,不是因为乘累加本身快,而是因为它的数据重用特性极好。
一个 3×3 卷积核在特征图上滑动,相邻两个输出像素使用的输入数据有重叠。比如一个 3×3 窗口向右滑一格,新的窗口和旧的重叠两列。这意味着同一份输入数据可以被多个输出窗口复用。另外,同一个输入像素会被同一层的所有输出通道(也就是所有卷积核)使用,这叫输入重用;同一份权重会被同一张特征图的所有空间位置使用,这叫权重重用。
硬件设计者所有的功夫都花在怎么把这个"重用"变成"片上缓存命中"。NVDLA给出的方案是:把计算切块,让一小块输入数据和一小批权重先搬到片上存储,在这一小块内完成尽可能多的乘累加,再搬下一块。这就是后面要讲的tiling(分块)思想的源头。
1.3 池化、激活这些算子也不是省油的灯
卷积之外,现代CNN里还有ReLU、池化(Pooling)、批归一化(BatchNorm,推理时通常融合成缩放加偏置)、全连接、Softmax等算子。从硬件角度看,它们分两类:
一类是逐元素操作,比如ReLU、批量归一化的缩放平移。这类算子计算量不大,但会引入额外的访存开销,因为每个元素都要从内存读一遍、写一遍。硬件上最好把它们和卷积"融合"到同一条流水线里,算完卷积立刻做激活,结果根本不出片,省掉一轮往返。
另一类是跨像素操作,比如池化,3×3窗口取最大值。它需要空间上的邻域数据,所以对数据流的组织方式有要求。NVDLA专门安排了一个平面数据处理器来做池化,它能以非常高的吞吐处理最大池化和平均池化,而且可以配置滑窗步长。
理解了这些算子各自的计算规律和访存规律,再去看硬件架构,就会觉得每个模块的出现都是被"逼"出来的:主计算单元应对乘累加,SDP应对逐元素变换,PDP应对空间窗口操作。没有这些算子的约束,硬件长不成这个样子。
2. 识图先看底盘:NVDLA的硬件格局
2.1 五大引擎各管一摊
NVDLA是NVIDIA在2017年开源的深度学习推理加速器架构。它不是一个完整SoC,而是一个可以集成到自定义芯片里的IP核。学习它最大的价值在于:它是为数不多把"AI加速器的设计思路"完整开源,并且配套了编译器、驱动和文档的项目。
NVDLA的硬件主体可以分成几个大块:
| 硬件单元 | 主要职责 | 对应的CNN算子 |
|---|---|---|
| 卷积核心(CSC/CMAC/CACC) | 数据调度、乘累加、部分和累加 | 卷积层、全连接层(1x1卷积) |
| SDP单点数据处理器 | 逐元素数学变换、查表 | ReLU、Sigmoid、缩放、偏置 |
| PDP平面数据处理器 | 空间窗口聚合 | 最大池化、平均池化 |
| CDP跨通道数据处理器 | 跨通道归一化 | 局部响应归一化(LRN) |
| Rubik | 数据布局转换 | NCHW/NHWC格式互转 |
先重点说卷积核心。它是整个加速器的心脏,内部又分成三个子模块:CSC(Convolution Sub-Core)负责数据搬运和重用调度,CMAC(Convolution MAC)是真正的乘累加阵列,CACC(Convolution Accumulator)负责把部分和累加并暂存在片上累加缓冲里。卷积层的计算主要就在这一条链路上完成。
SDP(Single Data Processor)处理ReLU、sigmoid、tanh、缩放、偏置、查表这类逐元素操作。它里面有几级可配置的处理流水线,还能直接吃卷积核心吐出来的中间结果,实现算子融合。PDP(Planar Data Processor)处理最大池化、平均池化等窗口类操作,它和卷积核心是独立的,在某些流水安排下可以并行工作。
CDP处理局部响应归一化这类跨通道操作,实际部署中用得相对少,很多主流网络根本用不着,但它也是一个完整的硬件模块。除了这些,还有一个非常关键的部件叫Rubik,专门做数据格式转换。比如把NHWC布局的特征图转换成NCHW布局,或者反过来。这个模块看似不起眼,实际上很多模型如果数据布局不匹配,计算效率会差一个数量级。
所有模块统一通过配置空间总线(CSB,Configuration Space Bus)由外部Host配置。Host可以是CPU、MCU,或者任何能访问这个总线的设备。
2.2 数据搬运决定生死
学NVDLA一定要建立的一个观念:计算单元是廉价的,数据搬运是昂贵的。
举例来说,NVDLA默认的大配置有2048个MAC单元,按1GHz跑,INT8下每秒能做超过2万亿次乘累加。这个算力级别意味着,只要数据喂得够快,单个卷积层往往在1毫秒内就能算完。但如果你让每个数据从DDR里现取现算,DDR的带宽很快就会变成瓶颈。一块LPDDR4x的带宽大概是30~40GB/s,按这个速度,搬3.2MB数据需要大约0.1毫秒,看起来还行,但注意卷积层往往是几十上百层堆叠的,每层都要搬输入、搬权重、搬输出。算下来访存时间远大于计算时间。
NVDLA缓解这个问题的方式是:
- 深度流水线:数据DMA进来、卷积计算、结果写回,三个环节同时进行(不同层或不同块之间),而不是等一层算完再搬下一层。
- 片上缓冲(DBUF):卷积核心前面有一块数据缓冲,专门缓存当前计算分块的输入特征图。权重也有专门的缓冲和预加载机制。
- 权重压缩:NVDLA支持对权重做稀疏压缩存储,减少从DRAM搬权重的字节数。
我在实际评估性能的时候,总会先画一张"数据流图",把每一层需要搬多少MB、能复用多少次列出来。这张图比任何性能模型都直观。
2.3 Host怎么指挥NVDLA:CSB与描述符
NVDLA不是独立运行的,它必须由外部Host"喂"指令。这个交互关系很多人一开始搞不明白。其实很简单,你可以把NVDLA想象成一个高级外设。
Host通过CSB总线向NVDLA的寄存器写入配置。这些配置包括:当前要执行什么类型的操作(卷积、池化、激活)、输入数据在内存里的地址、特征图尺寸、卷积核尺寸、步长、填充等等。
但NVDLA的寄存器数量很多,如果每个操作都一个寄存器一个寄存器地写,会非常繁琐。所以实际使用时,软件会先把这些配置打包成结构体(描述符和表面描述符),放到Host内存里,然后只需要告诉NVDLA"这个描述符在哪个地址"、触发一次启动,NVDLA就会自己去内存里DMA这些描述符,再按里面的内容执行。执行完通过中断通知Host。
这个设计很像GPU的command buffer。理解这一点,后面看nvdla_compiler生成的loadable文件时,就能明白那些二进制内容是什么了。
3. 从一层卷积算子到硬件流水线的完整旅程
3.1 空间分块:为什么不能一次算完整张特征图
现在进入正题:一个具体的卷积层,在NVDLA里是怎么算的?
前面那个例子,输入特征图3.2MB(56×56×256),如果再加上权重2.4MB、输出3.2MB,一次全放片上不太现实。NVDLA内部可用的缓冲空间有限(具体大小取决于配置),所以必须把计算切块。
NVDLA的做法是:把卷积层分解成一个个"操作"(operation),每个操作负责计算输出特征图的一个分块(tile)。硬件在一个循环里依次执行这些操作。
切块不是随便切的,要综合考虑两个因素:
- 输入分块要有足够的数据覆盖。比如算某个区域的输出,你要把该区域对应的输入区域连同卷积核半径的"halo"一起搬进缓冲。3×3卷积核、计算一个4×4的输出块,输入至少需要6×6的区域(4+2)。
- 分块大小要尽量匹配MAC阵列的形状。NVDLA有"atom"的概念,它规定了单次计算的基本粒度。如果分块和atom对齐,计算效率最高;不对齐的部分通常要靠填充(padding)补齐,会浪费一些算力。
很多刚入门的人在这里会犯一个错误:试图在软件层面模拟"整个卷积层一次算完",然后发现性能和预测差很远。原因是他们没有理解硬件是按分块循环工作的,每个分块之间有切换开销,分块太小会导致开销比率过高。
3.2 特征数据与权重的硬件布局
NVDLA对特征数据的组织方式有自己的要求。它在内存里使用的是一种以通道分组为基础的布局,内部对通道数量有对齐要求(比如按8的倍数组织),并且可以通过Rubik模块在NCHW和NHWC之间做转换。这个点特别容易踩坑,因为主流框架默认的布局和NVDLA的偏好不一定一致。编译器在处理模型时通常会生成转换操作,但如果手动写驱动或者改模型,很容易在数据布局上翻车。
权重更特殊。NVDLA的权重不是像Caffe模型文件里那样直接存放的,编译器会把权重预处理成硬件友好的格式,包括:
- 按卷积核、输出通道、输入通道重排;
- 可能进行稀疏压缩编码;
- 如果启用权重压缩,还会带上压缩相关的元数据。
所以你在nvdla_compiler中间产物里看到的权重,和原始caffemodel里的权重对不上,是很正常的,那是被"编排"过的结果。
3.3 卷积引擎内部的流水线节奏
假设分块已经确定,接下来看引擎内部怎么跑。
以一条典型的卷积计算流水线为例:
- Bridge DMA从DRAM把输入分块搬到片上数据缓冲DBUF;
- CSC从DBUF里取输入数据,同时取对应的权重数据,按预定的调度顺序把它们喂给CMAC阵列;
- CMAC阵列执行乘累加,产生部分和,送到CACC;
- CACC把部分和累加出完整结果,先存在累加缓冲里;
- 如果这层后面要接ReLU之类的激活操作,SDP可以直接从累加缓冲里取数据进行处理;
- 最后结果通过DMA写回DRAM。
关键在于第2、3、4步是高度流水的:CSC一边喂数据,CMAC一边算,CACC一边累加,三个环节同时进行。只有当当前分块的所有输入数据都用完了,CSC才会停下来去取下一个分块。层与层之间也一样,上一层的输出块还在往DRAM写的时候,下一层的输入块已经在往DBUF搬了,这就是硬件流水线里"重叠"的含义。
这期间Host要做的事情很少,只有开始前配置好描述符、结束后处理中断。这也是为什么NVDLA在嵌入式SoC里可以做到低功耗、低CPU占用率的原因。
4. 实操映射:拿一个 Conv->ReLU->Pooling 走一遍全流程
4.1 先算清楚这一层的大小
纸上谈兵没有用,我们来走一个实际例子。假设一个经典的小网络层:输入 64×64×64,卷积核 3×3×64,输出通道64,步长1,填充1,ReLU在后,接着是2×2最大池化。
先算计算量:输出特征图还是64×64×64。每个输出像素的MAC数:3×3×64 = 576。输出像素总数 64×64×64 = 262144 个。总MAC = 576 × 262144 ≈ 1.51亿。按2FLOP/MAC算,就是约3亿FLOPs,即0.3 GFLOPs。这对NVDLA来说,理论计算时间在0.2ms以内(按2TOPS算)。
再看数据量:输入 64×64×64×4B = 1MB;权重 3×3×64×64×4B = 147KB;输出 64×64×64×4B = 1MB。Pooling的输入就是刚才的输出,读1MB、写0.25MB。
从访存角度,这一层最划算的做法是:把权重常驻片上(才147KB),输入分块DMA进来、算完立刻做ReLU、就地(或在片上缓冲)做池化、把池化后的0.25MB写回DRAM。这样中间那个1MB的卷积输出根本不需要完整落回DRAM,访存总量可以少一大截。这就是算子融合的价值:ReLU和Pooling都被"粘"在卷积流水线上了。
4.2 nvdla_compiler在背后干了什么
NVDLA的官方软件栈是围绕Caffe模型设计的。用nvdla_compiler工具,输入一个prototxt和caffemodel,它会经过图解析、算子映射、内存规划、权重重排、指令生成这几步,最终产出一个loadable文件。
图解析阶段,编译器读入网络结构,识别出每一层是什么类型的算子。这里有个常见的误解:不是每一层都能直接映射到NVDLA的硬件单元。比如BatchNorm层,编译器会被融合到前面的卷积层里,变成对卷积输出做scale+bias;有些编译器支持的算子有限,遇到不支持的层直接报错。
内存规划阶段,编译器分析每个张量的生命周期,决定它们放在DRAM还是片上SRAM,以及各操作分块的顺序。这个阶段直接决定了运行时数据的实际访存路径。
指令生成阶段,编译器把每个操作实例化成描述符结构体,填入地址、大小、配置参数,然后打包成loadable。loadable里除了这些描述符,还有重排好的权重数据。
4.3 运行时:驱动和硬件的协同
拿到loadable之后,运行时就简单了:驱动把loadable加载到内存,然后配置NVDLA的执行入口,硬件开始一条一条跑操作序列。
值得注意的一点是,loadable里面包含了操作之间的依赖关系。比如某个卷积操作依赖前一个操作的输出就绪,硬件或驱动需要保证这个顺序。NVDLA的做法是通过操作序列的有向图,以及片上缓冲和DRAM之间的同步机制来管理。
我在自己做集成测试时,习惯先跑NVDLA自带的emulator模式。它用C模型模拟整个硬件行为,不需要真实芯片。虽然速度慢,但可以单步跟踪每个操作的执行过程、检查寄存器的配置对不对,排查问题比上FPGA快得多。
5. 学习地图:给后来者的路线与踩坑清单
5.1 按什么顺序读资料
NVDLA的学习资料不算少,但分散。我建议的顺序是:
第一步,先把CNN算子层面的东西吃透,尤其是卷积、池化、ReLU的数据流特征。这一步不需要碰硬件,用PyTorch或者Caffe把网络跑起来,统计每一层的FLOPs和访存量就够了。
第二步,读NVDLA的架构文档,重点看卷积核心的部分。官方在GitHub上的hw仓库有架构说明文档,还有一份"T19X训练材料",相当于官方的入门教程。核心目标是搞明白atom、tile、DBUF、CSC/CMAC/CACC这几个概念,能在纸上画出一条卷积数据的流向。
第三步,用nvdla_compiler跑通一个官方示例模型。Caffe版本的AlexNet、ResNet等都有现成示例。看着编译日志里每一个操作的类型和分块参数,对照架构文档理解"为什么这么切"。
第四步,有条件就上FPGA或者仿真环境。哪怕只是跑NVDLA的C模型仿真(emulator),也能加深理解。如果直接用RTL仿真加上波形分析,那理解会更深入,但对环境要求高很多。
第五步,尝试自己修改网络并在其上部署,或者尝试在驱动层加调试打印。这一步开始,你就会遇到各种对齐、格式、时序问题,也就是踩坑阶段,但收获最大。
5.2 上手必备工具清单
- nvdla/hw仓库:RTL代码、架构文档、测试用例;
- nvdla/sw仓库:nvdla_compiler、runtime驱动、emulator模型;
- Caffe框架(官方编译器主要是Caffe前端);
- 一个能跑Linux的开发板或FPGA验证平台。没有真硬件时,emulator模式永远是第一个伙伴。
工具链的部署本身也有不少坑。我最早折腾编译器的时候,发现它对CMake版本、protobuf版本都有要求,而且编译时间不短。建议直接用官方脚本构建,别自己在宿主机上裸编译,否则各种依赖冲突会耗尽你的耐心。另外NVDLA的代码比较老了,用新版本GCC编译可能会遇到警告变错误的问题,需要加一些兼容性参数。
5.3 四个让我印象深刻的坑
第一个坑是数据布局导致的精度错误。我用一个自己转换的模型跑emulator,结果前几层输出和CPU参考完全一致,到某一层突然出现NaN。查到最后是NHWC和NCHW混用,Rubik模块的转换配置不对。教训是:永远先用最简单的example模型验证数据通路,再上自己的模型。
第二个坑是分块越切越小导致性能雪崩。有一层输入分辨率特别大,我为了省片上存储把分块切得很小,结果性能直线下降。后来看C模型的统计信息才发现,每个操作的开销包括DMA配置、流水线排空和重启,分块小到一定程度后,开销占比会急剧上升。正确做法是让分块尽量大,大到位移开销可以忽略为止。
第三个坑是忽略了权重压缩的影响。启用权重稀疏压缩后,访存量确实降了,但某些层权重太密集,压缩率不高,反而多了解压开销。后来我养成了一个习惯:对每一层的权重稀疏度做统计,只对稀疏度超过一定阈值的层启用压缩。
第四个坑是中断处理。运行时驱动如果用轮询方式等操作完成,CPU占用会高;如果用中断,又要注意中断丢失和竞态条件。NVDLA的中断配置在不同实现里要求不一样,这一块最好直接用官方驱动模版改,不要自己从零写。
我个人在实际学习这条链路时的最大体会是:NVDLA虽然是硬件项目,但真正卡住大多数人的其实不是RTL,而是"算子数据流"和"硬件调度"这两个心智模型。如果你能把"一层卷积在硬件里被切成多少块、每块数据从哪来、算完去哪"这件事在脑子里跑通,后面读任何AI加速器的论文和代码都会快很多。最后再分享一个小技巧:遇到想不通的地方,就在纸上画数据流时空图,横轴是时间、纵轴是存储层次,把每次DMA搬运和计算标出来。我靠这个办法解决过不少性能分析和调试难题,希望你也能用上。