优化后剩余成本:重复遍历、缓存持有与分配器延迟
结论
本轮继续分析已冻结的优化补丁,没有再修改生产 Rust,也没有重算或替换上一轮前后加速比。
内容树不再主要浪费在整树深拷贝,但仍重复遍历和复制边界。 四种正文/模块负载的预算检查访问数是最终节点数的 11–13 倍,节点边界复制接近 4 次/最终节点。下一步应优先减少这些工作,而非先并行更多解释器任务。
私有模块的高成本不等于还有大量重复磁盘读取。 5,120 个可达模块身份只有 17 种源码内容,却因身份/来源参与模块编译键而仍做 5,120 次完整编译。身份不能合并,可研究共享身份无关的语法产物。
检查缓存没有整批总量上限。 私有图结束时持有 514 个检查缓存存储、512 个成功计划、14,848 个计划内记录项;8,192 的上限只约束一个 Context 存储。这不违反现有单 Context 限制,但不能将它当成 Vault 总预算。
先前归到 XIV 输出归属检查的百毫秒,实质定位到了分配器。 独立观察器抓到 opendir 内一次 32,816 字节 malloc 花费 102–105 ms,当前线程 CPU 时间几乎一样;目录读取和 43 字节 manifest 不是这个大头。尚未取得分配器内部栈,不能进一步断言具体是哪种 bin 合并或堆整理。
实例 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 中逐项扣除。
可分开研究:
实例索引/共享调用描述:checked program 内部建立局部索引,避免每次格式化规范键;对外仍保留稳定身份。替换映射后的实际目标、运行时按需特化及外部 IR 必须有正确重查路径。
参数槽位/绑定计划:检查时保存已知参数位置,减少 execute 中按名字 find 和构造命名输入表的重复工作;Option 转发、默认值、类型/能力校验不能省略。
共同边界的元数据成本:同时覆盖大量 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 的同义词。
六、下一轮优先级
共享内核先做“带计数的森林结果”与廉价边界元数据:直接面对已量出的 11–13 倍预算遍历和约 4 次边界复制;保持恢复、累计预算、读取顺序和来源。
检查计划准入/总预算:避免一次性私有计划长期强持有;用完整批次与连续编辑验证生命周期,不把提前 Drop 当作减少总工作。
私有模块语法层复用:保留 module/path/root 的语义绑定,明确自定义前端的纯度和共享产物契约;不要只改缓存键。
实例 ID/参数绑定计划:覆盖共同调用描述,并单独验证大量 Native 调用,不能只优化 Code Scope。
发布的受限缓冲区与统一路径计划:保持 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 观察也不能外推所有系统。