在数字芯片设计中,几乎所有模块都会涉及到队列管理。输入输出的管理、不同数据流的调度、乱序数据的重排序、不同模块的同步处理、资源管理,等等,均会涉及到队列管理逻辑。如何选择合适的硬件逻辑,对模块的微架构有较大的影响,需要基于具体需求做综合权衡后再做选择。本文简单罗列几种队列管理逻辑,均是个人曾经实现过的。
1 最简单的队列-FIFO
First In First Out,用于输入输出之间的缓冲,吸收输入侧的突发流量。实现也比较简单,深度固定的环形buffer,使用读写指针进行管理。需要注意的是,读写指针的管理,FIFO为空和FIFO为满,读写指针均是相等的,需使用另外的标号进行处理。也有其余的实现方式,比如移位寄存器。
使用SpinalHDL实现FIFO的代码如下。输入输出的push/pop,使用了valid/ready握手的Stream接口;使用Mem定义环形buffer,pushPtr/popPtr分别对应读写指针;特别关注risingOccupancy信号,push和pop没有同时发生时,更新为push,该信号可用于标记FIFO的空满状态。读写指针相等且该信号为低,表示FIFO为空;读写指针相等且该信号为高,表示FIFO为满。
val io = new Bundle {
val push = slave Stream (dataType)
val pop = master Stream (dataType)
val flush= in Bool() default(False)
val occupancy = out UInt (log2Up(depth + 1) bits)
val availability = out UInt (log2Up(depth + 1) bits)
}
val ram = Mem(dataType, depth)
val pushPtr = Counter(depth)
val popPtr = Counter(depth)
val ptrMatch = pushPtr === popPtr
val risingOccupancy = RegInit(False)
val pushing = io.push.fire
val popping = io.pop.fire
val empty = ptrMatch & !risingOccupancy
val full = ptrMatch & risingOccupancy
io.push.ready := !full
io.pop.valid := !empty & !(RegNext(popPtr.valueNext === pushPtr, False) & !full) _
io.pop.payload := ram.readSync(popPtr.valueNext)
when(pushing =/= popping) {
risingOccupancy := pushing
}
when(pushing) {
ram(pushPtr.value) := io.push.payload
pushPtr.increment()
}
when(popping) {
popPtr.increment()
}
2 共享Buffer的多队列FIFO
考虑一个场景,输入的请求需要分发至不同的输出侧,下游存在反压。简单实现,基于不同的输出分别设置FIFO,但可能存在资源浪费,某些数据流场景FIFO的利用率不高,尤其是在数据位宽较大的场景。
共享Buffer的多队列FIFO,每个队列的FIFO还是按照简单队列进行管理,基于每个队列管理读写指针。但是,不再使用环形Buffer,每个buffer entry记录其队列号、队列指针和Payload,如下图所示。对于Payload位宽较小的场景,收益不大,若存在大位宽时,可有效提升Buffer的利用率。
将数据写入Buffer时,先找一个Free Entry(Vliad为低),将该数据所属的队列号及其对应的写指针、Payload写入到对应的Entry内。读取Buffer时,则使用队列号和读指针进行匹配,将命中的Entry内容读取出来。若读写指针所能描述的范围比buffer深度大,则不需要额外的标号记录空满状态。存在的问题,若buffer深度较大或队列数量较多,队列号和指针匹配逻辑会占用较多的资源。
3 重力FIFO
类似于排队,从队头开始寻找可输出的Entry,调度输出并留下空位,后面的Entry再往前排,新输入的请求则放置在队列尾。如图所示,存在有效数据的Entry,其前面的Entry被调度后留下空位,该Entry就像受到重力作用往下掉,因此我也称之为重力FIFO。
该结构的问题,存在大量的移位,设想Payload位宽为32bit,深度为32,将近1kbit的寄存器在做移位处理,其功耗可想而知。但是对于一些具体场景,还是能够带来一些收益的,如队列数量较大,甚至大于buffer深度;至于Payload位宽较大的场景,可考虑二次索引处理,Payload保存至另外的buffer,该结构内的Payload Entry则缓存其索引信息。
4 Bitmap排序
先来看一个结构,深度为8的队列,每个Entry使用8bit缓存8个Entry的状态,若该状态信号满足触发条件,如全为0,则调度该Entry内容。
Bitmap排序就是使用了这一结构,在输入请求进入队列后,检查当前队列状态,存在关联请求的Entry位置置位为1,否则为0。若存在请求输出之后,所有Entry状态的对应位置均设为0。若某个Entry的状态信号全为0,则请求调度输出。其数据结构如下图所示,其中0/1仅作为状态信号的示例,并非实际场景。
该结构可以实现较为灵活的排序,队列的数量几乎不会受到限制,进入队列的请求,也可修改其Mask Bitmap,动态刷新其先后关系。与重力FIFO类似,无需额外的数据结构保存其队列关系,而是直接体现在原有结构内。存在的问题,队列深度会受到面积限制,面积与深度的平方成正比;另外,在动态更新Mask Bitmap之后,某些实现可能无法保证先后关系。
面积问题可以考虑用分级处理。如需实现256深度的队列,其Mask Bitmap需要65536个寄存器实现Mask Bitmap。分解为8个32深度的队列,需要的寄存器数量为8192;分解为16个16深度的队列,寄存器数量为4096。
5 小结
队列管理电路还有一个比较常见的实现,链表。在乱序数据的重排序、资源管理等等方面,通常会用链表实现,与上几个结构相比,链表会复杂一些。该部分将在下篇描述。
除最简单的FIFO之外,其余几个都没有代码,如各位要有兴趣,请留言,我可以再尝试写一些Spinal代码实现。
原作者:芯工阿文