Snapviewer Devlog #3: 性能优化
内存与速度性能问题排查
免责声明:我主要在 Windows 上使用最新的稳定版 Rust 工具链和 CPython 3.13 进行开发和测试。
1. 背景与动机
SnapViewer 能够高效处理大型内存快照——例如,支持高达 500 MB 的压缩快照。然而,在处理 1.3 GB的snapshot的时,我发现了严重的内存和速度瓶颈:
- 格式转换(pickle → 压缩 JSON)引发了约 30 GB 的内存峰值。
- 将压缩 JSON 加载到 Rust 数据结构中又引发了另一次约 30 GB 的内存激增。
频繁的 page fault 和强烈的磁盘 I/O(在任务管理器中观察到)导致应用程序响应迟缓,甚至频繁卡顿。为了解决这一问题,我们采用了 Profile-Guided Optimization(PGO,基于性能分析的优化)方法。
2. Profile-Guided Optimization(PGO)
PGO 需要通过实证分析来识别真正的热点。我首先使用 memory-stats crate 进行内存分析,在早期优化阶段进行轻量级检查。随后,我将数据加载流水线拆解为若干离散步骤:
- 读取压缩文件(重度磁盘 I/O)
- 从压缩流中提取 JSON 字符串
- 将 JSON 反序列化为原生 Rust 数据结构
- 填充内存中的 SQLite 数据库以支持即席 SQL 查询
- 在 CPU 上构建三角网格(triangle mesh)
- 初始化渲染窗口(CPU-GPU 数据传输)
性能分析揭示了两个主要的内存问题:过度使用 clone 和多个中间数据结构。以下是我实施的优化措施。
消除冗余的 Clone
在快速原型开发阶段,调用 .clone() 非常方便,但代价高昂。性能分析显示,克隆大型 Vec 显著加剧了内存峰值和 CPU 时间。
- 首次尝试:将对
Vec<T>的clone()改为借用的&[T]切片。但由于生命周期约束无法做到. - 最终方案:改用
Arc<[T]>。尽管我并未使用多线程,但Arc满足了 PyO3 的要求,且在此场景中未观察到明显开销。
仅此一项改动就显著降低了内存使用, 降低了启动耗时。
提前释放中间结构
构建三角网格涉及多个临时表示形式:
- 原始分配缓冲区
- 三角形列表(顶点 + 面索引)
- CPU 端的网格结构
- GPU 上传缓冲区
每个阶段都会保留其前驱数据直至作用域结束,从而推高了峰值内存占用。为及时释放这些中间数据,我们采取了以下措施:
- 使用作用域块(scoped blocks)限制生命周期
- 对不再需要的缓冲区显式调用
drop()
经过这些调整,峰值内存大约减少了三分之一。
3. 分片处理 JSON 反序列化
对包含超过 50,000 个条目的调用栈 JSON 进行反序列化时,内存使用急剧飙升。为缓解此问题:
- 将 JSON 数据分片,每片最多包含 50,000 个条目。
- 独立反序列化每个分片。
- 合并所得到的
Vec。
这种流式处理方法使每个分片的内存占用保持在较低水平,避免了之前的大规模单次分配。
值得注意的是,
serde_json::StreamDeserializer是另一个值得尝试的选项。
4. 重新设计快照格式
即使经过上述优化,调用栈数据仍然是内存中最大的组件——在 Rust 中和内存 SQLite 数据库中各存一份,造成重复。
为消除冗余,我重新思考了每种表示形式的用途:
- Rust 结构:用户点击时在屏幕上显示调用栈。
- SQLite 数据库:支持即席 SQL 查询。
由于 SnapViewer 是单线程的,且可容忍偶尔的磁盘 I/O,我将快照拆分为两个文件:
- allocations.json:轻量级 JSON,包含分配时间戳和大小。
- elements.db:SQLite 数据库,存储调用栈文本(按分配索引建立索引)。
这两个文件被一起压缩打包。运行时:
- 解压快照。
- 将
allocations.json加载到内存(占用很小)。 - 打开磁盘上的
elements.db。 - 用户点击时,通过
WHERE idx = <allocation_index>查询elements.db。
SQLite 高效的磁盘索引使这些查询非常迅速,对帧率几乎没有可感知的影响。
重构转换脚本
我对快照转换脚本进行了如下更新:
- 解析原始快照格式。
- 将调用栈批量插入内存 SQLite 数据库,然后将数据库转储为字节流。
- 将分配元数据序列化为 JSON。
- 将 JSON 与数据库字节流一起压缩。
虽然转换过程略慢,但生成的快照加载更快,且内存占用大幅降低。
5. 成果与经验总结
经过这些优化,SnapViewer 实现了以下改进:
- 不再因加载大型快照而触发 60+ GB 的内存峰值,因为我们完全不再将整个调用栈信息加载到内存中。
- 启动速度显著提升。
- 即使进行按需调用栈查询,渲染依然流畅。
我学到的经验:
- 不要总是把所有数据都加载到内存中。当你耗尽物理内存时,虚拟内存交换系统的性能可能比你想象的还要差。
- 当你需要将大部分数据存储在磁盘上,同时智能地缓存部分数据到内存时,SQLite 是一个好的选择。它内置了经过工业验证的高效算法。