整理一下关于MATLAB并行计算的内容,主要是Parallel Computing Toolbox工具包的内容,本文内容暂不涉及集群层面的并行,也不涉及GPU的使用。

parfor

for循环改成parfor循环是最简单直接的并行方式,它会启动不同的进程/线程来同步执行循环,这对循环中的内容有一定的要求:

  • 每次迭代相互独立,结果不依赖于其他迭代
  • 不支持某些动态操作变量的函数,如 evalassignin
  • 循环次数必须明确,不能依赖于运行时的计算结果
  • 不支持嵌套的parfor循环

对于简单的循环体,可以尝试直接用矢量化的语法,因为parfor在启动和结束过程中也会带来额外的时间成本。

parfor循环体内部,变量被大致分为如下几类:

  1. 循环变量,对它的限制是在循环内不能对循环变量再次赋值,并且不要在循环体外部再次使用,因为并行会导致无法确定循环变量的具体值
1
2
3
4
parfor i = 1:n
i = i + 1; % not allowed
a(i) = i;
end
  1. 切片变量,是指每个循环只访问该变量的特定位置,具体访问位置跟循环变量有关。在每一个循环中访问变量的位置必须是固定且不重合的。如果该变量在循环内被赋值,访问还必须是内存连续的(此时只能是循环变量再加固定的平移量),并且该变量不能在循环内动态变换大小
1
2
3
4
5
6
7
8
9
parfor i = 1:n
x(i) = a(2*i*i); % allowed;
y(i+2) = a(i) + b(i+1); % allowed;

c(i+1) = c(i) + 1; % not allowed;
z(2*i) = i; % not allowed;
a(i) = []; % not allowed;
a(end+1) = i; % not allowed
end
  1. 广播变量,是指纯粹的外部变量,在循环内未被重新赋值,只是作为只读变量被使用。
1
2
3
4
5
6
7
n = 100;
a = 10;
result = zeros(n,1);

parfor i = 1:n
result(i) = i + a; % a 是广播变量
end
  1. 临时变量,指在循环内部创建的临时变量,该变量仅在单次循环中有效,在并行的不同循环中会创建不同的副本,不会相互影响,并且该变量不会通过索引变量引用(否则该被归类到切片变量)。
1
2
3
4
5
6
7
n = 100;
result = zeros(n,1);

parfor i = 1:n
temp = i^2; % temp 是临时变量
result(i) = temp;
end

除此之外,还有一类特殊的变量,它会在每次迭代中累加或减少,MATLAB 会自动处理这些变量,并在并行计算的循环结束后将修改汇总,以确保结果的正确性。

1
2
3
4
5
s = 0;

parfor i = 1:10
s = s + i;
end

严格来说,我们不应该在并行中执行这样的操作,在别的语言中这种操作必须加锁才能保证安全性,但是MATLAB还是作为语法糖直接支持了这样的用法。

parfeval

parfeval允许我们在后台异步执行函数,并在计算完成时获取结果,这对于需要并行执行多个独立任务并在它们完成后收集结果的情况非常有用。

一个典型的例子如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
% 创建一个并行池
pool = gcp();

% 提交异步任务并获得 Future 对象
futures = cell(1, 10);
for i = 1:10
futures{i} = parfeval(@myFunction, 1, i);
end

% 阻塞式等待所有任务完成并获取结果
results = zeros(1, 10);
for i = 1:10
results(i) = fetchOutputs(futures{i});
end

% 显示结果
disp(results);

% 定义一个需要并行执行的函数
function result = myFunction(x)
pause(2); % 模拟一个耗时操作
result = x^2;
end

parfeval函数的调用方式为F = parfeval(fcn,numFcnOut,X1,...,Xm),得到的F是一个Future对象,fcn是需要调用的函数句柄,我们还需要提供返回值个数,以及所有的函数参数。这个语句会立刻返回,具体调用的函数会在后台继续异步执行。

我们可以通过fetchOutputs函数获取 Future 对象的返回值,这个语句会阻塞执行流,等待对应的后台任务完成并获取返回值。

我们可以使用cancel取消对应的任务

1
cancel(futures{i});

可以使用wait阻塞执行流,等待所有的后台任务完成

1
wait(futures);

parfevalOnAll可以在所有的worker中执行同样的命令,通常是用于统一的环境配置或者清理工作,例如

1
parfevalOnAll(@clear,0,"mex");

这种语句如果直接放在普通的并行语句中可能会报错,就像在parfor中不允许调用eval一样,因此MATLAB专门提供了parfevalOnAll

此外还有如下的函数:

  • afterEach:在某一个worker完成时执行指定的回调函数
  • afterAll:在所有worker完成时执行指定的回调函数
  • fetchNext:获取当前已经完成的某个worker的结果

其中的afterEach很奇怪,有两个同名但不同含义的接口,只有在类型正确时才会调用正确的版本。 这些函数的使用情景较少,并且并不是必要的,因此省略。

需要注意的是,因为parfeval会在后台执行任务,通常的控制台输出(包括警告信息)都会被隐藏。

spmd

parfor和不同,spmd是一种类似于MPI的并行实现方式,除了支持相互独立的循环并行,还支持在不同worker之间进行数据交换:发送数据,接收数据,等待接收等。

示例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
spmd
workerID = spmdIndex;

% 每个worker执行其计算任务
data = workerID^2;

if workerID ~= 1
% 其他的wokrer将其结果发送给worker1
spmdSend(data, 1);
else
% wokrer1接收来自其他wokrer的结果并进行汇总
total = data; % 自己的计算结果
for i = 2:spmdSize
% wokrer1接收其它worker的结果
received_data = spmdReceive(i);
fprintf('Worker 1 received data %d from worker %d\n', received_data, i);
total = total + received_data; % 求和
end
fprintf('Total result is %d\n', total); % 输出汇总结果
end
end

parpool

前面的所有并行语句其实都需要依赖并行池(parpool),MATLAB在执行中遇到parfor等语句时,如果没有开启并行池,会自动尝试开启并行池,因此我们通常可以忽略并行池的存在,但是MATLAB实际上也提供了更精细地控制并行池的相关接口。

parpool函数可以启动并行池

1
2
parpool(); % 启动本地默认配置的并行池
parpool('local', 4); % 启动具有4个工作进程的本地并行池

这里无参数情况下启动的本地默认配置与本地的核数有关,通常是尽可能开到最大。

gcp函数可以获取当前并行池对象(并行池是一个全局的单例对象)

1
2
pool = gcp(); % 获取当前并行池对象,如果没有并行池,会自动启动并返回
pool = gcp('nocreate'); % 如果没有并行池,则返回空

注意这里在无参数情况下调用gcp()时会自动启动并行池,如果仅仅需要查看状态必须加上'nocreate'参数。

我们可以通过下面的语句获取当前并行池的worker数量

1
2
3
4
5
6
poolobj = gcp("nocreate");
if isempty(poolobj)
poolsize = 0;
else
poolsize = poolobj.NumWorkers
end

下面的语句可以获取并手动关闭并行池

1
delete(gcp('nocreate')); % 关闭当前并行池

我们也可以手动获取并保存parpool函数返回的并行池,然后通过delete函数删除。

1
2
3
4
5
poolobj = parpool();

...

delete(poolobj)

在MATLAB退出之后,未关闭的并行池也会随之自动关闭。

MATLAB的并行池其实支持多进程和多线程两种工作模式,可以在设置界面中修改默认选项,在启动时也会展示相应的信息

1
2
3
parpool("Processes"); % 启动多进程并行池

parpool("Threads"); % 启动多线程并行池

多进程和多线程的并行池在 MATLAB 中的对比如下表

特性 多进程并行池 (Processes) 多线程并行池 (Threads)
内存空间 独立内存空间,每个进程有自己的内存 共享内存空间,所有线程共享内存
启动和管理开销
通信开销
数据竞争 无数据竞争风险 存在数据竞争风险
资源消耗 较高,因独立内存占用较多 较低,共享内存占用较少
适用任务 计算密集型、需要隔离的任务 需要频繁数据传输、共享数据的任务
默认配置 是(默认是多进程)
隔离性

可以使用下面的代码段检查当前并行池的类型

1
2
3
4
5
6
7
8
pool = gcp();

if isempty(pool)
disp('No parallel pool is currently open.');
else
disp(class(pool))
% parallel.ThreadPool or parallel.ProcessPool
end

在2021b版本之后,还有一个后台并行池backgroundPool的概念。

补充

这里列出一些可能需要的官方文档: