From 37ff7686f81548df7c51f5a4a39810eeb244410c Mon Sep 17 00:00:00 2001 From: zilch <147668916@qq.com> Date: Tue, 13 Oct 2026 16:13:43 +0800 Subject: [PATCH 1/2] fix(fastsac): preserve truncated final observations and stabilize actor --- .../src/motrix_env_core/array/env.py | 32 +- .../src/motrix_env_core/numba/manager/env.py | 55 ++- .../numba/manager/observations.py | 7 +- motrix_env_core/tests/test_array_env.py | 194 ++++++++- motrix_env_core/tests/test_numba_manager.py | 56 ++- .../src/motrix_rl_builtin/fastsac/agent.py | 93 ++--- .../fastsac/async_impl/collector.py | 56 +-- .../fastsac/async_impl/learner.py | 54 ++- .../fastsac/async_impl/train.py | 33 +- .../fastsac/async_impl/transport/ipc_ring.py | 66 ++- .../fastsac/async_impl/transport/ring.py | 143 ++++++- .../fastsac/async_impl/worker.py | 40 +- .../src/motrix_rl_builtin/fastsac/buffer.py | 131 +++++- .../motrix_rl_builtin/fastsac/sync/train.py | 22 +- .../fastsac/window_metrics.py | 65 +++ .../src/motrix_rl_builtin/fastsac/wrap.py | 28 +- .../src/motrix_rl_builtin/fastsac/wrap_np.py | 30 +- .../motrix_rl_builtin/fastsac/wrap_torch.py | 20 +- .../tests/fastsac_async_mocks.py | 2 +- .../tests/test_builtin_sim_backend.py | 8 +- .../tests/test_fastsac_async_multi.py | 18 +- .../tests/test_fastsac_buffer.py | 48 ++- .../tests/test_fastsac_collector.py | 188 ++++++++- .../tests/test_fastsac_final_obs.py | 391 ++++++++++++++++++ .../tests/test_fastsac_ipc_ring.py | 126 ++++++ .../tests/test_fastsac_learner.py | 151 +------ .../tests/test_fastsac_networks.py | 26 ++ .../test_fastsac_pipeline_equivalence.py | 4 +- wiki/design/array-env-final-observation.md | 29 ++ .../fastsac-async-heterogeneous-trainer.md | 11 + wiki/design/index.md | 3 + 31 files changed, 1773 insertions(+), 357 deletions(-) create mode 100644 motrix_rl_builtin/src/motrix_rl_builtin/fastsac/window_metrics.py create mode 100644 motrix_rl_builtin/tests/test_fastsac_final_obs.py create mode 100644 motrix_rl_builtin/tests/test_fastsac_networks.py create mode 100644 wiki/design/array-env-final-observation.md diff --git a/motrix_env_core/src/motrix_env_core/array/env.py b/motrix_env_core/src/motrix_env_core/array/env.py index 0e0697d9..d76d2ab8 100644 --- a/motrix_env_core/src/motrix_env_core/array/env.py +++ b/motrix_env_core/src/motrix_env_core/array/env.py @@ -33,6 +33,18 @@ def value_or_policy(self) -> np.ndarray: return self.policy if self.value is None else self.value +@dataclass +class FinalObs: + """Owned pre-reset observations of the current transition's truncated rows. + + Rows in ``obs`` align with ``env_ids``. Arrays remain valid across steps; + consumers must treat them as read-only. ``obs.value`` stays optional. + """ + + env_ids: np.ndarray + obs: NpObs + + @dataclass class ArrayEnvState: obs: NpObs @@ -50,6 +62,8 @@ class ArrayEnvState: # reflect the latest step: retain values across steps through # :meth:`process_metrics`, which returns a fresh dict of reduced scalars. metrics: dict[str, float | np.ndarray] = dataclasses.field(default_factory=dict) + # None when no row truncated or the implementation does not capture finals. + final_obs: FinalObs | None = None @property def done(self) -> np.ndarray: @@ -234,6 +248,15 @@ def reset(self, env_ids: np.ndarray) -> None: writes have completed. """ + def _capture_final_obs(self, env_ids: np.ndarray) -> FinalObs | None: + """Capture truncated rows before reset; default implementation is unsupported. + + Called after transition and truncation updates, before any reset write. + Implementations must leave normal observations and task state unchanged + and return owned row arrays, without advancing observation history. + """ + return None + def _max_episode_steps(self) -> int | None: return self._cfg.max_episode_steps @@ -279,6 +302,8 @@ def physics_step(self): """Advance the simulator by the configured substeps.""" def _prev_physics_step(self): + if self._state.final_obs is not None: + self._state = self._state.replace(final_obs=None) state = self._state state.reward.fill(0.0) state.terminated.fill(False) @@ -302,6 +327,7 @@ def step(self, actions: np.ndarray) -> ArrayEnvState: Timing: ``apply_action`` -> ``physics_step`` -> ``compute_transition`` (the only full simulator-data refresh) -> episode bookkeeping -> + optional pre-reset ``final_obs`` capture for truncated rows -> ``reset(env_ids)`` for done rows (may refresh selected rows) -> ``compute_observation`` (whole batch, pure construction). """ @@ -319,6 +345,10 @@ def step(self, actions: np.ndarray) -> ArrayEnvState: self._state = self.compute_transition(self._state) self._state.episode_steps += 1 self._update_truncate() + trunc_ids = np.flatnonzero(self._state.truncated) + if trunc_ids.size: + with self.perf.scope("final_obs"): + self._state = self._state.replace(final_obs=self._capture_final_obs(trunc_ids)) with self.perf.scope("reset"): self._reset_done_envs() with self.perf.scope("observation"): @@ -327,4 +357,4 @@ def step(self, actions: np.ndarray) -> ArrayEnvState: return self._state -__all__ = ["ArrayEnv", "ArrayEnvState", "EnvCfgType", "NpObs"] +__all__ = ["ArrayEnv", "ArrayEnvState", "EnvCfgType", "FinalObs", "NpObs"] diff --git a/motrix_env_core/src/motrix_env_core/numba/manager/env.py b/motrix_env_core/src/motrix_env_core/numba/manager/env.py index ff729257..dc800bc3 100644 --- a/motrix_env_core/src/motrix_env_core/numba/manager/env.py +++ b/motrix_env_core/src/motrix_env_core/numba/manager/env.py @@ -13,7 +13,7 @@ import gymnasium as gym import numpy as np -from motrix_env_core.array.env import ArrayEnv, ArrayEnvState, EnvCfgType, NpObs +from motrix_env_core.array.env import ArrayEnv, ArrayEnvState, EnvCfgType, FinalObs, NpObs from motrix_env_core.base import EnvCfg, ObsSpace from motrix_env_core.config import configclass from motrix_env_core.config.sim_reset import ManagerResetCfg, ResetTermCfg @@ -285,6 +285,21 @@ class _ObsBufferSlot: observation: NpObs kernel_outputs: NumbaKernelOutputs + @classmethod + def bind(cls, observation: NpObs, reward: np.ndarray, terminated: np.ndarray) -> _ObsBufferSlot: + value = observation.value + if value is None: + value = np.empty((observation.policy.shape[0], 0), dtype=observation.policy.dtype) + return cls( + observation=observation, + kernel_outputs=NumbaKernelOutputs( + policy_obs=observation.policy, + value_obs=value, + reward=reward, + terminated=terminated, + ), + ) + @dataclass class SwapObsBuffer: @@ -311,21 +326,7 @@ def create( reward: np.ndarray, terminated: np.ndarray, ) -> SwapObsBuffer: - def make_slot(observation: NpObs) -> _ObsBufferSlot: - value = observation.value - if value is None: - value = np.empty((observation.policy.shape[0], 0), dtype=observation.policy.dtype) - return _ObsBufferSlot( - observation=observation, - kernel_outputs=NumbaKernelOutputs( - policy_obs=observation.policy, - value_obs=value, - reward=reward, - terminated=terminated, - ), - ) - - return cls((make_slot(first), make_slot(second))) + return cls((_ObsBufferSlot.bind(first, reward, terminated), _ObsBufferSlot.bind(second, reward, terminated))) @property def current(self) -> _ObsBufferSlot: @@ -370,6 +371,7 @@ def __init__(self, cfg: EnvCfgType, num_envs: int = 1, backend: str | None = Non self.metrics: dict[str, np.ndarray] = {} self._kernel_outputs: NumbaKernelOutputs | None = None self._observation_buffer: SwapObsBuffer | None = None + self._final_obs_slot: _ObsBufferSlot | None = None # Persistent kernel-bound sim-reset-request buffer # (``ctx.sim_reset_requested``), cleared before each physics step and # consumed by _reset_done_envs. @@ -509,6 +511,8 @@ def init_state(self) -> ArrayEnvState: state.terminated, ) self._kernel_outputs = self._observation_buffer.current.kernel_outputs + final_obs = self._zeros_obs(self.observation_space) + self._final_obs_slot = _ObsBufferSlot.bind(final_obs, state.reward, state.terminated) self._refresh_sim_reads() inputs = self._kernel_inputs self._validate_kernel_context(inputs) @@ -548,6 +552,25 @@ def _execute_observe_kernel(self, inputs: tuple[Any, ...]) -> None: assert self._kernel_outputs is not None self._task_program.observe_kernel(inputs, self._kernel_outputs) + def _capture_final_obs(self, env_ids: np.ndarray) -> FinalObs: + """Observe pre-reset inputs after command on_transition. + + The compiled observe pass is batch-wide. Dedicated outputs preserve the + normal swap buffer; advanced indexing creates owned compact snapshots. + """ + assert self._task_program is not None + assert self._final_obs_slot is not None + slot = self._final_obs_slot + self._task_program.observe_kernel(self._kernel_inputs, slot.kernel_outputs) + obs = slot.observation + return FinalObs( + env_ids=env_ids, + obs=NpObs( + policy=obs.policy[env_ids], + value=None if obs.value is None else obs.value[env_ids], + ), + ) + def _execute_task_kernel(self, inputs: tuple[Any, ...]) -> None: """Evaluate and observe without advancing host-side commands.""" self._execute_evaluate_kernel(inputs) diff --git a/motrix_env_core/src/motrix_env_core/numba/manager/observations.py b/motrix_env_core/src/motrix_env_core/numba/manager/observations.py index 24d5cdef..b10c0a49 100644 --- a/motrix_env_core/src/motrix_env_core/numba/manager/observations.py +++ b/motrix_env_core/src/motrix_env_core/numba/manager/observations.py @@ -30,7 +30,12 @@ def __call__(self, ctx: BuildContext) -> ObsTerm: @dataclass(frozen=True, slots=True, init=False) class ObsTerm(BaseTerm): - """Observation dispatch term with a fixed output width.""" + """Pure observation dispatch with a fixed output width. + + Read context and write only the supplied output, except observation noise + may consume ctx.rand. Do not advance history or commands: truncated steps + may observe again before reset, with the RNG restored after that snapshot. + """ size: int diff --git a/motrix_env_core/tests/test_array_env.py b/motrix_env_core/tests/test_array_env.py index 30aabfbc..191be8b5 100644 --- a/motrix_env_core/tests/test_array_env.py +++ b/motrix_env_core/tests/test_array_env.py @@ -1,12 +1,90 @@ # Copyright Motphys Technology Co., Ltd. 2025, 2026 # SPDX-License-Identifier: Apache-2.0 -"""Regression tests for the ArrayEnv frontend hierarchy (issue #222 MR1).""" +"""Regression tests for the ArrayEnv hierarchy and final-observation lifecycle.""" -from motrix_env_core.array.env import ArrayEnv +import gymnasium as gym +import numpy as np +import pytest + +from motrix_env_core.array.env import ArrayEnv, ArrayEnvState, FinalObs, NpObs +from motrix_env_core.base import EnvCfg, ObsSpace +from motrix_env_core.config.scene import SceneCfg from motrix_env_core.direct.env import DirectEnv from motrix_env_core.numba.manager.env import ManagerEnv from motrix_env_core.registry import _infer_sim_backend +from motrix_env_core.sim.backend import RenderConfig, SimRenderer + + +class _CounterEnv(ArrayEnv): + """Exercise the frontend lifecycle without constructing a simulator backend.""" + + def __init__(self, *, with_value: bool = False): + super().__init__(EnvCfg(scene=SceneCfg(), ctrl_dt=1.0, max_episode_seconds=3.0), num_envs=4) + policy_space = gym.spaces.Box(-np.inf, np.inf, shape=(1,), dtype=np.float32) + value_space = gym.spaces.Box(-np.inf, np.inf, shape=(2,), dtype=np.float32) if with_value else None + self._observation_space = ObsSpace(policy=policy_space, value=value_space) + self._action_space = gym.spaces.Box(-1.0, 1.0, shape=(1,), dtype=np.float32) + self.counters = np.zeros(self.num_envs, dtype=np.float32) + self.next_terminated = np.zeros(self.num_envs, dtype=bool) + self.reset_rows: list[np.ndarray] = [] + self.physics_steps = 0 + self.observation_calls = 0 + + @property + def observation_space(self) -> ObsSpace: + return self._observation_space + + @property + def action_space(self) -> gym.spaces.Box: + return self._action_space + + def reset(self, env_ids: np.ndarray) -> None: + self.reset_rows.append(env_ids.copy()) + self.counters[env_ids] = 0.0 + + def apply_action(self, actions: np.ndarray, state: ArrayEnvState) -> ArrayEnvState: + return state + + def physics_step(self) -> None: + self.physics_steps += 1 + self.counters += 1.0 + + def compute_transition(self, state: ArrayEnvState) -> ArrayEnvState: + state.reward[:] = self.counters + state.terminated[:] = self.next_terminated + return state + + def compute_observation(self, state: ArrayEnvState) -> ArrayEnvState: + self.observation_calls += 1 + state.obs.policy[:, 0] = self.counters + if state.obs.value is not None: + state.obs.value[:, 0] = self.counters + state.obs.value[:, 1] = -self.counters + return state + + def create_renderer(self, config: RenderConfig) -> SimRenderer: + raise NotImplementedError("The counter environment has no simulator or renderer") + + +class _FinalCounterEnv(_CounterEnv): + def __init__(self, *, with_value: bool = False): + super().__init__(with_value=with_value) + self.capture_rows: list[np.ndarray] = [] + self.capture_episode_steps: list[np.ndarray] = [] + self.capture_rewards: list[np.ndarray] = [] + + def _capture_final_obs(self, env_ids: np.ndarray) -> FinalObs: + self.capture_rows.append(env_ids.copy()) + self.capture_episode_steps.append(self.state.episode_steps[env_ids].copy()) + self.capture_rewards.append(self.state.reward[env_ids].copy()) + policy = self.counters[env_ids, None].copy() + value = np.concatenate((policy, -policy), axis=1) if self.has_value_observation else None + return FinalObs(env_ids=env_ids.copy(), obs=NpObs(policy=policy, value=value)) + + +def _step(env: _CounterEnv) -> ArrayEnvState: + return env.step(np.zeros((env.num_envs, 1), dtype=np.float32)) def test_np_env_inherits_array_env() -> None: @@ -21,3 +99,115 @@ def test_manager_env_is_array_env_sibling_of_np_env() -> None: def test_registry_infers_np_backend_for_manager_env() -> None: assert _infer_sim_backend(ManagerEnv) == "np" assert _infer_sim_backend(DirectEnv) == "np" + + +@pytest.mark.parametrize("with_value", [False, True]) +def test_final_obs_absent_on_init_and_nontruncated_step(with_value: bool) -> None: + env = _FinalCounterEnv(with_value=with_value) + initial = env.init_state() + + assert initial.final_obs is None + np.testing.assert_array_equal(initial.obs.policy, np.zeros((4, 1))) + np.testing.assert_array_equal(env.reset_rows[0], np.arange(4)) + assert env.physics_steps == 0 + assert env.observation_calls == 1 + assert env.capture_rows == [] + + state = _step(env) + + assert state.final_obs is None + assert not state.truncated.any() + assert not state.terminated.any() + np.testing.assert_array_equal(state.episode_steps, np.ones(4)) + np.testing.assert_array_equal(state.obs.policy, np.ones((4, 1))) + assert env.physics_steps == 1 + assert env.observation_calls == 2 + assert len(env.reset_rows) == 1 + assert env.capture_rows == [] + + +@pytest.mark.parametrize("with_value", [False, True]) +def test_final_obs_contains_only_truncated_rows_before_auto_reset(with_value: bool) -> None: + env = _FinalCounterEnv(with_value=with_value) + env.init_state() + env.state.episode_steps[:] = [2, 0, 2, 0] + env.counters[:] = [10, 20, 30, 40] + env.next_terminated[1] = True + + state = _step(env) + + final = state.final_obs + assert isinstance(final, FinalObs) + assert isinstance(final.obs, NpObs) + np.testing.assert_array_equal(state.truncated, [True, False, True, False]) + np.testing.assert_array_equal(state.terminated, [False, True, False, False]) + np.testing.assert_array_equal(final.env_ids, [0, 2]) + np.testing.assert_array_equal(final.obs.policy, [[11], [31]]) + np.testing.assert_array_equal(env.capture_rows[0], [0, 2]) + np.testing.assert_array_equal(env.capture_episode_steps[0], [3, 3]) + np.testing.assert_array_equal(env.capture_rewards[0], [11, 31]) + np.testing.assert_array_equal(env.reset_rows[-1], [0, 1, 2]) + np.testing.assert_array_equal(state.episode_steps, [0, 0, 0, 1]) + np.testing.assert_array_equal(state.obs.policy, [[0], [0], [0], [41]]) + assert env.observation_calls == 2 + assert not np.shares_memory(final.obs.policy, env.counters) + assert not np.shares_memory(final.obs.policy, state.obs.policy) + if with_value: + np.testing.assert_array_equal(final.obs.value, [[11, -11], [31, -31]]) + np.testing.assert_array_equal(state.obs.value, [[0, 0], [0, 0], [0, 0], [41, -41]]) + assert final.obs.value_or_policy is final.obs.value + assert not np.shares_memory(final.obs.value, state.obs.value) + else: + assert final.obs.value is None + assert state.obs.value is None + assert final.obs.value_or_policy is final.obs.policy + + +@pytest.mark.parametrize("with_value", [False, True]) +def test_retained_final_obs_survives_later_steps_and_init_state(with_value: bool) -> None: + env = _FinalCounterEnv(with_value=with_value) + env.init_state() + env.state.episode_steps[:] = [2, 0, 2, 0] + env.counters[:] = [10, 20, 30, 40] + retained = _step(env) + final = retained.final_obs + assert final is not None + + current = _step(env) + assert current is not retained + assert current.final_obs is None + assert retained.final_obs is final + assert len(env.capture_rows) == 1 + + # A subsequent capture must not overwrite the first transition's snapshot. + env.state.episode_steps[1] = 2 + later = _step(env) + assert later.final_obs is not None + assert later.final_obs is not final + np.testing.assert_array_equal(later.final_obs.env_ids, [1, 3]) + np.testing.assert_array_equal(later.final_obs.obs.policy, [[23], [43]]) + + reinitialized = env.init_state() + assert reinitialized.final_obs is None + assert retained.final_obs is final + np.testing.assert_array_equal(final.env_ids, [0, 2]) + np.testing.assert_array_equal(final.obs.policy, [[11], [31]]) + if with_value: + np.testing.assert_array_equal(final.obs.value, [[11, -11], [31, -31]]) + else: + assert final.obs.value is None + + +def test_default_final_obs_hook_is_unsupported_on_truncation() -> None: + env = _CounterEnv() + env.init_state() + env.state.episode_steps[2] = 2 + env.counters[2] = 10.0 + + state = _step(env) + + assert state.final_obs is None + np.testing.assert_array_equal(state.truncated, [False, False, True, False]) + np.testing.assert_array_equal(env.reset_rows[-1], [2]) + np.testing.assert_array_equal(state.obs.policy, [[1], [1], [0], [1]]) + np.testing.assert_array_equal(state.episode_steps, [1, 1, 0, 1]) diff --git a/motrix_env_core/tests/test_numba_manager.py b/motrix_env_core/tests/test_numba_manager.py index 60a2f805..074be3b1 100644 --- a/motrix_env_core/tests/test_numba_manager.py +++ b/motrix_env_core/tests/test_numba_manager.py @@ -3,7 +3,7 @@ import sys from concurrent.futures import ThreadPoolExecutor -from dataclasses import fields +from dataclasses import fields, replace from pathlib import Path from types import SimpleNamespace @@ -801,6 +801,60 @@ def test_truncated_envs_reset_before_observation() -> None: np.testing.assert_allclose(state.reward, 0.005) +@pytest.mark.parametrize("with_value", [False, True]) +def test_final_observations_capture_command_before_reset_and_survive_steps(with_value: bool) -> None: + cfg = _ManagerEnvCfg(max_episode_seconds=0.03) + if with_value: + cfg.observations = {"policy": _ManagerPolicyObsCfg(), "value": _LanePolicyObsCfg()} + env = ManagerEnv(cfg, num_envs=3) + # No physics is needed: action/command terms encode observable state. + env.physics_step = lambda: None + initial = env.init_state() + assert initial.final_obs is None + initial.episode_steps[:] = [2, 0, 2] + env.command_terms["counter"].command[:] = 2.0 + state = env.step(np.full((3, 1), 0.25, dtype=np.float32)) + final = state.final_obs + assert final is not None + np.testing.assert_array_equal(final.env_ids, [0, 2]) + np.testing.assert_allclose(final.obs.policy[:, 1], 2.0) + np.testing.assert_allclose(state.obs.policy[[0, 2], 1], -1.0) + np.testing.assert_allclose(state.obs.policy[1, 1], 2.0) + if with_value: + np.testing.assert_array_equal(final.obs.value[:, 0], [0, 2]) + else: + assert final.obs.value is None + assert final.obs.value_or_policy is final.obs.policy + assert not np.shares_memory(final.obs.policy, state.obs.policy) + saved = final.obs.policy.copy() + next_state = env.step(np.full((3, 1), 0.25, dtype=np.float32)) + assert next_state.final_obs is None + assert state.final_obs is final + env.step(np.full((3, 1), 0.25, dtype=np.float32)) + np.testing.assert_array_equal(final.obs.policy, saved) + + +def test_final_observation_noise_uses_shared_observation_rng() -> None: + env = _ManagerEnv() + env.init_state() + rng_before = env._rand.state.copy() + observe = env._task_program.observe_kernel + + def noisy_observe(inputs, outputs): + observe(inputs, outputs) + env._rand.state[:] += np.uint64(1) + outputs.policy_obs[:, 0] = env._rand.state[:, 0].astype(np.float32) + + env._task_program = replace(env._task_program, observe_kernel=noisy_observe) + final = env._capture_final_obs(np.array([0], dtype=np.int64)) + np.testing.assert_array_equal(env._rand.state, rng_before + np.uint64(1)) + saved_final = final.obs.policy.copy() + np.testing.assert_array_equal(final.obs.policy[:, 0], (rng_before[[0], 0] + np.uint64(1)).astype(np.float32)) + env._execute_observe_kernel(env._kernel_inputs) + np.testing.assert_array_equal(env._rand.state, rng_before + np.uint64(2)) + np.testing.assert_array_equal(final.obs.policy, saved_final) + + def test_post_reset_observation_does_not_run_command_evaluation() -> None: env = _ManagerEnv(num_envs=3) state = env.init_state() diff --git a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/agent.py b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/agent.py index aa25986e..ad5f2734 100644 --- a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/agent.py +++ b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/agent.py @@ -23,25 +23,7 @@ from motrix_rl_builtin.fastsac.buffer import EmpiricalNormalization, SimpleReplayBuffer from motrix_rl_builtin.fastsac.config import FastSacAgentCfg from motrix_rl_builtin.fastsac.networks import Actor, Critic - - -def _own(outputs: tuple) -> tuple: - """Copy compiled-region outputs out of the CUDA graph's memory pool. - - With ``mode="reduce-overhead"`` the tensors a compiled function returns live - in the graph's pool, and the next ``cudagraph_mark_step_begin()`` invalidates - them -- reading one afterwards raises rather than returning stale data. These - outputs do outlive the call that made them: the metrics travel to the next log - record, and the actor pair is carried across policy-frequency gating. So they - are copied out here, while they are still valid. - - Cloning a handful of 0-dim tensors costs nothing and does not synchronize; - what the caller must keep avoiding is ``.item()`` / ``float()``, which does. - See https://docs.pytorch.org/docs/2.7/torch.compiler_cudagraph_trees.html - -- "clone tensors of a prior iteration (outside of torch.compile) before you - begin the next run". - """ - return tuple(t.clone() if torch.is_tensor(t) else t for t in outputs) +from motrix_rl_builtin.fastsac.window_metrics import WindowMetrics class FastSacAgent: @@ -57,6 +39,7 @@ def __init__( action_bias: torch.Tensor | None = None, writer=None, world_size: int = 1, + final_obs_bootstrap: bool = False, ): """Build the actor, twin distributional critics, optimizers and replay buffer. @@ -70,6 +53,8 @@ def __init__( across ranks manually after each backward and splits ``batch_size`` across ranks; the all-reduce stays in the eager orchestrator so the compiled halves remain CUDA-graph capturable (see _update_main). + ``final_obs_bootstrap`` enables the replay buffer's terminal-observation + storage for time-limit-truncated bootstrapping. """ self.cfg = cfg self.device = device @@ -86,6 +71,10 @@ def __init__( # publish-cadence bookkeeping. self.update_idx = 0 self._last_update_timing_ms: dict[str, float] = {} + # Window-aggregated diagnostics (see window_metrics.py): means and + # per-key extremes accumulated across update() calls until the trainer + # drains them at a logging boundary. + self.window_metrics = WindowMetrics() self.actor = Actor( n_obs=obs_dim, @@ -150,6 +139,7 @@ def __init__( n_steps=cfg.num_steps, gamma=cfg.gamma, device=device, + final_obs_bootstrap=final_obs_bootstrap, ) # AMP / autocast setup: auto-disabled on CPU. dtype resolved from cfg.amp_dtype. @@ -285,6 +275,9 @@ def _update_main(self, b: dict): self._allreduce_module_grads([self.qnet]) if cfg.max_grad_norm > 0: torch.nn.utils.clip_grad_norm_(self.qnet.parameters(), cfg.max_grad_norm) + self.window_metrics.add("qf_loss", qf_loss) + self.window_metrics.add("qf_max", tv_max, track_range=True) + self.window_metrics.add("qf_min", tv_min, track_range=True) self.q_optimizer.step() # Target-network soft update. Ordering constraints: after the target @@ -307,6 +300,9 @@ def _update_main(self, b: dict): # gradient manually to keep every rank's temperature identical. dist.all_reduce(self.log_alpha.grad, op=dist.ReduceOp.AVG) self.alpha_optimizer.step() + self.window_metrics.add("alpha_loss", alpha_loss) + self.window_metrics.add("alpha_loss_abs", alpha_loss.abs(), track_range=True) + self.window_metrics.add("alpha", self.log_alpha.exp()) return (qf_loss, alpha_loss.detach().float(), tv_max, tv_min) @@ -316,7 +312,13 @@ def _actor_backward(self, b: dict): actions, log_probs = self._actor_runtime.get_actions_and_log_probs(b["obs"]) q_outputs = self._qnet_runtime(b["critic_obs"], actions) q_values = self._qnet_runtime.get_value(F.softmax(q_outputs, dim=-1)) - qf_value = q_values.mean(dim=0) + # Use the minimum expected Q across critic heads rather than their + # mean: one overestimating head can lift the mean and encourage the + # actor to exploit that error. The minimum gives a conservative + # policy objective, at the cost of possible underestimation. The + # g1-wbt-dance Q-min + final-observation ablation was more stable + # than Q-mean + final-observation, without a hard actor-mean clamp. + qf_value = q_values.min(dim=0).values actor_loss = (self.log_alpha.exp().detach() * log_probs - qf_value).mean() self.actor_optimizer.zero_grad(set_to_none=True) @@ -331,23 +333,19 @@ def _update_pol(self, b: dict): self._allreduce_module_grads([self.actor, self.qnet]) if self.cfg.max_grad_norm > 0: torch.nn.utils.clip_grad_norm_(self.actor.parameters(), self.cfg.max_grad_norm) + self.window_metrics.add("policy_entropy", neg_logp, track_range=True) + self.window_metrics.add("actor_loss", actor_loss) self.actor_optimizer.step() return actor_loss, neg_logp - def update(self, num_updates: int): + def update(self, num_updates: int) -> None: """Run ``num_updates`` gradient steps, each on a fresh batch. ``num_updates`` is required — callers pass either ``cfg.num_updates`` - (sync) or a UTD-governed count (async). Returns a dict of **raw GPU - tensors** (no float conversion) for the last update, or ``None`` if - ``num_updates <= 0``. Converting to python floats would force a - cudaStreamSynchronize per call, draining the GPU pipeline and blocking - kernel submission for the next env step. The caller should ``.item()`` - / ``float()`` only when actually emitting a log record. - - Those tensors are safe to hold until then: under ``mode="reduce-overhead"`` - they would otherwise live in the CUDA graph pool and be invalidated by the - next update, so :func:`_own` copies them out at the point they are made. + (sync) or a UTD-governed count (async); values ``<= 0`` return without + stepping. Per-step diagnostics are accumulated in + :attr:`window_metrics` (raw tensors, no host synchronization); the + trainers drain them at their logging boundary. Policy-frequency gating uses the persistent ``self.update_idx`` counter (not a per-call ``i``), so the actor/Q update ratio is exactly @@ -356,13 +354,11 @@ def update(self, num_updates: int): """ cfg = self.cfg if num_updates <= 0: - return None + return # Global batch split across DDP ranks (== batch_size when world_size 1); # gradient averaging makes the update equivalent to the sync # trainer's single global-batch step. batch_per_env = max(cfg.batch_size // self.world_size // self.num_envs, 1) - last = (torch.zeros((), device=self.device),) * 5 - actor_pair = (last[3], last[4]) timing_s = {key: 0.0 for key in ("sample_normalize", "critic_alpha", "actor")} update_started = time.perf_counter() # Batched data preparation (Holosoma-style): sample once and normalize @@ -391,44 +387,25 @@ def update(self, num_updates: int): for i in range(num_updates): b = prepared[i] + self.window_metrics.add("batch_reward_max", b["rewards"].max(), track_range=True) + self.window_metrics.add("batch_reward_mean", b["rewards"].mean()) # Required with reduce-overhead (CUDA graph trees): open a new graph # generation for this update. NOTE it does not preserve anything -- # it *invalidates* the previous generation's outputs, which is why - # any output that outlives its own iteration goes through `_own`. + # no compiled output may be read after this loop iteration. torch.compiler.cudagraph_mark_step_begin() stage_started = time.perf_counter() - outputs = self._update_main(b) + self._update_main(b) timing_s["critic_alpha"] += time.perf_counter() - stage_started if (self.update_idx + i) % cfg.policy_frequency == 0: stage_started = time.perf_counter() - pol_outputs = self._update_pol(b) + self._update_pol(b) timing_s["actor"] += time.perf_counter() - stage_started - # Always own: the pair is carried across later generations - # within this call AND the returned metrics must stay readable - # after future update() calls replay the pol graph. - actor_pair = _own(pol_outputs) - - # Only the final step's main outputs feed the returned metrics; own - # them so they survive future update() calls' replays. Earlier - # steps' outputs are never read — only replaced below. - # The critic tuple carries a fourth tv_min element the metrics - # dict does not expose, so compose the five-slot layout from the - # first three main outputs. - main_outputs = _own(outputs) if i == num_updates - 1 else outputs - last = (*main_outputs[:3], *actor_pair) self.update_idx += num_updates timing_s["total"] = time.perf_counter() - update_started self._last_update_timing_ms = {key: value * 1000.0 for key, value in timing_s.items()} - return { - "qf_loss": last[0], - "alpha_loss": last[1], - "qf_max": last[2], - "actor_loss": last[3], - "policy_entropy": last[4], - "alpha": self.log_alpha.exp(), - } # --------------------------------------------------------------- rollout @torch.no_grad() diff --git a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/collector.py b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/collector.py index 5882662c..ded1ce5a 100644 --- a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/collector.py +++ b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/collector.py @@ -26,6 +26,7 @@ SharedTransitionRing, bind_flat_params, ) +from motrix_rl_builtin.fastsac.async_impl.transport.ring import FinalObsPayload from motrix_rl_builtin.fastsac.async_impl.transport.weight_channel import WeightReceiver from motrix_rl_builtin.fastsac.buffer import EmpiricalNormalization from motrix_rl_builtin.fastsac.config import FastSacAgentCfg, FastSacCfg @@ -235,11 +236,10 @@ def policy_lag(self) -> int: # ------------------------------------------------------------------ step def step_once(self) -> bool: - """Run one env-step batch and push it to the ring. + """Publish one step after checking slot and worst-case final-row capacity. - Returns ``False`` if the ring was full (backpressure) — the caller should - let the learner drain and retry. The env is only stepped when there is - room, so no transition is ever dropped. + The SPSC producer is the only allocator and the consumer only frees + capacity, so the pre-step check guarantees this step can be published. """ now = time.perf_counter() if self.ring.is_full(): @@ -251,30 +251,36 @@ def step_once(self) -> bool: self._wait_t += now - self._wait_started self._wait_started = None - t0 = now steps = self.control.collector_steps_at(self.collector_id) warming = steps < self._learning_starts - t_sample_actions = time.perf_counter() + t_sample = time.perf_counter() actions = self._sample_actions(warming) t_env = time.perf_counter() - next_obs, next_critic_obs, rewards, terminated, truncated = self.env.step(actions) + result = self.env.step(actions) + t_payload = time.perf_counter() + final_payload = None + if self.ring.final_obs_store is not None: + final = result.final_obs + if final is not None: + final_payload = FinalObsPayload( + torch.as_tensor(final.env_ids, dtype=torch.long), + torch.as_tensor(final.obs.policy), + torch.as_tensor(final.obs.value_or_policy), + ) + rewards = result.reward.detach() + terminated = result.terminated.detach().long() + truncated = result.truncated.detach().long() t_push = time.perf_counter() - - # The transition is (obs, action, reward, done) with obs being the - # pre-step observation; next_obs of step t is the stored obs of t+1. - # The learner derives next_obs from the successor slot, so the newest - # batch only becomes ingestible after the next push; the final batch - # pushed before training stops is intentionally dropped (one batch of - # num_envs transitions out of a full training run). - pushed = self.ring.push( + # Capacity was established before stepping; no other producer can use it. + self.ring.push( self.obs.detach(), self.critic_obs.detach(), actions.detach(), - rewards.detach(), - terminated.detach().long(), - truncated.detach().long(), + rewards, + terminated, + truncated, + final_obs=final_payload, ) - assert pushed, "ring became full after is_full() check — single-producer invariant violated" t_bookkeep = time.perf_counter() # episode bookkeeping (mirrors sync agent.py collect phase) @@ -293,7 +299,7 @@ def step_once(self) -> bool: self.recent_returns = self.recent_returns[-100:] self.recent_lengths = self.recent_lengths[-100:] - info = self.env.last_info + info = result.info reward_terms = info.get("Reward") if reward_terms: for k, v in reward_terms.items(): @@ -301,8 +307,8 @@ def step_once(self) -> bool: self.term_count += 1 self.last_env_metrics = {k: float(np.mean(v)) for k, v in info.get("metrics", {}).items()} - self.obs = next_obs - self.critic_obs = next_critic_obs + self.obs = result.obs + self.critic_obs = result.critic_obs self.control.inc_collector_steps(self.collector_id) t_sync = time.perf_counter() @@ -310,9 +316,9 @@ def step_once(self) -> bool: self.sync_weights(record_timing=True) t_done = time.perf_counter() - self._collect_t += t_done - t0 - self._sample_actions_t += t_env - t_sample_actions - self._env_step_t += t_push - t_env + self._collect_t += t_done - t_sample + self._sample_actions_t += t_env - t_sample + self._env_step_t += t_payload - t_env self._push_t += t_bookkeep - t_push self._bookkeep_t += t_sync - t_bookkeep self._sync_t += t_done - t_sync diff --git a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/learner.py b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/learner.py index 640b6af7..c70568c3 100644 --- a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/learner.py +++ b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/learner.py @@ -24,7 +24,11 @@ import torch.distributed as dist from motrix_rl_builtin.fastsac.agent import FastSacAgent -from motrix_rl_builtin.fastsac.async_impl.transport import Control, IpcTransitionRing, SharedTransitionRing +from motrix_rl_builtin.fastsac.async_impl.transport import ( + Control, + IpcTransitionRing, + SharedTransitionRing, +) from motrix_rl_builtin.fastsac.async_impl.transport.weight_channel import WeightSender from motrix_rl_builtin.fastsac.buffer import EmpiricalNormalization from motrix_rl_builtin.fastsac.config import FastSacCfg @@ -124,13 +128,16 @@ def acquire(self) -> None: """Join in-flight copies before the buffers get overwritten.""" self.stream.synchronize() - def stage(self, views) -> None: + def stage(self, views, final_payloads=None) -> None: """Copy one host run into the pinned buffers and issue the H2D ingest.""" k = views[0].shape[0] for stage, view in zip(self.buffers, views): stage[:k].copy_(view) # ring -> pinned (plain CPU memcpy) with torch.cuda.stream(self.stream): - self.rb.extend_batch(*(stage[:k] for stage in self.buffers)) + if final_payloads is None: + self.rb.extend_batch(*(stage[:k] for stage in self.buffers)) + else: + self.rb.extend_batch(*(stage[:k] for stage in self.buffers), final_payloads=final_payloads) self.pending = True def mark_in_flight(self) -> None: @@ -256,7 +263,7 @@ def drain(self) -> int: def _drain_rings(self, budget: int) -> int: for ring_id, ring in enumerate(self.rings): - base = ring.read_idx + base = ring.read_issued_count avail = min(budget, ring.size()) if avail <= 0: continue @@ -288,7 +295,7 @@ def _stall_diag(self, tag: str) -> None: oldest, newest, count, - [(r.read_idx, r.write_idx) for r in self.rings], + [(r.read_completed_count, r.write_completed_count) for r in self.rings], ) def _flush_complete_generations(self) -> int: @@ -308,14 +315,22 @@ def _flush_complete_generations(self) -> int: # runs arrive env-major per generation (n_env, dim); the buffer write # path is slot-major (k, n_env, dim), so stack generations first. stacked = tuple(torch.stack(column, dim=0) for column in zip(*runs)) - ingested = self._ingest_runs((stacked,)) + # Payload belongs to the same unread slots, not a separately stamped + # event stream. It is copied to owned rows before acknowledging slots. + final_payloads = None + if len(self.rings) == 1 and self.rings[0].final_obs_store is not None: + final_payloads = self.rings[0].read_final_span(len(generations)) + ingested = self._ingest_runs((stacked,), final_payloads=final_payloads) + if final_payloads is not None: + events = sum(p.env_ids.numel() for p in final_payloads if p is not None) + self.agent.window_metrics.add("final_obs_events", torch.tensor(float(events), device=self.agent.device)) for ring in self.rings: ring.commit_reads(ingested) if self._staging is not None: self._staging.mark_in_flight() return ingested - def _ingest_runs(self, runs) -> int: + def _ingest_runs(self, runs, final_payloads=None) -> int: """Ingest slot-major device runs ``(k, n_env, dim)``; caller commits reads. Every shard was lifted to the ingest device before assembly, so each @@ -327,11 +342,14 @@ def _ingest_runs(self, runs) -> int: """ if self._staging is not None: # pure-host rank: host run -> pinned buffers -> async H2D extend - self._staging.stage(runs[0]) + self._staging.stage(runs[0], final_payloads=final_payloads) return runs[0][0].shape[0] k = 0 for views in runs: - self.agent.rb.extend_batch(*views) + if final_payloads is None: + self.agent.rb.extend_batch(*views) + else: + self.agent.rb.extend_batch(*views, final_payloads=final_payloads) k += views[0].shape[0] return k @@ -341,7 +359,7 @@ def wait_ingest(self) -> None: if self._staging is not None: self._staging.finish() - def maybe_train(self, gstep: int) -> dict | None: + def maybe_train(self, gstep: int) -> bool: """Run ratio-governed updates when the global progress basis advanced. ``gstep`` is the trainer's full-batch-equivalent progress @@ -380,7 +398,7 @@ def maybe_train(self, gstep: int) -> dict | None: # cumulative delta grows, so nothing is lost on the next call. n = 0 if n <= 0: - return None + return False # Sampling reads replay-buffer slots the copy stream may still be # filling (staged async H2D ingest); make the compute stream wait for # the in-flight copies first, mirroring the pre-staging event wait. @@ -391,17 +409,17 @@ def maybe_train(self, gstep: int) -> dict | None: # ``num_updates``-sized updates (measured 15x more publishes than one # huge update call — collectors act on much fresher weights). base = self.agent.cfg.num_updates - metrics = None + trained = False remaining = n while remaining > 0 and not self.control.stop: - metrics = self.agent.update(min(base, remaining)) + self.agent.update(min(base, remaining)) + trained = True remaining -= base self._last_publish_ms = 0.0 - if metrics is not None: - started = time.perf_counter() - self.publish_if_due() - self._last_publish_ms = (time.perf_counter() - started) * 1000.0 - return metrics + started = time.perf_counter() + self.publish_if_due() + self._last_publish_ms = (time.perf_counter() - started) * 1000.0 + return trained # ------------------------------------------------------------------ publish def _sync_normalizer_stats(self) -> None: diff --git a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/train.py b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/train.py index 3327ec8f..ce47b5ff 100644 --- a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/train.py +++ b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/train.py @@ -44,8 +44,13 @@ from motrix_rl_builtin.fastsac.async_impl.panels import BootPanel from motrix_rl_builtin.fastsac.async_impl.stats import aggregate_collector_stats, nest_timing_path, timing_mean from motrix_rl_builtin.fastsac.async_impl.topology import resolve_trainer_topology -from motrix_rl_builtin.fastsac.async_impl.transport import Control, RingCursors, SharedTransitionRing +from motrix_rl_builtin.fastsac.async_impl.transport import ( + Control, + RingCursors, + SharedTransitionRing, +) from motrix_rl_builtin.fastsac.async_impl.transport.handshake import StartupHandshake +from motrix_rl_builtin.fastsac.async_impl.transport.ring import FinalObsStorage from motrix_rl_builtin.fastsac.async_impl.transport.weight_channel import WeightChannelShared from motrix_rl_builtin.fastsac.async_impl.worker import ( actor_param_numel, @@ -143,6 +148,7 @@ def train(self) -> None: # child rebuilds its own env because env objects are not picklable across spawn. probe = self._make_env(1, render=None, device=torch.device("cpu")) obs_dim, critic_obs_dim, act_dim, action_scale, action_bias = self._dims(probe) + max_episode_steps = probe.max_episode_steps probe.close() dims = (obs_dim, critic_obs_dim, act_dim) @@ -205,11 +211,24 @@ def train(self) -> None: # actually uses (topology is the single source of truth), not the # host inventory. annotate_run(self._context.run_dir, system=capture_system_info(topology=topology)) + final_obs_enabled = max_episode_steps is not None and num_collectors == 1 + final_stores: list[FinalObsStorage] | None = ( + [FinalObsStorage(async_options.ring_capacity, shard, obs_dim, critic_obs_dim) for shard in env_shards] + if final_obs_enabled + else None + ) rings: list[SharedTransitionRing | RingCursors] = [ RingCursors() if ipc - else SharedTransitionRing(async_options.ring_capacity, shard, obs_dim, critic_obs_dim, act_dim) - for ipc, shard in zip(ring_ipc, env_shards) + else SharedTransitionRing( + async_options.ring_capacity, + shard, + obs_dim, + critic_obs_dim, + act_dim, + final_obs_store=final_stores[i] if final_stores is not None else None, + ) + for i, (ipc, shard) in enumerate(zip(ring_ipc, env_shards)) ] weights = [WeightChannelShared(obs_dim=obs_dim) for _ in range(num_collectors)] control = Control(num_collectors) @@ -326,6 +345,11 @@ def _drain_child_errors() -> list[tuple[str, str]]: ], "learner_cpus": topology.learners[rank].cpus if learner_numa_nodes else None, "learner_numa_node": learner_numa_nodes[rank] if learner_numa_nodes else None, + "final_stores": ( + final_stores[rank * per_learner : (rank + 1) * per_learner] + if final_stores is not None + else None + ), }, name="fastsac-async-learner" if num_learners == 1 else f"fastsac-async-learner-{rank}", ) @@ -356,6 +380,7 @@ def _drain_child_errors() -> list[tuple[str, str]]: "collector_device": str(topology.collectors[i].device), "run_dir": str(self._context.run_dir), "handshake": handshake, + "final_store": final_stores[i] if final_stores is not None else None, }, name=f"fastsac-async-collector-{i}", ) @@ -824,7 +849,7 @@ def play(self, policy: str) -> None: try: while True: actions = agent.act(obs, deterministic=True) - obs, _, _, _, _ = env.step(actions) + obs = env.step(actions).obs if env.render() is False: break finally: diff --git a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/transport/ipc_ring.py b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/transport/ipc_ring.py index 943effe2..2a342155 100644 --- a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/transport/ipc_ring.py +++ b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/transport/ipc_ring.py @@ -47,7 +47,7 @@ import torch -from motrix_rl_builtin.fastsac.async_impl.transport.ring import RingCursors +from motrix_rl_builtin.fastsac.async_impl.transport.ring import FinalObsPayload, FinalObsStorage, RingCursors # A slot-boundary event and the cursor value it certifies once complete: # the write (or read) cursor value the entry advances the shared cursor to @@ -75,6 +75,7 @@ def __init__( obs_dim: int, critic_obs_dim: int, act_dim: int, + final_obs_store: FinalObsStorage | None = None, ): if not slots.is_cuda: raise ValueError(f"IPC transition ring requires CUDA slots, got device {slots.device}") @@ -88,6 +89,7 @@ def __init__( self.obs_dim = obs_dim self.critic_obs_dim = critic_obs_dim self.act_dim = act_dim + self.final_obs_store = final_obs_store # Field layout along the fused last dimension. self._o0 = 0 self._o1 = obs_dim @@ -99,8 +101,8 @@ def __init__( # event-proven (published) values, which LAG the issued counts while # copies are in flight — so slot indexing and publish targets must come # from the issued counts, never from the cursors. - self._issued_writes = self.cursors.write_idx - self._issued_reads = self.cursors.read_idx + self._write_issued_count = self.cursors.write_completed_count + self._read_issued_count = self.cursors.read_completed_count # Producer-side: events of pushed-but-not-yet-published slots, in # order. (published cursor value, event) — the write cursor value the # entry certifies once its event completes. Deques: flush pops from @@ -121,25 +123,41 @@ def _flush_writes(self) -> None: self.cursors._write[0] = events.popleft().cursor @property - def write_idx(self) -> int: + def write_completed_count(self) -> int: + """Cumulative slots whose writes completed and were published.""" self._flush_writes() - return self.cursors.write_idx + return self.cursors.write_completed_count @property - def read_idx(self) -> int: + def read_completed_count(self) -> int: + """Cumulative slots whose reads completed and were released.""" self._flush_reads() - return self.cursors.read_idx + return self.cursors.read_completed_count + + @property + def write_issued_count(self) -> int: + """Cumulative slots submitted for writing, including copies in flight.""" + return self._write_issued_count + + @property + def read_issued_count(self) -> int: + """Cumulative slots consumed, including acknowledgements still in flight.""" + return self._read_issued_count def size(self) -> int: - """Published (event-proven) unread count — the observable ring fill.""" - return self.write_idx - self.read_idx + """Published but not yet released slot count, including reads in flight.""" + return self.write_completed_count - self.read_completed_count def is_full(self) -> bool: # Backpressure must count the producer's issued (in-flight included) # writes, so a burst of pushes can never exceed capacity. The flush # publishes completed slots, mirroring the collector's every-step poll. self._flush_writes() - return self._issued_writes - self.read_idx >= self.capacity + completed_reads = self.read_completed_count + if self._write_issued_count - completed_reads >= self.capacity: + return True + store = self.final_obs_store + return store is not None and not store.can_write_rows(self.num_envs, completed_reads) def push( self, @@ -149,6 +167,7 @@ def push( rewards: torch.Tensor, dones: torch.Tensor, truncations: torch.Tensor, + final_obs: FinalObsPayload | None = None, ) -> bool: """Publish one env-step batch into the next slot. Returns False if full. @@ -158,6 +177,11 @@ def push( """ if self.is_full(): return False + store = self.final_obs_store + if final_obs is not None and store is None: + raise ValueError("final payload requires a slot final-observation store") + if store is not None and not store.can_write(final_obs, self.cursors.read_completed_count): + return False if self._staging_used and not self._staging_event.query(): # The previous H2D still reads the staging buffer; it completes in # well under one env step, so this is normally a no-op wait. @@ -169,14 +193,16 @@ def push( s[:, self._o3 : self._o4].copy_(rewards.reshape(-1, 1)) s[:, self._o4 : self._o5].copy_(dones.reshape(-1, 1)) s[:, self._o5 :].copy_(truncations.reshape(-1, 1)) - issued = self._issued_writes + issued = self._write_issued_count slot = issued % self.capacity + if store is not None: + store.write(issued, final_obs) stream = torch.cuda.current_stream(self.slots.device) self.slots[slot].copy_(s, non_blocking=True) event = torch.cuda.Event() event.record(stream) self._write_events.append(_PendingEvent(issued + 1, event)) - self._issued_writes = issued + 1 + self._write_issued_count = issued + 1 self._staging_event.record(stream) self._staging_used = True return True @@ -192,7 +218,7 @@ def has_next(self) -> bool: # Flush this endpoint's commit events so the producer's is_full sees # freed slots (the learner's every-loop poll). self._flush_reads() - return self.write_idx > self._issued_reads + return self.write_completed_count > self._read_issued_count def read_span(self) -> tuple[int, tuple[torch.Tensor, ...]]: """Longest contiguous unread run, as ``(count, field_views)``. @@ -203,10 +229,10 @@ def read_span(self) -> tuple[int, tuple[torch.Tensor, ...]]: slot after its H2D event completed. Returns ``(0, ())`` when empty. """ self._flush_reads() - available = self.write_idx - self._issued_reads + available = self.write_completed_count - self._read_issued_count if available <= 0: return 0, () - slot = self._issued_reads % self.capacity + slot = self._read_issued_count % self.capacity count = min(available, self.capacity - slot) run = self.slots[slot : slot + count] return count, ( @@ -218,6 +244,12 @@ def read_span(self) -> tuple[int, tuple[torch.Tensor, ...]]: run[:, :, self._o5], ) + def read_final_span(self, count: int) -> list[FinalObsPayload | None]: + """Copy host payloads belonging to the next issued read span.""" + if self.final_obs_store is None: + return [None] * count + return self.final_obs_store.read(self._read_issued_count, count) + def commit_reads(self, n: int) -> None: """Release ``n`` slots of a consumed run once their reads completed. @@ -228,5 +260,5 @@ def commit_reads(self, n: int) -> None: """ event = torch.cuda.Event() event.record(torch.cuda.current_stream(self.slots.device)) - self._read_events.append(_PendingEvent(self._issued_reads + n, event)) - self._issued_reads += n + self._read_events.append(_PendingEvent(self._read_issued_count + n, event)) + self._read_issued_count += n diff --git a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/transport/ring.py b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/transport/ring.py index e59b7d45..eddd6c53 100644 --- a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/transport/ring.py +++ b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/transport/ring.py @@ -38,6 +38,8 @@ from __future__ import annotations +from dataclasses import dataclass + import torch from motrix_rl_builtin.fastsac.async_impl.transport.common import _shared @@ -46,6 +48,10 @@ class RingCursors: """Host shared-memory read/write cursors shared by both ring transports. + Counts are cumulative env-step batches (slots), not physical slot indices; + they do not wrap at capacity. Completed writes are published to the consumer, + and completed reads release slots to the producer. + Created once by the parent process and inherited by both workers. Each cursor has a single writer (the producer owns ``_write``, the consumer owns ``_read``), so plain aligned int64 stores publish progress coherently @@ -57,19 +63,96 @@ def __init__(self): self._read = _shared((1,), torch.int64) @property - def write_idx(self) -> int: + def write_completed_count(self) -> int: # Consumer reads this; single-writer producer, so a plain aligned int64 # load is coherent. On x86/TSO the data loads that follow cannot be # reordered ahead of it, so no acquire barrier is needed (x86-only). return int(self._write[0]) @property - def read_idx(self) -> int: + def read_completed_count(self) -> int: # Producer reads this; single-writer consumer. On x86/TSO the following # is_full() decision is based on an up-to-date value without a barrier. return int(self._read[0]) +@dataclass +class FinalObsPayload: + """Owned compact CPU observation rows belonging to one transition slot.""" + + env_ids: torch.Tensor + obs: torch.Tensor + critic_obs: torch.Tensor + + +class FinalObsStorage: + """Shared sparse rows whose lifetime is governed by the transition cursors. + + Only the producer manages row allocation. Every slot records an absolute + row end, including slots without a payload. Observing an acknowledged slot + releases its rows; no independently published or consumed cursor exists. + """ + + def __init__(self, capacity: int, num_envs: int, obs_dim: int, critic_obs_dim: int): + self.slot_capacity = capacity + self.capacity = 3 * num_envs + self.env_ids = _shared((self.capacity,), torch.int64) + self.obs = _shared((self.capacity, obs_dim), torch.float32) + self.critic_obs = _shared((self.capacity, critic_obs_dim), torch.float32) + self.starts = _shared((capacity,), torch.int64) + self.counts = _shared((capacity,), torch.int64) + self.ends = _shared((capacity,), torch.int64) + self._row_write = 0 + self._row_read = 0 + self._reclaimed_slots = 0 + + def can_write(self, payload: FinalObsPayload | None, acknowledged: int) -> bool: + n = 0 if payload is None else len(payload.env_ids) + if n > self.capacity: + raise ValueError("final payload exceeds the sparse row store capacity") + return self.can_write_rows(n, acknowledged) + + def can_write_rows(self, count: int, acknowledged: int) -> bool: + """Reclaim completed slots and check space for a prospective row count.""" + # Reclaim before the producer can overwrite any slot metadata. The + # main ring's capacity guarantees these ends still belong to the + # acknowledged generation. IPC callers pass event-proven cursors. + while self._reclaimed_slots < acknowledged: + slot = self._reclaimed_slots % self.slot_capacity + self._row_read = int(self.ends[slot]) + self._reclaimed_slots += 1 + return self._row_write - self._row_read + count <= self.capacity + + def write(self, index: int, payload: FinalObsPayload | None) -> None: + """Write payload before the owning transition slot is published.""" + slot = index % self.slot_capacity + n = 0 if payload is None else len(payload.env_ids) + start = self._row_write + if n: + rows = (start + torch.arange(n, dtype=torch.int64)) % self.capacity + self.env_ids[rows] = torch.as_tensor(payload.env_ids, dtype=torch.int64) + self.obs[rows] = torch.as_tensor(payload.obs, dtype=torch.float32) + self.critic_obs[rows] = torch.as_tensor(payload.critic_obs, dtype=torch.float32) + self.starts[slot] = start + self.counts[slot] = n + self.ends[slot] = start + n + self._row_write = start + n + + def read(self, index: int, count: int) -> list[FinalObsPayload | None]: + """Copy all payload rows before the caller acknowledges their slots.""" + result = [] + for offset in range(count): + slot = (index + offset) % self.slot_capacity + n = int(self.counts[slot]) + if not n: + result.append(None) + continue + rows = (int(self.starts[slot]) + torch.arange(n, dtype=torch.int64)) % self.capacity + # Advanced indexing produces owned snapshots, including on wrap. + result.append(FinalObsPayload(self.env_ids[rows], self.obs[rows], self.critic_obs[rows])) + return result + + # ---------------------------------------------------------------- transition ring class SharedTransitionRing: """SPSC ring of transition batches with bounded backpressure (host fields). @@ -105,6 +188,7 @@ def __init__( critic_obs_dim: int, act_dim: int, cursors: RingCursors | None = None, + final_obs_store: FinalObsStorage | None = None, ): if capacity < 1: raise ValueError(f"ring_capacity must be >= 1, got {capacity}") @@ -118,23 +202,40 @@ def __init__( self.dones = _shared((capacity, num_envs), i64) self.truncations = _shared((capacity, num_envs), i64) self.cursors = cursors if cursors is not None else RingCursors() + self.final_obs_store = final_obs_store + + @property + def write_completed_count(self) -> int: + return self.cursors.write_completed_count + + @property + def read_completed_count(self) -> int: + return self.cursors.read_completed_count @property - def write_idx(self) -> int: - return self.cursors.write_idx + def write_issued_count(self) -> int: + """Cumulative slots written; host writes complete synchronously.""" + return self.write_completed_count @property - def read_idx(self) -> int: - return self.cursors.read_idx + def read_issued_count(self) -> int: + """Cumulative slots consumed; host acknowledgements complete synchronously.""" + return self.read_completed_count def size(self) -> int: - """Number of unread slots currently buffered.""" - return self.write_idx - self.read_idx + """Published but not yet released slot count.""" + return self.write_completed_count - self.read_completed_count def is_full(self) -> bool: - return self.size() >= self.capacity - - def push(self, obs, critic_obs, actions, rewards, dones, truncations) -> bool: + """Whether slots or worst-case final rows prevent another env step.""" + if self.size() >= self.capacity: + return True + store = self.final_obs_store + return store is not None and not store.can_write_rows(self.num_envs, self.read_completed_count) + + def push( + self, obs, critic_obs, actions, rewards, dones, truncations, final_obs: FinalObsPayload | None = None + ) -> bool: """Copy one env-step batch into the next slot. Returns False if full. Inputs are CPU tensors shaped ``(num_envs, dim)``; ``dones``/``truncations`` @@ -142,16 +243,24 @@ def push(self, obs, critic_obs, actions, rewards, dones, truncations) -> bool: """ if self.is_full(): return False - slot = self.write_idx % self.capacity + store = self.final_obs_store + if final_obs is not None and store is None: + raise ValueError("final payload requires a slot final-observation store") + if store is not None and not store.can_write(final_obs, self.read_completed_count): + return False + index = self.write_completed_count + slot = index % self.capacity self.obs[slot].copy_(obs) self.critic_obs[slot].copy_(critic_obs) self.actions[slot].copy_(actions) self.rewards[slot].copy_(rewards) self.dones[slot].copy_(dones) self.truncations[slot].copy_(truncations) + if store is not None: + store.write(index, final_obs) # Publish the slot. On x86/TSO the field copies above are guaranteed # visible before this cursor bump, so a consumer that reads the new - # write_idx also sees the data (x86-only; ARM would need a release here). + # write_completed_count also sees the data (x86-only; ARM would need a release here). self.cursors._write[0] += 1 return True @@ -172,7 +281,7 @@ def read_span(self) -> tuple[int, tuple]: size = self.size() if size <= 0: return 0, () - slot = self.read_idx % self.capacity + slot = self.read_completed_count % self.capacity count = min(size, self.capacity - slot) return count, ( self.obs[slot : slot + count], @@ -183,6 +292,12 @@ def read_span(self) -> tuple[int, tuple]: self.truncations[slot : slot + count], ) + def read_final_span(self, count: int) -> list[FinalObsPayload | None]: + """Owned final payloads aligned with the current transition read span.""" + if self.final_obs_store is None: + return [None] * count + return self.final_obs_store.read(self.read_completed_count, count) + def commit_reads(self, n: int) -> None: """Advance the read cursor by ``n`` slots of a consumed contiguous run. diff --git a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/worker.py b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/worker.py index f9d3decf..21ee5e56 100644 --- a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/worker.py +++ b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/worker.py @@ -50,6 +50,7 @@ SharedTransitionRing, ) from motrix_rl_builtin.fastsac.async_impl.transport.handshake import StartupHandshake +from motrix_rl_builtin.fastsac.async_impl.transport.ring import FinalObsStorage from motrix_rl_builtin.fastsac.async_impl.transport.weight_channel import ( GpuIpcWeightSender, HostWeightSender, @@ -124,6 +125,7 @@ def build_agent( action_bias: torch.Tensor | None, writer: SummaryWriter | None = None, world_size: int = 1, + final_obs_bootstrap: bool = False, ) -> FastSacAgent: """Build the learner's ``FastSacAgent``. @@ -131,6 +133,8 @@ def build_agent( ``action_bias`` map the tanh-squashed policy output to the env action range (``None`` = identity). ``writer`` is the parent-created TensorBoard writer; ``world_size`` is the DDP learner-rank count. + ``final_obs_bootstrap`` enables the replay buffer's terminal-observation + storage for time-limit-truncated bootstrapping. """ obs_dim, critic_obs_dim, act_dim = dims return FastSacAgent( @@ -144,6 +148,7 @@ def build_agent( action_bias=action_bias, writer=writer, world_size=world_size, + final_obs_bootstrap=final_obs_bootstrap, ) @@ -331,6 +336,7 @@ def run_collector_process( numa_node: int | None = None, cpus: list[int] | None = None, collector_device: str | None = None, + final_store: FinalObsStorage | None = None, ) -> None: role = f"collector[{collector_id}]" _install_graceful_sigint(control) @@ -369,7 +375,16 @@ def run_collector_process( if isinstance(ring, RingCursors): if ring_slots is None: raise RuntimeError("the learner shipped no transition-ring slots for the IPC ring handshake") - ring = IpcTransitionRing(ring, ring_slots, opts.ring_capacity, num_envs, obs_dim, critic_obs_dim, act_dim) + ring = IpcTransitionRing( + ring, + ring_slots, + opts.ring_capacity, + num_envs, + obs_dim, + critic_obs_dim, + act_dim, + final_obs_store=final_store, + ) collector = Collector( env, cfg, @@ -464,10 +479,14 @@ def run_learner_process( num_learners: int = 1, rendezvous_file: str | None = None, learner_device: str | None = None, + final_stores: list[FinalObsStorage] | None = None, ) -> None: """One learner process; ``rank 0`` additionally owns stats aggregation, logging and checkpointing. + ``final_stores`` is either absent for all owned collectors or contains one + store per owned ring; final-observation support is uniform across collectors. + Multi-learner (DDP) specifics: ``rings`` is this rank's slice (the parent partitions by collector ownership) while ``all_rings`` is the full list (rank-0 logging only); ``weights`` is this rank's slice of the weight @@ -526,6 +545,7 @@ def run_learner_process( action_scale, action_bias, world_size=num_learners, + final_obs_bootstrap=final_stores is not None, ) if resume_from: ckpt = torch.load(resume_from, map_location=device, weights_only=False) @@ -586,6 +606,7 @@ def run_learner_process( obs_dim, critic_obs_dim, act_dim, + final_obs_store=final_stores[j] if final_stores is not None else None, ) handshake.ship_ring_slots(rank * per_learner + j, ring_slots) @@ -610,7 +631,7 @@ def run_learner_process( start_anchored = False last_log_time = start_time last_log_step = resume_step - last_metrics = None + train_started = False # First payload goes out immediately (step 0) so the parent's panel # quiescence gate opens as soon as collectors take their first steps, # instead of after a silent full log window; resume keeps window @@ -664,9 +685,9 @@ def run_learner_process( # One decision path for both topologies: updates are gated on the # global progress basis (identical on every DDP rank; the single # learner's own position when num_collectors == 1). - metrics = learner.maybe_train(step) - if metrics is not None: - last_metrics = metrics + trained = learner.maybe_train(step) + if trained: + train_started = True elapsed_learn_s = time.perf_counter() - t_l t_learn_win += elapsed_learn_s learner_train_samples_ms.append(elapsed_learn_s * 1000.0) @@ -693,7 +714,14 @@ def run_learner_process( now = time.time() sps = (step - last_log_step) * num_envs / max(now - last_log_time, 1e-6) warming = step < agent.cfg.learning_starts - metrics_log = {k: float(v) for k, v in last_metrics.items()} if (last_metrics and not warming) else None + metrics_log = None + if train_started and not warming: + # Window-aggregated diagnostics only (means + extremes + # since the last log window): single-update events between + # log points stay visible, mirroring holosoma's averaged + # meter logging. train_started just gates "training + # started". + metrics_log = learner.agent.window_metrics.stats_and_reset() learn_pct = 100.0 * t_learn_win / max(now - last_log_time, 1e-9) learner_items: dict[str, Any] = {} if learner_drain_samples_ms: diff --git a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/buffer.py b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/buffer.py index f7179b77..ecae0a83 100644 --- a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/buffer.py +++ b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/buffer.py @@ -38,6 +38,7 @@ def __init__( n_steps: int = 1, gamma: float = 0.99, device=None, + final_obs_bootstrap: bool = False, ): super().__init__() self.n_env = n_env @@ -65,6 +66,44 @@ def __init__( self.critic_observations = z(n_critic_obs) self.ptr = 0 + # Final rows are allocated only for live truncated transitions. Capacity + # grows geometrically, bounded by the replay's transition capacity; no + # assumption about time between truncations is required. Allocation and + # ownership bookkeeping are eager ingest work, outside compiled updates. + self._final_obs_table = None + self._final_critic_table = None + self._final_slot = None + self._final_capacity = 0 + self._final_free: list[int] = [] + self._final_owners: list[dict[int, int]] = [{} for _ in range(cap)] + if final_obs_bootstrap: + self._enable_final_storage() + + def _enable_final_storage(self) -> None: + self._final_slot = torch.full((self.n_env, self._cap), -1, device=self.device, dtype=torch.int32) + self._grow_final_storage(self.n_env) + + def _grow_final_storage(self, needed: int) -> None: + old = self._final_capacity + capacity = min(self.n_env * self._cap, max(self.n_env, old * 2, old + needed)) + # Tables may be referenced by work on the compute and ingest streams. + # Growth is rare and eager; finish those uses before replacing backing. + if self.observations.device.type == "cuda": + torch.cuda.synchronize(self.device) + policy = torch.empty((capacity, self.n_obs), device=self.device) + critic = torch.empty((capacity, self.n_critic_obs), device=self.device) + if old: + policy[:old].copy_(self._final_obs_table) + critic[:old].copy_(self._final_critic_table) + else: + # Samples without a final row still gather index zero before masking. + policy[0].zero_() + critic[0].zero_() + self._final_obs_table = policy + self._final_critic_table = critic + self._final_free.extend(range(capacity - 1, old - 1, -1)) + self._final_capacity = capacity + @property def num_stored(self) -> int: # A transition is only complete once the following step's observation @@ -82,6 +121,7 @@ def extend(self, obs, critic_obs, actions, rewards, dones, truncations) -> None: invariant already relied upon by the async transition ring. """ slot = self.ptr % self._cap + self._invalidate_final_slot(slot, 1) self.observations[:, slot] = obs self.critic_observations[:, slot] = critic_obs self.actions[:, slot] = actions @@ -90,7 +130,7 @@ def extend(self, obs, critic_obs, actions, rewards, dones, truncations) -> None: self.truncations[:, slot] = truncations self.ptr += 1 - def extend_batch(self, obs, critic_obs, actions, rewards, dones, truncations) -> None: + def extend_batch(self, obs, critic_obs, actions, rewards, dones, truncations, *, final_payloads=None) -> None: """Append ``k`` transitions per environment from slot-major tensors. Inputs are shaped ``(k, n_env, dim)`` for obs / critic_obs / actions @@ -102,12 +142,70 @@ def extend_batch(self, obs, critic_obs, actions, rewards, dones, truncations) -> a side stream. """ k = obs.shape[0] + if k > self._cap: + # Preserve only the newest replay-capacity transitions, but keep + # absolute time progression identical to successive extends. + skip = k - self._cap + self.ptr += skip + self.extend_batch( + *(v[skip:] for v in (obs, critic_obs, actions, rewards, dones, truncations)), + final_payloads=None if final_payloads is None else final_payloads[skip:], + ) + return start = self.ptr % self._cap + self._invalidate_final_slot(start, k) n = min(k, self._cap - start) self._write_span(start, (t[:n] for t in (obs, critic_obs, actions, rewards, dones, truncations))) if k > n: self._write_span(0, (t[n:] for t in (obs, critic_obs, actions, rewards, dones, truncations))) self.ptr += k + if final_payloads is not None: + for offset, payload in enumerate(final_payloads): + if payload is not None: + self.write_final(payload.env_ids, self.ptr - k + offset, payload.obs, payload.critic_obs) + + def _invalidate_final_slot(self, start: int, count: int) -> None: + """Clear the final-obs grid for slots about to be overwritten. + + The transitions being written carry no terminal observation (yet — a + ``write_final`` for exactly these slots may follow the ingest); stale + pointers from a full ring cycle earlier must not survive.""" + if self._final_slot is None: + return + for offset in range(count): + owners = self._final_owners[(start + offset) % self._cap] + self._final_free.extend(owners.values()) + owners.clear() + n = min(count, self._cap - start) + self._final_slot[:, start : start + n] = -1 + if count > n: + self._final_slot[:, : count - n] = -1 + + @torch.no_grad() + def write_final(self, env_ids: torch.Tensor, t_abs: int, obs_rows: torch.Tensor, critic_rows: torch.Tensor) -> None: + """Attach owned final rows to an already-ingested transition. + + Host allocation tracks live references, not episode frequency. Device + tables are read by eager sampling, outside compiled gradient updates. + """ + ids = torch.as_tensor(env_ids, dtype=torch.long).cpu().tolist() + if not ids: + return + slot = t_abs % self._cap + owners = self._final_owners[slot] + for env_id in ids: + previous = owners.pop(env_id, None) + if previous is not None: + self._final_free.append(previous) + if len(self._final_free) < len(ids): + self._grow_final_storage(len(ids) - len(self._final_free)) + rows = [self._final_free.pop() for _ in ids] + owners.update(zip(ids, rows)) + device_ids = torch.tensor(ids, device=self.device, dtype=torch.long) + row_ids = torch.tensor(rows, device=self.device, dtype=torch.long) + self._final_obs_table[row_ids] = obs_rows.to(self.device, torch.float) + self._final_critic_table[row_ids] = critic_rows.to(self.device, torch.float) + self._final_slot[device_ids, slot] = row_ids.to(torch.int32) def _write_span(self, slot: int, fields) -> None: obs, critic_obs, actions, rewards, dones, truncations = fields @@ -150,6 +248,22 @@ def sample(self, batch_size: int) -> dict: "dones": torch.gather(self.dones, 1, ring).reshape(flat), "truncations": torch.gather(self.truncations, 1, ring).reshape(flat), } + if self._final_slot is not None: + # Time-limit-truncated transitions bootstrap from the stored + # TERMINAL observation, not the successor slot's auto-reset + # observation: the reset state's value has nothing to do with + # the episode that just timed out. + k_idx = self._final_slot.gather(1, ring.long()) + has_final = (k_idx >= 0) & (out["truncations"] > 0).view(n_env, batch_size) + k_safe = k_idx.clamp(min=0).long() + fin_obs = self._final_obs_table[k_safe] + fin_cobs = self._final_critic_table[k_safe] + out["next_obs"] = torch.where( + has_final.unsqueeze(-1), fin_obs, out["next_obs"].view(n_env, batch_size, no) + ).reshape(flat, no) + out["next_critic_obs"] = torch.where( + has_final.unsqueeze(-1), fin_cobs, out["next_critic_obs"].view(n_env, batch_size, nco) + ).reshape(flat, nco) out["effective_n_steps"] = torch.ones_like(out["dones"]) return out @@ -206,6 +320,21 @@ def sample(self, batch_size: int) -> dict: ) dones = self.dones.gather(1, final_ring).reshape(flat) truncations = self.truncations.gather(1, final_ring).reshape(flat) + if self._final_slot is not None: + # Same terminal-observation correction as the 1-step branch, + # applied at the window's final transition (the one that + # bootstraps when the window ends on a truncation). + k_idx = self._final_slot.gather(1, final_ring.long()) + has_final = (k_idx >= 0) & (truncations > 0).view(n_env, batch_size) + k_safe = k_idx.clamp(min=0).long() + fin_obs = self._final_obs_table[k_safe] + fin_cobs = self._final_critic_table[k_safe] + next_obs = torch.where(has_final.unsqueeze(-1), fin_obs, next_obs.view(n_env, batch_size, no)).reshape( + flat, no + ) + next_critic_obs = torch.where( + has_final.unsqueeze(-1), fin_cobs, next_critic_obs.view(n_env, batch_size, nco) + ).reshape(flat, nco) return { "obs": obs, diff --git a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/sync/train.py b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/sync/train.py index d616854c..5ebec0d2 100644 --- a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/sync/train.py +++ b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/sync/train.py @@ -255,7 +255,9 @@ def emit_msg(msg: str) -> None: else: actions = agent.act(obs, deterministic=False) - next_obs, next_critic_obs, rewards, terminated, truncated = env.step(actions) + result = env.step(actions) + next_obs, next_critic_obs = result.obs, result.critic_obs + rewards, terminated, truncated = result.reward, result.terminated, result.truncated agent.rb.extend(obs, critic_obs, actions, rewards, terminated.long(), truncated.long()) @@ -279,7 +281,7 @@ def emit_msg(msg: str) -> None: # accumulate per-term reward means over the logging window. # info["Reward"] holds each term's per-env contribution as a (N,) # array (shared convention across backends); mean over envs here. - reward_terms = env.last_info.get("Reward") + reward_terms = result.info.get("Reward") if reward_terms: for k, v in reward_terms.items(): term_accum[k] = term_accum.get(k, 0.0) + float(v.mean()) @@ -287,18 +289,18 @@ def emit_msg(msg: str) -> None: t_collect += time.perf_counter() - t0 # ---------------- learner phase: num_updates gradient steps on a sampled batch - metrics = None if not warming: t1 = time.perf_counter() - metrics = agent.update(cfg.num_updates) + agent.update(cfg.num_updates) t_learn += time.perf_counter() - t1 if agent.global_step % logging_interval == 0: now = time.time() sps = (agent.global_step - last_log_step) * agent.num_envs / max(now - last_log_time, 1e-6) - # Materialize metrics to floats here (and only here). update() - # returns raw tensors to avoid per-iter cudaStreamSynchronize. - metrics_log = {k: float(v) for k, v in metrics.items()} if metrics is not None else None + # Window-aggregated diagnostics (means + extremes since the + # last log window), shared semantics with the async + # trainer's payload. + metrics_log = agent.window_metrics.stats_and_reset() if not warming else None mean_ret = sum(recent_returns) / len(recent_returns) if recent_returns else float("nan") mean_len = sum(recent_lengths) / len(recent_lengths) if recent_lengths else float("nan") term_means = {k: v / max(term_count, 1) for k, v in term_accum.items()} @@ -308,7 +310,7 @@ def emit_msg(msg: str) -> None: learn_ms = t_learn * 1000.0 / n_iter_win phase_total = t_collect + t_learn learn_pct = 100.0 * t_learn / max(phase_total, 1e-9) - info = env.last_info + info = result.info # generic env metrics channel: the wrapper merges the state's # metrics snapshot into infos["metrics"], so backends log them # without hardcoding any metric name. @@ -350,7 +352,7 @@ def emit_msg(msg: str) -> None: self._writer.add_scalar(f"metrics/{k}", v, agent.global_step) for k, v in term_means.items(): self._writer.add_scalar(f"reward/{k}", v, agent.global_step) - if metrics is not None: + if metrics_log is not None: for k, v in metrics_log.items(): self._writer.add_scalar(f"train/{k}", v, agent.global_step) last_log_time, last_log_step = now, agent.global_step @@ -393,7 +395,7 @@ def play(self, policy: str) -> None: try: while True: actions = agent.act(obs, deterministic=True) - obs, _, _, _, _ = env.step(actions) + obs = env.step(actions).obs if env.render() is False: break finally: diff --git a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/window_metrics.py b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/window_metrics.py new file mode 100644 index 00000000..a1d4b149 --- /dev/null +++ b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/window_metrics.py @@ -0,0 +1,65 @@ +# Copyright Motphys Technology Co., Ltd. 2025, 2026 +# SPDX-License-Identifier: Apache-2.0 + +"""Window-aggregated training diagnostics. + +Mirrors holosoma's ``TensorAverageMeterDict`` plus per-key extremes: logging +only the last gradient step's values hides single-update events (e.g. a +one-iteration Q spike or entropy spike) inside a multi-iteration logging +window. Every key drains as the window mean; range-tracked keys additionally +drain ``{key}_max`` / ``{key}_min`` so transient spikes stay visible. + +Values are accumulated on their producing device (a few tensor adds per +update); the only host synchronization is the ``.item()`` at the drain, which +callers place at their logging boundary. +""" + +from __future__ import annotations + +import torch + + +class WindowMetrics: + """Accumulates per-update diagnostic scalars, drained once per log window.""" + + def __init__(self) -> None: + self._sums: dict[str, torch.Tensor] = {} + self._counts: dict[str, int] = {} + self._max: dict[str, torch.Tensor] = {} + self._min: dict[str, torch.Tensor] = {} + + def add(self, key: str, value: torch.Tensor, track_range: bool = False) -> None: + """Accumulate one scalar observation under ``key``. + + ``track_range=True`` also keeps the window max/min (drained as + ``{key}_max`` / ``{key}_min``) — used for the spike-sensitive + diagnostics (Q extrema and entropy). + """ + v = value.detach().float() + s = self._sums.get(key) + if s is None: + self._sums[key] = v.clone() + self._counts[key] = 1 + if track_range: + self._max[key] = v.clone() + self._min[key] = v.clone() + else: + self._sums[key] = s + v + self._counts[key] += 1 + if track_range: + self._max[key] = torch.maximum(self._max[key], v) + self._min[key] = torch.minimum(self._min[key], v) + + def stats_and_reset(self) -> dict[str, float]: + """Drain window means (plus tracked max/min) accumulated since the last call. + + Returns ``{key}`` window means plus ``{key}_max`` / ``{key}_min`` for + range-tracked keys, converted to floats; empty when nothing was + accumulated. Resets the accumulator for the next window. + """ + stats = {key: (total / self._counts[key]).item() for key, total in self._sums.items()} + stats.update({f"{key}_max": value.item() for key, value in self._max.items()}) + stats.update({f"{key}_min": value.item() for key, value in self._min.items()}) + self._sums, self._counts = {}, {} + self._max, self._min = {}, {} + return stats diff --git a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/wrap.py b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/wrap.py index 27227bf6..5e155984 100644 --- a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/wrap.py +++ b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/wrap.py @@ -2,14 +2,33 @@ # SPDX-License-Identifier: Apache-2.0 import abc +from dataclasses import dataclass import gymnasium as gym import numpy as np import torch +from motrix_env_core.array.env import FinalObs from motrix_env_core.base import ABEnv +@dataclass +class FastSacStepResult: + """One step's tensors, diagnostics and optional owned pre-reset snapshot. + + Observations follow the frontend's auto-reset semantics. ``final_obs`` + carries truncated rows before reset, or is absent when no snapshot exists. + """ + + obs: torch.Tensor + critic_obs: torch.Tensor + reward: torch.Tensor + terminated: torch.Tensor + truncated: torch.Tensor + final_obs: FinalObs | None + info: dict + + class FastSacEnvWrap(abc.ABC): """Base environment adapter consumed by FastSAC.""" @@ -27,7 +46,6 @@ def __init__( self.num_envs = env.num_envs self._low = np.asarray(action_space.low, dtype=np.float32) self._high = np.asarray(action_space.high, dtype=np.float32) - self.last_info: dict = {} self._renderer = None @property @@ -49,7 +67,13 @@ def reset(self) -> tuple[torch.Tensor, torch.Tensor]: ... def step( self, actions: torch.Tensor, - ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: ... + ) -> FastSacStepResult: ... + + @property + def max_episode_steps(self) -> int | None: + """The environment's time-limit in control steps, or ``None``.""" + resolver = getattr(self._env, "_max_episode_steps", None) + return resolver() if resolver is not None else None def render(self, *args, **kwargs) -> bool | None: if self._renderer is None: diff --git a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/wrap_np.py b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/wrap_np.py index 365c5915..20882d0b 100644 --- a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/wrap_np.py +++ b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/wrap_np.py @@ -3,7 +3,7 @@ """Numpy-backend env wrapper for FastSAC. -Wraps a Motrix ``DirectEnv`` as the torch-tensor vectorized environment consumed +Wraps a Motrix ``ArrayEnv`` as the torch-tensor vectorized environment consumed by FastSAC. The actor maps its tanh output to the action range declared by the environment; @@ -15,18 +15,18 @@ import numpy as np import torch -from motrix_env_core.direct.env import DirectEnv +from motrix_env_core.array.env import ArrayEnv from motrix_env_core.renderer import RenderConfig, create_renderer from motrix_rl.utils import env_infos -from motrix_rl_builtin.fastsac.wrap import FastSacEnvWrap +from motrix_rl_builtin.fastsac.wrap import FastSacEnvWrap, FastSacStepResult class FastSacNpEnvWrap(FastSacEnvWrap): - """Wrap a Motrix ``DirectEnv`` as a torch-tensor vectorized env for FastSAC.""" + """Wrap a Motrix ``ArrayEnv`` as a torch-tensor vectorized env for FastSAC.""" - def __init__(self, env: DirectEnv, device: torch.device, render: RenderConfig | None = None): + def __init__(self, env: ArrayEnv, device: torch.device, render: RenderConfig | None = None): super().__init__(env, device) - self._env: DirectEnv = env + self._env: ArrayEnv = env self._renderer = create_renderer(env, render) def _to_torch(self, arr: np.ndarray, dtype=torch.float32) -> torch.Tensor: @@ -34,18 +34,18 @@ def _to_torch(self, arr: np.ndarray, dtype=torch.float32) -> torch.Tensor: def reset(self) -> tuple[torch.Tensor, torch.Tensor]: state = self._env.init_state() - self.last_info = env_infos(state) return self._to_torch(state.obs.policy), self._to_torch(state.obs.value_or_policy) - def step(self, actions: torch.Tensor): + def step(self, actions: torch.Tensor) -> FastSacStepResult: np_actions = actions.detach().cpu().numpy().astype(np.float32) np_actions = np.clip(np_actions, self._low, self._high) state = self._env.step(np_actions) - self.last_info = env_infos(state) - return ( - self._to_torch(state.obs.policy), - self._to_torch(state.obs.value_or_policy), - self._to_torch(state.reward), - self._to_torch(state.terminated, dtype=torch.bool), - self._to_torch(state.truncated, dtype=torch.bool), + return FastSacStepResult( + obs=self._to_torch(state.obs.policy), + critic_obs=self._to_torch(state.obs.value_or_policy), + reward=self._to_torch(state.reward), + terminated=self._to_torch(state.terminated, dtype=torch.bool), + truncated=self._to_torch(state.truncated, dtype=torch.bool), + final_obs=state.final_obs, + info=env_infos(state), ) diff --git a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/wrap_torch.py b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/wrap_torch.py index a18b0e94..c11d877b 100644 --- a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/wrap_torch.py +++ b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/wrap_torch.py @@ -17,7 +17,7 @@ from motrix_env_core.renderer import RenderConfig, create_renderer from motrix_env_motrixsim.torch_env import TorchEnv from motrix_rl.utils import env_infos -from motrix_rl_builtin.fastsac.wrap import FastSacEnvWrap +from motrix_rl_builtin.fastsac.wrap import FastSacEnvWrap, FastSacStepResult class FastSacTorchEnvWrap(FastSacEnvWrap): @@ -35,18 +35,18 @@ def _to_torch(self, tensor: torch.Tensor, dtype=torch.float32) -> torch.Tensor: def reset(self) -> tuple[torch.Tensor, torch.Tensor]: state = self._env.init_state() - self.last_info = env_infos(state) return self._to_torch(state.obs.policy), self._to_torch(state.obs.value_or_policy) - def step(self, actions: torch.Tensor): + def step(self, actions: torch.Tensor) -> FastSacStepResult: env_actions = actions.detach().to(device=self._env.device, dtype=torch.float32) env_actions = torch.clamp(env_actions, self._low, self._high) state = self._env.step(env_actions) - self.last_info = env_infos(state) - return ( - self._to_torch(state.obs.policy), - self._to_torch(state.obs.value_or_policy), - self._to_torch(state.reward), - self._to_torch(state.terminated, dtype=torch.bool), - self._to_torch(state.truncated, dtype=torch.bool), + return FastSacStepResult( + obs=self._to_torch(state.obs.policy), + critic_obs=self._to_torch(state.obs.value_or_policy), + reward=self._to_torch(state.reward), + terminated=self._to_torch(state.terminated, dtype=torch.bool), + truncated=self._to_torch(state.truncated, dtype=torch.bool), + final_obs=None, + info=env_infos(state), ) diff --git a/motrix_rl_builtin/tests/fastsac_async_mocks.py b/motrix_rl_builtin/tests/fastsac_async_mocks.py index aaf9d7b8..7ecf26e4 100644 --- a/motrix_rl_builtin/tests/fastsac_async_mocks.py +++ b/motrix_rl_builtin/tests/fastsac_async_mocks.py @@ -23,7 +23,7 @@ def make_mock_learner(rings: list, extends: list | None) -> Learner: learner = Learner.__new__(Learner) learner.agent = SimpleNamespace( device=torch.device("cpu"), - rb=SimpleNamespace(extend_batch=lambda *c: extends.append([x.clone() for x in c])), + rb=SimpleNamespace(ptr=0, extend_batch=lambda *c: extends.append([x.clone() for x in c])), cfg=SimpleNamespace(learning_starts=0), ) learner.async_options = SimpleNamespace(max_ingest_per_iter=8) diff --git a/motrix_rl_builtin/tests/test_builtin_sim_backend.py b/motrix_rl_builtin/tests/test_builtin_sim_backend.py index fd9e4686..5926440f 100644 --- a/motrix_rl_builtin/tests/test_builtin_sim_backend.py +++ b/motrix_rl_builtin/tests/test_builtin_sim_backend.py @@ -15,7 +15,7 @@ from motrix_rl_builtin.fastsac.async_impl.transport import Control, SharedTransitionRing, StartupHandshake from motrix_rl_builtin.fastsac.async_impl.transport.weight_channel import HostWeightSender, WeightChannelShared from motrix_rl_builtin.fastsac.async_impl.worker import actor_param_numel, run_collector_process -from motrix_rl_builtin.fastsac.wrap import FastSacEnvWrap +from motrix_rl_builtin.fastsac.wrap import FastSacEnvWrap, FastSacStepResult from motrix_env_core.array.env import ArrayEnvState, NpObs from motrix_env_core.base import EnvCfg @@ -95,7 +95,11 @@ def test_rl_trainer_supports_sim_backend(monkeypatch, sim_backend: str) -> None: assert isinstance(wrapped, FastSacEnvWrap) initial = wrapped.reset() result = wrapped.step(torch.zeros((_NUM_ENVS, _ACT_DIM))) - assert all(tensor.device.type == "cpu" for tensor in (*initial, *result)) + assert isinstance(result, FastSacStepResult) + tensors = (*initial, result.obs, result.critic_obs, result.reward, result.terminated, result.truncated) + assert all(tensor.device.type == "cpu" for tensor in tensors) + assert result.final_obs is None + assert isinstance(result.info, dict) actions = env.step.call_args.args[0] assert isinstance(actions, np.ndarray if sim_backend == "np" else torch.Tensor) diff --git a/motrix_rl_builtin/tests/test_fastsac_async_multi.py b/motrix_rl_builtin/tests/test_fastsac_async_multi.py index f7e4de33..c79c3772 100644 --- a/motrix_rl_builtin/tests/test_fastsac_async_multi.py +++ b/motrix_rl_builtin/tests/test_fastsac_async_multi.py @@ -105,7 +105,7 @@ def test_drain_merges_shard_generations_into_full_batches() -> None: assert merged_obs[0, :4].eq(1.0).all() assert merged_obs[0, 4:].eq(2.0).all() # both read cursors advanced past the consumed generation - assert all(ring.read_idx == 1 for ring in rings) + assert all(ring.read_completed_count == 1 for ring in rings) def test_drain_holds_generation_until_all_rings_deliver() -> None: @@ -121,7 +121,7 @@ def test_drain_holds_generation_until_all_rings_deliver() -> None: assert learner._pending.next_gen == 1 _push_batch(rings[1], 2.5) assert learner.drain() == 1 - assert all(ring.read_idx == 2 for ring in rings) + assert all(ring.read_completed_count == 2 for ring in rings) def test_single_ring_drain_batches_multiple_slots() -> None: @@ -137,7 +137,7 @@ def test_single_ring_drain_batches_multiple_slots() -> None: assert extends[0][0].shape == (3, 4, 3) assert extends[0][0][0].eq(1.0).all() assert extends[0][0][2].eq(3.0).all() - assert ring.read_idx == 3 + assert ring.read_completed_count == 3 def _snapshot(collector_id: int, ret: float, episodes: int, lag: int, collect_ms: float) -> dict: @@ -412,11 +412,11 @@ def test_lockstep_strict_due_scales_with_global_step_delta(monkeypatch) -> None: monkeypatch.setattr(dist, "broadcast", lambda tensor, src=0: tensor) learner, calls = _lockstep_learner(num_updates=4, utd_mode="strict") - assert learner.maybe_train(8) is not None # delta 3 + assert learner.maybe_train(8) # delta 3 assert sum(calls) == 12 and all(c == 4 for c in calls) - assert learner.maybe_train(8) is None # no new global batches -> no update + assert not learner.maybe_train(8) # no new global batches -> no update assert sum(calls) == 12 - assert learner.maybe_train(9) is not None + assert learner.maybe_train(9) assert sum(calls) == 16 @@ -426,9 +426,9 @@ def test_lockstep_learner_bound_runs_base_on_any_progress(monkeypatch) -> None: monkeypatch.setattr(dist, "broadcast", lambda tensor, src=0: tensor) learner, calls = _lockstep_learner(num_updates=4, utd_mode="learner_bound") - assert learner.maybe_train(6) is not None + assert learner.maybe_train(6) assert sum(calls) == 4 - assert learner.maybe_train(6) is None + assert not learner.maybe_train(6) assert sum(calls) == 4 @@ -439,7 +439,7 @@ def test_lockstep_gated_before_learning_starts(monkeypatch) -> None: learner, calls = _lockstep_learner(num_updates=4, utd_mode="strict") learner.control.collector_steps = 19 # < learning_starts(10) * num_collectors(2) - assert learner.maybe_train(8) is None + assert not learner.maybe_train(8) assert calls == [] diff --git a/motrix_rl_builtin/tests/test_fastsac_buffer.py b/motrix_rl_builtin/tests/test_fastsac_buffer.py index 94b77e34..0057004e 100644 --- a/motrix_rl_builtin/tests/test_fastsac_buffer.py +++ b/motrix_rl_builtin/tests/test_fastsac_buffer.py @@ -229,7 +229,7 @@ def push(ring, t): if k: count, views = ring.read_span() assert count >= k - assert 1 <= count <= capacity - (ring.read_idx % capacity) # no wrap + assert 1 <= count <= capacity - (ring.read_completed_count % capacity) # no wrap for i in range(k): assert torch.all(views[0][i] == next_expected + i) assert torch.all(views[1][i] == next_expected + i) # critic dim reuse ok @@ -248,3 +248,49 @@ def push(ring, t): next_expected += count assert next_expected == capacity * 4 assert ring.size() == 0 + + +def test_shared_ring_counts_are_cumulative_and_synchronous_across_wraps(): + from motrix_rl_builtin.fastsac.async_impl.transport import SharedTransitionRing + + capacity = 3 + ring = SharedTransitionRing(capacity, 1, 1, 1, 1) + writes = reads = 0 + + def assert_counts(): + assert ring.write_issued_count == ring.write_completed_count == writes + assert ring.read_issued_count == ring.read_completed_count == reads + assert ring.cursors.write_completed_count == writes + assert ring.cursors.read_completed_count == reads + assert ring.size() == writes - reads + + def push(tag): + value = torch.tensor([[float(tag)]]) + flags = torch.zeros(1, dtype=torch.long) + return ring.push(value, value, value, value[:, 0], flags, flags) + + assert_counts() + for _ in range(4): + while not ring.is_full(): + assert push(writes) + writes += 1 + assert_counts() + assert not push(writes) + assert_counts() + # Partially acknowledge each run, including runs split at the wrap. + for _ in range(2): + count, views = ring.read_span() + assert count == min(writes - reads, capacity - reads % capacity) + assert float(views[0][0, 0, 0]) == reads + assert_counts() # borrowing views does not issue an acknowledgement + ring.commit_reads(1) + reads += 1 + assert_counts() + + while ring.has_next(): + count, views = ring.read_span() + torch.testing.assert_close(views[0][:, 0, 0], torch.arange(reads, reads + count, dtype=torch.float32)) + ring.commit_reads(count) + reads += count + assert_counts() + assert reads == writes > 2 * capacity diff --git a/motrix_rl_builtin/tests/test_fastsac_collector.py b/motrix_rl_builtin/tests/test_fastsac_collector.py index 853bfb15..8834e9b7 100644 --- a/motrix_rl_builtin/tests/test_fastsac_collector.py +++ b/motrix_rl_builtin/tests/test_fastsac_collector.py @@ -5,10 +5,12 @@ import time from types import SimpleNamespace +import numpy as np import pytest import torch from motrix_rl_builtin.fastsac.async_impl.collector import Collector, resolve_collector_inference_device from motrix_rl_builtin.fastsac.async_impl.transport import Control, SharedTransitionRing +from motrix_rl_builtin.fastsac.async_impl.transport.ring import FinalObsStorage from motrix_rl_builtin.fastsac.async_impl.transport.weight_channel import ( HostWeightReceiver, HostWeightSender, @@ -16,7 +18,9 @@ ) from motrix_rl_builtin.fastsac.buffer import EmpiricalNormalization from motrix_rl_builtin.fastsac.networks import Actor +from motrix_rl_builtin.fastsac.wrap import FastSacStepResult +from motrix_env_core.array.env import FinalObs, NpObs from motrix_env_core.perf import Perf _NUM_ENVS = 8 @@ -30,7 +34,6 @@ class _CpuEnv: def __init__(self): self.num_envs = _NUM_ENVS - self.last_info = {} self.last_actions = None @property @@ -43,12 +46,14 @@ def reset(self): def step(self, actions): assert actions.device.type == "cpu" self.last_actions = actions.clone() - return ( - torch.ones(_NUM_ENVS, _OBS_DIM), - torch.ones(_NUM_ENVS, _CRITIC_OBS_DIM), - torch.ones(_NUM_ENVS), - torch.zeros(_NUM_ENVS, dtype=torch.bool), - torch.zeros(_NUM_ENVS, dtype=torch.bool), + return FastSacStepResult( + obs=torch.ones(_NUM_ENVS, _OBS_DIM), + critic_obs=torch.ones(_NUM_ENVS, _CRITIC_OBS_DIM), + reward=torch.ones(_NUM_ENVS), + terminated=torch.zeros(_NUM_ENVS, dtype=torch.bool), + truncated=torch.zeros(_NUM_ENVS, dtype=torch.bool), + final_obs=None, + info={}, ) @@ -356,3 +361,172 @@ def test_compiled_cuda_stochastic_actions_change_and_stay_in_bounds() -> None: assert torch.all(first <= high) assert torch.all(second >= low) assert torch.all(second <= high) + + +class _BackpressureEnv: + num_envs = 2 + + def __init__(self): + self.steps = 0 + self.outputs = None + + @property + def env(self): + return self + + def reset(self): + return torch.zeros(2, 3), torch.zeros(2, 3) + + def step(self, actions): + self.steps += 1 + self.outputs = FastSacStepResult( + obs=torch.full((2, 3), 2.0), + critic_obs=torch.full((2, 3), 3.0), + reward=torch.tensor([1.0, 2.0]), + terminated=torch.zeros(2, dtype=torch.bool), + truncated=torch.tensor([False, True]), + final_obs=FinalObs(np.array([1]), NpObs(policy=np.full((1, 3), 7.0, dtype=np.float32))), + info={"Reward": {"alive": np.ones(2)}, "metrics": {"error": np.ones(2)}}, + ) + return self.outputs + + +def _backpressure_collector(*, capacity=16): + cfg = SimpleNamespace( + agent=SimpleNamespace( + actor_hidden_dim=16, + log_std_max=2.0, + log_std_min=-5.0, + use_tanh=True, + use_layer_norm=True, + obs_normalization=False, + learning_starts=0, + ), + trainer=SimpleNamespace( + async_options=SimpleNamespace( + collector_inference_device="cpu", + collector_compile=False, + collector_amp=False, + collector_amp_dtype="fp16", + weight_poll_interval=100, + ) + ), + ) + env, control = _BackpressureEnv(), Control() + store = FinalObsStorage(capacity, env.num_envs, 3, 3) + ring = SharedTransitionRing(capacity, env.num_envs, 3, 3, 1, final_obs_store=store) + collector = Collector(env, cfg, 3, 1, torch.ones(1), torch.zeros(1), ring, None, control) + collector.reset() + actions = torch.full((2, 1), 0.25) + calls = [] + collector._sample_actions = lambda warming: calls.append(warming) or actions + return collector, env, ring, control, actions, calls + + +def test_payload_backpressure_gates_before_actions_and_resumes_without_dropped_steps(monkeypatch): + collector, env, ring, control, _, calls = _backpressure_collector() + original_step = env.step + + def tagged_step(actions): + result = original_step(actions) + result.obs.fill_(env.steps) + result.critic_obs.fill_(100 + env.steps) + result.final_obs.obs.policy.fill(1000 + env.steps) + return result + + monkeypatch.setattr(env, "step", tagged_step) + pushes = [] + original_push = ring.push + + def push_once(*fields, **kwargs): + pushes.append(original_push(*fields, **kwargs)) + return pushes[-1] + + monkeypatch.setattr(ring, "push", push_once) + # Each result owns one terminal row. Leave fewer than num_envs rows free: + # even though this env would fit, the next step may truncate every env. + published = ring.final_obs_store.capacity - env.num_envs + 1 + for _ in range(published): + assert collector.step_once() + assert ring.size() < ring.capacity + assert ring.is_full() + obs_before = collector.obs.clone() + for _ in range(3): + assert not collector.step_once() + assert env.steps == len(calls) == len(pushes) == published + assert all(pushes) + assert control.collector_steps == collector._collect_n == collector.n_episodes == published + torch.testing.assert_close(collector.obs, obs_before) + + count, fields = ring.read_span() + payloads = ring.read_final_span(count) + assert count == published + torch.testing.assert_close(fields[0][:, 0, 0], torch.arange(published, dtype=torch.float32)) + ring.commit_reads(1) + assert not ring.is_full() + assert collector.step_once() + assert env.steps == len(calls) == len(pushes) == published + 1 + assert all(pushes) + assert control.collector_steps == collector._collect_n == collector.n_episodes == published + 1 + count, fields = ring.read_span() + assert count == published + torch.testing.assert_close(fields[0][:, 0, 0], torch.arange(1, published + 1, dtype=torch.float32)) + resumed_payloads = ring.read_final_span(count) + for offset, payload in enumerate(resumed_payloads, start=2): + torch.testing.assert_close(payload.obs, torch.full((1, 3), float(1000 + offset))) + # Previously read owned snapshots survive acknowledgement and publication. + torch.testing.assert_close(payloads[0].obs, torch.full((1, 3), 1001.0)) + + +def test_published_step_owns_transition_and_final_payload_buffers(): + collector, env, ring, control, actions, calls = _backpressure_collector() + initial_obs, initial_critic = collector.obs, collector.critic_obs + assert collector.step_once() + torch.testing.assert_close(collector.obs, torch.full((2, 3), 2.0)) + torch.testing.assert_close(collector.ep_return, torch.tensor([1.0, 0.0])) + assert collector.recent_returns == [2.0] + assert collector.recent_lengths == [1.0] + assert env.steps == len(calls) == 1 + assert collector.n_episodes == collector.term_count == collector._collect_n == control.collector_steps == 1 + + # The environment, action staging, and previous observations may all be + # reused as soon as step_once returns: the published slot owns its data. + initial_obs.fill_(99) + initial_critic.fill_(99) + actions.fill_(99) + result = env.outputs + for tensor in (result.obs, result.critic_obs, result.reward, result.terminated, result.truncated): + tensor.fill_(True if tensor.dtype == torch.bool else 99) + result.final_obs.env_ids.fill(0) + result.final_obs.obs.policy.fill(99) + count, fields = ring.read_span() + assert count == 1 + expected = ( + torch.zeros(2, 3), + torch.zeros(2, 3), + torch.full((2, 1), 0.25), + torch.tensor([1.0, 2.0]), + torch.zeros(2, dtype=torch.long), + torch.tensor([0, 1]), + ) + for actual, wanted in zip(fields, expected): + torch.testing.assert_close(actual[0], wanted) + payload = ring.read_final_span(count)[0] + assert payload.env_ids.tolist() == [1] + torch.testing.assert_close(payload.obs, torch.full((1, 3), 7.0)) + torch.testing.assert_close(payload.critic_obs, torch.full((1, 3), 7.0)) + + +def test_transition_ring_full_gates_before_actions_and_resumes(): + collector, env, ring, control, _, calls = _backpressure_collector(capacity=1) + assert collector.step_once() + assert ring.is_full() + for _ in range(3): + assert not collector.step_once() + assert env.steps == len(calls) == control.collector_steps == 1 + assert ring.write_completed_count == 1 + ring.commit_reads(1) + assert not ring.is_full() + assert collector.step_once() + assert env.steps == len(calls) == control.collector_steps == 2 + assert ring.write_completed_count == 2 diff --git a/motrix_rl_builtin/tests/test_fastsac_final_obs.py b/motrix_rl_builtin/tests/test_fastsac_final_obs.py new file mode 100644 index 00000000..6c5e1f14 --- /dev/null +++ b/motrix_rl_builtin/tests/test_fastsac_final_obs.py @@ -0,0 +1,391 @@ +# Copyright Motphys Technology Co., Ltd. 2025, 2026 +# SPDX-License-Identifier: Apache-2.0 + +"""Terminal-observation bootstrapping contracts. + +Time-limit-truncated transitions must bootstrap from the stored TERMINAL +(pre-reset) observation, not the successor slot's auto-reset observation — +the reset state's value is unrelated to the episode that timed out. These +tests pin the replay terminal-observation and non-consuming adapter contracts. +""" + +import multiprocessing as mp +import time +from types import SimpleNamespace + +import gymnasium as gym +import numpy as np +import pytest +import torch +from fastsac_async_mocks import make_mock_learner +from motrix_rl_builtin.fastsac.async_impl.learner import DrainStaging +from motrix_rl_builtin.fastsac.async_impl.transport.ring import ( + FinalObsPayload, + FinalObsStorage, + SharedTransitionRing, +) +from motrix_rl_builtin.fastsac.buffer import SimpleReplayBuffer +from motrix_rl_builtin.fastsac.wrap_np import FastSacNpEnvWrap + +from motrix_env_core.array.env import ArrayEnvState, FinalObs, NpObs + + +def test_numpy_adapter_reads_final_result_without_consuming_environment_state(): + final = FinalObs(np.array([0]), NpObs(np.array([[7.0]], dtype=np.float32))) + state = ArrayEnvState( + obs=NpObs(np.zeros((1, 1), dtype=np.float32)), + reward=np.zeros(1, dtype=np.float32), + terminated=np.zeros(1, dtype=bool), + truncated=np.ones(1, dtype=bool), + episode_steps=np.zeros(1, dtype=np.uint64), + final_obs=final, + ) + initial = state.replace(final_obs=None) + env = SimpleNamespace( + num_envs=1, + action_space=gym.spaces.Box(-1.0, 1.0, (1,), dtype=np.float32), + init_state=lambda: initial, + step=lambda actions: state, + ) + wrapped = FastSacNpEnvWrap(env, torch.device("cpu")) + obs, critic_obs = wrapped.reset() + torch.testing.assert_close(obs, torch.zeros(1, 1)) + torch.testing.assert_close(critic_obs, obs) + result = wrapped.step(torch.zeros(1, 1)) + assert result.final_obs is final + assert result.info == {"Reward": {}, "metrics": {}} + assert state.final_obs is final + assert result.final_obs.obs.value_or_policy is final.obs.policy + wrapped.reset() + assert result.final_obs is final + assert state.final_obs is final + + +N_ENV = 2 +N_OBS = 3 +N_ACT = 1 +N_CRITIC_OBS = 4 + + +def _fill_with_truncations(rb, times, trunc_ts, env=0): + """Feed scalar-encoded transitions; ``env`` truncates at each t in trunc_ts.""" + for t in range(times): + trunc = torch.zeros(N_ENV, dtype=torch.long) + if t in trunc_ts: + trunc[env] = 1 + rb.extend( + torch.full((N_ENV, N_OBS), float(t)), + torch.full((N_ENV, N_CRITIC_OBS), float(100 + t)), + torch.zeros(N_ENV, N_ACT), + torch.zeros(N_ENV), + torch.zeros(N_ENV, dtype=torch.long), + trunc, + ) + if trunc.any(): + rb.write_final( + torch.nonzero(trunc).flatten(), + t, + torch.full((1, N_OBS), 1000.0 + t), + torch.full((1, N_CRITIC_OBS), 2000.0 + t), + ) + + +def test_truncated_transitions_sample_terminal_observation(): + """Sampled truncated rows must return the recorded terminal observation; + all other rows keep the successor slot's observation.""" + rb = SimpleReplayBuffer(N_ENV, 16, N_OBS, N_ACT, N_CRITIC_OBS, device="cpu", final_obs_bootstrap=True) + _fill_with_truncations(rb, 10, trunc_ts={3, 8}) + + torch.manual_seed(0) + for _ in range(20): + batch = rb.sample(batch_size=32) + truncated = batch["truncations"] > 0 + if truncated.any(): + assert torch.all(batch["next_obs"][truncated][:, 0] >= 1000.0) + assert torch.all(batch["next_critic_obs"][truncated][:, 0] >= 2000.0) + continuing = batch["truncations"] == 0 + if continuing.any(): + assert torch.all(batch["next_obs"][continuing][:, 0] < 100.0) + + +def test_final_obs_survives_ring_wrap_and_row_reuse(): + """After enough truncations to reuse final rows and wrap the ring, every + live truncated transition still resolves to ITS OWN terminal observation.""" + buffer_size = 16 + max_ep = 4 + rb = SimpleReplayBuffer(1, buffer_size, 1, 1, 1, device="cpu", final_obs_bootstrap=True) + total = 40 + for t in range(total): + truncated = 1 if (t + 1) % max_ep == 0 else 0 + rb.extend( + torch.tensor([[float(t)]]), + torch.zeros(1, 1), + torch.zeros(1, 1), + torch.zeros(1), + torch.zeros(1, dtype=torch.long), + torch.tensor([truncated], dtype=torch.long), + ) + if truncated: + rb.write_final(torch.tensor([0]), t, torch.tensor([[5000.0 + t]]), torch.zeros(1, 1)) + + # Sampleable window: t in [ptr-1-num_stored, ptr-2]; expected truncations + # are the multiples of max_ep within it. + t_min = rb.ptr - 1 - rb.num_stored + t_max = rb.ptr - 2 + expected = {t for t in range(t_min, t_max + 1) if (t + 1) % max_ep == 0} + seen = set() + for _ in range(80): + batch = rb.sample(batch_size=16) + for row in batch["next_obs"][batch["truncations"] > 0]: + t = int(row.item() - 5000.0) + assert t in expected, row + seen.add(t) + assert seen == expected + + +def test_buffer_without_final_obs_bootstrap_unchanged(): + """final_obs_bootstrap=False keeps the pre-feature memory and sample shape.""" + rb = SimpleReplayBuffer(1, 8, 1, 1, 1, device="cpu") + assert rb._final_slot is None + for t in range(10): + rb.extend( + torch.tensor([[float(t)]]), + torch.zeros(1, 1), + torch.zeros(1, 1), + torch.zeros(1), + torch.zeros(1, dtype=torch.long), + torch.tensor([0], dtype=torch.long), + ) + batch = rb.sample(batch_size=4) + assert batch["next_obs"].shape == (4, 1) + + +def _ring(capacity=4, num_envs=3): + return SharedTransitionRing(capacity, num_envs, 2, 3, 1, final_obs_store=FinalObsStorage(capacity, num_envs, 2, 3)) + + +def _push(ring, tag, ids): + n = ring.num_envs + payload = None + if ids: + payload = FinalObsPayload( + torch.tensor(ids), torch.full((len(ids), 2), float(tag)), torch.full((len(ids), 3), float(tag + 100)) + ) + return ring.push( + torch.full((n, 2), float(tag)), + torch.full((n, 3), float(tag)), + torch.zeros(n, 1), + torch.zeros(n), + torch.zeros(n, dtype=torch.long), + torch.ones(n, dtype=torch.long), + final_obs=payload, + ) + + +def test_sparse_capacity_backpressure_is_atomic_and_acknowledgement_bound(): + ring = _ring(capacity=16) + batches = ring.final_obs_store.capacity // ring.num_envs + ids = list(range(ring.num_envs)) + for tag in range(1, batches + 1): + assert not ring.is_full() + assert _push(ring, tag, ids) + count, fields = ring.read_span() + assert count == batches + payloads = ring.read_final_span(count) + assert ring.size() < ring.capacity # transition slots remain available + assert ring.is_full() + assert not _push(ring, batches + 1, [0]) + assert ring.write_completed_count == batches + torch.testing.assert_close(fields[0][:, 0, 0], torch.arange(1, batches + 1, dtype=torch.float32)) + # Reading/copying payloads does not release the rows; only acknowledgement does. + assert ring.is_full() + ring.commit_reads(1) + assert not ring.is_full() + assert _push(ring, batches + 1, ids) + # Owned payloads remain valid after the acknowledged sparse rows are reused. + for tag, payload in enumerate(payloads, start=1): + torch.testing.assert_close(payload.obs, torch.full((ring.num_envs, 2), float(tag))) + torch.testing.assert_close(payload.critic_obs, torch.full((ring.num_envs, 3), float(tag + 100))) + assert ring.read_issued_count == 1 + + +def test_sparse_pre_step_gate_reserves_rows_for_every_environment(): + ring = _ring(capacity=16) + used = ring.final_obs_store.capacity - ring.num_envs + 1 + for tag in range(used): + assert not ring.is_full() + assert _push(ring, tag, [0]) + store = ring.final_obs_store + assert ring.size() < ring.capacity + assert store.can_write_rows(1, ring.read_completed_count) + assert not store.can_write_rows(ring.num_envs, ring.read_completed_count) + assert ring.is_full() # a sparse next payload might fit, but a full one would not + count, _ = ring.read_span() + ring.read_final_span(count) + assert ring.is_full() + ring.commit_reads(1) + assert store.can_write_rows(ring.num_envs, ring.read_completed_count) + assert not ring.is_full() + assert _push(ring, used, list(range(ring.num_envs))) + + +def test_slot_and_sparse_row_wrap_keep_payload_alignment(): + ring = _ring(capacity=3) + for tag in range(20): + ids = [] if tag % 4 == 0 else list(range(tag % 3 + 1)) + assert _push(ring, tag, ids) + count, fields = ring.read_span() + assert count == 1 + payload = ring.read_final_span(count)[0] + assert float(fields[0][0, 0, 0]) == tag + if ids: + assert payload.env_ids.tolist() == ids + torch.testing.assert_close(payload.obs, torch.full((len(ids), 2), float(tag))) + torch.testing.assert_close(payload.critic_obs, torch.full((len(ids), 3), float(tag + 100))) + else: + assert payload is None + ring.commit_reads(1) + + +def test_main_ring_full_leaves_sparse_payload_unpublished(): + ring = _ring(capacity=1) + assert _push(ring, 1, []) + assert not _push(ring, 2, [0, 1, 2]) + assert ring.read_final_span(1) == [None] + ring.commit_reads(1) + assert _push(ring, 2, [0, 1, 2]) + assert ring.read_final_span(1)[0].env_ids.tolist() == [0, 1, 2] + + +def _producer(ring, count): + torch.set_num_threads(1) + deadline = time.monotonic() + 20 + for tag in range(count): + ids = list(range(tag % ring.num_envs + 1)) + while not _push(ring, tag, ids): + if time.monotonic() > deadline: + raise TimeoutError("slot payload producer failed to make progress") + time.sleep(0.0001) + + +def test_spawned_producer_and_slow_consumer_preserve_slot_payloads(): + ring = _ring(capacity=5) + count = 100 + process = mp.get_context("spawn").Process(target=_producer, args=(ring, count)) + process.start() + deadline = time.monotonic() + 25 + consumed = 0 + try: + while consumed < count: + n, fields = ring.read_span() + if not n: + assert time.monotonic() < deadline + assert process.exitcode in (None, 0) + time.sleep(0.0001) + continue + payloads = ring.read_final_span(n) + time.sleep(0.001) # allow producer to try reusing unacknowledged rows + for offset, payload in enumerate(payloads): + tag = consumed + offset + assert float(fields[0][offset, 0, 0]) == tag + assert payload.env_ids.tolist() == list(range(tag % ring.num_envs + 1)) + assert payload.obs.eq(tag).all() + assert payload.critic_obs.eq(tag + 100).all() + ring.commit_reads(n) + consumed += n + process.join(timeout=5) + assert process.exitcode == 0 + finally: + if process.is_alive(): + process.terminate() + process.join(timeout=5) + + +def _buffer(): + return SimpleReplayBuffer(2, 3, 1, 1, 1, device="cpu", final_obs_bootstrap=True) + + +def _batch(count, first=0): + obs = torch.arange(first, first + count, dtype=torch.float32)[:, None, None].expand(count, 2, 1) + flags = torch.ones(count, 2, dtype=torch.long) + return obs, obs, obs, obs[..., 0], torch.zeros_like(flags), flags + + +def _payload(tag): + rows = torch.full((2, 1), float(tag)) + return SimpleNamespace(env_ids=torch.arange(2), obs=rows, critic_obs=rows + 100) + + +def _assert_live_rows(rb, tags): + for absolute, tag in tags.items(): + indices = rb._final_slot[:, absolute % rb._cap].long() + torch.testing.assert_close(rb._final_obs_table[indices], torch.full((2, 1), float(tag))) + torch.testing.assert_close(rb._final_critic_table[indices], torch.full((2, 1), float(tag + 100))) + live = [row for owners in rb._final_owners for row in owners.values()] + assert len(live) == len(set(live)) + assert not set(live).intersection(rb._final_free) + assert len(live) + len(rb._final_free) == rb._final_capacity + + +def test_every_step_truncation_grows_and_recycles_without_overwriting_live_rows(): + rb = _buffer() + for step in range(20): + rb.extend_batch(*_batch(1, step), final_payloads=[_payload(step)]) + _assert_live_rows(rb, {t: t for t in range(max(0, step - rb._cap + 1), step + 1)}) + assert rb._final_capacity == rb.n_env * rb._cap + + +def test_final_payload_batch_larger_than_replay_retains_latest_slots(): + rb = _buffer() + rb.extend_batch(*_batch(10), final_payloads=[_payload(t) for t in range(10)]) + assert rb.ptr == 10 + _assert_live_rows(rb, {t: t for t in range(6, 10)}) + + +def test_overwrite_without_final_releases_rows_and_clears_mapping(): + rb = _buffer() + rb.extend_batch(*_batch(4), final_payloads=[_payload(t) for t in range(4)]) + rb.extend_batch(*_batch(2, 4)) + assert rb._final_slot[:, :2].eq(-1).all() + _assert_live_rows(rb, {2: 2, 3: 3}) + + +@pytest.mark.parametrize("n_steps", [1, 3]) +def test_sample_uses_final_rows_after_pool_growth(n_steps): + rb = _buffer() + rb.n_steps = n_steps + rb.extend_batch(*_batch(4), final_payloads=[_payload(99) for _ in range(4)]) + sample = rb.sample(32) + torch.testing.assert_close(sample["next_obs"], torch.full_like(sample["next_obs"], 99)) + torch.testing.assert_close(sample["next_critic_obs"], torch.full_like(sample["next_critic_obs"], 199)) + + +@pytest.mark.parametrize("device", ["cpu", "cuda"]) +def test_final_payload_ingested_and_released_during_warmup(device): + if device == "cuda" and not torch.cuda.is_available(): + pytest.skip("CUDA unavailable") + ring = SharedTransitionRing(4, 2, 1, 1, 1, final_obs_store=FinalObsStorage(4, 2, 1, 1)) + learner = make_mock_learner([ring], []) + learner.agent.device = torch.device(device) + learner.agent.rb = SimpleReplayBuffer(2, 4, 1, 1, 1, device=device, final_obs_bootstrap=True) + learner.agent.window_metrics = SimpleNamespace(add=lambda *args, **kwargs: None) + learner._learning_starts = 1000 + learner._last_train_gstep = 0 + learner.ddp_rank = None + learner.agent.cfg = SimpleNamespace(num_updates=4) + learner.async_options.utd_mode = "strict" + if device == "cuda": + learner._staging = DrainStaging(learner.agent.rb, 4, torch.device(device)) + rows = torch.full((2, 1), 99.0) + final = FinalObsPayload(torch.arange(2), rows, rows + 1) + flags = torch.ones(2, dtype=torch.long) + for step in range(12): + obs = torch.full((2, 1), float(step)) + assert ring.push(obs, obs, obs, torch.zeros(2), torch.zeros_like(flags), flags, final_obs=final) + assert learner.drain() == 1 + assert learner.maybe_train(step + 1) is False + assert ring.size() == 0 + learner.wait_ingest() + batch = learner.agent.rb.sample(16) + torch.testing.assert_close(batch["next_obs"], torch.full_like(batch["next_obs"], 99)) + torch.testing.assert_close(batch["next_critic_obs"], torch.full_like(batch["next_critic_obs"], 100)) diff --git a/motrix_rl_builtin/tests/test_fastsac_ipc_ring.py b/motrix_rl_builtin/tests/test_fastsac_ipc_ring.py index b2bb9733..500ea670 100644 --- a/motrix_rl_builtin/tests/test_fastsac_ipc_ring.py +++ b/motrix_rl_builtin/tests/test_fastsac_ipc_ring.py @@ -22,6 +22,7 @@ RingCursors, SharedTransitionRing, ) +from motrix_rl_builtin.fastsac.async_impl.transport.ring import FinalObsPayload, FinalObsStorage from motrix_rl_builtin.fastsac.buffer import SimpleReplayBuffer CAPACITY, N_ENV, OBS, CRI, ACT = 5, 3, 4, 6, 2 @@ -108,6 +109,69 @@ def test_ipc_ring_cursor_publishes_only_after_event(): assert receiver.has_next() +@cuda_only +def test_ipc_ring_issued_counts_precede_event_proven_counts_across_wraps(monkeypatch): + """Control event visibility explicitly, never assume a copy is still in flight.""" + event_type = torch.cuda.Event + events = [] + + class ControlledEvent: + def __init__(self): + self.event = event_type() + self.released = False + events.append(self) + + def record(self, stream): + self.released = False + self.event.record(stream) + + def query(self): + return self.released and self.event.query() + + def synchronize(self): + self.event.synchronize() + self.released = True + + # Real CUDA events still order H2D/D2D copies, but the ring cannot observe + # completion until the test releases them. No sleeps or race-prone queries. + monkeypatch.setattr(torch.cuda, "Event", ControlledEvent) + owner, receiver = _owner_receiver() + assert owner.write_issued_count == owner.write_completed_count == 0 + assert receiver.read_issued_count == receiver.read_completed_count == 0 + + for t in range(CAPACITY * 2 + 1): + fields = _batch(t, seed=13) + assert owner.push(*fields) + assert owner.write_issued_count == t + 1 + assert owner.write_completed_count == owner.cursors.write_completed_count == t + assert receiver.read_span() == (0, ()) + assert not receiver.has_next() + + for event in events: + event.synchronize() + assert owner.write_completed_count == t + 1 + assert receiver.has_next() + count, views = receiver.read_span() + assert count == 1 + assert receiver.read_issued_count == receiver.read_completed_count == t + copied = tuple(view.clone() for view in views) + receiver.commit_reads(count) + assert receiver.read_issued_count == t + 1 + assert receiver.read_completed_count == receiver.cursors.read_completed_count == t + # The issued read position skips the consumed slot even while its + # acknowledgement is withheld and the producer still sees it occupied. + assert receiver.read_span() == (0, ()) + assert not receiver.has_next() + assert owner.size() == 1 + + for event in events: + event.synchronize() + assert receiver.read_completed_count == t + 1 + assert owner.size() == 0 + for actual, expected in zip(copied, fields): + torch.testing.assert_close(actual[0].cpu().to(expected.dtype), expected) + + @cuda_only def test_ipc_ring_backpressure_bounds_in_flight(): owner, receiver = _owner_receiver() @@ -120,10 +184,72 @@ def test_ipc_ring_backpressure_bounds_in_flight(): k, _ = receiver.read_span() receiver.commit_reads(k) torch.cuda.synchronize() + # The final commit's event may have completed only now; the shared read + # cursor advances lazily on the consumer's next flush, so poll once more + # before the producer's is_full() is meaningful. + receiver.has_next() assert not owner.is_full() assert owner.push(*_batch(CAPACITY, seed=3)) +@cuda_only +def test_ipc_sparse_pre_step_gate_waits_for_completed_read_acknowledgement(monkeypatch): + event_type = torch.cuda.Event + events = [] + + class ControlledEvent: + def __init__(self): + self.event = event_type() + self.released = False + events.append(self) + + def record(self, stream): + self.released = False + self.event.record(stream) + + def query(self): + return self.released and self.event.query() + + def synchronize(self): + self.event.synchronize() + self.released = True + + monkeypatch.setattr(torch.cuda, "Event", ControlledEvent) + capacity = 16 + cursors = RingCursors() + slots = torch.zeros(capacity, N_ENV, FEAT, device="cuda") + store = FinalObsStorage(capacity, N_ENV, OBS, CRI) + owner = IpcTransitionRing(cursors, slots, capacity, N_ENV, OBS, CRI, ACT, final_obs_store=store) + receiver = IpcTransitionRing(cursors, slots, capacity, N_ENV, OBS, CRI, ACT, final_obs_store=store) + # Leave some sparse room, but not enough for a worst-case full-env payload. + published = store.capacity - N_ENV + 1 + for tag in range(published): + assert not owner.is_full() + payload = FinalObsPayload(torch.tensor([0]), torch.full((1, OBS), float(tag)), torch.zeros(1, CRI)) + assert owner.push(*_batch(tag), final_obs=payload) + assert owner.size() < capacity + assert owner.is_full() + for event in events: + event.synchronize() + assert owner.write_completed_count == published + count, _ = receiver.read_span() + assert count == published + snapshots = receiver.read_final_span(count) + receiver.commit_reads(1) + assert receiver.read_issued_count == 1 + assert receiver.read_completed_count == 0 + assert owner.is_full() # issued GPU ingestion is not a row-release proof + for event in events: + event.synchronize() + assert receiver.read_completed_count == 1 + assert not owner.is_full() + payload = FinalObsPayload(torch.arange(N_ENV), torch.full((N_ENV, OBS), 99.0), torch.zeros(N_ENV, CRI)) + assert owner.push(*_batch(published), final_obs=payload) + torch.testing.assert_close(snapshots[0].obs, torch.zeros(1, OBS)) + # Do not release CUDA storage with the last H2D event still in flight. + torch.cuda.synchronize() + + @cuda_only def test_ipc_ring_back_to_back_pushes_do_not_collide(): """Regression: pushes issued while the cursor lags (events in flight) must diff --git a/motrix_rl_builtin/tests/test_fastsac_learner.py b/motrix_rl_builtin/tests/test_fastsac_learner.py index 6e742f47..7d9db875 100644 --- a/motrix_rl_builtin/tests/test_fastsac_learner.py +++ b/motrix_rl_builtin/tests/test_fastsac_learner.py @@ -1,65 +1,10 @@ # Copyright Motphys Technology Co., Ltd. 2025, 2026 # SPDX-License-Identifier: Apache-2.0 -"""UTD governance tests for the async learner.""" +"""Update-gating and timing-helpers tests for the FastSAC agent/learner.""" from types import SimpleNamespace -from motrix_rl_builtin.fastsac.async_impl.learner import Learner - - -def _make_learner(utd_mode: str, num_updates: int) -> Learner: - learner = Learner.__new__(Learner) - learner.agent = SimpleNamespace(cfg=SimpleNamespace(num_updates=num_updates)) - learner.async_options = SimpleNamespace(utd_mode=utd_mode) - learner.control = SimpleNamespace(collector_steps=0) - return learner - - -def test_own_copies_compiled_outputs_out_of_the_graph_pool() -> None: - """Regression: metrics read at log time came from an invalidated CUDA graph. - - `_update_pol` is gated by `policy_frequency`, so its outputs are carried - across later loop iterations -- each of which calls - `cudagraph_mark_step_begin()` and invalidates the generation they live in. - Reading them afterwards raised rather than returning stale numbers: - - RuntimeError: Error: accessing tensor output of CUDAGraphs that has - been overwritten by a subsequent run - - Needs a GPU: `reduce-overhead` compiles to CUDA graphs and is a no-op - without one, so there is nothing to invalidate on CPU. - """ - import pytest - import torch - - if not torch.cuda.is_available(): - pytest.skip("CUDA graphs require a GPU") - - from motrix_rl_builtin.fastsac.agent import _own - - @torch.compile(mode="reduce-overhead") - def step(x): - y = x @ x - return y.sum(), y.mean() - - x = torch.randn(64, 64, device="cuda") - - torch.compiler.cudagraph_mark_step_begin() - owned = _own(step(x)) - # Two further generations, as the gated loop would produce. - for _ in range(2): - torch.compiler.cudagraph_mark_step_begin() - step(x) - - assert all(torch.isfinite(torch.as_tensor(float(v))) for v in owned) - - -def test_own_leaves_non_tensors_alone() -> None: - from motrix_rl_builtin.fastsac.agent import _own - - assert _own((1, "a", None)) == (1, "a", None) - def _tiny_agent_cfg(**overrides): """Minimal valid FastSacAgentCfg namespace for a real (tiny) CUDA agent.""" @@ -100,68 +45,12 @@ def _tiny_agent_cfg(**overrides): return SimpleNamespace(**values) -def test_update_metrics_survive_later_graph_generations() -> None: - """Outputs carried across `cudagraph_mark_step_begin` must stay readable. - - Regression for the `_own` placement in ``FastSacAgent.update``: the actor - pair is carried across policy-frequency gating (produced at a non-final - iteration), and the main outputs are owned only at the final iteration. - Both must remain readable after FURTHER update calls open new CUDA graph - generations — reading graph-pool memory invalidated by a later replay - raises instead of returning stale numbers. - - Needs a GPU: reduce-overhead compiles to CUDA graphs and is a no-op - without one, so there is nothing to invalidate on CPU. - """ - import pytest - import torch - - if not torch.cuda.is_available(): - pytest.skip("CUDA graphs require a GPU") - - from motrix_rl_builtin.fastsac.agent import FastSacAgent - - n_env, obs, cri, act = 8, 5, 7, 3 - agent = FastSacAgent( - obs_dim=obs, - critic_obs_dim=cri, - act_dim=act, - num_envs=n_env, - cfg=_tiny_agent_cfg(), - device=torch.device("cuda"), - ) - for _ in range(40): - agent.rb.extend( - torch.randn(n_env, obs), - torch.randn(n_env, cri), - torch.randn(n_env, act), - torch.randn(n_env), - torch.zeros(n_env, dtype=torch.long), - torch.zeros(n_env, dtype=torch.long), - ) - - # warmup/compile, then exercise both gating positions: - agent.update(4) # update_idx 0 -> 4: actor gated at i=0 (non-final, carried) - first = agent.update(4) # update_idx 4 -> 8: actor gated at i=0 (non-final) - agent.update_idx = 5 - second = agent.update(4) # update_idx 5 -> 9: actor gated at i=3 (final iteration) - # Further generations have invalidated earlier graph pools; the returned - # metrics must be owned copies and therefore still readable. - agent.update(4) - for metrics in (first, second): - values = {key: float(value) for key, value in metrics.items()} - assert all(value == value for value in values.values()) # no NaNs from torn reads - - -def test_update_metrics_keep_actor_pair_when_final_step_skips_policy() -> None: - """The carried actor pair must stay the actor pair under gating. +def test_policy_frequency_gating_spans_update_calls() -> None: + """Actor updates exactly every policy_frequency-th gradient step, across calls. - Regression for the four-element critic tuple: re-deriving the pair as - ``(last[3], last[4])`` inside the loop picked up ``(tv_min, actor_loss)`` - instead — shifting every metric label by one and, under - ``mode="reduce-overhead"``, leaking an unowned CUDA-graph tensor into the - metrics whenever the FINAL gradient step skipped the policy update (the - next replay invalidated it and crashed the async learner's log path). + Gating reads the persistent ``update_idx`` counter, so the actor/Q ratio is + exactly ``1 / policy_frequency`` across update() calls, not just within one + (e.g. frequency 2: 4 steps -> 2 actor calls, 3 steps -> 1). """ import torch from motrix_rl_builtin.fastsac.agent import FastSacAgent @@ -184,23 +73,17 @@ def test_update_metrics_keep_actor_pair_when_final_step_skips_policy() -> None: torch.zeros(n_env, dtype=torch.long), torch.zeros(n_env, dtype=torch.long), ) - agent._update_main = lambda b: ( - torch.tensor(1.0), - torch.tensor(2.0), - torch.tensor(3.0), - torch.tensor(4.0), - ) - agent._update_pol = lambda b: (torch.tensor(1234.5), torch.tensor(777.0)) - - # update_idx 0 with policy_frequency 2: the actor step runs at i=0 while - # the FINAL iteration (i=1) skips it, so the pair must be carried intact. - metrics = agent.update(2) - - assert float(metrics["qf_loss"]) == 1.0 - assert float(metrics["alpha_loss"]) == 2.0 - assert float(metrics["qf_max"]) == 3.0 - assert float(metrics["actor_loss"]) == 1234.5 - assert float(metrics["policy_entropy"]) == 777.0 + agent._update_main = lambda b: None + actor_calls: list[int] = [] + agent._update_pol = lambda b: actor_calls.append(1) + + agent.update(4) # update_idx 0 -> 4: actor gates at steps 0, 2 + assert len(actor_calls) == 2 + agent.update(4) # update_idx 4 -> 8: actor gates at steps 4, 6 + assert len(actor_calls) == 4 + agent.update(3) # update_idx 8 -> 11: actor gates at steps 8, 10 + assert len(actor_calls) == 6 + assert agent.update_idx == 11 def test_nest_timing_path_merges_scalar_total_with_children_in_any_order() -> None: diff --git a/motrix_rl_builtin/tests/test_fastsac_networks.py b/motrix_rl_builtin/tests/test_fastsac_networks.py new file mode 100644 index 00000000..a2ec31d5 --- /dev/null +++ b/motrix_rl_builtin/tests/test_fastsac_networks.py @@ -0,0 +1,26 @@ +# Copyright Motphys Technology Co., Ltd. 2025, 2026 +# SPDX-License-Identifier: Apache-2.0 + +"""FastSAC actor output and gradient contracts.""" + +import pytest +import torch +from motrix_rl_builtin.fastsac.networks import Actor + + +@pytest.mark.parametrize("use_tanh", [True, False]) +def test_actor_preserves_gaussian_mean_and_its_gradient(use_tanh): + """Regression: a hard mean clamp must not kill gradients outside its bounds.""" + actor = Actor(2, 2, 16, 2.0, -5.0, use_tanh=use_tanh) + expected_mean = torch.tensor([4.0, -4.0]) + with torch.no_grad(): + actor.fc_mu.weight.zero_() + actor.fc_mu.bias.copy_(expected_mean) + + action, mean, _ = actor(torch.zeros(3, 2)) + torch.testing.assert_close(mean, expected_mean.expand(3, 2)) + expected_action = expected_mean.tanh() if use_tanh else expected_mean + torch.testing.assert_close(action, expected_action.expand(3, 2)) + + mean.sum().backward() + torch.testing.assert_close(actor.fc_mu.bias.grad, torch.full((2,), 3.0)) diff --git a/motrix_rl_builtin/tests/test_fastsac_pipeline_equivalence.py b/motrix_rl_builtin/tests/test_fastsac_pipeline_equivalence.py index 7f38f73b..c1aebf84 100644 --- a/motrix_rl_builtin/tests/test_fastsac_pipeline_equivalence.py +++ b/motrix_rl_builtin/tests/test_fastsac_pipeline_equivalence.py @@ -74,8 +74,8 @@ def test_generation_merge_matches_single_ring_byte_for_byte() -> None: # read cursors must advance by exactly the number of drained slots — a # desync here stalls the generation merge permanently (the learner waits # for a generation whose slots it already consumed) - assert single.rings[0].read_idx == batches - assert all(r.read_idx == batches for r in rings) + assert single.rings[0].read_completed_count == batches + assert all(r.read_completed_count == batches for r in rings) def test_generation_merge_preserves_n_step_adjacency() -> None: diff --git a/wiki/design/array-env-final-observation.md b/wiki/design/array-env-final-observation.md new file mode 100644 index 00000000..69ba28bc --- /dev/null +++ b/wiki/design/array-env-final-observation.md @@ -0,0 +1,29 @@ +# ArrayEnv 终态观测结果契约 + +## 摘要 + +ArrayEnv 在时间截断自动 reset 前捕获当前 transition 的终态观测,通过 `ArrayEnvState.final_obs` 发布稀疏行快照。该结果不绑定 RL provider;ManagerEnv 实现捕获,FastSAC adapter 只负责传递和消费,不修改环境结果。本设计仅覆盖 NumPy 前端与 truncated 行,不扩展 Torch 前端或纯 terminated 行。 + +## 结果结构与生命周期 + +`FinalObs` 为普通 dataclass,包含 `env_ids: np.ndarray` 和 `obs: NpObs`。观测行顺序与 env_ids 一致,policy/value 保持原始观测结构;没有独立 value 时仍为 None,消费者使用 `value_or_policy`。行数组为独立快照,可跨 step 保留;不需要重复复制 policy 作为 value。消费者不得修改共享结果。 + +`ArrayEnvState.final_obs` 为可选字段。初始化为 None,每次 step 在推进环境前通过 replace 清空当前结果;此前返回的 state 持有的终态快照不因此清空。step 在 compute_transition、episode_steps 和 truncation 更新后、任何 auto-reset 写入前调用 `_capture_final_obs(env_ids)`。仅有 truncated 行时调用;没有截断或实现不支持捕获时为 None。读取无消费副作用。 + +ArrayEnv 的默认捕获 hook 返回 None,明确表示当前实现不提供快照;不尝试通过重复调用可能含副作用的通用 compute_observation 自动兼容所有子类。ManagerEnv 使用已有 compiled observe kernel 实现。sim-reset-only 行不进入 final_obs。 + +## Manager 捕获语义 + +捕获读取 compute_transition 已刷新的 simulator 数据,并发生在 command on_transition 之后、command reset 之前。它与同一 pre-reset 状态的正常 observe 使用相同输入;不回退 command,不额外刷新 simulator。 + +Observation term 应仅读取上下文并写观测输出,不推进 command 或历史;现有噪声 term 可以消费 ctx.rand。终态捕获直接使用共享 per-lane RNG,不备份或恢复;额外 observe 消耗噪声随机数,因此可改变同 seed 下后续 reset 和正常观测的随机轨迹,但不要求保持捕获前后的随机序列一致。终态捕获会额外执行一次整批 observe,因此不能用观察函数维护每步只应更新一次的状态。捕获使用独立预绑定 output slot,不写正常 observation 双缓冲;NumPy 高级索引已生成独立行数组,无需额外 copy。 + +当前 compiled observe 不支持行子集,因此少量截断仍有整批执行成本。性能优化必须先分别测量 kernel 与 compact row gather 成本,不为了潜在用途新增编译入口。 + +## FastSAC 边界 + +NumPy adapter 保存最近 state 的 final_obs 引用,reset 清空 adapter 引用;公开只读属性不清空底层 state。collector 将 FinalObs 转换为传输所需的 env_ids、policy 与 value_or_policy。异步传输、背压和 replay 存储的生命周期不属于本次 Env 结构整理,见 [FastSAC 异步训练器设计的终态观测 payload](./fastsac-async-heterogeneous-trainer.md#终态观测-payload)。 + +## 验收 + +测试覆盖初始化、无截断、截断前后观测差异、多行选择、policy-only/value 观测、快照跨步寿命、command 时序和只读重复访问。性能测量区分无截断路径、少量/整批截断的 capture 成本;使用预热后的重复采样,不包含编译时间。 diff --git a/wiki/design/fastsac-async-heterogeneous-trainer.md b/wiki/design/fastsac-async-heterogeneous-trainer.md index a23ed43d..c6b3089f 100644 --- a/wiki/design/fastsac-async-heterogeneous-trainer.md +++ b/wiki/design/fastsac-async-heterogeneous-trainer.md @@ -140,6 +140,17 @@ host 环下 learner 的 drain 承担全部搬运:ring→pinned 的 CPU memcpy **语义一致性**:collector 只是把同步版 collect 相位原样搬到另一进程,transition 的构造代码相同——调用顺序、dtype(dones/truncations 用 long)、auto-reset 后的 next_obs 语义与同步版逐字节一致。这是「算法未变、只变执行拓扑」的基础。 +#### 终态观测 payload + +单 async collector 且 Env 声明 episode horizon 时,trainer 置 `final_obs_bootstrap=True`:据此创建稀疏行区、启用 ring payload,并让 replay 在构造时建立终态行表(bool 本身不参与容量推导)。Env 快照契约见 [ArrayEnv 终态观测](./array-env-final-observation.md)。普通 next observation 由 successor slot 推导,截断 transition 使用紧凑的 `FinalObsPayload(env_ids, obs, critic_obs)` 覆盖 bootstrap 观测。 + +- `FinalObsStorage` 是整条 ring 共用的稀疏共享行区(2*num_envs 行),slot 记录起点、行数及绝对行尾。主字段与 payload 完整写入后一起发布,消费确认后一起回收;CUDA IPC 的确认由完成事件控制。容量只影响背压,不依赖截断间隔。 +- 主 ring 满时 collector 不推进 Env;完成 step 后若稀疏区满,保存一个 owned pending step,只重试发布。成功后才更新当前观测、进度和统计。 +- Learner 在释放 slot 前复制终态行,并通过 replay 的 `extend_batch(..., final_payloads=...)` 一起 ingest,warmup 和不训练的循环也执行。设备采样等待全部 ingest 写入。 +- Replay 以存活 transition 引用管理空闲行,覆盖 slot 时回收;行表按需几何增长,最多 n_env*(buffer_size+1) 行,不缩短 replay 窗口或覆盖有效引用。sample 在 compiled 网络更新之外执行;罕见 CUDA 表增长先同步 outstanding work。 + +内存预算:终态观测额外 backing 为 live_rows*(obs_dim+critic_obs_dim)*4 bytes。连续每步截断会达到 dense 最坏规模;正常稀疏场景只分配所需行。同步及多 collector 的终态传输尚未接入。 + ### 4.2 WeightSnapshot — seqlock 双缓冲(learner → collector) collector 前向只需两样东西,打包成一个快照:**actor 权重**(展平成单个 float 向量)与 **obs_normalizer 统计量**(`_mean` / `_std` / `_var` / `count`)。不含 qnet、critic_obs_normalizer、optimizer,故体积小。 diff --git a/wiki/design/index.md b/wiki/design/index.md index aebd762b..87ba5c81 100644 --- a/wiki/design/index.md +++ b/wiki/design/index.md @@ -36,6 +36,9 @@ - [用户文档 Envs 栏目设计](./user-docs-envs-part.md) 在现有双语用户指南的 Tutorials 之后建立独立 `Envs` 一级栏目:明确 Environment、Robot、Training Task 与 Tutorial Workflow 的边界,基于 registry、双语 metadata 和 Hydra Task 配置自动生成 Env ID、description 与支持训练算法的 overview 表格,以 Whole Body Tracking、Humanoid Locomotion、Quadruped Locomotion 等任务主题组织环境,并通过先建导航、再统一页面、最后迁移文件的方式渐进落地。 +- [ArrayEnv 终态观测结果契约](./array-env-final-observation.md) + NumPy 前端的稀疏终态观测结果、捕获时机、快照所有权与 Manager/FastSAC 边界。 + ## 使用说明 - 设计文档应优先放入对应主题目录,而不是直接平铺在本层。 From d53ace6ad0a7001baf97c2bfb0850f17011f6333 Mon Sep 17 00:00:00 2001 From: zilch Date: Wed, 14 Oct 2026 00:40:23 +0800 Subject: [PATCH 2/2] fix(fastsac): support final observations across multiple collectors --- .../fastsac/async_impl/learner.py | 22 ++ .../fastsac/async_impl/train.py | 2 +- .../tests/test_fastsac_multi_final_obs.py | 208 ++++++++++++++++++ wiki/design/array-env-final-observation.md | 2 +- .../fastsac-async-heterogeneous-trainer.md | 10 +- 5 files changed, 237 insertions(+), 7 deletions(-) create mode 100644 motrix_rl_builtin/tests/test_fastsac_multi_final_obs.py diff --git a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/learner.py b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/learner.py index c70568c3..91bf7abf 100644 --- a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/learner.py +++ b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/learner.py @@ -29,6 +29,7 @@ IpcTransitionRing, SharedTransitionRing, ) +from motrix_rl_builtin.fastsac.async_impl.transport.ring import FinalObsPayload from motrix_rl_builtin.fastsac.async_impl.transport.weight_channel import WeightSender from motrix_rl_builtin.fastsac.buffer import EmpiricalNormalization from motrix_rl_builtin.fastsac.config import FastSacCfg @@ -320,6 +321,27 @@ def _flush_complete_generations(self) -> int: final_payloads = None if len(self.rings) == 1 and self.rings[0].final_obs_store is not None: final_payloads = self.rings[0].read_final_span(len(generations)) + elif self.rings[0].final_obs_store is not None: + # Ring env ids are shard-local; replay ids are local to this rank, + # with the same collector-order blocks as the transition merge above. + shards = [ring.read_final_span(len(generations)) for ring in self.rings] + final_payloads = [] + for generation in zip(*shards): + parts = [] + env_offset = 0 + for ring, payload in zip(self.rings, generation): + if payload is not None: + parts.append(FinalObsPayload(payload.env_ids + env_offset, payload.obs, payload.critic_obs)) + env_offset += ring.num_envs + final_payloads.append( + FinalObsPayload( + torch.cat([part.env_ids for part in parts]), + torch.cat([part.obs for part in parts]), + torch.cat([part.critic_obs for part in parts]), + ) + if parts + else None + ) ingested = self._ingest_runs((stacked,), final_payloads=final_payloads) if final_payloads is not None: events = sum(p.env_ids.numel() for p in final_payloads if p is not None) diff --git a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/train.py b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/train.py index ce47b5ff..7ba15e60 100644 --- a/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/train.py +++ b/motrix_rl_builtin/src/motrix_rl_builtin/fastsac/async_impl/train.py @@ -211,7 +211,7 @@ def train(self) -> None: # actually uses (topology is the single source of truth), not the # host inventory. annotate_run(self._context.run_dir, system=capture_system_info(topology=topology)) - final_obs_enabled = max_episode_steps is not None and num_collectors == 1 + final_obs_enabled = max_episode_steps is not None final_stores: list[FinalObsStorage] | None = ( [FinalObsStorage(async_options.ring_capacity, shard, obs_dim, critic_obs_dim) for shard in env_shards] if final_obs_enabled diff --git a/motrix_rl_builtin/tests/test_fastsac_multi_final_obs.py b/motrix_rl_builtin/tests/test_fastsac_multi_final_obs.py new file mode 100644 index 00000000..0f4e553f --- /dev/null +++ b/motrix_rl_builtin/tests/test_fastsac_multi_final_obs.py @@ -0,0 +1,208 @@ +# Copyright Motphys Technology Co., Ltd. 2025, 2026 +# SPDX-License-Identifier: Apache-2.0 + +"""Async collector shards preserve terminal rows on the rank-local timeline.""" + +from types import SimpleNamespace + +import pytest +import torch +from fastsac_async_mocks import make_mock_learner +from motrix_rl_builtin.fastsac.async_impl.learner import DrainStaging +from motrix_rl_builtin.fastsac.async_impl.transport import IpcTransitionRing, RingCursors +from motrix_rl_builtin.fastsac.async_impl.transport.ring import ( + FinalObsPayload, + FinalObsStorage, + SharedTransitionRing, +) +from motrix_rl_builtin.fastsac.buffer import SimpleReplayBuffer + + +def _ring(num_envs, capacity=5, *, ipc=False): + final = FinalObsStorage(capacity, num_envs, 2, 3) + if ipc: + slots = torch.zeros(capacity, num_envs, 2 + 3 + 1 + 3, device="cuda") + return IpcTransitionRing(RingCursors(), slots, capacity, num_envs, 2, 3, 1, final_obs_store=final) + return SharedTransitionRing(capacity, num_envs, 2, 3, 1, final_obs_store=final) + + +def _rows(generation, env_ids, terminal=False): + tags = generation * 100 + torch.tensor(env_ids, dtype=torch.float32) + if terminal: + tags = tags + 10000 + policy = torch.stack((tags, tags + 0.5), dim=1) + critic = torch.stack((tags + 100000, tags + 200000, tags + 300000), dim=1) + return policy, critic + + +def _push(ring, generation, offset, ids): + obs, critic = _rows(generation, list(range(offset, offset + ring.num_envs))) + truncated = torch.zeros(ring.num_envs, dtype=torch.long) + payload = None + if ids: + truncated[ids] = 1 + final_obs, final_critic = _rows(generation, [offset + i for i in ids], terminal=True) + payload = FinalObsPayload(torch.tensor(ids), final_obs, final_critic) + return ring.push( + obs, + critic, + torch.zeros(ring.num_envs, 1), + torch.ones(ring.num_envs), + torch.zeros_like(truncated), + truncated, + final_obs=payload, + ) + + +def _learner(rings, *, device="cpu", n_steps=1, buffer_size=8, staging=False): + learner = make_mock_learner(rings, []) + learner.agent.device = torch.device(device) + learner.agent.rb = SimpleReplayBuffer( + sum(r.num_envs for r in rings), + buffer_size, + 2, + 1, + 3, + n_steps=n_steps, + device=device, + final_obs_bootstrap=True, + ) + learner.agent.window_metrics = SimpleNamespace(add=lambda *args, **kwargs: None) + # IDs address this rank's replay, not global DDP environment IDs. + learner.ddp_rank = 2 + if staging: + learner._staging = DrainStaging( + learner.agent.rb, learner.async_options.max_ingest_per_iter, learner.agent.device + ) + return learner + + +def _assert_final(rb, generation, env_ids): + slot = generation % rb._cap + actual_ids = torch.nonzero(rb._final_slot[:, slot] >= 0).flatten().cpu().tolist() + assert actual_ids == sorted(env_ids) + if env_ids: + rows = rb._final_slot[env_ids, slot].long() + policy, critic = _rows(generation, env_ids, terminal=True) + torch.testing.assert_close(rb._final_obs_table[rows].cpu(), policy) + torch.testing.assert_close(rb._final_critic_table[rows].cpu(), critic) + + +def test_multi_collector_sparse_ids_and_empty_generations_remain_aligned(): + # Unequal shards, including a payload-less middle ring, pin cumulative + # num_envs offsets rather than ring_index * first_shard_size. + rings = [_ring(2), _ring(3), _ring(1)] + learner = _learner(rings) + calls = [] + + def capture(*fields, final_payloads=None): + calls.append((fields, final_payloads)) + + learner.agent.rb = SimpleNamespace(extend_batch=capture) + plans = [([1, 0], [0]), ([], []), ([0], []), ([], [0])] + for generation, (first_ids, last_ids) in enumerate(plans): + assert _push(rings[0], generation, 0, first_ids) + assert _push(rings[1], generation, 2, []) + assert _push(rings[2], generation, 5, last_ids) + assert learner.drain() == 4 + assert len(calls) == 1 + fields, payloads = calls[0] + assert len(payloads) == 4 + expected_ids = [[1, 0, 5], [], [0], [5]] + for generation, ids in enumerate(expected_ids): + obs, critic = _rows(generation, list(range(6))) + torch.testing.assert_close(fields[0][generation], obs) + torch.testing.assert_close(fields[1][generation], critic) + payload = payloads[generation] + if not ids: + assert payload is None + else: + assert payload.env_ids.tolist() == ids + final_obs, final_critic = _rows(generation, ids, terminal=True) + torch.testing.assert_close(payload.obs, final_obs) + torch.testing.assert_close(payload.critic_obs, final_critic) + assert all(r.read_completed_count == 4 for r in rings) + + +def test_staggered_collectors_backpressure_and_wrap_preserve_final_rows(): + fast, slow = _ring(2, capacity=5), _ring(3, capacity=4) + learner = _learner([fast, slow], buffer_size=5) + for first in range(0, 24, 3): + for generation in range(first, first + 3): + assert _push(fast, generation, 0, [0, 1]) + # Sparse final storage backpressures before the main transition ring. + assert fast.size() == 3 < fast.capacity + assert fast.is_full() + assert not _push(fast, first + 3, 0, [0, 1]) + assert learner.drain() == 0 + assert fast.read_completed_count == first + assert slow.read_completed_count == first + for generation in range(first, first + 3): + ids = [2, 0] if generation % 2 == 0 else [] + assert _push(slow, generation, 2, ids) + # Physical ring boundaries may split ready generations into runs. + while learner.agent.rb.ptr <= generation: + assert learner.drain() > 0 + learner.wait_ingest() + assert learner.agent.rb.ptr == generation + 1 + _assert_final(learner.agent.rb, generation, [0, 1] + ([4, 2] if ids else [])) + assert not fast.is_full() + assert fast.read_completed_count == slow.read_completed_count == first + 3 + # All retained rows must still belong to their generation after both + # transition/sparse transport wraps and replay-slot reuse. + for generation in range(max(0, first + 3 - learner.agent.rb._cap), first + 3): + ids = [0, 1, 4, 2] if generation % 2 == 0 else [0, 1] + _assert_final(learner.agent.rb, generation, ids) + + +@pytest.mark.parametrize("n_steps", [1, 3]) +@pytest.mark.parametrize("path", ["cpu", "cuda-staged", "cuda-mixed", "cuda-ipc"]) +def test_multi_collector_bootstraps_at_each_envs_final_boundary(n_steps, path): + if path != "cpu" and not torch.cuda.is_available(): + pytest.skip("CUDA unavailable") + has_ipc = path in ("cuda-mixed", "cuda-ipc") + rings = [_ring(2, ipc=path == "cuda-ipc"), _ring(3, ipc=has_ipc)] + learner = _learner( + rings, + device="cuda" if path != "cpu" else "cpu", + n_steps=n_steps, + staging=path == "cuda-staged", + ) + # Environment 1 times out at t=1; environment 4 at t=2. Other + # environments continue, and auto-reset successors differ from final rows. + for generation in range(5): + assert _push(rings[0], generation, 0, [1] if generation == 1 else []) + assert _push(rings[1], generation, 2, [2] if generation == 2 else []) + if has_ipc: + torch.cuda.synchronize() # publish IPC producer's completed event + assert learner.drain() == 1 + learner.wait_ingest() + _assert_final(learner.agent.rb, 1, [1]) + _assert_final(learner.agent.rb, 2, [4]) + torch.manual_seed(7) + sample = learner.agent.rb.sample(128) + starts = (sample["obs"][:, 0] // 100).long().cpu() + env_ids = (sample["obs"][:, 0] % 100).long().cpu() + saw_terminal = set() + saw_continuing = False + for row, (start, env_id) in enumerate(zip(starts.tolist(), env_ids.tolist())): + boundary = {1: 1, 4: 2}.get(env_id) + terminal = boundary is not None and start <= boundary < start + n_steps + if terminal: + end = boundary + saw_terminal.add(env_id) + expected_obs, expected_critic = _rows(end, [env_id], terminal=True) + assert sample["truncations"][row].item() == 1 + assert sample["dones"][row].item() == 0 + else: + end = min(start + n_steps, 4) + expected_obs, expected_critic = _rows(end, [env_id]) + assert sample["truncations"][row].item() == 0 + saw_continuing = True + torch.testing.assert_close(sample["next_obs"][row].cpu(), expected_obs[0]) + torch.testing.assert_close(sample["next_critic_obs"][row].cpu(), expected_critic[0]) + assert saw_terminal == {1, 4} + assert saw_continuing + if has_ipc: + torch.cuda.synchronize() + assert all(r.read_completed_count == 5 for r in rings) diff --git a/wiki/design/array-env-final-observation.md b/wiki/design/array-env-final-observation.md index 69ba28bc..a787eff9 100644 --- a/wiki/design/array-env-final-observation.md +++ b/wiki/design/array-env-final-observation.md @@ -22,7 +22,7 @@ Observation term 应仅读取上下文并写观测输出,不推进 command 或 ## FastSAC 边界 -NumPy adapter 保存最近 state 的 final_obs 引用,reset 清空 adapter 引用;公开只读属性不清空底层 state。collector 将 FinalObs 转换为传输所需的 env_ids、policy 与 value_or_policy。异步传输、背压和 replay 存储的生命周期不属于本次 Env 结构整理,见 [FastSAC 异步训练器设计的终态观测 payload](./fastsac-async-heterogeneous-trainer.md#终态观测-payload)。 +FastSAC adapter 的 step 返回 `FastSacStepResult`,将本步 obs、critic_obs、reward、terminated、truncated、final_obs 与 info 放在同一个结果中;NumPy adapter 直接传递 state 的 final_obs 引用,不在 wrapper 上缓存旁路状态。reset 仍返回 obs/critic_obs 二元组。collector 将 FinalObs 转换为传输所需的 env_ids、policy 与 value_or_policy。异步传输、背压和 replay 存储的生命周期不属于本次 Env 结构整理,见 [FastSAC 异步训练器设计的终态观测 payload](./fastsac-async-heterogeneous-trainer.md#终态观测-payload)。 ## 验收 diff --git a/wiki/design/fastsac-async-heterogeneous-trainer.md b/wiki/design/fastsac-async-heterogeneous-trainer.md index c6b3089f..353727a3 100644 --- a/wiki/design/fastsac-async-heterogeneous-trainer.md +++ b/wiki/design/fastsac-async-heterogeneous-trainer.md @@ -142,14 +142,14 @@ host 环下 learner 的 drain 承担全部搬运:ring→pinned 的 CPU memcpy #### 终态观测 payload -单 async collector 且 Env 声明 episode horizon 时,trainer 置 `final_obs_bootstrap=True`:据此创建稀疏行区、启用 ring payload,并让 replay 在构造时建立终态行表(bool 本身不参与容量推导)。Env 快照契约见 [ArrayEnv 终态观测](./array-env-final-observation.md)。普通 next observation 由 successor slot 推导,截断 transition 使用紧凑的 `FinalObsPayload(env_ids, obs, critic_obs)` 覆盖 bootstrap 观测。 +Async Env 声明 episode horizon 时,trainer 为所有 collector 创建各自的稀疏行区并启用 ring payload。每个 learner 收到本 rank 的 `final_stores` 列表,从其是否为 None 推导 agent 的 `final_obs_bootstrap`,让 replay 在构造时建立终态行表(bool 本身不参与容量推导)。所有 collector 的终态支持统一启用或禁用。Env 快照契约见 [ArrayEnv 终态观测](./array-env-final-observation.md)。普通 next observation 由 successor slot 推导,截断 transition 使用紧凑的 `FinalObsPayload(env_ids, obs, critic_obs)` 覆盖 bootstrap 观测。 -- `FinalObsStorage` 是整条 ring 共用的稀疏共享行区(2*num_envs 行),slot 记录起点、行数及绝对行尾。主字段与 payload 完整写入后一起发布,消费确认后一起回收;CUDA IPC 的确认由完成事件控制。容量只影响背压,不依赖截断间隔。 -- 主 ring 满时 collector 不推进 Env;完成 step 后若稀疏区满,保存一个 owned pending step,只重试发布。成功后才更新当前观测、进度和统计。 -- Learner 在释放 slot 前复制终态行,并通过 replay 的 `extend_batch(..., final_payloads=...)` 一起 ingest,warmup 和不训练的循环也执行。设备采样等待全部 ingest 写入。 +- `FinalObsStorage` 是每条 ring 独立的稀疏共享行区(3*num_envs 行,num_envs 为该 collector 的分片大小),slot 记录起点、行数及绝对行尾。主字段与 payload 完整写入后一起发布,消费确认后一起回收;CUDA IPC 的确认由完成事件控制。容量只影响背压,不依赖截断间隔。 +- Collector 在采样动作和推进 Env 前检查主 ring 空 slot 与至少 num_envs 个空闲终态行,按本步最坏情况保守检查;consumer 只释放容量,SPSC 下本步不会再因容量失败。两种容量背压均计入 collector 的 wait timing。 +- Learner 在释放 slot 前复制终态行,并通过 replay 的 `extend_batch(..., final_payloads=...)` 一起 ingest,warmup 和不训练的循环也执行。多 collector 按普通 transition 的 generation 顺序合并 payload,env_ids 加上本 rank 内前序 collector 的累计环境数偏移;无终态行的 generation 使用 None。未完成 generation 的 slot 与行保持未确认,快慢 collector 不会引起错配。设备采样等待全部 ingest 写入。 - Replay 以存活 transition 引用管理空闲行,覆盖 slot 时回收;行表按需几何增长,最多 n_env*(buffer_size+1) 行,不缩短 replay 窗口或覆盖有效引用。sample 在 compiled 网络更新之外执行;罕见 CUDA 表增长先同步 outstanding work。 -内存预算:终态观测额外 backing 为 live_rows*(obs_dim+critic_obs_dim)*4 bytes。连续每步截断会达到 dense 最坏规模;正常稀疏场景只分配所需行。同步及多 collector 的终态传输尚未接入。 +内存预算:终态观测额外 backing 为 live_rows*(obs_dim+critic_obs_dim)*4 bytes。连续每步截断会达到 dense 最坏规模;正常稀疏场景只分配所需行。同步训练路径与 Torch 前端的终态快照尚未接入。 ### 4.2 WeightSnapshot — seqlock 双缓冲(learner → collector)