在过去的一年里,Sorbet 团队一直致力于为 Ruby 开发一个实验性的、提前的编译器,由 Sorbet 和 LLVM 提供支持。今天我们分享它的源代码。它与 GitHub 上 Sorbet 的现有代码并存,主要在编译器/文件夹中:我们想事先说明:代码现在还远未准备好供外部使用,但我们欢迎您阅读代码并就我们的方法提供反馈!几周前,我们在一条推文中取笑了这一点,这引起了相当多的关注,同时也提出了很多问题:我们是多年基础设施赌注的忠实信徒。经过几年的 Ruby 基础工作,我们内部的 Ruby 编译器现在比 Ruby 对 Stripe 生产 API 流量的默认实现快 22-170%。如果有兴趣解决此类问题,我们正在招聘! — Patrick Collison (@patrickc) 2021 年 6 月 30 日如果您是 Sorbet 的现有用户,那么您不会有任何改变!您可以像现在一样继续使用 Sorbet 对 Ruby 代码库进行类型检查。虽然 Sorbet Compiler 代码位于 Sorbet 存储库中,但它不会改变 Sorbet 的任何内容,也不需要使用 Sorbet。没有 Sorbet Compiler 二进制版本。如果成功,我们可能有一天会发布 Sorbet Compiler 的预构建二进制文件,但现在我们没有任何确定的计划。如果您好奇,我们欢迎您阅读源代码并尝试自己编译ity。
虽然我们在生产中使用 Stripe 的 Sorbet Compiler,但不应将其视为“生产就绪”。我们不会优先解决不会影响 Stripe 的问题。这是一个公开开发的内部实验。抛开这些免责声明,让我们深入探讨常见问题。尽管 Sorbet Compiler 的 repo 是私有的,但我们已经与少数公司和个人共享了它,这很辛苦。有时改进编译器需要改进 Sorbet。此类更改现在只需要一个回购中的一个 PR。 Sorbet Compiler 依赖于 Sorbet 的内部数据结构。现在这两个 repos 是一个,它们可以直接共享内部数据结构。两个存储库中的大部分 Bazel 构建和测试基础设施都被复制,但现在可以共享。除了这些务实的考虑之外,我们还有兴趣从更大的社区收集对我们方法的反馈。请随意阅读源代码,并与我们联系您要说的话。
在 Stripe,我们的主要产品是 API。在 API 中,延迟是一个特性,就像 API 允许你做的一样。 Stripe 是一家用户至上的公司,这些用户要求降低延迟。 Stripe 的其他团队正在从 IO 角度解决延迟问题。与此同时,我们一直致力于构建一个“大锤”来提高 Ruby 计算性能。这是我们最受欢迎的问题,它有多种不同的形式:首先,不必将整个语言运行时交付到生产环境中,使用提前编译器,编译在 CI 中发生一次。我们已经在部署档案中提供了各种生成的代码和数据文件——编译后的工件与我们现有的构建管道无缝匹配。如果出现问题,这也限制了blastradius:如果我们需要“关闭”编译器,我们只需停止加载已编译的档案并让Ruby VM运行原始源。另一点:提前编译器在概念上更简单。当我们启动 Sorbet Compiler 项目时,我们估计构建一个提供真实世界性能改进的提前编译器将比构建一个同时满足我们的延迟目标和条带安全要求的 JIT 花费的时间更少。正如我们将在下面讨论的,我们选择的实现策略使提前编译更简单,更容易逐步推出。最后,在 Sorbet 对项目进行类型检查后,提前编译让项目利用静态呈现的类型信息。虽然 JIT 在运行时观察类型以告知它如何编译代码,但 Sorbet 已经存在此信息(除非代码使用 T.untyped)。当然,这是编译器和 JIT 之间的经典权衡:有时运行时类型信息实际上更好,因为 JIT 可以看穿接口和多态性。我们并没有声称在 Sorbet Compiler 中解决了这个权衡问题,但是 Stripe 的 Ruby 代码库被 Sorbet 广泛覆盖,因此在利用这种静态类型信息方面处于独特的位置。 Ruby 社区中的许多人都发现用 JRuby 或 TruffleRuby 替换 Ruby VM 是成功的。 JRuby 使 Ruby 可以轻松地与其他 JVM 语言进行互操作,TruffleRuby 和 GraalVM 也是如此。两者都声称具有令人印象深刻的性能改进以及多核、共享内存并发等诱人的功能。
但几乎所有 Stripe 的代码库都是在运行在默认 Ruby VM (YARV) 上的 Ruby 中实现的。我们不仅不需要 Java 虚拟机级别的互操作性,选择任一替代 Ruby 实现都会使迁移路径变得困难。 Stripe 严重依赖于带有本机扩展的 gem,并且如您所想,随着时间的推移,数百万行的 Ruby 代码库开始依赖于 Ruby 的实现,而不仅仅是 Ruby 的语言。切换到这些实现之一的另一个障碍是它必须在服务边界处完成。我们本可以迁移一些 Stripe 的较小服务,但 Stripe 对延迟最敏感的服务是大型的、单一的 Ruby 服务,没有明确的断点。要采用 JRuby 或 TruffleRuby inStripe 最重要的服务,我们必须能够运行所有代码或任何代码(至少在流量的子集上)。下面我们将讨论 Sorbet 编译器的实现,它通过几种方式解决了这种情况: Sorbet 编译器针对 Ruby 本地扩展,这些扩展与所有其他 Ruby 代码轻松互操作。我们不必放弃 Ruby VM(怪癖和所有),并且可以继续使用我们所有现有的 gems 和 nativeextensions。因为它编译为本机扩展,我们可以在源文件级别而不是服务级别启用编译器。当我们消除编译器中的错误并在生产中采用它时,我们可以创建任意大小的 Stripe 代码库进行试验。表达这个问题的另一种方式是:你打算将你的更改上传到 Ruby VM 吗? Ruby 从根本上说是一种解释型语言。除了可能有一个 CI 步骤来预下载第三方 gem 之外,许多 Ruby 项目没有任何类型的构建步骤——项目的 Ruby 源代码旨在运行未经 Ruby VM 处理的运行。
相比之下,我们的假设是我们可以通过支付一次性编译步骤的成本来实现实质性的性能改进。 Stripe 已经有一个广泛的 Ruby 构建管道,所以这个成本很小。并且如上所述,Sorbet Compiler 完全依赖于 Sorbet。特别是:它无法在尚未采用 Sorbet 的项目中工作。 Ruby 维护者已经明确表示,他们不希望对该语言的所有用户强制使用静态类型,因此上游没有 Sorbet 编译器的位置。 Stripe 的少数团队迫切需要他们的代码运行得更快。也许团队的最高优先事项是改善具有明确边界的小型服务的延迟。或者,一个团队正在启动一个绿地项目并预计会出现性能瓶颈。 Stripe 的团队可以根据自己的需要在 Ruby、Java 和 Go 之间进行选择来构建服务。但是 Stripe 现有的 Ruby 代码库有数百万行,并且实现了 Stripe 最关键的业务工作负载。即使我们想摆脱 Ruby(记住:很多人都看重 Ruby 的独特表达能力!),将 Stripe 的所有 Ruby 代码重写为另一种语言也需要很长时间。随着团队用其他语言重写或构建较小的项目,我们的团队一直在努力解决房间里的大象:数百万行现有的 Ruby 代码支持 Stripe 的核心产品。 Sorbet Compiler 是使用 Sorbet 的 Ruby 代码库的提前编译器,由 LLVM 和现有的 Ruby VM 提供支持。高级技术看起来像这样:有两个阶段:CI 中发生的事情(编译代码)和生产中发生的事情(运行代码)。
编译代码时,我们从普通的 *.rb 源文件开始,将它们提供给 Sorbet 进行类型检查。类型检查的输出是自定义类型注释的中间表示 (IR),Sorbet 编译器使用它来生成 LLVM IR。 LLVM 使用此 IR 并生成本机共享对象 (*.so) 文件。这些共享对象实际上是有效的 Ruby 本机扩展,符合并使用与包含本机扩展使用的 gem 相同的 API。因此,编译后的工件使用与所有其他 Ruby 代码相同的对象模型和运行时表示! Ruby VM 向扩展作者公开了相当多的 API,当特定语言功能不存在更快的编译策略时,这些 API 足以回调到 Ruby VM。未来我们可能会写更多关于我们如何从 Sorbet 的类型化 IR 到 Ruby 本地扩展的具体内容,但这意味着 Sorbet 编译器无法处理的东西很少。编译或不编译文件的决定是通过在 Ruby 源文件的顶部添加 #compiled:true 或 #compiled:false 注释来决定的。如果编译了一个文件,那么编译后的工件会像所有其他 Ruby 和数据文件一样捆绑到服务的部署存档中。在运行时,一个小的支持模块monkeypatches require_relative 以跳过需要原始*.rb 文件,而是需要新编译的*.sofile(如果可能)。由于这些编译的工件是 Ruby 原生扩展,它们可以定义类、模块和方法,就像需要 Ruby 文件一样。以这种方式构建的 Sorbet Compiler 将 Ruby 变成了一种用于编写 Ruby 原生扩展的语言!不必编写 C、C++、Rust 或其他一些编译语言来编写本机扩展,人们可以继续编写 Ruby,但可以获得本机编译速度的好处。我们仍处于该项目的早期阶段,但我们对初步结果感到鼓舞。我们已经在 Stripe 的生产环境中运行 Sorbet Compiler 大约一年了,正如上面提到的推文,我们终于开始看到回报,根据工作量不同的结果。
话虽如此,我们现在没有更多关于性能的内容要分享。我们不是根据综合基准来衡量自己,而是根据真实世界的 Stripe 工作负载来衡量自己,这些工作负载不是公开的。我们在源存储库中检查了一些综合基准,但这些都没有代表性,主要用于帮助我们调试和最小化我们在野外看到的性能问题。如果您有生产工作负载,您想尝试使用 Sorbet Compiler 随意获取源代码并让它运行您的基准测试(当然:先决条件是使用 Sorbet 获得基准测试)。由于我们尚未在不同于 Stripe 内部生产环境的环境中测试编译器,因此预计会出现问题,甚至可能会出现停止显示的错误。如果您确实尝试过,请随时告诉我们!在接下来的六个月里,我们将专注于使 Sorbet Compiler 在 Stripe 的实际代码上表现得更好。一旦我们取得更多进展,我们很高兴与您分享更多!