Notist
On this page
  1. 优化后剩余成本:重复遍历、缓存持有与分配器延迟
  2. 结论
  3. 测量边界
  4. 一、剩下的树工作是什么
  5. 确定的逻辑工作计数
  6. 应优先改变返回信息,不是删预算
  7. 来源仍不是廉价句柄
  8. 二、调用热点:Native 数量更大
  9. 三、私有图为何还慢
  10. 内容相同不等于模块身份相同
  11. 新的检查探针
  12. 另一个 clone 存在,但不是首要矛盾
  13. 四、缓存持有:有界需要说清是哪一层
  14. 五、XIV 发布检查:慢的是哪一层
  15. 粗粒度位置正确,算法归因不成立
  16. 已确认:一次小型目录缓冲区分配消耗大量 CPU
  17. no-op 比较仍有真实工作
  18. 六、下一轮优先级
  19. 复现、验收与限制

优化后剩余成本:重复遍历、缓存持有与分配器延迟

结论

本轮继续分析已冻结的优化补丁,没有再修改生产 Rust,也没有重算或替换上一轮前后加速比。

  1. 内容树不再主要浪费在整树深拷贝,但仍重复遍历和复制边界。 四种正文/模块负载的预算检查访问数是最终节点数的 11–13 倍,节点边界复制接近 4 次/最终节点。下一步应优先减少这些工作,而非先并行更多解释器任务。

  2. 私有模块的高成本不等于还有大量重复磁盘读取。 5,120 个可达模块身份只有 17 种源码内容,却因身份/来源参与模块编译键而仍做 5,120 次完整编译。身份不能合并,可研究共享身份无关的语法产物。

  3. 检查缓存没有整批总量上限。 私有图结束时持有 514 个检查缓存存储、512 个成功计划、14,848 个计划内记录项;8,192 的上限只约束一个 Context 存储。这不违反现有单 Context 限制,但不能将它当成 Vault 总预算。

  4. 先前归到 XIV 输出归属检查的百毫秒,实质定位到了分配器。 独立观察器抓到 opendir 内一次 32,816 字节 malloc 花费 102–105 ms,当前线程 CPU 时间几乎一样;目录读取和 43 字节 manifest 不是这个大头。尚未取得分配器内部栈,不能进一步断言具体是哪种 bin 合并或堆整理。

  5. 实例 ID/参数槽位有价值,但不是剩余成本的统解。 宽导出 105,031 次运行时调用中,88,135 次是 Native,只有 16,896 次 Code;只优化 Code Scope 无法覆盖全部热路径。

测量边界

对象仍是 214de5fec5ea796a047024510566168e1fa81d08 加生产补丁 1998c01a1b9279bb4c00ca7ada382ede1ec39b82cf3f771dc15b0f8446651cd2。原始同批前后报告为 docs/_logs/2026-11-03-optimization-profile.md,其中普通 CLI 的加速比保持不变。

本轮是 after-only 原因探针,分三种独立二进制:IO、密集求值、检查/缓存观察。各负载一次预热、三轮正式进程,固定 CPU 8,频率未锁定。另保留原来的粗粒度发布检查二进制做 syscall 与分配器定位。没有改变消费者、正文、页面数、site.data、锁文件或预算。

累计 78 次完整成功构建,1,167,712 个文件指纹、1,167,434 份 HTML 校验一致:5 次 seed、24 次 IO、20 次求值、20 次检查、1 次 strace、8 次分配器观察,均包含各自预热。它们不是唯一文件数,也不并入先前 225 次主/补充构建计算加速比。

除明确标作计数的数字外,下面新增计时是各自探针三轮均值。密集计时和逐访问计数会增加开销,改变分配状态;不能与普通 CLI 相减,也不能把递归 inclusive 时间相加。计时栈的 Self 分区及冻结输入/源码/二进制均通过核对。

一、剩下的树工作是什么

源码路径:

  • crates/notist-pipeline/src/code/eval.rs:391:bind_content 构造节点后再次调用 content_count。

  • 同文件 :447:present 对每个呈现结果重新遍历所有后代以计预算。

  • 同文件 :1178:item_shell 不复制 children/pending,但仍复制 ctor、实例身份、origin、字段和属性。

  • crates/notist-core/src/item.rs:486:每个 descendants 迭代器创建自己的 Vec 栈;反复从不同祖先开始遍历,会反复付这份工作。

确定的逻辑工作计数

最终节点包括保留的函数/插值边界,不是 HTML DOM 节点,也不是页面数。预算访问为 bind 与 present 的后代扫描次数之和;不代表不同节点的数量。

完整负载

最终节点

预算后代访问

访问/最终节点

边界复制

复制/最终节点

XIV

51,758

103,650

2.00

103,540

2.00

无模块正文

41,040

454,167

11.07

163,118

3.97

共享模块图

46,744

617,631

13.21

185,934

3.98

私有模块图

46,672

616,983

13.22

185,646

3.98

宽导出

89,168

1,097,751

12.31

355,630

3.99

这补充而非否定上一轮的所有权改造:children 的重复深拷贝确实已删除;现在暴露的是反复从祖先扫描、复制恢复边界、装配调用与来源信息。深层内容的预算扫描具有 O(节点数 × 有效深度) 的特征,当前负载量出了实际放大倍数。

应优先改变返回信息,不是删预算

bind/present 可研究返回“拥有的森林 + 非错误节点计数”,让父层合并计数,不重新展开 descendants。Runtime Content 的计数也可随不可变值携带,挂载时按每次逻辑使用计费。

必须保留:

  • Error 恢复占位不消耗正常节点预算,外围边界仍按原规则计数。

  • 多次挂载、for 展开和兄弟累计额度;不能因为共享 backing store 就只计一次。

  • 原有求值顺序、已执行读取依赖和 NeedResources;不能简单用“预算不够就跳过后续执行”改变效果。

  • 扩展后处理的输出仍需校验,不能用先前计数信任任意修改后的树。

当前 present 为保留超限后的原始恢复边界,提前对每个节点作 item_shell;present_call 也保留失败边界。因此不能直接删这些复制,改用已被改写的输出节点充当原边界。来源与恢复规则需要一起设计。

来源仍不是廉价句柄

Origin.source、SourceLocation.source、TypedExpr.source 和 Invocation.source 仍为 String;Origin.expansion 为 Vec。item_shell、capture 和 expand 会复制这些身份/展开链,虽不再复制整棵 children 树,仍可能触发大量小分配。

优先可研究共享源码身份和不可变签名/调用描述,而不是把所有 Item 套 Arc。更大的持久化展开链需要保持延迟挂载、重复挂载、错误位置和 source map 的展开顺序;不能只清空 origin 来换速度。本轮只计了边界复制与 expand 调用,没有逐字段分配量或 RSS 证据,不宣称已经量出某个字段的独立收益。

二、调用热点:Native 数量更大

完整负载

运行时 Code 调用

运行时 Native 调用

总调用

XIV

0

12

12

无模块正文

0

40,519

40,519

共享模块图

17,448

44,671

62,119

私有模块图

17,408

44,615

62,023

宽导出

16,896

88,135

105,031

宽导出约 83.9% 的运行时调用是 Native。其正文有更多表格行,不是只改变 import 形式的等工作量实验,不能把总调用数差归因于通配符。

实例查找的具体实现仍在 eval.rs:1000:每次构造 FunctionInstance.key 的 String,再查两次实例 map。definitions.rs:279 的规范键还格式化函数身份和 comptime 参数。宽导出本轮累计生成约 3,391,714 字节的逻辑键内容;这不是分配容量、实际 memcpy 或 RSS,路径前缀也属于本 fixture 的身份。

密集求值探针中,宽导出的实例实现查找为 23.533 ms;参数准备 Self 为 35.665 ms;capture.call Self 为 31.478 ms,边界复制为 39.905 ms。这些数字包含插桩影响,只用于细分当前路径,不能从普通 CLI 的 0.356587 s 中逐项扣除。

可分开研究:

  1. 实例索引/共享调用描述:checked program 内部建立局部索引,避免每次格式化规范键;对外仍保留稳定身份。替换映射后的实际目标、运行时按需特化及外部 IR 必须有正确重查路径。

  2. 参数槽位/绑定计划:检查时保存已知参数位置,减少 execute 中按名字 find 和构造命名输入表的重复工作;Option 转发、默认值、类型/能力校验不能省略。

  3. 共同边界的元数据成本:同时覆盖大量 Native 调用,而不只优化 16,896 次 Code Scope。

不应先以“Code Scope 无用复制已经删掉”为由跳过所有原生校验,也不能将局部实例编号直接作为跨 Analysis/wire 的稳定身份。

三、私有图为何还慢

内容相同不等于模块身份相同

静态遍历完整 fixture 的实际 imports,得到:

完整负载

可达模块身份

不同源码内容

源码字节总计

去重源码字节

共享图

18

18

6,639

6,639

私有图

5,120

17

1,557,504

6,437

宽导出

6

6

29,396

29,396

这不是消费者被裁剪后的实验;所有文件和正文均保持原样。私有模块的公开身份、相对 import/Path 基准、诊断与来源仍不同,不能按源码相同直接合并 CodeModule。

definitions.rs:63 的 compile_cached 键包含 module、path、source、resource_root 和前端身份,因此相同文本位于不同私有身份时仍执行完整 compile_uncached。该键目前是正确性边界,不应直接删除 module/path。

新的检查探针

私有图分析阶段 496.143 ms;其中 5,120 次未缓存模块编译为 151.608 ms,语法解析为其中的 84.525 ms。二者是父子关系,不能相加。

共享图仅 18 次实际编译,合计 0.663 ms;宽导出 6 次,合计 3.021 ms。已经共享的模块编译不是这两类负载的大头。

可考虑拆出按源码和方言/前端身份复用的身份无关语法产物,再按模块身份装配定义和来源。现有 Parse 虽含可共享 GreenNode,但嵌入内容保存 Box,该 trait 没有 Clone/Send/Sync 契约;不能把现有 Parse 直接塞进进程级共享缓存而认为设计已经完成。任意自定义前端仍须显式纯度身份。

另一个 clone 存在,但不是首要矛盾

check.rs:1491 仍对未检查的 CodeBody 作 cloned,包含 Expr 和诊断。这与已经共享的 checked InstanceRecord 是不同层。

本轮私有图 12,288 次 CodeBody clone 为 10.145 ms;共享图为 0.114、宽导出为 0.183。将只读 CodeBody 改成共享记录有合理性,但它只覆盖私有分析约 2% 的本探针区间,不能声称能解决整个约 496 ms 的分析成本。

四、缓存持有:有界需要说清是哪一层

以下在整站 Build/发布完成、Vault 销毁前观察;共享存储按 Arc 地址去重,地址只用于本进程观察,不作为缓存键。

完整负载

文档环境快照

缓存模块图

不同 checked 存储

成功计划

计划内记录项

XIV

51,739

3

3

0

0

无模块正文

521

3

3

0

0

共享图

521

10

10

9

238

私有图

521

128

514

512

14,848

宽导出

521

3

3

8

304

EnvironmentCache 的 128 个图上限,不会限制所有文档环境仍持有的 Context.checked。每个 Context 的 32 计划/8192 记录上限也不会合成一个 Vault 总上限。记录项不是去重后的物理对象或内存字节;这些观察也没有覆盖进程级 comemo 的全部持有。

私有图的检查计划没有跨页面复用的机会,却仍在首次成功检查后立即保留完整计划直到批次结束。检查探针里的 drop.vault 为 55.385 ms,旧探针曾为 36.553 ms;它受分配状态和插桩影响,不能把差值算作新回退,但保留规模本身已得到独立计数证明。

下一步应考虑:

  • 准入而非只设每表容量:重复请求后才强持有完整计划,或在预期共享的模块图内准入。不能以路径长得像 private 为策略依据。

  • 批次级总预算与淘汰:可作为可选的纯缓存预算/存储传入 Context,不必强制新的 ProjectSession。实例数不能精确代表大函数体占用,最终还需要成本与生命周期策略。

  • 冷/热语义一致:缓存淘汰、未准入或命中不能改变诊断、实例/深度限制、DataRetention、资源请求和 NeedResources。

不能简单把私有 Context 全合并成共享模块身份;那会破坏隔离和定义处绑定。

五、XIV 发布检查:慢的是哪一层

粗粒度位置正确,算法归因不成立

上一轮定位到输出归属检查约 100 ms,但细分 IO 探针中该区间仅 0.056 ms,manifest 读取约 0.008、解析约 0.005。不能把这个“消失”当作优化收益:生产实现未变化,细分插桩改变了分配时序与堆状态。

保留原粗粒度二进制的 strace 再现了约 92 ms 的 ownership 区间:

  • 打开输出目录的 openat 耗时约 11 μs。

  • 约 91.782 ms 后才进入 getdents64,后者自身约 18 μs,仅返回根目录的 8 个项(含 . 和 ..)。

  • 随后读取 43 字节 manifest 的操作只有微秒量级。

大间隔在两个 syscall 之间,不是 getdents64 花了 92 ms。仅凭 strace 尚不能区分用户态工作与调度,因此又加入独立分配器观察。

已确认:一次小型目录缓冲区分配消耗大量 CPU

Linux/glibc 观察器只包装 opendir 及其内部 malloc,转发到原来的 __libc_malloc,没有换分配器或修改 trim 参数。完整输入/输出仍逐文件验证。

XIV 三轮正式样本中,目录缓冲区的 一次 32,816 字节 malloc:

样本

malloc wall / ms

当前线程 CPU / ms

opendir wall / ms

1

102.908

102.629

102.950

2

104.911

104.626

104.956

3

102.549

102.186

102.594

私有图相同大小的一次 malloc 为 4.202–5.240 ms,线程 CPU 同样接近 wall。由此已能将大头定位到分配器内部的 CPU 工作,而非磁盘延迟、目录规模或 manifest JSON 解析。具体内部路径没有调用栈证据,fastbin consolidation/堆整理等仍只能作为待证机制,不写成已证结论。

这是原粗粒度探针二进制加独立观察器的定位结果,不等于已证明普通未插桩 release 必然在同一位置支付相同的 102–105 ms;不能从普通 CLI 秒数直接扣除这段时间。

这解释了为什么细分探针能改变“在哪一阶段看到成本”。下一次大的分配可能承接前面大量小对象释放后的维护工作;不能只按最后触发分配的函数名分配优化责任。

直接提前开一次目录、malloc_trim 或将维护工作移到计时外,不等于减少完整构建工作。也不应为了这个数删除输出归属检查。更合理的方向是减少前面节点/来源/调用描述的小分配与销毁,或用独立完整生命周期实验验证分配器/arena 方案;本轮没有把替换 allocator 当成生产改动。

no-op 比较仍有真实工作

IO 探针的 XIV 全字节比较为 253.789 ms;目录计划 11.571,根内 open 41.785,读取字节 84.347,读取前后 metadata 合计约 19.095,字节共享 4.111。各子区间并未覆盖所有扫描/关闭/路径和插桩开销,不能相加当作完整比较。

本轮仍有 51,744 个实际输出文件要验证。先前 trace 中大量 read 也能看到 32、32、64、128… 的请求序列,对应 FsReader 用空 Vec 调用 Take.read_to_end。可研究在确认普通文件和 metadata 上限之后,以受限的已知长度预留容量,减少短 read/增长;不能直接按整个 InputLimits 上限为每个小文件分配,也不能删除 limit + 1 和读取前后同句柄 metadata 校验。

强制替换时 no-op 比较虽很早发现差异,仍先花约 11.5 ms 构建完整目录集合;暂存和同步清旧各约 424 ms。减少规划常数可以做,但不能据此承诺消除写出/删除的大头。逐文件增量与 spool 仍需单独设计,不是当前 no-op 的同义词。

六、下一轮优先级

  1. 共享内核先做“带计数的森林结果”与廉价边界元数据:直接面对已量出的 11–13 倍预算遍历和约 4 次边界复制;保持恢复、累计预算、读取顺序和来源。

  2. 检查计划准入/总预算:避免一次性私有计划长期强持有;用完整批次与连续编辑验证生命周期,不把提前 Drop 当作减少总工作。

  3. 私有模块语法层复用:保留 module/path/root 的语义绑定,明确自定义前端的纯度和共享产物契约;不要只改缓存键。

  4. 实例 ID/参数绑定计划:覆盖共同调用描述,并单独验证大量 Native 调用,不能只优化 Code Scope。

  5. 发布的受限缓冲区与统一路径计划:保持 capability、有界读取和旧站失败恢复;真正变更发布再单列事务性逐文件方案。

不是所有建议都能把对应整段时间消除。尤其 CodeBody 的 10 ms、目录集合的 11.5 ms 都是局部范围;原来 XIV 的子进程、文档解析、模板渲染和全站上下文成本也仍在,不能把剩余总耗时都归给这里的新发现。

复现、验收与限制

入口为 tools/profile_residuals.py,说明见 tools/RESIDUAL_PROFILE.md;分配器观察器为 tools/residual_opendir_probe.c。证据目录为 /tmp/opencode/notist-residual-analysis-20261103/,保留初始与扩展 manifest/样本、三个源码快照和二进制、完整输出清单、计时调用边、约 80 MB 的 syscall trace、分配器日志及最终 integrity.json。

结构统计保存在 structural-inputs.json,分配器抽取记录保存在 allocator-observations.json。所有新增探针只修改临时快照;生产补丁、Cargo.lock 和上一轮快照/二进制未变化。普通加速比不更新,没有新增提交或推送。

本轮 Python 工具 27 项测试通过,覆盖临时插桩的 Rust 语法、生产文件不被改写及 C 观察器语法/日志解析。生产 Rust 与此前通过 734 项全特性、370 项无默认特性测试的补丁相同;本轮没有冒称重新执行这两套 Rust 验收。

没有 perf、gdb、heaptrack 或 valgrind 可用,未取得 allocator 内部栈、物理分配量或 RSS;不以缓存记录数或 wall 推断内存改善。密集探针不能替代普通 release 的同批 A/B,平台特定的 allocator 观察也不能外推所有系统。