VM RAM offload¶
架构说明见 VM RAM offload 架构;本文仍作为实现与证据附录保留。
1. Motivation¶
Agent 执行环境经常需要在两次交互之间保留进程状态。停止 VM 可以释放资源,却也丢失了尚未持久化的内存状态;只暂停 vCPU,又会继续占用 RAM。offload 用于这段等待期:保持 VMM 和设备状态,把 RAM 同步到文件,并请求操作系统回收驻留页,下一次交互仍由原 VM 继续执行。
文件 backing 可以保存原始 RAM,但其磁盘成本会随着写入增长。压缩模式进一步利用零页、重复字节和可压缩块减少存储量,以额外的 CPU、读写和暂停时间为代价。设计因此需要同时解决三个问题:运行中的访存如何保持正常语义,何时可以提交稳定的 RAM 数据,以及恢复时怎样读取少量需要的块。
当前范围是同一 VMM 的 offload/resume。CPU、设备、连接和时钟状态仍留在进程中;RAM 文件不能独立重启 VM,也不提供跨节点迁移。
2. 核心设计¶
文件映射作为运行中的 RAM¶
VMM 以 MAP_SHARED 映射文件,vCPU 和 virtio 设备继续通过原有地址访问 RAM。内核处理缺页和写回。offload 同步文件并请求回收页;resume 继续使用同一文件身份。这里没有在每次 offload 时复制一份 RAM,也没有因为改变路径而重新建立宿主映射。
普通模式的逻辑 FD 和物理 FD 指向同一个原始 RAM 文件。压缩模式把两者分开:物理 FD 指向 vm.ram manifest,逻辑 FD 指向 FUSE 的 mount-*/ram。FUSE 读取时解码已提交块,写回时把页存入 staging。
| 对象 | 职责 |
|---|---|
VmControl / RamBacking |
控制交换,持有 FD、选定路径和存储生命周期 |
| VMM / device memory gate | 暂停 CPU,阻止并排空设备访存,回收和恢复映射 |
CompressedMount / RamFs |
将压缩存储适配为可 mmap 的逻辑文件 |
CompressedRam |
管理 manifest、页暂存、dirty mask 和提交 |
SnapshotChain |
编码与校验 generation,解析继承,合并和 GC |
manifest、generation 和 staging¶
vm.ram 在压缩模式下只保存目录和 head,不保存 RAM payload。已提交内容保存在不可变 .pvdelta 中;第一次生成完整 base,后续 generation 只保存变化块并引用 parent。运行中尚未提交的数据保存在内核脏页或 staging。
不可变 generation 避免覆盖仍被读取的旧数据。提交先持久化 generation,再追加并同步 manifest head。这样 head 发布之后引用的数据已经存在。staging 保持可写,适合连续的小页更新;它不承担历史保留。
每个非均匀的 64 KiB 块独立使用 zstd level 1 压缩,并通过 seek table 定位。零块和其他均匀字节块只保存 fill。读取一个块不需要解压整个 RAM,但当前解码粒度仍是完整块。
磁盘目录、逐文件大小、schema 和字节图见文件格式。SVG 全集展开 manifest、generation、原始 RAM、staging 和 pin 的内部结构。
3. 关键数据和核心机制详细设计¶
文件身份与目录¶
/data/
├── vm.ram 压缩模式为 manifest;普通模式为原始 RAM
└── vm.ram.layers/ 仅压缩模式,权限 0700
├── .tmp<staging> 可写原始页
├── <id>.pvdelta 不可变 base 或 delta
├── <id>.pvpin 可选历史保留根,空文件
└── .tmp<capture> 新 generation 写入期间的临时文件
<用户cache>/pvisor/ram/
└── mount-<随机名>/ram FUSE 逻辑 RAM,仅压缩模式
/exports/
└── idle.ram 可选硬链接,指向原 backing inode
显式 backing 文件以 0600 创建,目标必须不存在;未指定路径时使用 dirs::cache_dir()/pvisor/ram 下的临时文件。压缩 sidecar 位于显式路径追加 .layers 的目录,或 cache 下的临时 layers-* 目录。
offload(Some(path)) 使用硬链接发布原 backing。host 验证源路径的 dev/ino 与所持 FD 一致,拒绝覆盖和跨文件系统目标,再更新 selected path。runner FD 和 mmap 保持不变。压缩 alias 只链接 manifest,descriptor 继续引用原 sidecar;不会创建与新 alias 同名的 layers 目录。
临时 compressed backing 发布后,原 layers 目录通过 keep() 转为持久保留。原临时 manifest 名称在退出时删除,alias 继续存在。Run Bundle、OverlayFS upper 和 OCI cache 不属于这组 RAM 文件。
存储捕获与恢复合同¶
宿主存储层的 SnapshotChain::capture 接收 RamLayout、可选父链、dirty block 集合及块读取函数。dirty 集合必须包含所有变化块,包括 vCPU 和设备写入;读取函数必须始终观察同一个稳定 epoch。存储层不会自行暂停 VM,遗漏 dirty 块会继承旧内容。父链必须属于同一目录且布局完全一致;布局变化需要以 parent=None 创建新 base。base 和合并轮次读取全部块,不能只提供 dirty 块内容。
SnapshotChain::restore_to 设置目标文件长度,按紧凑逻辑 offset 写入全部 RAM 块并同步文件。调用方必须在整个恢复期间静止所有目标访问者;失败可能留下已改长度或部分写入的目标,不提供事务回滚。它只恢复 RAM 字节,不启动 VM,也不恢复 CPU/设备状态。多 region 布局由直接存储层支持;当前 FUSE 适配器使用从 0 开始的逻辑文件 region,不能据此重建真实 Guest 物理布局。
页暂存与块读取¶
staging 是稀疏原始文件,byte offset 与紧凑逻辑 RAM offset 相同。每个 64 KiB 块用一个 u16 标记其中 16 个 4 KiB 页是否有效。第一次部分覆盖某页时,writer 从旧 chain 补齐其余字节;完整页覆盖直接写入。
读取先取得 committed block,再覆盖 staging 中的有效页;完整 staged block 可以跳过祖先读取。未写的初始 RAM 返回零。dirty mask 只存在内存中,因此 staging 文件本身不足以重建未提交 epoch。
4 KiB 暂存页、64 KiB 压缩块与主机页是三个不同粒度。VMM 按主机页对齐 file offset,mincore 也以主机页采样;Apple Silicon 主机页常见为 16 KiB。设备匿名共享窗口不进入 RAM backing。
从运行到 offload,再恢复¶
启动时,executor 创建 backing 和可选 FUSE mount,安装初始 guest lower 排除规则,复制 RAM FD 给内部 runner。VMM 设置文件长度、建立共享映射;host Connection 持有 backing,runner 持有 VMM 和控制线程。
普通 pause 只停止 vCPU,不关闭设备 gate,也不提交 generation。要取得稳定 RAM epoch,offload 必须继续排空设备访问和内核写回。
| 阶段 | 实际动作 |
|---|---|
| 路径准备 | host 可选创建 alias;发生在控制请求发送前 |
| CPU 静默 | runner 持 transition 锁,暂停所有 vCPU |
| 设备静默 | 释放 VMM mutex 后关闭、排空 memory gate,避免等待 I/O 时阻塞 VMM worker |
| 映射回收 | macOS 解除 HVF RAM 映射;对宿主 file-backed RAM 同步并请求回收 |
| runner 回复 | 返回 Offloaded 和驻留采样;CPU、gate 仍保持暂停/关闭 |
| host 完成 | 同步逻辑 FD,提交 compressed store,等待 worker 并校验 outcome |
| resume | macOS 恢复 HVF 映射,打开 gate,再恢复 vCPU;缺页按需读取 backing |
macOS 使用 msync(MS_SYNC | MS_INVALIDATE) 和 madvise(MADV_DONTNEED);Linux 使用 MS_SYNC 并额外请求 posix_fadvise(DONTNEED)。宿主 MAP_SHARED 地址仍保留。vCPU 转换共享 3 秒截止预算,设备排空为 5 秒;host offload 交换预算 300 秒,pause/resume 为 10 秒。
runner ack 后 host 仍有存储工作,所以调用者应以完整 operation outcome 为完成点。控制 stream 的 frame 是 4 字节大端长度加 JSON,最大 16 KiB。调用者丢弃 future 不会让交换任务放弃读 ack,以免后续请求读到旧回复。
状态、事件与内存报告¶
宿主 RunHandle / RunControlHandle 的 pause、resume、offload 操作使用 Core 的 OperationKind,结果为 Value::Vm。pause/offload 成功将外层 RunState 设为 Suspended,resume 成功设为 Running;结果中的 VmState::{Running,Paused,Offloaded} 细分实际 VM 控制状态。Suspended 本身不证明 RAM 已 offload,也不同于持久 checkpoint/suspend 流程。底层接口为 pvisor_vm::api::VmControl,由 VmmHandle 实现,offload 返回 RamReclaim。
控制转换串行执行,记录 vm.control_requested,再记录 vm.control_completed 或 vm.control_failed。请求事件发布失败时不会执行控制。控制成功后先更新 Run 状态,再发布完成事件;若此时事件写入失败,调用返回 VM control completed but observation failed 错误,已完成的控制和状态不回滚。调用方不能把这个错误解释成 VM 未执行操作。
Core 的 VmMemory 字段含义如下:
| 字段 | 含义 |
|---|---|
backing_file |
宿主选定的 live backing 绝对路径;host 用自身持有的路径替换 runner 报告值 |
backed_bytes |
本次回收覆盖的 file-backed RAM 范围,包含独立 kernel 区域,排除 DAX/GPU 共享窗口 |
resident_before_bytes |
回收前按宿主页采样的 mincore 驻留字节;无法查询时为 null |
resident_after_bytes |
runner 回收后的同类采样;无法查询时为 null |
mincore 在 Linux 包含文件页缓存,不能等同于进程 RSS。采样没有驻留归零承诺,后续访问可以重新带入页面;host 压缩提交还发生在 runner 采样之后。
提交与文件校验¶
manifest 长度为 44 + N + 80k:8 字节 magic、4 字节 descriptor 长度、N 字节 JSON、32 字节校验,再加 k 条 80 字节 head。generation 由 Zstd frames、seek table、metadata JSON 和 64 字节 footer 构成。
ID 是 metadata 原始 JSON 的 SHA-256;metadata 中还有各解码块的 checksum。读端同时检查格式、继承关系和块内容。JSON 中的 ID 是 32 个整数的数组,文件名使用 64 位十六进制表示。完整字段与 schema 见格式定义。
| 顺序 | 动作 |
|---|---|
| 1 | 逻辑 FD sync_all,将内核脏页写回 staging |
| 2 | 从 staging 和 parent 合成块,写临时 generation |
| 3 | generation fsync,no-clobber 发布,layers 目录 fsync |
| 4 | 追加 manifest head,再 fsync manifest |
| 5 | 更新内存 chain,清 dirty mask,truncate staging |
| 6 | 解析 current 与 pins 的祖先,删除不可达 generation |
FUSE fsync 只执行 flush_writes,不会发布 head;稳定 epoch 的 generation commit 由 offload 协调。已提交 head 之后的 GC 失败记录警告,不撤销提交。generation 或 head 写入失败会使 store 标为 failed,不能把它当可继续重试的正常 writer。
增量继承、合并与 GC¶
resolved 索引记录每个块最后的覆盖来源。dirty 块最终 checksum 与 parent 相同时可以省略;无变化 delta 可以复用 head。
parent depth 达到 8 后,下一次 capture 生成完整 base,parent=null。合并需要读取全部逻辑 RAM、解压旧块并重新编码,发生在 VM 暂停的 offload 路径中。这一轮的成本接近全量保存,即使最近修改很少。
GC 保留 current head 及所有 .pvpin 所指 head 的祖先。旧 manifest 记录不是保留根;合并后的旧链若未被 pin,可以删除。标准 offload 不自动 pin,也不提供从旧 head 重建 VM 的接口。
SnapshotChain::pin/unpin/capture/collect_unreachable 必须由目录 owner 串行调用;这些操作没有进程间锁。GC 要求 owner 独占目录,并先发布、fsync 当前 head;删除任何文件前先解析全部 pin 根及祖先,pin 名称非法或链缺失/损坏就停止回收。直接 capture 不隐式删除历史层,未 pin 的历史 ID 没有持久保留承诺。pin 和回收也不提供容量配额。
参考设计与取舍¶
- Zstd Seekable:采用独立 frame 和尾部 seek table;不可压缩块也使用 Zstd frame,不另设 RAW payload。
- QEMU mapped-ram:参考 RAMBlock、稳定逻辑位置和写入 bitmap,不复制固定磁盘物理位置。
- QEMU fast snapshot load:缺页和后台恢复需要协调页面归属;FUSE backing 不实现该 pager。
- Firecracker memory:参考 region 布局与 vCPU/用户态写入都参与增量追踪的边界。
- zram / zswap:参考零/同值省略和压缩对象组织,不复制内核分配器。
压缩对象紧凑连续排列,可以跨磁盘页;没有 storage-page 分配位图、可变 extent、hole punching 或在线碎片整理,不为每个小对象额外分配 4 KiB extent。PVZRAM v2 已替换旧可变块容器,当前 reader 不读取 v1 文件。
退出与失败¶
正常退出等待 child,取消或截止先终止进程树,再 detach control。backing 释放时尝试 unmount,删除 staging 和临时 mount 目录。未发布的临时 backing/layers 清理;显式文件、alias 和已 keep 的 layers 保留。
退出没有额外 generation commit。最后一次 offload 后 resume 所产生的新写入可能随 staging 清理而丢弃;持久 manifest 只描述最近一次 committed head。检查应在 writer 停止后通过只读 CompressedRam::open 进行。
已知问题集中在路径、状态和清理时序:新 alias 还没有持续安装初始 backing 的 guest 可见性约束;Offloaded 后 pause 可以报告 Paused,但资源仍保持 offloaded 条件;300 秒 Tokio 超时不能停止 blocking commit,worker 完成与卸载/删除之间还需明确同步。manifest 撕裂尾部当前直接报错。64 GiB layout 许可与 64 MiB JSON index 预算也尚未完全协调。
源码位置¶
| 文件 | 内容 |
|---|---|
| control.rs | backing 所有权、路径发布、控制交换和 host 提交 |
| supported.rs | 启动、FD 传递、guest 排除、runner 控制线程 |
| ram_file.rs | FUSE 文件与回调 |
| ram_backing.rs | manifest、staging、dirty mask、提交 |
| image.rs | generation 编码、继承、pin、合并和 GC |
| VMM ram.rs | RAM 映射、同步、回收与驻留采样 |
| libkrun / memory gate | CPU 与设备静默顺序 |
4. 实验数据支撑¶
下面的数据来自 2026-10-03 的定向测试和对已有磁盘产物的检查,基线为 e5359307 的产品路径及 1b97a9ef 的文档 helper。它们不覆盖随后出现的未提交冷页池实验代码。
| 检查范围 | 结果 | 说明 |
|---|---|---|
| backing、控制协议、RAM 存储与 Core 合同 | 31 通过 | 包含随机部分写、重复提交/合并、损坏拒绝、超时与 ack 校验 |
| device memory gate | 4 通过 | Reader/Writer 凭证、排空、重新开放和多 VM 独立性 |
| 宿主 RAM 映射/回收 | 2 通过 | 回收后共享数据保持、设备窗口排除、无效 backing 拒绝 |
| offload 拒绝后的路径副作用 | 1 通过 | 复现先发布的硬链接在转换失败后仍存在 |
这 38 项检查支持存储与控制层的若干不变量,尚不能证明真实 guest 的端到端组合。六个 VM 文档用例在准备 socket 时遇到 EPERM;直接 SDK 驱动也在 AgentCtl 初始化时受阻,未进入 guest。记录保存在 review_project/06-evidence/offload-20261003/,不能把这些阻断计为 VM 通过。
已有 256 MiB compressed RAM 样本中,manifest 逻辑长度 999 B,base 约 21.45 MiB,delta 约 1.85 MiB;两代文件实际分配总量为 23.30 MiB,另需计 manifest 和目录。样本有 10 条 head 记录,GC 后只保留一个 base 和一个 delta。逐字段大小核对给出了原始整数与文件位置。
这组数据说明格式与目录大小可以按公式核对,也展示了一次具体工作负载的存储结果。它没有测量物理内存节约、首次缺页恢复延迟、合并暂停时间或多 VM 密度。目前也没有足够数据判断 level 1 或固定 8 层合并是否最合适。
resident_before_bytes / resident_after_bytes 是 mincore 对 file-backed 映射的采样;backed_bytes 是覆盖范围。host 压缩提交发生在 runner 采样之后,还会读取和分配内存,因此这些字段不能代替 RSS、physical footprint 或完整 offload 后的系统内存测量。
5. 使用建议¶
需要在同一节点保留 VM、等待一段时间再继续执行时,可以考虑 offload。若只需短暂停止 CPU,pause 更直接。当前文件不适合承担关机后恢复、跨节点迁移或最终退出状态归档。
先用普通 backing 验证控制与恢复,再评估压缩模式。压缩要求 Linux FUSE 或 macFUSE kernel backend;backing 和 alias 应放在 guest 不可见的宿主私有目录,同一文件系统,并保留 manifest 所引用的 sidecar。调用者应等待 operation 成功后再使用发布路径。
测量时分别记录普通 delta 轮次和合并轮次的暂停时间、CPU、磁盘峰值及恢复延迟。合并会同时占用旧链和新 base,pin 会进一步延长历史保留;大 RAM、不可压缩数据和频繁重写尤其需要测量。现阶段不应把较小的样本文件当作容量规划依据。
需要保留最后一轮 RAM 数据时,显式完成 offload,再结束 writer;不要依赖正常退出自动提交。跨进程只读检查应避开活动 writer,目录清理应按整个 backing 的所有权处理,不能独立删除 parent generation。
与实验冷页池的关系¶
未提交工作树中的 PVISOR_EXPERIMENTAL_MEMORY_POOL 路径使用 Unix socket 和内存压缩对象池恢复部分冷块,与磁盘 generation 的 whole-VM offload 不同。当前接入拒绝与 FUSE compression 同开,也拒绝在实验 preparation/pager 开启时做 whole-VM offload。inventory JSON 仅用于诊断。这些实验不在上述测试与使用建议的验证范围内。其当前机制和实验结果见内存去重与冷页压缩。