ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MATLAB并行计算实战:parfor与parfeval加速参数扫描的完整指南

MATLAB并行计算实战:parfor与parfeval加速参数扫描的完整指南 前一阵子帮学院里的一个课题组跑参数扫描一跑就是十五六个小时单线程的 for 循环把 CPU 用得只剩 12% 的利用率。打开任务管理器看了看十来个核全在围观那一刻我是真的很想把 MATLAB 的多核能力榨干净。后来把代码改成 parfor、再加个 parfeval 做异步调度同样的任务不到三小时就跑完了。这篇文章就把我这一路的思路、踩过的坑、写在批注里的经验全部整理出来。本文聚焦 MATLAB 并发编程也就是怎么用 Parallel Computing Toolbox 里的 parfor、parfeval、spmd、parpool 等工具把一台机器上的多核 CPU、GPU 甚至多台机器用起来。适合做算法仿真、图像处理、深度学习调参、蒙特卡洛模拟的工程师和研究生。无论你之前有没有接触过并行读完都应该能判断自己的任务适不适合并行、该选哪个工具、怎么写才能真提速。1. 任务拆解与并发方案设计1.1 先判断你的任务适不适合并行很多教程一上来就教 parfor 怎么用但我建议你自己先回答一个问题你的 for 循环每次迭代之间有没有数据依赖这里说的依赖并不是指大家共享同一个输入文件而是指第 k 次迭代的结果会不会成为第 k1 次迭代的输入。比如递推滤波for k 2:N x(k) x(k-1) alpha * (y(k) - x(k-1)); end这种代码就不能直接用 parfor 改因为每次迭代依赖上一次的 x(k-1)。但注意不要让“看起来有依赖”把自己吓住很多任务换个维度就变成可并行。我之前处理一批时间序列每个序列内部都有递推按时间步并行是行不通的但按序列编号并行完全没问题。文件读取、图像处理、参数扫描这类“每个样本独立处理”的任务本质都属于 embarrassingly parallel是 parfor 最擅长处理的对象。判断方法其实很简单把循环体拿出来问自己“如果我同时跑 8 份每份给不同的 i会不会有人要等别人的数据”。如果答案是不会那就放心用 parfor。如果会要么换一个拆分粒度要么考虑用 parfeval 做异步任务调度。1.2 三个核心并行工具怎么选MATLAB 的并行工具箱里日常用得最多的是三个工具parfor、parfeval、spmd。它们不是互相替代的关系而是对应不同场景。parfor 是并行 for 循环适合循环次数已知、每次迭代相互独立、耗时相对均匀的场景。你把一个 for 改成 parforMATLAB 会自动把迭代分配给多个 worker看起来改动最小。但它的限制也多循环体里的变量访问模式必须符合 MATLAB 的分类规则不能随便写。parfeval 是异步函数求值你给 worker 池提交一堆任务每个任务是一个函数调用调用之间完全解耦。它的价值在于任务耗时可能差异很大有的 1 秒跑完、有的要 1 分钟如果用 parfor跑得快的 worker 只能等着慢的 worker 结束。用 parfeval 则可以谁先完成谁先返回还支持动态提交任务。这个工具在做自适应参数搜索时特别香。spmd 是单程序多数据所有 worker 同时执行同一段代码但每个 worker 处理自己的数据分区。它的使用场景相对少比较适合配合 distributed 数组做大矩阵的分布式计算。平时做仿真优化的话parfor 和 parfeval 占了绝大部分场景spmd 了解即可。如果拿打比方来说parfor 是流水线批量加工parfeval 是外卖平台按订单派送spmd 是每个工位按同一份 SOP 各干各的零件。1.3 并行池设计进程池和线程池的区别在本地机器上你用 parpool 创建并行池的时候会遇到“Processes”和“Threads”两种选择。这是比较多人忽略的细节但恰恰很容易引出大坑。进程池是最常见的一种每个 worker 是一个独立的 MATLAB 进程内存隔离一个 worker 崩了通常不会拖垮所有 worker。因为隔离好所以当你调用 MEX 编译出来的 C/C 函数、或者外部库时用进程池更安全。缺点是启动慢、每个 worker 都要加载一份 MATLAB 环境内存开销大。线程池在 R2020a 之后可用所有 worker 共享同一个进程里的线程启动快、通信开销小。但前提是你运行的操作都要求线程安全。如果你只是用 MATLAB 内置的计算函数比如矩阵乘法、图像处理工具箱的 imfilter 这些线程池一般没问题。可一旦你的并行任务里调用 MEX 并且这个 MEX 内部有全局状态或者调用的外部库不是线程安全的线程池就会表现出各种诡异崩溃——大概率无法复现的崩溃。所以我的经验是不确定的时候选进程池折腾成本最低。我现在自己写本地并行代码除非是纯内置函数计算并且内存吃紧否则都用 parpool(Processes, N)。2. 并发编程核心语法与隐蔽细节2.1 parfor 的变量分类与常见报错parfor 的语法简单但它背后的变量分类机制是初学者最容易翻车的地方。你写进 parfor 循环体的变量MATLAB 会把它分成几类循环变量、切片变量、广播变量、临时变量、归约变量。循环变量就是 parfor 后面的 i每个 worker 只能看到自己负责的那几个 i 值。切片变量在循环里以 A(i) 形式访问的变量每个 worker 只读写自己负责的那一段这是并行效率的关键。广播变量循环里只读、不按循环变量下标的变量例如一个公共参数矩阵它会被完整复制到每个 worker。临时变量循环体内临时计算出来的变量每次迭代独立不需要回传。归约变量可以用 、*、max 这些操作把各 worker 的部分结果合并起来的变量。最常见的问题是“The variable X in a parfor cannot be classified.”十有八九是因为你写了类似这样的代码parfor i 1:N data(i).value compute(i); end这里的 data(i).value 不被识别为切片变量因为 MATLAB 无法通过这个索引形式确认每次 i 都写在不同位置把它当广播变量处理可是你又在写它自然就报错了。解决办法也很直白用 cell 数组代替结构体数组或者把结构体字段拆出来变成多个独立变量。我这个习惯后来一直保持凡是写 parfor循环里尽量只处理数值矩阵和 cell这种形式最容易被正确分类。tmp cell(N, 1); parfor i 1:N tmp{i} compute(i); end data [tmp{:}];另一个隐蔽问题是归约变量不能随便使用两次。比如你想在一个 parfor 里同时算累加和和累加平方和很多人会自然写成sumX 0; sumX2 0; parfor i 1:N x g(i); sumX sumX x; sumX2 sumX2 x^2; end这在逻辑上没有毛病MATLAB 也能处理两个独立的归约变量。但如果写成 sumX sumX x; sumX sumX x2; 对同一个变量做两次归约分类器就无法确定你的意图了就会报错。遇到这种情况最简单的方法是合并成一个循环内只做一次归约或者把两个归约写成一个表达式或者干脆拆成两个 parfor。我是倾向于拆成两个变量的代码可读性也更好。2.2 随机数的可复现性和独立性怎么保证如果你在并行代码里用了 rand、randn、randi那你大概率会遇到一个大坑跑两次结果不一样。这并不完全是坏事。并行随机数的设计初衷就是让每个 worker 生成独立的随机流避免它们各自生成重复序列。问题在于如果你没有给 worker 配置随机流你又期望实验可复现那就会出现“换台机器结果就变了”、“今天跑和昨天跑结果变了”的情况。科研和工程项目里这种不可复现是会出大事的。我现在处理蒙特卡洛实验第一件事就是给并行池里的所有 worker 配同一个生成器spmd rng(0, threefry); end这样每个 worker 拿到的不是同一个随机数序列而是同一个母种子下互不重叠的子流既保证 worker 之间统计独立又保证整体结果可以复现。如果不放心还可以用 RandStream 显式管理比如每个 worker 设置自己的全局流。另外一个容易忽略的点是parfor 循环里如果调用了你自己封装的函数这个函数里面也用了 rand那么随机流只会在 worker 第一次进入循环时自动初始化这时候主程序里 rng 设置的种子并不保证传到 worker。所以正确顺序是先创建池再用 spmd 统一配随机流然后再跑 parfor。顺序错了种子设置等于白设。2.3 数据传输、内存和 broadcast 的隐形开销很多人以为 parfor 在本地运行时因为进程共享内存数据传递是零拷贝的。这是一个很普遍的误解。实际上本地进程池的每个 worker 都是独立 MATLAB 进程主进程要把变量序列化以后传给 workerworker 算完再把结果传回来。这中间不仅有时间开销还有内存乘法开销。一个大矩阵如果作为广播变量出现在循环里它会被复制到每个 worker。8 个 worker 就相当于内存里同时存在 9 份同样的数据8 份在 worker1 份在主进程。家庭台式机 16G 内存遇到 2G 的矩阵基本就是一个 out of memory 的下场。我现在遇到大矩阵的场景第一反应不是写 parfor而是先想“能不能不让 worker 拿全量矩阵”。比如参数扫描时没必要把整张高清图作为广播变量发给每个 worker可以先把图像缩小、裁出感兴趣区域再丢给 worker。或者反过来不要传图像本身只传文件路径让每个 worker 自己从磁盘读取同一份数据。后者的 IO 开销要小心但至少内存是可控的。如果数据确实大到单机放不下就要用 tall 数组和 datastore。tall 数组可以把大数据切块配合 parfor 或 tall 上的操作让 MATLAB 自动管理内存和调度。这个方案写起来相对复杂但它是内存上限问题的最稳妥解法。2.4 叠加 GPU 和 MEX 的实战体会MATLAB 的并发和 GPU 经常被混为一谈。严格来说gpuArray 走的是显卡并行和 CPU 多核并行是两个维度。你可以两个都上但不要指望同一个循环里随便改个 gpuArray 就一定更快。GPU 并行比较适合矩阵运算、FFT、卷积、线性代数这一类核心计算例如A gpuArray(rand(1000, 1000)); B fft(A); % 这行会自动在 GPU 上执行在小数据量下GPU 的优势完全体现不出来因为主内存和显存之间的拷贝开销可能比计算时间还大。我自己的经验是矩阵规模起码到 512x512 以上才值得考虑 GPU而且你还要关注显存占用。一个 10000x10000 的 double 矩阵是 800MB如果池里有 4 个 worker每个 worker 都在自己的 MATLAB 上下文里申请显存显存很快就爆了。MEX 这块我的建议是如果你有现成的 C 代码并且想让它并行跑尽量把 MEX 的每个调用设计成无共享状态。比如不要在 MEX 函数里用静态变量缓存数据不要依赖单例对象不要假设只有一个线程在调用。这时候用进程池是一个非常稳的选择因为每个 worker 是独立进程天然隔离了大部分冲突。但如果你用线程池并且 MEX 内部不是线程安全的轻则结果诡异重则直接崩溃还没有任何报错。3. 参数扫描实战从串行到异步并行3.1 需求分析与串行基线我拿一个具体场景来讲有一批图像我要测试一组去噪参数包括滤波核大小和 sigma目标是选出 PSNR 最高的参数组合。算法本身是现成的输入是一张图和一个参数结构体输出是一个 PSNR 标量。最开始是串行版本很普通for k 1:numel(paramList) score(k) evalDenoise(im, paramList(k)); end [bestScore, idx] max(score); bestParam paramList(idx);36 组参数每组平均 0.8 秒总共 30 秒左右。看起来不多但如果我有一百张测试图乘上去就是 50 分钟再算上模型里其他参数分支基本就是半天起步。这还不是最狠的。另一个更常见的场景是蒙特卡洛比如你要对同一组参数跑 1000 次随机模拟每次生成不同的噪声再统计误差均值。这时候循环体内部耗时可能只有几十毫秒但循环次数上万累计时间就非常可观。3.2 parfor 版本几行代码实现多核加速对于上面的参数扫描改成 parfor 非常简单scores zeros(1, numel(paramList)); parfor k 1:numel(paramList) scores(k) evalDenoise(im, paramList(k)); end [bestScore, idx] max(scores); bestParam paramList(idx);这里 scores 是切片变量paramList 和 im 是广播变量。注意 im 是一个大矩阵每个 worker 都会复制一份如果测试图是几百兆你就要考虑用 2.3 节的方法处理。我在这台 i5-124006 物理核 12 线程上做了个简单测量4 个 worker 的情况下30 秒的任务大约能压到 9.5 秒加速比 3.2 左右开 8 个 worker任务反而要 10 秒并没有继续变快。这说明在物理核数不足时硬上超线程只会带来资源竞争尤其是内存带宽已经成了瓶颈。所以不要盲目贪 worker 数量先用任务管理器看看物理核数再决定池大小。3.3 parfeval 异步版本实时进度和动态任务如果你的参数组合里有一些跑得快、有一些跑得慢或者你想在跑完一部分以后就能看到中间结果做判断parfor 就不够灵活了。这时候用 parfeval 把任务一个个提交到池里然后用 fetchNext 按完成顺序取结果是最顺手的方案。我自己常用这样一种结构pool gcp(nocreate); if isempty(pool) pool parpool(Processes, 4); end numTasks numel(paramList); futures(1:numTasks) parallel.FevalFuture; for k 1:numTasks futures(k) parfeval(pool, evalDenoise, 1, im, paramList(k)); end scores zeros(1, numTasks); for k 1:numTasks [idx, score] fetchNext(futures); scores(idx) score; fprintf(任务 %d/%d 完成PSNR %.4f\n, idx, numTasks, score); end这里有几个关键点。第一parfeval 提交的是函数调用每次调用都是独立的任务不存在循环变量分类问题第二fetchNext 会阻塞等待但只要任何一个任务完成就立刻返回所以你能实时感知进度第三如果你中途发现某些参数组合表现极差完全可以在循环里不再等待所有任务完成直接放弃剩余 futures或者追加新的任务。这种“跑一批、看一批、再决定下一批”的自适应思路在做网格搜索和随机搜索时非常实用。我之前做算法调参先用 parfeval 提交测试点根据前几个点的结果动态决定下一批候选参数的位置比一次性硬跑完整网格要省很多时间。3.4 用加速比数据决定要不要继续优化跑并行代码不能只满足于“CPU 占用变高了”你得量化收益。我的习惯是做一个简单的小脚本分别用 1、2、4、8 个 worker 跑同一个固定任务记录 T1、Tp然后算加速比 S T1/Tp 和效率 E S/p。我之前测过一批参数扫描结果大致是这样Workers运行时间(s)加速比 S并行效率 E130.01.00100%216.21.8592%49.53.2080%810.13.0538%这个表格很容易说明问题4 个 worker 时效率还在 80% 左右继续加到 8 个运行时间反而略微增加。这时候再加大并行规模就没有意义了下一步应该考虑把广播变量变成 worker 各自读文件或者优化单核代码。如果效率低于 60%基本可以断定数据传输或者内存带宽拖了后腿再往上加核就是浪费电。4. 排查与避坑并发编程问题速查4.1 提速不明显甚至更慢怎么定位遇到并行之后没有加速很多人第一反应是“代码写得有问题”。其实更常见的原因是任务粒度太小。parfor 和 parfeval 都有调度和通信开销如果单次迭代只有几毫秒并行调度的时间可能比计算本身还长。我建议单次迭代至少要有 10 毫秒以上的有效计算量才值得上并行。要是原本就是轻量计算你该做的不是开池而是把多次迭代合并起来例如一次处理一批数据批量操作的向量化效率远高于并行调度。另一个容易被忽略的原因是 MATLAB 内部本身已经多线程了。矩阵乘法、线性代数这些底层 BLAS 库自带多线程优化你哪怕写的是单层 for 循环可能在底层已经把多个核用起来了。这种情况你再叠加 parfor相当于两层并行互相抢核速度不涨反跌。判断方法很简单先跑一个串行版本同时盯着任务管理器如果单线程程序的 CPU 占用率已经超过 200%说明底层已经多线程了叠加并行之前要多想想。4.2 并行内存暴涨怎么处理症状很典型任务管理器里 MATLAB 相关进程内存从 4G 一路涨到 30G然后系统卡死最后 out of memory。原因绝大多数是广播变量复制多点副本。排查方法也很直接看主程序传给 parfor 函数的输入变量里哪个是完整矩阵哪个是大 cell。一旦发现广播变量太大按优先级排序的办法是能传路径就传路径让 worker 自己读能裁剪就裁剪能用 tall 数组就别一把梭。还有一个策略是把池规模调小虽然并行度低了但至少能跑完。4.3 并行随机数结果对不上怎么修并行池里跑蒙特卡洛结果每次不一样或者跟串行版本不一致这不是玄学是随机流没配置好。一致性要求就是 2.2 节里说的在 spmd 块里执行 rng(0, threefry)。注意如果你在启动并行池之前就在主进程设置了 rng这个设置并不会自动同步到 worker。有些场景并不要求完全一致只要求每次实验之间独立那只要保证不做重复配置就不会出事。但你要是给导师或客户交付结果我强烈建议统一随机流否则别人跑一次复现不了你解释成本极高。4.4 parfor 里的典型报错与应对我整理了一份自己反复用到的排查表症状可能原因排错/修复方法提示 The variable X in a parfor cannot be classified变量访问方式不满足切片或归约规则例如结构体数组的字段写入改成 cell 数组或独立数组确保 A(i) 是直接下标访问并行池中找不到自定义函数worker 没有把当前文件夹加入搜索路径在脚本开头用 addpath 添加函数目录或者在 send 之前检查 isfileparfor 嵌套 parfor 报错MATLAB 不允许嵌套并行循环外层用 parfor内层展开成普通 for或把内层改为 parfeval 提交并行结果和串行不一致随机流未配置/归约顺序不确定spmd 内统一 rng尽量减少对累计顺序的依赖GPU 版本反而更慢数据量太小拷贝开销大于计算收益增大矩阵规模或改用 arrayfun/pagefun 批量处理MEX 在并行池中崩溃外部库或全局状态不是线程安全改用进程池并把 MEX 状态设计成无共享表格里列出的这几个基本覆盖了我在实际项目中遇到过 80% 的问题。你如果遇到了别的报错排查口诀也分享一下先串行跑同一条路径确认功能没问题再看变量分类再看循环体是不是太重、是不是有随机数最后才怀疑 matlab 本身的并行机制。4.5 一些小习惯让并发代码更稳结尾再放几个不是语法层面但特别好用的小习惯。第一开发时先用小样本验证。别一上来就把 10000 次循环丢进并行池调试先用 10 次跑通功能再加上大数据量调试效率高很多。第二在 parfeval 的 fetchNext 循环里加个 fprintf 输出进度跑长任务时心里有底出了问题也知道卡在哪一个参数上。第三保存中间结果。如果任务要跑几个小时我习惯每个任务完成后立即把结果写进 .mat 或 .csv 文件这样即使某个 worker 崩了也不用从头再跑一遍。我自己的体会是MATLAB 并发编程的技术门槛并不高真正难的是任务拆分意识、内存控制意识和结果一致性意识。我们总以为“并行了就会更快”但实际工程里先想清楚数据在谁手上、任务之间有没有依赖、每个 worker 需要多大内存比调那几个语法参数重要得多。希望这篇整理能让你少踩几个坑也欢迎你拿这些思路回去改自己的长任务看看能不能把一天的等待压缩成一顿午饭的时间。
返回列表