2. 性能分析基础

在 MATLAB 并行计算与 GPU 优化中,最忌讳的做法是“凭感觉猜哪里慢”。MATLAB 的解释执行、JIT 加速、内存动态增长、隐式扩展等机制交织在一起,代码的实际耗时分布往往与直觉相反。性能分析的第一步不是优化,而是测量:先找到真正的瓶颈,再决定是向量化、预分配、改用 GPU,还是并行化。本节围绕三类工具展开——tic/toc、timeit 和 Profiler,并用它们量化向量化与预分配带来的收益。

一、tic/toc:最轻量的分段计时

tic 启动一个秒表,toc 读取从最近一次 tic 起经过的时间。它适合在代码中插入多个计时点,快速得到各段耗时。例如下面这段对 2000×2000 矩阵做逐元素运算的示例:

n = 2000;
A = rand(n);
B = rand(n);

tic;
C = zeros(n);
for i = 1:n
    for j = 1:n
        C(i,j) = A(i,j) * B(i,j) + sin(A(i,j));
    end
end
t_loop = toc;

tic;
D = A .* B + sin(A);
t_vec = toc;

fprintf('循环: %.4f s, 向量化: %.4f s, 加速比: %.1fx\n', ...
        t_loop, t_vec, t_loop/t_vec);

在典型桌面环境下,循环版本可能耗时数秒到十几秒,而向量化版本通常在 0.05~0.2 s 量级,加速比可达数十倍甚至上百倍。需要注意:tic/toc 测的是墙钟时间,包含首次函数调用、JIT 编译、内存分配等一次性开销,因此单次测量波动较大。若某段代码只运行一次,测得的数值可能明显偏高。

二、timeit:为短小函数提供稳健计时

timeit 专门解决上述问题。它接收一个无参函数句柄,自动进行预热(warm-up)并多次运行取中位数,从而抑制首次执行和系统抖动的影响。用法如下:

f = @() A .* B + sin(A);
t = timeit(f);          % 返回稳健的耗时估计
fprintf('向量化耗时: %.6f s\n', t);

对比要点:tic/toc 适合在长流程中定位“哪一段慢”,timeit 适合对某个具体操作做精确、可重复的基准测试。当你要比较两种写法(如 for 循环 vs arrayfun vs 向量化)时,应优先用 timeit,避免被单次噪声误导。

三、Profiler:定位真正的热点函数

当代码由多个函数、多层调用组成时,手工插桩成本高且容易遗漏。profile on / profile off 配合 profile viewer 可以给出完整的调用树、每个函数的自身耗时(Self Time)与总耗时(Total Time)、调用次数,以及逐行的耗时分布。

profile on
result = myPipeline(A, B);
profile off
profile viewer

阅读 Profiler 报告时,重点看三列:Total Time(含子函数)、Self Time(不含子函数)、Calls(调用次数)。若某函数 Self Time 很高但 Calls 很少,说明它单次执行就慢,通常是算法或内存问题;若 Self Time 不高但 Calls 极大,说明是“小函数被反复调用”的开销累积,应考虑向量化或批量处理。Profiler 会显著拖慢运行速度,因此只用于定位,不用它做性能基准。

性能分析工具选择与瓶颈定位路径 tic / toc 分段墙钟计时 适合长流程粗定位 timeit 预热 + 多次取中位数 适合短操作精确基准 Profiler 调用树 + 逐行耗时 适合多函数热点定位 瓶颈判断 Self Time 高? Calls 次数多? 内存反复增长? 是否可并行/GPU? 向量化 用矩阵运算替代逐元素循环 预分配 zeros/ones 预先分配数组 并行 / GPU parfor、gpuArray 加速 预分配效果示意(n = 5000,示例量级) 动态增长 C(end+1)=... ≈ 0.9 s 预分配 zeros(n,1) ≈ 0.02 s 动态增长每次扩容都要 复制旧数据,复杂度 O(n²)

四、向量化:把循环交给底层 BLAS/LAPACK

MATLAB 的矩阵运算底层调用高度优化的 BLAS/LAPACK 库,并支持多线程。向量化的本质是把逐元素循环改写成整体数组运算,让解释器只处理一次调用,把循环交给编译好的底层代码。常见改写模式包括:

  • 逐元素算术:C(i,j)=A(i,j)*B(i,j) → C = A .* B;
  • 条件筛选:循环加 if → 逻辑索引 C = A(A > 0);
  • 累加求和:循环累加 → sum(A, 'all') 或 sum(A, 2);
  • 函数映射:循环调用 sin → 直接 sin(A)(多数数学函数支持数组输入)。

但向量化并非万能。当运算存在前后依赖(如递推 x(i)=x(i-1)+a(i))时,无法直接向量化,此时可考虑 filter、cumsum 等内置函数,或改用 GPU/并行方案。另外,向量化会一次性申请大块临时内存,若数组极大,可能触发内存不足,需要分块处理。

五、预分配:消除数组动态增长的隐性开销

在循环中写 C(end+1) = value 或 C = [C; row] 时,MATLAB 每次都要申请更大的新内存、复制旧内容、释放旧内存。若循环 n 次,总复制量约为 O(n²),n 越大越致命。正确做法是先确定最终大小并预分配:

n = 5000;
% 反例:动态增长
tic;
C = [];
for i = 1:n
    C(end+1) = i^2;
end
t_bad = toc;

% 正例:预分配
tic;
C = zeros(n,1);
for i = 1:n
    C(i) = i^2;
end
t_good = toc;

fprintf('动态增长: %.4f s, 预分配: %.4f s\n', t_bad, t_good);

在示例量级下,动态增长可能比预分配慢一到两个数量级。判断是否需要预分配的信号很明确:只要循环体内出现数组长度变化,就应预分配。对于未知最终长度的场景,可先按上界预分配,循环结束后用 C = C(1:k) 截断。

测量建议:先用 tic/toc 粗分段落,再用 Profiler 找热点函数,最后用 timeit 对候选优化点做可重复的 A/B 对比。每次只改一个因素,记录加速比,避免多个改动叠加后无法归因。
常见误区:在 Profiler 开启状态下比较性能;用单次 tic/toc 结果下结论;把“代码更短”等同于“更快”;忽略首次调用 JIT 编译开销。这些都会导致优化方向错误。

把三者结合成一条工作流:tic/toc 定位慢段 → Profiler 找到热点函数与调用次数 → 针对热点做向量化或预分配 → timeit 验证加速比。只有先量化瓶颈,后续的并行化与 GPU 迁移才有明确目标,否则很容易把时间花在只占 5% 耗时的代码上。