跨页面批量求值并行实验
结论
确认批量并行能够加速这个整站工作负载。 两轮实验中,8 线程整站配对 wall 中位数下降 16.63%/17.75%,十组比较全部更快;补测求值阶段缩短 58.92%,约为串行的 2.43 倍速度。4 线程也有收益,但补测存在一次整站明显变慢。2 线程本原型没有整站收益,不适合直接自动启用。
线程创建不是这个粒度上的主要障碍;不需要深复制整个 Vault 或通过进程 IPC 搬运文档。真正需要继续处理的是适配器本身的额外工作、共享资源/缓存访问、跨线程生命周期及内存成本,以及预算与扩展的确定性契约。尚未确定这些成本的各自占比。
保留独立 worktree 和显式环境变量,不合并 main、不默认启用。若继续,先设计不迫使普通串行 Builder 增加 Send 约束的批量接口,并明确并行输入预算和扩展合同;再研究锁粒度和页级对象及时释放,而不是直接把线程数提高到机器的 32 核。
首轮结果
跨页面批量并行有实测收益;本原型值得继续,但不直接合入。 4/8 线程五轮整站配对变化中位数为 -11.83%/-16.63%,五轮全部更快。2 线程虽然缩短求值阶段,整站配对中位数反而慢 2.72%。
模式 | 整站 wall 中位数 | 求值阶段中位数 | 峰值 RSS 中位数 | 整站配对变化 |
|---|---|---|---|---|
独立构建的未修改基线 | 8.698 s | 未加计时 | 2754 MiB | 基准 |
候选原串行路径 | 8.604 s | 2.946 s | 2754 MiB | +0.05% |
适配器单线程,不创建工作线程 | 9.016 s | 3.255 s | 2766 MiB | +3.66% |
2 线程 | 8.926 s | 2.136 s | 2975 MiB | +2.72% |
4 线程 | 7.669 s | 1.461 s | 2925 MiB | -11.83% |
8 线程 | 7.255 s | 1.229 s | 2805 MiB | -16.63% |
配对变化按同轮候选/独立基线的比值计算,不是两组中位数相除。首轮存在明显异常慢样本:独立基线 16.969 s、候选原串行 22.941 s、适配器单线程 14.310 s;对应非自愿切换分别为 1390/1751/608。全部保留,不能精确归因为某一个系统事件。后四轮 4/8 线程也全部比独立基线快。
求值边界之外同样有成本变化。逐次 wall 减本次求值阶段计时后再取中位数,原串行为 5.680 s,2/4/8 线程为 6.798/6.241/6.013 s。这不是纯析构计时,无法由此确定分配器、跨线程释放或缓存效应的各自贡献;它只说明不能用求值阶段的节约直接代替整站收益。
逐次 user + system CPU 后取中位数,原串行为 8.891 s,4/8 线程为 11.012/12.314 s,分别增加约 24%/38%。更低延迟消耗了更多 CPU;RSS 也没有随线程数单调变化,不声称并行能降低内存。
线程创建是否会吞掉收益
tools/thread_scope_cost.rs 单独测量创建并 join 2/4/8 个不做业务工作的 scoped 线程:各先预热 20 次、保留 500 次。完整 scope 中位数为 21.760/34.901/63.361 µs,p95 为 30.711/49.491/97.062 µs。
这是本机当时的空任务上下文,不是实际求值调度的精确开销或跨机器保证。但本实现整段求值只创建一次少量线程,不能把这项成本乘以 51,739 页。整站秒级工作足以摊薄它;适配器单线程不创建线程仍有开销,也说明架构/状态访问/生命周期成本不能归咎于线程创建。
实验没有引入整份 AST、数据 DOM 或输出树的序列化/深复制。原求值中的 clone、分配、引用计数仍在,并行可能增加这些操作的争用。本轮未独立测量锁等待、内存带宽或分配器争用,不能宣称已经确定主要扩展性瓶颈。
直接配对补测
使用同一个候选二进制的原串行路径,分别与 4/8 线程各交错比较五对;不复用首轮样本,不剔除异常值。该控制可以排除独立构建的代码布局差异,但环境仍不是隔离的。
配对实验 | 串行整站中位数 | 并行整站中位数 | 整站配对变化中位数 | 求值阶段配对变化中位数 | 更快的配对 |
|---|---|---|---|---|---|
4 线程 | 7.944 s | 6.682 s | -16.10% | -51.88% | 4/5 |
8 线程 | 7.717 s | 6.398 s | -17.75% | -58.92% | 5/5 |
8 线程组的求值阶段中位数为 2.690 → 1.114 s,峰值 RSS 为 2756 → 2806 MiB,增加约 50 MiB;user/system CPU 中位数分别为 6.923/1.051 → 9.512/1.305 s。该内存代价是当前站点与对象布局的测量结果,不是固定的每线程内存开销。
4 线程补测的配对 wall 变化为 -18.82%、-14.82%、-20.71%、+30.92%、-16.10%。慢值为 11.950 s,求值阶段仍只有 1.335 s,非自愿切换 670;与其配对的串行为 9.128 s。可见阶段外存在明显干扰/额外成本,不能声称每次构建都更快,也不能仅凭 load 低就将这次样本删除。
两轮合计 56 次成功完整构建,每次核对 78,004 项产物、451,812,631 字节、51,739 个 HTML 页面,长度与 SHA-256 全部一致;新建的输出基线也与此前冻结输入的产物清单完全一致。输入与二进制指纹在测量后再次校验。
实现范围
基线为 49a643a8a9b76f5eca6b3a0eca2f5b0ab5c04fd2,已包含 native Construct 直接发射。实验分支 perf/eval-batch-parallel;没有修改或合并主工作目录。
每批 128 页,通过原子批次索引分配给最多 8 个 scoped 工作线程;每个阶段只创建少量线程。
不并行单页内部的调用、表达式或内容挂载。每页保留独立 Evaluator、预算、诊断及实例缓存。
借用已有 Analysis 和 Context,不复制整个 Vault,不序列化输入或输出。
同一 ResourceSession 和 DataCache 保留原有实现,通过一个 Mutex 适配器串行访问资源和缓存;锁不覆盖整页求值。
批次按原页面顺序归并结果。扩展仍走原 pipeline,但不同页面的扩展可能同时执行。
未设置 NOTIST_EVAL_WORKERS 时走原串行路径;设为 1 时使用适配器但不创建工作线程,2/4/8 启用并行。
NOTIST_EVAL_TIMING 输出求值阶段边界计时,包含文档装配和收尾,不是纯 Evaluator::run 时间。
这是可行性原型,不是可直接合并的公共 API 方案。当前 SSG 泛型接口临时增加了 Send 约束;并行阶段保留全部 Analysis 至归并,输出树也先全部收集,可能延长对象生命周期和抬高峰值内存。
正确性边界
全批次仍共用资源请求、输入字节及数据缓存预算,没有把每个工作线程变成独立的预算或缓存副本。不过跨页面请求交错会影响共享预算耗尽时哪一页先失败;命令扩展及具有跨页可观察状态的 native 扩展也不能仅凭 Send + Sync 就视为语义等价。因此默认保持串行,不声称本原型已保留所有扩展/预算边界的确定性。
三项新增回归覆盖:超过八个批次及非整批尾部、冷缓存下共享 JSON 仅解析两次、数据类型错误的来源和诊断、逐页依赖及完整 Evaluation 与串行一致、外部 Vault 句柄拒绝、空输入和逐页内容预算恢复。notist 与 notist-ssg 的 52 个测试套件合计 536 项通过;不是全工作区或全 feature 测试。
测量方法
tools/profile_eval_parallel.py 使用独立的冻结 icexiv-db 输入副本。只在该副本中迁移 package/site manifest 语法,保留文档、数据、模板与站点生成脚本;当前提交重新构建产物基线,不混用旧提交的产物清单。
基线和候选分别使用独立 Cargo target,release、debug=1、incremental=0、jobs=2。构建与回归结束后串行测量;普通基线、候选原串行路径、候选适配器单线程和 2/4/8 线程分别预热,随后五轮轮换/反转顺序。每次构建结束后,在计时之外核对全部产物长度与 SHA-256。记录整站 wall、user/system CPU、maxrss、上下文切换、load 和候选求值阶段边界时间。
已有产物条件下重建,不代表首次完整写出、严格冷缓存、单页编辑、serve 或不同站点。机器仍有其他活动,不是隔离主机。
证据目录:/tmp/opencode/notist-eval-parallel-20261109。基线源码、二进制、迁移前 manifest、输入和输出清单、逐次 stdout/stderr/资源计数与测试日志保留。曾因磁盘不足导致首次 smoke 检查失败;清理的仅为本次实验可重建的编译缓存,失败不纳入正式计时。
summary.json 保留首轮数据,confirmation-summary.json 保留补测;samples.json 和 confirmation-samples.json 保留全部原始记录。candidate-source 和 candidate-source-hashes.json 保存被测运行时代码,报告和工具可继续补充而不改动被测实现。