00:00:00(欢快的音乐) 大家好,谢谢大家。
00:00:07我是卢克·桑德伯格。
00:00:09我是 Vercel 的一名软件工程师,负责 Turbo Pack 项目。
00:00:12我在 Vercel 工作了大约六个月,这刚好给了我足够的时间站在这里,向大家介绍所有我没有参与的伟大工作。
00:00:23在加入 Vercel 之前,我在谷歌工作,负责内部的网页工具链,做一些奇怪的事情,比如构建一个 TSX 到 Java 字节码的编译器,以及参与 Closure Compiler 的工作。
00:00:37所以当我来到 Vercel 时,感觉就像踏上了另一个星球,一切都那么不同。
00:00:45团队所做的一切以及我们的目标都让我感到非常惊讶。
00:00:50所以今天我将分享 Turbo Pack 中的一些设计选择,以及我认为它们将如何让我们在现有卓越性能的基础上继续发展。
00:01:01为了更好地说明这一点,这是我们的总体设计目标。
00:01:06从中你可以立即推断出,我们可能做出了一些艰难的抉择。
00:01:14那么,冷构建呢?
00:01:17它们很重要,但我们的一个想法是,你不应该经历它们。
00:01:22这正是本次演讲的重点。
00:01:24在主题演讲中,大家已经听过我们如何利用增量特性来提升打包性能。
00:01:31我们在增量方面的一个核心思想是关于缓存。
00:01:35我们希望打包器所做的每一件事都可缓存,这样每当你做出更改时,我们只需重做与该更改相关的工作。
00:01:43换句话说,你的构建成本应该与你更改的大小或复杂性成正比,而不是与你应用程序的大小或复杂性成正比。
00:01:53这样我们就能确保,无论你导入多少图标库,Turbo Pack 都能持续为开发者提供良好的性能。
00:02:01为了帮助理解和阐述这个想法,让我们想象一下世界上最简单的打包器,它可能看起来像这样。
00:02:09这就是我们的“婴儿打包器”。
00:02:12这段代码可能有点多,不适合放在幻灯片上,但情况还会变得更糟。
00:02:17在这里,我们解析每个入口点。
00:02:20我们会跟踪它们的导入,解析它们的引用,在整个应用程序中递归地查找你所依赖的一切。
00:02:28最后,我们只需收集每个入口点所依赖的一切,然后将其放入一个输出文件。
00:02:35好了,我们有了一个“婴儿打包器”。
00:02:38显然这是很天真的做法,但如果我们从增量的角度来看,这其中没有任何部分是增量的。
00:02:45所以我们肯定会多次解析某些文件,这可能取决于你导入它们的次数,这太糟糕了。
00:02:53我们肯定会解析 React 的导入数百甚至数千次。
00:02:57所以,你知道,这很糟糕。
00:03:01所以,如果我们想让它至少有一点增量性,我们需要找到一种方法来避免重复工作。
00:03:08那么,我们来添加一个缓存。
00:03:10你可能会想象,这就是我们的解析函数。
00:03:15它非常简单。
00:03:15它可能是我们打包器的“主力”。
00:03:19你看,非常简单。
00:03:19我们读取文件内容,然后将其交给 SWC,让它为我们生成一个 AST。
00:03:25那么,我们来添加一个缓存。
00:03:27好的,这显然是一个简单而有效的胜利。
00:03:31但是,我相信你们中有些人以前写过缓存代码。
00:03:36也许这里有一些问题。
00:03:38比如,如果文件发生变化怎么办?
00:03:41这显然是我们关心的问题。
00:03:46还有,如果文件实际上不是一个文件,而是三个符号链接伪装的呢?
00:03:52很多包管理器都会那样组织依赖。
00:03:55而我们正在使用文件名作为缓存键。
00:03:59这足够吗?
00:04:00比如,我们正在为客户端和服务器打包。
00:04:03相同的文件最终会出现在两者中。
00:04:04这行得通吗?
00:04:05我们还在存储 AST 并返回它。
00:04:08所以现在我们必须担心突变问题。
00:04:11那么,最后,这不是一种非常天真的解析方式吗?
00:04:16我知道每个人都有大量的编译器配置。
00:04:21比如,其中一些必须包含在这里。
00:04:23是的,这些都是很好的反馈。
00:04:27这确实是一种非常天真的方法。
00:04:32对此,我当然会说,是的,这行不通。
00:04:36那么,我们该如何解决这些问题呢?
00:04:39请修复,并且不要犯任何错误。
00:04:44好的。
00:04:46那么,也许这样会好一点。
00:04:49你可以看到,这里我们有一些转换。
00:04:52我们需要对每个文件进行定制化处理,比如降级或实现缓存使用。
00:04:58我们还有一些配置。
00:05:00当然,我们需要将其包含在缓存键中。
00:05:04但也许你马上就会产生怀疑。
00:05:08比如,这正确吗?
00:05:09比如,仅仅根据名称来识别一个转换真的足够吗?
00:05:13我不知道,也许它本身就有一些复杂的配置。
00:05:16好的,那么,这个 JSON 值真的能捕捉到我们关心的一切吗?
00:05:24开发者会维护它吗?
00:05:26这些缓存键会有多大?
00:05:29我们会有多少份配置副本?
00:05:31我个人实际上见过这样的代码,我觉得它几乎不可能理解。
00:05:37好的,我们还尝试解决关于失效的另一个问题。
00:05:43所以我们添加了一个回调 API 来读取文件。
00:05:46这很棒,如果文件发生变化,我们就可以直接从缓存中清除它,这样就不会继续提供过时的内容。
00:05:55好的,但这实际上相当天真,因为我们确实需要清除缓存,但我们的调用者也需要知道他们需要获取一个新的副本。
00:06:03好的,那么我们开始传递回调。
00:06:06好的,我们做到了。
00:06:09我们将回调函数贯穿整个调用栈。
00:06:12你可以看到,我们允许调用者订阅更改。
00:06:16如果有任何更改,我们就可以重新运行整个打包过程;如果文件发生变化,我们就会调用它。
00:06:22太棒了,我们有了一个响应式打包器。
00:06:25但这仍然很难说是增量的。
00:06:28所以如果文件发生变化,我们仍然需要再次遍历所有模块并生成所有输出文件。
00:06:37所以,你知道,我们通过解析缓存节省了大量工作,但这还远远不够。
00:06:45最后,还有所有这些其他重复的工作。
00:06:49比如,我们肯定想要缓存导入。
00:06:52我们可能会多次找到一个文件,并且不断需要它的导入,所以我们想在那里放置一个缓存。
00:06:57而且,解析结果实际上相当复杂,所以我们肯定应该缓存它,这样我们就可以重用解析 React 所做的工作。
00:07:08但是,好的,现在我们又遇到了另一个问题。
00:07:11当你更新依赖或添加新文件时,你的解析结果会发生变化,所以我们需要在那里再添加一个回调。
00:07:18而且我们肯定也想缓存生成输出的逻辑,因为在 HMR 会话中,你只编辑了应用程序的一部分,那么为什么每次都要重写所有输出呢?
00:07:31此外,你可能还会删除一个输出文件,所以我们也应该监听那里的变化。
00:07:39好的,也许我们解决了所有这些问题,但我们仍然面临一个问题,那就是每当有任何变化时,我们都会从头开始。
00:07:48所以,这个函数的整个控制流都行不通,因为如果单个文件发生变化,我们实际上会希望跳到那个 for 循环的中间。
00:07:56最后,我们提供给调用者的 API 也非常天真。
00:08:03他们可能实际上想知道哪个文件发生了变化,这样他们就可以将更新推送到客户端。
00:08:07所以,是的。
00:08:11所以,这种方法实际上行不通。
00:08:13即使我们以某种方式在所有这些地方都传递了回调,你认为你真的能维护这段代码吗?
00:08:21你认为你能给它添加新功能吗?
00:08:24我不认为。
00:08:25我认为这只会彻底失败。
00:08:28对此,我会说,是的。
00:08:34那么,我们再次面临这个问题,我们该怎么办?
00:08:36你知道,就像你和 LLM 聊天一样,你首先需要知道自己想要什么。
00:08:43然后你必须非常清楚地表达出来。
00:08:48那么,我们到底想要什么呢?
00:08:50我们考虑了许多不同的方法,团队中的许多人实际上在打包器方面都有丰富的经验。
00:08:59所以,我们提出了这些粗略的要求。
00:09:02我们肯定希望能够缓存打包器中的每一个耗时操作。
00:09:05而且这应该非常容易实现。
00:09:08比如,每次你添加新缓存时,代码审查不应该收到 15 条评论。
00:09:12而且我实际上并不真正信任开发者能手动编写正确的缓存键,或者手动跟踪输入和依赖。
00:09:24所以,我们应该处理它。
00:09:26我们应该让它绝对万无一失。
00:09:30接下来,我们需要处理不断变化的输入。
00:09:33这在 HMR 中是一个重要的概念,但即使跨会话也是如此。
00:09:36所以,这主要是文件,但也可能是配置设置之类的东西。
00:09:40而有了文件系统缓存,它实际上最终也会涉及到环境变量之类的东西。
00:09:45所以,我们希望是响应式的。
00:09:47我们希望一旦有任何变化就能立即重新计算,并且我们不想在到处传递回调。
00:09:54最后,我们只需要利用现代架构,实现多线程,并普遍地快速。
00:10:02那么,也许你正在看这些要求,有些人可能会想,这和打包器有什么关系?
00:10:12对此,我当然会说,我的管理团队就在现场,所以我们真的不需要谈论那个。
00:10:20但实际上,我猜你们很多人都得出了更明显的结论。
00:10:24这听起来很像信号。
00:10:28是的,我正在描述一个听起来像信号的系统。
00:10:31它是一种组合计算、跟踪依赖关系并具有一定程度自动记忆化的方法。
00:10:37我应该指出,我们从各种系统中汲取了灵感,特别是 Rust 编译器和一个名为 Salsa 的系统。
00:10:45如果你感兴趣,甚至还有关于这些概念的学术文献,名为 Adaptons。
00:10:51好的,那么让我们看看这在实践中是什么样子,然后我们将从 JavaScript 的代码示例非常突兀地跳到 Rust。
00:11:01这就是我们构建的基础设施的一个例子。
00:11:05TurboTask 函数是我们编译器中一个可缓存的工作单元。
00:11:12所以,一旦你像这样标注一个函数,我们就可以跟踪它,从它的参数中构建一个缓存键,这使我们能够在需要时缓存它并重新执行它。
00:11:28这里的这些 VC 类型,你可以把它们想象成信号,这是一个响应式值,VC 代表值单元(value cell),但信号可能是一个更好的名字。
00:11:39当你像这样声明一个参数时,你是在说这可能会改变,我希望它改变时重新执行。
00:11:47那么我们怎么知道呢?
00:11:49我们通过 await 读取这些值。
00:11:52一旦你 await 这样一个响应式值,我们就会自动跟踪其依赖。
00:11:58最后,当然,我们执行我们想要进行的实际计算,并将其存储在一个单元格中。
00:12:07因此,由于我们自动跟踪了依赖关系,我们知道这个函数既依赖于文件内容,也依赖于配置的值。
00:12:17每次我们将新结果存储到单元格中时,我们都可以将其与之前的结果进行比较,如果它发生了变化,我们就可以将通知传播给所有读取过该值的人。
00:12:29所以,“变化”这个概念是我们实现增量性的关键。
00:12:33是的,最简单的情况就在这里。
00:12:37如果文件发生变化,Turbo Pack 会观察到这一点,使这个函数执行失效,并立即重新执行它。
00:12:45然后,如果我们碰巧生成了相同的 AST,我们就会立即停止,因为我们计算出了相同的单元格。
00:12:53现在,对于解析文件来说,你几乎无法对其进行任何编辑而不改变 AST。
00:13:00但我们可以利用 Turbo Pack 函数的基本可组合性来进一步推进这一点。
00:13:07所以在这里,我们看到另一个 Turbo Pack 缓存函数正在从模块中提取导入。
00:13:15你可以想象,这是打包器中一个非常常见的任务。
00:13:20我们需要提取导入,只是为了实际找到应用程序中的所有模块。
00:13:25我们利用它们来选择将模块分组到块中的最佳方式。
00:13:29当然,导入图对于像摇树优化这样的基本任务也很重要。
00:13:34因此,由于导入数据有如此多的不同消费者,缓存就非常有意义。
00:13:41所以这个实现并没有什么特别之处。
00:13:44这就像你在任何打包器中都会发现的那样。
00:13:46我们遍历 AST,将导入收集到我们喜欢的一些特殊数据结构中,然后返回它们。
00:13:55但这里的关键思想是,我们将它们存储到另一个单元格中。
00:13:58所以如果模块发生变化,我们确实需要重新运行这个函数,因为我们读取了它。
00:14:05但如果你考虑一下你对模块所做的更改类型,很少有更改会真正影响导入。
00:14:12所以你更改模块,更新函数体,一个字符串字面量,任何实现细节。
00:14:20它会使这个函数失效,然后我们会计算出相同的导入集。
00:14:25然后我们不会使任何读取过此内容的东西失效。
00:14:29所以如果你在 HMR 会话中考虑这一点,这意味着我们确实需要重新解析你的文件,但我们真的不需要再考虑如何进行分块决策了。
00:14:40我们不需要考虑任何摇树优化的结果,因为我们知道它们没有改变。
00:14:45所以我们可以立即从解析文件、进行这种简单分析,然后直接跳到生成输出。
00:14:53这就是我们实现极快刷新时间的方法之一。
00:14:57所以这是相当命令式的。
00:15:02思考这个基本思想的另一种方式是将其视为一个节点图。
00:15:06所以在左边,你可能会想象一个冷构建。
00:15:12最初,我们确实必须读取每个文件,解析它们,分析所有导入。
00:15:17作为副作用,我们已经收集了应用程序中的所有依赖信息。
00:15:21然后当有东西发生变化时,我们可以利用我们建立的依赖图来传播失效,回溯调用栈,并重新执行 Turbo Pack 函数。
00:15:32所以如果它们产生一个新值,我们就在那里停止。
00:15:35否则,我们继续传播失效。
00:15:37太棒了。
00:15:41你知道,这实际上是对我们实际工作中正在做的事情的一种大规模过度简化,你可能会想象。
00:15:47所以在今天的 Turbo Pack 中,大约有 2500 个不同的 Turbo 任务函数。
00:15:53在一个典型的构建中,我们可能会有数百万个不同的任务。
00:15:58所以它看起来可能更像这样。
00:16:01现在,我并不指望你们能读懂这些。
00:16:04真的无法把它全部放在幻灯片上。
00:16:06那么也许我们应该缩小一下。
00:16:08好的,这显然没有帮助。
00:16:14实际上,我们确实有更好的方法来跟踪和可视化 Turbo Pack 内部发生的事情。
00:16:21但从根本上说,这些方法通过舍弃绝大多数依赖信息来工作。
00:16:26现在我猜你们中有些人可能实际上有使用信号的经验,也许是不好的经验。
00:16:34你知道,我个人实际上喜欢堆栈跟踪,并且能够在调试器中单步进入和退出函数。
00:16:41所以你可能会怀疑这是否是万能药。
00:16:45显然它伴随着权衡。
00:16:47是的,对此我当然会说,你知道,我实际上想说的是,所有的软件工程都是关于管理权衡的。
00:17:01我们并非总是在精确地解决问题,而是在选择新的权衡组合来交付价值。
00:17:08因此,为了实现我们在 Turbo Pack 中关于增量构建的设计目标,我们把所有的筹码都押在了这种增量响应式编程模型上。
00:17:19当然,这带来了一些非常自然的后果。
00:17:23所以,你知道,也许我们确实解决了手动缓存系统和繁琐的失效逻辑问题。
00:17:33作为交换,我们必须管理一些复杂的缓存基础设施。
00:17:39当然,你知道,对我来说,这听起来是一个非常好的权衡。
00:17:42我喜欢复杂的缓存基础设施,但我们都必须承担其后果。
00:17:48所以首先当然是这个系统的核心开销。
00:17:54你知道,如果你在一个给定的构建或 HMR 会话中考虑,你实际上并没有改变太多。
00:18:04所以我们跟踪应用程序中每个导入和每个解析结果之间的所有依赖信息,但你实际上只会更改其中的一小部分。
00:18:13因此,我们收集的大部分依赖信息实际上从不需要。
00:18:16所以,你知道,为了管理这一点,我们不得不大量专注于推动和改进这个缓存层的性能,以降低开销,并让我们的系统能够扩展到越来越大的应用程序。
00:18:30接下来最明显的就是内存。
00:18:34你知道,缓存本质上总是时间与内存的权衡。
00:18:38而我们的在这方面并没有什么不同。
00:18:41我们的简单目标是缓存大小应与应用程序的大小线性扩展。
00:18:49但同样,我们必须注意开销。
00:18:51下一个有点微妙。
00:18:54正如你所料,打包器中有很多算法。
00:18:58其中一些算法需要对你的应用程序有全局性的理解。
00:19:03嗯,这是一个问题,因为任何时候你依赖全局信息,都意味着任何更改都可能使该操作失效。
00:19:10所以我们必须小心设计这些算法,仔细组合事物,以便我们能够保持增量性。
00:19:17最后,这可能有点像我个人的抱怨。
00:19:24Turbo Pack 中的一切都是异步的。
00:19:27这对于横向扩展性来说很棒,但再次,它损害了我们基本的调试性能分析目标。
00:19:38所以我相信你们很多人都有在 Chrome 开发者工具中调试异步代码的经验。
00:19:46这通常是一个相当不错的体验。
00:19:48但并非总是理想的。
00:19:49我向你保证,Rust 结合 LLDB 简直是落后光年。
00:19:53所以为了管理这一点,我们不得不投入开发定制的可视化、仪器化和追踪工具。
00:20:01你看,这又是一个不是打包器的基础设施项目。
00:20:07好的,那么让我们来看看我们是否做出了正确的选择。
00:20:11在 Vercel,我们有一个非常大的生产应用程序。
00:20:17我们认为它可能是世界上最大的应用程序之一,但你知道,我们并不真正确定。
00:20:21但它确实包含了大约 80,000 个模块。
00:20:23那么让我们看看 Turbo Pack 在其上的表现如何。
00:20:26对于快速刷新,我们确实在 Web Pack 能够提供的方面占据主导地位。
00:20:32但这已经算是旧闻了。
00:20:33Turbo Pack for dev 已经发布一段时间了,我真的希望每个人至少都在开发中使用它。
00:20:39但你知道,今天的新消息当然是构建已经稳定了。
00:20:42那么我们来看看一个构建。
00:20:44在这里你可以看到,对于这个应用程序,我们相对于 Web Pack 有了显著的胜利。
00:20:49这个特定的构建实际上是运行在我们新的实验性文件系统缓存层上的。
00:20:53所以那 94 秒中有大约 16 秒只是在最后清空缓存。
00:20:59随着文件系统缓存变得稳定,我们将会努力改进这一点。
00:21:04但当然,冷构建的问题在于它们是冷的,没有任何增量性。
00:21:07那么让我们看看一个实际的暖构建。
00:21:10所以使用冷构建的缓存,我们可以看到这一点。
00:21:14这只是我们目前进展的一瞥。
00:21:17因为我们有这种细粒度的缓存系统,我们实际上可以直接将缓存写入磁盘,然后在下一次构建时,将其读回,找出发生了什么变化,并完成构建。
00:21:26好的,这看起来很不错,但你们很多人可能会想,也许我个人并没有世界上最大的 Next.js 应用程序。
00:21:34那么我们来看一个更小的例子。
00:21:37react.dev 网站要小得多。
00:21:41它也很有趣,因为它是一个 React 编译器。
00:21:44毫不奇怪,它是 React 编译器的早期采用者。
00:21:47而 React 编译器是在 Babel 中实现的。
00:21:49这对我们的方法来说有点问题,因为这意味着对于应用程序中的每个文件,我们都需要让 Babel 来处理它。
00:21:55所以,从根本上说,我会说我们,或者说我,无法让 React 编译器更快。
00:22:01这不是我的工作。
00:22:02我的工作是 Turbo Pack。
00:22:03但我们可以准确地找出何时调用它。
00:22:07所以看看快速刷新时间,我实际上对这个结果有点失望。
00:22:13结果发现,那 140 毫秒中大约有 130 毫秒是 React 编译器造成的。
00:22:18Turbo Pack 和 Web Pack 都在做这件事。
00:22:22但有了 Turbo Pack,在 React 编译器处理完这个更改后,我们可以看到,哦,导入没有改变。
00:22:29将其放入输出并继续。
00:22:31再次,在冷构建中,我们看到了这种持续的 3 倍优势。
00:22:37需要明确的是,这是在我自己的机器上测试的。
00:22:39但同样,冷构建中没有增量性。
00:22:44而在暖构建中,我们看到了更好的时间。
00:22:47所以再次,对于暖构建,我们已经将缓存存储在磁盘上。
00:22:52我们需要做的基本上是,一旦开始,找出应用程序中哪些文件发生了变化,重新执行这些任务,然后重用之前构建中的所有其他内容。
00:23:01所以基本问题是,我们“Turbo”了吗?
00:23:05是的。
00:23:06是的,这当然在主题演讲中讨论过了。
00:23:09Turbo Pack 在 Next.js 16 中已经稳定。
00:23:12我们甚至成为了 Next.js 的默认打包器。
00:23:14所以,你知道,任务完成了,不客气。
00:23:17但是。(笑声)(观众鼓掌)
00:23:23如果你注意到主题演讲中那个“回滚”的事情,那是我试图让 Turbo Pack 成为默认打包器。
00:23:30只用了三次尝试。
00:23:31但我真正想再次留给大家的是这个。
00:23:35你知道,因为我们还没有完成。
00:23:37我们在性能方面还有很多工作要做,并且要完成文件系统缓存层的收尾工作。
00:23:42我建议大家在开发环境中试用一下。
00:23:44就这样。
00:23:46非常感谢大家。
00:23:47请来找我,问我问题。
00:23:49(观众鼓掌)(欢快的音乐)(欢快的音乐)