PMPP Learning-Chapter 12 Merge-An Introduction to Dynamic Input Data Identification
12 Merge-An Introduction to Dynamic Input Data Identification
有序归并操作接受两个有序列表并生成一个合并后的有序列表。
12.1 Background
假设数组中的每个元素都有一个键并且键定义了一个用 ≤ 表示的顺序关系。下图展示了基于数字排序关系的简单归并函数的操作。一般来说,如果键值相等的元素在输出中的顺序与其在输入中的顺序相同,则称排序操作是稳定的。
12.2 A Sequential Merge Algorithm
归并操作可以用如下一个简单的顺序算法来实现。顺序归并函数访问 A 和 B 的每个输入元素一次,并向 C 中每个位置写入一次。其算法复杂度为 O(m+n).
1 |
|
12.3 A Parallelization Approach
每个线程首先确定它将要负责的输出位置范围,并使用该输出范围作为 co-rank
函数的输入,以确定所负责 C 输出范围的对应的 A 和 B 输入范围。这样每个线程在它们的子数组上执行顺序合并函数,从而并行地进行合并。
- Observation 1:子数组
C[0]-C[k-1]
(k 个元素) 是A[0]-A[i-1]
(i 个元素) 和B[0]-B[k-i-1]
(k-i 个元素) 的归并结果。 - Observation 2:对于任意满足 0≤k≤m+n 的 k,我们可以找到唯一的 i 和 j 使得 k=i+j, 0≤i≤m, 0≤j≤n,并且子数组
C[0]-C[k-1]
是子数组A[0]-A[i-1]
和子数组B[0]-B[j-1]
合并的结果。唯一的索引 i 和 j 被称C[k]
的 co-rank.
我们可以通过将输出数组划分为子数组,并让每个线程负责一个子数组的生成来划分工作。由于并行归并算法中每个线程使用的输入元素的范围取决于实际的输入值使得我们需要辅助函数来完成。
12.4 Co-rank Function Implementation
将 co-rank 函数定义为接受输出数组 C 中元素的位置 k 和两个输入数组 A 和 B的信息,并返回输入数组 A 对应的 co-rank 值 i.
以下图为例,假设线程 1 的 co-rank 函数的目标是为其秩 k1=4 确定 co-rank值 i1=3 和 j1=1. 也就是说,从 C[4]
开始的子数组将由从 A[3]
和 B[1]
开始的子数组合并生成。我们可以发现线程 t 使用的输入子数组由线程 t 和线程 t+1 的 co-rank 确定。
目标是找到使得 A[i - 1] <= B[j]
并且 B[j - 1] <= A[i]
的索引。
- 如果
A[i-1] > B[j]
,说明A[i]
太大,需要减少 i,并增加 j。 - 如果
B[j-1] > A[i]
,说明B[j]
太大,需要减少 j,并增加 i。
每次调整时,i 和 j 都按照二分方式调整,即调整的步长是 delta / 2. i 和 i_low 确定了当前正在搜索的数组 A 的范围。
1 |
|
12.5 A Basic Parallel Merge Kernel
在剩下的小节里,我们假设输入数组 A 和 B 存储在全局内存中,一个内核被启动用来合并两个输入数组,输出一个同样位于全局内存中的数组 C.
下面内核是并行归并的直接实现。它首先通过计算当前线程 (k_curr
) 和下一个线程 (k_next
) 产生的输出子数组的起点来确定负责输出的范围。然后分别调用自己和后一个线程的 co_rank 函数来确定对应的 A 和 B 输入子数组的范围。最后调用顺序合并函数来合并两个输入子数组,并将结果写入输出子数组。
1 |
|
上面的基本归并内核有 2 个问题:
- warp 中的相邻线程在读写输入和输出子数组元素时不会访问相邻的内存位置。
- 线程在执行 co-rank 函数时还需要从全局内存访问 A 和 B 的元素。
12.6 A Tiled Merge Kernel to Improve Coalescing
注意到相邻线程使用的 A 和 B 子数组在内存中彼此相邻。我们可以为为每个块调用 co-rank 函数来获得其 A 和 B 子数组的起始和结束位置。
回忆一下改进内核内存合并的主要策略有三种:
- 重新组织线程到数据的映射。
- 重新组织数据本身。
- 以合并的方式在全局内存和共享内存之间传输数据,并在共享内存中执行不规则访问。
下图展示了分段合并内核的块级别设计。A_S 和 B_S 可能无法覆盖块的整个输入子数组,因此在每次迭代期间,块中的所有线程将协作从块的 A 和 B 子数组中加载 x 个元素。这样每个块有足够的输入元素来生成至少 x 个输出数组元素 (在最坏的情况下,当前输出部分的所有元素可能都来自 A 或 B 的子数组)。假设每个块负责 y 个输出元素,则需要进行 y/x 次归并。每个块中的线程将在每次迭代中使用 A_S 的一部分和 B_S 的一部分 (深灰色部分)
下面是分段合并内核的实现的第一部分。本质上是线程级基本合并内核的块级版本的代码。每个块的第一个线程负责计算当前块和下一个块的开始输出索引的位置以及他们的 co-rank. 结果被放入共享内存中,以便块中的所有线程都可以看到。
1 |
|
第二部分线程使用它们的 threadIdx.x
的值来确定要加载的元素,因此连续的线程加载连续的元素,内存访问是合并的。每次迭代从 A 和 B 数组中加载当前tile的起始点取决于块的所有线程在之前的迭代中消耗的 A 和 B 元素的总数。下图说明了 while 循环第二次迭代的索引计算。每个块在第一次迭代中消耗的 A 元素部分 为 A 子数组开头的白色小部分 (用竖条标记)。if 语句确保线程只加载 A 子数组剩余部分中的元素。
1 |
|
第三部分则是每个块的线程对共享内存的数组进行归并。在更新索引的部分中最后一次迭代中 A_s 和 B_s 可能没有 tile_size 个元素,调用 co-rank 可能会得到错误结果。但是,由于 while 循环不会进一步迭代,因此不会使用结果,因此不会造成任何影响。
1 |
|
12.7 A Circular Buffer Merge Kernel
上一节的内核不是那么高效因为下一次迭代 tile 的一部分已经被加载到共享内存中,但是我们每次迭代从全局内存中重新加载整个块,并覆盖掉前一次迭代中的这些元素。下图展示了 merge_circular_buffer_kernel 的主要思想,添加了两个额外的变量 A_S_start 和B_S_start,使得 while 循环的每次迭代动态确定从 A 和 B 的哪个位置开始加载,这样可以利用前一次迭代中剩余的 A_s 和 B_s 元素。修改后每个 for 循环都只加载 A_S_consumed 表示的填充 tile 所需的元素数量。因此,线程在第 i 次 for 循环迭代中加载的A 元素是 A[A_curr+A_S_consumed+i+threadIdx.x]
. 取模(%) 操作检查索引值是否大于或等于 tile_size.
!A Circular Buffer Scheme for Managing the Shared Memory Tiles
12.8 Thread Coarsening for Merge
多个线程并行执行归并的代价是每个线程必须执行自己的二进制搜索操作来识别其输出索引的 co-rank. 本章中介绍的所有内核都已经应用了线程粗化,因为它们都是为每个线程处理多个元素而设计的。在完全未粗化的内核中,每个线程将负责单个输出元素。