并行与分布式系统中的并行快速排序:基于分治的任务并行算法
题目描述
在并行与分布式系统中,快速排序(Quicksort)的并行化是一个经典问题。我们希望设计一个并行算法,能高效地将一个存储在共享内存(例如,多核机器)或分布式内存(例如,集群)中的大型无序数组进行排序。传统的快速排序由于其分治特性,天然适合并行化。然而,直接的并行化(例如,递归地并行处理两个子区间)可能导致负载不均和并行开销过大。本题的目标是讲解一个能有效平衡负载、控制并行粒度并优化通信的并行快速排序算法。
解题过程
我们将聚焦于共享内存环境(如多核CPU)下的并行化,其核心思想是“任务并行”,利用递归划分产生的独立子任务。这里介绍一个基于“分治”和“递归并行”的经典并行快速排序变体,它结合了任务窃取(Work Stealing)的思想以实现动态负载均衡。
步骤1: 串行快速排序回顾
快速排序的核心步骤是:
- 选择基准(Pivot):从数组中选择一个元素作为基准(例如,随机选择、选择第一个元素或三数取中)。
- 分区(Partition):重新排列数组,使得所有小于基准的元素放在基准前面,所有大于基准的放在后面。分区操作完成后,基准元素处于其最终排序位置。
- 递归排序:递归地将小于基准的子数组和大于基准的子数组排序。
这是一个典型的分治算法。时间复杂度平均为O(n log n)。
步骤2: 朴素的并行化思路及其问题
最直观的并行化方法是:在每次分区后,产生两个独立的子任务(两个子数组),并将它们分配给不同的处理器(或线程)进行递归排序。这可以形式化为一个递归并行任务树。
- 问题1:负载不均。如果分区不平衡(例如,基准恰好是最大/最小值),会导致一个任务非常大,另一个非常小。即使分区平衡,在递归树的不同分支上,任务大小也可能差异巨大,造成一些处理器空闲,一些过载。
- 问题2:并行开销大。如果为每个很小的子数组(例如,只有几个元素)也创建并行任务,那么创建和管理任务的成本(线程创建、调度、同步)可能超过排序本身的计算成本。
步骤3: 引入并行控制阈值(Granularity Control)
为了解决过度并行化的问题,我们引入一个阈值。当待排序的子数组大小小于这个阈值时,我们不再为其创建新的并行任务,而是退化为串行排序算法(例如,使用串行快速排序或插入排序,因为小数组上插入排序可能更快)。这个阈值通常根据实验确定,与系统特性(如线程切换开销、缓存行大小)相关。
步骤4: 基于任务池和工作窃取的动态负载均衡
我们采用“任务并行”模型,并利用一个共享的任务池(例如,一个双端队列,deque)来管理待处理的排序任务(每个任务对应一个数组区间 [left, right])。
- 初始任务:将整个数组范围 [0, n-1] 作为一个任务放入全局任务池。
- 线程工作流程:
- 每个工作线程(假设有P个)从任务池中获取任务。
- 当它获得一个任务(区间 [l, r])后:
a. 检查阈值:如果 (r - l + 1) <= THRESHOLD,则直接调用串行排序函数对该区间排序,然后继续获取新任务。
b. 执行分区:对区间 [l, r] 执行分区操作,得到基准的最终位置pivot_index。
c. 生成子任务:分区产生了两个新的子区间:[l, pivot_index-1] 和 [pivot_index+1, r]。
d. 任务推送策略:线程将自己接下来要处理的那个较大的子区间继续作为当前任务(即将当前任务的范围更新为这个较大区间),而将较小的子区间作为一个新任务推入任务池。这相当于“深度优先”地处理较大的分支,有助于尽快将大问题分解,同时将小分支留给其他可能空闲的线程。
e. 线程然后回到步骤a,处理它更新后的当前任务(即那个较大的子区间)。
- 工作窃取(Work Stealing):
- 当一个线程发现任务池为空,并且自己也没有可处理的当前任务(即它已经处理完整个递归分支)时,它不会空闲等待,而是成为一个“窃贼”(thief)。
- 窃贼线程会随机选择另一个线程(受害者,victim),尝试从其任务池的“另一端”(例如,受害者线程的任务池是本地deque,窃贼从其尾部偷取)窃取一个任务。
- 如果窃取成功,窃贼线程就开始处理这个偷来的任务。如果失败(受害者线程的任务池也为空),它可能会继续尝试窃取或短暂休眠后重试。
- 终止条件:当所有线程都处于空闲状态(没有本地任务,且任务池为空,且窃取失败)时,算法终止,整个数组排序完成。
步骤5: 算法伪代码(简化版)
# 全局(或线程共享)任务池:一个双端队列的列表,deques[thread_id]
deques = [new_deque() for _ in range(P)]
# 初始任务
deques[0].push_front( Task(l=0, r=n-1) )
# 线程i的执行函数
def worker_thread(thread_id):
local_task = None
while not all_threads_done(): # 终止检测逻辑
if local_task is None:
# 尝试从自己的本地队列获取任务
local_task = deques[thread_id].pop_front()
if local_task is None:
# 本地队列空,尝试工作窃取
victim = random_choice(other_threads(thread_id))
local_task = deques[victim].pop_back() # 从受害者尾部窃取
if local_task is None:
# 窃取失败,可能等待或进行全局终止检测
continue
l, r = local_task.l, local_task.r
if (r - l + 1) <= THRESHOLD:
serial_sort(A, l, r)
local_task = None # 任务完成,准备获取新任务
else:
pivot_idx = partition(A, l, r) # 分区操作
# 确定较大和较小的子区间
left_size = (pivot_idx - 1) - l + 1
right_size = r - (pivot_idx + 1) + 1
if left_size >= right_size:
larger_task = Task(l, pivot_idx-1)
smaller_task = Task(pivot_idx+1, r)
else:
larger_task = Task(pivot_idx+1, r)
smaller_task = Task(l, pivot_idx-1)
# 将较小的任务推入自己的本地队列前端
deques[thread_id].push_front(smaller_task)
# 继续处理较大的任务(作为新的 local_task)
local_task = larger_task
步骤6: 关键点总结
- 动态任务创建:任务在分区后动态产生,而不是预先静态分配。
- 粒度控制:通过阈值避免对过小问题创建并行任务,减少开销。
- 负载均衡:工作窃取机制使得空闲线程可以主动从繁忙线程那里“偷”工作,有效平衡了因分区不平衡或递归树形状不规则带来的负载不均。
- 局部性优化:每个线程倾向于处理连续的内存区域(深度优先处理较大子任务),有利于缓存利用。
- 无锁或细粒度锁:任务池(deque)的实现通常要求支持高效的并发
push_front和pop_front(由所有者线程执行)以及pop_back(由窃贼线程执行),这可以通过无锁编程或细粒度锁来实现,以减少同步开销。
扩展
在分布式内存环境中,并行快速排序的实现更为复杂,因为涉及数据在不同处理器间的移动(数据重分布)。常见的策略包括:
- 样本排序(Sample Sort):可以看作是快速排序的推广。所有处理器先取出本地数据的样本,集合起来选出全局的P-1个分割元(splitter),然后每个处理器根据这些分割元将自己的数据划分到P个桶中,接着进行全局的数据交换(all-to-all),使每个处理器最终获得一个桶,最后各处理器对本地桶内数据进行排序。这避免了递归带来的多轮通信,通常比直接并行化快速排序在分布式环境下更高效。
这个并行快速排序算法(结合工作窃取)是多核编程中任务并行的典范,它巧妙地运用了分治、递归并行和动态负载均衡策略,是理解并行算法设计思想的优秀案例。