File № 001 · Source Reading · Architectural Reconnaissance · 211,510 LoC

SkyPilot,
opened up.

一份用 6.5 小时把 21 万行代码读穿的笔记 — 从 sky launch foo.yaml 那个回车键,一路走到 MI300X 上 skylet 守护进程的事件循环。

Source Lines
211,510py
Modules
11M0–M9
Clouds Wired
26incl. K8s
Pipeline Stages
9enum

§ 00 · PrologueWhy we cracked this open.

SkyPilot 是开源多云编排里的事实标准。表面是一行 sky launch,底下藏着一套客户机/服务器/节点守护的三段式系统、一个混合 DP + ILP 求解器、一组面向 24 个云厂商的多态抽象,以及 6562 行单文件后端。读它,是为了把"如何用一个统一控制平面调度异构基础设施"这件事彻底搞清楚 — 因为我们手头正在搭的,是它的近亲。

这次精读分 11 个模块(M0 → M9,加一个中途插入的 M2.5),共 6.5 小时。本档案是读后的总览:架构总图、9-stage 执行流水线、Optimizer 的 DP / ILP 路由、两层 job 状态机、加新云后端的文件改动清单 — 全部以手绘 SVG 呈现,无运行时依赖,离线可读。

2,142
task.py
2,816
resources.py
228
dag.py
7,954
cli/command.py
3,237
client/sdk.py
3,524
server.py
939
execution.py
1,805
optimizer.py
6,562
cloud_vm_ray_backend.py
4,440
backend_utils.py
1,041
clouds/cloud.py
107
skylet/skylet.py

— 上表为 master 分支(commit f8bb042)核心文件行数;前 4 个最大的合计已是 SkyPilot 实际"心脏"的一半以上。

Plate I — Architecture, in repose whole system, single view 1 : ∞
Zone A · Client (user machine) sky CLI cli.py · 10 lines (shim) command.py 7,954 · Click commands client/sdk.py 3,237 · HTTP client Task / Resources / Dag in-memory objects ⟵ HTTP / REST ⟶ Zone B · API Server (FastAPI / uvicorn) server.py · FastAPI app 3,524 · 10+ middlewares (RBAC / Auth / Versioning) requests/ async executor spawn process multiprocessing execution.py 939 · the 9-stage pipeline (see Plate IV) optimizer.py 1,805 · DP + ILP backends/ CloudVmRay 6,562 launch(req) ⟵ SSH / Ray / gRPC ⟶ Zone C · Provisioned VMs / Pods / Slurm nodes Head node skylet daemon 107 lines · 6 events gRPC server 4 services Ray cluster · job_lib · log_lib · autostop_lib configs · services · subprocess_daemon Worker node(s) — N× user setup · user run · ray worker spawned by head, joined to ray cluster Special compute zones (run as ordinary tasks above): Jobs Ctrl (spot recovery) Serve Ctrl (autoscale) Load Balancer (FastAPI on VM) SSH / rsync / ray up 1 2 3
Three zones, one program. Zone A (Client) packages a Task and POSTs to Zone B. Zone B (FastAPI server) spawns a process per request, runs the 9-stage execution pipeline, and reaches into Zone C (provisioned VMs) over SSH / Ray / gRPC. The head-node skylet daemon is small (107 lines) — it is mostly an event-loop wrapper around four gRPC services and six recurring jobs. Special controllers (Managed Jobs · SkyServe · Load Balancer) are themselves ordinary SkyPilot tasks running in Zone C.

§ M0 · 20 minThe repository, in repose.

把 21 万行的仓库当成一个整体先看一眼。它叫 sky,主包就是 sky/,根目录还有三个有意思的伙伴:agent/(SkyPilot Agent Skills — 给 LLM agent 用的 GPU 接入封装,v0.12 新功能)、llm/(45+ 个 LLM 训练/serving 例子,从 DeepSeek-R1 到 Kimi-K2),还有 examples/amd/ — 4 个直接面向 MI 系列硬件的官方 YAML 范例。

观察 · OBSERVATION

主包 + 伙伴目录这种结构在大型 ML 系统里很常见,但 SkyPilot 把 llm/agent/ 都做成顶层目录(而不是塞进 examples/ 子目录),等于在宣告"这些是 first-class 的产品形态,不是普通示例"。版本和迭代节奏可以与主包解耦 — 这是个值得偷的工程姿势。

公开 API 表面 · sky/__init__.py

258 行的薄薄一份。它做的事情只有三件:(1) 在 import 时强制规范 HTTP/HTTPS 代理环境变量(因为 GCP SDK 对大小写敏感有 bug),(2) 重新导出 sky.client.sdk 里所有动词 API 到顶层命名空间,(3) 给 24 个云类型起别名。sky.launch 之所以不在 sky/launch.py,是因为 SkyPilot 用了门面模式 — 用户调 sky.launch() 感觉是本地函数,实际背后是 client → HTTP → server → execution pipeline 的整套链路。

# sky/__init__.py · line 83-84
# Keep this order to avoid cyclic imports
# pylint: disable=wrong-import-position
from sky import backends
from sky import batch  # noqa: F401
from sky import clouds
from sky.client.sdk import launch       # ← 重导出门面在这里
from sky.client.sdk import status
from sky.client.sdk import exec
...
AWS = clouds.AWS                          # ← 24 个云别名
GCP = clouds.GCP
Kubernetes = clouds.Kubernetes
K8s = Kubernetes                          # ← 别名的别名

那条 # Keep this order to avoid cyclic imports 注释是真实工程债 — 内部模块之间有循环依赖,导入顺序敏感。读源码时如果看到 task.pyresources.py 互相 import,不用惊讶。

§ M1 · 35 minThree objects, one universe.

三个文件,三个数据类,是 SkyPilot 整个 universe 的元素周期表:

FileLinesClassesRole
sky/task.py2,1422中枢请求对象(什么要跑)
sky/resources.py2,8162资源规格(在哪跑、怎么跑)
sky/dag.py2283Task DAG(多个 Task 怎么编排)

反直觉的是 — ResourcesTask 还大。原因是 Resources 有 30+ 个 @property 暴露视图:infra / cloud / region / zone / instance_type / cpus / memory / accelerators / use_spot / disk_size / disk_tier / network_tier / image_id / ports / labels / autostop_config / priority / docker_login_config / ... 每个字段都对应一个云上的实际配置维度,24 个云全部要覆盖 — 字段就是这么多。

设计模式 · IMMUTABLE VALUE OBJECT

所有 30+ 字段都是只读 @property,写操作走私有 _set_* 方法 + 公开 Resources.copy(**override) 函数式更新。这种 immutable 设计在 Optimizer 流水线里至关重要 — 它要从用户的"约束 Resources"枚举出 N 个候选(AWS-spot vs GCP-on-demand vs ...),如果 Resources 是 mutable 的,并发枚举就会互相污染。

付出的代价是 DX: Resources 类长达 2816 行,每个字段都要写 property + private setter + 在 copy/__init__ 里处理。这是教科书级的"开发者体验换正确性"工程取舍。

YAML → Python 的翻译:_fill_in_env_vars 的 JSON 中转大法

用户写 file_mounts: { /model/llama-${SIZE}b: s3://llama-weights/${SIZE}b },SkyPilot 怎么把 ${SIZE} 替换成实际值?直觉做法是递归遍历嵌套 dict 的每个字符串字段。SkyPilot 用了一个聪明的 trick:

def _fill_in_env_vars(yaml_field, task_envs):
    yaml_field_str = json.dumps(yaml_field)           # 1) 嵌套 dict → JSON 字符串

    def replace_var(match):
        var_name = match.group(1)
        return task_envs.get(var_name, match.group(0))

    pattern = r'\$\{?\b([a-zA-Z_][a-zA-Z0-9_]*)\b\}?' # 2) regex 抓所有 $VAR / ${VAR}
    yaml_field_str = re.sub(pattern, replace_var, yaml_field_str)
    return json.loads(yaml_field_str)                 # 3) JSON 反序列化回 dict

3 行核心代码就替代了"递归遍历嵌套结构"的几十行 walker。可行性的前提:(1) schema 已经被 validate_schema 在上游校验过,(2) 不需要支持 bash 默认值语法 ${VAR:-default}(这是个挂了好几年的 TODO,line 115)。性能上每次跑一次 JSON 序列化/反序列化不是最优,但对配置加载这种"少量、一次性"场景,简洁度胜过性能。

可偷的设计 · STEAL THIS

这个模式可以直接搬到你 AMD 的 agent 配置系统 — 需要在嵌套 YAML/JSON 里做 ${MI300X_HOSTS} / ${TRITON_VERSION} 之类的模板替换时,JSON 中转 + regex 比写递归 walker 简洁 10 倍,而且 LLM 也容易 review。

另外两个值得记的点

ManagedSecretRefline 279)— 一个 dataclass,三个字段(name / mount_path / scope_override)。它让 YAML 里能写 secrets: [secrets:HF_TOKEN, secrets:workspace:GH_PAT] 这种命名引用而不是内联值;token 实际值存在 server 端的 vault 里,YAML 自己不暴露。scope_override 决定查哪个命名空间(personal / workspace / global)。

register_task_validatorline 36)— SkyPilot 的 plugin 机制典范。一个全局列表 _task_validatorsTask.validate() 时遍历调用。谁注册不显式,靠 module import 副作用触发。如果你将来想给 SkyPilot 加"AMD-specific 任务校验"(比如 run 命令里出现 cuda 关键字但 resources 是 AMD 时警告),不用 fork 主代码,写个 plugin 注册到这里即可。

§ M2 / M2.5 · 60 minCLI · SDK · the FastAPI in the middle.

把用户敲的 sky launch foo.yaml 拆解开看,它跨越四个进程边界:终端 shell → Click CLI → Python SDK → FastAPI server → 子进程 executor。每一跳都值得展开。

Click CLI · sky/client/cli/command.py · 7,954 行

我们之前估计 2900 行,实际接近三倍 — 它把 30+ 个子命令(launch / exec / status / queue / cost_report / logs / down / stop / autostop / start / check / show-gpus / jobs / serve ...)的处理逻辑全放一起。launch 子命令处理器在 line 1235,接受 30+ 个 Click 选项 — 注意到 infracloudregionzone并存的,老接口(cloud/region/zone)和新接口(infra)渐进迁移期共存。

task_or_dag = _make_task_or_dag_from_entrypoint_with_overrides(
    entrypoint=entrypoint,        # foo.yaml 路径
    name=name, workdir=workdir,
    cloud=cloud, region=region, zone=zone,
    gpus=gpus, cpus=cpus, memory=memory,
    instance_type=instance_type, num_nodes=num_nodes,
    use_spot=use_spot, image_id=image_id,
    env=env, secret=secret,
    ...
)
# ↑ 这一调内部走 M1 的 Task.from_yaml_config
#   合并 CLI overrides 进 envs/secrets, 跑 _fill_in_env_vars,
#   返回纯净的 Task 对象

request_id = sdk.launch(task, dryrun=dryrun, ...)
# ↑ 这一调走 HTTP,返回 request_id (不是结果!)

Python SDK · sky/client/sdk.py · 3,237 行

关键签名揭示一切:def launch(task, ...) -> RequestId[Tuple[Optional[int], Optional[ResourceHandle]]]返回 RequestId 而不是结果。SDK 是异步的 — 你拿到 ID 之后用 sky.stream_and_get(request_id) 才能取实际结果(或者 CLI 替你做这件事)。原因:launch 可能要跑 30 分钟(provision VM + 装依赖 + 跑 setup),HTTP 请求绝不能阻塞。

SDK 还有几个 _is_launched_by_jobs_controller / _is_launched_by_sky_serve_controller 这种内部标志 — 这暗示 Managed Jobs 和 SkyServe 内部会递归调 SDK launch 子任务(在 controller VM 上启动 worker VM)。

FastAPI server · sky/server/server.py · 3,524 行

line 926 一行揭示骨架:

app = fastapi.FastAPI(prefix='/api/v1', debug=True, lifespan=lifespan)

然后是 10+ 个 middleware:RBAC / RequestID / BasicAuth / BearerToken / AuthProxy / SecurityHeaders / InternalDashboardPrefix / CacheControlStatic / PathClean / GracefulShutdown / APIVersion。从 routes 看,端点包括 /token / /api/v1/auth/* / /check / /enabled_clouds 以及一长串异步任务(cleanup_upload_ids / cleanup_unreferenced_file_mounts / loop_lag_monitor 等),全部跑在 FastAPI 的 lifespan 上下文里。

Request executor · sky/server/requests/executor.py

这是最关键的一段,整个客户机/服务器解耦的真相在这里:

multiprocessing.set_start_method('spawn', force=True)
# On macOS, the default start method for multiprocessing is 'fork', which...

每个 launch 请求被丢进一个独立的子进程(spawn,不是 fork)跑。这才是为什么 launch 不会阻塞其他请求:它在另一个 Python 解释器里跑,FastAPI 线程立刻返回 request_id。配合 BurstableExecutor,可以根据负载动态加进程。还有一个 OnDemandThreadExecutor(line 100)专门处理"轻量同步请求在协程里的执行"。

三段式架构的真相 · WHY THIS MATTERS

"用户调 sky.launch() 像本地函数"的体验,是 4 个进程 + 1 个 HTTP 协议 + spawn 多进程 executor 共同协作的结果。它的代价是整个系统是 eventually consistent 的 — 你拿到 request_id 那一刻,可能 VM 还没开始 provision。后续所有"等待 / 状态查询 / 取消"都要回到 server 查那个 request_id 的状态。

这种"submit → poll / stream"模式直接对应你 multi-agent kernel optimization 系统里的 agent 任务投递 — 同样的 request_id 设计可以借鉴。

Plate II — One launch, dissected CLI keystroke to spawned subprocess temporal
terminal command.py sdk.py server.py executor sky launch foo.yaml parse YAML · build Task sdk.launch(task) POST /api/v1/launch (HTTP) spawn subprocess request_id ← immediately request_id ⤿ later: sdk.stream_and_get(request_id) ⤿ to fetch actual result
四进程 / 一次 RPC / 一次子进程派生。在 server 返回 request_id 的那一刻,executor 子进程才刚刚开始跑真正的 launch 流水线(接下来 30 分钟的事情)。CLI 默认会自动 stream 后续日志,但 --async 模式下就会立刻退出,只留 ID 在手 — 这正是 controller 在 spot 恢复后能"接续"先前 launch 的关键。

§ M3 · 40 minNine stages, one pipeline.

一旦请求落到 executor 子进程,sky/execution.py(939 行)就开始按 9 个 stage 推进。我们之前 plan 时以为是 7 个 — 实际枚举有 9 个,多出来的是 CLONE_DISK(experimental,从另一个 cluster 克隆磁盘到新 cluster)和 OPTIMIZE(之前漏数)。

class Stage(enum.Enum):
    CLONE_DISK = enum.auto()
    OPTIMIZE = enum.auto()
    PROVISION = enum.auto()
    SYNC_WORKDIR = enum.auto()
    SYNC_FILE_MOUNTS = enum.auto()
    SETUP = enum.auto()
    PRE_EXEC = enum.auto()
    EXEC = enum.auto()
    DOWN = enum.auto()

每个 stage 都直接映射到 backend.method() 调用 — backend 抽象就是这套"做 9 件事"的契约:

StageMaps toWhat happens
CLONE_DISK_maybe_clone_disk_from_cluster从另一个 cluster 复制磁盘镜像
OPTIMIZEOptimizer.optimize(dag)挑 cloud × region × instance,写入 task.best_resources
PROVISIONbackend.provision(...)真正去云上拉 VM,返回 ResourceHandle
SYNC_WORKDIRbackend.sync_workdir(...)rsync 本地 workdir 到 VM
SYNC_FILE_MOUNTSbackend.sync_file_mounts(...)storage mounts(S3/GCS)+ file mounts
SETUPbackend.setup(...)跑用户 setup 命令(装依赖)
PRE_EXECbackend.set_autostop(...)配置 idle autostop
EXECbackend.execute(...)跑用户 run 命令,返回 job_id
DOWNbackend.teardown(...)--down 时拆 cluster
隐藏的并发控制 · OPTIMISTIC + LOCK-INTERIOR FALLBACK

execution.py:474-505 的注释揭示一个微妙设计:OPTIMIZE 在 per-cluster lock 之外跑(因为 optimize 耗时,不能阻塞别的请求),但 backend 拿到锁后会发现"决策可能已过期"(比如 cluster 刚被别人删了)。补救方法是给 backend 传一个 planner callback — 锁内如果发现需要重 plan,就调它再跑一次 optimizer。

这是"乐观优化 + 临界区兜底"的并发模式,处理"长决策 + 临界区状态可能漂移"的经典做法。

Plate III — The 9-stage pipeline execution.py, in one elevation temporal
t₀ t → 1. (optional) CLONE_DISK helper fn 2. OPTIMIZE Optimizer.optimize 3. ★ heavy PROVISION backend.provision Ray cluster · SSH 4. SYNC_WD rsync 5. SYNC_FM S3 / GCS 6. ★ heavy SETUP user setup cmd 7. PRE_EXEC autostop cfg 8. ★ main EXEC backend.execute → job_id 9. DOWN if --down execution.py · _execute_dag(...) 9 stages, fired in order, each maps to a backend method. = heavy stage (minutes) — stages are conditional; exec command only fires SYNC_WORKDIR + EXEC OPTIMIZE runs OUTSIDE per-cluster lock planner callback re-fires inside lock if cached decision stale (see line 474-505)
每个 stage 都是条件触发(if Stage.X in stages:),所以 sky exec(在已存在的 cluster 上跑命令)只会触发 SYNC_WORKDIR + EXEC 两个 stage。provision / setup / exec 三个 stage 是"贵"的 — 各自可能耗时数分钟。

§ M4 · 45 minThe Optimizer, DP × ILP.

Optimizer 是 SkyPilot 的技术宝石 — 1805 行做一件事:在 24 个云 × N 个 region × M 个 instance type × spot/on-demand × egress 的笛卡尔积里挑出代价最小(或时间最短)的资源分配方案。

关键的判别在第一步:拿到 Dag 之后,问它 dag.is_chain()

DAG 拓扑算法复杂度文件位置
Chain(线性 pipeline)动态规划 (DP)O(N · R²)_optimize_by_dp · line 429
General DAG(分支/合并)整数线性规划 (ILP),PuLP + CBCNP-hard,但实际可解_optimize_by_ilp · line 490

动态规划 · chain DAG 上的最短路径

状态定义:dp_best_objective[node][resources] = 用 resources 配置跑完 node 的最小累积代价。状态转移:

# dp_best_objective[node][resources]
#     = my_execution_cost(node, resources)
#     + min over parent_resources of (
#           dp_best_objective[parent][parent_resources]
#         + egress_cost(parent → node, parent_resources, resources)
#       )

再加一个 dp_point_backs[node][resources] = best_parent_resources 用于反向回溯路径。这就是 Bellman 最短路径在"资源候选图"上的应用 — 每个 (node, resources) 是图上一个节点,相邻节点之间的边权是 execution_cost + egress_cost。

整数线性规划 · 一般 DAG 上的双线性优化

ILP 公式的 docstring 是教科书级的清晰,直接抄录关键部分:

For cost optimization (after linearization):
  minimize_{c, e}  Σ c[v]ᵀ · k[v]     # execution costs at each node v
                 + Σ e[u,v]ᵀ · F[u,v] # egress costs on each edge (u,v)
  subject to:
    Σ c[v] == 1          for each v in V    # one-hot: pick one resource
    Σ e[u,v] == 1        for each (u,v) in E
    e[u,v] = flatten(c[u] @ c[v]ᵀ)           # linearize the bilinear term

For time (makespan) optimization:
  minimize finish_time[sink]
  subject to:
    finish_time[v] >= c[v]ᵀ·k[v] + finish_time[u] + e[u,v]ᵀ·F[u,v]
    for each (u,v) in E
    plus the same one-hot constraints
数学 trick · LINEARIZE THE BILINEAR

纯二次决策 c[u] · c[v]ᵀ("u 选 r₁ 且 v 选 r₂")让问题不是 ILP 而是更难求解的 quadratic IP。SkyPilot 用经典的 McCormick 线性化:引入辅助变量 e[u,v] 表示"edge 端配置 (r₁, r₂) 是否被同时选中",加 one-hot 约束使 ec 自洽,然后整个问题就成了 ILP,PuLP + CBC 求解器能跑。

这个 trick 在你做"多 agent 资源调度"时直接可用 — 任何"两个对象都得选某种配置且配对有代价"的问题,都能这么打平成 ILP。

三种"特殊"路径

除了主路径,optimize_job_group(line 1037)处理 v0.12 引入的"job group"语义 — 多个 task 必须跑在同一个 cloud / region 上(典型:RL 训练里 actor 和 replay-buffer 必须在同一可用区减少延迟)。它走的是 _optimize_same_infra + _find_common_infras + _select_best_infra 这条特殊路径,把"基础设施一致"作为硬约束。

Plate IV — How the Optimizer decides chain → DP, general → ILP conceptual
Dag topo_order, edges is_chain() dag.py:159 TRUE FALSE _optimize_by_dp optimizer.py:429 Bellman-style shortest path on (node, resources) graph state: dp[v][r] = min cost to reach v with r trans: dp[v][r] = exec(v,r) + min_{r'} (dp[parent][r'] + egress(r',r)) cost: O(N · R²) _optimize_by_ilp optimizer.py:490 · PuLP + CBC Integer Linear Programming McCormick linearization of c[u] ⊗ c[v] vars: c[v] ∈ {0,1}^|R| (one-hot node) e[u,v] ∈ {0,1}^|R|² (one-hot edge) obj: min Σ c[v]·k[v] + Σ e[u,v]·F[u,v] cost: NP-hard worst-case · CBC tractable → task.best_resources for each task in dag
所有 chain DAG(包括最常见的"单 task"特例)走 DP 分支,瞬间完成。多分支 / 多汇聚的 DAG 走 ILP,把笛卡尔积空间打成线性规划。OPTIMIZE 这个 stage 在 client 端还是 server 端跑?—— 在 server 端:catalog 数据和 PuLP 都装在 server 容器里,client 不背这个重担。

§ M5 / M6 · 75 minBackend × Cloud, the polymorphic dance.

这两层互为因果,必须一起读。backend.py(212 行)定义 Backend[ResourceHandle] 泛型抽象基类,列出 9 个 stage 对应的方法。cloud.py(1041 行)定义 Cloud 抽象基类,列出 14 个云必须实现的方法。

关键洞察是:Backend 是"调用方",Cloud 是"被调用方"。先读 backend 你才能知道 cloud 接口为什么长那样 — 抽象方法的形状是被调用模式塑造出来的。

Backend 抽象的形状

class Backend(Generic[_ResourceHandleType]):
    def provision(self, task, to_provision_config, dryrun, ...) -> Tuple[ResourceHandle, bool]: ...
    def sync_workdir(self, handle, workdir, envs_and_secrets): ...
    def sync_file_mounts(self, handle, all_file_mounts, storage_mounts): ...
    def setup(self, handle, task, detach_setup): ...
    def execute(self, handle, task, dryrun) -> Optional[int]: ...     # → job_id
    def teardown(self, handle, terminate): ...
    def register_info(self, **kwargs): ...
    # ... 加上几个 internal _method 钩子

主实现 CloudVmRayBackendcloud_vm_ray_backend.py 里,6562 行单文件。它的"主类"CloudVmRayBackend 本身从 line 3038 开始,到 line 6562 — 单个类 3500 行。文件里还有 7 个 helper 类:

ClassLineRole
GangSchedulingStatus385多节点 gang schedule 的状态枚举
FailoverCloudErrorHandlerV1 / V2402 / 529provision 失败时的故障转移决策(V1 老接口、V2 新接口)
RetryingVmProvisioner796关键 — 在 zone → region → cloud 三层重试 provision
SSHTunnelInfo1938SSH 隧道连接信息
CloudVmRayResourceHandle1955对外的 ResourceHandle 实现 — 持有 cluster 状态
LocalResourcesHandle2795local-mode 特例
SkyletClient2843★ backend → skylet 的 gRPC 客户端
CloudVmRayBackend3038主类

名字里的"Ray"是什么 Ray

CloudVmRayBackend 里的 "Ray" 指 ray.io 项目 — 但不是用 Ray 做分布式训练,而是借用 Ray 的 cluster launcher(ray up / ray attach)来管 VM 生命周期。SkyPilot 早期是 Ray 的扩展,后来独立发展,这是历史包袱 + 设计选择。sky/skylet/ray_patches/ 子目录里能看到他们对 Ray cluster launcher 的私有 patch。

Cloud 抽象的 14 个必实现方法

每个云(AWS / GCP / Kubernetes / SSH / Slurm / RunPod / ...)必须告诉 SkyPilot:

regions_with_offering(instance_type, accel, use_spot, region, zone)
zones_provision_loop(...)               # 迭代 zones 用于 provision 尝试
get_zone_shell_cmd()                    # 在 VM 内拿 zone 的 shell 命令
instance_type_to_hourly_cost(it, spot)  # 给 Optimizer 喂价
accelerators_to_hourly_cost(accel)
get_egress_cost(num_gigabytes)          # 给 ILP 喂 egress 矩阵
make_deploy_resources_variables(...)    # 把 Resources 翻译成 Ray YAML 模板的填充变量
get_vcpus_mem_from_instance_type(it)
get_accelerators_from_instance_type(it)
get_default_instance_type(...)
_get_feasible_launchable_resources(r)   # 预过滤候选 — 给 Optimizer 减负
get_credential_file_mounts()
_unsupported_features_for_resources(r)
query_status(name_id_filter)            # 从云上拉 cluster 当前状态

具体实现 26 个云覆盖 — 加起来代码量惊人(aws.py 1712 行,kubernetes.py 1491 行)。这正是"加新云后端"工作的核心 — 后面 Red Line 1 会详述。

§ M7 · 40 minSkylet, the satellite.

最让人惊喜的发现:sky/skylet/skylet.py 只有 107 行。这个被誉为"SkyPilot 的 kubelet"的节点守护进程,本体精简到不可思议 — 它就做两件事:(1) 启动 gRPC server,(2) 跑一个 6 事件的轮询事件循环。重逻辑全在配套模块里。

EVENTS = [
    events.AutostopEvent(),                         # idle 自动关机
    events.JobSchedulerEvent(),                     # 节点本地 job 调度
    events.ManagedJobEvent(),                       # managed job 状态同步
    events.ServiceUpdateEvent(pool=False),          # serving controller 健康
    events.ServiceUpdateEvent(pool=True),           # pool 状态刷新
    events.UsageHeartbeatReportEvent(),             # 用量遥测 (10 min 一次)
]

def run_event_loop():
    for event in EVENTS:
        event.start()
    while True:
        time.sleep(events.EVENT_CHECKING_INTERVAL_SECONDS)
        for event in EVENTS:
            event.run()

gRPC server 暴露 4 个服务,全部用 protobuf 生成的 stub:

ServiceProto谁是 client
AutostopServiceImplautostopv1backend, 配置 autostop
JobsServiceImpljobsv1backend, 提交/查 job
ServeServiceImplservev1serve controller
ManagedJobsServiceImplmanaged_jobsv1jobs controller

SkyletClient(cloud_vm_ray_backend.py:2843)就是这些 gRPC service 的客户端 — backend 通过它跟节点上的 skylet 通信。

值得偷的设计模式 · MICRO-DAEMON + FAT MODULES

守护进程本体保持极薄,重逻辑全部下沉到独立模块(job_lib.py 1459 行 · log_lib.py 909 行 · autostop_lib.py 382 行 · events.py 442 行 · services.py 634 行)。这种"小核心 + 大附件"的拆法让你可以单独测试每个 lib,而 daemon 本身只是 wiring。

对你 multi-agent kernel optimization 系统的 worker daemon 设计完全适用 — 把"agent 本体"做成 100 行的 event loop + gRPC server,把"kernel benchmark / profile / analysis"做成独立 lib。

Plate V — Skylet 107 lines, exploded 1 : 1
skylet.py 107 lines · main() gRPC server ThreadPool, min(32, cpu+4) AutostopService JobsService ServeService ManagedJobsService ↑ called by backend event loop while True: sleep(N); run all AutostopEvent JobSchedulerEvent ManagedJobEvent ServiceUpdateEvent (×2) UsageHeartbeat ↓ polls local state job_lib (1,459) · log_lib (909) · autostop_lib (382) events (442) · services (634) · constants (722)
左臂(gRPC)应答外部调用;右臂(event loop)巡视本地状态。两条腿共享同一组下层库 — 这是"daemon 即 wiring,logic 在 lib"的范式。

§ M8 · 35 minManaged Jobs, two state machines.

Managed Jobs 是 SkyPilot 的杀手特性 — 在 spot 实例上跑长跑作业不丢进度。它的核心设计是两层状态机,一个隔离另一个的瞬态故障:

class ManagedJobStatus(enum.Enum):
    """
    The ManagedJobStatus is a higher level status than the JobStatus.
    Each managed job submitted to a cluster will have a JobStatus
    associated with it:
        JobStatus = [INIT, SETTING_UP, PENDING, RUNNING, ...]
    Whenever the cluster is preempted and recovered, the JobStatus
    transitions multiple times.

    However, a managed job only has one ManagedJobStatus on the jobs controller.
        ManagedJobStatus = [PENDING, STARTING, RUNNING, ...]
    """

翻译成人话:

  • JobStatus(在 worker cluster 的节点上):被 spot 抢占时会经历 RUNNING → FAILED;恢复后新 cluster 起来又走 INIT → SETTING_UP → PENDING → RUNNING。一个 managed job 的生命周期里这个底层状态可能反复 RUNNING → FAILED → RUNNING。
  • ManagedJobStatus(在独立的 jobs controller VM 上):从用户视角看到的状态。底下被抢 N 次,这里始终是 RUNNING。只有当 controller 决定"不再 recover"了,才会进入 FAILED_NO_RESOURCEFAILED_CONTROLLER

这种"上层状态吸收下层抖动"的设计同样反映在 recovery_strategy.py(1024 行)里:

Strategy classLineBehavior on preemption
StrategyExecutor61抽象基类,定义 recover() 钩子
FailoverStrategyExecutor815等所有 zones/regions 都试一遍,再换 cloud
EagerFailoverStrategyExecutor936抢占后立即跳到下一个 region,不死磕

scheduler.py(466 行)单独控制"controller 进程"的并行度 — 因为每个 managed job 在 controller VM 上是一个独立 Python 进程,太多 controller 会撑爆 VM 内存。

直接相关 · DIRECTLY APPLICABLE

"两层状态机吸收瞬态故障"的设计是你的 multi-agent kernel optimization 系统应该照搬的 — agent 跑 kernel benchmark 时 ROCm 偶发 OOM / driver hang / 节点掉线,底层 task 状态反复抖动,但上层 agent 任务状态应该稳定保持 RUNNING,让 agent 不感知底层故障。recovery_strategy.py 那种"策略可插拔"的接口直接抄。

Plate VI — The two-level state machine Managed Jobs · spot resilience temporal
t₀ t → ↑ controller view RUNNING STARTING RUNNING ↓ worker cluster RUNNING FAILED (spot kill) RUNNING FAILED (spot kill) RUNNING (new cluster) recovery_strategy .recover() recover() preempt₁ preempt₂ A managed job survives two spot preemptions Top lane (controller view): one continuous RUNNING. Bottom lane (cluster view): three RUNNING phases on three different clusters.
用户看到的"一个 job 跑了 6 小时"是上层平直的红线;底下其实是三段独立的 cluster 生命周期,每次抢占触发 recovery_strategy.recover() 重新 provision + sync + setup + exec。两层状态机的隔离让"长跑作业"变成可能。

§ M9 · 35 minSkyServe, scaling out.

SkyServe 是模型 serving 子系统 — 把 N 个 replica 跑在多云多区,自动扩缩,路由请求。结构上跟 Managed Jobs 镜像对称:每个 service 有独立的 controller VM,下面挂 K 个 replica VM,外加一个独立的 load balancer 进程负责把流量分发到健康的 replica。

FileLinesRole
controller.py297service 生命周期
replica_managers.py1,564★ replica 健康监控 + 自愈
autoscalers.py1,288QueueLengthAutoscaler 等扩缩策略
load_balancer.py342FastAPI 进程,路由 HTTP
load_balancing_policies.py262round_robin / least_load / 等
spot_placer.py281replica 在 spot 上的放置策略
service_spec.py661YAML schema
serve_state.py835state DB
serve_utils.py1,934helpers

关键发现:load balancer 自己就是一个 FastAPI 进程load_balancer.py:53: self._app = fastapi.FastAPI()),跑在 controller VM 上。意味着 user request 的 hot path 是 client → LB FastAPI → replica HTTP server,多一跳但代价可控(同 VM 内)。

spot_placer.py 是个有趣的独立模块 — 它复用 Optimizer 的 ILP 逻辑,而是有专门策略:把 replica 分散在不同 region / zone,降低"同一时刻全军覆没"的概率。这是 serving 特有的可用性考虑。

Autoscaler 用 AutoscalerDecisionOperator 二元决策(SCALE_UP / SCALE_DOWN)+ AutoscalerDecision(operator, target) 数据类。QueueLengthAutoscaler 是一个具体实现 — 看队列深度做扩缩。这种"decision + operator"结构方便插入新策略(QPS / latency / GPU util)。

§ Traps · five of themWhat I wish I'd known.

新人最容易栽的五个坑 — 如果你将来读源码、做改动、或者跟 SkyPilot 团队提 PR,先记住这几条。

Trap № 1 · The "Ray" pun

cloud_vm_ray_backend.py 里的 "Ray" 不是分布式训练 Ray,是借 ray.io 的 cluster launcher 管 VM。sky/skylet/ray_patches/ 子目录可以确认 — 那是他们对 Ray cluster launcher 的私有 patch。SkyPilot 早期是 Ray 的扩展,独立后还沿用了 Ray 的 VM 编排底层。

Trap № 2 · Four state.py files

这是新人必混的:

  • sky/skylet/job_lib.py — 节点本地 job DB
  • sky/jobs/state.py — managed job 全局 DB(在 jobs controller 上)
  • sky/serve/serve_state.py — serving 状态
  • sky/server/state.py — API server 状态

四个不同 FSM,对应 4 个不同层级的 job 概念。读 M7 / M8 / M9 时画一张对照表别忘了。

Trap № 3 · _fill_in_env_vars 不只是字符串替换

看似只是 ${VAR} 替换,实际上还涉及 secrets 不写日志、Docker 登录凭证不进 envs、和 file_mounts/storage/service/volumes 的多场景调用。它是个安全边界,不是无脑替换 — 加 plugin 时如果你引入新的"需要替换 env vars 的字段",记得调它。

Trap № 4 · Optimizer 在 server 端跑(不在 client)

直觉很容易反过来想 — client 上有 PuLP 也能跑啊。但 catalog 数据(pricing / availability CSV)在 server 端容器里,client 不背这个重,所以 OPTIMIZE 是 server 端 executor 子进程里跑的。这点决定了:(1) catalog 同步策略只发生在 server 上,(2) client 想"先看看代价"得发请求到 server。

Trap № 5 · Plugin 机制隐式调用

register_task_validator · sky/server/plugins.py · plugin_hooks.py — SkyPilot 有完整 plugin 体系,但不在主流程显式调用,靠装饰器 + entry_points + module import 副作用触发。读代码时看到"这函数貌似没人调",先怀疑是 plugin hook。

§ Red lines · threeThe big questions.

读完整套源码,你应该能口头回答这三个大问题。每个红线问题贯穿多个模块,是真正的"懂了"指标。

如果要给 SkyPilot 加一个新云后端(比如 AMD ROCm 集群、或 Nebius / Crusoe 这种 neocloud),要改哪些文件?按什么顺序?

这是对你 AMD 工作直接相关的红线。读完 M5 / M6 / M7 你应该能列出:

  1. sky/clouds/<new>.py — 实现 Cloud 抽象基类的 14 个抽象方法
  2. sky/catalog/<new>_catalog.py — 提供 pricing / availability(给 Optimizer 喂价格矩阵)
  3. sky/provision/<new>/ — 实现节点起停的低层逻辑
  4. sky/clouds/__init__.py — 注册新云别名(让 sky.MyCloud 能用)
  5. sky/check.py — 加上凭证检查(sky check 能识别新云)
  6. (可选)sky/serve/spot_placer.py — 如果新云有 spot,注册放置策略
  7. (可选)sky/dashboard/ — Web UI 里显示新云的图标和元数据

CLAUDE.md 官方的"Adding a new cloud provider"流程是 4 步(前 3 步 + 第 4 步),实际工作量通常落在 1+3+5 — 见下方 Plate VII。

一条 sky launch foo.yaml 命令,从按下回车到 VM 上 run 起来,跨越多少个进程、几次 RPC、几次 SSH?

读完整个 repo 你应该能画一张时序图:

进程:terminal → CLI Python 进程 → SDK 同进程 → server FastAPI 进程 → executor spawn 的子进程 → ray launcher 在 client VM 起子进程 → 云 SDK 调云 API 起 VM → cloud-init 在 VM 上拉 skylet → skylet 起 4 个 gRPC service。共 9 个 Python 进程边界。

RPC / 网络:CLI → server 1 次 HTTP POST + N 次 stream/poll;server → 云 API 数十次;server SSH → VM 数次(rsync workdir、装依赖、提交 job);backend → skylet gRPC 数次。核心路径约 5 类网络协议:HTTP、云厂商 REST API、SSH、gRPC、object storage upload/download。

SkyPilot 的核心抽象到底是什么?Task / Resources / Cluster — 设计者把"状态"放在了哪一层?

三分法明显:

  • Task无状态请求(值对象)— 一次 launch 对应一个 Task,请求结束生命周期结束
  • Resources规格不可变值对象(functional update via .copy(**override))— 描述"想要什么"
  • Cluster带状态实体(DB 在 sky/server/state.pysky/skylet/job_lib.py)— 描述"现在是什么"

这种"请求 / 规格 / 实体"三分法是后续可以借鉴到你的 multi-agent kernel optimization 系统的核心设计原则。你的 kernel optimization 任务(Task)应该是无状态的、可重放的;硬件 + 软件配置(Resources)应该是不可变值对象,可以快速枚举变体;实际跑起来的 agent worker(Cluster 类比)才有状态、需要 DB。

Plate VII — Adding an AMD ROCm cloud file-by-file for Jhin
sky/ ├── clouds/ │ ├── amd_rocm.py [1] NEW │ ├── aws.py · gcp.py · k8s.py ... │ └── __init__.py · register here [4] EDIT ├── catalog/ │ └── amd_rocm_catalog.py [2] NEW ├── provision/ │ └── amd_rocm/ [3] NEW │ ├── __init__.py │ ├── config.py │ ├── instance.py │ └── utils.py ├── check.py · cred discovery [5] EDIT ├── serve/spot_placer.py · optional [6] OPT └── dashboard/ · UI icons [7] OPT implement 14 abstract methods to implement on AmdRocm(Cloud) these are the entry points the Optimizer / Backend will call ▸ regions_with_offering return [Region("us-mi300x-1")] etc. ▸ zones_provision_loop iterate retry zones ▸ instance_type_to_hourly_cost $ per hour for each instance ▸ accelerators_to_hourly_cost $/h for MI300X · MI355X ▸ get_egress_cost data out pricing ▸ make_deploy_resources_variables fill Ray YAML template ▸ get_vcpus_mem_from_instance_type → (192, 2304 GB) for MI300X ▸ get_accelerators_from_instance_type → {"MI300X": 8} ▸ get_default_instance_type default pick logic ▸ _get_feasible_launchable_resources pre-filter for Optimizer ▸ get_credential_file_mounts ~/.amd/credentials mount ▸ _unsupported_features_for_resources mark autostop / spot if unsupported ▸ query_status cluster state from AMD api ▸ get_zone_shell_cmd e.g. echo $AMD_ZONE Tip · copy aws.py or kubernetes.py as scaffold aws.py (1,712 lines) is the most mature reference for "real cloud with regions/zones/spot". kubernetes.py (1,491 lines) is the reference for "no real regions, just one giant pool" — closer to your AMD on-prem cluster. For AMD ROCm on-prem clusters: start from kubernetes.py + selectively re-add zones if you have multi-DC deployment. Estimated effort: 2-3 days for a working prototype, 1-2 weeks for production-grade with all 14 methods + tests.
第 1、2、3 步是新文件,第 4、5 步是修改现有文件,第 6、7 步可选。如果你的 AMD 集群是 K8s 上跑(CSP 风格),最简方式是直接用 SkyPilot 已有的 kubernetes 后端,不需要新写 cloud —— 只需在 K8s 上 deploy SkyPilot helm chart,集群就能被 sky launch --infra k8s 调度。

§ EpilogueWhat we found, and what's next.

SkyPilot 不是一个云抽象层;它是一个三段式分布式系统(client / API server / VM agent),中间用一个 9-stage 流水线把"用户意图"翻译成"VM 上跑的进程"。它的工程价值不是哪一个具体算法(虽然 Optimizer 的 DP+ILP 混合很漂亮),而是边界清晰 — Task 是请求、Resources 是规格、Backend 是执行契约、Cloud 是多态被调用方、Skylet 是节点上的事件循环、Managed Jobs 是两层状态机吸收瞬态故障、SkyServe 是多副本拓扑。每一层的职责锐利分明,加新功能(新云、新调度策略、新 serving 拓扑)有清晰的接入点。

对你 multi-agent kernel optimization 系统的几个直接借鉴:

  • 异步 request_id 模式 — agent 任务投递返回 ID,长跑用 stream/poll 拿结果。直接照搬 sky/server/requests/executor.py 的 spawn 多进程模式。
  • JSON 中转字符串替换 — agent 配置里的 ${MI300X_HOSTS} 模板替换,三行代码搞定,参考 M1 的 _fill_in_env_vars
  • 两层状态机吸收抖动 — kernel benchmark 偶发 OOM / driver hang 应该被下层状态机吸收,agent 任务的上层状态保持稳定。参考 M8 的 JobStatus vs ManagedJobStatus。
  • "daemon 是 wiring, logic 在 lib" — agent worker 本体保持 100 行级别,把 benchmark / profile / analysis 拆成独立 lib。参考 M7 的 skylet 107 行。
  • 插件化 validator / strategy — recovery strategy 可插拔,task validator 可注册。给你的 kernel optimization 加新策略时不要 fork 主代码,靠 plugin 注册。参考 register_task_validatorrecovery_strategy.py

— Fin.