- 概述
- 概念
- Task
- Worker
- Arg
- Chunk
- 迭代
- Taskel
- Parallelization Overhead
- 目标
- 场景
- 密集场景
- 稀疏场景
- Multiprocessing.Pool 的输入分块算法
- 量化算法效率
- 量化模型(待续)
概述
需求开发过程中,难免会遇到单核算力不足的场景。使用多核并行计算,理论上可以成倍提高运算效率。本文以Python的多进程模块为例,探讨和总结了多核计算任务分配的效率优化思路以及优化数学模型。
概念
首先约定一些概念。
Task
task
就是需要被执行的任务,包括执行函数、参数、任务ID等。
上图是使用Python Multiprocessing模块的Pool.map()实现并行计算的调用示例。
Worker
执行任务的单元,可以是进程、线程、核等。
Arg
执行函数的入参,使用并行通常会传入多个入参Args
并行调用执行函数。
Chunk
可理解为单次分配给一个worker的一组(多条)入参数据。
迭代
每完成针对一个chunk
的计算任务称为一轮迭代。
Taskel
执行任务的最小单元。当chunksize
大于1时,task
表示执行函数和由chunk
组成的元组,而taskel
(task element)表示单个执行函数的单个入参arg
。
Parallelization Overhead
并行开销(PO)指为了处理并行任务而需要付出的任务额外开销(比如流程切换、流程间通信等),由Python内部开销和进程间通信开销(inter-process communication overhead, IPC overhead)组成。对于Python实现层面而言,每个任务的开销包括入参和返回值的编解码,而进程间通信开销包括必要的同步线程和进程地址空间的数据拷贝(拷贝分为两步:父进程-队列,队列-子进程),开销大小取决于OS、硬件、数据量等因素。
目标
并行化的最终目标是减少计算任务的总处理时长。为了达成最终目标,技术目标就是最大化硬件资源的使用效率(utilization of hardware resources)。
为了完成这个技术目标,可以继续细化出一些子目标:
- 提高CPU核心使用率
- 最小化PO
- 限制内存使用,防止OS进行Paging
首先,task
必须是计算密集型任务,密集到并行计算的收益可以忽略并行开销。比如每个计算任务单元taskel
需要花费数小时,那么PO基本上可以忽略不计。
其次,要防止worker
空转,这就需要任务调度算法需要均匀地分配任务给每个worker
,即“负载均衡“ --- 尽可能地并行化执行。直观地举个例子:一个CPU被分配90%的任务,剩下5个CPU各分配2%的任务,如此分配很明显没有发挥出并行化的优势。
场景
既然我们的技术目标是最大化硬件资源的使用效率,如何给CPU均匀地分配计算任务就是可以通过算法来解决的问题。这里有个参数chunksize
就可以用来决定给每个CPU多少条输入数据,亦即多少个计算任务。
继续讨论chunksize
之前,我们先预设两个假设:
- 所有任务单元的计算量相差无几;
- 每个任务单元的计算时长远大于其PO时长;
设置两种极端场景:密集场景和稀疏场景分情况讨论:
密集场景
密集场景是指所有任务task
被一次性平均分发给所有worker
,当每个worker完成分配给自己的1个task
时,并行结束。
比如1,000个任务单元taskel
,输入数据args
被分成10个chunk
(chunksize
就是100),就有了10个task
。这些task
被平均分配给10个worker
,每个worker
执行1个task
。
换句话说,这种任务密集分配场景下,有多少worker
就要将arg
均匀地分成多少个chunk
。这样做的好处是显而易见的:最小化PO对计算总处理时间的影响。
当然,这种分配场景得到优势的一个重要假设,是每个任务单元taskel
的计算量相差无几。
稀疏场景
稀疏场景是另一种极端假设:每轮迭代给worker
仅分配一个taskel
。
实际上,“稀疏或密集”是一个优化问题。在实际计算场景中,每个任务单元taskel
的计算量通常是无法预测的,“计算量接近”往往是过强的假设。因此,每个worker
一次性被分配一组任务可能导致负载不均衡 --- 设想一个极端些的例子:workerA
一次性执行4个耗时2h的任务单元heavy taskel
(总耗时8h),workerB
一次性执行4个耗时0.1h的任务单元light taskel
(总耗时0.4h),workerB
空转浪费了7.6h。如果一次给worker
仅分配2个任务单元,最坏情况下workerB
完成4个light taskel
后,会继续获取2个heavy taskel
执行下去,总耗时从8h下降为4.4h,空转时长仅0.4h(忽略PO)。
每轮迭代给worker
分配尽可能少的任务单元taskel
,增加迭代次数,就意味着增加了调度的灵活性,但另一方面也增加了PO。
Multiprocessing.Pool 的输入分块算法
当代码中未指定chunksize
时,Multiprocessing.Pool
中的_map_async()
方法会根据输入数据Sequenceargs = [arg1, arg2, ..., argN]
长度\(N\)和worker
的数量\(W\)计算得到:
\[chunksize = \lceil \frac{N}{F \times W} \rceil\]
上面的计算公式是密集场景与稀疏场景的折衷 --- 令上式的系数\(F=1\)就是密集场景,\(F=N/W\)就是稀疏场景。
在Python Multiprocessing.Pool
的实现中,系数取了\(F=4\)。
量化算法效率
上面提到,Python Multiprocessing.Pool
的输入分块算法,系数取了\(F=4\),chunksize
约是密集场景的\(1/4\),也就是将密集场景chunk
的数量约增加了4倍。我们知道,chunk
的数量越多,增加了调度的灵活性,但总的PO也会增加。
这里的分块算法并不知道任务计算量以及底层操作系统、硬件层面的细节等影响进程间通信开销,因此只能是一个启发式的方法来提供基本功能提供给可能的所有场景。