1.问题描述
在介绍skid buffer之前,我们先来假设这样一种情况,在一个多级流水模型之中,比如最为经典的顺序五级流水的处理器模型中,各级之间通过仅通过valid-ready的握手信号进行数据传递,(需要注意的是,这里的输入侧和输出侧的握手信号是不建议直连的,这样不符合流水设计思想的同时,还会加中时序压力)当其中某级发生阻塞的时候,比如lsu的执行访存指令,但是cache未命中,需要从更下级的储存器去请求数据的时候,此时需要通过握手信号来需要阻塞流水线,理所应当的,我们拉低lsu的input_ready信号来阻塞来自上级流水的输入(比如EXU),可是问题是此时上上级(比如IDU)并未被阻塞,还在向上级(EXU)传输数据,同样的情况发生在所有的上游模块。这篇文章便是用来解决上述问题。
2.Half-Buffer
2.1 Half-Buffer是什么?
引发上述问题的原因是未能及时阻塞之前的流水线,再深究其原因,是因为其输入侧和输出侧的握手允许在相同时钟周期完成,所以阻塞的信息没有同步到上级。为了解决以上问题,我们现在为流水线每级做如下限定:
1.输入侧和输出侧不能同时完成握手操作。
2.在没有有效数据之前之前只能进行输入握手,在内部有有效数据后,只能做输出握手,在完成握手后才能重新开始输入。
而这种方法叫做Half-Buffer,他内部只有一个buffer来缓存数据,所以他不支持输入和输出侧同时完成握手。他的缺点是显而易见的,每次启动或停止的时候需要两个时钟周期的同时,还让最大带宽减半。但是,对于内部需要多个时钟周期来计算结果的模块而言,其影响并没有那么大。
2.2 Half-Buffer源码分析
这里我们选取fpgacpu网站上的源码进行讲解,网址会帖在文末。
首先是接口部分,需要注意的是,此处的CIRCULAR_BUFFER部分非0 时候,是允许内部有效数据在未完成输出侧握手的情况下接受新数据对原有数据进行覆盖的。因为这种模式我们使用不多,这里现不做介绍。
none
module Pipeline_Half_Buffer
#(
parameter WORD_WIDTH = 0,
parameter CIRCULAR_BUFFER = 0 // non-zero to enable
)
(
input wire clock,
input wire clear,
input wire input_valid,
output reg input_ready,
input wire [WORD_WIDTH-1:0] input_data,
output reg output_valid,
input wire output_ready,
output wire [WORD_WIDTH-1:0] output_data
);
localparam WORD_ZERO = {WORD_WIDTH{1'b0}};
这部分是half_buffer部分,可以看到其内部只有一个buffer用来储存数据:
reg half_buffer_load = 1'b0;
Register
#(
(WORD_WIDTH),
(WORD_ZERO)
)
half_buffer
(
(clock),
(half_buffer_load),
(clear),
(input_data),
(output_data)
);
空满信号的产生模块:
reg set_to_empty = 1'b0;
reg set_to_full = 1'b0;
wire buffer_full;
Register
#(
.WORD_WIDTH (1),
.RESET_VALUE (1'b0)
)
empty_full
(
.clock (clock),
.clock_enable (set_to_full),
.clear (set_to_empty),
.data_in (1'b1),
.data_out (buffer_full)
);
然后是最为重要的逻辑模块,我们可以发现,在非循环模式下,input_ready和output_valid是互斥的,这也就完成了我们之前所说的每次只能完成一边的握手。
在完成输入握手之后将full信号拉高,并将数据写入buffer,在完成输出握手之后,将empty信号拉高。同时我们看到,在初始情况下,内部为empty,所以必须先完成empty->full->empty这个流程,这与我们预期相符。
always @(*) begin
input_ready = (buffer_full == 1'b0) || (CIRCULAR_BUFFER != 0);
output_valid = (buffer_full == 1'b1);
set_to_full = (input_valid == 1'b1) && (input_ready == 1'b1);
set_to_empty = (output_valid == 1'b1) && (output_ready == 1'b1) && (set_to_full == 1'b0);
set_to_empty = (set_to_empty == 1'b1) || (clear == 1'b1);
half_buffer_load = (set_to_full == 1'b1);
end
endmodule
3.Skid Buffer
3.1 Skid Buffer是什么?
那么有没有其他方法能够解决问题的同时,避免到Half-Buffer带来的损耗呢?如果输入输出同时允许握手带来的后果是可能在阻塞的情况下冲刷掉内部的有效数据,那么如果我们让内部不止一个Buffer是不可以解决这个问题呢?
Skid Buffer就是这么来的,它其实是是一个最小的FIFO,深度为2,一个用于输出,一个用来缓存,同时在缓存的这个周期,就能将下一级的阻塞信号传递到上级,这样便可以在允许两次同时握手,消除Half-Buffer带来的两个周期和最大带宽的损耗的同时,拥有更好的布局布线空间。
3.2 Skid Buffer源码分析
这里我们同样选取fpgacpu网站上的源码进行讲解(ps:这个真的是最近发现的最宝藏的网站,之后如果有时间,可以会出一个专门介绍和解析这个网站源码的一个专栏)
首先是接口部分,需要注意的是,此处的CIRCULAR_BUFFER部分非0 时候,是指可以在内部数据已经满的情况下,进行覆盖,同理,我们对该模式不做解析。
none
module Pipeline_Skid_Buffer
#(
parameter WORD_WIDTH = 0,
parameter CIRCULAR_BUFFER = 0 // non-zero to enable
)
(
input wire clock,
input wire clear,
input wire input_valid,
output wire input_ready,
input wire [WORD_WIDTH-1:0] input_data,
output wire output_valid,
input wire output_ready,
output wire [WORD_WIDTH-1:0] output_data
);
localparam WORD_ZERO = {WORD_WIDTH{1'b0}};
然后是数据部分,我们可以清楚地看到,此处使用了两个Buffer,data_buffer_out为缓存buffer,output_Data为输出的数据,通过2mux1来决定输出来源于缓存还是input_data。他这个地方还有个聪明之处在于他将数据通路和状态解耦,这样大大的便捷了整体的设计,是一个值得学习的地方。
reg data_buffer_wren = 1'b0; // EMPTY at start, so don't load.
wire [WORD_WIDTH-1:0] data_buffer_out;
Register
#(
.WORD_WIDTH (WORD_WIDTH),
.RESET_VALUE (WORD_ZERO)
)
data_buffer_reg
(
.clock (clock),
.clock_enable (data_buffer_wren),
.clear (clear),
.data_in (input_data),
.data_out (data_buffer_out)
);
reg data_out_wren = 1'b1; // EMPTY at start, so accept data.
reg use_buffered_data = 1'b0;
reg [WORD_WIDTH-1:0] selected_data = WORD_ZERO;
always @(*) begin
selected_data = (use_buffered_data == 1'b1) ? data_buffer_out : input_data;
end
Register
#(
.WORD_WIDTH (WORD_WIDTH),
.RESET_VALUE (WORD_ZERO)
)
data_out_reg
(
.clock (clock),
.clock_enable (data_out_wren),
.clear (clear),
.data_in (selected_data),
.data_out (output_data)
);
接下来是最为重要的控制部分,首先我们先来将系统划分为以下几个状态:
Empty:输出和缓存区都没有数据。
Busy :在输出寄存器有一个有效值待处理,缓存区为空。
Full : 输出寄存器和缓存区都有有效数据待处理 。
需要注意的是,在Empty下,只支持输入侧的握手,在Full模式下,只支持输出侧的握手,这样可以有效防止数据的覆盖和重复读取。
我们来看一下每个状态之间的转换条件:
load:缓存区和输出寄存器为空,数据直接载入输出寄存器。(输入握手,输出没握手)
fill:输出寄存器为空,将数据载入缓存区。(输入握手,输出没握手)
flow:输出寄存器的值被下级接收的同时,将输入的数据载入到输出寄存器。(输入输出同时握手)
flush:输出寄存器的值被下级接受,将缓存区的有效数据载入输出寄存器(输入没握手,输出握手)
unload:输出寄存器的被下级接受,现在输出和缓存区都为空。(输入没握手,输出握手)。
在得到所有的转化条件之后,我们还需要去决定输入的ready和输出valid信号。我们只需要在当前非满时拉高ready信号,在当前非空的时候拉高valid信号即可。
Register
#(
.WORD_WIDTH (1),
.RESET_VALUE (1'b1) // EMPTY at start, so accept data
)
input_ready_reg
(
.clock (clock),
.clock_enable (1'b1),
.clear (clear),
.data_in ((state_next != FULL) || (CIRCULAR_BUFFER != 0)),
.data_out (input_ready)
);
Register
#(
.WORD_WIDTH (1),
.RESET_VALUE (1'b0)
)
output_valid_reg
(
.clock (clock),
.clock_enable (1'b1),
.clear (clear),
.data_in (state_next != EMPTY),
.data_out (output_valid)
);
然后,在输入握手时插入数据,在输出握手时移除数据:
reg insert = 1'b0;
reg remove = 1'b0;
always @(*) begin
insert = (input_valid == 1'b1) && (input_ready == 1'b1);
remove = (output_valid == 1'b1) && (output_ready == 1'b1);
end
最后便是状态的转化和数据通路的选择部分,在此不做赘述。
reg load = 1'b0; // Empty datapath inserts data into output register.
reg flow = 1'b0; // New inserted data into output register as the old data is removed.
reg fill = 1'b0; // New inserted data into buffer register. Data not removed from output register.
reg flush = 1'b0; // Move data from buffer register into output register. Remove old data. No new data inserted.
reg unload = 1'b0; // Remove data from output register, leaving the datapath empty.
reg dump = 1'b0; // New inserted data into buffer register. Move data from buffer register into output register. Discard old output data. (CBM)
reg pass = 1'b0; // New inserted data into buffer register. Move data from buffer register into output register. Remove old output data. (CBM)
always @(*) begin
load = (state == EMPTY) && (insert == 1'b1) && (remove == 1'b0);
flow = (state == BUSY) && (insert == 1'b1) && (remove == 1'b1);
fill = (state == BUSY) && (insert == 1'b1) && (remove == 1'b0);
unload = (state == BUSY) && (insert == 1'b0) && (remove == 1'b1);
flush = (state == FULL) && (insert == 1'b0) && (remove == 1'b1);
dump = (state == FULL) && (insert == 1'b1) && (remove == 1'b0) && (CIRCULAR_BUFFER != 0);
pass = (state == FULL) && (insert == 1'b1) && (remove == 1'b1) && (CIRCULAR_BUFFER != 0);
end
always @(*) begin
data_out_wren = (load == 1'b1) || (flow == 1'b1) || (flush == 1'b1) || (dump == 1'b1) || (pass == 1'b1);
data_buffer_wren = (fill == 1'b1) || (dump == 1'b1) || (pass == 1'b1);
use_buffered_data = (flush == 1'b1) || (dump == 1'b1) || (pass == 1'b1);
end
endmodule
4.刚玉中的流水代码分析
在开源代码刚玉中大量运用了流水线,我们以其为例子进行分析。我们以其axi_register_rd中对于ar port的流水处理进行分析。
刚玉采用了三种可选方式,bypass,Half-Buffer以及Skid-Buffer。我们针对其后两种进行分析。需要说明的是,其中s_axi为输入侧,m_axi为输出侧。ps:刚玉的作者Alex的代码水平真的十分高,他经常用一些互斥条件的组合来代替状态机的书写,所以对我来说想要理解往往需要花费一定的时间。
4.1 刚玉中的Half-Buffer
enable ready input next cycle if output buffer will be empty
wire s_axi_arready_early = !m_axi_arvalid_next;
always @* begin
transfer sink ready state to source
m_axi_arvalid_next = m_axi_arvalid_reg;
store_axi_ar_input_to_output = 1'b0;
if (s_axi_arready_reg) begin
m_axi_arvalid_next = s_axi_arvalid;
store_axi_ar_input_to_output = 1'b1;
end else if (m_axi_arready) begin
m_axi_arvalid_next = 1'b0;
end
end
我们可以看到,只有在输出侧在下一拍为低的时候,才拉高输入侧的ready信号,保证每一拍只有一侧的握手是可以完成的。
然后在输入侧ready的情况下,将上一级的有效信号传递到输出寄存器,这里比较有意思的是,他没有等到输入握手成功再传递,而是直接传递,这是因为输入侧的ready和输出侧的valid是互斥的,即使没有握手就传递,也不会出现两边同时握手的情况。
如果输入侧的ready无效,但是输入侧的ready有效时,将下一拍的输出侧的有效信号拉低,我当初看到这里很疑惑,后来一想其实很简单,因为输入侧的ready无效就意味着当前拍的输出侧valid肯定是拉高的,这句话其实可以理解成完成输出侧握手后,将已经处理过的有效信号拉低的操作。
4.2 刚玉中的Skid-Buffer
wire s_axi_arready_early = m_axi_arready | (~temp_m_axi_arvalid_reg & (~m_axi_arvalid_reg | ~s_axi_arvalid));
always @* begin
transfer sink ready state to source
m_axi_arvalid_next = m_axi_arvalid_reg;
temp_m_axi_arvalid_next = temp_m_axi_arvalid_reg;
store_axi_ar_input_to_output = 1'b0;
store_axi_ar_input_to_temp = 1'b0;
store_axi_ar_temp_to_output = 1'b0;
if (s_axi_arready_reg) begin
input is ready
if (m_axi_arready | ~m_axi_arvalid_reg) begin
output is ready or currently not valid, transfer data to output
m_axi_arvalid_next = s_axi_arvalid;
store_axi_ar_input_to_output = 1'b1;
end else begin
output is not ready, store input in temp
temp_m_axi_arvalid_next = s_axi_arvalid;
store_axi_ar_input_to_temp = 1'b1;
end
end else if (m_axi_arready) begin
input is not ready, but output is ready
m_axi_arvalid_next = temp_m_axi_arvalid_reg;
temp_m_axi_arvalid_next = 1'b0;
store_axi_ar_temp_to_output = 1'b1;
end
end
首先还是先来分析输入侧的ready信号,可以看到,他拉高的条件有两个,首先是输入侧的ready为高,这是为什么?我们来简单分析一下,当输出侧的ready为高的时候,他的输出寄存器主要有效,那么一定会被读取,所以当前状态永远不会是full,所以可以拉高。
第二个条件:
(~temp_m_axi_arvalid_reg & (~m_axi_arvalid_reg | ~s_axi_arvalid))
我们来解析一下,首先他要求缓存寄存器为空的同时,输入侧和输出寄存器不能同时有待处理的请求,这个也很好理解,我们这个系统最大的待处理请求只能是两个,如果不满足以上条件,那么系统中可能会出现待处理请求,缓存区的请求有被覆盖的风险。
if (s_axi_arready_reg) begin
// input is ready
if (m_axi_arready | ~m_axi_arvalid_reg) begin
// output is ready or currently not valid, transfer data to output
m_axi_arvalid_next = s_axi_arvalid;
store_axi_ar_input_to_output = 1'b1;
end else begin
// output is not ready, store input in temp
temp_m_axi_arvalid_next = s_axi_arvalid;
store_axi_ar_input_to_temp = 1'b1;
end
然后就是接下来的部分,我们看到,在输入侧ready的情况下,如果输出侧ready有效或者没有待处理的请求时,可以将新的请求从输入加载到输出寄存器。又是很奇怪是不是?这里真的感叹一句Alex的水平之高,好了,我们来认真分析一下,如果输出侧ready有效,那意味着当前状态不为full,那么任何被传递的请求都是可以被下级处理的,同理,如果下级已经没有待处理的请求,那么自然可以加载新的有效请求。然后,如果下级不能处理新的请求的时候,也就是对应我们之前的BUSY状态下,可以完成输入侧握手,不能完成输出侧握手的时候,我们就需要把输入侧的请求存入缓存区。
end else if (m_axi_arready) begin
// input is not ready, but output is ready
m_axi_arvalid_next = temp_m_axi_arvalid_reg;
temp_m_axi_arvalid_next = 1'b0;
store_axi_ar_temp_to_output = 1'b1;
end
最后,便是输出侧可以完成握手,但是输入侧不能完成的时候,对应之前的flush状态,输出寄存器被下级读取之后,我们把缓存区的数据载入到输出寄存器即可。
5.结语
文章主要分析了流水线中的Half-Buffer与Skid-Buffer的使用,之后如果有机会,将继续分享更多DE技巧。
Half-Buffer:http://fpgacpu.ca/fpga/Pipeline_Half_Buffer.html;
Skid Buffer:http://fpgacpu.ca/fpga/Pipeline_Skid_Buffer.html