注: 转自 微信公众号“高可用架构”:从20秒到0.5秒:一个使用Rust语言来优化Python性能的案例
导读:Python 被很多互联网系统广泛使用,但在另外一方面,它也存在一些性能问题,不过 Sentry 工程师分享的在关键模块上用另外一门语言 Rust 来代替 Python 的情况还是比较罕见,也在 Python 圈引发了热议,高可用架构小编将文章翻译转载如下。
Sentry 是一个帮助在线业务进行监控及错误分析的云服务,它每月处理超过十亿次错误。我们已经能够扩展我们的大多数系统,但在过去几个月,Python 写的 source map 处理程序已经成为我们性能瓶颈所在。(译者:source map 就是将压缩或者混淆过的代码与原始代码的对应表)
从上周开始,基础设施团队决定调查 source map 处理程序的性能瓶颈。——我们的 Javascript 客户端已经成为我们最受欢迎的程序,其中一个原因是我们通过 source map 反混淆 Javascript 的能力。然而,处理操作不是没有代价的。我们必须获取,解压缩,反混淆然后反向扩张,使 Javascript 堆栈跟踪可读。
当我们在 4 年前编写了原始处理流水线时,source map 生态系统才刚刚开始演化。随着它成长为一个复杂而成熟的 source map 处理程序,我们花了很多时间用 Python 来处理问题。
截至昨天,我们通过 Rust 模块替换我们老的 Python 的 souce map 处理模块,大大减少了处理时间和我们的机器上的 CPU 利用率。
为了解释这一切,我们需要先理解 source map 和用 Python 的缺点。
Python 的 Source Maps
随着我们的用户的应用程序变得越来越复杂,他们的 source map 也越来越复杂。在 Python 中解析 JSON 本身是足够快的,因为它们只是字符串而已。问题在于反序列化。每个 source map token 产生一个 Python 对象,我们有一些 source map 可能有几百万个 token。
将 source map token 反序列化的问题使得我们为基本 Python 对象支付巨大的成本。另外,所有这些对象都参与引用计数和垃圾收集,这进一步增加了开销。处理 30MB source map 使得单个 Python 进程在内存中扩展到〜 800MB,执行数百万次内存分配,并使垃圾收集器非常忙碌(译者注:token 是短生命周期对象,有新生代就好多了,这时候就体现出我大 Java 的优势了)。
由于这种反序列化需要对象头和垃圾回收机制,我们能在 Python 层做改进的空间非常小。
Rust 的 Source Maps
在调查发现问题在于 Python 的性能缺陷后&#xff0c;我们决定尝试 Rust source map 解析器的性能&#xff0c;这是为我们的 CLI 工具编写的。在将 Rust 解析器应用于问题很大的 source map 之后&#xff0c;其表明单独使用该库进行解析可以将处理时间从 > 20 秒减少到 <0.5 秒。这意味着即使忽略任何优化&#xff0c;只是将 Python 解析器替换为 Rust 解析器就可以缓解我们的性能瓶颈。
我们证明 Rust 确实更快后&#xff0c;就清理了一些 Sentry 内部 API&#xff0c;以便我们可以用新的库替换原来的实现。这个 Python 库命名为 libsourcemap&#xff0c;是我们自己的 Rust source map 的一个薄包装。
优化结果
部署该库后&#xff0c;专门用于 source map 处理的机器压力大大降低。
最糟糕的 source map 处理时间减少到原来的十分之一。
更重要的是&#xff0c;平均处理时间减少到〜 400 ms。
Javascript 是我们最受欢迎的项目语言&#xff0c;这种变化达到了将所有事件的端到端处理时间减少到〜 300 ms。
在 Python 中 嵌入 Rust
有很多方法可以暴露 Rust 库给 Python。我们选择将 Rust 代码编译成一个 dylib&#xff0c;并提供一些 ol&#39;C 函数&#xff0c;通过 CFFI 和 C 头文件暴露给 Python。有了 C 语言头文件&#xff0c;CFFI 生成一些 shim&#xff08; shim 是一个小型的函数库&#xff0c;用于透明地拦截 API 调用&#xff0c;修改传递的参数、自身处理操作、或把操作重定向到其他地方&#xff09;&#xff0c;可以调用 Rust。这样&#xff0c;libsourcemap 可以打开在运行时从 Rust 生成的动态共享库。
这个过程有两个步骤。第一个是在 setup.py 运行时配置 CFFI 的构建模块&#xff1a;
在构建模块之后&#xff0c;头文件通过 C 预处理器来处理&#xff0c;以便扩展宏&#xff08; CFFI 本身无法执行的过程&#xff09;。此外&#xff0c;这将告诉 CFFI 在哪里放置生成的 shim 模块。所有完成的之后&#xff0c;加载模块&#xff1a;
下一步是编写一些包装器代码来为 Rust 对象提供一个 Python API&#xff0c;这样能够转发异常。这发生在两个过程中&#xff1a;首先&#xff0c;确保在 Rust 代码中&#xff0c;我们尽可能使用结果对象。此外&#xff0c;我们需要处理好 panic&#xff0c;以确保他们不会跨越 DLL 边界。第二&#xff0c;我们定义了一个可以存储错误信息的帮助结构 ; 并将其作为 out 参数传递给可能失败的函数。
在 Python 中&#xff0c;我们提供了一个上下文管理器&#xff1a;
我们有一个特定错误类&#xff08; special_errors&#xff09;的字典&#xff0c;但如果没有找到具体的错误&#xff0c;将会抛一个通用的 SourceMapError。
从那里&#xff0c;我们实际上可以定义 source map 的基类&#xff1a;
在 Rust 中暴露 C API
我们从包含一些导出函数的 C 头开始&#xff0c;如何从 Rust 导出它们&#xff1f; 有两个工具&#xff1a;特殊的&#xff03; [no_mangle] 属性和 std :: panic 模块 ; 提供了 Rust panic 处理器。我们自己建立了一些 helper 来处理这个&#xff1a;一个函数用来通知 Python 发生了一个异常和两个异常处理 helper&#xff0c;一个通用的&#xff0c;另一个包装了返回值。有了这个&#xff0c;包装方法如下&#xff1a;
boxed_landingpad 的工作方式很简单。它调用闭包&#xff0c;用 panic :: catch_unwind 捕获 panic&#xff0c;解开结果&#xff0c;并在原始指针中加上成功值。如果发生错误&#xff0c;它会填充 err_out 并返回一个 NULL 指针。在 lsm_view_free 中&#xff0c;只需要从原始指针重新构建。
构建扩展
要实际构建扩展&#xff0c;我们必须在 setuptools 中做一些不太优雅的事情。幸运的是&#xff0c;在这件事上我们没有花太多时间&#xff0c;因为我们已经有一个类似的工具来处理。
这个做法最方便的部分是源代码用 cargo 编译&#xff0c;二进制安装最终的 dylib&#xff0c;消除任何最终用户使用 Rust 工具链的需要。
那些做得好&#xff0c;那些没做好&#xff1f;
我在 Twitter 上被问到&#xff1a;“ Rust 会有什么替代品&#xff1f;”说实话&#xff0c;Rust 很难替代。原因是&#xff0c;除非你想用性能更好的语言重写整个 Python 组件&#xff0c;否则只能使用本机扩展。在这种情况下&#xff0c;对语言的要求是相当苛刻的&#xff1a;它不能有一个侵入式运行时&#xff0c;不能有一个 GC&#xff0c;并且必须支持 C ABI。现在&#xff0c;我认为适合的语言是 C&#xff0c;C&#43;&#43; 和 Rust。
哪方面工作的好&#xff1a;
结合 Rust 和 Python 与 CFFI。有一些替代品&#xff0c;链接到 libpython&#xff0c;但构建更复杂。
在老一些的 CentOS 版本使用 Docker 来构建可移植的 Linux 容器。虽然这个过程是乏味的&#xff0c;然而不同的 Linux 发兴版和内核之间的稳定性的差异使得 Docker 和 CentOS 成为可接受的构建解决方案。
Rust 生态系统。我们使用 crates.io 的 serde 反序列化和 base64 库&#xff0c;两个库工作非常好。此外&#xff0c;mmap 支持使用由社区 memmap 提供的另一库。
哪方面工作的不好&#xff1a;
迭代和编译时间真的可以更好。我们每次更改字符时都编译模块和头文件。
setuptools 步骤非常脆弱。我们可能花了更多的时间来使 setuptools 工作。幸运的是&#xff0c;我们以前做过一次&#xff0c;所以这次更容易。
虽然 Rust 对我们的工作帮助很大&#xff0c;毫无疑问&#xff0c;有很多需要改进。特别是&#xff0c;用于导出 C ABI&#xff08;并使其对 Python 有用&#xff09;的基础设施应该有很大改进空间。编译时间也不是很长&#xff08;译者的话&#xff0c;不是很长的意思是可能够我沏杯茶&#xff0c;怀念 go 的编译速度&#xff09;。希望增量编译将有所帮助。
下一步
其实我们还有更多的改进空间。我们可以以更高效的格式启动缓存&#xff0c;比如一组存储在内存中的结构体而不是使用解析 JSON。特别是&#xff0c;如果与文件系统缓存配对&#xff0c;我们几乎可以完全消除加载的成本&#xff0c;因为我们平分了索引&#xff0c;这可以使用 mmap 非常有效。
鉴于这个好的结果&#xff0c;我们很可能会评估 Rust 更多在未来处理一些 CPU 密集型的业务。然而&#xff0c;对于大多数其他操作&#xff0c;程序花更多的时间等待 IO。
小结
虽然这个项目取得了巨大的成功&#xff0c;但是我们只花了很少的时间来实现。它降低了我们的处理时间&#xff0c;它也将帮助我们水平扩展。Rust 一直是这个工作的完美工具&#xff0c;因为它允许我们将昂贵的操作使用本地库完成&#xff0c;而且不必使用 C 或 C &#43;&#43;&#xff08;这不太适合这种复杂的任务&#xff09;。虽然很容易在 Rust 中编写 source map 解析器&#xff0c;但是使用 C / C&#43;&#43; 来完成的话&#xff0c;代码更多&#xff0c;且没那么有意思。
我们确实喜欢 Python&#xff0c;并且是许多 Python 开源计划的贡献者。虽然 Python 仍然是我们最喜欢的语言&#xff0c;但我们相信在合适的地方使用合适的语言。Rust 被证明是这项工作的最佳工具&#xff0c;我们很高兴看到 Rust 和 Python 将来会带给我们什么。
译者注&#xff1a;不熟悉 source map 的同学请看阮一峰的这篇文章 http://www.ruanyifeng.com/blo...