[RFC]RISC-V内存一致性模型

原文:https://gitee.com/laokz/OS-kernel-test/blob/master/memorder/riscv.md

RFC:请求批评指正,勿作引用。本文未解释内存一致性基本概念

《The RISC-V Instruction Set Manual, Volume I: User-Level ISA》明确规范了RISC-V的内存一致性模型,称为“RVWMO” (RISC-V Weak Memory Ordering)。RVWMO主要遵循了RC(release consistency)理论模型,用较少的内存访问顺序约束,为硬件实现和性能优化提供了宽松的条件;同时,禁止若干过于复杂费解的乱序情况,方便了软件程序的开发利用。总体上RVWMO是一种弱内存序模型。

为了便于从x86体系结构向RISC-V迁移,规范还明确了一个称为“Ztso”的标准扩展,提供完全兼容x86架构的RVTSO(RISC-V Total Store Ordering)内存模型。本文主要聚焦RVWMO,从软件视角梳理总结了RISC-V内存一致性模型主要内容。

RISC-V是正处于发展阶段的新兴指令集架构,RVWMO也是如此。这里描述的内容源自2019年最后发布的版本。

0、写在前面

为了避免歧义,在介绍RVWMO前先说明几个问题:

  • 用hart表示执行线程或执行单元。RISC-V使用hart一词表示一个逻辑CPU, 即一个硬件线程(超线程)。比如一个双核处理器,每核两个硬件线程,则该处理器有四个hart。hart不仅限于通常意义的处理器范畴,设备控制器甚至CPU集成的内存管理单元(MMU)都可以是hart。这些hart之间共享内存构成了内存乱序的源泉。

  • 指令执行用词。指令发生(happen)、执行(perform、execute)、完成(complete)这几个词对硬件来说有显著区别,但对于软件程序员,并不关心硬件如何分解执行指令,反而关心的是一条指令要么执行了,看到了结果,要么没执行。本文为软件视角,因此在使用这些词汇时,除特别说明外均指代同一含义。

  • 忽略内存对齐和大小问题。内存访问顺序约束的有效性依赖内存地址对齐和大小,未对齐的地址、超过机器字的大小,对内存访问顺序有复杂的影响,一般规律是拆分成多个自然对齐的访存指令,但需要根据规范和具体实现专门考察。本文忽略这一问题,假定所有的内存访问都是“自然对齐”、“自然大小”,都是原子性的访问(single-copy atomicity)。

  • 内存模型与具体实现是两回事。看似废话但值得一提。RVWMO是关于内存访问顺序约束的软硬件接口规范,明确了RISC-V合规架构最少要遵守的规则,提供了“最弱”的内存序约束;但是不排斥具体实现采取更加保守的策略,提供“更强”的内存序约束。因此,RVWMO上的乱序问题,包括litmus模拟测试中的乱序结果,在真实机器上并不必然存在!类似实验数据在《A Tutorial Introduction to the ARM and POWER Relaxed Memory Models》第4.7节可查到。明确这一点仅是为了更加深入地理解内存模型,编程实践中应遵循RVWMO模型以保证最大的兼容性。当然如果你的软件专用于特定机器,根据其特性忽视一些乱序问题可能会有效益。

  • 几个符号表示xxx ->po yyy表示程序序中指令xxx在yyy之前,xxx ->ppo yyy表示全局序和程序序中指令xxx都在yyy之前。

一、规则

目前RVWMO只是规范了常规内存的一致性要求,没有规范I/O内存的行为。RVWMO明确了13条规则、4条公理,为便于记忆和理解,可以归纳总结为以下4句话:

1. 遵守多拷贝原子性(multi-copy atomicity)

RVWMO通过全局序(global memory order)定义内存访问顺序约束,对于不允许乱序的情况称为“保留程序序”(preserved program order,PPO)。全局序即所有并行(并发)线程在内存系统中形成的最终内存访问顺序,各个线程对这个全局序的观察都是一致的,除了store buffer带来的“写后读”情况。store buffer是hart私有缓存,用于暂时存储要写入内存系统的数据,这里的数据对本hart可见,即写后再读可以读到这个写入的值,但对其它hart不可见,也因此双方可能观察到不一样的访问顺序。RISC-V包容这种情况。

为了形成一致的全局序,一个hart如果看到了另一个hart的写,则必须所有的hart都看到了这个写,否则会出现不一致(实际硬件实现并不一定有这种“同时看到”时间保证)。这个特性称为多拷贝原子性。也因为包容“写后读”情况,有的称这种原子性为other-multi-copy atomicity。

有了多拷贝原子性保证,程序员就无需再担心“因果性”(causality)、“累积性”(cumulativity)等令人头痛的问题。奇怪的是,规范正文中并没有明确说明这一特性,而仅是在附录中对此进行了解释。

2. 遵守自然的语法依赖

语法依赖准确的定义比较复杂且很绕,想追究细节最好是研读原文。简单地讲,语法依赖是指一条指令的源操作数与前面指令(不一定紧邻)的目的操作数是同一个寄存器,前面不出结果后面没法执行,这种逻辑上的限制“自然而然”地约束指令顺序。理解要点,一是看寄存器名而不分析值,这也是叫语法依赖而不叫语义依赖的原因;二是不是所有指令都有目的操作数,因此没有指令会依赖一条store语句(不包括sc指令),因为它没有目的操作数,见规范14.3节;三是x0寄存器不构成任何依赖,因为它的值是固定的、已知的。

显然,语法依赖具有传递性:B依赖A,则A ->ppo B;C依赖B,则B ->ppo C;所以必然A ->ppo C,即A全局序必在C之前。注意,规范也没有明确指明这一点。

示例1(原文序号为Figure A.10,下类同):

(a) ld a1,0(s0)
(b) xor a2,a1,a1
(c) add s1,s1,a2
(d) ld a5,0(s1)

指令(b)要执行必须(a)先读到a1,指令©要执行必须(b)先计算出a2,指令(d)要执行必须©先计算出s1。这个指令序列自上而下地构成了一个依赖链,因此两条不相关的内存读指令(a)和(d),被强制地保证了执行顺序,尽管指令(b)和©看似可优化掉的无用指令。这种“人为”依赖是性能攸关处的一个备选工具。

对于内存访问操作,语法依赖按寄存器用途分为三类并保证有序:

  • 地址依赖。前一条指令的结果是后一条访存指令的操作地址(用法相当于指针)。如:lw t1, (s0); lw t2, (t1)

  • 数据依赖。前一条指令的结果是后一条指令的操作数。如:lw t1, (s0); sw t1, (s1)

  • 控制依赖。两条指令间存在一个依赖于第一条指令的分支或间接跳转指令。用C语言的if语句比拟:条件语句对后续的所有指令(包括语句块之外的指令)构成控制依赖。但RVWMO仅保证对后续的store指令有序。

示例2(A.12):

(a) lw x1,0(x2)
(b) bne x1,x0,next
(c) next: sw x3,0(x4)

又是一个“人为”的控制依赖(b)确保了不相关访存操作(a)和©有序。

示例3

       代码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)和©的关系称为流水线依赖,并明确规定不能乱序。约束代码1和2的出发点是“在写地址或值未知时不能(无法)读这个写”–(b)地址或值未确定时©不能执行,又因为(b)地址或数据依赖(a),因此©在全局序上不能超越(a)。约束代码3和4的出发点是“前面地址未知时不能写”–(b)地址未确定时©不能执行,以防止地址冲突(参见下文“写不超前”),又因为(b)地址依赖(a),因此©在全局序上也不能超越(a)。规范中流水线依赖规则是单独的一类,列在这里仅为方便,尽管它们看起来不是那么“自然”。

3. 对同一地址,写不超前、读CoRR、原子操作不乱序

“同一地址”允许地址部分交叉重叠,规则约束的仅仅是这重叠部分(Overlapping-Address)。

  • 写不超前。对任何访存指令 ->po store,store指令在全局序上不会超越前面的指令。这可理解为,如果允许后面的写乱序,则前面指令应读的值就被覆盖了,或前面的写反而覆盖了后面的写。

示例4(A.5):

    Hart 0            Hart 1
-------------------------------------
    li t1, 1          li t2, 2
(a) sw t1,0(s0)   (d) lw a0,0(s1)
(b) fence w, w    (e) sw t2,0(s1)
(c) sw t1,0(s1)   (f) lw a1,0(s1)
                  (g) xor t3,a1,a1
                  (h) add s0,s0,t3
                  (i) lw a2,0(s0)

Outcome: a0=1, a1=2, a2=0(允许)

先看(d)和(e)的关系,它们是对同一地址的访问,因此store指令(e)在全局序上不能超越(d)。后面还会用到这个示例。

  • 读CoRR。对于同一地址的两个读,只要后一个load不到更老的值,就不约束两者的内存序,这个特性称为Coherence for Read-Read pairs。反过来说,当且仅当两个load中间没有对这一地址的写,且返回不同的值时(实际上是返回不同的写,不一定值不相同),要保证读的顺序不能乱。

上面示例4的结果,说明执行序列是(f)-(i)-(a)-©-(d)-(e),正是因为(e)的存在,使得同一地址的两个读(f)能读到比(d)更新的值,从而允许在内存序上超越(d)。前面说“写不超前”,那(e)怎么先写的呢?先写的怎么反倒在后面呢?如果没写(f)读的值哪来的呢?硬件告诉你:“我先把(e)写到store buffer里,反正别人也看不见,(f)你就接着往下整吧,回头我看看没错再告诉内存记账”。

示例5(A.6):

    Hart 0            Hart 1
-------------------------------------
    li t1, 1      (d) lw a0,0(s1)
(a) sw t1,0(s0)   (e) xor t2,a0,a0
(b) fence w, w    (f) add s4,s2,t2
(c) sw t1,0(s1)   (g) lw a1,0(s4)
                  (h) lw a2,0(s2)
                  (i) xor t3,a2,a2
                  (j) add s0,s0,t3
                  (k) lw a3,0(s0)

Outcome: a0=1, a1=v, a2=v, a3=0(允许)

执行序列是(h)-(k)-(a)-©-(d)-(g),(g)和(h)是对同一地址的读且读到的是同一个值(中间再无其它线程向该地址写),因此允许(h)及之后的指令先完成而不违反(g)、(h)之间的CoRR约束。

  • 原子操作不乱序。原子指令因为含有store操作,因此当其位于程序序的后面时不会超越前面的指令。当其位于前面时,如果后面的是store指令不会乱序,如果是load指令,规范明确不允许乱序,主要是为了保证原子指令的操作语义。需要注意的是,对于lr/sc原子指令对,成功的sc才代表这个原子指令的执行,失败的sc不产生任何内存操作,自然也不对内存序约束产生任何贡献。

再次提示,以上三条针对的是同一地址访问情况。

4. 其它任何情况都可以乱序

包括原子指令,因此需要专门的指令来强制约束内存访问顺序。

二、内存序约束指令

1. 通用指令

FENCE 用于约束常规内存和/或设备I/O内存的访问顺序。

格式: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写。

FENCE.TSO 可选指令,fence的变种,相当于fence rw, rw,除store ->po load的情况。

原子指令[.aqrl]

标准扩展A提供了原子操作指令,用于构建线程同步操作,同时提供了可选的单向内存序约束标记。

  • .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是可以的,但一般没必要。

  • 全省略时,没有约束。

2. 专用指令

程序执行时有很多隐式的内存访问操作,如CPU指令预取单元取指令、内存管理单元(MMU)访问页表等,当这部分内存发生变化时,为了保证变化及时生效,也需要约束内存访问顺序,更准确地说要同步各单元的操作。这部分执行机构使用一些专门的部件,像指令缓存、页表缓存、TLB等,因此需要专门的内存序约束指令。

FENCE.I 由指令集的Zifencei扩展定义,确保指令内存(代码段)的动态更新,对当前hart的指令预取单元可见。

格式:fence.i

在即时编译(JIT)等场景中,执行fence.i确保本hart所作的动态代码更新,和已传播(propagated)到本hart的其它hart所作的更新,即时生效。但fence.i不负责这些数据在各hart间的传播,因此要使动态更新对其它hart可见,执行更新的hart应执行前述的fence指令,并通过核间中断通知其它hart执行fence.i指令。

SFENCE.VMA 《The RISC-V Instruction Set Manual Volume II: Privileged Architecture》定义的特权指令,同步页表更新。在启用虚拟内存的情况下,系统执行时MMU会隐式地访问页表,缓存有关数据,执行虚拟地址转换,并预读数据,这些操作通常在显式内存访问之前执行。更新页表相关数据时,软件必须确保系统失效这些预先工作,重新使用新属性进行访问。与fence.i一样,sfence.vma仅作用于当前hart。

格式:sfence.vma vaddr, asid

vaddr=x0,asid=x0时,作用于所有地址空间的各级页表访问操作

vaddr=x0, asid!=x0时,作用于指定地址空间(address-space identifier)的各级页表访问操作,不包括全局映射

vaddr!=x0, asid=x0时,作用于所有地址空间指定虚拟地址对应的页表项访问操作

vaddr!=x0, asid!=x0时,作用于指定地址空间指定虚拟地址对应的页表项访问操作,不包括全局映射

主要有三种使用场景:

  • 更新PMP寄存器。PMP(Physical Memory Protection)是可选的控制物理内存可访问性的每hart系统寄存器,每个访存操作前都要经过PMP检查。同步指令为sfence.vma x0, x0。仅发生在M模式。

  • 更新satp寄存器。sapt(Supervisor Address Translation and Protection)是存储进程根页表信息的每hart系统寄存器,类似x86的CR3寄存器。可发生在S或M模式。

  • 更新页表。一般发生在S模式。

RISC-V目前没有规范cache、TLB失效/刷新指令。

三、移植参考

规范附录A.5提供了x86、Power、Arm以及linux、C/C++到RVWMO指令的映射指南。

x86

x86/TSO Operation RVWMO Mapping
Load l{b|h|w|d}; fence r,rw
Store fence rw,w; s{b|h|w|d}
Atomic RMW amo.{w|d}.aqrl 或
loop: lr.{w|d}.aq; ; sc.{w|d}.aqrl; bnez loop
Fence fence rw,rw

Power

Power Operation RVWMO Mapping
Load l{b|h|w|d}
Load-Reserve lr.{w|d}
Store s{b|h|w|d}
Store-Conditional sc.{w|d}
lwsync fence.tso
sync fence rw,rw
isync fence.i; fence r,r

Arm

ARM Operation RVWMO Mapping
Load l{b|h|w|d}
Load-Acquire* fence rw, rw; l{b|h|w|d}; fence r,rw
Load-Exclusive lr.{w|d}
Load-Acquire-Exclusive** lr.{w|d}.aqrl
Store s{b|h|w|d}
Store-Release fence rw,w; s{b|h|w|d}
Store-Exclusive sc.{w|d}
Store-Release-Exclusive sc.{w|d}.rl
dmb fence rw,rw
dmb.ld fence r,rw
dmb.st fence w,w
isb fence.i; fence r,r

* ARMv8也是遵循RCsc模型,不允许.rl ->po .aq乱序。为了约束这个顺序,这里的映射统一在load前加上强fence(RISC-V不推荐使用fence rw, r的用法)。

** 同上,这里的映射统一在lr指令加上.rl标记,这样sc.rl不会越界,普通的Store-Release也不会越界。

Linux

Linux Operation RVWMO Mapping
smp_mb() fence rw,rw
smp_rmb() fence r,r
smp_wmb() fence w,w
dma_rmb()* fence r,r
dma_wmb()* fence w,w
mb() fence iorw,iorw
rmb() fence ri,ri
wmb() fence wo,wo
smp_load_acquire() l{b|h|w|d}; fence r,rw
smp_store_release() fence.tso**; s{b|\h|w|d}

* linux5.8中的定义是fence ri,rifence wo,wo。这里的映射是假定DMA控制器符合《RISC-V UNIX-Class Platform Specification》的一致性规范,而linux目前尚无这种假定。

** linux中的定义是fence rw,w。Linux-Kernel Memory Consistency Model(LKMM)和memory-barriers.txt对同一hart上同一变量保护的关键区之间的内存序关系,如unlock ->po lockrelease ->po acquire情况,描述的比较模糊,litmus模拟测试显示是RCpc关系–即没有顺序约束!这很奇怪!LKMM的维护者称至少对于锁保护的情形,事实上linux支持的架构基本都是TSO约束的–即除了store ->po load外,两个关键区之间的其它关系对都是遵守程序序的,为此提出修改LKMM反映这一事实。这里的映射就是为了适应这一变化,但目前修改还没有实现。

Linux Construct RVWMO AMO Mapping
atomic__relaxed amo.{w|d}
atomic__acquire amo.{w|d}.aq
atomic__release amo.{w|d}.rl
atomic_ amo.{w|d}.aqrl
Linux Construct RVWMO LR/SC Mapping
atomic__relaxed loop: lr.{w|d}; ; sc.{w|d}; bnez loop
atomic__acquire loop: lr.{w|d}.aq; ; sc.{w|d}; bnez loop
atomic__release loop: lr.{w|d}; ; sc.{w|d}.aqrl*; bnez loop 或
fence.tso*; loop: lr.{w|d}; ; sc.{w|d}; bnez loop
atomic_ loop: lr.{w|d}.aq; ; sc.{w|d}.aqrl; bnez loop

* 基于前面同样的理由,这里加强了release语句的约束。

C11/C++11

C标准的有关表述硬是看不懂:-/,请高手解读!

C/C++ Construct RVWMO Mapping
Non-atomic load l{b|h|w|d}
atomic_load(memory_order_relaxed) l{b|h|w|d}
atomic_load(memory_order_acquire) l{b|h|w|d}; fence r,rw
atomic_load(memory_order_seq_cst) fence rw,rw; l{b|h|w|d}; fence r,rw
Non-atomic store s{b|h|w|d}
atomic_store(memory_order_relaxed) s{b|h|w|d}
atomic_store(memory_order_release) fence rw,w; s{b|h|w|d}
atomic_store(memory_order_seq_cst) fence rw,w; s{b|h|w|d}
atomic_thread_fence(memory_order_acquire) fence r,rw
atomic_thread_fence(memory_order_release) fence rw,w
atomic_thread_fence(memory_order_acq_rel) fence.tso
atomic_thread_fence(memory_order_seq_cst) fence rw,rw
C/C++ Construct RVWMO AMO Mapping
atomic_(memory_order_relaxed) amo.{w|d}
atomic_(memory_order_acquire) amo.{w|d}.aq
atomic_(memory_order_release) amo.{w|d}.rl
atomic_(memory_order_acq_rel) amo.{w|d}.aqrl
atomic_(memory_order_seq_cst) amo.{w|d}.aqrl
C/C++ Construct RVWMO LR/SC Mapping
atomic_(memory_order_relaxed) loop: lr.{w|d}; ; sc.{w|d}; bnez loop
atomic_(memory_order_acquire) loop: lr.{w|d}.aq; ; sc.{w|d}; bnez loop
atomic_(memory_order_release) loop: lr.{w|d}; ; sc.{w|d}.rl; bnez loop
atomic_(memory_order_acq_rel) loop: lr.{w|d}.aq; ; sc.{w|d}.rl; bnez loop
atomic_(memory_order_seq_cst) loop: lr.{w|d}.aqrl; ; sc.{w|d}.rl; bnez loop

四、小测验

  1. 规范的附录A.6提供了一个有趣的指导:
  • 程序员–自如运用规则1(同一地址写不超前)和4-8(内存序约束指令)

  • 专家–用规则9-11(语法依赖)加速关键路径

  • 极品–也很少使用规则2-3(同一地址读CoRR、原子不乱)和12-13(流水线依赖)

  1. 如果你能够看懂规范附录的A.20-A.21三个例子(原版有误,请下载最新版或看PR),清楚为什么公理模型(即RVWMO)允许这样的执行结果,理解“新增规则”的核心思想,恭喜恭喜!你毕业了!请将答案留下:-)

结语:以上所述是为了能够简单直观地理解把握RISC-V内存一致性模型,实际定义和规则有更为严谨的条件,推荐研读规范以求甚解。需要有所准备的是,硬件实现非常乐意“巧妙”地或说“莫名其妙”地破坏这些严谨的条件,且秘而不宣–CPU微架构实现是商业秘密,因此准确把握和利用这些规则并不容易。为此,建议在使用中,一是不设计逻辑复杂的依赖、同地址约束代码块,二是防止编译器、汇编器优化掉设计的语句,三是利用litmus模拟测试进行验证。

(2020-08-20 ver 1.0)

你可能感兴趣的:(笔记)