一个字符串不够用:编辑的代价账
一个文本编辑器要把打开的文件放在某处。最直白的选择是一段连续内存,或者在 JS 里一个字符串。读取快得没话说:第
个字符是一次寻址,取一段子串是一次 memcpy。问题出在写。
在长度
的连续内存正中插入一个字符,后半段
个字符全部要往后挪一格。删除同理。一个 10 MB 的文件在中间敲一个字符,逻辑上要搬走 500 万个字符;换成 JS 字符串还要更糟,字符串不可变,s.slice(0, p) + c + s.slice(p) 每次重建全串,搬的是
而不是
。
这个代价本身不新鲜。值得算清楚的是:它在什么规模上真的开始疼,以及换掉它要付出什么。
1 · 编辑操作的分布
编辑器的操作并不是均匀地落在文档各处。把一次真实的编辑会话拆开看,大致是三类:
- 写操作高度局部化。连续输入时,相邻两次插入的位置只差一格;即使是查找替换,也是一批彼此相距很远但各自成簇的编辑点。
- 读操作遍布全文。语法高亮要顺序扫过可视区,查找要扫全文,跳转到某一行要立即给出偏移。
- 随机访问的粒度是字符,行号查询的粒度是行。这两个坐标系必须同时便宜,而它们的换算关系随每次编辑变化。
朴素字符串在后两类上无可挑剔,在第一类上无可救药。而第一类正是交互延迟的所在:读操作可以摊在渲染帧里做,插入必须在下一帧之前完成。
2 · 五种实现的同一串编辑
本系列实现了四种缓冲结构,外加一个朴素字符串作为对照与真值。五者共用同一个接口:insert(pos, text) / delete(pos, len) / charAt(i) / substring(a, b) / lineStart(k) /
toString(),每个操作都统计搬移量(既有内容被挪动的字符数,不含新插入的文本本身,那笔开销五种实现完全一样)。
在 1 MB 的样本文档(1048576 个字符、39834 行)正中连续插入 1000 个字符,五者的搬移量是:
| 实现 | 搬移字符数 |
|---|---|
| 朴素字符串 | 1049075500 |
| gap buffer | 1572880 |
| piece table(数组) | 0 |
| piece tree(树) | 0 |
| rope | 0 |
三个零不是取整的结果,是精确值:piece table 与 rope 的插入只改指针与下标。gap buffer 的 157 万里有 152 万是一次性的,拆开看是 524288(光标从文档末尾跳到正中)加 1048592(洞用完后整体重排一次),中间那 15 次插入一个字符也没搬。
把插入位置改成随机,账立刻变样:rope 涨到 62346(每次插入平均切开一个 63 字符的叶子),gap buffer 涨到 342887016,两版 piece table 仍是 0。
3 · 规模与击键延迟
搬移量是逻辑量,真正决定手感的是墙钟。这两者的关系没有想象中直接。
最初的猜想是:朴素字符串搬 10.49 亿个字符只花 26.4 毫秒,快得不合理,V8 大概是用 ConsString 把拼接推迟了,slice 也可能只产生 SlicedString 而不复制。做一组控制实验就否掉了这个猜想:用 Uint16Array.set 老老实实搬同样多的 16 位字符,耗时 22.4 毫秒,等效 93.7
GB/s。1 MB 的文档整个待在 L2 里,搬移就是这么快。26.4 毫秒是真搬出来的。
于是问题变成规模。实测每次击键的耗时随文档变大而变化:
| 文档规模 | 朴素字符串每次击键 | 等效带宽 | piece tree 每次插入 |
|---|---|---|---|
| 0.25 MB | 0.009 ms | 61.5 GB/s | — |
| 1 MB | 0.031 ms | 68.6 GB/s | 0.0022 ms |
| 4 MB | 0.236 ms | 35.6 GB/s | — |
| 16 MB | 1.007 ms | 33.3 GB/s | 0.0015 ms |
| 64 MB | 4.191 ms | 32.0 GB/s | 0.0007 ms |
等效带宽在 4 MB 一档掉了一半,那是文档溢出末级 cache 的位置。此后带宽稳定在 32 GB/s 左右,耗时随文档长度线性增长。64 MB 时每次击键 4.19 毫秒,已经吃掉 60 fps 一帧预算的四分之一,而这还只是搬移,不含重新分词、高亮与布局。
同一区间里 piece tree 每次插入稳定在 0.001 毫秒量级,且随文档变大略微变快(大文档的 piece 更少、树更浅)。这个反向趋势不是错觉,它来自 piece 数只随编辑次数增长、与文档长度无关。
警示 · 用小文档量出来的墙钟不能外推。0.25 MB 与 64 MB 之间,朴素字符串每次击键的耗时差 465 倍,而搬移量只差 256 倍——多出来的那 1.8 倍全是 cache 的账。本系列此后一律用搬移量与访问结点数做横向对照,墙钟只在本页出现一次。
4 · 评价维度
把上面的观察整理成可对照的五项。后面三页每讲一种结构,都回到这张表上看它在哪一格上赢、哪一格上输。
定义 4.1(文本缓冲的评价维度)
- 局部编辑代价:在给定位置插入或删除,要搬动多少既有内容。
- 随机访问:
charAt(i)与substring(a, b)要访问多少结点。 -
行号查询:
lineStart(k)给出第 行的起始偏移要多少工作。 - 内存开销:结构本身相对文档长度的额外占用,以及碎片化程度。
- 撤销与协同:能否廉价地保存历史版本,能否表达「谁在什么位置插入了什么」。
第五项常被忽略,但它决定了结构能不能长成一个真实产品。撤销要求能回到任一历史版本;协同编辑要求把编辑表达成可交换、可重放的操作。这两条对「原地改写文本」的结构都不友好。
5 · 计量口径
三个计数器贯穿本系列,含义要先定死,否则跨结构的数字没法比。
moved 记既有内容被搬动的字符数。这是唯一可以跨五种实现直接比大小的量。新插入的文本本身不计,因为每种实现都得把它写进某处一次。
visited 记访问的结点数,单位随结构而变:朴素字符串与 gap buffer 记触碰的数组格,数组版 piece table 记扫过的 piece,piece tree 与 rope 记下降经过的树结点,另加换行位置表上的二分探测次数。跨家族只能比数量级,不能比绝对值。
scanned 记为统计换行而扫读的字符数。它不搬动任何东西,但确实是 CPU 时间,而且是行号查询在朴素结构上的全部代价。
这套口径有一处刻意的不对称值得说明:piece table 拆分一个 piece 时要重新数这一段里有几个换行,数组版是逐字符扫(记进 scanned),树版靠缓冲区的换行位置表二分(记进 visited)。同一件事落在两个计数器上,是因为两种实现做的确实是两件不同的事,合并计数会把 §5 的对照抹平。
注 · 五种实现按 UTF-16 code unit 计位,与 JS 的 String.length 同一口径。surrogate pair 因此可能被切在中间:delete(3, 1) 作用在 'ab😀cd' 上会留下一个孤立的高位代理。这是刻意保留的限制而非疏漏,buffer.test.ts
里有一组测试把它显式测出来。生产实现(如 ropey)会额外维护 code point 与 grapheme cluster 两套坐标。
6 · 参考文献
- Crowley, C. (1998). Data structures for text sequences. University of New Mexico, Technical Report.
- Boehm, H.-J., Atkinson, R., & Plass, M. (1995). Ropes: an alternative to strings. Software: Practice and Experience, 25(12), 1315–1330.
- Microsoft. (2018). Text buffer reimplementation. VS Code Blog, 2018-03-23.
- Finseth, C. A. (1991). The Craft of Text Editing: Emacs for the Modern World. Springer-Verlag. 第 6 章逐项对照了缓冲结构的取舍。