Ray Tune によるハイパーパラメーター検索
プレビュー
この機能は パブリック プレビュー段階です。
この例では Ray Tune を使用して、4 つの 1xA10 ノード全体で Qwen2.5 の LoRA ファインチューニングのハイパーパラメーターを検索します。ブートストラップコマンドがノード間にまたがる Ray クラスターを起動し、ドライバーが Ray Tune に対してトライアルごとに 1 つの GPU を要求します。クラスターは一度に 4 つのトライアルを実行し、残りは GPU が空き次第、起動します。
この検索では、ASHA スケジューラ(Asynchronous Successive Halving)を使用します。各トライアルは、固定されたステップ間隔でホールドアウト eval_loss を報告します。ASHA は、すべての候補を完了までトレーニングするのではなく、遅れているトライアルを停止します。
この例ではパブリックモデル (Qwen2.5-0.5B) を使用しています。そのため、Hugging Face トークンなしでそのまま実行されます。
ワークロードは以下の処理を行います:
code_source: snapshotを使用してローカルプロジェクトを upload します。- データセットをドライバー上で一度トークン化し、Tensor としてトライアルに渡します。
- 8 つの LoRA 構成をサンプリングし、一度に 4 つずつ実行します。
- スイープ設定、最適な構成、およびトライアルごとの損失を MLflow に Logs します。
前提条件
airCLI がインストールされ、認証されていること。「AI Runtime CLI のインストール」を参照してください。
Project Layout
次のファイルを含むディレクトリを作成します。
ray_tune_lora/
├── tune.yaml # air workload config (inline dependencies + Ray bootstrap)
└── tune_lora.py # Ray Tune driver + per-trial LoRA fine-tuning
ステップ 1: ワークロード YAML を記述する
tune.yaml 4 つの GPU_1xA10 ノードを要求し、environment の下で依存関係をインラインで宣言します (ランタイム version を使用)。ワークロードの command はノード全体で Ray クラスターを起動し、ドライバーを実行するため、この例では個別の依存関係ファイルやランチャースクリプトは必要ありません:
experiment_name: air-ray-tune-lora
environment:
version: 'databricks_ai_v5'
dependencies:
# databricks_ai_v5 ships ray, transformers, and datasets. It does not ship peft
# and needs a newer fsspec for huggingface_hub.
- peft>=0.13
- fsspec>=2024.6.1
# 4 1xA10 nodes. Ray Tune runs one trial per GPU.
compute:
num_accelerators: 4
accelerator_type: GPU_1xA10
code_source:
type: snapshot
snapshot:
root_path: .
command: |
cd $CODE_SOURCE_PATH
set -e
RAY_HEAD_PORT=6379
GPUS_PER_NODE=${LOCAL_WORLD_SIZE:-1}
if [ "${NODE_RANK:-0}" = "0" ]; then
echo "NODE_RANK=0: starting Ray head with $GPUS_PER_NODE GPU(s)..."
ray start --head --port=$RAY_HEAD_PORT --num-gpus="$GPUS_PER_NODE" --dashboard-host=0.0.0.0
# Stop the cluster on exit, even if the driver fails, so workers don't wait out the timeout.
trap 'ray stop --grace-period 5' EXIT
python tune_lora.py
else
echo "NODE_RANK=$NODE_RANK: connecting to Ray head at $MASTER_ADDR:$RAY_HEAD_PORT..."
# `ray start` returns as soon as this node joins, so the worker controls its own exit.
joined=""
for i in $(seq 1 12); do
if ray start --address="$MASTER_ADDR:$RAY_HEAD_PORT" --num-gpus="$GPUS_PER_NODE" 2>/dev/null; then
joined=1
break
fi
echo "Attempt $i failed, retrying in 5s..."
sleep 5
done
if [ -z "$joined" ]; then
echo "Worker failed to join the Ray head after all retries; aborting." >&2
exit 1
fi
# health-check exits non-zero once the head runs `ray stop`, which is this worker's cue
# to exit. The timeout keeps each probe short so the job finishes promptly; the counter
# caps the total wait.
echo "Worker joined; waiting for the head to finish the sweep..."
for _ in $(seq 1 360); do
if ! timeout 5 ray health-check --address "$MASTER_ADDR:$RAY_HEAD_PORT" 2>/dev/null; then
break
fi
sleep 5
done
echo "Head is no longer healthy; stopping local Ray and exiting."
ray stop --grace-period 5
fi
max_retries: 0
timeout_minutes: 45
env_variables:
NCCL_SOCKET_IFNAME: eth0
HF_HOME: /tmp/hf
ステップ2:検索スペースとスケジューラを定義する
ドライバーの main 関数はデータを一度トークン化し、検索スペースを定義してから、ASHA を構成します:
tuner = tune.Tuner(
# with_resources gives each trial a whole GPU so trials never share a device.
tune.with_resources(
tune.with_parameters(train_fn, train_data=train_data, eval_data=eval_data),
resources={"gpu": 1},
),
param_space={
"lr": tune.loguniform(1e-5, 1e-3),
"lora_r": tune.choice([8, 16, 32]),
"lora_alpha_ratio": tune.choice([1, 2]),
"lora_dropout": tune.uniform(0.0, 0.1),
"weight_decay": tune.choice([0.0, 0.01]),
"batch_size": tune.choice([4, 8]),
},
tune_config=tune.TuneConfig(
metric="eval_loss",
mode="min",
scheduler=ASHAScheduler(
max_t=MAX_ITERATIONS, grace_period=GRACE_PERIOD, reduction_factor=2
),
num_samples=NUM_SAMPLES,
),
)
results = tuner.fit()
tune.with_resources(..., resources={"gpu": 1}) 検索をクラスターにマッピングします。クラスターには 4 つの GPU があるため、Ray Tune は 4 つのトライアルを同時に実行します。スイープを広げるには、コードを変更するのではなく、YAML 内の num_accelerators を増やしてください。
各トライアルは、EVAL_STEPS オプティマイザー ステップごとにレポートします。grace_period は、トライアルが停止されるまでに取得するレポート数を設定し、max_t は存続するトライアルが取得するレポート数の上限を設定します。また、reduction_factor=2 は各ステップで下位約半分を停止します。
ステップ 3: 各トライアルからプルーニング メトリクスをレポートする
train_fn は 1 つのトライアルです。tune.report 呼び出しは、ASHA がトライアルを停止または
継続する場所です:
def train_fn(config, train_data=None, eval_data=None):
# Ray Tune pins one GPU per trial via CUDA_VISIBLE_DEVICES, so cuda:0 is this trial's.
device = torch.device("cuda")
model = AutoModelForCausalLM.from_pretrained(MODEL_NAME, dtype=torch.bfloat16)
model.config.use_cache = False
lora = LoraConfig(
r=config["lora_r"],
lora_alpha=config["lora_r"] * config["lora_alpha_ratio"],
lora_dropout=config["lora_dropout"],
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora).to(device)
...
if step % EVAL_STEPS == 0:
tune.report({
"eval_loss": evaluate(model, eval_loader, device),
"train_loss": out.loss.item(),
"step": step,
})
ASHA は、最も早く過学習する構成を優先してしまうトレーニング損失ではなく、ホールドアウト分割からの eval_loss で試行を比較します。build_datasets はドライバー上でデータを一度トークン化し、TensorDataset オブジェクトを返します。tune.with_parameters はそれらを他のノード上の試行に送信します。テンソルは値によってシリアル化されますが、Hugging Face データセットは他のノードが開くことのできないメモリマップドファイルへのパスとして到着します。
完全なスクリプトは、このページの下部にある Full tuning script に記載されています。
ステップ 4: ランを送信する
air run -f tune.yaml --dry-run
air run -f tune.yaml --watch
ステップ 5: ランを検査する
air get run <run-id>
air logs <run-id>
ドライバーはノード 0 で実行されるため、Ray Tune ステータス テーブルはそのノードのLogsからストリームされます。各試行につき 1 行で、サンプリングされた構成、イテレーション数、および最新の eval_loss が表示されます。ASHA によって停止された試行は、max_t よりも少ないイテレーション数の TERMINATED として表示されます。
結果の保存先
ランの終了時に、ドライバーは最適な構成とその eval_loss を出力し、experiment_name で指定された MLflow エクスペリメントに、スイープ設定および各トライアルの最終的な eval_loss とともに両方を Logs します。
いずれかのトライアルが失敗した場合、ドライバーはエラーを発生させます。
この例では、アダプターの重みは保持されません。最適なアダプターを保持するには、すべてのノードがアクセスできる Unity Catalog ボリューム上の RunConfig(storage_path=...) を tune.Tuner に指定します。
スイープのサイズを調整する
tune_lora.py の先頭にある定数は、スイープのサイズを制御します。これらを小さく設定すると、数分で変更の簡易テスト(スモークテスト)を行えますが、その場合 eval_loss の数値はノイズが多くなり、構成のランク付けには適さなくなります。
経過実時間は NUM_SAMPLES / num_accelerators を追跡するため、スイープに時間がかかりすぎる場合は検索を縮小するのではなく、num_accelerators を増やしてください。より大規模なモデルの場合は、accelerator_type をより大きな GPU に引き上げてください。構成をランダムにサンプリングするのではなく選択するには、TuneConfig に Optuna などの search_alg を渡します。
完全なチューニングスクリプト
コピー&ペースト用の完全な tune_lora.py:
#!/usr/bin/env python3
"""LoRA hyperparameter search for Qwen2.5-0.5B with Ray Tune + ASHA on 4 1xA10 nodes.
The workload's `command` starts a Ray head on node 0 and joins the other nodes as workers,
then runs this script on the head. Ray Tune requests one GPU per trial, so every node runs
one trial at a time. ASHA concentrates GPU time on the promising configurations by stopping
trials that fall behind at each rung.
Uses a public model (no Hugging Face token required) so the example runs as-is.
"""
import os
import mlflow
import ray
import torch
from datasets import load_dataset
from peft import LoraConfig, get_peft_model
from ray import tune
from ray.tune.schedulers import ASHAScheduler
from torch.utils.data import DataLoader, TensorDataset
from transformers import AutoModelForCausalLM, AutoTokenizer
MODEL_NAME = "Qwen/Qwen2.5-0.5B"
DATASET_NAME = "tatsu-lab/alpaca"
MAX_SEQ_LEN = 512
# Trials report every EVAL_STEPS optimizer steps, so ASHA sees at most MAX_ITERATIONS
# reports per trial and can start pruning once a trial has sent GRACE_PERIOD of them.
EVAL_STEPS = 25
MAX_ITERATIONS = 12
GRACE_PERIOD = 3
NUM_SAMPLES = 8
TRAIN_EXAMPLES = 2000
EVAL_EXAMPLES = 200
def build_datasets():
"""Tokenizes the SFT data once on the driver.
Returns TensorDatasets so the tokenized splits serialize by value, which is what lets
tune.with_parameters hand them to trials on any node in the cluster.
"""
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
raw = load_dataset(DATASET_NAME, split=f"train[:{TRAIN_EXAMPLES + EVAL_EXAMPLES}]")
def format_example(row):
prompt = f"### Instruction:\n{row['instruction']}\n\n"
if row.get("input"):
prompt += f"### Input:\n{row['input']}\n\n"
text = f"{prompt}### Response:\n{row['output']}{tokenizer.eos_token}"
out = tokenizer(text, truncation=True, max_length=MAX_SEQ_LEN, padding="max_length")
# -100 is cross-entropy's ignore_index, so the loss covers only real tokens and
# eval_loss stays a meaningful signal for ASHA to rank trials by.
out["labels"] = [token if mask == 1 else -100 for token, mask in zip(out["input_ids"], out["attention_mask"])]
return out
tokenized = raw.map(format_example, remove_columns=raw.column_names)
split = tokenized.train_test_split(test_size=EVAL_EXAMPLES, shuffle=True, seed=0)
def to_tensors(ds):
return TensorDataset(
torch.tensor(ds["input_ids"], dtype=torch.long),
torch.tensor(ds["attention_mask"], dtype=torch.long),
torch.tensor(ds["labels"], dtype=torch.long),
)
return to_tensors(split["train"]), to_tensors(split["test"])
def evaluate(model, loader, device):
"""Mean cross-entropy over the held-out split. This is the metric ASHA prunes on."""
model.eval()
total, batches = 0.0, 0
with torch.no_grad():
for input_ids, attention_mask, labels in loader:
out = model(
input_ids=input_ids.to(device),
attention_mask=attention_mask.to(device),
labels=labels.to(device),
)
total += out.loss.item()
batches += 1
model.train()
return total / max(batches, 1)
def train_fn(config, train_data=None, eval_data=None):
"""One trial: LoRA fine-tunes Qwen on a single GPU and reports eval_loss to ASHA."""
# Ray Tune pins one GPU per trial via CUDA_VISIBLE_DEVICES, so cuda:0 is this trial's.
device = torch.device("cuda")
model = AutoModelForCausalLM.from_pretrained(MODEL_NAME, dtype=torch.bfloat16)
model.config.use_cache = False
lora = LoraConfig(
r=config["lora_r"],
lora_alpha=config["lora_r"] * config["lora_alpha_ratio"],
lora_dropout=config["lora_dropout"],
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora).to(device)
train_loader = DataLoader(train_data, batch_size=config["batch_size"], shuffle=True, drop_last=True)
eval_loader = DataLoader(eval_data, batch_size=config["batch_size"])
optimizer = torch.optim.AdamW(
(p for p in model.parameters() if p.requires_grad),
lr=config["lr"],
weight_decay=config["weight_decay"],
)
model.train()
step = 0
max_steps = EVAL_STEPS * MAX_ITERATIONS
# Cycle the loader over multiple epochs until the step budget is spent.
while step < max_steps:
for input_ids, attention_mask, labels in train_loader:
out = model(
input_ids=input_ids.to(device),
attention_mask=attention_mask.to(device),
labels=labels.to(device),
)
out.loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
optimizer.zero_grad()
step += 1
if step % EVAL_STEPS == 0:
# ASHA stops or continues the trial based on this report.
tune.report(
{
"eval_loss": evaluate(model, eval_loader, device),
"train_loss": out.loss.item(),
"step": step,
}
)
if step >= max_steps:
break
def main():
ray.init(address="auto")
num_nodes = int(os.environ.get("NUM_NODES", 1))
total_gpus = int(ray.cluster_resources().get("GPU", 0))
if total_gpus < 1:
raise SystemExit("No GPUs registered with Ray; check GPU discovery on the cluster.")
print(f"Cluster ready: {num_nodes} node(s), {total_gpus} GPU(s)", flush=True)
print(f"Running {NUM_SAMPLES} trials, up to {total_gpus} concurrently\n", flush=True)
train_data, eval_data = build_datasets()
param_space = {
"lr": tune.loguniform(1e-5, 1e-3),
"lora_r": tune.choice([8, 16, 32]),
"lora_alpha_ratio": tune.choice([1, 2]),
"lora_dropout": tune.uniform(0.0, 0.1),
"weight_decay": tune.choice([0.0, 0.01]),
"batch_size": tune.choice([4, 8]),
}
tuner = tune.Tuner(
# with_resources gives each trial a whole GPU so trials never share a device.
tune.with_resources(
tune.with_parameters(train_fn, train_data=train_data, eval_data=eval_data),
resources={"gpu": 1},
),
param_space=param_space,
tune_config=tune.TuneConfig(
metric="eval_loss",
mode="min",
scheduler=ASHAScheduler(
max_t=MAX_ITERATIONS,
grace_period=GRACE_PERIOD,
reduction_factor=2,
),
num_samples=NUM_SAMPLES,
),
)
results = tuner.fit()
# Surface trial failures: a best result is only meaningful when the whole sweep ran.
if results.num_errors:
raise RuntimeError(
f"{results.num_errors} of {len(results)} trials errored; see the per-trial error files above."
)
best = results.get_best_result("eval_loss", "min")
print(f"\nBest config: {best.config}", flush=True)
print(f"Best eval_loss: {best.metrics['eval_loss']:.4f}", flush=True)
# AI Runtime injects MLFLOW_RUN_ID and configures the databricks tracking URI on the
# node, so logging needs no credentials here. Gating on the variable keeps the script
# runnable off-platform, where it is unset.
if os.environ.get("MLFLOW_RUN_ID"):
with mlflow.start_run(run_id=os.environ["MLFLOW_RUN_ID"]):
mlflow.log_params(
{
"model": MODEL_NAME,
"dataset": DATASET_NAME,
"num_samples": NUM_SAMPLES,
"scheduler": "ASHA",
"asha_max_t": MAX_ITERATIONS,
"asha_grace_period": GRACE_PERIOD,
**{f"best_{k}": v for k, v in best.config.items()},
}
)
mlflow.log_metric("best_eval_loss", best.metrics["eval_loss"])
for i, result in enumerate(results):
if result.metrics and "eval_loss" in result.metrics:
mlflow.log_metric("trial_eval_loss", result.metrics["eval_loss"], step=i)
ray.shutdown()
if __name__ == "__main__":
main()