BindingId 完整候选的 RSS 增长归因
状态
分阶段分配器、存活归属、单变量副本和结构数量普查均已完成。本次只分析,不修改已验收的生产实现;所有变体都是外部目录中的诊断副本,不自动提交。
基准是完整验收的两份冻结源码与各自二进制,目录/tmp/opencode/notist-binding-identity-complete-20261108-v2。新证据位于/tmp/opencode/notist-binding-rss-20261108-v2,每个探针/变体隔离 target,保留源码清单、二进制哈希、stderr、完整结果和汇总,不覆盖上一轮证据。
先区分几个不同问题
正常构建原八组样本的峰值 RSS 中位数之差是 +8.62 MiB;配对差额中位数为 +8.87 MiB,每对均增加,范围 +7.08~+10.27 MiB。不能只解释为一次采样噪声。
精确 Rust 请求堆峰值增加 4,288,910 字节(4.09 MiB),不等于 RSS 的 +8.62 MiB。
累计普通分配次数减少约 212 万,并不意味着同一时刻的存活对象、分配器取整后的空间或实际驻留页会减少。
CLI 局部析构后的请求堆差额仅 16,369 字节。峰值增量和退出前 allocator 保留空闲页,都不能直接称为新的长期内存泄漏。
探针方法与可解释边界
保留布局的 pages 探针
System allocator 仍按原始 Layout 调用 alloc / alloc_zeroed / realloc / dealloc。额外统计 Rust 活跃请求字节与 malloc_usable_size;阶段边界读取 mallinfo2 以及/proc/self/status 的 RSS、匿名页、文件页和 HWM。before / after 交错执行三对。
这套探针不加分配头,不改变业务对象布局;但新增指令、统计与二进制页仍有扰动,只用来解释阶段与分配器构成,不拿它替换正常构建的 clean RSS 数据。
保留原始请求量的 tags 探针
每个 Rust 分配额外存一个归属头,记录创建它的源码身份、空绑定索引、绑定声明、签名、作用域池、运行时 Scope 等逻辑区域。dealloc 在别的区域发生时仍归属原创建者,realloc也保留原拥有者。按原始 Layout 统计存活请求字节与分配数,不把探针头计入业务请求量。
它改变了实际分配布局,不能用于解释其自身 RSS。它给的是存活请求归属,不是原程序的页映射或完整调用栈。对齐、清零、扩缩容、内容保留和跨区域释放由独立 Rust 测试检查;两种模式均通过。
trim 诊断
另各跑一次 pages 变体,在每个阶段采样后调用 malloc_trim(0),观察哪些驻留空闲页可归还系统。它改变后续页面复用,不是正常构建样本,不把该结果当作优化收益或建议直接在生产阶段强制 trim。
请求堆增长出现在哪里
原独立账本的阶段差额:
阶段 | after − before 请求堆 |
|---|---|
分析/求值结束 | +4,288,558 B |
正文渲染前 | +4,288,558 B |
正文渲染/释放文档后 | +867,238 B |
返回完整输出前 | +867,238 B |
CLI 局部析构后 | +16,369 B |
新增量在输出正文与模板前已存在,并非输出字节增加。文档释放后大部分消失,剩余部分随批次宿主环境等继续保留,最后随 CLI 局部对象释放。
存活归属:不是签名或数字 ID 占掉几 MiB
tags 探针在分析/求值结束时的额外存活请求:
归属 | 额外存活字节 | MiB |
|---|---|---|
每个来源的绑定索引容器 | 2,483,520 | 2.368 |
来源身份本身扩张 | 413,920 | 0.395 |
未按上述区域单独标记的宿主/容器等 | 1,350,557 | 1.288 |
绑定声明与地址索引条目 | 10,642 | 0.010 |
已解析签名 | 9,571 | 0.009 |
成功作用域弱规范化池 | 809 | 0.001 |
检查特化的其余差额 | 11,499 | 0.011 |
Registry 安装的其余差额 | 7,696 | 0.007 |
空 Bindings 快照 | 240 | <0.001 |
该次诊断的总差额与独立账本只相差几十字节,属于探针自身临时输出差异;不声称与正常构建逐字节相同。上述逻辑区域不等同于严格的语义类型,“其余差额”的数量拆分见下节。
最大项来源明确:当时有 51,740 个存活的来源身份,每个都新分配一个Arc<Mutex>,其请求布局为 48 字节,故 51,740 × 48 = 2,483,520。Identity 新增 Arc 字段又使每份请求增加 8 字节,故 51,740 × 8 = 413,920。两者合计 2.763 MiB,占约 4.09 MiB 增量的 67.6%。这是急切分配空容器及其拥有关系的固定成本,不是局部变量数目变多,更不是给每个 ID 分配了一个对象。
另一个最初怀疑项被否定:空 Bindings 默认快照虽然在此前累计分配 155,219 次,合计约 5.92 MiB 请求,但该阶段只剩 6 份、240 字节;多数是短命对象,不能拿累计量当作本轮峰值的主要原因。运行时 Scope 的归属也在该阶段归零。
布局扩张会传播到宿主容器
用两份已构建探针的 rlib 单独编译布局探针,实测:
类型 | before | after |
|---|---|---|
Sources | 32 B | 40 B |
CodeProgram | 72 B | 80 B |
Context | 256 B | 264 B |
Environment | 288 B | 296 B |
ModuleScope | 56 B | 64 B |
Analysis | 360 B | 376 B |
CheckedProgram | 168 B | 176 B |
Evaluation | 184 B | 192 B |
InstanceRecord | 288 B | 312 B |
与 SSG Document 同字段的结构 | 424 B | 432 B |
Source 保持 8 B,TypedExpr 保持 136 B,Invocation 保持 144 B。这些都是本机布局,不是稳定 ABI;实际 Document 的数量与布局由完整构建中的 census 进一步确认。
Sources 多出的绑定拥有快照字段会传播到按值持有它的外层结构。若大 Vec 元素长 8 B,付费的是 capacity × 8,而非仅 len × 8;宿主 Arc 或 BTree 节点也有自己的取整。完整构建的普查给出:51,739 份文档、51,739 份 Environment、3 份缓存依赖图;DocumentVec 的容量为 65,536,实际 Document 为 424 → 432 B,与独立形状探针一致。
每份文档环境装配都会经 Environment::for_document → Context::with_scope 新建文档根 ModuleScope。新增的 pooled 标志虽只有 bool,但让整个 ModuleScope 从 56 B 变成64 B,且未入池的文档根作用域也付费。因此“池里只多 809 B”不能覆盖这部分旁路成本。这里的根作用域数量按普查的环境数量和该一对一装配路径核算,不伪称独立逐对象枚举。
由数量、实测布局和已记录的分配归属可核对如下账目:
来源 | 核算 | 额外请求 |
|---|---|---|
急切来源绑定索引容器 | 51,740 × 48 B | 2,483,520 B |
Identity 新增指针 | 51,740 × 8 B | 413,920 B |
Environment 内 Context 扩张 | 51,739 × 8 B | 413,912 B |
文档根 ModuleScope 的 pooled 布局开销 | 51,739 × 8 B | 413,912 B |
Document Vec 元素扩张 | capacity 65,536 × 8 B | 524,288 B |
上述合计 | 4,249,552 B | |
相对阶段实测 4,288,558 B 的净余量 | 39,006 B |
这些项解释了阶段请求增量的约 99.1%。余量是签名、声明索引、池元数据、检查记录等小项以及旧字符串/容器减少的净效应,不再有一个未解释的 MiB 级“签名内存”。实例记录虽然每份也大 24 B,但本工作负载没有足够多的存活实例使它成为主要项。
分配器如何把请求量放大为 RSS
三对 pages 探针在正文渲染前的中位数差额:
统计口径 | 增量 |
|---|---|
Rust 活跃请求 | +4.090 MiB |
Rust malloc_usable_size 总量 | +6.353 MiB |
glibc arena 内在用块 | +6.241 MiB |
glibc mmap 区域 | +0.500 MiB |
glibc arena 内空闲块 | +0.001 MiB |
匿名 RSS | +6.641 MiB |
文件 RSS | +0.527 MiB |
总 RSS | +7.168 MiB |
请求到 usable 的差额额外扩大约 2.26 MiB,说明块内取整/未收缩余量很重要。这个数包含尺寸类和 realloc 后可能保留的可用空间,探针没有将其逐分配点拆开,不能全部精确归于某一种小块尺寸类。glibc 在用块加mmap 的差额约 6.74 MiB,其中还含 chunk 管理等;不能把它们与 usable 重复相加。此阶段空闲块差额几乎为零,所以“最初全是碎片”也不成立。
正文渲染后,Rust 活跃请求只多 0.827 MiB,但 glibc 空闲块多约 5.01 MiB;RSS 差额仍约 6.65 MiB。释放业务对象不等于这些页立即从 RSS 消失,allocator 保留页会延续高水位。这与前面在用块的尺寸类放大是两个阶段的问题。
CLI 局部析构后,本组探针的 RSS 中位数差额甚至约 49.6 MiB,而 Rust 请求差额仅约16 KiB,glibc arena 空闲量差额约 59.7 MiB。它反映该退出前时刻的 allocator 布局/归还差异,不是“峰值多了 49.6 MiB”,也不是多保留了相同大小的绑定对象;正常 CLI随即退出,由系统释放。不能由此推断长期服务的泄漏或稳定驻留量。
trim 实验显示两版在正文渲染后都有约 1 GiB 量级驻留空闲页可以回收。它解释本站整体高 RSS 的重要组成,但不能把这一大块全算作 BindingId 的退化。trim 后 after 反而可能低于 before,说明页布局与可回收性会改变;不能据此宣称新版本业务对象更少。
正常构建的 +8.62 MiB 与阶段探针的 +7.17 MiB 不来自同一组可执行文件/采样点,不能硬拼成精确的逐字节等式。可靠结论是:确有约 4.09 MiB 业务请求增量,小块取整和 chunk 管理将其放大,后续空闲页驻留使 RSS 不随对象释放同步回落;其余还有文件页、采样与地址布局变化。没有用 RSS 减请求量就全称为“碎片”。
单变量验证与下一步
两个顺序相反的诊断组,各跑原 after-pages、共享空 Bindings、按需创建来源绑定索引、两者组合;以下是相对同批原候选的中位数差额,不是新的 clean 性能验收:
单变量/组合 | 请求堆峰值变化 | 返回输出前 RSS 变化 |
|---|---|---|
共享不可变空 Bindings,写入仍 COW | −80 B | +0.70 MiB |
来源索引改为 OnceLock 按需创建 | −2,069,552 B(−1.974 MiB) | −2.61 MiB |
两者组合 | −2,069,632 B | −2.61 MiB |
第二个实验具有精确的对象成本解释:未触及的索引不再分配 48 B,但 OnceLock<Arc<…>>比原 Arc 字段大 8 B;净效果为 (51,740 − 1) × 48 − 51,740 × 8 = 2,069,552 B,与观测一致。这也说明本批存活来源中实际上只有 1 个需要创建声明索引。它不是把拥有者丢掉或改成永久全局名字池,也没有缩减输出或提前销毁业务对象。
共享空快照只减少 80 B 峰值,RSS 反而略高。它可能减少短命分配,但本轮没有据此量化 CPU 收益,更不能把它当作 RSS 主修复。两样本的 RSS 数字只提供方向证据,不宣称 0.70 或 2.61 MiB 是跨机器稳定常数。
结论与建议顺序
优先考虑来源绑定索引的按需创建:因果证据最直接,避免绝大多数空容器;但本实验仅诊断副本,不代表已经修改或验收生产方案。应保留并发创建、弱键回收、函数拥有者和跨快照相等语义,再重新完整测试与 clean A/B。
其次审视 Sources 增加的字段如何在大量文档/Environment 中传播,以及 pooled 标志给所有未入池 ModuleScope 增加的 padding。避免为省这 8 B 反过来让每个普通作用域析构都争抢全局池锁,也不撤掉并发共享与回收保证。
共享空 Bindings 可作为单独的短命分配实验,不用它代替峰值修复。不为几个 KiB 的ResolvedSignature 放弃内部权威表示,也不回退到字符串查找。
allocator trim 只解释页驻留,不作为这轮内核修复,更不将输出驻留/SSG 生命周期专项混入。正常构建多 8.62 MiB,不等于绑定对象多 8.62 MiB。
本轮 6 次普通阶段探针、2 次 trim、2 次归属探针、8 次消融、2 次数量普查,共 20 次完整站点构建,逐次核对 78,004 项/451,812,631 字节输出,全部一致。正常版本的既有八组样本与精确账本也仍保留。没有修改生产源码、consumer、锁文件或默认预算,没有重启 CPU 采样服务器,没有把诊断耗时当作新的性能结论。
复现工具为 tools/profile_binding_rss.py、tools/binding_rss_probe.rs 与tools/binding_rss_layout_probe.rs;探针和插桩辅助的新增 2 项测试及工具总套件均通过。证据中的 summary.json、results.json、ablation-results.json 和 logs/ 保留原始数据。