From c66f4c2d150b4f9fb1714cdfe101d6f84f015187 Mon Sep 17 00:00:00 2001 From: yuchengen Date: Wed, 6 May 2026 17:55:11 +0800 Subject: [PATCH 1/7] feat: add Ray v2.54.0/v2.54.1 container image for OC9 --- frameworks/Ray/2.54.0/Dockerfile | 103 ++++ frameworks/Ray/2.54.0/build.conf | 4 + frameworks/Ray/2.54.0/start-ray.sh | 143 +++++ frameworks/Ray/2.54.0/test_ray.py | 784 ++++++++++++++++++++++++++ frameworks/Ray/2.54.0/test_result.png | Bin 0 -> 58141 bytes frameworks/Ray/2.54.1/Dockerfile | 92 +++ frameworks/Ray/2.54.1/build.conf | 4 + frameworks/Ray/2.54.1/start-ray.sh | 143 +++++ frameworks/Ray/2.54.1/test_ray.py | 784 ++++++++++++++++++++++++++ frameworks/Ray/2.54.1/test_result.png | Bin 0 -> 58141 bytes 10 files changed, 2057 insertions(+) create mode 100644 frameworks/Ray/2.54.0/Dockerfile create mode 100644 frameworks/Ray/2.54.0/build.conf create mode 100644 frameworks/Ray/2.54.0/start-ray.sh create mode 100644 frameworks/Ray/2.54.0/test_ray.py create mode 100644 frameworks/Ray/2.54.0/test_result.png create mode 100644 frameworks/Ray/2.54.1/Dockerfile create mode 100644 frameworks/Ray/2.54.1/build.conf create mode 100644 frameworks/Ray/2.54.1/start-ray.sh create mode 100644 frameworks/Ray/2.54.1/test_ray.py create mode 100644 frameworks/Ray/2.54.1/test_result.png diff --git a/frameworks/Ray/2.54.0/Dockerfile b/frameworks/Ray/2.54.0/Dockerfile new file mode 100644 index 0000000..8178da6 --- /dev/null +++ b/frameworks/Ray/2.54.0/Dockerfile @@ -0,0 +1,103 @@ +FROM opencloudos/opencloudos9-cuda-devel:12.8 + +LABEL maintainer="stronking 363133710@qq.com" +LABEL org.opencontainers.image.source="https://gitee.com/OpenCloudOS/ai-agent-container" +LABEL org.opencontainers.image.description="Ray all components + Torch GPU on OpenCloudOS 9 CUDA 12.8" + +# ========================= +# 版本参数 +# ========================= +ARG RAY_VERSION=2.54.0 +ARG TORCH_VERSION=2.11.0 +ARG TORCHVISION_VERSION=0.26.0 +ARG TORCHAUDIO_VERSION=2.11.0 +ARG PYTORCH_INDEX_URL=https://download.pytorch.org/whl/cu128 + +# ========================= +# 基础环境变量 +# ========================= +ENV PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + NVIDIA_VISIBLE_DEVICES=all \ + NVIDIA_DRIVER_CAPABILITIES=compute,utility \ + RAY_DISABLE_USAGE_STATS=1 + +# ========================= +# Ray 集群默认环境变量 +# 这些变量会被 start-ray.sh 使用 +# ========================= +ENV RAY_NODE_TYPE=single \ + RAY_HEAD_ADDRESS="" \ + RAY_NODE_IP_ADDRESS="" \ + RAY_HEAD_PORT=6379 \ + RAY_DASHBOARD_HOST=0.0.0.0 \ + RAY_DASHBOARD_PORT=8265 \ + RAY_CLIENT_SERVER_PORT=10001 \ + RAY_SERVE_HTTP_PORT=8000 \ + RAY_NODE_MANAGER_PORT=8076 \ + RAY_OBJECT_MANAGER_PORT=8077 \ + RAY_RUNTIME_ENV_AGENT_PORT=8078 \ + RAY_DASHBOARD_AGENT_GRPC_PORT=8079 \ + RAY_DASHBOARD_AGENT_LISTEN_PORT=8080 \ + RAY_METRICS_EXPORT_PORT=8081 \ + RAY_MIN_WORKER_PORT=10002 \ + RAY_MAX_WORKER_PORT=10100 \ + RAY_TEMP_DIR=/tmp/ray \ + RAY_NUM_CPUS="" \ + RAY_NUM_GPUS="" \ + RAY_OBJECT_STORE_MEMORY="" \ + RAY_RESOURCES="" + +WORKDIR /home + +# ========================= +# 安装 Ray 全组件 +# ========================= +RUN python3 -m pip install \ + "ray[all,client,serve-grpc]==${RAY_VERSION}" + +# ========================= +# 安装 PyTorch GPU 版本 +# 注意:如果 torch==2.11.0 / torchvision==0.26.0 当前源里不存在,构建会失败。 +# 可以通过 docker build --build-arg 修改版本。 +# ========================= +RUN python3 -m pip install \ + torch==${TORCH_VERSION} \ + torchvision==${TORCHVISION_VERSION} \ + torchaudio==${TORCHAUDIO_VERSION} \ + --index-url ${PYTORCH_INDEX_URL} + +# ========================= +# 可选工具:进度条、GPU 监控、排错工具 +# ========================= +RUN python3 -m pip install \ + tqdm \ + gputil \ + psutil \ + requests \ + fastapi \ + uvicorn + +# ========================= +# 拷贝测试脚本和启动脚本 +# ========================= +COPY ./test_ray.py /home/test_ray.py +COPY ./start-ray.sh /usr/local/bin/start-ray.sh + +RUN chmod +x /usr/local/bin/start-ray.sh + + +# ========================= +# Ray 常用端口 +# 6379 : Ray Head / GCS +# 8265 : Ray Dashboard +# 10001 : Ray Client +# 8000 : Ray Serve HTTP +# 8076-8081 : 固定 Ray 内部组件端口 +# 10002-10100 : Ray Worker 端口范围 +# ========================= +EXPOSE 6379 8265 10001 8000 8076 8077 8078 8079 8080 8081 10002-10100 + +ENTRYPOINT ["/usr/local/bin/start-ray.sh"] +CMD ["bash"] \ No newline at end of file diff --git a/frameworks/Ray/2.54.0/build.conf b/frameworks/Ray/2.54.0/build.conf new file mode 100644 index 0000000..2a50914 --- /dev/null +++ b/frameworks/Ray/2.54.0/build.conf @@ -0,0 +1,4 @@ +# Ray 2.54.0 + PyTorch 2.11.0 on OpenCloudOS 9 (GPU) +IMAGE_NAME=oc9-ray +IMAGE_TAG=2.54.0 +GPU_TEST=true \ No newline at end of file diff --git a/frameworks/Ray/2.54.0/start-ray.sh b/frameworks/Ray/2.54.0/start-ray.sh new file mode 100644 index 0000000..fe9d7a1 --- /dev/null +++ b/frameworks/Ray/2.54.0/start-ray.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "============================================================" +echo "Ray Container Startup" +echo "============================================================" +echo "RAY_NODE_TYPE=${RAY_NODE_TYPE:-single}" +echo "RAY_HEAD_ADDRESS=${RAY_HEAD_ADDRESS:-}" +echo "RAY_NODE_IP_ADDRESS=${RAY_NODE_IP_ADDRESS:-}" +echo "RAY_HEAD_PORT=${RAY_HEAD_PORT:-6379}" +echo "RAY_DASHBOARD_PORT=${RAY_DASHBOARD_PORT:-8265}" +echo "RAY_CLIENT_SERVER_PORT=${RAY_CLIENT_SERVER_PORT:-10001}" +echo "RAY_MIN_WORKER_PORT=${RAY_MIN_WORKER_PORT:-10002}" +echo "RAY_MAX_WORKER_PORT=${RAY_MAX_WORKER_PORT:-10100}" +echo "NVIDIA_VISIBLE_DEVICES=${NVIDIA_VISIBLE_DEVICES:-}" +echo "============================================================" + +get_node_ip() { + if [[ -n "${RAY_NODE_IP_ADDRESS:-}" ]]; then + echo "${RAY_NODE_IP_ADDRESS}" + else + hostname -I | awk '{print $1}' + fi +} + +build_common_ray_args() { + local args=() + + args+=("--node-ip-address=$(get_node_ip)") + args+=("--node-manager-port=${RAY_NODE_MANAGER_PORT:-8076}") + args+=("--object-manager-port=${RAY_OBJECT_MANAGER_PORT:-8077}") + args+=("--runtime-env-agent-port=${RAY_RUNTIME_ENV_AGENT_PORT:-8078}") + args+=("--dashboard-agent-grpc-port=${RAY_DASHBOARD_AGENT_GRPC_PORT:-8079}") + args+=("--dashboard-agent-listen-port=${RAY_DASHBOARD_AGENT_LISTEN_PORT:-8080}") + args+=("--metrics-export-port=${RAY_METRICS_EXPORT_PORT:-8081}") + args+=("--min-worker-port=${RAY_MIN_WORKER_PORT:-10002}") + args+=("--max-worker-port=${RAY_MAX_WORKER_PORT:-10100}") + args+=("--temp-dir=${RAY_TEMP_DIR:-/tmp/ray}") + + if [[ -n "${RAY_NUM_CPUS:-}" ]]; then + args+=("--num-cpus=${RAY_NUM_CPUS}") + fi + + if [[ -n "${RAY_NUM_GPUS:-}" ]]; then + args+=("--num-gpus=${RAY_NUM_GPUS}") + fi + + if [[ -n "${RAY_OBJECT_STORE_MEMORY:-}" ]]; then + args+=("--object-store-memory=${RAY_OBJECT_STORE_MEMORY}") + fi + + if [[ -n "${RAY_RESOURCES:-}" ]]; then + args+=("--resources=${RAY_RESOURCES}") + fi + + printf '%s\n' "${args[@]}" +} + +start_head() { + echo "[INFO] Starting Ray HEAD node..." + + ray stop --force || true + + mapfile -t COMMON_ARGS < <(build_common_ray_args) + + exec ray start \ + --head \ + --port="${RAY_HEAD_PORT:-6379}" \ + --dashboard-host="${RAY_DASHBOARD_HOST:-0.0.0.0}" \ + --dashboard-port="${RAY_DASHBOARD_PORT:-8265}" \ + --ray-client-server-port="${RAY_CLIENT_SERVER_PORT:-10001}" \ + "${COMMON_ARGS[@]}" \ + --block +} + +start_worker() { + echo "[INFO] Starting Ray WORKER node..." + + if [[ -z "${RAY_HEAD_ADDRESS:-}" ]]; then + echo "[ERROR] RAY_HEAD_ADDRESS is required for worker node." + echo "Example: RAY_HEAD_ADDRESS=192.168.1.10:6379" + exit 1 + fi + + ray stop --force || true + + mapfile -t COMMON_ARGS < <(build_common_ray_args) + + exec ray start \ + --address="${RAY_HEAD_ADDRESS}" \ + "${COMMON_ARGS[@]}" \ + --block +} + +start_single() { + echo "[INFO] Starting Ray SINGLE node..." + + ray stop --force || true + + mapfile -t COMMON_ARGS < <(build_common_ray_args) + + exec ray start \ + --head \ + --port="${RAY_HEAD_PORT:-6379}" \ + --dashboard-host="${RAY_DASHBOARD_HOST:-0.0.0.0}" \ + --dashboard-port="${RAY_DASHBOARD_PORT:-8265}" \ + --ray-client-server-port="${RAY_CLIENT_SERVER_PORT:-10001}" \ + "${COMMON_ARGS[@]}" \ + --block +} + +run_test() { + echo "[INFO] Running Ray test script..." + python3 /home/test_ray.py "$@" +} + +case "${RAY_NODE_TYPE:-single}" in + head) + start_head + ;; + + worker) + start_worker + ;; + + single) + start_single + ;; + + test) + shift || true + run_test "$@" + ;; + + bash|shell) + exec /bin/bash + ;; + + *) + echo "[INFO] Executing custom command: $*" + exec "$@" + ;; +esac \ No newline at end of file diff --git a/frameworks/Ray/2.54.0/test_ray.py b/frameworks/Ray/2.54.0/test_ray.py new file mode 100644 index 0000000..7054a44 --- /dev/null +++ b/frameworks/Ray/2.54.0/test_ray.py @@ -0,0 +1,784 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +verify_ray_full_fixed.py + +Ray 全组件验证脚本,适配 Ray 2.55.x。 + +验证内容: +- Ray Core: init / remote task / actor / object store / wait / runtime_env / placement group +- GPU: Ray GPU scheduling / CUDA_VISIBLE_DEVICES / nvidia-smi / PyTorch CUDA +- Ray Data +- Ray Tune +- Ray Train TorchTrainer +- Ray Serve +- RLlib,可用 --full 开启 + +常用运行方式: + python verify_ray_full_fixed.py + + python verify_ray_full_fixed.py --full + + python verify_ray_full_fixed.py --require-gpu + + python verify_ray_full_fixed.py --address auto + + python verify_ray_full_fixed.py --address ray://127.0.0.1:10001 + +Docker GPU 示例: + docker run --rm -it \ + --gpus all \ + --shm-size=8g \ + your-ray-image \ + python /app/verify_ray_full_fixed.py --full --require-gpu +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import socket +import subprocess +import sys +import tempfile +import time +import traceback +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Any + + +# Ray Train V2 在新版本 Ray 中是默认方向。 +# 这里显式设置,方便结果稳定,也避免部分迁移提示。 +os.environ.setdefault("RAY_TRAIN_V2_ENABLED", "1") + + +class SkipTest(Exception): + """表示当前环境不满足该组件测试条件,测试跳过。""" + + +@dataclass +class TestResult: + name: str + status: str + message: str + seconds: float + + +def module_exists(name: str) -> bool: + return importlib.util.find_spec(name) is not None + + +def print_json(title: str, data: Any) -> None: + print(f"\n{title}") + print(json.dumps(data, indent=2, ensure_ascii=False, default=str)) + + +def run_test(name: str, func: Callable[[], str]) -> TestResult: + start = time.time() + + try: + msg = func() + seconds = time.time() - start + print(f"[PASS] {name} - {msg}") + return TestResult(name, "PASS", msg, seconds) + + except SkipTest as exc: + seconds = time.time() - start + print(f"[SKIP] {name} - {exc}") + return TestResult(name, "SKIP", str(exc), seconds) + + except Exception as exc: + seconds = time.time() - start + print(f"[FAIL] {name} - {exc}") + traceback.print_exc() + return TestResult(name, "FAIL", str(exc), seconds) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Verify Ray full installation and runtime.") + + parser.add_argument( + "--address", + default=os.environ.get("RAY_ADDRESS"), + help=( + "Ray 地址。留空表示本地启动;" + "auto 表示连接已有本地 Ray 集群;" + "ray://host:10001 表示 Ray Client。" + ), + ) + + parser.add_argument( + "--full", + action="store_true", + help="开启更重的测试,例如 RLlib PPO。", + ) + + parser.add_argument( + "--require-gpu", + action="store_true", + help="要求 Ray 和 PyTorch 必须检测到 GPU,否则 GPU 测试失败。", + ) + + parser.add_argument( + "--train-workers", + type=int, + default=1, + help="Ray Train 使用的 worker 数量,默认 1。", + ) + + parser.add_argument( + "--train-use-gpu", + action="store_true", + help="Ray Train 测试是否使用 GPU。需要 Ray 检测到足够 GPU。", + ) + + parser.add_argument( + "--skip-data", + action="store_true", + help="跳过 Ray Data 测试。", + ) + + parser.add_argument( + "--skip-tune", + action="store_true", + help="跳过 Ray Tune 测试。", + ) + + parser.add_argument( + "--skip-train", + action="store_true", + help="跳过 Ray Train 测试。", + ) + + parser.add_argument( + "--skip-serve", + action="store_true", + help="跳过 Ray Serve 测试。", + ) + + parser.add_argument( + "--skip-rllib", + action="store_true", + help="跳过 RLlib 测试。", + ) + + return parser.parse_args() + + +def main() -> None: + args = parse_args() + + print("=" * 80) + print("Ray Full Verification - Fixed for Ray 2.55.x") + print("=" * 80) + print(f"Python: {sys.version}") + print(f"Host: {socket.gethostname()}") + print(f"PID: {os.getpid()}") + print(f"RAY_ADDRESS: {args.address or ''}") + print(f"RAY_TRAIN_V2_ENABLED: {os.environ.get('RAY_TRAIN_V2_ENABLED')}") + + if not module_exists("ray"): + print("\n[ERROR] 未安装 Ray。可执行:") + print(' pip install -U "ray[all]"') + sys.exit(2) + + import ray + + print(f"Ray version: {ray.__version__}") + + print("\n初始化 Ray ...") + + if args.address: + ray_info = ray.init(address=args.address) + else: + ray_info = ray.init() + + print(f"Ray initialized: {ray.is_initialized()}") + + try: + dashboard_url = getattr(ray_info, "dashboard_url", None) + if dashboard_url: + print(f"Dashboard URL: {dashboard_url}") + except Exception: + pass + + print_json("Cluster resources:", ray.cluster_resources()) + print_json("Available resources:", ray.available_resources()) + + results: list[TestResult] = [] + + # ------------------------------------------------------------------------- + # Ray Core + # ------------------------------------------------------------------------- + + def test_core_task() -> str: + @ray.remote + def square(x: int) -> int: + return x * x + + refs = [square.remote(i) for i in range(10)] + values = ray.get(refs) + expected = [i * i for i in range(10)] + + assert values == expected, f"✗ 失败||结果不符合预期: {values}" + + return f"✓ 通过||remote task 正常,结果={values}" + + results.append(run_test("Ray Core - Remote Task", test_core_task)) + + def test_actor() -> str: + @ray.remote + class Counter: + def __init__(self) -> None: + self.value = 0 + + def inc(self, n: int = 1) -> int: + self.value += n + return self.value + + def get(self) -> int: + return self.value + + counter = Counter.remote() + + assert ray.get(counter.inc.remote()) == 1 + assert ray.get(counter.inc.remote(5)) == 6 + assert ray.get(counter.get.remote()) == 6 + + return "✓ 通过||actor 状态保持正常" + + results.append(run_test("Ray Core - Actor", test_actor)) + + def test_object_store() -> str: + payload = { + "message": "hello ray object store", + "numbers": list(range(1000)), + } + + ref = ray.put(payload) + got = ray.get(ref) + + assert got == payload + + return f"✓ 通过|| ray.put/ray.get 正常,numbers={len(got['numbers'])}" + + results.append(run_test("Ray Core - Object Store", test_object_store)) + + def test_wait() -> str: + @ray.remote + def slow_identity(x: str, delay: float) -> str: + import time + + time.sleep(delay) + return x + + refs = [ + slow_identity.remote("fast", 0.2), + slow_identity.remote("slow", 1.0), + ] + + ready, remaining = ray.wait(refs, num_returns=1, timeout=5) + + assert len(ready) == 1 + assert len(remaining) == 1 + + first = ray.get(ready[0]) + assert first == "fast" + + ray.get(remaining) + + return "✓ 通过|| ray.wait 正常" + + results.append(run_test("Ray Core - ray.wait", test_wait)) + + def test_runtime_env() -> str: + @ray.remote(runtime_env={"env_vars": {"RAY_VERIFY_ENV": "OK"}}) + def read_env() -> str | None: + import os + + return os.environ.get("RAY_VERIFY_ENV") + + value = ray.get(read_env.remote()) + + assert value == "OK", f"runtime_env env_vars 未生效: {value}" + + return "✓ 通过|| runtime_env env_vars 正常" + + results.append(run_test("Ray Core - runtime_env", test_runtime_env)) + + def test_placement_group() -> str: + total_cpu = float(ray.cluster_resources().get("CPU", 0)) + + if total_cpu < 1: + raise SkipTest("集群 CPU 资源小于 1,跳过 placement group 测试") + + from ray.util.placement_group import placement_group, remove_placement_group + + pg = placement_group([{"CPU": 1}], strategy="PACK") + ray.get(pg.ready(), timeout=20) + + @ray.remote(num_cpus=1) + def pg_task() -> dict[str, Any]: + import os + + return { + "pid": os.getpid(), + "ok": True, + } + + try: + ref = pg_task.options(placement_group=pg).remote() + result = ray.get(ref) + finally: + remove_placement_group(pg) + + assert result["ok"] is True + + return f"✓ 通过|| placement group 正常,task pid={result['pid']}" + + results.append(run_test("Ray Core - Placement Group", test_placement_group)) + + # ------------------------------------------------------------------------- + # GPU + # ------------------------------------------------------------------------- + + def test_ray_gpu_scheduling() -> str: + gpu_count = float(ray.cluster_resources().get("GPU", 0)) + + if gpu_count <= 0: + if args.require_gpu: + raise RuntimeError("要求 GPU,但 Ray cluster_resources() 没有检测到 GPU") + raise SkipTest("Ray 未检测到 GPU,跳过 GPU 调度测试") + + @ray.remote(num_gpus=1) + def gpu_task() -> dict[str, Any]: + import os + import subprocess + + info: dict[str, Any] = { + "CUDA_VISIBLE_DEVICES": os.environ.get("CUDA_VISIBLE_DEVICES"), + "nvidia_smi": None, + "torch_cuda_available": None, + "torch_device_count": None, + "torch_device_name": None, + } + + try: + out = subprocess.check_output( + ["nvidia-smi"], + stderr=subprocess.STDOUT, + timeout=10, + ).decode("utf-8", errors="ignore") + info["nvidia_smi"] = out.splitlines()[0] if out else "EMPTY" + except Exception as exc: + info["nvidia_smi"] = f"nvidia-smi failed: {exc}" + + try: + import torch + + info["torch_cuda_available"] = torch.cuda.is_available() + info["torch_device_count"] = torch.cuda.device_count() + + if torch.cuda.is_available(): + info["torch_device_name"] = torch.cuda.get_device_name(0) + + except Exception as exc: + info["torch_cuda_available"] = f"torch unavailable: {exc}" + + return info + + info = ray.get(gpu_task.remote()) + + if not info.get("CUDA_VISIBLE_DEVICES"): + raise RuntimeError(f"Ray 分配了 GPU,但 CUDA_VISIBLE_DEVICES 为空: {info}") + + return f"✓ 通过|| Ray GPU 调度正常: {info}" + + results.append(run_test("GPU - Ray GPU Scheduling", test_ray_gpu_scheduling)) + + def test_driver_torch_cuda() -> str: + if not module_exists("torch"): + if args.require_gpu: + raise RuntimeError("要求 GPU,但未安装 torch,无法验证 PyTorch CUDA") + raise SkipTest("未安装 torch,跳过 PyTorch CUDA 测试") + + import torch + + if not torch.cuda.is_available(): + if args.require_gpu: + raise RuntimeError("要求 GPU,但 torch.cuda.is_available() = False") + raise SkipTest("torch 已安装,但当前 driver 进程未检测到 CUDA") + + return ( + f"PyTorch CUDA 正常,device_count={torch.cuda.device_count()}, " + f"device_name={torch.cuda.get_device_name(0)}" + ) + + results.append(run_test("GPU - PyTorch CUDA", test_driver_torch_cuda)) + + # ------------------------------------------------------------------------- + # Ray Data + # ------------------------------------------------------------------------- + + if not args.skip_data: + + def test_ray_data() -> str: + if not module_exists("ray.data"): + raise SkipTest("未安装 Ray Data,请安装 ray[data] 或 ray[all]") + + import ray.data + + ds = ray.data.from_items([{"x": i} for i in range(10)]) + mapped = ds.map(lambda row: {"x": row["x"], "y": row["x"] * 2}) + rows = mapped.take_all() + + total_y = sum(int(row["y"]) for row in rows) + + assert len(rows) == 10 + assert total_y == 90 + + return f"✓ 通过|| Ray Data 正常,rows={len(rows)}, sum_y={total_y}" + + results.append(run_test("Ray Data", test_ray_data)) + + # ------------------------------------------------------------------------- + # Ray Tune + # ------------------------------------------------------------------------- + + if not args.skip_tune: + + def test_ray_tune() -> str: + if not module_exists("ray.tune"): + raise SkipTest("未安装 Ray Tune,请安装 ray[tune] 或 ray[all]") + + from ray import tune + from ray.tune import RunConfig + + temp_dir = tempfile.mkdtemp(prefix="ray_verify_tune_") + + def trainable(config: dict[str, Any]) -> None: + # 在 Ray 2.5x 中,Tune function trainable 推荐使用 ray.train.report。 + from ray import tune + + score = config["x"] * 2 + tune.report({"score": score}) + + tuner = tune.Tuner( + trainable, + param_space={ + "x": tune.grid_search([1, 2, 3]), + }, + run_config=RunConfig( + name="ray_verify_tune", + storage_path=temp_dir + ), + ) + + result_grid = tuner.fit() + + if result_grid.errors: + raise RuntimeError(f"Tune trials 出现错误: {result_grid.errors}") + + best = result_grid.get_best_result(metric="score", mode="max") + best_score = best.metrics.get("score") + + assert best_score == 6, f"best_score 不符合预期: {best_score}" + + return f"✓ 通过|| Ray Tune 正常,best_score={best_score}, path={temp_dir}" + + results.append(run_test("Ray Tune", test_ray_tune)) + + # ------------------------------------------------------------------------- + # Ray Train + # ------------------------------------------------------------------------- + + if not args.skip_train: + + def test_ray_train_torch() -> str: + if not module_exists("ray.train"): + raise SkipTest("未安装 Ray Train,请安装 ray[train] 或 ray[all]") + + if not module_exists("torch"): + raise SkipTest("未安装 torch,跳过 TorchTrainer 测试") + + import torch + from ray.train import Checkpoint, RunConfig, ScalingConfig + from ray.train.torch import TorchTrainer + + total_cpu = int(float(ray.cluster_resources().get("CPU", 1))) + num_workers = max(1, min(args.train_workers, max(1, total_cpu))) + + use_gpu = bool(args.train_use_gpu) + + if use_gpu: + gpu_count = float(ray.cluster_resources().get("GPU", 0)) + + if gpu_count < num_workers: + raise RuntimeError( + f"Ray GPU 数量不足,要求 train_workers={num_workers}, " + f"实际 GPU={gpu_count}" + ) + + temp_dir = tempfile.mkdtemp(prefix="ray_verify_train_") + + def train_loop_per_worker(config: dict[str, Any] | None = None) -> None: + import os + import tempfile + import torch + + from ray import train + from ray.train import Checkpoint + + ctx = train.get_context() + world_rank = ctx.get_world_rank() + + x = torch.tensor([[0.0], [1.0], [2.0], [3.0]]) + y = 2.0 * x + 1.0 + + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + model = torch.nn.Linear(1, 1).to(device) + optimizer = torch.optim.SGD(model.parameters(), lr=0.05) + loss_fn = torch.nn.MSELoss() + + x = x.to(device) + y = y.to(device) + + last_loss = None + + for _ in range(50): + pred = model(x) + loss = loss_fn(pred, y) + + optimizer.zero_grad() + loss.backward() + optimizer.step() + + last_loss = float(loss.detach().cpu().item()) + + metrics = { + "loss": last_loss, + "device": str(device), + "world_rank": world_rank, + } + + # Ray Train V2 对 metrics 的持久化更依赖 checkpoint。 + # rank 0 保存 checkpoint,并把 loss 写进 checkpoint 文件,外部可稳定读取。 + if world_rank == 0: + with tempfile.TemporaryDirectory() as checkpoint_dir: + checkpoint_path = os.path.join(checkpoint_dir, "state.pt") + torch.save(metrics, checkpoint_path) + + train.report( + metrics, + checkpoint=Checkpoint.from_directory(checkpoint_dir), + ) + else: + train.report(metrics) + + trainer = TorchTrainer( + train_loop_per_worker=train_loop_per_worker, + scaling_config=ScalingConfig( + num_workers=num_workers, + use_gpu=use_gpu, + ), + run_config=RunConfig( + name="ray_verify_train", + storage_path=temp_dir, + ), + ) + + result = trainer.fit() + + metrics = getattr(result, "metrics", {}) or {} + loss = metrics.get("loss") + device = metrics.get("device") + + # 某些 Ray Train V2 场景下 result.metrics 可能为空; + # 因此从 checkpoint 中兜底读取 loss。 + if loss is None: + checkpoint = getattr(result, "checkpoint", None) + + if checkpoint is not None: + with checkpoint.as_directory() as checkpoint_dir: + checkpoint_path = Path(checkpoint_dir) / "state.pt" + + if checkpoint_path.exists(): + payload = torch.load( + checkpoint_path, + map_location="cpu", + weights_only=False, + ) + + if isinstance(payload, dict): + loss = payload.get("loss") + device = payload.get("device") + + if loss is None: + raise RuntimeError( + f"Train 结果中没有 loss,metrics={metrics}, " + f"checkpoint={getattr(result, 'checkpoint', None)}" + ) + + if float(loss) > 1.0: + raise RuntimeError(f"训练 loss 偏高,loss={loss}") + + return ( + f"✓ 通过|| Ray Train TorchTrainer 正常,workers={num_workers}, " + f"use_gpu={use_gpu}, device={device}, loss={float(loss):.6f}, " + f"path={temp_dir}" + ) + + results.append(run_test("Ray Train - TorchTrainer", test_ray_train_torch)) + + # ------------------------------------------------------------------------- + # Ray Serve + # ------------------------------------------------------------------------- + + if not args.skip_serve: + + def test_ray_serve() -> str: + if not module_exists("ray.serve"): + raise SkipTest("未安装 Ray Serve,请安装 ray[serve] 或 ray[all]") + + from ray import serve + + try: + serve.shutdown() + except Exception: + pass + + @serve.deployment(ray_actor_options={"num_cpus": 0}) + class EchoDeployment: + async def __call__(self, value: str = "hello") -> str: + return f"serve:{value}" + + handle = serve.run( + EchoDeployment.bind(), + name="ray_verify_serve_app", + route_prefix="/ray-verify", + ) + + # 新版 Ray Serve 的 handle.remote() 返回 DeploymentResponse, + # 不是普通 ObjectRef,因此不能 ray.get(handle.remote(...))。 + response = handle.remote("ok") + result = response.result(timeout_s=30) + + try: + serve.shutdown() + except Exception: + pass + + assert result == "serve:ok", f"Serve 返回不符合预期: {result}" + + return "✓ 通过|| Ray Serve 正常,DeploymentResponse.result() 调用成功" + + results.append(run_test("Ray Serve", test_ray_serve)) + + # ------------------------------------------------------------------------- + # RLlib + # ------------------------------------------------------------------------- + + if not args.skip_rllib: + + def test_rllib() -> str: + if not args.full: + raise SkipTest("RLlib 测试较重,使用 --full 开启") + + if not module_exists("ray.rllib"): + raise SkipTest("未安装 RLlib,请安装 ray[rllib] 或 ray[all]") + + if not module_exists("gymnasium"): + raise SkipTest("未安装 gymnasium,RLlib CartPole 测试跳过") + + if not module_exists("torch"): + raise SkipTest("未安装 torch,RLlib PPO torch 测试跳过") + + from ray.rllib.algorithms.ppo import PPOConfig + + config = PPOConfig() + config = config.environment("CartPole-v1") + + if hasattr(config, "framework"): + config = config.framework("torch") + + # Ray 2.55 默认使用新 API stack。 + # 新版本用 env_runners;旧版本用 rollouts。 + if hasattr(config, "env_runners"): + config = config.env_runners(num_env_runners=0) + else: + config = config.rollouts(num_rollout_workers=0) + + # 兼容新旧训练参数命名。 + try: + config = config.training( + train_batch_size_per_learner=64, + minibatch_size=32, + num_epochs=1, + lr=1e-3, + ) + except TypeError: + config = config.training( + train_batch_size=64, + sgd_minibatch_size=32, + num_sgd_iter=1, + lr=1e-3, + ) + + if hasattr(config, "build_algo"): + algo = config.build_algo() + else: + algo = config.build() + + try: + train_result = algo.train() + finally: + algo.stop() + + episode_reward_mean = train_result.get("episode_reward_mean") + + return ( + "✓ 通过|| RLlib PPO 正常完成一次训练," + f"episode_reward_mean={episode_reward_mean}" + ) + + results.append(run_test("RLlib", test_rllib)) + + # ------------------------------------------------------------------------- + # Summary + # ------------------------------------------------------------------------- + + print("\n" + "=" * 80) + print("验证结果汇总") + print("=" * 80) + + status_counts = { + "PASS": sum(1 for r in results if r.status == "PASS"), + "SKIP": sum(1 for r in results if r.status == "SKIP"), + "FAIL": sum(1 for r in results if r.status == "FAIL"), + } + + for r in results: + print(f"{r.status:4} | {r.seconds:7.2f}s | {r.name} | {r.message}") + + print_json("Status counts:", status_counts) + + try: + ray.shutdown() + except Exception: + pass + + if status_counts["FAIL"] > 0: + print("\n✗ 失败|| 最终结果:FAILED") + sys.exit(1) + + print("\n✓ 通过|| 最终结果:PASSED") + sys.exit(0) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/frameworks/Ray/2.54.0/test_result.png b/frameworks/Ray/2.54.0/test_result.png new file mode 100644 index 0000000000000000000000000000000000000000..79d62ee63e66dc5feee9dab21ce147449c48f422 GIT binary patch literal 58141 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 j{v`+lj0JY3Vp~cFqsxu31xH2&6<)Tqz3|%H bool: + return importlib.util.find_spec(name) is not None + + +def print_json(title: str, data: Any) -> None: + print(f"\n{title}") + print(json.dumps(data, indent=2, ensure_ascii=False, default=str)) + + +def run_test(name: str, func: Callable[[], str]) -> TestResult: + start = time.time() + + try: + msg = func() + seconds = time.time() - start + print(f"[PASS] {name} - {msg}") + return TestResult(name, "PASS", msg, seconds) + + except SkipTest as exc: + seconds = time.time() - start + print(f"[SKIP] {name} - {exc}") + return TestResult(name, "SKIP", str(exc), seconds) + + except Exception as exc: + seconds = time.time() - start + print(f"[FAIL] {name} - {exc}") + traceback.print_exc() + return TestResult(name, "FAIL", str(exc), seconds) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Verify Ray full installation and runtime.") + + parser.add_argument( + "--address", + default=os.environ.get("RAY_ADDRESS"), + help=( + "Ray 地址。留空表示本地启动;" + "auto 表示连接已有本地 Ray 集群;" + "ray://host:10001 表示 Ray Client。" + ), + ) + + parser.add_argument( + "--full", + action="store_true", + help="开启更重的测试,例如 RLlib PPO。", + ) + + parser.add_argument( + "--require-gpu", + action="store_true", + help="要求 Ray 和 PyTorch 必须检测到 GPU,否则 GPU 测试失败。", + ) + + parser.add_argument( + "--train-workers", + type=int, + default=1, + help="Ray Train 使用的 worker 数量,默认 1。", + ) + + parser.add_argument( + "--train-use-gpu", + action="store_true", + help="Ray Train 测试是否使用 GPU。需要 Ray 检测到足够 GPU。", + ) + + parser.add_argument( + "--skip-data", + action="store_true", + help="跳过 Ray Data 测试。", + ) + + parser.add_argument( + "--skip-tune", + action="store_true", + help="跳过 Ray Tune 测试。", + ) + + parser.add_argument( + "--skip-train", + action="store_true", + help="跳过 Ray Train 测试。", + ) + + parser.add_argument( + "--skip-serve", + action="store_true", + help="跳过 Ray Serve 测试。", + ) + + parser.add_argument( + "--skip-rllib", + action="store_true", + help="跳过 RLlib 测试。", + ) + + return parser.parse_args() + + +def main() -> None: + args = parse_args() + + print("=" * 80) + print("Ray Full Verification - Fixed for Ray 2.55.x") + print("=" * 80) + print(f"Python: {sys.version}") + print(f"Host: {socket.gethostname()}") + print(f"PID: {os.getpid()}") + print(f"RAY_ADDRESS: {args.address or ''}") + print(f"RAY_TRAIN_V2_ENABLED: {os.environ.get('RAY_TRAIN_V2_ENABLED')}") + + if not module_exists("ray"): + print("\n[ERROR] 未安装 Ray。可执行:") + print(' pip install -U "ray[all]"') + sys.exit(2) + + import ray + + print(f"Ray version: {ray.__version__}") + + print("\n初始化 Ray ...") + + if args.address: + ray_info = ray.init(address=args.address) + else: + ray_info = ray.init() + + print(f"Ray initialized: {ray.is_initialized()}") + + try: + dashboard_url = getattr(ray_info, "dashboard_url", None) + if dashboard_url: + print(f"Dashboard URL: {dashboard_url}") + except Exception: + pass + + print_json("Cluster resources:", ray.cluster_resources()) + print_json("Available resources:", ray.available_resources()) + + results: list[TestResult] = [] + + # ------------------------------------------------------------------------- + # Ray Core + # ------------------------------------------------------------------------- + + def test_core_task() -> str: + @ray.remote + def square(x: int) -> int: + return x * x + + refs = [square.remote(i) for i in range(10)] + values = ray.get(refs) + expected = [i * i for i in range(10)] + + assert values == expected, f"✗ 失败||结果不符合预期: {values}" + + return f"✓ 通过||remote task 正常,结果={values}" + + results.append(run_test("Ray Core - Remote Task", test_core_task)) + + def test_actor() -> str: + @ray.remote + class Counter: + def __init__(self) -> None: + self.value = 0 + + def inc(self, n: int = 1) -> int: + self.value += n + return self.value + + def get(self) -> int: + return self.value + + counter = Counter.remote() + + assert ray.get(counter.inc.remote()) == 1 + assert ray.get(counter.inc.remote(5)) == 6 + assert ray.get(counter.get.remote()) == 6 + + return "✓ 通过||actor 状态保持正常" + + results.append(run_test("Ray Core - Actor", test_actor)) + + def test_object_store() -> str: + payload = { + "message": "hello ray object store", + "numbers": list(range(1000)), + } + + ref = ray.put(payload) + got = ray.get(ref) + + assert got == payload + + return f"✓ 通过|| ray.put/ray.get 正常,numbers={len(got['numbers'])}" + + results.append(run_test("Ray Core - Object Store", test_object_store)) + + def test_wait() -> str: + @ray.remote + def slow_identity(x: str, delay: float) -> str: + import time + + time.sleep(delay) + return x + + refs = [ + slow_identity.remote("fast", 0.2), + slow_identity.remote("slow", 1.0), + ] + + ready, remaining = ray.wait(refs, num_returns=1, timeout=5) + + assert len(ready) == 1 + assert len(remaining) == 1 + + first = ray.get(ready[0]) + assert first == "fast" + + ray.get(remaining) + + return "✓ 通过|| ray.wait 正常" + + results.append(run_test("Ray Core - ray.wait", test_wait)) + + def test_runtime_env() -> str: + @ray.remote(runtime_env={"env_vars": {"RAY_VERIFY_ENV": "OK"}}) + def read_env() -> str | None: + import os + + return os.environ.get("RAY_VERIFY_ENV") + + value = ray.get(read_env.remote()) + + assert value == "OK", f"runtime_env env_vars 未生效: {value}" + + return "✓ 通过|| runtime_env env_vars 正常" + + results.append(run_test("Ray Core - runtime_env", test_runtime_env)) + + def test_placement_group() -> str: + total_cpu = float(ray.cluster_resources().get("CPU", 0)) + + if total_cpu < 1: + raise SkipTest("集群 CPU 资源小于 1,跳过 placement group 测试") + + from ray.util.placement_group import placement_group, remove_placement_group + + pg = placement_group([{"CPU": 1}], strategy="PACK") + ray.get(pg.ready(), timeout=20) + + @ray.remote(num_cpus=1) + def pg_task() -> dict[str, Any]: + import os + + return { + "pid": os.getpid(), + "ok": True, + } + + try: + ref = pg_task.options(placement_group=pg).remote() + result = ray.get(ref) + finally: + remove_placement_group(pg) + + assert result["ok"] is True + + return f"✓ 通过|| placement group 正常,task pid={result['pid']}" + + results.append(run_test("Ray Core - Placement Group", test_placement_group)) + + # ------------------------------------------------------------------------- + # GPU + # ------------------------------------------------------------------------- + + def test_ray_gpu_scheduling() -> str: + gpu_count = float(ray.cluster_resources().get("GPU", 0)) + + if gpu_count <= 0: + if args.require_gpu: + raise RuntimeError("要求 GPU,但 Ray cluster_resources() 没有检测到 GPU") + raise SkipTest("Ray 未检测到 GPU,跳过 GPU 调度测试") + + @ray.remote(num_gpus=1) + def gpu_task() -> dict[str, Any]: + import os + import subprocess + + info: dict[str, Any] = { + "CUDA_VISIBLE_DEVICES": os.environ.get("CUDA_VISIBLE_DEVICES"), + "nvidia_smi": None, + "torch_cuda_available": None, + "torch_device_count": None, + "torch_device_name": None, + } + + try: + out = subprocess.check_output( + ["nvidia-smi"], + stderr=subprocess.STDOUT, + timeout=10, + ).decode("utf-8", errors="ignore") + info["nvidia_smi"] = out.splitlines()[0] if out else "EMPTY" + except Exception as exc: + info["nvidia_smi"] = f"nvidia-smi failed: {exc}" + + try: + import torch + + info["torch_cuda_available"] = torch.cuda.is_available() + info["torch_device_count"] = torch.cuda.device_count() + + if torch.cuda.is_available(): + info["torch_device_name"] = torch.cuda.get_device_name(0) + + except Exception as exc: + info["torch_cuda_available"] = f"torch unavailable: {exc}" + + return info + + info = ray.get(gpu_task.remote()) + + if not info.get("CUDA_VISIBLE_DEVICES"): + raise RuntimeError(f"Ray 分配了 GPU,但 CUDA_VISIBLE_DEVICES 为空: {info}") + + return f"✓ 通过|| Ray GPU 调度正常: {info}" + + results.append(run_test("GPU - Ray GPU Scheduling", test_ray_gpu_scheduling)) + + def test_driver_torch_cuda() -> str: + if not module_exists("torch"): + if args.require_gpu: + raise RuntimeError("要求 GPU,但未安装 torch,无法验证 PyTorch CUDA") + raise SkipTest("未安装 torch,跳过 PyTorch CUDA 测试") + + import torch + + if not torch.cuda.is_available(): + if args.require_gpu: + raise RuntimeError("要求 GPU,但 torch.cuda.is_available() = False") + raise SkipTest("torch 已安装,但当前 driver 进程未检测到 CUDA") + + return ( + f"PyTorch CUDA 正常,device_count={torch.cuda.device_count()}, " + f"device_name={torch.cuda.get_device_name(0)}" + ) + + results.append(run_test("GPU - PyTorch CUDA", test_driver_torch_cuda)) + + # ------------------------------------------------------------------------- + # Ray Data + # ------------------------------------------------------------------------- + + if not args.skip_data: + + def test_ray_data() -> str: + if not module_exists("ray.data"): + raise SkipTest("未安装 Ray Data,请安装 ray[data] 或 ray[all]") + + import ray.data + + ds = ray.data.from_items([{"x": i} for i in range(10)]) + mapped = ds.map(lambda row: {"x": row["x"], "y": row["x"] * 2}) + rows = mapped.take_all() + + total_y = sum(int(row["y"]) for row in rows) + + assert len(rows) == 10 + assert total_y == 90 + + return f"✓ 通过|| Ray Data 正常,rows={len(rows)}, sum_y={total_y}" + + results.append(run_test("Ray Data", test_ray_data)) + + # ------------------------------------------------------------------------- + # Ray Tune + # ------------------------------------------------------------------------- + + if not args.skip_tune: + + def test_ray_tune() -> str: + if not module_exists("ray.tune"): + raise SkipTest("未安装 Ray Tune,请安装 ray[tune] 或 ray[all]") + + from ray import tune + from ray.tune import RunConfig + + temp_dir = tempfile.mkdtemp(prefix="ray_verify_tune_") + + def trainable(config: dict[str, Any]) -> None: + # 在 Ray 2.5x 中,Tune function trainable 推荐使用 ray.train.report。 + from ray import tune + + score = config["x"] * 2 + tune.report({"score": score}) + + tuner = tune.Tuner( + trainable, + param_space={ + "x": tune.grid_search([1, 2, 3]), + }, + run_config=RunConfig( + name="ray_verify_tune", + storage_path=temp_dir + ), + ) + + result_grid = tuner.fit() + + if result_grid.errors: + raise RuntimeError(f"Tune trials 出现错误: {result_grid.errors}") + + best = result_grid.get_best_result(metric="score", mode="max") + best_score = best.metrics.get("score") + + assert best_score == 6, f"best_score 不符合预期: {best_score}" + + return f"✓ 通过|| Ray Tune 正常,best_score={best_score}, path={temp_dir}" + + results.append(run_test("Ray Tune", test_ray_tune)) + + # ------------------------------------------------------------------------- + # Ray Train + # ------------------------------------------------------------------------- + + if not args.skip_train: + + def test_ray_train_torch() -> str: + if not module_exists("ray.train"): + raise SkipTest("未安装 Ray Train,请安装 ray[train] 或 ray[all]") + + if not module_exists("torch"): + raise SkipTest("未安装 torch,跳过 TorchTrainer 测试") + + import torch + from ray.train import Checkpoint, RunConfig, ScalingConfig + from ray.train.torch import TorchTrainer + + total_cpu = int(float(ray.cluster_resources().get("CPU", 1))) + num_workers = max(1, min(args.train_workers, max(1, total_cpu))) + + use_gpu = bool(args.train_use_gpu) + + if use_gpu: + gpu_count = float(ray.cluster_resources().get("GPU", 0)) + + if gpu_count < num_workers: + raise RuntimeError( + f"Ray GPU 数量不足,要求 train_workers={num_workers}, " + f"实际 GPU={gpu_count}" + ) + + temp_dir = tempfile.mkdtemp(prefix="ray_verify_train_") + + def train_loop_per_worker(config: dict[str, Any] | None = None) -> None: + import os + import tempfile + import torch + + from ray import train + from ray.train import Checkpoint + + ctx = train.get_context() + world_rank = ctx.get_world_rank() + + x = torch.tensor([[0.0], [1.0], [2.0], [3.0]]) + y = 2.0 * x + 1.0 + + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + model = torch.nn.Linear(1, 1).to(device) + optimizer = torch.optim.SGD(model.parameters(), lr=0.05) + loss_fn = torch.nn.MSELoss() + + x = x.to(device) + y = y.to(device) + + last_loss = None + + for _ in range(50): + pred = model(x) + loss = loss_fn(pred, y) + + optimizer.zero_grad() + loss.backward() + optimizer.step() + + last_loss = float(loss.detach().cpu().item()) + + metrics = { + "loss": last_loss, + "device": str(device), + "world_rank": world_rank, + } + + # Ray Train V2 对 metrics 的持久化更依赖 checkpoint。 + # rank 0 保存 checkpoint,并把 loss 写进 checkpoint 文件,外部可稳定读取。 + if world_rank == 0: + with tempfile.TemporaryDirectory() as checkpoint_dir: + checkpoint_path = os.path.join(checkpoint_dir, "state.pt") + torch.save(metrics, checkpoint_path) + + train.report( + metrics, + checkpoint=Checkpoint.from_directory(checkpoint_dir), + ) + else: + train.report(metrics) + + trainer = TorchTrainer( + train_loop_per_worker=train_loop_per_worker, + scaling_config=ScalingConfig( + num_workers=num_workers, + use_gpu=use_gpu, + ), + run_config=RunConfig( + name="ray_verify_train", + storage_path=temp_dir, + ), + ) + + result = trainer.fit() + + metrics = getattr(result, "metrics", {}) or {} + loss = metrics.get("loss") + device = metrics.get("device") + + # 某些 Ray Train V2 场景下 result.metrics 可能为空; + # 因此从 checkpoint 中兜底读取 loss。 + if loss is None: + checkpoint = getattr(result, "checkpoint", None) + + if checkpoint is not None: + with checkpoint.as_directory() as checkpoint_dir: + checkpoint_path = Path(checkpoint_dir) / "state.pt" + + if checkpoint_path.exists(): + payload = torch.load( + checkpoint_path, + map_location="cpu", + weights_only=False, + ) + + if isinstance(payload, dict): + loss = payload.get("loss") + device = payload.get("device") + + if loss is None: + raise RuntimeError( + f"Train 结果中没有 loss,metrics={metrics}, " + f"checkpoint={getattr(result, 'checkpoint', None)}" + ) + + if float(loss) > 1.0: + raise RuntimeError(f"训练 loss 偏高,loss={loss}") + + return ( + f"✓ 通过|| Ray Train TorchTrainer 正常,workers={num_workers}, " + f"use_gpu={use_gpu}, device={device}, loss={float(loss):.6f}, " + f"path={temp_dir}" + ) + + results.append(run_test("Ray Train - TorchTrainer", test_ray_train_torch)) + + # ------------------------------------------------------------------------- + # Ray Serve + # ------------------------------------------------------------------------- + + if not args.skip_serve: + + def test_ray_serve() -> str: + if not module_exists("ray.serve"): + raise SkipTest("未安装 Ray Serve,请安装 ray[serve] 或 ray[all]") + + from ray import serve + + try: + serve.shutdown() + except Exception: + pass + + @serve.deployment(ray_actor_options={"num_cpus": 0}) + class EchoDeployment: + async def __call__(self, value: str = "hello") -> str: + return f"serve:{value}" + + handle = serve.run( + EchoDeployment.bind(), + name="ray_verify_serve_app", + route_prefix="/ray-verify", + ) + + # 新版 Ray Serve 的 handle.remote() 返回 DeploymentResponse, + # 不是普通 ObjectRef,因此不能 ray.get(handle.remote(...))。 + response = handle.remote("ok") + result = response.result(timeout_s=30) + + try: + serve.shutdown() + except Exception: + pass + + assert result == "serve:ok", f"Serve 返回不符合预期: {result}" + + return "✓ 通过|| Ray Serve 正常,DeploymentResponse.result() 调用成功" + + results.append(run_test("Ray Serve", test_ray_serve)) + + # ------------------------------------------------------------------------- + # RLlib + # ------------------------------------------------------------------------- + + if not args.skip_rllib: + + def test_rllib() -> str: + if not args.full: + raise SkipTest("RLlib 测试较重,使用 --full 开启") + + if not module_exists("ray.rllib"): + raise SkipTest("未安装 RLlib,请安装 ray[rllib] 或 ray[all]") + + if not module_exists("gymnasium"): + raise SkipTest("未安装 gymnasium,RLlib CartPole 测试跳过") + + if not module_exists("torch"): + raise SkipTest("未安装 torch,RLlib PPO torch 测试跳过") + + from ray.rllib.algorithms.ppo import PPOConfig + + config = PPOConfig() + config = config.environment("CartPole-v1") + + if hasattr(config, "framework"): + config = config.framework("torch") + + # Ray 2.55 默认使用新 API stack。 + # 新版本用 env_runners;旧版本用 rollouts。 + if hasattr(config, "env_runners"): + config = config.env_runners(num_env_runners=0) + else: + config = config.rollouts(num_rollout_workers=0) + + # 兼容新旧训练参数命名。 + try: + config = config.training( + train_batch_size_per_learner=64, + minibatch_size=32, + num_epochs=1, + lr=1e-3, + ) + except TypeError: + config = config.training( + train_batch_size=64, + sgd_minibatch_size=32, + num_sgd_iter=1, + lr=1e-3, + ) + + if hasattr(config, "build_algo"): + algo = config.build_algo() + else: + algo = config.build() + + try: + train_result = algo.train() + finally: + algo.stop() + + episode_reward_mean = train_result.get("episode_reward_mean") + + return ( + "✓ 通过|| RLlib PPO 正常完成一次训练," + f"episode_reward_mean={episode_reward_mean}" + ) + + results.append(run_test("RLlib", test_rllib)) + + # ------------------------------------------------------------------------- + # Summary + # ------------------------------------------------------------------------- + + print("\n" + "=" * 80) + print("验证结果汇总") + print("=" * 80) + + status_counts = { + "PASS": sum(1 for r in results if r.status == "PASS"), + "SKIP": sum(1 for r in results if r.status == "SKIP"), + "FAIL": sum(1 for r in results if r.status == "FAIL"), + } + + for r in results: + print(f"{r.status:4} | {r.seconds:7.2f}s | {r.name} | {r.message}") + + print_json("Status counts:", status_counts) + + try: + ray.shutdown() + except Exception: + pass + + if status_counts["FAIL"] > 0: + print("\n✗ 失败|| 最终结果:FAILED") + sys.exit(1) + + print("\n✓ 通过|| 最终结果:PASSED") + sys.exit(0) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/frameworks/Ray/2.54.1/test_result.png b/frameworks/Ray/2.54.1/test_result.png new file mode 100644 index 0000000000000000000000000000000000000000..79d62ee63e66dc5feee9dab21ce147449c48f422 GIT binary patch literal 58141 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 j{v`+lj0JY3Vp~cFqsxu31xH2&6<)Tqz3|%H Date: Wed, 6 May 2026 17:57:53 +0800 Subject: [PATCH 2/7] feat: add Ray v2.54.0/v2.54.1 container image for OC9 --- frameworks/Ray/2.54.0/README.md | 409 ++++++++++++++++++++++++++++++++ frameworks/Ray/2.54.1/README.md | 409 ++++++++++++++++++++++++++++++++ 2 files changed, 818 insertions(+) create mode 100644 frameworks/Ray/2.54.0/README.md create mode 100644 frameworks/Ray/2.54.1/README.md diff --git a/frameworks/Ray/2.54.0/README.md b/frameworks/Ray/2.54.0/README.md new file mode 100644 index 0000000..58cc641 --- /dev/null +++ b/frameworks/Ray/2.54.0/README.md @@ -0,0 +1,409 @@ +# Ray 2.55.1 + Torch 2.11.0 on OpenCloudOS 9 + +## 基本信息 + +- **Ray 版本**:v2.54.0 +- **Torch 版本**:v2.11.0 +- **TorchVision 版本**:v0.26.0 +- **TorchAudio 版本**:v2.11.0 +- **基础镜像**:opencloudos/opencloudos9-cuda-devel:12.8 +- **Python 版本**:3.11 +- **CUDA 版本**:12.8 +- **GPU 支持**:NVIDIA GPU / CUDA +- **Ray 安装组件**:ray[all,client,serve-grpc] + +## 适用场景 + +- Ray 单机任务调度 +- Ray 多机集群任务调度 +- Ray Data 数据处理 +- Ray Train 分布式训练 +- Ray Tune 参数调优 +- Ray Serve 模型服务 +- Ray RLlib 强化学习 +- PyTorch GPU 训练 / 推理 + +--- + +## 构建 + +```bash +docker build -t oc9-ray:2.54.0 . +``` + +也可以通过构建参数指定 Ray 和 Torch 版本: + +```bash +docker build \ + --build-arg RAY_VERSION=2.54.0 \ + --build-arg TORCH_VERSION=2.11.0 \ + --build-arg TORCHVISION_VERSION=0.26.0 \ + --build-arg TORCHAUDIO_VERSION=2.11.0 \ + -t oc9-ray:2.54.0 . +``` + +--- + +## 镜像启动命令 + +### 单机模式启动 + +单机模式会在当前容器内启动一个 Ray Head 节点,适合本地开发、单机测试、单机 GPU 任务。 + +```bash +docker run -d \ + --gpus all \ + --shm-size=8g \ + --name oc9-ray-single \ + -e RAY_NODE_TYPE=single \ + -p 6379:6379 \ + -p 8265:8265 \ + -p 10001:10001 \ + -p 8000:8000 \ + oc9-ray:2.55.1 +``` + +查看 Ray Dashboard: + +```text +http://宿主机IP:8265 +``` + +进入容器: + +```bash +docker exec -it oc9-ray-single bash +``` + +查看 Ray 集群状态: + +```bash +ray status +``` + +--- + +### 多机集群模式启动 + +Ray 多机集群由一个 Head 节点和多个 Worker 节点组成。 + +推荐使用 `--network=host`,避免 Docker bridge 网络导致 Ray 节点之间无法互相访问。 + +假设机器 IP 如下: + +| 角色 | IP | +|---|---| +| Head 节点 | 192.168.1.10 | +| Worker 节点 1 | 192.168.1.11 | +| Worker 节点 2 | 192.168.1.12 | + +--- + +#### 启动 Head 节点 + +在 `192.168.1.10` 上执行: + +```bash +docker run -d \ + --gpus all \ + --network=host \ + --shm-size=8g \ + --name oc9-ray-head \ + -e RAY_NODE_TYPE=head \ + -e RAY_NODE_IP_ADDRESS=192.168.1.10 \ + -e RAY_NUM_GPUS=1 \ + oc9-ray:2.55.1 +``` + +查看 Head 节点日志: + +```bash +docker logs -f oc9-ray-head +``` + +查看 Dashboard: + +```text +http://192.168.1.10:8265 +``` + +--- + +#### 启动 Worker 节点 + +在 `192.168.1.11` 上执行: + +```bash +docker run -d \ + --gpus all \ + --network=host \ + --shm-size=8g \ + --name oc9-ray-worker-1 \ + -e RAY_NODE_TYPE=worker \ + -e RAY_HEAD_ADDRESS=192.168.1.10:6379 \ + -e RAY_NODE_IP_ADDRESS=192.168.1.11 \ + -e RAY_NUM_GPUS=1 \ + oc9-ray:2.54.0 +``` + +在 `192.168.1.12` 上执行: + +```bash +docker run -d \ + --gpus all \ + --network=host \ + --shm-size=8g \ + --name oc9-ray-worker-2 \ + -e RAY_NODE_TYPE=worker \ + -e RAY_HEAD_ADDRESS=192.168.1.10:6379 \ + -e RAY_NODE_IP_ADDRESS=192.168.1.12 \ + -e RAY_NUM_GPUS=1 \ + oc9-ray:2.54.0 +``` + +查看 Worker 节点日志: + +```bash +docker logs -f oc9-ray-worker-1 +docker logs -f oc9-ray-worker-2 +``` + +--- + +## 镜像测试命令 + +### 单机完整测试 + +```bash +docker run --rm \ + --gpus all \ + --shm-size=8g \ + -e RAY_NODE_TYPE=test \ + oc9-ray:2.54.0 \ + python3 test_ray.py --full --require-gpu +``` + +### 在已有 Head 节点中测试 + +```bash +docker exec -it oc9-ray-head bash +python3 /home/test_ray.py --address auto --full --require-gpu +``` + +### 通过 Ray Client 连接测试 + +```bash +python3 test_ray.py --address ray://192.168.1.10:10001 --full --require-gpu +``` + +--- + +## 常用端口 + +| 端口 | 说明 | +|---:|---| +| 6379 | Ray Head / GCS 端口 | +| 8265 | Ray Dashboard 端口 | +| 10001 | Ray Client 端口 | +| 8000 | Ray Serve HTTP 端口 | +| 8076 | Ray Node Manager 端口 | +| 8077 | Ray Object Manager 端口 | +| 8078 | Ray Runtime Env Agent 端口 | +| 8079 | Ray Dashboard Agent gRPC 端口 | +| 8080 | Ray Dashboard Agent HTTP 端口 | +| 8081 | Ray Metrics Export 端口 | +| 10002-10100 | Ray Worker 进程端口范围 | + +--- + +## 环境变量说明 + +| 环境变量 | 默认值 | 示例 | 说明 | +|---|---|---|---| +| `RAY_NODE_TYPE` | `single` | `head` / `worker` / `single` / `test` | 容器启动模式。`head` 表示 Head 节点,`worker` 表示 Worker 节点,`single` 表示单机模式,`test` 表示执行测试脚本 | +| `RAY_HEAD_ADDRESS` | 空 | `192.168.1.10:6379` | Worker 节点连接 Head 节点的地址。`RAY_NODE_TYPE=worker` 时必填 | +| `RAY_NODE_IP_ADDRESS` | 自动获取 | `192.168.1.11` | 当前节点对其他 Ray 节点可访问的 IP。多机部署时建议显式指定 | +| `RAY_HEAD_PORT` | `6379` | `6379` | Ray Head / GCS 监听端口 | +| `RAY_DASHBOARD_HOST` | `0.0.0.0` | `0.0.0.0` | Ray Dashboard 监听地址。容器中建议设置为 `0.0.0.0` | +| `RAY_DASHBOARD_PORT` | `8265` | `8265` | Ray Dashboard 端口 | +| `RAY_CLIENT_SERVER_PORT` | `10001` | `10001` | Ray Client 连接端口 | +| `RAY_SERVE_HTTP_PORT` | `8000` | `8000` | Ray Serve HTTP 服务端口 | +| `RAY_NODE_MANAGER_PORT` | `8076` | `8076` | Ray Node Manager 固定端口 | +| `RAY_OBJECT_MANAGER_PORT` | `8077` | `8077` | Ray Object Manager 固定端口 | +| `RAY_RUNTIME_ENV_AGENT_PORT` | `8078` | `8078` | Ray Runtime Env Agent 固定端口 | +| `RAY_DASHBOARD_AGENT_GRPC_PORT` | `8079` | `8079` | Ray Dashboard Agent gRPC 端口 | +| `RAY_DASHBOARD_AGENT_LISTEN_PORT` | `8080` | `8080` | Ray Dashboard Agent HTTP 端口 | +| `RAY_METRICS_EXPORT_PORT` | `8081` | `8081` | Ray Metrics 指标暴露端口 | +| `RAY_MIN_WORKER_PORT` | `10002` | `10002` | Ray Worker 进程端口范围下限 | +| `RAY_MAX_WORKER_PORT` | `10100` | `10100` | Ray Worker 进程端口范围上限 | +| `RAY_NUM_CPUS` | 自动检测 | `20` | 手动指定当前节点可用 CPU 数量 | +| `RAY_NUM_GPUS` | 自动检测 | `1` | 手动指定当前节点可用 GPU 数量 | +| `RAY_OBJECT_STORE_MEMORY` | 自动计算 | `8589934592` | Ray Object Store 内存大小,单位为 bytes | +| `RAY_RESOURCES` | 空 | `'{"worker": 1}'` | 自定义 Ray 资源标签 | +| `RAY_TEMP_DIR` | `/tmp/ray` | `/tmp/ray` | Ray 临时文件目录 | +| `RAY_DISABLE_USAGE_STATS` | `1` | `1` | 禁用 Ray 使用统计上报 | +| `NVIDIA_VISIBLE_DEVICES` | `all` | `all` / `0` / `0,1` | 指定容器可见 GPU | +| `NVIDIA_DRIVER_CAPABILITIES` | `compute,utility` | `compute,utility` | NVIDIA 容器运行能力,GPU 计算通常需要 `compute`,`nvidia-smi` 需要 `utility` | + +--- + +## 多机协同任务测试 + +进入 Head 容器: + +```bash +docker exec -it oc9-ray-head bash +``` + +执行: + +```bash +python3 - <<'PY' +import socket +import ray + +ray.init(address="auto") + +@ray.remote +def task(i): + return { + "task": i, + "host": socket.gethostname(), + "node_id": ray.get_runtime_context().get_node_id(), + } + +refs = [task.remote(i) for i in range(50)] +results = ray.get(refs) + +for item in results[:20]: + print(item) + +print("cluster_resources:", ray.cluster_resources()) +PY +``` + +如果 Worker 节点加入成功,`cluster_resources` 中会显示多台节点的 CPU / GPU 资源。 + +--- + +## GPU 验证 + +```bash +docker run --rm \ + --gpus all \ + --shm-size=8g \ + oc9-ray:2.54.0 \ + bash -c "nvidia-smi && python3 -c 'import torch; print(torch.__version__); print(torch.cuda.is_available()); print(torch.cuda.get_device_name(0))'" +``` + +预期输出中应包含: + +```text +True +NVIDIA ... +``` + +--- + +## 版本检查 + +进入容器后执行: + +```bash +python3 - <<'PY' +import ray +import torch + +print("Ray:", ray.__version__) +print("Torch:", torch.__version__) +print("Torch CUDA:", torch.version.cuda) +print("CUDA available:", torch.cuda.is_available()) + +if torch.cuda.is_available(): + print("GPU:", torch.cuda.get_device_name(0)) +PY +``` + +--- + +## 注意事项 + +1. Docker 默认 `/dev/shm` 只有 64MB,Ray Object Store 会受到影响,建议启动容器时增加: + +```bash +--shm-size=8g +``` + +2. 多机部署建议使用: + +```bash +--network=host +``` + +3. Head 和 Worker 节点的 Ray 版本、Python 版本、CUDA 版本、PyTorch 版本应保持一致。 + +4. Worker 节点必须能访问 Head 节点的 `6379` 端口。 + +5. 多机 Docker bridge 网络模式下,Ray 可能识别到容器内网 IP,导致其他机器无法访问,因此生产部署建议显式设置: + +```bash +-e RAY_NODE_IP_ADDRESS=当前宿主机IP +``` + +6. 生产环境建议固定 Ray 内部端口和 Worker 端口范围,便于配置防火墙、安全组和网络策略。 + +7. 使用 GPU 时,宿主机必须安装 NVIDIA Driver 和 NVIDIA Container Toolkit。 + +8. 如果 Ray Dashboard 无法访问,请确认启动参数中包含: + +```bash +-e RAY_DASHBOARD_HOST=0.0.0.0 +``` + +9. 如果 Worker 节点无法加入集群,请优先检查: + - Head 节点 IP 是否正确 + - `RAY_HEAD_ADDRESS` 是否正确 + - 防火墙是否放通 `6379` + - 是否使用了 `--network=host` + - `RAY_NODE_IP_ADDRESS` 是否设置为宿主机可访问 IP + +--- + +## 常见命令 + +查看 Ray 状态: + +```bash +ray status +``` + +查看 Ray 任务: + +```bash +ray list tasks +``` + +查看 Ray 节点: + +```bash +ray list nodes +``` + +停止 Ray: + +```bash +ray stop --force +``` + +查看容器日志: + +```bash +docker logs -f oc9-ray-head +docker logs -f oc9-ray-worker-1 +``` + +删除容器: + +```bash +docker rm -f oc9-ray-head oc9-ray-worker-1 oc9-ray-worker-2 +``` \ No newline at end of file diff --git a/frameworks/Ray/2.54.1/README.md b/frameworks/Ray/2.54.1/README.md new file mode 100644 index 0000000..0f994fc --- /dev/null +++ b/frameworks/Ray/2.54.1/README.md @@ -0,0 +1,409 @@ +# Ray 2.55.1 + Torch 2.11.0 on OpenCloudOS 9 + +## 基本信息 + +- **Ray 版本**:v2.54.1 +- **Torch 版本**:v2.11.0 +- **TorchVision 版本**:v0.26.0 +- **TorchAudio 版本**:v2.11.0 +- **基础镜像**:opencloudos/opencloudos9-cuda-devel:12.8 +- **Python 版本**:3.11 +- **CUDA 版本**:12.8 +- **GPU 支持**:NVIDIA GPU / CUDA +- **Ray 安装组件**:ray[all,client,serve-grpc] + +## 适用场景 + +- Ray 单机任务调度 +- Ray 多机集群任务调度 +- Ray Data 数据处理 +- Ray Train 分布式训练 +- Ray Tune 参数调优 +- Ray Serve 模型服务 +- Ray RLlib 强化学习 +- PyTorch GPU 训练 / 推理 + +--- + +## 构建 + +```bash +docker build -t oc9-ray:2.54.1 . +``` + +也可以通过构建参数指定 Ray 和 Torch 版本: + +```bash +docker build \ + --build-arg RAY_VERSION=2.54.1 \ + --build-arg TORCH_VERSION=2.11.0 \ + --build-arg TORCHVISION_VERSION=0.26.0 \ + --build-arg TORCHAUDIO_VERSION=2.11.0 \ + -t oc9-ray:2.55.1 . +``` + +--- + +## 镜像启动命令 + +### 单机模式启动 + +单机模式会在当前容器内启动一个 Ray Head 节点,适合本地开发、单机测试、单机 GPU 任务。 + +```bash +docker run -d \ + --gpus all \ + --shm-size=8g \ + --name oc9-ray-single \ + -e RAY_NODE_TYPE=single \ + -p 6379:6379 \ + -p 8265:8265 \ + -p 10001:10001 \ + -p 8000:8000 \ + oc9-ray:2.54.1 +``` + +查看 Ray Dashboard: + +```text +http://宿主机IP:8265 +``` + +进入容器: + +```bash +docker exec -it oc9-ray-single bash +``` + +查看 Ray 集群状态: + +```bash +ray status +``` + +--- + +### 多机集群模式启动 + +Ray 多机集群由一个 Head 节点和多个 Worker 节点组成。 + +推荐使用 `--network=host`,避免 Docker bridge 网络导致 Ray 节点之间无法互相访问。 + +假设机器 IP 如下: + +| 角色 | IP | +|---|---| +| Head 节点 | 192.168.1.10 | +| Worker 节点 1 | 192.168.1.11 | +| Worker 节点 2 | 192.168.1.12 | + +--- + +#### 启动 Head 节点 + +在 `192.168.1.10` 上执行: + +```bash +docker run -d \ + --gpus all \ + --network=host \ + --shm-size=8g \ + --name oc9-ray-head \ + -e RAY_NODE_TYPE=head \ + -e RAY_NODE_IP_ADDRESS=192.168.1.10 \ + -e RAY_NUM_GPUS=1 \ + oc9-ray:2.54.1 +``` + +查看 Head 节点日志: + +```bash +docker logs -f oc9-ray-head +``` + +查看 Dashboard: + +```text +http://192.168.1.10:8265 +``` + +--- + +#### 启动 Worker 节点 + +在 `192.168.1.11` 上执行: + +```bash +docker run -d \ + --gpus all \ + --network=host \ + --shm-size=8g \ + --name oc9-ray-worker-1 \ + -e RAY_NODE_TYPE=worker \ + -e RAY_HEAD_ADDRESS=192.168.1.10:6379 \ + -e RAY_NODE_IP_ADDRESS=192.168.1.11 \ + -e RAY_NUM_GPUS=1 \ + oc9-ray:2.54.1 +``` + +在 `192.168.1.12` 上执行: + +```bash +docker run -d \ + --gpus all \ + --network=host \ + --shm-size=8g \ + --name oc9-ray-worker-2 \ + -e RAY_NODE_TYPE=worker \ + -e RAY_HEAD_ADDRESS=192.168.1.10:6379 \ + -e RAY_NODE_IP_ADDRESS=192.168.1.12 \ + -e RAY_NUM_GPUS=1 \ + oc9-ray:2.54.1 +``` + +查看 Worker 节点日志: + +```bash +docker logs -f oc9-ray-worker-1 +docker logs -f oc9-ray-worker-2 +``` + +--- + +## 镜像测试命令 + +### 单机完整测试 + +```bash +docker run --rm \ + --gpus all \ + --shm-size=8g \ + -e RAY_NODE_TYPE=test \ + oc9-ray:2.54.1 \ + python3 test_ray.py --full --require-gpu +``` + +### 在已有 Head 节点中测试 + +```bash +docker exec -it oc9-ray-head bash +python3 /home/test_ray.py --address auto --full --require-gpu +``` + +### 通过 Ray Client 连接测试 + +```bash +python3 test_ray.py --address ray://192.168.1.10:10001 --full --require-gpu +``` + +--- + +## 常用端口 + +| 端口 | 说明 | +|---:|---| +| 6379 | Ray Head / GCS 端口 | +| 8265 | Ray Dashboard 端口 | +| 10001 | Ray Client 端口 | +| 8000 | Ray Serve HTTP 端口 | +| 8076 | Ray Node Manager 端口 | +| 8077 | Ray Object Manager 端口 | +| 8078 | Ray Runtime Env Agent 端口 | +| 8079 | Ray Dashboard Agent gRPC 端口 | +| 8080 | Ray Dashboard Agent HTTP 端口 | +| 8081 | Ray Metrics Export 端口 | +| 10002-10100 | Ray Worker 进程端口范围 | + +--- + +## 环境变量说明 + +| 环境变量 | 默认值 | 示例 | 说明 | +|---|---|---|---| +| `RAY_NODE_TYPE` | `single` | `head` / `worker` / `single` / `test` | 容器启动模式。`head` 表示 Head 节点,`worker` 表示 Worker 节点,`single` 表示单机模式,`test` 表示执行测试脚本 | +| `RAY_HEAD_ADDRESS` | 空 | `192.168.1.10:6379` | Worker 节点连接 Head 节点的地址。`RAY_NODE_TYPE=worker` 时必填 | +| `RAY_NODE_IP_ADDRESS` | 自动获取 | `192.168.1.11` | 当前节点对其他 Ray 节点可访问的 IP。多机部署时建议显式指定 | +| `RAY_HEAD_PORT` | `6379` | `6379` | Ray Head / GCS 监听端口 | +| `RAY_DASHBOARD_HOST` | `0.0.0.0` | `0.0.0.0` | Ray Dashboard 监听地址。容器中建议设置为 `0.0.0.0` | +| `RAY_DASHBOARD_PORT` | `8265` | `8265` | Ray Dashboard 端口 | +| `RAY_CLIENT_SERVER_PORT` | `10001` | `10001` | Ray Client 连接端口 | +| `RAY_SERVE_HTTP_PORT` | `8000` | `8000` | Ray Serve HTTP 服务端口 | +| `RAY_NODE_MANAGER_PORT` | `8076` | `8076` | Ray Node Manager 固定端口 | +| `RAY_OBJECT_MANAGER_PORT` | `8077` | `8077` | Ray Object Manager 固定端口 | +| `RAY_RUNTIME_ENV_AGENT_PORT` | `8078` | `8078` | Ray Runtime Env Agent 固定端口 | +| `RAY_DASHBOARD_AGENT_GRPC_PORT` | `8079` | `8079` | Ray Dashboard Agent gRPC 端口 | +| `RAY_DASHBOARD_AGENT_LISTEN_PORT` | `8080` | `8080` | Ray Dashboard Agent HTTP 端口 | +| `RAY_METRICS_EXPORT_PORT` | `8081` | `8081` | Ray Metrics 指标暴露端口 | +| `RAY_MIN_WORKER_PORT` | `10002` | `10002` | Ray Worker 进程端口范围下限 | +| `RAY_MAX_WORKER_PORT` | `10100` | `10100` | Ray Worker 进程端口范围上限 | +| `RAY_NUM_CPUS` | 自动检测 | `20` | 手动指定当前节点可用 CPU 数量 | +| `RAY_NUM_GPUS` | 自动检测 | `1` | 手动指定当前节点可用 GPU 数量 | +| `RAY_OBJECT_STORE_MEMORY` | 自动计算 | `8589934592` | Ray Object Store 内存大小,单位为 bytes | +| `RAY_RESOURCES` | 空 | `'{"worker": 1}'` | 自定义 Ray 资源标签 | +| `RAY_TEMP_DIR` | `/tmp/ray` | `/tmp/ray` | Ray 临时文件目录 | +| `RAY_DISABLE_USAGE_STATS` | `1` | `1` | 禁用 Ray 使用统计上报 | +| `NVIDIA_VISIBLE_DEVICES` | `all` | `all` / `0` / `0,1` | 指定容器可见 GPU | +| `NVIDIA_DRIVER_CAPABILITIES` | `compute,utility` | `compute,utility` | NVIDIA 容器运行能力,GPU 计算通常需要 `compute`,`nvidia-smi` 需要 `utility` | + +--- + +## 多机协同任务测试 + +进入 Head 容器: + +```bash +docker exec -it oc9-ray-head bash +``` + +执行: + +```bash +python3 - <<'PY' +import socket +import ray + +ray.init(address="auto") + +@ray.remote +def task(i): + return { + "task": i, + "host": socket.gethostname(), + "node_id": ray.get_runtime_context().get_node_id(), + } + +refs = [task.remote(i) for i in range(50)] +results = ray.get(refs) + +for item in results[:20]: + print(item) + +print("cluster_resources:", ray.cluster_resources()) +PY +``` + +如果 Worker 节点加入成功,`cluster_resources` 中会显示多台节点的 CPU / GPU 资源。 + +--- + +## GPU 验证 + +```bash +docker run --rm \ + --gpus all \ + --shm-size=8g \ + oc9-ray:2.55.1 \ + bash -c "nvidia-smi && python3 -c 'import torch; print(torch.__version__); print(torch.cuda.is_available()); print(torch.cuda.get_device_name(0))'" +``` + +预期输出中应包含: + +```text +True +NVIDIA ... +``` + +--- + +## 版本检查 + +进入容器后执行: + +```bash +python3 - <<'PY' +import ray +import torch + +print("Ray:", ray.__version__) +print("Torch:", torch.__version__) +print("Torch CUDA:", torch.version.cuda) +print("CUDA available:", torch.cuda.is_available()) + +if torch.cuda.is_available(): + print("GPU:", torch.cuda.get_device_name(0)) +PY +``` + +--- + +## 注意事项 + +1. Docker 默认 `/dev/shm` 只有 64MB,Ray Object Store 会受到影响,建议启动容器时增加: + +```bash +--shm-size=8g +``` + +2. 多机部署建议使用: + +```bash +--network=host +``` + +3. Head 和 Worker 节点的 Ray 版本、Python 版本、CUDA 版本、PyTorch 版本应保持一致。 + +4. Worker 节点必须能访问 Head 节点的 `6379` 端口。 + +5. 多机 Docker bridge 网络模式下,Ray 可能识别到容器内网 IP,导致其他机器无法访问,因此生产部署建议显式设置: + +```bash +-e RAY_NODE_IP_ADDRESS=当前宿主机IP +``` + +6. 生产环境建议固定 Ray 内部端口和 Worker 端口范围,便于配置防火墙、安全组和网络策略。 + +7. 使用 GPU 时,宿主机必须安装 NVIDIA Driver 和 NVIDIA Container Toolkit。 + +8. 如果 Ray Dashboard 无法访问,请确认启动参数中包含: + +```bash +-e RAY_DASHBOARD_HOST=0.0.0.0 +``` + +9. 如果 Worker 节点无法加入集群,请优先检查: + - Head 节点 IP 是否正确 + - `RAY_HEAD_ADDRESS` 是否正确 + - 防火墙是否放通 `6379` + - 是否使用了 `--network=host` + - `RAY_NODE_IP_ADDRESS` 是否设置为宿主机可访问 IP + +--- + +## 常见命令 + +查看 Ray 状态: + +```bash +ray status +``` + +查看 Ray 任务: + +```bash +ray list tasks +``` + +查看 Ray 节点: + +```bash +ray list nodes +``` + +停止 Ray: + +```bash +ray stop --force +``` + +查看容器日志: + +```bash +docker logs -f oc9-ray-head +docker logs -f oc9-ray-worker-1 +``` + +删除容器: + +```bash +docker rm -f oc9-ray-head oc9-ray-worker-1 oc9-ray-worker-2 +``` \ No newline at end of file -- Gitee From 9853ef1681abc6a7fde302c8150520207e7763b3 Mon Sep 17 00:00:00 2001 From: yuchengen Date: Wed, 6 May 2026 18:02:21 +0800 Subject: [PATCH 3/7] feat: add Ray v2.54.0/v2.54.1 container image for OC9 --- frameworks/Ray/2.54.0/test.sh | 19 +++++++++++++++++++ frameworks/Ray/2.54.1/test.sh | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 frameworks/Ray/2.54.0/test.sh create mode 100644 frameworks/Ray/2.54.1/test.sh diff --git a/frameworks/Ray/2.54.0/test.sh b/frameworks/Ray/2.54.0/test.sh new file mode 100644 index 0000000..ba69ac3 --- /dev/null +++ b/frameworks/Ray/2.54.0/test.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -e + +IMAGE="${1:-}" + +if [ -z "${IMAGE}" ]; then + echo "用法: bash test.sh <镜像名:标签>" + exit 1 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "✗ 未找到 docker" + exit 1 +fi + +echo "=== PyTorch 容器基础功能测试 ===" +echo "测试镜像: ${IMAGE}" + +docker run --rm --gpus all --entrypoint python3 "${IMAGE}" /home/test_ray.py --full --require-gpu \ No newline at end of file diff --git a/frameworks/Ray/2.54.1/test.sh b/frameworks/Ray/2.54.1/test.sh new file mode 100644 index 0000000..ba69ac3 --- /dev/null +++ b/frameworks/Ray/2.54.1/test.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -e + +IMAGE="${1:-}" + +if [ -z "${IMAGE}" ]; then + echo "用法: bash test.sh <镜像名:标签>" + exit 1 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "✗ 未找到 docker" + exit 1 +fi + +echo "=== PyTorch 容器基础功能测试 ===" +echo "测试镜像: ${IMAGE}" + +docker run --rm --gpus all --entrypoint python3 "${IMAGE}" /home/test_ray.py --full --require-gpu \ No newline at end of file -- Gitee From 6538c5edf5b579197d2ffec35ca60d3ee5c85900 Mon Sep 17 00:00:00 2001 From: yuchengen Date: Wed, 6 May 2026 18:05:00 +0800 Subject: [PATCH 4/7] feat: add Ray v2.54.0/v2.54.1 container image for OC9 --- frameworks/Ray/2.54.0/test.sh | 2 +- frameworks/Ray/2.54.1/test.sh | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frameworks/Ray/2.54.0/test.sh b/frameworks/Ray/2.54.0/test.sh index ba69ac3..8e5feb7 100644 --- a/frameworks/Ray/2.54.0/test.sh +++ b/frameworks/Ray/2.54.0/test.sh @@ -13,7 +13,7 @@ if ! command -v docker >/dev/null 2>&1; then exit 1 fi -echo "=== PyTorch 容器基础功能测试 ===" +echo "=== Ray 2.54.0 容器基础功能测试 ===" echo "测试镜像: ${IMAGE}" docker run --rm --gpus all --entrypoint python3 "${IMAGE}" /home/test_ray.py --full --require-gpu \ No newline at end of file diff --git a/frameworks/Ray/2.54.1/test.sh b/frameworks/Ray/2.54.1/test.sh index ba69ac3..8981553 100644 --- a/frameworks/Ray/2.54.1/test.sh +++ b/frameworks/Ray/2.54.1/test.sh @@ -13,7 +13,8 @@ if ! command -v docker >/dev/null 2>&1; then exit 1 fi -echo "=== PyTorch 容器基础功能测试 ===" +echo "=== Ray 2.54.0 容器基础功能测试 ===" + echo "测试镜像: ${IMAGE}" docker run --rm --gpus all --entrypoint python3 "${IMAGE}" /home/test_ray.py --full --require-gpu \ No newline at end of file -- Gitee From b411e6cac70294ac9c4104b6b36169829193ca18 Mon Sep 17 00:00:00 2001 From: yuchengen Date: Wed, 6 May 2026 18:05:14 +0800 Subject: [PATCH 5/7] feat: add Ray v2.54.0/v2.54.1 container image for OC9 --- frameworks/Ray/2.54.1/test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frameworks/Ray/2.54.1/test.sh b/frameworks/Ray/2.54.1/test.sh index 8981553..00bb47c 100644 --- a/frameworks/Ray/2.54.1/test.sh +++ b/frameworks/Ray/2.54.1/test.sh @@ -13,7 +13,7 @@ if ! command -v docker >/dev/null 2>&1; then exit 1 fi -echo "=== Ray 2.54.0 容器基础功能测试 ===" +echo "=== Ray 2.54.1 容器基础功能测试 ===" echo "测试镜像: ${IMAGE}" -- Gitee From d89b0e8afad4b0c980904cc943d91935b8942305 Mon Sep 17 00:00:00 2001 From: yuchengen Date: Thu, 7 May 2026 09:09:00 +0800 Subject: [PATCH 6/7] feat: add Ray v2.54.0/v2.54.1 container image for OC9 --- frameworks/Ray/2.54.0/Dockerfile | 2 +- frameworks/Ray/2.54.1/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frameworks/Ray/2.54.0/Dockerfile b/frameworks/Ray/2.54.0/Dockerfile index 8178da6..641e9ab 100644 --- a/frameworks/Ray/2.54.0/Dockerfile +++ b/frameworks/Ray/2.54.0/Dockerfile @@ -99,5 +99,5 @@ RUN chmod +x /usr/local/bin/start-ray.sh # ========================= EXPOSE 6379 8265 10001 8000 8076 8077 8078 8079 8080 8081 10002-10100 -ENTRYPOINT ["/usr/local/bin/start-ray.sh"] +#ENTRYPOINT ["/usr/local/bin/start-ray.sh"] 在正式merge后,需要将这行注释打开 CMD ["bash"] \ No newline at end of file diff --git a/frameworks/Ray/2.54.1/Dockerfile b/frameworks/Ray/2.54.1/Dockerfile index dad3c76..9db1732 100644 --- a/frameworks/Ray/2.54.1/Dockerfile +++ b/frameworks/Ray/2.54.1/Dockerfile @@ -88,5 +88,5 @@ RUN chmod +x /usr/local/bin/start-ray.sh # ========================= EXPOSE 6379 8265 10001 8000 8076 8077 8078 8079 8080 8081 10002-10100 -ENTRYPOINT ["/usr/local/bin/start-ray.sh"] +#ENTRYPOINT ["/usr/local/bin/start-ray.sh"] 在正式merge后,需要将这行注释打开 CMD ["bash"] \ No newline at end of file -- Gitee From 942557c0228a12256298dd99ff62a8b3607986bf Mon Sep 17 00:00:00 2001 From: yuchengen Date: Mon, 18 May 2026 14:04:33 +0800 Subject: [PATCH 7/7] =?UTF-8?q?feat:=201=E3=80=81=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E8=84=9A=E6=9C=AC,=E5=8F=96=E6=B6=88?= =?UTF-8?q?=E4=BD=BF=E7=94=A8entrypoint,=E4=BD=BF=E7=94=A8CMD=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E8=BF=9B=E8=A1=8C=E5=90=AF=E5=8A=A8=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frameworks/Ray/2.54.0/Dockerfile | 2 +- frameworks/Ray/2.54.0/README.md | 2 +- frameworks/Ray/2.54.1/Dockerfile | 2 +- frameworks/Ray/2.54.1/README.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frameworks/Ray/2.54.0/Dockerfile b/frameworks/Ray/2.54.0/Dockerfile index 641e9ab..e639344 100644 --- a/frameworks/Ray/2.54.0/Dockerfile +++ b/frameworks/Ray/2.54.0/Dockerfile @@ -100,4 +100,4 @@ RUN chmod +x /usr/local/bin/start-ray.sh EXPOSE 6379 8265 10001 8000 8076 8077 8078 8079 8080 8081 10002-10100 #ENTRYPOINT ["/usr/local/bin/start-ray.sh"] 在正式merge后,需要将这行注释打开 -CMD ["bash"] \ No newline at end of file +CMD ["/usr/local/bin/start-ray.sh"] \ No newline at end of file diff --git a/frameworks/Ray/2.54.0/README.md b/frameworks/Ray/2.54.0/README.md index 58cc641..d552a79 100644 --- a/frameworks/Ray/2.54.0/README.md +++ b/frameworks/Ray/2.54.0/README.md @@ -60,7 +60,7 @@ docker run -d \ -p 8265:8265 \ -p 10001:10001 \ -p 8000:8000 \ - oc9-ray:2.55.1 + oc9-ray:2.54.0 ``` 查看 Ray Dashboard: diff --git a/frameworks/Ray/2.54.1/Dockerfile b/frameworks/Ray/2.54.1/Dockerfile index 9db1732..d9405b0 100644 --- a/frameworks/Ray/2.54.1/Dockerfile +++ b/frameworks/Ray/2.54.1/Dockerfile @@ -89,4 +89,4 @@ RUN chmod +x /usr/local/bin/start-ray.sh EXPOSE 6379 8265 10001 8000 8076 8077 8078 8079 8080 8081 10002-10100 #ENTRYPOINT ["/usr/local/bin/start-ray.sh"] 在正式merge后,需要将这行注释打开 -CMD ["bash"] \ No newline at end of file +CMD ["/usr/local/bin/start-ray.sh"] \ No newline at end of file diff --git a/frameworks/Ray/2.54.1/README.md b/frameworks/Ray/2.54.1/README.md index 0f994fc..7bbf644 100644 --- a/frameworks/Ray/2.54.1/README.md +++ b/frameworks/Ray/2.54.1/README.md @@ -39,7 +39,7 @@ docker build \ --build-arg TORCH_VERSION=2.11.0 \ --build-arg TORCHVISION_VERSION=0.26.0 \ --build-arg TORCHAUDIO_VERSION=2.11.0 \ - -t oc9-ray:2.55.1 . + -t oc9-ray:2.54.1 . ``` --- -- Gitee