|
| 1 | +"""Atomic local batches over the stateless remote training endpoint.""" |
| 2 | +from __future__ import annotations |
| 3 | + |
| 4 | +import copy |
| 5 | +import json |
| 6 | +import math |
| 7 | + |
| 8 | +import numpy as np |
| 9 | + |
| 10 | +from .remote import RemoteError, _code, _integer |
| 11 | +from .http_policy import _json |
| 12 | + |
| 13 | + |
| 14 | +class RemoteNativeVectorEnv: |
| 15 | + def __init__(self, client, game, n, config_json="{}", seed=0, threads=0, *, batch_size=32): |
| 16 | + self._client = client |
| 17 | + self.n = _integer(n, 4096) |
| 18 | + self._batch_size = _integer(batch_size, 64) |
| 19 | + if not self.n or not self._batch_size: |
| 20 | + raise ValueError("n must be 1..4096 and batch_size 1..64") |
| 21 | + if _integer(threads, 64) != 0: |
| 22 | + raise ValueError("remote workers are controlled by the server; leave threads=0") |
| 23 | + seed = _integer(seed, 2**64 - self.n) |
| 24 | + prototype = client.native(game, config_json, seed) |
| 25 | + self.num_players = prototype.num_players |
| 26 | + self.action_space_size = prototype.action_space_size |
| 27 | + self._shape = prototype._shape |
| 28 | + row_bytes = math.prod(self._shape) * 4 + self.action_space_size + self.num_players * 8 + 6 |
| 29 | + if row_bytes * self.n > 128 * 1024 * 1024: |
| 30 | + raise ValueError("batch numeric arrays exceed 128 MiB") |
| 31 | + candidate = [prototype] + [prototype.clone() for _ in range(self.n - 1)] |
| 32 | + operations = [ |
| 33 | + (index, 0, {"op": "create", "game_id": game, "config": _json(config_json), |
| 34 | + "seed": seed + index, "seat": 0}) |
| 35 | + for index in range(1, self.n) |
| 36 | + ] |
| 37 | + self._run(candidate, operations) |
| 38 | + self._project(candidate, None) |
| 39 | + self._envs = candidate |
| 40 | + |
| 41 | + def _chunks(self, jobs): |
| 42 | + batch, size = [], 32 |
| 43 | + for job in jobs: |
| 44 | + length = len(json.dumps(job[2], allow_nan=False, separators=(",", ":")).encode()) + 1 |
| 45 | + if length + 32 > 4 * 1024 * 1024: |
| 46 | + raise RemoteError("REQUEST_TOO_LARGE") |
| 47 | + if batch and (len(batch) >= self._batch_size or size + length > 4 * 1024 * 1024): |
| 48 | + yield batch |
| 49 | + batch, size = [], 32 |
| 50 | + batch.append(job) |
| 51 | + size += length |
| 52 | + if batch: |
| 53 | + yield batch |
| 54 | + |
| 55 | + def _run(self, candidate, jobs, rewards=None, *, unchanged=False): |
| 56 | + for chunk in self._chunks(jobs): |
| 57 | + results = self._client.batch([job[2] for job in chunk]) |
| 58 | + for (index, seat, _), result in zip(chunk, results): |
| 59 | + if result["status"] == "error": |
| 60 | + raise RemoteError(_code(result)) |
| 61 | + candidate[index]._accept(result, seat, unchanged=unchanged) |
| 62 | + if rewards is not None: |
| 63 | + rewards[index] = result["frame"]["rewards"] |
| 64 | + if candidate[index].num_players != self.num_players or candidate[index]._shape != self._shape: |
| 65 | + raise RemoteError("INVALID_RESPONSE") |
| 66 | + |
| 67 | + def _seats(self, candidate, seats): |
| 68 | + if seats is not None and len(seats) != self.n: |
| 69 | + raise ValueError("seat count must equal batch size") |
| 70 | + result = [] |
| 71 | + for index, env in enumerate(candidate): |
| 72 | + actors = env.current_players() |
| 73 | + if len(actors) > 1: |
| 74 | + raise ValueError("VectorEnv requires sequential turns") |
| 75 | + result.append((actors[0] if actors else 0) if seats is None |
| 76 | + else _integer(seats[index], self.num_players - 1)) |
| 77 | + return result |
| 78 | + |
| 79 | + def _project(self, candidate, seats): |
| 80 | + seats = self._seats(candidate, seats) |
| 81 | + jobs = [ |
| 82 | + (index, seat, {"op": "observe", "checkpoint": env._checkpoint, "seat": seat}) |
| 83 | + for index, (env, seat) in enumerate(zip(candidate, seats)) |
| 84 | + if seat not in env._frames |
| 85 | + ] |
| 86 | + self._run(candidate, jobs, unchanged=True) |
| 87 | + # Vector inference needs one view per row; keep Python mask storage bounded. |
| 88 | + for env, seat in zip(candidate, seats): |
| 89 | + env._frames = {seat: env._frames[seat]} |
| 90 | + return seats |
| 91 | + |
| 92 | + def _arrays(self, candidate, seats=None, rewards=None): |
| 93 | + seats = self._project(candidate, seats) |
| 94 | + output = { |
| 95 | + "observation": np.empty((self.n, *self._shape), dtype=np.float32), |
| 96 | + "action_mask": np.empty((self.n, self.action_space_size), dtype=np.int8), |
| 97 | + "seat": np.array(seats, dtype=np.int16), |
| 98 | + "to_act": np.empty(self.n, dtype=np.int16), |
| 99 | + "terminated": np.empty(self.n, dtype=bool), |
| 100 | + "truncated": np.empty(self.n, dtype=bool), |
| 101 | + "rewards": np.zeros((self.n, self.num_players), dtype=np.float64) if rewards is None else rewards.copy(), |
| 102 | + } |
| 103 | + for index, (env, seat) in enumerate(zip(candidate, seats)): |
| 104 | + raw = env._frames[seat] |
| 105 | + tensor = raw["observation"]["tensor"] |
| 106 | + output["observation"][index] = np.array(tensor["values"], dtype=np.float32).reshape(self._shape) |
| 107 | + output["action_mask"][index] = raw["action_mask"] |
| 108 | + actors = raw["to_act"] |
| 109 | + output["to_act"][index] = actors[0] if actors else -1 |
| 110 | + output["terminated"][index] = raw["terminated"] |
| 111 | + output["truncated"][index] = raw["truncated"] |
| 112 | + return output |
| 113 | + |
| 114 | + def frame(self, seats=None): |
| 115 | + candidate = [env.clone() for env in self._envs] |
| 116 | + result = self._arrays(candidate, seats) |
| 117 | + self._envs = candidate |
| 118 | + return result |
| 119 | + |
| 120 | + def step(self, actions): |
| 121 | + if len(actions) != self.n: |
| 122 | + raise ValueError("action count must equal batch size") |
| 123 | + candidate = [env.clone() for env in self._envs] |
| 124 | + jobs = [] |
| 125 | + for index, (env, action) in enumerate(zip(candidate, actions)): |
| 126 | + actors = env.current_players() |
| 127 | + if not actors and any(env.flags()) and action is None: |
| 128 | + continue |
| 129 | + if len(actors) != 1 or action is None: |
| 130 | + raise ValueError("use legal indices for active rows and None for finished rows") |
| 131 | + seat = actors[0] |
| 132 | + jobs.append((index, seat, {"op": "step", "checkpoint": env._checkpoint, |
| 133 | + "seat": seat, "action": _integer(action, self.action_space_size - 1)})) |
| 134 | + rewards = np.zeros((self.n, self.num_players), dtype=np.float64) |
| 135 | + self._run(candidate, jobs, rewards) |
| 136 | + result = self._arrays(candidate, rewards=rewards) |
| 137 | + self._envs = candidate |
| 138 | + return result |
| 139 | + |
| 140 | + def reset_at(self, indices, seeds, positions): |
| 141 | + if len(indices) != len(seeds) or len(indices) != len(positions): |
| 142 | + raise ValueError("indices, seeds, and positions must have equal lengths") |
| 143 | + candidate = [env.clone() for env in self._envs] |
| 144 | + jobs, seen = [], set() |
| 145 | + for index, seed, position in zip(indices, seeds, positions): |
| 146 | + index, seed = _integer(index, self.n - 1), _integer(seed, 2**64 - 1) |
| 147 | + if index in seen or (position is not None and not isinstance(position, str)): |
| 148 | + raise ValueError("indices must be unique and positions must be strings or None") |
| 149 | + seen.add(index) |
| 150 | + jobs.append((index, 0, {"op": "reset", "checkpoint": candidate[index]._checkpoint, |
| 151 | + "seed": seed, "position": position, "seat": 0})) |
| 152 | + self._run(candidate, jobs) |
| 153 | + result = self._arrays(candidate) |
| 154 | + self._envs = candidate |
| 155 | + return result |
| 156 | + |
| 157 | + def clone(self): |
| 158 | + result = copy.copy(self) |
| 159 | + result._envs = [env.clone() for env in self._envs] |
| 160 | + return result |
| 161 | + |
| 162 | + def get_state(self): |
| 163 | + return [env.get_state() for env in self._envs] |
| 164 | + |
| 165 | + def set_state(self, snapshots): |
| 166 | + if len(snapshots) != self.n or any(not isinstance(item, str) for item in snapshots): |
| 167 | + raise ValueError("checkpoint count or type is invalid") |
| 168 | + if sum(len(item.encode()) for item in snapshots) > 128 * 1024 * 1024: |
| 169 | + raise ValueError("batch checkpoints exceed 128 MiB") |
| 170 | + candidate, jobs = [env.clone() for env in self._envs], [] |
| 171 | + for index, (env, snapshot) in enumerate(zip(candidate, snapshots)): |
| 172 | + checkpoint = _json(snapshot) |
| 173 | + if (not isinstance(checkpoint, dict) or checkpoint.get("game_id") != env.game_id |
| 174 | + or checkpoint.get("config") != env._checkpoint["config"]): |
| 175 | + raise ValueError("checkpoint must match game and configuration") |
| 176 | + jobs.append((index, 0, {"op": "observe", "checkpoint": checkpoint, "seat": 0})) |
| 177 | + self._run(candidate, jobs) |
| 178 | + self._project(candidate, None) |
| 179 | + self._envs = candidate |
0 commit comments