第四章:行静态脉动阵列设计
各位同学,今天我们来聊聊脉动阵列里一个非常经典的数据流——行静态(Row-Stationary)。说实话,我在做第一个卷积加速器项目时,用的就是这种架构。当时团队里争论了很久,到底选哪种数据流,最后还是行静态胜出。为什么?因为它对数据复用的理解,最贴近卷积的本质。
4.1 行静态数据流:核心思想
行静态,顾名思义,就是让卷积核的每一行,在计算过程中保持不动。你想想看,卷积操作本质上是什么?是卷积核在输入特征图上滑动,每次做一次点乘。如果我们把卷积核的某一行固定在一个PE里,让输入数据流过来,那就能最大程度地复用权重。
我个人习惯把这种数据流叫做“权重驻留”模式。说白了,就是权重不动,数据动。这和之前讲过的输出静态、权重静态都不一样。输出静态是累加器不动,权重静态是权重不动但数据广播。行静态呢?它让每个PE负责卷积核的一行,然后输入数据按行滑动。
核心要点:行静态数据流中,每个PE内部存储卷积核的一行权重。输入特征图的数据以行为单位,在PE阵列中流水传递。每个PE完成一行权重的乘累加后,将部分和传递给下一行PE。
我在项目中遇到过一个问题:如果卷积核尺寸是3x3,那我们就需要9个PE组成一个3x3的阵列。每个PE存一行权重,也就是3个权重值。这样,输入数据每来一行,所有PE同时计算,效率非常高。
4.2 行静态脉动阵列的PE单元设计
PE单元是脉动阵列的心脏。行静态的PE,内部结构其实不复杂,但有几个关键点要注意。
一个典型的行静态PE包含:
- 权重寄存器组:存储卷积核的一行权重,比如3个权重值
- 输入数据寄存器:暂存当前输入的像素值
- 乘累加器(MAC):做乘法和累加
- 部分和寄存器:存储中间计算结果
- 数据转发逻辑:将输入数据传递给下一个PE
嗯,这里要注意:权重寄存器组是静态的,也就是说,在整个计算过程中,权重值不会改变。只有当我们切换到下一个卷积核时,才会更新这些寄存器。
// 行静态PE的简化Verilog描述
module row_stationary_pe #(
parameter WEIGHT_WIDTH = 8,
parameter DATA_WIDTH = 8,
parameter ACCUM_WIDTH = 16
)(
input clk, rst_n,
input [DATA_WIDTH-1:0] data_in, // 输入数据
input [WEIGHT_WIDTH-1:0] weight_in, // 权重输入(仅加载时使用)
input [ACCUM_WIDTH-1:0] psum_in, // 来自上一行的部分和
output [DATA_WIDTH-1:0] data_out, // 传递给下一PE的数据
output [ACCUM_WIDTH-1:0] psum_out, // 累加后的部分和
input load_weights, // 权重加载使能
input compute_en // 计算使能
);
reg [WEIGHT_WIDTH-1:0] weight_reg; // 权重寄存器
reg [DATA_WIDTH-1:0] data_reg; // 数据寄存器
reg [ACCUM_WIDTH-1:0] accum_reg; // 累加寄存器
// 权重加载:只在load_weights有效时更新
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
weight_reg <= 0;
else if (load_weights)
weight_reg <= weight_in;
end
// 数据传递:每个时钟周期将数据向右传递
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
data_reg <= 0;
else
data_reg <= data_in;
end
// 乘累加:计算使能时执行
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
accum_reg <= 0;
else if (compute_en)
accum_reg <= psum_in + data_reg * weight_reg;
end
assign data_out = data_reg;
assign psum_out = accum_reg;
endmodule
这段代码看起来简单,但实际项目中坑不少。我曾经因为忘记处理部分和的清零逻辑,导致整个芯片的卷积结果全是错的。排查了整整两天才发现,原来累加器在计算完一个输出像素后,没有及时复位。
避坑指南:行静态PE的累加器复位时机非常关键。每个输出像素计算完成后,必须清零。否则上一个结果会污染下一个结果。我建议用单独的clear信号,而不是依赖全局复位。
4.3 数据调度与缓冲策略
数据调度,说白了就是决定什么时候把什么数据送到哪个PE。行静态的数据流,核心是“行流水”。
我们来看一个具体的例子。假设输入特征图是5x5,卷积核是3x3,步长为1。那么:
- 第一拍:输入数据的第一行进入第一行PE。每个PE存了卷积核对应行的权重。
- 第二拍:第一行数据向右传递到第二行PE,同时第二行输入数据进入第一行PE。
- 第三拍:第一行数据到达第三行PE,第二行数据到达第二行PE,第三行数据进入第一行PE。
- 此时,三个PE都有了各自需要的数据,开始计算第一个输出像素。
你想想看,这种流水方式的好处是什么?数据复用率极高。每个输入像素会被多个PE重复使用,减少了片外带宽的压力。
缓冲策略方面,我建议采用双缓冲(Double Buffering)。为什么?因为权重加载和计算可以重叠。当一组权重在计算时,下一组权重已经在后台加载了。
个人经验:双缓冲的深度不一定要很大。对于3x3的卷积核,每个PE只需要3个权重值,用两个寄存器组就够了。一个用于当前计算,一个用于预加载下一组权重。这样切换卷积核时零延迟。
4.4 性能模型分析
性能模型,就是用来估算你的设计能跑多快、效率有多高。行静态脉动阵列的性能模型,主要看三个指标:吞吐率、利用率和延迟。
先看吞吐率。假设PE阵列是R行C列,每个PE每个时钟周期完成一次乘累加。那么理论峰值吞吐率就是R x C x F,其中F是时钟频率。但实际中,由于数据加载和边界效应,实际吞吐率会低一些。
利用率呢?行静态的利用率通常很高,因为数据流是连续的。但有一个特殊情况:当卷积核尺寸和阵列尺寸不匹配时,会有一些PE闲置。比如你的阵列是4x4,但卷积核是3x3,那就会有一行一列的PE用不上。
| 参数 | 公式 | 说明 |
|---|---|---|
| 理论吞吐率 | R × C × F | R行C列,频率F |
| 实际吞吐率 | (R × C × F) × η | η为利用率 |
| 计算延迟 | (H × W × C_in) / (R × C) | H高W宽C_in输入通道 |
| 带宽需求 | 2 × 数据位宽 × 频率 | 输入和权重各一份 |
我记得有一次,我们设计了一个16x16的脉动阵列,理论吞吐率很高,但实际跑起来只有60%的利用率。后来一查,问题出在数据加载上。输入数据从片外DDR读进来,带宽不够,导致PE经常空等。这就是典型的“存储墙”问题。
所以,性能模型不能只看PE的计算能力,还要考虑数据供给能力。我建议你在设计初期,就用这个公式估算一下:
计算时间 = 数据加载时间 + 计算时间 + 数据写出时间
如果数据加载时间远大于计算时间,那你的PE阵列就是在“饿肚子”。这时候要么加大带宽,要么优化数据复用策略。
总结一下:行静态脉动阵列,核心是让权重驻留在PE中,数据以行流水的方式传递。它的优势是权重复用率高、控制逻辑简单。缺点是阵列尺寸受限于卷积核尺寸,灵活性稍差。但如果你做的是固定尺寸的卷积加速,行静态是个好选择。
好了,这一章的内容就到这里。下一章我们会讲另一种数据流——输出静态,看看它和行静态有什么不同,各自适合什么场景。
可可精品资料库