CS61C – 10 – Parallelism (4)

这一部分的内容对应于 lec 35 的相关内容, (b 大上的参考课程为 [Summer 20])这一部分承接了前三讲中关于线程级并行的内容,是并行系列讲座的收官之作。它从前两讲的软件和编程模型,深入到了支撑这些模型的底层硬件机制,解决了并行计算中最根本的两个问题:如何实现正确的同步如何维护多核数据一致性

从整体思路而言, lec 35 的逻辑脉络是非常清晰的,它的主体包括两大部分:

  • 硬件对同步的支持工作:包括原子操作、RISC – V 下的相关指令、无竞态条件锁的实现以及 OpenMP 中是如何通过这些硬件原语构建高级同步指令
  • 缓存一致性:包括多核缓存面临的新问题,由此引发的性能陷阱以及解决相关问题所必需的相关协议

原子操作

在前一讲的内容中我们提到,在软件层面上实现原子操作是有缺陷的,因为软件在执行时不能保证当前操作不被中断。因而在检查和设置之间可能存在间隙,导致竞态。

相应的解决方案是由 CPU 提供原子指令,在一个不可中断且中立的总线周期内完成读改写操作。外部观察者只能看到操作前或操作后的状态,看不到中间态。

在 RISC – V 中,相应的指令为 AMO

amoop.w rd, rs2, (rs1)

该指令相当于原子地执行

tmp = Mem[rs1]; 
rd = tmp; 
Mem[rs1] = tmp op rs2;

其中op 可以是 addandorswapxor 等命令符

那么无竞态锁的正确实现其实为:

li   t0, 1         # 要写入锁的值 (1)
Try:
    amoswap.w.aq t1, t0, (a0) # 原子交换: t1 = old_lock; lock = 1;
    bnez t1, Try        # 如果 old_lock != 0(锁已被占),循环重试
# ----------- 临界区开始 -----------
# ... 访问共享资源 ...
# ----------- 临界区结束 -----------
amoswap.w.rl x0, x0, (a0) # 原子写入: lock = 0; (x0是只读的0寄存器)

其中.aq (acquire) 和 .rl (release) 用于保证内存操作的顺序性,防止指令重排破坏同步逻辑。

实现的逻辑其实很简单,由 amoswap 指令原子地完成了“读取锁值”和“写入 1 ”两个操作,接着获取寄存器 t1 的值,如果获取失败(t1 != 0),线程在循环中不断尝试(Try 标签处),直到成功。并在自己的临界区执行结束后 使用 amoswap 将锁值原子地写回 0 。

  • 这种锁称为自旋锁(Spinlock)。它在等待时会持续消耗CPU周期,适用于临界区执行时间非常短的场景。对于长临界区,使用会让出CPU的锁(如互斥锁)效率更高,不过讲座没提。

缓存一致性

这是并行处理器下新面对的另一个问题。多核处理器每个核心都有私有缓存。如果核心0修改了其缓存中的变量X,核心1的缓存中可能还是旧的X值,这违反了共享内存的语义。

问题其实类似于 DBS 的并发,但解决方法与 DBS 不尽相同。我们在这设计了一种协议,使其对任一个内存地址的写操作对处理器均可见,且要求所有处理器看见的读写顺序是一致的,也就是所谓的缓存一致性协议

缓存一致性协议

 在诸多一致性协议中,侦听协议是一种常见的实现方式。所有缓存都“侦听”连接它们和内存的总线(或互联网络)上的事务。当一个缓存要写数据时,它通过总线广播一个消息,其他缓存收到消息后采取行动(如作废其副本)来保证数据一致。

此外还有 MOESI ,这同样是一个景点的缓存行状态协议,每个缓存行会处于以下状态之一:

  • M (Modified): 脏数据,只在当前缓存中,与内存不一致。可写。
  • O (Owned): 脏数据,但其他缓存可能有只读副本(S状态)。当前缓存负责在需要时提供数据或写回内存。是MESI协议的优化扩展。
  • E (Exclusive): 干净数据,只在本缓存中,与内存一致。可写(写入后变为M)。
  • S (Shared): 干净数据,可能存在于多个缓存中,与内存一致。只读。
  • I (Invalid): 数据无效(是旧副本)。

缓存通过侦听总线上的读写请求,并根据规则在上述状态间转换,来保证所有缓存看到的数据视图是一致的。

但缓存一致性同样可能引发潜在问题,我们考虑这样一个场景:

“ 两个无关的变量 X 和 Y 恰好位于同一个缓存行中。两个不同的核心分别频繁地读写 X 和 Y

这会发生什么?显然会出现频繁的缓存缺失,导致大量的不必要的一致性流量,严重损害性能。这也被称为“一致性缺失(Coherence Miss)”

当然解决方案也是存在的,我们可以通过编译器指令进行内存对齐,从而保证每个变量独占一个缓存行,或者填充冗余字节作为 Padding,将不同线程的字段分隔到不同缓存行。

写在最后

CS61C 中关于并行的部分至此告一段落,相应的 lab 10 的内容还是比较容易上手的,不过似乎没有考虑到架构的可移植性(对,说的就是你 x86 – msvc (╬☉д⊙)什么库都缺)写起来比较不顺手,但总之这一讲将硬件、体系结构和软件深刻地联系了起来,当我们在 OpenMP 里写下一句 #pragma omp critical 时,底层是成千上万个晶体管在通过复杂的协议和原子操作,为我们确保程序的正确性。

评论

发表回复

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