Locality, Communication, and Contention¶
Parallel Programming: Case Study¶
我们通过一个例子作为引入, 让大家感受一下三种模型对应的并行方式的区别:
- data parallel:
reduce
... - shared address space (SPMD):
lock()
/unlock()
- message passing:
send()
/recv()
Data-parallel expression of solver¶
- 分解 (Decomposition): 将单个网格元素的处理视为独立工作
- 任务分配 (Assignment): 由系统处理
- 协调通信 (Orchestration Communication): 由系统处理,通过内置的通信原语
- 协调同步 (Orchestration: Synchronization): 由系统处理,
for_all
块的结束隐式地等待所有工作线程完成
Shared address space (with SPMD threads) expression of solver¶
给个例子: 通过副本, 实现 barrier()
需求量减少 (3 -> 1)
Message passing expression of solver¶
我们深入讨论一下 send()
&& recv()
的形式:
[1] Synchronous (Blocking) Send and Receive
这样的话, 我们就发现上述 "msg passing solver" 的代码存在一个重大缺陷:
在上面的实现中,每个线程都遵循“先发送所有消息,再接收所有消息”的模式
假设我们有多个线程,例如线程 P0、P1、P2 等
- P1 会尝试向 P0
send
一行数据,同时 P1 还会尝试向 P2send
另一行数据 - 与此同时,P0 也在尝试向 P1
send
数据,P2 也在尝试向 P1send
数据
如果所有线程都同时执行它们的 send
操作:
- P1 调用
send
向 P0 发送。根据同步发送的定义,P1 会阻塞,等待 P0 调用recv
- 同时,P0 也调用
send
向 P1 发送。P0 也会阻塞,等待 P1 调用recv
结果是,P0 和 P1 都在等待对方执行 recv
操作,而它们自己都在 send
调用处阻塞,无法进行到 recv
操作。这种相互等待的局面会导致所有参与通信的线程都永久阻塞,从而形成死锁
这个问题本质的根源是: 消息发送和接收顺序不匹配
所有线程都试图首先发送消息,这意味着没有任何线程能够先执行接收操作来“解锁”其他线程的发送,从而导致所有线程相互等待,陷入死锁状态
[2] Synchronous (Blocking) Send and Receive 的暴力修正
[3] Non-blocking asynchronous send/recv
这才是上述deadlock最优雅的解决方案
send()
: 立即返回, 返回一个handle || other work 可以继续干recv()
: 立即返回, 返回一个handle || other work 可以继续干checksend()
: 通过handle检查执行状态checkrecv()
: 通过handle检查执行状态
概念普及: 计算强度 (AI)
计算强度 (Arithmetic Intensity, AI)
\(AI = \frac{amount of computation (instructions)}{amount of communication (bytes)}\)
这个概念很直观, 肯定是越高越好(说明计算利用率更高)
Communication¶
Inherent vs. Artifactual Communication¶
(1) Inherent Communication:
- def: 由于并行需要, 必须要执行的 communication
- 可以通过不同的 assignment 策略来优化它的 AI (计算强度)
(2) Artifactual Communication:
- def: 除了inherent外的其他communication, 主要体现是: 不是算法逻辑本身所必需的,而是由系统实现的实际细节所导致的
- namely: 即使理论上可以避免的通信, 但由于硬件或软件工作方式的限制, 仍然会发生
简单理解成“额外开销”即可
形象理解
- 固有通信: 你在完成一项任务(做饭)时,必须采购的食材(米、菜、肉),没有它们你无法完成任务
- 人工通信: 你在采购过程中,因为超市的购物车太大、或者你记性不好,导致的一些额外支出或重复劳动
Techniques for Reducing Communication¶
- 从 cache hit 角度分析 (空间局部性):
- "Blocking": reorder computation to make working sets map well to system’s memory hierarchy
- 从 程序利用率[循环融合] 角度分析 (时间局部性):
- 在非融合版本中,每次单个数学操作(如
A[i] + B[i]
)涉及2次加载和1次存储(对于tmp1
)。因此, 算术强度为 1/3 - 在融合版本中,对于数组中的每个 i,程序执行了3次数学操作
(A[i]+B[i], (A[i]+B[i])*C[i], (tmp)*D[i])
,但只需要4次加载(A[i], B[i], C[i], D[i])
和1次存储(E[i])
。因此, 算术强度提高到了 3/5
- 在非融合版本中,每次单个数学操作(如
- sharing data:
- Schedule threads working on the "same data structure" at the same time on the "same processor"
Contention¶
contention (争夺)
(1) 引入: 由于共享资源的稀缺性, 越到后面等待的时间越长
(2) 何时发生: "多对一"
Contention occurs when many requests to a resource are made within a small window of time (the resource is a "hot spot")
我们可以考虑两种访问“共享资源”的方式:
- 扁平化访问 (
flat comm
):- Pros: 延迟很低
- Cons: 很容易发生 contention
- 树状访问 (
tree-struct comm
):- Pros: 不容易发生 contention
- Cons: 延迟较高
(3) 回顾: 上节课的 distributed work queues
本质是防止 "hot spot" 现象
distributed queue
: 每个thread先从自己的queue里取, 防止“一窝蜂取一个中心化的queue”random choice
: 每个thread在自己queue清空后, 会steal
, 这是随机化的, 防止 "所有人同时save同一个人"
Performance Measurement: Roofline model¶
指标:
- 横轴: Operation Intensity
- 即: "运算强度", aka.
Arithmetic Intensity (AI)
- 即: "运算强度", aka.
- 纵轴: Attainable GFlops
- 可达到的GFlops. 表示应用程序在给定机器上实际能够达到的 浮点运算性能
- 直接衡量了程序的执行速度或吞吐量, 表示 "性能"
曲线:
- 对角线区域: 内存带宽受限
- 当AI较低时, 处理器需要频繁地从内存中获取数据, 但 每次获取的数据 所能支撑的计算量很少
- 在这种情况下,即使 processor 的计算单元(ALU/FPU)非常快,它也会因为等待数据而空闲
- 水平线区域: 计算资源受限
- 当应用程序的运算强度足够高时: 意味着处理器每次从内存获取数据后,都能执行大量的计算。此时,数据供应已经不是瓶颈,处理器可以饱和地利用其计算单元
优化措施:
- 对角线区域: 内存带宽受限
- 需要提高 AI
- 比如, 改进数据局部性, 减少 artificial comm, "一次加载, 多次计算"
- 水平线区域: 计算资源受限
- 如果应用程序的性能落在此区域,且远低于机器的峰值
- 优化重点应放在充分利用处理器的计算资源上,例如: 通过向量化(SIMD指令)、多线程或更高效的并行算法设计来提高并行度,从而逼近理论峰值计算能力