序言
最近我给 Rspack 提了一个 RP(Pull Request),用于完善 SourceMapDevToolPlugin
的功能和配置选项。你可以在这个链接查看 PR 的具体内容:github.com/web-infra-d... 。
PR 的改动比较大。在完成了新功能并编写了必要的测试用例后,我兴奋地创建了 PR 并期待着评审。评审结果显示功能实现得不错,但考虑到代码的大幅度变更,我被要求进行性能基准测试。测试结果让人有些失望,因为性能显著下降了。
针对性能下降的问题,我投入了三天时间来解决。由于我还是 Rust 领域的新手,对如何进行 Rust 性能分析并不太了解,这个任务较为困难。
经过反复尝试多种方法后,我发现在 Mac 电脑上,用 Instruments 工具做 Rust 应用的 CPU 时间性能分析是最为有效的手段。本文将详细介绍如何利用 Instruments 对 Rust 应用进行 CPU/时间性能分析。
Instruments 的 Time Profiler 工具
Instruments 是内嵌于 macOS 的 Xcode 开发环境中的一个强大的分析工具集。它不仅可用于监测内存使用、文件 I/O 和网络活动等领域,还可以帮助开发者深入理解 CPU 性能问题,这对于优化 Rust 程序至关重要。
在 Instruments 的众多工具中,我们将聚焦于 Time Profiler------一个用于揭示程序中 CPU 时间分配的分析器。它唯一的任务是通过采样来揭示 CPU 如何在程序的不同部分之间分配时间,这对开发者意味着能够快速识别性能瓶颈并对症下药。
Time Profiler 基于采样动态记录程序在运行中的堆栈信息,而不是跟踪记录每次函数调用,这允许它在不显著影响程序性能的前提下,提供详尽的性能分析。通过设置适当的采样间隔,如每 1 毫秒采样一次,Instruments 能够搜集到精确的性能数据用于分析。
准备动作
为了充分了解 Instruments 的 Time Profiler 工具,我们准备一个运行时长足够以便于采样的示例程序。
下面的代码是一个基于莱布尼茨公式(Leibniz formula for π)的简版 π 值近似计算的 Rust 代码。莱布尼茨公式利用无穷级数来逼近 π 的值,其公式如下:
π = 4/1 - 4/3 + 4/5 - 4/7 + 4/9 - 4/11 + ...
rust
#[inline(never)]
fn calculate_pi(iterations: u64) -> f64 {
let mut pi: f64 = 0.0;
let mut denominator: f64 = 1.0;
for i in 0..iterations {
if i % 2 == 0 {
pi += 4.0 / denominator;
} else {
pi -= 4.0 / denominator;
}
denominator += 2.0;
}
pi
}
fn main() {
let pi = calculate_pi(1_000_000_000);
println!("Calculated Pi is: {}", pi);
}
我们在 calculate_pi
函数前使用 #[inline(never)]
属性来禁止内联优化,这样能确保在 Instruments 分析结果中清楚地看到这个函数。
紧接着,我们对 Cargo.toml
进行配置以生成足够的调试信息,确保在 Instruments 中能够获取人类可读的信息。以下配置可以理解为 Rust 版本的生成源码映射。
toml
[profile.release]
debug = 1 # 启用调试信息
strip = false # 不要去除 symbols
最后,通过运行 cargo run --release
来构建你的 Rust 应用。这里添加 --release
标志以确保我们正在分析经过 Rust 优化的代码------也就是用户最终将使用的版本。
运行 Time Profiler 分析 Rust 程序
运行 Time Profiler 工具只需两个简单的步骤:
第一步,启动 Time Profiler: 打开你的终端,导航到你的 Rust 项目目录。使用以下命令,触发性能分析的记录:
bash
xcrun xctrace record --template 'Time Profile' --output ./output.trace --launch -- /path/to/your/rust/project/target/release/your_binary
这条命令利用 xctrace
工具采用 Time Profiler
模板记录性能数据,最终生成一个 output.trace
文件。请确保替换 /path/to/your/rust/project/target/release/your_binary
为你的可执行文件的真实路径。
第二步,查看 .trace
文件 : 分析记录完毕后,用 open
命令来打开刚生成的 .trace
文件,这将自动触发 Instruments 并加载性能数据。
bash
open ./output.trace
Instruments 启动后你就能直观地看到性能采样结果。例如,在我们的分析中,calculate_pi
函数单独耗费了大约 4.90 秒的 CPU 时间,几乎是程序运行时间的全部,占了 99.9%。这种一目了然的数据展示有助于我们迅速锁定可能的性能瓶颈。
解决 Rspack 性能劣化
回到我们的 Rspack 项目中,使用 Time Profiler 工具来对 main 分支和开发分支的性能进行对比分析。通过仔观察,我们发现性能下降主要出现在 process_assets_stage_dev_tooling
方法上。
具体数据如下:
- main 分支耗时: 1.06 秒
- 开发分支耗时: 2.22 秒
仔细检查调用栈显示,性能下降是由 filter_map
迭代中的 Source.map
函数调用引起的。该函数负责为代码生成 source map。
相关代码段如下所示:
rust
let assets = compilation
.assets()
.iter()
.filter_map(|(file, asset)| {
let is_match = match &self.test {
Some(test) => test(file.clone()),
None => true,
};
if is_match {
asset.get_source().map(|source| {
let map_options = MapOptions::new(self.columns);
let source_map = source.map(&map_options);
(file, source, source_map)
})
} else {
None
}
})
.collect::<Vec<_>>();
依赖于代码 diff,问题的根源在于我之前将原本利用 Rust 的 rayon 库实现的并发迭代器 par_iter
更改为了单线程的 iter
。顾名思义,par_iter
用于在多线程中并行处理迭代任务。在实际并未了解 rayon 的情况下,我错误地认为这一更改无关紧要。
实际上,在 main 分支的 Time Profiler 数据中,我们可以清楚地看到 source.map
方法确实在多个线程上并行执行。
我随后将代码改回使用 par_iter
,并以此解决了性能问题。性能基准测试随之恢复正常。
最后
Rust性能很高么?不,它完全顶不住我的代码。一行代码查了三天,绷不住了。
哈哈,仅是开个玩笑。其实这次深入 Rust 的性能分析我更加理解了一点:要发挥 Rust 的最佳性能,还需要深刻理解它的特性。Rust 确实是一个功能强大的工具,但释放它的全部潜力,同样需要我们的持续学习和实践。