4. 循环与向量化在批量计算中的取舍
批量计算的核心矛盾在于:同一套计算逻辑要作用在成百上千组输入上,而 MATLAB 的两种基本组织方式——显式循环与向量化——在速度、内存和可读性上各有代价。本节不讨论“向量化一定优于循环”这类口号,而是给出可操作的判断依据:什么时候用 for,什么时候用 parfor,什么时候用逻辑索引或 arrayfun 家族,以及当内存成为瓶颈时如何退让。
一、先分清三类“批量”对象
选择实现方式前,先确认数据形态,因为它直接决定可用工具:
- 同构数值数组:所有元素类型、尺寸一致,例如 1000×500 的矩阵,每列是一组参数。这类数据优先考虑向量化或逻辑索引。
- 元胞数组:每个元素可以是不同尺寸的矩阵、字符串或结构体,例如 200 个不同长度的实验数据段。这类数据适合
cellfun或for。 - 结构体数组:字段名固定、字段值类型可能不同,例如 500 条记录,每条含
time、signal、fs。这类数据适合structfun或字段级向量化。
把元胞数组硬转成三维数值数组再向量化,往往因补齐长度而浪费大量内存;反过来,把同构矩阵拆成元胞再逐元素处理,则白白损失向量化收益。第一步判断错了,后面怎么优化都事倍功半。
二、向量化与逻辑索引:优先但不迷信
对于同构数值数组,向量化的本质是把循环交给底层 BLAS/LAPACK 或逐元素运算内核,减少解释器开销。例如对 1000 组信号分别做归一化,若数据已组织为矩阵 X(每列一组),可直接写:
mu = mean(X, 1); % 1×1000
sd = std(X, 0, 1); % 1×1000
Xn = (X - mu) ./ sd; % 隐式扩展,逐列归一化
逻辑索引则用于“按条件筛选后再计算”。例如只对幅值超过阈值的样本做平方:
mask = abs(X) > 0.5;
X(mask) = X(mask).^2;
这里 mask 是与 X 同尺寸的逻辑数组,X(mask) 取出满足条件的元素,赋值时再写回原位置。逻辑索引避免了 find 产生下标数组的额外内存,在筛选比例不高时尤其划算。
但向量化并非没有代价。当需要构造大型中间矩阵时,内存峰值可能远超预期。例如用 bsxfun 或隐式扩展计算 10000×10000 的距离矩阵,中间结果就是 800 MB(double 类型),若机器内存紧张,反而不如分块循环。此时应把大矩阵切成若干列块,每块单独向量化,控制峰值内存。
三、for、parfor 与 arrayfun 家族的适用边界
for 循环的优势是内存可控、逻辑清晰、便于插入调试输出和异常处理。当每次迭代的结果依赖上一次(如递推滤波),或每次迭代的数据量很小但次数极多时,for 仍是合理选择。MATLAB 的 JIT 对简单 for 已有较好优化,不必为了“看起来向量化”而把代码写得难以维护。
parfor 用于迭代之间相互独立、且单次迭代计算量足够大的场景。它的收益来自多核并行,但启动并行池、传输数据都有开销。经验上,若单次迭代耗时低于几毫秒,parfor 的加速可能被通信开销抵消。使用 parfor 时要注意:循环体内不能有跨迭代的依赖,不能随意修改外部变量,切片数组的索引方式也有限制。
arrayfun、cellfun、structfun 本质是“把函数作用到每个元素/字段上”的语法糖。它们让代码更紧凑,但通常不带来速度优势,甚至因函数句柄调用而略慢。它们的真正价值在于表达清晰:当每个元素需要调用一个已有函数、且元素尺寸不一致时,cellfun(@(x) myfun(x, p), C) 比手写循环更直观。若追求速度,应优先考虑能否把 myfun 本身向量化。
四、内存与速度的实测判断方法
不要凭感觉选择实现方式,而应做最小对照实验。以“对 500 个 1024 点信号做 FFT 并求主频”为例,可以准备三种写法:
- for 循环:逐段调用
fft,结果存入预分配的矩阵。 - 矩阵化:把 500 段拼成 1024×500 矩阵,一次
fft处理所有列。 - parfor:在 for 基础上改为并行循环。
测量时用 tic/toc 记录墙钟时间,用 whos 或 memory 观察峰值内存。典型结果是:矩阵化最快且代码最短,但若信号长度差异大、无法整齐拼接,则只能回到 for 或 cellfun;parfor 在单段 FFT 耗时约 1 ms 时可能没有明显收益,而在单段耗时 50 ms 以上时加速比才接近核数。
五、一个可复用的取舍检查清单
| 检查项 | 倾向向量化/逻辑索引 | 倾向 for/parfor/cellfun |
|---|---|---|
| 数据是否同构 | 是,尺寸类型一致 | 否,元胞或变长 |
| 迭代是否独立 | 不涉及 | 独立才可用 parfor |
| 单次迭代耗时 | 不涉及 | 低于数毫秒慎用 parfor |
| 中间矩阵规模 | 可接受,不超内存 | 过大则分块或循环 |
| 代码可读性 | 表达式简洁 | 循环逻辑更直观 |
最后强调一点:预分配是循环写法的底线。在 for 或 parfor 中不断用 result(end+1) = ... 扩展数组,会触发反复内存重分配,速度可能比向量化慢一个数量级以上。正确做法是先 zeros 或 cell 预分配,再按索引写入。把这一条做好,很多“循环太慢”的问题其实已经解决大半。