yxc20098's picture
Vendor the necessary openra_rl_training + openra_env modules
9063f2b
Raw
History Blame Contribute Delete
6.32 kB
"""Pool of Rust-backed OpenRA environments for fast in-process rollout.
Mirrors the surface of `env_pool.EnvPool` but swaps the gRPC-backed
`OpenRAEnvironment` for the native `openra_train.OpenRAEnv` (a Rust
deterministic simulator built via maturin/PyO3).
Key differences from the gRPC pool:
* No game server / port allocation. Each `OpenRAEnv` is a
self-contained Rust object — instantiation is microseconds.
* Episodes are deterministic given (scenario_path, seed). The pool
accepts a `seed_generator` (defaults to a monotonic counter) so
callers can reseed each acquire if desired.
* `step` accepts a list of `openra_train.Command` objects (build
them with `Command.move_units(...)`, `Command.attack_unit(...)`,
`Command.observe()`).
The pool is process-local; for honest parallelism, fan out via
`concurrent.futures.ProcessPoolExecutor` and have each worker own its
own `RustEnvPool` (or just instantiate `OpenRAEnv` directly).
Drop-in for the existing `env_pool.EnvPool`:
* `acquire(timeout=...) -> env`
* `release(env)`
* `update_scenario(path)` — refreshes the default scenario for new
envs and resets the seed counter.
* `shutdown()` — drops references; Rust GC frees the worlds.
"""
from __future__ import annotations
import itertools
import logging
import queue
import threading
from typing import Any, Callable, Iterator
logger = logging.getLogger(__name__)
def _default_seed_generator(start: int = 0) -> Iterator[int]:
return itertools.count(start)
class RustEnvHandle:
"""Thin wrapper to give the Rust env a uniform `reset / step / close`
interface that mirrors the gRPC env without leaking PyO3 types
upward."""
def __init__(self, scenario_path: str, seed: int):
# Lazy import so import-time failures don't break the rest of
# the training package on machines without the wheel built.
import openra_train
self._cls_command = openra_train.Command
self._env = openra_train.OpenRAEnv(scenario_path, int(seed))
self.scenario_path = scenario_path
self.seed = int(seed)
@property
def Command(self):
"""Expose `openra_train.Command` for callers that want to
construct Move/Attack/Observe payloads without re-importing."""
return self._cls_command
def reset(self, seed: int | None = None) -> dict[str, Any]:
if seed is not None and int(seed) != self.seed:
# Re-instantiate to pick up the new seed (the underlying
# Rust env owns the world; reset() re-uses the original
# seed). Cheap — Rust instantiation is sub-millisecond.
import openra_train
self._env = openra_train.OpenRAEnv(self.scenario_path, int(seed))
self.seed = int(seed)
return self._env.reset()
def step(self, commands: list[Any]) -> tuple[dict[str, Any], float, bool, dict[str, Any]]:
"""Apply a list of `openra_train.Command` objects, returns
(obs, reward, done, info)."""
return self._env.step(commands)
def close(self) -> None:
# No external resources to release; the Rust world is freed
# when this handle is dropped.
self._env = None
class RustEnvPool:
"""Thread-safe pool of Rust-backed environments.
Args:
size: Number of environment instances.
scenario_path: Path to the rush-hour-style scenario YAML.
seed_generator: Iterator yielding seeds for each new env. If
None, defaults to `itertools.count(0)`.
env_factory: Optional override; receives `(scenario_path, seed)`
and returns an env-like object exposing `reset(...)` /
`step(...)`. Useful for testing.
"""
def __init__(
self,
size: int = 4,
scenario_path: str = "",
seed_generator: Iterator[int] | None = None,
env_factory: Callable[[str, int], Any] | None = None,
):
if size < 1:
raise ValueError(f"RustEnvPool size must be >=1, got {size}")
if not scenario_path:
raise ValueError("RustEnvPool requires a non-empty scenario_path")
self._size = size
self._scenario_path = scenario_path
self._seed_gen = seed_generator or _default_seed_generator()
self._factory = env_factory or (lambda path, seed: RustEnvHandle(path, seed))
self._pool: queue.Queue = queue.Queue()
self._envs: list = []
self._lock = threading.Lock()
for _ in range(size):
seed = next(self._seed_gen)
env = self._factory(scenario_path, seed)
self._envs.append(env)
self._pool.put(env)
def acquire(self, timeout: float = 30.0):
"""Get an available environment (blocks if all busy).
The Rust env is in-process and deterministic, so the timeout
only applies if all envs are checked out by other threads.
"""
return self._pool.get(timeout=timeout)
def release(self, env) -> None:
"""Return an environment to the pool."""
self._pool.put(env)
def update_scenario(self, scenario_path: str) -> None:
"""Replace the scenario used for newly-instantiated envs.
Existing pooled envs keep their current scenario until released
and re-acquired with `acquire(reset=True)` (callers should use
this method in conjunction with explicit env replacement).
"""
with self._lock:
self._scenario_path = scenario_path
@property
def scenario_path(self) -> str:
return self._scenario_path
@property
def size(self) -> int:
return self._size
@property
def available(self) -> int:
return self._pool.qsize()
def shutdown(self) -> None:
"""Drop all env references and drain the pool."""
with self._lock:
for env in self._envs:
try:
if hasattr(env, "close"):
env.close()
except Exception:
logger.exception("Error closing Rust env")
self._envs.clear()
while not self._pool.empty():
try:
self._pool.get_nowait()
except queue.Empty:
break