前言
第十一章学了前缀和,这章学归并(Merge) ——把两个有序数组合并成一个有序数组。归并是排序算法的核心组件(归并排序),也是数据库操作的基础(JOIN)。并行归并的挑战在于:输出位置取决于两个数组的数据内容 ,不像矩阵乘法那样位置固定。第十二章讲解如何用**协同排名(Co-Rank)**技术解决这个"动态输入数据识别"问题。
📦 配套资源 :本系列文章配有完整的 GitHub 仓库 ,包含每章的练习题解答、CUDA 代码实现和详细注释。所有代码都经过测试,可以直接运行。
归并基础
什么是归并
归并 :给定两个已排序 的数组 A 和 B,生成一个包含所有元素的有序 数组 C。
1 2 3 A = [1, 3, 5, 7, 9] B = [2, 4, 6, 8, 10] C = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
串行归并
经典的双指针算法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 void merge_sequential (int *A, int m, int *B, int n, int *C) { int i = 0 , j = 0 , k = 0 ; while (i < m && j < n) { if (A[i] <= B[j]) { C[k++] = A[i++]; } else { C[k++] = B[j++]; } } while (i < m) C[k++] = A[i++]; while (j < n) C[k++] = B[j++]; }
时间复杂度 O(m+n),空间复杂度 O(1)(不计输出)。
并行化的挑战
问题 :输出位置 k 对应的输入位置 (i, j) 取决于数据内容。
1 2 3 要填充 C[5],需要知道: - A 和 B 中有多少元素 ≤ C[5] 的值 - 这取决于 A 和 B 的具体内容
不像矩阵乘法那样可以根据线程 ID 直接计算输入位置。
核心问题 :如何让每个线程知道自己应该处理 A 和 B 的哪一段?
协同排名(Co-Rank)
核心洞察
对于输出位置 k,假设它对应 A 中的前 i 个元素和 B 中的前 j 个元素,则:
这就是协同排名 约束。
问题转化 :给定 k,找到满足约束且保持有序性的 (i, j)。
二分搜索解法
有序性条件 :
1 A[i-1] ≤ B[j] 且 B[j-1] ≤ A[i]
即:A 中第 i 个元素应该排在 B 的第 j 个元素之前或相等,反之亦然。
算法 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 int co_rank (int k, int *A, int m, int *B, int n) { int i_low = max(0 , k - n); int i_high = min(k, m); while (i_low < i_high) { int i = (i_low + i_high) / 2 ; int j = k - i; if (i > 0 && j < n && A[i-1 ] > B[j]) { i_high = i; } else if (j > 0 && i < m && B[j-1 ] > A[i]) { i_low = i + 1 ; } else { return i; } } return i_low; }
时间复杂度 :O(log(min(k, m, n)))
示例
1 2 3 4 5 6 7 8 9 10 11 12 13 A = [1, 3, 5, 7, 9] (m=5) B = [2, 4, 6, 8, 10] (n=5) k = 6(输出位置 6) i + j = 6 尝试 i=3: j=3 A[2]=5 ≤ B[3]=8? ✓ B[2]=6 ≤ A[3]=7? ✓ 找到! 所以 C[0..5] 来自 A[0..2] 和 B[0..2] C[6..9] 来自 A[3..4] 和 B[3..4]
基础并行归并
每线程一个元素
最简单的并行化:每个线程负责计算一个输出元素。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 __global__ void merge_basic(int *A, int m, int *B, int n, int *C) { int k = blockIdx.x * blockDim.x + threadIdx.x; if (k < m + n) { int i = co_rank(k, A, m, B, n); int j = k - i; int i_next = co_rank(k + 1, A, m, B, n); int j_next = k + 1 - i_next; // 确定这个位置的值 if (i_next > i) { C[k] = A[i]; } else { C[k] = B[j]; } } }
问题
效率低 :每个线程都做一次二分搜索(O(log n)),总工作量 O(n log n)。
串行只需 O(n),并行反而做了更多工作!
分块并行归并
思路
不要每个元素都二分搜索,而是:
把输出分成若干块(Tile)
每个 Tile 做一次 Co-Rank 找到边界
Tile 内部用串行归并
算法
1 2 3 4 5 6 7 8 9 10 11 输出大小:m + n Tile 大小:T Tile 数量:(m + n + T - 1) / T 对于 Tile k: 起始位置:k * T 结束位置:min((k+1) * T, m+n) Co-Rank 找到 (i_start, j_start) 和 (i_end, j_end) 归并 A[i_start..i_end] 和 B[j_start..j_end]
CUDA 实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 #define TILE_SIZE 1024 __global__ void merge_tiled(int *A, int m, int *B, int n, int *C) { // 每个 Block 处理一个 Tile int k_start = blockIdx.x * TILE_SIZE; int k_end = min(k_start + TILE_SIZE, m + n); // Co-Rank 找边界 __shared__ int i_start, j_start, i_end, j_end; if (threadIdx.x == 0) { i_start = co_rank(k_start, A, m, B, n); j_start = k_start - i_start; i_end = co_rank(k_end, A, m, B, n); j_end = k_end - i_end; } __syncthreads(); // 每个线程处理 Tile 中的一部分 int tile_size = k_end - k_start; int elements_per_thread = (tile_size + blockDim.x - 1) / blockDim.x; int local_k = threadIdx.x * elements_per_thread; int local_k_end = min(local_k + elements_per_thread, tile_size); // 线程内 Co-Rank int A_seg_size = i_end - i_start; int B_seg_size = j_end - j_start; int local_i = co_rank(local_k, A + i_start, A_seg_size, B + j_start, B_seg_size); int local_j = local_k - local_i; int local_i_end = co_rank(local_k_end, A + i_start, A_seg_size, B + j_start, B_seg_size); int local_j_end = local_k_end - local_i_end; // 串行归并 merge_sequential(A + i_start + local_i, local_i_end - local_i, B + j_start + local_j, local_j_end - local_j, C + k_start + local_k); }
工作量分析
操作
次数
单次复杂度
Block Co-Rank
(m+n)/T
O(log(m+n))
线程 Co-Rank
(m+n)/T × blockDim
O(log T)
串行归并
(m+n)/T × blockDim
O(T/blockDim)
总工作量 ≈ O(m+n),接近串行!
共享内存优化
动机
前面的实现每个线程都访问全局内存做串行归并。如果把 Tile 数据加载到共享内存,可以大幅减少全局内存访问。
实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 #define TILE_SIZE 1024 __global__ void merge_shared(int *A, int m, int *B, int n, int *C) { __shared__ int A_s[TILE_SIZE]; __shared__ int B_s[TILE_SIZE]; __shared__ int C_s[TILE_SIZE]; int k_start = blockIdx.x * TILE_SIZE; int k_end = min(k_start + TILE_SIZE, m + n); int tile_size = k_end - k_start; // Co-Rank 找边界 __shared__ int i_start, j_start, i_end, j_end; if (threadIdx.x == 0) { i_start = co_rank(k_start, A, m, B, n); j_start = k_start - i_start; i_end = co_rank(k_end, A, m, B, n); j_end = k_end - i_end; } __syncthreads(); int A_size = i_end - i_start; int B_size = j_end - j_start; // 加载到共享内存 for (int i = threadIdx.x; i < A_size; i += blockDim.x) { A_s[i] = A[i_start + i]; } for (int i = threadIdx.x; i < B_size; i += blockDim.x) { B_s[i] = B[j_start + i]; } __syncthreads(); // 线程内 Co-Rank(使用共享内存) int elements_per_thread = (tile_size + blockDim.x - 1) / blockDim.x; int local_k = threadIdx.x * elements_per_thread; int local_k_end = min(local_k + elements_per_thread, tile_size); int local_i = co_rank(local_k, A_s, A_size, B_s, B_size); int local_j = local_k - local_i; int local_i_end = co_rank(local_k_end, A_s, A_size, B_s, B_size); int local_j_end = local_k_end - local_i_end; // 归并到共享内存 merge_sequential(A_s + local_i, local_i_end - local_i, B_s + local_j, local_j_end - local_j, C_s + local_k); __syncthreads(); // 写回全局内存 for (int i = threadIdx.x; i < tile_size; i += blockDim.x) { C[k_start + i] = C_s[i]; } }
性能提升
版本
全局内存访问
性能
基础
O(m+n) 次
1×
分块
O(m+n) 次
~5×
共享内存
O(m+n)/T 次
~10×
循环缓冲区优化
问题
共享内存有限,如果 A_size + B_size > 共享内存容量怎么办?
思路:流式处理
用循环缓冲区 逐块处理:
加载 A 和 B 的一小块到共享内存
归并能归并的部分
加载下一块,继续归并
重复直到完成
关键点
消费跟踪 :记录 A 和 B 各消费了多少元素。
缓冲区管理 :循环使用缓冲区空间。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 __global__ void merge_circular(int *A, int m, int *B, int n, int *C) { __shared__ int buffer_A[BUFFER_SIZE]; __shared__ int buffer_B[BUFFER_SIZE]; // ... 初始化 ... int a_consumed = 0, b_consumed = 0; int a_loaded = 0, b_loaded = 0; int c_produced = 0; while (c_produced < tile_size) { // 填充缓冲区 while (a_loaded - a_consumed < BUFFER_SIZE && a_loaded < A_size) { buffer_A[a_loaded % BUFFER_SIZE] = A_s[a_loaded]; a_loaded++; } while (b_loaded - b_consumed < BUFFER_SIZE && b_loaded < B_size) { buffer_B[b_loaded % BUFFER_SIZE] = B_s[b_loaded]; b_loaded++; } // 归并一批 // ... } }
这种技术在数据量远超共享内存时很有用。
归并路径可视化
Co-Rank 的几何解释
把归并过程可视化为 2D 网格中的路径:
1 2 3 4 5 6 7 8 9 10 11 12 B: 0 1 2 3 4 +---+---+---+---+ A:0 | | | | | +---+---+---+---+ 1 | | | | | +---+---+───+───+ 2 | | |\ | | +---+---+─\─+---+ 3 | | | \| | +---+---+---+\--+ 4 | | | | \ | +---+---+---+---+
路径规则 :
从 (0,0) 到 (m,n)
每步向右(取 A 元素)或向下(取 B 元素)
选择较小的元素决定方向
Co-Rank(k) :路径上第 k 步的位置。
并行化视角
1 2 3 4 5 6 把输出分成 T 段: 段 0: 路径 [0, T) 段 1: 路径 [T, 2T) ... 每段的起点由 Co-Rank 确定。
归并排序
分治结构
归并排序 = 递归拆分 + 归并合并
1 2 3 4 5 6 7 8 9 10 11 12 13 [3,1,7,4,5,2,6,8] ↓ 拆分 [3,1,7,4] [5,2,6,8] ↓ 拆分 [3,1] [7,4] [5,2] [6,8] ↓ 拆分 [3][1] [7][4] [5][2] [6][8] ↓ 归并 [1,3] [4,7] [2,5] [6,8] ↓ 归并 [1,3,4,7] [2,5,6,8] ↓ 归并 [1,2,3,4,5,6,7,8]
并行归并排序
每层归并可以并行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 void parallel_merge_sort(int *data, int n) { // 从单元素开始,逐层归并 for (int width = 1; width < n; width *= 2) { int num_merges = (n + 2 * width - 1) / (2 * width); // 并行执行所有归并 merge_kernel<<<num_merges, BLOCK_SIZE>>>( data, n, width ); } } __global__ void merge_kernel(int *data, int n, int width) { int merge_id = blockIdx.x; int left = merge_id * 2 * width; int mid = min(left + width, n); int right = min(left + 2 * width, n); // 归并 [left, mid) 和 [mid, right) merge_shared(data + left, mid - left, data + mid, right - mid, temp + left); }
复杂度
指标
串行归并排序
并行归并排序
层数
log₂N
log₂N
每层工作
O(N)
O(N)
总工作
O(N log N)
O(N log N)
并行时间
O(N log N)
O(log²N)*
*假设有足够多的处理器,每层归并并行完成。
与其他算法的关系
归并 vs 基数排序
特性
归并排序
基数排序
比较次数
O(N log N)
O(N × 位数)
稳定性
稳定
稳定
适用类型
通用
整数/定长键
GPU 友好度
中等
高
基数排序在 GPU 上通常更快,但归并排序适用范围更广。
归并 vs 快速排序
特性
归并排序
快速排序
最坏复杂度
O(N log N)
O(N²)
空间
O(N)
O(log N)
并行化
容易
较难
稳定性
稳定
不稳定
归并排序的确定性和稳定性使其在并行计算中更受欢迎。
CUB/Thrust 库
使用 Thrust
1 2 3 4 5 6 7 8 9 10 #include <thrust/merge.h> #include <thrust/device_vector.h> void mergeWithThrust(int *A, int m, int *B, int n, int *C) { thrust::device_ptr<int> d_A(A); thrust::device_ptr<int> d_B(B); thrust::device_ptr<int> d_C(C); thrust::merge(d_A, d_A + m, d_B, d_B + n, d_C); }
使用 CUB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 #include <cub/cub.cuh> void mergeWithCub(int *d_A, int m, int *d_B, int n, int *d_C) { void *d_temp = nullptr; size_t temp_bytes = 0; cub::DeviceMerge::Merge(d_temp, temp_bytes, d_A, m, d_B, n, d_C); cudaMalloc(&d_temp, temp_bytes); cub::DeviceMerge::Merge(d_temp, temp_bytes, d_A, m, d_B, n, d_C); cudaFree(d_temp); }
小结
第十二章深入讲解并行归并:
核心挑战 :输出位置取决于输入数据内容(动态数据识别),不能简单根据线程 ID 确定。
Co-Rank 技术 :给定输出位置 k,用二分搜索找到对应的输入位置 (i, j),满足 i + j = k。这是并行归并的关键。
分块归并 :把输出分成 Tile,每个 Tile 做一次 Co-Rank 找边界,Tile 内串行归并。平衡了并行开销和工作效率。
共享内存优化 :把 Tile 数据加载到共享内存,减少全局内存访问。循环缓冲区处理大 Tile。
归并路径 :归并过程可视化为 2D 网格中的路径,Co-Rank 找的是路径上的点。
归并排序 :log N 层归并,每层可以并行。总工作量 O(N log N),并行时间可达 O(log² N)。
归并是排序、数据库操作的基础。掌握 Co-Rank 技术,就能处理各种"输出位置依赖输入数据"的问题。下一章学习排序——归并的直接应用。
参考资料:
Hwu, W., Kirk, D., & El Hajj, I. (2022). Programming Massively Parallel Processors: A Hands-on Approach (4th Edition). Morgan Kaufmann.
Odeh, S., Green, O., Miklau, Z., Rupnow, K., & Hwu, W. (2012). Merge Path - A Visually Intuitive Approach to Parallel Merging . IPDPS.
NVIDIA CUB Library
本文 GitHub 仓库 : https://github.com/psmarter/PMPP-Learning