[Feature] Viva 编辑器 10 项性能债务:整篇染色超预算(正则回溯、StateStack 分配)与超长行、重载、小地图的 O(n) 路径
メンテナーはふだん 1 日以内に返信
まだ誰も着手していません。
評価
- 難易度
- 5/5
- 見積もり時間
- 1週間以上
- 初心者へのやさしさ
- 15/100
- issue の種類
- リファクタリング
- 明瞭さ
- おおむね明確
- 活発さ
- 活発
- 技術スタック
- go
- 領域
- devtools, performance
調査の方向性
This is an umbrella issue for ten separate performance items, so pick one item and treat the others as out of scope. Start with item 10 (BenchmarkEditorViewType/longline in desktop/internal/editorview/perf_bench_test.go), since the issue recommends recording baselines before changing anything else. Run scripts/desktop/perf/editor-bench.sh run on an idle machine to capture before-and-after numbers. Done means the benchmark exists, the baseline is recorded, and the budgets in desktop/benchmarks/editor-budgets.json are unchanged.
索引モデルが issue の本文から書いたものです。
説明
Problem to solve / 要解决的问题
PR #1799 为 Viva 编辑器建立了性能基线,并完成第一轮优化:110 项结果中,超预算的从 52 项降到 3 项。在这一过程中发现了 10 项改动前就已存在的性能债务。按维护者的决定,它们合并在这一个 Issue 里跟踪,每一项都可以单独修复、单独提 PR。
- 代码位置:都在
feature/1794-viva-workbench-shell的当前提交e92acfa84上核对过。 - 数据来源:在参考机
m4-mbp上运行scripts/desktop/perf/editor-bench.sh所得。 - 预算:见
desktop/benchmarks/editor-budgets.json。
-
StateStack.push每次压栈都在堆上新建一个节点- 位置:
desktop/internal/editor/textmate/tokenizer.go:162,调用点在:358和:576。 - 数据:
BenchmarkEditorSyntaxFullPass/go/100KiB的最终结果是 54.1 ms/op、28,269 次分配/op。优化过程中做过一次内存剖析(-memprofilerate 512),这个场景分配的字节有 83% 来自push。 - 原因:
StateStack是持久链表,因为行末状态要被检查点共享。每进入一条 begin/while 规则,就要新分配一个约百字节的节点。withContent和withEndRule会再复制一次节点。:576为带patterns的捕获组重新分词时也会压一个节点,用完就丢掉。
- 位置:
-
widestLine每次编辑都要重新量整行- 位置:
desktop/internal/editorview/view.go:609,调用点在:718。 - 现象:每次插入或删除后,
v.widest = max(v.widest, widestLine(...))都会重新读取编辑所在的行并计算单元格数。- 函数里有一条捷径:字节数加上制表符的上限不超过当前最宽值时就跳过。但被编辑的行如果本身就是最宽的一行,这条捷径不起作用。
- 结果是在超长行上(例如单行 10 MiB 的 JSON 或压缩过的 JS),每次按键都要读完整行,开销与行长成正比。这违反 develop-lithe Skill 第四节第 12、13 条。
- 目前没有基准能量化这一点(见第 10 项)。
- 附带问题:
widest只增不减,删掉最宽的行之后,横向滚动范围不会收回。
- 位置:
-
replaceFromDisk在窗口线程上把整篇文档复制一遍-
位置:
desktop/internal/editorview/view.go:379;wholeDocumentEdit在:753,:758处是Text: replacement.String()。 -
现象:有两个入口,都在窗口线程上执行:
- 文件被外部修改后重载:
workbench/document_observation.go:275调用Reload。 - 发生冲突时选用磁盘版本:
workbench/document_conflict.go:128调用ResolveWithDisk。
生产代码设置了
OnEdit(workbench/documents.go:399),所以每次都会:- 把整篇新内容复制成一个字符串,10 MiB 的文档就是 10 MiB 的分配和复制;
- 把它作为一次整篇替换交给
OnEdit,语言服务器也会收到全文。
工作线程上的
Prepare有BenchmarkEditorViewPrepareReload覆盖,窗口线程上的这部分没有基准。 - 文件被外部修改后重载:
-
原因:撤销用的
restore(view.go:780)只报告改动的范围,重载没有这样做。这违反第四节第 9、10 条。
-
-
每次检查文档都要把整篇基线复制出来再比较
- 位置:
desktop/internal/workbench/document_observation.go:187。 - 现象:
bytes.Equal(data, baseline.Bytes())里,baseline.Bytes()每次都把已保存的 rope 复制成一整块连续内存。- 再加上读入的文件内容,每次检查的临时内存峰值约为文档大小的 2 倍。10 MiB 的文档会多出约 10 MiB 的临时分配。
- 几个检查同时进行时(上限为
documentChecksInFlight),这部分开销会叠加。 - 检查在后台执行,不阻塞窗口线程,但带来分配和 GC 压力。
- 触发来源是文件变化等事件:
document_observation.go、documents.go、document_conflict.go里有多处会设置checkPending。
- 位置:
-
语义高亮转换时逐行复制文本,每个位置都从行首重新换算
- 位置:
desktop/internal/editor/syntax/semantic.go,buildSemantic在:208,AppendLine在:229,utf16Offset在:265。 - 现象:
- 每个含 token 的行都用
text.AppendLine复制一遍。语言服务器返回整篇 token 时,相当于把整篇文档复制一遍。 - 每个 token 的起点都从行首按 UTF-16 重新扫描。一行有 k 个 token 时开销是 O(k × 行长),在超长行上接近平方级。
- 每个含 token 的行都用
- 状态:
Highlighter.SetSemantic目前在生产代码里没有调用方,因为 Viva 还没接入语义高亮。这是潜在成本,应在接入之前处理。
- 位置:
-
正则引擎是回溯解释器,整篇染色的吞吐受它限制
-
位置:
desktop/internal/editor/textmate/internal/regexp2,fork 自 dlclark/regexp2。 -
数据:下表是同一台机器上的最终结果。预算是 50 ns/字节,相当于 100 KiB 用 5.12 ms,即 20 MB/s。这 3 项是 PR #1799 之后仅剩的超预算结果。
结果 实测 超出预算 与优化前相比 BenchmarkEditorSyntaxFullPass/go/100KiB54.1 ms(约 1.9 MB/s,约 530 ns/字节) +957% −13.9% BenchmarkEditorSyntaxFullPass/java/100KiB40.2 ms(约 2.5 MB/s) +686% −6.7% BenchmarkEditorSyntaxFullPass/markdown/100KiB14.9 ms(约 6.9 MB/s) +191% −21.4% PR #1799 已经减少了正则的执行次数,而匹配次数不变:
语法 执行次数(优化前 → 后) 匹配次数 Go 480k → 277k(−42%) 33,013 Java 204k → 134k(−34%) 21,614 Markdown 110k → 40k(−64%) 8,025 -
原因:TextMate 在每个位置都要逐条尝试当前作用域的所有规则,每条规则都交给回溯解释器执行。首字符集很宽的复杂模式,在大多数位置都会进入完整匹配然后失败。
-
影响:窗口只给视口染色,
BenchmarkEditorSyntaxViewportCold约 1 ms,在 8.3 ms 的预算内,所以不阻塞输入和绘制。受影响的是整篇或大段染色完成所需的时间。
-
-
(?:\n|$)一类模式算不出首字符,无法剪枝- 位置:
desktop/internal/editor/textmate/internal/regexp2/runner.go:1296,findFirstChar。 - 数据:在 Go 语法的整篇染色中,
(?:\n|$)执行了 22,087 次,跑了 197,425 条指令,只匹配 971 次,没有首字符集。按指令数,它在 Go 语法的所有模式里排在前列。PR #1799 也把这类模式列为 Markdown 超预算的原因之一。 - 原因:
findFirstChar只能靠锚点、Boyer-Moore 前缀或首字符集来剪枝。$是零宽、可为空的分支,首字符分析得不到集合,于是每个候选位置都要进入解释器。
- 位置:
-
自动换行时,超长行每次编辑都要整行重新换行
-
位置:
desktop/internal/editorview/wrap.go,spliceRows在:236,countRows在:184,rowsOf在:135。 -
现象:每次编辑时:
spliceRows先调用clear(v.rowCache),清掉所有行的换行缓存。- 再对编辑涉及的行调用
countRows。新条目不是精确值,所以先用rangeCells量整行,再用wrapRange把整行断成若干段。 - 下一帧里,因为缓存已经清空,
rowsOf又把这一行断一遍。
结果是在超长行上每次按键大约要读整行三遍,视口里其他的长行也要重新断行。窗口宽度变化有
BenchmarkEditorViewRewrap覆盖,开着自动换行时输入没有基准。
-
-
小地图每次重建都要重新换行
-
位置:
desktop/internal/editorview/minimap.go,buildMinimap在:223,minimapRows在:284。 -
现象:以下任一情况都会让
buildMinimap重建全部标记:- 文本修订号变化,即每次编辑;
- 染色代数
hlGen变化,即后台染色每推进一步; - 主题变化。
重建时,
minimapRows对不在视图换行缓存里的长行,每次都从行首重新断行,放在临时缓冲里,以免挤掉可见行的缓存。- 开着小地图和自动换行、打开含超长行的文件时,每次重建的开销与小地图覆盖的长行总长度成正比。这违反第四节第 13 条:一帧的开销只取决于可见范围。
- 现有的
BenchmarkEditorViewPaint/minimap用的是不换行的普通 Go 文档,覆盖不到这种情况。
-
-
没有在超长行上输入的基准
- 位置:
desktop/internal/editorview/perf_bench_test.go:196,BenchmarkEditorViewType。 - 现象:
BenchmarkEditorViewType只在 Go 文档的普通行上输入。longline场景(:146,含超长行的 JSON)只用于绘制。- 以下情况都没有基准:超长行上的输入、回车和撤销,以及在此基础上开着自动换行或小地图。
- 因此第 2、8、9 项没有数据,回归也发现不了。
- 位置:
Proposed solution / 建议方案
下面是建议的方向。具体做法在各自的 PR 里,用同一台机器上改动前后的基准验证后再定。建议先做第 10 项,为第 2、8、9 项建立改动前的数据。
- 只让行末状态持久化:
- 同一行里的临时压栈,尤其是
:576的捕获组重新分词,改用可复用的节点或按行分配的 arena,只把行末状态提升为堆上的节点。 - 或者对行末状态做哈希合并,复用相同的状态。
- 验证:看
BenchmarkEditorSyntaxFullPass的分配次数和字节数,并确认检查点的Equal行为不变。
- 同一行里的临时压栈,尤其是
- 复用每行的单元格数:
- 换行布局已经在
lineWrap.cells里按行记了单元格数。不换行时也可以维护每行的宽度。 - 行内的编辑如果不含换行和制表符,就按插入、删除文本的单元格数增减,不再重读整行。
- 最宽的行变窄时,再按需重新求最大值,同时修正
widest只增不减的问题。
- 换行布局已经在
- 把改动范围的计算移到工作线程:
Prepare已经在工作线程上把全文读了一遍,可以同时和基线比较,得出改动范围(例如公共前缀和后缀),随Content交给窗口线程。- 窗口线程只应用改动,向
OnEdit也只报告改动的部分。如果基线在此期间变了,就退回整篇替换。 - 补一个窗口线程上的重载基准。
- 不复制整篇基线:
- 先比较长度,再按 rope 的块(
Chunks)和读入的数据逐段比较。 - 或者在保存时记下大小、修改时间和内容摘要,没变化就不读文件。
- 先比较长度,再按 rope 的块(
- 改进语义高亮转换:
- 在一行里按 token 的顺序向前推进一个“字节偏移 / UTF-16 偏移”游标,不再从行首重新扫描。
- 直接从 rope 的块读取,不复制整行;必要时把转换放到工作线程。
- 在 Viva 接入语义高亮之前完成,同时补一个语义高亮基准。
- 减少回溯:
- 对不用反向引用、后行断言和
\G等特性的模式,提供不回溯的执行路径(例如编译到 RE2 或 DFA 风格的引擎),其余模式继续用 regexp2。 - 或者参照 vscode-oniguruma 的 scanner,把一个作用域的所有规则合成一次扫描,直接找出最早的匹配。
- 无论哪种做法,所有语法测试的匹配结果都必须和现在一致。
- 对不用反向引用、后行断言和
- 让首字符分析认识行尾:对
(?:\n|$),以及以$、\n这类可空分支开头的模式,把“下一个\n或文本末尾”当作首字符候选,用IndexByte('\n')直接跳过去。 - 增量换行:
- 编辑时只让被改动行的缓存条目失效,不清空整张缓存。
- 这一行从包含编辑点的那一段开始重新断行;后面的段一旦和旧的段对上(按长度差平移后相同)就停止。
- 小地图换行:
- 给小地图单独设一个有上限的按行换行缓存,或者复用
countRows的结果。 - 编辑时只重建改动行的标记。
hlGen变化时,只重新着色染色结果变了的行;Highlighter的onChange(first, last)已经给出了范围。
- 给小地图单独设一个有上限的按行换行缓存,或者复用
- 补基准:
- 新增
BenchmarkEditorViewType/longline,以及开着自动换行、开着小地图时在长行上输入的场景,视图要按窗口的真实配置来设置。 - 现有的
^BenchmarkEditorViewType/预算(1 ms、64 KiB、规模比 ≤ 2)会自动覆盖这些场景。 - 先记录改动前的结果,再修第 2、8、9 项。
- 新增
Target platform / 目标平台
Cross-platform / 跨平台
Area / 模块
Editor / 编辑器
Priority / 优先级
P2 / 后续考虑:目前不着急,可安排到后续版本
Alternatives considered / 已考虑的替代方案
- 上调预算、删除场景或缩小文档规模:编辑器性能规则禁止这样做,不考虑。
- 整个换成 Go 标准库的
regexp:TextMate 语法依赖后行断言、反向引用和\G,RE2 不支持这些,只能用在一部分模式上(见第 6 项)。 - 通过 cgo 接入 Oniguruma:VS Code 的 vscode-textmate 用的是 vscode-oniguruma 的原生或 WASM 扫描器,一次扫描就能找出最早的匹配。但这会引入 C 依赖和跨平台构建的负担,需要另外评估。
- 保持现状:
- 窗口只给视口染色,第 6、7 项不阻塞输入。
- 第 2、3、8、9 项只在超长行、大文件重载或开着小地图时出现。
- 但这几项都违反“热路径和窗口线程不做与总长成正比的工作”的规则,开销会随行长或文件大小增长,目前还没有数据(见第 10 项)。
Additional context / 补充信息
- 测量环境:
- 机器:Apple M4 MacBook Pro,即基线参考机
m4-mbp,物理机;系统为 macOS 27.3,arm64,Go 版本 go1.27.3。 - 文档规模:大文档默认 10 MiB,可用
LITHE_EDITOR_PERF_LARGE_BYTES或--large-bytes调整。 - 局限:这些是无头 Go 基准的结果,不代表真实窗口里的延迟;Windows 没有测量。
- 机器:Apple M4 MacBook Pro,即基线参考机
- 规则与决策:
- 预算:
desktop/benchmarks/editor-budgets.json。 - 规则:
.agents/skills/develop-lithe/references/editor-performance.md。 - 决策原因:
.agents/notes/implemented/testing/2026-11-09-viva-editor-performance-baseline.md。
- 预算:
- 相关 Issue:
- #1794:Viva 工作台迁移。
- #1805:Core 行编辑整篇复制文档,与本 Issue 是不同的问题。
- 来源分支与 PR:
feature/1794-viva-workbench-shell,PR #1799。性能基线和第一轮优化的提交范围是5b24494b4..3031c2ff5,以上代码位置在该分支的e92acfa84上核对。 - 基线命令:
scripts/desktop/perf/editor-bench.sh run:运行基准并生成报告。scripts/desktop/perf/editor-bench.sh compare BASE CANDIDATE:对比两次结果。scripts/desktop/perf/editor-bench.sh record --machine m4-mbp --kind physical:只在空闲机器上运行,写入desktop/benchmarks/history/m4-mbp.jsonl。目前这个文件里只有一条优化前在a7e52e32e记录的结果。
- 同机前后对比:
- 修复每一项时,在同一台空闲机器上分别运行改动前和改动后的
editor-bench.sh run,用compare对比,把结果写进 PR 说明。 - 运行期间不要编译或运行其他程序。
- 超出预算只报告、不阻塞,但改动新引入的超预算结果要逐项解释。
- 不得上调预算、删除场景或缩小文档规模。
- 修复每一项时,在同一台空闲机器上分别运行改动前和改动后的
Checklist / 提交前检查
- I searched existing issues for duplicates. / 我已搜索现有 Issue,确认没有重复建议。
- 主要言語
- TypeScript
- スター
- 1.8k
- フォーク
- 151
- 平均マージ
- 9時間 32分
- マージ済み PR(30日)
- 406
環境構築
このプロジェクトには開発コンテナ、Dockerfile、コントリビューションガイドがありません。まず README を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
1lck/Lithe-IDEA のほかの issue
-
review: high
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
1lck/Lithe-IDEA#1797 ·
メンテナーはふだん 1 日以内に返信
-
area:other bug P2 platform:other review: high
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
1lck/Lithe-IDEA#1789 · コメント 2 件 ·
メンテナーはふだん 1 日以内に返信
-
review: medium
難易度 2/5 1〜3時間 初心者へのやさしさ 70/100
1lck/Lithe-IDEA#1670 ·
メンテナーはふだん 1 日以内に返信
-
area:editor bug P0 platform:macos review: low
難易度 2/5 1〜3時間 初心者へのやさしさ 65/100
1lck/Lithe-IDEA#1118 · コメント 2 件 ·
メンテナーはふだん 1 日以内に返信
-
refactor review: high
難易度 4/5 3〜5日 初心者へのやさしさ 30/100
1lck/Lithe-IDEA#1815 ·
メンテナーはふだん 1 日以内に返信
1lck/Lithe-IDEA の issue をすべて見る
似ている issue
-
Bug: Cloudflare Pages static _redirects identity rule causes RSC Flight to reload as HTML対応中かも @Donvero が今日担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
Mantitup-Org/vista#210 ·
-
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
simstudioai/sim#9831 ·
メンテナーはふだん 1 日以内に返信
-
status: waiting for maintainer
難易度 2/5 1〜3時間 初心者へのやさしさ 88/100
mui/material-ui#49751 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 88/100
メンテナーはふだん 2 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
diplodoc-platform/transform#1184 ·
メンテナーはふだん 1 日以内に返信