在 AIStudio 训练任务中使用 verl 运行 DeepSeek-R1-Distill-Qwen-1.5B 单 GPU GRPO
注意
本文中的平台预置镜像地址是示例。使用前,请在镜像中心或当前实例的预置镜像列表中确认当前可用区提供了对应仓库及 tag;如果列表中不存在,请改用页面显示的可用镜像地址。替换 Registry 域名并不能保证相同仓库或 tag 在其他环境或可用区中存在。
本教程使用一个 AIStudio Worker 和一张 A100 GPU,让 DeepSeek-R1-Distill-Qwen-1.5B 回答 GSM8K 数学题,并通过 verl 完成 SGLang 生成、规则奖励和全参数 GRPO 更新。两步训练用于确认链路和持久化输出可以正常工作。
实战目标
完成本教程后,您可以从任务日志和共享存储确认:
- verl 从共享存储读取固定版本的代码、模型和 GSM8K 数据;
- SGLang 在每个训练 step 生成 8 条回答,verl 计算规则奖励并通过 FSDP2 更新模型;
- 两个训练 step 全部完成,TensorBoard 中出现训练指标;
- rollout、日志和
global_step_2checkpoint 在 Worker 释放后仍可读取。
场景信息
本教程使用以下配置:
- verl:
v0.6.0@ddd86f527a4af75095e4677b02b5aa272913a088,从共享存储读取固定 commit; - 镜像:平台预置镜像
cr.infini-ai.com/infini-ai/verl:app-verl0.6-transformers4.56.1-sglang0.5.2-mcore0.13.0-te2.2; - 模型:ModelScope 的
deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B@6fc93244f442ee2b5ab5c8000687ef5f7ffe1d03; - 数据集:Hugging Face 的
openai/gsm8k@740312add88f781978c0658806c59bc2815b9866,转换为 verl v0.6 使用的 Parquet 格式; - 资源来源:Spot 资源;每次重跑使用新的
RUN_ID,避免覆盖已有输出; - 分布式框架:单机;一个 Worker 的任务不需要 AIStudio 托管 Ray;
- Worker 拓扑:1 个 Worker、1 张 A100-SXM4-80GB,RDMA 关闭;
- 训练范围:全参数 GRPO,FSDP2,
train_batch_size=4,每个 prompt 生成 2 个回答,共运行 2 个 step; - 共享存储:准备资源时在 AICoder 中挂载到
/mnt/rlinf-reproduction,训练任务中把同一存储卷挂载到/mnt/verl-reproduction;模型权重只保存一份。
本场景在 AIStudio 中怎样运行
AIStudio 创建 Worker、挂载共享存储并执行启动命令。分布式框架决定 Ray 由谁创建:
- 单机(本教程):任务使用 1 个 Worker,创建页只提供 单机 选项。verl 在 Worker 内启动本地 Ray,启动命令无需执行
ray start或ray job submit。 - Ray:需要多个 Worker 时,由 AIStudio 创建 Ray Head 和 Worker,并通过 RuntimeEnv 向各节点传递工作目录和环境变量。多 Worker 任务需要独立验证节点数量、GPU、共享路径和分布式 checkpoint。
本教程中的组件都运行在同一个 Worker:
AIStudio 单机训练任务
└── Worker 0 / A100 GPU 0
└── verl 本地 Ray
├── SGLang 生成回答
├── GSM8K 规则奖励
└── FSDP2 actor 更新模型、数据和 verl 代码从共享存储读取。每次运行的日志、rollout、TensorBoard 事件和 checkpoint 写入 runs/verl/single-gpu-grpo/<RUN_ID>/。
开始前准备
在创建 GPU 任务前,使用挂载同一共享存储的 AICoder 或开发机准备 verl 代码、模型、数据集和运行脚本。本文使用宁夏 B;您也可以选择其他可用区,但镜像、任务和共享存储需要位于同一可用区。
如果这些输入已经准备完成,可以直接执行 Step 1;校验通过后从 Step 2 创建训练任务。
准备期间使用 /mnt/rlinf-reproduction 作为 AICoder 中的共享存储路径。请先确认该路径来自可写共享存储,并为模型、数据和一次 checkpoint 预留至少 25 GiB 可用空间:
export PREP_ROOT=/mnt/rlinf-reproduction
findmnt -T "$PREP_ROOT"
test -w "$PREP_ROOT"
df -hT "$PREP_ROOT"
command -v git
command -v modelscope
command -v hf
python3 -c 'import pyarrow; assert pyarrow.__version__ == "20.0.0"; print(pyarrow.__version__)'数据转换使用 PyArrow 20.0.0。缺少 modelscope 或 hf 命令时,请先按照后文链接在准备环境中安装相应 CLI;这些工具只用于准备输入,不在 GPU 任务中安装依赖或下载文件。
选用平台预置镜像
创建训练任务时,直接选择以下平台预置镜像,无需上传到自己的镜像仓库:
cr.infini-ai.com/infini-ai/verl:app-verl0.6-transformers4.56.1-sglang0.5.2-mcore0.13.0-te2.2该镜像提供 PyTorch、Ray、Transformers 和 SGLang 等运行依赖。verl 源代码按照下一节固定在共享存储中,训练任务不会联网克隆代码。
准备固定版本的 verl 代码
在 AICoder 或开发机中执行:
set -euo pipefail
export PREP_ROOT=/mnt/rlinf-reproduction
export VERL_COMMIT=ddd86f527a4af75095e4677b02b5aa272913a088
export VERL_ROOT="$PREP_ROOT/code/verl/$VERL_COMMIT"
test ! -e "$VERL_ROOT" || {
echo "verl target already exists: $VERL_ROOT" >&2
exit 1
}
mkdir -p "$PREP_ROOT/code/verl"
git clone https://github.com/verl-project/verl.git "$VERL_ROOT"
git -C "$VERL_ROOT" checkout --detach "$VERL_COMMIT"
test "$(git -C "$VERL_ROOT" rev-parse HEAD)" = "$VERL_COMMIT"
test -z "$(git -C "$VERL_ROOT" status --porcelain)"如果目标目录已经存在,请检查 commit 和工作树;只有两项都符合时才继续使用。训练任务只读取这个 checkout,不会执行 git clone、git fetch 或依赖安装。共享存储中的代码和输出目录准备方法见准备代码、数据与输出目录。
下载并校验模型
以下命令使用 ModelScope CLI 下载固定 revision。工具准备方法见AICoder 下载模型。
set -euo pipefail
export PREP_ROOT=/mnt/rlinf-reproduction
export MODEL_REVISION=6fc93244f442ee2b5ab5c8000687ef5f7ffe1d03
export MODEL_ROOT="$PREP_ROOT/models/modelscope/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B/$MODEL_REVISION"
test ! -e "$MODEL_ROOT" || {
echo "Model target already exists: $MODEL_ROOT" >&2
exit 1
}
mkdir -p "$MODEL_ROOT"
modelscope download deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B \
--revision "$MODEL_REVISION" \
--local-dir "$MODEL_ROOT" \
--max-workers 2
test "$(stat -c %s "$MODEL_ROOT/model.safetensors")" -eq 3554214621
printf '%s %s\n' \
58858233513d76b8703e72eed6ce16807b523328188e13329257fb9594462945 \
"$MODEL_ROOT/model.safetensors" | sha256sum -c -
(
cd "$MODEL_ROOT"
find . -type f ! -path './.cache/*' ! -name SHA256SUMS -print0 \
| LC_ALL=C sort -z \
| xargs -0 sha256sum > SHA256SUMS
sha256sum -c SHA256SUMS
test "$(sha256sum SHA256SUMS | cut -d ' ' -f 1)" = \
0ea1d330342b4b9efbd1c3648360fbc4c2e3b1d5abd6120e49ef837649001f7b
)如果模型目录已经由其他教程准备,并且最后三项校验通过,可以直接复用,无需再次下载或复制权重。
下载固定版本的 GSM8K
以下命令使用 Hugging Face CLI 下载两个 Parquet 文件。工具准备方法见使用 HuggingFace 镜像站下载模型和数据集。
set -euo pipefail
export PREP_ROOT=/mnt/rlinf-reproduction
export DATASET_REVISION=740312add88f781978c0658806c59bc2815b9866
export DATASET_ROOT="$PREP_ROOT/datasets/huggingface/openai/gsm8k/$DATASET_REVISION"
export RAW_ROOT="$DATASET_ROOT/raw"
test ! -e "$DATASET_ROOT" || {
echo "Dataset target already exists: $DATASET_ROOT" >&2
exit 1
}
mkdir -p "$RAW_ROOT"
hf download openai/gsm8k \
main/train-00000-of-00001.parquet \
main/test-00000-of-00001.parquet \
--repo-type dataset \
--revision "$DATASET_REVISION" \
--local-dir "$RAW_ROOT"
(
cd "$RAW_ROOT"
printf '%s %s\n' \
ea82612ea9582142387730c793eb67d3b12849002bc0b7fa6f8efafa7351419d \
main/train-00000-of-00001.parquet \
ee7b8da9e381df27b9e3f7758a159ab2bdaa4dbaa910546cbbc47e0cb44e4f59 \
main/test-00000-of-00001.parquet > SHA256SUMS
sha256sum -c SHA256SUMS
)在 $RAW_ROOT/SOURCE.json 保存以下内容:
{
"dataset": "openai/gsm8k",
"revision": "740312add88f781978c0658806c59bc2815b9866"
}把 GSM8K 转换为 verl 数据格式
保存以下 Python 文件到 /mnt/rlinf-reproduction/tools/verl/single-gpu-grpo/prepare-gsm8k-v1.py:
显示代码隐藏代码,文件 prepare-gsm8k-v1.py155 行
#!/usr/bin/env python3
"""Convert a pinned openai/gsm8k snapshot into verl v0.6 GRPO Parquet files."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path
import pyarrow as pa
import pyarrow.parquet as pq
DATASET = "openai/gsm8k"
REVISION = "740312add88f781978c0658806c59bc2815b9866"
INSTRUCTION = ' Let\'s think step by step and output the final answer after "####".'
RAW_FILES = {
"train": {
"relative_path": "main/train-00000-of-00001.parquet",
"sha256": "ea82612ea9582142387730c793eb67d3b12849002bc0b7fa6f8efafa7351419d",
"rows": 7473,
},
"test": {
"relative_path": "main/test-00000-of-00001.parquet",
"sha256": "ee7b8da9e381df27b9e3f7758a159ab2bdaa4dbaa910546cbbc47e0cb44e4f59",
"rows": 1319,
},
}
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def extract_solution(answer: str) -> str:
match = re.search(r"#### (\-?[0-9\.\,]+)", answer)
if match is None:
raise ValueError(f"GSM8K answer does not contain a final solution: {answer!r}")
return match.group(1).replace(",", "")
def convert_split(raw_root: Path, work_root: Path, split: str) -> dict[str, object]:
spec = RAW_FILES[split]
source = raw_root / str(spec["relative_path"])
if not source.is_file():
raise FileNotFoundError(source)
observed_hash = sha256(source)
if observed_hash != spec["sha256"]:
raise RuntimeError(f"Raw {split} SHA-256 mismatch: {observed_hash}")
raw_table = pq.read_table(source, columns=["question", "answer"])
if raw_table.num_rows != spec["rows"]:
raise RuntimeError(f"Unexpected {split} row count: {raw_table.num_rows}")
records = []
for index, raw in enumerate(raw_table.to_pylist()):
question = raw["question"]
answer = raw["answer"]
if not isinstance(question, str) or not question:
raise ValueError(f"Invalid question at {split}:{index}")
if not isinstance(answer, str) or not answer:
raise ValueError(f"Invalid answer at {split}:{index}")
records.append(
{
"data_source": DATASET,
"prompt": [{"role": "user", "content": question + INSTRUCTION}],
"ability": "math",
"reward_model": {"style": "rule", "ground_truth": extract_solution(answer)},
"extra_info": {
"split": split,
"index": index,
"answer": answer,
"question": question,
},
}
)
output = work_root / f"{split}.parquet"
table = pa.Table.from_pylist(records)
pq.write_table(table, output, compression="snappy", use_dictionary=True)
restored = pq.read_table(output)
required_columns = {"data_source", "prompt", "ability", "reward_model", "extra_info"}
if restored.num_rows != spec["rows"] or set(restored.column_names) != required_columns:
raise RuntimeError(f"Prepared {split} Parquet failed schema or row-count validation")
sample = restored.slice(0, 1).to_pylist()[0]
if sample["data_source"] != DATASET or sample["reward_model"]["style"] != "rule":
raise RuntimeError(f"Prepared {split} Parquet failed semantic validation")
return {
"file": output.name,
"bytes": output.stat().st_size,
"rows": restored.num_rows,
"sha256": sha256(output),
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--raw-root", type=Path, required=True)
parser.add_argument("--output-root", type=Path, required=True)
args = parser.parse_args()
raw_root = args.raw_root.resolve()
output_root = args.output_root.resolve()
source = json.loads((raw_root / "SOURCE.json").read_text(encoding="utf-8"))
if source.get("dataset") != DATASET or source.get("revision") != REVISION:
raise RuntimeError("Raw dataset identity does not match the pinned GSM8K snapshot")
if output_root.exists():
raise FileExistsError(f"Refusing to overwrite prepared dataset: {output_root}")
work_root = output_root.with_name(f"{output_root.name}.preparing-{os.getpid()}")
if work_root.exists():
raise FileExistsError(work_root)
work_root.mkdir(parents=True)
try:
outputs = {split: convert_split(raw_root, work_root, split) for split in ("train", "test")}
manifest = {
"status": "prepared",
"prepared_at": datetime.now(timezone.utc).isoformat(),
"dataset": DATASET,
"revision": REVISION,
"configuration": "main",
"conversion_authority": "verl v0.6.0 examples/data_preprocess/gsm8k.py",
"conversion_script_sha256": sha256(Path(__file__).resolve()),
"python": sys.version.split()[0],
"pyarrow": pa.__version__,
"raw_files": RAW_FILES,
"outputs": outputs,
}
(work_root / "dataset-manifest.json").write_text(
json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
checksum_lines = [f"{outputs[split]['sha256']} {outputs[split]['file']}" for split in ("train", "test")]
(work_root / "SHA256SUMS").write_text("\n".join(checksum_lines) + "\n", encoding="utf-8")
os.replace(work_root, output_root)
except BaseException:
shutil.rmtree(work_root, ignore_errors=True)
raise
print(json.dumps(manifest, indent=2, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())运行脚本并校验处理结果:
set -euo pipefail
export PREP_ROOT=/mnt/rlinf-reproduction
export DATASET_REVISION=740312add88f781978c0658806c59bc2815b9866
export DATASET_ROOT="$PREP_ROOT/datasets/huggingface/openai/gsm8k/$DATASET_REVISION"
export RAW_ROOT="$DATASET_ROOT/raw"
export PREPARED_ROOT="$DATASET_ROOT/processed/verl-v0.6.0-v1"
export PREPARE_SCRIPT="$PREP_ROOT/tools/verl/single-gpu-grpo/prepare-gsm8k-v1.py"
python3 -m py_compile "$PREPARE_SCRIPT"
test "$(sha256sum "$PREPARE_SCRIPT" | cut -d ' ' -f 1)" = \
21cf6d2f92378bd03543c05a71af35257e283b4f06f45c62437fbf04b3f63169
python3 "$PREPARE_SCRIPT" \
--raw-root "$RAW_ROOT" \
--output-root "$PREPARED_ROOT"
(
cd "$PREPARED_ROOT"
sha256sum -c SHA256SUMS
)输出应包含 7,473 条训练数据和 1,319 条测试数据。dataset-manifest.json 记录数据集 revision、处理脚本哈希、行数和输出文件哈希。
保存运行前检查和完成检查脚本
以下脚本在训练前确认 GPU、共享存储和固定输入,在训练结束后检查 step、rollout、TensorBoard 和 checkpoint。保存到 /mnt/rlinf-reproduction/tools/verl/single-gpu-grpo/verify-single-gpu-grpo-v1.py:
显示代码隐藏代码,文件 verify-single-gpu-grpo-v1.py279 行
#!/usr/bin/env python3
"""Positive preflight and completion checks for the bounded single-GPU verl GRPO run."""
from __future__ import annotations
import argparse
import hashlib
import importlib
import importlib.metadata
import json
import math
import os
import re
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
EXPECTED_COMMIT = "ddd86f527a4af75095e4677b02b5aa272913a088"
EXPECTED_DATASET_REVISION = "740312add88f781978c0658806c59bc2815b9866"
EXPECTED_MODEL_MANIFEST = "0ea1d330342b4b9efbd1c3648360fbc4c2e3b1d5abd6120e49ef837649001f7b"
REQUIRED_SCALARS = (
"training/global_step",
"actor/pg_loss",
"actor/grad_norm",
"actor/lr",
"critic/score/mean",
)
FATAL_PATTERNS = (
r"Traceback \(most recent call last\)",
r"Error executing job with overrides",
r"CUDA out of memory",
r"RayTaskError",
r"NCCL[^\n]*(?:error|unhandled|failed)",
r"Segmentation fault",
r"Killed(?:\s|$)",
)
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def run(*command: str) -> str:
return subprocess.run(command, check=True, text=True, capture_output=True).stdout.strip()
def package_record(distribution: str, module_name: str | None = None) -> dict[str, object]:
module = importlib.import_module(module_name or distribution.replace("-", "_"))
try:
distribution_version = importlib.metadata.version(distribution)
except importlib.metadata.PackageNotFoundError:
distribution_version = None
return {
"distribution_version": distribution_version,
"module_version": getattr(module, "__version__", None),
"module_file": getattr(module, "__file__", None),
}
def atomic_json(path: Path, data: dict[str, object]) -> None:
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
os.replace(temporary, path)
def preflight(args: argparse.Namespace) -> int:
import pyarrow.parquet as pq
import torch
output_dir = args.output_dir.resolve()
output_dir.mkdir(parents=True, exist_ok=True)
source_commit = run("git", "-C", str(args.verl_root), "rev-parse", "HEAD")
if source_commit != EXPECTED_COMMIT:
raise RuntimeError(f"Unexpected verl commit: {source_commit}")
if run("git", "-C", str(args.verl_root), "status", "--porcelain"):
raise RuntimeError("The pinned verl checkout is not clean")
model_manifest_hash = sha256(args.model_root / "SHA256SUMS")
if model_manifest_hash != EXPECTED_MODEL_MANIFEST:
raise RuntimeError(f"Unexpected model manifest: {model_manifest_hash}")
dataset_manifest = json.loads((args.data_root / "dataset-manifest.json").read_text(encoding="utf-8"))
if dataset_manifest.get("revision") != EXPECTED_DATASET_REVISION:
raise RuntimeError("Unexpected processed GSM8K revision")
datasets = {}
for split, expected_rows in (("train", 7473), ("test", 1319)):
path = args.data_root / f"{split}.parquet"
table = pq.read_table(path)
if table.num_rows != expected_rows:
raise RuntimeError(f"Unexpected {split} rows: {table.num_rows}")
sample = table.slice(0, 1).to_pylist()[0]
if sample["data_source"] != "openai/gsm8k" or sample["reward_model"]["style"] != "rule":
raise RuntimeError(f"Invalid {split} semantic fields")
datasets[split] = {"path": str(path), "rows": table.num_rows, "bytes": path.stat().st_size}
if torch.cuda.device_count() != 1:
raise RuntimeError(f"Expected exactly one visible GPU, got {torch.cuda.device_count()}")
properties = torch.cuda.get_device_properties(0)
gpu = {
"name": properties.name,
"capability": list(torch.cuda.get_device_capability(0)),
"total_memory_bytes": properties.total_memory,
"torch_cuda_version": torch.version.cuda,
}
nvidia_smi = run(
"nvidia-smi",
"--query-gpu=driver_version,name,memory.total",
"--format=csv,noheader,nounits",
).splitlines()
mount = json.loads(
run("findmnt", "-J", "-T", str(args.storage_root), "-o", "TARGET,SOURCE,FSTYPE,OPTIONS")
)["filesystems"][0]
if mount["fstype"] == "overlay" or "rw" not in mount["options"].split(","):
raise RuntimeError(f"Storage root is not a writable shared mount: {mount}")
packages = {
"torch": package_record("torch"),
"ray": package_record("ray"),
"transformers": package_record("transformers"),
"sglang": package_record("sglang"),
"pyarrow": package_record("pyarrow"),
"tensorboard": package_record("tensorboard"),
"verl": package_record("verl"),
}
record = {
"status": "passed",
"phase": "preflight",
"checked_at": datetime.now(timezone.utc).isoformat(),
"run_id": args.run_id,
"target_steps": args.target_steps,
"train_batch_size": args.train_batch_size,
"rollout_n": args.rollout_n,
"python": {"version": sys.version.split()[0], "executable": sys.executable},
"nvidia_smi": nvidia_smi,
"gpu": gpu,
"mount": mount,
"packages": packages,
"verl_source": {"path": str(args.verl_root), "commit": source_commit, "clean": True},
"model": {"path": str(args.model_root), "manifest_sha256": model_manifest_hash},
"dataset": {"path": str(args.data_root), "manifest": dataset_manifest, "splits": datasets},
}
atomic_json(output_dir / "preflight.json", record)
print(json.dumps(record, indent=2, ensure_ascii=False))
return 0
def load_scalars(tensorboard_root: Path) -> tuple[list[Path], dict[str, list[dict[str, float | int]]]]:
from tensorboard.backend.event_processing.event_accumulator import EventAccumulator
event_files = sorted(tensorboard_root.rglob("events.out.tfevents.*"))
if not event_files or any(path.stat().st_size == 0 for path in event_files):
raise RuntimeError("No non-empty TensorBoard event file was produced")
scalars: dict[str, list[dict[str, float | int]]] = {}
for event_file in event_files:
accumulator = EventAccumulator(str(event_file), size_guidance={"scalars": 0})
accumulator.Reload()
for tag in accumulator.Tags().get("scalars", []):
scalars.setdefault(tag, []).extend(
{"step": event.step, "value": event.value, "wall_time": event.wall_time}
for event in accumulator.Scalars(tag)
)
return event_files, scalars
def postflight(args: argparse.Namespace) -> int:
output_dir = args.output_dir.resolve()
log_text = args.training_log.read_text(encoding="utf-8", errors="replace")
for pattern in FATAL_PATTERNS:
if re.search(pattern, log_text, flags=re.IGNORECASE):
raise RuntimeError(f"Fatal training marker found: {pattern}")
required_log_markers = (
"[validate_config] All configuration checks passed successfully!",
f"Total training steps: {args.target_steps}",
f"global_step_{args.target_steps}",
)
missing_markers = [marker for marker in required_log_markers if marker not in log_text]
if missing_markers:
raise RuntimeError(f"Training log is missing completion markers: {missing_markers}")
event_files, scalars = load_scalars(output_dir / "tensorboard")
metric_summary = {}
for tag in REQUIRED_SCALARS:
events = scalars.get(tag, [])
if not events:
raise RuntimeError(f"TensorBoard scalar is missing: {tag}")
latest = max(events, key=lambda event: int(event["step"]))
if int(latest["step"]) < args.target_steps or not math.isfinite(float(latest["value"])):
raise RuntimeError(f"TensorBoard scalar did not reach a finite target step: {tag}={latest}")
metric_summary[tag] = latest
expected_rollouts = args.train_batch_size * args.rollout_n
rollout_summary = {}
for step in range(1, args.target_steps + 1):
path = output_dir / "rollouts" / f"{step}.jsonl"
rows = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
if len(rows) != expected_rollouts:
raise RuntimeError(f"Unexpected rollout count at step {step}: {len(rows)}")
for row in rows:
if row.get("step") != step or not row.get("input") or not isinstance(row.get("output"), str):
raise RuntimeError(f"Invalid rollout row at step {step}")
if not math.isfinite(float(row["score"])):
raise RuntimeError(f"Non-finite rollout score at step {step}")
rollout_summary[str(step)] = {
"path": str(path),
"rows": len(rows),
"bytes": path.stat().st_size,
"score_min": min(float(row["score"]) for row in rows),
"score_max": max(float(row["score"]) for row in rows),
}
checkpoint_root = output_dir / "checkpoints"
checkpoint = checkpoint_root / f"global_step_{args.target_steps}"
actor = checkpoint / "actor"
required_checkpoint_files = (
actor / "model_world_size_1_rank_0.pt",
actor / "optim_world_size_1_rank_0.pt",
actor / "extra_state_world_size_1_rank_0.pt",
checkpoint / "data.pt",
checkpoint_root / "latest_checkpointed_iteration.txt",
)
checkpoint_files = {}
for path in required_checkpoint_files:
if not path.is_file() or path.stat().st_size == 0:
raise RuntimeError(f"Checkpoint file is missing or empty: {path}")
checkpoint_files[str(path.relative_to(output_dir))] = path.stat().st_size
if (checkpoint_root / "latest_checkpointed_iteration.txt").read_text().strip() != str(args.target_steps):
raise RuntimeError("Checkpoint tracker does not match the target step")
record = {
"status": "passed",
"phase": "postflight",
"verified_at": datetime.now(timezone.utc).isoformat(),
"run_id": args.run_id,
"target_steps": args.target_steps,
"train_batch_size": args.train_batch_size,
"rollout_n": args.rollout_n,
"tensorboard": {
"event_files": [{"path": str(path), "bytes": path.stat().st_size} for path in event_files],
"metrics": metric_summary,
},
"rollouts": rollout_summary,
"checkpoint": {"path": str(checkpoint), "files": checkpoint_files},
}
atomic_json(output_dir / "verification.json", record)
(output_dir / "SUCCESS").write_text(datetime.now(timezone.utc).isoformat() + "\n", encoding="utf-8")
print(json.dumps(record, indent=2, ensure_ascii=False))
return 0
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="phase", required=True)
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--run-id", required=True)
common.add_argument("--output-dir", type=Path, required=True)
common.add_argument("--target-steps", type=int, required=True)
common.add_argument("--train-batch-size", type=int, required=True)
common.add_argument("--rollout-n", type=int, required=True)
before = subparsers.add_parser("preflight", parents=[common])
before.add_argument("--storage-root", type=Path, required=True)
before.add_argument("--verl-root", type=Path, required=True)
before.add_argument("--model-root", type=Path, required=True)
before.add_argument("--data-root", type=Path, required=True)
after = subparsers.add_parser("postflight", parents=[common])
after.add_argument("--training-log", type=Path, required=True)
return parser.parse_args()
if __name__ == "__main__":
parsed = parse_args()
raise SystemExit(preflight(parsed) if parsed.phase == "preflight" else postflight(parsed))这个脚本只有在训练日志、TensorBoard、rollout 和 checkpoint 都达到目标时才写入 SUCCESS。因此,后续验收不会只依赖 AIStudio 的任务状态。
保存训练启动脚本
保存以下 Shell 文件到 /mnt/rlinf-reproduction/tools/verl/single-gpu-grpo/run-single-gpu-grpo-v2.sh:
显示代码隐藏代码,文件 run-single-gpu-grpo-v2.sh166 行
#!/usr/bin/env bash
set -euo pipefail
: "${STORAGE_ROOT:?Set STORAGE_ROOT to the shared-storage mount path}"
: "${RUN_ID:?Set a unique RUN_ID}"
readonly VERL_COMMIT=ddd86f527a4af75095e4677b02b5aa272913a088
readonly DATASET_REVISION=740312add88f781978c0658806c59bc2815b9866
readonly TARGET_STEPS=2
readonly TRAIN_BATCH_SIZE=4
readonly ROLLOUT_N=2
readonly VERL_ROOT="${STORAGE_ROOT}/code/verl/${VERL_COMMIT}"
readonly MODEL_ROOT="${STORAGE_ROOT}/models/modelscope/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B/6fc93244f442ee2b5ab5c8000687ef5f7ffe1d03"
readonly DATA_ROOT="${STORAGE_ROOT}/datasets/huggingface/openai/gsm8k/${DATASET_REVISION}/processed/verl-v0.6.0-v1"
readonly TOOL_ROOT="${STORAGE_ROOT}/tools/verl/single-gpu-grpo"
readonly VERIFY_SCRIPT="${TOOL_ROOT}/verify-single-gpu-grpo-v1.py"
readonly OUTPUT_ROOT="${STORAGE_ROOT}/runs/verl/single-gpu-grpo"
readonly OUTPUT_DIR="${OUTPUT_ROOT}/${RUN_ID}"
if [[ -e "${OUTPUT_DIR}" ]]; then
echo "Refusing to reuse OUTPUT_DIR: ${OUTPUT_DIR}" >&2
exit 1
fi
mkdir -p "${OUTPUT_ROOT}"
mkdir "${OUTPUT_DIR}"
readonly DRIVER_LOG="${OUTPUT_DIR}/driver.log"
readonly TRAINING_LOG="${OUTPUT_DIR}/training.log"
readonly CHECKPOINT_ROOT="${OUTPUT_DIR}/checkpoints"
readonly TENSORBOARD_DIR="${OUTPUT_DIR}/tensorboard"
readonly ROLLOUT_DIR="${OUTPUT_DIR}/rollouts"
readonly HYDRA_DIR="${OUTPUT_DIR}/hydra"
exec > >(tee -a "${DRIVER_LOG}") 2>&1
on_error() {
local status=$?
if [[ ! -e "${OUTPUT_DIR}/SUCCESS" ]]; then
printf '%s exit=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${status}" > "${OUTPUT_DIR}/FAILED"
fi
exit "${status}"
}
trap on_error ERR
for path in "${VERL_ROOT}" "${MODEL_ROOT}" "${DATA_ROOT}"; do
test -d "${path}"
done
for path in "${DATA_ROOT}/train.parquet" "${DATA_ROOT}/test.parquet" "${VERIFY_SCRIPT}"; do
test -r "${path}"
done
if [[ -n "${PYTHONPATH:-}" ]]; then
export PYTHONPATH="${VERL_ROOT}:${PYTHONPATH}"
else
export PYTHONPATH="${VERL_ROOT}"
fi
export PYTHONUNBUFFERED=1
export HYDRA_FULL_ERROR=1
export HF_HUB_OFFLINE=1
export HF_DATASETS_OFFLINE=1
export TRANSFORMERS_OFFLINE=1
export WANDB_MODE=disabled
export TOKENIZERS_PARALLELISM=true
export TENSORBOARD_DIR
unset RAY_ADDRESS || true
echo "RUN_ID: ${RUN_ID}"
echo "STORAGE_ROOT: ${STORAGE_ROOT}"
findmnt -T "${STORAGE_ROOT}" -o TARGET,SOURCE,FSTYPE,OPTIONS
nvidia-smi --query-gpu=driver_version,name,memory.total --format=csv,noheader
echo "VERL_COMMIT: $(git -C "${VERL_ROOT}" rev-parse HEAD)"
test -z "$(git -C "${VERL_ROOT}" status --porcelain)"
(cd "${MODEL_ROOT}" && sha256sum -c SHA256SUMS)
(cd "${DATA_ROOT}" && sha256sum -c SHA256SUMS)
python3 "${VERIFY_SCRIPT}" preflight \
--run-id "${RUN_ID}" \
--output-dir "${OUTPUT_DIR}" \
--target-steps "${TARGET_STEPS}" \
--train-batch-size "${TRAIN_BATCH_SIZE}" \
--rollout-n "${ROLLOUT_N}" \
--storage-root "${STORAGE_ROOT}" \
--verl-root "${VERL_ROOT}" \
--model-root "${MODEL_ROOT}" \
--data-root "${DATA_ROOT}"
training_command=(
python3 -m verl.trainer.main_ppo
"algorithm.adv_estimator=grpo"
"algorithm.use_kl_in_reward=false"
"data.train_files=${DATA_ROOT}/train.parquet"
"data.val_files=${DATA_ROOT}/test.parquet"
"data.train_batch_size=${TRAIN_BATCH_SIZE}"
"data.max_prompt_length=512"
"data.max_response_length=256"
"data.filter_overlong_prompts=true"
"data.truncation=error"
"data.shuffle=false"
"actor_rollout_ref.model.path=${MODEL_ROOT}"
"actor_rollout_ref.model.use_shm=false"
"actor_rollout_ref.model.use_remove_padding=true"
"actor_rollout_ref.model.enable_gradient_checkpointing=true"
"actor_rollout_ref.actor.strategy=fsdp2"
"actor_rollout_ref.ref.strategy=fsdp2"
"critic.strategy=fsdp2"
"reward_model.strategy=fsdp2"
"actor_rollout_ref.actor.optim.lr=1e-6"
"actor_rollout_ref.actor.ppo_mini_batch_size=${TRAIN_BATCH_SIZE}"
"actor_rollout_ref.actor.ppo_micro_batch_size_per_gpu=2"
"actor_rollout_ref.actor.use_kl_loss=false"
"actor_rollout_ref.actor.entropy_coeff=0"
"actor_rollout_ref.actor.fsdp_config.param_offload=true"
"actor_rollout_ref.actor.fsdp_config.optimizer_offload=true"
"actor_rollout_ref.rollout.name=sglang"
"actor_rollout_ref.rollout.tensor_model_parallel_size=1"
"actor_rollout_ref.rollout.gpu_memory_utilization=0.3"
"actor_rollout_ref.rollout.n=${ROLLOUT_N}"
"actor_rollout_ref.rollout.log_prob_micro_batch_size_per_gpu=2"
"actor_rollout_ref.rollout.max_num_seqs=16"
"actor_rollout_ref.rollout.max_model_len=768"
"actor_rollout_ref.rollout.max_num_batched_tokens=2048"
"actor_rollout_ref.rollout.enable_chunked_prefill=false"
"actor_rollout_ref.rollout.enforce_eager=true"
"trainer.logger=[console,tensorboard]"
"trainer.project_name=verl_aistudio"
"trainer.experiment_name=${RUN_ID}"
"trainer.n_gpus_per_node=1"
"trainer.nnodes=1"
"trainer.val_before_train=false"
"trainer.test_freq=-1"
"trainer.save_freq=${TARGET_STEPS}"
"trainer.total_epochs=1"
"trainer.total_training_steps=${TARGET_STEPS}"
"trainer.resume_mode=disable"
"trainer.default_local_dir=${CHECKPOINT_ROOT}"
"trainer.rollout_data_dir=${ROLLOUT_DIR}"
"ray_kwargs.ray_init.num_cpus=8"
"hydra.job.chdir=false"
"hydra.output_subdir=null"
"hydra.run.dir=${HYDRA_DIR}"
)
printf '%q ' "${training_command[@]}" > "${OUTPUT_DIR}/resolved-command.txt"
printf '\n' >> "${OUTPUT_DIR}/resolved-command.txt"
printf 'Executing: '
printf '%q ' "${training_command[@]}"
printf '\n'
set +e
"${training_command[@]}" 2>&1 | tee -a "${TRAINING_LOG}"
training_status=${PIPESTATUS[0]}
set -e
if (( training_status != 0 )); then
echo "verl training exited with status ${training_status}" >&2
exit "${training_status}"
fi
python3 "${VERIFY_SCRIPT}" postflight \
--run-id "${RUN_ID}" \
--output-dir "${OUTPUT_DIR}" \
--target-steps "${TARGET_STEPS}" \
--train-batch-size "${TRAIN_BATCH_SIZE}" \
--rollout-n "${ROLLOUT_N}" \
--training-log "${TRAINING_LOG}"
trap - ERR
echo "Verification passed: ${OUTPUT_DIR}/verification.json"FSDP2 设置用于在所选镜像中完成 SGLang 权重同步。请保留以下四项:
actor_rollout_ref.actor.strategy=fsdp2
actor_rollout_ref.ref.strategy=fsdp2
critic.strategy=fsdp2
reward_model.strategy=fsdp2运行这个场景
按照以下步骤检查输入、创建训练任务并确认训练输出。
Step 1 校验共享存储中的固定输入
分配 GPU 前,在挂载同一共享存储的 AICoder 或开发机中执行:
set -euo pipefail
export PREP_ROOT=/mnt/rlinf-reproduction
export VERL_COMMIT=ddd86f527a4af75095e4677b02b5aa272913a088
export MODEL_REVISION=6fc93244f442ee2b5ab5c8000687ef5f7ffe1d03
export DATASET_REVISION=740312add88f781978c0658806c59bc2815b9866
export VERL_ROOT="$PREP_ROOT/code/verl/$VERL_COMMIT"
export MODEL_ROOT="$PREP_ROOT/models/modelscope/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B/$MODEL_REVISION"
export DATA_ROOT="$PREP_ROOT/datasets/huggingface/openai/gsm8k/$DATASET_REVISION/processed/verl-v0.6.0-v1"
export TOOL_ROOT="$PREP_ROOT/tools/verl/single-gpu-grpo"
findmnt -T "$PREP_ROOT" -o TARGET,SOURCE,FSTYPE,OPTIONS
test -w "$PREP_ROOT"
test "$(git -C "$VERL_ROOT" rev-parse HEAD)" = "$VERL_COMMIT"
test -z "$(git -C "$VERL_ROOT" status --porcelain)"
(cd "$MODEL_ROOT" && sha256sum -c SHA256SUMS)
(cd "$DATA_ROOT" && sha256sum -c SHA256SUMS)
python3 -m py_compile \
"$TOOL_ROOT/prepare-gsm8k-v1.py" \
"$TOOL_ROOT/verify-single-gpu-grpo-v1.py"
bash -n "$TOOL_ROOT/run-single-gpu-grpo-v2.sh"
test "$(sha256sum "$TOOL_ROOT/prepare-gsm8k-v1.py" | cut -d ' ' -f 1)" = \
21cf6d2f92378bd03543c05a71af35257e283b4f06f45c62437fbf04b3f63169
test "$(sha256sum "$TOOL_ROOT/verify-single-gpu-grpo-v1.py" | cut -d ' ' -f 1)" = \
470bf80987abab7004e56198bae0cc6ca4ba59b150a08fd562f9c8bde7f32e16
test "$(sha256sum "$TOOL_ROOT/run-single-gpu-grpo-v2.sh" | cut -d ' ' -f 1)" = \
e593e2b2a4c010db70b89a79332579a3b0003f52b33cff5e4f842baba8e20b8f命令应显示一个可写共享存储挂载,代码工作树保持干净,模型和数据的 SHA256SUMS 全部通过,三个脚本的语法检查和哈希检查以退出码 0 结束。
Step 2 创建单 Worker、单 GPU 训练任务
在 AIStudio 中选择 训练任务,然后设置:
- 资源类型:选择 spot。
- 可用区:本文使用宁夏 B;也可以选择满足镜像、GPU 和共享存储条件的其他可用区。
- 镜像:选择本教程指定的 verl 0.6 + SGLang 平台预置镜像。
- Worker 规格:选择包含 1 张 A100-SXM4-80GB 的规格。
- Worker 数量:填写
1。 - 分布式框架:保持 单机。
- RDMA 配置:保持关闭。
一个 Worker 的任务无需填写 RuntimeEnv。verl 会在 Worker 内启动本地 Ray;启动命令也不需要设置 RAY_ADDRESS。
Step 3 挂载共享存储并设置 RUN_ID
在存储配置中选择准备模型和数据时使用的同一共享存储卷,并设置:
- 将该共享存储卷在 Worker 内的访问路径设置为
/mnt/verl-reproduction; - 访问权限:可读写;
- 环境变量:添加一个新的运行标识,例如
RUN_ID=verl-single-gpu-grpo-exp-001。
/mnt/rlinf-reproduction 和 /mnt/verl-reproduction 是同一存储卷在不同容器中的访问路径。训练脚本根据 STORAGE_ROOT=/mnt/verl-reproduction 拼接代码、模型、数据和输出路径,不会复制模型权重。
每次创建、克隆或重跑任务时更换 RUN_ID。启动脚本检测到同名输出目录后会停止,避免覆盖已有日志和 checkpoint。
Step 4 让 TensorBoard 读取本次运行
开启 任务可视化,在 日志存储路径 中填写:
/mnt/verl-reproduction/runs/verl/single-gpu-grpo/${RUN_ID}/tensorboardverl 的 TensorBoard logger 和 AIStudio 托管的 TensorBoard 服务会使用同一个 run 目录。${RUN_ID} 由任务环境变量提供;固定绝对路径同样受支持。变量替换、固定路径和任务结束后的查看方式见使用训练任务托管的 TensorBoard 服务。
Step 5 填写短启动命令
在 启动命令 中填写:
set -euo pipefail
export STORAGE_ROOT=/mnt/verl-reproduction
nvidia-smi --query-gpu=driver_version,name --format=csv,noheader
sha256sum /mnt/verl-reproduction/tools/verl/single-gpu-grpo/run-single-gpu-grpo-v2.sh | grep -q ^e593e2b2a4c010db70b89a79332579a3b0003f52b33cff5e4f842baba8e20b8f
exec /bin/bash /mnt/verl-reproduction/tools/verl/single-gpu-grpo/run-single-gpu-grpo-v2.sh启动命令先打印 NVIDIA driver 版本和 GPU 型号,确认启动脚本哈希后再执行。输入校验、训练参数、错误传播和完成检查都保留在具名文件中,便于在 AICoder 中做语法检查和排错。
Step 6 确认配置并创建任务
提交前检查以下值是否一致:
- 1 个 Worker、1 张 A100-SXM4-80GB、单机、RDMA 关闭;
- 平台预置的 verl 0.6 + SGLang 镜像;
- 共享存储的 Worker 内路径为
/mnt/verl-reproduction,并且可读写; RUN_ID在本次任务中唯一;- TensorBoard 路径和启动命令都使用
/mnt/verl-reproduction; - 启动命令执行
run-single-gpu-grpo-v2.sh。
确认后单击 确认创建。任务创建完成后,在任务详情中再次核对共享存储路径和 RUN_ID。
Step 7 确认两步 GRPO 训练完成
打开任务详情的 任务日志,依次检查:
日志开头打印 NVIDIA driver 版本和
NVIDIA A100-SXM4-80GB。preflight.json的内容出现在日志中,并显示一个可写共享存储挂载、1 张可见 GPU、固定 verl commit、模型 manifest 和 GSM8K revision。verl 输出以下配置检查和训练范围:
language-text[validate_config] All configuration checks passed successfully! Total training steps: 2step 日志先后包含
training/global_step:1和training/global_step:2。日志显示 checkpoint 写入
checkpoints/global_step_2。最后出现:
language-textVerification passed: .../verification.json
训练指标可能因采样结果不同而变化。验收脚本要求指标为有限数值,但两步运行不用于判断模型是否收敛或效果是否提升。
Step 8 在共享存储中检查输出
任务结束后,在挂载同一共享存储的 AICoder 或开发机中,把 RUN_ID 替换为本次任务的值:
set -euo pipefail
export RUN_ID=verl-single-gpu-grpo-exp-001
export OUTPUT_DIR="/mnt/rlinf-reproduction/runs/verl/single-gpu-grpo/$RUN_ID"
test -s "$OUTPUT_DIR/SUCCESS"
test -s "$OUTPUT_DIR/verification.json"
grep -F '"status": "passed"' "$OUTPUT_DIR/verification.json"
grep -F '"target_steps": 2' "$OUTPUT_DIR/verification.json"
test "$(wc -l < "$OUTPUT_DIR/rollouts/1.jsonl")" -eq 8
test "$(wc -l < "$OUTPUT_DIR/rollouts/2.jsonl")" -eq 8
find "$OUTPUT_DIR/tensorboard" -type f -name 'events.out.tfevents.*' -size +0
find "$OUTPUT_DIR/checkpoints/global_step_2" -type f -size +0
du -sh "$OUTPUT_DIR/checkpoints/global_step_2"
sed -n '1,240p' "$OUTPUT_DIR/verification.json"verification.json 应记录 status: passed、目标 step、两份 rollout、TensorBoard 指标和 checkpoint 文件。checkpoint 约占 20 GiB;实际大小可能随框架输出和文件系统显示方式略有不同。
训练任务结束后,任务托管的 TensorBoard 服务不再用于查看日志。事件文件仍保留在共享存储中,可以在挂载同一存储的开发机中启动 TensorBoard;请参阅在开发机中运行 TensorBoard 服务。
运行更多训练步数
两步任务用于确认训练链路和输出格式。需要运行更长训练时:
- 把
run-single-gpu-grpo-v2.sh复制为新的版本化文件,在新文件中增大TARGET_STEPS。 - 保留 FSDP2 以及固定的模型和数据版本;如果改变 batch、序列长度、rollout 数量或 offload 设置,请重新评估显存和完成检查。
- 如果需要中间 checkpoint,把
trainer.save_freq改为所需间隔;当前脚本只在最后一步保存。 - 对新脚本执行
bash -n并记录新的 SHA-256,然后让启动命令执行新文件。 - 使用新的
RUN_ID,并确认共享存储可容纳多个 checkpoint。
更长训练需要根据目标单独设置评测、checkpoint 保留和恢复策略。本教程的结果只说明所选版本和资源可以按文中配置完成两步训练。
处理本场景特有故障
找不到 verl 模块
所选镜像提供训练依赖,verl 源代码从共享存储加载。确认固定 checkout 位于 /mnt/verl-reproduction/code/verl/ddd86f527a4af75095e4677b02b5aa272913a088,并且启动脚本把该目录加入 PYTHONPATH。
共享存储显示为 OverlayFS 或只读
确认任务挂载的是准备资源时使用的同一共享存储卷,Worker 内路径为 /mnt/verl-reproduction,权限为可读写。修正配置后使用新的 RUN_ID 重新运行。
日志出现 pidfd_getfd: Operation not permitted
确认任务执行的是 run-single-gpu-grpo-v2.sh,并且解析后的命令包含四项 strategy=fsdp2 设置。该错误发生在 SGLang 同步更新后权重时;本教程已验证的是 FSDP2 配置。脚本版本和哈希都正确但仍出现该错误时,请同时检查镜像 tag、verl commit 和 resolved-command.txt。
任务结束但没有 SUCCESS
查看同一 RUN_ID 目录中的 FAILED、driver.log 和 training.log。SUCCESS 只会在两个 step、有限指标、两份 rollout、TensorBoard 事件和 global_step_2 checkpoint 全部通过后生成;应修复最先失败的检查,再使用新的 RUN_ID 完整重跑。
增大训练参数后出现显存不足
恢复本教程的 batch、序列长度、rollout 数量和 offload 设置,先确认本教程的两步配置仍能完成。如果更大配置是训练目标,请把资源和参数调整作为一次新的验证,不要用两步任务的结果推断更大配置也能在单张 GPU 上运行。