一个已经完成 Python 初始化、采用 COW 的 Guest,为什么执行几行代码仍会增加不少内存?这轮实验围绕这个问题逐层检查:先看 wazero 为每个实例分配了什么,再看 Python 写入了哪些共享页,最后比较分配器布局和系统压缩。
最终标准库候选在 100→500 个驻留 Guest 的验证中,每个新增 Guest 的 PSS 约为空闲 597 KiB、5000 行 JSON 2497 KiB、分页工具处理 930 KiB。JSON 在同一完整构建对照中下降约 40%。
本文是 Pysolate 独立实验的历程,优化候选尚未合并到 Spine 或 Pysolate 主分支。Spine 的 Linux COW 讲解 解释基础执行路径;这里记录额外实验及取舍,不把实验分支当作网站当前源码已经提供的功能。
1. 先固定问题:一个新程序究竟增加多少内存?
编译代码、准备 Python 映像会产生固定成本。多实例平台更关心再接纳一个活 Guest 的成本,因此主指标使用端点斜率:
每个新增 Guest 的 PSS = (PSS(N_high) − PSS(N_low)) / (N_high − N_low)
每个端点在独立进程中测量,轮换执行次序;准备阶段统一回收 Host 的临时初始化分配,所有 Guest 完成指定工作后停在 Host hold barrier。主结果在驻留阶段的额外强制 Go GC 之前读取。另取强制回收后的数据做诊断,不用它替代主结果。
测量环境为本机 Linux arm64 VM,2 vCPU、2 GiB、4 KiB OS 页。PSS 包含 Guest、引擎和 Go Host 的进程内驻留份额,共享页按份额计算;内核页表另计,没有完整 memory cgroup 计费。因此它不是生产最大并发数或延迟 SLO。
工作负载均是真实 CPython-Wasm 执行,但属于合成探针:
- idle:最小程序进入等待,并非一个完全未执行的实例。
- echo:完成 100 次短 Host 调用后等待。
- JSON:5000 个字典与嵌套列表,执行 dumps→loads,持有结果并验证校验和。
- agent-stream:经实际 Host JSON 通路读取 8 页,每页 256 条,筛选汇总并保留最后一页。
- agent-retain:相同分页调用,保留全部数据。
无压缩实验要求采样时没有 swap;压缩实验单独报告。不同构建、不同轮次的数据不拼成一条精确累计收益曲线。
2. 第一阶段:linear memory 共享了,Host 元数据没有
最初先怀疑 CPython 引用计数。但检查发现,在执行 Python 之前,实例化本身就会重复分配大量 Host 数据。
一个活 Guest 的增量
├─ wazero 实例状态:data segment 描述、函数引用、table、执行栈
├─ Python linear memory:COW 私有页 + 新增长区
├─ Go Host:goroutine、请求状态、工具编码与临时对象
└─ 内核:页表等(不包含在 PSS 中)
去掉没有用途的 data segment 描述
当时 artifact 有 10,002 个 data segments:前两个是空 passive,其余 10,000 个是 active。原 COW 转换清空了 active payload,却留下描述;每个实例仍分配约 240 KiB 的 [][]byte。
实验改为按指令边界检查引用,只删除没有被 memory.init/data.drop 引用的尾部 active 描述,保留 passive 索引并更新 DataCount。原始数据仍按顺序写入 seed,重叠覆盖和跨实例 passive drop 隔离保持原行为。
函数引用从分散分配到共享
每个实例还有 12,476 个本地函数引用。每个记录 24 字节,另有容量 14,336 的 keepalive 指针 slice,仅这个 slice 就是 114,688 字节。
先改成地址稳定、有类型的固定块,去掉逐对象 keepalive 指针;随后调整块大小以减少 Go 分配级别取整。更大的收益来自检查模块是否可以共享静态函数表:
- 只有一个非导入、非导出的 funcref table,采用常量、界内的本地函数初始化。
- 拒绝会读取、修改、增长或暴露这些引用的操作;未知指令族回退。
- table 和函数描述由 compiledModule 持有,间接调用使用当前实例 context。
- 保留 null、越界、签名及跨 Runtime 类型身份检查。
这个有明确准入条件的 prototype 消除了约 398 KiB/Guest 的存活 Go heap。它不适用于任意 Wasm 模块;启用它需要维护引擎改动和语义回退。
这一阶段的结果
早期各轮汇总中,idle PSS 从约 1519 降至 580 KiB/Guest,JSON 从约 4468 降至 3668 KiB/Guest。这是跨轮概览;最近一步的独立配对是 slab255→static table:idle 995.82→579.64、JSON 4050.18→3667.91 KiB/Guest。
旧候选另做过 100/500/1000 实例验证,idle 约570、echo约619 KiB/Guest,18个单元均无swap。这个1000实例结果属于早期候选,不能冒充后面的最终 allocator 组合也测到了1000。
还有两项不同性质的改动:延后查找 release 导出,避免等待期间额外保留调用栈,约省11 KiB存活heap;sealed image 跳过全零页,将共享文件实际分配从约151.41降至49.34 MiB。后者降低固定成本,不算每Guest增量。
3. 引用计数会写页,但它还不是完整解释
引擎冗余削减后,轻脚本仍会让约122–128个4 KiB页私有化,与准备映像相比只有约19–21 KiB字节不同。小范围写入可以触发整页COW,但这些页混合了对象、allocator元数据和执行状态,不能全部归到引用计数。
独立 JSON 对象统计显示,最终去重可达对象约 1.108 MiB;没有保留两份完整结果,也没有退化为纯 Python JSON 解码。对象大小与物理驻留差额包含分配开销、空闲空间和页放大,不能直接全部当作可删除冗余。
这把下一轮问题收窄为:请求分配是否正在复用初始化堆,顺带写脏原本可以共享的页?
4. INIT/EXEC 分堆:先遇到回退,再找到大页放大
原型在同一个 linear memory 内使用两套 wasi-libc dlmalloc。初始化使用原堆;初始化后首次导出分配切换到 EXEC,新分配进入第二个堆,旧指针的释放按归属处理,旧 realloc 迁移时保留内容,分配失败不丢失旧对象。
Prepared 初始化
↓
INIT 堆:Python 基线、已导入模块、原 allocator 状态
│ snapshot / COW
↓
EXEC 堆:请求的新 libc 分配
↓
Guest 关闭:两个区域随实例一起释放
这是按分配阶段分开,不是预测对象寿命。初始化对象仍可被修改,freelist也可能复用旧对象;没有增加第二个Wasm memory。
第一版分堆的 idle 反而增大。停止进程检查 smaps,发现新增长区实际用了 2 MiB透明大页(THP)。单Guest诊断中,idle新区域驻留2048 KiB,JSON为4096 KiB;仅对Guest映射使用 MADV_NOHUGEPAGE 后,分别为40和1916 KiB。系统全局THP策略未修改。
随后固定两侧都使用NOHUGEPAGE,再比较原堆与分堆。48个测量单元、每条件3次重复,n=10/100:
| 负载 | 原堆 | INIT/EXEC分堆 | 单位 |
|---|---|---|---|
| idle | 888.40 | 633.73 | KiB/Guest |
| JSON | 3947.91 | 3126.62 | KiB/Guest |
不能只看默认THP条件下的端点差:大页分配在两个端点并不稳定,会污染斜率。这里的机制判断同时依赖映射诊断和受控对照;没有测量禁用THP的CPU/TLB代价。
请求堆的字节账本
对当时 nohuge split 的 JSON 新堆解析 dlmalloc chunk 链,覆盖了1,961,984 B私有驻留:
| 分类 | 字节 |
|---|---|
| chunk headers | 315,616 |
| live payload与对齐余量 | 1,372,736 |
| free payload | 273,568 |
| 起始对齐与top foot | 64 |
其中一个逻辑约1 MiB的桥接buffer,大部分页面没有触碰。不能看到 malloc(1 MiB) 就认定每个Guest都能省1 MiB物理内存。该账本对应这一版分堆,不能直接套到最终pymalloc候选。
5. 保留旧堆元数据,修正容量损失
EXEC不再从旧libc堆分配,旧free却仍更新空闲链、合并块,让初始化页发生COW。对于当前单次使用的Guest,这些操作不会给新堆提供可用空间。
下一版保留INIT libc块,直到整个Guest关闭才随映射释放;Python引用计数、析构和GC仍正常运行。它只省去旧libc块的空闲记账,不是让所有Python对象不释放。
同轮匹配对照中,分堆→旧堆保留的JSON为 3121.64→2919.24 KiB/Guest,agent-stream为 1392.22→1175.96 KiB/Guest。
分堆也暴露了容量问题:新堆最初从128 MiB初始memory末端增长,旧堆未用尾部无法再利用。最大内存仍为512 MiB时,4 MiB bytearray逐块分配探针从472 MiB payload降至380 MiB。将标准库Guest初始memory调为32 MiB后,恢复到 472 MiB。这是修复逻辑容量损失,不是再次减少相同数量的物理内存;NumPy等更大初始化产物需另测。
6. 完整启用 pymalloc,比另造小对象分配器简单
核查实际WASI构建后确认,这批Guest没有启用pymalloc,小对象经libc malloc分配。前面的chunk账本已经显示大量header与padding,下一步直接完整重编译同一CPython 3.14.0,比较 --without-pymalloc 和 --with-pymalloc。
不能只修改 pyconfig.h 或替换某个对象文件:这可能改变解释器结构布局。四种产物采用相同SDK、32 MiB初始memory、512 MiB上限及Host路径,72个测量单元通过,n=10/100、每条件3次,无swap。
| 匹配构建 | idle | JSON | agent-stream |
|---|---|---|---|
| control,无pymalloc | 883.02 | 4197.47 | 1569.56 |
| control,有pymalloc | 682.53 | 3788.36 | 1146.93 |
| 旧堆保留,无pymalloc | 628.13 | 2901.24 | 1150.71 |
| 旧堆保留,有pymalloc | 616.36 | 2503.64 | 992.00 |
单位均为KiB/Guest。在旧堆保留基础上,pymalloc让JSON再降 13.7%;完整组合相对本表control/no-pymalloc下降 40.4%。本表沿用前期引擎优化,40.4%不是从最初版本算起的全部累计收益。
pymalloc使JSON匿名映射分量约减少655 KiB,COW映像分量却约增加242 KiB。它降低小块开销,也复用了INIT已有pool。独立allocator诊断中,arena从9增至12(每个256 KiB),活跃block增加1,177,664 B,空pool从101降到14。新对象确实消费了旧空间,但这个统计没有每个arena地址,还不能精确区分每页的refcount、GC与pool写入。
7. 没有保留的优化,也需要记录
| 尝试 | 观察 | 决定 |
|---|---|---|
| 固定桥接scratch buffer | idle略降,JSON约2909 KiB几乎不变,其他负载收益不稳定;静态区也改变内存布局 | 不作默认 |
| 快照前全量GC | idle约612→587,但JSON约2531→3178,保留分页数据约1274→1937 KiB/Guest | 拒绝 |
| 用户代码执行后全量GC | JSON诊断中反而增加旧页写入 | 不作为省内存手段 |
| 扫描并归还与映像相同的页 | 早期诊断有可回收量,但属于通用页回收,还需计扫描与恢复成本 | 未加入主结果 |
初始化GC可能改变freelist、pool可用性和GC状态,目前没有分离它们的贡献。观察到JSON回退已经足以决定不采用,不需要先给每个字节编造解释。也没有通过禁用GC、广泛immortal化或删除用户保留结果来换取数字。
8. 最新候选与 native prefork、压缩的关系
之前“JSON压缩后仍比native大”的比较对应旧产物。启用pymalloc后,用最新候选重做同负载配对:n=10/100,每条件3次,24个单元;两侧都测暖态,以及暂停后通过系统LZ4 zram回收私有页的冷态。
| 后端 | 暖态增量 | 冷态增量 |
|---|---|---|
| native prewarmed fork | 3854.04 | 1126.60 |
| 最新Pysolate候选 | 2502.18 | 999.51 |
单位KiB/Guest。冷态计 PSS + zram实际物理占用增量,不再把逻辑Swap重复相加。恢复后再次核对计算结果。
最新Wasm在这项JSON探针中暖态边际低约35%、冷态低约11%。native的压缩比例更好,但压缩前需要保存的字节更多。N=100时冷态总量Wasm仍略高(约135732对126766 KiB),固定成本没有消失。
native为CPython 3.14.7,Wasm为3.14.0;native是受信程序的fork参照,没有对齐完整对抗隔离。这里不能推出Wasm普遍优于native,更没有CPU优势结论。强制冷却也不代表生产自动回收策略;压缩是双方均可采用的Linux能力,不计作本项目独有优化。
9. 收尾:扩大到500 Guest,还有没有明显回退?
最终保留组合是:引擎元数据优化、局部NOHUGEPAGE、INIT/EXEC libc分堆与旧堆保留、显式pymalloc、32 MiB初始memory。最后只扩规模,不再改变机制。
100/500个驻留Guest,每条件2次独立进程,第二轮反转数量顺序,12/12单元通过,所有快照Swap=0。单位KiB/Guest,取两次斜率的中位数:
| 负载 | 第一次斜率 | 第二次斜率 | 中位数 | 500 Guest总PSS(MiB,中位数) |
|---|---|---|---|---|
| idle | 597.04 | 596.03 | 596.54 | 341.60 |
| JSON | 2494.38 | 2499.36 | 2496.87 | 1268.72 |
| agent-stream | 937.30 | 922.95 | 930.13 | 511.81 |
页表增量另约30.09、32.66、29.70 KiB/Guest。最终候选的容量探针仍达到118×4 MiB=472 MiB payload,捕获MemoryError后还能分配1 KiB。真实Guest测试覆盖allocator边界、fresh/Prepared隔离、工具调用、weakref、析构和GC;这些测试不等于任意Python包和长期生命周期验证。
剩余成本已有明确类别,但还没有逐字节归因完成。继续分离pymalloc旧/新状态,需要处理arena所属、旧对象free/realloc、freelist与OOM。此时没有发现另一个简单、稳定且收益明确的改动,因而结束本轮,不将“仍可能省”视为无限扩建理由。
10. 对后续工具数据通路的启发
内存优化不只发生在allocator。如果程序只负责把下载结果交给另一个工具,先把整份数据实体化成Python对象可能没有必要。后续已记录三个候选,尚未实现:
- 保留JSON控制消息,为二进制数据增加Guest-owned buffer与Python memoryview路径,先接受一次明确复制。
- Guest A经Host调用Guest B时,避免中间重复解码、编码;两个独立linear memory不能直接互用指针。
- 对纯转交数据使用Host-owned句柄,真正计算时才读取;Host持有的字节必须计入总内存、配额与生命周期。
Go工具已经进程内执行,单纯“移到Go”不会自动消除序列化。后续应区分必须遍历数据的计算与只转交数据的编排,测完整数据路径,不能把占用从Guest移到Host后就宣布节省。
证据身份与发布范围
本页摘录已有实验记录,没有在文档更新时重跑或改写原始数据。不同阶段的源记录分别为引擎六轮报告、分堆48单元、后续三轮210单元、pymalloc72单元、初始化GC60单元、最新压缩24单元和最终密度12单元;诊断与容量探针另计,不把这些单元称为独立业务任务。
最终Guest SHA256:
e19ab5d2f9c2598d70caf0d386decb69d8d3128e03a4990c4947ca5aba5f9101
最终密度原始记录SHA256:
77277bb5e61b631a37f5689580e95fe202d6b8fcfc92f09cf389e26865547aa5
复现材料已整理进仓库的 experiments/memory:实验 runtime 与 wazero 源码快照、补丁、10 个 Guest 变体、工作负载和历轮记录。make bench-analyze 离线复算四种 allocator 配置与最终密度的 84 个历史单元;Linux 上 make bench-full 采集当前候选的新结果。目录 README 说明了早期版本的归档范围与可重建范围。
文档站只展示讲解与数值,源码和二进制从仓库取得。实验使用独立 Go module,根目录 Spine 仍保留教学实现;生产集成需另行处理引擎维护、构建配置与受支持包集合。