ARTICLE DETAIL

资讯详情

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

CNN加速器从零设计指南:RTL实现、FPGA验证与数据流架构解析

CNN加速器从零设计指南:RTL实现、FPGA验证与数据流架构解析 这段时间正好把实验“4-1”要做的CNN加速器从头到尾跑通了一版从算法拆解到RTL实现再到板上验证踩了不少坑也积累了一些很实在的经验。很多朋友问AI芯片、CNN加速器这类项目到底从哪下手是不是一定要用脉动阵列是不是非得上先进工艺——其实入门不复杂关键是把“算力需求”和“数据流结构”讲清楚。这篇我就按自己实际做的过程来聊尽量把设计思路、模块划分、参数计算和验证方法都写明白希望给正在做数字IC设计课设、FPGA加速实验或者刚接触AI芯片方向的读者一个能直接参照的版本。1. 先搞清楚CNN加速器究竟在加速什么很多第一次做这个主题的人上来就开始写Verilog想赶紧把卷积算出来。但我建议先回到算法本身把一个普通卷积到底做了什么、时间消耗在哪里想清楚后面写RTL才会顺。1.1 加速的目标是循环不是单个乘法一个卷积层可以表示成四层或者五层循环for (int oc 0; oc OC; oc) for (int oy 0; oy OY; oy) for (int ox 0; ox OX; ox) for (int ic 0; ic IC; ic) for (int ky 0; ky KY; ky) for (int kx 0; kx KX; kx) sum input[ic][oy*strideky][ox*stridekx] * weight[oc][ic][ky][kx];最内层是一个乘累加操作也就是所谓的MAC。每做一次MAC需要同时取一个输入像素和一个权重然后把结果累加进部分和。所以一个加速器真正要解决的是两件事第一让每个时钟周期尽可能多完成MAC操作第二让喂给计算单元的数据能持续不断地供应上。先估算一个具体例子。假设一层卷积的输出是32x32输出通道是32输入通道是16卷积核是3x3输出像素数32×32 1024每个输出像素需要做 16×3×3 144 次MAC该层总MAC1024×32×144 4,718,592约4.72M MAC如果目标是在1毫秒内跑完这层在200MHz时钟下可用周期数是200000个周期意味着平均每个周期至少要完成 4718592 / 200000 ≈ 23.6 次MAC。所以加速器至少要有24个MAC单元同时工作这还只是理想情况没有算上数据搬运的额外开销。从这个估算可以看出设计目标不是“实现卷积”而是“在给定面积和功耗预算下尽量提高MAC的并行度和数据供给效率”。1.2 设计目标的量化从算法需求反推硬件指标我这次设计的规格参考了一个比较小的网络层先做验证容易后面再扩展参数数值说明输入特征图尺寸32×32教学验证用的小图输入通道数 IC8取一个适中的通道数输出通道数 OC8避免PE阵列过大卷积核尺寸3×3最常见的配置stride / padding1 / 1保持尺寸不变方便对齐数据位宽INT8对称量化累加器位宽INT32每层卷积前保证不溢出这层卷积的总MAC数为 32×32×8×8×3×3 589,824约0.59M MAC。如果我的PE阵列是4×4即16个MAC单元并行在200MHz下峰值算力是3200M MAC/s跑完这层仅需不到0.2ms。实际上因为数据搬运和流水线冒险会有折扣但整体远远够用。硬件指标可以这么定MAC阵列16个MAC排成4行4列输入缓存三行输入数据每行32像素8通道即3×32×8×8bit 6KB权重缓存8个输出通道、8个输入通道、3x3核即8×8×9×8bit 4.5KB部分和缓存32×32×8×32bit 32KB工作频率200MHz这几个数字是整个设计的基础后面所有的缓存划分、状态机调度、接口带宽都是围绕它们展开的。2. 架构选型数据流才是加速器的灵魂确定了算力目标以后下一个问题是“计算阵列、缓存和存储之间的数据怎么流动”。加速器的性能瓶颈往往不在乘法器本身而在于数据供给和复用是否合理。2.1 主流的数据流策略对比学术界对CNN加速器数据流有很多讨论工业界也分成几大流派但从实现角度看可以归纳为三种第一种叫权重固定典型代表是脉动阵列里的WS数据流。权重在计算阵列中保持不动输入特征图数据从一侧注入部分和在另一侧输出。它的好处是权重读一遍就可以被反复使用适合权重量很大、需要大量复用的场景。缺点是控制相对复杂数据注入节奏必须和计算节奏严格对齐否则阵列中间会产生气泡。第二种叫输出固定典型代表是OS数据流。每个计算单元自己负责一个输出像素的部分和累计输入和权重都是流式进入累加结果留在本单元内。这样做的好处是部分和几乎不搬运省功耗适合输出特征图通道数多的情况。缺点是每个单元需要较为独立的局部寄存器逻辑开销稍大。第三种叫行固定也就是Eyeriss里采用的RS数据流。它把一个卷积行的权重映射到一行PE上让输入数据和权重的行复用关系都得到优化。效果很好但映射规则复杂硬件调度非常考验功力。我做这个项目时没有直接上脉动阵列而是选择了“输出固定”的简化版本。原因很简单课程实验的验证周期短我需要的是逻辑清晰、状态好调、出问题容易定位。OS数据流天然适合把“每个PE负责一个输出位置”的思路固化下来控制器只需要关心“当前要算哪个输出通道的哪个输出点”不需要维护脉动阵列那种错拍对齐关系。2.2 我采用的架构与模块划分整体上CNN加速器可以拆成六个模块总线接口和DMA控制全局缓存控制输入行缓冲阵列权重缓存4×4 MAC计算阵列激活、池化与输出控制总线接口负责从外部的DDR或者上位机接收数据这里我用了一个简单的AXI-Lite口做寄存器配置再用一个AXI-Stream数据口做批量搬运。DMA控制逻辑把外部数据拆成长度合适的burst放进内部SRAM。全局缓存控制的作用是把输入特征图分块加载到片上对于32×32的输入图我直接把整张图放进片上SRAM省去了分块调度。但如果你要处理224×224甚至更大的图就需要按Tile的方式切块搬运这部分我也会在后面的问题排查里展开讲。输入行缓冲阵列是卷积窗口生成的关键它负责维护“当前正在参与计算的连续三行数据”并且每个周期能同时给出卷积窗口内的9个或3x3xCin数据。权重缓存则是按输出通道组织给每个PE送对应的权重。MAC阵列完成乘累加以后结果统一送进激活和池化模块最终写回输出SRAM。整体数据通路不复杂真正的难度在于状态机怎么安排时序让16个MAC单元尽量满负荷工作。3. 关键模块实现细节这一部分我把几个核心模块的实现过程展开说一遍每段尽量把设计意图和容易踩坑的地方都讲清楚。硬件设计里最常见的问题不是“不会写逻辑”而是“不知道为什么要这么写”我希望这部分能弥补这一点。3.1 行缓冲与卷积窗口生成卷积计算窗口的生成可以用一组行缓冲完成。简单来说输入SRAM里的数据按行写入行缓冲行缓冲里始终保持三行数据然后在每个时钟周期从三行中各取一个连续的三点序列拼成一个3×3窗口。我用的行缓冲结构是reg [7:0] line_buf [0:2][0:31]; // 3行, 每行32像素, 8bit reg [7:0] win_buf [0:2][0:2]; // 3x3窗口每个时钟周期做两件事从SRAM读一个新像素写入当前正在更新的那一行把当前窗口数据整体左移新进来的像素进最右侧这个结构实现起来很简单但需要注意几个边界问题。第一个是padding。如果stride1且padding1那么输出尺寸保持32×32不变需要在图像上下左右各补一圈0。我是在状态机里判断当前行列坐标如果落在边界外就输出0而不是真的在SRAM里开辟带padding的存储区这样避免了额外拷贝。第二个是行切换的时刻。当一行处理完以后需要把行缓冲整体向上移动让第二行变成第一行第三行变成第二行空出的第三行等新数据从SRAM写入。这里如果时序处理不当很容易导致窗口数据错位而且这种错位在波形图上很难一眼看出来。我在调这个模块的时候遇到过一个非常隐蔽的问题第一行数据在启动后的第一个周期就出现在窗口里但实际上应该等三个周期才凑满一个完整的3×3窗口。后来我用一个valid counter来做控制只有连续有效数据满9个以后才允许MAC阵列接收窗口数据问题才解决。3.2 PE阵列与部分和累加计算阵列我用了4×4的PE网格每个PE内部包含一个INT8乘法器和一个INT32累加器。为什么累加器要用32位因为8bit数据乘8bit数据乘积是16bit而卷积累加次数可能达到几千次16bit累加一定溢出所以放宽到32bit是常规做法。每个PE的乘法器直接用组合逻辑实现不需要展开成移位加。在200MHz频率下8x8乘法器的组合延迟是很充裕的不会成为关键路径。真正要注意的是累加器的清零时机和部分和切换时机。我用OS数据流每个PE固定负责输出特征图中某几个位置的循环累加。当一个输出像素的所有IC×3×3次MAC累加结束后累加器的结果需要被锁存到输出缓冲然后累加器清零准备下一个输出位置的计算。这里如果锁存和清零的时序重叠会把新结果覆盖掉我在代码里用了两拍流水一拍锁存下一拍清零。还有一个容易被忽略的地方多输出通道的切换。当计算完第一个输出通道的累加后权重缓冲要切换到第二个输出通道的权重同时每个PE要开始新的累加序列。权重切换会带来流水线气泡这也是为什么我在状态机里把“切换后预取第一轮数据”单独做了一拍保证PE阵列不会在切换瞬间读到旧的权重。MAC阵列的输出还需要经过加法树吗严格来说4×4阵列如果是每个PE负责不同的输出位置那么不需要加法树但如果多个PE合作计算同一个输出像素的部分和比如按输入通道切分就需要加法树合并。我做的时候让一个PE单独负责一个输出位置的全部累加省去了加法树逻辑简单很多。这个选择在PE数量增加时会变得不划算但如果只是16个MAC级别非常合适。3.3 量化、激活与池化CNN加速器通常不会用FP32做推理因为面积、功耗和带宽都不划算。我这次用的是INT8对称量化就是把浮点权重映射到[-127, 127]的范围公式是scale_w max_abs_weight / 127quant_weight round(weight / scale_w)同理输入特征图也会做同样的量化两者的scale可以在推理之前合并成一个scale_factor等累加完成后一次性乘回去。我们在硬件里没有实现浮点乘法而是用一个定点乘法配合右移来做公式近似为output_fixed accumulated_sum * scale_factor其中scale_factor是浮点转化成定点再右移N位。这个定点近似的精度非常关键scale_factor的位宽我用了16bit右移量根据数据范围决定。激活函数我只实现了ReLU也就是判断累加结果是否小于0小于0直接输出0。这个在硬件上非常好做只需要对累加结果的符号位做个判断。池化模块我实现的是2×2最大池化复用了一组比较器每四个输出像素取最大值。如果在状态机里配一个“是否启用池化”的寄存器就能很方便地把池化和非池化的网络层统一调度。3.4 控制状态机与指令调度控制模块是整个加速器最复杂、也是大多数初学者写不好的地方。我的做法是把控制逻辑分成两级顶层指令状态机和底层计算状态机。顶层指令状态机维护一个“当前指令”指令可以分成几类load_input从外部加载输入图到SRAMload_weight加载权重到权重缓存conv_run启动卷积计算store_output把输出SRAM里的数据搬出去这样做的好处是加速器可以复用加载和计算的重叠时间。如果只有一个状态机那么计算的时候总线闲着加载的时候MAC阵列闲着利用率会很低。我用了双缓冲的方法输入SRAM分成两个Bank前一层在计算时下一层的数据可以同时写入另一个Bank。同理输出也做了乒乓缓存。底层计算状态机负责寄存器级的控制和地址生成。它要维护输出通道、输出行、输出列、输入通道、卷积核行和卷积核列这些循环变量并且为行缓冲、权重缓存和MAC单元产生各模块的使能信号。我强烈建议在写RTL之前先用C或者Python把状态机的行为模拟一遍。用一个寄存器表示状态用一个时钟表示状态机的一个节拍算一下特定输入尺寸下会跳多少个周期提前发现状态跳转的死循环点。否则直接写Verilog出了问题很难定位尤其是一堆状态叠在一起的时候翻波形图翻到怀疑人生。4. 验证方法与调试实录硬件设计里有一句老话设计占一半验证占一半。CNN加速器的数据通路长状态调度复杂如果没有一套完整的验证方法一个看似正确的模块在系统联调时会出现大量怪异现象。4.1 用Python参考模型做RTL对比验证我做的第一件事是写一个Python参考模型把卷积、量化、ReLU、池化全部实现一遍然后把计算结果转成十六进制文件作为RTL仿真的真值来源。验证流程是这样的生成随机输入数据保存为input.txt同时计算对应的量化权重保存为weight.txt运行Python模型得到参考输出保存为golden.txt写一个testbench读入input.txt和weight.txt驱动加速器RTL跑仿真仿真结束后把RTL输出的结果写到一个rtl_out.txt用脚本逐字节比较golden.txt和rtl_out.txt这个流程看起来简单但非常有效。让我从大量隐藏问题里解脱出来比如边界padding漏补、输出地址计算错位、累加溢出等。4.2 常见问题与排查速查表我把这次调试中遇到的典型问题整理成一个表格方便大家遇到类似现象时快速定位现象可能原因排查方法输出大部分正确个别位置差很多边界padding处理不当或行缓冲切换错位对比出错像素的行列坐标检查对应时刻的行缓冲内容结果整体值偏大或偏小scale_factor定点化精度不够打印累加和与scale_factor的乘积看是否超过目标范围仿真结果对板子结果错时钟复位异常或异步FIFO跨时钟问题检查复位释放时序确认数据跨时钟域时打了两拍输出全零MAC累加器清零时机错误或计算状态机没有启动检查conv_run使能抓取累加器清零信号的波形帧率比预期低很多数据加载和计算没有重叠检查乒乓缓存是否真正生效DMA是否等待计算完成才开始下一次搬运SRAM读写冲突同一个周期既写SRAM又读SRAM检查地址冲突或者改用双端口SRAM4.3 时序收敛与性能调优RTL功能正确后就是综合和时序收敛。在FPGA上做这个项目时最重要的经验是给乘法器输出加流水寄存器。8bit乘法器本身延迟不大但是16个乘法器输出一起进累加逻辑时组合路径会显著变长。我在每个乘法器输出后插了一级寄存器把乘法和累加分成两个流水级时序一下就好了。另外部分和SRAM的大位宽读写也容易成为关键路径。我把32bit×1024深度的部分和缓存拆成了两片分别存高16位和低16位这样每片SRAM的位宽降一半综合后时序更好。还需要注意乘法器的位宽优化。理论上8×8乘出来是16bit但如果输入数据范围被约束得更窄比如网络层输入经过量化后只落在[-64, 63]那么乘积实际只需要14bit累加路径可以适当瘦身。不过这种优化要和量化策略联动不能单独改硬件。性能和面积之间需要做个平衡。我用的是“16个PE”面积很小时序很容易收敛。如果你要做更极限的性能比如把PE扩展到64个甚至128个就需要引入更细致的调度比如把输入通道切成两组分别输入两组PE然后通过加法树合并部分和这样数据并行度更高但控制复杂度也随之上升。在存储规划上我建议把片上SRAM利用率作为性能指标之一。很多加速器看起来算力很高但实际利用率不到50%原因就是数据搬移跟不上计算节奏。你可以统计一个卷积层的总周期数除以理论最小周期数得到一个“有效利用率”。如果利用率低于70%大概率是加载和计算没有充分重叠或者行缓冲在某些周期没有新的有效窗口产生。5. 写在最后的经验总结这个4-1的CNN加速器项目做完后我个人最大的收获不是学会了怎么写几个Verilog模块而是真正理解了软硬件协同设计的含义。算法层的循环变换、数据布局和量化策略每一个选择都会影响硬件的面积和性能。比如你把循环顺序从“输出通道在外面”改成“输入通道在外面”可能完全改变了权重缓存的命中和数据复用率硬件面积不变性能却差一大截。如果你也准备做类似的项目我的建议是不要一开始就追求脉动阵列、多核异构这些炫酷名头先把一个中小规模的加速器完整跑通。PE阵列可以用最简单的网格数据流选最容易理解的OS数据流验证用Python黄金模型加上严格的波形比对等整套流程都熟透了再往Eyeriss或者Systolic Array方向深入。毕竟在芯片设计这个领域能把一个小功能做到可验证、可交付、可解释比画一个看起来很高级但跑不起来的框图要重要得多。
返回列表