我们如何测量

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 ms0.66 ms7.9 倍
编辑器2×7.16 ms0.85 ms8.4 倍

首帧延迟

场景显示原生Space加速比
界面1×5.28 ms0.84 ms6.3 倍
界面2×7.45 ms0.95 ms7.9 倍
编辑器1×9.58 ms1.92 ms5.0 倍
编辑器2×12.88 ms1.98 ms6.5 倍
文档1×100.23 ms38.03 ms2.6 倍
文档2×110.89 ms37.83 ms2.9 倍

界面场景在 1× 下,各次运行之间的差异比其他场景更大,最多达 36%。

已经绘制过的文字

当屏幕上的文字已经以相同的尺寸绘制过时,两个渲染器耗时大致相同。这就是首页写“最高”的原因:它的数字来自最好的一次缩放测试,那里每一帧都以新尺寸绘制文字。

编辑

Space 把文件的文本保存在 rope 中:这是一棵树,它的叶子是最多包含 1,024 字节文本的文本块,每个节点最多有 16 个子节点。一次编辑会改动一个文本块,以及从该文本块向上到根节点这条路径上的计数。只有当文件增大许多倍时,树才会增加一层,所以这条路径始终很短。

在文本块内部,rope 用 str_indices 库统计字符、行和 UTF-16 单元。开启 simd 特性后,str_indices 借助 NEON(Apple 芯片的 SIMD 指令)一次读取 16 字节。这就是读取并统计一个文本块的速度。它只占一次编辑的一小部分。

树的层数

文件文本块层数
1 KB11
1 MB1,0244
100 MB102,4006
1 GB1,048,5767

这是由文件文本构建的 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 KB1 MB100 MB1 GB
插入一个字符64 ns133 ns178 ns211 ns
删除一个字符187 ns264 ns330 ns375 ns
插入一个换行307 ns371 ns416 ns443 ns
删除一个换行318 ns405 ns470 ns522 ns

首页显示的是第一行。耗时并非恒定:文件大小增至原来的一百万倍时,耗时约为原来的 3.3 倍,因为树的层数增加了。

Rope 在随机位置的编辑

编辑1 KB1 MB100 MB1 GB
插入一个字符64 ns146 ns886 ns1,282 ns
删除一个字符147 ns299 ns921 ns1,271 ns
由偏移量查找行和列14 ns49 ns84 ns114 ns

从 100 MB 起,在随机位置的编辑所需时间是在光标处编辑的好几倍。在 1 GB 的文件中约为 1.3 µs。

文本缓冲区的编辑

编辑1 KB1 MB100 MB1 GB
在光标处插入一个字符9.7 µs10.1 µs9.9 µs10.0 µs
在光标处删除一个字符12.6 µs13.2 µs13.0 µs13.2 µs
在光标处插入一个换行11.7 µs11.8 µs12.0 µs12.2 µs
在光标处删除一个换行16.5 µs16.4 µs16.8 µs17.1 µs
在随机位置插入一个字符22.1 µs33.5 µs33.8 µs35.7 µs
在随机位置删除一个字符25.2 µs31.8 µs31.6 µs33.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 ns43 ns141 ns
换行符12 ns47 ns143 ns
UTF-16 单元35 ns117 ns290 ns

这是通过 rope 自带的函数(len_chars、len_lines、len_utf16)统计一个 1 KB 文本块所用的时间。“每次一个机器字”是关闭 simd 的同一个 rope,一次读取 8 字节。“普通循环”是对字节的简单循环。rustc 已经把它部分转成了 SIMD 代码;关闭这项优化后,统计字符需要 173 ns。

在长文本中统计

统计项NEON每次一个机器字普通循环
字符63.6 GB/s25.2 GB/s7.2 GB/s
换行符61.1 GB/s22.3 GB/s7.3 GB/s
UTF-16 单元23.5 GB/s13.7 GB/s3.7 GB/s

对 64 MB 文本做同样的统计。在单个 1 KB 文本块上,使用 NEON 的速度分别为 85.8、85.8 和 29.2 GB/s。

NEON 为编辑带来了什么

在光标处编辑1 KB1 MB100 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 次运行)。