Signed-off-by: Meng <meng_chen99@163.com> Signed-off-by: Yangshen Deng <yangshen.d@outlook.com> Signed-off-by: Suli Wang <50828270+wsl2000@users.noreply.github.com> Co-authored-by: Meng <meng_chen99@163.com> Co-authored-by: Suli Wang <50828270+wsl2000@users.noreply.github.com> Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
359 lines
12 KiB
Python
359 lines
12 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
|
|
"""Formal OpenPI end-to-end parity: upstream DreamZero server vs `vllm serve`.
|
|
|
|
This test uses DreamZero's own client-side observation builders from
|
|
`${DREAMZERO_REPO}/test_client_AR.py`, and client-side websocket protocol from
|
|
`${DREAMZERO_REPO}/eval_utils/policy_client.py`.
|
|
|
|
The only client-side adaptation for vLLM is the websocket path:
|
|
DreamZero's upstream server serves at `/`, while vLLM serves OpenPI at
|
|
`/v1/realtime/robot/openpi`.
|
|
|
|
Current scope for this test:
|
|
- default two-GPU run (`nproc_per_node=2` on upstream, `--cfg-parallel-size 2` on `vllm serve`)
|
|
- non-`torch.compile` (upstream launched through
|
|
`upstream_socket_server_no_compile.py`, vLLM with `--enforce-eager`)
|
|
- non-DiT-cache / non-skip-schedule (`NUM_DIT_STEPS=16`)
|
|
|
|
Serving contract locked by this test:
|
|
- upstream DreamZero still boots from the local checkpoint directory
|
|
- vLLM boots from the official DreamZero HF repo name (`GEAR-Dreams/DreamZero-DROID`)
|
|
rather than a prepared local bundle path
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pytest
|
|
import torch
|
|
|
|
from tests.helpers.runtime import get_open_port
|
|
|
|
msgpack_numpy = pytest.importorskip("openpi_client.msgpack_numpy")
|
|
|
|
_DREAMZERO_REPO_ENV = os.environ.get("DREAMZERO_REPO")
|
|
DREAMZERO_REPO = Path(_DREAMZERO_REPO_ENV).expanduser() if _DREAMZERO_REPO_ENV else None
|
|
if DREAMZERO_REPO is not None and str(DREAMZERO_REPO) not in sys.path:
|
|
sys.path.insert(0, str(DREAMZERO_REPO))
|
|
|
|
try:
|
|
import test_client_AR as dreamzero_client
|
|
from eval_utils.policy_client import WebsocketClientPolicy
|
|
except Exception: # pragma: no cover - guarded by pytest skip below
|
|
dreamzero_client = None
|
|
WebsocketClientPolicy = None
|
|
_BaseWebsocketClientPolicy = WebsocketClientPolicy if WebsocketClientPolicy is not None else object
|
|
|
|
CHECKPOINT_DIR = DREAMZERO_REPO / "checkpoints" / "dreamzero" if DREAMZERO_REPO is not None else None
|
|
VLLM_MODEL = os.environ.get("VLLM_DREAMZERO_MODEL", "GEAR-Dreams/DreamZero-DROID")
|
|
SERVICE_READY_TIMEOUT_S = int(os.environ.get("OPENPI_SERVICE_READY_TIMEOUT_S", "900"))
|
|
PROMPT = "Move the pan forward and use the brush in the middle of the plates to brush the inside of the pan"
|
|
SESSION_ID = "openpi-e2e-parity-session"
|
|
|
|
pytestmark = [
|
|
pytest.mark.skipif(not torch.cuda.is_available(), reason="GPU required"),
|
|
pytest.mark.skipif(
|
|
dreamzero_client is None or WebsocketClientPolicy is None,
|
|
reason="DreamZero client modules are required on PYTHONPATH",
|
|
),
|
|
pytest.mark.skipif(
|
|
DREAMZERO_REPO is None or not DREAMZERO_REPO.exists(),
|
|
reason="DreamZero source repo is required at DREAMZERO_REPO",
|
|
),
|
|
pytest.mark.skipif(
|
|
CHECKPOINT_DIR is None or not CHECKPOINT_DIR.exists(), reason="DreamZero local checkpoint is required"
|
|
),
|
|
]
|
|
|
|
|
|
class OpenPIWebsocketClientPolicy(_BaseWebsocketClientPolicy):
|
|
"""DreamZero client protocol with an OpenPI websocket path suffix."""
|
|
|
|
def __init__(
|
|
self,
|
|
host: str = "127.0.0.1",
|
|
port: int = 8000,
|
|
path: str = "/v1/realtime/robot/openpi",
|
|
) -> None:
|
|
self._uri = f"ws://{host}:{port}{path}"
|
|
self._packer = msgpack_numpy.Packer()
|
|
self._ws, self._server_metadata = self._wait_for_server()
|
|
|
|
|
|
def _vllm_executable() -> str:
|
|
fallback = Path(sys.executable).with_name("vllm")
|
|
if fallback.exists():
|
|
return str(fallback)
|
|
exe = shutil.which("vllm")
|
|
if exe:
|
|
return exe
|
|
raise FileNotFoundError("Unable to locate `vllm` executable in current environment.")
|
|
|
|
|
|
def _cfg_parallel_size() -> int:
|
|
return int(os.environ.get("OPENPI_E2E_CFG_PARALLEL_SIZE", "2"))
|
|
|
|
|
|
def _pick_test_gpus() -> list[str]:
|
|
cfg_parallel_size = _cfg_parallel_size()
|
|
override = os.environ.get("OPENPI_E2E_GPUS") or os.environ.get("OPENPI_E2E_GPU")
|
|
if override is not None:
|
|
gpus = [part.strip() for part in override.split(",") if part.strip()]
|
|
if not gpus:
|
|
raise ValueError("OPENPI_E2E_GPUS is set but empty.")
|
|
if len(gpus) < cfg_parallel_size:
|
|
raise RuntimeError(f"Need {cfg_parallel_size} GPUs, but OPENPI_E2E_GPUS only provided {gpus}.")
|
|
return gpus
|
|
|
|
query = subprocess.check_output(
|
|
[
|
|
"nvidia-smi",
|
|
"--query-gpu=index,memory.used",
|
|
"--format=csv,noheader,nounits",
|
|
],
|
|
text=True,
|
|
)
|
|
gpu_rows = []
|
|
for line in query.strip().splitlines():
|
|
gpu_index, used_mb = [part.strip() for part in line.split(",", maxsplit=1)]
|
|
gpu_rows.append((int(used_mb), gpu_index))
|
|
gpu_rows.sort()
|
|
gpus = [gpu_index for _, gpu_index in gpu_rows[: max(cfg_parallel_size, 1)]]
|
|
if len(gpus) < cfg_parallel_size:
|
|
raise RuntimeError(
|
|
f"Need {cfg_parallel_size} GPUs for cfg_parallel_size={cfg_parallel_size}, "
|
|
f"but found only {len(gpus)} candidates."
|
|
)
|
|
return gpus
|
|
|
|
|
|
def _torchrun_argv(script: str, port: int) -> list[str]:
|
|
return [
|
|
sys.executable,
|
|
"-m",
|
|
"torch.distributed.run",
|
|
"--standalone",
|
|
"--nproc_per_node",
|
|
str(_cfg_parallel_size()),
|
|
script,
|
|
"--port",
|
|
str(port),
|
|
"--model_path",
|
|
str(CHECKPOINT_DIR),
|
|
]
|
|
|
|
|
|
def _run_upstream_service(port: int, log_path: Path) -> subprocess.Popen[str]:
|
|
env = os.environ.copy()
|
|
env.setdefault("PYTHONPATH", "")
|
|
env["PYTHONPATH"] = f"{Path.cwd()}:{DREAMZERO_REPO}:{env['PYTHONPATH']}".rstrip(":")
|
|
env["CUDA_VISIBLE_DEVICES"] = ",".join(_pick_test_gpus())
|
|
env.setdefault("NO_ALBUMENTATIONS_UPDATE", "1")
|
|
env["ATTENTION_BACKEND"] = "torch"
|
|
env.setdefault("ENABLE_TENSORRT", "false")
|
|
env["ENABLE_DIT_CACHE"] = "false"
|
|
env["NUM_DIT_STEPS"] = "16"
|
|
env["DYNAMIC_CACHE_SCHEDULE"] = "false"
|
|
argv = _torchrun_argv(
|
|
str(Path("tests/dreamzero/upstream/upstream_socket_server_no_compile.py")),
|
|
port,
|
|
)
|
|
log_file = log_path.open("w")
|
|
proc = subprocess.Popen(
|
|
argv,
|
|
stdout=log_file,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
cwd=str(Path.cwd()),
|
|
env=env,
|
|
)
|
|
proc._codex_log_file = log_file # type: ignore[attr-defined]
|
|
return proc
|
|
|
|
|
|
def _run_vllm_service(port: int, log_path: Path) -> subprocess.Popen[str]:
|
|
env = os.environ.copy()
|
|
gpus = _pick_test_gpus()
|
|
cfg_parallel_size = _cfg_parallel_size()
|
|
if cfg_parallel_size > len(gpus):
|
|
raise RuntimeError(
|
|
f"cfg_parallel_size={cfg_parallel_size} requires at least {cfg_parallel_size} GPUs, but only got {gpus}."
|
|
)
|
|
env["CUDA_VISIBLE_DEVICES"] = ",".join(gpus[:cfg_parallel_size])
|
|
env.setdefault("ATTENTION_BACKEND", "torch")
|
|
env.setdefault("DIFFUSION_ATTENTION_BACKEND", "TORCH_SDPA")
|
|
env.setdefault("MASTER_PORT", str(get_open_port()))
|
|
argv = [
|
|
_vllm_executable(),
|
|
"serve",
|
|
VLLM_MODEL,
|
|
"--omni",
|
|
"--host",
|
|
"127.0.0.1",
|
|
"--port",
|
|
str(port),
|
|
"--served-model-name",
|
|
"dreamzero-droid",
|
|
"--enforce-eager",
|
|
]
|
|
if cfg_parallel_size > 1:
|
|
argv.extend(["--cfg-parallel-size", str(cfg_parallel_size)])
|
|
log_file = log_path.open("w")
|
|
proc = subprocess.Popen(
|
|
argv,
|
|
stdout=log_file,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
env=env,
|
|
cwd=str(Path.cwd()),
|
|
)
|
|
proc._codex_log_file = log_file # type: ignore[attr-defined]
|
|
return proc
|
|
|
|
|
|
def _stop_process(proc: subprocess.Popen[str]) -> None:
|
|
log_file = getattr(proc, "_codex_log_file", None)
|
|
if proc.poll() is None:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=30)
|
|
except subprocess.TimeoutExpired: # pragma: no cover - cleanup path
|
|
proc.kill()
|
|
proc.wait(timeout=10)
|
|
if log_file is not None:
|
|
log_file.close()
|
|
|
|
|
|
def _build_obs_sequence() -> tuple[dict, dict]:
|
|
camera_frames = dreamzero_client.load_camera_frames()
|
|
chunks = dreamzero_client.build_frame_schedule(
|
|
min(v.shape[0] for v in camera_frames.values()),
|
|
1,
|
|
)
|
|
obs0 = dreamzero_client._make_obs_from_video(camera_frames, [0], PROMPT, SESSION_ID)
|
|
obs1 = dreamzero_client._make_obs_from_video(camera_frames, chunks[0], PROMPT, SESSION_ID)
|
|
return obs0, obs1
|
|
|
|
|
|
def _wait_for_client_ready(client_factory, timeout_s: float, proc=None, log_path: Path | None = None):
|
|
deadline = time.time() + timeout_s
|
|
last_err: Exception | None = None
|
|
while time.time() < deadline:
|
|
if proc is not None and proc.poll() is not None:
|
|
details = ""
|
|
if log_path is not None and log_path.exists():
|
|
details = log_path.read_text(errors="replace")[-8000:]
|
|
raise RuntimeError(f"Service exited before becoming ready with code {proc.returncode}.\n{details}")
|
|
try:
|
|
return client_factory()
|
|
except Exception as exc: # pragma: no cover - retry path
|
|
last_err = exc
|
|
time.sleep(1)
|
|
raise TimeoutError(f"Timed out waiting for websocket service: {last_err}")
|
|
|
|
|
|
def _collect_outputs_with_client(client) -> tuple[dict, list[np.ndarray]]:
|
|
metadata = client.get_server_metadata()
|
|
obs0, obs1 = _build_obs_sequence()
|
|
outputs = [
|
|
client.infer(dict(obs0)),
|
|
client.infer(dict(obs1)),
|
|
]
|
|
assert _normalize_reset_response(client.reset({})) == "reset successful"
|
|
outputs.append(client.infer(dict(obs0)))
|
|
client._ws.close()
|
|
return metadata, outputs
|
|
|
|
|
|
def _normalize_reset_response(response) -> str:
|
|
if isinstance(response, str):
|
|
return response
|
|
decoded = msgpack_numpy.unpackb(response)
|
|
if isinstance(decoded, dict):
|
|
return str(decoded.get("status"))
|
|
return str(decoded)
|
|
|
|
|
|
def _normalize_metadata(metadata: dict) -> dict:
|
|
normalized = dict(metadata)
|
|
if isinstance(normalized.get("image_resolution"), tuple):
|
|
normalized["image_resolution"] = list(normalized["image_resolution"])
|
|
return normalized
|
|
|
|
|
|
def _assert_logs_clean(log_path: Path) -> None:
|
|
text = log_path.read_text(errors="replace")
|
|
if "SignalException: Process" in text and "got signal: 15" in text:
|
|
text = text.split("Traceback (most recent call last):", 1)[0]
|
|
assert "Traceback" not in text, text
|
|
assert "RuntimeError:" not in text, text
|
|
|
|
|
|
def _assert_upstream_log_matches_vllm_baseline(log_path: Path) -> None:
|
|
text = log_path.read_text(errors="replace")
|
|
assert "DIT Compute Steps 8 steps" not in text, text
|
|
assert "DIT Compute Steps 16 steps" in text, text
|
|
|
|
|
|
def test_openpi_service_matches_upstream_server_noncompile(tmp_path: Path) -> None:
|
|
expected_metadata = {
|
|
"image_resolution": [180, 320],
|
|
"n_external_cameras": 2,
|
|
"needs_wrist_camera": True,
|
|
"needs_stereo_camera": False,
|
|
"needs_session_id": True,
|
|
"action_space": "joint_position",
|
|
}
|
|
|
|
upstream_port = get_open_port()
|
|
upstream_log = tmp_path / "dreamzero_upstream.log"
|
|
upstream_proc = _run_upstream_service(upstream_port, upstream_log)
|
|
try:
|
|
upstream_client = _wait_for_client_ready(
|
|
lambda: WebsocketClientPolicy(host="127.0.0.1", port=upstream_port),
|
|
timeout_s=SERVICE_READY_TIMEOUT_S,
|
|
proc=upstream_proc,
|
|
log_path=upstream_log,
|
|
)
|
|
upstream_metadata, upstream_outputs = _collect_outputs_with_client(upstream_client)
|
|
finally:
|
|
_stop_process(upstream_proc)
|
|
_assert_logs_clean(upstream_log)
|
|
_assert_upstream_log_matches_vllm_baseline(upstream_log)
|
|
|
|
vllm_port = get_open_port()
|
|
vllm_log = tmp_path / "vllm_openpi.log"
|
|
vllm_proc = _run_vllm_service(vllm_port, vllm_log)
|
|
try:
|
|
vllm_client = _wait_for_client_ready(
|
|
lambda: OpenPIWebsocketClientPolicy(host="127.0.0.1", port=vllm_port),
|
|
timeout_s=SERVICE_READY_TIMEOUT_S,
|
|
proc=vllm_proc,
|
|
log_path=vllm_log,
|
|
)
|
|
vllm_metadata, vllm_outputs = _collect_outputs_with_client(vllm_client)
|
|
finally:
|
|
_stop_process(vllm_proc)
|
|
_assert_logs_clean(vllm_log)
|
|
|
|
assert _normalize_metadata(upstream_metadata) == expected_metadata
|
|
assert _normalize_metadata(vllm_metadata) == expected_metadata
|
|
|
|
for idx, (actual, expected) in enumerate(zip(vllm_outputs, upstream_outputs, strict=True)):
|
|
np.testing.assert_allclose(
|
|
actual,
|
|
expected,
|
|
rtol=1e-2,
|
|
atol=1e-3,
|
|
err_msg=f"OpenPI step {idx} output mismatch",
|
|
)
|