Skip to content

Perf/core hot paths - #350

Open
tobegold574 wants to merge 2 commits into
floatboatai:mainfrom
tobegold574:perf/core-hot-paths
Open

tobegold574 wants to merge 2 commits into
floatboatai:mainfrom
tobegold574:perf/core-hot-paths

Conversation

@tobegold574

@tobegold574 tobegold574 commented Sep 18, 2026 •

Copy link
Copy Markdown

PR 标题:
perf(core): eliminate redundant full-document work on update hot paths
描述正文:

Summary / 摘要

消除编辑器每次事务热路径(光标移动与击键)上重复执行的全文档操作
(字符串物化、AST 重遍历、全文档正则扫描)。公开 API 零变化、渲染
输出完全一致;同时修复一个导致围栏代码块从未真正被语法高亮过的存量
bug(1 行,可独立拆分)。

Motivation / 背景与动机

  • Issue: N/A
  • Roadmap (docs/ROADMAP.md):部分覆盖 openspec/changes/add-missing-features
    任务 4.2("Performance benchmarks")跟踪的基准缺失。
  • OpenSpec change: 无——按 CONTRIBUTING §3.1 作为内部重构提交。本 PR
    不新增、不修改任何公开 API,装饰输出完全一致(由新增的不变量测试
    钉住)。GOVERNANCE §3 要求先开 proposal 的是公开 API 新增/新插件/
    破坏性变更/安全敏感工作,均不适用。如维护者认为本次范围属于
    "significant performance work",我可以补一个 proposal。

核心发现:collectLivePreviewRanges() 是 (ast, doc) 的纯函数——
它的 selection 参数从未被函数体读取(光标感知逻辑在 buildDecorations
里做)。但在每次光标移动时,编辑器都会重新遍历整棵 AST、执行两次全文档
正则扫描(wikilink + 图片)并重新排序;此外 live-preview、widget 扩展、
wikilinks StateField、事务管线和斜杠菜单路径每次更新合计要做 2~3 次
完整的 doc.toString() 全文档物化。

Changes / 变更内容

全部功能性改动位于 packages/core(9 个文件,+367/−87):

  • live-preview-ranges.ts:移除从未被读取的 selection 参数
    (保留为可选参数以兼容旧调用方),并写明输出与光标无关这一前提。
  • live-preview.ts:
    • build() 缓存升级为 {docText (Text 身份), doc, ast, codeTokens, ranges}——纯光标移动复用全部中间产物;缓存命中判断从 O(n) 字符串
      比较改为 O(1) 身份比较;每次 build 的 toString() 从 2~3 次降为 1 次。
    • 代码块高亮定位 doc.slice(blockFrom) → doc.indexOf("\n", blockFrom)。
    • 每块 token 查找:在全局按 from 升序的 token 数组上二分查找
      (原先每块都从下标 0 线性重扫,O(块数 × token 总数))。
  • live-preview-highlight.ts:
    • Bug 修复(1 行,scope || kind):hljs 令牌树遍历器匹配的
      kind 字段在 hljs 实际输出中不存在(实际字段是 scope),
      emit() 恒产出 0 个 token——围栏代码块此前从未真正被高亮过。
      本 PR 之后代码块首次渲染出高亮配色。如维护者希望拆成独立 PR
      也很容易,去掉它不影响任何性能改动。
    • 新增 (lang, code, contentStart) 二级 LRU,缓存命中不再在每次
      重建时逐 token 重新分配 rebase 数组。
  • widget-extension.ts:空 widget 定义检查前置到 doc.toString()
    之前;解析出的 AST 与文档串按 Text 身份 + transform 版本缓存
    (widget 范围仍按光标每次重算——其显隐依赖光标,行为不变)。
  • transaction-pipeline.ts:未注册任何 filter/listener 时整体旁路
    (与管线接入前编辑器自身的 fallback 行为一致);documentBefore/After
    改为惰性记忆 getter(读取时值完全一致);空注册表直接返回共享空数组。
  • event-emitter.ts + editor.ts:新增内部 hasListeners();无监听者
    时跳过斜杠状态全量计算与 selectionChange payload 构造。
  • wikilinks.ts:按 Text 身份缓存扫描结果——光标移动复用文档串与
    匹配列表。
  • live-preview-table.ts(仅纯计算):estimatedHeight 加 memo
    (source 在实例内不可变);visualLength 从逐字符 RegExp 改为
    charCode 区间判断(代理对行为精确等价);Math.max(1, ...map())
    参数展开改为标量循环。未触碰任何交互状态、rAF 或事件逻辑。

新增文件:packages/core/test/perf-invariants.test.ts(6 个不变量
测试)、packages/core/test/perf/hot-paths.bench.ts 与
micro-costs.bench.ts(vitest bench;未接入 CI)。

Why this matters at scale / 量级

在 ~1MB 合成文档(模拟 demo 真实内容混合)上,同机背靠背配对测量
(main vs 本分支,vitest bench):

场景(每次操作 = 10 次交互) main 本分支 差异
cursor-move 532.9 ms/次 371.6 ms/次 −30.3%(快 1.43×)
keystroke 15,584 ms(±44% 抖动) 13,916 ms(±3.8%) 均值 −10.7%,方差坍缩 10×

确定性微成本(rme ≤ 1.7%):旧实现在 1MB 文档上每次光标移动仅
collectLivePreviewRanges 一项就要付 ~173–309ms,外加 2 次全文档
toString()、一次重复的全文档 wikilink 扫描,以及无人监听时的斜杠
命令重排序。击键方差从 ±44% 坍缩到 ±3.8% 反映的是 GC 尖峰的消除。
所有成本随文档大小线性放大(98KB 时约 2.4ms/次光标移动)。
复现:pnpm vitest bench --run packages/core/test/perf/hot-paths.bench.ts。

Testing / 测试

  • pnpm test——packages/core:282/282 全过(18 个文件),
    含 6 个新增不变量测试。全仓说明:Windows 本机存在 12 个存量
    失败(plugin-host-broker.test.ts 的 symlink EPERM 需要管理员
    权限),以及 app-runtime-integration.test.ts 在全量并发下的
    偶发抖动;两者在干净 main 上同样复现(broker 文件 11 failed),
    与本 PR 无关。app-runtime-integration 在本分支单独运行 8/8 通过。
  • 受影响包构建通过(pnpm build 全绿);pnpm typecheck 全绿。
  • 新增 vitest 用例:perf-invariants.test.ts 钉住各缓存依赖的
    不变量——ranges 对 selection 不敏感;缓存 range 上光标进出
    切换 ×2;widget 跨缓存 AST 更新的可见性切换;wikilink 扫描
    缓存一致性;惰性 documentBefore/After 取值;高亮 rebase memo
    实例稳定(正是这个用例暴露了上面的 hljs bug)。
  • electron-demo 手动验证:electron-demo 渲染层集成套件在本分支
    通过(app-runtime-integration 单独运行 8/8),表格交互另有
    模拟事件单测覆盖(点击进入编辑、表格外失焦、选区恢复——均为
    packages/core/test 中派发的真实 DOM 事件)。CLAUDE.md 规则 7
    的 6 条人工交互路径本次未逐一手点:live-preview-table.ts 的
    diff 只涉及纯计算(高度估算),未触碰任何交互状态 / rAF /
    事件代码。如 reviewer 需要,我可以补一轮人工验证。

Commits(2 段,每段独立通过 typecheck 与测试套件)

  1. perf(core): 消除更新热路径上的冗余全文档操作 —— 9 个源文件的
    全部性能改动
  2. test(core): 新增热路径缓存不变量回归测试与基准 —— 6 个回归
    用例 + 2 个 vitest bench 基准

Out of scope(审计中识别但本 PR 刻意不做)

  • 表格 rAF 轮询改事件驱动(每个可见表格存在一个持续运行的
    requestAnimationFrame 循环)——位于 CLAUDE.md 12 条规则约束的
    交互状态机内,改动必须人工过全部 6 条鼠标交互路径,超出本 PR
    "零行为变化"的边界,宜作为独立 PR 并附人工验证记录。
  • 表格拖拽命中测试的几何缓存——mousemove 期间 O(行×列) 的
    getBoundingClientRect 强制布局,同样位于交互路径内,理由同上。
  • emitChange 惰性 AST(无 onChange 监听者时跳过 mdast 重建,
    getAst() 按需物化)——涉及 getAst() 的求值时机语义,需要单独
    设计与更完整的时序测试。
  • 基准接入 CI——jsdom 端到端 wall-clock 噪音(同机漂移可达 ±2×)
    不足以支撑 pass/fail 门槛,需要配对测量基建;对应 roadmap 4.2,
    建议以 nightly 趋势记录的形式独立推进。

Compliance / 合规自检

  • CLA signed——首次开 PR 时按 CLA 机器人提示签署。
  • AI disclosure:本 PR 功能代码并非非 AI 生成。← 如实
    不勾选,说明见下

    AI 使用说明:本 PR 在热路径审计、实现、测试与基准等环节大量
    使用了 AI 辅助,按 GOVERNANCE §6.2 如实披露,而非声称为纯
    手写。我对每一行改动做过逐行审阅,在源码内以中文注释标注了
    改动原因与作者/日期,能够逐一解释并辩护各设计决策;所有测量
    数据可通过随 PR 提交的基准文件复现。
  • 新增依赖:无(基准使用仓库已有的 vitest)。
  • 未提交构建产物。
  • 无密钥 / .env / 个人 vault 数据。

Checklist / 自检清单

  • 标题遵循 Conventional Commits(perf(core):,scope 在许可清单内)。
  • 公开 API 变化——无;无需更新 README/类型定义
    (EventEmitter#hasListeners 为内部方法,类未导出)。
  • 触碰了 live-preview-table.ts → 已核对 CLAUDE.md 的 12 条表格
    规则:规则 1–6、8–12 涉及交互状态、rAF 轮询、拖拽、
    contentEditable 与表格 StateField——这些代码均未触碰;
    规则 7 的覆盖情况见 Testing 一节。
  • 新 capability / 破坏性变更 → 无,因此不附 OpenSpec(理由见
    Motivation)。
  • 改动符合 GOVERNANCE §4 的项目范围(core 性能优化在范围内)。

Screenshots / Recordings

无——无可见 UI 变化。(hljs bug 修复后围栏代码块首次显示出语法
高亮配色,这是修复生效的表现,不是回归。)

光标移动/击键每次触发的事务中,存在大量只取决于文档内容、与光标无关的
重复计算。本提交将其全部缓存化,行为与公开 API 零变化:

- live-preview:build() 缓存升级为 {docText(Text 身份), doc, ast,
  codeTokens, ranges},纯光标移动复用全部中间产物;缓存命中判断由
  O(n) 字符串比较改为 O(1) Text 身份比较;每次 build 的
  doc.toString() 从 2~3 次降为 1 次。
- live-preview:代码块高亮定位 doc.slice(blockFrom) 改为
  doc.indexOf,消除每块每次重建 O(文档长度) 的尾部拷贝;全局 token
  数组由逐块头部线性重扫(O(块数×token 总数))改为二分查找。
- live-preview-highlight:新增 (lang, code, contentStart) 二级 LRU,
  缓存命中不再逐 token 重建 rebase 数组。
- widget-extension:空 widget 定义检查前置到 doc.toString() 之前;
  AST 与文档串按 Text 身份 + transform 版本缓存(widget 显隐仍随
  光标实时重算,行为不变)。
- transaction-pipeline:无 filter/listener 时整体旁路(与管线接入前
  编辑器自身 fallback 一致);documentBefore/After 改惰性记忆 getter
  (读取值不变);空注册表返回共享空数组,dispatch 不再做
  Array.from+filter+sort。
- event-emitter/editor:新增内部 hasListeners(),无监听者时跳过
  斜杠状态全量计算与 selectionChange payload 构造。
- wikilinks:StateField 扫描结果按 Text 身份缓存,光标移动复用
  文档串与匹配列表。
- live-preview-table:仅纯计算——estimatedHeight 加 memo(CM6 每次
  高度图刷新都会读)、visualLength 改 charCode 区间判断(代理对行为
  精确等价)、Math.max(1,...map()) 展开改标量循环;未触碰任何
  交互状态/rAF/事件逻辑。

1MB 文档同机配对实测:光标移动 -30.3%(532.9→371.6 ms/次),
击键方差 ±44%→±3.8%(GC 尖峰消除)。
- perf-invariants.test.ts:6 个用例钉住各缓存依赖的不变量——
  ranges 对 selection 不敏感、光标进出切换×2(缓存复用路径)、
  widget 可见性跨缓存 AST 更新、wikilink 扫描缓存一致性、
  惰性 documentBefore/After 取值、高亮 rebase memo 实例稳定
  (该用例同时暴露并锁定了 hljs scope/kind 存量 bug 的修复)。
- test/perf/hot-paths.bench.ts:~1MB 合成文档端到端基准
  (open+destroy / cursor-move / keystroke,vitest bench)。
- test/perf/micro-costs.bench.ts:逐项度量被消除工作的确定性
  微成本基准(无 DOM、无 CodeMirror view)。

基准未接入 CI(jsdom 端到端噪音大,仅供本地配对对比)。
@CLAassistant

CLAassistant commented Sep 18, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants