PMPP-第十五章:图遍历
前言
第十四章我们学习了稀疏矩阵,那一章的重点是 SpMV(稀疏矩阵-向量乘法)。其实,图(Graph)和稀疏矩阵是一体两面的:图的邻接矩阵通常就是稀疏矩阵。第十五章我们将深入探讨图算法的核心——图遍历,特别是广度优先搜索(BFS)。BFS 是最短路径、连通分量、最大流等众多图算法的基础,也是 GPU 处理不规则数据结构的典型案例。
📦 配套资源:本系列文章配有完整的 GitHub 仓库,包含每章的练习题解答、CUDA 代码实现和详细注释。所有代码都经过测试,可以直接运行。
图的表示
从现实世界到图
图由节点(Vertex)和边(Edge)组成。
- 社交网络:节点是人,边是关注/好友关系。
- 道路网:节点是路口,边是道路。
- 引用网络:节点是论文,边是引用关系。
这些图通常是稀疏的(每个节点平均只连接少数其他节点)且无标度的(少数节点有极多连接,俗称"大V")。
存储格式:CSR
上一章介绍的 CSR (Compressed Sparse Row) 格式不仅适合 SpMV,也是存储图的标准格式。
对于一个有 个节点、 条边的图:
- row_ptr (长度 ):
row_ptr[i]指向节点 的邻居列表在col_idx中的起始位置。 - col_idx (长度 ):存储所有边的目标节点。
1 | 节点 0 的邻居:col_idx[row_ptr[0]] ... col_idx[row_ptr[1]-1] |
广度优先搜索 (BFS)
问题定义
给定起点 ,BFS 需要访问所有可达节点,并计算从 到每个节点的最短距离(层数)。
串行 BFS
经典实现使用队列(Queue):
1 | void BFS_sequential(int S, int *row_ptr, int *col_idx, int *level, int num_nodes) { |
并行化的挑战
- 不规则访问:每个节点的邻居数量差异巨大(负载不均衡)。
- 动态工作集:每一层的节点数都在变化。
- 并发冲突:多个节点可能同时尝试访问同一个邻居。
并行 BFS:按层同步
GPU 适合解决可以分层的任务。BFS 天然分层:
第 0 层(起点) -> 第 1 层邻居 -> 第 2 层邻居 -> …
我们可以采用**按层同步(Level Synchronous)**的方法:
- 维护一个**前沿(Frontier)**数组,标记当前层需要扩展的节点。
- 并行处理 Frontier 中的每个节点,找到下一层的邻居。
- 更新 Frontier,进入下一轮迭代。
初始版本:布尔前沿数组
用一个布尔数组 F 表示当前层节点,next_F 表示下一层。
1 | __global__ void BFS_kernel(int *row_ptr, int *col_idx, int *level, |
Host 端循环:
1 | while (frontier_not_empty) { |
痛点:并发写冲突
1 | if (level[neighbor] == -1) { |
当多个节点指向同一个邻居时,会发生写竞争。
虽然在这里,只要有一个线程写入成功即可(因为距离都是 current_depth + 1),且 next_F 设为 true 多次也无妨,但在有些硬件或算法变体上,这会导致数据未定义行为。
更安全的做法是使用原子操作,但这很慢。
在 BFS 这种距离只更新一次的场景,可以利用良性竞争:多线程同时写入相同的值通常是可以接受的,或者使用 atomicCAS 保证只有第一个访问者能更新。
1 | if (level[neighbor] == -1) { |
优化一:工作效率与稀疏前沿
上面的布尔数组方法有一个大问题:工作效率极低。
每一层都要启动 num_nodes 个线程,即使当前层只有 1 个节点!
稀疏前沿(Sparse Frontier):
不存储布尔值,而是将当前层节点的 ID 存储在一个紧凑的队列中。
Queue: [0, 5, 12]
这样只需启动 num_frontier 个线程。这需要动态队列管理。
全局原子队列
1 | __global__ void BFS_queue_kernel(..., int *queue, int *q_count, int *next_queue, int *next_q_count) { |
问题:所有线程争抢 next_q_count 这个全局计数器,原子操作争用极其严重!
优化二:私有化与分层队列
类似于第九章的直方图优化,我们可以使用**私有化(Privatization)**技术。
- Block 级队列:每个 Block 在共享内存中维护一个小的局部队列。
- 局部聚合:线程发现新邻居时,先写入共享内存队列。
- 全局提交:Block 满或结束时,一次性申请全局队列空间,将局部队列拷贝过去。
1 | __shared__ int s_queue[BLOCK_SIZE]; |
这大大减少了全局原子操作的次数。
优化三:方向优化(Top-Down vs Bottom-Up)
这是 BFS 优化中最重要的策略之一,源自 Scott Beamer 的开创性工作。
Top-Down (Push)
传统的 BFS 是**推(Push)**模式:
- 看着 frontier 里的节点。
- 检查它们的邻居。
- 如果邻居没访问过,更新邻居。
优点:前沿(Frontier)很小时效率高。
缺点:前沿很大时,边数可能是节点数的几十倍,大量冗余检查(很多邻居已经被访问过了)。
Bottom-Up (Pull)
反过来思考:**拉(Pull)**模式。
- 看着所有未访问的节点。
- 检查它们的邻居是否在 frontier 里。
- 只要发现一个邻居在 frontier 里,我就"被发现"了,更新自己,不需要检查其他邻居。
优点:当前沿很大时,不需要遍历所有边,只需找到一个父节点即可停止。
缺点:需要扫描所有未访问节点,前沿小时效率极低。
混合策略 (Direction-Optimizing BFS)
- 开始时(前沿小):用 Top-Down。
- 中间时(前沿爆炸):切换到 Bottom-Up。
- 结尾时(前沿收缩):切回 Top-Down。
这通常能带来数倍的性能提升。
负载均衡:处理无标度网络
在社交网络中,某些节点可能有百万级邻居(如名人),而大多节点只有几个邻居。
如果一个线程处理一个节点:
- 线程 A 处理 2 个邻居(瞬间完成)。
- 线程 B 处理 100万 个邻居(卡死整个 Warp/Block)。
解决方案:
- 节点并行:每个线程处理一个节点(适合普通节点)。
- 边并行:对于度数极大的节点,让整个 Warp 甚至整个 Block 协作处理它的邻居列表。
这需要预处理来识别大度节点,类似 SpMV 中的 CSR-Vector 或 CSR-Stream 策略。
总结
第十五章展示了如何用 GPU 解决图遍历问题。相比于规则的矩阵运算,图算法充满了挑战:
- CSR 格式:是图和稀疏矩阵的桥梁。
- 按层同步:是并行 BFS 的基本框架。
- 队列管理:从简单的布尔数组到复杂的双层队列(私有化),是为了解决工作效率和原子争用问题。
- 方向优化:Push vs Pull 的动态切换,体现了算法设计对数据特征的适应。
- 负载均衡:应对无标度特性,需要细粒度的任务调度。
图算法是高性能计算(HPC)皇冠上的明珠之一(Graph500 排名),掌握了它,意味着你对 GPU 并行模式的理解达到了新的高度。
参考资料:
- Hwu, W., Kirk, D., & El Hajj, I. (2022). Programming Massively Parallel Processors: A Hands-on Approach (4th Edition). Morgan Kaufmann.
- Merrill, D., et al. (2012). Scalable GPU Graph Traversal. PPoPP.
- Beamer, S., et al. (2012). Direction-Optimizing Breadth-First Search. SC12.
本文 GitHub 仓库: https://github.com/psmarter/PMPP-Learning
