1151 字
6 分钟
多线程优化程序效率

封面图来源: Mandelbrot set Wikipedia

首先简单看一下机器 CPU 的情况,在linux系统上用lscpu查看得到如下信息:

Architecture: x86_64 CPU op-mode(s): 32-bit, 64-bit Address sizes: 46 bits physical, 57 bits virtual Byte Order: Little Endian CPU(s): 112 On-line CPU(s) list: 0-111 ... Thread(s) per core: 1 Core(s) per socket: 112 Socket(s): 1

可以看到机器上 CPU 物理核心数有 112 个,首先看一下串行代码生成 Mandelbrot Set 图像时的逻辑:

void mandelbrotSerial( float x0, float y0, float x1, float y1, int width, int height, int startRow, int totalRows, int maxIterations, int output[]) { float dx = (x1 - x0) / width; float dy = (y1 - y0) / height; int endRow = startRow + totalRows; for (int j = startRow; j < endRow; j++) { for (int i = 0; i < width; ++i) { float x = x0 + i * dx; float y = y0 + j * dy; int index = (j * width + i); output[index] = mandel(x, y, maxIterations); } } }

主要逻辑就是去遍历二维数组的每行每列,逐个坐标传入mandel函数,将生成的像素值写回output数组中。

从串行的代码中我们可以看到,图像中不同位置上的像素值的生成是相互独立的,因此我们可以考虑使用多线程进行优化,将矩阵划分为多个小块,每个矩阵块由一个独立的线程来处理。首先在主线程中起若干个线程,并为线程初始化参数以及分配任务:

for(int i = 0; i < numThreads; ++i) { // 初始化线程参数 } for(int i = 1; i < numThreads; ++i) { workers[i] = std::thread(workerThreadStart, &args[i]); } workerThreadStart(args[0]);

然后编写各个线程执行的入口,在这里,我们简单的将整个矩阵按行数切分为与线程数相等的若干矩阵块,每个线程负责一个矩阵块上像素的生成(注意处理图像高度不能整除线程数的情况):

void workerThreadStart(WorkerArgs * const args) { auto numRows = args->height / args->numThreads; auto startRow = args->threadId * numRows; auto totalRows = args->threadId == args->numThreads - 1 ? args->height - startRow : numRows; mandelbrotSerial( args->x0, args->y0, args->x1, args->y1, args->width, args->height, startRow, totalRows, args->maxIterations, args->output ); }

接下来对不同版本生成Mandelbrot Set图像的程序效率进行测试,我们要生成的图像尺寸为1600 * 1200,采用不同线程数的耗时以及加速比如下:

TimeThread NumberSpeed Up
519.108ms11.00
260.503ms21.99
315.763ms31.64
211.582ms42.45
207.785ms52.50
158.449ms63.28
151.082ms73.44
127.186ms84.08
67.159ms167.73
34.852ms3214.89

将上述统计图转换为折线图如下:

线程数与加速比关系图

可以看到,随着线程数的增多,实际的加速比与线程数并不会呈现线性变化,起到16个线程的时候,实际加速比甚至不到理想加速比的一半。影响实际加速比的因素有很多,包括我们在创建、管理以及销毁线程时引入的额外开销。同时,多个线程也会争夺共享资源,如内存带宽、缓存以及I/O设备。当每个线程所分配的任务不均时,部分的线程可能早于其他线程完成工作,从而在闲置等待,影响整体性能。此外,如果线程之间需要频繁地进行同步操作,也会引入额外的开销。

在折线图中,我们可以看到在线程数等于3的时候,加速比反而下降了,我们可以在workerThreadStart函数中监控每个线程完成任务所需时间,并打印日志记录,得到如下结果:

workerThreadStart func finished. Thread [0] cost 0.101471 ms. workerThreadStart func finished. Thread [2] cost 0.101956 ms. workerThreadStart func finished. Thread [1] cost 0.316338 ms.

可以看到,这是我们上述描述影响实际加速比中的“部分线程早于其他线程完成任务”的情况,我们可以尝试修改任务分配的策略以提高加速比。在生成Mandelbrot Set图像的时候,可能存在着部分区域计算时需要迭代的次数较多,根据任务的这个特性,我们可以尝试按行循环分配任务给线程,实现任务的负载均衡,将workerThreadStart函数修改如下:

void workerThreadStart(WorkerArgs * const args) { int height = args->height; for (int i = 0; i < height; ++i) { if (i % args->numThreads == args->threadId) { mandelbrotSerial(args->x0, args->y0, args->x1, args->y1, args->width, args->height, i, 1, args->maxIterations, args->output); } } }

重新测试采用不同线程数的耗时及加速比,得到数据如下:

TimeThread NumberSpeed Up
518.575ms11.00
259.573ms22.00
173.203ms33.00
129.991ms43.99
104.195ms54.98
86.918ms65.97
74.631ms76.95
65.338ms87.94
33.106ms1615.66
18.967ms3227.34

将上述统计图转换为折线图如下:

优化后的线程数与加速比关系图

可以看到,此时在16线程的范围内,实际加速比已经非常接近理想加速比,说明将任务分配以达到负载均衡能大大提高程序并行的效率。

多线程优化程序效率
https://0130w.github.io/posts/computer/parallel_fractal_generation_using_threads/
作者
0130
发布于
2025-02-13
许可协议
CC BY-NC-SA 4.0