整站构建性能与热点评估:9a3c5f7
结论
本次冻结提交为 9a3c5f7bdaeaa8eca31b6160f140a8928704322c。主要问题是百万级求值/内容物化操作携带的短命对象构造、堆分配与销毁,其次是宿主路径比较和重复树遍历。不是最终页面模板渲染太慢,也没有证据支持继续优先扩大模块/数据解析缓存或名称整数化工程。
优先做减少无用构造的小型实验,再处理路径重复工作和遍历栈;不能把采样占比直接当作可获得的加速比例。本轮仅测量和分析,没有改动生产实现,也没有实施优化。
范围、证据与可信度
工作负载:冻结完整站点,51,739 页;产物 78,004 项,共 451,812,631 字节。
操作:CLI
notist build,每轮都重新执行整站构建,不是编辑后的增量构建。从 git archive 导出提交,分别构建普通 release、frame-pointer release、coarse 和 detail 探针版本;各有独立 Cargo target,编译并行度 2。
使用独立输入副本和输出目录,没有改动 consumer,也没有重用其他实验的输出目录。
一次首次发布、五次普通计时、两次 frame-pointer 控制、三次 coarse、两次 detail、两次 CPU 采样、一次分配采样。
共 16 次完整构建,每次均核对全部输出的长度和 SHA-256,全部与冻结基准一致;最终再次核对源码、二进制、输入和输出。
CPU:gperftools,499 Hz,采样 Rust 父进程的用户空间调用栈;两次共 8,235 个样本,全部含目标可执行文件帧;展开 inline frame,同一栈内递归函数只计一次 inclusive。
分配:独立 LD_PRELOAD 诊断包装器调用 glibc 原实现,统计成功分配 API 调用,并概率采集调用栈;不统计扩展子进程。122,419 条分配栈均找到应用帧,记录丢失为 0。
工具回归:tools/test_profile_hotspots.py 的 4 项测试通过;tools/test_profile_*.py 共 67 项通过。
证据目录:/tmp/opencode/notist-hotspots-9a3c5f7-20261109。主要文件:manifest.json、samples.json、phase-summary.json、operation-summary.json、cpu-summary.json、allocation-stacks.attribution.json,以及 logs、sources、bin、reproducer。原始 CPU .prof、展开后的调用栈、分配二进制记录和符号缓存均保留。
构建时间和资源
版本 | 次数 | wall 中位数 | user CPU 中位数 | system CPU 中位数 |
|---|---|---|---|---|
普通 release | 5 | 8.407 s | 7.642 s | 1.084 s |
frame pointer 控制 | 2 | 8.438 s | 7.599 s | 1.037 s |
coarse 阶段探针 | 3 | 8.349 s | 7.577 s | 1.033 s |
detail 细粒度探针 | 2 | 10.642 s | 9.871 s | 1.051 s |
普通五次为 9.965、8.407、8.406、8.391、8.843 秒,全部保留,未事后剔除慢样本。最慢一次有 12 次 major fault;仅据此不能认定波动完全由缺页造成。首次向空输出目录发布为 12.035 秒,不纳入五次中位数;这不是清空操作系统缓存后的严格冷启动实验。其余运行已有产物,发布主要核对内容是否变化,不代表完整首次写盘成本。
普通运行 maxrss 的中位数约 2.70 GiB。这是峰值资源指标,不是内存泄漏证据,也不是存活对象的精确归因。源码上,分析结果和求值树存在整批持有边界,输出字节也集中装配;若要降低峰值,需另做分阶段 RSS/存活分配实验。
frame-pointer 控制中位数比普通约高 0.36%;coarse 略低并不证明探针无开销。detail 比普通约慢 26.6%,仅用于调用次数和路径证据,不用其递归计时给真实构建分配秒数。本轮没有交错测量旧提交,因此不能把与已有报告的绝对时间差解释为提交级性能回归。
过程热点:哪个阶段耗时
以下为 coarse 三次的阶段中位数;比例分母是同组 cli.total 的 8.271 秒,不是普通版本的 8.407 秒。
过程 | wall | 占阶段总时间 |
|---|---|---|
文档求值 | 3.071 s | 37.1% |
正文链接/嵌入处理、HTML 及该阶段回收 | 1.886 s | 22.8% |
文档分析,包含宿主环境装配 | 0.994 s | 12.0% |
资源准备、复制及模板加载 | 0.795 s | 9.6% |
发布 | 0.456 s | 5.5% |
扩展执行与路由阶段 | 0.301 s | 3.6% |
公共模板上下文 | 0.141 s | 1.7% |
最终页面模板渲染阶段 | 0.067 s | 0.8% |
分析与求值约占一半,正文阶段是第二大块。表未列全装配、目录与析构边界;各项中位数不保证严格相加。detail 中 HTML 调用本身约 0.645 秒、registry 选择约 0.124 秒;正文剩余时间还包含遍历、路径处理、查表和销毁,不能全部归因于链接解析。发布中的 unchanged 比较约 0.402 秒,说明“产物未变化”仍然要付出逐项核对成本。扩展进程阶段约 0.205 秒,包含子进程执行与等待,不等于纯 I/O 等待。
操作热点:CPU 实际执行什么
CPU 比例是两次父进程采样的范围,不是包括扩展子进程的 wall 比例。self 仅计最内层执行位置;inclusive 包含被调用工作,各行有重叠,不可相加。
操作/调用路径 | CPU 样本比例 | 解释 |
|---|---|---|
glibc malloc/free/realloc 等自身 | 27.0~28.2% self | 堆管理本身已是显著成本 |
文档求值 run | 37.3~37.4% inclusive | 与阶段计时相互印证 |
present 内容物化 | 约 35.8% inclusive | 含 execute、表达式及构造 |
execute 调用路径 | 26.2~26.5% inclusive | 实参、字段、派发、构造与分配 |
expr 表达式求值 | 13.6~13.7% inclusive | 是上述调用路径的一部分 |
std::path::compare_components | 9.9~10.8% inclusive | 宿主文件路径/索引键比较,不是语言变量名比较 |
HTML render_with_diagnostics | 7.0~7.5% inclusive | 正文阶段的一个子项 |
Node::descendants | 3.6~4.4% inclusive | 含遍历栈分配与增长 |
Dict::insert | 2.9~3.2% inclusive | 包含查找、键处理、容器及分配 |
implementation 查找路径 | 2.9~3.1% inclusive | 包含特化键与缓存子项 |
memcpy/memmove 自身 | 约 2.3% self | 不是全部克隆/复制的总成本 |
实例/编译期值 key | 约 1.5% inclusive | 不应与 implementation 重复相加 |
来源引用校验 | 1.0~1.3% inclusive | 不是首要热点,不应通过省略校验优化 |
路径比较涉及 Vault::ensure_environment、Assets::insert、DocumentIndex::find、ResourceSession::register_source、Files::inspect 和 publication::unchanged。因此不能把这约一成 CPU 都归给正文链接,也不能认为为语言字段引入 Symbol 就能消除它。
频次与分配证据
detail 两次的主要调用次数一致:execute/implementation 各 6,073,286 次,present 8,440,426 次,bind_content 2,213,972 次,data.native 1,955,784 次。present 含递归,不等于不同内容对象数量。真实 data.parse 为 565 次,parse_hit 为 51,173 次;实际模块编译 1 次、bind_cached 3 次。已有数据解析和模块绑定复用确实生效,主要问题在命中这些复用后仍需执行的百万级小操作。
分配 probe 精确统计父进程成功 API 调用:malloc 132,729,790 次、calloc 37 次、realloc 14,726,628 次。这不是同时存活对象数量,也不是每次 realloc 都发生了物理复制。malloc/calloc 以 1/2048、realloc 以 1/256 概率采集栈;以下按逆概率估计,不是逐调用点精确计数:
最近应用分配点 | malloc 次数估计 |
|---|---|
Shared<Vec>::new | 1,672 万 |
Dict::default | 1,070 万 |
Shared<Vec>::new | 1,048 万 |
Dict::insert<&String> | 970 万 |
Text::from(String) | 686 万 |
present | 607 万 |
Shared<BTreeMap<BindingId, Runtime>>::new | 606 万 |
Node::descendants | 464 万 |
Shared::new | 401 万 |
Content::one | 395 万 |
resources::normalize 另有约 280 万次 realloc 的估计。概率样本中的稀有大分配字节方差较大,不据累计字节给优化排序。
问题与优化优先级
第一优先级:高频通路的不必要构造
crates/notist-eval/src/eval.rs:1139:所有 execute 都先 Scope::new,但只有 code 分支使用输入环境。Scope 的共享容器即使为空也要分配。应实验将作用域构造及输入装配收敛到 Code 路径。
crates/notist-eval/src/eval.rs:1253、1375:先 item_shell 克隆字段/实例并构造空 children,紧接着覆盖 content;present 的 Element 路径也先产生空 children 再赋值。应考虑一次构造最终 payload,而非创建后丢弃中间容器。
crates/notist-core/src/shared.rs:434:Dict::insert 先 Into,查不到再转换 Text;借用键会经历不必要的临时字符串构造,更新已有键也先构造字符串。可实验借用查找、仅在插入时创建最终键。
crates/notist-eval/src/eval.rs:171、656:单元素 Content 和递归 present 分别构造 Piece 存储、输出 Vec;宜先移除确定无用的中间对象,再评估是否需要更大范围的表示调整。
证据链是“百万级调用 + 大量对应分配 + 分配器显著 CPU + 源码中的冗余构造”,不是单凭函数名猜测。仍需独立 A/B 才能量化收益;不能声称 28% 分配器 CPU 全部可消除。必须保持实参求值与错误顺序、调用深度、内容/数据预算、来源链以及重复挂载的独立执行语义。
第二优先级:路径构造和有序索引的重复成本
src/resources.rs:483 的 normalize 逐分量增长 PathBuf;路径作为多处 BTreeMap/BTreeSet 的键反复比较。应先找同一输入视图中重复规范化、拼接和查表的调用链,优先自然复用已规范化表示及已有句柄。不能只把路径换成字符串或全局 ID 就宣称解决问题,也不能缓存授权结果、跳过根目录边界或产物内容核对。
第三优先级:重复遍历与遍历栈分配
crates/notist-core/src/item.rs:689 的 descendants 每次 vec![self],然后 extend children。这一通用辅助操作产生约 464 万次 malloc;就算单次很便宜,调用总量也足以形成 CPU 热点。求值结构检查、正文链接与嵌入检查、标题取文等都有树访问。先检查可合并的遍历职责及可复用的工作空间,再考虑容器调整。必须保留遍历顺序、所有来源/链接检查,不能改成可能无界溢出的递归以换掉堆栈。
暂不优先
扩大模块或数据解析缓存:实际未命中工作已经很少。
全面 FieldId/Symbol/特化键重构:特化键约 1.5% CPU,优先级低于容器和路径;不排除后续小型实验。
最终页面模板并行化:阶段占比不足 1%,不能解决主要串行阶段。
无证据地更换 allocator 或并行化求值:可能掩盖对象 churn,且会带来内存、缓存共享和输入视图语义成本。
边界与复现
本轮没有测增量编辑、serve 响应、LSP/Worker 交互、不同内容复杂度或不同规模站点,不能将本次排序外推为所有操作的结论。也没有精确 off-CPU、磁盘系统调用或硬件缓存 miss 分解;不能用 wall − user − system 推算 I/O 时间。
在提交 9a3c5f7 上使用 tools/profile_hotspots.py 的 prepare、build、measure、cpu、allocations、verify,均传入新的 --out;分析使用 tools/analyze_hotspots.py --out。prepare 后将 site 符号链接替换为独立输入副本、排除 dist,再开始构建和测量;本次首轮因此确为向空产物目录发布。依赖的冻结基准、采样库绝对路径和二进制摘要见 manifest.json;工具副本见 reproducer。