增量计算
Turbopack 使用自动按需驱动的增量计算架构,为大型 Next.js 和 React 应用程序提供 React 快速刷新。
此架构使用缓存来记住函数被调用时使用的值以及它们返回的值。增量构建会根据本地更改的大小进行扩展,而不是根据应用程序的大小进行扩展。
Turbopack 的架构基于十多年的学习和先前的研究。它从 webpack、Salsa(它为 Rust-Analyzer 和 Ruff 提供支持)、Parcel、Rust 编译器的查询系统、Adapton 以及许多其他工具中汲取灵感。
背景:手动增量计算
许多构建系统都包含显式的依赖关系图,在评估构建规则时必须手动填充这些依赖关系图。显式声明依赖关系图在理论上可以提供最佳结果,但在实践中,它为错误留下了空间。
指定显式依赖关系图的难度意味着通常在粗粒度的文件级别进行缓存。这种粒度确实有一些好处:较少的增量结果意味着要缓存的数据更少,如果磁盘空间或内存有限,这可能是值得的。
此类架构的一个示例是 GNU Make,其中输出目标和先决条件是手动配置的,并表示为文件。像 GNU Make 这样的系统由于其粗粒度而错失了缓存机会:它们不理解并且无法缓存编译器内的内部数据结构。
函数级细粒度自动增量计算
在 Turbopack 中,输入文件和生成的构建工件之间的关系并不简单。捆绑器采用全程序分析来消除死代码(“摇树”)和模块图中常见依赖项的集群。因此,构建工件(在多个应用程序路由之间共享的 JavaScript 文件)与输入文件形成复杂的许多对多关系。
Turbopack 使用非常细粒度的缓存架构。由于手动声明和将依赖项添加到图中容易出现人为错误,因此 Turbopack 需要一种可以扩展的自动化解决方案。
值单元
为了方便自动缓存和依赖项跟踪,Turbopack 引入了“值单元”(Vc<…>
) 的概念。每个值单元都表示一个细粒度的执行片段,就像电子表格中的单元格一样。读取单元格时,它会记录当前正在执行的函数及其所有单元格,作为对该单元格的依赖项。
通过在读取单元格之前不将其标记为依赖项,Turbopack 比 传统的自上而下的记忆化方法提供了更细粒度的缓存。例如,参数可能是一个对象或包含许多值单元的映射。它只需要在它实际读取的单元格发生更改时重新计算跟踪的函数,而不需要在对象或映射的任何部分发生更改时重新计算跟踪的函数。
值单元表示 Turbopack 内部的几乎所有内容,例如磁盘上的文件、抽象语法树 (AST)、有关模块导入和导出的元数据,或用于分块和捆绑的集群信息。
标记脏数据和传播更改
当单元格的值发生更改时,Turbopack 必须确定要重新计算哪些工作。它使用 Adapton 的两阶段脏数据和传播算法。
通常,源代码文件位于依赖关系图的底部。当增量执行引擎发现文件的内容已更改时,它会将读取该文件的函数及其关联的值单元标记为“脏”。为了监视文件系统更改,Turbopack 使用 inotify
(Linux) 或 通过 notify
crate 的等效平台 API。
接下来是传播,从下往上重新运行捆绑器,冒泡产生新结果的任何计算。这种传播是“按需驱动的”,这意味着系统仅在依赖单元格是“活动查询”的一部分时才重新计算它。活动查询可以是当前启用热重载的打开网页,甚至是构建完整生产应用程序的请求。
如果单元格不是活动查询的一部分,则其脏标志的传播将延迟,直到依赖关系图发生更改或创建新的活动查询。
其他优化:聚合树
依赖关系图可能包含数十万个小型跟踪函数的唯一调用,并且增量执行引擎必须频繁遍历该图以检查和更新脏标志。
Turbopack 使用“聚合树”优化这些操作。聚合树的每个节点都表示一组跟踪函数调用,从而减少与依赖项跟踪相关的一些内存开销,并减少必须遍历的节点数。
使用 Rust 和 Tokio 进行并行和异步执行
为了并行执行,Turbopack 将 Rust 与 Tokio 异步运行时一起使用。每个跟踪的函数都作为 Tokio 任务生成到 Tokio 的线程池中。这使得 Turbopack 能够受益于 Rust 的低开销并行性和 Tokio 的工作窃取调度器。
虽然在大多数情况下,打包是 CPU 密集型的,但当从慢速硬盘、网络驱动器构建,或者从持久缓存读取或写入时,它可能会变成 IO 密集型的。与我们原本可能做到的相比,Tokio 允许 Turbopack 更优雅地处理这些性能下降的情况。