File size: 20,538 Bytes
912284f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 | #!/usr/bin/env python3
"""Reward server for GRPO garment training.
Runs on one or more GPUs and serves garment simulation reward over HTTP.
The GRPO trainer sends *_specification.json content + sample_id.
This server does:
1. Warp physics simulation (parallel across configured GPUs)
2. Find GT mesh from GarmentCodeData
3. Compute chamfer distance
4. Return reward scalar (4-tier: parse=H20 / spec=H20 / sim / chamfer)
Usage:
python sim_scripts/reward_server.py \
--port 59876 \
--garmentcode-root /filesdir/GarmentCode \
--gcd-root /filesdir/GarmentCodeData_v2 \
--gpus 3 \
--work-dir /tmp/grpo_reward_work
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import shutil
import subprocess
import sys
import threading
import time
import uuid
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
from typing import Any, Callable, Optional
import numpy as np
import yaml
try:
import torch
except Exception:
torch = None
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Reward tiers (must match H20 side grpo_config.py)
# ---------------------------------------------------------------------------
@dataclass
class RewardTiers:
r_spec: float = 0.2 # spec ok but sim fail
r_sim_base: float = 0.3 # sim ok, no chamfer available
chamfer_max: float = 0.05 # normalization cap (meters)
# ---------------------------------------------------------------------------
# Mesh I/O + Chamfer distance
# ---------------------------------------------------------------------------
def _load_obj_vertices(obj_path: Path) -> np.ndarray:
"""Read vertex positions from OBJ file -> (N, 3) float64 array."""
verts = []
with open(obj_path, "r") as f:
for line in f:
if line.startswith("v "):
parts = line.strip().split()
verts.append([float(parts[1]), float(parts[2]), float(parts[3])])
return np.array(verts, dtype=np.float64)
def _load_ply_vertices(path: Path) -> np.ndarray:
"""Read vertex positions from PLY file (binary or ASCII) -> (N, 3) float64 array."""
from plyfile import PlyData
plydata = PlyData.read(str(path))
x = np.array(plydata["vertex"]["x"], dtype=np.float64)
y = np.array(plydata["vertex"]["y"], dtype=np.float64)
z = np.array(plydata["vertex"]["z"], dtype=np.float64)
return np.stack([x, y, z], axis=1)
def _load_mesh_vertices(path: Path) -> np.ndarray:
if path.suffix.lower() == ".ply":
return _load_ply_vertices(path)
return _load_obj_vertices(path)
@lru_cache(maxsize=4096)
def _load_mesh_vertices_cached(path_str: str) -> np.ndarray:
"""Cache mesh vertices to avoid repeated disk I/O for GT samples."""
return _load_mesh_vertices(Path(path_str))
def _directed_chamfer(source: np.ndarray, target: np.ndarray, batch_size: int = 4096) -> float:
n = source.shape[0]
min_dists = np.empty(n, dtype=np.float64)
for start in range(0, n, batch_size):
end = min(start + batch_size, n)
diff = source[start:end, None, :] - target[None, :, :]
min_dists[start:end] = (diff * diff).sum(axis=-1).min(axis=1)
return float(np.sqrt(min_dists).mean())
def _directed_chamfer_torch(
source: np.ndarray,
target: np.ndarray,
device: "torch.device",
batch_size: int = 4096,
) -> float:
"""Directed chamfer on GPU using torch.cdist."""
src = torch.from_numpy(source).to(device=device, dtype=torch.float32)
tgt = torch.from_numpy(target).to(device=device, dtype=torch.float32)
total = 0.0
count = 0
with torch.no_grad():
for start in range(0, src.shape[0], batch_size):
chunk = src[start:start + batch_size]
dists = torch.cdist(chunk, tgt, p=2)
mins = dists.min(dim=1).values
total += float(mins.sum().item())
count += int(mins.numel())
return total / max(count, 1)
def chamfer_distance(pred: np.ndarray, gt: np.ndarray, gpu_id: Optional[int] = None) -> float:
"""Symmetric chamfer distance between two point clouds.
Prefer GPU computation when torch+CUDA are available.
"""
if torch is not None and torch.cuda.is_available():
try:
if gpu_id is None:
device = torch.device("cuda:0")
else:
device = torch.device(f"cuda:{gpu_id}")
return _directed_chamfer_torch(pred, gt, device) + _directed_chamfer_torch(gt, pred, device)
except Exception as e:
logger.warning("GPU chamfer failed, fallback to CPU: %s", e)
return _directed_chamfer(pred, gt) + _directed_chamfer(gt, pred)
def find_gt_sim_mesh(sample_id: str, gcd_root: Path) -> Optional[Path]:
"""Find GT simulated mesh: <gcd_root>/garments_*/default_body/<sample_id>/<sample_id>_sim.{ply,obj}"""
for garments_dir in sorted(gcd_root.iterdir()):
if not garments_dir.is_dir():
continue
sample_dir = garments_dir / "default_body" / sample_id
if not sample_dir.is_dir():
continue
for suffix in (".ply", ".obj"):
mesh = sample_dir / f"{sample_id}_sim{suffix}"
if mesh.exists():
return mesh
return None
def build_gt_mesh_index(gcd_root: Path) -> dict[str, Path]:
index: dict[str, Path] = {}
for garments_dir in sorted(gcd_root.iterdir()):
if not garments_dir.is_dir():
continue
default_body = garments_dir / "default_body"
if not default_body.is_dir():
continue
for sample_dir in default_body.iterdir():
if not sample_dir.is_dir():
continue
sample_id = sample_dir.name
for suffix in (".ply", ".obj"):
mesh = sample_dir / f"{sample_id}_sim{suffix}"
if mesh.exists():
index[sample_id] = mesh
break
return index
# ---------------------------------------------------------------------------
# Simulation
# ---------------------------------------------------------------------------
_FATAL_FAIL_KEYS = (
"crashes", "frame_timeout", "simulation_timeout",
"cloth_body_intersection", "cloth_self_intersection", "static_equilibrium",
)
def check_sim_success(sim_props_path: Path) -> bool:
if not sim_props_path.exists():
return False
try:
props = yaml.safe_load(sim_props_path.read_text())
fails = props["sim"]["stats"]["fails"]
except Exception:
return False
return not any(fails.get(k, []) for k in _FATAL_FAIL_KEYS)
def run_simulation(
spec_path: Path,
garmentcode_root: Path,
sim_config: str,
gpu_id: int,
out_dir: Path,
) -> dict[str, Any]:
"""Run Warp simulation via test_garment_sim.py subprocess."""
name = "sim_result"
cmd = [
sys.executable, "test_garment_sim.py",
"-p", str(spec_path),
"-s", sim_config,
"--out_path", str(out_dir),
"--out_name", name,
"--disable_timestamp",
]
env = os.environ.copy()
env["CUDA_VISIBLE_DEVICES"] = str(gpu_id)
proc = subprocess.run(
cmd, cwd=str(garmentcode_root), env=env,
capture_output=True, text=True, timeout=600,
)
rd = out_dir / name
front = rd / f"{name}_render_front.png"
back = rd / f"{name}_render_back.png"
sim_props = rd / "sim_props.yaml"
sim_mesh = rd / f"{name}_sim.obj"
render_ok = proc.returncode == 0 and front.exists() and back.exists()
physics_ok = check_sim_success(sim_props) if sim_props.exists() else False
return {
"success": render_ok and physics_ok,
"sim_mesh": str(sim_mesh) if sim_mesh.exists() else None,
}
# ---------------------------------------------------------------------------
# Full reward for one spec: simulate + chamfer + score
# ---------------------------------------------------------------------------
def compute_reward_for_spec(
spec_json_content: str,
sample_id: str,
gc_root: Path,
sim_config: str,
gpu_id: int,
work_path: Path,
gcd_root: Path,
tiers: RewardTiers,
keep_work_dir: bool = False,
gt_mesh_lookup: Optional[Callable[[str], Optional[Path]]] = None,
worker_pool: Optional[Any] = None,
sim_timeout_s: int = 30,
) -> dict[str, Any]:
"""Simulate spec, compute chamfer vs GT, return reward."""
result = {
"sample_id": sample_id,
"sim_ok": False,
"chamfer": None,
"reward": tiers.r_spec,
"tier": "sim_fail",
"work_dir": None,
"timing_s": {},
}
t0 = time.perf_counter()
job_dir = work_path / f"simjob_{uuid.uuid4().hex[:8]}"
job_dir.mkdir(parents=True, exist_ok=True)
spec_path = job_dir / "garment_specification.json"
spec_path.write_text(spec_json_content, encoding="utf-8")
try:
t_sim0 = time.perf_counter()
if worker_pool is not None:
sim = worker_pool.submit_and_wait(
str(spec_path), str(job_dir), timeout=max(5, sim_timeout_s + 5),
)
else:
sim = run_simulation(spec_path, gc_root, sim_config, gpu_id, job_dir)
result["timing_s"]["sim"] = round(time.perf_counter() - t_sim0, 4)
except Exception as e:
logger.warning("Simulation error for %s: %s", sample_id, e)
if keep_work_dir:
result["work_dir"] = str(job_dir)
else:
shutil.rmtree(str(job_dir), ignore_errors=True)
result["timing_s"]["total"] = round(time.perf_counter() - t0, 4)
return result
if sim.get("error"):
logger.warning("Worker sim error for %s: %s", sample_id, sim["error"])
if not sim["success"]:
if keep_work_dir:
result["work_dir"] = str(job_dir)
else:
shutil.rmtree(str(job_dir), ignore_errors=True)
result["timing_s"]["total"] = round(time.perf_counter() - t0, 4)
return result
result["sim_ok"] = True
# Chamfer distance vs GT
chamfer = None
if sample_id and sim["sim_mesh"]:
t_lookup0 = time.perf_counter()
if gt_mesh_lookup is not None:
gt_mesh = gt_mesh_lookup(sample_id)
else:
gt_mesh = find_gt_sim_mesh(sample_id, gcd_root)
result["timing_s"]["gt_lookup"] = round(time.perf_counter() - t_lookup0, 4)
if gt_mesh is not None:
try:
t_cd0 = time.perf_counter()
pred_verts = _load_obj_vertices(Path(sim["sim_mesh"]))
gt_verts = _load_mesh_vertices_cached(str(gt_mesh))
chamfer_gpu = sim.get("gpu_id", gpu_id) if worker_pool is not None else gpu_id
chamfer = chamfer_distance(pred_verts, gt_verts, gpu_id=chamfer_gpu)
result["timing_s"]["chamfer"] = round(time.perf_counter() - t_cd0, 4)
except Exception as e:
logger.warning("Chamfer error for %s: %s", sample_id, e)
if keep_work_dir:
result["work_dir"] = str(job_dir)
else:
shutil.rmtree(str(job_dir), ignore_errors=True)
if chamfer is not None:
result["chamfer"] = round(chamfer, 6)
norm = min(chamfer / tiers.chamfer_max, 1.0)
result["reward"] = round(tiers.r_sim_base + (1.0 - tiers.r_sim_base) * (1.0 - norm), 4)
else:
result["reward"] = tiers.r_sim_base
result["tier"] = "sim_ok"
result["timing_s"]["total"] = round(time.perf_counter() - t0, 4)
return result
# ---------------------------------------------------------------------------
# Job tracking (for async batch endpoint)
# ---------------------------------------------------------------------------
@dataclass
class BatchJob:
job_id: str
items: list[dict]
results: list[Optional[dict]] = field(default_factory=list)
done: threading.Event = field(default_factory=threading.Event)
submitted_at: float = 0.0
# ---------------------------------------------------------------------------
# HTTP Server
# ---------------------------------------------------------------------------
def run_server(
port: int,
garmentcode_root: str,
gcd_root: str,
sim_config: str,
gpus: list[int],
work_dir: str,
chamfer_max: float,
keep_work_dir: bool,
use_persistent_workers: bool = True,
sim_timeout_s: int = 30,
):
import bottle
from bottle import request
gc_root = Path(garmentcode_root).resolve()
gcd_path = Path(gcd_root)
work_path = Path(work_dir)
work_path.mkdir(parents=True, exist_ok=True)
tiers = RewardTiers(chamfer_max=chamfer_max)
worker_pool = None
if use_persistent_workers:
from sim_worker import SimWorkerPool
print(f"[RewardServer] Starting persistent worker pool ({len(gpus)} GPUs)...")
worker_pool = SimWorkerPool(gpus, garmentcode_root, sim_config, sim_timeout_s=sim_timeout_s)
import atexit
atexit.register(worker_pool.shutdown)
print(f"[RewardServer] Worker pool ready.")
gpu_cycle = [0]
gpu_locks = {g: threading.Semaphore(1) for g in gpus}
active_jobs: dict[str, BatchJob] = {}
jobs_lock = threading.Lock()
logger.info("[RewardServer] Building GT mesh index from %s ...", gcd_path)
gt_mesh_index = build_gt_mesh_index(gcd_path)
logger.info("[RewardServer] Indexed %d GT meshes", len(gt_mesh_index))
def _get_gt_mesh_cached(sample_id: str) -> Optional[Path]:
if not sample_id:
return None
return gt_mesh_index.get(sample_id)
def _next_gpu() -> int:
g = gpus[gpu_cycle[0] % len(gpus)]
gpu_cycle[0] += 1
return g
def _process_one_locked(spec_content: str, sample_id: str, gpu: int) -> dict:
need_lock = worker_pool is None
if need_lock:
gpu_locks[gpu].acquire()
try:
return compute_reward_for_spec(
spec_content, sample_id,
gc_root, sim_config, gpu, work_path, gcd_path, tiers,
keep_work_dir=keep_work_dir, gt_mesh_lookup=_get_gt_mesh_cached,
worker_pool=worker_pool,
sim_timeout_s=sim_timeout_s,
)
except Exception as e:
return {"sample_id": sample_id, "sim_ok": False, "reward": 0.0,
"tier": "error", "error": str(e), "chamfer": None}
finally:
if need_lock:
gpu_locks[gpu].release()
app = bottle.Bottle()
@app.route("/health", method="GET")
def health():
return json.dumps({"status": "ok", "gpus": gpus, "active_jobs": len(active_jobs)})
@app.route("/simulate", method="POST")
def simulate_one():
"""Synchronous: simulate one spec, compute chamfer & reward.
POST body: {"spec_json": "...", "sample_id": "rand_XXXX"}
Returns: {"sim_ok": bool, "chamfer": float|null, "reward": float, "tier": str}
"""
item = json.loads(request.body.read())
spec = item.get("spec_json", "")
if not spec:
return json.dumps({"error": "missing spec_json", "sim_ok": False, "reward": 0.0})
gpu = _next_gpu()
return json.dumps(_process_one_locked(spec, item.get("sample_id", ""), gpu))
@app.route("/simulate_batch", method="POST")
def simulate_batch():
"""Async: submit batch. POST body: [{"spec_json":"...", "sample_id":"..."}, ...]
Returns: {"job_id": str}. Poll GET /result/<job_id>.
"""
items = json.loads(request.body.read())
job_id = uuid.uuid4().hex
job = BatchJob(job_id=job_id, items=items, submitted_at=time.time())
with jobs_lock:
active_jobs[job_id] = job
def _worker():
from concurrent.futures import ThreadPoolExecutor, as_completed
results = [None] * len(items)
with ThreadPoolExecutor(max_workers=len(gpus)) as pool:
futs = {}
for i, item in enumerate(items):
gpu = _next_gpu()
futs[pool.submit(
_process_one_locked,
item.get("spec_json", ""), item.get("sample_id", ""), gpu,
)] = i
for fut in as_completed(futs):
results[futs[fut]] = fut.result()
job.results = results
job.done.set()
threading.Thread(target=_worker, daemon=True).start()
return json.dumps({"job_id": job_id})
@app.route("/result/<job_id>", method="GET")
def get_result(job_id):
with jobs_lock:
job = active_jobs.get(job_id)
if job is None:
bottle.response.status = 404
return json.dumps({"error": "unknown job_id"})
if not job.done.is_set():
return json.dumps({"status": "pending"})
elapsed = time.time() - job.submitted_at
with jobs_lock:
active_jobs.pop(job_id, None)
return json.dumps({
"status": "done",
"results": job.results,
"elapsed_s": round(elapsed, 2),
})
print(f"[RewardServer] Starting on port {port} with {len(gpus)} GPUs: {gpus}")
print(f"[RewardServer] GarmentCode: {gc_root}")
print(f"[RewardServer] GarmentCodeData: {gcd_path}")
print(f"[RewardServer] Chamfer max: {tiers.chamfer_max}")
print(f"[RewardServer] Keep work dir: {keep_work_dir}")
print(f"[RewardServer] Persistent sim: {use_persistent_workers}")
print(f"[RewardServer] Sim timeout(s): {sim_timeout_s}")
bottle.run(app, host="0.0.0.0", port=port, server="tornado")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_gpu_list(raw: str) -> list[int]:
"""Parse physical CUDA GPU ids for the simulation server."""
gpus: list[int] = []
for item in raw.split(","):
item = item.strip()
if not item:
continue
try:
gpus.append(int(item))
except ValueError as exc:
raise argparse.ArgumentTypeError(
f"--gpus must be a comma-separated list of integers, got {raw!r}"
) from exc
if not gpus:
raise argparse.ArgumentTypeError("--gpus must contain at least one GPU id")
if len(set(gpus)) != len(gpus):
raise argparse.ArgumentTypeError(f"--gpus contains duplicates: {raw!r}")
return gpus
def main():
parser = argparse.ArgumentParser(description="GRPO Reward Server (sim + chamfer + reward)")
parser.add_argument("--port", type=int, default=59876)
parser.add_argument("--garmentcode-root", type=str, default="/filesdir/GarmentCode")
parser.add_argument("--gcd-root", type=str, default="/filesdir/GarmentCodeData_v2")
parser.add_argument("--sim-config", type=str, default="assets/Sim_props/default_sim_props.yaml")
parser.add_argument(
"--gpus",
type=parse_gpu_list,
default=parse_gpu_list(os.environ.get("SIM_GPUS", "3")),
help=(
"Physical GPU ids used by the simulation server. Defaults to SIM_GPUS "
"or GPU 3, so a 4-card machine can train on 0,1,2 and simulate on 3."
),
)
parser.add_argument("--work-dir", type=str, default="/tmp/grpo_reward_work")
parser.add_argument("--chamfer-max", type=float, default=0.05)
parser.add_argument("--sim-timeout", type=int, default=30,
help="Per-sample timeout in seconds; long tasks are marked as failed")
parser.add_argument("--keep-work-dir", action="store_true", help="Keep per-request simulation folders")
parser.add_argument("--no-persistent-workers", action="store_true",
help="Disable persistent sim workers (fall back to subprocess per request)")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO)
run_server(
port=args.port,
garmentcode_root=args.garmentcode_root,
gcd_root=args.gcd_root,
sim_config=args.sim_config,
gpus=args.gpus,
work_dir=args.work_dir,
chamfer_max=args.chamfer_max,
keep_work_dir=args.keep_work_dir,
use_persistent_workers=not args.no_persistent_workers,
sim_timeout_s=args.sim_timeout,
)
if __name__ == "__main__":
main()
|