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 是一个好的选择。它内置了经过工业验证的高效算法。