[Feature] Viva 编辑器 10 项性能债务:整篇染色超预算(正则回溯、StateStack 分配)与超长行、重载、小地图的 O(n) 路径

Aperta
#1,816 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
5/5
Tempo stimato
Più di una settimana
Idoneità per principianti
15/100
Tipo di issue
Refactoring
Chiarezza
Abbastanza chiara
Stato di attività
Attiva
Stack tecnologico
go

Direzione di ricerca

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.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

review: high
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。
  1. 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 的捕获组重新分词时也会压一个节点,用完就丢掉。
  2. 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 只增不减,删掉最宽的行之后,横向滚动范围不会收回。
  3. 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 条。

  4. 每次检查文档都要把整篇基线复制出来再比较

    • 位置: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。
  5. 语义高亮转换时逐行复制文本,每个位置都从行首重新换算

    • 位置:desktop/internal/editor/syntax/semantic.go,buildSemantic 在 :208,AppendLine 在 :229,utf16Offset 在 :265。
    • 现象:
      • 每个含 token 的行都用 text.AppendLine 复制一遍。语言服务器返回整篇 token 时,相当于把整篇文档复制一遍。
      • 每个 token 的起点都从行首按 UTF-16 重新扫描。一行有 k 个 token 时开销是 O(k × 行长),在超长行上接近平方级。
    • 状态:Highlighter.SetSemantic 目前在生产代码里没有调用方,因为 Viva 还没接入语义高亮。这是潜在成本,应在接入之前处理。
  6. 正则引擎是回溯解释器,整篇染色的吞吐受它限制

    • 位置:desktop/internal/editor/textmate/internal/regexp2,fork 自 dlclark/regexp2。

    • 数据:下表是同一台机器上的最终结果。预算是 50 ns/字节,相当于 100 KiB 用 5.12 ms,即 20 MB/s。这 3 项是 PR #1799 之后仅剩的超预算结果。

      结果 实测 超出预算 与优化前相比
      BenchmarkEditorSyntaxFullPass/go/100KiB 54.1 ms(约 1.9 MB/s,约 530 ns/字节) +957% −13.9%
      BenchmarkEditorSyntaxFullPass/java/100KiB 40.2 ms(约 2.5 MB/s) +686% −6.7%
      BenchmarkEditorSyntaxFullPass/markdown/100KiB 14.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 的预算内,所以不阻塞输入和绘制。受影响的是整篇或大段染色完成所需的时间。

  7. (?:\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 前缀或首字符集来剪枝。$ 是零宽、可为空的分支,首字符分析得不到集合,于是每个候选位置都要进入解释器。
  8. 自动换行时,超长行每次编辑都要整行重新换行

    • 位置:desktop/internal/editorview/wrap.go,spliceRows 在 :236,countRows 在 :184,rowsOf 在 :135。

    • 现象:每次编辑时:

      1. spliceRows 先调用 clear(v.rowCache),清掉所有行的换行缓存。
      2. 再对编辑涉及的行调用 countRows。新条目不是精确值,所以先用 rangeCells 量整行,再用 wrapRange 把整行断成若干段。
      3. 下一帧里,因为缓存已经清空,rowsOf 又把这一行断一遍。

      结果是在超长行上每次按键大约要读整行三遍,视口里其他的长行也要重新断行。窗口宽度变化有 BenchmarkEditorViewRewrap 覆盖,开着自动换行时输入没有基准。

  9. 小地图每次重建都要重新换行

    • 位置:desktop/internal/editorview/minimap.go,buildMinimap 在 :223,minimapRows 在 :284。

    • 现象:以下任一情况都会让 buildMinimap 重建全部标记:

      • 文本修订号变化,即每次编辑;
      • 染色代数 hlGen 变化,即后台染色每推进一步;
      • 主题变化。

      重建时,minimapRows 对不在视图换行缓存里的长行,每次都从行首重新断行,放在临时缓冲里,以免挤掉可见行的缓存。

      • 开着小地图和自动换行、打开含超长行的文件时,每次重建的开销与小地图覆盖的长行总长度成正比。这违反第四节第 13 条:一帧的开销只取决于可见范围。
      • 现有的 BenchmarkEditorViewPaint/minimap 用的是不换行的普通 Go 文档,覆盖不到这种情况。
  10. 没有在超长行上输入的基准

    • 位置:desktop/internal/editorview/perf_bench_test.go:196,BenchmarkEditorViewType。
    • 现象:
      • BenchmarkEditorViewType 只在 Go 文档的普通行上输入。longline 场景(:146,含超长行的 JSON)只用于绘制。
      • 以下情况都没有基准:超长行上的输入、回车和撤销,以及在此基础上开着自动换行或小地图。
      • 因此第 2、8、9 项没有数据,回归也发现不了。
Proposed solution / 建议方案

下面是建议的方向。具体做法在各自的 PR 里,用同一台机器上改动前后的基准验证后再定。建议先做第 10 项,为第 2、8、9 项建立改动前的数据。

  1. 只让行末状态持久化:
    • 同一行里的临时压栈,尤其是 :576 的捕获组重新分词,改用可复用的节点或按行分配的 arena,只把行末状态提升为堆上的节点。
    • 或者对行末状态做哈希合并,复用相同的状态。
    • 验证:看 BenchmarkEditorSyntaxFullPass 的分配次数和字节数,并确认检查点的 Equal 行为不变。
  2. 复用每行的单元格数:
    • 换行布局已经在 lineWrap.cells 里按行记了单元格数。不换行时也可以维护每行的宽度。
    • 行内的编辑如果不含换行和制表符,就按插入、删除文本的单元格数增减,不再重读整行。
    • 最宽的行变窄时,再按需重新求最大值,同时修正 widest 只增不减的问题。
  3. 把改动范围的计算移到工作线程:
    • Prepare 已经在工作线程上把全文读了一遍,可以同时和基线比较,得出改动范围(例如公共前缀和后缀),随 Content 交给窗口线程。
    • 窗口线程只应用改动,向 OnEdit 也只报告改动的部分。如果基线在此期间变了,就退回整篇替换。
    • 补一个窗口线程上的重载基准。
  4. 不复制整篇基线:
    • 先比较长度,再按 rope 的块(Chunks)和读入的数据逐段比较。
    • 或者在保存时记下大小、修改时间和内容摘要,没变化就不读文件。
  5. 改进语义高亮转换:
    • 在一行里按 token 的顺序向前推进一个“字节偏移 / UTF-16 偏移”游标,不再从行首重新扫描。
    • 直接从 rope 的块读取,不复制整行;必要时把转换放到工作线程。
    • 在 Viva 接入语义高亮之前完成,同时补一个语义高亮基准。
  6. 减少回溯:
    • 对不用反向引用、后行断言和 \G 等特性的模式,提供不回溯的执行路径(例如编译到 RE2 或 DFA 风格的引擎),其余模式继续用 regexp2。
    • 或者参照 vscode-oniguruma 的 scanner,把一个作用域的所有规则合成一次扫描,直接找出最早的匹配。
    • 无论哪种做法,所有语法测试的匹配结果都必须和现在一致。
  7. 让首字符分析认识行尾:对 (?:\n|$),以及以 $、\n 这类可空分支开头的模式,把“下一个 \n 或文本末尾”当作首字符候选,用 IndexByte('\n') 直接跳过去。
  8. 增量换行:
    • 编辑时只让被改动行的缓存条目失效,不清空整张缓存。
    • 这一行从包含编辑点的那一段开始重新断行;后面的段一旦和旧的段对上(按长度差平移后相同)就停止。
  9. 小地图换行:
    • 给小地图单独设一个有上限的按行换行缓存,或者复用 countRows 的结果。
    • 编辑时只重建改动行的标记。
    • hlGen 变化时,只重新着色染色结果变了的行;Highlighter 的 onChange(first, last) 已经给出了范围。
  10. 补基准:
    • 新增 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 没有测量。
  • 规则与决策:
    • 预算: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,确认没有重复建议。
Lingua principale
TypeScript
Stelle
1.8k
Fork
151
Merge medio
9h 32m
PR unite (30g)
406

Preparare l'ambiente

Questo progetto non fornisce container di sviluppo, Dockerfile né guida per i contributori, quindi l'ambiente è a tuo carico: parti dal suo README e consulta la nostra guida al primo contributo per i passaggi generali.

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di 1lck/Lithe-IDEA

Tutte le issue di 1lck/Lithe-IDEA

Issue simili

Altre issue su TypeScript

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.