C++ 原子和 memory_order 与 RDMA

C++ atomics and memory_order with RDMA

在现代内存上使用单侧 RDMA 时,无锁,如果数据对象跨越多个缓存行,就会出现远程 reader 如何安全地查看其传入数据的问题。

在 Derecho 开源多播和复制日志库(https://GitHub.com/Derecho-Project)中,我们有这种模式。写入器 W 被授予写入 reader, R 中的内存范围的权限。内存已正确固定和映射。现在,假设写入涉及某种跨越许多缓存行的数据向量,这很常见。我们使用一个守卫:一个递增的计数器(也在 RDMA 可访问内存中,但在其他一些缓存行中)。 R 旋转,观察计数器……当它看到变化时,这会告诉 R“你有一条新消息”,然后 R 读取向量中的数据。后来我们有第二种模式,R 对 W 说,“我已经处理完那条消息了,你可以再发送一条。”

我的问题:对于现代内存模型,应将哪种 C++ 原子风格用于要写入向量的内存?这会被表示为宽松的一致性吗?我希望我的代码适用于 ARM 和 AMD,而不仅仅是具有强大 TSO 内存模型的英特尔。

那么对于我的计数器,当 R 旋转以等待计数器更新时,我希望如何声明计数器?是否需要将其声明为获取-释放原子?

最后,在 R 观察到计数器增加后,就速度或正确性而言,将所有内容都声明为宽松的,然后在此处使用 memory_order 栅栏是否有任何优点?我的想法是,使用第二种方法,我在所有 RDMA 内存上使用最小一致性模型(以及所有此类内存的相同模型),而且我只需要在计数器是后调用成本更高的 memory_order 栅栏观察到增加。所以它只发生一次,在访问我的向量之前,而每次我的轮询线程循环时,获取释放原子计数器都会触发内存防护机制。对我来说,这听起来非常昂贵。

最后的想法引出了另一个问题:我是否也必须将此内存声明为易失性的,以便 C- 编译器意识到数据可以在其脚下更改,或者编译器本身可以看到std::atomic 类型声明?在 Intel 上,对于总存储排序,TSO 加上 volatile 是肯定需要的。

[编辑:新信息](我想在这里吸引一些帮助!)

一个选项似乎是将 RDMA 内存区域声明为 std::atomic 但是每次我们的谓词评估线程重新测试守卫时都使用锁(在 RDMA 中内存,将使用相同的宽松 属性 声明)。我们将保留 C++ volatile 注释。

原因是使用具有获取-释放语义的锁,内存一致性硬件将被警告它需要屏蔽先前的更新。锁本身(互斥量)可以在谓词线程的本地声明,然后将存在于本地 DRAM 中,这很便宜,并且由于这不是任何东西都争用的锁,因此锁定它可能与 test_and_set,而解锁只是写入0。如果谓词为真,我们触发的代码体是在访问锁之后(可能是在释放锁之后)运行ning,所以我们建立需要的顺序确保硬件将使用实际内存读取来获取受保护的对象。但是通过我们的谓词测试的每个循环——每个“旋转”——我们最终都会对每个谓词进行锁定 acquire/release。所以这会导致一些减速。

选项二,看似开销较小,也将 RDMA 区域声明为 std::atomic,具有宽松的一致性,但省略了锁并像我们现在所做的那样进行测试。然后,当谓词测试为真时,我们将执行具有语义的显式内存栅栏 (std::memory-order)。我们得到相同的障碍,但只在谓词评估为真时支付成本,因此开销更少。

但现在我们 运行 进入了一个不同类型的问题。 Intel 有总存储顺序 TSO,并且因为任何线程都执行一些先写后读操作,Intel 可能出于预防而被迫从内存中获取保护变量,担心 TSO 可能会被违反。具有 volatile 的 C++ 肯定会包含 fetch 指令。但是在 ARM 和 AMD 上,硬件本身是否有可能在硬件寄存器或其他东西中存储一些保护变量很长时间,从而导致我们的“类自旋”循环出现极度延迟?对 ARM 和 AMD 一无所知,这似乎是一种担忧。但也许你们中有人知道的比我多得多?

嗯,目前似乎缺乏这方面的专业知识。可能 std::atomics 选项的新颖性以及 ARM 和 AMD 将如何实现宽松一致性的普遍不确定性让人们很难知道答案,推测也无济于事。

据我了解,正确答案似乎是:

  1. 由于英特尔的 TSO(商店总订单)政策,整个问题不会在英特尔上出现。使用 TSO,因为守卫在它守卫的向量之后更新,因此在任何总存储顺序中,守卫最后更新。看到守卫的变化可以保证接收者将看到更新的矢量元素。此外,AMD 和 ARM 上的默认设置可能会模仿 TSO。
  2. 通过显式声明 RDMA 内存区域具有 relaxed_consistency,开发人员选择了更便宜的内存模型,但承担了插入内存栅栏的义务。最明显的方法是在读取守卫之前获取锁,然后在读取之后释放锁。即使没有其他线程争用锁,这也会产生成本。首先,锁定操作本身需要几个时钟周期。但更广泛地说,锁定一个随机互斥锁会对缓存产生一些未知的影响,因为硬件必须假定锁实际上是在争用的,可能已经发生了等待,并且它脚下的值可能已经改变。 这将导致需要量化的成本。
  3. 等价地,守卫可以声明使用acquire_release一致性。看起来,这会创建一个内存栅栏,并且用于写入向量的先前更新将对任何看到保护值更改的 reader 可见。同样,成本需要量化。
  4. 也许,可以在由谓词触发的代码块的顶部进行围栏读取。这将使栅栏脱离主谓词循环,因此栅栏的成本只会支付一次,并且仅在谓词实际为真时才支付。

我们还需要在 C++ 中将原子标记为 volatile。事实上,C++ 可能应该注意到何时访问 std::atomic 类型,并将其视为对 volatile 的访问。但是,目前 C++ 编译器是否正在实施此策略并不明显。