我们如何将 Deno 语言服务器的速度提高了十倍
编程应该简单,这就是我们构建 Deno 的原因,它“开箱即用”,内置了一体化工具、原生 TypeScript 支持和Web 标准 API。(你只需将文件命名为 .ts
扩展名即可开始使用 TypeScript。)Deno 提高生产力的一项主要方式是通过我们的语言服务器,它提供自动补全、工具提示、代码检查(linting)、代码格式化等功能。
最近,一位客户报告称,我们的语言服务器在大型代码库中存在显著的性能问题。他们的反馈促使我们调查并优化了语言服务器,从而将自动补全时间大幅缩短——从 8 秒缩短到不到 1 秒。这篇博文详细介绍了我们如何实现这一改进,从发现问题到实施解决方案。
这篇博文介绍了我们提升语言服务器性能的过程
“我们遇到了问题”
当我们的客户告诉我们语言服务器正在阻碍他们的开发速度时,我们知道需要进行调查。
这位客户提供了关于他们仓库、所用机器等详细信息,以帮助我们重现问题并开始调查。几分钟内,我们的团队就创建了一个专门的 Slack 频道,并开始汇总关于下一步行动的笔记和想法。
但在我们深入了解调查过程之前,语言服务器究竟是如何工作的?
LSP 的高级概述
语言服务器协议(“LSP”)是文本编辑器和语言服务器之间的标准接口,用于提供自动补全、跳转到定义、悬停文档等功能。
我们的语言服务器用 Rust 编写,并使用 TypeScript 编译器 tsc
来分析用户编辑器中的 TypeScript 文件。这意味着编辑器的 Deno 扩展(例如 VSCode Deno 扩展)将与 Rust 服务器通信,Rust 服务器随后会与 tsc
来回传递信息,然后最终向扩展返回自动补全建议、文档等。
对我们的语言服务器有了这种高级理解后,我们现在可以深入调查以提高性能。
提升可观察性
性能对我们所有的项目都至关重要。每当提交拉取请求(PR)时,我们的 CI/CD 都会自动在其上运行关键基准测试,以衡量其对性能的影响。理想情况下,这套基准测试测量结果应足够全面,能够有意义地覆盖“真实世界”的场景。
虽然我们已经在 deno lsp
中进行了一些日志记录,但我们需要更彻底的基准测试。然而,以传统意义进行基准测试是困难的——语言服务器是如此交互式,用户使用它的方式有数百万种。想出一套能很好地代表大多数用户体验的全面基准测试将很困难。
幸运的是,对于这次特定的调查,我们也了解了客户遇到性能问题时的条件。他们使用一个非常大的代码库,其中包含超过 7.5 万行 TypeScript 代码以及超过 75 万行依赖中的 TypeScript 代码。我们创建了一个基准测试来涵盖这个用例——它模拟了一系列对语言服务器的 API 调用,模仿用户在一个庞大代码库中点击文件。这帮助我们快速评估了拉取请求(PR)对客户特定用例的性能影响。
除了这个基准测试,我们还在语言服务器中添加了 instrumentation(性能监控),以便我们可以了解资源随时间的使用情况,这生成了火焰图,帮助我们缩小在代码库中可能存在资源低效使用的地方。
火焰图
火焰图是一种帮助可视化堆栈跟踪的图表。它有助于识别给定调用栈中时间花在了哪里。这使得很容易看出哪些函数是给定操作的意外瓶颈。
阅读火焰图时,每个“段”代表一个函数调用。X轴是执行时间(因此段越宽,该函数调用执行所需的时间越长),Y轴是调用栈深度(因此当一个函数调用另一个函数时,它会在其下方有一个段)。
我们从语言服务器记录了两个火焰图:一个来自 Rust,一个来自 JavaScript。从 Rust 生成的火焰图,我们将其导入到 Jaeger 中,而从 JavaScript 生成的火焰图,我们可以在 Chrome 开发者工具中打开。
这是我们在 JavaScript 端看到的情况
黄色代表我们的 Rust 代码,绿色代表我们的 JavaScript 代码,紫色代表 TypeScript 编译器。
第一个宽大的紫色段(红色标记)表示 TypeScript 编译器向 Rust 服务器请求所有文件内容以同步项目状态的过程。紧随其右的紫色细条是实际工作——例如,返回自动补全建议。这个火焰图表明状态同步过程耗时过长,因此我们性能工作的重点变成了尽可能减少该段的宽度。
优化
在我们深入探讨修复方案之前,让我们更详细地了解一下 Rust 服务器如何与 tsc
交互。
为了提供准确的自动补全、悬停文档等功能,TypeScript 编译器包含一个用户正在工作的实时代码库的最新模型。为了在 tsc
中维护代码库的实时模型,每当 Rust 服务器向其请求自动补全建议时,它都会向 Rust 服务器请求代码库中所有文件的最新版本,并将其存储到内存中。
每次用户敲击按键时读取超过 10 万行代码(包括依赖项)似乎是巨大的、不必要的 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 免费周边商品。