跳到主要内容
How we optimized our LSP

我们如何将 Deno 语言服务器提速十倍

编程应该很简单,这就是为什么我们将 Deno 构建为“开箱即用”,它包含 一体化工具原生 TypeScript 支持Web 标准 API。(您只需将文件命名为 .ts 扩展名即可开始使用 TypeScript。)Deno 提高生产力的一个主要方式是通过我们的 语言服务器,它提供自动补全、工具提示、代码检查、代码格式化等功能。

最近,一位客户报告说,我们语言服务器在大型代码库中存在明显的性能问题。他们的反馈促使我们调查和优化我们的语言服务器,从而显著减少了自动补全时间——从 8 秒缩短到不到 1 秒。这篇博客文章详细介绍了我们如何实现这一改进,从识别问题到实施解决方案。

这篇博客文章介绍了我们改进语言服务器性能的过程

“我们遇到了问题”

当我们的一位客户告诉我们,我们的语言服务器正在阻碍他们的开发速度时,我们就知道我们需要进行调查。

User commenting on Deno language server

这位客户提供了关于他们的代码仓库、他们使用的机器等详细信息,以帮助我们重现问题并开始调查。几分钟之内,我们的团队创建了一个专门的 Slack 频道,并开始汇总关于后续步骤的笔记和想法。

但在我们深入研究调查过程之前,语言服务器究竟是如何工作的呢?

LSP 的高级概述

语言服务器协议(“LSP”)是文本编辑器和语言服务器之间的标准接口,用于提供自动补全、跳转到定义、悬停文档等功能。

Diagram of 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 端看到的情况

JavaScript flamegraph before optimization

黄色代表我们的 Rust 代码,绿色代表我们的 JavaScript 代码,紫色代表 TypeScript 编译器。

第一个宽紫色段(红色标记)表示 TypeScript 编译器正在向 Rust 服务器请求所有文件内容以同步项目状态的过程。紧靠右侧的紫色细条是实际工作——例如,返回自动补全建议。这张火焰图表明,同步状态的过程花费的时间太长,因此我们性能工作的重点变成了尽可能减小该段的宽度。

优化

在我们深入研究修复之前,让我们更详细地了解一下我们的 Rust 服务器如何与 tsc 接口。

为了提供准确的自动补全、悬停文档等功能,TypeScript 编译器包含用户正在使用的实时代码库的最新模型。为了在 tsc 中维护代码库的实时模型,每当 Rust 服务器向其请求自动补全建议时,它都会向 Rust 服务器请求代码库中所有文件的最新版本,并将其存储在内存中。

How LSP works

每次用户敲击键盘时都读取超过 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。

LSP with addition of cache optimization

我们添加了一个缓存层,以减少在状态同步过程中 Rust 服务器和 TypeScript 编译器之间必须进行的调用次数。

结果

随着 1.43 版本的发布,我们很高兴宣布我们对语言服务器所做的所有性能改进。

这是在我们优化工作之前我们的语言服务器的火焰图

Before

这是来自 1.43 版本的更新的 JavaScript 火焰图

After

左侧的那个大的紫色段在这里变得窄得多,这表明我们消除了 Rust 和 TypeScript 编译器之间状态同步过程所需的时间。

我们的客户也很激动

User commenting on LSP optimizations

接下来是什么?

Deno 旨在简化编程。将 Deno 与我们的语言服务器结合使用,可以使软件构建更直观、更高效,因为您可以在文本编辑器中直接获取自动补全建议、文档、类型定义等。我们在这里的性能优化使我们的语言服务器在大型代码库中运行得更快。

感谢我们的客户告知我们他们的问题,我们能够在单个发布周期内解决它。所以是的,您的反馈对我们很重要——我们认真对待它,并致力于为我们的用户提供最高质量的软件。如果还有其他错误或功能请求,请在 GitHubDiscord 中告知我们。

🚨️ 您的公司是否在生产环境中使用 Deno? 🚨️

我们很乐意听取您的反馈。 填写此调查,如果我们与您交谈,您将获得免费的 Deno 周边商品。