vs中readfile的作用_VS Code 中的 Text Buffer 的重新实现 [译+解读]

原文标题: Text Buffer Reimplementation

原文作者: Peng Lyu ( VS Code Team member)

这是一篇由 VS Code Team 核心成员写的文章,绝对硬核干货,它是基于传统的 piece table 数据结构的一次优化改造。关于 piece table 的数据结构原理可以参考另外一篇翻译文章:

# 正文开始

在 VS Code 的 1.21 发布版本中包含了一项重大改进:全新的 text buffer 实现,在内存和速度方面都有大幅的性能提升。在这篇文章里,我会告诉大家:我们是如何选择和设计数据结构和算法,从而达成这些改进的。

在关于 javascript 程序的性能讨论中,经常会有一种声音:建议使用 native code 实现。针对 VS Code 的 text buffer 问题,这些讨论早在一年多以前就开始了。 在经历深度探索之后,我们发现用 c++ 实现 text buffer 的确会使内存大幅度下降,但是并没有得到我们期望的性能提升。在 native 和 V8 引擎之间字符串转换非常消耗性能,消耗了通过 c++ 实现带来的性能优势。 在文章的末尾,我们会详细讨论这个问题。

既然不能采用 native 的方式,所以我们必须找到方法来优化 javasript/typescript 代码。有一些非常有启发性的文章在讲如何优化 js, 例如 Vyacheslav Egorov 的这篇文章里[Maybe you don't need Rust and WASM to speed up your JS](https://mrale.ph/blog/2018/02/03/maybe-you-dont-need-rust-to-speed-up-your-js.html),展示了如何把 javascript 引擎推到极限,从而榨取尽可能多的性能。即使不使用这些偏底层的引擎优化技巧,通过使用更合适的数据结构和更快的算法来把速度提升一个或几个数量级,仍然是可能的。

# 之前的 text buffer 数据结构

编辑器的核心模型是基于文本行的,例如,开发者是一行一行地读写代码,编译器提供的运行时诊断/堆栈追踪提供了行数和列数,tokenization 引擎是一行一行地运行,等等。虽然简单,我们希望通过 text buffer 的实现来增强 VS Code 的想法, 从我们第一天启动 Monaco Editor 项目时就没有变过。 开始的时候,我们使用的是以行为单位的数组的方法,并且运行良好,因为常规的文本输入都相对较小。当用户正在输入时,我们在数组中定位到用户正在输入的行,并且修改这一行的字符串进行替换;当用户插入新的一行时,我们在行数组( line array) 中插入一个新的行对象,由 JS 引擎帮我们完成繁重的底层内存操作。

但是,我们在不断地收到 VS Code 的崩溃反馈:当打开某些文件的时候会导致内存不足。例如,用户打开一个 35 MB 的文件失败。问题原因在于,这个文件里有太多的行,1370万行。我们之前为每一行创建了一个 ModelLine 对象,每个对象大概占用了 40 - 60 字节,所以 line array 使用了大约 600 MB 的内存来存放文档。这个内存大小是文件原始大小的 20 倍。

line array 表示方法的另外一个问题就是打开一个文件的速度。 为了构建 line array,我必须把内容按行进行拆分,每一行一个字符串对象。这个拆分行为消耗大量性能,你会在下面的基准测试(benchmark) 中看到这一点。

# 新的 text buffer 实现

line array 表示方法占用了大量内存,并且在构建的时候非常耗时,但它的好处是能够快速进行行查找。理想的情况下,我们希望只存储文件的文本,没有额外的 metadata。 所以,我们开始寻找一种数据结构,占用尽可能少的 metadata。在评估完一些数据结构以后,我发现 piece table 可能是一个很好的备选项。

## 使用 piece table 来避免过多的 meta-data

Piece Table 是一种用于表示文本文档中一系列编辑的数据结构:

class PieceTable {

original: string; // original contents

added: string; // user added contents

nodes: Node[];

}

class Node {

type: NodeType;

start: number;

length: number;

}

enum NodeType {

Original,

Added

}

在文件加载完之后,piece table 将整个文件内容存储在 `original` field 中, 这个时候 `added` field 是空的, table 里只有一个类型为`NodeType.Original`的 Node。 当用户在文件尾部输入时,我们将新增加的内容添加到 `added` field, 并且在 node list 中插入一个类型为 `NodeType.Added` 的 Node。类似地,如果用户是在文件中间输入时,我们将原来的 Node 进行拆分,并且根据需要插入一个新的 Node。

下面的动画展示了如何在 piece table 中以一行一行的方式访问文档行。它有两个 buffer (original 和 added), 三个 Node(在原来的内容中间插入了一段文字)。

piece table 的初始内存大小,十分接近原始文件的大小,编辑动作需要的内容正比于编辑动作的数量和新增文本的大小。所以,一般来说,piece table 在内存方面具有巨大的优势。 但是,低内存的代价就是,访问一个行(真实的行)的速度慢。例如,如果你想要得到第 1000 行的内容,唯一的办法是从文档的开始的每一个字符开始遍历,从第 999 个换行符开始,读取字符,直到下一个换行符号。

## 使用 caching 进行更快的行查找

传统的 piece table 的 nodes 中只包含了偏移量,但是我们可以在里面添加换行信息,从而可以更快的行查找。直观的方法就是,在 node 节点的文本中找到每一个换行符的 offset,并且存储起来。

class PieceTable {

original: string;

added: string;

nodes: Node[];

}

class Node {

type: NodeType;

start: number;

length: number;

lineStarts: number[]; // 存储换行符的 offset

}

enum NodeType {

Original,

Added

}

例如,如果你想从一个给定的 Node 中找到第二行,你可以从 `node.lineStarts[0]` and `node.lineStarts[1]` 的相对偏移量中读取到这一行的文本的位置。因为我们直到一个 Node 中有多少个换行,所以访问一个文档中随机行变得很直接:从第一个 Node (而不是之前的字符)开始读取,直到找到目标点的换行符。

这个算法仍然很简单,但是相比之前,已经很好了,我们可以直接跳过文本中的 chunk,而不是之前的一个字符一个字符的查找。我们会在下面讨论,如何可以做的更好。

## 避免字符串拼接陷阱

piece table 包含有个两个 buffer,一个用于存放是原始文件内容,另外一个用于存放用户编辑。 在 VS Code 中,我们通过 node.js 的 fs.readFile 来以 64KB 的 chunks 来加载文件。 所以,当文件很大时,例如 64MB,我们会得到 1000 个 chunks。 在收到所有的 chunks 以后,我们把它们拼接为一个巨大的字符串,存放到 piece table 的 original buffer 中。

这看起来是可行合理的,但是事实上,V8 引擎会无情打你的脸。我尝试过打开一个 500 MB 的文件 但是得到了一个异常,因为在我使用的 V8 引擎的版本中,字符串的最大长度是 256 MB。 这个限制会在 V8 引擎的未来版本中提高到 1 GB,但是并不是从根本上解决问题。

相比原来的 piece table 中只有两个 buffer (original buffer 和 added buffer) 而言, 我们改成了 buffers list, 尽量保证这个 list 不能过长,通过 fs.readFile 的时候避免字符串拼接。 每次收到 64KB 的 chunk,我们会把它放进 buffer list 中,并且创建一个 Node 指向这个 buffer。

class PieceTable {

buffers: string[]; // buffer list

nodes: Node[];

}

class Node {

bufferIndex: number; // buffer index

start: number; // start offset in buffers[bufferIndex]

length: number;

lineStarts: number[];

}

## 使用平衡二叉树(红黑树)来加速行查找

不使用字符串拼接的方法的情况下,我们现在打开大文件,可能会产生潜在的性能问题。比如,打开一个 64MB 的文件,piece table 会有 1000 个节点。即使我们在每个节点中缓存了换行符位置,我们也不会知道哪一个绝对行是对应哪一个节点。 为了获取某一行的内容,我们需要从第一个节点开始遍历,直到找到该行。对于最坏的 case,这个时间复杂度是 O(n), n 表示的节点的数量。

在每个 node 中缓存绝对的行数(第几行),并且在 list 上使用 二分查找法,可以加快查找速度。 但是,当我们修改一个节点时,必须要遍历后面所有的节点,进行行数更新。 这是不合适的,但是二分查找法的思路是对的。 为了能够达到同样的效果,我们可以使用平衡二叉树。

我们现在必须决定使用什么 metadata 来作为比较树节点的关键。正如前面提到的,使用node 在文档中的 offset 或 绝对行数会使得编辑动作的时间复杂度为 O(N). 如果我们想要让时间复杂度降为 O(log N), 我们需要(树)节点和和的它的子节点的相对信息。所以,当用户编辑文本时,我们重新计算修改该节点的 metadata,并且把变化一直冒泡到树的根节点。

(译者插入:如果没有明白红黑树是如何构建的,请看文章最后的图片说明)

如果一个节点只有4个属性(bufferIndex, start, length, lineStarts),会花费数秒才能找结果。 为了更快地找到结果,我们可以把左节点的 text length 和 line starts 也存储在节点中。通过这种方法,从根节点开始通过 offset 或 line number 搜索,可以非常高效。 存储右节点也能达到同样的效果,但是我们不需要两个都存。

改造以后的结构如下:

class PieceTable {

buffers: string[];

rootNode: Node; // 红黑树的根节点

}

class Node {

bufferIndex: number;

start: number;

length: number;

lineStarts: number[];

left_subtree_length: number; // 左节点的 text length

left_subtree_lfcnt: number; // 左节点的 换行符 数量

left: Node;

right: Node;

parent: Node;

}

在所有不同类型的平衡二叉树数据结构中,我们最终选择了红黑树,因为它更编辑友好。

## 减少对象分配

假定我们在每个节点中存储了换行符的偏移量。当我们修改节点时,这些偏移量必须要更新。例如,一个节点中有999个换行符,那么 lineStarts 数组中有 1000 个元素。如果我们把这个节点平均对半拆分为两个节点,每个节点包含大概 500 个元素。 因为我们不是直接操作线性内存空间,把数组一拆为二比单纯的移动指针要更消耗性能。

好消息是 piece table 中的 buffer 要么是 read-only, 要么是 append-only。 所以换行符在 buffer 中并不会移动。 节点中只需要简单的持有换行符在对应的 buffer 中的 2个 引用。 我们做的越少,性能越好。 我们的基准测试表明,在对 text buffer 操作中应用了这些改进,会使得性能比原来快 3 倍。 在后面实际实现中获得了更多的提升。

class Buffer {

value: string;

lineStarts: number[];

}

class BufferPosition {

index: number; // index in Buffer.lineStarts

remainder: number;

}

class PieceTable {

buffers: Buffer[];

rootNode: Node;

}

class Node {

bufferIndex: number;

start: BufferPosition;

end: BufferPosition;

...

}

## Piece Tree

我更喜欢称这种 text buffer 为:使用红黑树的多 buffer 的 piece table, 用于优化 line model。 但是在我们日常的站例会中,每个人的发言必须要在 90 秒内完成,多次重复这么长的句子是不明智的,所以我就简单称它为 piece tree.

理论上理解这种数据结构是一回事,在实际中的性能又是另外一回事。我们使用的开发语言,代码运行的环境,使用方调用API的方式,还有其他因素都可能会影响到结果。 基准测试(Benchmarks)提供了一个全面综合性的评估结果, 所以我们用同样的小/中/大文件,同时在line array 和 piece tree 两种版本下,运行基准测试。

## 基准测试

测试文件:

1. checker.ts: 1.46 MB, 26 K 行

2. sqlite.c: 4.31 MB, 128 K 行

3. Russian English Bilingual dictionary: 14 MB, 552 K 行

手动创建的更大的文件:

4. Chromium heap snapshot of newly opened VS Code Insider: 54 MB, 3 M 行

5. checker.ts X 128 - 184MB, 3M lines

### 1. 内存使用情况

在加载完文件以后,piece tree 的内存使用量十分接近于原始文件的大小,大幅度低于之前的实现。 第一轮, piece tree 胜出。

### 2. 打开文件耗时

查找和缓存换行符,要比将文件拆分为 line array 要快得多。

### 3. 编辑性能

模拟了两个工作流:

1. 在文档中随机的位置进行编辑。

2. 按顺序编辑。

我通过以下方式来模拟这两种场景:在文档中执行 1000 个随机的编辑动作,或按顺序 1000 次插入, 观察对比两种实现的耗时。

和预期的一样,当文件很小时,line array 胜出。 在一个小数组里访问一个随机位置或调整一个100-150字符的字符串,是非常快的。 当文件超过 100K 行时,line array 开始变得非常吃力了。在大文件中按顺序插入使得这种情况变得更糟糕,因为 js 引擎在处理 大数组的 resize 时会做很多的事情。Piece Tree 表现得很稳定,因为每一次编辑只是在 buffer 中追加,以及一些在 红黑树中的操作。

### 4. 读取性能

在我们的 text buffer 中,最高频调用的 API 是 getLineContent。 它被 view code 模块,tokenizer 模块, link detector 模块,以及一切和文档内容相关的模块调用。 某些代码会遍历整个文件(如 link detector), 另外一些代码只会读取窗口中需要展示的内容,(如 view code). (译者插入:在 VS Code 中大量应用了 virtual list 技术,一次只在 DOM 里展示可以看见的代码行,不会展示整个文件内容。)

所以针对这些场景,我通过如下方法来设计测试基准:

* 在执行 1000 次随机编辑后,调用 getLineContent 来获取所有行

* 在执行 1000 次顺序编辑后,调用 getLineContent 来获取所有行

* 在执行 1000 次随机编辑后,读取 10 个不同的 行视窗 (译者插入:行视窗的意思只指 virtual list 中一次真实DOM里对应的内容。)

* 在执行 1000 次顺序编辑后,读取 10 个不同的 行视窗

我们发现了 piece tree 的缺陷所在。对于一个大文件,1000次编辑会导致成千上万个节点。 尽管查找行的时间复杂度是 O(logN), 但它肯定要比 line array 中查找行的 O(1) 要大得多。

上千次编辑还是相对不常见的 case,在大文件中替换一个高频出现的字符换有可能会出现。同时,我们关心的是每次调用 getLineContent 的耗时毫秒数。 几乎大部分的 getLineContent 调用都是来自 view rendering 和 tokenization,这些后处理过程通常会耗时几十毫秒,而 getLineContent 在其中的耗时占比不到 1%。 但是,我们正在考虑最终实现一个标准化步骤:当节点数量特别大的时候,重新创建 buffer 和 节点。

# 结论总结

piece tree 在大部分场景中性能是优于 line array,除了行查找以外,不过那也是预期之内的。

## 经验教训

* 最重要的教训就是一定要基于实际情况做分析。

实践中,我对哪些方法是最优方案的假设会经常跟事实不符。例如,当我开始对 text buffer 重新实现时,我的精力主要集中在对一些原子操作(insert, delete, search)的调优。 但是当我最终和 VS Code 集成时,这些优化并没有起到多大的作用。 最高频的 API 是 getLineContent。

* 处理换行符(CRLF)或 混合换行符序列 是程序员的噩梦。

对于每一个文本修改,我们都需要检查它是否将原来的行进行拆分,或是产生一个新的 CRLF 行。在 tree 的上下文中,为了处理所有的 case,需要经过很多的尝试,最终才能找到正确并快速的方法。

* 垃圾回收(GC)可能会吃掉你的 CPU 时间。

之前我们的 text model 里的 buffer 是存在数组中的,我们会经常调用 getLineContent, 即使是非必需的场景。

例如,如果我们只想直到某一行的第一个字符,我们先使用 getLineContent,然后再执行 charCodeAt。 在 piece tree 中,getLineContent 在检查完 character code 之后,会创建一个 substring, 然后随即把它丢弃。 这是浪费的,我们正在使用更好更合适的方法。

## 为什么不用 native

我发誓,最开始的时候,我们也是这么想的。

我们尝试过了,但是不行。

我们用 c++ 实现了一个 text buffer,并且使用 nodejs (node module bindings) 集成到 VS Code 中。(译者插入:不是 WebAssembly 方式)。 text buffer 是 VS Code 中高频调用的基础模块,所以调用频次非常高。 如果它们都是 JS 实现的话,V8 引擎可以内联这些调用。但是使用 native 的 text buffer, 会有大量的 JavaScript <=> C++ 的来回调用,因为这个高频的调用次数,所以拖慢了所有操作。

例如,VS Code 中的 代码注释切换命令 是通过遍历所有选中的行,然后一个一个的分析。 这个逻辑是用 JS 写的,每一行的读取都会调用 `TextBuffer.getLineContent` 方法。 对于每一次调用,我们最终都会跨越 C++/JavaScript 边界,返回一个 js string。

我们有的选项很简单(2 种)。在 C++ 中,要么选择在每次 getLineContent 调用时分配一个新的 javascript 字符串(意味着字符串拷贝),要么选择使用 V8 引擎的 SlicedString or ConstString 类型。 但是,我们只有在我们的存储本身就是 V8 字符串的情况下,我们才能使用 V8 字符串类型。 不幸的是, V8 字符串是多线程不安全的。

我们尝试过通过修改 TextBuffer 的API,或者将更多的代码移植到 C++ 中来克服这个问题,避免边界损耗。 但是,我们意识到我们在同一时间做两件事情:使用新的数据结构来替换 line array, 我们在使用 c++ 而不是 javascript。 所以,与其在一件结果完全未知的事情上耗费半年时间,不如保持 text buffer 的 javascript runtime, 唯一的变化只是数据结构和算法。 在我们的选项里,这才是正确的决定。

# 未来工作

我们仍然还有很多的 case 需要优化。例如, 查找命令 (ctrl + F) 仍然是一行一行的运行,实不应该。当只是做一个字符串的 substring 时,我们同样可以避免不必要的调用 getLineContent。 我们会持续的发布这些优化。 即使没有未来的这些优化,新的 text buffer 实现带来了更好的用户体验,已经成为 VS Code 稳定版本中的默认实现。

# 翻译补充

Piece Tree 中最难理解的结构是,如何在红黑树如何构建并且保持 Node 在文本中的顺序。 通过阅读源码(https://github.com/rebornix/PieceTree),对于打开文件的过程中产生的所有 Node,只会往树的右侧添加;对于文件的插入编辑动作,首先要找出编辑位置对应的 Node,根据编辑位置将原来的 Node 做拆分成 A,B 两个 Node, 将新增文本产生的 Node C 插入 A 的右子分支,然后将 B 节点 插入在 C 的右子分支,整个过程通过 红黑树的规则,将整个 二叉树 维持在深度可控的平衡状态。

* 红黑树的作用只是用来维持二叉树结构的平衡问题。

* Node 对应文本中的顺序,是保持不变的。

另外,这篇文章给我们两个重要的启发:

1. 通过数据结构和算法来优化性能,永远都是最优解,而不遇到问题就抱怨 javascript 语言本身。

2. 任何数据过长的时候,应该意识到潜在可能的性能问题。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值