§ 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 呈现,无运行时依赖,离线可读。
— 上表为 master 分支(commit f8bb042)核心文件行数;前 4 个最大的合计已是 SkyPilot 实际"心脏"的一半以上。
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 范例。
主包 + 伙伴目录这种结构在大型 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.py 和 resources.py 互相 import,不用惊讶。
§ M1 · 35 minThree objects, one universe.
三个文件,三个数据类,是 SkyPilot 整个 universe 的元素周期表:
| File | Lines | Classes | Role |
|---|---|---|---|
sky/task.py | 2,142 | 2 | 中枢请求对象(什么要跑) |
sky/resources.py | 2,816 | 2 | 资源规格(在哪跑、怎么跑) |
sky/dag.py | 228 | 3 | Task DAG(多个 Task 怎么编排) |
反直觉的是 — Resources 比 Task 还大。原因是 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 个云全部要覆盖 — 字段就是这么多。
所有 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 序列化/反序列化不是最优,但对配置加载这种"少量、一次性"场景,简洁度胜过性能。
这个模式可以直接搬到你 AMD 的 agent 配置系统 — 需要在嵌套 YAML/JSON 里做 ${MI300X_HOSTS} / ${TRITON_VERSION} 之类的模板替换时,JSON 中转 + regex 比写递归 walker 简洁 10 倍,而且 LLM 也容易 review。
另外两个值得记的点
ManagedSecretRef(line 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_validator(line 36)— SkyPilot 的 plugin 机制典范。一个全局列表 _task_validators,Task.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 选项 — 注意到 infra、cloud、region、zone 是并存的,老接口(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)专门处理"轻量同步请求在协程里的执行"。
"用户调 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 设计可以借鉴。
--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 件事"的契约:
| Stage | Maps to | What happens |
|---|---|---|
| CLONE_DISK | _maybe_clone_disk_from_cluster | 从另一个 cluster 复制磁盘镜像 |
| OPTIMIZE | Optimizer.optimize(dag) | 挑 cloud × region × instance,写入 task.best_resources |
| PROVISION | backend.provision(...) | 真正去云上拉 VM,返回 ResourceHandle |
| SYNC_WORKDIR | backend.sync_workdir(...) | rsync 本地 workdir 到 VM |
| SYNC_FILE_MOUNTS | backend.sync_file_mounts(...) | storage mounts(S3/GCS)+ file mounts |
| SETUP | backend.setup(...) | 跑用户 setup 命令(装依赖) |
| PRE_EXEC | backend.set_autostop(...) | 配置 idle autostop |
| EXEC | backend.execute(...) | 跑用户 run 命令,返回 job_id |
| DOWN | backend.teardown(...) | --down 时拆 cluster |
execution.py:474-505 的注释揭示一个微妙设计:OPTIMIZE 在 per-cluster lock 之外跑(因为 optimize 耗时,不能阻塞别的请求),但 backend 拿到锁后会发现"决策可能已过期"(比如 cluster 刚被别人删了)。补救方法是给 backend 传一个 planner callback — 锁内如果发现需要重 plan,就调它再跑一次 optimizer。
这是"乐观优化 + 临界区兜底"的并发模式,处理"长决策 + 临界区状态可能漂移"的经典做法。
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 + CBC | NP-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
纯二次决策 c[u] · c[v]ᵀ("u 选 r₁ 且 v 选 r₂")让问题不是 ILP 而是更难求解的 quadratic IP。SkyPilot 用经典的 McCormick 线性化:引入辅助变量 e[u,v] 表示"edge 端配置 (r₁, r₂) 是否被同时选中",加 one-hot 约束使 e 与 c 自洽,然后整个问题就成了 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 这条特殊路径,把"基础设施一致"作为硬约束。
§ 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 钩子
主实现 CloudVmRayBackend 在 cloud_vm_ray_backend.py 里,6562 行单文件。它的"主类"CloudVmRayBackend 本身从 line 3038 开始,到 line 6562 — 单个类 3500 行。文件里还有 7 个 helper 类:
| Class | Line | Role |
|---|---|---|
GangSchedulingStatus | 385 | 多节点 gang schedule 的状态枚举 |
FailoverCloudErrorHandlerV1 / V2 | 402 / 529 | provision 失败时的故障转移决策(V1 老接口、V2 新接口) |
RetryingVmProvisioner | 796 | 关键 — 在 zone → region → cloud 三层重试 provision |
SSHTunnelInfo | 1938 | SSH 隧道连接信息 |
CloudVmRayResourceHandle | 1955 | 对外的 ResourceHandle 实现 — 持有 cluster 状态 |
LocalResourcesHandle | 2795 | local-mode 特例 |
SkyletClient | 2843 | ★ backend → skylet 的 gRPC 客户端 |
CloudVmRayBackend | 3038 | 主类 |
名字里的"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:
| Service | Proto | 谁是 client |
|---|---|---|
AutostopServiceImpl | autostopv1 | backend, 配置 autostop |
JobsServiceImpl | jobsv1 | backend, 提交/查 job |
ServeServiceImpl | servev1 | serve controller |
ManagedJobsServiceImpl | managed_jobsv1 | jobs controller |
SkyletClient(cloud_vm_ray_backend.py:2843)就是这些 gRPC service 的客户端 — backend 通过它跟节点上的 skylet 通信。
守护进程本体保持极薄,重逻辑全部下沉到独立模块(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。
§ 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_RESOURCE或FAILED_CONTROLLER。
这种"上层状态吸收下层抖动"的设计同样反映在 recovery_strategy.py(1024 行)里:
| Strategy class | Line | Behavior on preemption |
|---|---|---|
StrategyExecutor | 61 | 抽象基类,定义 recover() 钩子 |
FailoverStrategyExecutor | 815 | 等所有 zones/regions 都试一遍,再换 cloud |
EagerFailoverStrategyExecutor | 936 | 抢占后立即跳到下一个 region,不死磕 |
scheduler.py(466 行)单独控制"controller 进程"的并行度 — 因为每个 managed job 在 controller VM 上是一个独立 Python 进程,太多 controller 会撑爆 VM 内存。
"两层状态机吸收瞬态故障"的设计是你的 multi-agent kernel optimization 系统应该照搬的 — agent 跑 kernel benchmark 时 ROCm 偶发 OOM / driver hang / 节点掉线,底层 task 状态反复抖动,但上层 agent 任务状态应该稳定保持 RUNNING,让 agent 不感知底层故障。recovery_strategy.py 那种"策略可插拔"的接口直接抄。
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。
| File | Lines | Role |
|---|---|---|
controller.py | 297 | service 生命周期 |
replica_managers.py | 1,564 | ★ replica 健康监控 + 自愈 |
autoscalers.py | 1,288 | QueueLengthAutoscaler 等扩缩策略 |
load_balancer.py | 342 | FastAPI 进程,路由 HTTP |
load_balancing_policies.py | 262 | round_robin / least_load / 等 |
spot_placer.py | 281 | replica 在 spot 上的放置策略 |
service_spec.py | 661 | YAML schema |
serve_state.py | 835 | state DB |
serve_utils.py | 1,934 | helpers |
关键发现: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,先记住这几条。
cloud_vm_ray_backend.py 里的 "Ray" 不是分布式训练 Ray,是借 ray.io 的 cluster launcher 管 VM。sky/skylet/ray_patches/ 子目录可以确认 — 那是他们对 Ray cluster launcher 的私有 patch。SkyPilot 早期是 Ray 的扩展,独立后还沿用了 Ray 的 VM 编排底层。
state.py files这是新人必混的:
sky/skylet/job_lib.py— 节点本地 job DBsky/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 时画一张对照表别忘了。
_fill_in_env_vars 不只是字符串替换看似只是 ${VAR} 替换,实际上还涉及 secrets 不写日志、Docker 登录凭证不进 envs、和 file_mounts/storage/service/volumes 的多场景调用。它是个安全边界,不是无脑替换 — 加 plugin 时如果你引入新的"需要替换 env vars 的字段",记得调它。
直觉很容易反过来想 — client 上有 PuLP 也能跑啊。但 catalog 数据(pricing / availability CSV)在 server 端容器里,client 不背这个重,所以 OPTIMIZE 是 server 端 executor 子进程里跑的。这点决定了:(1) catalog 同步策略只发生在 server 上,(2) client 想"先看看代价"得发请求到 server。
register_task_validator · sky/server/plugins.py · plugin_hooks.py — SkyPilot 有完整 plugin 体系,但不在主流程显式调用,靠装饰器 + entry_points + module import 副作用触发。读代码时看到"这函数貌似没人调",先怀疑是 plugin hook。
§ Red lines · threeThe big questions.
读完整套源码,你应该能口头回答这三个大问题。每个红线问题贯穿多个模块,是真正的"懂了"指标。
这是对你 AMD 工作直接相关的红线。读完 M5 / M6 / M7 你应该能列出:
sky/clouds/<new>.py— 实现Cloud抽象基类的 14 个抽象方法sky/catalog/<new>_catalog.py— 提供 pricing / availability(给 Optimizer 喂价格矩阵)sky/provision/<new>/— 实现节点起停的低层逻辑sky/clouds/__init__.py— 注册新云别名(让sky.MyCloud能用)sky/check.py— 加上凭证检查(sky check能识别新云)- (可选)
sky/serve/spot_placer.py— 如果新云有 spot,注册放置策略 - (可选)
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。
三分法明显:
- Task 是无状态请求(值对象)— 一次 launch 对应一个 Task,请求结束生命周期结束
- Resources 是规格不可变值对象(functional update via
.copy(**override))— 描述"想要什么" - Cluster 是带状态实体(DB 在
sky/server/state.py和sky/skylet/job_lib.py)— 描述"现在是什么"
这种"请求 / 规格 / 实体"三分法是后续可以借鉴到你的 multi-agent kernel optimization 系统的核心设计原则。你的 kernel optimization 任务(Task)应该是无状态的、可重放的;硬件 + 软件配置(Resources)应该是不可变值对象,可以快速枚举变体;实际跑起来的 agent worker(Cluster 类比)才有状态、需要 DB。
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_validator和recovery_strategy.py。
— Fin.