仓库地址:github.com/hhk-png/cyc...
本章目标:搞清楚 Tune 的四个抽象(
Tuner/TuneConfig/param_space/search_alg/scheduler)怎么组合、采样器和调度器该选哪个、 以及怎么把 trial 数量换算成 GPU·小时。 读完你应该能设计一次"预算可控、结论可信"的调参实验。
14.1 定位与核心抽象
Ray Tune 解决的问题很朴素:跑很多次训练、每次换一组超参,在预算用完之前把 最好的那组找出来。 朴素做法的三个痛点(串行太慢、并行要自己管 GPU 与失败 重试、结果要自己汇总)正好对应下面这几个抽象:
python
from ray import tune
from ray.tune import RunConfig # ← 注意:是 ray.tune 的 RunConfig
tuner = tune.Tuner(
trainable, # ① 要跑什么(函数 或 Trainable 类)
param_space={...}, # ② 跑哪些组合(搜索空间)
tune_config=tune.TuneConfig(
num_samples=20, # 跑多少组
metric="loss", mode="min", # ③ 怎么判断哪个好
search_alg=..., scheduler=..., # ④ 下一组试什么 / ⑤ 谁该提前停
max_concurrent_trials=4, # 同时跑几个
),
run_config=RunConfig(...), # 存储、失败处理、日志
)
results = tuner.fit() # → ResultGrid
tune.RunConfig 是可用的 (ray/tune/impl/config.py 里有一个 pass-through 包装类,专门负责识别「你用的是不是 ray.tune.RunConfig」)。
⚠️ 真正要小心的是同名不同类 :
ray.tune.RunConfig与ray.train.RunConfig不是同一个类。 给Tuner的是前者,给Trainer的是后者 ------ 搞混了会拿到弃用警告, 或者某些字段静默失效。⚠️ 这是「三件套」而不是一个类 :
RunConfig、CheckpointConfig、FailureConfig在ray.tune与ray.train下各有一套 , 每一套都有自己的 pass-through 包装类。给Tuner传ray.train.*的三个 中的任何一个,都会触发RayDeprecationWarning("...should be imported from ray.tune when passing it to the Tuner")。
pythonfrom ray import tune from ray.train import RunConfig as TrainRunConfig # 给 Trainer 用 from ray.tune import (RunConfig, # 给 Tuner 用 ------ 三个都从这 CheckpointConfig, FailureConfig) tune.Tuner(..., run_config=RunConfig(...))两者字段有重叠(
name/storage_path/failure_config/checkpoint_config), 所以搞混之后往往不报错,只是行为不对 ------ 这类问题最难查。
术语对齐
| 术语 | 含义 |
|---|---|
| trial | 一次"用一组超参跑一遍训练"的尝试 |
| trainable | 被跑的东西:一个函数(def trainable(config): ...)或 Trainable 子类 |
search space(param_space) |
超参的取值范围,值可以是分布对象 |
search algorithm(search_alg) |
决定"下一组超参试什么" |
| scheduler | 决定"哪些 trial 现在就该停" |
ResultGrid |
tuner.fit() 的返回值,可索引、可筛选的结果集合 |
Result |
单个 trial 的结果:metrics / checkpoint / error / path |
关键区分:search_alg 管"往哪搜",scheduler 管"什么时候放弃" ------ |
|
| 两者正交,可以同时用(比如 Optuna 搜 + ASHA 早停)。 |
python
results = tuner.fit()
best = results.get_best_result(metric="loss", mode="min")
df = results.get_dataframe() # 所有 trial 的指标表 → pandas
for r in results:
if r.error: # 失败的 trial 也在里面
print(r.config, r.error)
ResultGrid 里失败的 trial 不会消失 ,它们带着 error 字段留在那儿。 统计"有多少 trial 是被 OOM 干掉的"是排查调参管道问题的第一步。
14.2 稳定性提示
ray.tune.Tuner在源码里标注stability="beta"(ray/tune/tuner.py上的@PublicAPI(stability="beta"));TuneConfig和create_scheduler同样是 beta。也就是说 Tune 的主入口本身还没承诺稳定。ray.tune.run仍然存在 ,但官方推荐用Tuner+tuner.fit()。 检索没有找到明确的"tune.run已弃用"的官方声明 ------ 准确表述是:tune.run未删除、但已不是推荐路径。
还有一组容易踩的历史包袱:
| 老 API | 现状 |
|---|---|
tune.report(...) / tune.checkpoint_dir(...) |
Ray 2.0 起弃用 ,改用 ray.tune.report + Checkpoint |
ray.air.session.report(...) |
⚠️ 方向别搞反 :它是 2.0 起推荐的新 API (不是被弃用的那个),到 **Train V2(2.51 起)**才被 ray.train.report / ray.tune.report 取代 ------ 所以 V1 教程里出现它是正常的 |
在 Tune 函数里调 ray.train.get_context() / get_checkpoint() / report |
已升级为 DeprecationWarning(PR #57810),要用 ray.tune.* 对应版本 |
Checkpoint 的导入位置 |
传给 ray.tune.report 时必须从 ray.tune 导入 |
实践建议 :Tune 的训练函数里 report / get_checkpoint / get_context 一律从 ray.tune 取;Ray Train 的训练循环里一律从 ray.train 取。 位置错了不会立刻炸,但会淹没在警告里。
14.3 搜索空间
param_space 的每个值可以是常量,也可以是一个分布对象:
python
from ray import tune
param_space = {
"optimizer": tune.grid_search(["adam", "sgd"]), # 枚举:每个值都试
"activation": tune.choice(["relu", "gelu", "silu"]), # 离散选择
"dropout": tune.choice([0.0, 0.1, 0.2, 0.3]), # 等概率;要不等概率得用 sample_from
"momentum": tune.uniform(0.8, 0.99), # 连续均匀
"lr": tune.loguniform(1e-5, 1e-1), # 跨数量级必须用对数
"batch_size": tune.randint(16, 129), # 注意上界是开区间
"num_layers": tune.qrandint(2, 9, 2), # 量化:2,4,6,8
"hidden": tune.qlograndint(64, 1025, 64), # 对数整数
"beta": tune.sample_from(lambda spec: spec.config.lr * 10),
}
几个必须知道的细节:
tune.randint(lower, upper)的上界是开区间 (range语义)。 写成randint(16, 128)你永远拿不到 128。loguniform和uniform不是一回事 。lr用uniform(1e-5, 1e-1)时, 采样值有 ≈99% 的概率落在 1e-3 以上((0.1-0.001)/(0.1-1e-5))------ 你实际上几乎没搜小学习率区间。tune.choice没有权重参数 ,它只做等概率选择。要"不等概率"得自己写tune.sample_from(lambda spec: ...)。grid_search会展开成笛卡尔积 。两个各 5 值的grid_search= 25 个 trial,并且和num_samples相互作用 (网格固定,num_samples控制每个 网格点重复几次)。用之前先算一下 trial 总数。sample_from依赖执行顺序 ,只有部分搜索算法支持;用随机搜索时它按param_space的字典顺序求值。
搜索空间是最大的成本杠杆
一个常被忽略的事实:搜索空间的维度比搜索算法重要得多。 8 个连续超参 各做 10 点采样 = 10^8 种组合;而随机搜索在超过 ~10 维之后基本退化成碰运气。 所以工程上的第一优先级是把维度砍下来:
| 该固定就固定 | 该搜就搜 |
|---|---|
| 架构(层数/宽度)、优化器类型 ------ 先定一个 baseline | 学习率(跨数量级,影响最大) |
| 数据增强强度、训练步数 ------ 单独做消融 / 固定预算才公平 | weight decay、dropout、warmup 比例 |
14.4 采样器(search_alg)
| 采样器 | 导入 | 特点 | 何时用 |
|---|---|---|---|
BasicVariantGenerator |
默认,无需指定 | 随机搜索 / 网格搜索 | 维度低、预算小、要可复现 |
OptunaSearch |
ray.tune.search.optuna |
TPE,成熟、生态好 | 中等维度、预算中等(首选) |
HyperOptSearch |
ray.tune.search.hyperopt |
TPE 的经典实现 | 与已有 HyperOpt 流程对接 |
AxSearch |
ray.tune.search.ax |
高斯过程 / BoTorch | 低维(<10)、每次评估很贵 |
BayesOptSearch |
ray.tune.search.bayesopt |
经典贝叶斯优化 | 需要 bayesian-optimization 包 |
关键取舍 :贝叶斯类方法(Optuna TPE / Ax / BayesOpt)需要串行或半串行的 评估 ,至少要等若干个 trial 出结果才能建模。你并行开 32 个 trial 时,前 32 个 基本是随机的 ------ 贝叶斯的价值被并行度稀释。所以 低并发 + 贝叶斯 通常优于 高并发 + 随机 (前提是单次评估足够贵);反过来,单次评估只要几分钟时, 高并发随机搜索 + 好调度器往往最快、最省心。
python
from ray.tune.search.optuna import OptunaSearch
tuner = tune.Tuner(trainable, param_space=param_space,
tune_config=tune.TuneConfig(
num_samples=50, metric="loss", mode="min",
search_alg=OptunaSearch(metric="loss", mode="min"),
max_concurrent_trials=4)) # ← 别开太高,否则 TPE 建模用不上
14.5 调度器(scheduler)
调度器决定提前停掉没希望的 trial。这是省预算最直接的手段。
| 调度器 | 原理 | 适用 | 注意 |
|---|---|---|---|
ASHA (ASHAScheduler) |
异步逐次减半:所有 trial 先跑少量 step,按指标淘汰一大部分(默认淘汰 3/4),剩下的跑更久,层层递进 | 默认首选,尤其 trial 多、单次贵 | 需要按 step 上报指标;grace_period 要给够 |
HyperBand (HyperBandScheduler) |
同步版逐次减半:分轮次,每轮内所有 trial 必须等齐 | 评估时间方差大 | 同步 → 有 straggler 问题,利用率低于 ASHA |
PBT (PopulationBasedTraining) |
进化:定期把差 trial 的参数换成好 trial 的,并扰动 | 训练过程中的超参(lr 调度、熵系数) | 会"改配置",不是纯粹早停;要 perturbation_interval |
| MedianStoppingRule | 指标低于同 step 中位数就停 | 最简单、最容易解释 | 比 ASHA 保守,省得少 |
| FIFOScheduler | 不早停,来了就跑(默认值) | 调试 / 评估次数很少 | --- |
ASHA 到底怎么工作
ini
r=1 ┌ t1 ─┐┌ t2 ─┐┌ t3 ─┐┌ t4 ─┐┌ t5 ─┐┌ t6 ─┐┌ t7 ─┐┌ t8 ─┐ 各跑 1 个 rung
└─────┘└─────┘└─────┘└─────┘└─────┘└─────┘└─────┘└─────┘
↓ 按指标排序,淘汰后 75%(reduction_factor=4 的默认行为;不是 50%)
r=2 ┌ t1 ────────┐┌ t3 ────────┐ 各跑 2 个 rung
└────────────┘└────────────┘
↓ 再淘汰 3/4
r=4 ┌ t1 ─────────────────────┐ → best
└─────────────────────────┘
⚠️ 默认淘汰比例是 3/4 而不是 1/2 ------ 图里的
reduction_factor=4就是"每级只留 1/4"。想要经典的"逐次减半"要显式写reduction_factor=2。本书早先的图与文字用了 50%,与同节 "默认 4(每级淘汰 3/4)"的说法矛盾 ,这里以reduction_factor为准。
- 异步:某个 trial 跑完一个 rung 就能升到下一级,不用等其他 trial ------ 这是它比 HyperBand 高效的原因(没有同步 barrier)。
- 总资源大致是
num_samples × grace_period × reduction_factor / (reduction_factor - 1)这个量级,远小于让所有 trial 跑满。 reduction_factor默认 4(每级淘汰 3/4)。调成 2 更保守;调成 4 以上 淘汰太狠,容易误杀"慢热"的配置。
该选哪个
| 你的情况 | 建议 |
|---|---|
| 不知道该选什么 | ASHA (grace_period 约为单次评估预算的 1/10) |
| trial 数少(<10)、评估极贵 | 不用调度器,或 MedianStoppingRule |
| 要调训练过程中的超参(lr schedule、RL 的熵系数) | PBT |
| 评估时间差异极大(有的 1 分钟有的 1 小时) | ASHA(HyperBand 会被 straggler 拖死) |
| 只是跑网格、要看完整曲线 | FIFOScheduler(不早停) |
| 想复现论文里的同步逐次减半 | HyperBandScheduler |
python
from ray.tune.schedulers import ASHAScheduler
scheduler = ASHAScheduler(metric="loss", mode="min",
max_t=100, grace_period=10, reduction_factor=4)
grace_period 设太小会误杀:前 10 步 loss 高但后面会降的配置会被提前 干掉;设太大则省不下预算。经验值是"总步数的 10%",且要和 lr warmup 长度对齐。
14.6 报告指标与检查点
现代写法
python
from ray import tune
from ray.tune import Checkpoint
def trainable(config):
model = build_model(config)
for step in range(100):
loss = train_one_step(model, config)
tune.report({"loss": loss, "step": step}) # 上报 → 调度器才有得看
with tempfile.TemporaryDirectory() as d: # 结束时存 checkpoint
torch.save(model.state_dict(), os.path.join(d, "model.pt"))
tune.report({"loss": loss}, checkpoint=Checkpoint.from_directory(d))
要点:上报频率决定调度器的反应速度 ------ 这是这一节唯一需要你主动决策的 参数。一个 epoch 报一次,ASHA 就只能按 epoch 粒度淘汰;每个 epoch 要 10 分钟 时,早停就废了一半。另外 Checkpoint 必须从 ray.tune 导入 (传给 tune.report 时)。不存 checkpoint 也能调参,只要你不打算 "用最优配置再训一次"或做容错恢复。
🔴 ray.tune.Checkpoint 只有"目录"一个构造器 (这条本书早先写错过): ray/tune/trainable/trainable_fn_utils.py 里写得很清楚 ------
python
@_copy_doc(TrainCheckpoint)
class Checkpoint(TrainCheckpoint):
# NOTE: This is just a pass-through wrapper around `ray.train.Checkpoint`
# in order to detect whether the import module was correct `ray.tune.Checkpoint`.
pass
它是 ray.train.Checkpoint 的一个 pass-through 子类 (存在的唯一目的是 "从哪个模块导入的"检测),所以它的能力集与 ray.train.Checkpoint 完全一致 : Checkpoint.from_directory(local_dir) 与 Checkpoint(path=...) 可用, 而 from_dict / to_dict / from_uri / uri 这些名字在 2.58 已被移除 ------ 调用会抛 AttributeError(带一段迁移提示)。本书早先把 from_dict({...}) 列成"最常用的构造器"是错的,见第 13 章 §13.3 里那张 "已被元类拦截的名字"表。
要上报一个小字典(比如 {"step": n})不要塞进 checkpoint ------ 它本来就该走 tune.report 的 metrics 字典。真要落成文件, 自己写进目录再 from_directory。
metric / mode 与严格检查
tune.TuneConfig(metric="loss", mode="min") 表示越小越好;mode="max" 用于 accuracy 这类指标。如果你 report 了多个指标、但 metric 指定的那个某次 report 里没出现,Tune 默认直接报错。相关环境变量:
| 环境变量 | 作用 |
|---|---|
TUNE_DISABLE_STRICT_METRIC_CHECKING=1 |
关掉"每次 report 都必须含 metric"的严格检查 |
TUNE_DISABLE_AUTO_CALLBACK_LOGGERS |
关掉自动挂上的日志 callback(注意不是 ..._CALLBACK_SYNCER) |
TUNE_RESULT_BUFFER_LENGTH |
结果缓冲长度,影响内存与 dashboard 刷新 |
TUNE_MAX_PENDING_TRIALS_PG |
放置组模式下同时挂起的 trial 数 上限。这才是真正的兜底并发开关;RAY_TUNE_MAX_CONCURRENT_TRIALS 这个环境变量不存在,别照抄 |
实践建议 :如果 trial 可能中途抛异常退出、而你想让它"至少贡献已跑出的 指标",就该设 TUNE_DISABLE_STRICT_METRIC_CHECKING=1 ------ 否则一个格式不 一致的 report 会让整个实验失败。
14.7 与 Ray Train 的组合(HPO + 分布式训练)
写法在第 13.8 节讲过,这里只说取舍 。Tune 调分布式训练时成本会相乘:
scss
总 GPU·小时 ≈ num_samples × (每 trial 的 GPU 数) × (每 trial 的墙钟小时)
例:20 个 trial × 每个 4 张 GPU × 每个 0.5 小时 = 40 GPU·小时
加 ASHA 早停后实际可能只花 1/3 到 1/2(大部分 trial 跑不到 30 分钟就被砍了) ------ 这就是为什么分布式训练场景下调度器不是可选项。
并发控制:一个容易算错的参数
正确算法是 max_concurrent_trials = floor(集群总 GPU 数 / 每个 trial 的 GPU 数)。 比如 8 张卡、每个 trial 要 4 张卡 → max_concurrent_trials=2。
常见的错 是把它设得比这个值大。后果不是"跑得更快",而是一部分 trial 卡在 PENDING,占着 trial 名额不干活 ------ dashboard 上看起来"都在跑",实际 只有 2 个在推进。更糟的是它会让依赖"看到完整 trial 结果"的搜索算法 (TPE/贝叶斯)拿到一堆空结果。
另一种控制方式:给 trainable 声明资源,让 Tune 自己算能开几个。
python
# ✅ 正确:用 tune.with_resources 包住 trainable
tuner = tune.Tuner(
tune.with_resources(objective, {"cpu": 4, "gpu": 1}),
param_space={...},
)
⚠️
TuneConfig没有resources_per_trial这个字段 ------ 那是tune.run()时代的参数,照抄会直接TypeError。TuneConfig的实际字段是mode/metric/search_alg/scheduler/num_samples/max_concurrent_trials/time_budget_s/reuse_actors/trial_name_creator/trial_dirname_creator/chdir_to_trial_dir。 Tuner API 下声明资源只有两条路: ①tune.with_resources(trainable, {"cpu": 4, "gpu": 1}); ② trainable 本身是 Ray Train 的Trainer时,走它的ScalingConfig。
with_resources 和 max_concurrent_trials 选一个用就行 : 前者是"每个 trial 要多少",后者是"同时最多几个",两个都设容易互相矛盾。 并且 max_concurrent_trials 会与 ConcurrencyLimiter 互斥 ------ 如果你已经把 search_alg 包成了 ConcurrencyLimiter, 再设 max_concurrent_trials 会直接抛异常(官方 docstring 明确写了这一点)。 只用一个。
14.8 成本控制实践
调参最常见的失败不是"搜不到好结果",而是"烧了三天 GPU 得出一个不可信的结论"。
第 1 步:smoke run。 先用 num_samples=2、max_t=5 跑通,确认训练函数 能跑、能上报、能存 checkpoint,mode 没搞反,以及每个 trial 的实际耗时和 GPU 占用。几分钟的投入能挡掉 80% 的低级错误。
第 2 步:先定预算,再反推 trial 数。
ini
可用预算 = 40 GPU·小时
单个 trial 满配成本 = 4 GPU × 0.5 h = 2 GPU·小时
ASHA 平均只跑满配的 40% → 每 trial 实际 ≈ 0.8 GPU·小时
→ num_samples ≈ 40 / 0.8 = 50
一定不要反过来 (先定 num_samples=200 再看要花多少钱)。
第 3 步:粗搜 → 精搜。
| 阶段 | 搜索空间 | num_samples |
调度器 | 单 trial 预算 |
|---|---|---|---|---|
| 粗搜 | 宽(lr 1e-5--1e-1) | 大(30--100) | ASHA 激进 | 短(总步数的 10--20%) |
| 精搜 | 窄(围绕粗搜最优 ± 一个数量级) | 小(10--20) | ASHA 保守 | 长(60--100%) |
粗搜单 trial 预算短是因为排序比精度重要 ------ 你只需要知道哪个数量级的学习率 好,不需要知道它最终能到多少 loss。
第 4 步:留出验证集。 Tune 报的 best 是在搜索集上选出来的 ,天然有 选择偏差,num_samples 越大越明显。务实做法:拿粗搜的 top-3 配置在没参与 搜索的验证集上各跑一次,再决定交付哪个。
成本换算速查
| 你改的旋钮 | 成本变化 |
|---|---|
num_samples 翻倍 |
成本翻倍(线性) |
max_concurrent_trials 翻倍 |
墙钟时间减半(资源够的话),成本不变 |
| 每个 trial 的 GPU 数翻倍 | 成本翻倍,墙钟通常减不到一半 |
开 ASHA(grace_period=10% 总步数,reduction_factor=4) |
成本降到 1/3 -- 1/2 |
最重要的一条 :max_concurrent_trials 只影响墙钟时间,不影响总成本。 想省钱只有三个办法 ------ 少搜、早停、缩短单次评估;想省时间只有一个办法 ------ 提高并发(前提是资源够、且搜索算法能容忍)。
14.9 完整 HPO 示例
不依赖 GPU、不需要 PyTorch,只用 NumPy 和一个手写的目标函数 ------ 可以直接跑起来 看调度器怎么砍 trial。目标函数故意做成"慢热"(早期 loss 高、后期会降), 用来观察 grace_period 怎么影响误杀。
python
"""模拟"每个 trial 是一次训练",找使 loss 最小的 (x, y, lr)。"""
import time
import numpy as np
from ray import tune
from ray.tune.schedulers import ASHAScheduler
OPTIMUM = np.array([0.3, -0.7]) # 真实最优(现实中你不知道)
def objective(x, y, lr, step):
"""带噪声的二次函数;lr 太小 → 收敛慢(模拟慢热),step 越大噪声越小。"""
progress = min(1.0, lr * step / 20.0)
base = (x - OPTIMUM[0]) ** 2 + (y - OPTIMUM[1]) ** 2
return base * (2.0 - progress) + 0.05 * (1.0 - progress) * np.random.rand()
def trainable(config):
for step in range(1, 101):
loss = objective(config["x"], config["y"], config["lr"], step)
tune.report({"loss": loss, "step": step}) # 每 step 上报,调度器才反应得过来
time.sleep(0.001) # 模拟计算耗时
param_space = {"x": tune.uniform(-2.0, 2.0),
"y": tune.uniform(-2.0, 2.0),
"lr": tune.loguniform(1e-3, 1.0)} # 跨数量级 → 必须 loguniform
tuner = tune.Tuner(
trainable, param_space=param_space,
tune_config=tune.TuneConfig(
num_samples=40, metric="loss", mode="min",
scheduler=ASHAScheduler(metric="loss", mode="min",
max_t=100, # 单 trial 最多 100 step
grace_period=20, # 至少 20 step 才允许淘汰
reduction_factor=4),
max_concurrent_trials=8),
)
results = tuner.fit()
best = results.get_best_result(metric="loss", mode="min")
print("best config:", best.config, "最优解附近:", OPTIMUM)
steps = [r.metrics.get("step", 0) for r in results if not r.error]
print(f"平均实际跑了 {np.mean(steps):.1f}/100 step,"
f"全跑满的成本是现在的 {100 / np.mean(steps):.1f} 倍")
print(results.get_dataframe()[["config/lr", "config/x", "loss", "step"]]
.sort_values("loss").head(5))
跑完会看到两件事:best.config 的 lr 偏向 loguniform 区间的中上部(lr 太小的 trial 是"慢热"的,会被 ASHA 提前淘汰 ------ 这就是 grace_period 的 取舍);大量 trial 的 step 停在 20,成本只有"全跑满"的 1/3 左右。
换成真实训练时,把 objective() 换成 model.train() + loss.backward()、 time.sleep 换成真实 GPU 计算、加上 checkpoint=Checkpoint.from_directory(d)、 把 max_concurrent_trials 换成 tune.with_resources(objective, {"gpu": 1}) 即可 (⚠️ 不是 TuneConfig(resources_per_trial=...) ------ 那个字段不存在,见 §14.7)。 唯一别改的是上报频率 ------ 报得太稀,早停就失效了。
14.10 与 mini-ray 的关系
mini-ray 没有实现 Ray Tune (README 的"明确不做"清单里列了 Ray Data/Train/Tune/Serve/RLlib)。但 Tune 的核心机制并不神秘, 就是 Ray Core 几个原语的组合:
| Tune 的组件 | 对应的 Ray Core 原语 | mini-ray 里的位置 |
|---|---|---|
| trial(一次训练尝试) | 一个 actor 或一个远程函数 | actor.py / remote.py |
with_resources / max_concurrent_trials |
资源模型 + 调度队列 | scheduler.py |
| 每个 trial 上报指标 | actor 方法调用 + driver 收集 | raylet_client.py |
| 失败的 trial 自动重试 | 任务重试 / actor 重启 | test_fault_tolerance.py |
ResultGrid 汇总 |
driver 侧的 ray.get 聚合 |
object_ref.py |
| 早停(调度器) | 纯 driver 侧逻辑 (发 ray.cancel) |
ray.cancel 在 miniray/__init__.py(由 raylet.py / raylet_client.py 支撑) |
最后一行值得单独说:调度器本身不需要任何分布式原语 。ASHA 的全部逻辑就是 "读各 trial 的最新指标 → 排序 → 对后一半调 ray.cancel(ref, force=True)":
python
# mini-ray 风格的玩具 ASHA 骨架(伪代码)
import miniray as ray
refs = [trainable.remote(cfg) for cfg in sample_configs(40)]
for rung in [20, 40, 80, 100]:
ray.wait(refs, num_returns=len(refs), timeout=rung) # 等到这一级
metrics = ray.get([m.remote(r) for r in refs]) # 取最新指标
keep = top_half(refs, metrics) # 排序留一半
for r in set(refs) - set(keep):
ray.cancel(r, force=True) # 淘汰
refs = keep
和真实 ASHA 的差别只在:真实的调度器是异步 的(不等齐)、把 grace_period / reduction_factor 参数化、并和 checkpoint/恢复机制对接。 可运行的对照物见 examples/06_fault_tolerance.py 与 tests/test_core.py 里的 ray.cancel 用例。
14.11 Stopper:和调度器并列的另一条"提前停"路径
§14.5 的调度器是跨 trial 比较 之后决定砍谁;Stopper 是单个 trial 自己满足 条件就停 ,或者让整个实验停。两者可以同时用,也可以在不用调度器时单独用。
python
from ray import tune
from ray.tune import RunConfig
from ray.tune.stopper import (CombinedStopper, MaximumIterationStopper,
Stopper, TimeoutStopper, TrialPlateauStopper)
tuner = tune.Tuner(
trainable, param_space=param_space,
run_config=RunConfig(stop=CombinedStopper(
# 平台期:指标在 num_results 次上报内标准差小于 std 就停掉这个 trial
TrialPlateauStopper(metric="loss", mode="min",
std=0.01, num_results=4, grace_period=4),
MaximumIterationStopper(max_iter=100), # 最多 100 次迭代
TimeoutStopper(timeout_s=600), # 600 秒后停掉整个实验
)),
tune_config=tune.TuneConfig(num_samples=20, metric="loss", mode="min"),
)
| Stopper | 停谁 | 判据 | 关键参数 |
|---|---|---|---|
TrialPlateauStopper |
单个 trial | 指标在 num_results 次上报内的标准差 < std |
metric / mode / std(默认 0.01)/ num_results(默认 4)/ grace_period(默认 4)/ metric_threshold |
MaximumIterationStopper |
单个 trial | 迭代数到上限 | max_iter |
TimeoutStopper |
整个实验 | 墙钟超时 | timeout_s |
ExperimentPlateauStopper |
整个实验 | 全局最优指标进入平台期 | patience 等 |
CombinedStopper |
组合 | 任一子 stopper 触发即触发(OR) | 位置参数传多个 stopper |
FunctionStopper / NoopStopper |
--- | 用一个函数当 stopper / 什么都不做 | --- |
自定义 Stopper 只需要实现两个方法:
python
class MyStopper(Stopper):
def __call__(self, trial_id: str, result: dict) -> bool:
"""返回 True → 停掉这个 trial。result 就是最近一次 report 的 dict。"""
return result.get("loss", 1e9) < 1e-3
def stop_all(self) -> bool:
"""返回 True → 停掉整个实验(所有 trial)。"""
return self._budget_exhausted
⚠️ 两个容易搞混的点:
TimeoutStopper停的是整个实验,不是单个 trial。 想限制"每个 trial 最多 跑多久",要么在训练函数里自己数墙钟并raise,要么用MaximumIterationStopper配合可控的每步耗时。stop_all()一返回 True,整个实验就结束了 ------ 包括还没跑完的 trial。要 "优雅收尾"就得自己在stop_all()之前做检查,否则会被当成一次失败退出。
stop= 的字典简写
Tune 支持用字典表达"指标到阈值就停"。键是你在 report 里报过的指标名 (也包括 training_iteration、time_total_s 这类自动填充字段),值是阈值:
python
tuner = tune.Tuner(
trainable, param_space=param_space,
run_config=RunConfig(stop={"training_iteration": 20, # 最多 20 轮
"mean_accuracy": 0.95}), # 或准确率到 0.95
)
⚠️ 这里有个语义陷阱:字典形式假定指标"越大越好" ------ trial 在指标超过 阈值时停。想表达"loss 低于 0.01 就停",用 TrialPlateauStopper(metric="loss", mode="min", metric_threshold=0.01) 或自定义 Stopper,别指望字典。time_budget_s 本质就是自动装了一个 TimeoutStopper (该参数现在该往哪儿传见 §14.16)。
未确认 :任务材料里提到
stop={"metric": "loss", "mode": "min"}这种 "指定指标 + 方向"的简写。本次检索到的 tune-stopping 官方文档里,字典形式是 "指标名 → 阈值",而metric/mode是TuneConfig的参数、不是stop的键。 该简写是否存在未确认,本节不建议使用。
14.12 tune.Callback:把实验过程钩出来
前面所有"上报 → 调度器反应"的链路都是单向 的:训练函数往 Tune 里塞指标, Tune 决定停不停。Callback 是唯一一个在 Tune 内部事件上挂钩子的入口。
python
from ray import tune
from ray.tune import Callback, RunConfig
class EarlyStopWatcher(Callback):
def __init__(self):
self.stopped, self.done = [], []
def on_trial_start(self, iteration, trials, trial, **info):
print(f"[开始] {trial.trial_id} {trial.config}")
def on_trial_complete(self, iteration, trials, trial, **info):
# 被调度器砍掉 / 被 Stopper 停掉 / 正常跑完,走的都是这里,
# 区别只在 trial.last_result 与你自己的迭代上限
self.done.append(trial.trial_id)
def on_trial_error(self, iteration, trials, trial, **info):
print(f"[失败] {trial.trial_id}: {trial.error}")
def on_checkpoint(self, iteration, trials, trial, checkpoint, **info):
print(f"[检查点] {trial.trial_id} → {checkpoint.path}")
def on_experiment_end(self, trials, **info):
print(f"结束:{len(self.done)} 个 trial 走完")
tuner = tune.Tuner(trainable, param_space=param_space,
run_config=RunConfig(callbacks=[EarlyStopWatcher()]))
| 钩子 | 触发时机 | 常见用途 |
|---|---|---|
on_trial_start |
trial 起来之后 | 打日志、记录启动顺序 |
on_trial_result |
每次 report 之后 |
自定义实时监控(它会非常频繁) |
on_trial_complete |
trial 结束(正常 / 被砍 / 被停) | 统计早停命中率、汇总指标 |
on_trial_error |
trial 抛异常 | 报警、把失败配置落盘 |
on_trial_save / on_checkpoint |
trial 存了 checkpoint | 额外搬运、校验 checkpoint |
on_trial_restore |
trial 从 checkpoint 恢复 | 恢复路径的可观测性 |
on_experiment_end |
实验结束 | 生成报告、释放外部资源 |
几个必须知道的点:
- 回调挂在
RunConfig(callbacks=[...])上 ,不是TuneConfig------ 放错了不 会报错,只是永远不触发。 - 回调在 driver 进程里串行执行 。在
on_trial_result里做重活(写数据库、 发 HTTP)会直接拖慢整个实验;重活请丢队列或攒批。 - "被早停"不是一个独立事件 。调度器砍 trial 走的就是
on_trial_complete, 且error is None------ 要靠trial.last_result和已知的迭代上限去区分"跑完了" 与"被砍了"。这是拿到早停通知的唯一入口。 - 自定义指标与模型产物 :
report的metrics可以是任意可序列化字典(嵌套 dict 会被展平成a/b,方便get_dataframe()),但调度器只看metric指定 的那一个标量 。导出的模型产物(ONNX、tokenizer、config.json)走Checkpoint.from_directory(d),让 Tune 管搬运与保留策略,不要塞进 metrics ------ metrics 会写进每条 result 与 JSON/CSV,塞大对象会让结果文件爆炸。 - Tune 自带的 TensorBoard / CSV / JSON logger 本身就是用
Callback实现的, 可用TUNE_DISABLE_AUTO_CALLBACK_LOGGERS=1关掉。
14.13 with_resources / with_parameters:给 trainable 挂东西
这两个函数不改变搜索算法,改变的是**"trainable 这个对象本身携带什么"**。它们 可以嵌套,且优先于 TuneConfig 里的同名设置:
python
from ray import tune
def train_fn(config, train_data, val_data, model_ref):
... # train_data / val_data / model_ref 由 with_parameters 注入
trainable = tune.with_resources(
tune.with_parameters(train_fn,
train_data=train_ds, # 大对象:数据集
val_data=val_ds,
model_ref=model_ref),
resources={"cpu": 4, "gpu": 0.5}, # 支持小数 GPU
)
tuner = tune.Tuner(trainable, param_space=param_space)
| API | 作用 | 什么时候用 |
|---|---|---|
tune.with_resources(trainable, resources=...) |
把资源需求绑在 trainable 上 | 一个实验里不同 trainable 资源需求不同;或你要交付一个自带资源声明的 trainable 对象 |
tune.with_parameters(trainable, **kwargs) |
把固定参数 / 大对象注入 trainable | 参数不是超参、不该被搜,也不适合放进 param_space |
with_parameters 的代价要说清楚 :它把这些对象放进 Ray object store , 每个 trial 通过 object ref 取用。好处是大对象只传一次、所有 trial 共享 ------ 比闭包(每个 trial 各自序列化一份)或写进 param_space(会进每条结果记录)都划算。 代价是:
- 占对象存储内存。放一个 50 GB 的数据集进去,会直接吃满对象存储;
- 恢复实验时必须重新指定 。object ref 绑在当前 Ray 会话 上,换集群、换 脚本、对象被 GC 之后,
Tuner.restore()会报错并列出缺失的with_parameters对象。修法不是 给restore传with_parameters=(它没有 这个形参), 而是把重新包好的 trainable 传进去:tune.Tuner.restore(path, trainable=tune.with_parameters(fn, **same_kwargs)); - 巨型数据集不该走这条路 ,应该用 Ray Data 或共享存储路径(
/mnt/...、s3://...),让每个 trial 自己按需读。
with_resources与TuneConfig的分工 (⚠️ 前提是TuneConfig没有resources_per_trial字段 ------ 见 §14.7):
- 声明资源 → 只有
tune.with_resources(trainable, {...})这一条路;- 声明并发/搜索算法/调度器 →
TuneConfig。需要"同一个
Tuner里不同 trainable 拿不同资源"时更是只能用它 ------ 因为with_resources是包在 trainable 上的,天然支持按 trainable 区分。
14.14 checkpoint 的保留与清理
默认行为是全都留着 ------ 一个 50 trial、每轮存一次的实验,几天就能把盘写满, 大量时间花在往存储同步没用的东西上。控制点有两个:CheckpointConfig(保几个、 保哪几个)和 RunConfig(存在哪、叫什么)。
python
from ray import tune
from ray.tune import CheckpointConfig # ⚠️ 从 ray.tune 导入,不是 ray.train
# (同名不同类,传错会吃弃用警告)
from ray.tune import RunConfig
run_config = RunConfig(
name="lr-sweep-01",
storage_path="/mnt/shared/ray_results", # 必须全节点可访问
checkpoint_config=CheckpointConfig(
num_to_keep=3, # 只留 3 个
checkpoint_score_attribute="loss", # 按哪个指标挑(得是 metrics 里的键)
checkpoint_score_order="min", # min:留指标最低的 3 个
),
)
| 字段 | 含义 | 注意 |
|---|---|---|
num_to_keep |
最多在盘上留几个 checkpoint | None = 全留;必须 None 或 >= 1 ------ 给 0 或负数会直接 ValueError(ray/train/v2/api/config.py 的 __post_init__) |
checkpoint_score_attribute |
用哪个指标给 checkpoint 打分 | 必须是 checkpoint 字典里 的数值键;不设 = 保留最近的 N 个 |
checkpoint_score_order |
"max" / "min" |
方向要和 mode 一致,别搞反;默认是 "max" ,取值只接受这两个(其它值 ValueError) |
checkpoint_frequency |
❌ 已弃用 (2.58 里是 _DEPRECATED 哨兵) |
传了会抛 DeprecationWarning ,不是"静默忽略"。V2 下"多久存一次"由你的训练函数自己决定 ------你想存就调 ray.train.report(..., checkpoint=...) |
checkpoint_at_end |
❌ 已弃用 | 同上,传了就抛。源码里的原文是 "checkpoint_at_end is deprecated since it does not apply to user-defined training functions." ------ 训练函数是你写的,结束时的 checkpoint 也该由你自己报 |
🔴 本书早先这两行写错了 (原来把
checkpoint_frequency/checkpoint_at_end列成可用字段,还配了"存太勤会成瓶颈"的调参建议)。在 V2(2.51 起默认开启) 下它们不是"还能用但已弃用",而是传进去就抛 :ray/train/v2/api/config.py的CheckpointConfig.__post_init__里对这两个字段 都是raise DeprecationWarning(...)。想控制存 checkpoint 的节奏, 唯一的地方是你的训练函数 (ray.train.report的调用频率)------ 这也和第 13 章 §13.3 说的"上报频率决定一切"是同一件事。
三条实践经验:
- 调参实验里 checkpoint 的作用被高估了 。只需要"用最优配置再训一次"或做 容错恢复时,
num_to_keep=1+ 指定打分指标是最省事的组合 ------ 留最优一个, 其余全删。 num_to_keep单独设基本没用 。不配checkpoint_score_attribute时它保留的 是最近的 N 个,通常不是你要的那个。storage_path要给共享存储 。多节点下/tmp是各节点各自的,恢复时会 找不到对象(§14.16)。
14.15 类式 Trainable 与 reuse_actors
前面所有例子都是函数式 trainable (def trainable(config))------ 简单、够用。 但有两种情况需要类式 Trainable:
python
from ray.tune import Trainable
class MyTrainable(Trainable):
def setup(self, config): # 每个 trial 开始时调一次
self.model = build_model(config)
self.step_count = 0
def step(self) -> dict: # 一次迭代,返回值即 report 的内容
loss = self.model.train_one_step()
self.step_count += 1
return {"loss": loss, "training_iteration": self.step_count}
def save_checkpoint(self, checkpoint_dir: str) -> str:
torch.save(self.model.state_dict(), f"{checkpoint_dir}/model.pt")
return checkpoint_dir # 返回目录或 dict
def load_checkpoint(self, checkpoint_dir: str) -> None:
self.model.load_state_dict(torch.load(f"{checkpoint_dir}/model.pt"))
def cleanup(self): # trial 结束时释放外部资源
...
| 生命周期方法 | 时机 | 用途 |
|---|---|---|
setup(config) |
每 trial 一次 | 建模型 / 建连接。别在 __init__ 里做重活 |
step() |
每次迭代 | 训练一步并返回指标 dict |
save_checkpoint(dir) / load_checkpoint(dir) |
Tune 决定存 / 恢复时 | 容错恢复、PBT 交换权重都依赖它 |
cleanup() |
trial 结束 | 关连接、删临时文件 |
reset_config(config) / reset() |
actor 复用(见下) | 换配置、重置状态 |
老教程里的
_setup/_train/_save/_restore在较新版本已改为不带 下划线的名字(具体在哪一版移除未确认)。照抄老代码会得到"方法写了但从不 被调用"这类安静的错误。
什么时候还必须用类式 :① 要用 reuse_actors;② 训练循环本身是"每次 step() 由 Tune 决定跑多久",而不是写死的 for step in range(N);③ 有昂贵的 可复用状态(大模型常驻、连接池)。
reuse_actors:省掉每个 trial 的启动成本
默认每个 trial 新建一个 actor、跑完销毁。trial 多、而 trainable 启动成本高 (加载模型、编译 kernel)时,这一步会吃掉可观比例的时间:
python
tuner = tune.Tuner(
MyTrainable, param_space=param_space,
tune_config=tune.TuneConfig(num_samples=50, reuse_actors=True),
)
- 前提 :trainable 是类式 的 ------ 函数式 trainable 每个 trial 带一套独立的 闭包状态,无法安全复用(该限制的精确边界未确认,以官方文档为准)。
- 代价是状态会串味 。复用的 actor 会把上一个 trial 留在实例上的属性、没关掉 的连接、没重置的 optimizer 带进下一个 trial。每个 trial 独有的状态必须在
setup()里重建,不要放在__init__。 - 收益最明显的场景:PBT(trial 反复被"重启"到别人的 checkpoint 上)和需要 加载大模型的调参。
14.16 time_budget_s 与 Tuner.restore()
这两个是运维层面的开关:一个是"到点收工",一个是"接着上次跑"。
python
from ray import tune
from ray.tune import FailureConfig # ⚠️ 同上:从 ray.tune 导入
from ray.tune import RunConfig
from ray.tune.stopper import TimeoutStopper
tuner = tune.Tuner(
trainable, param_space=param_space,
run_config=RunConfig(name="lr-sweep-01",
storage_path="/mnt/shared/ray_results",
failure_config=FailureConfig(max_failures=2),
stop=TimeoutStopper(timeout_s=2 * 3600)), # 最多 2 小时
tune_config=tune.TuneConfig(num_samples=200, metric="loss", mode="min"),
)
results = tuner.fit() # 到点后未完成的 trial 被停掉,已完成的照常进 ResultGrid
关于
time_budget_s:⚠️ 本书早先说"它是不是还接受未确认" ------ 那个方向 找错了。它是tune.TuneConfig的正式字段 (签名:time_budget_s: Optional[Union[int, float, timedelta]] = None), 不是RunConfig的。所以它现在仍然可用,写法是:
pythontune.Tuner(trainable, param_space=..., tune_config=tune.TuneConfig(time_budget_s=3600))内部它确实会被转换成一个
TimeoutStopper,所以语义与手写TimeoutStopper完全一致,只是更简洁。想显式控制就用上面的写法。
恢复:Tuner.restore()
driver 挂掉、机器被抢占、或者参数写错了想改完接着跑 ------ 都靠 Tuner.restore():
python
import os
from ray import tune
path = os.path.expanduser("~/ray_results/lr-sweep-01") # = storage_path/name
assert tune.Tuner.can_restore(path) # 先检查再恢复
tuner = tune.Tuner.restore(
path,
trainable=trainable, # 用了 with_parameters 时:重新包一遍
resume_unfinished=True, # 未完成的 trial 继续跑(默认)
resume_errored=False, # 失败的 trial 是否重跑
restart_errored=False, # 失败的是否从零重启(而非从 checkpoint 恢复)
)
results = tuner.fit()
早期版本用
overwrite_trainable=覆盖 trainable,现在的形态是直接传trainable=(改名发生的版本未确认)。
⚠️ 恢复不是万能的,四个前提缺一不可:
| 前提 | 说明 |
|---|---|
RunConfig(name, storage_path) 与上次完全一致 |
路径就是 storage_path/name,改名字等于换了个实验 |
param_space 与上次一致 |
⚠️ 必须一致,不能借恢复改搜索空间 。Tuner.restore() 确实有 param_space 形参,但它的用途只有一个:重建失效的 object ref (ray.put 的数据集、with_parameters 传的对象)。内部 _validate_param_space_on_restore 会在键集不一致时抛 ValueError ------ 官方明确写「Changing the hyperparameter search space then resuming is NOT supported」 |
with_parameters 的对象要重新给 |
object ref 绑在旧会话上(见 §14.13) |
storage_path 是共享存储 |
多节点下 /tmp 是各节点各自的 |
别用恢复来「续命」一个设计错了的实验 :stop、num_samples、param_space 都不能借 restore 改 (param_space 传进去只是为了重绑 object ref, 键集对不上会直接 ValueError)。要改就开新实验,把旧的 ResultGrid 读出来做对比。 tune.Tuner.can_restore(path) 可以在脚本里做自动判断(判断失败就退化成新实验)。
14.17 采样器补全:AxSearch、ConcurrencyLimiter 与 Repeater
§14.4 的表里提到过 AxSearch / BayesOptSearch,这里补三件必须知道的事。
AxSearch 的真实状态
| 事实 | 内容 |
|---|---|
| 是否被弃用 | 没有(本次检索未找到任何"已弃用 / 停止维护"的声明) |
| 依赖 | pip install ax-platform(文档同时提到 sqlalchemy) |
| 搜索空间 | 支持从 param_space 自动转换,也接受手工 space |
| 不支持 | 网格搜索的转换 ;量化会被丢弃 (qrandint / qloguniform 这类只留一条 warning) |
| 保存 / 恢复 | ✅ 已实现 ------ 2.58 的 ray/tune/search/ax/ax_search.py 里有真实的 save(checkpoint_path) / restore(checkpoint_path)(用 cloudpickle)。⚠️ 本书早先写"未实现"是错的(同样确实未实现 的是 TuneBOHB / SigOptSearch / DragonflySearch) |
"量化被静默丢弃"这条最容易坑人:你以为在搜整数,实际在搜连续值。用 AxSearch 前先把 param_space 里的 q* 系列换成显式列表或 tune.choice。 保存 / 恢复是实现了的 (见上表),所以 AxSearch 可以进需要 checkpoint 恢复的 流程(比如中途被抢占后续跑);真正缺的是网格搜索的转换。
ConcurrencyLimiter:贝叶斯类采样器的安全阀
⚠️ 这是一个真实的陷阱 :OptunaSearch / HyperOptSearch / BayesOptSearch / AxSearch 这类采样器内部维护串行状态 ------ 它们要拿到上一个 trial 的结果才能 给出下一个建议。Ray 官方把它们当顺序采样器 处理,并提供 ConcurrencyLimiter 作为显式包装器:
python
from ray import tune
from ray.tune.search import ConcurrencyLimiter
from ray.tune.search.optuna import OptunaSearch
search_alg = ConcurrencyLimiter(
OptunaSearch(metric="loss", mode="min"),
max_concurrent=4, # 最多同时 4 个 trial 在跑
)
tuner = tune.Tuner(trainable, param_space=param_space,
tune_config=tune.TuneConfig(search_alg=search_alg, num_samples=50))
三条必须知道的行为:
ConcurrencyLimiter通过拦截suggest()实现限流 :在跑的 trial 达到max_concurrent时返回None,让调度器停下来等。- 它不是可选项 。不包的时候,Tune 对非
BasicVariantGenerator的采样器会把 "待处理 trial 上限"当作 1 处理。典型症状是:资源明明够、num_samples也设了,trial 却一个一个串行跑 ,墙上时间长得莫名其妙。 (PR #63770 修的就是"max_concurrent_trials对自定义采样器不生效":修复前, 一个 16 trial × 2 秒、4 worker、TPE 的场景要 60.4 秒(全程串行),修复后 12.0 秒。修复思路是当采样器被ConcurrencyLimiter包住时,拿它的max_concurrent当待处理上限;Repeater套ConcurrencyLimiter这类多层 包装也会被识别。) - 自带并发逻辑的采样器 (如
SigOptSearch/HEBO)如果被ConcurrencyLimiter包住,以包装器的max_concurrent为准 ,随后由该 采样器自己的内部逻辑接管(Searcher API 为此加了set_max_concurrency, PR #20576)。
临时兜底 :
TUNE_MAX_PENDING_TRIALS_PG环境变量可以手工设待处理上限,在 不含 #63770 的版本上这是唯一的绕过办法。它是内部开关,别写进生产脚本。
Repeater:同一组配置换多个种子重跑
python
from ray.tune.search import ConcurrencyLimiter, Repeater
from ray.tune.search.optuna import OptunaSearch
search_alg = Repeater(
ConcurrencyLimiter(OptunaSearch(metric="loss", mode="min"), max_concurrent=2),
repeat=3, # 每条建议的配置跑 3 次(不同种子)
)
Repeater 把内层采样器的每条建议重复 N 次 ,并用 search_alg.metric 上的 平均值回灌给内层采样器 ------ 是为高方差训练(RL 最典型)设计的。
⚠️ 官方明确提示:
Repeater不要和TrialScheduler一起用 ------ 早停会砍掉 重复实验里的部分 run,平均值就不再是无偏的。要用Repeater就关掉scheduler,把预算花在"重复"上而不是"早停"上。
14.18 PBT 实战:什么时候它真的赢
§14.5 的表里 PBT 只有一行。它和 ASHA 不是同类东西,值得单独看。
ASHA 优化的是"哪组超参好" (一组超参从头到尾不变);PBT 优化的是"这组超参 在训练过程中该怎么变" 。PBT 维护一个 population,定期把表现差的 trial 重启到 表现好的 trial 的 checkpoint 上 ,再扰动它的超参继续跑:
scss
iter 0 t1(0.3) t2(0.5) t3(0.9) t4(0.2) ← 括号里是当前指标
│ │ │ │
每 perturbation_interval 次迭代做一次 exploit + explore
↓ ↓ ↓ ↓
iter N t1(0.9) t2(0.9) t3(0.9) t4(0.9) ← 差的 trial 被"重启"到好 trial 的
↑ 权重来自 t3,超参被扰动 checkpoint 上,再扰动超参
python
import random
from ray import tune
from ray.tune.schedulers import PopulationBasedTraining
def explore(config):
"""custom_explore_fn:扰动后做一次合法化(PBT 不替你保证组合合法)"""
config["train_batch_size"] = max(config["train_batch_size"],
config["minibatch_size"] * 2)
config["lr"] = min(max(config["lr"], 1e-6), 1e-2)
return config
pbt = PopulationBasedTraining(
time_attr="training_iteration", # "时间"按什么量 ------ 决定 interval 的单位
perturbation_interval=10, # 每 10 次迭代 exploit + explore 一次
hyperparam_mutations={ # 哪些超参可以被改、怎么改
"lr": lambda: random.uniform(1e-5, 1e-2), # 可调用 → 连续扰动
"minibatch_size": [128, 256, 512, 1024], # 列表 → 从里面挑
},
resample_probability=0.25, # 25% 概率重新采样,其余在旧值附近扰动
custom_explore_fn=explore, # 扰动后合法化(可选,但强烈建议)
)
tuner = tune.Tuner(
MyTrainable, # 必须支持完整的 save / load
param_space={"lr": 3e-4, "minibatch_size": 256},
tune_config=tune.TuneConfig(
scheduler=pbt, num_samples=8, # population 大小 = num_samples
metric="episode_return_mean", mode="max",
reuse_actors=True, # PBT 反复重启 trial,复用 actor 收益明显
),
)
硬性要求(不满足就跑不起来或结果无意义):
| 要求 | 原因 |
|---|---|
| trainable 必须支持完整的 save / load | PBT 要把甲 trial 的权重装进乙 trial;只存一半状态 = 静默错误 |
hyperparam_mutations 里的参数要在 param_space 里有初值 |
只在 mutations 里出现、param_space 里没有的参数,初值也会从 mutations 采 |
只有列进 hyperparam_mutations 的参数会被改 |
想让它变必须写进去 |
perturbation_interval 建议等于 checkpoint_interval(或它的整数倍) |
否则 exploit 时拿到旧 checkpoint,"复制好配置"退化成随机 |
它比 ASHA 贵,而且贵得理直气壮 :ASHA 靠少跑 省钱(大部分 trial 在前 10% 就被淘汰),PBT 不早停 ------ population 里每个成员都要持续跑;想让它"跑得快" 还得给足资源(官方 PBT 示例直接写"需要 ≥8 张 GPU 才能让所有 trial 并行,否则 只能轮转,效率大打折扣")。所以:
| 场景 | 选谁 |
|---|---|
| 超参最优值固定、只想快点找到 | ASHA ------ 这里用 PBT 是纯粹的浪费 |
| 超参最优值随训练进程变化(lr schedule、熵系数、数据课程、batch 缩放) | PBT |
| RL 任务、奖励高方差、需要在线适应 | PBT(它的主场) |
| 预算紧张、单 trial 很贵 | ASHA |
quantile_fraction(被当作"好"的 trial 占比,只有这一档的 trial 有权 被抄袭):PopulationBasedTraining的默认值是 0.25 ------ 即每轮只把表现最好的 1/4 当作"老师",其余 3/4 从它们身上抄参数再扰动。 调大它会让"抄袭"更宽容(多样性↑、收敛↓);调小则更精英化。 仍建议按你所装版本的签名再确认一次。
14.19 本章小结
- Tune 的核心抽象是
Tuner(要跑什么 + 怎么跑)+TuneConfig(跑多少 / 怎么判断好坏 / 怎么搜 / 怎么停) ,tuner.fit()返回ResultGrid。 ray.tune.Tuner与TuneConfig在源码里都标注stability="beta";ray.tune.run仍在但已不是推荐路径(检索未找到明确的弃用声明)。 Tune 训练函数里请用ray.tune.*的report/get_checkpoint/get_context,Checkpoint也要从ray.tune导入。 ⚠️ 而且ray.tune.Checkpoint的能力集与ray.train.Checkpoint完全一致 (它只是后者的 pass-through 子类):只有from_directory(local_dir)与Checkpoint(path=...)两个造法 ------from_dict/to_dict/from_uri在 2.58 已移除,调用会抛AttributeError(详见 §14.6 与第 13 章 §13.3)。 小字典本来就该走tune.report的 metrics,不要塞进 checkpoint。- 搜索空间里
randint上界是开区间 、跨数量级的参数必须用loguniform;grid_search会展开成笛卡尔积,用前先算 trial 总数。 搜索空间的维度比搜索算法更重要。 - 采样器:
BasicVariantGenerator(随机,默认)→OptunaSearch(TPE, 中等维度首选)→AxSearch/BayesOptSearch(低维、评估极贵)。贝叶斯类 方法需要串行或半串行 ,max_concurrent_trials开太大会稀释效果。 - 调度器:ASHA 是默认首选 (异步逐次减半,无同步 barrier);HyperBand 同步、怕 straggler;PBT 用于调训练过程 中的超参;MedianStoppingRule 最保守。
grace_period设太小会误杀慢热配置。 - 成本公式:总 GPU·小时 ≈
num_samples× 每 trial GPU 数 × 每 trial 小时 ;max_concurrent_trials只影响墙钟时间、不影响总成本 ,且应等于floor(集群总 GPU / 每 trial GPU)(设大了会让 trial 卡在 PENDING)。 - 标准流程:smoke run → 定预算 → 粗搜(宽空间、短预算、激进早停)→ 精搜(窄空间、长预算)→ 用没参与搜索的验证集复核 top-3 。Tune 的调度器在 Core 层就是"读指标 → 排序 →
ray.cancel",没有分布式魔法;mini-ray 提供了 这些原语,但没有实现 Tune 本身。 Stopper与调度器是两条独立的早停路径 :调度器跨 trial 比较,Stopper单 trial / 全实验判定。stop=的字典简写是"指标名 → 阈值"且假定越大越好 ;TimeoutStopper停的是整个实验。tune.Callback挂在RunConfig(callbacks=[...])上 (不是TuneConfig), 是拿到早停通知(on_trial_complete)与做自定义日志的唯一入口;回调在 driver 里串行执行,别放重活。模型产物走Checkpoint,不要塞进 metrics。with_resources绑资源、with_parameters把大对象放进 object store (只传一次但占内存)。⚠️ 恢复实验时Tuner.restore()没有with_parameters=这个形参 ------ 正确做法是把重新包好的 trainable 传进去:tune.Tuner.restore(path, trainable=tune.with_parameters(fn, **same_kwargs))(见 §14.13)。checkpoint 默认全留 ,用CheckpointConfig(num_to_keep=N, checkpoint_score_attribute=..., checkpoint_score_order=...)控制。reuse_actors=True能省掉每个 trial 的启动成本 ,代价是状态串味 ------ 类式Trainable里每个 trial 独有的状态必须在setup()里重建。恢复实验靠Tuner.restore(path, trainable=...),前提是RunConfig(name, storage_path)与param_space和上次完全一致。- 贝叶斯类采样器(Optuna / HyperOpt / BayesOpt / Ax)不并发安全 :用
ConcurrencyLimiter(searcher, max_concurrent=N)包住,否则会退化成逐个串行 跑(PR #63770 修的就是这件事)。AxSearch还会静默丢弃量化 、不支持 网格搜索转换 ------ 但它的保存 / 恢复是实现了的 (save/restore走 cloudpickle),能配合 checkpoint 恢复的流程。Repeater用于换种子重复评估, 但不能和调度器同用。 - PBT 是"调训练过程中的超参",不是早停 :需要 population 全体跑满、 trainable 完整支持 save/load、
hyperparam_mutations与perturbation_interval配好。它比 ASHA 贵,只在"最优超参随训练进程变化"时才是正确选择。
下一章进 Ray Serve:把模型变成在线服务,以及它和 Ray Data LLM 的分工。