949 lines
38 KiB
Python
949 lines
38 KiB
Python
import logging
|
|
import math
|
|
import time
|
|
from collections import defaultdict
|
|
from typing import Collection, DefaultDict, List, Optional, Union
|
|
|
|
import gymnasium as gym
|
|
from gymnasium.wrappers.vector import DictInfoToList
|
|
|
|
import ray
|
|
from ray._common.deprecation import Deprecated
|
|
from ray.rllib.algorithms.algorithm_config import AlgorithmConfig
|
|
from ray.rllib.callbacks.callbacks import RLlibCallback
|
|
from ray.rllib.callbacks.utils import make_callback
|
|
from ray.rllib.core import (
|
|
COMPONENT_ENV_TO_MODULE_CONNECTOR,
|
|
COMPONENT_MODULE_TO_ENV_CONNECTOR,
|
|
COMPONENT_RL_MODULE,
|
|
DEFAULT_AGENT_ID,
|
|
DEFAULT_MODULE_ID,
|
|
)
|
|
from ray.rllib.core.columns import Columns
|
|
from ray.rllib.core.rl_module.rl_module import RLModule, RLModuleSpec
|
|
from ray.rllib.env import INPUT_ENV_SINGLE_SPACES, INPUT_ENV_SPACES
|
|
from ray.rllib.env.env_context import EnvContext
|
|
from ray.rllib.env.env_runner import ENV_STEP_FAILURE, EnvRunner
|
|
from ray.rllib.env.single_agent_episode import SingleAgentEpisode
|
|
from ray.rllib.utils import force_list
|
|
from ray.rllib.utils.annotations import override
|
|
from ray.rllib.utils.checkpoints import Checkpointable
|
|
from ray.rllib.utils.framework import get_device
|
|
from ray.rllib.utils.metrics import (
|
|
ENV_RUNNER_STATE_SERVER_PULL_TIMER,
|
|
ENV_TO_MODULE_CONNECTOR,
|
|
EPISODE_DURATION_SEC_MEAN,
|
|
EPISODE_LEN_MAX,
|
|
EPISODE_LEN_MEAN,
|
|
EPISODE_LEN_MIN,
|
|
EPISODE_RETURN_MAX,
|
|
EPISODE_RETURN_MEAN,
|
|
EPISODE_RETURN_MIN,
|
|
MODULE_TO_ENV_CONNECTOR,
|
|
NUM_AGENT_STEPS_SAMPLED,
|
|
NUM_AGENT_STEPS_SAMPLED_LIFETIME,
|
|
NUM_ENV_STEPS_SAMPLED,
|
|
NUM_ENV_STEPS_SAMPLED_LIFETIME,
|
|
NUM_EPISODES,
|
|
NUM_EPISODES_LIFETIME,
|
|
NUM_MODULE_STEPS_SAMPLED,
|
|
NUM_MODULE_STEPS_SAMPLED_LIFETIME,
|
|
RLMODULE_INFERENCE_TIMER,
|
|
SAMPLE_TIMER,
|
|
TIME_BETWEEN_SAMPLING,
|
|
WEIGHTS_SEQ_NO,
|
|
)
|
|
from ray.rllib.utils.spaces.space_utils import unbatch
|
|
from ray.rllib.utils.typing import EpisodeID, ResultDict, StateDict
|
|
from ray.tune.registry import ENV_CREATOR, _global_registry
|
|
from ray.util import log_once
|
|
from ray.util.annotations import PublicAPI
|
|
|
|
logger = logging.getLogger("ray.rllib")
|
|
|
|
|
|
# TODO (sven): As soon as RolloutWorker is no longer supported, make `EnvRunner` itself
|
|
# a Checkpointable. Currently, only some of its subclasses are Checkpointables.
|
|
@PublicAPI(stability="alpha")
|
|
class SingleAgentEnvRunner(EnvRunner, Checkpointable):
|
|
"""The generic environment runner for the single agent case."""
|
|
|
|
@override(EnvRunner)
|
|
def __init__(self, *, config: AlgorithmConfig, **kwargs):
|
|
"""Initializes a SingleAgentEnvRunner instance.
|
|
|
|
Args:
|
|
config: An `AlgorithmConfig` object containing all settings needed to
|
|
build this `EnvRunner` class.
|
|
"""
|
|
super().__init__(config=config, **kwargs)
|
|
|
|
self.tune_trial_id: str = kwargs.get("tune_trial_id")
|
|
self.spaces = kwargs.get("spaces", {})
|
|
|
|
# Create our callbacks object.
|
|
self._callbacks: List[RLlibCallback] = [
|
|
cls() for cls in force_list(self.config.callbacks_class)
|
|
]
|
|
|
|
# Set device.
|
|
self._device = get_device(
|
|
self.config,
|
|
0 if not self.worker_index else self.config.num_gpus_per_env_runner,
|
|
)
|
|
|
|
# Create the vectorized gymnasium env.
|
|
self.env: Optional[gym.vector.VectorEnv] = None
|
|
self.num_envs: int = 0
|
|
if (
|
|
self.worker_index is None
|
|
or self.worker_index > 0
|
|
or self.config.create_env_on_local_worker
|
|
or self.config.num_env_runners == 0
|
|
):
|
|
self.make_env()
|
|
|
|
# Create the env-to-module connector pipeline.
|
|
self._env_to_module = self.config.build_env_to_module_connector(
|
|
env=self.env, spaces=self.spaces, device=self._device
|
|
)
|
|
# Cached env-to-module results taken at the end of a `_sample_timesteps()`
|
|
# call to make sure the final observation (before an episode cut) gets properly
|
|
# processed (and maybe postprocessed and re-stored into the episode).
|
|
# For example, if we had a connector that normalizes observations and directly
|
|
# re-inserts these new obs back into the episode, the last observation in each
|
|
# sample call would NOT be processed, which could be very harmful in cases,
|
|
# in which value function bootstrapping of those (truncation) observations is
|
|
# required in the learning step.
|
|
self._cached_to_module = None
|
|
|
|
# Create the RLModule.
|
|
self.module: Optional[RLModule] = None
|
|
self.make_module()
|
|
|
|
# Create the module-to-env connector pipeline.
|
|
self._module_to_env = self.config.build_module_to_env_connector(
|
|
env=self.env, spaces=self.spaces
|
|
)
|
|
|
|
self._needs_initial_reset: bool = True
|
|
self._ongoing_episodes: List[Optional[SingleAgentEpisode]] = [
|
|
None for _ in range(self.num_envs)
|
|
]
|
|
|
|
self._done_episodes_for_metrics: List[SingleAgentEpisode] = []
|
|
self._ongoing_episodes_for_metrics: DefaultDict[
|
|
EpisodeID, List[SingleAgentEpisode]
|
|
] = defaultdict(list)
|
|
self._weights_seq_no: int = 0
|
|
# Set by the Algorithm when `config.use_env_runner_state_server=True`.
|
|
self._env_runner_state_server = None
|
|
|
|
# Measures the time passed between returning from `sample()`
|
|
# and receiving the next `sample()` request from the user.
|
|
self._time_after_sampling = None
|
|
|
|
# Save whether to convert episodes to numpy during sample
|
|
# In `OfflineSingleAgentEnvRunner`, this result is set to False
|
|
# during initialisation
|
|
self.episodes_to_numpy = self.config.episodes_to_numpy
|
|
|
|
@override(EnvRunner)
|
|
def sample(
|
|
self,
|
|
*,
|
|
num_timesteps: int = None,
|
|
num_episodes: int = None,
|
|
explore: bool = None,
|
|
random_actions: bool = False,
|
|
force_reset: bool = False,
|
|
) -> List[SingleAgentEpisode]:
|
|
"""Runs and returns a sample (n timesteps or m episodes) on the env(s).
|
|
|
|
If neither `num_timesteps` nor `num_episodes` are provided and the config
|
|
`batch_mode` is "truncate_episodes" then
|
|
`config.get_rollout_fragment_length(self.worker_index) * self.num_envs`
|
|
timesteps will be sampled.
|
|
|
|
Args:
|
|
num_timesteps: The minimum number of timesteps to sample during this call.
|
|
The episodes returned will contain the total timesteps greater than or
|
|
equal to num_timesteps and less than num_timesteps + num_envs_per_env_runner.
|
|
Note that only one of `num_timesteps` or `num_episodes` may be provided.
|
|
Since we sample from envs in parallel, the number of returned timesteps
|
|
will be between num_timesteps and num_timesteps + num_envs_per_env_runner - 1.
|
|
num_episodes: The minimum number of episodes to sample during this call.
|
|
Note that only one of `num_timesteps` or `num_episodes` may be provided.
|
|
Since we sample from envs in parallel, the number of returned episodes
|
|
will be between num_episodes and num_episodes + num_envs_per_env_runner - 1.
|
|
explore: If True, will use the RLModule's `forward_exploration()`
|
|
method to compute actions. If False, will use the RLModule's
|
|
`forward_inference()` method. If None (default), will use the `explore`
|
|
boolean setting from `self.config` passed into this EnvRunner's
|
|
constructor. You can change this setting in your config via
|
|
`config.env_runners(explore=True|False)`.
|
|
random_actions: If True, actions will be sampled randomly (from the action
|
|
space of the environment). If False (default), actions or action
|
|
distribution parameters are computed by the RLModule.
|
|
force_reset: Whether to force-reset all vectorized environments before
|
|
sampling. Useful if you would like to collect a clean slate of new
|
|
episodes via this call. Note that when sampling n episodes
|
|
(`num_episodes != None`), this is fixed to True.
|
|
|
|
Returns:
|
|
A list of `SingleAgentEpisode` instances, carrying the sampled data.
|
|
"""
|
|
if self.env is None:
|
|
raise ValueError(
|
|
f"{self} doesn't have an env! Can't call `sample()` on it."
|
|
)
|
|
|
|
assert not (num_timesteps is not None and num_episodes is not None)
|
|
|
|
# Log time between `sample()` requests.
|
|
if self._time_after_sampling is not None:
|
|
self.metrics.log_value(
|
|
key=TIME_BETWEEN_SAMPLING,
|
|
value=time.perf_counter() - self._time_after_sampling,
|
|
)
|
|
|
|
# Pull-based weight sync: if a global `EnvRunnerStateServer` is configured, ask
|
|
# it for the latest state, transferring it only if it is newer than ours.
|
|
if self._env_runner_state_server is not None:
|
|
try:
|
|
# Single round-trip: the server returns the full state only if it is
|
|
# newer than ours (else None). Fall back to current weights if the
|
|
# server is unavailable.
|
|
with self.metrics.log_time(ENV_RUNNER_STATE_SERVER_PULL_TIMER):
|
|
_server_state = ray.get(
|
|
self._env_runner_state_server.pull_if_newer.remote(
|
|
self._weights_seq_no
|
|
)
|
|
)
|
|
except ray.exceptions.RayError as e:
|
|
_server_state = None
|
|
# Logged once per EnvRunner to avoid spamming this per-`sample()` path.
|
|
if log_once("env_runner_state_server_pull_failed"):
|
|
logger.warning(
|
|
"EnvRunner failed to pull state from the "
|
|
f"`EnvRunnerStateServer` ({type(e).__name__}). Falling back to "
|
|
"the current weights/connector states; sampling continues and "
|
|
"this should self-heal once the server is reachable again. This "
|
|
"warning is logged only once per EnvRunner."
|
|
)
|
|
if _server_state is not None:
|
|
self.set_state(_server_state)
|
|
|
|
# Log current weight seq no.
|
|
self.metrics.log_value(
|
|
key=WEIGHTS_SEQ_NO,
|
|
value=self._weights_seq_no,
|
|
window=1,
|
|
)
|
|
|
|
with self.metrics.log_time(SAMPLE_TIMER):
|
|
# If no execution details are provided, use the config to try to infer the
|
|
# desired timesteps/episodes to sample and exploration behavior.
|
|
if explore is None:
|
|
explore = self.config.explore
|
|
|
|
if (
|
|
num_timesteps is None
|
|
and num_episodes is None
|
|
and self.config.batch_mode == "truncate_episodes"
|
|
):
|
|
num_timesteps = (
|
|
self.config.get_rollout_fragment_length(self.worker_index)
|
|
* self.num_envs
|
|
)
|
|
|
|
# Sample n timesteps.
|
|
if num_timesteps is not None:
|
|
assert num_timesteps >= 0
|
|
samples = self._sample(
|
|
num_timesteps=num_timesteps,
|
|
explore=explore,
|
|
random_actions=random_actions,
|
|
force_reset=force_reset,
|
|
)
|
|
# Sample m episodes.
|
|
elif num_episodes is not None:
|
|
assert num_episodes >= 0
|
|
samples = self._sample(
|
|
num_episodes=num_episodes,
|
|
explore=explore,
|
|
random_actions=random_actions,
|
|
)
|
|
# For complete episodes mode, sample as long as the number of timesteps
|
|
# done is smaller than the `train_batch_size`.
|
|
else:
|
|
samples = self._sample(
|
|
num_episodes=self.num_envs,
|
|
explore=explore,
|
|
random_actions=random_actions,
|
|
)
|
|
|
|
# Make the `on_sample_end` callback.
|
|
make_callback(
|
|
"on_sample_end",
|
|
callbacks_objects=self._callbacks,
|
|
callbacks_functions=self.config.callbacks_on_sample_end,
|
|
kwargs=dict(
|
|
env_runner=self,
|
|
metrics_logger=self.metrics,
|
|
samples=samples,
|
|
),
|
|
)
|
|
|
|
self._time_after_sampling = time.perf_counter()
|
|
|
|
return samples
|
|
|
|
def _sample(
|
|
self,
|
|
*,
|
|
num_timesteps: Optional[int] = None,
|
|
num_episodes: Optional[int] = None,
|
|
explore: bool,
|
|
random_actions: bool = False,
|
|
force_reset: bool = False,
|
|
) -> List[SingleAgentEpisode]:
|
|
"""Helper method to sample n timesteps or m episodes."""
|
|
ts = 0
|
|
eps = 0
|
|
done_episodes_to_return: List[SingleAgentEpisode] = []
|
|
|
|
# Have to reset the env (on all vector sub_envs).
|
|
if force_reset or num_episodes is not None or self._needs_initial_reset:
|
|
ts = 0
|
|
self._reset_envs_and_episodes(explore)
|
|
|
|
if num_episodes is not None:
|
|
self._needs_initial_reset = True
|
|
|
|
# Loop through `num_timesteps` timesteps or `num_episodes` episodes.
|
|
|
|
while (
|
|
(ts < num_timesteps) if num_timesteps is not None else (eps < num_episodes)
|
|
):
|
|
# Act randomly.
|
|
if random_actions:
|
|
to_env = {
|
|
Columns.ACTIONS: self.env.action_space.sample(),
|
|
}
|
|
# Compute an action using the RLModule.
|
|
else:
|
|
# Env-to-module connector (already cached).
|
|
to_module = self._cached_to_module
|
|
assert to_module is not None
|
|
self._cached_to_module = None
|
|
|
|
# RLModule forward pass: Explore or not.
|
|
if explore:
|
|
# Global env steps sampled are (roughly) this EnvRunner's lifetime
|
|
# count times the number of env runners in the algo.
|
|
global_env_steps_lifetime = (
|
|
self.num_env_steps_sampled_lifetime
|
|
// (self.config.num_env_runners or 1)
|
|
+ ts
|
|
) * (self.config.num_env_runners or 1)
|
|
with self.metrics.log_time(RLMODULE_INFERENCE_TIMER):
|
|
to_env = self.module.forward_exploration(
|
|
to_module, t=global_env_steps_lifetime
|
|
)
|
|
else:
|
|
with self.metrics.log_time(RLMODULE_INFERENCE_TIMER):
|
|
to_env = self.module.forward_inference(to_module)
|
|
|
|
# Module-to-env connector.
|
|
to_env = self._module_to_env(
|
|
rl_module=self.module,
|
|
batch=to_env,
|
|
episodes=self._ongoing_episodes,
|
|
explore=explore,
|
|
shared_data=self._shared_data,
|
|
metrics=self.metrics,
|
|
metrics_prefix_key=(MODULE_TO_ENV_CONNECTOR,),
|
|
)
|
|
|
|
# Extract the (vectorized) actions (to be sent to the env) from the
|
|
# module/connector output. Note that these actions are fully ready (e.g.
|
|
# already unsquashed/clipped) to be sent to the environment and might not
|
|
# be identical to the actions produced by the RLModule/distribution, which
|
|
# are the ones stored permanently in the episode objects.
|
|
actions = to_env.pop(Columns.ACTIONS)
|
|
actions_for_env = to_env.pop(Columns.ACTIONS_FOR_ENV, actions)
|
|
# Try stepping the environment.
|
|
results = self._try_env_step(actions_for_env)
|
|
# If the env step fails, reset the envs and continue the loop.
|
|
if results == ENV_STEP_FAILURE:
|
|
ts = 0
|
|
self._reset_envs_and_episodes(explore)
|
|
continue
|
|
|
|
observations, rewards, terminateds, truncateds, infos = results
|
|
observations, actions = unbatch(observations), unbatch(actions)
|
|
|
|
call_on_episode_start = set()
|
|
for env_index in range(self.num_envs):
|
|
extra_model_output = {k: v[env_index] for k, v in to_env.items()}
|
|
extra_model_output[WEIGHTS_SEQ_NO] = self._weights_seq_no
|
|
|
|
# Episode has no data in it yet -> Was just reset and needs to be called
|
|
# with its `add_env_reset()` method.
|
|
if not self._ongoing_episodes[env_index].is_reset:
|
|
self._ongoing_episodes[env_index].add_env_reset(
|
|
observation=observations[env_index],
|
|
infos=infos[env_index],
|
|
)
|
|
call_on_episode_start.add(env_index)
|
|
|
|
# Call `add_env_step()` method on episode.
|
|
else:
|
|
# Only increase ts when we actually stepped (not reset as a reset
|
|
# does not count as a timestep).
|
|
ts += 1
|
|
self._ongoing_episodes[env_index].add_env_step(
|
|
observation=observations[env_index],
|
|
action=actions[env_index],
|
|
reward=rewards[env_index],
|
|
infos=infos[env_index],
|
|
terminated=terminateds[env_index],
|
|
truncated=truncateds[env_index],
|
|
extra_model_outputs=extra_model_output,
|
|
)
|
|
|
|
# Env-to-module connector pass cache results as we will do the RLModule
|
|
# forward pass only in the next `while`-iteration.
|
|
if self.module is not None:
|
|
kwargs = {
|
|
Columns.OBS: observations,
|
|
Columns.ACTIONS: actions,
|
|
Columns.REWARDS: rewards,
|
|
Columns.INFOS: infos,
|
|
Columns.TERMINATEDS: terminateds,
|
|
Columns.TRUNCATEDS: truncateds,
|
|
}
|
|
self._cached_to_module = self._env_to_module(
|
|
episodes=self._ongoing_episodes,
|
|
batch={},
|
|
explore=explore,
|
|
rl_module=self.module,
|
|
shared_data=self._shared_data,
|
|
metrics=self.metrics,
|
|
metrics_prefix_key=(ENV_TO_MODULE_CONNECTOR,),
|
|
# Also pass in data as kwargs so that connectors have easy access to batched data
|
|
**kwargs,
|
|
)
|
|
|
|
for env_index in range(self.num_envs):
|
|
# Call `on_episode_start()` callback (always after reset).
|
|
if env_index in call_on_episode_start:
|
|
self._make_on_episode_callback(
|
|
"on_episode_start", env_index, self._ongoing_episodes
|
|
)
|
|
# Make the `on_episode_step` callbacks.
|
|
else:
|
|
self._make_on_episode_callback(
|
|
"on_episode_step", env_index, self._ongoing_episodes
|
|
)
|
|
|
|
# Episode is done.
|
|
if self._ongoing_episodes[env_index].is_done:
|
|
eps += 1
|
|
|
|
# Make the `on_episode_end` callbacks (before finalizing the episode
|
|
# object).
|
|
self._make_on_episode_callback(
|
|
"on_episode_end", env_index, self._ongoing_episodes
|
|
)
|
|
|
|
# Numpy'ize the episode.
|
|
if self.episodes_to_numpy:
|
|
# Any possibly compress observations.
|
|
done_episodes_to_return.append(
|
|
self._ongoing_episodes[env_index].to_numpy()
|
|
)
|
|
# Leave episode as lists of individual (obs, action, etc..) items.
|
|
else:
|
|
done_episodes_to_return.append(
|
|
self._ongoing_episodes[env_index]
|
|
)
|
|
|
|
# Create a new episode object with no data in it and execute
|
|
# `on_episode_created` callback (before the `env.reset()` call).
|
|
self._new_episode(env_index, self._ongoing_episodes)
|
|
|
|
# Stop processing more envs if we've collected enough episodes.
|
|
if num_episodes is not None and eps >= num_episodes:
|
|
break
|
|
|
|
# Return done episodes ...
|
|
self._done_episodes_for_metrics.extend(done_episodes_to_return)
|
|
# ... and all ongoing episode chunks.
|
|
|
|
# Also, make sure we start new episode chunks (continuing the ongoing episodes
|
|
# from the to-be-returned chunks).
|
|
ongoing_episodes_to_return = []
|
|
# Only if we are doing individual timesteps: We have to maybe cut an ongoing
|
|
# episode and continue building it on the next call to `sample()`.
|
|
if num_timesteps is not None:
|
|
ongoing_episodes_continuations = [
|
|
eps.cut(len_lookback_buffer=self.config.episode_lookback_horizon)
|
|
for eps in self._ongoing_episodes
|
|
]
|
|
|
|
for eps in self._ongoing_episodes:
|
|
# Just started Episodes do not have to be returned. There is no data
|
|
# in them anyway.
|
|
if eps.t == 0:
|
|
continue
|
|
eps.validate()
|
|
self._ongoing_episodes_for_metrics[eps.id_].append(eps)
|
|
|
|
# Numpy'ize the episode.
|
|
if self.episodes_to_numpy:
|
|
# Any possibly compress observations.
|
|
ongoing_episodes_to_return.append(eps.to_numpy())
|
|
# Leave episode as lists of individual (obs, action, etc..) items.
|
|
else:
|
|
ongoing_episodes_to_return.append(eps)
|
|
|
|
# Continue collecting into the cut Episode chunks.
|
|
self._ongoing_episodes = ongoing_episodes_continuations
|
|
|
|
# Ray metrics
|
|
self._log_env_steps(metric=self._metrics_num_env_steps_sampled, num_steps=ts)
|
|
|
|
self._increase_sampled_metrics(ts, len(done_episodes_to_return))
|
|
|
|
# Return collected episode data.
|
|
return done_episodes_to_return + ongoing_episodes_to_return
|
|
|
|
@override(EnvRunner)
|
|
def get_spaces(self):
|
|
if self.env is None:
|
|
return self.spaces
|
|
return {
|
|
INPUT_ENV_SPACES: (self.env.observation_space, self.env.action_space),
|
|
INPUT_ENV_SINGLE_SPACES: (
|
|
self.env.single_observation_space,
|
|
self.env.single_action_space,
|
|
),
|
|
DEFAULT_MODULE_ID: (
|
|
self._env_to_module.observation_space,
|
|
self.env.single_action_space,
|
|
),
|
|
}
|
|
|
|
@override(EnvRunner)
|
|
def get_metrics(self) -> ResultDict:
|
|
# Compute per-episode metrics (only on already completed episodes).
|
|
for eps in self._done_episodes_for_metrics:
|
|
assert eps.is_done
|
|
episode_length = len(eps)
|
|
episode_return = eps.get_return()
|
|
episode_duration_s = eps.get_duration_s()
|
|
# Don't forget about the already returned chunks of this episode.
|
|
if eps.id_ in self._ongoing_episodes_for_metrics:
|
|
for eps2 in self._ongoing_episodes_for_metrics[eps.id_]:
|
|
episode_length += len(eps2)
|
|
episode_return += eps2.get_return()
|
|
episode_duration_s += eps2.get_duration_s()
|
|
del self._ongoing_episodes_for_metrics[eps.id_]
|
|
|
|
self._log_episode_metrics(
|
|
episode_length, episode_return, episode_duration_s
|
|
)
|
|
|
|
# Now that we have logged everything, clear cache of done episodes.
|
|
self._done_episodes_for_metrics.clear()
|
|
|
|
# Return reduced metrics.
|
|
return self.metrics.reduce()
|
|
|
|
@override(Checkpointable)
|
|
def get_state(
|
|
self,
|
|
components: Optional[Union[str, Collection[str]]] = None,
|
|
*,
|
|
not_components: Optional[Union[str, Collection[str]]] = None,
|
|
**kwargs,
|
|
) -> StateDict:
|
|
state = {NUM_ENV_STEPS_SAMPLED_LIFETIME: self.num_env_steps_sampled_lifetime}
|
|
|
|
if self._check_component(COMPONENT_RL_MODULE, components, not_components):
|
|
state[COMPONENT_RL_MODULE] = self.module.get_state(
|
|
components=self._get_subcomponents(COMPONENT_RL_MODULE, components),
|
|
not_components=self._get_subcomponents(
|
|
COMPONENT_RL_MODULE, not_components
|
|
),
|
|
**kwargs,
|
|
)
|
|
state[WEIGHTS_SEQ_NO] = self._weights_seq_no
|
|
if self._check_component(
|
|
COMPONENT_ENV_TO_MODULE_CONNECTOR, components, not_components
|
|
):
|
|
state[COMPONENT_ENV_TO_MODULE_CONNECTOR] = self._env_to_module.get_state()
|
|
if self._check_component(
|
|
COMPONENT_MODULE_TO_ENV_CONNECTOR, components, not_components
|
|
):
|
|
state[COMPONENT_MODULE_TO_ENV_CONNECTOR] = self._module_to_env.get_state()
|
|
|
|
return state
|
|
|
|
@override(Checkpointable)
|
|
def set_state(self, state: StateDict) -> None:
|
|
if COMPONENT_ENV_TO_MODULE_CONNECTOR in state:
|
|
self._env_to_module.set_state(state[COMPONENT_ENV_TO_MODULE_CONNECTOR])
|
|
if COMPONENT_MODULE_TO_ENV_CONNECTOR in state:
|
|
self._module_to_env.set_state(state[COMPONENT_MODULE_TO_ENV_CONNECTOR])
|
|
|
|
# Update the RLModule state.
|
|
if COMPONENT_RL_MODULE in state:
|
|
# A missing value for WEIGHTS_SEQ_NO or a value of 0 means: Force the
|
|
# update.
|
|
weights_seq_no = state.get(WEIGHTS_SEQ_NO, 0)
|
|
|
|
# Only update the weights, if this is the first synchronization or
|
|
# if the weights of this `EnvRunner` lag behind the actual ones.
|
|
if weights_seq_no == 0 or self._weights_seq_no < weights_seq_no:
|
|
rl_module_state = state[COMPONENT_RL_MODULE]
|
|
if isinstance(rl_module_state, ray.ObjectRef):
|
|
rl_module_state = ray.get(rl_module_state)
|
|
if (
|
|
isinstance(rl_module_state, dict)
|
|
and DEFAULT_MODULE_ID in rl_module_state
|
|
):
|
|
rl_module_state = rl_module_state[DEFAULT_MODULE_ID]
|
|
self.module.set_state(rl_module_state)
|
|
|
|
# Update our weights_seq_no, if the new one is > 0.
|
|
if weights_seq_no > 0:
|
|
self._weights_seq_no = weights_seq_no
|
|
|
|
# Update lifetime counters.
|
|
if NUM_ENV_STEPS_SAMPLED_LIFETIME in state:
|
|
self.num_env_steps_sampled_lifetime = state[NUM_ENV_STEPS_SAMPLED_LIFETIME]
|
|
|
|
@override(Checkpointable)
|
|
def get_ctor_args_and_kwargs(self):
|
|
return (
|
|
(), # *args
|
|
{"config": self.config}, # **kwargs
|
|
)
|
|
|
|
@override(Checkpointable)
|
|
def get_metadata(self):
|
|
metadata = Checkpointable.get_metadata(self)
|
|
metadata.update(
|
|
{
|
|
# TODO (sven): Maybe add serialized (JSON-writable) config here?
|
|
}
|
|
)
|
|
return metadata
|
|
|
|
@override(Checkpointable)
|
|
def get_checkpointable_components(self):
|
|
return [
|
|
(COMPONENT_RL_MODULE, self.module),
|
|
(COMPONENT_ENV_TO_MODULE_CONNECTOR, self._env_to_module),
|
|
(COMPONENT_MODULE_TO_ENV_CONNECTOR, self._module_to_env),
|
|
]
|
|
|
|
@override(EnvRunner)
|
|
def assert_healthy(self):
|
|
"""Checks that self.__init__() has been completed properly.
|
|
|
|
Ensures that the instance has a `MultiRLModule` and an
|
|
environment defined.
|
|
|
|
Raises:
|
|
AssertionError: If the EnvRunner Actor has NOT been properly initialized.
|
|
"""
|
|
# Make sure we have built our gym.vector.Env and RLModule properly.
|
|
assert self.env and hasattr(self, "module")
|
|
|
|
@override(EnvRunner)
|
|
def make_env(self) -> None:
|
|
"""Creates a vectorized gymnasium env and stores it in `self.env`.
|
|
|
|
Note that users can change the EnvRunner's config (e.g. change
|
|
`self.config.env_config`) and then call this method to create new environments
|
|
with the updated configuration.
|
|
"""
|
|
if self.env is not None:
|
|
try:
|
|
self.env.close()
|
|
except Exception as e:
|
|
logger.warning(
|
|
"Tried closing the existing env, but failed with error: "
|
|
f"{e.args[0]}"
|
|
)
|
|
|
|
env_config = self.config.env_config
|
|
if not isinstance(env_config, EnvContext):
|
|
env_ctx = EnvContext(
|
|
env_config,
|
|
worker_index=self.worker_index,
|
|
num_workers=self.num_workers,
|
|
remote=self.config.remote_worker_envs,
|
|
)
|
|
else:
|
|
env_ctx = env_config
|
|
|
|
# No env provided -> Error.
|
|
if not self.config.env:
|
|
raise ValueError(
|
|
"`config.env` is not provided! "
|
|
"You should provide a valid environment to your config through "
|
|
"`config.environment([env descriptor e.g. 'CartPole-v1'])`."
|
|
)
|
|
# Register env for the local context.
|
|
# Note, `gym.register` has to be called on each worker.
|
|
elif isinstance(self.config.env, str) and _global_registry.contains(
|
|
ENV_CREATOR, self.config.env
|
|
):
|
|
env_name = "rllib-single-agent-env-v0"
|
|
entry_point = _global_registry.get(ENV_CREATOR, self.config.env)
|
|
gym.register(
|
|
env_name,
|
|
entry_point=lambda: entry_point(env_ctx),
|
|
vector_entry_point=lambda num_envs: entry_point(
|
|
env_ctx | {"num_envs": num_envs}
|
|
),
|
|
)
|
|
env_config = {}
|
|
elif callable(self.config.env):
|
|
env_name = "rllib-single-agent-env-v0"
|
|
gym.register(
|
|
env_name,
|
|
entry_point=lambda: self.config.env(env_ctx),
|
|
vector_entry_point=lambda num_envs: self.config.env(
|
|
env_ctx | {"num_envs": num_envs}
|
|
),
|
|
)
|
|
env_config = {}
|
|
else:
|
|
env_name = self.config.env
|
|
|
|
vectorize_mode = gym.VectorizeMode(self.config.gym_env_vectorize_mode)
|
|
self.env = DictInfoToList(
|
|
gym.make_vec(
|
|
env_name,
|
|
num_envs=self.config.num_envs_per_env_runner,
|
|
vectorization_mode=vectorize_mode,
|
|
**env_config,
|
|
)
|
|
)
|
|
|
|
self.num_envs: int = self.env.num_envs
|
|
assert self.num_envs == self.config.num_envs_per_env_runner
|
|
|
|
# Set the flag to reset all envs upon the next `sample()` call.
|
|
self._needs_initial_reset = True
|
|
|
|
# Call the `on_environment_created` callback.
|
|
make_callback(
|
|
"on_environment_created",
|
|
callbacks_objects=self._callbacks,
|
|
callbacks_functions=self.config.callbacks_on_environment_created,
|
|
kwargs=dict(
|
|
env_runner=self,
|
|
metrics_logger=self.metrics,
|
|
env=self.env.unwrapped,
|
|
env_context=env_ctx,
|
|
),
|
|
)
|
|
|
|
@override(EnvRunner)
|
|
def make_module(self):
|
|
env = self.env.unwrapped if self.env is not None else None
|
|
try:
|
|
module_spec: RLModuleSpec = self.config.get_rl_module_spec(
|
|
env=env, spaces=self.get_spaces(), inference_only=True
|
|
)
|
|
# Build the module from its spec.
|
|
self.module = module_spec.build()
|
|
|
|
# Move the RLModule to our device.
|
|
# TODO (sven): In order to make this framework-agnostic, we should maybe
|
|
# make the RLModule.build() method accept a device OR create an additional
|
|
# `RLModule.to()` override.
|
|
self.module.to(self._device)
|
|
|
|
# If `AlgorithmConfig.get_rl_module_spec()` is not implemented, this env runner
|
|
# will not have an RLModule, but might still be usable with random actions.
|
|
except NotImplementedError:
|
|
self.module = None
|
|
|
|
@override(EnvRunner)
|
|
def stop(self):
|
|
# Close our env object via gymnasium's API.
|
|
if self.env is not None:
|
|
self.env.close()
|
|
|
|
def _reset_envs(self, episodes, shared_data, explore):
|
|
# Create n new episodes and make the `on_episode_created` callbacks.
|
|
for env_index in range(self.num_envs):
|
|
self._new_episode(env_index, episodes)
|
|
|
|
# Erase all cached ongoing episodes (these will never be completed and
|
|
# would thus never be returned/cleaned by `get_metrics` and cause a memory
|
|
# leak).
|
|
self._ongoing_episodes_for_metrics.clear()
|
|
|
|
# Try resetting the environment.
|
|
observations, infos = self._try_env_reset(
|
|
# Only seed (if seed provided) upon initial reset.
|
|
seed=self._seed if self._needs_initial_reset else None,
|
|
# TODO (sven): Support options?
|
|
options=None,
|
|
)
|
|
observations = unbatch(observations)
|
|
|
|
# Set initial obs and infos in the episodes.
|
|
for env_index in range(self.num_envs):
|
|
episodes[env_index].add_env_reset(
|
|
observation=observations[env_index],
|
|
infos=infos[env_index],
|
|
)
|
|
|
|
# Run the env-to-module connector to make sure the reset-obs/infos have
|
|
# properly been processed (if applicable).
|
|
self._cached_to_module = None
|
|
if self.module:
|
|
kwargs = {
|
|
Columns.OBS: observations,
|
|
Columns.INFOS: infos,
|
|
}
|
|
self._cached_to_module = self._env_to_module(
|
|
rl_module=self.module,
|
|
episodes=episodes,
|
|
explore=explore,
|
|
shared_data=shared_data,
|
|
metrics=self.metrics,
|
|
metrics_prefix_key=(ENV_TO_MODULE_CONNECTOR,),
|
|
**kwargs,
|
|
)
|
|
|
|
# Call `on_episode_start()` callbacks (always after reset).
|
|
for env_index in range(self.num_envs):
|
|
self._make_on_episode_callback("on_episode_start", env_index, episodes)
|
|
|
|
def _new_episode(self, env_index, episodes=None):
|
|
episodes = episodes if episodes is not None else self._ongoing_episodes
|
|
episodes[env_index] = SingleAgentEpisode(
|
|
observation_space=self.env.single_observation_space,
|
|
action_space=self.env.single_action_space,
|
|
)
|
|
self._make_on_episode_callback("on_episode_created", env_index, episodes)
|
|
|
|
def _make_on_episode_callback(
|
|
self, which: str, idx: int, episodes: List[SingleAgentEpisode]
|
|
):
|
|
kwargs = dict(
|
|
episode=episodes[idx],
|
|
env_runner=self,
|
|
metrics_logger=self.metrics,
|
|
env=self.env.unwrapped,
|
|
rl_module=self.module,
|
|
env_index=idx,
|
|
)
|
|
if which == "on_episode_end":
|
|
kwargs["prev_episode_chunks"] = self._ongoing_episodes_for_metrics[
|
|
episodes[idx].id_
|
|
]
|
|
|
|
make_callback(
|
|
which,
|
|
callbacks_objects=self._callbacks,
|
|
callbacks_functions=getattr(self.config, f"callbacks_{which}"),
|
|
kwargs=kwargs,
|
|
)
|
|
|
|
def _increase_sampled_metrics(self, num_steps, num_episodes_completed):
|
|
# Per sample cycle stats.
|
|
self.metrics.log_value(NUM_ENV_STEPS_SAMPLED, num_steps, reduce="sum")
|
|
self.metrics.log_value(
|
|
(NUM_AGENT_STEPS_SAMPLED, DEFAULT_AGENT_ID),
|
|
num_steps,
|
|
reduce="sum",
|
|
)
|
|
self.metrics.log_value(
|
|
(NUM_MODULE_STEPS_SAMPLED, DEFAULT_MODULE_ID),
|
|
num_steps,
|
|
reduce="sum",
|
|
)
|
|
self.metrics.log_value(
|
|
NUM_EPISODES,
|
|
num_episodes_completed,
|
|
reduce="sum",
|
|
)
|
|
# Lifetime stats.
|
|
self.metrics.log_value(
|
|
NUM_ENV_STEPS_SAMPLED_LIFETIME,
|
|
num_steps,
|
|
reduce="lifetime_sum",
|
|
with_throughput=True,
|
|
)
|
|
self.metrics.log_value(
|
|
(NUM_AGENT_STEPS_SAMPLED_LIFETIME, DEFAULT_AGENT_ID),
|
|
num_steps,
|
|
reduce="lifetime_sum",
|
|
)
|
|
self.metrics.log_value(
|
|
(NUM_MODULE_STEPS_SAMPLED_LIFETIME, DEFAULT_MODULE_ID),
|
|
num_steps,
|
|
reduce="lifetime_sum",
|
|
)
|
|
self.metrics.log_value(
|
|
NUM_EPISODES_LIFETIME,
|
|
num_episodes_completed,
|
|
reduce="lifetime_sum",
|
|
)
|
|
return num_steps
|
|
|
|
def _log_episode_metrics(self, length, ret, sec):
|
|
# Log general episode metrics.
|
|
# Use the configured window, but factor in the parallelism of the EnvRunners.
|
|
# As a result, we only log the last `window / num_env_runners` steps here,
|
|
# because everything gets parallel-merged in the Algorithm process.
|
|
win = max(
|
|
1,
|
|
int(
|
|
math.ceil(
|
|
self.config.metrics_num_episodes_for_smoothing
|
|
/ (self.config.num_env_runners or 1)
|
|
)
|
|
),
|
|
)
|
|
self.metrics.log_value(EPISODE_LEN_MEAN, length, window=win)
|
|
self.metrics.log_value(EPISODE_RETURN_MEAN, ret, window=win)
|
|
self.metrics.log_value(EPISODE_DURATION_SEC_MEAN, sec, window=win)
|
|
# Per-agent returns.
|
|
self.metrics.log_value(
|
|
("agent_episode_return_mean", DEFAULT_AGENT_ID), ret, window=win
|
|
)
|
|
# Per-RLModule returns.
|
|
self.metrics.log_value(
|
|
("module_episode_return_mean", DEFAULT_MODULE_ID), ret, window=win
|
|
)
|
|
|
|
# For some metrics, log min/max as well.
|
|
self.metrics.log_value(EPISODE_LEN_MIN, length, reduce="min", window=win)
|
|
self.metrics.log_value(EPISODE_RETURN_MIN, ret, reduce="min", window=win)
|
|
self.metrics.log_value(EPISODE_LEN_MAX, length, reduce="max", window=win)
|
|
self.metrics.log_value(EPISODE_RETURN_MAX, ret, reduce="max", window=win)
|
|
|
|
@Deprecated(
|
|
new="SingleAgentEnvRunner.get_state(components='rl_module')",
|
|
error=True,
|
|
)
|
|
def get_weights(self, *args, **kwargs):
|
|
pass
|
|
|
|
@Deprecated(new="SingleAgentEnvRunner.set_state()", error=True)
|
|
def set_weights(self, *args, **kwargs):
|
|
pass
|