跳转至

Data-Parallel Thinking

这节课的重点在算法, 之前我们提到很多 "workers assignment", 今天来讲其对应的一些操作

举个简单的例子, 当每个worker都做完自己的任务, 应该如何"聚合"呢?

这就自然引出了今天的主题: data-parallel 操作背后的算法设计


我们会经常在 CCL(集体通信语言) 领域的论文中接触到这些名词:

Text Only
1
2
3
4
map
reduce
groupBy
join

(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 的官方源语定义:

alt text


相信看到这里你有点摸不着头脑, 主要的困惑应该是 "副作用" 到底是什么? 为什么要用 "side-effect free primitives" 来处理 data-parallel model?

我们举个例子, 假设我们要计算一个序列的和,并将每个元素同时加 10

(1) 带有副作用的map: 单一函数作用于sequence

允许副作用的 map 会导致大规模争用, 并要求程序员手动管理复杂的细粒度同步:

  • 极大地损害了并行效率
  • 给写程序带来了巨大障碍
C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 传统数组/循环 (带有副作用)
g(x) = { 
    global_sum = global_sum + x;
    return x + 10;
}

a[] = {3, 4, 5, 6, 7, 8};

// racing: 多个并行线程 (例如处理 3 和 8 的线程) 将同时尝试写入或读取 global_sum 这个共享变量
// 因此必须要: 引入复杂的细粒度同步机制 (mutex)

(2) 无副作用的map

C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
f(x) = { 
    return x + 10;
}

a[] = {3, 4, 5, 6, 7, 8};

// no racing: map 操作只读取输入序列并写入输出序列. 由于 f 是纯函数, 线程之间无需同步或通信

// 解决方案内置于 primitives 中:
// 计算总和的任务应该使用专门用于 累加/聚合 的 primitives: fold / reduce

通过要求 f 是无副作用的, 以及 sequence 数据类型限制只能通过特定的原语访问:

  1. 大规模并行化成为可能: map 可以将计算任务分解给数千个 CUDA 线程同时执行,而无需担心线程间的同步开销
  2. 抽象化同步: 程序员无需关心如何管理锁或原子操作。这些原语的底层实现已经包含了最高效的并行同步和通信策略

Fold

Fold 的官方源语定义: 加数求和

alt text

如何为 Fold 并行化:

alt text

Scan

Scan 的官方源语定义: 前缀和

alt text

Case Study: Work-efficient Exclusive Scan

(1) 实现方式:

alt text

Scan 是一种关键的数据并行原语, 但其高效实现并非一成不变, 而是需要根据目标并行机器的特性 (如: Shared-memory CPU vs. SIMD GPU) 定制不同的策略

(2) 两个真实系统的部署策略:

  • Shared-memory CPU
    • 如此设计的合理性:
      • 具有极高的局部性
      • 在小型多核系统上, 两个处理器之间访问数据的成本可能相同
  • SIMD GPU
    • 如此设计的合理性: (并未选择上述的 work-effieient exclusive scan (WEES))
      • 会产生复杂的跨步(striding) 和数据依赖,导致较低的 SIMD 利用率. 即 Warp 中的许多线程在某些步骤中可能处于空闲状态
      • 需要的指令数量比 WEES 的实现少 2 倍以上

alt text

(3) 大规模部署: Multi-level Implementation

扫描算法通常采用 "分层实现" 以匹配 Memory Hierarchy 和机器的不同并行级别

alt text

  1. "Warp-level" 扫描: 使用 scan_warp 在 32 个线程内部高效完成部分扫描结果 (局部前缀和)
  2. "跨Warp" / "TB-level" 扫描: 解决不同 Warp 计算结果之间的依赖关系
    • 收集基准值: 每个 Warp31 线程(即 Warp 的最后一个线程)将其 Warp 的局部总和 复制到共享内存中,作为该 Warp 的基准值
    • 扫描基准值: 通常由 Warp0 对这些基准值进行第二次扫描, 计算出每个 Warp 应该加上的全局偏移量
    • 应用基准值: 其他 Warp (warp_id > 0) 将它们应该应用的全局偏移量 (ptr[warp_id-1]) 加到它们各自的局部扫描结果
  3. "跨TB" / "Kernel-level" 扫描: 对于超大型数组 (如 100 万元素), 需要多个 Kernel Launch 来处理
    • 每个线程块计算其局部扫描结果和总和
    • 然后启动第二个 Kernel 来扫描这些总和 (基准值)
    • 最后启动第三个 Kernel 将基准值加到所有元素上

Parallel Segmented Scan

分段扫描: 允许程序员以规则的、数据并行的方式来表达那些 原本由不规则数据结构主导的复杂计算, 从而可以利用现有的、高效的并行原语实现

常见操作: 用 bool 数组 (flags[])表示分段

alt text

举个详细的例子, 加深理解:

  1. 常规的并行方法是 "一个线程(或处理器)负责计算矩阵的一行"
    • 问题在于每行的数据量不同(irregular data)
    • 有的线程很快算完,有的还在忙,负载不均,效率低下
  2. Segmented Scan(分段扫描)的思想
    • 我们不按“行”来并行,而是按“每一个非零元素”来并行
    • 总共有 7 个非零元素,所以可以(理想情况下)启动 7 个线程,每个线程只做一个乘法。这是一个规则的、数据并行的操作
    • 然后,我们再用一个高效的并行原语("Segmented Scan")把这些乘积按行加起来

alt text

我觉得这个例子非常值得深入研究, 有几个很tricky的设计点:

  1. x[col[i]]的正确性: col_idxx_idx 是一一对应的, 从矩阵的"长边"遍历可知
  2. flag[]的设计: 将不同的segment分开

Other Primitives

还有很多其他的通信源语:

  • groupBy: 针对 pair<> 类型, 将 key 相同的pair进行合并
    • alt text
  • filter: 筛除不符合过滤条件的data
  • sort: data排序
  • gather: 将 "大buffer"内容 映射到 "小vector"里, 映射规则由"idx vector"给出
    • alt text
  • scatter: 根据"idx vector"给出的映射规则, 将 "小vector"内容 还原到 "大buffer"中
    • 上面 gather 的逆向过程就是 scatter
    • alt text

Summary

这节课聚焦于 Data-parallel Model 的算法实现, 以及 "为什么要采用primitives"

梳理一下重点:

  1. 为什么要采用 primitives?
    • irregular parallelism -> regular parallelism: 确保负载均衡
    • 细粒度 -> 粗粒度: 减少在lock()等操作的开销, 同时为coding减轻负担
  2. 常见 primitives:
    • map
    • fold
    • scan
    • groupBy
    • filter
    • sort
    • gather / scatter