我们如何测量
Space 用自己的渲染器绘制文字,这个渲染器内置于它的 GPUI 框架分支版本中。它依据 Slug 算法,直接从轮廓出发,在 GPU 上绘制每个字形。GPUI 原有的渲染器(图表中称为“原生”)则是在每遇到一个新尺寸时,让 Core Graphics 在 CPU 上绘制每个字形,再把结果保存在纹理中。
我们在同一台 Mac 上,用同样的文字,测量两个渲染器的耗时。
测试环境
- Mac: Apple M3 Pro
- 系统: macOS 27
- 日期: 2026 年 10 月 1 日
- 显示: 每个场景分别以 1× 和 2×(Retina)绘制
场景
- 界面: 应用控件、一个边栏和 25 行表单,使用 IBM Plex Sans,字号 12 到 20 磅,在 1440 × 900 的窗口中约有 2,000 个字形。
- 编辑器: 两个窗格的 Rust 代码,外加一个文件树和一个终端,使用 Lilex,字号 12 和 13 磅,在 1440 × 900 的窗口中约有 3,800 个字形。
- 文档: 六列英文、代码和中文,字号 10 到 32 磅,在 2560 × 1440 的窗口中约有 13,700 个字形。
- 缩放: 编辑器场景,在 200 帧中每一帧都放大 1%,因此没有重复的尺寸。
我们计时的内容
每个数字都是绘制一帧所用的时间:CPU 的工作与 GPU 的工作相加。结果为三轮测试所得帧耗时的中位数,每轮 30 帧(缩放为 200 帧)。
首帧延迟是绘制以前没有画过的尺寸的文字时,那一帧所用的时间,就像文字刚出现时那样。在界面、编辑器和文档的测试中,每一帧都是这样的帧。在缩放测试中,每一帧都是新尺寸,就像你缩放时那样。
缩放
| 场景 | 显示 | 原生 | Space | 加速比 |
|---|---|---|---|---|
| 编辑器 | 1× | 5.23 ms | 0.66 ms | 7.9 倍 |
| 编辑器 | 2× | 7.16 ms | 0.85 ms | 8.4 倍 |
首帧延迟
| 场景 | 显示 | 原生 | Space | 加速比 |
|---|---|---|---|---|
| 界面 | 1× | 5.28 ms | 0.84 ms | 6.3 倍 |
| 界面 | 2× | 7.45 ms | 0.95 ms | 7.9 倍 |
| 编辑器 | 1× | 9.58 ms | 1.92 ms | 5.0 倍 |
| 编辑器 | 2× | 12.88 ms | 1.98 ms | 6.5 倍 |
| 文档 | 1× | 100.23 ms | 38.03 ms | 2.6 倍 |
| 文档 | 2× | 110.89 ms | 37.83 ms | 2.9 倍 |
界面场景在 1× 下,各次运行之间的差异比其他场景更大,最多达 36%。
已经绘制过的文字
当屏幕上的文字已经以相同的尺寸绘制过时,两个渲染器耗时大致相同。这就是首页写“最高”的原因:它的数字来自最好的一次缩放测试,那里每一帧都以新尺寸绘制文字。
编辑
Space 把文件的文本保存在 rope 中:这是一棵树,它的叶子是最多包含 1,024 字节文本的文本块,每个节点最多有 16 个子节点。一次编辑会改动一个文本块,以及从该文本块向上到根节点这条路径上的计数。只有当文件增大许多倍时,树才会增加一层,所以这条路径始终很短。
在文本块内部,rope 用 str_indices 库统计字符、行和 UTF-16 单元。开启 simd 特性后,str_indices 借助 NEON(Apple 芯片的 SIMD 指令)一次读取 16 字节。这就是读取并统计一个文本块的速度。它只占一次编辑的一小部分。
树的层数
| 文件 | 文本块 | 层数 |
|---|---|---|
| 1 KB | 1 | 1 |
| 1 MB | 1,024 | 4 |
| 100 MB | 102,400 | 6 |
| 1 GB | 1,048,576 | 7 |
这是由文件文本构建的 rope 的层数,其中文本块那一行算作一层。我们是从一个能报告深度的 rope 构建版本中读取这些层数的。首页上的图是一幅示意图:每个节点显示它最多 16 个子节点中的 5 个,而层数是真实的。
编辑的测试设置
- Mac: Apple M3 Pro
- 系统: macOS 27.2
- 日期: 2026 年 11 月 7 日
- 构建: Rust 1.99.1
- 文本: 形如
let value_7 = compute(7, "text") * 2;的行,全部为 ASCII,截取成 1 KB、1 MB、100 MB 和 1 GB 的文件
编辑的测量方法
一次编辑是插入或删除一个字符或一个换行。在光标处是指在文件中间连续输入 200 个字符,再把它们删掉。在随机位置是指每次编辑都落在文件中新的随机偏移量处。
每个数字是一次编辑的耗时:401 批、每批 200 次编辑的中位数(100 MB 和 1 GB 为 201 批),之前有 21 批预热。表格显示三次运行的中位数。同一个数字的各次运行之间,在光标处最多相差 10%,在随机位置最多相差 19%。
文本缓冲区的数字是 60 批、每批 100 次编辑的中位数。统计和 NEON 的数字也是中位数,来自三次运行。
Rope 在光标处的编辑
| 编辑 | 1 KB | 1 MB | 100 MB | 1 GB |
|---|---|---|---|---|
| 插入一个字符 | 64 ns | 133 ns | 178 ns | 211 ns |
| 删除一个字符 | 187 ns | 264 ns | 330 ns | 375 ns |
| 插入一个换行 | 307 ns | 371 ns | 416 ns | 443 ns |
| 删除一个换行 | 318 ns | 405 ns | 470 ns | 522 ns |
首页显示的是第一行。耗时并非恒定:文件大小增至原来的一百万倍时,耗时约为原来的 3.3 倍,因为树的层数增加了。
Rope 在随机位置的编辑
| 编辑 | 1 KB | 1 MB | 100 MB | 1 GB |
|---|---|---|---|---|
| 插入一个字符 | 64 ns | 146 ns | 886 ns | 1,282 ns |
| 删除一个字符 | 147 ns | 299 ns | 921 ns | 1,271 ns |
| 由偏移量查找行和列 | 14 ns | 49 ns | 84 ns | 114 ns |
从 100 MB 起,在随机位置的编辑所需时间是在光标处编辑的好几倍。在 1 GB 的文件中约为 1.3 µs。
文本缓冲区的编辑
| 编辑 | 1 KB | 1 MB | 100 MB | 1 GB |
|---|---|---|---|---|
| 在光标处插入一个字符 | 9.7 µs | 10.1 µs | 9.9 µs | 10.0 µs |
| 在光标处删除一个字符 | 12.6 µs | 13.2 µs | 13.0 µs | 13.2 µs |
| 在光标处插入一个换行 | 11.7 µs | 11.8 µs | 12.0 µs | 12.2 µs |
| 在光标处删除一个换行 | 16.5 µs | 16.4 µs | 16.8 µs | 17.1 µs |
| 在随机位置插入一个字符 | 22.1 µs | 33.5 µs | 33.8 µs | 35.7 µs |
| 在随机位置删除一个字符 | 25.2 µs | 31.8 µs | 31.6 µs | 33.4 µs |
这是编辑器的文本缓冲区,它会编辑 rope,同时还保存片段、撤销历史和锚点。在光标处,各种大小下都约为 10 到 17 µs,是 rope 自身编辑耗时的 40 到 60 倍。同一个数字的三次运行最多相差 63%,其中一半相差不到 10%,主要出现在 1 KB 一列。
完整的一次按键
| 文件 | 每个字符的耗时 |
|---|---|
| 1,000 行(约 48 KB) | 2.15 ms |
这是每帧向该文件输入一个字符,耗时取三次运行的中位数。每一轮依次包括按键、编辑、布局、绘制和一次 Metal 绘制,不等待显示器。它并不是你看到的延迟。在 120 Hz 下,显示器的一帧持续 8.3 ms,所以决定你所见的是这一帧,而不是编辑。
在一个文本块中统计
| 统计项 | NEON | 每次一个机器字 | 普通循环 |
|---|---|---|---|
| 字符 | 12 ns | 43 ns | 141 ns |
| 换行符 | 12 ns | 47 ns | 143 ns |
| UTF-16 单元 | 35 ns | 117 ns | 290 ns |
这是通过 rope 自带的函数(len_chars、len_lines、len_utf16)统计一个 1 KB 文本块所用的时间。“每次一个机器字”是关闭 simd 的同一个 rope,一次读取 8 字节。“普通循环”是对字节的简单循环。rustc 已经把它部分转成了 SIMD 代码;关闭这项优化后,统计字符需要 173 ns。
在长文本中统计
| 统计项 | NEON | 每次一个机器字 | 普通循环 |
|---|---|---|---|
| 字符 | 63.6 GB/s | 25.2 GB/s | 7.2 GB/s |
| 换行符 | 61.1 GB/s | 22.3 GB/s | 7.3 GB/s |
| UTF-16 单元 | 23.5 GB/s | 13.7 GB/s | 3.7 GB/s |
对 64 MB 文本做同样的统计。在单个 1 KB 文本块上,使用 NEON 的速度分别为 85.8、85.8 和 29.2 GB/s。
NEON 为编辑带来了什么
| 在光标处编辑 | 1 KB | 1 MB | 100 MB |
|---|---|---|---|
| 插入一个字符 | 快 2% | 慢 2% | 慢 1% |
| 删除一个字符 | 快 13% | 快 10% | 快 8% |
| 插入一个换行 | 快 7% | 快 7% | 快 6% |
| 删除一个换行 | 快 7% | 快 6% | 快 5% |
这些是直接对 rope 计时的结果,分别在开启和关闭 simd 时测得。simd 特性控制 str_indices;memchr 库有自己的 NEON 代码,在两种情况下都保持开启。删除和换行会把整个文本块重新读一遍,NEON 做这件事更快。普通插入只统计新增的那个字节,所以 NEON 不影响这项操作的耗时。用 64 MB 文本构建一个 rope,开启 NEON 时速度为 3.03 GB/s,关闭时为 2.69 GB/s,快了 13%(各 15 次运行)。