[snapshot] CUDA / CRIU:内存里是什么、restore 为何进命名空间
对照 ai-dynamo/snapshot 与 NVIDIA cuda-checkpoint。介绍见 [snapshot]0-snapshot介绍;内部逻辑见 [snapshot]1-snapshot内部逻辑;CUDA API 见 [snapshot]4-cuda-checkpoint分析;CRIU 编排见 [snapshot]6-CRIU逻辑。
本文只回答五件事:capture 把哪些 GPU 状态放到哪、CRIU dump / restore 各干什么、CUDA restore 又装回哪些东西、为什么 restore 要进「另一套」namespace,以及容器里看到的 PID 和最内层 PID 为什么不是同一个数。
0. 先把对象拆开
一个 GPU 业务进程同时占着两半资源,两半不能用同一种工具拍:
业务进程 = CPU / 内核资源 + GPU 上的 CUDA 状态
(虚拟内存、线程、fd、 (context、显存、stream…)
socket、mount…)
↑ ↑
CRIU 能拍 CRIU 拍不了
由驱动 cuCheckpoint* 拍
顺序是固定的,反了会失败:
capture: lock → CUDA checkpoint → CRIU dump
restore: CRIU restore → CUDA restore → unlock
「放到内存里」这句话只对 CUDA checkpoint 成立,而且是放到 这个进程自己的 host 虚拟内存,不是放到 GPU 上,也不是单独写一份「GPU 镜像文件」。CRIU dump 再把这份 host 内存(连同普通堆栈)写成磁盘上的 pages-*.img。
1. CUDA checkpoint(capture):是不是把 GPU 信息放入内存?有哪些?
是。 cuCheckpointProcessCheckpoint 做的事可以记成:把这个 PID 在 GPU 上的可恢复状态,搬进 该进程的 CPU 地址空间,然后把 GPU 侧占用拆掉,让 /proc/{pid}/maps 变成 CRIU 能处理的形态。
官方流程说明:Checkpointing CUDA Applications with CRIU。Snapshot 实际调用的是 helper 包的 cuCheckpointProcessLock / Checkpoint(见 agent/cmd/cuda-checkpoint-helper)。
1.1 搬进 host 内存的是什么
驱动没有公开逐字段清单,但从 API 语义和 restore 必须重建的对象,可以确定至少包括:
| 类别 | 含义 | capture 之后 |
|---|---|---|
| CUDA context | 该进程在每张可见 GPU 上的驱动上下文 | 从 GPU 摘下,元数据留在 host |
| device memory | cuMemAlloc / 运行时分配的显存内容(权重、KV、中间结果等) |
拷到 host 缓冲区,GPU 上那份释放 |
| 执行对象 | stream、event、module、部分 graph 等与 context 绑定的对象 | 序列化进同一份进程内状态,restore 时重建 |
| 分配布局 | 各块显存原来的设备地址 / 句柄关系 | 留下,restore 尽量按原地址装回(换卡时用 gpuPairs) |
不会作为「GPU 信息」塞进进程内存的:
- GPU 的 UUID / 设备节点(那是节点和容器能看见哪些卡,写在制品
manifest.yaml的sourceGpuUuids里,给换卡用) - 驱动内核模块本身、BAR、其它进程的显存
nvidia-smi 在 checkpoint 成功后通常 看不到这个 PID 还占着 GPU:显存已经离卡,进程处于 CHECKPOINTED。
1.2 磁盘上另外还有、但不在「那份 host 缓冲」里的
| 文件 | 谁写 | 干什么 |
|---|---|---|
manifest.yaml 的 cudaRestore.pids |
agent | dump 时记下的 最内层 PID(见第 5 节) |
sourceGpuUuids |
agent | dump 时容器用的 GPU UUID |
cuda-checkpoint-job(--launch-job 时) |
官方 cuda-checkpoint wrap |
多卡 / 迁卡时驱动认的 job 记录(含 member PID、当时的 GPU UUID) |
lock 本身几乎不搬数据:只挡住会改 GPU 的 Driver API,等已提交的 kernel 做完。真正搬显存的是 checkpoint。
一句话:capture 阶段的 CUDA 侧 = 显存和 context 进该进程的 host 内存,GPU 腾空;UUID 等标签进制品元数据,不进那块缓冲。
2. CRIU dump 具体干了哪些事?
在 CUDA 已经是 CHECKPOINTED 之后,agent 在宿主机 PID 命名空间里对源容器的 host PID 做 criu dump(go-criu RPC → criu swrk)。它 不进 源 Pod 去 exec。
CRIU 拍的是 Linux 内核能描述的进程树,典型包括:
| 拍什么 | 落成什么(概念上) |
|---|---|
| 虚拟内存:匿名页、文件映射、堆栈 | pages-*.img 以及 pagemap |
| 线程、寄存器、TLS、信号、rlimit | 各 core-*.img / pstree |
| 打开的 fd、pipe、部分 socket | fdinfo / inet / unix 镜像 |
| 部分 mount、cgroup、tty(受选项限制) | mount / cgroup 镜像 |
| 刚才 CUDA checkpoint 写进 host 的那块缓冲 | 就在匿名页里,没有单独的 gpu.img |
它 明确不拍:CUDA context、仍在 GPU 上的东西(此时应该已经没有了)。若不先做 CUDA checkpoint,CRIU 会在 maps 里撞上巨大的 CUDA 预留 / device mapping,报 Unsupported mapping。
Dump 同时 agent 还会尽量打一份 overlay 可写层差量(rootfs-diff.tar),那是文件系统,不是 CRIU 内存镜像。
dump 默认不把源进程留在 running:源树随后停掉。GPU 数据此时只活在 制品目录 里。
3. CRIU restore:是不是把 dump 文件恢复到内存中?
是,但不是「解压到空机器」,而是:在 已经存在的目标容器 namespace 里,根据镜像文件 重新长出一棵进程树,把当时的地址空间、线程、fd 装回 新进程的内存。
要点:
- Kubernetes 先把 restore Pod 拉起来,PID 1 通常是
sleep infinity,占着 pid / mnt / net / uts / ipc。 - agent 用
nsenter进这套 namespace,再criu restore(rstSibling):恢复出来的是 sleep 的 兄弟进程,sleep 一般还是 PID 1。 - CRIU 读制品里的 image:页填回 VMA,寄存器填回线程,socket/pipe 按选项重建。CUDA 那份 host 缓冲作为普通匿名页一起回来。此时进程 CUDA 状态仍是
CHECKPOINTED:CPU 活了,GPU 还是空的。 - 还会按 dump 时的形态把 嵌套的 PID namespace 一起恢复(这是第 5 节两套 PID 的根因)。
所以:dump 文件 → 活进程的 CPU 内存,对;dump 文件 → 直接进 GPU,不对。进 GPU 是下一步 CUDA restore。
4. CUDA restore:是不是把内存里的 GPU 信息加载到 GPU?加载哪些?
是。 cuCheckpointProcessRestore 对着已经 CRIU 回来、仍为 CHECKPOINTED 的 PID:
- 在目标进程当前能看见的 GPU 上重建 context;
- 再分配显存,把 host 里那份缓冲 拷回 device(换卡时按
CUcheckpointRestoreArgs.gpuPairs:oldUuid → newUuid); - 重建 stream / event 等;
Unlock之后状态回到RUNNING,原先堵在 lock 上的 CUDA 调用继续走。
加载的就是第 1 节那批:显存内容 + context / 执行对象 / 地址布局。
不加载:一个新的模型文件、容器镜像里的权重(那些若需要,已经在 CRIU 内存或 rootfs diff 里)。
换卡时 源卡不必再插在这台容器里。host 里已经有完整副本;gpuPairs 只告诉驱动「以前那张卡的状态装到哪张新卡」。目标卡要同架构、显存够。
5. 为什么 restore 还要进「别的」namespace?PID 和 INNER 是什么?
这里的「空间」是 Linux namespace(命名空间),不是特指业务 overlay 网络。restore 会进目标容器的 pid / mnt / net / uts / ipc(agent 日志里的 nsenter)。有时还要处理 net ns 里的 IP 变了。
5.1 为什么不能在 agent 自己的 namespace 里 criu restore
| 角色 | 默认在哪套 namespace |
|---|---|
| snapshot-agent | 宿主机 PID(hostPID),自己的 mount |
| 源 / 恢复容器 | kubelet 建的一套独立 pid、mnt、net… |
CRIU restore 的语义是:把镜像种进「当前进程所在」的那些 namespace。
若在 agent 里直接 restore,进程会种到宿主机 pid ns / 错误的 mount ns,和恢复 Pod 无关,也看不到容器里的 /dev/nvidia*。
所以 agent 只做外围:发现 GPU、算 GPU-old=GPU-new、把 /snapshot-binaries 和制品 bind 进 恢复容器的 mount ns,然后:
nsenter --uts --ipc --net --pid --mount --target=<placeholder 的 host PID> \
-- nsrestore …
之后 criu restore、cuda-checkpoint-helper --action restore 都跑在 恢复容器那套 namespace 里(进程由 agent fork,日志仍在 agent)。
5.2 进了哪些 namespace、各干什么
| namespace | restore 侧具体操作 |
|---|---|
| mnt | 看到容器 rootfs、/dev、emptyDir、刚 bind 进来的 criu/helper 和 checkpoint 目录;先打 rootfs diff,再 criu restore |
| pid | 新进程出现在容器的 pid ns;CRIU 还会恢复 dump 时那层 内层 pid ns(见下) |
| net | 恢复出来的 socket 落在 新 Pod 的 net ns。源 Pod IP 已经没了,Snapshot 用 inet remap 插件按新 Pod IP 改地址 |
| uts / ipc | hostname、SysV IPC 与容器一致,避免和宿主机搅在一起 |
| cgroup | 把恢复树挂进这个容器的 cgroup(CriuOpts 里的 cgroup 根) |
Dump 时 不必 nsenter 进源容器:agent 用 host PID 调 cuCheckpoint* / criu dump,因为 hostPID 下 /proc/<hostPid> 就能指到那个进程。Restore 种树必须「人在屋檐下」,所以要进目标 ns。
5.3 一个进程两个号:容器里看见的 PID,和 INNER
Linux 允许 pid ns 嵌套。/proc/{pid}/status 的 NSpid: 从外到内列出同一进程的编号,最后一列是最内层。
业务容器本身已有一层 pid ns。dump 时进程在自己的内层 ns 里是例如 27。CRIU 按原样把 这层 pid ns 一起恢复,所以 restore 之后:
NSpid: 58 27
↑ ↑
│ └── INNER:dump 时就有的号,job file / manifest 记的是这个
└── 在「恢复容器这层 pid ns」里的新号(本文称 PID / observed)
同一条 smoketest:ps 看到 58,NSpid 最后一列仍是 27。
58 会变(每次 restore 容器 pid ns 重新编号);27 跟着快照走,一般不变。
Snapshot 约定(ReadProcessDetails / ResolveManifestPIDsToObservedPIDs):
| 名字 | 取自 | 谁用 |
|---|---|---|
| INNER(InnermostPID) | NSpid 最后一列 |
制品 cudaRestore.pids;job file 的 member;cuCheckpointProcessGetState 按 checkpoint 记录查找时 |
| PID(ObservedPID) | 当前这层 /proc 目录名,也就是 NSpid 里对应当前 ns 的那一列 |
在 当前 ns 里 kill / ptrace / 对活进程 restore 时,调用者「看见」的号 |
agent 自动路径:读到 manifest 的 INNER,再换成当前能看见的 PID,传给 helper——因为 helper 是在 恢复容器这层 pid ns 里 attach 活进程的。
手工在恢复容器里跑官方 cuda-checkpoint 时,常见分叉:
--get-state查的是 checkpoint 记录,记录里是 INNER;传容器 PID 会「找不到这个进程」。--action restore/unlock有的驱动路径认 INNER(和 job file 一致),有的认调用者 ns 里的 observed PID。以该版本 CLI 实测为准,不要混用两个号各试一半还不记下来。
记忆:INNER = 快照里的身份证;PID = 你现在这间屋子里的座位号。人是同一个。
dump 时(源容器 pid ns)
smoketest 在内层叫 27 → 写入 manifest / job file
restore 后(恢复容器 pid ns + 被 CRIU 恢复的内层 ns)
/proc/58/status NSpid: 58 27
GetState(27) → 对得上 CHECKPOINTED 记录
在容器里 attach 58 → 对得上那条活着的 smoketest
5.4 和「网络」的关系(避免和 pid 搅在一起)
- net ns:恢复 Pod 是新 veth、新 IP。CRIU 恢复 TCP/UDP 时不能还绑着源 Pod IP,所以有 inet remap。这是 网络命名空间,和 INNER 无关。
- pid ns:只决定进程编号怎么套娃,决定你到底该把
27还是58传给 CUDA CLI。
6. 串起来的一张图
┌─ capture ─────────────────────────────────────────────────────────┐
│ 源容器进程(host PID 对 agent 可见) │
│ lock → checkpoint:显存+context → 该进程 host 内存,GPU 释放 │
│ criu dump:host 内存(含那份 GPU 缓冲)→ 制品 pages-*.img │
│ 记下 INNER PID、sourceGpuUuids、job file │
└───────────────────────────────────────────────────────────────────┘
│
▼
┌─ restore ─────────────────────────────────────────────────────────┐
│ 新 Pod:sleep 占着 ns;agent nsenter 进入 pid/mnt/net/… │
│ criu restore:image → 新进程 CPU 内存(仍 CHECKPOINTED) │
│ 内层 pid ns 一起回来 → NSpid: {新PID} <INNER> │
│ CUDA restore:host 缓冲 → 当前可见 GPU(可 gpuPairs 换卡) │
│ unlock → RUNNING │
└───────────────────────────────────────────────────────────────────┘
7. 和相邻文档怎么分工
| 文档 | 讲什么 |
|---|---|
| 本文 | GPU 状态在 host / 磁盘 / 卡之间怎么走;为何 nsenter;PID vs INNER |
| [snapshot]4-cuda-checkpoint分析 | cuCheckpoint* 每个函数、helper 怎么编 |
| [snapshot]6-CRIU逻辑 | go-criu、插件、dump/restore 选项 |
| [snapshot]7-placeholder与恢复流程 | 为什么要 sleep 占位 |
| [snapshot]8-capture与restore命令在哪执行 | 每条命令在 agent 还是容器 ns |