mvp v1 finish, 跑通docker 3容器ppo on ray cluster

This commit is contained in:
yuyr 2025-12-22 10:51:49 +08:00
commit aff97f8643
29 changed files with 3629 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
verl/
skypilot-ssh-test/
ray_in_docker/

2133
doc/arch.excalidraw Normal file

File diff suppressed because it is too large Load Diff

279
doc/hl_design.md Normal file
View File

@ -0,0 +1,279 @@
# AI Infra 训练平台建设方案
## 1. 愿景与目标
### 1.1 愿景
构建一套**端到端的智能化AI训练平台**,将分散的训练框架、资源调度、监控运维、数据管理能力整合为统一的标准化流水线,让大模型训练算法团队**专注于模型创新而非基础设施运维**,同时与现有运维智能体深度协同,实现训练任务的智能化运维闭环。
### 1.2 核心目标
| 目标维度 | 描述 |
|---------|------|
| **效率提升** | 训练任务从准备到启动时间缩短 70%,故障恢复时间缩短 50% |
| **标准化** | 建立统一的训练流程规范,消除"人人一套环境"的混乱局面 |
| **可观测性** | 全链路监控覆盖,训练状态、资源利用、异常事件一目了然 |
| **智能运维** | 与运维智能体对接,实现断训自动分析、故障智能诊断 |
---
## 2. 整体架构概览
```
┌─────────────────────────────────────────────────────────────────────┐
│ 用户交互层 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Web 前端 │ │ CLI 工具 │ │ API 接口 │ │
│ │ (任务提交) │ │ (高级用户) │ │ (自动化集成) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ 平台服务层 │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
│ │ 任务调度 │ │ 数据管理 │ │ 模型管理 │ │
│ │ (SkyPilot) │ │(schema/dataset)│ │ (版本/产物) │ │
│ └───────────────┘ └───────────────┘ └───────────────┘ │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
│ │ 镜像管理 │ │ 指标追踪 │ │ 日志中心 │ │
│ │(Local Registry)│ │ (W&B) │ │ (集中采集) │ │
│ └───────────────┘ └───────────────┘ └───────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ 智能运维层 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 运维智能体对接 │ │
│ │ • 断训自动分析 • 故障根因定位 • 资源利用率优化建议 │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ 基础设施层 │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
│ │ Kubernetes │ │ GPU 集群 │ │ 分布式存储 │ │
│ │ (容器编排) │ │ H20/A6000/H100│ │ (JuiceFS) │ │
│ └───────────────┘ └───────────────┘ └───────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
---
## 3. 用户故事
### 3.1 算法工程师视角
> **作为**一名算法工程师,
> **我希望**通过简单的界面配置就能提交一个多节点 RLHF 训练任务,
> **以便于**我可以专注于模型和数据本身,而不是花大量时间在环境配置和资源协调上。
**验收标准:**
- [ ] 在 Web 界面上选择数据集、模型、训练配置
- [ ] 一键提交后,系统自动完成资源分配、镜像拉取、任务启动
- [ ] 实时查看训练进度曲线和关键指标
---
> **作为**一名算法工程师,
> **我希望**训练中断时能快速定位问题原因,
> **以便于**减少排查时间,尽快恢复训练。
**验收标准:**
- [ ] 系统自动检测训练中断事件
- [ ] 智能体自动分析中断原因OOM、网络故障、硬件异常等
- [ ] 提供可操作的恢复建议
---
> **作为**一名算法工程师,
> **我希望**启动一个 Notebook 环境调试代码.
> **以便于**小规模试跑训练,测试训练数据集、调整模型参数
**验收标准:**
- [ ] Notebook容器启动速度
- [ ] 开发容器内置依赖包完善度,按照新包
---
### 3.2 团队负责人视角
> **作为**团队负责人,
> **我希望**能够看到所有训练任务的整体资源利用情况,
> **以便于**合理规划算力资源,识别资源浪费。
**验收标准:**
- [ ] 仪表盘展示各集群 GPU 利用率趋势
- [ ] 任务队列可视化,等待/运行/完成状态一目了然
- [ ] 资源使用报表按项目/用户统计
---
### 3.3 运维工程师视角
> **作为**运维工程师,
> **我希望**训练任务的监控数据能自动接入现有运维系统,
> **以便于**统一管理,减少割裂的监控工具。
**验收标准:**
- [ ] 训练任务指标自动推送到运维智能体
- [ ] 异常告警自动触发智能体分析流程
- [ ] 与现有运维系统数据互通
---
## 4. 里程碑规划
### 里程碑总览
```
M1 M2 M3 M4
│ │ │ │
────●──────────────────●──────────────────●──────────────────●────────▶
│ │ │ │
基础设施就绪 训练流水线上线 监控运维闭环 智能化运维
```
---
### M1: 基础设施就绪
**目标:** 完成底层平台搭建,具备运行训练任务的基础能力
| 交付物 | 说明 |
|-------|------|
| K8S 集群 | H20 集群上部署 Kubernetes支持 GPU 调度 |
| 本地 Registry | 内网镜像仓库,解决镜像拉取问题 |
| JuiceFS/MinIO | 分布式存储,数据集和模型 checkpoint 持久化 |
| 基础镜像 | veRL 训练镜像,预置常用依赖 |
**关键验证点:**
- 能够手动在 K8S 上启动单节点训练任务
- 数据从 JuiceFS 正常读写
- 镜像从本地 Registry 正常拉取
- 引入 Volcano 或 Kueue并配置 Gang Scheduling 策略实现All-or-Nothing 的资源分配
- 确认JuiceFS 的本地 SSD 缓存策略在其中一台机器部署MiniIO单节点另外两台机器上部署JuiceFS client
- 网络通信支持 RoCE/InfiniBand
- Notebook 交互式开发环境
---
### M2: 训练流水线上线
**目标:** 用户可通过前端提交和管理训练任务
| 交付物 | 说明 |
|-------|------|
| SkyPilot 集成 | 任务调度与资源编排 |
| W&B 本地服务 | 训练指标追踪与可视化 |
| 任务管理前端 | 数据上传、任务提交、进度查看、日志查看 |
| 数据管理模块 | 支持从 HuggingFace 链接或 FTP 导入数据集 |
**关键验证点:**
- 端到端完成一次多节点 SFT 训练
- 通过前端提交任务,查看 W&B 训练曲线
- 训练日志完整保存并可查询
- 多租户、项目制配额管理功能
---
### M3: 监控运维闭环
**目标:** 实现任务全生命周期监控,与运维智能体初步对接
| 交付物 | 说明 |
|-------|------|
| 资源监控 | GPU 利用率、显存、网络带宽实时采集 |
| 日志采集 | 训练日志集中存储,支持检索 |
| 智能体对接 | 断训事件自动推送,触发智能体分析 |
| 告警机制 | 异常状态OOM、任务卡死等自动告警 |
**关键验证点:**
- 训练任务异常时5 分钟内收到告警
- 断训事件自动生成分析报告
- Grafana 仪表盘展示集群整体健康状态
- sidecar方式部署 DCGM Exporter 来获取细粒度指标自动采集到Prometheus
- 断训时“保留现场”机制,供人工/智能体排查介入
---
### M4: 智能化运维
**目标:** 深度整合运维智能体,实现智能调度与自愈
| 交付物 | 说明 |
|-------|------|
| 故障自愈 | 常见故障自动处理(如重新调度到健康节点) |
| 智能调度 | 基于历史数据优化任务资源分配 |
| 根因分析 | 复杂故障场景的深度分析能力 |
| 容量预测 | 基于任务趋势预测算力需求 |
---
## 5. 约束与风险
### 5.1 已知约束
| 约束项 | 影响 | 应对策略 |
|-------|------|---------|
| **内网环境** | 无法直接访问HF/Dockerhub/Github资源模型、数据集 | 本地 Registry + 数据导入工具 |
| **算力平台限制** | 现有平台调度能力有限 | 引入 SkyPilot 作为上层调度 |
| **数据持久化** | 需要可靠的分布式存储 | JuiceFS + MinIO 方案 |
### 5.2 潜在风险
| 风险 | 可能性 | 影响 | 缓解措施 |
|-----|-------|------|---------|
| K8S 与现有系统集成复杂 | 中 | 高 | 先在 H20 集群小范围验证 |
| 智能体接口适配工作量大 | 中 | 中 | 早期明确接口规范,持续对齐 |
| 用户习惯迁移阻力 | 低 | 中 | 渐进式推广,保留手动模式 |
---
## 6. 资源与依赖
### 6.1 硬件资源
| 集群 | 配置 | 用途 |
|-----|------|------|
| H20 集群 | 2 节点 × 8 卡 = 16 卡 | 主力训练集群,首期部署目标 |
| A6000 集群 | 2 节点 × 4 卡 = 8 卡 | 开发测试、小规模实验 |
| H100 集群 | 多节点 | 目前仅提供容器方式不确定能否提供KubeConfig接入大规模训练 |
### 6.2 外部依赖
| 依赖项 | 状态 | 负责方 |
|-------|------|-------|
| yd运维智能体接口 | 已有基础 | |
| argus运维系统 | 已有 | 运维团队 |
---
## 7. 成功标准
### 阶段一完成标准M1 + M2
- [ ] 算法工程师可通过 Web 界面完成 SFT/RLHF 训练全流程
- [ ] 任务提交到开始训练时间 < 10 分钟
- [ ] 训练指标实时可视化,延迟 < 1 分钟
- [ ] 至少完成 3 个实际项目的验证使用
### 阶段二完成标准M3 + M4
- [ ] 断训事件 100% 自动检测并推送智能体
- [ ] 常见故障OOM、节点失联自动生成分析报告
- [ ] GPU 整体利用率提升 20%(通过更好的调度)
- [ ] 平均故障恢复时间MTTR缩短 50%
---
## 附录:关键技术选型
| 领域 | 选型 | 选型理由 |
|-----|------|---------|
| 容器编排 | Kubernetes | 业界标准,生态成熟 |
| 任务调度 | SkyPilot | 专为 ML 场景设计,支持多集群 |
| 分布式存储 | JuiceFS + MinIO | 兼容 POSIX适合训练场景 |
| 实验追踪 | W&B (自部署) | 功能完善,团队已有使用经验 |
| 镜像仓库 | Harbor / Registry | 内网环境必需 |
| 训练框架 | veRL / Megatron | 支持 RLHF与现有工作对齐 |

18
doc/requirements.md Normal file
View File

@ -0,0 +1,18 @@
目标设计一套AI任务调度和管理系统
环境在K8S集群商业化封装的算力平台上运行若干个GPU容器和CPU容器
约束CPU容器可以暴露端口供desktop访问CPU容器可以通过ssh访问GPU容器容器的生命周期在单独的算力平台控制台上管理所有容器
有共享的NFS文件系统
目标在CPU容器上部署一个web server 向desktop提供服务后端运行一个类似skypilot的程序能够注册和管理GPU容器实例进来。
- 用户在desktop上通过网站提交执行模型训练的任务任务管理程序会将任务加入调度队列等资源池有空闲资源的时候就会调度任务到对应的GPU节点上执行任务。任务有单节点任务和多节点任务两类预先的节点也提前配置好了多节点任务的容器之间是专门
走的IB网络。
任务完成技术选型主要是普通的skypilot不支持现成的ssh节点管理。你调研是否有节点满足需求的任务调度管理方案。
补充:运行的任务一般是 verl / skyRL 强化学习训练框架以及llamafactory 框架等。GPU节点容器假设是已经预先安装好了对应的环境依赖任务管理器只需要调度和查看任务执行情况还有监控任务是否健康有没有任务中断。
未来规划:
- 统一数据集管理,包括模型库、数据集库
- 统一日志采集&规范
- 统一容器GPU/CPU指标采集
- 统一的 weight & bias 指标服务器
结构图:见附件

315
doc/storage_deployment.md Normal file
View File

@ -0,0 +1,315 @@
# 关于MinIO + JuiceFS 在H20部署
这是一个架构设计的关键决策点。在 **H20 节点运行 K3s** 的前提下,我建议采取 **“存储组件独立部署Outside客户端插件化接入Inside”** 的混合策略。
简单来说:**MinIO 和 Redis 跑在 K3s 外面(用 DockerJuiceFS Client 通过 CSI 跑在 K3s 里面。**
以下是详细的架构拆解和理由:
### 1\. 架构图解
```mermaid
graph TD
subgraph "物理层 / Host OS (H20 Node)"
NVMe[NVMe SSD (物理盘)]
end
subgraph "K3s 集群 (容器层)"
Pod[训练任务 Pod]
CSI[JuiceFS CSI Driver (DaemonSet)]
Pod -- "PVC 挂载" --> CSI
end
subgraph "独立 Docker 容器 (非 K3s)"
Redis[Redis 容器]
MinIO[MinIO 容器]
end
%% 关键数据流
CSI -- "1. 读写缓存" --> NVMe
CSI -- "2. 网络 IO" --> MinIO
CSI -- "3. 元数据 IO" --> Redis
%% 避免环路
MinIO -. "数据持久化" .- NVMe
```
-----
### 2\. 组件部署位置详解
#### A. MinIO & Redis建议 **独立于 K3s 部署 (Outside)**
**方式**直接在宿主机Host OS上使用 `docker run``docker-compose` 启动,或者使用 `systemd` 管理。
**理由**
1. **爆炸半径隔离(最重要)**AI 实验初期K3s 集群可能会因为 CNI 网络插件配置错误、Device Plugin 崩溃、或者各种误操作导致集群不可用Crash。如果存储后端MinIO跑在 K3s 里K3s 一挂,你的数据就“锁”在里面了,连日志都拷不出来。
2. **资源竞争保护**K8s 的调度器虽然有 QoS但在只有 2 个节点的情况下H20 的高负载很容易导致控制面不稳。把 MinIO 独立出来,保证存储服务的 CPU/内存资源不受 K3s 内部 Pod 挤压。
3. **网络直通**:宿主机直接部署 MinIO使用的是 `host` 网络,少了一层 K8s Service/Ingress 转发,对于大带宽吞吐稍微有一点点性能优势。
#### B. JuiceFS Client建议 **集成在 K3s 内部 (Inside)**
**方式**:使用 **JuiceFS CSI Driver**
**理由**
1. **符合“平台化”愿景**:你的目标是做一个 "AI Infra 平台"。对于上层用户(算法工程师),他们不应该关心宿主机挂载了什么目录。他们提交的 YAML 只需要写:
```yaml
volumes:
- name: data
persistentVolumeClaim:
claimName: pvc-juicefs
```
这是标准的云原生体验。
2. **权限与隔离**CSI Driver 可以更好地处理容器内的挂载点权限问题。如果手动在宿主机 `mount` 然后用 `hostPath` 映射进容器,经常会遇到 `Permission Denied` 或者 `root` 用户归属问题,非常头疼。
3. **自动缓存管理**CSI Driver 可以通过配置 `StorageClass`,自动帮你处理缓存路径的挂载和清理。
-----
### 3\. 实操配置指南 (关键点)
既然你只有两台 H20 并且要上 K3s以下是落地的关键配置
#### 第一步:在宿主机启动 MinIO 和 Redis (后端)
不要用 Helm直接写一个 `docker-compose.yml` 放在 `/opt/ai-infra/` 下运行:
```yaml
version: '3'
services:
minio:
image: minio/minio
network_mode: "host" # 直接用宿主机网络,性能最好
volumes:
- /mnt/data1/minio_data:/data # 挂载到大容量HDD或SATA SSD
command: server /data --console-address ":9001"
redis:
image: redis:7
network_mode: "host"
volumes:
- /mnt/data1/redis_data:/data
command: redis-server --appendonly yes
```
#### 第二步:在 K3s 中安装 JuiceFS CSI Driver (前端)
1. **安装 CSI Driver**
```bash
helm repo add juicefs https://juicedata.github.io/charts/
helm install juicefs-csi-driver juicefs/juicefs-csi-driver -n kube-system
```
2. **创建 Secret (连接信息)**
```yaml
apiVersion: v1
kind: Secret
metadata:
name: juicefs-sc-secret
namespace: default
type: Opaque
stringData:
name: "myjfs"
metaurl: "redis://<宿主机IP>:6379/1" # 注意这里填宿主机内网IP
storage: "minio"
bucket: "http://<宿主机IP>:9000/mybucket"
access-key: "minioadmin"
secret-key: "minioadmin"
```
3. **创建 StorageClass (核心中的核心)**
这里要配置**本地缓存**,让 JuiceFS 能够利用 H20 的 NVMe。
```yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: juicefs-sc
provisioner: csi.juicedata.com
parameters:
csi.storage.k8s.io/node-publish-secret-name: juicefs-sc-secret
csi.storage.k8s.io/node-publish-secret-namespace: default
csi.storage.k8s.io/provisioner-secret-name: juicefs-sc-secret
csi.storage.k8s.io/provisioner-secret-namespace: default
# === 关键性能参数 ===
cache-dir: /mnt/nvme0/juicefs-cache # H20 宿主机上的 NVMe 路径
cache-size: "2048000" # 2TB 缓存上限
buffer-size: "512" # 写缓冲
prefetch: "2" # 预读并发度
```
### 4\. 备选方案:如果 CSI 搞不定怎么办?
如果 M1 阶段你发现 CSI Driver 调试太麻烦(有时候 K3s 的路径和标准 K8s 不一样,导致 CSI 挂载失败),可以退回到 **“Host Mount + HostPath”** 模式:
1. **Outside**: 在宿主机直接运行 `juicefs mount ... /mnt/ai-data`
2. **Inside**: 在 Pod YAML 里直接映射:
```yaml
volumes:
- name: data
hostPath:
path: /mnt/ai-data
type: Directory
```
**结论:**
优先尝试 **CSI Driver** 方案这才是正规军的做法能为你的“平台化”愿景打下基础。MinIO/Redis 请务必保持独立,不要让 K3s 的波动影响到底层数据安全。
----------------
# 关于Minio扩容
这是一个非常关键的运维问题。
首先要泼一盆冷水(也是为了避坑):**MinIO 不支持从“单机模式Standalone”无缝平滑扩容到“分布式模式Distributed”**。
* **单机模式**下MinIO 是直接把文件存成普通文件。
* **分布式模式**下MinIO 会启用**纠删码Erasure Coding**,把文件切片打散存储。
底层数据结构完全不同,因此你**不能**简单地把新节点加入旧集群。你必须通过\*\*“建立新集群 -\> 数据迁移 -\> 切换流量”\*\*的方式来完成。
鉴于你使用了 JuiceFS这个迁移过程其实比纯 MinIO 迁移要灵活得多。以下是针对你 H20/K3s 环境的最佳实战方案:
-----
### 方案概览:蓝绿部署迁移法
我们不修改旧的 MinIO而是搭建一个新的 3 节点集群,同步数据,然后改一下 K8s Secret 指向新集群。
#### 前置条件
假设你现有的环境是:
* **Old MinIO**: 跑在 Node A (192.168.1.10)
* **New Target**: 准备在 Node A, Node B, Node C 上跑 3 节点分布式 MinIO。
-----
### 第一步:搭建全新的 3 节点 MinIO 集群
由于旧的 MinIO 还在通过 `host` 网络运行(占用 9000 端口),新集群如果不关掉旧的,需要用**不同端口**(比如 9100或者部署在不同机器上。
假设你新增了机器,或者错开端口。以下是 3 节点分布式 MinIO 的 `docker-compose.yml` 示例(需要在三台机器上都运行):
```yaml
version: '3'
services:
minio-distributed:
image: minio/minio
network_mode: "host"
hostname: "node1" # 另外两台改为 node2, node3
# 关键:分布式启动命令,必须列出所有节点
command: server http://192.168.1.10:9100/data http://192.168.1.11:9100/data http://192.168.1.12:9100/data --console-address ":9101"
volumes:
- /mnt/data_new:/data # 挂载新的数据盘(或者旧盘的新目录)
environment:
MINIO_ROOT_USER: "admin"
MINIO_ROOT_PASSWORD: "strongpassword"
```
*注意3 节点 MinIO 允许挂掉 1 台机器而不丢失数据。*
-----
### 第二步:数据迁移 (两种路径)
鉴于你用的是 JuiceFS这里有**两条路**可选:
#### 路径 A底座迁移推荐速度快原汁原味
直接搬运 MinIO 里的对象块Block。因为 JuiceFS 把数据切成了固定的 Block 存在 MinIO 里,我们只需要把这些 Block 从旧 MinIO 搬到新 MinIO**不需要经过 JuiceFS 客户端**。
1. **安装 `mc` (MinIO Client)** 命令行工具。
2. **配置别名**
```bash
mc alias set oldm http://192.168.1.10:9000 minioadmin minioadmin
mc alias set newm http://192.168.1.10:9100 admin strongpassword
```
3. **全量镜像 (Mirror)**
```bash
# 创建新桶
mc mb newm/mybucket
# 开始同步数据 (将旧桶数据镜像到新桶)
# --watch 参数可以持续监听增量数据,适合不停机迁移
mc mirror --watch oldm/mybucket newm/mybucket
```
#### 路径 BJuiceFS 层面迁移(适合要换云厂商/存储类型)
如果你想顺便整理数据碎片,或者从 MinIO 迁移到 阿里云 OSS可以用这个。但在你的场景下路径 A 更快。
* 命令:`juicefs sync minio://... minio://...` (不推荐,因为需要解密再加密,消耗 CPU)。
-----
### 第三步:停机切换 (Cutover)
为了保证 100% 数据一致性建议申请一个短时间的维护窗口10-20分钟
1. **停止训练任务**Scale down 所有的 Training Job。
2. **停止旧 MinIO 写入**
* 确保 `mc mirror` 已经追平了数据(没有 pending
* 你可以把旧 MinIO 设为只读,或者直接停止旧容器。
3. **最后一次 Sync**
* 运行 `mc mirror --overwrite` 确保最后一点差异也同步了。
4. **修改 K8s Secret**
* 这是 JuiceFS 的魔力所在。你**不需要**重新格式化 JuiceFS也**不需要**修改元数据引擎Redis。你只需要告诉 JuiceFS“底下的对象存储换地方了”。
* 编辑 K8s 里的 Secret
<!-- end list -->
```bash
kubectl edit secret juicefs-sc-secret
```
* 修改 `bucket``access-key/secret-key`
<!-- end list -->
```yaml
stringData:
# Redis 配置保持不变!元数据还在那里
metaurl: "redis://192.168.1.10:6379/1"
# 修改 bucket 地址为新集群地址
# 注意MinIO 分布式集群通常前面会加一个 Nginx 做负载均衡
# 或者你可以填任意一个节点的 IP (MinIO 会自动转发)
bucket: "http://192.168.1.10:9100/mybucket"
access-key: "admin"
secret-key: "strongpassword"
```
5. **重启 CSI Driver**
* 删除 JuiceFS CSI 的 Pod让它们重建并加载新的 Secret。
* 删除使用该 PVC 的 Pod。
-----
### 第四步:验证与回收
1. **验证**
* 启动一个测试 Pod挂载 PVC。
* `ls` 一下目录,确认文件都在。
* `cat` 一个文件确认能读JuiceFS 会去新 MinIO 拿数据块)。
2. **回收**
* 确认一切正常运行几天后。
* 下线旧的单节点 MinIO 容器。
* 释放旧的磁盘空间。
### 专家建议 (Pro Tips)
1. **负载均衡 (Load Balancer)**
* 到了 3 节点分布式阶段,建议在 3 台 MinIO 前面架设一个 **Nginx** 或者 **HAProxy**
* 这样 JuiceFS Client 连接的是 `http://nginx:9000`Nginx 轮询转发给后端 3 台 MinIO。
* **好处**:如果某台 MinIO 挂了Nginx 自动剔除,训练任务完全无感。如果直连某台 IP那台挂了训练就断了。
2. **元数据备份**
* 在做这一切之前,**务必备份 Redis 的 dump.rdb**。
* 只要 Redis 在JuiceFS 的文件结构就在。对象存储里的数据块丢了还能通过文件名找回来(虽然内容坏了),但 Redis 丢了,对象存储里那一堆 `chunk-xxx` 的文件就是一堆毫无意义的二进制垃圾,神仙难救。
3. **拓扑限制**
* MinIO 扩容通常是“倍增”或者“对等扩容”。比如 4 节点扩容,通常是再加 4 节点(变成 2 个 Server Pool
* 所以,规划 3 节点时,最好磁盘大小一致,网络环境一致。

BIN
specs/assets/arch.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 MiB

BIN
specs/assets/layout.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 MiB

BIN
specs/assets/networking.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

BIN
specs/assets/scheduling.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 MiB

115
specs/hl_design_v2.md Normal file
View File

@ -0,0 +1,115 @@
# 基于Ray Cluster的overlay计算集群方案
**“中心化控制平面 + 分布式计算平面”** 的架构设计。系统基于 **Ray Cluster** 构建应用层调度器,利用 **SSH** 作为带外Out-of-Band节点发现机制并在底层通过 **NFS** 实现数据一致性。
### 0\. 算力平台资源
![](./assets/layout.png)
算力平台上可以申请单节点容器有无GPU均可以及分布式任务下多节点容器。
初步设想在同一个用户账户下预先申请一批分布式任务节点以及一批不同规格GPU数量的计算容器
并将这些计算容器全部纳管在一个CPU容器中的服务程序下提供统一的访问入口供云桌面多用户使用。
以下是该方案的**技术架构规范说明**
### 1\. 架构拓扑 (Architectural Topology)
![arch.png](./assets/arch.png)
系统采用 **Hub-and-Spoke (星型)** 拓扑结构逻辑上划分为控制平面Control Plane和计算平面Data Plane
* **控制平面 (Control Plane / Head Node)**
* 部署于 **CPU 容器**。
* 承载 **Ray GCS (Global Control Store)**负责集群元数据管理、任务调度表维护、Actor 寻址。
* 承载 **API Gateway (FastAPI)**:对外暴露 RESTful 接口,封装 Job Submission API。
* 承载 **Node Provisioner (Bootstrap Daemon)**:负责节点生命周期管理的守护进程。
* **计算平面 (Data Plane / Worker Nodes)**
* 部署于 **GPU 容器池**。
* 运行 **Raylet**负责本地资源GPU/CPU/RAM管理和 Task 执行。
* 集成 **Object Store (Plasma)**:负责节点间共享内存对象传输。
* **IB Interconnect**:配置 NCCL 环境变量,绕过 TCP 协议栈,直接利用 InfiniBand 进行 GPU 显存直连通信RDMA
* **存储平面 (Storage Plane)**
* **NFS Protocol**:全节点挂载 `/mnt/shared`
* **Artifacts Repository**:统一存储 Codebase、Datasets、Checkpoints、Logs。
### 2\. 核心机制设计 (Core Mechanisms)
#### 2.1 节点发现与纳管 (Node Discovery & Bootstrapping)
![](./assets/node_bootstrap.png)
采用 **SSH-based Side-channel Injection (SSH 侧信道注入)** 机制绕过 K8s 控制面限制。
* **触发机制**Provisioner 接收目标节点 IP 及凭证。
* **握手流程**
1. Provisioner 建立 SSH 连接 (TCP/22)。
2. 注入 **Ray Runtime Config**,执行 `ray start --address=<HEAD_VIP>:6379`
3. 注入 **Custom Resources** (如 `{"node_id_xyz": 1}`) 用于后续的亲和性调度。
* **状态同步**Raylet 启动后通过 TCP 回连 GCS建立长连接心跳。
#### 2.2 任务调度策略 (Scheduling Strategy)
![](./assets/scheduling.png)
系统针对不同负载类型,采用差异化的调度原语:
* **Type A: SFT Workloads (Job-Level Scheduling)**
* **调度模式****Driver-on-Worker**。
* **资源原语**:使用 Ray Job Entrypoint 资源约束 (`entrypoint_num_gpus=8`)。
* **调度逻辑**
1. API Server 接收请求,提交 Job 到 GCS。
2. GCS 调度器执行 **Resource Matching**,寻找满足 GPU 数量及 Custom Resource (节点亲和性) 的 Worker。
3. 将 Shell Command (`torchrun`) 下发至目标 Worker 的 Raylet 执行。
* **进程模型**`torchrun` 作为子进程运行,独占节点 GPU。
* **Type B: RL Workloads (Actor-Level Scheduling)**
* **调度模式****Driver-on-Head** (或 Driver-on-CPU-Worker)。
* **资源原语**:使用 Ray **Placement Groups**
* **调度逻辑**
1. Driver 进程在 Head 节点启动。
2. Driver 申请 Placement Group (例如:`{"CPU": 1}` for Controller, `{"GPU": 1} * N` for Rollout Workers)。
3. GCS 将不同的 Actors 实例化到集群内的各个 Worker 节点。
* **通信模型**Controller 与 Workers 通过 gRPC (Ray Protocol) 交换控制指令Workers 之间通过 NCCL (IB) 交换 Tensor 数据。
#### 2.3 网络通信规范 (Networking Specification)
![](./assets/networking.png)
* **Control Plane Traffic (TCP/IP)**
* 范围Head \<-\> WorkerWorker \<-\> Worker (非 Tensor 数据)。
* 端口6379 (Redis/GCS), 8265 (Dashboard), 10000-65535 (Ephemeral Ports)。
* 要求:容器间 Overlay 网络互通。
* **Data Plane Traffic (RDMA/IB)**
* 范围Worker \<-\> Worker (DDP/FSDP 通信)。
* 协议NCCL over IB Verbs。
* 配置:通过 `runtime_env` 注入 `NCCL_IB_HCA` 等环境变量,确保训练流量卸载至 IB 网卡,不占用 Overlay 网络带宽。
### 3\. 工作流序列 (Workflow Sequence)
#### 3.1 节点上线流程
![](./assets/node_onboarding.png)
#### 3.2 任务提交流程 (以 SFT 为例)
![](./assets/task_submit.png)
### 4\. 数据持久化与隔离 (Persistence & Isolation)
* **Runtime Environment Isolation**
* 利用 Ray 的 `runtime_env` 特性,在 Job 级别隔离 Python 依赖 (pip packages) 和环境变量。
* 支持为每个 Job 指定独立的 `working_dir` (指向 NFS 上的特定版本代码库)。
* **Log Persistence**
* Stdout/Stderr 重定向至 NFS 挂载路径 (`/mnt/shared/logs/{job_id}/`),实现无状态容器的日志持久化。
### 5\. 总结
该架构通过**应用层集群化 (Application-Layer Clustering)** 技术,在异构且受限的 K8s 容器环境之上,构建了一套**逻辑上的 HPC 集群**。它解耦了底层基础设施管理(由 SSH 负责)与上层计算任务调度(由 Ray 负责),是处理 Verl/SkyRL 此类强耦合分布式应用的标准化解决方案。

120
specs/mvp/v1/mvp_plan.md Normal file
View File

@ -0,0 +1,120 @@
# MVP 计划V1
本文档目标:把当前“口述/实验记录”整理成**可复现、可验收**的 MVP 计划,并明确下一步最小闭环。
## 1. 背景与目标
我们要验证的最小闭环是:
1) 在“HeadCPU 容器)+ WorkerGPU 容器)”的 Ray Cluster 上,能够跑通一次 `verl` 的 PPO 训练。
2) 训练所需的 **数据集 / 模型缓存 / 训练产物checkpoint/ 日志** 不落在容器临时文件系统里,而是落在**共享存储NFS**,容器重启后可继续使用。
3) 所有步骤能写成一套**清晰命令/脚本**,新人可照着复现。
## 2. 环境与假设
- 机器H20 机器(具体规格由算力平台提供)
- 访问方式:通过 `ssh h1a` 远程登录(进入算力平台/宿主访问入口)
- 容器:算力平台可申请 CPU 容器(对外暴露端口)与若干 GPU 容器(可 SSH 互通)
- 共享存储:所有容器可挂载同一套 NFS`specs/hl_design_v2.md` 中假设为 `/mnt/shared`
## 3. 已验证现状(现有实验)
目录 `ray_in_docker/` 已经做过一次可运行的实验(偏“本地/示例级别”):
- 用 `docker-compose` 起了 2 个 `verl` 镜像容器:
- `verl-head`:作为 Ray HeadDashboard 端口 `8265`
- `verl-worker`:作为 Ray Worker
- 在容器中执行:
- 下载 GSM8K 数据集(`examples/data_preprocess/gsm8k.py`
- 拉取 HuggingFace 模型(示例:`Qwen/Qwen2.5-0.5B-Instruct`
- `ray start --head` + `ray start --address=...`
- 通过 `ray job submit ... python -m verl.trainer.main_ppo ...` 提交 PPO 训练任务(见 `ray_in_docker/ray_example/ppo_train.sh`
结论:**训练脚本可以跑通**。
## 4. 当前主要问题(从实验到平台化 MVP 的差距)
1) **数据 / 模型 / 输出落在容器内**:容器重启/替换后不可复用;也不利于多人共享与审计。
2) **缓存路径不规范**HuggingFace cache、Ray 临时目录、Hydra 输出目录等可能分散在容器默认路径。
3) **可复现不足**:缺少明确的目录规范、统一的启动/提交流程、验收口径。
4) Ray 节点**打标签/亲和性调度**的方法未固化:需要明确是否统一用 `ray start --resources`,以及命名规范如何设计。
## 5. MVP V1最小闭环定义
`specs/hl_design_v2.md` 的方向为准,但 V1 **只做最小可运行原型**,暂不做完整 Web/调度系统。
### 5.1 目录规范(统一落到 NFS
约定所有容器统一挂载 NFS 到 `/mnt/shared`,并在其中固定目录结构:
- `/mnt/shared/code/`:代码(可选:按版本/分支隔离)
- `/mnt/shared/datasets/`:数据集(如 `gsm8k/`
- `/mnt/shared/hf/`HuggingFace 缓存(设置 `HF_HOME=/mnt/shared/hf`
- `/mnt/shared/ray/`Ray 运行期临时目录(可选:设置 `RAY_TMPDIR=/mnt/shared/ray/tmp`
- `/mnt/shared/outputs/`训练输出根目录Hydra/日志/ckpt 统一落这里)
- `/mnt/shared/outputs/logs/<job_id>/`
- `/mnt/shared/outputs/checkpoints/<job_id>/`
### 5.2 最小集群形态
- 1 个 HeadCPU 容器)
- 跑 `ray start --head --dashboard-host=0.0.0.0`
- 暴露 `8265` 给 Desktop/用户查看 Job 状态
- 1~2 个 WorkerGPU 容器)
- 跑 `ray start --address=<head_ip>:6379` 加入集群
- (可选)通过 `--resources='{\"gpu_pool_a\": 1}'` 给节点打标签
### 5.3 最小训练任务
- 目标任务:跑通一次 `verl.trainer.main_ppo`(以 GSM8K 为例)
- 要求:
- `data.train_files` / `data.val_files` 指向 `/mnt/shared/datasets/...`
- HuggingFace 模型下载缓存落到 `/mnt/shared/hf`
- 训练输出Hydra outputs、checkpoint、stdout/stderr落到 `/mnt/shared/outputs/...`
建议在提交命令里显式覆盖 Hydra 输出目录(示例,具体目录名按需调整):
- `hydra.run.dir=/mnt/shared/outputs/logs/${JOB_TAG}`
## 6. 实施步骤Checklist
### 6.1 一次性准备
- [ ] 确认所有容器已挂载 NFS 到同一路径:`/mnt/shared`
- [ ] 在 `/mnt/shared/` 下创建目录:`datasets/ hf/ outputs/ ray/`
- [ ] 在所有容器中设置/注入环境变量(推荐写入统一脚本):
- `HF_HOME=/mnt/shared/hf`
- `HF_ENDPOINT=https://hf-mirror.com`(如需)
- `RAY_TMPDIR=/mnt/shared/ray/tmp`(可选)
### 6.2 启动集群Head + Worker
- [ ] 在 Head 容器启动 Ray Head并记录 `head_ip:6379`
- [ ] 在每个 Worker 容器执行 `ray start --address=...` 加入集群
- [ ] 在 Head 上通过 `ray status` / Dashboard 验证节点已注册
### 6.3 准备数据与模型
- [ ] 数据集下载到:`/mnt/shared/datasets/gsm8k/`
- [ ] 模型缓存落到:`/mnt/shared/hf/`(拉取一次即可多任务复用)
### 6.4 提交训练任务
- [ ] 用 `ray job submit --address=http://<head>:8265 ...` 提交 PPO 训练
- [ ] 训练日志与 checkpoint 在 `/mnt/shared/outputs/` 可见
## 7. 验收标准V1
- [ ] Ray Head/Worker 能稳定加入同一集群Dashboard 可见)
- [ ] PPO 训练任务可提交并跑通(至少完成若干 step/epoch
- [ ] 数据集、HF 缓存、训练输出均在 `/mnt/shared/` 下可复用(容器重启后仍在)
- [ ] 有一份“从零到跑通”的命令清单(或脚本)可复现
## 8. 未决问题(记录待补齐)
- [ ] Ray 节点标签/亲和性调度:是否统一用 `ray start --resources`,以及命名规范如何设计
- [ ] RL workload 的 Driver 放置策略:先按 `verl` 默认即可,后续再按 `specs/hl_design_v2.md` 收敛到“Driver-on-Head / Placement Group”等模式
## 9. 下一步(进入 V2
当 V1 达到“可复现 + 产物可落盘”的验收标准后,下一阶段工作见:`specs/mvp_plan_v2.md`

111
specs/mvp/v1/v1_action.md Normal file
View File

@ -0,0 +1,111 @@
# MVP V1 远程实验行动文档(待确认后执行)
## 1. 任务复述(我理解的需求)
你希望我在远程机器 `argus@h1` 上,进入目录 `/home2/argus/infra/mvp`,把 MVP V1 的“原本流程”**手动完整跑一遍并验证**。要求:
1) 在宿主机上编写脚本,脚本通过 `docker exec` 在容器内执行命令,负责协调启动顺序(先 head、后 worker
2) 集群拓扑改为:
- 1 个 Ray Head**没有 GPU**,并且 Head 的 Ray 资源 `CPU=0`(防止 Ray 把训练任务调度到 head
- 2 个 Ray Worker各自 **4 GPU**(总 8 GPU
3) PPO 训练需要“轻量化”,把 `total_epochs` 改为 `1`
4) 先在本地仓库 `src/mvp/v1/` 写好脚本与 compose 文件;再拷贝到远程目录执行与验证。
5) 在你确认这份行动文档没问题之前,我**不执行**远程操作。
## 2. 本地已准备的文件(在本仓库内)
- `src/mvp/v1/docker-compose.yaml`3 容器head + 2 workerhead 不使用 nvidia runtimeworker0/1 各限制 4 GPU。
- `src/mvp/v1/scripts/`:宿主机脚本(内部全部用 `docker exec`
- `01_up.sh`:起容器
- `20_start_head.sh`:启动 Ray head`--num-cpus=0 --num-gpus=0`
- `21_start_workers.sh`:启动 Ray worker 加入集群
- `30_prepare_data_and_model.sh`:准备 GSM8K 数据与预下载模型
- `40_submit_ppo_epoch1.sh`:提交 PPO`trainer.total_epochs=1`,并设置 `nnodes=2, n_gpus_per_node=4`
- `run_all.sh`:按顺序一键执行
## 3. 远程环境前置条件(需要你确认/保证)
`argus@h1` 上:
- Docker 可用,且有 `docker compose` 插件Compose v2
- NVIDIA runtime 可用worker 容器需要 `runtime: nvidia`),宿主机有至少 8 张 GPU。
- 不强制要求提前准备 `./verl`:脚本会在宿主机侧检查 `${PWD}/verl`,如果不存在会自动执行:
- `git clone https://github.com/volcengine/verl.git`
此外本实验默认写入持久化目录:`/home2/argus/infra/mvp/shared`(会自动创建)。
## 4. 拷贝到远程(我执行前会再次征求你确认)
从本地(本机)同步到远程:
1) 同步脚本与 compose
- `rsync -av ./src/mvp/v1/ argus@h1:/home2/argus/infra/mvp/src/mvp/v1/`
- `rsync -av ./specs/mvp/v1_action.md argus@h1:/home2/argus/infra/mvp/specs/mvp/v1_action.md`
2) `verl/` 默认不需要同步(远程会 clone。如果你更希望固定版本/避免网络波动,也可以手动同步:
- `rsync -av --delete ./verl/ argus@h1:/home2/argus/infra/mvp/verl/`
## 5. 远程执行步骤(在宿主机上)
在远程机器执行:
1) 进入目录:
- `cd /home2/argus/infra/mvp`
2) 确保脚本可执行(首次同步后需要做一次):
- `chmod +x ./src/mvp/v1/scripts/*.sh`
3) 启动容器:
- `./src/mvp/v1/scripts/01_up.sh`
4) 安装 editable 版 `verl`(保证 `python -m verl...` 可用):
- `./src/mvp/v1/scripts/10_install_verl_editable.sh`
5) 启动 Ray Head禁止调度到 head
- `./src/mvp/v1/scripts/20_start_head.sh`
6) 启动两个 Ray Worker 加入集群:
- `./src/mvp/v1/scripts/21_start_workers.sh`
7) 准备数据 + 预下载模型(落到 `./shared`
- `./src/mvp/v1/scripts/30_prepare_data_and_model.sh`
8) 提交 PPO`total_epochs=1`,必须用 `ray job submit` 在 head 提交;通过 `--entrypoint-resources` 强制 driver 调度到 worker
- `./src/mvp/v1/scripts/40_submit_ppo_epoch1.sh`
9) 观察状态:
- `./src/mvp/v1/scripts/50_status.sh`
- 打开 Ray Dashboard`http://<h1宿主机IP>:8265`
也可以一键跑:
- `./src/mvp/v1/scripts/run_all.sh`
## 6. 验收与验证点(执行时我会逐项检查)
1) Head 节点无 GPU在 head 容器内 `nvidia-smi` 应不可用或无设备worker 内可见 4 张)。
2) Head 的 Ray 逻辑资源为 `CPU=0, GPU=0`head 不应承载训练任务调度资源(通过 `ray start --num-cpus=0 --num-gpus=0`)。
3) 集群节点数量正确:`ray status` 中应看到 1 head + 2 worker。
4) PPO driver 不在 head`ray job list` 里该 `submission_id``driver_info.node_ip_address` 应该是 worker 的 IP`172.19.0.3/172.19.0.4`),不能是 head`172.19.0.2`)。
5) PPO 训练只跑 1 个 epoch提交参数包含 `trainer.total_epochs=1`
6) checkpoint 落盘:`/mnt/shared/jobs/<job_id>/checkpoints/` 有产物(脚本通过 `trainer.default_local_dir` 强制指向该目录;不设置 `trainer.default_hdfs_dir`)。
7) 数据与缓存落盘:`/home2/argus/infra/mvp/shared/` 下出现 datasets/hf/jobs 等目录。
补充(磁盘保护):
- checkpoint 不要每步保存(会非常占空间);当前脚本默认 `trainer.save_freq=10`(每 10 step 保存一次)。
## 10. 目录命名约定submission id
- 脚本默认会显式指定 `ray job submit --submission-id=$SUBMISSION_ID`,并使用同一个值作为输出目录名:
- 输出目录:`/mnt/shared/jobs/$SUBMISSION_ID/`
- 你可以在提交时自定义 ID推荐这样便于检索
- `SUBMISSION_ID=my_run_20251219_001 ./src/mvp/v1/scripts/40_submit_ppo_epoch1.sh`
## 7. 风险点与兜底
- 如果 `runtime: nvidia` 在该环境不生效:需要改成 compose 的 `gpus:` 写法(我会按远程 docker 版本调整)。
- 如果 Ray Jobs 的 driver 必须在 head 启动Ray 机制如此):这不影响“训练任务不调度到 head”但 head 仍会有一个 job driver 进程。
- 如果 `verl` 在镜像内已安装但版本不匹配:脚本会优先 `pip install -e /workspace/verl` 以保证行为一致。
## 8. 你需要确认的 3 个问题(你已确认,我按此执行)
1) `verl/`:脚本会在远程自动 `git clone https://github.com/volcengine/verl.git`(如你希望固定版本,可改成同步或 checkout tag/commit
2) GPU`0-7` 可用worker0 用 `0-3`worker1 用 `4-7`)。
3) PPO用满 8 GPU`nnodes=2, n_gpus_per_node=4`)。
## 9. 你新增的关键要求(我已纳入脚本)
- 数据与模型必须落在 `/mnt/shared`(由宿主机 `./shared` bind mount 提供),并且具备**幂等**
- 数据:如果 `train.parquet/test.parquet` 已存在则跳过下载。
- 模型:优先检测本地 cache`HF_HOME=/mnt/shared/hf`);存在则跳过,否则才下载。
- 提交 job 时显式注入 `HF_HOME/HUGGINGFACE_HUB_CACHE/TRANSFORMERS_CACHE`,确保训练使用持久化缓存与数据路径。

118
src/mvp/v1/README.md Normal file
View File

@ -0,0 +1,118 @@
# MVP V1Ray + VERL PPO实验脚本
本目录用于在“宿主机 + Docker 容器”环境下,**用宿主机脚本(`docker exec`**协调启动 Ray 集群,并通过 **`ray job submit`(在 head 提交)**跑通一次 `verl` 的 PPO 训练闭环(`total_epochs=1`),且数据/模型/日志/ckpt 都持久化到宿主机目录。
## 1. 运行环境与拓扑
### 1.1 依赖
- 宿主机Linux
- 必需工具:`docker``docker compose`Compose v2 插件)、`git`
- GPU至少 8 张可用 GPU索引 `0-7`Docker 的 NVIDIA runtime 可用
### 1.2 集群拓扑3 个容器)
- `mvp-ray-head`Ray Head
- **不挂 GPU**(容器内 `nvidia-smi` 不可用)
- `ray start --head --num-cpus=0 --num-gpus=0`head 只做控制面,不参与计算调度
- 暴露 dashboard宿主机端口 `8265`
- `mvp-ray-worker-0`Ray Worker
- 4 GPU`0,1,2,3`
- `ray start ... --resources='{"worker_node": 100}'`
- `mvp-ray-worker-1`Ray Worker
- 4 GPU`4,5,6,7`
- `ray start ... --resources='{"worker_node": 100}'`
**关键点driver 不在 head**
- 作业通过 head 提交:`ray job submit ...`
- 通过 `--entrypoint-resources='{"worker_node": 1}'` 强制 entrypoint/driver 只能调度到 workerhead 没有该资源)
## 2. 持久化目录(宿主机 <-> 容器)
在宿主机项目根目录(运行脚本时的 `${PWD}`)下使用 `./shared` 做持久化根目录,并 bind mount 到容器内 `/mnt/shared`
- 宿主机:`./shared`
- 容器:`/mnt/shared`
主要内容:
- 数据集:`/mnt/shared/datasets/gsm8k/`
- HF 缓存:`/mnt/shared/hf/`(脚本会设置 `HF_HOME`,并尽量幂等跳过重复下载)
- 每个 Ray Job 的输出(按 submission id 分目录):
- `/mnt/shared/jobs/<submission_id>/logs/`
- `/mnt/shared/jobs/<submission_id>/checkpoints/`
## 3. 整体流程(代码逻辑)
脚本都在 `src/mvp/v1/scripts/`,整体顺序如下:
1) `00_prereq_check.sh`
- 检查 `docker/docker compose/git`
2) `05_ensure_verl_repo.sh`
- 若项目根目录下没有 `./verl`,自动 `git clone https://github.com/volcengine/verl.git`
3) `01_up.sh`
- 创建持久化目录(`./shared/...`
- `docker compose up -d` 启动 3 个容器
4) `10_install_verl_editable.sh`
- 在 3 个容器内执行 `pip install -e /workspace/verl`(确保 `python -m verl...` 可用且代码与 `./verl` 同步)
5) `20_start_head.sh`
- 在 `mvp-ray-head` 内启动 Ray headCPU=0、GPU=0
6) `21_start_workers.sh`
- 在两个 worker 内启动 Ray worker 加入集群
- 同时给 worker 打 `worker_node` 自定义资源标签
7) `30_prepare_data_and_model.sh`
- 数据集:若 `train.parquet/test.parquet` 已存在则跳过,否则生成
- 模型:使用 HF cache`HF_HOME=/mnt/shared/hf`),存在则跳过,不存在才下载
8) `40_submit_ppo_epoch1.sh`
- 在 head 容器里执行 `ray job submit`
- 显式指定 `--submission-id=$SUBMISSION_ID`
- 通过 `--entrypoint-resources='{"worker_node": 1}'` 强制 driver 在 worker
- 训练参数:
- `trainer.total_epochs=1`
- `trainer.total_training_steps=29`GSM8K 该配置下对应 29 steps
- `trainer.save_freq=10`(每 10 step 保存一次 checkpoint避免磁盘爆炸
- `trainer.default_local_dir=/mnt/shared/jobs/$SUBMISSION_ID/checkpoints`
- `hydra.run.dir=/mnt/shared/jobs/$SUBMISSION_ID/logs/hydra`
9) `50_status.sh`
- 打印 `ray status` / `ray job list` / `ray job status` / `ray job logs | tail`
## 4. 运行方法
### 4.1 一键执行
在项目根目录执行:
- `./src/mvp/v1/scripts/run_all.sh`
### 4.2 分步执行(推荐)
按顺序执行:
- `./src/mvp/v1/scripts/01_up.sh`
- `./src/mvp/v1/scripts/10_install_verl_editable.sh`
- `./src/mvp/v1/scripts/20_start_head.sh`
- `./src/mvp/v1/scripts/21_start_workers.sh`
- `./src/mvp/v1/scripts/30_prepare_data_and_model.sh`
- `SUBMISSION_ID=ppo_h20_8g_$(date +%Y%m%d_%H%M%S) ./src/mvp/v1/scripts/40_submit_ppo_epoch1.sh`
- `./src/mvp/v1/scripts/50_status.sh`
### 4.3 查看与停止
- Dashboard`http://<宿主机IP>:8265`
- 列出作业(在 head 容器内):
- `docker exec mvp-ray-head bash -lc "ray job list --address=http://127.0.0.1:8265"`
- 停止某个 submission id
- `docker exec mvp-ray-head bash -lc "ray job stop --address=http://127.0.0.1:8265 <submission_id>"`
### 4.4 清理
- 停止并删除容器:`./src/mvp/v1/scripts/02_down.sh`
- 清理输出(谨慎,数据量可能很大):删除 `./shared/jobs/<submission_id>/`
## 5. 常见坑
- **不传 `--submission-id` 会导致“输出目录难以等于 submission id”**:因为 hydra/ckpt 目录需要在提交前确定。当前脚本会显式传 `--submission-id=$SUBMISSION_ID`,并使用同名目录。
- **checkpoint 太大**PPO 的 checkpoint 非常占空间。当前脚本默认 `save_freq=10`,如仍过大,可调大 `save_freq` 或减少保存内容/频率。
更多分步操作与验收标准见:`specs/mvp/v1_action.md`

View File

@ -0,0 +1,86 @@
version: "3.8"
services:
ray_head:
image: verlai/verl:sgl055.latest
container_name: mvp-ray-head
command: sleep infinity
ports:
- "8265:8265"
volumes:
- ./verl:/workspace/verl
- ./shared:/mnt/shared
shm_size: "10g"
ulimits:
nofile:
soft: 65536
hard: 65536
cap_add:
- SYS_ADMIN
- SYS_PTRACE
networks:
- mvp-ray-net
environment:
HF_HOME: "/mnt/shared/hf"
HUGGINGFACE_HUB_CACHE: "/mnt/shared/hf/hub"
TRANSFORMERS_CACHE: "/mnt/shared/hf/transformers"
HF_ENDPOINT: "https://hf-mirror.com"
PYTHONUNBUFFERED: "1"
ray_worker_0:
image: verlai/verl:sgl055.latest
container_name: mvp-ray-worker-0
command: sleep infinity
volumes:
- ./verl:/workspace/verl
- ./shared:/mnt/shared
shm_size: "10g"
ulimits:
nofile:
soft: 65536
hard: 65536
cap_add:
- SYS_ADMIN
- SYS_PTRACE
networks:
- mvp-ray-net
runtime: nvidia
environment:
NVIDIA_VISIBLE_DEVICES: "0,1,2,3"
NVIDIA_DRIVER_CAPABILITIES: "all"
HF_HOME: "/mnt/shared/hf"
HUGGINGFACE_HUB_CACHE: "/mnt/shared/hf/hub"
TRANSFORMERS_CACHE: "/mnt/shared/hf/transformers"
HF_ENDPOINT: "https://hf-mirror.com"
PYTHONUNBUFFERED: "1"
ray_worker_1:
image: verlai/verl:sgl055.latest
container_name: mvp-ray-worker-1
command: sleep infinity
volumes:
- ./verl:/workspace/verl
- ./shared:/mnt/shared
shm_size: "10g"
ulimits:
nofile:
soft: 65536
hard: 65536
cap_add:
- SYS_ADMIN
- SYS_PTRACE
networks:
- mvp-ray-net
runtime: nvidia
environment:
NVIDIA_VISIBLE_DEVICES: "4,5,6,7"
NVIDIA_DRIVER_CAPABILITIES: "all"
HF_HOME: "/mnt/shared/hf"
HUGGINGFACE_HUB_CACHE: "/mnt/shared/hf/hub"
TRANSFORMERS_CACHE: "/mnt/shared/hf/transformers"
HF_ENDPOINT: "https://hf-mirror.com"
PYTHONUNBUFFERED: "1"
networks:
mvp-ray-net:
driver: bridge

View File

@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
source "${SCRIPT_DIR}/lib.sh"
require_cmd docker
require_cmd git
if ! docker compose version >/dev/null 2>&1; then
echo "docker compose plugin not available; please install docker compose v2" >&2
exit 1
fi
echo "OK: docker + docker compose + git"

20
src/mvp/v1/scripts/01_up.sh Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
source "${SCRIPT_DIR}/lib.sh"
"${SCRIPT_DIR}/00_prereq_check.sh"
"${SCRIPT_DIR}/05_ensure_verl_repo.sh"
mkdir -p \
"${ROOT_DIR}/shared/hf" \
"${ROOT_DIR}/shared/datasets" \
"${ROOT_DIR}/shared/jobs" \
"${ROOT_DIR}/shared/outputs" \
"${ROOT_DIR}/shared/ray"
dc up -d
dc ps

9
src/mvp/v1/scripts/02_down.sh Executable file
View File

@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
source "${SCRIPT_DIR}/lib.sh"
dc down

View File

@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
source "${SCRIPT_DIR}/lib.sh"
VERL_DIR="${ROOT_DIR}/verl"
if [[ -d "${VERL_DIR}/.git" ]]; then
echo "OK: verl repo exists: ${VERL_DIR}"
exit 0
fi
echo "verl repo not found at ${VERL_DIR}; cloning..."
rm -rf "${VERL_DIR}"
git clone https://github.com/volcengine/verl.git "${VERL_DIR}"
echo "OK: cloned verl -> ${VERL_DIR}"

View File

@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
source "${SCRIPT_DIR}/lib.sh"
install_one() {
local name="$1"
echo "[${name}] ensure verl importable"
if ! dexec "${name}" python3 -c "import verl; print(verl.__file__)" >/dev/null 2>&1; then
echo "[${name}] verl not importable; installing editable from /workspace/verl"
dexec "${name}" bash -lc "pip install -e /workspace/verl"
else
echo "[${name}] verl import OK"
fi
}
install_one "${HEAD_CONTAINER}"
install_one "${WORKER0_CONTAINER}"
install_one "${WORKER1_CONTAINER}"

View File

@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
source "${SCRIPT_DIR}/lib.sh"
echo "[head] ray stop (ignore errors)"
dexec "${HEAD_CONTAINER}" bash -lc "ray stop --force || true"
echo "[head] ray start (CPU=0, GPU=0 to prevent scheduling on head)"
HEAD_IP="$(container_ip "${HEAD_CONTAINER}")"
echo "[head] container ip: ${HEAD_IP}"
dexec "${HEAD_CONTAINER}" bash -lc "ray start --head --node-ip-address=${HEAD_IP} --dashboard-host=0.0.0.0 --dashboard-port=8265 --port=6379 --num-cpus=0 --num-gpus=0"
echo "[head] ray status"
dexec "${HEAD_CONTAINER}" bash -lc "ray status || true"

View File

@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
source "${SCRIPT_DIR}/lib.sh"
start_worker() {
local name="$1"
local node_ip
node_ip="$(container_ip "${name}")"
echo "[${name}] ray stop (ignore errors)"
dexec "${name}" bash -lc "ray stop --force || true"
local head_ip
head_ip="$(container_ip "${HEAD_CONTAINER}")"
echo "[${name}] container ip: ${node_ip}"
echo "[${name}] ray start -> join ${head_ip}:6379 (num_gpus=4, resources worker_node=100)"
dexec "${name}" bash -lc "ray start --node-ip-address=${node_ip} --address=${head_ip}:6379 --num-gpus=4 --resources='{\"worker_node\": 100}'"
}
start_worker "${WORKER0_CONTAINER}"
start_worker "${WORKER1_CONTAINER}"
echo "[head] waiting for workers to register"
for _ in $(seq 1 30); do
if dexec "${HEAD_CONTAINER}" bash -lc "ray status" | grep -q "Active:"; then
break
fi
sleep 2
done
dexec "${HEAD_CONTAINER}" bash -lc "ray status || true"

View File

@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
source "${SCRIPT_DIR}/lib.sh"
DATA_DIR="/mnt/shared/datasets/gsm8k"
MODEL_ID="Qwen/Qwen2.5-0.5B-Instruct"
echo "[head] prepare dataset (idempotent) -> ${DATA_DIR}"
dexec "${HEAD_CONTAINER}" bash -lc "mkdir -p ${DATA_DIR} && if [[ -f ${DATA_DIR}/train.parquet && -f ${DATA_DIR}/test.parquet ]]; then echo 'dataset_exists: skip'; else python3 /workspace/verl/examples/data_preprocess/gsm8k.py --local_save_dir ${DATA_DIR}; fi"
echo "[head] ensure model cached to persistent HF_HOME (idempotent) -> ${MODEL_ID}"
PY_CODE="$(cat <<'PY'
import os
model_id = os.environ["MODEL_ID"]
hf_home = os.environ.get("HF_HOME", "/mnt/shared/hf")
os.environ.setdefault("HF_HOME", hf_home)
os.environ.setdefault("HUGGINGFACE_HUB_CACHE", os.path.join(hf_home, "hub"))
os.environ.setdefault("TRANSFORMERS_CACHE", os.path.join(hf_home, "transformers"))
from huggingface_hub import snapshot_download
try:
snapshot_download(repo_id=model_id, local_files_only=True)
print("model_cache_exists: skip", model_id)
except Exception:
print("model_cache_missing: downloading", model_id)
snapshot_download(repo_id=model_id)
print("model_cached_ok:", model_id)
PY
)"
printf "%s\n" "${PY_CODE}" | dexec "${HEAD_CONTAINER}" bash -lc "MODEL_ID='${MODEL_ID}' python3 -"

View File

@ -0,0 +1,70 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
source "${SCRIPT_DIR}/lib.sh"
SUBMISSION_ID="${SUBMISSION_ID:-mvp_ppo_$(timestamp)_$RANDOM}"
# 为了让“输出目录 = submission id”默认把 JOB_TAG 也设成 SUBMISSION_ID可手动覆盖
JOB_TAG="${JOB_TAG:-${SUBMISSION_ID}}"
JOB_DIR="/mnt/shared/jobs/${SUBMISSION_ID}"
MODEL_ID="Qwen/Qwen2.5-0.5B-Instruct"
TRAIN_FILE="/mnt/shared/datasets/gsm8k/train.parquet"
VAL_FILE="/mnt/shared/datasets/gsm8k/test.parquet"
echo "[head] create job dir: ${JOB_DIR}"
dexec "${HEAD_CONTAINER}" bash -lc "mkdir -p ${JOB_DIR}/logs ${JOB_DIR}/checkpoints ${JOB_DIR}/config"
SUBMIT_CMD="python3 -m verl.trainer.main_ppo \
data.train_files=${TRAIN_FILE} \
data.val_files=${VAL_FILE} \
data.train_batch_size=256 \
data.max_prompt_length=512 \
data.max_response_length=512 \
actor_rollout_ref.model.path=${MODEL_ID} \
actor_rollout_ref.actor.optim.lr=1e-6 \
actor_rollout_ref.actor.ppo_mini_batch_size=64 \
actor_rollout_ref.actor.ppo_micro_batch_size_per_gpu=4 \
actor_rollout_ref.rollout.name=sglang \
actor_rollout_ref.rollout.log_prob_micro_batch_size_per_gpu=8 \
actor_rollout_ref.rollout.tensor_model_parallel_size=1 \
actor_rollout_ref.rollout.gpu_memory_utilization=0.4 \
actor_rollout_ref.ref.log_prob_micro_batch_size_per_gpu=4 \
critic.optim.lr=1e-5 \
critic.model.path=${MODEL_ID} \
critic.ppo_micro_batch_size_per_gpu=4 \
algorithm.kl_ctrl.kl_coef=0.001 \
trainer.logger=console \
trainer.val_before_train=False \
trainer.n_gpus_per_node=4 \
trainer.nnodes=2 \
trainer.save_freq=10 \
trainer.test_freq=29 \
trainer.total_epochs=1 \
trainer.total_training_steps=29 \
trainer.resume_mode=disable \
trainer.default_local_dir=${JOB_DIR}/checkpoints \
+ray_kwargs.ray_init.address=auto \
hydra.run.dir=${JOB_DIR}/logs/hydra"
printf "%s\n" "${SUBMIT_CMD}" | dexec "${HEAD_CONTAINER}" bash -lc "cat > ${JOB_DIR}/config/submit_cmd.txt"
echo "[head] submit PPO via ray job submit (force driver on worker via entrypoint resources)"
SUBMIT_OUT="$(dexec "${HEAD_CONTAINER}" bash -lc "export HF_HOME=/mnt/shared/hf HUGGINGFACE_HUB_CACHE=/mnt/shared/hf/hub TRANSFORMERS_CACHE=/mnt/shared/hf/transformers HF_ENDPOINT=https://hf-mirror.com PYTHONUNBUFFERED=1; ray job submit --address=http://127.0.0.1:8265 --submission-id='${SUBMISSION_ID}' --entrypoint-num-cpus=1 --entrypoint-resources='{\"worker_node\": 1}' --runtime-env-json='{\"env_vars\":{\"HF_HOME\":\"/mnt/shared/hf\",\"HUGGINGFACE_HUB_CACHE\":\"/mnt/shared/hf/hub\",\"TRANSFORMERS_CACHE\":\"/mnt/shared/hf/transformers\",\"HF_ENDPOINT\":\"https://hf-mirror.com\",\"PYTHONUNBUFFERED\":\"1\"}}' --no-wait -- ${SUBMIT_CMD}")"
printf "%s\n" "${SUBMIT_OUT}"
printf "%s\n" "${SUBMIT_OUT}" | dexec "${HEAD_CONTAINER}" bash -lc "cat > ${JOB_DIR}/logs/ray_job_submit.out"
PARSED_SUBMISSION_ID="$(printf "%s\n" "${SUBMIT_OUT}" | sed -r 's/\x1b\\[[0-9;]*m//g' | grep -Eo "raysubmit_[A-Za-z0-9_-]+" | head -n 1 || true)"
if [[ -n "${PARSED_SUBMISSION_ID}" && "${PARSED_SUBMISSION_ID}" != "${SUBMISSION_ID}" ]]; then
echo "WARN: submission id mismatch: expected=${SUBMISSION_ID} parsed=${PARSED_SUBMISSION_ID}" >&2
fi
echo "${SUBMISSION_ID}" | dexec "${HEAD_CONTAINER}" bash -lc "cat > ${JOB_DIR}/config/ray_submission_id.txt"
echo "ray submission id: ${SUBMISSION_ID}"
echo "submitted. track via Ray dashboard: http://<host>:8265 (driver should be scheduled on a worker due to entrypoint resources)"
echo "job dir: ${JOB_DIR}"

26
src/mvp/v1/scripts/50_status.sh Executable file
View File

@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
source "${SCRIPT_DIR}/lib.sh"
echo "[head] ray status"
dexec "${HEAD_CONTAINER}" bash -lc "ray status || true"
echo "[head] ray jobs list (optional)"
dexec "${HEAD_CONTAINER}" bash -lc "ray job list --address=http://127.0.0.1:8265 || true"
LATEST_JOB_DIR="$(dexec "${HEAD_CONTAINER}" bash -lc "ls -1dt /mnt/shared/jobs/* 2>/dev/null | head -n 1 || true")"
if [[ -n "${LATEST_JOB_DIR}" ]]; then
echo "[host] latest job dir: ${LATEST_JOB_DIR}"
echo "[host] ray submission id (if exists):"
SUB_ID="$(dexec "${HEAD_CONTAINER}" bash -lc "cat ${LATEST_JOB_DIR}/config/ray_submission_id.txt 2>/dev/null || true")"
echo "${SUB_ID}"
if [[ -n "${SUB_ID}" ]]; then
echo "[head] ray job status:"
dexec "${HEAD_CONTAINER}" bash -lc "ray job status --address=http://127.0.0.1:8265 ${SUB_ID} || true"
echo "[head] ray job logs (tail):"
dexec "${HEAD_CONTAINER}" bash -lc "ray job logs --address=http://127.0.0.1:8265 ${SUB_ID} 2>/dev/null | tail -n 60 || true"
fi
fi

48
src/mvp/v1/scripts/lib.sh Executable file
View File

@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/../../../../" && pwd)"
COMPOSE_FILE="${ROOT_DIR}/src/mvp/v1/docker-compose.yaml"
HEAD_CONTAINER="mvp-ray-head"
WORKER0_CONTAINER="mvp-ray-worker-0"
WORKER1_CONTAINER="mvp-ray-worker-1"
dc() {
docker compose --project-directory "${ROOT_DIR}" -f "${COMPOSE_FILE}" "$@"
}
require_cmd() {
local cmd="$1"
command -v "${cmd}" >/dev/null 2>&1 || {
echo "missing required command: ${cmd}" >&2
exit 1
}
}
ensure_container_running() {
local name="$1"
if ! docker ps --format '{{.Names}}' | grep -qx "${name}"; then
echo "container not running: ${name}" >&2
exit 1
fi
}
dexec() {
local name="$1"
shift
ensure_container_running "${name}"
docker exec -i "${name}" "$@"
}
container_ip() {
local name="$1"
ensure_container_running "${name}"
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "${name}"
}
timestamp() {
date +"%Y%m%d_%H%M%S"
}

13
src/mvp/v1/scripts/run_all.sh Executable file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
"${SCRIPT_DIR}/01_up.sh"
"${SCRIPT_DIR}/10_install_verl_editable.sh"
"${SCRIPT_DIR}/20_start_head.sh"
"${SCRIPT_DIR}/21_start_workers.sh"
"${SCRIPT_DIR}/30_prepare_data_and_model.sh"
"${SCRIPT_DIR}/40_submit_ppo_epoch1.sh"
"${SCRIPT_DIR}/50_status.sh"