一直想找机会聊一聊预读FIFO这个问题,趁着这次机会比较系统的整体讲一讲预读FIFO的一些实现方法,注意事项,以及优化手段。

1、背景知识

FIFO 是一个具有简单概念的存储元件:应用逻辑的一部分在 FIFO 的一侧写入数据。在 FIFO 的另一侧,以相同的顺序(FIFO = First In First Out)从中读取这些数据。不细看逻辑,一个 FIFO 的整体框架如图1所示。通常,数据是存放在MEM中,然后在外围增加逻辑电路,对MEM的指定地址进行读写操作。MEM对于FPGA而言,可选BRAM,DRAM,REG,URAM等,ASIC通常是BRAM或REG,这里的BRAM也即厂商定制的大块RAM。ASIC可以定制任意位宽深度的RAM,FPGA都是统一大小,根据厂商/器件不同,大小可以是9K/10K/18K/20K/36K/288K等等,通过拼接来实现各种位宽组合的RAM。

【配图留空】图1 标准FIFO架构

一个标准的FIFO(以同步FIFO为例),通常是读使能的下一拍可以获取到FIFO内的读数据,其读时序如下:

【配图留空】图2 标准FIFO读时序

上面的这种时序很好理解,当FIFO非空时,只要外部有读使能fifo_ren,就把内部的数据送到读口上。伪代码如下:

// —— 写操作 ——
if (w_en == 1 && count < DEPTH) begin
    mem[wptr] <= wdata;
    wptr      <= wptr + 1;
end
// —— 读操作 ——
if (fifo_ren == 1 && count > 0) begin
    dout <= mem[rptr];   // 数据下一拍才出现在读口上
    rptr <= rptr + 1;
end

按照正常实现,FIFO似乎总是至少有一拍延迟来自MEM本身的读延迟,除非提前知道外部要读了,内部先把这个数读出来放到读端口,可是内部怎么可能知道接口下一拍要读呢?接下来看预读FIFO的实现。

2、预读FIFO

预读FIFO的时序参照下图,也就是说读使能拉高的当拍就能把数据取走,你还没开口,店员已经把奶茶提前放在取餐台上了。

【配图留空】图3 预读FIFO读时序

2.1 为什么需要预读FIFO?

1)减少读侧延迟 — 数据已就绪,rd_en 更像一个“确认/消耗”信号,适合对延迟敏感的数据通路。

2)与下游模块配合 — 如果下游模块期望“数据有效+使能”的同步握手(如 AXI Stream valid & ready),预读FIFO天然适配:FIFO的 ~empty 作为 valid,下游的 rd_en 作为 ready。

2.2 预读FIFO的实现方案

实现预读FIFO一般有以下两种方法。

方案一:输出寄存器(cache)预取(Prefetch)

标准 FIFO 的 dout 直接连 RAM 输出,所以 r_en 有效后必须等 RAM 读出一拍。FWFT 的做法是在 RAM 和 dout 之间插一个寄存器,让数据提前住进去。

方案二:读地址提前一拍生效

这种方案不额外加预取寄存器,而是让 RAM 的读地址永远比读指针快一步,这样 RAM 输出端口上始终趴着下一个该读的数据。

两种方式都比较常见,但是在使用上有诸多不同,应用限制和注意事项我们在讲方案的时候再比对。

【配图留空】图4 方案二架构:双读指针+读地址2选1

在写侧,和我们常规的FIFO没有什么区别,外部写数据/使能来了,更新指针,同时数据写入到对应指针地址。

在读侧,我们保存两个指针,rptr0和rptr1,MEM的读地址2选1。

rptr0 <= fifo_ren ? rptr0 + 1 : rptr0;      // 读指针:当前趴在读口上的数据
rptr1 <= fifo_ren ? rptr1 + 1 : rptr0 + 1;  // 始终领先 rptr0 一步
mem_rd_addr = fifo_ren ? rptr1 : rptr0;     // MEM 读地址(2选1,组合逻辑)

也就是说,空闲时 rptr1 保持在 rptr0+1,MEM 输出口上始终趴着下一个待读的数据;fifo_ren 拉高的当拍,读地址切换到 rptr1,当前数据从读口取走的同时,MEM 已经在预读下一个数据,下一时刻下个数据就送到了读口上。

另外注意 nempty 的存量管理:这种架构下永远有 1 个数据被预读出 MEM、趴在读口上,nempty 必须按指针差(wptr − rptr0)计算,把这个已经在读口上的数据也算进存量;不能按「MEM 中尚未读出的数据」来算,否则 nempty 会提前一个数据跌落,下游会少读一个数据。

2.3 方案二的局限性及问题

1)最多接受MEM有1拍延迟

大部分情况FPGA是可以接受的,BRAM分布在芯片的各个地方,一般不会变成时序阻塞点(不绝对)。如果需要优化RAM时序,两拍出甚至三拍(例如使用URAM)那么就做不到了。

2)功耗高

所有的读写操作都直接影响MEM,而MEM的读写率和功耗强相关。同时MEM需要一直保持读操作来实现预读——为什么?仔细看架构图,用户读侧给MEM的读使能使用虚线表示,也就是说其实MEM的读使能和外部读使能没有直接连线,之前说过,MEM的读永远比fifo_ren要早才行,不然没办法提前送数据出去。实际上MEM的读有两个可选项,一个接常1,一个接FIFO内部的非空信号nempty。这就是说MEM会长期保持读操作!!

顺便讲一下接常1和非空信号nempty的时序区别,接1说明MEM一直在读,那么0地址的数据在写入之后就已经开始送到读口了,但是如果接非空信号nempty,读数据至少比nempty晚1拍才送到读口,因此会有应用限制:

  • CTRL_REN = 1(连nempty时)读使能至少比nempty晚一拍!
  • CTRL_REN = 0(常1),可以非空即读(fifo读使能直接连nempty)。

3)存在读写冲突的问题

上述设计在两个场景存在MEM的读写冲突问题。

读使能常1时,FIFO写入第一个数据,在地址0处产生读写冲突。

FIFO内部存在1个数据时,同时写和读,在地址1处产生读写冲突。

具体时序我就不讲了,根据上面的原理算一下读写两侧的MEM addr就行。

这种读写冲突在FPGA上其实没什么关系,几乎所有厂商对读写冲突的处理都是保证数据正确写入,冲突拍的读出数据不保证,和写模式也有关。实际上,这种架构的FIFO不受到读数据错误的影响,因为实际上冲突那拍数据用户是不用的,自行画时序图,所以对FPGA而言不感知这种冲突。但是ASIC不一样,厂家提供的RAM IP行为不统一,可以高度定制(做这种hazard处理终归是要额外处理逻辑的),很可能会出现冲突的时候写入失败,FIFO失效。

【配图留空】图5 方案一架构:同步FIFO与异步FIFO中的cache与MEM

可以发现,这种方案同步FIFO和异步FIFO是有区别的,当然你也可以用异步FIFO的方式实现同步FIFO,但是会有额外的功耗开销。为什么两种架构不同?关键还是时钟不同步,小cache无法同时工作在两个时钟域,因此只能所有的读都通过cache读出,写入是直接写入到MEM。但是同步模式就不一样了,写入的时候就可以先写入到cache中,只要cache深度足够,完全可以不操作MEM。(FIFO的总深度是MEM深度+cache深度。)

2.4 方案一(Cache预取)详细工作流程

同步FIFO

工作流程可以总结为:

1、写数据时:优先写入cache,cache满了之后再写入MEM。
2、读数据时:从cache里面读,同时把MEM的数据补充到cache中,保证cache一直是最新数据。

cache的深度一般可以设置为MEM_Latency+1,这样可以保证寄存输出。极限场景也可以设置为MEM_Latency,这样在连续读的场景可能出现MEM数据直通到读端口(本质就是cache太少,来不及先写入cache再读出来)。

我们关注以下几个问题:

1、数据什么时候写入cache?

1)外部fifo写使能拉高,且cache未满的状态,数据写入cache。
2)cache满,外部fifo写使能和读使能同时拉高,直接替换掉读出的那个数据。
3)MEM非空,外部有fifo读请求,一边读MEM数据,一边往外送cache数据,再把MEM读出的数据写入到cache。

2、何时更新cache的写地址?

cache写使能拉高,对应1中的1)2)两种场景。
MEM读请求为高,对应1中的3)场景。

3、cache的读处理

很简单,直接连到外部fifo读使能,cache的读地址。

4、数据什么时候写入MEM?

除了1中1)2)两种场景,外部只要写入数据,就写入MEM。

5、MEM什么时候读?

只要MEM非空,外部读了,就需要读MEM更新cache数据。这里需要注意的是,MEM读出来的数据是有延迟的,所以需要对cache的写使能/地址打拍处理,和MEM的数据对齐再写入cache。

异步FIFO

思路和同步FIFO基本一致。

  • 什么时候写MEM -> 只要外部有写使能,就写入MEM。
  • 什么时候读MEM -> 只要外部有读使能且MEM非空,就读MEM。
  • 什么时候写cache -> 只要读了MEM。
  • 什么时候读cache -> 只要外部有读使能。

上面的这些描述没有考虑读写保护等场景,简化了一下。

2.5 方案适用性总结

整体来说,方案二的适用场景还是广一些。方案一(cache预取)的主要问题在于:

1)额外面积:尤其是数据位宽比较大的时候,cache阵列的面积消耗会比MEM大得多。
2)想不出来了。

3、FIFO的拼接

一般FPGA的硬核IP资源实现,组件不涉及,直接拼RAM就行了。

【配图留空】图6 FIFO拼接:位宽扩展与深度扩展

4、零延迟FIFO 和标准FIFO的互相转换

零延迟FIFO转换成标准FIFO:很简单,把数据打拍出就可以了。

将已有的标准FIFO,转换成零延迟FIFO:可以参考以下代码。

(代码示例展示了一个 module basic_fwft_fifo)

只是增加逻辑替换了标准FIFO的 @rd_en 和 @empty两个信号,即包装成了零延迟FIFO。

5、如何改进时序?

HS CBB中实现FIFO逻辑的代码其实很少,但是一个文件动辄上千行,大部分场景都是为了优化时序或者适配异步复位/同步复位。

同步FIFO

MEM的输入输出都可以增加打拍,MEM整个读延迟会增加,影响cache深度。

cache_pre_wr_ptr_vld 用来控制写地址的翻转。

cache 读端地址使用onehot优化时序,数据切片,减小扇出。

原本的做法是:

cache_rdata = cache_mem[cache_rd_ptr]

优化: cache_rd_ptr 变成 onehot 格式 cache_rd_ptr_onehot,那么每个DEPTH的cache都有自己的1bit onehot读。另外,再把这个 cache_rd_ptr_onehot 复制多份,每一份控制cache里面的一部分数据。具体举例:深度为2,位宽为256的 cache,cache0[127:0] 和 cache0[255:128] 分别由 cache_rd_ptr_onehot[0] 和它的复制份 cache_rd_ptr_onehot_cp[0] 控制,各自完成选择后再拼接成一个完整的读数据。

异步FIFO

增加了格雷码/二进制转换的打拍。