Data-Parallel Thinking¶
这节课的重点在算法, 之前我们提到很多 "workers assignment", 今天来讲其对应的一些操作
举个简单的例子, 当每个worker都做完自己的任务, 应该如何"聚合"呢?
这就自然引出了今天的主题: data-parallel 操作背后的算法设计
我们会经常在 CCL(集体通信语言) 领域的论文中接触到这些名词:
Text Only | |
---|---|
1 2 3 4 |
|
(1) 操作对象: sequences of data
- 注意 sequence 是我们特别引出的新名词, 可以将 "sequence" 理解成 "array"
- 但二者有区别: array 可以任意访问, 比如
a[i]
; 但 sequence 只能被对应的primitives访问并操作, 比如b = map f a
(2) 这些 primitives 是内置的, 代码调用它们的时候, 会自适应机器, 从而在并行的机器上高效运行
(3) 为什么要有sequence这个奇怪的定义? aka. 为什么它只能被对应的primitives访问?
Data-parallel model 的目标是: 将计算组织成对序列元素的操作,例如: 对序列的所有元素执行相同的函数
像array那样任意访问, 可能意味着对内存的随机读写, 这很容易引入复杂的、细粒度的同步需求。通过使用 primitives, 旨在将细粒度同步转化为粗粒度同步 , 并专注于对大数据集合执行简单的、高度可并行化的操作 (可参考下面Map部分对于"数据并行原语的优势总结")
这种限制(只能通过特定的、定义清晰的原语访问)使得运行时系统或编译器能够确定操作之间 没有细粒度的依赖或冲突, 从而给实现提供了重新排序/并行化处理序列元素的灵活性,实现者可以根据需要决定如何高效地进行并行处理
Map¶
Map
的官方源语定义:
相信看到这里你有点摸不着头脑, 主要的困惑应该是 "副作用" 到底是什么? 为什么要用 "side-effect free primitives" 来处理 data-parallel model?
我们举个例子, 假设我们要计算一个序列的和,并将每个元素同时加 10
(1) 带有副作用的map
: 单一函数作用于sequence
允许副作用的 map
会导致大规模争用, 并要求程序员手动管理复杂的细粒度同步:
- 极大地损害了并行效率
- 给写程序带来了巨大障碍
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 |
|
(2) 无副作用的map
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 |
|
通过要求 f 是无副作用的, 以及 sequence 数据类型限制只能通过特定的原语访问:
- 大规模并行化成为可能: map 可以将计算任务分解给数千个 CUDA 线程同时执行,而无需担心线程间的同步开销
- 抽象化同步: 程序员无需关心如何管理锁或原子操作。这些原语的底层实现已经包含了最高效的并行同步和通信策略
Fold¶
Fold
的官方源语定义: 加数求和
如何为 Fold
并行化:
Scan¶
Scan
的官方源语定义: 前缀和
Case Study: Work-efficient Exclusive Scan¶
(1) 实现方式:
Scan
是一种关键的数据并行原语, 但其高效实现并非一成不变, 而是需要根据目标并行机器的特性 (如: Shared-memory CPU vs. SIMD GPU) 定制不同的策略
(2) 两个真实系统的部署策略:
- Shared-memory CPU
- 如此设计的合理性:
- 具有极高的局部性
- 在小型多核系统上, 两个处理器之间访问数据的成本可能相同
- 如此设计的合理性:
- SIMD GPU
- 如此设计的合理性: (并未选择上述的
work-effieient exclusive scan (WEES)
)- 会产生复杂的跨步(striding) 和数据依赖,导致较低的 SIMD 利用率. 即 Warp 中的许多线程在某些步骤中可能处于空闲状态
- 需要的指令数量比
WEES
的实现少 2 倍以上
- 如此设计的合理性: (并未选择上述的
(3) 大规模部署: Multi-level Implementation
扫描算法通常采用 "分层实现" 以匹配 Memory Hierarchy 和机器的不同并行级别
- "Warp-level" 扫描: 使用
scan_warp
在 32 个线程内部高效完成部分扫描结果 (局部前缀和) - "跨Warp" / "TB-level" 扫描: 解决不同 Warp 计算结果之间的依赖关系
- 收集基准值: 每个 Warp31 线程(即 Warp 的最后一个线程)将其 Warp 的局部总和 复制到共享内存中,作为该 Warp 的基准值
- 扫描基准值: 通常由 Warp0 对这些基准值进行第二次扫描, 计算出每个 Warp 应该加上的全局偏移量
- 应用基准值: 其他 Warp (
warp_id > 0
) 将它们应该应用的全局偏移量 (ptr[warp_id-1]
) 加到它们各自的局部扫描结果
- "跨TB" / "Kernel-level" 扫描: 对于超大型数组 (如 100 万元素), 需要多个 Kernel Launch 来处理
- 每个线程块计算其局部扫描结果和总和
- 然后启动第二个 Kernel 来扫描这些总和 (基准值)
- 最后启动第三个 Kernel 将基准值加到所有元素上
Parallel Segmented Scan¶
分段扫描: 允许程序员以规则的、数据并行的方式来表达那些 原本由不规则数据结构主导的复杂计算, 从而可以利用现有的、高效的并行原语实现
常见操作: 用 bool
数组 (flags[]
)表示分段
举个详细的例子, 加深理解:
- 常规的并行方法是 "一个线程(或处理器)负责计算矩阵的一行"
- 问题在于每行的数据量不同(irregular data)
- 有的线程很快算完,有的还在忙,负载不均,效率低下
- Segmented Scan(分段扫描)的思想
- 我们不按“行”来并行,而是按“每一个非零元素”来并行
- 总共有 7 个非零元素,所以可以(理想情况下)启动 7 个线程,每个线程只做一个乘法。这是一个规则的、数据并行的操作
- 然后,我们再用一个高效的并行原语("Segmented Scan")把这些乘积按行加起来
我觉得这个例子非常值得深入研究, 有几个很tricky的设计点:
x[col[i]]
的正确性:col_idx
和x_idx
是一一对应的, 从矩阵的"长边"遍历可知flag[]
的设计: 将不同的segment分开
Other Primitives¶
还有很多其他的通信源语:
groupBy
: 针对pair<>
类型, 将key
相同的pair进行合并filter
: 筛除不符合过滤条件的datasort
: data排序gather
: 将 "大buffer"内容 映射到 "小vector"里, 映射规则由"idx vector"给出scatter
: 根据"idx vector"给出的映射规则, 将 "小vector"内容 还原到 "大buffer"中- 上面
gather
的逆向过程就是scatter
- 上面
Summary¶
这节课聚焦于 Data-parallel Model 的算法实现, 以及 "为什么要采用primitives"
梳理一下重点:
- 为什么要采用 primitives?
- irregular parallelism -> regular parallelism: 确保负载均衡
- 细粒度 -> 粗粒度: 减少在
lock()
等操作的开销, 同时为coding减轻负担
- 常见 primitives:
map
fold
scan
groupBy
filter
sort
gather
/scatter