跳到主要内容

Hermes 迈向默认引擎

·14 分钟阅读
Xuan Huang
黄轩
Meta 软件工程师

2019 年我们发布 Hermes 以来,它在社区中的采用率不断提高。流行的 React Native 应用元框架 Expo 的团队最近宣布了对 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 Fiber 启用的并发渲染可以通过将渲染工作分解成块来避免调度长时间运行的 JavaScript 任务。然而,JavaScript 线程还有一个常见的延迟来源——当 JavaScript 引擎不得不“停止世界”来执行垃圾回收(GC)时。

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

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

攻克性能痛点

应用程序的启动时间对于许多应用的成功至关重要,我们正不断突破 React Native 的界限。对于我们在 Hermes 中实现的任何新 JavaScript 功能,我们都会仔细监控它们对生产性能的影响,并确保它们不会导致指标回退。在 Facebook,我们目前正在 Metro 中试验一个专用于 Hermes 的 Babel 转换配置文件,用 Hermes 的原生 ESNext 实现来替换十几个 Babel 转换。我们观察到许多界面的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,我们倾向于将项目放在一个大型单体仓库中。通过让引擎(Hermes)和宿主(React Native)紧密迭代,我们为垂直集成提供了很大的空间。仅举几例:

  • Hermes 通过支持Chrome DevTools Protocol,支持使用 Chrome 调试器进行设备上 JavaScript 调试。这比旧的“远程 JS 调试”(使用应用内代理在桌面 Chrome 中运行 JS)更好,因为它支持调试同步原生调用并保证了运行时环境的一致性。与 React DevTools、Metro、Inspector 等结合,Hermes 调试器现在是 Flipper 的一部分,提供了“一站式”的开发体验。
  • React Native 应用初始化路径中分配的对象通常生命周期较长,不符合世代 GC 所利用的世代假说。因此,我们在 React Native 中配置 Hermes,将前 32MiB 直接分配到旧代(称为预代分配),以避免触发 GC 暂停并延迟 TTI。
  • 新的 React Native 架构大量基于JSI(JavaScript Interface),这是一个轻量级的通用 API,用于将 JavaScript 引擎嵌入到 C++ 程序中。通过由维护 JS 引擎的团队同时维护 JSI API 实现,我们有信心提供最佳的集成,它在 Facebook 的规模下是可靠、高性能且经过实战检验的。
  • 获取 JavaScript 并发原语(例如Promise)和平台并发原语(例如微任务)在语义上正确且高性能,这对于 React 并发渲染和 React Native 应用的未来至关重要。过去,React Native 中的 Promise 是通过非标准化的setImmediate API 进行填充的。我们正在通过 JSI 使 JavaScript 引擎的原生 Promise 和微任务在平台可用,并引入queueMicrotask(Web 标准的近期新增功能),以更好地支持现代异步 JavaScript 代码。

携手整个社区

Hermes 对我们在 Facebook 来说非常棒。但我们的工作并未完成,直到我们的社区能够使用 Hermes 来驱动整个生态系统的体验,让每个人都能利用其所有功能并发挥其全部潜力。

扩展到新平台

Hermes 最初仅针对 React Native on Android 开源。自那时以来,我们很高兴看到社区成员将 Hermes 支持扩展到 React Native 生态系统已扩展到的许多其他平台

Callstack 在将Hermes 带到 iOS 的 React Native 0.64 中发挥了主导作用。他们撰写了一系列文章并举办了播客,介绍了他们是如何实现的。根据他们的基准测试,Hermes 在 Mattermost 应用中与 JSC 相比,在 iOS 上启动时间持续提高了约 40%,内存减少了约 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.4v0.5 发布了 opt-in npm 包,并且从v0.7 开始默认启用。
  • ECMAScript 国际化 API 规范(ECMA-402,或 Intl第二个最受欢迎的功能Intl 是一个庞大的 API 集,通常需要实现包含6MB 的Unicode CLDR 数据。这就是为什么像FormatJS(又名 react-intl这样的填充库和像社区 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 以及许多其他在写作过程中提供帮助的人。