如何让 Deno 语言服务器快十倍
编程应该简单,这就是我们构建 Deno 为 “一应俱全” 的原因,包含 一体化工具、原生 TypeScript 支持 和 Web 标准 API。(您只需将文件命名为 .ts
扩展名即可开始使用 TypeScript。)Deno 提高生产力的一个主要方式是通过我们的 语言服务器,它提供自动完成、工具提示、代码整理、代码格式化等功能。
最近,一位客户报告说我们的语言服务器在大型代码库中存在重大性能问题。他们的反馈促使我们调查并优化了语言服务器,从而使自动完成时间大幅缩短 - 从 8 秒缩短到不到 1 秒。这篇文章详细介绍了我们如何实现这种改进,从识别问题到实施解决方案。
这篇文章介绍了改进语言服务器性能的过程
“我们遇到了问题”
当一位客户告诉我们,我们的语言服务器阻碍了他们的开发速度时,我们知道我们需要调查。
这位客户提供了关于他们的仓库、他们使用的机器以及其他信息,帮助我们重现问题并开始调查。几分钟内,我们的团队创建了一个专门的 Slack 频道,并开始收集关于下一步行动的笔记和想法。
但在深入研究我们的调查过程之前,语言服务器究竟是如何工作的呢?
对 LSP 的高级概述
语言服务器协议(“LSP”)是文本编辑器和语言服务器之间的标准接口,用于提供自动完成、转到定义、悬停文档等功能。
我们的语言服务器是用 Rust 编写的,并使用 tsc
(TypeScript 编译器)来分析用户编辑器中的 TypeScript 文件。这意味着编辑器的 Deno 扩展(例如 VSCode Deno 扩展)将与 Rust 服务器通信,然后 Rust 服务器会与 tsc
进行来回传递,最后用自动完成建议、文档等响应扩展。
有了对语言服务器的高级理解,我们现在可以深入研究我们的调查以改进性能。
提高可观察性
性能对我们所有的项目都很重要。每当提交一个 PR 时,我们的 CI/CD 会自动对其运行关键基准测试,以衡量其对性能的影响。理想情况下,基准测试度量集应该足够全面,能够有意义地涵盖 “现实世界” 场景。
虽然我们已经在 deno lsp
中做了一些日志记录,但我们需要更全面的基准测试。然而,以传统的方式进行基准测试很困难 - 语言服务器是如此交互式,并且用户可以使用它的方法有无数种。要制定一套能够很好地代表大多数用户体验的综合基准测试将非常困难。
幸运的是,对于这次特定的调查,我们也知道客户遇到性能问题的环境。他们在一个非常大的代码库中,其中包含超过 75k 行 TypeScript 代码和超过 750k 行 TypeScript 依赖项。我们 创建了一个基准测试,涵盖了这种情况 - 它模拟了一系列对语言服务器的 API 调用,这些调用模拟了用户在一个大型代码库中点击文件。这有助于我们快速评估 PR 对客户特定用例的性能影响。
除了这个基准测试,我们还添加了语言服务器的仪器,这样我们就可以了解资源使用情况随时间的变化,从而生成火焰图,帮助我们缩小代码库中资源可能被低效使用的范围。
火焰图
火焰图 是一种图表类型,可以帮助可视化堆栈跟踪。它有助于识别给定调用堆栈中时间花费的位置。这使得很容易看到哪些函数是给定操作的意外瓶颈。
在阅读火焰图时,每个 “段” 代表一个函数调用。X 轴是执行时间(因此段越宽,执行该函数调用所需的时间越长),而 Y 轴是调用堆栈深度(因此当一个函数调用另一个函数时,它将在其下方有一个段)。
我们记录了语言服务器的两个火焰图:一个是来自 Rust,另一个是来自 JavaScript。来自 Rust 生成的火焰图,我们将其输入 Jaeger,而来自 JavaScript 生成的火焰图,我们可以在 chrome 开发工具中打开。
以下是我们在 JavaScript 端看到的
黄色代表我们的 Rust 代码,绿色代表我们的 JavaScript 代码,紫色代表 TypeScript 编译器。
第一个宽的紫色段(用红色标记)表示 TypeScript 编译器向 Rust 服务器请求所有文件内容以同步项目状态的过程。紧邻右边的紫色细条是实际工作 - 例如,返回自动完成建议。这个火焰图表明同步状态的过程花费的时间太长,所以我们性能工作的重点变成了尽可能地缩短该段的宽度。
优化
在我们深入研究修复方法之前,让我们更详细地了解 Rust 服务器如何与 tsc
交互。
为了提供准确的自动完成、悬停文档等,TypeScript 编译器包含用户正在使用的实时代码库的最新模型。为了在 tsc
中维护代码库的实时模型,每当 Rust 服务器向其请求自动完成建议时,它都会向 Rust 服务器请求所有文件的最新版本,并将它们存储到内存中。
每当用户按下键时,读取超过 100k 行代码(包括依赖项)似乎是一种巨大的、不必要的 CPU 使用。在 Rust 和 JavaScript 之间传递文本很昂贵,我们需要找到一种方法来尽量减少这种情况。
从这一发现开始,我们的团队做出了几项 PR,目标是
- 减少 TypeScript 编译器需要向 Rust 服务器请求的文件数量
- 使 TypeScript 编译器请求的操作(例如模块解析)更快
- 更智能地缓存未更改的文件,以降低同步成本
减少发送到 TypeScript 编译器的文件数量
以前,TypeScript 编译器会请求所有文件的最新版本。这包括依赖项及其依赖项。当然,并非所有文件都是必需的,对吧?其中一些肯定不会经常改变。
我们决定更新 TypeScript 编译器,使其仅请求本地文件的最新版本。原因是,如果有人远程导入(例如,通过 npm:
、jsr:
或 URL),这些依赖项会在本地缓存,并且很可能不会更改。
提高 `tsc` 和 Rust 之间接口的效率
以前,我们把文件存储在 TypeScript 编译器的内存中,每次它都需要请求代码库中每个文件的最新版本。
以前,我们必须频繁地调用 Rust 来请求有关文件的信息,例如它们的最新版本和实际源代码。从 JavaScript 调用 Rust,尤其是它们之间传递数据,非常昂贵。这意味着来自 `tsc` 的请求,这些请求应该很快得到回答(例如,这个文件的最新版本是什么?),却比预期的花费了更长时间。随着代码库越来越大,由于开销会随着项目中文件和依赖项的数量而增加,与 `tsc` 同步的行为变得比 `tsc` 的实际计算更占用资源。
首先,我们决定在 Rust 和 `tsc` 之间引入一个缓存层,用于存储项目的状态。无论何时 `tsc` 请求一些信息,我们都希望尽可能地从缓存中获取答案。与所有缓存一样,跟踪缓存何时是最新的非常重要。使用过时的缓存值会导致错误的响应,并为用户提供错误的建议或错误的诊断。为了避免这种情况,我们添加了一种机制,使 Rust 服务器能够通知 JavaScript 接口文件更改,以便它可以只驱逐缓存中可能已更改的值。这最大限度地提高了缓存使用率,同时仍然提供最新的响应。
缓存层大大减少了在 Rust 和 `tsc` 之间传递数据的需要。现在,当 `tsc` 请求信息时,这些值已经存储在 JavaScript 层,这意味着它只是传递一个指针,不需要任何复制或调用 Rust。
结果
随着 1.43 版本的发布,我们很高兴地宣布我们在语言服务器中进行的所有性能改进。
这是我们优化工作之前语言服务器的火焰图。
这是来自 1.43 的更新的 JavaScript 火焰图。
左侧的紫色部分在这里窄了很多,表明我们消除了 Rust 和 TypeScript 编译器之间状态同步过程所需的时间。
我们的客户也很高兴。
下一步是什么?
Deno 旨在简化编程。使用 Deno 和我们的语言服务器可以使构建软件更直观、更高效,因为您可以在文本编辑器中直接获取自动完成建议、文档、类型定义等。我们在这里进行的性能优化使我们的语言服务器在大型代码库中运行得更快。
感谢我们的客户向我们告知了他们的问题,我们能够在一个发布周期内解决它。所以是的,您的反馈对我们很重要——我们认真对待它,并致力于为用户提供最高质量的软件。如果有其他错误或功能请求,请在 GitHub 或 Discord 上告诉我们。
🚨️ 您是否在公司生产环境中使用 Deno? 🚨️
我们很乐意听取您的反馈。 填写此调查问卷,如果我们与您交谈,您将获得免费的 Deno 商品。