Anchor 论文精读:解耦 GPU 内存所有权,让"浅层中断"不再付出全量恢复的代价

论文:Anchor: Mitigating GPU Shallow Disruptions with Decoupled Memory
作者:Haoyi Ma、Shiwei Gao、Youmin Chen、Junrong Huang、Youyou Lu、Jiwu Shu(清华大学 & 上海交通大学)
发表:SOSP '26(ACM SIGOPS 第 32 届操作系统原理研讨会)

TL;DR

大规模 LLM 集群中的绝大多数故障其实是"浅层中断"——进程崩了,但 GPU 硬件和 OS 内核毫发无损。现有恢复机制对此一视同仁:checkpoint → 重启 → 全量重载,哪怕数据本来就还完好在显存里。

Anchor 的核心主张是:内存所有权不应与进程生命周期绑定。它用一个 daemon 进程持有 GPU 内存,worker 通过 GPU IPC 机制零开销地映射访问;worker 崩溃后,新 worker 直接把同一块物理显存重新映射回来,数据从未离开过 GPU。配合基于"访问路径"的张量身份解析和面向训练/推理语义的轻量一致性协议,Anchor 在 DeepSpeed 和 vLLM 上实现后,推理恢复时间平均缩短 60.7%,训练平均缩短 26.6%;Qwen3-235B 推理恢复从 143.78 s 降到 27.85 s。


一、问题:为什么 GPU 集群的故障恢复这么贵?

1.1 故障画像:大多数故障其实很"浅"

作者分析了三个生产集群公开的故障追踪数据(Alibaba、ByteDance、Shanghai AI Lab),把中断归为四类:

故障类别 ByteDance Alibaba Shanghai AI Lab
用户主动重启 17.3% — 71.4%
运行时异常 10.7% 11.7% 7.4%
软件栈故障 70.8% 54.1% 18.0%
毁灭性故障 1.2% 34.2% 3.2%
浅层中断合计 98.8% 65.8% 96.8%

注:Alibaba 的故障中未单独统计用户主动重启一类,三类合计 100%。其中瞬时网络错误(NCCL 超时、链路抖动、连接重置)是其最大的单一来源。

论文据此把故障分成两个等级:

  • 毁灭性故障(destructive failure):GPU 硬件、驱动或宿主 OS 发生不可恢复的故障(设备丢失、不可纠正的 ECC 错误、机器重启),任务必须迁移到新节点;
  • 浅层中断(shallow disruption):GPU 设备及其软件栈完好,只有用户态 worker 进程退出。用户重启、运行时异常、软件栈故障全部属于此类。

关键结论:毁灭性故障只占少数,浅层中断在三个集群中占 65.8%–98.8%。

1.2 现有恢复的统一套路:checkpoint → restart → reload

无论故障多轻,现有系统都走同一条路:周期性 checkpoint,故障后回滚到最近有效检查点,全量加载恢复。但 LLM 的运行时状态极大——训练要保权重 + 优化器状态(Adam 混合精度下每参数 16–20 字节),推理要保权重 + KV cache。作者在 8×A800 节点上实测的代价:

训练(单卡,保存 / 恢复耗时,单位秒):

方法 Llama3.2-1B Qwen3-1.7B Llama3.2-3B
保存(DeepSpeed) 24.98 36.05 66.20
保存(CheckFreq) 9.83 12.77 24.68
保存(In-memory) 5.31 5.64 9.07
恢复(DeepSpeed) 24.01 28.84 42.86
恢复(CheckFreq) 24.73 27.12 42.42
恢复(In-memory) 15.93 19.04 25.19

推理(恢复耗时,权重已在 CPU page cache 中):

模型 Qwen3-8B Qwen3-14B Qwen3-32B Qwen3-235B
Replay(重算 prefill) 38.48 56.47 52.22 153.07
KV-memory(CPU KV 检查点) 34.20 38.20 49.02 143.78

即便是优化到极致的方案,Qwen3-235B 的推理重启仍需 143.78 秒。

1.3 一个"病态"的观察与根因

对于浅层中断,调度器通常已经会原地重启(in-place restart)任务——因为内核和硬件完好,不需要重新分配节点。但此时出现了一个病态现象:checkpoint 机制先把数据从 GPU 上存出去,再原样加载回同一块 GPU。

为什么数据必须离开 GPU?根因是 GPU 执行模型的 fate-sharing(命运共享)特性:一个 CUDA context 拥有它分配的所有 GPU 内存对象;进程崩溃时 context 被销毁,全部内存被驱动回收。这正是 Anchor 要打破的耦合。


二、核心洞察:把内存所有权从 worker 进程中解耦

Anchor 引入一个 daemon 进程作为 worker 的 sidecar,由它来持有(allocate 并 own)需要跨重启存活的 GPU 内存。其技术支点是现代 GPU 原生的 IPC memory 机制(CUDA 的 cudaIpcGetMemHandle/OpenMemHandle,AMD 侧对应 hipIpcGet/OpenMemHandle),它具备两个关键性质:

  1. 引用计数:内存区域通过 IPC 被多个 context 共享时,GPU 驱动维护引用计数。任何共享进程退出只会减一,不会触发实际回收——只要 daemon 还持有引用,内存就活着;
  2. VM–PM 映射的零开销访问:IPC 共享的底层实现是把 owner 的物理内存地址插入消费方的 GPU 页表。映射建立后,消费方的访问直接由 GPU 硬件 MMU/TLB 服务,正常执行路径上没有任何额外运行时开销——张量在 worker 地址空间里看起来与自分配内存毫无区别。

于是整个机制变成:worker 要分配张量时,daemon 代为分配并持有所有权,再通过 IPC 授予 worker 读写权限;worker 在自己的 context 里照常计算。worker 因浅层中断退出时,daemon 里的内存因引用计数不受影响;新 worker 启动后,daemon 把同一块 GPU 内存重新映射给它。这样,每卡数十 GB、每节点数百 GB 的 GPU 内存都可以"锚定"在 GPU 上。

值得强调的是 daemon 的窄失效面:它只执行内存分配和 IPC,不执行任何用户定义的 CPU 逻辑或 GPU kernel,因此不受用户代码错误的影响(这与 Singularity 等代理方案形成对比——代理发 kernel,kernel 故障会击穿代理)。


三、从"锚定一个张量"到"锚定一个 ML 系统"

单张量锚定只是起点。落地到真实 ML 框架,Anchor 要解决两个挑战:C1 身份解析(daemon 存的是裸 GPU 内存对象,框架用的是带应用语义的逻辑张量,重启后如何重建一一对应)和 C2 一致性(worker 可能在 kernel 执行中途崩溃,daemon 里的数据是"半成品")。

3.1 总体架构与工作流

系统由四个组件构成:

  • Allocation Proxy(worker 侧):驻留在分配路径上,拦截受管 scope 内的分配/释放请求,翻译成与 daemon 的协议操作;
  • Memory Manager(daemon 内):分配 GPU 内存对象,维护逻辑名到物理对象的映射;
  • Rebinder:为对象生成稳定标识,worker 重启后把框架中的逻辑张量重新绑定到 daemon 持有的 GPU 对象;
  • Consistency Guard:保证保留状态的一致性——训练恢复到"已完成的优化器步"边界,推理只暴露已提交的 KV cache 元数据并重放未提交后缀。

Scope 式托管是刻意的设计取舍:Anchor 只托管需要跨重启存活的长期对象(模型权重、优化器状态、KV cache 页),激活值、临时缓冲等短命对象仍走原生分配路径,执行路径上零拦截。用户只需把状态初始化代码包进 managed_scope:

1
2
3
4
5
6
7
8
9
10
11
# === 初始化:拦截分配 ===
with Anchor.managed_scope():
model = LLaMA(config) # 被拦截
optimizer = Adam(model.params) # 被拦截
# scope 退出:naming + rebind + IPC mapping

# === 训练:正常执行,无需任何修改 ===
for batch in dataloader:
loss = model(batch)
loss.backward()
optimizer.step() # 内部触发一致性协议

工作流三阶段:

  1. 拦截与占位张量创建:进入 scope 后,每次分配请求先生成一个轻量 placeholder 张量,承载元数据并记录创建方式,把全局命名和对象解析推迟到 scope 退出;
  2. 身份解析:scope 退出时,Rebinder 扫描所有存活的占位张量,为每个对象解析出访问路径标识,连同元数据发给 daemon;daemon 按逻辑名匹配既有的 GPU 对象或新分配,返回 IPC handle;
  3. IPC 映射:worker 在自己的 GPU context 里映射这些 handle,原位替换占位张量的数据指针——此后框架把它们当普通张量无缝使用。

释放与回收:受管对象被释放时,Proxy 把释放事件转发给 daemon 回收。一个特殊分支是 worker 正常退出(如代码调试后的主动重启):Anchor 检测 Python 解释器状态,停止转发释放命令,使保留对象留在 daemon 中以备重启后重绑复用。

3.2 Rebinder:跨重启稳定的张量身份(C1)

为什么现有命名方案都不行?

  • 分配序号:不允许增删张量——而用户调试后恰恰可能改代码;
  • 源码位置:重构导致行号漂移,循环体内的同一行会产生多个不同实例;
  • 框架工具(如 PyTorch state_dict):只覆盖注册在 nn.Module 层级里的 nn.Parameter。而如下面的 vLLM 引擎例子,KV cache 是用户应用里的原生 dict,游离在任何 PyTorch module 之外。
1
2
3
4
5
6
7
8
9
10
11
12
class LLMEngine:
def __init__(self, config):
self.model = LLaMA(config) # 标准 nn.Module
# KV cache 是原生 dict,对 state_dict() 不可见
# 访问路径: engine.kv_cache['layer_0']
self.kv_cache = {}
for i in range(config.num_layers):
self.kv_cache[f"layer_{i}"] = torch.zeros(
config.kv_shape, device="cuda")

with Anchor.managed_scope():
engine = LLMEngine(config)

方案:用访问路径(access path)作为标识符。 Python 的执行模型中,所有存活对象通过属性引用和容器索引构成一张有向对象图。Anchor 用 inspect.current_frame() 拿到调用栈活跃帧的变量作为遍历根,每个对象都能通过 .attr 与 [i]/[key] 组成的路径定位——例如 engine.kv_cache['layer_0']。这类路径由带清晰应用语义的索引操作构成,跨多次运行天然稳定。

别名与确定性:同一个对象可能有多个访问路径(如 LLM 的 embedding 权重常被输入/输出层共享)。Anchor 采用确定性搜索:从根集出发做 BFS,同层按访问器的字典序展开,并用 visited 集合断环;对象在 BFS 中首次被到达的路径即为其稳定标识。

延迟命名(deferred naming):完整 BFS 必须自顶向下(对象不知道自己的父节点),若每次分配后都全图遍历代价太高。因此 Anchor 把命名推迟到 scope 退出时一次性完成:scope 内每个被拦截的分配请求只生成占位张量;退出时单次 BFS,给所有仍存活的占位张量集体赋予规范路径——scope 内创建但中途释放的临时对象自然被排除。

占位张量(placeholder tensor):为保证命名期内框架的元数据查询(用 shape 推断其他缓冲大小等)透明可用,占位张量是一个只含单个物理元素的 GPU 对象,其 shape/dtype/device 与请求张量完全一致,而 stride 全零——所有逻辑索引映射到同一物理元素,元数据正确且显存开销极小。由于其生命周期限于初始化阶段(框架只读元数据、不执行计算 kernel),零 stride 不会引发正确性问题。

占位张量还记录初始化方式,供 daemon 首次创建时复现对象:

  • Shape 型(如 torch.empty(shape, dtype)):直接记录算子与参数;
  • Like 型(如 torch.zeros_like(src)):提取源张量元数据并归约为 shape 型;
  • Copy 型(如 tensor.clone()、tensor.to(device)):数据内容依赖源张量,而命名是延迟的——Anchor 在拦截时刻就把源数据缓冲到 CPU,保证延迟重建的正确性。

Daemon 匹配的三种结果:➊ Match——标识已存在且元数据兼容(dtype/shape 匹配),返回既有对象的 IPC handle;➋ 首次创建——标识不存在,新分配并返回新 handle;➌ 冲突——标识存在但元数据不兼容,依策略要么报告冲突并安全退出,要么释放旧对象、按新元数据重建。拿到 handle 后,worker 原位替换占位张量的数据指针,框架即可无缝使用。

3.3 Consistency Guard:崩溃瞬间的"半成品"(C2)

通用方案:跟踪 + CoW + 组回滚

应用无关的保障借鉴 PhoenixOS 思路,分三步:

  1. 内存跟踪——worker 即将启动 GPU kernel 时,通过拦截分析 API 调用参数,识别 kernel 将修改哪些 daemon 托管区域,并在运行时验证工作集;
  2. GPU 驻留写时复制——kernel 真正执行前,daemon 分配 GPU 缓冲并备份待改数据;
  3. 基于组的回滚/提交——把一组 kernel(如训练/推理的一步,涵盖所有层)视为原子单元,因为层间激活值没有保存,部分层回滚不可行;组内全部完成则释放 CoW 缓冲,中断则回滚到旧版本。

代价显而易见:跟踪要拦截分析 API 与 kernel 行为;更重要的是 CoW 需要在显存中全量备份待写数据——更新优化器状态或模型权重时可达数十 GB。

训练协议:最终化梯度是"免费的 redo log"

训练一致性的定义:完成一个训练迭代后的模型状态(权重 + 优化器状态)与对应 checkpoint 版本一致。

观察训练流水线:反向传播中梯度 collective 与计算重叠,DeepSpeed/FSDP 等框架要求所有 collective 完成后才能开始 optimizer step——论文称之为梯度终结边界(gradient-finalization boundary)。由此得到关键观察:

梯度终结边界保证梯度在整个优化器步内不可变。于是"当前模型状态 + 最终化梯度 + 每步元数据"完全确定下一版本。只要保留梯度到步结束,Anchor 就能用既有梯度缓冲区作为 redo log 重放任何未完成的 chunk 更新,零额外显存开销。

唯一的缺口:正在修改的 chunk 可能被写了一半,直接重放会二次更新部分元素。Anchor 用单个 undo buffer(只覆盖 cursor 处的活跃 chunk)+ 持久化 cursor 弥合,协议按 chunk 粒度执行(chunk = 一次 optimizer 调用所写的一组参数与优化器状态;DeepSpeed ZeRO-3 下对应一个参数子组的本地分片,受 sub_group_size 约束,默认 10⁹ 个元素):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# chunk[], grad[], undo_buf, cursor, undo_chunk 由 daemon 持有,跨崩溃存活

procedure ApplyChunk(i):
undo_buf ← Copy(chunk[i])
undo_chunk ← i # 发布完整 undo
Optimizer.Apply(chunk[i], grad[i])
cursor ← i + 1 # 提交该 chunk

# ── 正常路径 ──
cursor ← 0; undo_chunk ← ⊥
for i = 0 to N−1: ApplyChunk(i)

# ── 恢复路径 ──
if cursor < N then: # 优化步中途被打断
if undo_chunk = cursor then
chunk[cursor] ← Copy(undo_buf) # 回滚半写的 chunk
for j = cursor to N−1:
ApplyChunk(j) # 用梯度 redo log 重放

梯度终结边界之后,各 rank 独立更新自己参数分片的 chunk,无需跨 rank 协调。三种中断位置都能正确收敛:被打断在修改中的 rank 回滚后重放;已完成更新但未推进 cursor 的 rank 回滚后重放;已提交的 rank 跳过回滚只重放剩余部分。

开销对比鲜明:通用 CoW 协议需全量复制权重与优化器状态;Anchor 只需一个恢复 chunk 大小的 undo buffer(正常路径复制耗时 0.03–0.07 s),redo log 直接复用既有梯度缓冲。

推理协议:append-only KV + CPU 侧元数据

推理一致性的定义:中断后 KV cache 处于"已完成一步推理"的状态。观察:PagedAttention 下 KV cache 条目是 append-only 的——引擎把当前新 token(s) 的 KV 直接盲写进对应 slot(不依赖旧值),再调用 attention kernel。最坏情况是 worker 在 kernel 执行中崩溃,留下被部分写污染的 slot;但恢复时引擎重放该前向 pass,脏数据会被正确数据覆盖。

协议因此只需 CPU 侧元数据管理:

  • 正常执行:worker 自由写入;一个 decode 步或 chunked prefill 完成后,Anchor 在 CPU 侧提交该序列的元数据(块表映射、序列长度 cursor);
  • 中断恢复:若崩溃发生在元数据提交前,恢复时从 daemon 加载最后提交的元数据重建页表视图,从该点继续;被打断前向 pass 涉及的 slot 会在重执行时被覆盖,无需显式清理。

开销:零 GPU 显存开销,仅持久化少量页表元数据;每个请求的代价是重算一个(decode)token 或一个 prefill chunk。


四、实现要点

  • 纯用户态实现,基于 PyTorch 2.9.1、DeepSpeed 0.17.7、vLLM 0.14.1;对每个 ML 框架的侵入仅 200–300 行代码;
  • 分配拦截:PyTorch 默认的 caching allocator 做大粒度物理分配再切逻辑块,daemon 无法识别被管对象。Anchor 在受管 scope 内关闭 caching allocator——daemon 在初始化期不频繁分配释放,不会造成碎片,同时保证这些区域由 daemon 独占管理;
  • 多 GPU:每张卡一个独立 daemon,worker 只与本卡 daemon 通信,Anchor 层面无跨卡协调。NCCL 等库自分配的内部缓冲(通信环、scratch memory)位于受管 scope 之外,留在 worker 本地;
  • 回退策略:daemon 失效面窄,但为覆盖范围之外的情况,Anchor 保守回退到最新应用检查点,触发条件有三个:①毁灭性故障;②daemon 已死;③故障无法被确信地归类为浅层。

五、实验评估

5.1 设置

  • 平台:8×NVIDIA A800-SXM4-80GB(NVLink 全互联,双 NUMA),2×Xeon Platinum 8358,1 TiB 内存,Ubuntu 22.04.5 / Linux 5.15.0;256 GB NVMe 共享文件系统 + 400 GB tmpfs。可移植性实验用单卡 AMD Instinct MI100(ROCm 6.4.3-128);
  • 负载:训练用 WikiText-2,Llama3.2-1B / Qwen3-1.7B / Llama3.2-3B(单卡)+ Qwen2.5-14B(8 卡),BF16 + DeepSpeed ZeRO-3,每 100 步存检查点,共 2,000 步;推理用清洗后的 ShareGPT trace,固定并发,Qwen3-8B/14B/32B(单卡)+ Qwen3-235B(8 卡),每轮固定 10,000 请求;
  • 故障模型:注入非毁灭性故障——终止 worker 进程但不复位 GPU 设备;训练每秒 0.1%、推理每秒 1% 的故障概率,原地重启并测量"重新连接保留状态、恢复有效工作"的时间。

5.2 恢复延迟与持久化开销

训练侧持久化的阻塞延迟(秒):

方法 Llama3.2-1B Qwen3-1.7B Llama3.2-3B Qwen2.5-14B
DeepSpeed native 24.98 36.05 66.20 66.76
CheckFreq 9.83 12.77 24.68 35.61
In-memory checkpoint 5.31 5.64 9.07 28.16
Anchor 0.03 0.04 0.07 0.05

作者严谨地提醒:这些数字不应被解读为"保存加速比"——基线物化全量快照,Anchor 只保护一个驻留在 GPU 上的恢复 chunk,操作覆盖的状态量和执行频率都不同;真正相关的结论是 chunk 级保护足够小,从而带来低稳态开销。

恢复延迟:恢复时间被分解为"启动 + 状态恢复"。对 Anchor 而言,加载/重挂载几乎消失,剩余延迟由框架启动主导:

  • 训练:相对最强基线(In-memory)平均降低 26.6%;undo/redo 记录把优化器状态恢复到最新,避免回滚到更早的检查点;
  • 推理:相对最强基线平均降低 60.7%;Qwen3-235B 从 KV-memory 的 143.78 s 降到 27.85 s,5.16× 加速(Replay 为 153.07 s)。基线需要重建推理引擎并搬回 KV 状态,而 Anchor 权重与 KV cache 都在原地。

5.3 端到端性能(注入故障的完整运行)

  • 训练:吞吐相对最强基线提升 5.1%,相对 DeepSpeed native 达 1.81×。检查点方案要付周期性持久化 + 故障后加载 + 检查点间隔内的步数重算;Anchor 把已完成进度留在显存里:梯度终结前故障只需重算被中断的迭代,优化步中途故障回滚当前 chunk 后用保留梯度完成更新;
  • 推理:延迟相对最强基线降低 17.0%。每次重启都省掉模型重载、CPU→GPU KV 回搬和 prefill 重算,收益在 10,000 请求的运行中累积。端到端收益小于 60.7% 的孤立恢复收益——因为无故障计算仍占主导,这是符合预期的。

5.4 正确性验证

重绑定正确性——构造 8 种程序变更,配置 daemon 遇元数据冲突即报告并安全退出;以"修改后程序无重绑定的全新执行"为 ground truth,输出一致才判为正确:

用例 程序变更 分配序匹配 Anchor
(a) 启用 LoRA 适配器 ✓ ✓
(b) 移除视觉编码器 ▲ ✓
© 启用 INT8 量化 ▲ ✓
(d) 重命名 KV cache 张量 ✓ ✓
(e) 交换 attention/MLP 初始化顺序 ▲ ✓
(f) 移除最后一个 decoder 层 ▲ ✓
(g) 缩小 KV cache 容量 ▲ ▲(安全拒绝)
(h) 更换 KV cache 布局(NHD→HND) ✗ ✗

✓ 正确;▲ 安全拒绝;✗ 错误

Anchor 正确处理 6 例,分配序匹配只有 2 例。在 (b)©(e)(f) 中分配顺序变了而对象标识未变——例如 INT8 量化在每层引入额外 scale 张量并使后续分配整体后移,Anchor 仍能正确匹配。(g) 触发元数据冲突,daemon 安全拒绝——这是设计内的行为。(h) 揭示了一个本质局限:换 attention backend 改变了对缓存数据的解释方式,但标识、shape、dtype 都没变,两种方法都静默复用了不兼容的数据——Anchor 无法检测"标识与元数据均未变"的语义变更。

训练状态一致性——在五个阶段(更新前、更新中、提交前、提交后、步间)反复注入 worker 故障,共 10 次。恢复的 2,000 步运行与无故障运行的 loss 轨迹高度重合,平滑后的每步差异保持在 2.5×10⁻² 以下(小幅差异源于同一 GPU kernel 跨运行的浮点结果差异)。全部 10 次注入故障均成功恢复。

5.5 无故障(稳态)开销

以无故障原生基线为参照、工作负载达到稳态后测量:

  • 训练:GPU 显存 +4.9%(主要是一个 chunk 的 undo buffer,而非模型状态全量副本;最终化梯度直接复用作 redo log,不再另拷),step time +2.6%(最大 4.7%,来自更新前的 chunk 拷贝与 cursor 推进)。训练路径不每步序列化完整检查点;
  • 推理:GPU 显存 +0.45%,token 吞吐 −2.0%(最大 −3.2%)。Anchor 不复制模型权重或 KV 载荷,只在 decode / chunked-prefill 边界提交每序列元数据。

5.6 AMD 可移植性

在 MI100 上以 ROCm/HIP IPC 复现训练实验:Anchor 恢复延迟 17.3–19.0 s,基线 19.9–28.7 s;恢复分解与 NVIDIA 一致——重挂载消除了绝大部分恢复工作,框架初始化成为主要成本。持久化阻塞延迟:Anchor 1.07/1.76/0.04 s(Llama3.2-1B / Qwen2.5-1.5B / Qwen3-0.6B),对比 DeepSpeed native 16.81/21.32/10.02 s、CheckFreq 4.06/5.17/2.57 s、In-memory 3.89/4.17/2.48 s。这说明机制不依赖 CUDA 特定的 IPC 实现。


六、边界与讨论(论文自己承认的局限)

  1. 重绑定假设:访问路径相同不代表语义相同。例如 DeepSpeed 将多个参数的优化器状态按固定大小分组,模型结构新增权重会使受影响组及后续组的张量改变——"名字没变但语义大变"的修改不受支持;
  2. 故障覆盖与回退:daemon 不执行用户逻辑,失效面远小于 worker;但毁灭性故障、daemon 死亡、故障类型不确定这三种情况会回退到既有检查点——Anchor 与现有 checkpointing 是互补而非替代;
  3. 静默错误不在此列:SDC 和 buggy kernel 的错误写入不会以 worker 退出等显式信号暴露,属于 Anchor 范围之外,需专用 GPU 诊断配合应用级校验处理;
  4. 轻量协议的适用前提:训练协议假设存在梯度终结边界,推理协议假设 KV 更新只追加——当前主流框架满足;若不满足(如把参数更新融合进 backward 的优化器、维护原地更新递归状态的线性注意力解码器),则退回通用 CoW 协议。

七、相关工作坐标系

  • 检查点式恢复(周期式 / Just-in-Time;PhOS、NVCR 等进程透明 checkpoint/restart):本质上都是 save-and-reconstruct——显式把可恢复状态物化到运行进程之外,故障后重建。Anchor 则在非毁灭性故障下直接复用幸存的 GPU 驻留状态;
  • 代理式 GPU 系统(Singularity、NVIDIA MPS):Singularity 的代理负责发 kernel,kernel 故障会击穿代理,需重放调用重建 GPU;MPS 的客户端共享故障域。Anchor 的 daemon 只做内存分配与 IPC,worker 故障不会波及 GPU 状态;
  • 跨进程 GPU 资源访问(NCCL/UCX/UCC、ServerlessLLM):此前系统用 IPC 支持存活进程间的并发通信/共享/加载;Anchor 用 IPC 解耦的是内存对象生命周期与 worker 生命周期;
  • 虚拟-物理内存解耦(GMLake、vAttention、vTensor):这些工作在单个运行中的进程内解耦虚拟与物理内存;Anchor 解耦的是跨进程生命周期的内存所有权。

八、总结与点评

一句话总结:Anchor 把"故障恢复"从"存出去再搬回来"变成了"原地等新进程来认领"——利用 GPU IPC 的引用计数与硬件页表映射,让长期状态跨进程崩溃存活,再用基于 Python 对象图访问路径的身份解析和面向训练/推理语义的轻量一致性协议,把这件事做成了一套 200–300 行代码侵入的实用系统。

几点值得咀嚼的设计智慧:

  1. 故障分级而不是一视同仁。浅层中断占比高达 65.8%–98.8%,为它们省下的每一秒都是真实的集群算力。这套"按故障严重性选择恢复动作"的思路与生产系统已有的健康监测信号天然衔接;
  2. 对"命运共享"的精准打击。问题被归结为"所有权与进程生命周期耦合"这一个根因,解法也相应地只动所有权——daemon 不碰任何用户代码和 kernel,换来极窄的失效面。这是典型的"最小特权"式系统设计;
  3. 巧用应用语义换一致性成本。通用 CoW 方案可行但太贵(数十 GB 级备份);而"最终化梯度 = 免费 redo log"、"KV cache append-only + 盲写覆盖"这两个观察,把训练和推理的一致性成本分别压到一个 chunk 和零显存。领域知识在正确性与开销之间撬动的杠杆,比任何通用机制都长;
  4. 诚实的评估。论文明确指出 0.03 s vs 5.31 s 不是保存加速比、端到端收益小于孤立恢复收益、以及 case (h) 这类自身无法处理的语义变更——这些自我限定让结论更可信,也划清了系统与既有 checkpointing 的互补边界。

对从事 LLM 训练/推理基础设施的工程师而言,这篇论文提供的最大启发或许是:当进程崩溃成为常态时,值得重新审视"数据到底属于谁"这个最基本的问题。


附录:Anchor 背后的操作系统底层知识,从零讲起

一句话主线:论文的全部设计,其实叠在五个基础概念上——进程与地址空间、虚拟内存与页表、CUDA context、IPC 共享内存、守护进程。核心矛盾是"进程一死,它名下的内存全部陪葬";核心解法是"把内存挂到一个几乎不会死的进程名下,再靠硬件页表让访问它像访问自己的内存一样快"。

下面按"是什么 → 直觉理解 → 论文里怎么用"的顺序,把每个概念拆开讲。

A.1 进程与地址空间:程序一死,它的整个世界跟着消失

是什么:程序是躺在磁盘上的文件,进程是它被运行起来的那一刻——双击打开微信,就产生了一个进程。操作系统给每个进程一个独立的虚拟地址空间:一块属于它自己的、从 0 开始编址的"私人记忆领地",里面装着它的代码、变量、堆栈。

关键规则:进程退出(无论是正常结束还是崩溃)时,操作系统会做一次大扫除——把它名下的所有资源统统回收:内存、打开的文件、它向 GPU 申请的显存,一个不留。这种"一荣俱荣、一损俱损"的特性有个术语,叫 fate-sharing(命运共享)。

论文里对应什么:这正是问题的起点。worker 进程崩溃 → 操作系统回收它的一切 → GPU 驱动随之销毁它的 context、回收它的显存 → 模型权重、KV cache 全部蒸发。这就是为什么传统方案"不得不"做 checkpoint:数据必须先存到 GPU 之外才能幸免(对应正文 §3.1 的"病态观察")。另外补一句论文里 “shallow” 的含义:浅层中断时,OS 内核和 GPU 硬件都完好,死的只是用户态这一个进程——所以才有"原地重启、数据其实还在卡上"的机会可抓。

A.2 虚拟内存与页表:每个进程活在自己的"平行世界"

是什么:进程里代码使用的地址叫虚拟地址,真实内存条的地址叫物理地址,两者之间的翻译由硬件(MMU,内存管理单元)查一张表(页表,page table)来完成。

直觉:你家的门牌号是"3 栋 502"(虚拟地址),快递员查物业登记表(页表)才知道实际对应仓库的哪个格子(物理地址)。妙处在于:每个进程都以为自己住在"3 栋 502",但物理上各是各的格子——所以两个进程互不干扰,这就是地址空间的隔离。为了不每次都查表,CPU 用 TLB(一种高速缓存)记最近用过的翻译结果,所以日常访问基本是纯硬件动作,没有软件开销。

这个机制的关键妙用:既然翻译表是人为维护的,那如果两张页表里的某几条指向同一块物理内存,两个进程就能共享那块内存——而且各自访问时完全感觉不到任何区别。

论文里对应什么:GPU 其实就是一台挂在主机里的小型计算机,它自己也有 MMU、TLB 和页表。论文说的 “VM–PM mapping”(虚拟-物理映射)与 “zero-overhead access” 就是这套原理在 GPU 上的翻版:一旦共享关系建立,worker 访问"借来的"显存时,翻译直接由 GPU 硬件完成,不经过任何软件中转——性能与访问自己分配的显存一模一样。这就是"复用幸存显存却不交性能税"的根基。

A.3 CUDA context:GPU 世界的"进程"

是什么:GPU 不直接认识主机上的进程。进程想用 GPU 时,驱动会先为它创建一个 context(上下文)——可以理解为进程在 GPU 侧开的"租户账户",里面有自己的 GPU 页表、自己的地址空间;后续所有的 cudaMalloc、kernel 启动都记在这个账户名下。

关键规则:和操作系统如出一辙——谁在 context 里分配的显存归 context 所有;进程退出 → context 被清理 → 它名下显存全部释放(CUDA 在进程退出时自动清理,cudaDeviceReset 也是同样的全量释放语义)。

论文里对应什么:这就是论文点名的那条 “fate-sharing property of the GPU execution model”:a CUDA context owns all GPU memory objects it allocates。数据必须"存出去再搬回来"的根因就在这条规则上。Anchor 的第一刀,就是砍在这条规则上——而砍它的工具,就是下一个概念。

A.4 GPU IPC 内存机制:让两张页表指向同一块显存

是什么:现代 GPU 原生提供了跨进程共享显存的机制。CUDA 里是两个 API:cudaIpcGetMemHandle(拥有者把某块显存"登记"成一个 handle)和 cudaIpcOpenMemHandle(其他进程拿 handle 在自己的 context 里"打开");AMD 侧对应 hipIpcGet/OpenMemHandle(论文脚注 1)。

handle 是什么:一串不透明的字节,像储物柜的取件凭证——它不暴露地址、不可伪造使用,交给 GPU 驱动,驱动帮你把对应的物理显存挂进你自己的 GPU 页表。

论文抓住的两个性质:

  1. 引用计数(reference counting):一块显存被多个 context 共享时,驱动记着"还有几个人在用"。任何一个进程退出只让计数减一,不触发实际回收——只要还有一个持有者在,显存就活着;
  2. 零开销访问:"打开"的本质,是把拥有者的物理内存地址插入使用方的 GPU 页表(上一节讲过的那个动作)。之后访问由 GPU 硬件 MMU/TLB 直接服务,没有运行时开销。

直觉:引用计数解决"显存别跟着某个进程陪葬";页表映射解决"借来的显存用起来跟自己的一样快"。两个性质一组合,"跨进程复用显存"就从不可能变成了几乎免费。

论文里对应什么:这是 Anchor 全部技术的支点。daemon 代 worker 分配显存并持有所有权,再通过 IPC 把使用权授予 worker;worker 崩溃时计数只减一,显存岿然不动;新 worker 启动后再"打开"一次,同一块物理显存回来了——数据的物理位置从未变过。

A.5 Daemon(守护进程):一个刻意"什么都不做"的进程

是什么:daemon 指在后台长期运行、不与用户直接交互的服务进程(比如 Linux 里的 sshd)。sidecar 指与主进程同生共死、在旁边干辅助活的进程。Anchor 的 daemon 是 worker 的 sidecar:worker 启动它也启动,负责本卡的显存分配与 IPC handle 管理(多卡时每卡一个,互不通信)。

为什么它不会死:这是最巧妙的一步。worker 死的原因五花八门——用户代码抛异常、NCCL 超时、kernel 非法访存、OOM……这些全都发生在"执行用户逻辑"的进程里。而 Anchor 的 daemon 只做两件事:分配显存、管理 handle,不执行任何用户 CPU 代码,不跑任何 GPU kernel。用户代码的 bug 击不穿它。系统设计里管这叫窄失效面(narrow failure surface):能害死它的原因列表非常非常短。

直觉:延续正文的保管箱类比——worker 是来来去去、可能摔倒的顾客,daemon 是常驻柜台、只负责登记和递凭证的管理员。顾客摔倒不影响柜台。

论文里对应什么:"锚"得住的根本原因,是被锚定数据的持有者(daemon)几乎不会死。论文也留了兜底:万一 daemon 也死了、或故障无法确信归类为浅层,就回退到传统的 checkpoint 方案(对应正文"回退策略"三个触发条件)。

A.6 顺带一提:Python 对象图(不是 OS,但机制同源)

论文用"访问路径"给张量起名字,靠的是 Python 运行时的一个特性:所有存活对象通过属性引用和容器索引连成一张对象图,从栈上的变量出发总能走到任何对象。这本质上是垃圾回收里"可达性"思想的反面运用——对象只要被引用就不会被回收,所以沿着引用走就一定能找到它。你会发现它和显存的引用计数是同一个思想在不同层面的体现:“还有人在指着我,我就存在”。这部分属于语言运行时知识,点到为止。

A.7 把积木搭回去:这些概念如何拼成 Anchor

一条完整的因果链:

  1. 进程模型 + fate-sharing(A.1、A.3)造成了问题:进程死 → context 死 → 显存陪葬 → 只能 checkpoint;
  2. 虚拟内存/页表(A.2)提供了理论可能:同一块物理内存,可以出现在多个"视角"里,且硬件翻译、零开销;
  3. GPU IPC(A.4)把这个可能产品化:handle + 引用计数 + 页表映射,让显存所有权可以与任意一个进程脱钩;
  4. daemon(A.5)把"永远有一个持有者"落到实处:一个窄失效面的进程托住全部长期数据;
  5. 于是浅层中断发生时——死的只是 worker 的"视角",物理显存安然无恙,新 worker 重新"打开"即可。原本"存出去再搬回来"的大动作,被"换一个持有者"的轻动作替代了。

A.8 汇总速查表

概念 一句话本质 在 Anchor 中的角色
进程与地址空间 进程是运行中的程序,死了名下资源全回收 问题的根源(fate-sharing)
虚拟内存 / 页表 / MMU / TLB 硬件查表把虚拟地址翻译成物理地址 共享显存"用起来像自己的"且零开销
CUDA context 进程在 GPU 侧的租户账户,拥有它分配的显存 被打破的对象:context 死 ≠ 显存死
GPU IPC 内存 handle + 引用计数 + 页表映射 技术支点:所有权可与进程脱钩
Daemon / 窄失效面 只管显存、不跑用户代码的后台进程 "锚"的载体:几乎不会死的持有者

如果你只记住一件事,那就是:操作系统里"共享"的通用套路——让多个视角的翻译表指向同一份物理资源,并用引用计数决定它何时真正消失。 Anchor 把这个套路从 CPU 内存搬到了 GPU 显存上,再叠加一个刻意简单的持有者进程而已。


延伸资源