跳至主要内容

Hermes 成为默认引擎的征程

·阅读时长 12 分钟
Xuan Huang
黄宣
Meta 软件工程师

自从 我们在 2019 年宣布 Hermes 以来,它在社区中获得了越来越多的采用。Expo 团队维护着 React Native 应用的一个流行的元框架,最近 宣布了实验性 支持 Hermes,此前 Hermes 一直是 Expo 最受欢迎的功能之一Realm 团队(一个流行的移动数据库)也最近发布了其对 Hermes 的 alpha 支持。在这篇文章中,我们想要重点介绍过去两年来我们在推动 Hermes 成为**React Native 最佳** JavaScript 引擎方面取得的一些最令人兴奋的进展。展望未来,我们相信,凭借这些改进以及更多即将推出的改进,我们可以使 Hermes 成为 React Native 在所有平台上的默认 JavaScript 引擎。

针对 React Native 进行优化

Hermes 的主要特点是它如何提前执行编译工作,这意味着启用了 Hermes 的 React Native 应用会随附预编译的优化字节码,而不是纯 JavaScript 源代码。这大大减少了用户启动产品所需的工作量。来自 Facebook 和社区应用的测量结果表明,启用 Hermes 通常可以将产品的 TTI(或 交互时间)指标减少近一半。

话虽如此,我们一直在努力改进 Hermes 的许多其他方面,使其作为专门针对 React Native 的 JavaScript 引擎变得更好。

为 Fabric 构建新的垃圾回收器

随着即将推出的 React Native 新架构中的 Fabric 渲染器,可以在 UI 线程上同步调用 JavaScript。但是,这意味着如果 JavaScript 线程执行时间过长,可能会导致明显的 UI 帧下降并阻塞用户输入。React 18 中启用的并发渲染 通过将渲染工作分成块来避免调度长时间的 JavaScript 任务,利用了 React 的 Fiber 架构。但是,JavaScript 线程还有另一个常见的延迟来源——当 JavaScript 引擎必须“停止世界”以执行垃圾回收 (GC) 时。

Hermes 中以前的默认垃圾回收器,GenGC,是一个单线程的分代垃圾回收器。新一代使用典型的半空间复制策略,旧一代使用标记压缩策略,使其非常擅长积极地将内存返回给操作系统。由于其单线程特性,GenGC 的缺点是会导致较长的 GC 暂停。在像 Facebook for Android 这样复杂的应用中,我们观察到平均暂停时间为 200 毫秒,或在 p99 时为 1.4 秒。考虑到 Facebook for Android 庞大而多样化的用户群,我们甚至看到它长达 7 秒。

为了缓解这个问题,我们实现了一个全新的**大部分并发**的 GC,名为 Hades。Hades 收集其年轻代的方式与 GenGC 完全相同,但它使用快照式标记清除收集器来管理其老一代,这可以通过在后台线程执行大部分工作来显着减少 GC 暂停时间,而不会阻止引擎的主线程执行 JavaScript 代码。**我们的统计数据显示,Hades 在 64 位设备上的 p99.9 处仅暂停 48 毫秒(比 GenGC 快 34 倍!)**,在 32 位设备上的 p99.9 处约为 88 毫秒(在其中它作为单线程的**增量** GC 运行)。这些暂停时间改进可能会以整体吞吐量的代价为代价,因为需要更昂贵的写屏障、更慢的基于空闲列表的分配(而不是碰撞指针分配器)以及增加的堆碎片。我们认为这些是正确的权衡,并且我们能够通过合并和我们将要讨论的其他内存优化来实现整体更低的内存消耗。

解决性能痛点

应用程序的启动时间对于许多应用程序的成功至关重要,我们一直在不断突破 React Native 的界限。对于我们在 Hermes 中实现的任何新的 JavaScript 功能,我们都会仔细监控它们对生产性能的影响,并确保它们不会降低指标。在 Facebook,我们目前正在实验使用 Metro 中 Hermes 的专用 Babel 变换配置文件 来替换十几种 Babel 变换,使用 Hermes 的原生 ESNext 实现。我们能够观察到许多界面的**TTI 提高了 18-25%**,以及**字节码大小的整体减少**,并且我们预计开源项目也会看到类似的结果。

除了启动性能外,我们还将内存占用量确定为 React Native 应用(尤其是 虚拟现实 应用)的改进机会。由于我们作为 JavaScript 引擎拥有底层控制权,因此我们能够通过压缩位和字节来交付多轮内存优化

  1. 以前,所有 JavaScript 值都表示为 64 位 NaN 装箱编码的标记值,以在 64 位架构上表示浮点双精度数和指针。但是,这在实践中是浪费的,因为大多数数字都是小整数 (SMI),并且客户端应用程序的 JavaScript 堆通常预计不会大于 4GiB。为了解决这个问题,我们引入了一种新的 32 位编码,其中 SMI 和指针编码在 29 位中(因为指针是 8 字节对齐的,我们可以假设最低 3 位始终为零),其余的 JS 数字被装箱到堆中。**这将 JavaScript 堆大小减少了约 30%。**
  2. 不同类型的 JavaScript 对象在 JavaScript 堆中表示为不同类型的 GC 管理单元。通过积极优化这些单元头的内存布局,**我们能够将内存使用量进一步减少约 15%**。

我们在 Hermes 中做出的一个关键决策是**不实现即时 (JIT) 编译器**,因为我们认为对于大多数 React Native 应用来说,额外的预热成本以及二进制文件和内存占用增加实际上并不值得。多年来,我们投入了大量精力优化解释器性能和编译器优化,使 Hermes 的吞吐量能够与其他引擎在 React Native 工作负载方面相竞争。我们将继续专注于通过识别各处的性能瓶颈(解释器调度循环、栈布局、对象模型、GC 等)来提高吞吐量。敬请期待即将发布的版本中更多数据!

垂直集成的先驱

在 Facebook,我们更倾向于在一个大型的单一代码库 (monorepo)中集中项目。通过让引擎 (Hermes) 和宿主 (React Native) 紧密迭代,我们为垂直集成创造了大量空间。举几个例子:

  • Hermes 通过使用Chrome DevTools 协议支持使用 Chrome 调试器进行设备上的 JavaScript 调试。它比传统的“远程 JS 调试”(使用应用内代理在桌面 Chrome 中运行 JS)更好,因为它支持调试同步原生调用并保证一致的运行时环境。与 React DevTools、Metro、Inspector 等工具一起,Hermes 调试器现在是Flipper的一部分,为开发者提供一站式体验。
  • 在 React Native 应用的初始化路径中分配的对象通常是长期存在的,并且不遵循分代 GC 利用的分代假设。因此,我们在 React Native 中配置了 Hermes,将前 32MiB 直接分配到旧生代(称为预分配),以避免触发 GC 暂停并延迟 TTI。
  • 新的 React Native 架构很大程度上基于JSI(或 JavaScript 接口),这是一个轻量级、通用 API,用于将 JavaScript 引擎嵌入到 C++ 程序中。通过让维护 JS 引擎的团队也维护 JSI API 实现,我们有信心提供最佳的集成,使其在 Facebook 规模下可靠、高效且经受住了考验。
  • 获取 JavaScript 并发原语(例如Promise)和平台并发原语(例如微任务)在语义上正确且高效对于 React 并发渲染和 React Native 应用的未来至关重要。从历史上看,React Native 中的 Promise 使用非标准的setImmediate API 进行polyfill。我们正在努力通过 JSI 提供来自 JS 引擎的原生 Promise 和微任务,并在平台上引入queueMicrotask(最近添加到 Web 标准中),以更好地支持现代异步 JavaScript 代码。

携手整个社区

Hermes 对 Facebook 来说非常棒。但我们的工作只有在社区能够使用 Hermes 为整个生态系统提供支持时才算完成,这样每个人都可以利用其所有功能并充分发挥其潜力。

扩展到新平台

Hermes 最初仅开源用于 Android 上的 React Native。从那时起,我们很高兴看到社区成员将 Hermes 支持扩展到React Native 生态系统扩展到的许多其他平台

Callstack 带领了将Hermes 引入 React Native 0.64 的 iOS 版本的工作。他们撰写了一系列文章,并主持了一个播客,介绍了他们如何实现这一目标。根据他们的基准测试,与 Mattermost 应用的 JSC 相比,Hermes 能够始终如一地将启动时间提高约 40% 并将 iOS 内存减少约 18%,并且应用大小仅增加了 2.4 MiB。我鼓励您亲眼见证

Microsoft 一直致力于将Hermes 引入 React Native for Windows 和 macOS。在Microsoft Build 2020上,Microsoft 分享了 Hermes 的内存影响(工作集)比 React Native for Windows 上的 Chakra 引擎低 13%。最近,在一些合成基准测试中,他们发现 Hermes 0.8(与 Hades 和前面提到的 SMI 和指针压缩优化一起发布)比其他引擎的内存使用量减少了 30%-40%。毫不奇怪,基于 React Native 构建的桌面 Messenger视频通话体验也由 Hermes 提供支持。

最后但并非最不重要的是,Hermes 也一直在为 Oculus 上使用 React 技术系列构建的所有虚拟现实体验提供支持,包括 Oculus Home。

支持我们的社区

我们认识到,仍然存在一些障碍阻碍部分社区采用 Hermes,我们致力于构建对这些缺失功能的支持。我们的目标是提供全面的功能,使 Hermes 成为大多数 React Native 应用的正确选择。以下是社区如何塑造 Hermes 路线图的:

  • ProxyReflect 最初被排除在 Hermes 之外,因为 Facebook 没有使用它们。我们还担心添加 Proxy 会降低即使不使用 Proxy 时的属性查找性能。但是,由于 MobX 和 Immer 等流行库,Proxy 很快成为Hermes 最受欢迎的功能。我们仔细评估并决定仅为社区构建它,并且我们设法以非常低的成本实现了它。由于这是我们不使用的功能,因此我们依靠社区来证明其稳定性。我们首先在标志后面测试 Proxy,并为v0.4 版本v0.5 版本创建了 opt-in npm 包,并且它从v0.7 版本开始默认启用
  • ECMAScript 国际化 API 规范 (ECMA-402 或 Intl)第二个最受欢迎的功能Intl 是一组庞大的 API,通常需要实现包含价值 6MB 的Unicode CLDR 数据。这就是为什么像FormatJS (也称为 react-intl)这样的 polyfill 和像社区 JSC 的国际版本构建这样的 JS 引擎如此庞大的原因。为了避免大幅增加 Hermes 的二进制文件大小,我们决定通过使用操作系统中包含的库提供的 ICU 功能并对其进行映射来采用另一种策略,但代价是在不同平台上存在一些(通常是微小的)行为差异。
    • Microsoft 合作构建了 Android 上的支持。它涵盖了从 ECMA-402 到 ES2020 的几乎所有内容,大小影响仅为 3%(每个 ABI 57-62K)。我们在 Twitter 上进行了一个投票,结果强烈支持默认包含 Intl,所以我们也这么做了,它从v0.8 版本开始可用
    • Facebook 赞助了Major League Hacking,以启动一个远程开源奖学金计划。去年,我们推出了Hermes 采样分析器。今年,我们的研究员将与 Hermes、React Native 和 Callstack 的成员合作,为 iOS 上的 Hermes Intl 添加支持。敬请期待!
  • 我们感谢大家与我们一起发现影响社区的问题。

总结

总而言之,我们的愿景是使 Hermes 能够成为所有 React Native 平台上的默认 JavaScript 引擎。我们已经开始朝着这个方向努力,并且希望听到大家对这个方向的反馈。

为生态系统做好顺利采用的准备对我们来说极其重要。我们鼓励您试用 Hermes,并在我们的GitHub 仓库上提交任何反馈、问题、功能请求和不兼容性问题。

致谢

我们非常感谢 Hermes 团队、React Native 团队以及 React Native 社区的众多贡献者为改进 Hermes 所做出的努力。

我还要特别感谢 (按字母顺序排列) Eli White、Luna Wei、Neil Dhar、Tim Yung、Tzvetan Mikov 以及许多其他人在写作过程中提供的帮助。