第一次让我对MATLAB并发编程产生强烈兴趣的是一次极其痛苦的参数扫描任务。当时我需要对某个仿真模型跑三万个参数组合串行循环跑了一天一夜才完成大约四分之一按照这个速度整个任务要四天。后来我把那段for循环改成了parfor在八核机器上只用了不到八个小时就跑完提速非常明显。从那一刻起我就清楚地意识到MATLAB的并发编程不是锦上添花而是在面对沉重计算量时真正能帮你活下来的工具。这个话题其实有不少人问过我。很多人觉得“MATLAB的并发编程”就是加一行parfor实际用起来却发现各种报错、各种不兼容、甚至并行以后比串行还慢。也有人分不清MATLAB里的并行计算和Java、Python那种“并发编程”到底是不是一回事。这篇文章我打算从实际工程角度把MATLAB并发编程的几个核心工具拆开讲透包括parfor、parfeval、spmd以及它们背后的并行工作机制、变量分类规则、内存模型、GPU加速等容易被忽略的细节。不论你是做图像处理、深度学习、优化求解还是搞蒙特卡洛模拟和数据扫描这篇文章应该都能给你一些直接能用的参考。1. MATLAB并发编程的全局认知1.1 MATLAB的“并发”到底是什么刚开始接触MATLAB并发编程时一个很普遍的误解是它和Java里的多线程、Python里的多进程$exchange$应该是差不多的东西吧事实并不是这样。MATLAB中的并发编程绝大多数情况下指的是多工作进程Worker并行而不是线程级别的并发。这个理解如果一开始就搞错了后面遇到的大部分坑都会让你一头雾水。MATLAB的并行计算工具箱Parallel Computing Toolbox会在本机或者集群上启动多个独立的MATLAB进程这些进程有各自独立的内存空间、各自独立的解释器、各自独立的随机数流。主会话把任务切分后分发给各个Worker去执行再用不同的方式把结果收回来。这本质上是一种“多进程数据并行”方案和Java里多个线程共享同一个堆内存、操作同一个对象的并发模型有根本性差异。也正是因为这种架构MATLAB的并行代码在数据传递上天然带有复制开销这个问题后面我会重点展开。由于每个Worker都是一个独立的MATLAB进程所以有一个特别容易被忽略的事实主工作区里边的普通变量在Worker里是不存在的。你写了parfor循环体里用的变量如果不是通过循环索引切片生成的也不是在循环内部构建的MATLAB就会把它自动识别成“广播变量”并复制到每个Worker去。这个复制过程看似无害但如果广播的是一个几百兆的大数组那么每个Worker都要复制一份内存占用就暴涨。1.2 并发编程能解决什么问题到底什么样的任务适合用MATLAB并发编程我认为核心条件有三个。第一任务可以切分。比如蒙特卡洛模拟里要生成十万条随机路径路径和路径之间没有依赖关系天然适合并行。参数扫描也是同样的道理每组参数对应的仿真可以独立计算。反过来如果循环内下一次迭代要用到上一次迭代的计算结果典型的时序递推、卡尔曼滤波等那这种强依赖的循环就不适合直接并行化。第二单次任务的体量要足够大。并行计算一定是有通信开销和调度开销的。如果一次迭代里只做了一个加法循环体耗时可能只有几微秒这种情况下并行化的通信开销反而会超过计算开销结果就是并行比串行还慢。我个人的经验判断是如果单次迭代的耗时并行的通信消耗的比例小于5%到10%并行化才有意义否则不如直接串行。第三总体任务量大到串行需要等待很长时间。如果任务几秒钟就跑完了完全不需要上并行。开一个parpool本身就是有启动开销的本地池也要花几十秒甚至更久这点经常被新手忽略。用一句话总结MATLAB并发编程适合处理“可拆分、无强依赖、单任务有规模、总体耗时大”的计算需求。反过来如果你手里的任务是纯串行递推、任务体量很小、或者机器只有两个核心那还是老老实实用for循环。2. 三大并行工具的选型与核心细节2.1 parfor最常见的数据并行parfor是大多数MATLAB用户接触并发的第一道门也是实际项目里用得最多的一个。它的核心思想很简单原本写for i 1:N的地方改成parfor i 1:N循环体里的计算就会被自动分发到多个Worker上并行执行。但这背后有一套严格的变量分类规则如果不遵守代码轻则报错重则在错误的计算结果上悄悄运行。先看变量分类。在parfor循环里变量一共分五类循环变量就是parfor i 1:N里的i每个迭代有自己的副本。切片变量用循环索引整体赋值或取用的数组变量比如A(i) ...、B(:, i) ...这类变量会被自动切分到不同Worker上。广播变量在循环体外定义、循环内只读不写的变量会被完整复制到每个Worker。临时变量循环体内创建、循环体内部使用的变量每个迭代独立不传回主会话。归约变量比如sum sum x这种累积计算MATLAB支持内建的归约操作多个Worker分别算局部结果后再合并。理解这五类变量是parfor正确使用的基础。我见过太多报错都是因为变量被错误分类导致的比如在循环体里对循环变量本身做了修改或者对非切片变量的数组元素做了写操作MATLAB会直接抛错提示“变量被分类为临时变量但并非如此”等等。除了变量分类parfor还有三个比较容易踩的规则第一循环体内不能有break和continue。因为循环被切分到多个Worker上并行执行根本无法保证某个迭代先运行完所以“提前中断”在逻辑上就不可行。遇到需要中断的场景要么改用parfeval要么把数据分段后手动控制。第二循环迭代之间不能有顺序依赖。parfor不保证迭代顺序严格来说每个迭代可能在任何Worker上、在任何时刻执行。如果代码里有A(i) A(i-1) 1这种递推parfor会直接报错或者在逻辑上出错。第三循环内不能动态修改循环数组的大小。a(end1) x这种在普通for里可以工作的写法在parfor里是完全禁止的。解决方法是预先分配好数组或者用cell数组在循环结束后拼接。从实操角度我常用的检查方法是先把parfor改回for跑一遍确认结果正确后再改回parfor如果结果不一致优先怀疑并行随机数流设置、变量共享冲突或归约顺序问题。2.2 parfeval异步任务与动态调度如果说parfor是“一次性把一堆活分包出去然后等全部干完”那么parfeval就是“在后台异步地派出一个任务然后再决定什么时候收结果”。parfeval的全称是parallel function evaluation它返回一个Future对象这个对象代表一个正在后台执行的任务。你可以同时派发多个parfeval任务每个任务完成后的结果会保存在对应的Future里需要用的时候再用fetchOutputs取回来。parfeval和parfor最大的区别在于两点一是异步性派发任务之后主会话不会被阻塞你可以继续做别的事情二是动态性你不需要提前把所有任务一次性分配好可以根据运行情况动态地添加新任务这对那些计算量分布极不均匀的问题特别有用。举个实际场景你要对一百个不同的图像做处理但其中有些图像特别复杂、处理时间特别长有些则很简单。如果都用parfor所有Worker必须等最后的迭代执行完成后才能一起结束快的Worker会空闲等待。用parfeval就不一样你可以派发一百个任务每一个Worker处理完一个任务后自动领取下一个实现负载均衡。这就等于把“静态分配”变成了“动态调度”。用parfeval时还需要注意一件事Future对象取回结果时会按派发的顺序对应但你也可以通过wait配合afterEach等高级玩法实现回调机制。我用过比较多的组合是先派发一批任务然后在一个while循环里用fetchOutputs逐个检查哪些任务已经完成一旦完成就立即取回结果并排上新的任务这样整个管线一直保持满载。2.3 spmd多Worker协同工作spmd和parfor的思路完全不同。parfor是“同一份计算逻辑作用在不同的数据块上最后汇总结果”而spmdSingle Program Multiple Data是“多个Worker同时执行同一份代码但各Worker可以访问彼此的数据相互通信、协同解决问题”。spmd的典型场景包括大规模线性代数运算、多节点内存分布式数组操作以及需要Worker间交换中间结果的任务。在spmd块内部你的代码会在每个Worker上分别执行访问各Worker自己的“复合变量”Composite时可以像x{1}、x{2}这样按Worker编号取用。如果需要Worker之间传数据可以使用labSend、labReceive、labBroadcast等函数进行直接通信这种“消息传递”的并行模型和MPIMessage Passing Interface很像。不过说句实话对于绝大多数个人计算需求spmd的使用频率远低于parfor和parfeval。因为spmd要求你手动处理数据分发、进程编号和通信逻辑心智负担比较大。只有当你发现parfor这种“数据并行不够用”、需要更细粒度的协同计算时才值得去翻spmd的文档深入研究。在我的经验里spmd出现最多的场景是分布式内存的distributed数组配合大规模矩阵运算而这往往又需要MATLAB的Parallel Server配合集群环境。3. 从串行到并行的完整实战3.1 环境准备与并行池管理写并行代码之前先要确认自己的环境。并行计算工具箱是付费工具箱可以用ver命令检查。如果没有安装直接在MATLAB里调用parpool会报错。确认工具箱之后可以先用gcp命令获取当前并行池。如果还没有启动gcp会返回空需要显式调用parpool启动。% 检查并行计算工具箱是否可用 ver(parallel) % 获取当前并行池如果为空则启动默认本地池 pool gcp(nocreate); if isempty(pool) parpool(local, 4); % 启动4个本地Worker有核数限制 end这里有个细节parpool(local, 4)表示启动4个本地Worker但Worker数量不能超过CPU物理核数否则可能因为超线程导致性能反而下降。如果你不确定机器有多少核可以用feature(numcores)查询物理核数。我通常建议先设成物理核数或者物理核数减一留一个核心给主会话做系统交互。parpool的启动是需要时间的本地池一般要二十秒到一分钟。这个启动开销意味着如果你的任务只有十几秒完全没有必要启动并行池。另外每次MATLAB重启之后并行池是需要重新启动的所以如果要做很多轮测试尽量保持并行池一直开着。还有一点要注意的是并行池的“作业存储”路径。MATLAB在并行任务执行过程中会在本地临时目录创建很多作业文件如果系统临时目录空间不足或者权限受限parpool启动时会报错。遇到这种问题可以把并行配置的JobStorageLocation改到有足够空间的目录% 获取本地并行配置 config parallel.cluster.Local; config.JobStorageLocation D:\MATLAB_ParallelJobs; % 重新保存配置 config.saveProfile(local_new); parpool(local_new, 4);3.2 案例用parfor改造参数扫描仿真我这里用一个具体案例展示完整的改造过程。假设我们正在做一个简单的金融期权定价模型需要在一组参数网格上跑蒙特卡洛模拟。串行代码如下% 参数网格 S0 100; K 105; T 1; r 0.05; sigma 0.2; strikeRange [95, 100, 105, 110, 115]; maturityRange [0.5, 1.0, 1.5, 2.0]; Nsim 100000; % 每条路径模拟次数 numStrikes length(strikeRange); numMats length(maturityRange); priceMatrix zeros(numStrikes, numMats); tic; for i 1:numStrikes for j 1:numMats % 对每个(S_i, T_j)组合做蒙特卡洛模拟 ST S0 * exp((r - 0.5*sigma^2)*maturityRange(j) sigma*sqrt(maturityRange(j))*randn(Nsim,1)); payoffs max(ST - strikeRange(i), 0); priceMatrix(i,j) exp(-r*maturityRange(j)) * mean(payoffs); end end toc;改造为parfor的版本非常直观% 注意parfor里不能嵌套两层循环除非外层是parfor、内层是普通for tic; parfor i 1:numStrikes tmpRow zeros(1, numMats); for j 1:numMats ST S0 * exp((r - 0.5*sigma^2)*maturityRange(j) sigma*sqrt(maturityRange(j))*randn(Nsim,1)); payoffs max(ST - strikeRange(i), 0); tmpRow(j) exp(-r*maturityRange(j)) * mean(payoffs); end priceMatrix(i,:) tmpRow; end toc;这里有两个关键点。第一parfor循环里最好不要嵌套另一个parfor因为MATLAB不支持嵌套并行内层循环就用普通for。第二内层循环的临时结果先存到tmpRow最后再整体赋值给priceMatrix(i,:)切片变量这样处理可以避免在循环体内对切片变量做逐个元素的写入从而减少并行通信开销。很多人一开始会写成priceMatrix(i,j) ...这在parfor里会报错或者效率极低。因为对二维切片的列索引j写入不满足切片变量的规则MATLAB无法有效地把这些写入分配到不同的Worker上。改成整行赋值之后每个Worker处理一行写一个完整的行切片就完全符合切片变量的要求了。跑完以后我发现六万个蒙特卡洛路径、二十组参数串行耗时大约25秒parfor在四核本地池上耗时大约8秒加速比大概在三倍左右。这个加速比符合预期因为本地机器除了计算之外还有资源竞争任何并行化都不可能做到理想加速比。3.3 用parfeval做异步任务并动态获取结果如果我需要提前在任务完成时就看到结果或者要动态调度大批量任务那么parfor就不够灵活了这种情况下用parfeval更合适。同样是上面的参数扫描我们可以用parfeval加上fetchOutputs实现逐步收集结果% 预先创建Future数组 numTasks numStrikes * numMats; F(numTasks) parallel.Future; % 预分配 taskIdx 0; for i 1:numStrikes for j 1:numMats taskIdx taskIdx 1; % 用parfeval派发任务第一个参数是函数句柄后面的参数是函数输入 F(taskIdx) parfeval(optionPricing, 1, S0, K, strikeRange(i), maturityRange(j), r, sigma, Nsim); end end % 收集结果fetchOutputs会阻塞直到所有任务完成 priceList fetchOutputs(F); priceMatrixAsync reshape(priceList, numStrikes, numMats);这里的核心是把每个计算任务封装成一个函数然后用parfeval派发到后台。由于parfeval是异步执行的主会话在派发任务后不会被阻塞你可以继续做其他事情等到需要结果时再调用fetchOutputs来获取。如果任务很多一个一个派发可能会让MATLAB的调度开销增长所以我通常会把派发操作放在一个循环里派发完所有任务后再用wait或afterEach等机制统一处理结果。不过有个经验是parfeval一次派发几千个任务每个任务都比较轻量的话调度开销可能会抵消并行收益。这时候可以手动把任务打包成较大块比如一个parfeval任务处理多个参数组减少任务数量、增大单任务计算量整体效率往往会更好。parfeval还有一点比parfor优越的是它可以逐个取回已经完成的任务而不用等全部任务结束。比如你的parfeval任务中某一个特别快完成了你完全可以在其他任务还在跑的时候先把结果拿出来做部分验证或者提前生成中间报告。这在parfor里是不太可能实现的因为parfor只有整体结束这一种状态。4. 常见问题与排查技巧实录4.1 parfor变量分类错误写parfor几乎绕不过去的一个错误就是变量分类问题。一个我经常看到的例子是parfor i 1:N data(i) data(i-1) i; % 报错 end这个写法被MATLAB判定为对切片变量data的非法索引因为data(i-1)不是以循环变量i作为直接索引。MATLAB规定切片索引必须形如v(i)、v(:, i)这种“线性且步长为1”的形式。data(i-1)里的偏移量是i-1不符合直接索引规则。解决办法是重新设计数据结构把需要先后依赖的循环改成串行或者用parfeval手动控制任务间的数据依赖。只要你看到MATLAB报错里提到“变量被标记为切片变量但索引方式无效”基本就是这个原因。另一个高频问题是广播变量内存占用爆炸。比如原本主会话里有一个2GB的矩阵在parfor循环体里要读取它来计算结果。这个矩阵会被自动作为广播变量复制到每一个Worker上如果有4个Worker内存占用直接多出8GB。如果机器内存不够就会出现“Out of Memory”错误。解决广播变量的一个常用技巧是尽可能在循环体内部只传递必要的数据片段或者使用parallel.pool.Constant来缓存大型只读数据让每个Worker只加载一次而不是每轮迭代都复制% 使用parallel.pool.Constant来缓存大型只读数据 bigData load(hugeData.mat); constData parallel.pool.Constant(bigData); parfor i 1:N result(i) myFun(constData.Value, i); endparallel.pool.Constant会在每个Worker启动时加载一次数据之后所有迭代都共享这个Worker内的这份数据副本不会重复复制。4.2 随机数并行流的问题MATLAB默认的randn和rand在parfor循环里会自动处理不同的随机数流保证每个迭代的随机数序列独立但这个独立性并不等于“可复现”。如果你希望在每次运行parfor后得到完全一样的结果就需要显式设置并行流的种子。我的做法是在每个Worker上设置独立的RandStream可以这样实现% 为4个worker生成各自独立的随机子流 [s1, s2, s3, s4] RandStream.create(mrg32k3a, NumStreams, 4); % 在parfor循环内部根据labindex选择不同的随机流 parfor i 1:N % 通过getCurrentStream获取当前worker的随机流并设置种子 stream RandStream(mt19937ar, Seed, i); RandStream.setGlobalStream(stream); result(i) randn(); end不过这个示例如果直接用你会发现在真实的parfor里直接调用RandStream.setGlobalStream会影响整个Worker的随机数状态进而影响其他迭代的独立性。更稳妥的方式是使用parfor自带的随机数流管理在循环体内部直接调用randn或rand并给每次运行设置一个全局种子。实际上MATLAB对parfor里的随机数生成有内建保护。在R2011a之后parfor会自动使用不同的随机数流不需要手动干预。你只需要在启动并行池之前设置好主会话的随机种子然后每次运行并行循环时每个Worker都会基于这个种子生成自己的独立随机流。关键在于如果你需要“每次运行结果完全一致”通常最保险的是手动为每个迭代生成一系列随机数而不是依赖内部自动流因为内部流的具体实现细节在不同版本里可能有变化。4.3 内存开销、死锁与GPU显存不足内存问题除了广播变量之外还有两个常见来源一是每个Worker是独立MATLAB进程总内存开销是“基础开销乘以Worker数”二是parfor循环里每个迭代都会生成临时变量这些临时变量在迭代结束时会自动释放但释放速度取决于MATLAB的内存管理机制。遇到莫名“Out of Memory”我建议按照以下顺序排查减少Worker数量看看内存占用是否随Worker数线性下降。检查是否有大型广播变量如果有改用parallel.pool.Constant。检查循环体内是否创建了大数组但没及时清理比如每个迭代都会生成一个巨大的中间矩阵结束后会自动释放但如果JIT没有及时回收内存峰值就会很高。在循环体内尽可能用局部变量而不是让临时变量传到主工作区。接着说一个比较危险的“死锁”问题。如果你使用spmd配合labSend时A Worker在等待接收数据而B Worker也在等待接收数据互相等待就会造成死锁。最常见的原因是接收顺序不匹配labSend和labReceive必须成对出现发送方和接收方要遵守同样的编号顺序。遇到spmd块一直卡住不结束的情况基本就是死锁。排查方法是给labReceive加上超时参数或者在代码里统一用labBroadcast来代替点对点通信避免消息顺序不一致。GPU并行计算是另一个容易踩显存不足问题的地方。使用gpuArray时所有参与计算的数组都放在GPU显存里显存通常在8GB到24GB之间比系统内存小得多。一个在CPU上跑得很好的大数组搬到GPU上就可能直接爆显存。我的建议是在GPU计算前先估算一遍数组大小用gpuDevice查询显存总量和剩余量然后计算数据需要占用多少显存。如果超出可以采用分块计算的方式每次只把一小块数据传到GPU上。4.4 性能优化与加速技巧速查并行化的最终目的是性能但不是所有并行化都能带来正收益。这里给大家一份我实际用过的性能评估清单先用tic/toc对串行版本计时得到一个基准。用parfor并行版本再次计时计算加速比。观察CPU利用率如果CPU利用率一直很低说明计算机瓶颈可能不在CPU而在内存访问或者磁盘I/O。用profile on运行串行版本找到热点函数。如果计算热点非常集中可以考虑把热点函数单独用parfeval异步执行让不同热点函数之间并行。如果parfor每次迭代的耗时太短低于几毫秒建议手动合并迭代。比如原来循环1万次每次1毫秒可以将每100次迭代合并成一个批次这样任务数量从1万降到100调度开销大幅下降。关于并行任务合并我举一个具体例子% 原本每次迭代处理一个数据点任务太轻 parfor i 1:10000 result(i) processPoint(i); end % 合并后每个任务处理100个数据点任务数减少到100 batchSize 100; numBatches 10000 / batchSize; result zeros(1, 10000); parfor b 1:numBatches startIdx (b-1)*batchSize 1; endIdx b*batchSize; for i startIdx:endIdx result(i) processPoint(i); end end这个优化有时候能把整体耗时再提升一到两倍尤其是在迭代体计算量很小、parfor调度开销占比高的情况下。当然如果单个迭代的计算量本身已经很大比如单次迭代就要跑几十秒那就不需要合并。最后必须提到的是GPU加速。MATLAB的GPU支持已经相当成熟很多内置函数比如fft、conv、矩阵乘法、gpuArray等都能直接受益。但GPU并不是银弹它有显存限制、数据传输开销和精度限制。我的经验是当数据规模足够大比如矩阵维度上万、且计算本身很适合GPU的SIMT架构时加速效果很可观但如果矩阵规模很小或者频繁在CPU和GPU之间搬数据反而会比CPU更慢。A rand(5000); B rand(5000); gA gpuArray(A); gB gpuArray(B); % 一次性传输到GPU后后面的运算都在GPU上完成 tic; C gA * gB; toc; result gather(C); % 只在最后需要输出时传回CPU这段代码里最关键的是gather函数的调用时点。很多人习惯每一步运算都gather回CPU结果性能惨不忍睹。正确做法是尽量让中间计算全程留在GPU上只在最后需要输出、保存或做CPU端后处理时才把结果取回来。5. 进阶从本地池到集群部署的扩展思考如果你的计算任务大到单机已经撑不住那么下一步就是上集群了。MATLAB Parallel Server允许你使用远程计算集群把parpool开在远程集群节点上。这种情况下parpool的配置不再是local而是对应集群Profile的名字。由于本地池和远程集群的JobStorageLocation、Worker启动方式都不同脚本迁移时最容易出问题的就是文件路径。每个Worker都运行在集群节点上时必须保证所有节点都能访问到相同的工作目录和数据文件路径。我在实际项目中经验最深的一点是并行代码的调试难度远高于串行代码。在parfor循环里打disp测试时输出会被打乱甚至丢失在spmd块里Debug时每个Worker的断点行为都很诡异。所以我的工程习惯是先在串行模式下完全验证逻辑再把循环改写成parfor并确保结果一致。这样能有效隔离“逻辑错误”和“并行错误”降低排查难度。还有一个小技巧如果想在parfor执行过程中实时观察进度可以在循环体内用textprogress等工具向文件写进度日志或者用一个Future对象配合afterEach来实现异步回调。后者稍微复杂一点但能够在不阻塞主会话的情况下更新进度条实际开发体验非常好。在结束之前我想分享一个自己一直在用的经验。用MATLAB做并发编程最容易陷入的误区是“为了让代码看起来高级所以强行加并行”。真正让并行发挥价值的地方是那些串行确实等到发疯的任务。而要让并行稳定高效地跑起来我始终建议把重点放在三件事上一是对变量分类规则做到心里有数二是学会评估并行开销与收益的平衡三是养成先串行验证再并行部署的工程习惯。这些平时看起来不起眼的细节恰恰决定了你的并行代码是稳定跑完一个通宵还是中途崩掉让你第二天上班时怀疑人生。