4、行缓冲器设计:行缓冲器原理、FIFO实现、数据对齐逻辑
行缓冲器,说白了就是卷积加速器里的「数据搬运工」。
我刚开始做这个模块时,总觉得它不就是个FIFO嘛,有啥好设计的?结果第一次上板,图像边缘全是乱的,数据错位得一塌糊涂。嗯,从那以后我再也不敢小看这个模块了。
4.1 行缓冲器原理
卷积运算有个特点:它需要同时访问一个窗口内的所有像素。比如3x3卷积,你得同时拿到第1行、第2行、第3行的数据。但图像输入是一行一行串行进来的,怎么办?
行缓冲器就是干这个的——它把前面几行的数据缓存下来,等当前行数据到了,一起送给卷积计算单元。
核心思想:用空间换时间。用存储资源(BRAM/寄存器)缓存多行数据,实现并行访问。
我习惯把行缓冲器比作「流水线上的暂存区」。图像数据像流水一样流过,行缓冲器把前面几行的「样品」留下来,等当前行的「样品」到了,凑齐一个窗口再送检。
具体来说,对于一个KxK的卷积核,你需要缓存K-1行数据。为什么是K-1?因为当前行正在输入,只需要缓存它前面的K-1行就够了。
| 卷积核大小 | 需要缓存的行数 | 典型实现方式 |
|---|---|---|
| 3x3 | 2行 | 2个FIFO或移位寄存器 |
| 5x5 | 4行 | 4个FIFO或移位寄存器 |
| 7x7 | 6行 | 6个FIFO或移位寄存器 |
4.2 FIFO实现
行缓冲器最常用的实现方式就是FIFO。为什么选FIFO?因为它天然支持「先进先出」,正好符合图像数据逐行流动的特性。
我在项目中遇到过一个问题:用分布式RAM(LUT)搭FIFO,结果资源爆了。后来改用BRAM实现的FIFO,资源瞬间降下来。你想想看,一张1080p的图像,一行就有1920个像素,用LUT存?太奢侈了。
我的经验:行宽超过64像素,果断用BRAM FIFO。小于64像素,可以用移位寄存器或者分布式RAM FIFO,延迟更低。
FIFO的深度怎么定?很简单:等于图像的行宽。比如图像宽度是640,那FIFO深度就是640。为什么?因为你要存一整行数据,等下一行数据来了才能开始输出。
下面是一个典型的行缓冲器FIFO实现代码(Verilog):
module line_buffer #(
parameter DATA_WIDTH = 8,
parameter LINE_WIDTH = 640,
parameter NUM_LINES = 2 // 3x3卷积需要缓存2行
)(
input wire clk,
input wire rst_n,
input wire wr_en,
input wire [DATA_WIDTH-1:0] din,
output wire [DATA_WIDTH-1:0] dout_line0, // 第0行输出
output wire [DATA_WIDTH-1:0] dout_line1, // 第1行输出
output wire [DATA_WIDTH-1:0] dout_line2 // 第2行输出(当前行)
);
// 实例化FIFO作为行缓冲器
fifo #(
.DATA_WIDTH(DATA_WIDTH),
.FIFO_DEPTH(LINE_WIDTH)
) u_fifo_0 (
.clk (clk),
.rst_n (rst_n),
.wr_en (wr_en),
.din (din),
.rd_en (rd_en),
.dout (line0_data)
);
fifo #(
.DATA_WIDTH(DATA_WIDTH),
.FIFO_DEPTH(LINE_WIDTH)
) u_fifo_1 (
.clk (clk),
.rst_n (rst_n),
.wr_en (rd_en),
.din (line0_data),
.rd_en (rd_en_next),
.dout (line1_data)
);
// 当前行数据直接输入
assign dout_line2 = din;
endmodule
注意看上面的代码:第一个FIFO写输入数据,第二个FIFO写第一个FIFO的输出。这样数据就一级一级往后传,形成了「流水线」效果。
注意:FIFO的读使能要小心控制。我曾经犯过一个错:读使能一直拉高,结果FIFO空的时候也在读,读出了垃圾数据,整张图像都是雪花点。排查了整整一天才找到问题。
4.3 数据对齐逻辑
行缓冲器把数据存好了,但还有个关键问题:怎么把三行数据同时送到卷积窗口?
说白了,就是数据对齐。你得保证第0行的第N个像素、第1行的第N个像素、第2行的第N个像素,在同一个时钟周期到达卷积计算单元。
我习惯用移位寄存器来做对齐。每个FIFO输出后面接一个K深度的移位寄存器,K是卷积核的宽度。这样每个时钟周期,移位寄存器里都保存着一个完整的窗口数据。
举个例子,3x3卷积的对齐逻辑:
// 数据对齐模块
module data_align #(
parameter DATA_WIDTH = 8,
parameter KERNEL_SIZE = 3
)(
input wire clk,
input wire rst_n,
input wire [DATA_WIDTH-1:0] line0_in,
input wire [DATA_WIDTH-1:0] line1_in,
input wire [DATA_WIDTH-1:0] line2_in,
output wire [DATA_WIDTH-1:0] window [0:KERNEL_SIZE-1][0:KERNEL_SIZE-1]
);
// 每行数据通过移位寄存器生成3个tap
reg [DATA_WIDTH-1:0] shift_reg0 [0:KERNEL_SIZE-1];
reg [DATA_WIDTH-1:0] shift_reg1 [0:KERNEL_SIZE-1];
reg [DATA_WIDTH-1:0] shift_reg2 [0:KERNEL_SIZE-1];
integer i;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
for (i = 0; i < KERNEL_SIZE; i = i + 1) begin
shift_reg0[i] <= 0;
shift_reg1[i] <= 0;
shift_reg2[i] <= 0;
end
end else begin
// 数据右移
shift_reg0[0] <= line0_in;
shift_reg0[1] <= shift_reg0[0];
shift_reg0[2] <= shift_reg0[1];
shift_reg1[0] <= line1_in;
shift_reg1[1] <= shift_reg1[0];
shift_reg1[2] <= shift_reg1[1];
shift_reg2[0] <= line2_in;
shift_reg2[1] <= shift_reg2[0];
shift_reg2[2] <= shift_reg2[1];
end
end
// 输出对齐后的3x3窗口
assign window[0][0] = shift_reg0[2];
assign window[0][1] = shift_reg0[1];
assign window[0][2] = shift_reg0[0];
assign window[1][0] = shift_reg1[2];
assign window[1][1] = shift_reg1[1];
assign window[1][2] = shift_reg1[0];
assign window[2][0] = shift_reg2[2];
assign window[2][1] = shift_reg2[1];
assign window[2][2] = shift_reg2[0];
endmodule
看到没?shift_reg0[2]是最早进来的数据,对应窗口的最左边。shift_reg0[0]是最新进来的数据,对应窗口的最右边。这样排列,刚好和卷积核的乘法顺序一致。
关键点:数据对齐的时序非常关键。每个时钟周期,移位寄存器更新一次,窗口数据就「滑动」一个像素。这就是滑动窗口的实现原理。
下面我用一张图来展示行缓冲器的整体数据流:
从这张图可以看得很清楚:数据从左边进来,经过FIFO缓存行数据,再经过移位寄存器做窗口对齐,最后输出一个完整的3x3窗口给卷积计算单元。
一个小技巧:如果你用的是Xilinx的FPGA,可以直接用XPM_FIFO原语,省去自己写FIFO的麻烦。我刚开始不知道有这个,自己手写FIFO,结果时序怎么都跑不满。换成XPM_FIFO后,问题迎刃而解。
嗯,行缓冲器这块内容就这些。核心就是三点:FIFO存行数据、移位寄存器做对齐、控制好读写使能。把这三点搞明白了,行缓冲器这块就不会出大问题。