From 6da22189da15aa949eb333fecc9a0a18aa590787 Mon Sep 17 00:00:00 2001 From: jbk <2634358021@qq.com> Date: Sat, 24 Dec 2022 21:20:22 +0800 Subject: [PATCH] add articles/20221224-riscv-memory-consistency-model.md --- ...20221224-riscv-memory-consistency-model.md | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 articles/20221224-riscv-memory-consistency-model.md diff --git a/articles/20221224-riscv-memory-consistency-model.md b/articles/20221224-riscv-memory-consistency-model.md new file mode 100644 index 0000000..f92886c --- /dev/null +++ b/articles/20221224-riscv-memory-consistency-model.md @@ -0,0 +1,239 @@ +> Corrector: [TinyCorrect](https://gitee.com/tinylab/tinycorrect) v0.1 - [pangu autocorrect epw]
+> Author: Kyrie jia bokai021013@gmail.com
+> Date: 2022/12/24
+> Revisor: Falcon falcon@tinylab.org
+> Project: [RISC-V Linux 内核剖析](https://gitee.com/tinylab/riscv-linux)
+> Proposal: [RISC-V Linux Synchronization 技术调研与分析](https://gitee.com/tinylab/riscv-linux/issues/I5MUAN)
+> Sponsor: PLCT Lab, ISCAS + +# RISC-V 架构内存一致性模型调研 + +## 前言 + +在进行单机并发编程操作时,计算机一般是通过内部互联总线,采用读写共享内存的方式完成对共享数据的访问。而为了提高处理性能,芯片厂商在硬件层面加了许多优化,并且不同的处理器架构采取的优化方法也不尽相同,这就导致可能会出现不同的内存乱序访问行为,因而会给软件程序员带来相当大的困扰。下面给出一个示例: + +```c +static int A = 0, B = 0, C = 0 ; +static void thread_cpu1(void) +{ + C = 1; + A = 1; +} +static void thread_cpu2(void) +{ + while(A == 0) ; + B = 1 ; +} +static void thread_cpu3(void) +{ + while(B == 0) ; + printf("A = %d, C = %d\n", A, B); +} + +``` + +如上所示,三个处理器(P1,P2,P3)共享 A,B,C 三个初始化为 0 的变量,那么最终的输出结果会是什么呢?可能大多数人心中的答案是**A == 1 && C == 1**,这是最符合直觉的答案,但其实**A == 0 && C == 1,A == 1 && C == 0,A == 1 && C == 0**,这些答案都是有可能的。而这就跟处理器之间是否能**看见**变量的最新修改值有关了。 + +> **看见**:当一个处理器(PA)对共享变量(V)的本地副本进行了修改,而另一个处理器(PB)用于保存相应变量的缓存行因此而失效或被更新,则称 PB 看见了 PA 对变量 V 的修改 + +以 **A == 0 && C == 1** 为例,当 P1 上的赋值语句 C = 1 及 A = 1 完成之后,P2 看见 P1 对 A 的修改,从而跳出循环并执行 B = 1,接下来 P3 看见 P2 对 B 的修改,从而跳出循环并打印 A,C。在打印 A,C 时,存在这样的可能,也即 P3 看见了 P1 对 C 的修改,而没看见 P1 对 A 的修改,从而打印出 **A == 0 && C == 1**。 + +也就是说,当你以为 C = 1 执行完之后,所有处理器都应该看见 C 的最新值,但实际可能并不是这样,有的处理器确实看不到最新值导致结果和预期不一样。而内存一致性模型就是为了解决这个问题的。 + +## 内存一致性模型简介 + +内存一致性模型(Memory Consistency Model)是用来描述多处理器(多线程)对共享存储器访问行为的规范,在不同的内存一致性模型里,多线程对共享存储器的访问行为有非常大的差别。这些差别会严重影响程序的执行逻辑,甚至会造成软件逻辑问题。 + +### 内存一致性模型分类 + +内存一致性关注对内存的读写操作访问,有此可得到 4 种不同的指令组合,依次为 Store-Load、Store-Store、Load-Store、Load-Load,通过允许调整这些指令组合执行的顺序,可以获得不同的内存一致性模型。目前有多种内存一致性模型,从上到下模型的限制由强到弱,依次为: + +- 顺序一致性(Sequential Consistency,简写 SC)模型 +- 完全存储定序(Total Store Order,简写 TSO)模型 +- 部分存储定序(Part Store Order,简写 PSO)模型 +- 宽松存储(Relax Memory Order,简写 RMO)模型 + +#### SC 模型 + +SC 模型也称为强定序模型,CPU 按照程序代码的指令次序来执行所有的 Store 与 Load 指令,并且从其它 CPU 和内存的角度来看,感知到的数据变化也完全是按照指令执行的次序。在这种模型下,程序不需要使用任何内存屏障,但是性能会比较差。为了提升系统的性能,不同处理器架构或多或少对这种强一致性模型进行了放松,允许对某些指令组合进行重排序。 + +> **重排序**:是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。 + +#### TSO 模型 + +TSO 模型允许对 Store-Load 指令组合进行重排序,即如果第一条指令是 Store 指令,第二条指令是 Load 指令,那么在程序执行时,Load 指令可能先于 Store 指令执行。TSO 模型的硬件实现,通常是在 CPU 和 Cache 之间引入了 Store Buffer,Store Buffer 只有 Store(即内存写)指令会使用到,并且 Store Buffer 以 FIFO(先进先出)的方式处理写入的数据。这种情况下,Store-Store 指令组合仍能按照顺序执行,但 Load 指令则可能会先于 Store 指令完成执行。x86 架构采用的就是 TSO 模型。 + +#### PSO 模型 + +PSO 模型是在 TSO 模型的基础上,进一步放宽内存访问限制,允许对 Store-Store 指令组合进行重排序。从硬件实现的角度来看,就是允许了 CPU 以非 FIFO 的方式处理 Store Buffer 中的指令。 + +#### RMO 模型 + +RMO 模型是最为宽松的内存一致性模型,它在 PSO 模型的基础上进一步放宽了访存指令的执行限制,其不仅允许 Store-Load、Store-Store 指令组合重排序,还进一步允许了 Load-Load、Load-Store 指令组合重排序,只要是地址无关的指令,在读写访问的时候都可以打乱所有 load/store 的顺序。而我们接下来要研究的 RISC-V 架构采用的就是 RMO 模型。 + +## RISC-V 内存一致性模型 + +2015 年左右,普林斯顿团队发现 RISC-V 最初采用的内存一致性规范存在一定的缺陷,自 2015 年 12 月起,RISC-V 基金会一直与普林斯顿团队以及其他 MCM 专家合作,帮助加强 RISC-V ISA 规范。官方于 2019 年发布的 [《The RISC-V Instruction Set Manual, Volume I: User-Level ISA》][001] 明确了 RISC-V 的内存一致性模型,将其称为 “RVWMO” (RISC-V Weak Memory Ordering)。 + +RVWMO 主要遵循 RMO 模型,旨在提供可以灵活地构建高性能可扩展程序的设计,同时提供一个易于处理的编程模型,禁止过于复杂的重排序情况,方便软件程序员的开发利用。RVWMO 明确了 3 条公理,规范了常规内存的一致性要求,但仍没有规范 I/O 内存,指令抓取,页表遍历等行为。下面我们来看一下官方的定义: + +### 定义 + +RVWMO 内存模型是根据全局内存序(global memory order)定义的,即所有 hart 产生的内存操作的总顺序。一般来说,多线程程序有许多种可能的执行顺序,每个执行顺序都有自己相应的全局内存序,规定明确指出任何满足所有内存模型约束的执行都是合法的执行(就内存模型而言)。 + +> RISC-V 用 hart(hardware thread)表示硬件执行线程或执行单元 +> +> **全局序**即所有并发线程在内存系统中形成的最终内存访问顺序,各个线程对这个**全局序**的观察都是一致的(除了 store buffer 带来的“写后读”情况) + +#### 内存模型规则 + +内存操作的程序序(program order)反映了生成每次加载和存储的指令在该 hart 的动态指令流中的逻辑布局顺序;内存访问指令会生成内存操作,即 load/store/load&&store 操作。对于这些操作集合,RVWMO 做出了这些规定: + +##### 遵守拷贝原子性 + +RVWMO 通过**全局序**定义内存访问顺序约束,对于不允许重排序的情况称为**保留程序序**(preserved program order,PPO)。store buffer 是 hart 私有缓存,用于暂时存储要写入内存系统的数据,这里的数据对本 hart 可见,即写后再读可以读到这个写入的值,但对其它 hart 不可见(读不到写入的最新值),也因此双方可能观察到不一样的访问顺序。 + +因此 RVWMO 为了形成一致的全局序,规定一个 hart 如果**看见**另一个 hart 的写,则必须所有的 hart 都**看见**这个写,否则会出现不一致(实际硬件实现并不一定有这种“同时**看见**”时间保证)。这个特性称为拷贝原子性。 + +##### 存在保留程序序 + +保留程序序的完整定义是:如果内存操作 a,b 都是常规主存访问(非 I/O 访问),且在程序序中 a 领先于 b,并且满足以下任意一项条件,即满足在保留程序序中,a 领先于 b。 + +- 地址重叠约束 +- 显式同步 +- 语法依赖 +- 流水线依赖 + +下面分别介绍一下上述条件: + +###### 地址重叠约束 + +对于指令地址部分交叉重叠,规则约束的是这些重叠的部分,第一个原则是:**写不超前**,即对于 store 指令来说,在全局序上不能超越前面的指令,否则前面读到或者写入的值就会混乱。 + +第二个原则是:**读 - 读一致性**,对于同一地址的读操作来说,只要后一个读不到更老的值,就不约束两者内存序,也就是说仅当这两个读之间没有对同一地址的写操作且返回不同值时(读到了不同的写),才要保证读的顺序。 + +第三个原则是:**原子操作不乱序**,原子指令因为含有 store 操作,因此当其位于程序序的后面时不会超越前面的指令。当其位于前面时,如果后面的是 store 指令不会乱序,如果是 load 指令,规范明确不允许乱序,主要是为了保证原子指令的操作语义。需要注意的是,对于 LR/SC 原子指令对,成功的 SC 才代表这个原子指令的执行,失败的 SC 不产生任何内存操作,自然也不对内存序约束产生任何贡献。 + +###### 显式同步 + +RISC-V 中定义了一个 FENCE 指令用于提供相同硬件线程上写指令内存与指令获取之间的显式同步,约束了可以乱序执行的指令范围,一般来说,fence 指令前后的指令不能交换顺序。 + +``` +格式:fence [iorw], [iorw] + +逗号前后的 iorw 分别表示 fence 指令要约束的前后指令的类型,i 表示设备输入,o 表示设备输出,r 表示内存读,w 表示内存写。 +``` + +对于常规内存访问规范只推荐了 5 种组合: + +- FENCE RW,RW +- FENCE RW,W +- FENCE R,RW +- FENCE R,R +- FENCE W,W + +当需要跨越内存种类明确约束访问顺序时,只能使用 fence 指令。特别地,访问 time、cycle、mcycle 控制状态寄存器(CSR)时可能需要 fence 指令,因为 CSRs 通常为弱内存序的内存映射 I/O 单元,与常规内存也无必然的顺序约束;在使用时用 i 表示 CSR 读,o 表示 CSR 写。 + +###### 语法依赖 + +语法依赖(syntactic dependency)是指一条指令的源操作数与前面指令(不一定紧邻)的目的操作数是同一个寄存器,前面不出结果后面没法执行,这种逻辑上的限制“自然而然”地约束指令顺序。 + +这里需要与语义依赖区别开,语法依赖是同一寄存器的限制,而语义依赖是变量值的依赖;并且不是所有指令都有目的操作数和源操作数的,因此没有指令会语法依赖一条 store 语句,因为它没有目的操作数,也没有一条 load 指令语法依赖其他指令,因为它没有源操作数。下面给出一个示例: + +```assembly +(a) ld a1,0(s0) +(b) xor a2,a1,a1 +(c) add s1,s1,a2 +(d) ld a5,0(s1) +``` + +这里(b)指令的源操作数在 a1 寄存器里,而(a)指令的目的操作数也在 a1 寄存器里,所以(b)指令语法依赖(a)指令,同理,(c) 语法依赖 (b),(d) 语法依赖 (c),这个指令序列自上而下地构成了一个依赖链,因此两条不相关的内存读指令(a)和 (d),被强制地保证了执行顺序。 + +对于内存访问操作,语法依赖按寄存器用途分为三类并保证有序: + +- **地址依赖**。前一条指令的结果是后一条访存指令的操作地址。如:`lw t1, (s0); lw t2, (t1)`。 + +- **数据依赖**。前一条指令的结果是后一条指令的操作数。如:`lw t1, (s0); sw t1, (s1)`。 + +- **控制依赖**。两条指令间存在一个依赖于第一条指令的分支或间接跳转指令。用 C 语言的 if 语句比拟:条件语句对后续的所有指令(包括语句块之外的指令)构成控制依赖。但 RVWMO 仅保证对后续的 store 指令有序。如下所示: + + ``` + (a) lw x1,0(x2) + (b) bne x1,x0,next + (c) next: sw x3,0(x4) + ``` + + (b) 依赖于(a)且是一个分支跳转语句,因此使(a)和 (c) 语句执行有序,这就是控制依赖。 + +###### 流水线依赖 + +如果多条指令之间可能存在依赖关系,那么它们不能随意地同时在处理器的多个流水线单元上同时执行,这称为指令流水线依赖,也就是说要保证指令流水线的执行有序。如下所示: + +``` + 代码 1 代码 2 代码 3 代码 4 +------------------------------------------------------------- +(a) lw t0, (s0) lw t0, (s0) lw, t0, (s0) lw, t0, (s0) +(b) sw t1, (t0) sw t0, (t1) sw, t1, (t0) lw, t1, (t0) +(c) lw t2, (t0) lw t2, (t1) sw, t2, (s1) sw, t2, (s1) +``` + +这四段特殊的代码范例都是前两条指令构成语法依赖,第三条指令进行相关的读或不知是否相关的写,RISC-V 根据“几乎所有真实 CPU 流水线执行机构的行为”,将这种范例中(a)和 (c) 的关系称为**流水线依赖**,并明确规定不能乱序。约束代码 1 和 2 的出发点是“在写地址或值未知时不能读这个写”,即(b)地址或值未确定时(c)不能执行,又因为(b)地址或数据依赖 (a),因此(c)在**全局序**上不能超越 (a)。约束代码 3 和 4 的出发点是“前面地址未知时不能写”,即(b)地址未确定时(c)不能执行,以防止地址冲突,又因为(b)地址依赖 (a),因此(c)在全局序上也不能超越 (a)。也就是说这个流水线必须保证指令有序执行。 + +##### 提供内存模型原语 + +RVWMO 指令集为加载(load)和存储(store)等内存操作提供了一组内存模型原语,用于对相关指令进行标注,其实也算是显式同步的一种方法。这里原语如下: + +``` + 与处理器一致 顺序一致 + 加载 acquire-RCpc acquire-RCsc + 释放 release-RCpc release-RCsc +``` + +同时标准扩展 A(atomic) 也提供了原子操作指令,用于构建线程同步操作,同时提供了可选的单向内存序约束标记如下: + +- .aq,约束为 acquire 内存序,后续的不论是读还是写指令都不超前于本指令执行,如:`amoswap.w.aq`。.aq 不约束前面的指令。注意:只有 aq 标记而没有 rl 标记的 sc 指令是不合适的。 +- .rl,约束为 release 内存序,前面的不论是读还是写指令都在本指令前完成,如:`sc.rl`。.rl 不约束后面的指令,但 RISC-V 规定如果后面的指令有 aq 标记,则约束其不能超越 rl 标记指令,也就是同一 hart 的 acquire-release 标记保护的关键区不交叉、不乱序(这种 RC 模型称为 RCsc,相应允许交叉的称为 RCpc)。注意:没有 aq 标记而只有 rl 标记的 lr 指令也是不合适的。 +- .aqrl,约束为顺序一致(Sequential Consistency,SC)内存序,前面的读写指令发生在本指令之前,后面的发生在本指令之后。对于 lr/sc 原子指令对来说,SC 内存序约束应采用 `lr.aq/sc.aqrl` 序列,因为该原子指令执行的标志是成功的 sc 操作,sc.aqrl 确保了前后指令均不越界;反过来如 `lr.aqrl/sc.rl`,其它 hart 可能观察到 sc 后的指令发生在 sc 之前;当然 `lr.aqrl/sc.aqrl` 是可以的,但一般没必要。 +- 全省略时,没有约束。 + +#### 内存模型公理 + +RISC-V 程序的执行遵循 RVWMO 内存一致性模型,前提是存在一个符合保留程序序的全局内存序,并且满足返回值公理、原子性公理和进度公理。下面我们分别介绍这三个公理。 + +##### 返回值公理 + +每个 load i 操作的每个字节都需要返回有 store 操作(保证在以下 store 操作中的全局内存序是最新的)写入该字节的值,即读操作返回最近的对同一个地址写操作的值。 + +> 1.存储了写入字节并且全局内存序在 i 之前 +> +> 2.存储了写入字节并且程序序在 i 之前 + +##### 原子性公理 + +如果 r 和 w 是有对齐的 LR 和 SC 指令在一个 hart h 中生成的成对的 load 和 store 操作,s 是一个 store x 字节的指令,r 返回一个有 s 写入的值,那么 s 必须在全局内存序中位于 w 之前,并且在全局内存序中,在 s 后面和 w 前面不能有除 h 以外的 hart store 这 x 字节的指令。 + +> AMO 和 LR/SC 的原子性: +> +> AMOs 在内存中获取一个旧值,执行算术操作(交换除外),并将新值写入内存,所有这些都在一个原子操作中完成。 +> +> LR 获取一个预定,如果预定有效 SC 会 执行 store 操作,然后释放预定;预定可以以任何理由被取消,但如果预定地址范围与任何其他 hart 之间存在 store,则必须终止该预定。 + +##### 进度公理 + +在全局内存序中,任何内存操作之前都不能有无限序列的其他内存操作。 + +> 意味着更强的公平性,一致性模型保证序列都可以得到执行。 + +## 总结 + +RVWMO 模型提供了硬件与软件之间的一层接口,使软件程序员能根据这个模型实现并发编程,得益于它的宽松内存序。而 RISC-V 还提供了一种比 RVWMO 更严格的内存一致性模型,用于支持“全存储排序”,即 “Ztso” 扩展,主要是为了便于从 x86 体系结构向 RISC-V 迁移,提供了完全兼容 x86 架构的 RVTSO(RISC-V Total Store Ordering)内存模型。可以看出 RISC-V 正处在发展的黄金时期,与其他架构相比,具有强大的竞争力。 + +## 参考资料 + +- https://riscv.org/wp-content/uploads/2018/05/14.25-15.00-RISCVMemoryModelTutorial.pdf +- https://github.com/riscv/riscv-isa-manual +- https://riscv.org/technical/specifications/ +- https://zhuanlan.zhihu.com/p/422848235 +- https://gitee.com/laokz/OS-kernel-test/tree/master/memorder + +[001]: https://github.com/riscv/riscv-isa-manual/releases/download/Ratified-IMAFDQC/riscv-spec-20191213.pdf -- Gitee