访存实现漫谈 — 龙芯杯参赛笔记

笔者在暑假参加了今年的龙芯杯竞赛,然后三等奖遗憾跑路了。虽然奖项上不尽如人意,但是也确实是有很多很多工作做的不好,在此处整理一些 LA32R 访存系统相关和参赛的思路,留作记录。

0 我们有什么条件

硬件上:

  • LUT 限制
  • BRAM 限制
  • 时序限制

软件上:

  • LA32R 使用指令维护数据缓存和指令缓存的一致性
  • TLB 理论上需要全相连实现

LUT 时序和 BRAM 时序我在做的时候一般也叫面积限制。

实践上面积限制,时序限制和性能限制会是三个几乎正交方向,三者间的 trade-off 乃至于和后端(特别是发射,写回部分)与前端(特别是刷回部分)的 trade-off 会是文中的主因。

一个很容易发生的错误是认为模块间的耦合度不高,事实上在硬件的限制下,理想的抽象很难存在,因此常常会有妥协抽象以满足限制的情况。特别是和前后端的交互往往会对两边产生较大影响,往往也会耦合的越高。

1 访存类型

在报告时我没有谈及 uncache 部分,因为给定的报告时间很有限。这里可以多说几句。

首先我们会分成访(load)和存(store)两部分,再这基础上还有一致可缓存和强序非缓存的区别。原子访存较为特殊,可以和上面两者并列。剩下的部分则是缓存一致性操作(cacop/*bar)。

因此我们可以这么讨论:

  • uncache load/store
  • cache load/store
  • atomic load/store

2 如何超标量

访存由于要和外界交互,因此自然会面临最多的不确定性,也就更难进行推测执行和多发射。但是如果既不推测执行,也不做多发射,那将会成为非常棘手的瓶颈。(试想一下,隔几条指令就要等一次 AXI 交互。)

对于储存器只有一个读写口的问题有比较通行的解决方案,使用 bank 就可以了。另一个比较棘手的问题则是 store 指令如何推测执行。

store 的后效性极大,因为该指令一旦执行就会影响 cache 中乃至下级储存器的状态,撤回代价很高。一个自然的思路就是推测执行的 store 不会真的执行而是放在 buffer 中,通过编号确定他会不会影响后续的 load 指令,直到后端确定该指令被执行为止。

但是如果是乱序发射,这样做还是会有更多问题。假设 B 为 load 指令,A 为 store 指令,B 在 A 之后且 B 先于 A 被(乱序)发射给访存模块,该指令将有可能读取到错误的结果,而访存系统无从判断。

我有去翻过香山的实现,他们在此引入的预测器和刷回机制,这毋庸质疑会带来巨大的面积和时序开销。参赛所提供的板子的板上资源非常有限,我认为这是不可能做到的。

另一个办法其实就是委曲求全,放弃 store 的乱序执行,让 load 部分乱序。也就是通过发射时额外保证来避免不会发生上文的情况。这也是我们选择的方法。

如果你足够灵敏,应该会发现这里有个很尴尬的事情。由于分 bank,访存模块内部会有一个排队机制,同样的,后端也有一个用于保证发射顺序的排队机制。这很没有道理,但是当我们注意到这里已经为时已晚,我们只能在此基础上修修补补。

3 Uncache

uncache 的棘手在于两个:

  1. 在地址翻译之前,无法区分 uncache 和 cache 指令。
  2. uncache 必须要在其确定被执行时才被执行。因为乱序和推测都有可能破坏「强序非缓存」的保证,换句话说,uncache 指令的作用往往无法被撤回。

在我的实现中,将 uncache 并入正常访存的流水几乎不可能(因为两次请求合并会抹除太多信息)。只能在发现 uncache 指令后另如 buffer 等待其被确定执行。

在我们的后端实现中,一个指令要先被写回才能确定其被执行。这很矛盾,因为不可能在没有执行的时候写回。我们采取了一个很 dirty 的做法,对于访存指令,允许发送两次写回,一次写回标记其为 uncache,另一次则是真正的写回工作。

4 cache/atomic

4.1 存指令的请求合并

存指令的特殊性不仅在上文提到的巨大后效性,更在于其是不需要有意义的写回的(即影响寄存器)。

另一个事实则是,执行写请求会影响性能,因为要保持写请求的请求一致性。

我们可以合并确定执行的写请求。实践上,对同一片区域的写入很容易重复出现,这么做会获得很大的提升。

4.2 atomic

标准里声称对 uncache 地址的 atomic 操作是未定义行为,所以我们只需要考虑 cached atomic。

如果你足够投机取巧,会发现不需要一对一实现手册中对 atomic 锁的要求。因为你大可以声称所有的写请求都会更改 cache 状态因而需要撤回锁,所以只需要在有写的时候解锁即可。

另一个和普通执行不同的是,原子存也是需要修改寄存器的。我们的实现中把原子存同时看作为 load 和 store 指令,在两条流水线同时执行。

4.3 Cache Hit

hit 是一个很理想的情况。对于替换请求,hit 后即刻上全局锁,下一周期替换即可。对于读请求则更为简单,直接向下转发结果即可

5 Cache Miss

缓存失命中的代价是巨大的,因此也有必要单列一章来聊聊缓存失命中的代价。

5.1 MSHR

Miss Status Handling Register

这个名字比较高大上,其实还是请求合并的思路的。

所有 miss 的请求都会转发给 MSHR。

我们的实现将 MSHR 的 Tag 部分和 Data 部分切为两极,来缓解时序压力。

Tag 部分用于将地址和 id 对应,Data 部分则记录每一个请求的具体地址及其结果(如果获取到了)。

Tag 部分将会根据地址从下级储存器读一整个行,并将结果缓存在 Tag 部分(避免多次请求,更避免一个地址对应多个 id),同时发给 Data。

5.2 Cache 替换

Data 部分收到数据后将会发起 cache 替换流程。基本的流程是

获取被替换行并上锁 -> 写回该行 -> 发送地址给存请求合并队列 -> 替换该行并解锁

这里和命中时共享同一个锁。

一个值得一提的细节是一开始这两种情况不会用同一个锁,我们通过锁等级来减少命中替换的代价,但是最终因为面积代价而放弃。

6 杂谈

6.1 关键路径

实践上最棘手的关键路径在两个部分:

  • 刷回
  • tag 比较

前者是刷回部分不适合切级,容易出现问题。后者则是根本无法切级,也缺乏优化空间。

一般的实现应该很难规避后者的代价,应当在设计初期就考虑令此部分单独一级。

6.2 异步的传播性

其实与其说异步,不如说是握手。

一般情况下握手自然要引入状态机,但是会发现在获取数据和发送数据两个阶段,整个是空闲的。

一个自然的思路的合并这两个阶段来介绍一个周期的代价,特别是对于 cache 请求,合并后可以做到 SRAM 行为。

但是也要注意到这么做会很容易产生关键路径,因为会将 ready 相关路径变得很长。

6.3 死锁

因为复杂的排序机制和极多的队列,很容易在某个极端的情况发生死锁。会发生这种情况一般来说就是和后端的沟通不到位或者是自己的实现时考量不全面。

话是这么说,但是毕竟是「极端情况」,没那么容易想到。因此也要做好和死锁问题缠斗的准备并考虑构建/随机生成测试来测出这种问题。

6.4 布线压力

因为你的代码最终还是要到物理世界。每一个 LUT 之间是要接线的,所以在面积上升的时候,LUT 间的线也大概率会变长。

也就是说,面积较大会给时许带来灾难性的负面影响。

7 结语

听说今年特等奖的队伍引入了 L2 缓存,感觉还是太厉害了,我们的时序和面积(这里特指 LUT)都很难引入了。而且新的缓存还有新的缓存一致性问题,恐怕不是经验老道的人都很难做到了吧(笑)。

其实到头来感觉队里的每个人都尽力了,但是在团队合作上可能还是有诸多问题,也希望如果有读到本篇博客的人要参赛,一定一定要注意队内的沟通,尽早合并代码。代码在合并之前你是不知道自己有多少「想当然」忽略掉的细节的。

闭幕式上有个老师说有些队,队内任务分配不均。其实不均可能是好事情,因为事实上,耦合度越高性能越可能更好,但是耦合度高的代码,多人维护的难度也会大于单人。这让我回想到了算法竞赛的时候,队内三人水平相当时一般是需要一些所谓「化学反应」才能发挥出 1+1 >= 2 的效果的,不然搞不好就是 1+1 ‹ 1 了。

不管如何,这个比赛的水平是一年比一年吓人了,希望以后能见到更多有趣的设计。

本文仅作记录,如有错误请多多指正。

2025/09/14 于 杭州电子科技大学

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

message
account_circle
Please input name.
email
Please input email address.
links

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理