括号对彩色化速度提升 10,000 倍
2021 年 9 月 29 日,作者 Henning Dieterichs,@hediet_dev
在 Visual Studio Code 中处理深层嵌套的括号时,很难区分哪些括号是匹配的,哪些不是。
为了简化此过程,2016 年,一位名叫 CoenraadS 的用户开发了出色的 Bracket Pair Colorizer 扩展,用于对匹配的括号进行着色,并将其发布到 VS Code Marketplace。此扩展变得非常流行,现在是 Marketplace 上下载量前 10 的扩展之一,安装量超过 600 万。
为了解决性能和准确性问题,CoenraadS 于 2018 年推出了 Bracket Pair Colorizer 2,现在也拥有超过 300 万的安装量。
Bracket Pair Colorizer 扩展是 VS Code 扩展性强大功能的一个很好的例子,它大量使用了 Decoration API 来对括号进行着色。
我们很高兴看到 VS Code Marketplace 提供了更多此类社区提供的扩展,所有这些扩展都以非常有创意的方式帮助识别匹配的括号对,包括:Rainbow Brackets、Subtle Match Brackets、Bracket Highlighter、Blockman 和 Bracket Lens。这些各种各样的扩展表明 VS Code 用户确实希望获得更好的括号支持。
性能问题
不幸的是,Decoration API 的非增量性质和缺少对 VS Code 令牌信息的访问导致 Bracket Pair Colorizer 扩展在大型文件上运行缓慢:当在 TypeScript 项目的 checker.ts 文件(该文件有超过 42k 行代码)的开头插入一个括号时,直到所有括号对的颜色更新完成大约需要 10 秒。在这 10 秒的处理过程中,扩展宿主进程的 CPU 占用率高达 100%,所有由扩展支持的功能,如自动完成或诊断,都停止工作。幸运的是,VS Code 的架构确保了 UI 保持响应,并且文档仍然可以保存到磁盘。
CoenraadS 意识到了这个性能问题,并在扩展的第 2 版中投入了大量精力来提高速度和准确性,方法是重用 VS Code 的令牌和括号解析引擎。然而,VS Code 的 API 和扩展架构并非旨在在涉及数十万个括号对时实现高性能的括号对着色。因此,即使在 Bracket Pair Colorizer 2 中,在文件开头插入 {
后,颜色也要过一段时间才能反映新的嵌套级别
虽然我们很乐意只提高扩展的性能(这肯定需要引入针对高性能场景优化的更高级 API),但渲染器和扩展主机之间的异步通信严重限制了当作为扩展实现时括号对着色的速度。这个限制无法克服。特别是,一旦括号对出现在视口中,就不应该异步请求其颜色,因为这会在滚动大型文件时导致明显的闪烁。有关此问题的讨论可以在 问题 #128465 中找到。
我们做了什么
相反,在 1.60 更新中,我们在 VS Code 核心中重新实现了该扩展,并将此时间缩短到不到一毫秒——在这个特定示例中,这比之前快了 10,000 倍以上。
可以通过添加设置 "editor.bracketPairColorization.enabled": true
来启用此功能。
现在,即使对于包含数十万个括号对的文件,更新也几乎察觉不到。请注意,在第 2 行输入 {
后,第 42,788 行的括号颜色会立即反映新的嵌套级别
一旦我们决定将其移至核心,我们也借此机会研究了如何使其尽可能快。谁不喜欢算法挑战呢?
在不受公共 API 设计限制的情况下,我们可以使用 (2,3)-树、无递归树遍历、位运算、增量解析和其他技术来降低扩展的最坏情况更新 时间复杂度(即文档打开后处理用户输入所需的时间),从
此外,通过重用渲染器中现有的令牌及其增量令牌更新机制,我们获得了另一个巨大的(但常量)加速。
Web 版 VS Code
除了性能更优越之外,新实现还支持 Web 版 VS Code,您可以在 vscode.dev 和 github.dev 中看到它的实际应用。由于 Bracket Pair Colorizer 2 重用 VS Code 令牌引擎的方式,无法将该扩展迁移为我们所说的 Web 扩展。
我们的新实现不仅适用于 Web 版 VS Code,还直接适用于 Monaco Editor!
括号对颜色化的挑战
括号对颜色化的核心在于快速确定视口中所有括号及其(绝对)嵌套级别。视口可以描述为文档中按行号和列号表示的范围,通常是整个文档的一小部分。
不幸的是,括号的嵌套级别取决于其前面**所有**的字符:将任何字符替换为开括号 "{
" 通常会增加所有后续括号的嵌套级别。
因此,当最初对文档末尾的括号进行颜色化时,必须处理整个文档的每一个字符。
括号对颜色化扩展中的实现通过在插入或删除单个括号时再次处理整个文档来解决此挑战(这对于小文档来说非常合理)。然后必须使用 VS Code Decoration API 删除并重新应用颜色,该 API 将所有颜色装饰发送到渲染器。
如前所述,这对于包含数十万个括号对(以及同样数量的颜色装饰)的大型文档来说速度很慢。由于扩展无法增量更新装饰,必须一次性全部替换,因此括号对颜色化扩展也无法做得更好。尽管如此,渲染器以一种巧妙的方式(通过使用所谓的 区间树)组织所有这些装饰,因此在收到(可能是数十万个)装饰后,渲染始终很快。
我们的目标是不必在每次按键时都重新处理整个文档。相反,处理单个文本编辑所需的时间应该只随文档长度呈(多)对数增长。
然而,我们仍然希望能够以(多)对数时间查询视口中所有括号及其嵌套级别,就像使用 VS Code 的装饰 API(它使用前面提到的区间树)时一样。
算法复杂度
可以跳过有关算法复杂度的部分。
在下文中,
然而,我们允许初始化时间复杂度为
语言语义使括号对颜色化变得困难
真正让括号对颜色化变得困难的是根据文档语言定义实际括号的检测。特别是,我们不希望在注释或字符串中检测开括号或闭括号,如下面的 C 示例所示
{ /* } */ char str[] = "}"; }
只有第三个 "}
" 才闭合括号对。
对于令牌语言不规则的语言,例如带有 JSX 的 TypeScript,这甚至更难。
[1] 处的括号是与 [2] 还是 [3] 处的括号匹配?这取决于模板文字表达式的长度,只有具有无界状态(即非规则分词器)的分词器才能正确确定。
令牌来救援
幸运的是,语法高亮显示也必须解决类似的问题:在前面的代码片段中,[2] 处的括号应该渲染为字符串还是纯文本?
事实证明,简单地忽略语法高亮识别的注释和字符串中的括号对大多数括号对来说效果很好。<
... >
是我们迄今为止发现的唯一有问题的一对,因为这些括号通常既用于比较,也用作泛型类型的对,同时具有相同的令牌类型。
VS Code 已经拥有一个高效且同步的机制来维护用于语法高亮的令牌信息,我们可以重用它来识别开括号和闭括号。
这是括号对颜色化扩展的另一个挑战,它对性能产生了负面影响:它无法访问这些令牌,必须自行重新计算。我们长期思考如何高效可靠地将令牌信息暴露给扩展,但得出结论,在不将大量实现细节泄露到扩展 API 中的情况下,我们无法做到这一点。因为扩展仍然必须为文档中的每个括号发送颜色装饰列表,所以仅靠这样的 API 甚至无法解决性能问题。
附带一提,当在文档开头应用一个更改所有后续令牌的编辑(例如,对于 C 类语言插入 /*
)时,VS Code 不会一次性对长文档进行重新分词,而是随着时间的推移分块进行。这确保了 UI 不会冻结,即使分词是同步在渲染器中发生的。
基本算法
核心思想是使用 递归下降解析器 构建一个 抽象语法树 (AST),它描述所有括号对的结构。当找到一个括号时,检查令牌信息,如果它在注释或字符串中则跳过该括号。分词器允许解析器查看和读取此类括号或文本令牌。
现在的诀窍是只存储每个节点的长度(并且还要为所有非括号的东西设置文本节点以弥补空白),而不是存储绝对开始/结束位置。只提供长度,仍然可以有效地在 AST 中定位给定位置的括号节点。
下图显示了一个带有长度注释的示例 AST
将其与使用绝对开始/结束位置的经典 AST 表示进行比较
两个 AST 描述的都是同一个文档,但是当遍历第一个 AST 时,绝对位置必须动态计算(这样做开销很小),而在第二个 AST 中它们已经预先计算好了。
然而,当在第一个树中插入单个字符时,只需要更新节点本身的长度及其所有父节点的长度——所有其他长度保持不变。
当像第二个树中那样存储绝对位置时,文档中后面**每个**节点的位置都必须递增。
此外,通过不存储绝对偏移量,可以共享长度相同的叶节点,以避免内存分配。
这就是带有长度注释的 AST 在 TypeScript 中的定义方式
type Length = ...;
type AST = BracketAST | BracketPairAST | ListAST | TextAST;
/** Describes a single bracket, such as `{`, `}` or `begin` */
class BracketAST {
constructor(public length: Length) {}
}
/** Describes a matching bracket pair and the node in between, e.g. `{...}` */
class BracketPairAST {
constructor(
public openingBracket: BracketAST;
public child: BracketPairAST | ListAST | TextAST;
public closingBracket: BracketAST;
) {}
length = openingBracket.length + child.length + closingBracket.length;
}
/** Describes a list of bracket pairs or text nodes, e.g. `()...()` */
class ListAST {
constructor(
public items: Array<BracketPairAST | TextAST>
) {}
length = items.sum(item => item.length);
}
/** Describes text that has no brackets in it. */
class TextAST {
constructor(public length: Length) {}
}
查询这样的 AST 以列出视口中所有括号及其嵌套级别相对简单:进行深度优先遍历,动态计算当前节点的绝对位置(通过添加早期节点的长度),并跳过完全在请求范围之前或之后的节点的子节点。
这个基本算法已经可以工作,但仍有一些未解决的问题
- 如何确保查询给定范围内的所有括号具有所需的对数性能?
- 在打字时,如何避免从头构建新的 AST?
- 如何处理令牌块更新?当打开大型文档时,令牌最初不可用,而是分块提供。
确保查询时间是对数级别的
在给定范围内查询括号时,真正影响性能的是非常长的列表:我们无法对其子节点进行快速二分查找以跳过所有不相关的非相交节点,因为我们需要对每个节点的长度求和以动态计算绝对位置。在最坏情况下,我们需要遍历所有这些节点。
在下面的示例中,我们必须查看 13 个节点(蓝色)才能找到位置 24 处的括号
虽然我们可以计算和缓存长度总和以实现二分查找,但这与存储绝对位置有相同的问题:每次单个节点增长或缩小时,我们都需要重新计算所有这些长度总和,这对于非常长的列表来说代价高昂。
相反,我们允许列表将其他列表作为子项
class ListAST {
constructor(public items: Array<ListAST | BracketPairAST | TextAST>) {}
length = items.sum(item => item.length);
}
这如何改善情况?
如果我们能确保每个列表只有有限数量的子节点,并且类似于对数高度的平衡树,那么事实证明,这足以获得查询括号所需的对数性能。
保持列表树平衡
我们使用 (2,3)-树 来强制这些列表保持平衡:每个列表必须至少有 2 个且最多有 3 个子节点,并且列表的所有子节点在平衡列表树中必须具有相同的高度。请注意,括号对在平衡树中被视为高度为 0 的叶节点,但它可能在 AST 中有子节点。
在初始化期间从头构建 AST 时,我们首先收集所有子节点,然后将它们转换为这样的平衡树。这可以在线性时间内完成。
前面示例的一个可能的 (2,3)-树可能如下所示。请注意,我们现在只需要查看 8 个节点(蓝色)即可找到位置 24 处的括号对,并且列表是具有 2 个还是 3 个子节点存在一些自由度。
最坏情况复杂性分析
可以跳过有关算法复杂度的部分。
目前,我们假设每个列表都类似于 (2,3)-树,因此最多有 3 个子节点。
为了最大化查询时间,我们查看一个具有以下特征的文档:
{
{
... O(log N) many nested bracket pairs
{
{} [1]
}
...
}
}
尚未涉及列表,但我们已经需要遍历
现在,在最坏的情况下,我们通过插入额外的
{}{}{}{}{}{}{}{}... O(N / log N) many
{
{}{}{}{}{}{}{}{}... O(N / log N) many
{
... O(log N) many nested bracket pairs
{
{}{}{}{}{}{}{}{}... O(N / log N) many
{} [1]
}
...
}
}
在相同嵌套级别的每个括号列表都会产生一个高度为
的树。因此,要找到 [1] 处的节点,我们必须遍历
因此,查询括号的最坏情况时间复杂度为
此外,这表明 AST 的最大高度为
增量更新
关于高效括号对颜色化的最有趣的问题仍然悬而未决:给定当前(平衡)AST 和一个替换特定范围的文本编辑,我们如何高效地更新树以反映该文本编辑?
想法是重用用于初始化的递归下降解析器,并添加一个缓存策略,以便可以重用和跳过不受文本编辑影响的节点。
当递归下降解析器在位置
以下示例显示了插入单个开括号时哪些节点可以重复使用(绿色)(省略了单独的括号节点)
通过重新解析包含编辑的节点并重用所有未更改的节点来处理文本编辑后,更新后的 AST 如下所示。请注意,所有 11 个可重用节点都可以通过消耗 3 个节点 B、H 和 G 来重用,并且只有 4 个节点需要重新创建(橙色)
如本示例所示,平衡列表不仅使查询速度快,而且还有助于一次重用大量节点。
算法复杂度
可以跳过有关算法复杂度的部分。
我们假设文本编辑替换了一个大小最多为
我们只需要重新解析与编辑范围相交的节点。因此,最多需要重新解析
显然,如果一个节点不与编辑范围相交,那么它的任何子节点也不相交。因此,我们只需要考虑重用不与编辑范围相交但其父节点相交的节点(这将隐式重用节点及其父节点都不与编辑范围相交的所有节点)。此外,这样的父节点不能完全被编辑范围覆盖,否则其所有子节点都将与编辑范围相交。然而,AST 中的每个级别最多只有两个部分与编辑范围相交的节点。由于 AST 最多有
因此,要构建更新后的树,我们最多需要重新解析
这也会决定更新操作的时间复杂度,但有一个注意事项。
我们如何重新平衡 AST?
不幸的是,最后一个示例中的树不再平衡。
当将一个重用的列表节点与一个新解析的节点组合时,我们必须做一些工作来维护 (2,3)-树的属性。我们知道重用的节点和新解析的节点都已经是 (2,3)-树,但它们可能具有不同的高度——所以我们不能简单地创建父节点,因为 (2,3)-树节点的所有子节点都必须具有相同的高度。
我们如何有效地将所有这些不同高度的节点连接成一个 (2,3)-树?
这可以很容易地简化为将较小的树前置或附加到较大的树的问题:如果两棵树具有相同的高度,则足以创建一个包含两个子节点的列表。否则,我们将高度为
因为这具有运行时
为了平衡前一个示例中的列表 α 和 γ,我们对其子节点执行连接操作(红色列表违反了 (2,3)-树属性,橙色节点具有意外的高度,绿色节点在重新平衡时重新创建)
由于在不平衡树中列表 B 的高度为 2,括号对 β 的高度为 0,我们需要将 β 附加到 B,然后完成列表 α。剩余的 (2,3)-树是 B,因此它成为新的根并替换列表 α。继续处理 γ,其子节点 δ 和 H 的高度为 0,而 G 的高度为 1。
我们首先连接 δ 和 H,并创建一个高度为 1 的新父节点 Y(因为 δ 和 H 具有相同的高度)。然后我们连接 Y 和 G,并创建一个新的父列表 X(出于同样的原因)。X 然后成为父括号对的新子节点,替换不平衡列表 γ。
在这个例子中,平衡操作有效地将最顶层列表的高度从 3 降低到 2。然而,AST 的总高度从 4 增加到 5,这负面影响了最坏情况下的查询时间。这是由括号对 β 引起的,它在平衡列表树中作为叶节点,但实际上包含另一个高度为 2 的列表。
在平衡父列表时考虑 β 的内部 AST 高度可能会改善最坏情况,但会脱离 (2,3)-树的理论。
算法复杂度
可以跳过有关算法复杂度的部分。
我们最多需要连接
因为连接两个不同高度的节点的时间复杂度是
我们如何高效地找到可重用节点?
我们为此任务提供了两种数据结构:_编辑前位置映射器_和_节点读取器_。
位置映射器 尽可能地将新文档中(应用编辑后)的位置映射到旧文档中(应用编辑前)。它还会告诉我们当前位置和下一个编辑之间的长度(或者如果是编辑中,则为 0)。这在
中完成。当处理文本编辑并解析节点时,此组件会给我们一个可能重用的节点的位置以及此节点可以具有的最大长度——显然,我们想要重用的节点必须短于到下一个编辑的距离。
节点读取器 可以在 AST 中给定位置快速找到满足给定谓词的最长节点。要找到我们可以重用的节点,我们使用位置映射器查找其旧位置和允许的最大长度,然后使用节点读取器找到此节点。如果我们找到了这样的节点,我们就知道它没有改变,并且可以重用它并跳过其长度。
因为节点读取器是以单调递增的位置进行查询的,所以它不必每次都从头开始搜索,而是可以从上一个重用节点的末尾开始搜索。关键在于一种无递归的树遍历算法,它可以深入节点,也可以跳过它们或返回到父节点。当找到可重用节点时,遍历停止并继续处理对节点读取器的下一个请求。
一次查询节点读取器的复杂度最多为
令牌更新
当在不包含文本 */
的长 C 样式文档开头插入 /*
时,整个文档将变为单个注释,并且所有令牌都将更改。
由于令牌是在渲染器进程中同步计算的,因此无法在不冻结 UI 的情况下一次性重新分词。
相反,令牌会随着时间的推移分批更新,这样 JavaScript 事件循环就不会被阻塞太久。虽然这种方法不会减少总阻塞时间,但它提高了更新期间 UI 的响应能力。在最初对文档进行分词时也使用相同的机制。
幸运的是,由于括号对 AST 的增量更新机制,我们可以立即应用此类批处理令牌更新,方法是将更新视为单个文本编辑,该编辑将重新分词的范围替换为自身。一旦所有令牌更新都进来,括号对 AST 就保证处于与从头创建时相同的状态——即使用户在重新分词进行中编辑文档也是如此。
这样,即使文档中的所有令牌都发生变化,不仅分词性能良好,括号对颜色化也同样如此。
但是,当文档在注释中包含大量不平衡括号时,文档末尾的括号颜色可能会闪烁,因为括号对解析器会识别出这些括号应该被忽略。
为了避免在打开文档并导航到其末尾时括号对颜色闪烁,我们在初始分词过程完成之前维护两个括号对 AST。第一个 AST 是在没有令牌信息的情况下构建的,并且不接收令牌更新。第二个 AST 最初是第一个 AST 的克隆,但它接收令牌更新并随着分词的进行和令牌更新的应用而逐渐分歧。最初,第一个 AST 用于查询括号,但一旦文档完全分词,第二个 AST 将接管。
因为深度克隆几乎与重新解析文档一样昂贵,所以我们实现了写时复制,从而实现了在
长度编码
编辑器视图使用行号和列号描述视口。颜色装饰也期望以基于行/列的范围表示。
为了避免偏移量和基于行/列的位置之间的转换(这可以在
请注意,这种方法与直接通过行索引的数据结构(例如使用字符串数组来描述文档的行内容)显著不同。特别是,这种方法可以在行内和行间进行单次二分查找。
添加两个这样的长度很容易,但需要区分情况:虽然行数直接相加,但第一个长度的列数只有在第二个长度跨越零行时才包含在内。
令人惊讶的是,大部分代码不需要知道长度是如何表示的。只有位置映射器变得明显复杂,因为必须注意单行可以包含多个文本编辑。
作为实现细节,我们将这些长度编码为一个数字以减少内存压力。JavaScript 支持最大值为
进一步的困难:未闭合的括号对
到目前为止,我们假设所有括号对都是平衡的。然而,我们也希望支持未闭合和未打开的括号对。递归下降解析器的优点在于我们可以使用锚定集来改进错误恢复。
考虑以下示例
( [1]
} [2]
) [3]
显然,[2] 处的 }
没有闭合任何括号对,代表一个未打开的括号。 [1] 和 [3] 处的括号匹配得很好。但是,当在文档开头插入 {
时,情况发生了变化
{ [0]
( [1]
} [2]
) [3]
现在,[0] 和 [2] 应该匹配,而 [1] 是一个未闭合的括号,[3] 是一个未打开的括号。
特别地,在以下示例中,[1] 应该是一个未闭合的括号,在 [2] 之前终止。
{
( [1]
} [2]
{}
否则,打开一个括号可能会改变不相关后续括号对的嵌套级别。
为了支持这种错误恢复,锚集可以用来跟踪调用者可以继续使用的预期令牌集。在前面示例中的位置 [1] 处,锚集将是}
}
时,它不会消耗它并返回一个未闭合的括号对。
在第一个示例中,[2] 处的锚集是)
}
。因为它不属于锚集,所以它被报告为一个未打开的括号。
在重用节点时需要考虑这一点:当在 ( } )
前加上 {
时,不能重用 ( } )
。我们使用位集来编码锚定集,并为每个节点计算包含未打开括号的集合。如果它们相交,我们就不能重用该节点。幸运的是,括号类型很少,所以这对性能影响不大。
未来展望
高效的括号对颜色化是一个有趣的挑战。有了新的数据结构,我们还可以更有效地解决与括号对相关的其他问题,例如 通用括号匹配 或 显示彩色行范围。
尽管 JavaScript 可能不是编写高性能代码的最佳语言,但通过降低渐近算法复杂度可以获得很大的速度提升,尤其是在处理大型输入时。
编码愉快!
Henning Dieterichs,VS Code 团队成员 @hediet_dev