3.代码并行优化
在C++中,推荐使用OpenMP进行多线程计算。使用时需要在CMakeLists.txt里面添加以下代码:
find_package(OpenMP REQUIRED)
target_link_libraries(CodeCraft-2021 PUBLIC OpenMP::OpenMP_CXX)
👆可左右滑动查看完整代码
在矩阵求和例子中,我们可以把任务均分到每个线程上,每个线程负责计算矩阵的一部分的和,最后对所有线程求得的和进行汇总,代码如下:
// input: int n, int m, int mat[n][m]
// output: int sum_mat
#include <omp.h>
int sums[2] = {0};
#pragma omp parallel for num_threads(2)
for (int i = 0; i < n; ++i) {
for (int j = 0; j < m; ++j) {
sums[omp_get_thread_num()] += mat[i][j];
}
}
int sum_mat = 0;
for (int i = 0; i < 2; ++i) {
sum_mat += sums[i];
}
#pragma omp parallel for 会将下一行的 for 循环自动分块并行化,其中 num_threads(2)为使用的线程数量,默认为机器的逻辑处理器数量(可用 omp_get_num_threads()方法获取),在这里使用 2 和线上保持一致。omp_get_thread_num()方法可获取当前运行该代码的线程号,范围为从 0 开始的自然数。
在Java中,这里介绍两种方式进行多线程编程,ExecutorService和Parallel Stream。
ExecutorService建议使用newFixedThreadPool方法初始化一个大小为nThreads的线程池。代码如下:
// input: int n, int m, int[][] mat
// output: int sumMat
int nThreads = 2;
ExecutorService executorService = Executors.newFixedThreadPool(nThreads);
List<Future<Integer>> tasks = new ArrayList<>(n);
for (int i = 0; i < n; ++i) {
int[] vec = mat[i];
tasks.add(executorService.submit(() -> {
int sum = 0;
for (int j = 0; j < m; ++k) {
sum += vec[j];
}
return sum;
}));
}
int sumMat = 0;
for (Future<Integer> task : tasks) {
sumMat += task.get();
}
executorService.shutdown(); // 必需,否则程序无法正常结束
👆可左右滑动查看完整代码
在单个任务比较小的情况下(即m较小),由于并行产生的额外开销,上述代码反而比串行执行更慢。为了减少额外开销,我们可以将整个矩阵提前分块均分到每个线程上。代码如下:
// input: int n, int m, int[][] mat
// output: int sumMat
int nThreads = 2;
ExecutorService executorService = Executors.newFixedThreadPool(nThreads);
List<Future<Integer>> tasks = new ArrayList<>(nThreads);
int block = (n + nThreads - 1) / nThreads;
for (int i = 0; i < nThreads; ++i) {
int start = i * block;
int end = Math.min((i + 1) * block, m);
tasks.add(executorService.submit(() -> {
int sum = 0;
for (int j = start; j < end; ++j) {
for (int k = 0; k < m; ++k) {
sum += mat[j][k];
}
}
return sum;
}));
}
int sumMat = 0;
for (Future<Integer> task : tasks) {
sumMat += task.get();
}
executorService.shutdown(); // 必需,否则程序无法正常结束
👆可左右滑动查看完整代码
Parallel Stream则是另外一个思路,将计算过程视为一个流(Stream),并用filter、map、sum、collect等方法将计算过程串起来,代码上会简洁很多,适用场景和ExecutorService不一样。该方法不能指定使用的线程数量。
// input: int n, int m, int[][] mat
// output: int sumMat
int sumMat = Stream.of(mat).parallel().mapToInt(vec -> {
int sumVec = 0;
for (int i = 0; i < m; ++i) {
sumVec += vec[i];
}
return sumVec;
}).sum();
在Python中,由于GIL(Global Interpreter Lock)的存在,多线程编程不能完整利用上多个CPU核心,只能使用多进程并行。
为了让多个进程间共用同一份矩阵数据,可以将矩阵放在全局变量,在Linux下利用Copy-on-write的特性,避免矩阵的拷贝。代码如下:
# input: n: Int, m: Int, mat: List[List[Int]] or numpy.ndarray
# output: sum_mat: Int
from multiprocessing import Pool
def sum_vec(i):
s = 0
for j in range(m):
s += mat[i][j]
return s
with Pool(2) as p:
sum_mat = sum(p.map(sum_vec, range(n))) # 若将mat作为入参会引起序列化拷贝
👆可左右滑动查看完整代码
和上面Java的例子一样,可以提前对矩阵均分为2个部分均分给2个进程,减少额外开销,提高并行效率。
额外提醒,比赛为Python提供了CPython和PyPy两种解释器,前者推荐用numpy.ndarray实现上述的矩阵,并用numpy.sum实现求和,后者推荐直接用Python自带的列表类型实现矩阵。
不同的算法有着不一样的并行思路,下面将举两个细粒度不一样的并行思路。
01
在一些比较困难的问题上,最优解是很难直接求出来的,比如本次的赛题。在有限的时间内只能求出一个较优的解。在一些算法中,可以通过修改参数(包括随机种子)得到不一样的解,那么可以简单的用多个线程/进程同时运行不同参数的同个算法,将最终产生的不同的解取最优解。
02
在进行更细粒度的并行前,需要先确认程序的瓶颈位置。根据Amdahl’s law,将时间占比为p的瓶颈部分进行s倍提速后,整个程序将缩短为1/((1-p)+p/s)倍运行时间。确定瓶颈位置可以简单的用代码计时或是用相关语言的Profiler工具(推荐后者)。确定完瓶颈之后,找到相互独立的计算模块进行并行。
注意事项
👉在并行的程序中,不能通过CPU时间(如C++的clock())计算运行时间,而应该使用系统时间。
👉尽量避免线程/进程间的通信和同步。
👉不要盲目地进行并行。并行自身也会产生性能代价,根据不同的并行任务数、任务大小,并行的实际加速比差异很大,需要实际测量。