前文聊了队列管理的几种典型电路,硬件逻辑简单,代码实现时容易操作。链表也是队列管理的常用电路,相比前文的几种结构,会稍微复杂一些。
1 什么是链表
在非连续、非顺序的物理存储结构上,通过指针的方式记录元素的顺序关系。在硬件电路中,通常使用generate组建table,记录每个entry的内容,除了有效位和Payload之外,还有link_next,可能有head/tail信息。head/tail信息也可以基于队列进行单独记录,而不在table体现。
使用NEXT标注链表下一个元素的位置,也即table对应的标号。
2 链表操作
使用链表作为队列管理电路,涉及到的逻辑主要包括查找空闲entry、添加元素和释放元素,其中添加和释放需要先获取tail或head位置。除此之外,如有其余需求,则做相应处理。
2.1 查找entry
新请求输入至队列,可放置在table任意位置,需查找到一个空闲的entry添加该请求至相关队列的尾部。一般来说,从table的index 0处开始查找,获取第一个可用的entry,使用如下代码。
always @* begin : gen_idle_entry
integer i;
reg flag;
flag = 1'b0;
for ( i = 0; i < TABLE_DEPTH; i = i + 1) begin
if (~flag & table_entry_idle[i]) begin
table_entry_sel[i] = 1'b1;
flag = 1'b0;
end else begin
table_entry_sel[i] = 1'b0;
end
end
end
在Spinal Lib提供了一种简洁的方式,可以参考一下。
def first[T <: Data](that : T) : T = new Composite(that, "ohFirst"){
val input = that.asBits.asUInt
val masked = input & ~(input - 1)
val value = cloneOf(that)
value.assignFromBits(masked.asBits)
}.value
除此之外,还可以用其余的查找方式,其实就是一种调度逻辑,如定义一个计数器,需要查找时则计数,检查对应entry是否空闲,但速度较慢。
2.2 添加元素
完成entry查找后,有两方面内容,一是将请求保存至entry内,二是更新上一元素的NEXT信号。
将请求保存至entry,较为简单,根据查找逻辑得到的sel信号选择对应entry,更新其内容及其head/tail信号。
更新上一元素的NEXT信号及其head/tail信号。若table内采用了tail信号,相关处理代码如下,其中table_next信号可使用不带复位的寄存器。
assign table_next_update[index] = table_valid[index] & table_tail[index] & (table_queue_id[index] == req_queue_id);
always @ (posedge clk) if (table_next_update[index]) begin
table_next[index] <= req_table_entry;
end
always @ (posedge clk or negedge rst_n) begin
if(~rst_n)
table_tail[index] <= 1'b0;
else if (table_next_update[index])
table_tail[index] <= 1'b0;
else if (req_table_sel[index])
table_tail[index] <= 1'b1;
end
2.3 释放元素
链表通常用于记录操作的先后顺序,tail添加,head释放;但也有用于管理credit的场景,tail添加,也在tail释放。
在链表的head释放,主要需要完成两个操作,一是释放Entry,对valid进行清零,二是head信号传递。首先,通过调度逻辑选取需释放的链表Entry,获取其NEXT信号,并将其valid信号进行清零;然后,将NEXT所指向Entry的head信号进行置位。若存在时序紧张,可将NEXT信号进行打拍,但要注意valid清零与head置位是否存在功能时序要求。
在链表的tail释放,是类似的,区别是需置位tail信号,基于释放的Entry匹配获取链表的上一元素,代码如下。对于这一场景,也可以考虑使用逆向链表,释放逻辑就跟上面的head释放是类似的了,但添加元素会有所区别。插入一个问题,存在双向链表的数据结构,但从硬件来看,其实没有必要,或者说硬件链表就是双向链表,一侧使用NEXT标识,另一侧使用NEXT匹配。
always @ (posedge clk or negedge rst_n) begin
if (~rst_n)
table_tail[index] <= 1'b0;
else if (table_next_update[index]) // new entry added to tail
table_tail[index] <= 1'b0;
else if (req_table_sel[index]) // added as new entry
table_tail[index] <= 1'b1;
else if (release_grant & (table_next[index] == release_index))
table_tail[index] <= 1'b1;
end
3 链表应用
链表的特征是在无序的结构内记录先后顺序关系。理论来说,所有队列管理电路都可以使用链表实现,但其需要一些逻辑开销,并不适用于所有场景。个人理解,存在多个队列和乱序释放的场景,可以考虑使用链表。
只有单个队列,直接使用FIFO就足够了;顺序释放元素,其存储结构相当于是顺序释放的,使用链表的必要性也不大。在乱序释放的场景中,必定会存在离散的空闲Entry,若需要提高table的利用率,链表则是一个较优解。
举一个链表使用的典型例子。AXI接口的不同ID存在乱序返回的场景,发送至不同目的侧的延时存在差别,上游存在多个源头,且不同源头又期望顺序返回响应。需要使用数据结构记录ID的状态,可以使用链表维护ID的使用及其先后关系。新发出操作先从table获取一个空闲的Entry,也即添加元素,使用该Entry的Index作为ID,基于源头建立链表关系;操作返回后,则按顺序释放Entry,返回响应。当然,若仅存在一个源头,或上游也无需顺序返回响应,使用链表的意义不大。
4 类FIFO的伪链表
考虑一个场景,需要较大数量的Entry,如256,实现链表结构,可想而知,维护该链表所需的资源。另外,链表管理逻辑、Entry Payload 选取需要的MUX逻辑等等,在后端实现时,还可能出现Congestion风险。
简单的做法就是,将较大数量的Entry进行分组,组内按顺序使用,不同组之间则使用链表NEXT指针。如将256分为16组,每组16个Entry,不同组之间基于链表维护,组内Entry使用则按顺序使用,同一组的16个Entry空闲或其空闲数量满足要求后,可再分配使用。基于每组16个Entry进行逻辑处理,不同组之间可添加寄存器打拍等等,降低Congestion风险。
之前在做IP开发时,TLP转AXI操作涉及到的ID分配和维护,就采用了这种伪链表的形式。每组32个Entry,例化了很多组,实现了AXI接口较大的Outstanding数量。将TLP转化为AXI操作,需将较大的TLP报文切分为多个AXI操作,每个TLP报文作为一个队列,由于TLP报文最大为4KByte,一个队列最多只存在32个元素,多组之间就不再需要NEXT指针进行维护了。每组内进行空闲Entry的搜索,找出连续且可用的最大数量的Entry,再供TLP转换AXI时使用。
这一结构相对简单,存在的问题是会降低利用率。但是,从另一方面来看,在系统中,乱序是小概率的,顺序是常见的,使用这一结构可以达到期望的功能,在PPA方面也是较优解。
5 小结
以上两篇文章仅是简单罗列了在过往工作中涉及到的队列管理逻辑,未必全面,希望能对大家有一些启发。
原作者:芯工阿文