在芯片设计中,尤其是流水线设计中。为了考虑数据安全性,避免数据被覆盖等情况。常常需要使用握手与反压机制。当握手成功的时候认为数据发生了交互。当进行反压的时候则认为没有能力接收数据,上级应该维持住相应的数据保持不变。
1、回顾AXI-stream协议
ARM在AXI协议中使用了这套机制,AXI中的AXI-stream就非常适用于流水线设计。
我们现在只考虑点对点的情况,Master发出Valid、Data信号,接收ready信号。Slave接收Valid、Data信号,发出Ready信号。当握手成功的时候,对于Master而言,意味着它可以更新此时的Data和Valid了。而对于Slave而言,意味着在时钟上升沿的时候,它会成功采样此时的数据。其实握手的本质就意味着双方都认为数据发生了真正的传输,因此可以变换成新的数据了。
有关AXI-stream协议本身大家可以回顾此篇文章,以下对于协议本身只做简单回顾。
https://zhuanlan.zhihu.com/p/642670753
- Valid先于Ready拉高:
- Ready先于Valid拉高:
- Valid和Ready同时拉高:
2、非阻塞型流水线
我们想一下,如果没有握手与反压机制会怎么样?
我们考虑下面这样的一个例子,输入DIN先通过寄存器打一拍,然后经过第一级组合逻辑运算以后再打一拍,然后再经过第二级组合逻辑运算再打一拍。然后输出到Dout。看上去能够很好的满足要求,但是如果:
- Dout所连接的模块或其内部的某一级无法接收新的数据;(下游接收不了数)
- 中间的组合逻辑一拍运算不完;(上游数据没有准备好)
就会出现相应的问题,该例子中的寄存器在任何情况下,都会在时钟上升沿采样新的数据,但很多时候我们还没有准备好数据,或者是下游还不能接收新的数据。这样的情况都会导致运算结果出错,因此我们需要考虑使用带enable的寄存器,在我们想要的时候,才进行数据的更新,至于这个enable信号如何规定,我们后面再讲,我们先看一下带Enable的DFF长什么样子。
3、阻塞型流水线
3.1、DFF Model
需要在想要的情况再赋值给DFF,我们就需要用到带有enable的DFF,其RTL代码大致如下所示:
reg [31:0] dout; always@(posedge clk or negedge rst_n) begin if(!rst_n) dout<=32'h0; else if(en) dout<=din; end
可以看到,在这种情况下,只有enable拉高使能的时候,才会把新的din输出给dout,否则dout维持不变。我们一般有两种方式,一种是保持数据不变,另外一种是关闭时钟,通常称为门控时钟。分别如下图所示,其中门控时钟的策略更加适用于高速低功耗的电路中。
3.2、阻塞流水线
正因为非阻塞流水线无法实现我们预期的功能。因此我们需要借助enable信号来实现阻塞流水线,从而引出我们的握手与反压。这里我们首先考虑不使用FIFO的反压机制。
如下图所示,为阻塞型流水线。其中的Enable有两种机制:
- 全局使能,只要某一级发生阻塞了,整个流水线都会停住,这样会存在某几级流水线可以正常工作,但无法发数和接收数据。存在性能浪费。
- 独立使能,逐级反压。只要我自己的后级可以接收数据,我就把数往后发,再往前或者再往后是怎样的情况和我没关系。
3.3、阻塞型流水线设计实例
我们考虑pipe1->pipe2的组合逻辑是一个加法器,为单周期指令。而pipe2->pipe3的组合逻辑是一个乘法器,为多周期运算,需要三个时钟周期。
此例子中存在后级反压前级的情况,因此我们就需要根据握手协议来选择合适的时候传递数据,核心思想就是保证中的每一级寄存器的数据安全,不要当前的值还没有使用,就被新的值给覆盖掉了。
这种情况我们没有使用FIFO,但实际上我们可以把每一级寄存器当成一个深度为1的FIFO,下一级有无数据可以看对应的valid信号。下一级无数据或者下一级已经准备好了,那么就可以向上一级取数据。
下面的代码实例是前向打拍,因此ready的时序会比较差,此外正常情况,我们多周期指令实际上会拆成多级流水线,用这样的一级流水线加内部状态记录使能ready的情况很少见。实际上这样性能会比较差,因为存在断流。大家感兴趣可以自己写细粒度流水线的例子。
接下来我们分析一下代码:
- 首先我们分析什么情况下会反压,即存在ready拉低。
pipe1_ready拉高即代表pipe1可以接收新的数据,什么情况下pipe1可以接收新的数据呢?
第一种情况是pipe1_valid为0,这种情况下我这个寄存器没有实际有效的输出,处于空闲状态,那自然而然可以接收一笔数据,与后级是啥情况都没有任何关系。
另外一种情况是pipe2_ready&pipe1_done。对于单周期运算而言,pipe1_done默认为1,即认为只要输入valid,那么下一个时钟周期上升沿必然可以采样到已经运算好的数据,不会采样到一个还处于运算中途的数据。此时当pipe2_ready拉高,即使我pipe1_valid为1,也就是此时不处于空闲状态。但由于下一级可以接收我的数据,因此不用担心数据还没用就被覆盖掉了。
我们来分析一下。如果pipe2_ready拉高,并且我pipe1_valid为0,即此时实际上处于第一种情况。那自然而然可以接收数据。如果pipe1_valid为高,实际上这种情况真正对应的是pipe2_ready&pipe1_2_pipe2_valid,即下游握手成功。这个时候当前的数据会在下一个时钟上升沿的情况下就被pipe2采走,因此我pipe1_ready拉高自然没有关系。你即使新的数据进来了,也不可能覆盖掉我之前的数据。
大家如果理解不了可以思考一下深度为1的同步FIFO。第一种情况实际上就是FIFO为空,那当然可以拉高ready,你进来一个新的数据这个FIFO才没有浪费掉。第二种情况实际上就是FIFO里面有一个数据,但是我这个数据已经准备好给别人拿走了,并且别人也可以拿走,这样你进来一个新的数据没有关系。因为之前的那个数据被下一级拿走了,没有被浪费掉。(我们上述的基于valid和ready的pipe,实际上和深度为1的同步FIFO没有太大区别,不过相比于深度为1的FIFO不会断流,大家可以分析一下为什么)。
我们再来看第二级pipe,第二级的区别在于其要运算乘法指令,而乘法指令的运算需要3个时钟周期。因此当第二级pipe的valid拉高的时候,并且第三级的ready拉高的时候。并不意味着这个数据是运算完成的数据了,如果此时直接采样,会采到错误的数据,同时第二级的pipe也会被错误的数据覆盖。
我们看一下第二级pipe的pipe2_ready。什么情况下pipe2_ready拉高呢?
第一种情况是pipe2_valid为0,这种情况和上面的情况一样。即寄存器没有真正有效的输出,实际上处于空闲状态,那当然可以接收一笔数据。
另外一种情况是pipe2_done && pipe3_ready。pipe3_ready意味着下一级可以接收上一级的数据。但这并不意味着pipe2自己也可以接收上一级的数据。因为这是一个多周期运算,它后级可以接收数据了,不代表你的数据已经算完了。你只有算完的情况下,并且后级可以接收了。这种情况下你才可以接收来自前级新的数据。这里我们使用一个移位寄存器,当pipe2_valid拉高的时候,即代表数据有效了。我们相应的统计两拍,然后在第三拍的时候。可以把valid和data一起向后传过去。
- 我们再看一下valid信号,即什么时候数据准备好了,并且可以传到下一级
首先对于pipe1_2_pipe2_valid信号而言,当pipe1_valid拉高的时候该信号也会拉高。也就是pipe1拿到数据以后,默认下一级下一拍就去采是没有问题的。那pipe1_valid什么时候拉高呢?我们认为pipe1_ready拉高的时候,相应的可以将valid_i赋值给pipe1_valid。(这里不是valid依赖于ready拉高,因为pipe1_valid不是和pipe1_ready握手)实际上我们考虑,valid_i为高,则说明握手成功,因此可以把pipe1_valid拉高,否则pipe1_valid仍然为0。
pipe2_2_pipe3_valid是类似的,其区别在于。pipe2_valid拉高的时候,它的数据还没有运算完,因此不能马上将pipe2_2_pipe3_valid拉高,否则下一级就采集到了错误的数据了。
至于data,则认为握手成功的时候可以更新为新的数据。
module BlockPipe( input clk, input rst_n, //upstream input valid_i, input [31:0] data_i, output ready_o, //downstream input ready_i, output valid_o, output [31:0] data_o ); //pipeline signals reg pipe1_valid; reg [31:0] pipe1_data; wire pipe1_ready,pipe1_done; reg pipe2_valid; reg [31:0] pipe2_data; wire pipe2_ready,pipe2_done; reg pipe3_valid; reg [31:0] pipe3_data; wire pipe3_ready,pipe3_done; wire pipe1_2_pipe2_valid; wire pipe2_2_pipe3_valid; reg [1:0] mul_done; wire [31:0] mul_result; //pipeline 1 stage assign pipe1_done = 1'b1; assign pipe1_ready = !pipe1_valid || (pipe1_done && pipe2_ready); assign pipe1_2_pipe2_valid = pipe1_valid & pipe1_done; always @(posedge clk or negedge rst_n) begin if(!rst_n) pipe1_valid <= 1'b0; else if(pipe1_ready) pipe1_valid <= valid_i; end always @(posedge clk or negedge rst_n) begin if(!rst_n) pipe1_data <= 32'h0; else if(valid_i && pipe1_ready) pipe1_data <= data_i; end //pipeline 2 stage assign pipe2_done = mul_done[1]; assign pipe2_ready = !pipe2_valid || (pipe2_done && pipe3_ready); assign pipe2_2_pipe3_valid = pipe2_valid & pipe2_done; assign mul_result = pipe2_data * 4'h5; always @(posedge clk or negedge rst_n) begin if(!rst_n) mul_done <= 2'b00; else if(pipe2_ready && pipe1_2_pipe2_valid) mul_done <= 2'b00; else if(pipe2_valid) mul_done <= {mul_done[1:0],1'b1}; end always @(posedge clk or negedge rst_n) begin if(!rst_n) pipe2_valid <= 1'b0; else if(pipe2_ready) pipe2_valid <= pipe1_2_pipe2_valid; end always @(posedge clk or negedge rst_n) begin if(!rst_n) pipe2_data <= 32'h0; else if(pipe1_2_pipe2_valid && pipe2_ready) pipe2_data <= pipe1_data + 4'h4; end //pipeline 3 stage assign pipe3_done = 1'b1; assign pipe3_ready = !pipe3_valid || (pipe3_done && ready_i); always @(posedge clk or negedge rst_n) begin if(!rst_n) pipe3_valid <= 1'b0; else if(pipe3_ready) pipe3_valid <= pipe2_2_pipe3_valid; end always @(posedge clk or negedge rst_n) begin if(!rst_n) pipe3_data <= 32'h0; else if(pipe2_2_pipe3_valid && pipe3_ready) pipe3_data <= mul_result; end assign valid_o = pipe3_valid; assign data_o =pipe3_data; assign ready_o = pipe1_ready; endmodule
该例子中ready时序较差。大家可以在两端使用fw_bw双向的pipe打拍,来让时序变好。或者我们也可以使用带存储体的反压。
4、带存储体的反压
最典型的例子就是借助于同步FIFO。我们看下图这样的一个例子,中间运算的数据会持续的进入到同步FIFO当中,后级模块再从同步FIFO里面去取数据。这种情况下,什么时候应该反压前级呢?
很多人会觉得当同步FIFO满了以后反压前级,但是我们的10级流水线内部是非阻塞型的,也就是每一个时钟周期上升沿都会更新数据。如果你同步FIFO已经满了再进行反压。此时R0确实不会再出数据了,但是10级流水线内部的数据怎么办呢?每一个周期上级数据都会覆盖掉下一级的数据。最后一级的数据又不能进入到同步FIFO当中,因此数据都被冲刷掉了。
这个时候就应该用Almost Full去反压。当FIFO Depth=Max Depth-pipe_length的时候,就拉低ready。这样你的10笔数据,还是可以进入到同步FIFO当中,不至于数据被冲刷掉。当FIFO非空的时候就给下一级的valid_o拉高,即valid_o=~full,而下一级的ready_i可以作为FIFO的读使能信号。
这种方式的缺点就是开销比较大,此外还要确定好FIFO深度,almost_full情况。避免数据被冲刷掉。优点是时序较好,设计简单,不用考虑复杂的握手条件。
上述的例子如果有问题,欢迎大家指正。