# MVP v1.1 行动文档（实施方案 / 部署测试 / 验收口径）

本文档面向“把 v1 跑通的实验脚本，升级为可长期回归的 v1.1 最小系统”，并给出**开发改造 → 部署测试 → 验收**的可复现流程。

> v1.1 的核心约束（来自讨论结论）
> - 仍然必须通过 **head 节点执行 `ray job submit`** 提交任务。
> - 训练/driver **必须落在 worker**（head 不跑训练）。
> - 多版本 `verl` 共存：同一镜像不变，必须通过 **Ray Job `runtime_env.env_vars` 注入 `PYTHONPATH`** 让 job 粒度选择代码版本。
> - 存储只考虑 NFS：dev 环境我们自己 mount；生产环境容器内统一看到 `/private/`。

---

## 1. 目标与非目标

### 1.1 目标（v1.1 必须做到）

1) **可回归**：同一环境连续跑多次 PPO 回归，不互相覆盖，输出按 submission id 归档。  
2) **可扩展**：新增并验收通过 2 个 workload（**GRPO + SFT**）并跑通闭环。  
3) **可排障**：每个 job 目录包含完整的提交快照、关键 env、Ray 状态快照与日志入口。  
4) **可多版本共存**：同一 Ray 集群内，不同 job 通过 `PYTHONPATH` 选择不同 `verl` 代码快照。  

### 1.2 非目标（v1.1 不做）

- 不做平台 API/队列/多租户/RBAC（这是 v2/v3）。  
- 不做复杂调度（拓扑、IB 域、NUMA、Gang 等自动化策略）。  

---

## 2. 运行环境约定（dev / prod 一致抽象）

### 2.1 拓扑（单机 3 容器）

- `mvp-ray-head`：无 GPU，`ray start --head --num-cpus=0 --num-gpus=0`（控制面 only）
- `mvp-ray-worker-0`：4 GPU
- `mvp-ray-worker-1`：4 GPU

### 2.2 “head 不跑训练”的硬约束实现（必须）

1) **head CPU=0**：从资源层面阻断默认 task/driver 落到 head。  
2) **worker 自定义资源标签**：worker 启动时带 `--resources='{"worker_node": 100}'`。  
3) **ray job submit 强制 entrypoint 落 worker**：提交时必须带：  
   - `--entrypoint-resources='{"worker_node": 1}'`
   - `--entrypoint-num-cpus=1`（显式声明 driver 需要的 CPU）

> 验证口径：`ray job list` 的 `driver_info.node_ip_address` 必须是 worker 的 IP，而不是 head IP。

### 2.3 共享存储（NFS）与路径（关键）

- 生产环境：容器内共享根路径固定为 `/private/`（算力平台统一挂载 NFS）。  
- 开发环境：docker compose 也应把宿主机共享目录挂载到容器内的 `/private/`，从而做到 dev/prod 一致。

统一约定（容器内视角）：

- `SHARED_ROOT=/private`
- Job 输出：`${SHARED_ROOT}/jobs/<submission_id>/`

建议的共享目录结构（v1.1 新增 `common/` 与 `user/`）：

- `${SHARED_ROOT}/datasets/`：通用数据（例如 gsm8k parquet）
- `${SHARED_ROOT}/hf/`：HuggingFace cache（模型/分词器/权重）
- `${SHARED_ROOT}/jobs/`：按 submission id 归档的作业目录（强制）
- `${SHARED_ROOT}/outputs/`：临时/非强约束输出（不建议长期依赖）
- `${SHARED_ROOT}/ray/`：Ray 调试痕迹（可选，通常 Ray 默认写 `/tmp/ray`）
- `${SHARED_ROOT}/common/`：所有用户可读写共享区（模型/数据/代码快照）
  - `${SHARED_ROOT}/common/models/`：可复用基础模型（可用软链指向 hf cache 或 snapshot）
  - `${SHARED_ROOT}/common/datasets/`：共享数据（或与 `datasets/` 统一规划）
  - `${SHARED_ROOT}/common/code/`：代码快照（多版本 `verl` / 自定义 reward）
- `${SHARED_ROOT}/user/`：用户自定义内容（默认所有用户可写）
  - `${SHARED_ROOT}/user/code/`：reward_fn 等自定义 Python 代码

---

## 3. 开发实施方案（代码改造清单）

> v1.1 建议新增 `src/mvp/v1.1/`（保持 v1 可回归不被破坏）。

### 3.1 JobSpec（最小标准化）

v1.1 的工程化目标是把“提交机制”迁移到 Ray Python SDK，因此输入拆为两份 YAML：

1) Ray 基础配置（YAML）：address / entrypoint resources / runtime_env 等  
2) 训练 JobSpec（YAML）：workload 语义与训练参数（仍由 Hydra overrides 组织）

训练 JobSpec（YAML）至少包含：

- `submission_id`：可空；为空时由 submitter 生成（但最终必须显式传给 `ray job submit --submission-id`）
- `workload`：`ppo` / `grpo` / `sft`（v1.1 必须 `ppo` + `grpo` + `sft`）
- `shared_root`：默认 `/private`（容器内路径）
- `code_path`：`verl` 代码快照目录（用于多版本共存）
- `reward_fn_path`（可选）：指向 `${shared_root}/user/code/...` 下的 Python 文件或模块入口
- `model` / `dataset`：必须指向共享存储的持久化路径（避免每次下载/生成）
- `ray`：`address=http://127.0.0.1:8265`（从 head 容器内部视角）
- `resources`：
  - `entrypoint_resources={"worker_node":1}`
  - `entrypoint_num_cpus=1`
- `trainer_overrides`：训练参数覆盖（v1.1 默认 `total_epochs=1`、`save_freq=10`）
- `env_vars`：会被透传到 `runtime_env.env_vars`（必须包含 `PYTHONPATH` 注入）

交付物（v1.1 SDK 方式）：

- `src/mvp/v1.1/py/configs/dev.yaml`（Ray 基础配置示例）
- `src/mvp/v1.1/py/jobspecs/{ppo,grpo,sft}.yaml`（训练 JobSpec 示例）
- `src/mvp/v1.1/py/run.py`（入口：使用 Ray Python SDK 提交/查询/停止/拉日志）
- 设计文档：`specs/mvp/v1.1/sdk_submit_refactor.md`

### 3.2 多版本 `verl` 共存（必须）

原则：**镜像固定不变**；job 粒度通过 `PYTHONPATH` 选择 `verl` 代码快照。

提交时必须注入（runtime_env）：

- `PYTHONPATH="<CODE_PATH>:$PYTHONPATH"`（`CODE_PATH` 放最前面）

并要求 job 在日志中打印一行确认 import 来源，例如：

- `python -c "import verl,inspect; print(verl.__file__)"`（或训练入口启动时打印）

v1.1 具体实现（可复现）：

- 先用 `src/mvp/v1.1/scripts/31_snapshot_verl_code.sh` 生成代码快照目录 `${SHARED_ROOT}/common/code/verl/<code_id>/`
  - 该目录里会包含一个 `mvp_marker.py`（`MARKER=<code_id>`）
- 提交 job 时让 `code_path` 指向该快照目录；submitter 会在 entrypoint 前打印：
  - `MVP_PRECHECK_VERL_FILE`（验证 import 来源）
  - `MVP_PRECHECK_MARKER`（验证选择的 code_path）

### 3.3 `submit_job` 工具（组装 ray job submit）

新增一个提交器（建议 Python，避免复杂 bash quoting）：

- 输入：JobSpec JSON
- 产物：
  - 生成/确定 `submission_id`
  - 创建 `${SHARED_ROOT}/jobs/<id>/config/`、`logs/`、`checkpoints/`
  - 写入 `config/job_spec.json`（原样快照）
  - 写入 `config/runtime_env.json`（最终用于 submit 的 JSON）
  - 写入 `config/submit_cmd.txt`（最终命令行）
- 执行：在 **head 容器内**运行 `ray job submit ...`

### 3.4 可排障：debug bundle（强制落盘）

在 job 生命周期的关键节点收集并落盘（至少 2 类）：

- `ray status`
- `ray job list`
- `ray list nodes`
- `ray list actors`

建议落盘到：

- `${SHARED_ROOT}/jobs/<id>/debug/`（每次收集带时间戳文件名）

### 3.5 Workload 扩展：GRPO（v1.1 新增闭环）

优先用与 PPO 相同入口 `python -m verl.trainer.main_ppo`，仅通过配置切换算法：

- `algorithm.adv_estimator=grpo`
- 其余保持最小可跑：`total_epochs=1`、`save_freq=10`

### 3.6 Workload 扩展：SFT on Ray（v1.1 必须新增闭环）

#### 3.6.1 入口与参考实现

- 入口：`python -m verl.trainer.sft_trainer_ray`
- 参考代码：`verl/verl/trainer/sft_trainer.py`（非 Ray 版本）与 `verl/verl/trainer/sft_trainer_ray.py`（Ray 版本）

> v1.1 要验收的是 “SFT on Ray”，因此默认使用 `sft_trainer_ray.py`。

#### 3.6.2 连接已有 Ray 集群（必须）

`sft_trainer_ray.py` 内部直接调用 `ray.init()`，为了确保它连接到**已有集群**（head+workers），v1.1 约定：

- 提交 job 时通过 `runtime_env.env_vars` 注入：`RAY_ADDRESS=auto`

如果发现 `ray.init()` 未按预期读取 `RAY_ADDRESS`（Ray 版本差异风险），v1.1 需要提供一个 launcher 兜底：

- 由 launcher 先显式 `ray.init(address="auto")`，再调用 SFT trainer 逻辑

#### 3.6.3 SFT 数据格式（parquet schema）

`sft_trainer_ray` 默认使用 `MultiTurnSFTDataset`，parquet 中至少需要：

- `messages` 列：list[dict]，dict 至少含 `role`/`content`

v1.1 的 `prepare` 阶段需要生成并持久化 SFT 数据，例如：

- `${SHARED_ROOT}/datasets/gsm8k_sft/train.parquet`
- `${SHARED_ROOT}/datasets/gsm8k_sft/val.parquet`（可选）

单条样本的 `messages` 形态示例：

- `[{ "role": "user", "content": "<question>" }, { "role": "assistant", "content": "<answer>" }]`

> 注意：SFT parquet 不能直接复用 PPO/RL 的 parquet（schema 不同）。

#### 3.6.4 重要细节：SFT Ray Driver 不应依赖 GPU

在 `ray job submit` 模式下，我们的 entrypoint（driver）默认 **不会分配 GPU**（我们只指定了 `--entrypoint-num-cpus=1`，没有指定 `--entrypoint-num-gpus`）。

而 `verl/verl/trainer/sft_trainer_ray.py` 的 driver 逻辑里会用 `trainer.device` 来创建 `torch.tensor(..., device=...)` 做统计，如果设置为 `cuda` 且 driver 没有 GPU，会触发：

- `RuntimeError: No CUDA GPUs are available`

因此 v1.1 的 SFT on Ray 验收默认要求：

- `trainer.device=cpu`（driver 只做 orchestration；真正训练仍由 Ray 的 TrainingWorker/资源池占用 GPU）

### 3.7 v1.1 脚本化交付（必须独立完整）

`src/mvp/v1.1/` 需要像 v1 一样提供一套完整脚本，确保 v1.1 可独立运行、可回归：

- `src/mvp/v1.1/docker-compose.yaml`（容器名建议与 v1 区分，避免冲突）
- `src/mvp/v1.1/scripts/00_prereq_check.sh`（含 GPU/目录/NFS/verl 代码检查）
- `src/mvp/v1.1/scripts/01_up.sh` / `02_down.sh`（起停）
- `src/mvp/v1.1/scripts/20_start_head.sh` / `21_start_workers.sh`
- `src/mvp/v1.1/scripts/30_prepare_data_and_model.sh`（包含 PPO 数据 + SFT 数据）
- `src/mvp/v1.1/scripts/40_submit_ppo_epoch1.sh`
- `src/mvp/v1.1/scripts/41_submit_grpo_epoch1.sh`
- `src/mvp/v1.1/scripts/42_submit_sft_minimal.sh`
- `src/mvp/v1.1/scripts/50_status.sh`
- `src/mvp/v1.1/scripts/31_snapshot_verl_code.sh`（多版本 code snapshot）
- `src/mvp/v1.1/scripts/43_submit_jobspec.sh`（通过 JobSpec 提交）
- `src/mvp/v1.1/scripts/12_install_py_deps.sh`（安装 PyYAML 等依赖）
- `src/mvp/v1.1/scripts/44_submit_sdk.sh`（通过 Ray Python SDK + YAML 提交）

---

## 4. 部署与测试流程（dev 环境）

> dev 环境以远程机目录为例：`argus@h1:/home2/argus/infra/mvp`。v1.1 的所有内容要求放在：
>
> - `argus@h1:/home2/argus/infra/mvp/v1.1/`
>
> 并在该目录中通过脚本使用 `docker exec` 协调容器。

### 4.0 清理 v1 环境（必须先做）

v1 已在 `argus@h1` 部署过容器与 Ray。为保证 v1.1 的可重复测试，开始 v1.1 前必须清理 v1：

1) 停止并删除 v1 容器（推荐用 v1 的 down 脚本）  
2) 确认 `docker ps` 中不再有 v1 的 `mvp-ray-head/mvp-ray-worker-*`  

v1.1 的脚本里也提供了一个 best-effort 清理脚本：`src/mvp/v1.1/scripts/03_cleanup_v1_legacy.sh`（远程目录中同名脚本）。

### 4.1 环境准备（一次性 / 幂等）

1) 目录检查（远程机）：
   - `${WORKDIR}/shared/` 存在并具备上述子目录（含 `common/`、`user/`）
2) `verl` 代码目录检查：
   - `${WORKDIR}/verl` 不存在则执行 `git clone https://github.com/volcengine/verl.git`
3) GPU 可用性检查：
   - 设备存在（例如 0-7 可见），并按 worker 容器分配（每个 worker 4 GPU）
4) 模型与数据持久化路径：
   - 模型与数据必须落在 `${SHARED_ROOT}` 下；若已存在则跳过下载/生成
   - SFT parquet 同样必须落在 `${SHARED_ROOT}` 下；若已存在则跳过生成

### 4.2 启动 Ray 集群（每次测试）

1) `docker compose up -d`
2) head：`ray start --head --num-cpus=0 --num-gpus=0 ...`
3) workers：`ray start --address=<head>:6379 --resources='{"worker_node":100}' ...`
4) 验证：`ray status` 显示 1 head + 2 worker，且 head `CPU:0 GPU:0`

### 4.3 提交 PPO 回归（必须跑 2 次）

1) 生成 JobSpec（可用模板 + 覆盖项）
2) 在 head 容器内执行 submitter（或直接 `ray job submit`）
3) 验证要点：
   - `ray job list`：driver node 是 worker
   - `${SHARED_ROOT}/jobs/<id>/` 下存在 `config/`、`logs/`、`checkpoints/`
   - checkpoint 每 10 step 产生（例如 `global_step_10`）

### 4.4 提交 GRPO（新增 workload 验收）

同 PPO，但覆盖 `algorithm.adv_estimator=grpo`，确保能进入 RUNNING 并完成最小步数。

### 4.5 提交 SFT on Ray（新增 workload 验收，必须）

1) 确认 `${SHARED_ROOT}/datasets/gsm8k_sft/train.parquet` 已存在（由 v1.1 prepare 生成）。  
2) 通过 head 容器执行 `ray job submit` 提交 `python -m verl.trainer.sft_trainer_ray`。  
3) 关键约束：
   - `runtime_env.env_vars.RAY_ADDRESS=auto`（连接已有集群）
   - `--entrypoint-resources='{"worker_node": 1}'`（driver 落 worker）
   - `PYTHONPATH=<code_path>:$PYTHONPATH`（多版本 verl）
4) 最小化训练配置建议（避免 OOM/耗时过长）：
   - `trainer.total_epochs=1`
   - `trainer.total_training_steps=10~30`
   - `trainer.save_freq=10`
   - `trainer.nnodes=2`、`trainer.n_gpus_per_node=4`（用满 8 卡做一次最小分布式验证）
   - `data.train_files=${SHARED_ROOT}/datasets/gsm8k_sft/train.parquet`
   - `trainer.default_local_dir=${SHARED_ROOT}/jobs/<id>/checkpoints`

### 4.6 工程化验证：JobSpec + 多版本共存（v1.1 必须）

1) 生成两个 code snapshot（不同 `CODE_ID`）：
   - `CODE_ID=codeA ./scripts/31_snapshot_verl_code.sh`
   - `CODE_ID=codeB ./scripts/31_snapshot_verl_code.sh`
2) 分别修改/复制 JobSpec 模板，使 `code_path` 指向不同 snapshot：
   - `${SHARED_ROOT}/common/code/verl/codeA`
   - `${SHARED_ROOT}/common/code/verl/codeB`
3) 用 JobSpec 提交（必须从 head）：
   - `./scripts/43_submit_jobspec.sh /workspace/mvp/v1.1/templates/ppo.json`（示例）
4) 在 Ray job logs 中验证：
   - `MVP_PRECHECK_MARKER` 打印为对应的 `codeA`/`codeB`
   - `MVP_PRECHECK_VERL_FILE` 指向 `${SHARED_ROOT}/common/code/verl/...` 而不是镜像内 site-packages

---

## 5. 验收标准（Definition of Done）

### 5.1 Hardening DoD（全部必选）

- [ ] 提交必须来自 head：能在 head 容器内看到 `ray job submit ...` 的提交记录  
- [ ] driver 不在 head：`ray job list` 的 `driver_info.node_ip_address` ∈ worker IP，且 ≠ head IP  
- [ ] 输出目录按 submission id 隔离：`${SHARED_ROOT}/jobs/<submission_id>/` 不复用、不覆盖  
- [ ] 数据/模型持久化：再次提交时不重复下载/生成（有 “skip if exists” 的日志）  
- [ ] checkpoint 策略有效：默认 `save_freq=10`，不会每 step 保存爆盘  
- [ ] debug bundle 落盘：`${SHARED_ROOT}/jobs/<id>/debug/` 至少包含 2 类 Ray 状态快照  
- [ ] 多版本共存验证通过：日志中能确认 `verl` import 来源来自 JobSpec 指定的 `code_path`  

### 5.2 Workload DoD（GRPO + SFT 都必须）

- [ ] GRPO job 能提交、RUNNING、完成最小训练步数  
- [ ] GRPO job 产物目录满足与 PPO 相同的目录规范与 debug 规范  
- [ ] SFT job 能提交、连接已有集群并跑到至少 1 个 step（建议最小步数/epoch）  
- [ ] SFT job 产物目录满足与 PPO 相同的目录规范与 debug 规范  

---

## 6. 生产环境部署注意事项（v1.1 需要考虑但不强制在 dev 全量模拟）

- 容器由算力平台创建：我们只负责 SSH 进去纳管（启动 ray / 提交 job / 收集产物）。  
- 容器内共享路径为 `/private`：所有脚本必须以 `SHARED_ROOT=/private` 工作，不得写死 `/mnt/shared`。  
- 认证仅内部 token：在 submitter 中把 token 作为 env var 透传（不写入日志明文）。  
