从零实现卷积神经网络硬件加速:Verilog脉动阵列核心设计详解

发布时间:2026/9/4 21:15:00
从零实现卷积神经网络硬件加速:Verilog脉动阵列核心设计详解 简介本资源是一套完整可运行的Verilog脉动阵列卷积加速器实现面向计算机体系结构、AI芯片设计、数字电路课程学习者及毕设/课设开发者聚焦TPU核心计算单元——脉动阵列在卷积运算中的硬件建模与验证。压缩包共32个文件1.17MB含18个Verilog模块源码如PE.v、PE_array.v、controller.v、accelerator.v等、6个文本配置与测试说明、3个.npy实验数据文件、3个Python数据预处理脚本及1份PDF实验报告和1份README文档测试用例覆盖单元级PE、shifter到系统级pe_all_testbench全链路验证。已有178人下载学习所有模块均通过仿真测试并功能完备支持直接上手仿真、理解脉动阵列数据流调度机制亦可作为AI加速器课程设计基础框架或毕设硬件部分原型具备良好可扩展性与教学适配性。1. 项目缘起从软件仿真到硬件加速的跨越几年前我在做一个图像识别的边缘计算项目当时用Python写的卷积神经网络CNN在树莓派上跑识别一张图要好几秒功耗还高得烫手。那会儿我就琢磨能不能用硬件来加速这个最耗时的卷积计算软件层面的优化已经到瓶颈了无非是调调库、用用GPU但真想追求极致的能效比和实时性还得是专用硬件。TPU张量处理单元的核心秘密武器——脉动阵列Systolic Array就成了我研究的重点。这东西听起来高大上其实原理很直观它像一条精心设计的流水线让数据和计算在阵列中“脉动”流动最大化地复用数据减少访存从而爆发出惊人的计算吞吐量。但网上的资料要么是论文里复杂的数学描述和架构图看得人云里雾里要么就是一些高度抽象的行为级仿真代码离真正的硬件实现隔着一层纱。对于一个硬件工程师来说不亲手用Verilog敲出一个能综合、能下板的模块心里总是不踏实。所以我决定自己动手实现一个用于计算卷积的脉动阵列核心模块。这个项目的目标很明确不是复现一个完整的TPU而是深入最核心的计算单元用Verilog语言从寄存器传输级RTL设计开始构建一个参数可配置、接口清晰、经过充分仿真验证的卷积计算模块。我会把整个思考过程、设计细节、踩过的坑以及最终的源码和实验数据都分享出来希望能给同样对硬件加速感兴趣的朋友们一块可靠的“砖头”。2. 脉动阵列为何是卷积计算的“天作之合”在深入代码之前我们必须先搞清楚为什么脉动阵列这种结构特别适合做卷积或者说更广义的矩阵乘法GEMM运算。理解这一点是设计好这个模块的前提。2.1 卷积的本质多重矩阵乘法的叠加很多人一提到卷积就想到滑动窗口、乘加运算。没错但对于硬件实现尤其是追求规则化和并行化的硬件我们需要一个更“整齐”的视角。实际上一个卷积层可以通过一个叫做im2colImage to Column的变换被转换成一个大大的矩阵乘法。简单来说对于输入特征图IFMAP我们根据卷积核大小和步长将其每个可能的卷积窗口展平成一个列向量所有这些列向量拼起来就形成了一个大的二维矩阵。卷积核的权重也展平成一个行向量对于单个输出通道或矩阵对于多个输出通道。这样卷积运算就变成了这个权重矩阵和im2col变换后的输入矩阵的乘法结果就是输出特征图OFMAP的数据。注意im2col变换会引入巨大的数据冗余同一输入像素在不同窗口被重复展开在软件上这是内存开销但在脉动阵列的硬件数据流设计中这种“冗余”恰恰可以被巧妙地利用和掩盖通过数据复用减少对高带宽存储的依赖。2.2 脉动阵列的精妙数据流与计算流的交响脉动阵列可以看作一个计算单元的网格比如8x8, 32x32, 256x256。每个处理单元PE非常简单通常只做乘积累加MAC操作PE.out PE.reg PE.in_weight * PE.in_data。它的精妙之处在于数据流动方式权重Weight通常沿阵列的列方向垂直同步向下流动。每个时钟周期权重向下移动一行。输入数据Data/IFMAP通常沿阵列的行方向水平同步向右流动。每个时钟周期数据向右移动一列。部分和Partial Sum在PE内部累加并可以沿着阵列的对角线或列方向传递用于跨PE的累加。想象一下一个权重元素W和一个数据元素D在某个PE相遇。它们相乘的结果被累加到该PE的部分和寄存器中。下一个周期W流到了它下面的PED流到了它右边的PE。此时原来的PE迎来了新的W和D进行运算而W和D则在新的PE中与新的邻居来自其他PE的部分和结合继续完成更大范围的乘积累加。这样设计带来的核心优势极高的数据复用率每个权重和数据在穿过整个阵列的过程中会与多行/多列的数据/权重进行计算被重复使用多次。这极大地降低了对片外存储器如DDR带宽的需求而带宽往往是硬件加速器的瓶颈。规则的数据流和控制流整个阵列的PE执行相同的操作MAC只需要全局的时钟和简单的数据移动控制硬件控制逻辑极其简单易于扩展。高并行度一个N x N的阵列每个时钟周期理论上可以进行N^2次乘法和N^2次加法操作计算密度非常高。对于卷积经im2col变换后这种需要大量规整乘加运算的任务脉动阵列能将数据流和计算流完美匹配把计算资源“喂”得饱饱的效率自然远高于通用处理器。3. 模块架构设计与关键接口定义有了理论铺垫我们开始动手设计。我的目标是实现一个参数化的、可综合的Verilog模块。它不是一个完整的SoC而是一个计算核心需要与外部存储器控制器、数据搬运单元等协同工作。3.1 顶层模块接口与参数我定义的顶层模块名为systolic_array_conv。通过parameter实现高度可配置这是工业级IP的常见做法。module systolic_array_conv #( parameter DATA_WIDTH 8, // 输入数据和权重的位宽 parameter ACC_WIDTH 32, // 累加器的位宽防止溢出 parameter ARRAY_SIZE 8, // 脉动阵列的大小如8表示8x8 parameter KERNEL_SIZE 3, // 卷积核尺寸假设为方形如3x3 parameter STRIDE 1, // 卷积步长 parameter IFMAP_CH 4, // 输入特征图通道数 parameter OFMAP_CH 8 // 输出特征图通道数卷积核个数 )( input wire clk, input wire rst_n, // 数据加载接口类AXI-Stream简化版 input wire data_in_valid, input wire [DATA_WIDTH-1:0] data_in, output wire data_in_ready, input wire weight_in_valid, input wire [DATA_WIDTH-1:0] weight_in, output wire weight_in_ready, // 计算结果输出接口 output wire data_out_valid, output wire signed [ACC_WIDTH-1:0] data_out, input wire data_out_ready, // 控制信号 input wire start, // 启动一次计算 output wire busy, // 模块忙标志 output wire done // 计算完成标志 );参数设计考量DATA_WIDTH8 这是为了模拟量化后的神经网络常用INT8平衡精度和硬件开销。ACC_WIDTH32 8位乘8位得到16位结果在ARRAY_SIZE8的情况下最多累加8次需要约19-20位。设置32位留有充足余量便于后续添加批归一化Batch Norm或激活函数的偏移计算。ARRAY_SIZE8 这是一个折衷。阵列越大并行度越高但布线延迟和面积也会急剧增加。8x8对于学习和中小规模FPGA验证比较合适。接口设计考量采用了类AXI-Stream的valid/ready握手协议。这是现代片上互联NoC和IP核间通信的事实标准能很好地处理数据生产者和消费者之间的速度匹配问题。将data_in和weight_in分离是因为在实际系统中权重通常一次性加载到阵列附近的权重缓存Weight Buffer中并保持静止或缓慢更新而输入数据则连续流入。这里为了模块的通用性设计为均可流式输入。start/busy/done是简单的全局状态信号方便上层控制器进行调度。3.2 核心PE单元的设计PE是阵列的基石。它的设计直接关系到性能、面积和功耗。module processing_element #( parameter DATA_WIDTH 8, parameter ACC_WIDTH 32 )( input wire clk, input wire rst_n, input wire en, // PE使能信号可用于功耗门控 // 数据流 input wire signed [DATA_WIDTH-1:0] data_in_left, // 来自左侧PE的数据 output reg signed [DATA_WIDTH-1:0] data_out_right, // 输出到右侧PE的数据 // 权重流 input wire signed [DATA_WIDTH-1:0] weight_in_top, // 来自上方PE的权重 output reg signed [DATA_WIDTH-1:0] weight_out_bottom, // 输出到下方PE的权重 // 部分和流 input wire signed [ACC_WIDTH-1:0] psum_in_top, // 来自上方PE的部分和用于累加 output reg signed [ACC_WIDTH-1:0] psum_out_bottom // 输出到下方PE的部分和 // 注实际设计中psum的流向可能有多种向下、向右、累加后输出这里采用经典的向下传递模式。 ); reg signed [DATA_WIDTH-1:0] weight_reg; // 当前PE锁存的权重 reg signed [ACC_WIDTH-1:0] psum_reg; // 当前PE的部分和寄存器 always (posedge clk or negedge rst_n) begin if (!rst_n) begin weight_reg 0; psum_reg 0; data_out_right 0; weight_out_bottom 0; psum_out_bottom 0; end else if (en) begin // 1. 锁存传入的权重和部分和对于第一行/列这些输入来自外部 weight_reg weight_in_top; // 注意psum_in_top是来自上一个PE的累加结果需要与本地计算结果相加 // 本地计算data_in_left * weight_reg (注意是上一周期锁存的权重) psum_reg psum_in_top (data_in_left * weight_reg); // 2. 将数据和权重传递给下一个PE直通或寄存器打拍 data_out_right data_in_left; weight_out_bottom weight_reg; // 传递锁存后的权重 psum_out_bottom psum_reg; // 传递累加后的部分和 end end endmodulePE设计的关键点与踩坑记录数据对齐问题大坑 注意看psum_reg的计算psum_in_top (data_in_left * weight_reg)。这里用的是weight_reg即上一个周期锁存的weight_in_top。为什么因为在本时钟周期data_in_left和weight_in_top是同时到达PE端口的。如果直接用它们相乘那么这个乘积无法在本周期内与同样刚到达的psum_in_top相加需要额外的组合逻辑可能导致时序紧张。更严重的是这样设计会破坏脉动节奏。标准的做法是PE在时钟上升沿锁存来自上方和左侧的输入权重和部分和而数据则是直通或打一拍传到右侧。本地计算使用的是锁存的权重和当前到达的数据。这确保了每个PE内的乘法和加法有完整的时钟周期完成并且数据、权重、部分和在阵列中的“流动”是同步的。使能信号en 这个信号非常有用。当阵列不需要工作时可以通过拉低en关闭所有PE的时钟门控如果综合工具支持或至少阻止寄存器翻转能显著降低静态和动态功耗。寄存器打拍 vs. 直通 上述代码中数据、权重、部分和的传递都经过了一级寄存器打拍。这增加了一个周期的延迟但极大地改善了时序Timing因为打破了组合逻辑长路径。这是面积换速度的典型做法。在高速设计中几乎必须这么做。3.3 阵列互联与控制逻辑生成将PE实例化并连接成网格是体力活也是精细活。我们需要生成阵列第一行和第一列的特殊输入以及收集最后一行的输出。// 在 systolic_array_conv 模块内部 genvar i, j; wire signed [DATA_WIDTH-1:0] data_h [0:ARRAY_SIZE][0:ARRAY_SIZE]; // 水平数据线 wire signed [DATA_WIDTH-1:0] weight_v [0:ARRAY_SIZE][0:ARRAY_SIZE]; // 垂直权重线 wire signed [ACC_WIDTH-1:0] psum_v [0:ARRAY_SIZE][0:ARRAY_SIZE]; // 垂直部分和线 // 初始化边界输入 assign data_h[0][0] ext_data_in; // 来自外部数据流需要经过FIFO或缓存对齐 assign weight_v[0][0] ext_weight_in; // 来自外部权重流 assign psum_v[0][0] 0; // 第一行PE的顶部部分和输入为0 generate for (i 0; i ARRAY_SIZE; i i 1) begin: row_gen for (j 0; j ARRAY_SIZE; j j 1) begin: col_gen processing_element #( .DATA_WIDTH(DATA_WIDTH), .ACC_WIDTH(ACC_WIDTH) ) u_pe ( .clk(clk), .rst_n(rst_n), .en(pe_enable), // 全局PE使能可由状态机控制 .data_in_left( (j0) ? data_h[i][0] : data_h[i][j] ), .data_out_right( data_h[i][j1] ), .weight_in_top( (i0) ? weight_v[0][j] : weight_v[i][j] ), .weight_out_bottom( weight_v[i1][j] ), .psum_in_top( (i0) ? psum_v[0][j] : psum_v[i][j] ), .psum_out_bottom( psum_v[i1][j] ) ); end end endgenerate // 收集输出阵列最后一行的psum_out_bottom就是最终的部分和输出 // 需要将它们组织起来通过一个输出FIFO或寄存器链送出。 reg [ARRAY_SIZE-1:0] out_col_valid; // 每列输出有效标志 always (posedge clk) begin // 这里需要一个复杂的逻辑判断何时最后一行的psum是有效结果。 // 这涉及到计算“水线”watermark即从第一个数据进入阵列到第一个结果流出阵列的延迟周期数。 // 延迟周期数 ARRAY_SIZE * 2 - 1 对于这种数据流模式 end互联与控制逻辑的难点输入数据对齐 外部数据流是连续的但阵列需要严格对齐的数据和权重。通常需要在阵列前放置输入FIFO或双缓冲Double Buffer由一个状态机控制在正确的时间点将数据“喂”给阵列的第一行和第一列。这是控制逻辑中最容易出错的部分。输出收集与有效性判断 阵列的输出不是每个周期都有效。从开始输入到第一个有效结果输出有一个固定的流水线延迟。之后在理想满负荷情况下每个周期可以输出一个有效结果对于8x8阵列可能是8个通道的部分和。需要设计一个输出缓冲区和对应的data_out_valid生成逻辑确保只将完整的、正确的计算结果送出。非方形卷积与填充处理 我们的阵列是方形的但卷积核和特征图可能不是。这需要通过外部控制器将计算任务“切片”Tiling分解成多个能被阵列处理的子矩阵乘法。模块内部可能还需要支持累加模式即多次计算的结果在模块内部累加以完成一个更大矩阵的乘法。4. 仿真验证与实验数据分析设计完成之后验证是重中之重。我使用SystemVerilog搭建了测试平台TB并针对几个关键场景进行测试。4.1 测试平台构建与激励生成TB的主要任务是生成仿真的输入数据和权重 我写了一个简单的C程序生成随机整数或者更有意义的模式如全1、递增序列并写入文件。TB通过$readmemh读取这些文件模拟数据流。模拟外部控制器行为 TB中的一个task模拟了上层状态机它按照预设的时序在data_in_ready和weight_in_ready为高时将数据压入模块。这测试了握手协议。自动结果比对 这是TB的核心。我同样用C语言或Python写了一个黄金模型Golden Model执行相同的卷积运算使用浮点或高精度整数。TB将模块的输出data_out捕获与黄金模型的输出逐拍比较。任何失配都会立即报错并终止仿真。// 简化的TB数据比对片段 initial begin int golden_output[$]; // ... 从文件加载黄金模型输出到 golden_output ... fork begin: data_monitor int idx 0; forever begin (posedge clk); if (dut.data_out_valid dut.data_out_ready) begin if (dut.data_out ! golden_output[idx]) begin $error(Mismatch at output %0d: RTL%h, Golden%h, idx, dut.data_out, golden_output[idx]); $finish; end idx; if (idx golden_output.size()) begin $display(*** All %0d outputs matched! Test PASSED. ***, idx); $finish; end end end end join end4.2 关键测试场景与结果我设计了多组测试逐步增加复杂度基础功能测试3x3卷积单通道场景 输入特征图5x5卷积核3x3步长1填充0。权重设为全1输入为递增序列。目的 验证最基本的乘加和流水线逻辑是否正确。结果 RTL输出与黄金模型完全一致。通过观察波形可以清晰地看到数据和权重在阵列中脉动传播的过程第一个结果在预期延迟2*ARRAY_SIZE-115个周期后出现。多通道测试IFMAP_CH4, OFMAP_CH8场景 模拟一个真实的卷积层。输入4通道输出8通道。这意味着需要计算8个不同的3x3x4卷积核。实现 外部控制器需要将4通道输入数据在通道维度上拼接或依次送入并将8个卷积核的权重依次加载。阵列每次计算一个输出通道的一个空间位置但并行计算所有输入通道的累加。这需要复杂的调度。结果 这是对控制逻辑的真正考验。我最初的设计在这里出现了错误输出数据的顺序和黄金模型对不上。排查后发现是输出结果收集FIFO的写入顺序和读取顺序与多通道计算流程不匹配。教训对于复杂的数据流必须在设计初期就明确每一个时钟周期每一级流水线中数据的确切含义和索引并用文档或注释详细记录。性能与吞吐量分析理论峰值算力 对于8x8 INT8阵列运行在200MHz时钟下。每个周期完成8*864次乘加MAC操作。INT8乘加通常可视为一次操作。因此理论峰值算力为64 MAC/cycle * 200e6 cycle/s 12.8 GMAC/s或25.6 GOPS一次乘加算两次操作。实测有效算力 在完成一个较大尺寸的卷积后统计总计算周期和实际完成的MAC操作总数。由于数据加载、填充、切片导致的空闲周期Bubble有效算力通常低于峰值。我的测试显示在优化了数据加载顺序后有效算力能达到峰值算力的70%以上这对于一个简化设计来说已经不错。资源占用FPGA综合 使用Vivado针对Xilinx Zynq-7000系列器件进行综合。一个8x8的阵列不含外部FIFO和复杂控制器大约占用LUT: ~5000FF: ~4000DSP48E1: 64个每个PE的乘法器映射为一个DSP块时序报告 关键路径出现在PE内部psum_reg的累加路径上psum_in_top (data_in_left * weight_reg)。在200MHz目标下有约0.5ns的建立时间裕量Slack。如果频率要求更高可能需要将累加器拆分为更多流水线级。4.3 遇到的典型问题与调试技巧问题仿真结果出现不定态X。排查 首先检查所有寄存器在复位时是否被正确初始化。然后重点检查数组如data_h、weight_v的索引是否可能越界。在generate循环中i和j的边界[0:ARRAY_SIZE]容易出错data_h[i][j1]当jARRAY_SIZE-1时会访问data_h[i][ARRAY_SIZE]这个元素必须被声明。技巧 在仿真初期使用$display打印关键信号和索引值。使用波形查看器将数组展开查看能快速定位哪个节点出现了X。问题数据输出顺序混乱与预期不符。排查 这是数据流控制逻辑的经典问题。我画了一个详细的时空图Space-Time Diagram横轴是时间周期纵轴是阵列的行或列。手动追踪第一个输入数据D00和第一个权重W00进入阵列后它们如何移动在哪个PE相遇结果何时从底部输出。然后将这个理论波形与仿真波形对比立刻发现了输出有效信号生成逻辑比实际数据晚了一个周期。技巧 对于脉动阵列这种高度规则的结构画图是最有效的调试方法。不要只依赖看代码。问题时序不满足无法达到目标频率。排查 综合后的时序报告显示关键路径过长。使用Vivado的“Schematic”视图或“Timing”视图中的“Fail Path”追踪发现关键路径是穿过多个PE的组合逻辑链例如部分和从第一行PE直接组合逻辑传递到最后一行。解决 这就是为什么我在PE设计中坚持使用寄存器打拍传递数据而不是组合逻辑直通。对于已经打拍但依然紧张的路径可以考虑插入更多的流水线寄存器。例如将PE内部的(data_in_left * weight_reg)乘法结果先寄存一拍再与psum_in_top相加。这会增加一个周期延迟但能大幅提高频率。5. 从模块到系统集成考量与优化方向完成一个独立运行的模块只是第一步。要把它用在一个真实的系统中还需要考虑很多外围因素。5.1 与存储系统的协同脉动阵列是计算引擎它自己不做数据搬运。需要一个强大的DMA直接内存访问控制器和多层次缓存Memory Hierarchy来为它服务。权重缓存 权重通常在计算一批数据前加载一次然后保持不变。因此需要一个片上SRAM作为权重缓存Weight Buffer容量要能放下至少一层网络的所有权重。DMA负责从外部DDR将权重搬入此缓存然后阵列以流式或静态方式从缓存读取。输入/输出双缓冲 为了隐藏数据传输时间通常采用乒乓缓冲Ping-Pong Buffer。当阵列在处理Buffer A的数据时DMA正在向Buffer B填充下一批数据。计算完成后再交换角色。这确保了计算单元几乎不会因等待数据而空闲。我的模块适配 我的模块接口是流式的因此需要在外围封装一个数据打包/解包单元。这个单元负责从输入双缓冲中读取数据按照阵列需要的顺序和节奏组装成data_in流。同时它也从阵列的输出流接收数据解包后写入输出双缓冲。5.2 支持更复杂的网络层我们的模块目前专注于标准卷积。但现代CNN还有深度可分离卷积Depthwise Separable Conv 这可以分解为逐通道卷积Depthwise和逐点卷积Pointwise。逐通道卷积是每个输入通道单独卷积计算密度低不适合用大阵列。一种优化思路是将阵列拆分成多个小单元并行处理不同通道。逐点卷积是1x1卷积本质是密集矩阵乘非常适合我们的阵列。池化层Pooling 通常在卷积后接一个池化层。一种高效的设计是将池化单元如最大值比较、加法树直接放在阵列的输出端作为后处理模块减少中间数据的写回和读取。激活函数 如ReLU可以在输出数据流出阵列时用一个简单的比较和选择逻辑实时完成几乎不增加延迟。5.3 面积、功耗与性能的权衡探索精度可配置 可以将DATA_WIDTH做成运行时可配置虽然会增大面积以支持混合精度训练或推理。例如某些层用INT8某些层用INT4。稀疏性支持 神经网络权重和激活值存在大量零。可以设计一种跳过零计算的机制在数据进入阵列前进行压缩或者让PE检测到零输入时关闭乘加操作以节省功耗。近似计算 在PE的乘法器中可以使用近似乘法器如截断乘法器来进一步降低面积和功耗这对某些对精度不敏感的边缘应用很有吸引力。这个Verilog实现的脉动阵列卷积模块是一个理解硬件加速器核心思想的绝佳起点。它从最基础的PE单元开始构建起规则的数据流最终完成复杂的卷积运算。整个过程中对时序的把握、对数据流的精确控制、对仿真验证的重视是硬件设计成败的关键。我把项目源码、详细的文档说明以及仿真实验数据都整理了出来其中包含了多个不同配置的测试用例和波形文件。通过这个项目我深刻体会到硬件设计不仅仅是写RTL代码更是对计算、存储、通信之间平衡艺术的不断探索。下次或许我们可以聊聊如何为这个阵列设计一个智能的调度器让它能更高效地处理各种形状的卷积层。本文还有配套的精品资源点击获取