括号对颜色化提速 10,000 倍
2021 年 9 月 29 日,作者 Henning Dieterichs,@hediet_dev
在 Visual Studio Code 中处理深度嵌套的括号时,很难分辨哪些括号匹配,哪些不匹配。
为了简化这一过程,2016 年,一位名叫 CoenraadS 的用户开发了出色的 Bracket Pair Colorizer 扩展,用于为匹配的括号着色,并将其发布到 VS Code Marketplace。这个扩展变得非常流行,现在是 Marketplace 上下载量排名前 10 的扩展之一,安装量超过 600 万。
为了解决性能和准确性问题,2018 年,CoenraadS 推出了 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 项目中包含超过 42k 行代码的 checker.ts 文件开头插入单个括号时,需要大约 10 秒才能更新所有括号对的颜色。在这 10 秒的处理过程中,扩展主机进程会占用 100% 的 CPU,并且所有由扩展提供的功能(例如自动补全或诊断)都会停止工作。幸运的是,VS Code 的架构确保了用户界面保持响应,并且文档仍然可以保存到磁盘。
CoenraadS 意识到了这个性能问题,并通过重用 VS Code 的令牌和括号解析引擎,投入了大量精力来提高该扩展版本 2 的速度和准确性。然而,VS Code 的 API 和扩展架构并未设计为在涉及数十万个括号对时实现高性能的括号对颜色化。因此,即使在 Bracket Pair Colorizer 2 中,在文件开头插入 {
之后,颜色也需要一些时间才能反映新的嵌套级别。
虽然我们很乐意只改进扩展的性能(这肯定需要引入针对高性能场景优化的更高级 API),但渲染器和扩展主机之间的异步通信严重限制了作为扩展实现的括号对颜色化的速度。这个限制无法克服。特别是,括号对颜色不应在它们出现在视口中时就异步请求,因为这会在滚动大型文件时导致可见的闪烁。有关此问题的讨论可在 issue #128465 中找到。
我们做了什么
相反,在 1.60 更新中,我们在 VS Code 的核心中重新实现了该扩展,并将时间缩短到不到一毫秒——在这个特定的例子中,这比以前快了 10,000 多倍。
通过添加设置 "editor.bracketPairColorization.enabled": true
可以启用此功能。
现在,即使对于包含数十万个括号对的文件,更新也不再明显。请注意,在第 2 行输入 {
后,第 42,788 行的括号颜色如何立即反映出新的嵌套级别。
一旦我们决定将其移入核心,我们也借此机会研究如何使其尽可能快。谁不喜欢算法挑战呢?
在不受公共 API 设计限制的情况下,我们可以使用 (2,3)-树、无递归树遍历、位运算、增量解析以及其他技术,以减少扩展的最坏情况更新时间复杂度(即处理用户输入(当文档已打开时)所需的时间),从
此外,通过重用渲染器中现有的令牌及其增量令牌更新机制,我们获得了另一个巨大的(但恒定的)加速。
VS Code 网页版
除了性能更优越之外,新实现还支持 VS Code 网页版,您可以在 vscode.dev 和 github.dev 中看到它的实际应用。由于 Bracket Pair Colorizer 2 重用了 VS Code 令牌引擎的方式,因此无法将该扩展迁移为我们所说的 Web 扩展。
我们的新实现不仅适用于 VS Code 网页版,还直接适用于 Monaco 编辑器!
括号对颜色化的挑战
括号对颜色化主要是在视口中快速确定所有括号及其(绝对)嵌套级别。视口可以描述为文档中由行号和列号定义的范围,通常只占整个文档的一小部分。
不幸的是,括号的嵌套级别取决于它之前的所有字符:用开括号 "{
" 替换任何字符通常会增加所有后续括号的嵌套级别。
因此,在最初为文档末尾的括号着色时,必须处理整个文档中的每个字符。
括号对颜色化扩展的实现通过在每次插入或删除单个括号时重新处理整个文档来解决这一挑战(对于小文档来说,这样做是非常合理的)。然后必须使用 VS Code Decoration API 删除并重新应用颜色,该 API 会将所有颜色装饰发送到渲染器。
如前所述,对于包含数十万个括号对以及同样数量的颜色装饰的大文档来说,这会很慢。由于扩展无法增量更新装饰,必须一次性替换所有装饰,因此括号对颜色化扩展也无法做得更好。尽管如此,渲染器还是以巧妙的方式组织了所有这些装饰(通过使用所谓的区间树),因此在接收到(可能数十万个)装饰后,渲染速度始终很快。
我们的目标是不必在每次按键时重新处理整个文档。相反,处理单个文本编辑所需的时间应该只随着文档长度的增加而呈(多项式)对数增长。
然而,我们仍然希望能够以(多项式)对数时间在视口中查询所有括号及其嵌套级别,就像使用 VS Code 的 Decoration API(它使用前面提到的区间树)那样。
算法复杂度
您可以随意跳过有关算法复杂度的部分。
在下文中,
但是,我们允许初始化时间复杂度为
语言语义使得括号对颜色化变得困难
真正使括号对颜色化变得困难的是,检测文档语言中定义的实际括号。特别是,我们不希望检测注释或字符串中的开括号或闭括号,如下面的 C 语言示例所示:
{ /* } */ char str[] = "}"; }
只有第三个出现的 "}
" 才闭合了括号对。
对于令牌语言不规则的语言(例如带有 JSX 的 TypeScript)来说,这变得更加困难:
位置 [1] 的括号与位置 [2] 还是 [3] 的括号匹配?这取决于模板字面量表达式的长度,只有具有无限状态的词法分析器(即非正则词法分析器)才能正确确定。
令牌前来救援
幸运的是,语法高亮必须解决类似的问题:前面代码片段中 [2] 处的括号应该渲染为字符串还是纯文本?
事实证明,简单地忽略语法高亮识别出的注释和字符串中的括号,对于大多数括号对来说效果足够好。<
... >
是我们迄今为止发现的唯一有问题的一对,因为这些括号通常既用于比较,也用作泛型类型的配对,同时具有相同的令牌类型。
VS Code 已经有一个高效且同步的机制来维护用于语法高亮的令牌信息,我们可以重用它来识别开括号和闭括号。
这是括号对颜色化扩展的另一个挑战,它对性能产生了负面影响:它无法访问这些令牌,必须自行重新计算它们。我们曾长期思考如何高效可靠地向扩展公开令牌信息,但最终得出结论,如果不让大量实现细节泄露到扩展 API 中,我们无法做到这一点。由于扩展仍然需要为文档中的每个括号发送颜色装饰列表,仅仅这样的 API 也无法解决性能问题。
顺便提一下,当在文档开头应用一个会改变所有后续令牌的编辑时(例如在类 C 语言中插入 /*
),VS Code 不会一次性对长文档进行重新分词,而是随着时间的推移分块进行。这确保了用户界面不会冻结,即使分词是在渲染器中同步进行的。
基本算法
核心思想是使用递归下降解析器来构建一个抽象语法树 (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 并创建一个新的父节点 Y,其高度为 1(因为 δ 和 H 具有相同的高度)。然后,我们连接 Y 和 G 并创建一个新的父列表 X(出于相同的原因)。然后,X 成为父括号对的新子节点,替换不平衡的列表 γ。
在这个例子中,平衡操作有效地将最顶层列表的高度从 3 降低到 2。然而,AST 的总高度从 4 增加到 5,这负面影响了最坏情况下的查询时间。这是由于括号对 β 造成的,它在平衡列表树中充当叶节点,但实际上包含另一个高度为 2 的列表。
在平衡父列表时考虑 β 的内部 AST 高度可以改善最坏情况,但会脱离 (2,3)-树的理论。
算法复杂度
您可以随意跳过有关算法复杂度的部分。
我们最多需要连接
因为连接两个不同高度的节点的时间复杂度为
我们如何高效地找到可重用节点?
我们为此任务准备了两种数据结构:*编辑前位置映射器*和*节点读取器*。
位置映射器将新文档中(应用编辑后)的位置映射到旧文档中(应用编辑前),如果可能的话。它还会告诉我们当前位置和下一个编辑之间的长度(如果在编辑中则为 0)。这在
中完成。当处理文本编辑并解析一个节点时,这个组件会给出我们可能重用的节点的位置以及该节点可能具有的最大长度——显然,我们想要重用的节点必须短于到下一个编辑的距离。
节点读取器可以在 AST 中给定位置快速找到满足给定谓词的最长节点。为了找到我们可以重用的节点,我们使用位置映射器查找其旧位置和允许的最大长度,然后使用节点读取器找到该节点。如果找到这样的节点,我们就知道它没有改变,可以重用它并跳过其长度。
由于节点读取器是按单调递增的位置进行查询的,因此它不必每次都从头开始搜索,而是可以从上次重用节点的末尾开始。这里的关键是一种无递归的树遍历算法,它能够深入节点,也能跳过它们或返回到父节点。当找到可重用节点时,遍历停止并继续处理对节点读取器的下一个请求。
单次查询节点读取器的复杂度最高可达
令牌更新
当在不包含文本 */
的长 C 风格文档开头插入 /*
时,整个文档会变成一个注释块,所有令牌都会发生变化。
由于令牌是在渲染器进程中同步计算的,因此如果不冻结用户界面,就无法一次性进行重新分词。
相反,令牌是随时间分批更新的,这样 JavaScript 事件循环就不会被阻塞太长时间。虽然这种方法不会减少总阻塞时间,但它提高了更新期间用户界面的响应速度。在最初对文档进行分词时也使用了相同的机制。
幸运的是,由于括号对 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