[Feature] Viva 编辑器 10 项性能债务:整篇染色超预算(正则回溯、StateStack 分配)与超长行、重载、小地图的 O(n) 路径
Los mantenedores suelen responder en 1 día
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 5/5
- Tiempo estimado
- Más de una semana
- Aptitud para principiantes
- 15/100
- Tipo de issue
- Refactorización
- Claridad
- Bastante claro
- Estado de actividad
- Activo
- Stack tecnológico
- go
- Área
- devtools, performance
Línea de trabajo
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.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
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,确认没有重复建议。
- Lenguaje dominante
- TypeScript
- Estrellas
- 1.8k
- Forks
- 151
- Merge medio
- 9 h 32 min
- PR fusionados (30 d)
- 406
Preparar el entorno
Este proyecto no incluye contenedor de desarrollo, Dockerfile ni guía de contribución, así que la configuración corre por tu cuenta: empieza por su README y consulta nuestra guía para la primera contribución para los pasos generales.
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de 1lck/Lithe-IDEA
-
review: high
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
1lck/Lithe-IDEA#1797 ·
Los mantenedores suelen responder en 1 día
-
area:other bug P2 platform:other review: high
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
1lck/Lithe-IDEA#1789 · 2 comentarios ·
Los mantenedores suelen responder en 1 día
-
review: medium
Dificultad 2/5 1-3 horas Aptitud para principiantes 70/100
1lck/Lithe-IDEA#1670 ·
Los mantenedores suelen responder en 1 día
-
area:editor bug P0 platform:macos review: low
Dificultad 2/5 1-3 horas Aptitud para principiantes 65/100
1lck/Lithe-IDEA#1118 · 2 comentarios ·
Los mantenedores suelen responder en 1 día
-
refactor review: high
Dificultad 4/5 3-5 días Aptitud para principiantes 30/100
1lck/Lithe-IDEA#1815 ·
Los mantenedores suelen responder en 1 día
Todos los issues de 1lck/Lithe-IDEA
Issues similares
-
Bug: Cloudflare Pages static _redirects identity rule causes RSC Flight to reload as HTMLPosiblemente ocupada @Donvero la tomó hoy. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
Mantitup-Org/vista#210 ·
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
simstudioai/sim#9831 ·
Los mantenedores suelen responder en 1 día
-
status: waiting for maintainer
Dificultad 2/5 1-3 horas Aptitud para principiantes 88/100
mui/material-ui#49751 ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 88/100
Los mantenedores suelen responder en 2 días
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
diplodoc-platform/transform#1184 ·
Los mantenedores suelen responder en 1 día