最新款的 GPU,反而是短期任务最难弄到手的资源。如果你所在的 AI 团队规模不大,想用上 NVIDIA HGX B300、AMD Instinct MI350X 和 MI355X,基本只能走合同容量或 12 个月预留套餐两条路,而且都得跟销售团队谈。对于要连续跑上一年的生产级推理集群来说,这没什么问题;但如果你想在今天下午就启动一轮超参数扫描、需要在客户实际使用的同款芯片上完成一次评估,或者想在 gfx950 上先做基准测试再决定是否采用某个 kernel,这种模式就完全不合适了。
目前处于公开预览阶段的 Spot GPU Droplets 打开了第三扇门。你只需一次 API 调用,就能从 DigitalOcean 的闲置容量中创建同样规格的 B300、MI350X 和 MI355X,按秒计费,价格在 Droplet 创建的那一刻即已锁定。不用竞价,没有长期承诺,也无需跟销售沟通。作为交换,你只需接受一个条件:当 DigitalOcean 需要收回这部分容量时,可以回收该 Droplet,目标是通过邮件提前两小时通知。启动盘和临时盘会随之一起被回收,因此你的进度必须保存在别的地方。
这正是本教程要讲的内容。启动一台 Spot GPU Droplet 只需一条命令,下文会配上截图展示每一步操作。真正的功夫在于让你的任务对失去机器这件事无感,其套路就是一个粒度设置得当的检查点-恢复循环:即便实例被回收,损失的也只是几分钟的重算,而不是整次运行。我们会亲手搭建这个循环,在一台 MI355X 上跑起来,然后中途销毁 Droplet,看看替补实例如何从断点处精确续跑。
本教程中的所有内容均在 2026 年 9 月 9 日实际运行验证过。相关代码、systemd unit、cloud-init 模板以及原始日志都放在我创建的 GitHub 仓库里:github.com/anishsingh20/spot-gpu-checkpoint-resume。

这张图展示了目前在 DigitalOcean 上获取最新一代 GPU 的三种方式。合同方式和 12 个月预留方式都要走销售渠道,并且都要求签定期限。竞价型是目前唯一能自行通过 API 直接开通的选项,也是 MI355X 在所有渠道中唯一的售卖形式。价格取自 2026 年 9 月 9 日的定价页面,不同 GPU 价格各不相同:MI350X 竞价价 $4.00,比它的预留价还低;而 B300 的 $11.19 竞价价,买的是灵活性,不是低价。但无论选哪种,你拿到的都是同样的硬件,只是竞价型不用签合同。本教程使用的就是 MI355X 竞价套餐。

TL;DR
- 竞价型是在 DigitalOcean 上自助获取 B300、MI350X 和 MI355X 的按小时计费途径。规格标识(size slug)以
-spot结尾;1 GPU 和 8 GPU 两种规格都有。价格在创建时锁定,Droplet 存续期内保持不变,因此运行中任务的成本绝不会波动。 - 两小时的提前通知已经算宽裕,而且训练循环并不依赖它。DigitalOcean 力争在回收前两小时发出邮件提醒,这足够你从容完成一次检查点保存,甚至还能把剩余的 GPU 时间用掉。定期向 Spaces 上传检查点,本就能保证任务安全;而提前通知加上 SIGTERM 处理器,则让一次回收变得毫无波澜。
- 在 MI355X 上实测,2.01 亿参数模型配合 AdamW:每个检查点大小为 2,417 MB,从 MEM1 上传到 Spaces NYC3 需要 9 至 12 秒。以每秒 3.5 步的训练速度计算,每 600 步保存一次检查点约占总运行时间(墙钟时间)的 6%;每 1,200 步保存一次则约占 3%。
- 回收演练全流程:用
systemctl stop优雅退出并上传最终检查点,全程耗时 13.7 秒。销毁 Droplet,再用 cloud-init 创建一台替换机器,59 秒后进入“active”状态,5 分 24 秒后“从已保存的步骤恢复训练”。零步丢失。 - 整套演练——两台 Droplet 外加一次完整的 3,000 步训练——总共只花了约 $2.50,按的还是锁定不变的每小时 $4.50 价格。
- 代码已托管在 GitHub:anishsingh20/spot-gpu-checkpoint-resume。克隆下来,换成你自己的模型,直接跑即可。
什么是 Spot GPU Droplets
GPU Droplets 提供两种容量档位,二者采用相同的 GPU 配置。产品对比页面把界线划得很清楚:按需是固定费率的保底容量,永远不会被回收;Spot 则是视空闲情况提供的容量,费率对新 Droplet 可能每日调整,但每台在创建时即锁定价格,且这类容量可能被回收——回收时以提前两小时通知为目标。
2026 年 9 月 9 日公开预览阶段提供的机型阵容,数据来自 sizes API(doctl compute size list)和控制台:
| 规格标识 | GPU | 显存 | vCPU / 内存 | 启动盘 / 临时盘 NVMe | Spot 区域 | Spot 费率 |
|---|---|---|---|---|---|---|
gpu-mi350x1-288gb-spot |
1x MI350X | 288 GB | 24 / 256 GB | 720 GB / 5 TB | ATL1, RIC1 | 每 GPU 小时 $4.00 |
gpu-mi355x1-288gb-spot |
1x MI355X | 288 GB | 24 / 256 GB | 720 GB / 5 TB | MEM1 | 每 GPU 小时 $4.50 |
gpu-b300x1-288gb-spot |
1x B300,风冷 | 288 GB | 28 / 448 GB | 720 GB / 5 TB | RIC1 | 每 GPU 小时 $11.19 |
gpu-b300x1-288gb-lc-spot |
1x B300,液冷 | 288 GB | 28 / 448 GB | 720 GB / 5 TB | MKC1 | 每 GPU 小时 $11.19 |
gpu-mi350x8-2304gb-spot |
8x MI350X | 2.3 TB | 192 / 2 TB | 2 TB / 40 TB | ATL1, RIC1 | 每小时 $32.00 |
gpu-mi355x8-2304gb-spot |
8x MI355X | 2.3 TB | 192 / 2 TB | 2 TB / 40 TB | MEM1 | 每小时 $36.00 |
gpu-b300x8-2304gb-spot, -lc-spot |
8x B300 | 2.3 TB | 224 / 3.5 TB | 2 TB / 40 TB | RIC1, MKC1 | 每小时 $89.52 |
实际跑下来,有三点实用经验:
- 容量是实时变动的,所以两个区域都要查。Spot 用的是当前空闲的资源。我测试期间,RIC1 显示“该数据中心当前没有可用 GPU”,而 MEM1 却供应充足。如果你的 GPU 在两个区域都有列出,就把创建命令写成脚本,让两个区域都试一遍。
- 8 GPU 机型同样有竞价版。一台 8 卡 MI355X 节点每小时 36 美元,附带 40 TB 临时存储,拿它花一个下午跑一次数据并行训练完全可行,而且本教程里的循环代码在这样的节点上跑起来一模一样。
- 临时磁盘是本地 NVMe,1x 机型 5 TB,8x 机型 40 TB。存放数据集副本、做数据暂存最合适不过;但它就在 Droplet 本机,想长期保留的数据请放到 Spaces。在
gpu-amd-base镜像上,它会以未格式化的/dev/vdc出现,需要自行格式化并挂载。
竞价实例是为批量模型训练、批量或异步推理、超参数调优和渲染而生。生产环境的推理服务、对延迟敏感的应用和有状态服务则应该交给按需 GPU Droplet——它们有 SLA 保障,永远不会被回收。大多数 AI 团队两类负载都有,这两个层级本来就是搭配使用的。
为什么两小时的预告窗口会改变设计思路
各家竞价产品做的其实是同一笔交易,区别只在于容量被收回时会发生什么。
| 服务商 | 中断预告 | 预告方式 | 这段时间够做什么 |
|---|---|---|---|
| DigitalOcean 竞价 GPU Droplet | 目标 2 小时 | 向团队账户和 Droplet 创建者发送邮件 | 可以从容地按计划完成一次 checkpoint,还能继续用完剩余的 GPU |
| DigitalOcean 竞价 GPU 节点池(DOKS) | 同样目标 2 小时 | 邮件通知,外加 Kubernetes Events 和节点状态更新,随后自动执行 cordon 和 drain | 先做感知 PodDisruptionBudget 的 drain,然后删除整个节点池 |
| AWS EC2 Spot | 2 分钟 | 实例元数据和 EventBridge | 若状态量不大、处理逻辑已经就绪,还来得及做最后一次 checkpoint |
| GCP Spot 虚拟机 | 约 30 秒 | 元数据服务器、ACPI 关机信号 | 刷写一个标志文件,其他基本来不及做 |
资料来源:DigitalOcean 公开预览版条款第 2.3、2.4、3.3 节;AWS EC2 Spot 中断通知;GCP Spot 虚拟机抢占机制。
大多数讲“如何应对 Spot 中断”的指南,都是围绕两分钟甚至三十秒的紧张窗口写的。两小时则完全是另一量级的设计问题:它足够长,可以让当前的检查点周期跑完并完成上传,然后继续训练,直到 Droplet 真正被回收。通知以邮件形式送达,而不是以实例元数据信号的形式出现,条款里也特意写明两小时只是一个目标值。因此整套方案采用分层设计:定期上传到 Spaces 的检查点本身就能保证任务安全,无论时机如何;而邮件加上 SIGTERM 处理器,则让一次普通的回收几乎不付出任何代价。

这就是一个 Spot 任务的一生。绝大部分时间都是正常训练,每 N 步上传一次检查点,在 MI355X 上每次上传约需 10 秒。回收邮件送达时,你有一个从容的时间窗口:可以手动执行 touch /tmp/checkpoint-now 强制刷出一次检查点,然后继续训练;如果想干净利落地结束任务,也可以直接 systemctl stop。到了 T 零时刻,Droplet 及其本地磁盘随之消失,计费同步停止。几分钟后,一台运行着完全相同命令的替补机器读取 Spaces 里一个极小的 LATEST 指针文件,从保存下来的步数继续训练。下方的卡片把两类东西分得一清二楚:哪些会随 Droplet 一起消失(本地磁盘,以及自上次上传以来的全部训练步数),哪些不会(Spaces 里的检查点、git 中的代码)。整个循环围绕检查点设计,因此无论邮件是否准时到达,它都能正常运转。
启动你的第一台 Spot GPU Droplet
我们来创建一台 Spot GPU Droplet,确认拿到的 GPU 型号和价格都与页面显示一致,然后装上 PyTorch。看完这一节,你就有一台能正常运行的 MI355X 机器,可以直接跑下一节的训练循环。过程中你会发现,几乎没有什么是 Spot 特有的:规格标识只是以 -spot 结尾,控制台多出一个购买方式切换项,其他一切都和普通 Droplet 没两样。这正是重点所在——Spot 并不要求你学习一款新产品。
第一台 Droplet 我用控制台创建,方便你看清每一步的选择;替换用的那台则改用 doctl,这样整个流程都能写成脚本。截图取自我 2026 年 9 月 9 日的操作过程。
在控制台中创建 Spot GPU Droplet
进入 GPU Droplets 页面,点击 Create GPU Droplet。还没做任何选择,控制台就先告诉你 Spot 资源分布在哪些区域。

先选数据中心。区域选择器下方的横幅列出了四个支持 Spot 的区域:ATL1、MEM1、RIC1 和 MKC1;置灰的区域只是当时没有 GPU 余量。我选了 Memphis。右侧的摘要卡片已经显示出我接下来要选的方案——每小时 4.50 美元的 MI355X(Spot),并以明文标注了回收说明。

选择购买方式是新增的控件:按需(On-demand)提供 SLA 保障的可用性,Spot 则以更低成本运行能容忍中断的工作负载。选定 Spot 和 AMD 平台后,MEM1 提供两种 MI355X 方案:8 GPU 形态,配备 2.25 TB 显存和 40 TB 临时存储;1 GPU 形态,配备 288 GB 显存和 5 TB 临时存储——两者都是每 GPU 小时 4.50 美元。我选了单 GPU 那款。

SSH 密钥、公网 IPv4 和可选的指标代理,与其他任何 Droplet 都一样。本教程中请保持公网 IPv4 开启,这样你才能通过 SSH 登录并查看日志。Summary(摘要)卡片中显示的费率,就是点击 Create(创建)时锁定的价格。

点击 Create 后几秒钟,Droplet 页面便显示正在开通中,费用卡片上已经写着每小时 $4.500——这就是你锁定的费率。明天新 Droplet 的竞价价格可能会波动,而这台 Droplet 的价格不会变。

大约一分钟后,Droplet 进入 Active 状态,拿到了一个可用于 SSH 登录的公网 IP,以及位于默认 MEM1 VPC 中的私有 IP。计费从此刻开始,按秒计算,最低计费五分钟。

Configuration Details 确认了整套配置:一块配备 288 GB 显存的 GPU、24 个 vCPU、256 GB 内存、一块 720 GB 启动盘和一块 5 TB 暂存盘,全部按已锁定的每小时 $4.500 计费。建议截图留档,方便日后备查。
如何使用 doctl 创建 Spot GPU Droplet
本教程中用来创建替换 Droplet 的命令如下,除密钥指纹外与原文一字不差:
doctl compute droplet create spot-trainer-02 \
--region mem1 \
--size gpu-mi355x1-288gb-spot \
--image gpu-amd-base \
--ssh-keys $SSH_KEY_FINGERPRINT \
--user-data-file /tmp/user-data.yaml \
--tag-names spot-tutorial \
--wait
gpu-amd-base 是“AMD AI/ML Ready Image”镜像的 slug(预装 Ubuntu 24.04 和 ROCm 7.14,不含 PyTorch)。NVIDIA 机型请选用 gpu-h100x1-base 或 gpu-h100x8-base,即“NVIDIA AI/ML Ready”镜像;驱动栈已预装,同样的 slug 在 B300 上也适用。如果不想自己安装 torch,AMD 还发布了多个框架镜像(amd-pytorchrocm7、amd-vllmrocm7、amddeveloperclou-rocm714software)。可以用 doctl compute image list --public | grep -i "ai/ml\|rocm\|nvidia" 列出这些镜像;slug 会随时间变化,写死之前最好先核实一遍。
如何通过 API 创建 Spot GPU Droplet
创建调用与普通 Droplet 完全相同,只需换成 spot 尺寸的 slug(API 参考文档):
curl -X POST "https://api.digitalocean.com/v2/droplets" \
-H "Authorization: Bearer $DIGITALOCEAN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "spot-trainer-01",
"region": "mem1",
"size": "gpu-mi355x1-288gb-spot",
"image": "gpu-amd-base",
"ssh_keys": ["'"$SSH_KEY_FINGERPRINT"'"],
"tags": ["spot-tutorial"]
}'
我第一个 Droplet 的响应结果已保存到仓库中的 evidence/droplet-create-response.json 里。通过 size slug 就能识别出竞价型 Droplet,按标签或名称审计实例时,这个字段非常有用。
验证硬件是否正常
在 AMD 镜像上,SSH 连进去后我首先运行了这几条命令:
$ rocm-smi --showproductname
GPU[0] : Card Series: AMD Instinct MI355X VF
GPU[0] : GFX Version: gfx950
$ amd-smi version
AMDSMI Tool: 26.5.0 | ROCm version: 7.14.0 | amdgpu version: 6.19.14
$ lsblk -d -o NAME,SIZE,TYPE
vda 720G disk # boot
vdc 5T disk # scratch, unformatted
$ nproc; free -g | head -2
24
Mem: 251 total
$ python3 -c "import torch"
ModuleNotFoundError: No module named 'torch'
在 NVIDIA 镜像上,对应的命令是 nvidia-smi。请确认 GPU 型号一行与你订购的机型一致。计费按秒计算,最低收费五分钟,而且关机后的 Droplet 依然会计费,所以用完的 Droplet 应直接销毁,而不是仅仅关机(参见定价 FAQ)。
gpu-amd-base 镜像预装了 ROCm,但没有 PyTorch。请让 wheel 索引与 amd-smi 报告的 ROCm 版本保持一致:
python3 -m venv /root/venv
/root/venv/bin/pip install --index-url https://download.pytorch.org/whl/rocm7.14 torch
/root/venv/bin/pip install boto3
/root/venv/bin/python -c "import torch; print(torch.cuda.get_device_name(0), torch.version.hip)"
# AMD Instinct MI355X VF 7.14.60850
ROCm 通过 torch.cuda API 暴露 GPU 接口,因此下面的训练脚本与具体厂商无关。现在你已经有了一台按固定小时费率计费的 GPU 机器。接下来,我们要让跑在这台机器上的任务,就算机器没了也不受影响。
构建检查点与恢复循环
本节的目标是做出这样一个训练任务:可以随时、在任何机器上把它杀掉,再用一条命令在全新机器上重启,最多损失几分钟的工作量,而且不需要任何人在旁看守。
为实现这个目标,我们做三件事,按成本从低到高依次是:
- 定时把进度保存到 Spaces。每 N 步,脚本就把模型权重、优化器状态和步数计数写入位于 Droplet 之外的 Spaces 对象存储。仅凭这一条,任务就有了安全保障:无论 Droplet 以何种方式消失,你损失最多的也只是上次保存之后的那部分工作。
- 退出前再保存一次。当进程收到 SIGTERM——无论是来自
systemctl stop、系统关机还是实例回收——它会写出最后一个检查点并干净地退出。只需五行代码,优雅停机就能做到零步损失。 - 给自己准备一个应对回收邮件的按钮。在 Droplet 上执行
touch /tmp/checkpoint-now,脚本就会立即保存并继续训练。当两小时通知送达时,你按一下它,就能继续用这台你正在付费的 GPU 跑下去。
恢复正是保存的镜像操作,而且是全自动的。启动时,脚本会在 Spaces 里查找一个名为 LATEST 的小指针文件。如果它存在,脚本就下载它所指向的检查点,并从那一步继续;如果不存在,就从零开始。由于替代的 Droplet 运行完全相同的命令、使用相同的 --run-id,它自然能找到 LATEST,自己接着跑。
再说一下检查点存放的位置。Spot 区域(ATL1、RIC1、MKC1、MEM1)是新建的 AI 数据中心,目前还没有 Spaces 区域,所以我在 MEM1 上使用了 NYC3,实测每上传 2.4 GB 需要 9 到 12 秒。请查看 Spaces 可用性,选一个离你最近的。

这张图描绘的正是一个 Droplet 实际经历的循环。启动时,它先向 Spaces 请求 LATEST,据此恢复训练,或者从头开始。训练过程中,每 N 步上传一次检查点,直到上传完成后才改写 LATEST 指向新检查点——这样指针永远不会指向一个只传了一半的文件。无论收到 SIGTERM 信号还是出现标志文件,中断都会立即触发一次保存。Droplet 被回收后,箭头便绕回起点,在新机器上重新开始。底部的四个数字是这套方案在 MI355X 上的实测开销:每个检查点 2,417 MB,上传约需 10 秒,从 systemctl stop 到干净退出耗时 13.7 秒,整个演练全程零步骤丢失。
获取代码
以下所有内容都托管在 github.com/anishsingh20/spot-gpu-checkpoint-resume:
| 文件 | 用途 |
|---|---|
spot_train.py |
训练主循环,涵盖检查点保存、断点恢复、SIGTERM 落盘以及标志文件触发机制 |
spot-train.service |
systemd 服务单元,把训练脚本作为服务运行,并负责转发 SIGTERM |
cloud-init-resume.yaml |
模板文件,无需 SSH 就能把一台全新的 Droplet 变成续训环境 |
make_user_data.py |
从本地 env 文件读取内容填充模板,让密钥不会进入 git 仓库 |
list_checkpoints.py |
查看某次运行在 Spaces 里存了哪些内容,以及 LATEST 指向何处 |
evidence/ |
本教程中所有数字的出处:原始日志与列表输出 |
代码验证了什么。运行在竞价 GPU 上的 PyTorch 任务能否按计划把完整状态保存到 Spaces,以及这一操作的时间开销;收到 SIGTERM 后会完成最后一次保存并干净退出;一台全新的 Droplet,哪怕只拿到同一条命令,也能找到最新存档并无损续训、不丢任何数据;此外还验证了跟随 LATEST 在任何时候都是安全的。
代码未验证什么。由 DigitalOcean 主动发起的回收——这没有按钮可点,所以演练把两个环节分开模拟:一次优雅停机,以及一次强制销毁重建,而两种情形下的代码路径完全相同。模型质量——替身模型刻意在一个无法学习的任务上训练,损失值因此始终徘徊在 1.0 附近,真正值得关注的是步数计数。多节点训练——这里只有一台 Droplet、一个进程。把模型换成你自己的,文件其余部分也毫不在意。
创建存储桶和访问密钥
在控制台的 Spaces Object Storage 下创建一个存储桶,也可以用任意 S3 客户端对接区域端点来创建。然后创建一个访问密钥,权限范围仅限该存储桶,因为 Droplet 内部可以读取 cloud-init 的用户数据:
doctl spaces keys create spot-xx-xxxx-xx-xx-key \
--grants "bucket=spot-xx-xxxx-xx-xx;permission=readwrite"
把这四个值以普通 KEY=VALUE 格式逐行写入 /root/.spaces.env(不要加 export,因为 systemd 的 EnvironmentFile 不识别它;我就因为这个失误白白多经历了一次服务启动失败):
SPACES_KEY=DO00...
SPACES_SECRET=...
SPACES_REGION=nyc3
SPACES_BUCKET=spot-xx-xxxx-xx-xx
给密钥文件加上 chmod 600 权限。任务完成后记得轮换密钥。
训练脚本
整个项目只有一个文件,依赖 PyTorch 和 boto3。模型是个占位替身,但尺寸经过了精心设计——跑一步就是实打实的 GPU 计算(2.01 亿参数,六个 d=2048 的 MLP 模块)。把它换成你自己的模型即可,文件里其余代码一概不用动。
#!/usr/bin/env python3
"""
Checkpoint-and-resume training loop for DigitalOcean Spot GPU Droplets.
Three layers of protection, cheapest first:
1. Periodic checkpoint every --every-steps to Spaces. This is what keeps
the run safe no matter how or when the Droplet goes away.
2. SIGTERM/SIGINT trap -> checkpoint now and exit cleanly. Costs five lines
and makes `systemctl stop` (or any graceful shutdown) lose zero steps.
3. Drain trigger for the two-hour reclaim email: touch /tmp/checkpoint-now
(flush, keep going) or send SIGTERM (flush, exit). Same code path.
Resume is automatic. On start the script reads checkpoints//LATEST
from Spaces, restores model + optimizer + step, and continues. No checkpoint
means a fresh run. Run the identical command on the replacement Droplet.
Env (put these in /root/.spaces.env, chmod 600, `source` it before running):
SPACES_KEY, SPACES_SECRET Spaces access key pair
SPACES_REGION Spaces region, e.g. nyc3
SPACES_BUCKET bucket name
Run:
python3 spot_train.py --run-id sweep-01 --total-steps 2000 --every-steps 400
Tested 2026-09-09 on gpu-mi355x1-288gb-spot (MEM1), ROCm 7.14, torch rocm7.14.
"""
import argparse
import io
import os
import signal
import sys
import time
import boto3
import torch
import torch.nn as nn
FLUSH_FLAG = "/tmp/checkpoint-now"
_exit_requested = False
def _on_signal(signum, frame):
global _exit_requested
_exit_requested = True
print(f"[signal] {signal.Signals(signum).name} received, will checkpoint and exit",
flush=True)
def spaces_client():
region = os.environ["SPACES_REGION"]
return boto3.client(
"s3",
region_name=region,
endpoint_url=f"https://{region}.digitaloceanspaces.com",
aws_access_key_id=os.environ["SPACES_KEY"],
aws_secret_access_key=os.environ["SPACES_SECRET"],
)
def save_checkpoint(s3, bucket, run_id, step, model, optimizer, keep):
t0 = time.time()
buf = io.BytesIO()
torch.save({"step": step,
"model_state": model.state_dict(),
"optimizer_state": optimizer.state_dict()}, buf)
size_mb = buf.tell() / 1e6
buf.seek(0)
key = f"checkpoints/{run_id}/step-{step:09d}.pt"
s3.upload_fileobj(buf, bucket, key)
# Write the pointer only after the object is fully uploaded, so LATEST
# never names a half-written checkpoint.
s3.put_object(Bucket=bucket, Key=f"checkpoints/{run_id}/LATEST", Body=key.encode())
dt = time.time() - t0
print(f"[checkpoint] step {step} -> s3://{bucket}/{key} "
f"({size_mb:,.0f} MB in {dt:.1f}s)", flush=True)
prune_old(s3, bucket, run_id, keep)
return dt
def prune_old(s3, bucket, run_id, keep):
prefix = f"checkpoints/{run_id}/step-"
resp = s3.list_objects_v2(Bucket=bucket, Prefix=prefix)
keys = sorted(o["Key"] for o in resp.get("Contents", []))
for old in keys[:-keep]:
s3.delete_object(Bucket=bucket, Key=old)
print(f"[prune] deleted {old}", flush=True)
def load_latest(s3, bucket, run_id, model, optimizer):
try:
latest = s3.get_object(Bucket=bucket, Key=f"checkpoints/{run_id}/LATEST")
except s3.exceptions.NoSuchKey:
print(f"[resume] no checkpoint for run-id={run_id}, starting fresh", flush=True)
return 0
key = latest["Body"].read().decode()
t0 = time.time()
buf = io.BytesIO()
s3.download_fileobj(bucket, key, buf)
buf.seek(0)
ckpt = torch.load(buf, map_location="cpu", weights_only=False)
model.load_state_dict(ckpt["model_state"])
optimizer.load_state_dict(ckpt["optimizer_state"])
print(f"[resume] restored {key} in {time.time() - t0:.1f}s, "
f"continuing from step {ckpt['step']}", flush=True)
return ckpt["step"]
class StandInModel(nn.Module):
"""A stack of MLP blocks sized so one step is real GPU work (~200M params).
Replace with your model; nothing else in this file cares what it is."""
def __init__(self, d_model=2048, d_hidden=8192, layers=6):
super().__init__()
self.blocks = nn.ModuleList([
nn.Sequential(nn.LayerNorm(d_model), nn.Linear(d_model, d_hidden),
nn.GELU(), nn.Linear(d_hidden, d_model))
for _ in range(layers)
])
def forward(self, x):
for blk in self.blocks:
x = x + blk(x)
return x
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--run-id", required=True)
ap.add_argument("--total-steps", type=int, default=2000)
ap.add_argument("--every-steps", type=int, default=400)
ap.add_argument("--keep", type=int, default=3, help="checkpoints to retain in Spaces")
ap.add_argument("--batch", type=int, default=64)
ap.add_argument("--seq", type=int, default=512)
args = ap.parse_args()
signal.signal(signal.SIGTERM, _on_signal)
signal.signal(signal.SIGINT, _on_signal)
if not torch.cuda.is_available():
sys.exit("no GPU visible to torch (on AMD: check rocm-smi and the rocm wheel index)")
device = "cuda" # ROCm exposes the GPU through the same torch.cuda API
print(f"[gpu] {torch.cuda.get_device_name(0)}, torch {torch.__version__}, "
f"hip {torch.version.hip}", flush=True)
torch.manual_seed(0)
model = StandInModel().to(device)
n_params = sum(p.numel() for p in model.parameters())
print(f"[model] {n_params / 1e6:,.0f}M params", flush=True)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4)
loss_fn = nn.MSELoss()
s3 = spaces_client()
bucket = os.environ["SPACES_BUCKET"]
step = load_latest(s3, bucket, args.run_id, model, optimizer)
start_step, t_start, ckpt_time = step, time.time(), 0.0
while step < args.total_steps:
x = torch.randn(args.batch, args.seq, 2048, device=device)
target = torch.roll(x, 1, dims=1)
loss = loss_fn(model(x), target)
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
step += 1
if step % 50 == 0:
torch.cuda.synchronize()
rate = (step - start_step) / (time.time() - t_start)
print(f"[train] step {step}/{args.total_steps} loss {loss.item():.4f} "
f"{rate:.2f} steps/s", flush=True)
flag = os.path.exists(FLUSH_FLAG)
if (_exit_requested or flag or step % args.every_steps == 0
or step == args.total_steps):
torch.cuda.synchronize()
ckpt_time += save_checkpoint(s3, bucket, args.run_id, step, model,
optimizer, args.keep)
if flag:
os.remove(FLUSH_FLAG)
if _exit_requested:
break
wall = time.time() - t_start
done = step - start_step
print(f"[summary] ran steps {start_step}->{step} in {wall:.0f}s wall, "
f"{ckpt_time:.0f}s of that in checkpoints "
f"({100 * ckpt_time / max(wall, 1e-9):.1f}% tax), "
f"{done / max(wall - ckpt_time, 1e-9):.2f} steps/s while training",
flush=True)
if _exit_requested:
print("[exit] checkpointed and exiting cleanly; rerun the same command to resume",
flush=True)
sys.exit(0)
print(f"[done] {args.total_steps} steps complete", flush=True)
if __name__ == "__main__":
main()
四个看似不起眼、实则很关键的细节:
- 先上传文件,再更新指针。如果 Droplet 在上传途中挂掉,
LATEST仍指向上一个完整的检查点,恢复时绝不会加载到被截断的文件。 - 把优化器也存下来。AdamW 会为每个参数维护两个动量张量,因此检查点的体积约为权重的三倍(2.01 亿参数,2,417 MB)。省略这一步并不会让恢复更快,只会让效果悄悄变差。
- 及时清理。
--keep 3会在每次上传后删除较旧的检查点。少了这一步,按这个频率跑上 10 小时,存储桶里就会堆积 200 个检查点、足足 480 GB。 - 没有 GPU 就大声报错。这个脚本的第一版会自动回退到 CPU。在一台每小时 4.50 美元的机器上,用这种方式才发现 wheel 索引配错了,代价着实不菲。
作为服务运行
一个 systemd unit 能带来不少好处:转发 SIGTERM 信号、设置足够长的停止超时让最后一次上传顺利完成、日志文件在 SSH 会话断开后依然保留,还能配合 cloud-init 实现开机自启。具体配置见 spot-train.service:
[Unit]
Description=Checkpoint-and-resume training on a Spot GPU Droplet
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/root
EnvironmentFile=/root/.spaces.env
# Same --run-id on every Droplet that runs this job; that is what makes resume work.
ExecStart=/root/venv/bin/python /root/spot_train.py --run-id sweep-01 --total-steps 3000 --every-steps 600
# On shutdown or `systemctl stop`, systemd sends SIGTERM and waits this long
# for the final checkpoint to upload before it escalates to SIGKILL.
KillSignal=SIGTERM
TimeoutStopSec=600
Restart=no
StandardOutput=append:/root/train.log
StandardError=append:/root/train.log
[Install]
WantedBy=multi-user.target
cp spot-train.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now spot-train
tail -f /root/train.log
第一个 Droplet 记录的日志,此处有删减(完整文件见:evidence/02-run1-train-drill.log):
[gpu] AMD Instinct MI355X VF, torch 2.14.0+rocm7.14, hip 7.14.60850
[model] 201M params
[resume] no checkpoint for run-id=sweep-01, starting fresh
[train] step 50/3000 loss 1.1003 3.05 steps/s
[train] step 600/3000 loss 1.0026 3.57 steps/s
[checkpoint] step 600 -> s3://spot-ckpt-anish-0909/checkpoints/sweep-01/step-000000600.pt (2,417 MB in 10.2s)
[train] step 1200/3000 loss 1.0026 3.49 steps/s
[checkpoint] step 1200 -> s3://spot-ckpt-anish-0909/checkpoints/sweep-01/step-000001200.pt (2,417 MB in 10.4s)
[train] step 1800/3000 loss 1.0023 3.46 steps/s
[checkpoint] step 1800 -> s3://spot-ckpt-anish-0909/checkpoints/sweep-01/step-000001800.pt (2,417 MB in 12.4s)
[train] step 1850/3000 loss 1.0010 3.38 steps/s
回收演练实测
你没法让 DigitalOcean 听指挥回收 Droplet,所以我分两半来模拟:一半是温和中断(也就是那封邮件给你时间从容处理的情况,等同于执行关机),另一半是彻底丢失(毫无预警地销毁 Droplet,然后重新拉起一台替代机)。两者合起来,正好覆盖了真实回收可能对你造成的一切。
前一半:优雅刷盘
训练跑到第 1850 步时,从另一个 SSH 会话执行 systemctl stop spot-train:
[train] step 1850/3000 loss 1.0010 3.38 steps/s
[signal] SIGTERM received, will checkpoint and exit
[checkpoint] step 1892 -> s3://spot-ckpt-anish-0909/checkpoints/sweep-01/step-000001892.pt (2,417 MB in 9.3s)
[prune] deleted checkpoints/sweep-01/step-000000600.pt
[summary] ran steps 0->1892 in 568s wall, 42s of that in checkpoints (7.4% tax), 3.60 steps/s while training
[exit] checkpointed and exiting cleanly; rerun the same command to resume
time systemctl stop 显示总耗时 13.7 秒:其中包含一个尚在执行的步骤、一次 9.3 秒的上传,以及指针写入。之后 Spaces 中的状态见 evidence/03-spaces-listing-after-drill.txt:
2,417.0 MB 06:11:42Z checkpoints/sweep-01/step-000001200.pt
2,417.0 MB 06:14:41Z checkpoints/sweep-01/step-000001800.pt
2,417.0 MB 06:15:15Z checkpoints/sweep-01/step-000001892.pt
LATEST -> checkpoints/sweep-01/step-000001892.pt
无需退出进程,同样能触发刷盘保存:执行 touch /tmp/checkpoint-now 即可,脚本会在下一个 step 边界上传检查点、删除标记文件,然后继续训练。收到回收邮件、而你还想用满剩余的两小时 GPU 时长时,就运行这条命令。
下半场:销毁并替换
下半场才是硬仗。我直接把 Droplet 彻底销毁——回收机制对你的文件系统干的就是这件事——然后新建一台替代实例,由它的 cloud-init 安装整套环境并启动同一个 unit。由于这个 unit 携带相同的 --run-id,新 Droplet 自己就能找到 LATEST 并自动续训。
cloud-init 模板是 cloud-init-resume.yaml。它会写入 /root/.spaces.env、/root/spot_train.py 和 unit 文件,然后安装 torch 并启用服务。密钥绝不能进仓库:make_user_data.py 会从本地 env 文件读取内容填充模板,并把脚本内联进去。
python3 make_user_data.py --env ~/.spaces.env > /tmp/user-data.yaml
doctl compute droplet delete spot-trainer-01 --force # the "reclaim"
doctl compute droplet create spot-trainer-02 --region mem1 \
--size gpu-mi355x1-288gb-spot --image gpu-amd-base \
--ssh-keys $SSH_KEY_FINGERPRINT --user-data-file /tmp/user-data.yaml \
--tag-names spot-tutorial --wait
以下时间线整理自 evidence/04-reclaim-and-recreate.log、journalctl 和 evidence/05-run2-resume-complete.log:
| 事件 | 时间(UTC) | 距销毁经过时长 |
|---|---|---|
droplet delete 请求被受理(即模拟的回收) |
06:18:27 | 0:00 |
| 替换用的新 Droplet 已激活,IP 分配完成 | 06:19:25 | 0:58 |
| SSH 开始接受连接 | 06:19:57 | 1:30 |
cloud-init 执行完毕,spot-train 启动 |
06:23:31 | 5:04 |
读取 LATEST,恢复 2.4 GB 检查点(13.2 秒) |
06:23:45 | 5:18 |
| 训练恢复至第 1900 步,输出首行日志 | 06:23:51 | 5:24 |
| 跑到第 3000 步,任务完成 | 06:29:18 | 10:51 |
这五分钟里有四分钟都耗在 pip install torch 拉取 ROCm 7.14 的 wheel 包上。要是先给装好 venv 的 Droplet 打一个快照,替换实例直接从快照创建,cloud-init 就只需写入三个文件再启动 unit,中断间隙便缩短到一分钟左右。
替换实例记录下的日志:
cloud-init finished 2026-09-09T06:23:31Z
[gpu] AMD Instinct MI355X VF, torch 2.14.0+rocm7.14, hip 7.14.60850
[model] 201M params
[resume] restored checkpoints/sweep-01/step-000001892.pt in 13.2s, continuing from step 1892
[train] step 1900/3000 loss 1.0009 1.30 steps/s
[train] step 2400/3000 loss 1.0012 3.53 steps/s
[checkpoint] step 2400 -> s3://spot-ckpt-anish-0909/checkpoints/sweep-01/step-000002400.pt (2,417 MB in 10.1s)
[train] step 3000/3000 loss 1.0008 3.47 steps/s
[checkpoint] step 3000 -> s3://spot-ckpt-anish-0909/checkpoints/sweep-01/step-000003000.pt (2,417 MB in 10.3s)
[summary] ran steps 1892->3000 in 330s wall, 20s of that in checkpoints (6.2% tax), 3.58 steps/s while training
[done] 3000 steps complete
第 0 到 1892 步在一台 Droplet 上运行,第 1892 到 3000 步跑在另一台上,任务全程毫无察觉。损失值始终徘徊在 1.0 附近,因为这个替身任务在设计上就学不到东西;步骤计数和运行耗时才是真正说明问题的结果。

演练结束两分钟后,GPU Droplets 列表只剩一行:spot-trainer-02,IP 已经换成了新的。原先那台 Droplet 不见了。这就是回收加替换之后你的机群的样子:名字和地址变了,形态没变,而把它们串起来的那条线,存放在 Spaces 里。

那条“线”就在这里。存储桶里的 checkpoints/sweep-01/ 包含三个 2.25 GiB 的检查点,分别对应第 1200、1800 和 1892 步,外加 LATEST——一个 38 字节的文件,内容就是最新检查点的文件名。第 600 步的检查点已被清理。替补机器一启动,需要的就只有这个文件夹。

替补机器激活一分钟后:同一区域、同一镜像、同样的锁定费率。图表为空是因为 cloud-init 还在安装 torch;四分钟后,GPU 已经忙于第 1900 步了。
运行结束后,两台 Droplet 都已销毁。整个教程消耗的 GPU 总时长(包括一次冒烟测试和一次演练):约 33 分钟,按锁定费率 $4.50 计算,大约花了 $2.50。存储桶则完全在 Spaces 基础套餐额度之内。
这套流程要花多少钱,Spot GPU Droplets 能帮你省下多少?
循环的开销就是检查点耗时在挂钟时间中所占的比例,它取决于三个你能控制的变量:检查点大小、上传到 Spaces 的带宽,以及间隔 N。从本次运行来看:2,417 MB 上传约需 10 秒(六次上传在 9.3 到 12.4 秒之间),训练速度约为每秒 3.5 步。
| 检查点间隔 | 每个间隔的训练时长 | 检查点开销占比 | 零通知回收时面临风险的工作量 |
|---|---|---|---|
| 300 步 | 86 秒 | 10.4% | 最多 1.4 分钟 |
| 600 步(本次运行) | 171 秒 | 5.5% | 最多 2.9 分钟 |
| 1,200 步 | 343 秒 | 2.8% | 最多 5.7 分钟 |
| 3,000 步 | 857 秒 | 1.2% | 最多 14 分钟 |
原则是:选择一个你能承受其“风险工作量”的最大 N。对 10 小时的任务来说,损失 6 分钟不算什么,3% 的开销也完全值得;对这个模型而言,每 1,200 步存一次就是正确答案。而 40 分钟的任务,更密的存档节奏则是廉价的保险。更大的模型会改写整张表:7B 参数模型配上 AdamW,每个检查点约 84 GB,按这个上传速度要花约 6 分钟,这时你会改为每小时存一次检查点,并考虑把上传分片处理。
一次回收再恢复的周期,代价包括资源重新分配的空窗期(本次为 5 分 24 秒,若预置快照则约 1 分钟)、一次恢复(13 秒),外加最多一个间隔的重算。再和价格对照:MI350X 竞价实例 $4.00 对比预留实例 $4.76,每天被回收一次,相当于 24 小时中损失约 6 分钟,即 0.4%,换来的是每小时便宜 16%,而且不用签 12 个月长约。至于 B300,竞价实例的价值在于可获得性和灵活性,而不在时价:本周就能用上 GPU,按小时计费,没有任何绑定,而这个循环保证这份灵活性绝不会毁掉你任何一次训练。
批量推理变体
对批量推理来说,检查点保存的不是模型状态,而是一份已完成清单。在 Spaces 的一个清单对象里记录哪些条目已经处理完毕,恢复时直接跳过它们:
def load_done(s3, bucket, run_id):
try:
obj = s3.get_object(Bucket=bucket, Key=f"batch/{run_id}/done.txt")
return set(obj["Body"].read().decode().split())
except s3.exceptions.NoSuchKey:
return set()
def mark_done(s3, bucket, run_id, done_ids):
s3.put_object(Bucket=bucket, Key=f"batch/{run_id}/done.txt",
Body="\n".join(sorted(done_ids)).encode())
循环处理各个条目,把每条已完成的 ID 加入集合,每处理完 K 条就重写一次清单文件,刷新触发条件与训练循环保持一致。这样一来,每次回收最多只需重做 K 条。同时边处理边把输出写入 Spaces,并以条目 ID 作为键,清单和输出就不会出现不一致。
如果你的批处理任务用的是官方目录中的模型,而不是你自己的权重,那可以完全绕开 GPU,直接使用推理引擎上的批量推理,由它替你打理任务的整个生命周期。Spot 这套模式则适用于由你自己部署服务的模型。
Kubernetes 上的 Spot GPU 节点池
同一预览计划也覆盖了DOKS 上的 Spot GPU 节点池,不过两者的运行机制有不少差异,值得单独说明。节点会被打上标签并添加污点,标记为 Spot 容量,因此 Pod 必须显式声明容忍才能调度上去。回收以节点池为单位,而不是逐个节点进行;DigitalOcean 会先封锁并排空整个池,在排空超时的限制内遵守 PodDisruptionBudget,并在开始回收时发出 Kubernetes 事件和节点状态。Spot 池创建后只能缩容,不能扩容;想新增容量,就得按当时的价格另建一个新池。集群仍然需要一个 CPU 节点池。详情见条款第 3 节。前面那段检查点循环无需任何改动,即可作为一个 Job 运行,只需加上对 Spot 污点的容忍;而这些 Kubernetes 信号也为触发刷新提供了可编程的钩子。
何时使用 Spot GPU Droplets,何时不使用

三个问题就能定夺。任务能否在无人值守的情况下从已保存的状态恢复?丢失自上次检查点以来的进度,再加上更换 Droplet 所需的五分钟左右,这个代价可以接受吗?你想不想这周就按小时用上这块 GPU,不必打销售电话,也不用签 12 个月的合约?三个都是“是”,那 Spot GPU Droplets 就是正确之选,而适合它的场景相当多:带检查点的训练、超参数扫描(每次试验各自对应一个 run-id)、批量推理与评估、数据预处理,以及在 B300 或 MI355X 芯片上测试内核。只要有一个“否”,就该转向按需或预留容量,这条界线同样清晰:任何承载线上流量的服务、任何需要人工实时输入的应用、无法弹性重启的紧耦合多节点训练,以及 12 个月预留价更划算的长期 B300 工作负载。一句话总结:只要任务能保存自己的进度,Spot GPU Droplets 就能帮你省钱、省时间,或者两者兼得。
关于 Spot GPU Droplets 的常见问题
1. MI355X 或 B300 有没有按需价格可以拿来和竞价价对比?
目前没有。截至 2026 年 9 月 9 日,按需价目表最高只到 H200(4.47 美元)和 MI325X(3.80 美元)。B300 和 MI350X 提供 12 个月预留价(分别为 7.94 美元和 4.76 美元);MI355X 仅提供竞价实例。你可以把竞价价和预留价放在一起比较,也可以掂量一下:不签长约、这周就拿到 GPU,这对你来说值多少钱。
2. Droplet 被回收时我会收到 SIGTERM 吗?
文档中说明的告知方式是一封提前两小时的邮件;对 DOKS 节点池而言,则是 Kubernetes Events 和节点状态。循环里已处理 SIGTERM,因此优雅停机不会丢失任何进度——但它并不依赖这个信号:周期性的检查点本身就足以保障任务安全。
3. 公布价变化时,我正在运行的 Droplet 竞价价格也会跟着变吗?
不会。价格在创建时锁定,Droplet 存续期内保持不变(见条款 2.6,控制台的 Droplet 页面上也会显示该价格)。重建的 Droplet 则按创建当时的价格锁定。
4. 检查点间隔应该设多长?
先测量一次检查点上传所需的时间,再除以你愿意为此付出的墙钟时间占比(3% 是合理的默认值),得出的就是每个间隔的训练时长。以本次运行为例:上传耗时 10 秒,按 3% 占比折算,间隔为 330 秒,按每秒 3.5 步约合 1,200 步。最后再确认一下:即使丢掉一个间隔的训练进度,也不至于造成灾难性损失。
5. 为什么不把检查点存到 5 TB 临时盘上?那速度更快啊
因为临时盘位于 Droplet 本地,实例被回收时会随之销毁(见条款第 2.5 节)。你可以用临时盘存放数据集,或者在上传前暂存检查点,但持久副本应保存在 Spaces 中。
6. 用 Volumes 块存储代替 Spaces 行不行?
Volume 的生命周期独立于 Droplet,可以在同一区域内重新挂载到替换实例上,从而完全省去上传环节。不过请先确认你的竞价区域是否提供 Volumes;新建的 AI 数据中心(MEM1、RIC1、MKC1)仍在陆续补齐产品。Spaces 则在任何区域都可用,这正是本教程选用它的原因。
7. Spot GPU Droplets 有 SLA 或技术支持吗?
Spot GPU Droplets 目前处于公开预览(Public Preview)阶段,仅在工作时间按“尽力而为”原则提供支持(详见服务条款第 2.7 节)。这类实例专为可保存进度、可随时重启的工作负载而设计,而这正是上文那个循环带给你的能力。如果需要 SLA 保障的算力,请选用按需 GPU Droplets。
8. 能在 Spot 实例上运行多节点训练吗?
可以,前提是框架支持弹性重启。在紧耦合任务中,一台 Droplet 被回收就会拖住其余所有节点,因此对多数团队而言,最省心的做法是用一台 8 GPU 的 Spot Droplet,而不是八台 1 GPU 的。在 DOKS 上,整个节点池会被一并回收,各节点状态因此保持一致。更多细节可参阅《DigitalOcean Kubernetes 上的弹性 GPU 算力:从容应对 Spot 中断》
总结
长期以来,想用上最新 GPU 只有两难选择:要么签长约,要么排队等。而对于占据 AI 团队一周大部分工作量的那些任务,Spot GPU Droplets 让这道选择题彻底成为历史。点击 Create 后大约一分钟,一台 B300 或 MI355X 就交到你手上——按小时计费,费率在点击创建的那一刻即锁定,用完随时归还。想今天下午就跑的超参扫描、要在客户同款芯片上做的评估、提交前想在 gfx950 上验证的 kernel——这一切,如今一次 API 调用即可触达。
Spot 实例对你只有一个要求:任务得能保存自身进度,而本教程要展示的,正是这个要求实际上有多容易满足。一个脚本,每 N 步上传一次 checkpoint 并移动指针;五行代码捕获 SIGTERM,顺手完成一次免费的收尾保存;一个标志文件,把那封提前两小时送达的回收预告邮件变成一个按钮——按一下,回头接着干活;再配一个 cloud-init 模板,接替的 Droplet 便能自行恢复训练,用的还是同一条命令、同一个 run-id,快到你还没读完那封邮件。在 MI355X 上,这套循环的开销只占墙钟时间的 5.5%,刷盘并退出耗时 13.7 秒;Droplet 被销毁后,5 分 24 秒即可恢复,训练步数一步未丢。整套演练——两台 Droplet 加上一次完整的 3000 步训练——总共只花了约 2.50 美元。
这就是 Spot GPU Droplet 给出的交易条件:一次性投入一个下午做工程,此后最新一代 GPU 便能随你支配。代码已备好,随时可克隆:github.com/anishsingh20/spot-gpu-checkpoint-resume。换上你的模型,指向一个存储桶,然后创建你的第一台 Spot GPU Droplet。接着读一读对比文档和公开预览条款,再到可用性矩阵里查查你的 GPU 在哪些区域有货。等模型训练完毕、要上线提供服务时,按需 GPU Droplet 与 Inference Engine 会接手生产端的工作。
参考资料
- Spot GPU Droplets 与按需 GPU Droplets 对比,DigitalOcean 官方文档(DigitalOcean 最后核验于 2026 年 8 月 11 日;本文于 2026 年 9 月 9 日查阅)
- GPU Droplet 定价——Spot 套餐(价格采集于 2026 年 9 月 9 日;价格每日均有变动)
- 各区域 GPU Droplet 可用性
- DigitalOcean 公开预览条款:竞价型 GPU Droplet 与节点池(最后更新于 2026 年 8 月 10 日)
- 现已推出:公开预览版竞价型 GPU Droplet,更新日志
- Droplets API:创建和Sizes API
- Droplet 元数据服务和提供用户数据(cloud-init)
- Spaces 对象存储、Spaces 可用区域、Spaces 访问密钥
- 批量推理操作指南
- AWS EC2 Spot 实例中断通知
- GCP Spot 虚拟机文档
- 本教程的代码与验证材料:github.com/anishsingh20/spot-gpu-checkpoint-resume