Work Distribution and Scheduling¶
我们想要实现更好的 workload balance:
- 所有 processors 都得不停歇地工作
- 希望减少这一过程中的开销
- 最小化: computational overhead (scheduling / assignment)
- 最小化: sync cost
三种assignment¶
static assignment¶
(1) "静态任务分配"特点:
- 不依赖于运行时因素: 在程序执行之前,或者至少在工作实际执行之前,就决定了哪个线程将执行哪部分工作,而不会在程序运行时根据任务的实际执行时间长短来动态调整分配
- 不一定在编译时确定: 尽管叫“静态”,但这并 不意味着任务分配算法必须在编译时完全固定。分配算法可能仍然依赖于运行时参数
(2) "静态任务分配"优点:
- 简单
- 几乎没有运行时开销 (zero runtime overhead)
(3) 何时使用"静态任务分配":
cost (exec time) of work && amount of work are predictable 😍
- 每个work的overhead一致 (理想状态)
- work的overhead不完全一致
semi-static assignment¶
介于完全静态分配和完全动态分配之间。它结合了"static"和"dynamic"的特点,旨在利用任务成本的可预测性,同时允许一定程度的适应性
特点:
- 工作成本在不久的将来是可知的
- 虽然任务的执行成本可能不是在整个程序运行期间都完全不变或预先已知,但可以根据最近的执行历史来很好地预测即将到来的任务成本
- 应用程序会周期性地 分析 其执行情况并 重新 调整分配
- 这是“Semi-static”的核心机制: 程序不会一直使用同一个分配方案,而是会定期地检查任务的实际执行情况
- 基于这些剖析结果,程序会重新计算并调整任务的分配方案
- 注意: 在重新调整间隔期间,分配是“静态”的
- 在两次重新调整之间的时间段内,任务的分配是固定不变的,就像完全静态分配一样
- 只有当应用程序进行下一次剖析并决定重新调整时,分配方案才会改变
Tip
其实仔细一想,这个思想在很多论文里都提到过,本质是一回事:
比如 satitch:
- round1: 根据出一个初始化结果
- round2~n: 根据“演化”情况,在前者的基础上做出一点点微调
dynamic assignment¶
(1) "动态任务分配"特点:
在 运行时(runtime) 进行 assignment
(2) 何时使用"动态任务分配":
cost (exec time) of work or amount of work is unpredictable 😷
常见的 dynamic assignment 是基于 work queue
:
Static assignment 🤝 Dynamic assignment
事实上, 这两者并不是either的关系, 而是应该同时存在
- 运行前, static assign 做好
- 运行时, dynamic assign 动态调度
task size¶
如何选择合适的 task size 本身就是个trade-off
- task size 小:
- Pros: 更容易 workload balance
- Cons: sync之类的数量就会变高,这些属于 "non-working" 开销
- task size 调大些:
- Pros: 可以减少 "non-working" 开销
- Cons: 粒度过大, 难调控分配
Note
- Pros == advantages
- Cons == disadvantages
task scheduling¶
Schedule long task first to reduce “slop” at end of computation.
这一部分还有一些更加深入的话题,但是我们只给一下大致的做法与思想,细节会在后面展开:
(1) thread stealing
使用 distributed queues 减少 all workers sync 的开销
(2) what if tasks have dependencies
只有一个 task 的所有“前向依赖”都 solved 了,才能被 worker thread 处理
对于 fork-join 并行的调度¶
这一部分以 Clik Plus
(by MIT) 为例进行演示
(1) 基础语义
cilk_spawn foo(args);
- 很像
fork()
(create new logical thread of control) - 当调用者线程执行
cilk_spawn foo(args)
; 时,它启动了foo
函数的执行,但它本身不必等待foo
完成,就可以继续执行cilk_spawn
之后的代码 - 本质: 异步执行 [跟传统C语言不同]
对比 clik
AND C
:
- C 标准函数调用: 当一个线程调用
foo()
时, 控制权会完全转移 到foo()
函数- 调用线程会执行
foo()
中的指令, 并暂停执行自己函数中foo()
之后的代码 - 只有当
foo()
返回后, 控制权才会回到调用线程, 它才能继续执行foo()
之后的指令
- 调用线程会执行
- clik:
cilk_spawn
关键字指示系统可以创建一个新的逻辑控制流来执行foo
函数- 意味着
foo
可以与调用者并行执行 - 调用者线程在发出
cilk_spawn
后,可以选择继续执行其函数中cilk_spawn
之后的代码(称为"continuation") - 而
foo
则可能被另一个空闲的线程执行,或者甚至由调用者线程自己稍后执行
- 意味着
cilk_sync;
- 就像
join()
: 起到“汇聚”作用
(2) Abstraction vs. Implementation
cilk_spawn
提供的是一种并行执行的“可能性”或“声明”- 它告诉系统“这里有可以并行执行的工作”,但不关心具体如何调度(例如,哪个线程、何时)
- 这种高度抽象使得 Cilk 运行时可以采用各种复杂的调度策略(例如thread stealing, 马上就介绍)来高效地实现并行
cilk_sync
则是一个明确的“同步点”- 它强制所有被 spawn 的相关工作必须在此时完成,从而对调度行为施加了严格的约束,以确保程序的正确性
以 quicksort()
为例:
(3) Writing && Scheduling fork-join
programs
Writing:
原则: 体现适当的并行度 (potential parallelism)
- 要确保独立工作量至少与机器的并行执行能力相当,以充分利用所有处理器资源
- 为了更好地平衡负载,理想情况下,独立工作量应该多于机器的并行执行能力
- 同时也要注意,不要暴露过多的独立工作,导致任务粒度过小。过多的"松弛度"会带来管理细粒度任务的额外开销
- 引入 "并行松弛度 (
parallel slack
)" 的概念, 一般来说: \(ps=8\) 比较合适
Scheduling:
简单的调度,如暴力映射到不同threads上,是不行的。诸如context switch之类的开销太大,不可行
因此我们需要更加精细化、更加智能化、更加群体合作化的scheduling方式
下文就会讲到
Clik 底层实现¶
如何实现 clik_spawn()
¶
(1) Idle threads “steal” work from busy threads
(2) child first? OR continuation first?
一般默认: child first!
线程自己执行 spawn
的child部分, 把剩下的 continuation
塞进queue里
(3) 运行顺序: dequeue head/tail
Work queue implemented as a dequeue (double ended queue)
- Local thread pushes/pops from the "tail" (bottom)
- Remote threads steal from "head" (top)
(4) 当一个queue空闲了, 它该选择谁去帮扶?
空闲线程随机选择受害者线程
- 当一个工作线程完成了自己的所有工作(本地工作队列为空)而进入空闲状态时,它不会坐等或按固定顺序轮询
- 相反,它会随机选择一个其他线程作为潜在的“受害者”,尝试从该受害者线程的工作队列中窃取工作
- 为什么随机选择?随机性有助于避免“热点”问题(即: 所有空闲线程都集中尝试从同一个忙碌线程窃取)
(5) 为什么 Remote threads steal from "head"?
1) 这样可以steal最大量的工作, 以减少steal次数
当采用 “Continuation Stealing (child first)” 策略时, 调用者线程执行子任务, 并将 continuation(通常是较大粒度的、更早的任务部分) 推入其队列
队列顶部的任务往往是更早被推入的、代表着更大范围或更粗粒度的计算
因此,从队列顶部窃取可以获得一个较大的工作块。窃取大块工作意味着空闲线程不必频繁地进行窃取操作,从而减少了工作窃取的总次数和相关的同步开销
下面这个例子可以说明为什么"队列顶部的任务往往更加粗粒度":
2) 最大化每个线程执行工作的局部性
当窃取发生时,窃取线程拿走的是队列顶部的 “旧”延续。这意味着本地线程可以继续保持其良好的局部性,因为它仍在深度优先地处理新产生的子任务
而窃取线程获得一个独立的、大粒度的工作块,也可以在新获取的工作块内实现良好的局部性
3) 窃取线程和本地线程不会争用队列的相同元素,从而实现高效的无锁实现
本地线程从队列的尾部操作(push
/pull
),而窃取线程从队列的头部操作(steal
)
由于它们在队列的不同端进行操作,它们之间对队列数据结构的争用大大减少
这种“两端分离”的特性使得高效的、甚至无锁 (lock-free) 或弱锁 (relaxed locking) 的双端队列实现成为可能
如何实现 clik_sync()
¶
使用 Descriptor
来追踪当前的 {spawn, done}
情况
这一部分对于流程的理解还是很重要的,建议直接去看PPT
TL;DR: clik
最大特性: Greedy Join Scheduling Policy
- 永不空闲 (除非无工可做): Cilk 的所有工作线程都会始终尝试窃取工作
- 只有在整个系统均无工作可窃取时, 才"真正空闲": 只有当整个并行系统中的所有线程都耗尽了本地工作且无法从其他任何线程窃取到工作时,线程才会进入空闲状态
- 原始
spawn
线程不一定执行cilk_sync
后的逻辑:- 由于窃取的存在,启动了
cilk_spawn
调用的原始工作线程可能不会是最终执行cilk_sync
之后代码的线程 - 任何一个完成了所有相关被
spawn
任务的空闲线程都可能“接管”并继续执行cilk_sync
之后的代码
- 由于窃取的存在,启动了
额外说明:
- 管理窃取和同步点的额外开销(如: descriptor更新)仅在实际发生工作窃取时才会出现
- 窃取应不常发生: 上面提到,
child first + head stealing
, 会steal较大粒度的工作, 因此窃取操作本身应该不频繁地发生 - 大多数时间在本地工作: 大多数情况下,工作线程都在本地执行任务 (stealing毕竟只是少数)