chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
from ray.rllib.core.learner.learner import Learner
from ray.rllib.core.learner.learner_group import LearnerGroup
__all__ = [
"Learner",
"LearnerGroup",
]
@@ -0,0 +1,813 @@
import abc
import logging
from typing import (
TYPE_CHECKING,
Any,
Collection,
Dict,
Iterable,
Optional,
Tuple,
Union,
)
import numpy
from ray.rllib.connectors.learner.learner_connector_pipeline import (
LearnerConnectorPipeline,
)
from ray.rllib.core import ALL_MODULES, COMPONENT_METRICS_LOGGER
from ray.rllib.core.learner.training_data import TrainingData
from ray.rllib.core.rl_module.multi_rl_module import MultiRLModule
from ray.rllib.core.rl_module.rl_module import RLModule
from ray.rllib.policy.sample_batch import MultiAgentBatch, SampleBatch
from ray.rllib.utils import unflatten_dict
from ray.rllib.utils.annotations import (
OverrideToImplementCustomLogic,
OverrideToImplementCustomLogic_CallToSuperRecommended,
override,
)
from ray.rllib.utils.checkpoints import Checkpointable
from ray.rllib.utils.metrics import (
DATASET_NUM_ITERS_TRAINED,
DATASET_NUM_ITERS_TRAINED_LIFETIME,
MODULE_TRAIN_BATCH_SIZE_MEAN,
NUM_ENV_STEPS_TRAINED,
NUM_ENV_STEPS_TRAINED_LIFETIME,
NUM_MODULE_STEPS_TRAINED,
NUM_MODULE_STEPS_TRAINED_LIFETIME,
WEIGHTS_SEQ_NO,
)
from ray.rllib.utils.metrics.metrics_logger import MetricsLogger
from ray.rllib.utils.minibatch_utils import (
MiniBatchCyclicIterator,
MiniBatchDummyIterator,
MiniBatchRayDataIterator,
)
from ray.rllib.utils.typing import (
DeviceType,
ModuleID,
NamedParamDict,
ResultDict,
StateDict,
TensorType,
)
if TYPE_CHECKING:
from ray.rllib.algorithms.algorithm_config import AlgorithmConfig
from ray.rllib.core.learner.differentiable_learner_config import (
DifferentiableLearnerConfig,
)
logger = logging.getLogger(__name__)
class DifferentiableLearner(Checkpointable):
"""A differentiable `Learner` class enabling functional parameter updates.
This class is part of RLlib's Meta Learning API and provides a differentiable
`Learner`, allowing higher-order updates within the meta-learning loop.
Unlike standard learners, this class operates on a provided `RLModule` reference
instead of creating its own. Updated cloned module parameters are returned to the
caller, facilitating further update processes.
The `update`, `_update`, `compute_gradients`, and `apply_gradients` methods
require, in addition to other arguments, a dictionary of cloned `RLModule`
parameters for functional updates.
"""
# The framework an instance uses.
framework_str: str
# The key for the total loss.
TOTAL_LOSS_KEY: str = "total_loss"
def __init__(
self,
*,
config: "AlgorithmConfig",
learner_config: "DifferentiableLearnerConfig",
module: Optional[RLModule] = None,
**kwargs,
) -> None:
# An `AlgorithmConfig` used to access certain global configurations.
self.config: "AlgorithmConfig" = config.copy(copy_frozen=False)
# The specific configuration for the differentiable learner instance.
self.learner_config: "DifferentiableLearnerConfig" = learner_config
# The reference to the caller's module.
self._module: Optional[MultiRLModule] = module
# The reference to the caller's device.
self._device: Optional[DeviceType] = None
# A counter for functional weight updates.
self._weights_seq_no: int = 0
# Whether self.build has already been called.
self._is_built: bool = False
# The learner connector pipeline.
self._learner_connector: Optional[LearnerConnectorPipeline] = None
# The Learner's own MetricsLogger to be used to log RLlib's built-in metrics or
# custom user-defined ones (e.g. custom loss values). When returning from an
# `update_from_...()` method call, the Learner will do a `self.metrics.reduce()`
# and return the resulting (reduced) dict.
self.metrics: MetricsLogger = MetricsLogger(
stats_cls_lookup=config.stats_cls_lookup,
root=False,
)
# In case of offline learning and multiple learners, each learner receives a
# repeatable iterator that iterates over a split of the streamed data.
self.iterator: MiniBatchRayDataIterator = None
@OverrideToImplementCustomLogic_CallToSuperRecommended
def build(self, device: Optional[DeviceType] = None) -> None:
if self._is_built:
logger.debug("DifferentiableLearner already built. Skipping built.")
# If a device was passed, set the `DifferentiableLearner`'s device.
if device:
self._device = device
# TODO (simon): Move the `build_learner_connector` to the
# `DifferentiableLearnerConfig`.
self._learner_connector = self.learner_config.build_learner_connector(
input_observation_space=None,
input_action_space=None,
device=self._device,
)
# This instance is now ready for use.
self._is_built = True
@OverrideToImplementCustomLogic
@abc.abstractmethod
def compute_gradients(
self,
loss_per_module: Dict[ModuleID, TensorType],
params: Dict[ModuleID, NamedParamDict],
**kwargs,
) -> Dict[ModuleID, NamedParamDict]:
"""Computes functional gradients based on the given losses.
Note that this method requires computing gradients functionally,
without relying on an optimizer. If an optimizer is needed, a
differentiable optimizer from a third-party package must be used.
Args:
loss_per_module: Dict mapping module IDs to their individual total loss
terms, computed by the individual `compute_loss_for_module()` calls.
The overall total loss (sum of loss terms over all modules) is stored
under `loss_per_module[ALL_MODULES]`.
params: A dictionary containing cloned parameters for each module id.
**kwargs: Forward compatibility kwargs.
Returns:
The gradients in the same (dict) format as `params`.
"""
@OverrideToImplementCustomLogic
@abc.abstractmethod
def apply_gradients(
self,
gradients: Dict[ModuleID, NamedParamDict],
params: Dict[ModuleID, NamedParamDict],
) -> Dict[ModuleID, NamedParamDict]:
"""Applies given gradients functionally.
Note that this method requires functional parameter updates,
meaning modifications must not be performed in-place (e.g., via an
optimizer or directly within the `MultiRLModule`).
Args:
gradients: A dictionary containing named gradients for each module id.
params: A dictionary containing named parameters for each module id.
Returns:
The updated parameters in the same (dict) format as `params`.
"""
@OverrideToImplementCustomLogic
def should_module_be_updated(self, module_id, multi_agent_batch=None):
"""Returns whether a module should be updated or not based on `self.config`.
Args:
module_id: The ModuleID that we want to query on whether this module
should be updated or not.
multi_agent_batch: An optional MultiAgentBatch to possibly provide further
information on the decision on whether the RLModule should be updated
or not.
"""
should_module_be_updated_fn = self.config.policies_to_train
# If None, return True (by default, all modules should be updated).
if should_module_be_updated_fn is None:
return True
# If collection given, return whether `module_id` is in that container.
elif not callable(should_module_be_updated_fn):
return module_id in set(should_module_be_updated_fn)
return should_module_be_updated_fn(module_id, multi_agent_batch)
@OverrideToImplementCustomLogic
def compute_losses(
self, *, fwd_out: Dict[str, Any], batch: Dict[str, Any]
) -> Dict[str, Any]:
"""Computes the loss(es) for the module being optimized.
This method must be overridden by MultiRLModule-specific Learners in order to
define the specific loss computation logic. If the algorithm is single-agent,
only `compute_loss_for_module()` should be overridden instead. If the algorithm
uses independent multi-agent learning (default behavior for RLlib's multi-agent
setups), also only `compute_loss_for_module()` should be overridden, but it will
be called for each individual RLModule inside the MultiRLModule.
For the functional update to work, no `forward` call should be made
within this method, especially not a non-functional one. Instead, use
the model outputs provided by `fwd_out`.
Args:
fwd_out: Output from a functional call to the `forward_train()` method of the
underlying MultiRLModule (`self.module`) during training
(`self.update()`).
batch: The train batch that was used to compute `fwd_out`.
Returns:
A dictionary mapping module IDs to individual loss terms.
"""
loss_per_module = {}
for module_id in fwd_out:
loss = self.compute_loss_for_module(
module_id=module_id,
# TODO (simon): Check, if this should be provided per
# `DifferentiableLearnerConfig`.
config=self.config.get_config_for_module(module_id),
batch=batch[module_id],
fwd_out=fwd_out[module_id],
)
loss_per_module[module_id] = loss
return loss_per_module
@OverrideToImplementCustomLogic
@abc.abstractmethod
def compute_loss_for_module(
self,
*,
module_id: ModuleID,
config: "AlgorithmConfig",
batch: Dict[str, Any],
fwd_out: Dict[str, TensorType],
) -> TensorType:
"""Computes the loss for a single module.
Think of this as computing loss for a single agent. For multi-agent use-cases
that require more complicated computation for loss, consider overriding the
`compute_losses` method instead.
Args:
module_id: The id of the module.
config: The AlgorithmConfig specific to the given `module_id`.
batch: The train batch for this particular module.
fwd_out: The output of the forward pass for this particular module.
Returns:
A single total loss tensor. If you have more than one optimizer on the
provided `module_id` and would like to compute gradients separately using
these different optimizers, simply add up the individual loss terms for
each optimizer and return the sum. Also, for recording/logging any
individual loss terms, you can use the `Learner.metrics.log_value(
key=..., value=...)` or `DifferentiableLearner.metrics.log_dict()` APIs.
See: :py:class:`~ray.rllib.utils.metrics.metrics_logger.MetricsLogger` for
more information.
"""
def update(
self,
params: Dict[ModuleID, NamedParamDict],
training_data: TrainingData,
*,
_no_metrics_reduce: bool = False,
**kwargs,
) -> Tuple[Dict[ModuleID, NamedParamDict], ResultDict]:
"""Make a functional update on provided parameters.
You can use this method to take more than one backward pass on the batch. All
configuration parameters for the iteration loop are set within the
`learner_config`. Note, the same configuration will be used for all module ids
in `MultiRLModule`.
Args:
params: A parameter dictionary holding named parameters for each module id.
These parameters must be a clone of the module's original parameters to
perform a functional update on them.
training_data: A `TrainingData` instance containing the data or data iterators
to be used in updating the given parameters in `param`.
Returns:
The functionally updated parameters in the (dict) format they were passed in
and a `ResultDict` object produced by a call to `self.metrics.reduce()`. The
returned dict may be arbitrarily nested and must have `Stats` objects at
all its leafs, allowing components further downstream (i.e. a user of this
Learner) to further reduce these results (for example over n parallel
Learners).
"""
self._check_is_built()
# TODO (simon): Implement a `before_gradient_based_update`, if necessary.
# Call `before_gradient_based_update` to allow for non-gradient based
# preparations-, logging-, and update logic to happen.
# self.before_gradient_based_update(timesteps=timesteps or {})
training_data.validate()
training_data.solve_refs()
assert training_data.batches is None, "`training_data.batches` must be None!"
self._weights_seq_no += 1
batch_iter = self._create_iterator_if_necessary(
training_data=training_data,
num_total_minibatches=self.learner_config.num_total_minibatches,
num_epochs=self.learner_config.num_epochs,
minibatch_size=self.learner_config.minibatch_size,
shuffle_batch_per_epoch=self.learner_config.shuffle_batch_per_epoch,
)
# Perform the actual looping through the minibatches or the given data iterator.
for iteration, tensor_minibatch in enumerate(batch_iter):
# Check the MultiAgentBatch, whether our RLModule contains all ModuleIDs
# found in this batch. If not, throw an error.
unknown_module_ids = set(tensor_minibatch.policy_batches.keys()) - set(
self.module.keys()
)
if unknown_module_ids:
raise ValueError(
f"Batch contains one or more ModuleIDs ({unknown_module_ids}) that "
f"are not in this Learner!"
)
# Make the actual in-graph/traced `_update` call. This should return
# all tensor values (no numpy).
fwd_out, loss_per_module, params, _ = self._update(
# TODO (simon): Maybe filter ParamDict by module keys in batch.
tensor_minibatch.policy_batches,
params,
)
# TODO (sven): Maybe move this into loop above to get metrics more accuratcely
# cover the minibatch/epoch logic.
# Log all timesteps (env, agent, modules) based on given episodes/batch.
self._log_steps_trained_metrics(tensor_minibatch)
self._set_slicing_by_batch_id(tensor_minibatch, value=False)
if self.iterator:
# Record the number of batches pulled from the dataset.
self.metrics.log_value(
(ALL_MODULES, DATASET_NUM_ITERS_TRAINED),
iteration + 1,
reduce="sum",
)
self.metrics.log_value(
(ALL_MODULES, DATASET_NUM_ITERS_TRAINED_LIFETIME),
iteration + 1,
reduce="lifetime_sum",
)
# Log all individual RLModules' loss terms and its registered optimizers'
# current learning rates.
# Note: We do this only once for the last of the minibatch updates, b/c the
# window is only 1 anyways.
for mid, loss in loss_per_module.items():
self.metrics.log_value(
key=(mid, self.TOTAL_LOSS_KEY),
value=loss,
window=1,
)
# Call `after_gradient_based_update` to allow for non-gradient based
# cleanups-, logging-, and update logic to happen.
# TODO (simon): Check, if this should stay here, when running multiple
# gradient steps inside the iterator loop above (could be a complete epoch)
# the target networks might need to be updated earlier.
# self.after_gradient_based_update(timesteps=timesteps or {})
# Reduce results across all minibatch update steps.
if not _no_metrics_reduce:
return params, loss_per_module, self.metrics.reduce()
else:
return params, loss_per_module, {}
def _create_iterator_if_necessary(
self,
*,
training_data: TrainingData,
num_total_minibatches: int = 0,
num_epochs: int = 1,
minibatch_size: Optional[int] = None,
shuffle_batch_per_epoch: bool = False,
**kwargs,
) -> Iterable:
"""Provides a batch iterator."""
# Data iterator provided.
if training_data.data_iterators:
num_iters = kwargs.pop("num_iters", None)
if num_iters is None:
raise ValueError(
"Learner.update(data_iterators=..) requires `num_iters` kwarg!"
)
def _collate_fn(_batch: Dict[str, numpy.ndarray]) -> MultiAgentBatch:
_batch = unflatten_dict(_batch)
_batch = MultiAgentBatch(
{
module_id: SampleBatch(module_data)
for module_id, module_data in _batch.items()
},
env_steps=sum(
len(next(iter(module_data.values())))
for module_data in _batch.values()
),
)
_batch = self._convert_batch_type(_batch, to_device=False)
return self._set_slicing_by_batch_id(_batch, value=True)
def _finalize_fn(batch: MultiAgentBatch) -> MultiAgentBatch:
return self._convert_batch_type(batch, to_device=True, use_stream=True)
if not self.iterator:
# This iterator holds a `ray.data.DataIterator` and manages it state.
self.iterator = MiniBatchRayDataIterator(
iterator=training_data.data_iterators[0],
collate_fn=_collate_fn,
finalize_fn=_finalize_fn,
minibatch_size=minibatch_size,
num_iters=num_iters,
**kwargs,
)
batch_iter = self.iterator
else:
batch = self._make_batch_if_necessary(training_data=training_data)
assert batch is not None
# TODO: Move this into LearnerConnector pipeline?
# Filter out those RLModules from the final train batch that should not be
# updated.
for module_id in list(batch.policy_batches.keys()):
if not self.should_module_be_updated(module_id, batch):
del batch.policy_batches[module_id]
if not batch.policy_batches:
return {}
batch = self._set_slicing_by_batch_id(batch, value=True)
if minibatch_size:
batch_iter_cls = MiniBatchCyclicIterator
elif num_epochs > 1:
# `minibatch_size` was not set but `num_epochs` > 1.
minibatch_size = batch.count
# Note that there is no need to shuffle here, b/c we don't have
# minibatches.
batch_iter_cls = MiniBatchCyclicIterator
else:
# `minibatch_size` and `num_epochs` are not set by the user.
batch_iter_cls = MiniBatchDummyIterator
batch_iter = batch_iter_cls(
batch,
num_epochs=num_epochs,
minibatch_size=minibatch_size,
shuffle_batch_per_epoch=shuffle_batch_per_epoch and (num_epochs > 1),
num_total_minibatches=num_total_minibatches,
)
return batch_iter
@OverrideToImplementCustomLogic
@abc.abstractmethod
def _update(
self,
batch: Dict[str, Any],
params: Dict[ModuleID, NamedParamDict],
**kwargs,
) -> Tuple[Any, Dict[ModuleID, NamedParamDict], Any, Any]:
"""Contains all logic for an in-graph/traceable functional update step.
Framework specific subclasses must implement this method. This should include
functional calls to the RLModule's `forward_train`, `compute_loss`,
`compute_gradients`, `postprocess_gradients`, and `apply_gradients` methods
and return a tuple with all the individual results as well as the functionally
updated parameter dictionary.
Args:
batch: The train batch already converted to a Dict mapping str to (possibly
nested) tensors.
kwargs: Forward compatibility kwargs.
Returns:
A tuple consisting of:
1) the output of a functional forward call to the RLModule using
`params`,
2) the functionally updated parameters in the (dict) format passed in,
3) the `loss_per_module` dictionary mapping module IDs to individual
loss tensors,
4) a metrics dict mapping module IDs to metrics key/value pairs.
"""
@override(Checkpointable)
def get_state(
self,
components: Optional[Union[str, Collection[str]]] = None,
*,
not_components: Optional[Union[str, Collection[str]]] = None,
**kwargs,
) -> StateDict:
"""Gets the state of the `DifferentiableLearner` instance.
Note, because the `MultiRLModule` held by this class is only a reference
it is not contained in the class' state.
"""
self._check_is_built()
state = {
"should_module_be_updated": self.config.policies_to_train,
WEIGHTS_SEQ_NO: self._weights_seq_no,
}
if self._check_component(COMPONENT_METRICS_LOGGER, components, not_components):
# TODO (sven): Make `MetricsLogger` a Checkpointable.
state[COMPONENT_METRICS_LOGGER] = self.metrics.get_state()
return state
@override(Checkpointable)
def set_state(self, state: StateDict) -> None:
"""Sets the state of the `DifferentiableLearner` instance."""
self._check_is_built()
weights_seq_no = state.get(WEIGHTS_SEQ_NO, 0)
# Update our weights_seq_no, if the new one is > 0.
if weights_seq_no > 0:
self._weights_seq_no = weights_seq_no
# Update our trainable Modules information/function via our config.
# If not provided in state (None), all Modules will be trained by default.
if "should_module_be_updated" in state:
self.config.multi_agent(policies_to_train=state["should_module_be_updated"])
# TODO (sven): Make `MetricsLogger` a Checkpointable.
if COMPONENT_METRICS_LOGGER in state:
self.metrics.set_state(state[COMPONENT_METRICS_LOGGER])
@override(Checkpointable)
def get_ctor_args_and_kwargs(self):
return (
(), # *args,
{
"config": self.config,
}, # **kwargs
)
@override(Checkpointable)
def get_checkpointable_components(self):
if not self._check_is_built(error=False):
self.build()
return []
# TODO (simon): Duplicate in Learner. Move to base class "Learnable".
def _make_batch_if_necessary(self, training_data):
batch = training_data.batch
# Call the learner connector on the given `episodes` (if we have one).
if training_data.episodes is not None:
# If we want to learn from Episodes, we must have a LearnerConnector
# pipeline to translate into a train batch first.
if self._learner_connector is None:
raise ValueError(
f"If episodes provided for training, Learner ({self}) must have a "
"LearnerConnector pipeline (but pipeline is None)!"
)
# Call the learner connector pipeline.
shared_data = {}
batch = self._learner_connector(
rl_module=self.module,
batch=training_data.batch if training_data.batch is not None else {},
episodes=training_data.episodes,
shared_data=shared_data,
metrics=self.metrics,
)
# Convert to a batch.
# TODO (sven): Try to not require MultiAgentBatch anymore.
batch = MultiAgentBatch(
{
module_id: (
SampleBatch(module_data, _zero_padded=True)
if shared_data.get(f"_zero_padded_for_mid={module_id}")
else SampleBatch(module_data)
)
for module_id, module_data in batch.items()
},
env_steps=sum(len(e) for e in training_data.episodes),
)
# Single-agent SampleBatch: Have to convert to MultiAgentBatch.
elif isinstance(training_data.batch, SampleBatch):
if len(self.module) != 1:
raise ValueError(
f"SampleBatch provided, but RLModule ({self.module}) has more than "
f"one sub-RLModule! Need to provide MultiAgentBatch instead."
)
batch = MultiAgentBatch(
{next(iter(self.module.keys())): training_data.batch},
env_steps=len(training_data.batch),
)
# If we already have an `MultiAgentBatch` but with `numpy` array, convert to
# tensors.
elif (
isinstance(training_data.batch, MultiAgentBatch)
and training_data.batch.policy_batches
and (
isinstance(
next(iter(training_data.batch.policy_batches.values()))["obs"],
numpy.ndarray,
)
or next(iter(training_data.batch.policy_batches.values()))["obs"].device
!= self._device
)
):
batch = self._convert_batch_type(training_data.batch)
return batch
# TODO (simon): Duplicate in Learner. Move to base class "Learnable".
def _set_slicing_by_batch_id(
self, batch: MultiAgentBatch, *, value: bool
) -> MultiAgentBatch:
"""Enables slicing by batch id in the given batch.
If the input batch contains batches of sequences we need to make sure when
slicing happens it is sliced via batch id and not timestamp. Calling this
method enables the same flag on each SampleBatch within the input
MultiAgentBatch.
Args:
batch: The MultiAgentBatch to enable slicing by batch id on.
value: The value to set the flag to.
Returns:
The input MultiAgentBatch with the indexing flag is enabled / disabled on.
"""
for pid, policy_batch in batch.policy_batches.items():
# We assume that arriving batches for recurrent modules OR batches that
# have a SEQ_LENS column are already zero-padded to the max sequence length
# and have tensors of shape [B, T, ...]. Therefore, we slice sequence
# lengths in B. See SampleBatch for more information.
if (
self.module[pid].is_stateful()
or policy_batch.get("seq_lens") is not None
):
if value:
policy_batch.enable_slicing_by_batch_id()
else:
policy_batch.disable_slicing_by_batch_id()
return batch
# TODO (simon): Duplicate in Learner. Move to base class "Learnable".
def _check_is_built(self, error: bool = True) -> bool:
if self.module is None:
if error:
raise ValueError(
"Learner.build() must be called after constructing a "
"Learner and before calling any methods on it."
)
return False
return True
@abc.abstractmethod
def _get_tensor_variable(
self,
value: Any,
dtype: Any = None,
trainable: bool = False,
) -> TensorType:
"""Returns a framework-specific tensor variable with the initial given value.
This is a framework specific method that should be implemented by the
framework specific sub-classes.
Args:
value: The initial value for the tensor variable variable.
Returns:
The framework specific tensor variable of the given initial value,
dtype and trainable/requires_grad property.
"""
# TODO (simon): Duplicate in Learner. Move to base class "Learnable".
def _reset(self):
self.metrics = MetricsLogger(
stats_cls_lookup=self.config.stats_cls_lookup,
root=False,
)
self._is_built = False
# TODO (simon): Duplicate in Learner. Move to base class "Learnable".
def _log_steps_trained_metrics(self, batch: MultiAgentBatch):
"""Logs this iteration's steps trained, based on given `batch`."""
# Loop through all modules.
for mid, module_batch in batch.policy_batches.items():
# Log weights seq no for this batch.
self.metrics.log_value(
(mid, WEIGHTS_SEQ_NO),
self._weights_seq_no,
window=1,
)
module_batch_size = len(module_batch)
# Log average batch size (for each module).
self.metrics.log_value(
key=(mid, MODULE_TRAIN_BATCH_SIZE_MEAN),
value=module_batch_size,
)
# Log module steps (for each module).
self.metrics.log_value(
key=(mid, NUM_MODULE_STEPS_TRAINED),
value=module_batch_size,
reduce="sum",
)
self.metrics.log_value(
key=(mid, NUM_MODULE_STEPS_TRAINED_LIFETIME),
value=module_batch_size,
reduce="lifetime_sum",
)
# Log module steps (sum of all modules).
self.metrics.log_value(
key=(ALL_MODULES, NUM_MODULE_STEPS_TRAINED),
value=module_batch_size,
reduce="sum",
)
self.metrics.log_value(
key=(ALL_MODULES, NUM_MODULE_STEPS_TRAINED_LIFETIME),
value=module_batch_size,
reduce="lifetime_sum",
)
# Log env steps (all modules).
self.metrics.log_value(
(ALL_MODULES, NUM_ENV_STEPS_TRAINED),
batch.env_steps(),
reduce="sum",
)
self.metrics.log_value(
(ALL_MODULES, NUM_ENV_STEPS_TRAINED_LIFETIME),
batch.env_steps(),
reduce="lifetime_sum",
with_throughput=True,
)
@OverrideToImplementCustomLogic
@abc.abstractmethod
def _make_functional_call(
self,
module: MultiRLModule,
params: Dict[ModuleID, NamedParamDict],
batch: MultiAgentBatch,
**kwargs,
) -> Dict[ModuleID, Dict[str, TensorType]]:
"""Makes a functional call to a module.
Functional calls enable the Learner to (a) use the same module as its
`MetaLearner` and (b) to generate and apply gradients without modifying
the `RLModule` parameters directly.
Args:
module: The `MultiRLModule` to be used for the functional call. Note, this
module's `forward` method must call the `foward_train`.
params: A dictionary containing containing for each `RLModule`'s id its
named parameter dictionary. For functional calls to work, these
parameters need to be cloned.
batch: A `MultiAgentBatch` instance to be used in the functional call.
Returns:
A dictionary with the output of the module's forward pass.
"""
@property
def module(self) -> MultiRLModule:
"""The MultiRLModule reference that is being used in updates."""
return self._module
@module.setter
def module(self, module: MultiRLModule) -> None:
"""Sets the `MultiRLModule`.
Args:
module: The reference to the `MultiRLModule` of the class that holds the
instance of this `DifferentiableLearner` instance.
"""
self._module = module
@@ -0,0 +1,148 @@
from dataclasses import dataclass, fields
from typing import Callable, List, Optional, Union
import gymnasium as gym
from ray.rllib.connectors.connector_v2 import ConnectorV2
from ray.rllib.core.learner.differentiable_learner import DifferentiableLearner
from ray.rllib.core.rl_module.multi_rl_module import MultiRLModuleSpec
from ray.rllib.core.rl_module.rl_module import RLModule
from ray.rllib.utils.typing import DeviceType, ModuleID
@dataclass
class DifferentiableLearnerConfig:
"""Configures a `DifferentiableLearner`."""
# TODO (simon): We implement only for `PyTorch`, so maybe we use here directly
# TorchDifferentiableLearner` and check for this?
# The `DifferentiableLearner` class. Must be derived from `DifferentiableLearner`.
learner_class: Callable
learner_connector: Optional[
Callable[["RLModule"], Union["ConnectorV2", List["ConnectorV2"]]]
] = None
add_default_connectors_to_learner_pipeline: bool = True
is_multi_agent: bool = False
policies_to_update: List[ModuleID] = None
# The learning rate to use for the nested update. Note, in the default case this
# learning rate is only used to update parameters in a functional form, i.e. the
# `RLModule`'s stateful parameters are only updated in the `MetaLearner`. Different
# logic can be implemented in customized `DifferentiableLearner`s.
lr: float = 3e-5
# TODO (simon): Add further hps like clip_grad, ...
# The total number of minibatches to be formed from the batch per learner, e.g.
# setting `train_batch_size_per_learner=10` and `num_total_minibatches` to 2
# runs 2 SGD minibatch updates with a batch of 5 per training iteration.
num_total_minibatches: int = 0
# The number of epochs per training iteration.
num_epochs: int = 1
# The minibatch size per SGD minibatch update, e.g. with a `train_batch_size_per_learner=10`
# and a `minibatch_size=2` the training step runs 5 SGD minibatch updates with minibatches
# of 2.
minibatch_size: int = None
# If the batch should be shuffled between epochs.
shuffle_batch_per_epoch: bool = False
def __post_init__(self):
"""Additional initialization processes."""
# Ensure we have a `DifferentiableLearner` class.
if not issubclass(self.learner_class, DifferentiableLearner):
raise ValueError(
"`learner_class` must be a subclass of `DifferentiableLearner "
f"but is {self.learner_class}."
)
def build_learner_connector(
self,
input_observation_space: Optional[gym.spaces.Space],
input_action_space: Optional[gym.spaces.Space],
device: Optional[DeviceType] = None,
):
from ray.rllib.connectors.learner import (
AddColumnsFromEpisodesToTrainBatch,
AddObservationsFromEpisodesToBatch,
AddStatesFromEpisodesToBatch,
AddTimeDimToBatchAndZeroPad,
AgentToModuleMapping,
BatchIndividualItems,
LearnerConnectorPipeline,
NumpyToTensor,
)
custom_connectors = []
# Create a learner connector pipeline (including RLlib's default
# learner connector piece) and return it.
if self.learner_connector is not None:
val_ = self.learner_connector(
input_observation_space,
input_action_space,
# device, # TODO (sven): Also pass device into custom builder.
)
from ray.rllib.connectors.connector_v2 import ConnectorV2
# ConnectorV2 (piece or pipeline).
if isinstance(val_, ConnectorV2):
custom_connectors = [val_]
# Sequence of individual ConnectorV2 pieces.
elif isinstance(val_, (list, tuple)):
custom_connectors = list(val_)
# Unsupported return value.
else:
raise ValueError(
"`AlgorithmConfig.training(learner_connector=..)` must return "
"a ConnectorV2 object or a list thereof (to be added to a "
f"pipeline)! Your function returned {val_}."
)
pipeline = LearnerConnectorPipeline(
connectors=custom_connectors,
input_observation_space=input_observation_space,
input_action_space=input_action_space,
)
if self.add_default_connectors_to_learner_pipeline:
# Append OBS handling.
pipeline.append(
AddObservationsFromEpisodesToBatch(as_learner_connector=True)
)
# Append all other columns handling.
pipeline.append(AddColumnsFromEpisodesToTrainBatch())
# Append time-rank handler.
pipeline.append(AddTimeDimToBatchAndZeroPad(as_learner_connector=True))
# Append STATE_IN/STATE_OUT handler.
pipeline.append(AddStatesFromEpisodesToBatch(as_learner_connector=True))
# If multi-agent -> Map from AgentID-based data to ModuleID based data.
if self.is_multi_agent:
pipeline.append(
AgentToModuleMapping(
rl_module_specs=(
self.rl_module_spec.rl_module_specs
if isinstance(self.rl_module_spec, MultiRLModuleSpec)
else set(self.policies)
),
agent_to_module_mapping_fn=self.policy_mapping_fn,
)
)
# Batch all data.
pipeline.append(BatchIndividualItems(multi_agent=self.is_multi_agent))
# Convert to Tensors.
pipeline.append(NumpyToTensor(as_learner_connector=True, device=device))
return pipeline
def update_from_kwargs(self, **kwargs):
"""Sets all slots with values defined in `kwargs`."""
# Get all field names (i.e., slot names).
field_names = {f.name for f in fields(self)}
for key, value in kwargs.items():
if key in field_names:
setattr(self, key, value)
File diff suppressed because it is too large Load Diff
+821
View File
@@ -0,0 +1,821 @@
import copy
import itertools
from functools import partial
from typing import (
TYPE_CHECKING,
Any,
Callable,
Collection,
Dict,
List,
Optional,
Set,
Type,
Union,
)
import ray
from ray._common.deprecation import Deprecated
from ray.rllib.core import (
COMPONENT_LEARNER,
COMPONENT_RL_MODULE,
)
from ray.rllib.core.learner.learner import Learner
from ray.rllib.core.learner.training_data import TrainingData
from ray.rllib.core.rl_module import validate_module_id
from ray.rllib.core.rl_module.multi_rl_module import MultiRLModuleSpec
from ray.rllib.core.rl_module.rl_module import RLModuleSpec
from ray.rllib.policy.policy import PolicySpec
from ray.rllib.policy.sample_batch import MultiAgentBatch
from ray.rllib.utils.actor_manager import (
FaultTolerantActorManager,
RemoteCallResults,
ResultOrError,
)
from ray.rllib.utils.annotations import override
from ray.rllib.utils.checkpoints import Checkpointable
from ray.rllib.utils.metrics.ray_metrics import (
DEFAULT_HISTOGRAM_BOUNDARIES_SHORT_EVENTS,
TimerAndPrometheusLogger,
)
from ray.rllib.utils.typing import (
EpisodeType,
ModuleID,
RLModuleSpecType,
ShouldModuleBeUpdatedFn,
StateDict,
T,
)
from ray.train._internal.backend_executor import BackendExecutor
from ray.util.annotations import PublicAPI
from ray.util.metrics import Histogram
if TYPE_CHECKING:
from ray.rllib.algorithms.algorithm_config import AlgorithmConfig
from ray.util.placement_group import PlacementGroup
def _get_backend_config(learner_class: Type[Learner]) -> str:
if learner_class.framework == "torch":
from ray.train.torch.config import TorchConfig, _TorchBackend
# Override `_TorchBackend` share_cuda_visible_devices=True setting.
# We need this to be False to make sure Learner actors only see their
# own GPU. There is no need in RLlib's LearnerGroups for 2 different Learner
# actors to communicate with each other through their GPUs.
class _RLlibTorchBackend(_TorchBackend):
share_cuda_visible_devices = False
class RLlibTorchConfig(TorchConfig):
@property
def backend_cls(self):
return _RLlibTorchBackend
backend_config = RLlibTorchConfig()
else:
raise ValueError(
"`learner_class.framework` must be 'torch' (but is "
f"{learner_class.framework}!"
)
return backend_config
class RLlibBackendExecutor(BackendExecutor):
# Override `BackendExecutor` placement group creation logic. We need to pass our own
# to make sure the one of the Algorithm (Trainable) is used for all the
# Algorithm's actors.
def _create_placement_group(self):
pass
# TODO (sven): Change this once there is a better (public) API for this in the
# superclass.
def set_placement_group(self, placement_group):
if placement_group is not None:
self._placement_group = placement_group
@PublicAPI(stability="alpha")
class LearnerGroup(Checkpointable):
"""Coordinator of n (possibly remote) Learner workers.
Each Learner worker has a copy of the RLModule, the loss function(s), and
one or more optimizers.
"""
def __init__(
self,
*,
config: "AlgorithmConfig",
# TODO (sven): Rename into `rl_module_spec`.
module_spec: Optional[RLModuleSpecType] = None,
placement_group: Optional["PlacementGroup"] = None,
):
"""Initializes a LearnerGroup instance.
Args:
config: The AlgorithmConfig object to use to configure this LearnerGroup.
Call the `learners(num_learners=...)` method on your config to
specify the number of learner workers to use.
Call the same method with arguments `num_cpus_per_learner` and/or
`num_gpus_per_learner` to configure the compute used by each
Learner worker in this LearnerGroup.
Call the `training(learner_class=...)` method on your config to specify,
which exact Learner class to use.
Call the `rl_module(rl_module_spec=...)` method on your config to set up
the specifics for your RLModule to be used in each Learner.
module_spec: If not already specified in `config`, a separate overriding
RLModuleSpec may be provided via this argument.
placement_group: An optional `PlacementGroup` instance to set the
`RLlibBackendExecutor`'s `self._placement_group` attribute to.
If run within an Algorithm (tune.Trainable), the placement group of tune
trial actor is passed through here.
"""
self.config = config.copy(copy_frozen=False)
self._module_spec = module_spec
learner_class = self.config.learner_class
module_spec = module_spec or self.config.get_multi_rl_module_spec()
self._learner = None
self._workers = None
# If a user calls self.shutdown() on their own then this flag is set to true.
# When del is called the backend executor isn't shutdown twice if this flag is
# true. the backend executor would otherwise log a warning to the console from
# ray train.
self._is_shut_down = False
# How many timesteps had to be dropped due to a full input queue?
self._ts_dropped = 0
# A single local Learner.
if not self.is_remote:
self._learner = learner_class(config=config, module_spec=module_spec)
self._learner.build()
self._worker_manager = None
# Ray metrics
self._metrics_local_learner_training_data_solve_refs = Histogram(
name="rllib_learner_local_training_data_solve_refs_time",
description="Time spent in resolve training data refs for local learner.",
boundaries=DEFAULT_HISTOGRAM_BOUNDARIES_SHORT_EVENTS,
tag_keys=("rllib",),
)
self._metrics_local_learner_training_data_solve_refs.set_default_tags(
{"rllib": self.__class__.__name__}
)
# N remote Learner workers.
else:
backend_config = _get_backend_config(learner_class)
num_cpus_per_learner = (
self.config.num_cpus_per_learner
if self.config.num_cpus_per_learner != "auto"
else 1
if self.config.num_gpus_per_learner == 0
else 0
)
num_gpus_per_learner = max(0, self.config.num_gpus_per_learner)
resources_per_learner = {
"CPU": num_cpus_per_learner,
"GPU": num_gpus_per_learner,
**(self.config.custom_resources_per_learner or {}),
}
backend_executor = RLlibBackendExecutor(
backend_config=backend_config,
num_workers=self.config.num_learners,
resources_per_worker=resources_per_learner,
max_retries=0,
)
# Set the placement group - if any - of the BackendExecutor.
backend_executor.set_placement_group(placement_group)
backend_executor.start(
train_cls=learner_class,
train_cls_kwargs={
"config": config,
"module_spec": module_spec,
},
)
self._backend_executor = backend_executor
self._workers = [w.actor for w in backend_executor.worker_group.workers]
ray.get(
[
worker._set_learner_index_and_placement_group.remote(
learner_index=idx,
placement_group=placement_group,
)
for idx, worker in enumerate(self._workers)
]
)
# Run the neural network building code on remote workers.
ray.get([w.build.remote() for w in self._workers])
self._worker_manager = FaultTolerantActorManager(
self._workers,
max_remote_requests_in_flight_per_actor=(
self.config.max_requests_in_flight_per_learner
),
)
# Ray metrics
self._metrics_update_time = Histogram(
name="rllib_learner_group_update_time",
description="Time spent in LearnerGroup.update()",
boundaries=DEFAULT_HISTOGRAM_BOUNDARIES_SHORT_EVENTS,
tag_keys=("rllib",),
)
self._metrics_update_time.set_default_tags({"rllib": self.__class__.__name__})
# TODO (sven): Replace this with call to `self.metrics.peek()`?
# Currently LearnerGroup does not have a metrics object.
def get_stats(self) -> Dict[str, Any]:
"""Returns the current stats for the input queue for this learner group."""
return {
"learner_group_ts_dropped_lifetime": self._ts_dropped,
"actor_manager_num_outstanding_async_reqs": (
0
if self.is_local
else self._worker_manager.num_outstanding_async_reqs()
),
}
@property
def is_remote(self) -> bool:
return self.config.num_learners > 0
@property
def is_local(self) -> bool:
return not self.is_remote
def update(
self,
*,
batch: Optional[MultiAgentBatch] = None,
batches: Optional[List[MultiAgentBatch]] = None,
batch_refs: Optional[List[ray.ObjectRef]] = None,
episodes: Optional[List[EpisodeType]] = None,
episodes_refs: Optional[List[ray.ObjectRef]] = None,
data_iterators: Optional[List[ray.data.DataIterator]] = None,
training_data: Optional[TrainingData] = None,
timesteps: Optional[Dict[str, Any]] = None,
async_update: bool = False,
return_state: bool = False,
# User kwargs passed onto the Learners.
**kwargs,
) -> List[Dict[str, Any]]:
"""Performs gradient based updates on Learners in parallel.
Updates are performed with data from any of the provided arguments
(batch, batches, batch_refs, episodes, episodes_refs, data_iterators, training_data).
Args:
batch: A data batch to use for the update. If there are more
than one Learner workers, the batch is split amongst these and one
shard is sent to each Learner.
batch_refs: A list of Ray ObjectRefs to the batches. If there are more
than one Learner workers, the list of batch refs is split amongst these and
one list shard is sent to each Learner.
episodes: A list of Episodes to process and perform the update
for. If there are more than one Learner workers, the list of episodes
is split amongst these and one list shard is sent to each Learner.
episodes_refs: A list of Ray ObjectRefs to the episodes. If there are more
than one Learner workers, the list of episode refs is split amongst these and
one list shard is sent to each Learner.
timesteps: A dictionary of timesteps to pass to the Learners's update method.
This is usually used for learning rate scheduling but can be used for any other purpose.
training_data: A TrainingData object to use for the update. If not provided,
a new TrainingData object will be created from the batch, batches, batch_refs,
episodes, and episodes_refs.
async_update: Whether the update request(s) to the Learner workers should be
sent asynchronously. If True, will return NOT the results from the
update on the given data, but all results from prior asynchronous update
requests that have not been returned thus far.
return_state: Whether to include one of the Learner worker's state from
after the update step in the returned results dict (under the
`_rl_module_state_after_update` key). Note that after an update, all
Learner workers' states should be identical, so we use the first
Learner's state here. Useful for avoiding an extra `get_weights()` call,
e.g. for synchronizing EnvRunner weights.
num_epochs: The number of complete passes over the entire train batch. Each
pass might be further split into n minibatches (if `minibatch_size`
provided).
minibatch_size: The size of minibatches to use to further split the train
`batch` into sub-batches. The `batch` is then iterated over n times
where n is `len(batch) // minibatch_size`.
shuffle_batch_per_epoch: Whether to shuffle the train batch once per epoch.
If the train batch has a time rank (axis=1), shuffling will only take
place along the batch axis to not disturb any intact (episode)
trajectories. Also, shuffling is always skipped if `minibatch_size` is
None, meaning the entire train batch is processed each epoch, making it
unnecessary to shuffle.
**kwargs:
Returns:
If `async_update` is False, a dictionary with the reduced results of the
updates from the Learner(s) or a list of dictionaries of results from the
updates from the Learner(s).
If `async_update` is True, a list of list of dictionaries of results, where
the outer list corresponds to separate previous calls to this method, and
the inner list corresponds to the results from each Learner(s). Or if the
results are reduced, a list of dictionaries of the reduced results from each
call to async_update that is ready.
"""
with TimerAndPrometheusLogger(self._metrics_update_time):
# Create and validate TrainingData object, if not already provided.
if training_data is None:
training_data = TrainingData(
batch=batch,
batches=batches,
batch_refs=batch_refs,
episodes=episodes,
episodes_refs=episodes_refs,
data_iterators=data_iterators,
)
training_data.validate()
# NEW: allow caller to defer Ray.get()/materialization to the learner thread.
# TODO (simon): Set to `False` and create attribute in config.
defer_solve = kwargs.pop("defer_solve_refs_to_learner", False)
# Local Learner instance.
if self.is_local:
if async_update:
raise ValueError(
"Can't call `update(async_update=True)` when running with "
"`num_learners=0`! Set `config.num_learners > 0` to allow async "
"updates."
)
# Only solve refs here if NOT deferring. When deferring, the Learner/GPU
# loader thread will call `training_data.solve_refs()` and build the CPU MAB.
if not defer_solve:
# Ray metrics
with TimerAndPrometheusLogger(
self._metrics_local_learner_training_data_solve_refs
):
training_data.solve_refs()
if return_state:
kwargs["return_state"] = return_state
# Return the single Learner's update results.
return [
self._learner.update(
training_data=training_data,
timesteps=timesteps,
**kwargs,
)
]
# Remote Learner actors' kwargs.
remote_call_kwargs = [
dict(
training_data=td_shard,
timesteps=timesteps,
# If `return_state=True`, only return it from the first Learner
# actor.
return_state=(return_state and i == 0),
**kw,
**kwargs,
)
for i, (td_shard, kw) in enumerate(
training_data.shard(
num_shards=len(self),
len_lookback_buffer=self.config.episode_lookback_horizon,
**kwargs,
)
)
]
# Async updates.
if async_update:
# Retrieve all ready results (kicked off by prior calls to this method).
results = self._worker_manager.fetch_ready_async_reqs(
timeout_seconds=0.0
)
# Send out new request(s), if there is still capacity on the actors
# (each actor is allowed only some number of max in-flight requests
# at the same time).
num_sent_requests = self._worker_manager.foreach_actor_async(
"update",
kwargs=remote_call_kwargs,
)
# Some requests were dropped, record lost ts/data.
if num_sent_requests != len(self):
factor = 1 - (num_sent_requests / len(self))
# TODO (sven): Move this logic into a TrainingData API as well
# (`TrainingData.env_steps()`).
if training_data.batch_refs is not None:
dropped = (
len(training_data.batch_refs)
* self.config.train_batch_size_per_learner
)
elif training_data.batch is not None:
dropped = len(training_data.batch)
# List of Ray ObjectRefs (each object ref is a list of episodes of
# total len=`rollout_fragment_length * num_envs_per_env_runner`)
elif training_data.episodes_refs is not None:
dropped = (
len(training_data.episodes_refs)
* self.config.get_rollout_fragment_length()
* self.config.num_envs_per_env_runner
)
else:
assert training_data.episodes is not None
dropped = sum(len(e) for e in training_data.episodes)
self._ts_dropped += factor * dropped
# Sync updates.
else:
results = self._worker_manager.foreach_actor(
"update",
kwargs=remote_call_kwargs,
)
results = self._get_results(results)
return results
def add_module(
self,
*,
module_id: ModuleID,
module_spec: RLModuleSpec,
config_overrides: Optional[Dict] = None,
new_should_module_be_updated: Optional[ShouldModuleBeUpdatedFn] = None,
) -> MultiRLModuleSpec:
"""Adds a module to the underlying MultiRLModule.
Changes this Learner's config in order to make this architectural change
permanent wrt. to checkpointing.
Args:
module_id: The ModuleID of the module to be added.
module_spec: The ModuleSpec of the module to be added.
config_overrides: The `AlgorithmConfig` overrides that should apply to
the new Module, if any.
new_should_module_be_updated: An optional sequence of ModuleIDs or a
callable taking ModuleID and SampleBatchType and returning whether the
ModuleID should be updated (trained).
If None, will keep the existing setup in place. RLModules,
whose IDs are not in the list (or for which the callable
returns False) will not be updated.
Returns:
The new MultiRLModuleSpec (after the change has been performed).
"""
validate_module_id(module_id, error=True)
# Force-set inference-only = False.
module_spec = copy.deepcopy(module_spec)
module_spec.inference_only = False
results = self.foreach_learner(
func=lambda _learner: _learner.add_module(
module_id=module_id,
module_spec=module_spec,
config_overrides=config_overrides,
new_should_module_be_updated=new_should_module_be_updated,
),
)
marl_spec = self._get_results(results)[0]
# Change our config (AlgorithmConfig) to contain the new Module.
# TODO (sven): This is a hack to manipulate the AlgorithmConfig directly,
# but we'll deprecate config.policies soon anyway.
self.config.policies[module_id] = PolicySpec()
if config_overrides is not None:
self.config.multi_agent(
algorithm_config_overrides_per_module={module_id: config_overrides}
)
self.config.rl_module(rl_module_spec=marl_spec)
if new_should_module_be_updated is not None:
self.config.multi_agent(policies_to_train=new_should_module_be_updated)
return marl_spec
def remove_module(
self,
module_id: ModuleID,
*,
new_should_module_be_updated: Optional[ShouldModuleBeUpdatedFn] = None,
) -> MultiRLModuleSpec:
"""Removes a module from the Learner.
Args:
module_id: The ModuleID of the module to be removed.
new_should_module_be_updated: An optional sequence of ModuleIDs or a
callable taking ModuleID and SampleBatchType and returning whether the
ModuleID should be updated (trained).
If None, will keep the existing setup in place. RLModules,
whose IDs are not in the list (or for which the callable
returns False) will not be updated.
Returns:
The new MultiRLModuleSpec (after the change has been performed).
"""
results = self.foreach_learner(
func=lambda _learner: _learner.remove_module(
module_id=module_id,
new_should_module_be_updated=new_should_module_be_updated,
),
)
marl_spec = self._get_results(results)[0]
# Change self.config to reflect the new architecture.
# TODO (sven): This is a hack to manipulate the AlgorithmConfig directly,
# but we'll deprecate config.policies soon anyway.
del self.config.policies[module_id]
self.config.algorithm_config_overrides_per_module.pop(module_id, None)
if new_should_module_be_updated is not None:
self.config.multi_agent(policies_to_train=new_should_module_be_updated)
self.config.rl_module(rl_module_spec=marl_spec)
return marl_spec
@override(Checkpointable)
def get_state(
self,
components: Optional[Union[str, Collection[str]]] = None,
*,
not_components: Optional[Union[str, Collection[str]]] = None,
**kwargs,
) -> StateDict:
state = {}
if self._check_component(COMPONENT_LEARNER, components, not_components):
if self.is_local:
state[COMPONENT_LEARNER] = self._learner.get_state(
components=self._get_subcomponents(COMPONENT_LEARNER, components),
not_components=self._get_subcomponents(
COMPONENT_LEARNER, not_components
),
**kwargs,
)
else:
worker = self._worker_manager.healthy_actor_ids()[0]
assert len(self) == self._worker_manager.num_healthy_actors()
_comps = self._get_subcomponents(COMPONENT_LEARNER, components)
_not_comps = self._get_subcomponents(COMPONENT_LEARNER, not_components)
results = self._worker_manager.foreach_actor(
lambda w: w.get_state(_comps, not_components=_not_comps, **kwargs),
remote_actor_ids=[worker],
)
state[COMPONENT_LEARNER] = self._get_results(results)[0]
return state
@override(Checkpointable)
def set_state(self, state: StateDict) -> None:
if COMPONENT_LEARNER in state:
if self.is_local:
self._learner.set_state(state[COMPONENT_LEARNER])
else:
state_ref = ray.put(state[COMPONENT_LEARNER])
self.foreach_learner(
lambda _learner, _ref=state_ref: _learner.set_state(ray.get(_ref))
)
def get_weights(
self, module_ids: Optional[Collection[ModuleID]] = None
) -> StateDict:
"""Convenience method instead of self.get_state(components=...).
Args:
module_ids: An optional collection of ModuleIDs for which to return weights.
If None (default), return weights of all RLModules.
Returns:
The results of
`self.get_state(components='learner/rl_module')['learner']['rl_module']`.
"""
# Return the entire RLModule state (all possible single-agent RLModules).
if module_ids is None:
components = COMPONENT_LEARNER + "/" + COMPONENT_RL_MODULE
# Return a subset of the single-agent RLModules.
else:
components = [
"".join(tup)
for tup in itertools.product(
[COMPONENT_LEARNER + "/" + COMPONENT_RL_MODULE + "/"],
list(module_ids),
)
]
state = self.get_state(components)[COMPONENT_LEARNER][COMPONENT_RL_MODULE]
return state
def set_weights(self, weights) -> None:
"""Convenience method instead of self.set_state({'learner': {'rl_module': ..}}).
Args:
weights: The weights dict of the MultiRLModule of a Learner inside this
LearnerGroup.
"""
self.set_state({COMPONENT_LEARNER: {COMPONENT_RL_MODULE: weights}})
@override(Checkpointable)
def get_ctor_args_and_kwargs(self):
return (
(), # *args
{
"config": self.config,
"module_spec": self._module_spec,
}, # **kwargs
)
@override(Checkpointable)
def get_checkpointable_components(self):
# Return the entire ActorManager, if remote. Otherwise, return the
# local worker. Also, don't give the component (Learner) a name ("")
# as it's the only component in this LearnerGroup to be saved.
return [
(
COMPONENT_LEARNER,
self._learner if self.is_local else self._worker_manager,
)
]
def foreach_learner(
self,
func: Callable[[Learner, Optional[Any]], T],
*,
healthy_only: bool = True,
remote_actor_ids: List[int] = None,
timeout_seconds: Optional[float] = None,
return_obj_refs: bool = False,
mark_healthy: bool = False,
**kwargs,
) -> RemoteCallResults:
r"""Calls the given function on each Learner L with the args: (L, \*\*kwargs).
Args:
func: The function to call on each Learner L with args: (L, \*\*kwargs).
healthy_only: If True, applies `func` only to Learner actors currently
tagged "healthy", otherwise to all actors. If `healthy_only=False` and
`mark_healthy=True`, will send `func` to all actors and mark those
actors "healthy" that respond to the request within `timeout_seconds`
and are currently tagged as "unhealthy".
remote_actor_ids: Apply func on a selected set of remote actors. Use None
(default) for all actors.
timeout_seconds: Time to wait (in seconds) for results. Set this to 0.0 for
fire-and-forget. Set this to None (default) to wait infinitely (i.e. for
synchronous execution).
return_obj_refs: whether to return ObjectRef instead of actual results.
Note, for fault tolerance reasons, these returned ObjectRefs should
never be resolved with ray.get() outside of the context of this manager.
mark_healthy: Whether to mark all those actors healthy again that are
currently marked unhealthy AND that returned results from the remote
call (within the given `timeout_seconds`).
Note that actors are NOT set unhealthy, if they simply time out
(only if they return a RayActorError).
Also not that this setting is ignored if `healthy_only=True` (b/c this
setting only affects actors that are currently tagged as unhealthy).
Returns:
A list of size len(Learners) with the return values of all calls to `func`.
"""
if self.is_local:
results = RemoteCallResults()
results.add_result(
None,
ResultOrError(result=func(self._learner, **kwargs)),
None,
)
return results
return self._worker_manager.foreach_actor(
func=partial(func, **kwargs),
healthy_only=healthy_only,
remote_actor_ids=remote_actor_ids,
timeout_seconds=timeout_seconds,
return_obj_refs=return_obj_refs,
mark_healthy=mark_healthy,
)
def __len__(self):
return 0 if self.is_local else len(self._workers)
def shutdown(self):
"""Shuts down the LearnerGroup."""
if self.is_local and self._learner is not None:
self._learner.shutdown()
if self.is_remote and hasattr(self, "_backend_executor"):
self._backend_executor.shutdown(graceful_termination=True)
self._is_shut_down = True
def __del__(self):
if not self._is_shut_down:
self.shutdown()
def _get_results(self, results):
processed_results = []
for result in results:
result_or_error = result.get()
if result.ok:
processed_results.append(result_or_error)
else:
raise result_or_error
return processed_results
@Deprecated(new="LearnerGroup.update(batch=.., **kwargs)", error=False)
def update_from_batch(self, batch, **kwargs):
return self.update(batch=batch, **kwargs)
@Deprecated(new="LearnerGroup.update(episodes=.., **kwargs)", error=False)
def update_from_episodes(self, episodes, **kwargs):
return self.update(episodes=episodes, **kwargs)
@Deprecated(new="LearnerGroup.update_from_batch(async=True)", error=True)
def async_update(self, *args, **kwargs):
pass
@Deprecated(
old="LearnerGroup.load_module_state()",
help="To restore RLModule or MultiRLModule state "
"use LearnerGroup.restore_from_path(path=..., component=...). "
"See docs for more details: "
"https://docs.ray.io/en/latest/rllib/rl-modules.html#checkpointing-rlmodules",
error=False,
)
def load_module_state(
self,
*,
multi_rl_module_ckpt_dir: Optional[str] = None,
modules_to_load: Optional[Set[str]] = None,
rl_module_ckpt_dirs: Optional[Dict[ModuleID, str]] = None,
) -> None:
"""Load the checkpoints of the modules being trained by `LearnerGroup`.
`load_module_state` can be used 3 ways:
1. Load a checkpoint for the `MultiRLModule` being trained by this
`LearnerGroup`. Optionally, limit the modules that are loaded
from the checkpoint by specifying the `modules_to_load` argument.
2. Load the checkpoint(s) for single agent `RLModules` that
are in the `MultiRLModule` being trained by this `LearnerGroup`.
3. Load a checkpoint for the `MultiRLModule` being trained by this
`LearnerGroup` and load the checkpoint(s) for single agent `RLModules`
that are in the `MultiRLModule`. The checkpoints for the single
agent `RLModules` take precedence over the module states in the
`MultiRLModule` checkpoint.
At least one of `multi_rl_module_ckpt_dir` or `rl_module_ckpt_dirs`
must be specified.
`modules_to_load` can only be specified if `multi_rl_module_ckpt_dir`
is provided.
Args:
multi_rl_module_ckpt_dir: The path to the checkpoint for the
`MultiRLModule`.
modules_to_load: A set of `RLModule` ids to load from the checkpoint.
rl_module_ckpt_dirs: A mapping from module ids to the path to a
checkpoint for a single agent `RLModule`.
"""
if not (multi_rl_module_ckpt_dir or rl_module_ckpt_dirs):
raise ValueError(
f"At least one of `multi_rl_module_ckpt_dir` or "
f"`rl_module_ckpt_dirs` must be provided. "
f"Got {multi_rl_module_ckpt_dir=} and {rl_module_ckpt_dirs=}."
)
if modules_to_load and not multi_rl_module_ckpt_dir:
raise ValueError(
f"`modules_to_load` can only be specified if a "
f"multi_rl_module_ckpt_dir is provided. "
f"Got {modules_to_load=} and {multi_rl_module_ckpt_dir=}."
)
# MultiRLModule checkpoint is provided.
if multi_rl_module_ckpt_dir:
# Restore the entire MultiRLModule state.
if modules_to_load is None:
self.restore_from_path(
path=multi_rl_module_ckpt_dir,
component=COMPONENT_LEARNER + "/" + COMPONENT_RL_MODULE,
),
# Restore individual module IDs.
else:
for module_id in modules_to_load:
path = multi_rl_module_ckpt_dir + "/" + module_id
self.restore_from_path(
path=path,
component=(
COMPONENT_LEARNER
+ "/"
+ COMPONENT_RL_MODULE
+ "/"
+ module_id
),
)
if rl_module_ckpt_dirs:
for module_id, path in rl_module_ckpt_dirs.items():
self.restore_from_path(
path=path,
component=(
COMPONENT_LEARNER + "/" + COMPONENT_RL_MODULE + "/" + module_id
),
)
+340
View File
@@ -0,0 +1,340 @@
import tempfile
import unittest
import gymnasium as gym
import numpy as np
import ray
from ray.rllib.core import DEFAULT_MODULE_ID
from ray.rllib.core.learner.learner import Learner
from ray.rllib.core.testing.testing_learner import BaseTestingAlgorithmConfig
from ray.rllib.policy.sample_batch import MultiAgentBatch
from ray.rllib.utils.framework import try_import_torch
from ray.rllib.utils.metrics import (
ALL_MODULES,
MODULE_TRAIN_BATCH_SIZE_MEAN,
NUM_ENV_STEPS_TRAINED,
NUM_ENV_STEPS_TRAINED_LIFETIME,
NUM_MODULE_STEPS_TRAINED,
NUM_MODULE_STEPS_TRAINED_LIFETIME,
WEIGHTS_SEQ_NO,
)
from ray.rllib.utils.numpy import convert_to_numpy
from ray.rllib.utils.test_utils import check, get_cartpole_dataset_reader
torch, _ = try_import_torch()
class TestLearner(unittest.TestCase):
ENV = gym.make("CartPole-v1")
@classmethod
def setUp(cls) -> None:
ray.init()
@classmethod
def tearDown(cls) -> None:
ray.shutdown()
def test_end_to_end_update(self):
"""Tests the end-to-end update process for a single-agent scenario.
We check that the loss is decreasing and that the metrics are where we expect them and that values are as expected.
"""
config = BaseTestingAlgorithmConfig()
learner = config.build_learner(env=self.ENV)
reader = get_cartpole_dataset_reader(batch_size=512)
for seq_num in range(1, 1000):
batch = reader.next().as_multi_agent()
batch = learner._convert_batch_type(batch)
results = learner.update(batch=batch)
self.assertEqual(
batch.count, results[DEFAULT_MODULE_ID][MODULE_TRAIN_BATCH_SIZE_MEAN]
)
self.assertEqual(
batch.count, results[DEFAULT_MODULE_ID][NUM_MODULE_STEPS_TRAINED]
)
self.assertEqual(
batch.count,
results[DEFAULT_MODULE_ID][NUM_MODULE_STEPS_TRAINED_LIFETIME],
)
self.assertEqual(seq_num, results[DEFAULT_MODULE_ID][WEIGHTS_SEQ_NO])
self.assertEqual(
batch.count, results[DEFAULT_MODULE_ID][MODULE_TRAIN_BATCH_SIZE_MEAN]
)
self.assertTrue(learner.TOTAL_LOSS_KEY in results[DEFAULT_MODULE_ID])
self.assertEqual(
batch.count, results[ALL_MODULES][NUM_MODULE_STEPS_TRAINED]
)
self.assertEqual(
batch.count, results[ALL_MODULES][NUM_MODULE_STEPS_TRAINED_LIFETIME]
)
self.assertEqual(batch.count, results[ALL_MODULES][NUM_ENV_STEPS_TRAINED])
self.assertEqual(
batch.count, results[ALL_MODULES][NUM_ENV_STEPS_TRAINED_LIFETIME]
)
self.assertLess(results[DEFAULT_MODULE_ID][Learner.TOTAL_LOSS_KEY], 0.58)
def test_compute_gradients(self):
"""Tests the compute_gradients correctness.
Tests that if we sum all the trainable variables the gradient of output w.r.t.
the weights is all ones.
"""
config = BaseTestingAlgorithmConfig()
learner = config.build_learner(env=self.ENV)
params = learner.get_parameters(learner.module[DEFAULT_MODULE_ID])
tape = None
loss_per_module = {ALL_MODULES: sum(param.sum() for param in params)}
gradients = learner.compute_gradients(loss_per_module, gradient_tape=tape)
# Type should be a mapping from ParamRefs to gradients.
self.assertIsInstance(gradients, dict)
for grad in gradients.values():
check(grad, np.ones(grad.shape))
def test_postprocess_gradients(self):
"""Tests the base grad clipping logic in `postprocess_gradients()`."""
# Clip by value only.
config = BaseTestingAlgorithmConfig().training(
lr=0.0003, grad_clip=0.75, grad_clip_by="value"
)
learner = config.build_learner(env=self.ENV)
# Pretend our computed gradients are our weights + 1.0.
grads = {
learner.get_param_ref(v): v + 1.0
for v in learner.get_parameters(learner.module[DEFAULT_MODULE_ID])
}
# Call the learner's postprocessing method.
processed_grads = list(learner.postprocess_gradients(grads).values())
# Check clipped gradients.
# No single gradient must be larger than 0.1 or smaller than -0.1:
self.assertTrue(
all(
np.max(grad) <= config.grad_clip and np.min(grad) >= -config.grad_clip
for grad in convert_to_numpy(processed_grads)
)
)
# Clip by norm.
config.grad_clip = 1.0
config.grad_clip_by = "norm"
learner = config.build_learner(env=self.ENV)
# Pretend our computed gradients are our weights + 1.0.
grads = {
learner.get_param_ref(v): v + 1.0
for v in learner.get_parameters(learner.module[DEFAULT_MODULE_ID])
}
# Call the learner's postprocessing method.
processed_grads = list(learner.postprocess_gradients(grads).values())
# Check clipped gradients.
for proc_grad, grad in zip(
convert_to_numpy(processed_grads),
convert_to_numpy(list(grads.values())),
):
l2_norm = np.sqrt(np.sum(grad**2.0))
if l2_norm > config.grad_clip:
check(proc_grad, grad * (config.grad_clip / l2_norm))
# Clip by global norm.
config.grad_clip = 5.0
config.grad_clip_by = "global_norm"
learner = config.build_learner(env=self.ENV)
# Pretend our computed gradients are our weights + 1.0.
grads = {
learner.get_param_ref(v): v + 1.0
for v in learner.get_parameters(learner.module[DEFAULT_MODULE_ID])
}
# Call the learner's postprocessing method.
processed_grads = list(learner.postprocess_gradients(grads).values())
# Check clipped gradients.
global_norm = np.sqrt(
np.sum(
[np.sum(grad**2.0) for grad in convert_to_numpy(list(grads.values()))]
)
)
if global_norm > config.grad_clip:
for proc_grad, grad in zip(
convert_to_numpy(processed_grads),
grads.values(),
):
check(proc_grad, grad * (config.grad_clip / global_norm))
def test_apply_gradients(self):
"""Tests the apply_gradients correctness.
Tests that if we apply gradients of all ones, the new params are equal to the
standard SGD/Adam update rule.
"""
config = BaseTestingAlgorithmConfig().training(lr=0.0003)
learner = config.build_learner(env=self.ENV)
# calculated the expected new params based on gradients of all ones.
params = learner.get_parameters(learner.module[DEFAULT_MODULE_ID])
n_steps = 100
expected = [
(
convert_to_numpy(param)
- n_steps * learner.config.lr * np.ones(param.shape)
)
for param in params
]
for _ in range(n_steps):
gradients = {learner.get_param_ref(p): torch.ones_like(p) for p in params}
learner.apply_gradients(gradients)
check(params, expected)
def test_add_remove_module(self):
"""Tests the compute/apply_gradients with add/remove modules.
Tests that if we add a module with SGD optimizer with a known lr (different
from default), and remove the default module, with a loss that is the sum of
all variables the updated parameters follow the SGD update rule.
"""
config = BaseTestingAlgorithmConfig().training(lr=0.0003)
learner = config.build_learner(env=self.ENV)
rl_module_spec = config.get_default_rl_module_spec()
rl_module_spec.observation_space = self.ENV.observation_space
rl_module_spec.action_space = self.ENV.action_space
learner.add_module(
module_id="test",
module_spec=rl_module_spec,
)
learner.remove_module(DEFAULT_MODULE_ID)
# only test module should be left
self.assertEqual(set(learner.module.keys()), {"test"})
# calculated the expected new params based on gradients of all ones.
params = learner.get_parameters(learner.module["test"])
n_steps = 100
expected = [
convert_to_numpy(param) - n_steps * learner.config.lr * np.ones(param.shape)
for param in params
]
for _ in range(n_steps):
tape = None
loss_per_module = {ALL_MODULES: sum(param.sum() for param in params)}
gradients = learner.compute_gradients(loss_per_module, gradient_tape=tape)
learner.apply_gradients(gradients)
check(params, expected)
def test_save_to_path_and_restore_from_path(self):
"""Tests, whether a Learner's state is properly saved and restored."""
config = BaseTestingAlgorithmConfig()
# Get a Learner instance for the framework and env.
learner1 = config.build_learner(env=self.ENV)
with tempfile.TemporaryDirectory() as tmpdir:
learner1.save_to_path(tmpdir)
learner2 = config.build_learner(env=self.ENV)
learner2.restore_from_path(tmpdir)
self._check_learner_states("torch", learner1, learner2)
# Add a module then save/load and check states.
with tempfile.TemporaryDirectory() as tmpdir:
rl_module_spec = config.get_default_rl_module_spec()
rl_module_spec.observation_space = self.ENV.observation_space
rl_module_spec.action_space = self.ENV.action_space
learner1.add_module(
module_id="test",
module_spec=rl_module_spec,
)
learner1.save_to_path(tmpdir)
learner2 = Learner.from_checkpoint(tmpdir)
self._check_learner_states("torch", learner1, learner2)
# Remove a module then save/load and check states.
with tempfile.TemporaryDirectory() as tmpdir:
learner1.remove_module(module_id=DEFAULT_MODULE_ID)
learner1.save_to_path(tmpdir)
learner2 = Learner.from_checkpoint(tmpdir)
self._check_learner_states("torch", learner1, learner2)
def _check_learner_states(self, framework, learner1, learner2):
check(learner1.module.get_state(), learner2.module.get_state())
check(learner1._get_optimizer_state(), learner2._get_optimizer_state())
check(learner1._module_optimizers, learner2._module_optimizers)
def test_multi_agent_learner_results(self):
"""Tests the learner results for a multi-agent scenario.
We check that all metrics are where we expect them and that values are as expected.
"""
config = BaseTestingAlgorithmConfig()
learner = config.build_learner(env=self.ENV)
learner.remove_module(module_id=DEFAULT_MODULE_ID)
learner.add_module(
module_id="mod1", module_spec=config.get_rl_module_spec(env=self.ENV)
)
learner.add_module(
module_id="mod2", module_spec=config.get_rl_module_spec(env=self.ENV)
)
reader = get_cartpole_dataset_reader(batch_size=512)
results = {}
for seq_num in range(1, 5):
batch1 = reader.next()
batch2 = reader.next()
multi_agent_batch = MultiAgentBatch(
{"mod1": batch1, "mod2": batch2}, batch1.count + batch2.count
)
batch = learner._convert_batch_type(multi_agent_batch)
results = learner.update(batch)
# Lifetime steps are aggregated at the root, so the return value in the results will contain only the last step.
for module_id, sa_batch_count in zip(
["mod1", "mod2"], [batch1.count, batch2.count]
):
self.assertEqual(
sa_batch_count,
results[module_id][NUM_MODULE_STEPS_TRAINED_LIFETIME],
)
self.assertEqual(seq_num, results[module_id][WEIGHTS_SEQ_NO])
self.assertEqual(
sa_batch_count, results[module_id][MODULE_TRAIN_BATCH_SIZE_MEAN]
)
# We don't know what the value should be, just check for existence.
self.assertTrue(learner.TOTAL_LOSS_KEY in results[module_id])
self.assertEqual(
batch1.count + batch2.count,
results[ALL_MODULES][NUM_MODULE_STEPS_TRAINED_LIFETIME],
)
self.assertEqual(
batch1.count + batch2.count,
results[ALL_MODULES][NUM_MODULE_STEPS_TRAINED],
)
self.assertEqual(
batch1.count + batch2.count,
results[ALL_MODULES][NUM_ENV_STEPS_TRAINED_LIFETIME],
)
self.assertEqual(
batch1.count + batch2.count, results[ALL_MODULES][NUM_ENV_STEPS_TRAINED]
)
if __name__ == "__main__":
import sys
import pytest
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,573 @@
import tempfile
import unittest
import gymnasium as gym
import numpy as np
import pytest
import ray
from ray.rllib.algorithms.algorithm_config import AlgorithmConfig
from ray.rllib.algorithms.bc import BCConfig
from ray.rllib.core import (
COMPONENT_LEARNER,
COMPONENT_RL_MODULE,
DEFAULT_MODULE_ID,
Columns,
)
from ray.rllib.core.learner.learner import Learner
from ray.rllib.core.rl_module.multi_rl_module import MultiRLModule, MultiRLModuleSpec
from ray.rllib.core.rl_module.rl_module import RLModuleSpec
from ray.rllib.core.testing.testing_learner import BaseTestingAlgorithmConfig
from ray.rllib.core.testing.torch.bc_learner import BCTorchLearner
from ray.rllib.core.testing.torch.bc_module import DiscreteBCTorchModule
from ray.rllib.env.multi_agent_episode import MultiAgentEpisode
from ray.rllib.env.single_agent_episode import SingleAgentEpisode
from ray.rllib.examples.envs.classes.multi_agent import MultiAgentCartPole
from ray.rllib.utils.metrics import ALL_MODULES, LEARNER_CONNECTOR
from ray.rllib.utils.metrics.metrics_logger import MetricsLogger
from ray.rllib.utils.test_utils import check
from ray.util.timer import _Timer
REMOTE_CONFIGS = {
"remote-cpu": AlgorithmConfig.overrides(num_learners=1),
"remote-gpu": AlgorithmConfig.overrides(num_learners=1, num_gpus_per_learner=1),
"multi-gpu-ddp": AlgorithmConfig.overrides(num_learners=2, num_gpus_per_learner=1),
"multi-cpu-ddp": AlgorithmConfig.overrides(num_learners=2, num_cpus_per_learner=2),
# "multi-gpu-ddp-pipeline": AlgorithmConfig.overrides(
# num_learners=2, num_gpus_per_learner=2
# ),
}
LOCAL_CONFIGS = {
"local-cpu": AlgorithmConfig.overrides(num_learners=0, num_gpus_per_learner=0),
"local-gpu": AlgorithmConfig.overrides(num_learners=0, num_gpus_per_learner=1),
}
FAKE_EPISODES = [
SingleAgentEpisode(
observation_space=gym.spaces.Box(-1.0, 1.0, (4,), np.float32),
observations=[
np.array([0.1, 0.2, 0.3, 0.4], dtype=np.float32),
np.array([0.5, 0.6, 0.7, 0.8], dtype=np.float32),
np.array([0.9, 1.0, 1.1, 1.2], dtype=np.float32),
np.array([0.1, 0.2, 0.3, 0.4], dtype=np.float32),
np.array([-0.1, -0.2, -0.3, -0.4], dtype=np.float32),
],
action_space=gym.spaces.Discrete(2),
actions=[0, 1, 1, 0],
rewards=[1.0, -1.0, 0.5, 0.3],
terminated=True,
len_lookback_buffer=0, # all data part of actual episode
),
]
FAKE_MA_EPISODES = [
MultiAgentEpisode(
agent_module_ids={
0: "p0",
1: "p1",
},
observation_space=gym.spaces.Dict(
{
0: FAKE_EPISODES[0].observation_space,
1: FAKE_EPISODES[0].observation_space,
}
),
observations=[
{
0: FAKE_EPISODES[0].get_observations(i),
1: FAKE_EPISODES[0].get_observations(i),
}
for i in range(5)
],
action_space=gym.spaces.Dict(
{
0: FAKE_EPISODES[0].action_space,
1: FAKE_EPISODES[0].action_space,
}
),
actions=[
{
0: FAKE_EPISODES[0].get_actions(i),
1: FAKE_EPISODES[0].get_actions(i),
}
for i in range(4)
],
rewards=[
{
0: FAKE_EPISODES[0].get_rewards(i),
1: FAKE_EPISODES[0].get_rewards(i),
}
for i in range(4)
],
len_lookback_buffer=0, # all data part of actual episode
),
]
FAKE_MA_EPISODES[0].to_numpy()
FAKE_MA_EPISODES_WO_P1 = [
MultiAgentEpisode(
agent_module_ids={0: "p0"},
observation_space=gym.spaces.Dict({0: FAKE_EPISODES[0].observation_space}),
observations=[{0: FAKE_EPISODES[0].get_observations(i)} for i in range(5)],
action_space=gym.spaces.Dict({0: FAKE_EPISODES[0].action_space}),
actions=[{0: FAKE_EPISODES[0].get_actions(i)} for i in range(4)],
rewards=[{0: FAKE_EPISODES[0].get_rewards(i)} for i in range(4)],
len_lookback_buffer=0, # all data part of actual episode
),
]
FAKE_MA_EPISODES_WO_P1[0].to_numpy()
class TestLearnerGroupSyncUpdate(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
ray.init()
@classmethod
def tearDownClass(cls) -> None:
ray.shutdown()
def test_learner_group_build_from_algorithm_config(self):
"""Tests whether we can build a learner_groupobject from algorithm_config."""
env = gym.make("CartPole-v1")
# Config that has its own learner class and RLModule spec.
config = BaseTestingAlgorithmConfig()
learner_group = config.build_learner_group(env=env)
print(learner_group)
learner_group.shutdown()
# Config for which user defines custom learner class and RLModule spec.
config = (
BaseTestingAlgorithmConfig()
.training(learner_class=BCTorchLearner)
.rl_module(
rl_module_spec=RLModuleSpec(
module_class=DiscreteBCTorchModule,
model_config={"fcnet_hiddens": [32]},
)
)
)
learner_group = config.build_learner_group(env=env)
print(learner_group)
learner_group.shutdown()
def test_update_multi_gpu(self):
return
scaling_modes = ["multi-gpu-ddp", "remote-gpu"]
for scaling_mode in scaling_modes:
print(f"Testing scaling mode: {scaling_mode}.")
env = gym.make("CartPole-v1")
config_overrides = REMOTE_CONFIGS[scaling_mode]
config = BaseTestingAlgorithmConfig().update_from_dict(config_overrides)
learner_group = config.build_learner_group(env=env)
min_loss = float("inf")
for iter_i in range(1000):
results = learner_group.update(episodes=FAKE_EPISODES)
loss = np.mean(
[res[ALL_MODULES][Learner.TOTAL_LOSS_KEY] for res in results]
)
min_loss = min(loss, min_loss)
print(f"[iter = {iter_i}] Loss: {loss:.3f}, Min Loss: {min_loss:.3f}")
# The loss is initially around 0.69 (ln2). When it gets to around
# 0.57 the return of the policy gets to around 100.
if min_loss < 0.57:
break
for res1, res2 in zip(results, results[1:]):
self.assertEqual(
res1[DEFAULT_MODULE_ID]["mean_weight"],
res2[DEFAULT_MODULE_ID]["mean_weight"],
)
self.assertLess(min_loss, 0.57)
# Make sure the learner_group resources are freed up so that we don't
# autoscale.
learner_group.shutdown()
del learner_group
def test_add_module_and_remove_module(self):
scaling_modes = ["local-cpu", "multi-cpu-ddp"]
for scaling_mode in scaling_modes:
print(f"Testing scaling mode: {scaling_mode}.")
ma_env = MultiAgentCartPole({"num_agents": 2})
config_overrides = REMOTE_CONFIGS.get(scaling_mode) or LOCAL_CONFIGS.get(
scaling_mode
)
config = (
BCConfig()
.update_from_dict(config_overrides)
.multi_agent(
policies={"p0"},
policy_mapping_fn=lambda aid, *ar, **kw: f"p{aid}",
)
.rl_module(
rl_module_spec=MultiRLModuleSpec(
rl_module_specs={"p0": RLModuleSpec()},
)
)
)
learner_group = config.build_learner_group(env=ma_env)
# Update once with the default policy.
learner_group.update(episodes=FAKE_MA_EPISODES_WO_P1)
# Add a test_module.
learner_group.add_module(
module_id="p1",
module_spec=config.get_multi_rl_module_spec(env=ma_env).module_specs[
"p0"
],
)
# Do training that includes the test_module.
results = learner_group.update(episodes=FAKE_MA_EPISODES)
# check that module ids are updated to include the new module
module_ids_after_add = {"p0", "p1"}
# Compare module IDs in results with expected ones.
self.assertEqual(
set(results[0].keys()) - {ALL_MODULES}, module_ids_after_add
)
# Remove the test_module.
learner_group.remove_module(module_id="p1")
# Run training without the test_module.
results = learner_group.update(episodes=FAKE_MA_EPISODES_WO_P1)
# check that module ids are updated after remove operation to not
# include the new module
# remove the total_loss key since its not a module key
self.assertEqual(set(results[0].keys()) - {ALL_MODULES}, {"p0"})
# make sure the learner_group resources are freed up so that we don't
# autoscale
learner_group.shutdown()
del learner_group
class TestLearnerGroupCheckpointRestore(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
ray.init()
@classmethod
def tearDownClass(cls) -> None:
ray.shutdown()
def test_restore_from_path_multi_rl_module_and_individual_modules(self):
"""Tests whether MultiRLModule- and single RLModule states can be restored."""
# this is expanded to more scaling modes on the release ci.
scaling_modes = ["local-cpu", "multi-gpu-ddp"]
for scaling_mode in scaling_modes:
print(f"Testing scaling mode: {scaling_mode}.")
# env will have agent ids 0 and 1
env = MultiAgentCartPole({"num_agents": 2})
config_overrides = REMOTE_CONFIGS.get(scaling_mode) or LOCAL_CONFIGS.get(
scaling_mode
)
config = BaseTestingAlgorithmConfig().update_from_dict(config_overrides)
learner_group = config.build_learner_group(env=env)
spec = config.get_multi_rl_module_spec(env=env).module_specs[
DEFAULT_MODULE_ID
]
learner_group.add_module(module_id="0", module_spec=spec)
learner_group.add_module(module_id="1", module_spec=spec)
learner_group.remove_module(DEFAULT_MODULE_ID)
module_0 = spec.build()
module_1 = spec.build()
multi_rl_module = MultiRLModule()
multi_rl_module.add_module(module_id="0", module=module_0)
multi_rl_module.add_module(module_id="1", module=module_1)
# Check if we can load just the MultiRLModule.
with tempfile.TemporaryDirectory() as tmpdir:
multi_rl_module.save_to_path(tmpdir)
old_learner_weights = learner_group.get_weights()
learner_group.restore_from_path(
tmpdir,
component=COMPONENT_LEARNER + "/" + COMPONENT_RL_MODULE,
)
# Check the weights of the module in the learner group are the
# same as the weights of the newly created MultiRLModule
check(learner_group.get_weights(), multi_rl_module.get_state())
learner_group.set_state(
{
COMPONENT_LEARNER: {COMPONENT_RL_MODULE: old_learner_weights},
}
)
check(learner_group.get_weights(), old_learner_weights)
# Check if we can load just single agent RL Modules.
with tempfile.TemporaryDirectory() as tmpdir:
module_0.save_to_path(tmpdir)
with tempfile.TemporaryDirectory() as tmpdir2:
temp_module = spec.build()
temp_module.save_to_path(tmpdir2)
old_learner_weights = learner_group.get_weights()
learner_group.restore_from_path(
tmpdir,
component=COMPONENT_LEARNER + "/" + COMPONENT_RL_MODULE + "/0",
)
learner_group.restore_from_path(
tmpdir2,
component=COMPONENT_LEARNER + "/" + COMPONENT_RL_MODULE + "/1",
)
# check the weights of the module in the learner group are the
# same as the weights of the newly created MultiRLModule
new_multi_rl_module = MultiRLModule()
new_multi_rl_module.add_module(module_id="0", module=module_0)
new_multi_rl_module.add_module(module_id="1", module=temp_module)
check(learner_group.get_weights(), new_multi_rl_module.get_state())
learner_group.set_weights(old_learner_weights)
# Check if we can first load a MultiRLModule, then a single agent RLModule
# (within that MultiRLModule). Check that the single agent RL Module is
# loaded over the matching submodule in the MultiRLModule.
with tempfile.TemporaryDirectory() as tmpdir:
module_0 = spec.build()
multi_rl_module = MultiRLModule()
multi_rl_module.add_module(module_id="0", module=module_0)
multi_rl_module.add_module(module_id="1", module=spec.build())
multi_rl_module.save_to_path(tmpdir)
with tempfile.TemporaryDirectory() as tmpdir2:
module_1 = spec.build()
module_1.save_to_path(tmpdir2)
learner_group.restore_from_path(
tmpdir,
component=COMPONENT_LEARNER + "/" + COMPONENT_RL_MODULE,
)
learner_group.restore_from_path(
tmpdir2,
component=COMPONENT_LEARNER + "/" + COMPONENT_RL_MODULE + "/1",
)
new_multi_rl_module = MultiRLModule()
new_multi_rl_module.add_module(module_id="0", module=module_0)
new_multi_rl_module.add_module(module_id="1", module=module_1)
check(learner_group.get_weights(), new_multi_rl_module.get_state())
del learner_group
class TestLearnerGroupSaveAndRestoreState(unittest.TestCase):
FAKE_BATCH = {
Columns.OBS: np.array(
[
[0.1, 0.2, 0.3, 0.4],
[0.5, 0.6, 0.7, 0.8],
[0.9, 1.0, 1.1, 1.2],
[1.3, 1.4, 1.5, 1.6],
],
dtype=np.float32,
),
Columns.NEXT_OBS: np.array(
[
[0.1, 0.2, 0.3, 0.4],
[0.5, 0.6, 0.7, 0.8],
[0.9, 1.0, 1.1, 1.2],
[1.3, 1.4, 1.5, 1.6],
],
dtype=np.float32,
),
Columns.ACTIONS: np.array([0, 1, 1, 0]),
Columns.REWARDS: np.array([1.0, -1.0, 0.5, 0.6], dtype=np.float32),
Columns.TERMINATEDS: np.array([False, False, True, False]),
Columns.TRUNCATEDS: np.array([False, False, False, False]),
Columns.VF_PREDS: np.array([0.5, 0.6, 0.7, 0.8], dtype=np.float32),
Columns.ACTION_DIST_INPUTS: np.array(
[[-2.0, 0.5], [-3.0, -0.3], [-0.1, 2.5], [-0.2, 3.5]], dtype=np.float32
),
Columns.ACTION_LOGP: np.array([-0.5, -0.1, -0.2, -0.3], dtype=np.float32),
Columns.EPS_ID: np.array([0, 0, 0, 0]),
}
@classmethod
def setUpClass(cls) -> None:
ray.init()
@classmethod
def tearDownClass(cls) -> None:
ray.shutdown()
def test_save_to_path_and_restore_from_path(self):
"""Check that saving and loading learner group state works."""
# this is expanded to more scaling modes on the release ci.
scaling_modes = ["local-cpu"] # , "multi-gpu-ddp"]
for scaling_mode in scaling_modes:
print(f"Testing scaling mode: {scaling_mode}.")
env = gym.make("CartPole-v1")
config_overrides = REMOTE_CONFIGS.get(scaling_mode) or LOCAL_CONFIGS.get(
scaling_mode
)
config = BaseTestingAlgorithmConfig().update_from_dict(config_overrides)
learner_group = config.build_learner_group(env=env)
# Checkpoint the initial learner state for later comparison.
initial_learner_checkpoint_dir = tempfile.TemporaryDirectory().name
learner_group.save_to_path(initial_learner_checkpoint_dir)
# Test the convenience method `.get_weights()`.
initial_weights = learner_group.get_weights()
# Do a single update.
learner_group.update(episodes=FAKE_EPISODES)
weights_after_update = learner_group.get_state(
components=COMPONENT_LEARNER + "/" + COMPONENT_RL_MODULE
)[COMPONENT_LEARNER][COMPONENT_RL_MODULE]
# Weights after the update must be different from original ones.
check(initial_weights, weights_after_update, false=True)
# Checkpoint the learner state after 1 update for later comparison.
learner_after_1_update_checkpoint_dir = tempfile.TemporaryDirectory().name
learner_group.save_to_path(learner_after_1_update_checkpoint_dir)
# Remove that learner, construct a new one, and load the state of the old
# learner into the new one.
learner_group.shutdown()
del learner_group
learner_group = config.build_learner_group(env=env)
learner_group.restore_from_path(learner_after_1_update_checkpoint_dir)
# Do another update.
results_2nd_update_with_break = learner_group.update(episodes=FAKE_EPISODES)
weights_after_2_updates_with_break = learner_group.get_state(
components=COMPONENT_LEARNER + "/" + COMPONENT_RL_MODULE
)[COMPONENT_LEARNER][COMPONENT_RL_MODULE]
learner_group.shutdown()
del learner_group
# Construct a new learner group and load the initial state of the learner.
learner_group = config.build_learner_group(env=env)
learner_group.restore_from_path(initial_learner_checkpoint_dir)
weights_after_restore = learner_group.get_state(
components=COMPONENT_LEARNER + "/" + COMPONENT_RL_MODULE
)[COMPONENT_LEARNER][COMPONENT_RL_MODULE]
check(initial_weights, weights_after_restore)
# Perform 2 updates to get to the same state as the previous learners.
learner_group.update(episodes=FAKE_EPISODES)
results_2nd_update_without_break = learner_group.update(
episodes=FAKE_EPISODES
)
weights_after_2_updates_without_break = learner_group.get_weights()
learner_group.shutdown()
del learner_group
# Compare the results of the two updates.
for r1, r2 in zip(
results_2nd_update_with_break,
results_2nd_update_without_break,
):
r1[ALL_MODULES].pop(LEARNER_CONNECTOR)
r2[ALL_MODULES].pop(LEARNER_CONNECTOR)
check(
MetricsLogger.peek_results(results_2nd_update_with_break),
MetricsLogger.peek_results(results_2nd_update_without_break),
rtol=0.05,
)
check(
weights_after_2_updates_with_break,
weights_after_2_updates_without_break,
rtol=0.05,
)
class TestLearnerGroupAsyncUpdate(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
ray.init()
@classmethod
def tearDown(cls) -> None:
ray.shutdown()
def test_async_update(self):
"""Test that async style updates converge to the same result as sync."""
scaling_modes = ["multi-gpu-ddp", "multi-cpu-ddp", "remote-gpu"]
for scaling_mode in scaling_modes:
print(f"Testing scaling mode: {scaling_mode}.")
env = gym.make("CartPole-v1")
config_overrides = REMOTE_CONFIGS[scaling_mode]
config = BaseTestingAlgorithmConfig().update_from_dict(config_overrides)
learner_group = config.build_learner_group(env=env)
timer_sync = _Timer()
timer_async = _Timer()
with timer_sync:
learner_group.update(episodes=FAKE_EPISODES, async_update=False)
with timer_async:
result_async = learner_group.update(
episodes=FAKE_EPISODES, async_update=True
)
# Ideally the first async update will return nothing, and an easy
# way to check that is if the time for an async update call is faster
# than the time for a sync update call.
self.assertLess(timer_async.mean, timer_sync.mean)
self.assertIsInstance(result_async, list)
loss = float("inf")
iter_i = 0
while True:
result_async = learner_group.update(
episodes=FAKE_EPISODES, async_update=True
)
if not result_async:
continue
self.assertIsInstance(result_async, list)
self.assertIsInstance(result_async[0], dict)
# Check one async Learner result.
loss = result_async[0][DEFAULT_MODULE_ID][Learner.TOTAL_LOSS_KEY]
# The loss is initially around 0.69 (ln2). When it gets to around
# 0.57 the return of the policy gets to around 100.
if loss < 0.57:
break
# Compare reported "mean_weight" with actual ones.
_check_multi_worker_weights(learner_group, result_async)
iter_i += 1
learner_group.shutdown()
self.assertLess(loss, 0.57)
def _check_multi_worker_weights(learner_group, results):
# Check that module weights are updated across workers and synchronized.
# for i in range(1, len(results)):
learner_1_results = results[0]
for module_id, mod_result in learner_1_results.items():
if module_id == ALL_MODULES:
continue
results = MetricsLogger.peek_results(results)
reported_mean_weights = np.mean([r[module_id]["mean_weight"] for r in results])
# Compare the reported mean weights (merged across all Learner workers,
# which all should have the same weights after updating) with the actual
# current mean weights.
parameters = learner_group.get_state(
components=(
COMPONENT_LEARNER + "/" + COMPONENT_RL_MODULE + "/" + module_id
),
)[COMPONENT_LEARNER][COMPONENT_RL_MODULE][module_id]
actual_mean_weights = np.mean([w.mean() for w in parameters.values()])
check(reported_mean_weights, actual_mean_weights, rtol=0.02)
if __name__ == "__main__":
import sys
class_ = sys.argv[1] if len(sys.argv) > 1 else None
sys.exit(pytest.main(["-v", __file__ + ("" if class_ is None else "::" + class_)]))
@@ -0,0 +1,125 @@
import itertools
import unittest
import gymnasium as gym
import ray
from ray.rllib.algorithms.algorithm_config import TorchCompileWhatToCompile
from ray.rllib.core.testing.testing_learner import BaseTestingAlgorithmConfig
from ray.rllib.policy.sample_batch import MultiAgentBatch
from ray.rllib.utils.test_utils import get_cartpole_dataset_reader
from ray.rllib.utils.torch_utils import _dynamo_is_available
class TestLearner(unittest.TestCase):
@classmethod
def setUp(cls) -> None:
ray.init()
@classmethod
def tearDown(cls) -> None:
ray.shutdown()
# Todo (rllib-team): Fix for torch 2.0+
@unittest.skip("Failing with torch >= 2.0")
@unittest.skipIf(not _dynamo_is_available(), "torch._dynamo not available")
def test_torch_compile(self):
"""Test if torch.compile() can be applied and used on the learner.
Also tests if we can update with the compiled update method without errors.
"""
env = gym.make("CartPole-v1")
is_multi_agents = [False, True]
what_to_compiles = [
TorchCompileWhatToCompile.FORWARD_TRAIN,
TorchCompileWhatToCompile.COMPLETE_UPDATE,
]
for is_multi_agent, what_to_compile in itertools.product(
is_multi_agents, what_to_compiles
):
print(
f"Testing is_multi_agent={is_multi_agent},"
f"what_to_compile={what_to_compile}"
)
config = BaseTestingAlgorithmConfig().framework(
torch_compile_learner=True,
torch_compile_learner_what_to_compile=what_to_compile,
)
learner = config.build_learner(env=env)
learner.build()
reader = get_cartpole_dataset_reader(batch_size=512)
for iter_i in range(10):
batch = reader.next()
learner.update(batch.as_multi_agent())
rl_module_spec = config.get_default_rl_module_spec()
rl_module_spec.observation_space = env.observation_space
rl_module_spec.action_space = env.action_space
learner.add_module(module_id="another_module", module_spec=rl_module_spec)
for iter_i in range(10):
batch = MultiAgentBatch(
{"another_module": reader.next(), "default_policy": reader.next()},
0,
)
learner.update(batch)
learner.remove_module(module_id="another_module")
# Todo (rllib-team): Fix for torch 2.0+
@unittest.skip("Failing with torch >= 2.0")
@unittest.skipIf(not _dynamo_is_available(), "torch._dynamo not available")
def test_torch_compile_no_breaks(self):
"""Tests if torch.compile() does encounter too many breaks.
torch.compile() should ideally not encounter any breaks when compiling the
update method of the learner. This method tests if we encounter only a given
number of breaks.
"""
env = gym.make("CartPole-v1")
config = BaseTestingAlgorithmConfig().framework(torch_compile_learner=True)
learner = config.build_learner(env=env)
import torch._dynamo as dynamo
reader = get_cartpole_dataset_reader(batch_size=512)
batch = reader.next().as_multi_agent()
batch = learner._convert_batch_type(batch)
# The followingcall to dynamo.explain() breaks depending on the torch version.
# It works for torch==2.0.0.
# TODO(Artur): Fit this to to the correct torch version once it is enabled on
# CI.
# This is a helper method of dynamo to analyze where breaks occur.
(
explanation,
out_guards,
graphs,
ops_per_graph,
break_reasons,
explanation_verbose,
) = dynamo.explain(learner._update, batch)
print(explanation_verbose)
# There should be only one break reason - `return_value` - since inputs and
# outputs are not checked
# TODO(Artur): Attempt bringing breaks down to 1. (This may not be possible)
# Note: This test is skipped on CI if torch dynamo is available.
self.assertEqual(len(break_reasons), 3)
if __name__ == "__main__":
import sys
import pytest
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,82 @@
import torch
from ray.rllib.algorithms.ppo import PPOConfig
def test_ppo_checkpoint_restore_preserves_optimizer_state(tmp_path):
config = (
PPOConfig()
.environment("CartPole-v1")
.framework("torch")
.env_runners(num_env_runners=0)
.learners(num_learners=0)
.training(
train_batch_size=128,
minibatch_size=32,
num_epochs=1,
)
)
algo1 = config.build_algo()
try:
# Populate optimizer state before checkpointing.
algo1.train()
learner1 = algo1.learner_group._learner
optimizer1 = next(iter(learner1._named_optimizers.values()))
original_state = optimizer1.state_dict()
ckpt_dir = algo1.save_to_path(tmp_path / "ckpt")
finally:
algo1.stop()
algo2 = config.build_algo()
try:
algo2.restore_from_path(ckpt_dir)
learner2 = algo2.learner_group._learner
optimizer2 = next(iter(learner2._named_optimizers.values()))
restored_state = optimizer2.state_dict()
param_group = optimizer2.param_groups[0]
# Param-group metadata must remain Python scalars after restore.
assert isinstance(param_group["betas"][0], float)
assert isinstance(param_group["betas"][1], float)
assert not torch.is_tensor(param_group["betas"][0])
assert not torch.is_tensor(param_group["betas"][1])
# Restored optimizer state should match the original optimizer state.
assert restored_state["param_groups"] == original_state["param_groups"]
assert restored_state["state"].keys() == original_state["state"].keys()
for param_id, original_param_state in original_state["state"].items():
restored_param_state = restored_state["state"][param_id]
assert torch.equal(
restored_param_state["step"], original_param_state["step"]
)
assert torch.equal(
restored_param_state["exp_avg"], original_param_state["exp_avg"]
)
assert torch.equal(
restored_param_state["exp_avg_sq"], original_param_state["exp_avg_sq"]
)
# Optimizer state buffers must still be tensors.
state = next(iter(optimizer2.state.values()))
assert torch.is_tensor(state["step"])
assert torch.is_tensor(state["exp_avg"])
assert torch.is_tensor(state["exp_avg_sq"])
# Training must continue after restore.
algo2.train()
finally:
algo2.stop()
if __name__ == "__main__":
import sys
import pytest
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,374 @@
import contextlib
import logging
from typing import Any, Dict, Optional, Tuple
from ray.rllib.algorithms.algorithm_config import (
TorchCompileWhatToCompile,
)
from ray.rllib.core.columns import Columns
from ray.rllib.core.learner.differentiable_learner import DifferentiableLearner
from ray.rllib.core.rl_module.torch.torch_rl_module import (
TorchCompileConfig,
)
from ray.rllib.policy.sample_batch import MultiAgentBatch
from ray.rllib.utils.annotations import override
from ray.rllib.utils.framework import try_import_torch
from ray.rllib.utils.metrics import (
DIFF_NUM_GRAD_UPDATES_VS_SAMPLER_POLICY,
WEIGHTS_SEQ_NO,
)
from ray.rllib.utils.torch_utils import convert_to_torch_tensor
from ray.rllib.utils.typing import DeviceType, ModuleID, NamedParamDict, TensorType
logger = logging.getLogger("__name__")
torch, nn = try_import_torch()
# TODO (simon): Maybe create again a base class `TorchLearnable`.
class TorchDifferentiableLearner(DifferentiableLearner):
"""A `DifferentiableLearner` class leveraging PyTorch for functional updates.
This class utilizes PyTorch 2.0's `func` module to perform functional
updates on the provided parameters.
"""
# Set the framework to `"torch"`.
framework: str = "torch"
def __init__(self, **kwargs):
# First initialize the `DifferentiableLearner` base class to set
# the configurations and `MultiRLModule`.
super().__init__(**kwargs)
# Whether to compile the RL Module of this learner. This implies that the.
# forward_train method of the RL Module will be compiled. Further more,
# other forward methods of the RL Module will be compiled on demand.
# This is assumed to not happen, since other forwrad methods are not expected
# to be used during training.
self._torch_compile_forward_train = False
self._torch_compile_cfg = None
# Whether to compile the `_uncompiled_update` method of this learner. This
# implies that everything within `_uncompiled_update` will be compiled,
# not only the forward_train method of the RL Module.
# Note that this is experimental.
# Note that this requires recompiling the forward methods once we add/remove
# RL Modules.
self._torch_compile_complete_update = False
if self.config.torch_compile_learner:
if (
self.config.torch_compile_learner_what_to_compile
== TorchCompileWhatToCompile.COMPLETE_UPDATE
):
self._torch_compile_complete_update = True
self._compiled_update_initialized = False
else:
self._torch_compile_forward_train = True
self._torch_compile_cfg = TorchCompileConfig(
torch_dynamo_backend=self.config.torch_compile_learner_dynamo_backend,
torch_dynamo_mode=self.config.torch_compile_learner_dynamo_mode,
)
# TODO (simon): See, if we can include these without a torch optimizer.
# self._lr_schedulers = {}
# self._lr_scheduler_classes = None
# if self.config._torch_lr_scheduler_classes:
# self._lr_scheduler_classes = self.config._torch_lr_scheduler_classes
def _uncompiled_update(
self,
batch: Dict,
params: Dict[ModuleID, NamedParamDict],
**kwargs,
) -> Tuple[Any, Any, Dict[ModuleID, NamedParamDict], Any]:
"""Performs a single functional update using a batch of data.
This update operates on parameters passed via a functional call to the
`MultiRLModule` and leverages PyTorch 2.0's `autograd` module. Parameters
are not modified in-place within `self._module`; instead, updates are
applied to the cloned parameters provided.
Args:
batch: A dictionary (or `MultiAgentBatch`) containing training data for
all modules in the `MultiRLModule` (that should be trained).
params: A dictionary of named parameters for each module id.
Returns:
A tuple consisting of:
1) the output of a functional forward call to the RLModule using
`params`,
2) the `loss_per_module` dictionary mapping module IDs to individual
loss tensors,
3) the functionally updated parameters in the (dict) format passed in,
4) a metrics dict mapping module IDs to metrics key/value pairs.
"""
# TODO (sven): Causes weird cuda error when WandB is used.
# Diagnosis thus far:
# - All peek values during metrics.reduce are non-tensors.
# - However, in impala.py::training_step(), a tensor does arrive after learner
# group.update(), so somehow, there is still a race condition
# possible (learner, which performs the reduce() and learner thread, which
# performs the logging of tensors into metrics logger).
self._compute_off_policyness(batch)
# Make a functional forward pass with the provided parameters.
fwd_out = self._make_functional_call(params, batch)
loss_per_module = self.compute_losses(fwd_out=fwd_out, batch=batch)
# Compute gradients for the provided parameters.
gradients = self.compute_gradients(loss_per_module, params)
with contextlib.ExitStack() as stack:
if self.config.num_learners > 1:
for mod in self.module.values():
# Skip non-torch modules, b/c they may not have the `no_sync` API.
if isinstance(mod, torch.nn.Module):
stack.enter_context(mod.no_sync())
# TODO (simon): See, if we need here postprocessing of gradients.
# postprocessed_gradients = self.postprocess_gradients(gradients)
# Make a stateless (of `params`) update of the `RLModule` parameters.
params = self.apply_gradients(gradients, params)
# Deactivate tensor-mode on our MetricsLogger and collect the (tensor)
# results.
return fwd_out, loss_per_module, params, {}
# TODO (simon): Maybe make type for gradients.
@override(DifferentiableLearner)
def compute_gradients(
self,
loss_per_module: Dict[ModuleID, TensorType],
params: Dict[ModuleID, NamedParamDict],
**kwargs,
) -> Dict[ModuleID, NamedParamDict]:
"""Computes functionally gradients based on the given losses.
This method uses `torch.autograd.grad` to make the backward pass on the
`MultiRLModule` which enables a functional backward pass. If a PyTorch
optimizer is needed a differentiable one must be used (e.g. `torchopt`).
Args:
loss_per_module: Dict mapping module IDs to their individual total loss
terms, computed by the individual `compute_loss_for_module()` calls.
The overall total loss (sum of loss terms over all modules) is stored
under `loss_per_module[ALL_MODULES]`
params: A dictionary containing named parameters for each module id.
**kwargs: Forward compatibility kwargs.
Returns:
The (named) gradients in the same (dict) format as `params`.
"""
# TODO (simon): Add grad scalers later.
total_loss = sum(loss_per_module.values())
# Use `torch`'s `autograd` to compute gradients and create a graph, so we can
# compute higher order gradients. Allow specified inputs not being used in outputs
# as probably not all modules/parameters of a `MultiRLModule` are used in the loss.
# Note, parameters are named parameters as this is needed by the
# `torch.func.functional_call`
# TODO (simon): Make sure this works for `MultiRLModule`s. This here can have
# all parameter tensors in a list. But the `functional_call` above needs named
# parameters for each module. Implement this via `foreach_module`.
grads = torch.autograd.grad(
total_loss,
sum((list(param.values()) for mid, param in params.items()), []),
create_graph=True,
retain_graph=True,
allow_unused=True,
)
# Map all gradients to their keys.
named_grads = {
module_id: {
name: grad for (name, _), grad in zip(module_params.items(), grads)
}
for module_id, module_params in params.items()
}
return named_grads
@override(DifferentiableLearner)
def apply_gradients(
self,
gradients: Dict[ModuleID, NamedParamDict],
params: Dict[ModuleID, NamedParamDict],
) -> Dict[ModuleID, NamedParamDict]:
"""Applies the given gradients in a functional manner.
This method requires functional parameter updates, meaning modifications
must not be performed in-place (e.g., using an optimizer or directly within
the `MultiRLModule`).
Args:
gradients: A dictionary containing named gradients for each module id.
params: A dictionary containing named parameters for each module id.
Returns:
The updated parameters in the same (dict) format as `params`.
"""
policies_to_update = self.learner_config.policies_to_update or list(
gradients.keys()
)
# Note, because this is a functional update we cannot apply in-place
# modifications of parameters.
updated_params = {}
for module_id, module_grads in gradients.items():
if module_id not in policies_to_update:
updated_params[module_id] = params[module_id]
continue
updated_params[module_id] = {}
for name, grad in module_grads.items():
# If updates should not be skipped turn `nan` and `inf` gradients to zero.
if (
not self.config.torch_skip_nan_gradients
and not torch.isfinite(grad).all()
):
# Warn the user about `nan` gradients.
logger.warning(f"Gradients {name} contain `nan/inf` values.")
# If updates should be skipped, do not step the optimizer and return.
if not self.config.torch_skip_nan_gradients:
logger.warning(
"Setting `nan/inf` gradients to zero. If updates with "
"`nan/inf` gradients should not be set to zero and instead "
"the update be skipped entirely set `torch_skip_nan_gradients` "
"to `True`."
)
# If necessary turn `nan` gradients to zero. Note, this can corrupt the
# internal state of the optimizer, if many `nan` gradients occur.
grad = torch.nan_to_num(grad)
if self.config.torch_skip_nan_gradients or torch.isfinite(grad).all():
# Update each parameter, by a simple gradient descent step.
updated_params[module_id][name] = (
params[module_id][name] - self.learner_config.lr * grad
)
elif grad is None or not torch.isfinite(grad).all():
logger.warning(
"Skipping this update. If updates with `nan/inf` gradients "
"should not be skipped entirely and instead `nan/inf` "
"gradients set to `zero` set `torch_skip_nan_gradients` to "
"`False`."
)
return updated_params
def _make_functional_call(
self, params: Dict[ModuleID, NamedParamDict], batch: MultiAgentBatch
) -> Dict[ModuleID, NamedParamDict]:
"""Makes a functional call for each module in the `MultiRLModule`."""
return self._module.foreach_module(
lambda mid, m: torch.func.functional_call(m, params[mid], batch[mid]),
return_dict=True,
)
@override(DifferentiableLearner)
def _get_tensor_variable(
self, value, dtype=None, trainable=False
) -> "torch.Tensor":
tensor = torch.tensor(
value,
requires_grad=trainable,
# TODO (simon): Make GPU-trainable.
# device=self._device,
dtype=(
dtype
or (
torch.float32
if isinstance(value, float)
else torch.int32
if isinstance(value, int)
else None
)
),
)
return nn.Parameter(tensor) if trainable else tensor
def _convert_batch_type(
self,
batch: MultiAgentBatch,
to_device: bool = True,
pin_memory: bool = False,
use_stream: bool = False,
) -> MultiAgentBatch:
batch = convert_to_torch_tensor(
batch.policy_batches,
device=self._device if to_device else None,
pin_memory=pin_memory,
use_stream=use_stream,
)
# TODO (sven): This computation of `env_steps` is not accurate!
length = max(len(b) for b in batch.values())
batch = MultiAgentBatch(batch, env_steps=length)
return batch
def _compute_off_policyness(self, batch):
# Log off-policy'ness of this batch wrt the current weights.
off_policyness = {
(mid, DIFF_NUM_GRAD_UPDATES_VS_SAMPLER_POLICY): (
(self._weights_seq_no - module_batch[WEIGHTS_SEQ_NO]).float()
)
for mid, module_batch in batch.items()
if WEIGHTS_SEQ_NO in module_batch
}
for key in off_policyness.keys():
mid = key[0]
if Columns.LOSS_MASK not in batch[mid]:
off_policyness[key] = torch.mean(off_policyness[key])
else:
mask = batch[mid][Columns.LOSS_MASK]
num_valid = torch.sum(mask)
off_policyness[key] = torch.sum(off_policyness[key][mask]) / num_valid
self.metrics.log_dict(off_policyness, window=1)
@override(DifferentiableLearner)
def build(self, device: Optional[DeviceType] = None) -> None:
"""Builds the TorchDifferentiableLearner.
This method is specific to TorchDifferentiableLearner. Before running super() it will
initialize the device properly based on `self.config`, so that `_make_module()`
can place the created module on the correct device. After running super() it
wraps the module in a TorchDDPRLModule if `config.num_learners > 0`.
Note, in inherited classes it is advisable to call the parent's `build()`
after setting up all variables because `configure_optimizer_for_module` is
called in this `Learner.build()`.
"""
# TODO (simon): Allow different `DifferentiableLearner` instances in a
# `MetaLearner` to run on different GPUs.
super().build(device=device)
if self._torch_compile_complete_update:
torch._dynamo.reset()
self._compiled_update_initialized = False
self._possibly_compiled_update = torch.compile(
self._uncompiled_update,
backend=self._torch_compile_cfg.torch_dynamo_backend,
mode=self._torch_compile_cfg.torch_dynamo_mode,
**self._torch_compile_cfg.kwargs,
)
# Otherwise, we use the possibly compiled `forward_train` in
# the module, compiled in the `MetaLearner`.
else:
# Nothing, to do.
self._possibly_compiled_update = self._uncompiled_update
@override(DifferentiableLearner)
def _update(
self, batch: Dict[str, Any], params: Dict[ModuleID, NamedParamDict]
) -> Tuple[Any, Dict[ModuleID, NamedParamDict], Any, Any]:
# The first time we call _update after building the learner or
# adding/removing models, we update with the uncompiled update method.
# This makes it so that any variables that may be created during the first
# update step are already there when compiling. More specifically,
# this avoids errors that occur around using defaultdicts with
# torch.compile().
if (
self._torch_compile_complete_update
and not self._compiled_update_initialized
):
self._compiled_update_initialized = True
return self._uncompiled_update(batch, params)
else:
return self._possibly_compiled_update(batch, params)
+697
View File
@@ -0,0 +1,697 @@
import contextlib
import logging
from collections import defaultdict
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
Hashable,
Optional,
Sequence,
Tuple,
)
from ray.rllib.algorithms.algorithm_config import (
AlgorithmConfig,
TorchCompileWhatToCompile,
)
from ray.rllib.core.columns import Columns
from ray.rllib.core.learner.learner import LR_KEY, Learner
from ray.rllib.core.rl_module.multi_rl_module import (
MultiRLModule,
MultiRLModuleSpec,
)
from ray.rllib.core.rl_module.rl_module import (
RLModule,
RLModuleSpec,
)
from ray.rllib.core.rl_module.torch.torch_rl_module import (
TorchCompileConfig,
TorchDDPRLModule,
TorchRLModule,
)
from ray.rllib.policy.sample_batch import MultiAgentBatch
from ray.rllib.utils.annotations import (
OverrideToImplementCustomLogic,
OverrideToImplementCustomLogic_CallToSuperRecommended,
override,
)
from ray.rllib.utils.framework import get_device, try_import_torch
from ray.rllib.utils.metrics import (
ALL_MODULES,
DIFF_NUM_GRAD_UPDATES_VS_SAMPLER_POLICY,
NUM_NON_TRAINABLE_PARAMETERS,
NUM_TRAINABLE_PARAMETERS,
WEIGHTS_SEQ_NO,
)
from ray.rllib.utils.numpy import convert_to_numpy
from ray.rllib.utils.torch_utils import convert_to_torch_tensor
from ray.rllib.utils.typing import (
ModuleID,
Optimizer,
Param,
ParamDict,
ShouldModuleBeUpdatedFn,
StateDict,
TensorType,
)
if TYPE_CHECKING:
from ray.rllib.algorithms.algorithm_config import AlgorithmConfig
torch, nn = try_import_torch()
logger = logging.getLogger(__name__)
class TorchLearner(Learner):
framework: str = "torch"
def __init__(self, **kwargs):
super().__init__(**kwargs)
# Whether to compile the RL Module of this learner. This implies that the.
# forward_train method of the RL Module will be compiled. Further more,
# other forward methods of the RL Module will be compiled on demand.
# This is assumed to not happen, since other forwrad methods are not expected
# to be used during training.
self._torch_compile_forward_train = False
self._torch_compile_cfg = None
# Whether to compile the `_uncompiled_update` method of this learner. This
# implies that everything within `_uncompiled_update` will be compiled,
# not only the forward_train method of the RL Module.
# Note that this is experimental.
# Note that this requires recompiling the forward methods once we add/remove
# RL Modules.
self._torch_compile_complete_update = False
if self.config.torch_compile_learner:
if (
self.config.torch_compile_learner_what_to_compile
== TorchCompileWhatToCompile.COMPLETE_UPDATE
):
self._torch_compile_complete_update = True
self._compiled_update_initialized = False
else:
self._torch_compile_forward_train = True
self._torch_compile_cfg = TorchCompileConfig(
torch_dynamo_backend=self.config.torch_compile_learner_dynamo_backend,
torch_dynamo_mode=self.config.torch_compile_learner_dynamo_mode,
)
# Loss scalers for mixed precision training. Map optimizer names to
# associated torch GradScaler objects.
self._grad_scalers = None
if self.config._torch_grad_scaler_class:
self._grad_scalers = defaultdict(
lambda: self.config._torch_grad_scaler_class()
)
self._lr_schedulers = {}
self._lr_scheduler_classes = None
if self.config._torch_lr_scheduler_classes:
self._lr_scheduler_classes = self.config._torch_lr_scheduler_classes
@OverrideToImplementCustomLogic
@override(Learner)
def configure_optimizers_for_module(
self,
module_id: ModuleID,
config: "AlgorithmConfig" = None,
) -> None:
module = self._module[module_id]
# For this default implementation, the learning rate is handled by the
# attached lr Scheduler (controlled by self.config.lr, which can be a
# fixed value or a schedule setting).
params = self.get_parameters(module)
optimizer = torch.optim.Adam(params)
# Register the created optimizer (under the default optimizer name).
self.register_optimizer(
module_id=module_id,
optimizer=optimizer,
params=params,
lr_or_lr_schedule=config.lr,
)
def _uncompiled_update(
self,
batch: Dict,
**kwargs,
):
"""Performs a single update given a batch of data."""
# TODO (sven): Causes weird cuda error when WandB is used.
# Diagnosis thus far:
# - All peek values during metrics.reduce are non-tensors.
# - However, in impala.py::training_step(), a tensor does arrive after learner
# group.update(), so somehow, there is still a race condition
# possible (learner, which performs the reduce() and learner thread, which
# performs the logging of tensors into metrics logger).
self._compute_off_policyness(batch)
fwd_out = self.module.forward_train(batch)
loss_per_module = self.compute_losses(fwd_out=fwd_out, batch=batch)
gradients = self.compute_gradients(loss_per_module)
with contextlib.ExitStack() as stack:
if self.config.num_learners > 1:
for mod in self.module.values():
# Skip non-torch modules, b/c they may not have the `no_sync` API.
if isinstance(mod, torch.nn.Module):
stack.enter_context(mod.no_sync())
postprocessed_gradients = self.postprocess_gradients(gradients)
self.apply_gradients(postprocessed_gradients)
return fwd_out, loss_per_module, {}
@override(Learner)
def compute_gradients(
self, loss_per_module: Dict[ModuleID, TensorType], **kwargs
) -> ParamDict:
for optim in self._optimizer_parameters:
# `set_to_none=True` is a faster way to zero out the gradients.
optim.zero_grad(set_to_none=True)
if self._grad_scalers is not None:
total_loss = sum(
self._grad_scalers[mid].scale(loss)
for mid, loss in loss_per_module.items()
)
else:
total_loss = sum(loss_per_module.values())
# If we don't have any loss computations, `sum` returns 0.
if isinstance(total_loss, int):
assert total_loss == 0
return {}
# If all parameters for this module are currently frozen, we can't optimize it.
if total_loss.grad_fn is None:
return {}
total_loss.backward()
grads = {pid: p.grad for pid, p in self._params.items()}
return grads
@override(Learner)
def apply_gradients(self, gradients_dict: ParamDict) -> None:
# Set the gradient of the parameters.
for pid, grad in gradients_dict.items():
# If updates should not be skipped turn `nan` and `inf` gradients to zero.
if (
not self.config.torch_skip_nan_gradients
and not torch.isfinite(grad).all()
):
# Warn the user about `nan` gradients.
logger.warning(f"Gradients {pid} contain `nan/inf` values.")
# If updates should be skipped, do not step the optimizer and return.
if not self.config.torch_skip_nan_gradients:
logger.warning(
"Setting `nan/inf` gradients to zero. If updates with "
"`nan/inf` gradients should not be set to zero and instead "
"the update be skipped entirely set `torch_skip_nan_gradients` "
"to `True`."
)
# If necessary turn `nan` gradients to zero. Note this can corrupt the
# internal state of the optimizer, if many `nan` gradients occur.
self._params[pid].grad = torch.nan_to_num(grad)
# Otherwise, use the gradient as is.
else:
self._params[pid].grad = grad
# For each optimizer call its step function.
for module_id, optimizer_names in self._module_optimizers.items():
for optimizer_name in optimizer_names:
optim = self.get_optimizer(module_id, optimizer_name)
# If we have learning rate schedulers for a module add them, if
# necessary.
if self._lr_scheduler_classes is not None:
if (
module_id not in self._lr_schedulers
or optimizer_name not in self._lr_schedulers[module_id]
):
# Set for each module and optimizer a scheduler.
self._lr_schedulers[module_id] = {optimizer_name: []}
# If the classes are in a dictionary each module might have
# a different set of schedulers.
if isinstance(self._lr_scheduler_classes, dict):
scheduler_classes = self._lr_scheduler_classes[module_id]
# Else, each module has the same learning rate schedulers.
else:
scheduler_classes = self._lr_scheduler_classes
# Initialize and add the schedulers.
for scheduler_class in scheduler_classes:
self._lr_schedulers[module_id][optimizer_name].append(
scheduler_class(optim)
)
# Step through the scaler (unscales gradients, if applicable).
if self._grad_scalers is not None:
scaler = self._grad_scalers[module_id]
scaler.step(optim)
self.metrics.log_value(
(module_id, "_torch_grad_scaler_current_scale"),
scaler.get_scale(),
window=1, # snapshot in time, no EMA/mean.
)
# Update the scaler.
scaler.update()
# `step` the optimizer (default), but only if all gradients are finite.
elif self.config.torch_skip_nan_gradients or all(
param.grad is None or torch.isfinite(param.grad).all()
for group in optim.param_groups
for param in group["params"]
):
optim.step()
# If gradients are not all finite warn the user that the update will be
# skipped.
elif not all(
torch.isfinite(param.grad).all()
for group in optim.param_groups
for param in group["params"]
):
logger.warning(
"Skipping this update. If updates with `nan/inf` gradients "
"should not be skipped entirely and instead `nan/inf` "
"gradients set to `zero` set `torch_skip_nan_gradients` to "
"`False`."
)
@OverrideToImplementCustomLogic_CallToSuperRecommended
@override(Learner)
def after_gradient_based_update(self, *, timesteps: Dict[str, Any]) -> None:
"""Called after gradient-based updates are completed.
Should be overridden to implement custom cleanup-, logging-, or non-gradient-
based Learner/RLModule update logic after(!) gradient-based updates have been
completed.
Note, for `framework="torch"` users can register
`torch.optim.lr_scheduler.LRScheduler` via
`AlgorithmConfig._torch_lr_scheduler_classes`. These schedulers need to be
stepped here after gradient updates and reported.
Args:
timesteps: Timesteps dict, which must have the key
`NUM_ENV_STEPS_SAMPLED_LIFETIME`.
# TODO (sven): Make this a more formal structure with its own type.
"""
# If we have no `torch.optim.lr_scheduler.LRScheduler` registered call the
# `super()`'s method to update RLlib's learning rate schedules.
if not self._lr_schedulers:
return super().after_gradient_based_update(timesteps=timesteps)
# Only update this optimizer's lr, if a scheduler has been registered
# along with it.
for module_id, optimizer_names in self._module_optimizers.items():
for optimizer_name in optimizer_names:
# If learning rate schedulers are provided step them here. Note,
# stepping them in `TorchLearner.apply_gradients` updates the
# learning rates during minibatch updates; we want to update
# between whole batch updates.
if (
module_id in self._lr_schedulers
and optimizer_name in self._lr_schedulers[module_id]
):
for scheduler in self._lr_schedulers[module_id][optimizer_name]:
scheduler.step()
optimizer = self.get_optimizer(module_id, optimizer_name)
self.metrics.log_value(
# Cut out the module ID from the beginning since it's already
# part of the key sequence: (ModuleID, "[optim name]_lr").
key=(
module_id,
f"{optimizer_name[len(module_id) + 1:]}_{LR_KEY}",
),
value=self._get_optimizer_lr(optimizer),
window=1,
)
@override(Learner)
def _get_optimizer_state(self) -> StateDict:
ret = {}
for name, optim in self._named_optimizers.items():
ret[name] = {
"module_id": self._optimizer_name_to_module[name],
"state": convert_to_numpy(optim.state_dict()),
}
return ret
@override(Learner)
def _set_optimizer_state(self, state: StateDict) -> None:
for name, state_dict in state.items():
# Ignore updating optimizers matching to submodules not present in this
# Learner's MultiRLModule.
module_id = state_dict["module_id"]
if name not in self._named_optimizers and module_id in self.module:
self.configure_optimizers_for_module(
module_id=module_id,
config=self.config.get_config_for_module(module_id=module_id),
)
if name in self._named_optimizers:
# Keep optimizer param_groups untouched so scalar metadata such as
# betas, lr, eps, foreach, capturable preserve their Python types.
# Only convert per-parameter optimizer buffers (exp_avg, exp_avg_sq,
# step, etc.) to tensors on the learner device.
optimizer_state = state_dict["state"]
loaded_state = {
**optimizer_state,
"state": convert_to_torch_tensor(
optimizer_state["state"],
device=self._device,
),
}
self._named_optimizers[name].load_state_dict(loaded_state)
@override(Learner)
def get_param_ref(self, param: Param) -> Hashable:
return param
@override(Learner)
def get_parameters(self, module: RLModule) -> Sequence[Param]:
return list(module.parameters())
@override(Learner)
def _convert_batch_type(
self,
batch: MultiAgentBatch,
to_device: bool = True,
pin_memory: bool = False,
use_stream: bool = False,
) -> MultiAgentBatch:
batch_dict = convert_to_torch_tensor(
batch.policy_batches,
device=self._device if to_device else None,
pin_memory=pin_memory,
use_stream=use_stream,
)
batch = MultiAgentBatch(batch_dict, env_steps=batch.env_steps())
return batch
@override(Learner)
def add_module(
self,
*,
module_id: ModuleID,
# TODO (sven): Rename to `rl_module_spec`.
module_spec: RLModuleSpec,
config_overrides: Optional[Dict] = None,
new_should_module_be_updated: Optional[ShouldModuleBeUpdatedFn] = None,
) -> MultiRLModuleSpec:
# Call super's add_module method.
marl_spec = super().add_module(
module_id=module_id,
module_spec=module_spec,
config_overrides=config_overrides,
new_should_module_be_updated=new_should_module_be_updated,
)
# we need to ddpify the module that was just added to the pool
module = self._module[module_id]
if self._torch_compile_forward_train:
module.compile(self._torch_compile_cfg)
elif self._torch_compile_complete_update:
# When compiling the update, we need to reset and recompile
# _uncompiled_update every time we add/remove a module anew.
torch._dynamo.reset()
self._compiled_update_initialized = False
self._possibly_compiled_update = torch.compile(
self._uncompiled_update,
backend=self._torch_compile_cfg.torch_dynamo_backend,
mode=self._torch_compile_cfg.torch_dynamo_mode,
**self._torch_compile_cfg.kwargs,
)
if isinstance(module, TorchRLModule):
self._module[module_id].to(self._device)
if self.distributed:
if (
self._torch_compile_complete_update
or self._torch_compile_forward_train
):
raise ValueError(
"Using torch distributed and torch compile "
"together tested for now. Please disable "
"torch compile."
)
self._module.add_module(
module_id,
TorchDDPRLModule(module, **self.config.torch_ddp_kwargs),
override=True,
)
self._log_trainable_parameters()
return marl_spec
@override(Learner)
def remove_module(self, module_id: ModuleID, **kwargs) -> MultiRLModuleSpec:
marl_spec = super().remove_module(module_id, **kwargs)
if self._torch_compile_complete_update:
# When compiling the update, we need to reset and recompile
# _uncompiled_update every time we add/remove a module anew.
torch._dynamo.reset()
self._compiled_update_initialized = False
self._possibly_compiled_update = torch.compile(
self._uncompiled_update,
backend=self._torch_compile_cfg.torch_dynamo_backend,
mode=self._torch_compile_cfg.torch_dynamo_mode,
**self._torch_compile_cfg.kwargs,
)
self._log_trainable_parameters()
return marl_spec
@override(Learner)
def build(self) -> None:
"""Builds the TorchLearner.
This method is specific to TorchLearner. Before running super() it will
initialize the device properly based on `self.config`, so that `_make_module()`
can place the created module on the correct device. After running super() it
wraps the module in a TorchDDPRLModule if `config.num_learners > 0`.
Note, in inherited classes it is advisable to call the parent's `build()`
after setting up all variables because `configure_optimizer_for_module` is
called in this `Learner.build()`.
"""
self._device = get_device(self.config, self.config.num_gpus_per_learner)
super().build()
if self._torch_compile_complete_update:
torch._dynamo.reset()
self._compiled_update_initialized = False
self._possibly_compiled_update = torch.compile(
self._uncompiled_update,
backend=self._torch_compile_cfg.torch_dynamo_backend,
mode=self._torch_compile_cfg.torch_dynamo_mode,
**self._torch_compile_cfg.kwargs,
)
else:
if self._torch_compile_forward_train:
if isinstance(self._module, TorchRLModule):
self._module.compile(self._torch_compile_cfg)
elif isinstance(self._module, MultiRLModule):
for module in self._module._rl_modules.values():
# Compile only TorchRLModules, e.g. we don't want to compile
# a RandomRLModule.
if isinstance(self._module, TorchRLModule):
module.compile(self._torch_compile_cfg)
else:
raise ValueError(
"Torch compile is only supported for TorchRLModule and "
"MultiRLModule."
)
self._possibly_compiled_update = self._uncompiled_update
self._make_modules_ddp_if_necessary()
@override(Learner)
def _update(self, batch: Dict[str, Any]) -> Tuple[Any, Any, Any]:
# The first time we call _update after building the learner or
# adding/removing models, we update with the uncompiled update method.
# This makes it so that any variables that may be created during the first
# update step are already there when compiling. More specifically,
# this avoids errors that occur around using defaultdicts with
# torch.compile().
if (
self._torch_compile_complete_update
and not self._compiled_update_initialized
):
self._compiled_update_initialized = True
return self._uncompiled_update(batch)
else:
return self._possibly_compiled_update(batch)
@OverrideToImplementCustomLogic
def _make_modules_ddp_if_necessary(self) -> None:
"""Default logic for (maybe) making all Modules within self._module DDP."""
# If the module is a MultiRLModule and nn.Module we can simply assume
# all the submodules are registered. Otherwise, we need to loop through
# each submodule and move it to the correct device.
# TODO (Kourosh): This can result in missing modules if the user does not
# register them in the MultiRLModule. We should find a better way to
# handle this.
if self.config.num_learners > 1:
# Single agent module: Convert to `TorchDDPRLModule`.
if isinstance(self._module, TorchRLModule):
self._module = TorchDDPRLModule(
self._module, **self.config.torch_ddp_kwargs
)
# Multi agent module: Convert each submodule to `TorchDDPRLModule`.
else:
assert isinstance(self._module, MultiRLModule)
for key in self._module.keys():
sub_module = self._module[key]
if isinstance(sub_module, TorchRLModule):
# Wrap and override the module ID key in self._module.
self._module.add_module(
key,
TorchDDPRLModule(
sub_module, **self.config.torch_ddp_kwargs
),
override=True,
)
def rl_module_is_compatible(self, module: RLModule) -> bool:
return isinstance(module, nn.Module)
@override(Learner)
def _check_registered_optimizer(
self,
optimizer: Optimizer,
params: Sequence[Param],
) -> None:
super()._check_registered_optimizer(optimizer, params)
if not isinstance(optimizer, torch.optim.Optimizer):
raise ValueError(
f"The optimizer ({optimizer}) is not a torch.optim.Optimizer! "
"Only use torch.optim.Optimizer subclasses for TorchLearner."
)
for param in params:
if not isinstance(param, torch.Tensor):
raise ValueError(
f"One of the parameters ({param}) in the registered optimizer "
"is not a torch.Tensor!"
)
@override(Learner)
def _make_module(self) -> MultiRLModule:
module = super()._make_module()
self._map_module_to_device(module)
return module
def _map_module_to_device(self, module: MultiRLModule) -> None:
"""Moves the module to the correct device."""
if isinstance(module, torch.nn.Module):
module.to(self._device)
else:
for key in module.keys():
if isinstance(module[key], torch.nn.Module):
module[key].to(self._device)
@override(Learner)
def _log_trainable_parameters(self) -> None:
# Log number of non-trainable and trainable parameters of our RLModule.
num_trainable_params = defaultdict(int)
num_non_trainable_params = defaultdict(int)
for mid, rlm in self.module._rl_modules.items():
if isinstance(rlm, TorchRLModule):
for p in rlm.parameters():
n = p.numel()
if p.requires_grad:
num_trainable_params[(mid, NUM_TRAINABLE_PARAMETERS)] += n
else:
num_non_trainable_params[
(mid, NUM_NON_TRAINABLE_PARAMETERS)
] += n
self.metrics.log_dict(
{
**{
(ALL_MODULES, NUM_TRAINABLE_PARAMETERS): sum(
num_trainable_params.values()
),
(ALL_MODULES, NUM_NON_TRAINABLE_PARAMETERS): sum(
num_non_trainable_params.values()
),
},
**num_trainable_params,
**num_non_trainable_params,
}
)
def _compute_off_policyness(self, batch):
# Log off-policy'ness of this batch wrt the current weights.
off_policyness = {
(mid, DIFF_NUM_GRAD_UPDATES_VS_SAMPLER_POLICY): (
(self._weights_seq_no - module_batch[WEIGHTS_SEQ_NO]).float()
)
for mid, module_batch in batch.items()
if WEIGHTS_SEQ_NO in module_batch
}
for key in off_policyness.keys():
mid = key[0]
if Columns.LOSS_MASK not in batch[mid]:
off_policyness[key] = torch.mean(off_policyness[key])
else:
mask = batch[mid][Columns.LOSS_MASK]
num_valid = torch.sum(mask)
off_policyness[key] = torch.sum(off_policyness[key][mask]) / num_valid
self.metrics.log_dict(off_policyness, window=1)
@override(Learner)
def _get_tensor_variable(
self, value, dtype=None, trainable=False
) -> "torch.Tensor":
tensor = torch.tensor(
value,
requires_grad=trainable,
device=self._device,
dtype=(
dtype
or (
torch.float32
if isinstance(value, float)
else torch.int32
if isinstance(value, int)
else None
)
),
)
return nn.Parameter(tensor) if trainable else tensor
@staticmethod
@override(Learner)
def _get_optimizer_lr(optimizer: "torch.optim.Optimizer") -> float:
for g in optimizer.param_groups:
return g["lr"]
@staticmethod
@override(Learner)
def _set_optimizer_lr(optimizer: "torch.optim.Optimizer", lr: float) -> None:
for g in optimizer.param_groups:
g["lr"] = lr
@staticmethod
@override(Learner)
def _get_clip_function() -> Callable:
from ray.rllib.utils.torch_utils import clip_gradients
return clip_gradients
@staticmethod
@override(Learner)
def _get_global_norm_function() -> Callable:
from ray.rllib.utils.torch_utils import compute_global_norm
return compute_global_norm
@@ -0,0 +1,449 @@
import contextlib
import logging
from itertools import cycle
from typing import Any, Dict, List, Optional, Tuple
import ray
from ray.rllib.algorithms.algorithm_config import AlgorithmConfig
from ray.rllib.core import ALL_MODULES
from ray.rllib.core.learner.learner import Learner
from ray.rllib.core.learner.torch.torch_differentiable_learner import (
TorchDifferentiableLearner,
)
from ray.rllib.core.learner.torch.torch_learner import TorchLearner
from ray.rllib.core.learner.training_data import TrainingData
from ray.rllib.core.rl_module.apis import SelfSupervisedLossAPI
from ray.rllib.policy.sample_batch import MultiAgentBatch
from ray.rllib.utils.annotations import (
OverrideToImplementCustomLogic,
OverrideToImplementCustomLogic_CallToSuperRecommended,
override,
)
from ray.rllib.utils.framework import try_import_torch
from ray.rllib.utils.metrics import (
DATASET_NUM_ITERS_TRAINED,
DATASET_NUM_ITERS_TRAINED_LIFETIME,
DIFFERENTIABLE_LEARNER_RESULTS,
)
from ray.rllib.utils.metrics.utils import to_snake_case
from ray.rllib.utils.typing import (
EpisodeType,
ModuleID,
NamedParamDict,
ResultDict,
TensorType,
)
logger = logging.getLogger("__name__")
torch, nn = try_import_torch()
class TorchMetaLearner(TorchLearner):
"""A `TorchLearner` designed for meta-learning with functional updates.
This `TorchLearner` manages one or more `DifferentiableLearner` instances,
which perform functional updates on the `MultiRLModule`. These updates enable
the computation of higher-order gradients, making this class suitable for
meta-learning applications.
The `update` method executes one or more update loops on its
`DifferentiableLearner` instances, leveraging the functionally updated
parameters for its own learning process.
"""
# A list of `TorchDifferentiableLearner`s that run inner functional update
# loops.
others: List[TorchDifferentiableLearner]
def __init__(
self,
**kwargs,
):
# First, initialize the `TorchLearner`.
super().__init__(**kwargs)
# Initialize all configured `TorchDifferentiableLearner`s.
self.others = [
other_config.learner_class(
config=self.config,
learner_config=other_config,
module=self.module,
)
for other_config in self.config.differentiable_learner_configs
]
@OverrideToImplementCustomLogic_CallToSuperRecommended
@override(TorchLearner)
def build(self) -> None:
"""Builds the `TorchMetaLearner`.
This method should be called before the meta-learner is used. It is
responsible for setting up the `TorchDifferentiableLearner`s.
"""
super().build()
# Build all `DifferentiableLearner`s.
for other in self.others:
other.build(device=self._device)
# Ensure 'others' have a module reference.
for other in self.others:
if not other.module:
other.module = self.module
@OverrideToImplementCustomLogic
@override(Learner)
def update(
self,
batch: Optional[MultiAgentBatch] = None,
batches: Optional[List[MultiAgentBatch]] = None,
batch_refs: Optional[List[ray.ObjectRef]] = None,
episodes: Optional[List[EpisodeType]] = None,
episodes_refs: Optional[List[ray.ObjectRef]] = None,
data_iterators: Optional[List[ray.data.DataIterator]] = None,
training_data: Optional[TrainingData] = None,
*,
# TODO (simon): Maybe defined here other_training_data to provide
# TrainingData for differentiable Learners. List to nest them.
# other_training_data: List[Union[List[Any], TrainingData]]
# TODO (sven): Make this a more formal structure with its own type.
timesteps: Optional[Dict[str, Any]] = None,
num_total_minibatches: int = 0,
num_epochs: int = 1,
minibatch_size: Optional[int] = None,
shuffle_batch_per_epoch: bool = False,
_no_metrics_reduce: bool = False,
others_training_data: Optional[List[TrainingData]] = None,
**kwargs,
) -> ResultDict:
"""Performs a meta-update on the `MultiRLModule`.
This method allows multiple backward passes on a batch by iteratively
applying functional updates from the `TorchDifferentiableLearner` instances
in `others`. The resulting differentiable parameters are then used in this
learner's own functional forward pass on the `MultiRLModule`, enabling the
computation of higher-order gradients during the backward pass.
"""
self._check_is_built()
# Call `before_gradient_based_update` to allow for non-gradient based
# preparations-, logging-, and update logic to happen.
self.before_gradient_based_update(timesteps=timesteps or {})
if training_data is None:
training_data = TrainingData(
batch=batch,
batches=batches,
batch_refs=batch_refs,
episodes=episodes,
episodes_refs=episodes_refs,
data_iterators=data_iterators,
)
# Validate training data.
# TODO (simon): Pass in a `TrainingData` list for all differentiable learners.
# to be used if necessary. Some algorithms need it.
training_data.validate()
training_data.solve_refs()
assert training_data.batches is None, "`training_data.batches` must be None!"
# Increase the _weigths_seq_no in each `update` run.
self._weights_seq_no += 1
# Create a batch iterator.
# TODO (simon): Create this method in the `Learner`.
batch_iter = self._create_iterator_if_necessary(
training_data=training_data,
num_total_minibatches=num_total_minibatches,
num_epochs=num_epochs,
minibatch_size=minibatch_size,
shuffle_batch_per_epoch=shuffle_batch_per_epoch,
**kwargs,
)
# If no training data for `DifferentiableLearner`s have been passed in, cycle
# over the main training_data for each `DifferentiableLearner`.
if not others_training_data:
others_training_data = cycle([training_data])
# Perform the actual looping through the minibatches or the given data iterator.
for iteration, tensor_minibatch in enumerate(batch_iter):
# Check the MultiAgentBatch, whether our RLModule contains all ModuleIDs
# found in this batch. If not, throw an error.
unknown_module_ids = set(tensor_minibatch.policy_batches.keys()) - set(
self.module.keys()
)
if unknown_module_ids:
raise ValueError(
f"Batch contains one or more ModuleIDs ({unknown_module_ids}) that "
f"are not in this Learner!"
)
# Clone the parameters for the differentiable updates.
params = self._clone_named_parameters()
# Update all differentiable learners.
others_loss_per_module = []
others_results = {}
for other, other_training_data in zip(self.others, others_training_data):
# TODO (simon): Maybe return all losses from each other's update step.
params, other_loss_per_module, other_results = other.update(
training_data=other_training_data,
params=params,
# TODO (simon): Check, if this is still needed.
_no_metrics_reduce=_no_metrics_reduce,
**kwargs,
)
others_loss_per_module.append(other_loss_per_module)
# TODO (simon): Find a more elegant way for naming.
others_results[to_snake_case(other.__class__.__name__)] = other_results
# Log training results from the `DifferentiableLearner`s.
# TODO (simon): Right now metrics are not carried over b/c of
# the double tensormode problem.
self.metrics.aggregate(
stats_dicts=[others_results], key=DIFFERENTIABLE_LEARNER_RESULTS
)
# Make the actual in-graph/traced meta-`_update` call. This should return
# all tensor values (no numpy).
fwd_out, loss_per_module, _ = self._update(
tensor_minibatch.policy_batches,
params,
others_loss_per_module,
)
# TODO (sven): Maybe move this into loop above to get metrics more accuratcely
# cover the minibatch/epoch logic.
# Log all timesteps (env, agent, modules) based on given episodes/batch.
self._log_steps_trained_metrics(tensor_minibatch)
self._set_slicing_by_batch_id(tensor_minibatch, value=False)
if self.iterator:
# Record the number of batches pulled from the dataset.
self.metrics.log_value(
(ALL_MODULES, DATASET_NUM_ITERS_TRAINED),
iteration + 1,
reduce="sum",
)
self.metrics.log_value(
(ALL_MODULES, DATASET_NUM_ITERS_TRAINED_LIFETIME),
iteration + 1,
reduce="lifetime_sum",
)
# Log all individual RLModules' loss terms and its registered optimizers'
# current learning rates.
# Note: We do this only once for the last of the minibatch updates, b/c the
# window is only 1 anyways.
for mid, loss in loss_per_module.items():
self.metrics.log_value(
key=(mid, self.TOTAL_LOSS_KEY),
value=loss,
window=1,
)
# Call `after_gradient_based_update` to allow for non-gradient based
# cleanups-, logging-, and update logic to happen.
# TODO (simon): Check, if this should stay here, when running multiple
# gradient steps inside the iterator loop above (could be a complete epoch)
# the target networks might need to be updated earlier.
self.after_gradient_based_update(timesteps=timesteps or {})
# Reduce results across all minibatch update steps.
if not _no_metrics_reduce:
return self.metrics.reduce()
def _clone_named_parameters(self):
"""Clone named parameters for functional updates."""
return self.module.foreach_module(
lambda _, m: {name: p.clone() for name, p in m.named_parameters()},
return_dict=True,
)
@OverrideToImplementCustomLogic
@override(TorchLearner)
def _update(
self,
batch: Dict[str, Any],
params: NamedParamDict,
others_loss_per_module: List[Dict[ModuleID, TensorType]] = None,
) -> Tuple[Any, Any, Any]:
# The first time we call _update after building the learner or
# adding/removing models, we update with the uncompiled update method.
# This makes it so that any variables that may be created during the first
# update step are already there when compiling. More specifically,
# this avoids errors that occur around using defaultdicts with
# torch.compile().
if (
self._torch_compile_complete_update
and not self._compiled_update_initialized
):
self._compiled_update_initialized = True
return self._uncompiled_update(batch, params, others_loss_per_module)
else:
return self._possibly_compiled_update(batch, params, others_loss_per_module)
def _uncompiled_update(
self,
batch: Dict,
params: NamedParamDict,
others_loss_per_module: List[Dict[ModuleID, TensorType]] = None,
**kwargs,
):
"""Performs a single functional update using a batch of data.
This update utilizes a functional forward pass via PyTorch 2.0's `func` module.
The updated parameters provided by `TorchDifferentiableLearner` instances are
functions of the `MultiRLModule`'s parameters, allowing for differentiation.
To ensure compatibility, the `MultiRLModule`'s `forward` method must encapsulate
all logic from `forward_train` and support additional keyword arguments (`kwargs`).
"""
# TODO (sven): Causes weird cuda error when WandB is used.
# Diagnosis thus far:
# - All peek values during metrics.reduce are non-tensors.
# - However, in impala.py::training_step(), a tensor does arrive after learner
# group.update(), so somehow, there is still a race condition
# possible (learner, which performs the reduce() and learner thread, which
# performs the logging of tensors into metrics logger).
self._compute_off_policyness(batch)
# TODO (simon): For this to work, the `RLModule.forward` must run the
# the `forward_train`. Passing in arguments which makes the `forward` modular
# could be a workaround.
# Make a functional forward call to include higher-order gradients in the meta
# update.
fwd_out = self._make_functional_call(params, batch)
loss_per_module = self.compute_losses(
fwd_out=fwd_out, batch=batch, others_loss_per_module=others_loss_per_module
)
gradients = self.compute_gradients(loss_per_module)
with contextlib.ExitStack() as stack:
if self.config.num_learners > 1:
for mod in self.module.values():
# Skip non-torch modules, b/c they may not have the `no_sync` API.
if isinstance(mod, torch.nn.Module):
stack.enter_context(mod.no_sync())
postprocessed_gradients = self.postprocess_gradients(gradients)
self.apply_gradients(postprocessed_gradients)
# Deactivate tensor-mode on our MetricsLogger and collect the (tensor)
# results.
return fwd_out, loss_per_module, {}
@override(Learner)
def compute_losses(
self,
*,
fwd_out: Dict[str, Any],
batch: Dict[str, Any],
others_loss_per_module: List[Dict[ModuleID, TensorType]] = None,
**kwargs,
) -> Dict[ModuleID, TensorType]:
"""Computes the loss(es) for the module being optimized.
This method must be overridden by MultiRLModule-specific Learners in order to
define the specific loss computation logic. If the algorithm is single-agent,
only `compute_loss_for_module()` should be overridden instead. If the algorithm
uses independent multi-agent learning (default behavior for RLlib's multi-agent
setups), also only `compute_loss_for_module()` should be overridden, but it will
be called for each individual RLModule inside the MultiRLModule.
For the functional update to work, no `forward` call should be made
within this method, especially not a non-functional one. Instead, use
the model outputs provided by `fwd_out`.
Losses from `DifferentiableLearner` instances in `others_loss_per_module`
can be leveraged for more advanced module-wise loss calculations.
Args:
fwd_out: Output from a call to the `forward_train()` method of the
underlying MultiRLModule (`self.module`) during training
(`self.update()`).
batch: The train batch that was used to compute `fwd_out`.
others_loss_per_module: A list of losses per module id from the contained
`DifferentiableLearner` instances in`self.others`.
Returns:
A dictionary mapping module IDs to individual loss terms.
"""
loss_per_module = {}
for module_id in fwd_out:
module_batch = batch[module_id]
module_fwd_out = fwd_out[module_id]
module = self.module[module_id].unwrapped()
if isinstance(module, SelfSupervisedLossAPI):
loss = module.compute_self_supervised_loss(
learner=self,
module_id=module_id,
config=self.config.get_config_for_module(module_id),
batch=module_batch,
fwd_out=module_fwd_out,
others_loss_per_module=others_loss_per_module,
)
else:
loss = self.compute_loss_for_module(
module_id=module_id,
config=self.config.get_config_for_module(module_id),
batch=module_batch,
fwd_out=module_fwd_out,
others_loss_per_module=others_loss_per_module,
)
loss_per_module[module_id] = loss
return loss_per_module
@OverrideToImplementCustomLogic
@override(Learner)
def compute_loss_for_module(
self,
*,
module_id: ModuleID,
config: "AlgorithmConfig",
batch: Dict[str, Any],
fwd_out: Dict[str, TensorType],
others_loss_per_module: List[Dict[ModuleID, TensorType]] = None,
) -> TensorType:
"""Computes the loss for a single module.
This method calculates the loss for an individual agent. In multi-agent
scenarios requiring more complex loss computations, consider overriding the
`compute_losses` method instead.
Losses from `DifferentiableLearner` instances in `others_loss_per_module`
can be leveraged for more advanced module-wise loss calculations.
Args:
module_id: The id of the module.
config: The AlgorithmConfig specific to the given `module_id`.
batch: The train batch for this particular module.
fwd_out: The output of the forward pass for this particular module.
others_loss_per_module: A list of losses per module id from the contained
`DifferentiableLearner` instances in`self.others`.
Returns:
A single total loss tensor. If you have more than one optimizer on the
provided `module_id` and would like to compute gradients separately using
these different optimizers, simply add up the individual loss terms for
each optimizer and return the sum. Also, for recording/logging any
individual loss terms, you can use the `Learner.metrics.log_value(
key=..., value=...)` or `Learner.metrics.log_dict()` APIs. See:
:py:class:`~ray.rllib.utils.metrics.metrics_logger.MetricsLogger` for more
information.
"""
# By default, `others_loss_per_module` is not used; instead, the superclass
# method is called.
return super().compute_loss_for_module(
module_id=module_id, config=config, batch=batch, fwd_out=fwd_out
)
def _make_functional_call(
self, params: Dict[ModuleID, NamedParamDict], batch: MultiAgentBatch
) -> Dict[ModuleID, NamedParamDict]:
"""Make a functional forward call to all modules in the `MultiRLModule`."""
return self._module.foreach_module(
lambda mid, m: torch.func.functional_call(m, params[mid], batch[mid]),
return_dict=True,
)
+159
View File
@@ -0,0 +1,159 @@
import dataclasses
from collections import defaultdict
from typing import List, Optional
import tree # pip install dm_tree
import ray
from ray.rllib.env.multi_agent_episode import MultiAgentEpisode
from ray.rllib.policy.sample_batch import MultiAgentBatch
from ray.rllib.utils.minibatch_utils import (
ShardBatchIterator,
ShardEpisodesIterator,
ShardObjectRefIterator,
)
from ray.rllib.utils.typing import EpisodeType
# TODO (sven): Switch to dataclass(slots=True) once on py >= 3.10.
@dataclasses.dataclass
class TrainingData:
batch: Optional[MultiAgentBatch] = None
batches: Optional[List[MultiAgentBatch]] = None
batch_refs: Optional[List[ray.ObjectRef]] = None
episodes: Optional[List[EpisodeType]] = None
episodes_refs: Optional[List[ray.ObjectRef]] = None
data_iterators: Optional[List[ray.data.iterator.DataIterator]] = None
def validate(self):
# Exactly one training data type must be provided.
if (
sum(
td is not None
for td in [
self.batch,
self.batches,
self.batch_refs,
self.episodes,
self.episodes_refs,
self.data_iterators,
]
)
!= 1
):
raise ValueError("Exactly one training data type must be provided!")
def shard(
self,
num_shards: int,
len_lookback_buffer: Optional[int] = None,
**kwargs,
):
# Single batch -> Split into n smaller batches.
if self.batch is not None:
return [
(TrainingData(batch=b), {})
for b in ShardBatchIterator(self.batch, num_shards=num_shards)
]
# TODO (sven): Do we need a more sohpisticated shard mechanism for this case?
elif self.batches is not None:
assert num_shards == len(self.batches)
return [(TrainingData(batch=b), {}) for b in self.batches]
# List of batch refs.
elif self.batch_refs is not None:
return [
(TrainingData(batch_refs=b), {})
for b in ShardObjectRefIterator(self.batch_refs, num_shards=num_shards)
]
# List of episodes -> Split into n equally sized shards (based on the lengths
# of the episodes).
elif self.episodes is not None:
num_total_minibatches = 0
if "minibatch_size" in kwargs and num_shards > 1:
num_total_minibatches = self._compute_num_total_minibatches(
self.episodes,
num_shards,
kwargs["minibatch_size"],
kwargs.get("num_epochs", 1),
)
return [
(
TrainingData(episodes=e),
{"num_total_minibatches": num_total_minibatches},
)
for e in ShardEpisodesIterator(
self.episodes,
num_shards=num_shards,
len_lookback_buffer=len_lookback_buffer,
)
]
# List of episodes refs.
elif self.episodes_refs is not None:
return [
(TrainingData(episodes_refs=e), {})
for e in ShardObjectRefIterator(self.episodes_refs, num_shards)
]
# List of data iterators.
else:
assert self.data_iterators and len(self.data_iterators) == num_shards
return [
(TrainingData(data_iterators=[di]), {}) for di in self.data_iterators
]
def solve_refs(self):
# Batch references.
if self.batch_refs is not None:
# Solve the ray.ObjRefs.
batches = tree.flatten(ray.get(self.batch_refs))
# If only a single batch, set `self.batch`.
if len(batches) == 1:
self.batch = batches[0]
# Otherwise, set `self.batches`.
else:
self.batches = batches
# Empty `self.batch_refs`.
self.batch_refs = None
# Episode references.
elif self.episodes_refs is not None:
# It's possible that individual refs are invalid due to the EnvRunner
# that produced the ref has crashed or had its entire node go down.
# In this case, try each ref individually and collect only valid results.
try:
episodes = tree.flatten(ray.get(self.episodes_refs))
except ray.exceptions.OwnerDiedError:
episode_refs = self.episodes_refs
episodes = []
for ref in episode_refs:
try:
episodes.extend(ray.get(ref))
except ray.exceptions.OwnerDiedError as e:
ray.logger.warning(
f"episode-ref {ref} died and can't be collected with error: {e}. This can happen if an EnvRunner is lost (for example because of a node failure) and is not critical in such cases."
)
self.episodes = episodes
self.episodes_refs = None
@staticmethod
def _compute_num_total_minibatches(
episodes,
num_shards,
minibatch_size,
num_epochs,
):
# Count total number of timesteps per module ID.
if isinstance(episodes[0], MultiAgentEpisode):
per_mod_ts = defaultdict(int)
for ma_episode in episodes:
for sa_episode in ma_episode.agent_episodes.values():
per_mod_ts[sa_episode.module_id] += len(sa_episode)
max_ts = max(per_mod_ts.values())
else:
max_ts = sum(map(len, episodes))
return int((num_epochs * max_ts) / (num_shards * minibatch_size))
+58
View File
@@ -0,0 +1,58 @@
import copy
from ray.rllib.utils.framework import try_import_torch
from ray.rllib.utils.typing import NetworkType
from ray.util import PublicAPI
torch, _ = try_import_torch()
def make_target_network(main_net: NetworkType) -> NetworkType:
"""Creates a (deep) copy of `main_net` (including synched weights) and returns it.
Args:
main_net: The main network to return a target network for
Returns:
The copy of `main_net` that can be used as a target net. Note that the weights
of the returned net are already synched (identical) with `main_net`.
"""
# Deepcopy the main net (this should already take care of synching all weights).
target_net = copy.deepcopy(main_net)
# Make the target net not trainable.
if isinstance(main_net, torch.nn.Module):
target_net.requires_grad_(False)
else:
raise ValueError(f"Unsupported framework for given `main_net` {main_net}!")
return target_net
@PublicAPI(stability="beta")
def update_target_network(
*,
main_net: NetworkType,
target_net: NetworkType,
tau: float,
) -> None:
"""Updates a target network (from a "main" network) using Polyak averaging.
Thereby:
new_target_net_weight = (
tau * main_net_weight + (1.0 - tau) * current_target_net_weight
)
Args:
main_net: The nn.Module to update from.
target_net: The target network to update.
tau: The tau value to use in the Polyak averaging formula. Use 1.0 for a
complete sync of the weights (target and main net will be the exact same
after updating).
"""
if isinstance(main_net, torch.nn.Module):
from ray.rllib.utils.torch_utils import update_target_network as _update_target
else:
raise ValueError(f"Unsupported framework for given `main_net` {main_net}!")
_update_target(main_net=main_net, target_net=target_net, tau=tau)