括号对颜色化提速 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 项目的 checker.ts 文件(超过 42k 行代码)开头插入单个括号时,大约需要 10 秒钟才能更新所有括号对的颜色。 在这 10 秒的处理过程中,扩展主机进程的 CPU 占用率达到 100%,并且所有由扩展驱动的功能(例如自动完成或诊断)都停止运行。 幸运的是,VS Code 的架构确保了 UI 仍然保持响应,并且文档仍然可以保存到磁盘。
CoenraadS 意识到了这个性能问题,并花费了大量精力来提高扩展版本 2 的速度和准确性,方法是重用 VS Code 中的令牌和括号解析引擎。 然而,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)-树、无递归树遍历、位运算、增量解析和其他技术来降低扩展的最坏情况更新 时间复杂度(即当文档已打开时处理用户输入所需的时间)从
此外,通过重用渲染器中现有的令牌及其增量令牌更新机制,我们获得了另一个巨大的(但恒定的)速度提升。
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 的 decoration API(它使用上述间隔树)时一样。
算法复杂度
您可以随意跳过有关算法复杂度的部分。
在下文中,
但是,我们允许初始化时间复杂度为
语言语义使括号对颜色化变得困难
使括号对颜色化真正困难的是检测文档语言定义的实际括号。 特别是,我们不希望检测注释或字符串中的左括号或右括号,以下 C 示例演示了这一点
{ /* } */ char str[] = "}"; }
只有第三次出现的“}
”才闭合括号对。
对于令牌语言不规则的语言(例如带有 JSX 的 TypeScript),这种情况甚至更加困难
位置 [1] 的括号是否与位置 [2] 或 [3] 的括号匹配? 这取决于模板文字表达式的长度,只有具有无限状态的令牌生成器(非正则令牌生成器)才能正确确定这一点。
令牌来救援
幸运的是,语法突出显示必须解决类似的问题:前一个代码片段中位置 [2] 的括号应该渲染为字符串还是纯文本?
事实证明,仅忽略语法突出显示标识的注释和字符串中的括号就足以满足大多数括号对的需求。 <
... >
是我们迄今为止发现的唯一有问题的一对,因为这些括号通常既用于比较,又用作泛型类型的括号对,同时具有相同的令牌类型。
VS Code 已经有一种高效且同步的机制来维护用于语法突出显示的令牌信息,我们可以重用它来识别左括号和右括号。
这是 Bracket Pair Colorization 扩展的另一个挑战,它对性能产生负面影响:它无法访问这些令牌,必须自行重新计算它们。 我们长期思考如何高效可靠地向扩展公开令牌信息,但得出的结论是,如果不将大量实现细节泄露到扩展 API 中,我们就无法做到这一点。 因为扩展仍然必须为文档中的每个括号发送颜色装饰列表,所以仅凭这样的 API 甚至无法解决性能问题。
作为旁注,当在文档开头应用编辑(例如为类似 C 的语言插入 /*
)以更改所有后续令牌时,VS Code 不会一次性重新标记长文档,而是随着时间的推移分块进行。 这确保了 UI 不会冻结,即使令牌化在渲染器中同步发生也是如此。
基本算法
核心思想是使用 递归下降解析器来构建一个 抽象语法树 (AST),该树描述了所有括号对的结构。 当找到括号时,检查令牌信息,如果括号位于注释或字符串中,则跳过该括号。 令牌生成器允许解析器查看和读取此类括号或文本令牌。
现在的技巧是仅存储每个节点的长度(并且还为所有不是括号的内容设置文本节点以覆盖间隙),而不是存储绝对开始/结束位置。 仅使用可用长度,仍然可以在 AST 中有效地找到给定位置的括号节点。
下图显示了带有长度注释的示例 AST
将其与使用绝对开始/结束位置的经典 AST 表示形式进行比较
两个 AST 都描述了相同的文档,但是当遍历第一个 AST 时,必须动态计算绝对位置(这样做很便宜),而它们已经在第二个 AST 中预先计算好了。
但是,当在第一个树中插入单个字符时,只需要更新节点本身及其所有父节点的长度——所有其他长度保持不变。
当绝对位置像第二个树中那样存储时,文档中每个后续节点的位置都必须递增。
此外,通过不存储绝对偏移量,可以共享具有相同长度的叶节点,从而避免分配。
这是可以在 TypeScript 中定义带有长度注释的 AST 的方式
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) 树?
这可以很容易地简化为将较小的树前置或附加到较大的树的问题:如果两棵树具有相同的高度,则创建包含两个子节点的列表就足够了。 否则,我们插入高度较小的树
因为这具有运行时
为了平衡上一个示例中的列表 α 和 γ,我们对其子节点执行 concat 操作(红色列表违反了 (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 就会接管。
因为深度克隆几乎与重新解析文档一样昂贵,所以我们实现了写时复制,从而可以在
长度编码
中完成克隆。编辑器视图使用行号和列号描述视口。 颜色装饰也应表示为基于行/列的范围。
为了避免偏移量和基于行/列的位置之间的转换(可以在
请注意,这种方法与直接按行索引的数据结构(例如,使用字符串数组来描述文档的行内容)显着不同。 特别是,这种方法可以在行之间和行内执行单个二分查找。
添加两个这样的长度很容易,但需要区分情况:虽然行计数是直接添加的,但仅当第二个长度跨越零行时,才包含第一个长度的列计数。
令人惊讶的是,大多数代码不需要知道长度是如何表示的。 只有位置映射器变得更加复杂,因为必须注意单行可以包含多个文本编辑。
作为实现细节,我们将此类长度编码为单个数字以减少内存压力。 JavaScript 支持的最大整数为
进一步的困难:未闭合的括号对
到目前为止,我们假设所有括号对都是平衡的。 但是,我们也希望支持未闭合和未打开的括号对。 递归下降解析器的优点在于我们可以使用锚点集来改进错误恢复。
考虑以下示例
( [1]
} [2]
) [3]
显然,[2] 处的 }
不闭合任何括号对,并且表示一个未打开的括号。 [1] 和 [3] 处的括号很好地匹配。 但是,当在文档开头插入 {
时,情况会发生变化
{ [0]
( [1]
} [2]
) [3]
现在,[0] 和 [2] 应该匹配,而 [1] 是一个未闭合的括号,[3] 是一个未打开的括号。
特别是,在以下示例中,[1] 应该是一个在 [2] 之前终止的未闭合括号
{
( [1]
} [2]
{}
否则,打开括号可能会更改不相关的后续括号对的嵌套级别。
为了支持这种错误恢复,可以使用锚点集来跟踪调用者可以继续使用的预期标记集。 在上一个示例中位置 [1] 处,锚点集将是}
}
时,它不会消耗它并返回一个未闭合的括号对。
在第一个示例中,[2] 处的锚点集是)
}
。 因为它不是锚点集的一部分,所以它被报告为未打开的括号。
在重用节点时需要考虑这一点:当在其前面添加 {
时,不能重用对 ( } )
。 我们使用位集来编码锚点集,并计算每个节点包含的未打开括号的集合。 如果它们相交,我们就不能重用该节点。 幸运的是,括号类型只有几种,因此这不会对性能产生太大影响。
展望未来
高效的括号对着色是一个有趣的挑战。 借助新的数据结构,我们还可以更有效地解决与括号对相关的其他问题,例如[常规括号匹配](https://vscode.js.cn/docs/editing/editingevolved#_bracket-matching)或[显示彩色行范围](https://github.com/microsoft/vscode/issues/131001)。
即使 JavaScript 可能不是编写高性能代码的最佳语言,但通过降低渐近算法复杂度,尤其是在处理大型输入时,也可以获得很多速度提升。
编码愉快!
Henning Dieterichs,VS Code 团队成员 @hediet_dev