本文将比较x86和ARM的memory ordering的差异。

本文讨论 AArch64(Armv8-A/Armv9-A)上普通 CPU 之间的硬件 Memory Ordering。示例假定访问的是可共享、cacheable 的 Normal Memory,访问自然对齐且单次访问自身不撕裂。对照表中的不同地址访问还假定没有地址依赖、数据依赖、Acquire、Release 或 Barrier。本文不讨论编译器重排、C/C++ data race、MMIO、DMA、Device Memory、非对齐访问和 mixed-size 访问。

x86 与 ARM 的核心差异可以先浓缩成一句话:

x86 对普通内存访问提供较强的隐含顺序,主要放松不同地址的 Store→Load;Arm 对普通、独立、跨地址访问提供的隐含顺序更少,程序需要通过 Acquire、Release 或 Barrier 明确建立所需顺序。

Prerequisite

浅谈x86的TSO memory ordering

ARM 的基本模型:Weakly Ordered

AArch64 对普通 LDRSTR 提供的是较弱的顺序保证。

对于不同地址、没有依赖关系的普通访问,可以先用下面这个入门模型理解:

较老操作 → 较新操作 x86 ARM 普通 LDR/STR
Load → Load 保持 可能表现为重排
Load → Store 保持 可能表现为重排
Store → Store 保持 可能表现为重排
Store → Load 不同地址时可能重排;同地址不能读回旧值 不同地址时可能表现为重排

这个表是便于建立直觉的简化模型。

Arm对其模型的官方概括是:

Arm 是弱顺序架构,允许内存访问以不同于程序顺序的方式被观察和完成。

与此同时,同一位置的写仍然被序列化;弱顺序并不意味着同一变量可以毫无规则地变化。

Message Passing

初始状态:

1
2
data = 0 // 数据
flag = 0 // ready flag

生产者:

1
2
3
4
CPU 0

data = 42;
flag = 1;

消费者:

1
2
3
4
CPU 1

r1 = flag;
r2 = data;

我们关心:

1
2
r1 == 1
r2 == 0

也就是:

消费者已经看见”数据准备完成”的 flag,却仍然读到旧 data。

x86 为什么禁止这个结果?

x86 保持:

1
Store → Store

因此 CPU 0 对外不能表现为:

1
2
3
flag = 1
先于
data = 42

x86 也保持:

1
Load → Load

因此 CPU 1 对外不能表现为:

1
2
先读 data
后读 flag

所以在普通 x86 WB 内存的机器指令层面:

1
flag == 1 && data == 0

是不允许的。

ARM 为什么可能允许?

ARM 对普通独立访问不自动保留这两条跨地址顺序:

1
2
3
4
5
6
7
8
9
10
11
CPU 0:

data = 42
↓ ARM 普通 store 不保证这一跨地址顺序
flag = 1

CPU 1:

读取 flag
↓ ARM 普通 load 不保证这一跨地址顺序
读取 data

Acquire 和 Release 是怎么解决问题的?

正确的 ARM 版本可以写成:

1
2
3
4
5
6
7
8
9
生产者:

写 data = 42
Store-Release flag = 1

消费者:

Load-Acquire flag
读取 data

Release:挡住前面的操作

Release store 的含义是:

1
2
3
release 之前的 load/store

必须排在 release store 之前

图示:

1
2
3
之前的访问  ──────>  Store-Release

│ 后面的访问仍可能向前移动

因此:

1
2
data = 42
STLR flag = 1

STLR 保证的是 Producer 侧此前对 data 的访问排在对 flag 的 release store 之前:

1
2
3
写 data

STLR flag = 1

它只解决发布端的顺序,并不能单独约束 Consumer。Consumer 如果仍使用两个普通 LDR,对 data 的读取仍可能在观察 flag 之前就已被满足,因此 release 单独使用仍不能排除 Message Passing 的坏结果。在本文采用的 release/acquire 方案中,Consumer 还需要通过 LDAR 观察这个 flag,再读取 data

但 release 是单向的:

1
2
Store-Release
后面的访问

后面的访问并不一定被 release 阻挡。

Acquire:挡住后面的操作

Acquire load 的含义是:

1
2
3
Load-Acquire

后面的 load/store 必须排在 acquire 之后

图示:

1
2
3
4
之前的访问可能越过


Load-Acquire ──────> 后面的访问

所以:

1
2
LDAR flag
读取 data

不允许对 data 的读取越过 acquire,跑到读取 flag 之前。

Acquire/Release 提供单向排序语义,而不是 Full Barrier。可以把它们直观地理解为单向的 ordering primitive:Acquire 约束该访问之后的操作,Release 约束该访问之前的操作。

要建立跨 CPU 的发布关系,LDAR 必须观察到 STLR 发布的值

当 Consumer 的 LDAR 读取到 Producer 的 STLR 写入的 flag == 1 时,Store-Release 和 Load-Acquire 会将以下顺序连接起来:

1
2
3
4
5
6
7
8
9
10
11
Producer 写 data


Producer STLR flag = 1

│ Consumer 的 LDAR 观察到该值

Consumer LDAR flag == 1


Consumer 读 data

因此,在这个 AArch64 Message Passing 测试中:

1
flag == 1 && data == 0

的执行被禁止。

C/C++ 原子内存模型中也有对应的 release/acquire 发布模式,但那属于语言层内存模型,本文不展开。

为什么生产者和消费者两边都要有顺序?

只有 release:

1
2
生产者:data = 42; store_release(flag, 1);
消费者:load_relaxed(flag); load_relaxed(data);

生产者的发布顺序正确,但消费者仍可能把 data 的 load 提前。

只有 acquire:

1
2
生产者:store_relaxed(data, 42); store_relaxed(flag, 1);
消费者:load_acquire(flag); load_relaxed(data);

消费者顺序正确,但生产者可能先让 flag 可见。

所以典型发布关系需要:

1
2
生产者 release
消费者 acquire

总结

x86 TSO

可以把 x86 想成:

1
2
3
4
5
每个 CPU 都有一个 FIFO store buffer

普通 load 可以绕过其中较老的、不同地址的 store

除此之外,大部分程序顺序都保留

因此最主要的硬件弱点是:

1
Store → Load

ARM Weak Ordering

可以把 ARM 想成:

1
2
3
4
5
6
普通 LDR/STR 对独立跨地址访问提供的隐含顺序较少

跨不同地址的顺序默认不代表同步关系

需要时由程序通过 acquire/release/barrier
明确告诉CPU保留哪些顺序

ARM 并不是”随机执行”,它仍然具备:

  • 单线程语义;
  • 同地址 coherence;
  • 原子操作;
  • 依赖关系约束;
  • acquire/release;
  • barrier;
  • 正式定义的 allowed/forbidden executions。

它只是比 x86 给硬件留出了更大的优化空间。

记忆图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
x86 TSO

├── Load → Load 保留
├── Load → Store 保留
├── Store → Store 保留
└── Store → Load 不同地址时可能放松


ARM 普通、独立、跨地址访问

├── Load → Load 不保证
├── Load → Store 不保证
├── Store → Store 不保证
└── Store → Load 不同地址时不保证

同地址访问仍受到单地址 coherence、读己之写和相关架构规则约束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Coherence:

    同一个地址的写,大家按什么顺序看见

Ordering:

    不同地址的访问,大家按什么顺序看见

Atomicity:

    单次操作是否不可分割

Acquire:

    后面的访问不能跑到前面

Release:

    前面的访问不能跑到后面

Full barrier:

    前面的访问全部排在后面的访问之前

x86:

    大部分顺序默认存在,只主要放松 Store→Load

ARM:

    普通访问默认较弱,需要显式表达必要顺序

参考资料:

  1. Intel 64 and IA-32 Architectures Software Developer’s Manual,Volume 3A
  2. Arm Architecture Reference Manual for A-profile architecture
  3. How to generate litmus tests automatically with the diy7 tool
  4. DPDK Optimization on Arm