Download runtime/rl/service.py from BAAI/PingPongRealWorldRL: direct link, hf CLI and curl.
- Browser
- Download file 16.3 kB
-
https://huggingface.co/BAAI/PingPongRealWorldRL/resolve/main/runtime/rl/service.py
- Command line
-
hf download hf://BAAI/PingPongRealWorldRL/runtime/rl/service.py
-
curl -L -o service.py https://huggingface.co/BAAI/PingPongRealWorldRL/resolve/main/runtime/rl/service.py
16.3 kB
| """Lifecycle owner for the opt-in H2 collector, ordered ball SUB and GPU process.""" | |
| from __future__ import annotations | |
| from datetime import datetime | |
| from dataclasses import replace | |
| import multiprocessing as mp | |
| from pathlib import Path | |
| import queue | |
| import signal | |
| import time | |
| import uuid | |
| import numpy as np | |
| import torch | |
| import yaml | |
| from .collector import ResidualCollector | |
| from .data import ActionContract, BASE_ACTION_DIM, OBS_DIM, REWARD_PROTOCOL | |
| from .events import ADJUDICATION_VERSION, BallStream, CourtGeometry | |
| from .journal import AsyncJournal | |
| from .learner import learner_main, load_checkpoint | |
| from .transport import LearnerStatus, PolicyDoubleBuffer | |
| class RLService: | |
| def __init__(self, config, h2_contract, spec, origin, *, mode, project_root, checkpoint=None, run_dir=None, | |
| observe_only=False, observation_dim=OBS_DIM, policy_metadata=None): | |
| if observe_only and (mode != "baseline" or checkpoint is not None): | |
| raise ValueError("record-only evaluation requires baseline without a checkpoint") | |
| if not isinstance(observation_dim, int) or observation_dim <= 0: | |
| raise ValueError("evaluation observation_dim must be a positive integer") | |
| if not observe_only and observation_dim != OBS_DIM: | |
| raise ValueError("residual service requires the original 182-D observation") | |
| if mode not in ("train", "residual", "baseline"): | |
| raise ValueError("RL service mode must be train, residual or baseline") | |
| if mode == "residual" and not checkpoint: | |
| raise ValueError("--rl-mode residual requires --rl-checkpoint") | |
| if mode == "baseline" and checkpoint: | |
| raise ValueError("baseline mode does not load a checkpoint") | |
| inference_policy = None | |
| if checkpoint and Path(checkpoint).suffix.lower() == ".onnx": | |
| if mode != "residual": | |
| raise ValueError("ONNX residual checkpoints are inference-only; use --rl-mode residual") | |
| from .onnx_policy import OnnxResidualPolicy | |
| inference_policy = OnnxResidualPolicy(checkpoint) | |
| saved = inference_policy.saved | |
| else: | |
| saved = load_checkpoint(checkpoint) if checkpoint else None | |
| if saved is not None and mode == "residual": | |
| # Evaluate the saved experiment even after the default recipe was | |
| # edited. Only transport/output settings belong to this new session. | |
| config = replace(type(config)(**saved["config"]), | |
| zmq_endpoint=config.zmq_endpoint, output_dir=config.output_dir, | |
| episode_queue_capacity=config.episode_queue_capacity, | |
| logging_queue_capacity=config.logging_queue_capacity, | |
| ball_queue_capacity=config.ball_queue_capacity) | |
| self.config, self.mode = config, mode | |
| self.observe_only, self.observation_dim = observe_only, observation_dim | |
| self.contract = None if observe_only else ActionContract( | |
| tuple(h2_contract.policy_names), tuple(h2_contract.action_scale_policy), config.action_scales_rad) | |
| if saved is not None: | |
| self.contract.validate_saved(saved.get("contract")) | |
| if saved is not None and mode == "train" and config.to_dict() != type(config)(**saved["config"]).to_dict(): | |
| raise ValueError("training resume requires the checkpoint's exact RL config") | |
| parent = Path(config.output_dir) | |
| if not parent.is_absolute(): | |
| parent = Path(project_root) / parent | |
| self.run_dir = Path(run_dir) if run_dir else parent / (datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:8]) | |
| self.run_dir.mkdir(parents=True, exist_ok=False) | |
| contract_record = (dict(format="h2-policy-evaluation-v1", robot="h2", obs_dim=observation_dim, | |
| action_dim=BASE_ACTION_DIM, policy_names=list(h2_contract.policy_names), | |
| action_scale=list(map(float, h2_contract.action_scale_policy)), | |
| recording_only=True, action_clip="runner_unchanged") if observe_only else self.contract.to_dict()) | |
| resolved = dict(rl=config.to_dict(), contract=contract_record, mode=mode, | |
| reward_protocol=REWARD_PROTOCOL, independent_evaluation=mode in ("baseline", "residual"), | |
| adjudication_version=ADJUDICATION_VERSION, velocity_source="adapter_zmq", | |
| checkpoint=str(Path(checkpoint).resolve()) if checkpoint else None, | |
| robot_base_origin_world=list(map(float, origin)), collector_device="cpu", learner_parallel_during_hit=mode == "train", | |
| source_commit="4fa48dd2672bd52d9349fb3f7902755d665aa725") | |
| if inference_policy is not None: | |
| resolved["inference_export"] = saved["export"] | |
| if observe_only: | |
| resolved.update(recording_only=True, observation_dim=observation_dim, | |
| policy=dict(policy_metadata or {}), action_clip="runner_unchanged") | |
| (self.run_dir / "resolved_config.yaml").write_text(yaml.safe_dump(resolved, sort_keys=False)) | |
| # Avoid tiny CPU MLPs spawning large BLAS/OpenMP teams on each control tick. | |
| if not observe_only: | |
| torch.set_num_threads(1) | |
| self.status = self.sync = self.episodes = self.stop_event = None | |
| self.process = self.source = self.journal = self.collector = None | |
| self.closed = False | |
| self.tick_samples = [] | |
| self.source_generation = 0 | |
| self.source_error = None | |
| self.last_telemetry_s = 0.0 | |
| self.control_periods = [] | |
| self.last_collector_compute_s = 0.0 | |
| self.failure_announced = False | |
| previous_signals = {sig: signal.signal(sig, self._interrupt_startup) | |
| for sig in (signal.SIGINT, signal.SIGTERM)} | |
| try: | |
| if mode == "train": | |
| context = mp.get_context("spawn") | |
| self.status = LearnerStatus(context) | |
| self.sync = PolicyDoubleBuffer(context) | |
| self.episodes = context.Queue(maxsize=config.episode_queue_capacity) | |
| self.stop_event = context.Event() | |
| self.journal = AsyncJournal(self.run_dir, config.logging_queue_capacity) | |
| self.collector = ResidualCollector(config, self.contract, CourtGeometry.from_spec(spec, origin), | |
| self.sync, self.status, self.episodes, self.journal, mode=mode, saved=saved, | |
| observe_only=observe_only, observation_dim=observation_dim, | |
| inference_policy=inference_policy) | |
| if mode == "train": | |
| self.process = context.Process(target=learner_main, args=(config, self.contract, self.episodes, | |
| self.sync, self.status, self.stop_event, str(self.run_dir), checkpoint), name="h2-residual-learner") | |
| started = time.monotonic() | |
| print("[STARTUP] learner initialization begin (timeout_s=60)", flush=True) | |
| self.process.start() | |
| limit = started + 60 | |
| while not self.status.ready.value and not self.status.done.value: | |
| if self.status.failed.value or not self.process.is_alive(): | |
| raise RuntimeError(f"H2 learner startup failed; see {self.run_dir / 'learner_error.txt'}") | |
| if time.monotonic() >= limit: | |
| raise RuntimeError("H2 learner startup timed out") | |
| time.sleep(0.02) | |
| print(f"[STARTUP] learner initialization complete elapsed_s={time.monotonic()-started:.3f}", flush=True) | |
| self.source = BallStream(config, origin) | |
| except BaseException: | |
| # No episodes have been collected during startup. A learner stuck | |
| # before ready can be stopped without interrupting a training drain. | |
| if (self.process is not None and self.process.pid is not None and | |
| not self.status.ready.value and not self.status.done.value): | |
| self.process.terminate() | |
| self.process.join() | |
| self.status.failed.value = 1 | |
| try: | |
| self.close() | |
| except Exception as error: | |
| print(f"[RL ERROR] startup cleanup: {error}") | |
| raise | |
| finally: | |
| for sig, handler in previous_signals.items(): | |
| signal.signal(sig, handler) | |
| learner = config.learner_device if mode == "train" else "none" | |
| if observe_only: | |
| print(f"[POLICY EVAL] run={self.run_dir} obs_dim={observation_dim} recording_only=true action=runner_unchanged") | |
| else: | |
| print(f"[RL] mode={mode} run={self.run_dir} residual=7x{config.action_scales_rad}rad learner={learner}") | |
| if mode != "train": | |
| print(f"[RL EVAL] policy={mode} checkpoint={checkpoint or 'none'}; no exploration or learning; stop manually") | |
| def _interrupt_startup(signum, _frame): | |
| raise KeyboardInterrupt(f"RL startup interrupted by signal {signum}") | |
| def _check_process(self): | |
| if (self.process is not None and self.process.pid is not None and | |
| not self.process.is_alive() and not self.status.done.value): | |
| self.status.failed.value = 1 | |
| if self.status is not None and self.status.failed.value and not self.failure_announced: | |
| print(f"[RL ERROR] learner failed; see {self.run_dir / 'learner_error.txt'}") | |
| self.failure_announced = True | |
| def poll(self): | |
| self.tick_samples = self.source.drain() | |
| generation = self.source.error_generation | |
| self.source_error = self.source.last_error if generation != self.source_generation else None | |
| self.source_generation = generation | |
| self._check_process() | |
| def step(self, observation, base_action, reference, latest_command_id): | |
| now_s = time.monotonic() | |
| try: | |
| action = self.collector.step(observation, base_action, now_s=now_s, | |
| active_command_id=reference.command_id, latest_command_id=latest_command_id, | |
| hit_deadline_s=reference.active_hit_deadline_s, target_landing_xy=reference.active_landing_xy, | |
| reference_active=not reference.is_frozen, samples=self.tick_samples, | |
| racket_distance_m=self.source.racket_distance(now_s), | |
| source_fresh=now_s-self.source.last_receipt_s <= self.config.max_ball_gap_s, | |
| source_error=self.source_error) | |
| except Exception as error: | |
| self.collector.disabled = True | |
| self.collector.abort(f"rl_runtime_error:{error}") | |
| if self.observe_only: | |
| print(f"[POLICY EVAL ERROR] scoring disabled, policy unchanged: {error}") | |
| else: | |
| print(f"[RL ERROR] residual disabled, base policy continues: {error}") | |
| action = base_action if self.observe_only else base_action.copy() | |
| self.last_collector_compute_s = time.monotonic() - now_s | |
| return base_action if self.observe_only else action | |
| def applied(self): | |
| self.collector.applied() | |
| def abort(self, reason): | |
| self.collector.abort(reason) | |
| def telemetry(self, step_dt, generation_s): | |
| now = time.monotonic() | |
| self.control_periods.append(float(step_dt)) | |
| if now-self.last_telemetry_s >= 1: | |
| self.journal.telemetry(dict(monotonic_s=now, control_period_s=float(step_dt), | |
| control_period_max_s=max(self.control_periods), | |
| control_period_p95_s=float(np.percentile(self.control_periods, 95)), | |
| collector_compute_s=self.last_collector_compute_s, | |
| closd_generation_s=float(generation_s), **self.collector.snapshot(), | |
| journal=self.journal.snapshot(), learner=self.status.snapshot() if self.status else None, | |
| learner_backlog_balls=(self.collector.valid_balls-self.status.ingested_balls.value | |
| if self.status else 0))) | |
| self.last_telemetry_s = now | |
| self.control_periods.clear() | |
| def close(self): | |
| if self.closed: | |
| return | |
| self.closed = True | |
| # The runner has detached from the robot. Further terminal Ctrl+C must | |
| # not interrupt checkpoint writes or queue cleanup. | |
| previous_signals = {sig: signal.signal(sig, signal.SIG_IGN) | |
| for sig in (signal.SIGINT, signal.SIGTERM)} | |
| errors = [] | |
| try: | |
| for resource in (self.collector, self.source): | |
| if resource is not None: | |
| try: | |
| if resource is self.collector: | |
| resource.abort("external_stop") | |
| else: | |
| resource.close() | |
| except Exception as error: | |
| errors.append(str(error)) | |
| if self.process is not None and self.process.pid is not None: | |
| self._drain_learner() | |
| except Exception as error: | |
| errors.append(str(error)) | |
| finally: | |
| # Failure in one resource must not skip the others. | |
| for resource in (self.journal, self.episodes): | |
| if resource is not None: | |
| try: | |
| if resource is self.episodes: | |
| if self.status.failed.value: | |
| self.episodes.cancel_join_thread() | |
| resource.close() | |
| if resource is self.episodes and not self.status.failed.value: | |
| self.episodes.join_thread() | |
| except Exception as error: | |
| errors.append(str(error)) | |
| for sig, handler in previous_signals.items(): | |
| signal.signal(sig, handler) | |
| if self.status is not None and self.status.failed.value: | |
| errors.append(f"learner failed; recovery artifacts: {self.run_dir}") | |
| if errors: | |
| raise RuntimeError("RL shutdown incomplete: " + "; ".join(errors)) | |
| print(f"[RL EXIT] training and logs complete: {self.run_dir}") | |
| def _drain_learner(self): | |
| collector = self.collector | |
| last_report = -float("inf") | |
| stop_sent = False | |
| while self.process.is_alive(): | |
| self._check_process() | |
| collector._try_deliver() | |
| if collector.delivery is None and not stop_sent: | |
| self.status.stop_after_balls.value = collector.valid_balls | |
| self.stop_event.set() | |
| stop_sent = True | |
| now = time.monotonic() | |
| if now - last_report >= 5: | |
| backlog = collector.valid_balls - self.status.ingested_balls.value | |
| backlog += int(collector.delivery is not None) | |
| print(f"[RL DRAIN] balls={backlog} updates={self.status.pending_updates.value} " | |
| f"stage={self.status.stage_name} run={self.run_dir}", flush=True) | |
| last_report = now | |
| self.process.join(timeout=0.1) | |
| self.process.join() | |
| self._check_process() | |
| if self.status.failed.value: | |
| self._save_undelivered() | |
| def _save_undelivered(self): | |
| """Recover queued complete balls after the consumer has exited.""" | |
| batches = [] | |
| # Queue feeder may still be sending a large episode. A short timed read | |
| # lets the feeder advance; unlike join_thread it cannot wait on a dead consumer. | |
| while True: | |
| try: | |
| batches.append(self.episodes.get(timeout=0.2)) | |
| except queue.Empty: | |
| break | |
| delivery = self.collector.delivery | |
| if delivery is not None: | |
| batches.append(delivery[0]) | |
| if batches: | |
| path = self.run_dir / "undelivered_episodes.pt" | |
| torch.save(batches, path) | |
| print(f"[RL RECOVERY] {len(batches)} unconsumed balls saved to {path}") | |
| if delivery is not None: | |
| _, record = delivery | |
| record["summary"].update(delivery_error="learner_failed_before_delivery", | |
| recoverable_episode="undelivered_episodes.pt") | |
| self.journal.submit_reserved(record) | |
| self.collector.delivery = None | |