Files
2026-07-13 13:17:40 +08:00

633 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import atexit
import logging
import queue
import threading
import weakref
from queue import Queue
from typing import Any, Dict, List
import ray
from ray.rllib.algorithms.impala.impala import LEARNER_RESULTS_CURR_ENTROPY_COEFF_KEY
from ray.rllib.core import 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.apis import ValueFunctionAPI
from ray.rllib.utils.annotations import (
OverrideToImplementCustomLogic_CallToSuperRecommended,
override,
)
from ray.rllib.utils.framework import try_import_torch
from ray.rllib.utils.lambda_defaultdict import LambdaDefaultDict
from ray.rllib.utils.metrics import (
ALL_MODULES,
NUM_ENV_STEPS_SAMPLED_LIFETIME,
)
from ray.rllib.utils.metrics.metrics_logger import MetricsLogger
from ray.rllib.utils.metrics.ray_metrics import (
DEFAULT_HISTOGRAM_BOUNDARIES_SHORT_EVENTS,
TimerAndPrometheusLogger,
)
from ray.rllib.utils.schedules.scheduler import Scheduler
from ray.rllib.utils.typing import ModuleID, ResultDict
from ray.util.metrics import Gauge, Histogram
logger = logging.getLogger(__name__)
torch, _ = try_import_torch()
GPU_LOADER_QUEUE_WAIT_TIMER = "gpu_loader_queue_wait_timer"
GPU_LOADER_LOAD_TO_GPU_TIMER = "gpu_loader_load_to_gpu_timer"
LEARNER_THREAD_IN_QUEUE_WAIT_TIMER = "learner_thread_in_queue_wait_timer"
LEARNER_THREAD_ENV_STEPS_DROPPED = "learner_thread_env_steps_dropped"
LEARNER_THREAD_UPDATE_TIMER = "learner_thread_update_timer"
RAY_GET_EPISODES_TIMER = "ray_get_episodes_timer"
QUEUE_SIZE_GPU_LOADER_QUEUE = "queue_size_gpu_loader_queue"
QUEUE_SIZE_LEARNER_THREAD_QUEUE = "queue_size_learner_thread_queue"
QUEUE_SIZE_RESULTS_QUEUE = "queue_size_results_queue"
# Aggregation cycle size.
BATCHES_PER_AGGREGATION = 10
# Stop sentinel for the `_LearnerThread`
_STOP_SENTINEL = object()
class IMPALALearner(Learner):
@override(Learner)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Ray metrics
self._metrics_learner_impala_update = Histogram(
name="rllib_learner_impala_update_time",
description="Time spent in the 'IMPALALearner.update()' method.",
boundaries=DEFAULT_HISTOGRAM_BOUNDARIES_SHORT_EVENTS,
tag_keys=("rllib",),
)
self._metrics_learner_impala_update.set_default_tags(
{"rllib": self.__class__.__name__}
)
self._metrics_learner_impala_update_solve_refs = Histogram(
name="rllib_learner_impala_update_solve_refs_time",
description="Time spent on resolving refs in the 'Learner.update()'",
boundaries=DEFAULT_HISTOGRAM_BOUNDARIES_SHORT_EVENTS,
tag_keys=("rllib",),
)
self._metrics_learner_impala_update_solve_refs.set_default_tags(
{"rllib": self.__class__.__name__}
)
self._metrics_learner_impala_update_make_batch_if_necessary = Histogram(
name="rllib_learner_impala_update_make_batch_if_necessary_time",
description="Time spent on making a batch in the 'Learner.update()'.",
boundaries=DEFAULT_HISTOGRAM_BOUNDARIES_SHORT_EVENTS,
tag_keys=("rllib",),
)
self._metrics_learner_impala_update_make_batch_if_necessary.set_default_tags(
{"rllib": self.__class__.__name__}
)
self._metrics_learner_impala_get_learner_state_time = Histogram(
name="rllib_learner_impala_get_learner_state_time",
description="Time spent on get_state() in IMPALALearner.update().",
boundaries=DEFAULT_HISTOGRAM_BOUNDARIES_SHORT_EVENTS,
tag_keys=("rllib",),
)
self._metrics_learner_impala_get_learner_state_time.set_default_tags(
{"rllib": self.__class__.__name__}
)
# Set the aggregation threshold to the broadcast interval. We return
# a state at the same time the metrics are aggregated.
global BATCHES_PER_AGGREGATION
BATCHES_PER_AGGREGATION = self.config.broadcast_interval
@override(Learner)
def build(self) -> None:
super().build()
# APPO/IMPALA require RLock for thread safety around metrics.
self.metrics._threading_lock = threading.RLock()
# Aggregation signaling (replaces condition-variable contention) ---
self._agg_event = threading.Event()
self._submitted_updates = 0 # producer-side counter (update thread(s))
self._num_updates = 0 # learner-side counter
self._num_updates_lock = threading.Lock()
# Set the update kwargs passed in the main thread for use in the learner thread.
self._update_kwargs = {}
self._model_io_lock = threading.RLock()
self._learner_state_queue = Queue(maxsize=1)
self._learner_state_lock = threading.Lock()
self._learner_state = None
# Dict mapping module IDs to entropy Scheduler instances.
self.entropy_coeff_schedulers_per_module: Dict[
ModuleID, Scheduler
] = LambdaDefaultDict(
lambda module_id: Scheduler(
fixed_value_or_schedule=(
self.config.get_config_for_module(module_id).entropy_coeff
),
framework=self.framework,
device=self._device,
)
)
# Create queues as bounded queues to create real back-pressure & stabilize
# GPU memory usage.
# Small loader in-queue to keep threads busy without flooding.
# TODO (simon): Do extensive testing to find an optimal queue size.
loader_qsize = max(2, 10 * self.config.num_gpu_loader_threads)
# Note, we are passing now the timesteps dictionary through the queue.
self._gpu_loader_in_queue: "Queue[tuple[TrainingData, Dict[str, Any]]]" = Queue(
maxsize=loader_qsize
)
# Learner in-queue must be tiny. 1 strictly serializes GPU-resident batches.
# TODO (simon): Add a parameter to define queue size.
if not hasattr(self, "_learner_thread_in_queue"):
self._learner_thread_in_queue: "Queue[tuple[Any, Dict[str, Any]]]" = Queue(
maxsize=self.config.learner_queue_size
)
# Get the rank of this learner, if necessary.
self._rank: int = (
torch.distributed.get_rank() if torch.distributed.is_initialized() else 0
)
# Define the out-queue for the metrics from the `_LearnerThread`.
# TODO (simon): Add types for items.
self._learner_thread_out_queue: "Queue[Dict[str, Any]]" = Queue()
# Create and start `_GPULoaderThread`(s).
if self.config.num_gpus_per_learner > 0:
self._gpu_loader_threads: List[threading.Thread] = [
_GPULoaderThread(
in_queue=self._gpu_loader_in_queue,
out_queue=self._learner_thread_in_queue,
device=self._device,
metrics_logger=self.metrics,
)
for _ in range(self.config.num_gpu_loader_threads)
]
for t in self._gpu_loader_threads:
t.start()
# Create and start the `_LearnerThread`.
self._learner_thread: threading.Thread = _LearnerThread(
update_method=Learner.update,
in_queue=self._learner_thread_in_queue,
out_queue=self._learner_thread_out_queue,
learner=self,
)
self._learner_thread.start()
@override(Learner)
def update(
self,
training_data: TrainingData,
*,
timesteps: Dict[str, Any],
return_state: bool = False,
**kwargs,
) -> ResultDict:
"""
Args:
batch:
timesteps:
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.
**kwargs:
Returns:
"""
# Set the update kwargs passed in the main thread for use in the learner thread.
self._update_kwargs = kwargs
with TimerAndPrometheusLogger(self._metrics_learner_impala_update):
# Get the train batch from the object store.
with TimerAndPrometheusLogger(
self._metrics_learner_impala_update_solve_refs
):
# Resolve object refs and ensure we have a proper batch object.
# TODO (simon): Check, if we can resolve the object references and
# run the pipeline on the GPULoaderThreads.
training_data.solve_refs()
with TimerAndPrometheusLogger(
self._metrics_learner_impala_update_make_batch_if_necessary
):
batch = self._make_batch_if_necessary(training_data=training_data)
assert batch is not None
# Enqeue the batch (bounded backpressure).
if self.config.num_gpus_per_learner > 0:
# Pass timesteps alongside batch (no globals).
self._gpu_loader_in_queue.put((batch, timesteps))
# Only occasionally log loader queue size.
if (self._submitted_updates & 0xFF) == 0:
self.metrics.log_value(
(ALL_MODULES, QUEUE_SIZE_GPU_LOADER_QUEUE),
self._gpu_loader_in_queue.qsize(),
window=1,
)
# TODO (simon): Check, if we want to get here stats from the
# RingBuffer.
else:
# No GPU loader: directly enqueue to learner queue.
_LearnerThread.enqueue(
self._learner_thread_in_queue, (batch, timesteps), self.metrics
)
# Return the module state, if requested and available.
if return_state:
try:
with self._learner_state_lock:
self._learner_state = self._learner_state_queue.get_nowait()
except queue.Empty:
logger.debug("No learner state available in the queue yet.")
# Every 20th block call we submit results. Otherwise we keep the
# thread running without interruption to avoid thread contention.
self._submitted_updates += 1
if (self._submitted_updates % BATCHES_PER_AGGREGATION) != 0:
result = {}
if return_state and self._learner_state:
result["_rl_module_state_after_update"] = self._learner_state
return result
# Result submission: wait until learner finished BATCHES_PER_AGGREGATION updates (blocking).
self._agg_event.wait()
# Reset the aggregation event to keep the `_LearnerThread` running.
self._agg_event.clear()
if self._learner_thread_out_queue:
try:
result = self._learner_thread_out_queue.get(timeout=0.001)
except queue.Empty:
result = {}
# Return the module state, if requested and existent.
if return_state and self._learner_state:
result["_rl_module_state_after_update"] = self._learner_state
return result
@OverrideToImplementCustomLogic_CallToSuperRecommended
def before_gradient_based_update(self, *, timesteps: Dict[str, Any]) -> None:
super().before_gradient_based_update(timesteps=timesteps)
for module_id in self.module.keys():
# Update entropy coefficient via our Scheduler.
new_entropy_coeff = self.entropy_coeff_schedulers_per_module[
module_id
].update(timestep=timesteps.get(NUM_ENV_STEPS_SAMPLED_LIFETIME, 0))
self.metrics.log_value(
(module_id, LEARNER_RESULTS_CURR_ENTROPY_COEFF_KEY),
new_entropy_coeff,
window=1,
)
@override(Learner)
def remove_module(self, module_id: str):
super().remove_module(module_id)
self.entropy_coeff_schedulers_per_module.pop(module_id)
@override(Learner)
def shutdown(self) -> None:
# Stop the learner thread deterministically: setting the stop event
# and enqueuing a sentinel wakes the consumer if it's blocked on
# `_in_queue.get()`. Then `join` ensures it has fully exited before
# we return, so any subsequent `ray.shutdown()`/interpreter teardown
# can't race with the daemon thread.
thread = getattr(self, "_learner_thread", None)
if thread is not None and thread.is_alive():
thread.request_stop()
thread.join(timeout=5.0)
@classmethod
@override(Learner)
def rl_module_required_apis(cls) -> list[type]:
# In order for a PPOLearner to update an RLModule, it must implement the
# following APIs:
return [ValueFunctionAPI]
ImpalaLearner = IMPALALearner
class _GPULoaderThread(threading.Thread):
def __init__(
self,
*,
in_queue: "Queue[tuple[TrainingData, Dict[str, Any]]]",
out_queue: "Queue[tuple[Any, Dict[str, Any]]]",
device: "torch.device",
metrics_logger: MetricsLogger,
):
super().__init__(name="_GPULoaderThread")
self.daemon = True
self._in_queue = in_queue
self._out_queue = out_queue
self._device = device
self.metrics = metrics_logger
# Use a single CUDA stream for each loader thread.
self._use_cuda_stream = (
torch is not None
and hasattr(torch, "cuda")
and device is not None
and getattr(device, "type", None) == "cuda"
)
self._stream = (
torch.cuda.Stream(device=self._device) if self._use_cuda_stream else None
)
self._metrics_impala_gpu_loader_thread_step_time = Histogram(
name="rllib_learner_impala_gpu_loader_thread_step_time",
description="Time taken in seconds for gpu loader thread _step.",
boundaries=DEFAULT_HISTOGRAM_BOUNDARIES_SHORT_EVENTS,
tag_keys=("rllib",),
)
self._metrics_impala_gpu_loader_thread_step_time.set_default_tags(
{"rllib": "IMPALA/GPULoaderThread"}
)
self._metrics_impala_gpu_loader_thread_step_in_queue_get_time = Histogram(
name="rllib_learner_impala_gpu_loader_thread_step_get_time",
description="Time taken in seconds for gpu loader thread _step _in_queue.get().",
boundaries=DEFAULT_HISTOGRAM_BOUNDARIES_SHORT_EVENTS,
tag_keys=("rllib",),
)
self._metrics_impala_gpu_loader_thread_step_in_queue_get_time.set_default_tags(
{"rllib": "IMPALA/GPULoaderThread"}
)
self._metrics_impala_gpu_loader_thread_step_load_to_gpu_time = Histogram(
name="rllib_learner_impala_gpu_loader_thread_step_load_to_gpu_time",
description="Time taken in seconds for GPU loader thread _step to load batch to GPU.",
boundaries=DEFAULT_HISTOGRAM_BOUNDARIES_SHORT_EVENTS,
tag_keys=("rllib",),
)
self._metrics_impala_gpu_loader_thread_step_load_to_gpu_time.set_default_tags(
{"rllib": "IMPALA/GPULoaderThread"}
)
self._metrics_impala_gpu_loader_thread_in_qsize_beginning_of_step = Gauge(
name="rllib_impala_gpu_loader_thread_in_qsize_beginning_of_step",
description="Size of the _GPULoaderThread in-queue size, at the beginning of the step.",
tag_keys=("rllib",),
)
self._metrics_impala_gpu_loader_thread_in_qsize_beginning_of_step.set_default_tags(
{"rllib": "IMPALA/GPULoaderThread"}
)
# Robust pinned-memory copy: fall back if batch contains CUDA tensors already.
# TODO (simon): Find a more compliant solution.
def _to_device_safe(self, batch):
try:
return batch.to_device(self._device, pin_memory=True)
except RuntimeError as e:
msg = str(e)
if "only dense CPU tensors can be pinned" in msg or "pin_memory" in msg:
return batch.to_device(self._device, pin_memory=False)
raise
def run(self) -> None:
while True:
with TimerAndPrometheusLogger(
self._metrics_impala_gpu_loader_thread_step_time
):
self._step()
def _step(self) -> None:
self._metrics_impala_gpu_loader_thread_in_qsize_beginning_of_step.set(
value=self._in_queue.qsize()
)
# Get a new batch (CPU) and the global timesteps from the loader in--queue (blocking).
with self.metrics.log_time((ALL_MODULES, GPU_LOADER_QUEUE_WAIT_TIMER)):
with TimerAndPrometheusLogger(
self._metrics_impala_gpu_loader_thread_step_in_queue_get_time
):
ma_batch_on_cpu, timesteps = self._in_queue.get()
# Load the batch onto the GPU device; enable pinned memory for async copies.
with self.metrics.log_time((ALL_MODULES, GPU_LOADER_LOAD_TO_GPU_TIMER)):
if self._use_cuda_stream and self._stream is not None:
# Issue copies on a non-default stream so they can overlap with compute.
with torch.cuda.stream(self._stream):
ma_batch_on_gpu = self._to_device_safe(ma_batch_on_cpu)
# TODO (simon): Maybe use the `use_stream` in `convert_to_tensor`.
# No explicit synching here. Consumer will naturally serialize when needed.
else:
ma_batch_on_gpu = self._to_device_safe(ma_batch_on_cpu)
# Enqueue to Learner threads in-queue (GPU-resident batch and global timesteps).
_LearnerThread.enqueue(
self._out_queue, (ma_batch_on_gpu, timesteps), self.metrics
)
class _LearnerThread(threading.Thread):
def __init__(
self,
*,
update_method,
in_queue: "Queue[tuple[Any, Dict[str, Any]]]",
out_queue: "Queue[Dict[str, Any]]",
learner: IMPALALearner,
):
super().__init__(name="_LearnerThread")
self.daemon = True
self.learner = learner
self._update_method = update_method
# Note, we pass now the timesteps dictionary through the queue.
self._in_queue: "queue.Queue[tuple[Any, Dict[str, Any]]]" = in_queue
# TODO (simon): Type hints.
self._out_queue = out_queue
self._stop_event = threading.Event()
# Ray metrics
self._metrics_learner_impala_thread_step = Histogram(
name="rllib_learner_impala_learner_thread_step_time",
description="Time taken in seconds for learner thread _step.",
boundaries=DEFAULT_HISTOGRAM_BOUNDARIES_SHORT_EVENTS,
tag_keys=("rllib",),
)
self._metrics_learner_impala_thread_step.set_default_tags(
{"rllib": "IMPALA/LearnerThread"}
)
self._metrics_learner_impala_thread_step_update = Histogram(
name="rllib_learner_impala_learner_thread_step_update_time",
description="Time taken in seconds for learner thread _step update.",
boundaries=DEFAULT_HISTOGRAM_BOUNDARIES_SHORT_EVENTS,
tag_keys=("rllib",),
)
self._metrics_learner_impala_thread_step_update.set_default_tags(
{"rllib": "IMPALA/LearnerThread"}
)
# Stop cleanly at interpreter shutdown so the daemon thread doesn't
# get killed mid-call inside an auto_init-wrapped Ray API (which
# would otherwise trigger e.g. `start_reaper` -> `preexec_fn not
# supported at interpreter shutdown`). Use a weakref so this hook
# doesn't pin the thread (and therefore the Learner) alive.
weak_self = weakref.ref(self)
def _request_stop_at_exit():
t = weak_self()
if t is not None:
t.request_stop()
atexit.register(_request_stop_at_exit)
# Keeps compatibility, but thread-safe.
@property
def stopped(self) -> bool:
return self._stop_event.is_set()
# Call this to stop the thread and wake it if it's blocked on .get()
def request_stop(self) -> None:
self._stop_event.set()
# Wake the consumer if it's blocked on an empty queue
try:
self._in_queue.put_nowait(_STOP_SENTINEL)
except queue.Full:
# If the queue is full, the consumer will wake soon anyway.
logger.warning(
"_LearnerThread.request_stop(): in_queue is full; cannot enqueue stop sentinel."
)
def run(self) -> None:
while True:
# Returns always `True` until stop-signal/sentinel is sent.
if not self.step():
break
def step(self) -> bool:
# Get a batch and wait, if the input queue is empty (blocking; no polling).
with self.learner.metrics.log_time(
(ALL_MODULES, LEARNER_THREAD_IN_QUEUE_WAIT_TIMER)
):
item = self._in_queue.get()
# Handle the stop/sentinel signal(s).
# TODO (simon): Check, if we need `None` for belt-and-suspenders/comp.
if item is _STOP_SENTINEL or self.stopped:
try:
self._in_queue.task_done()
except Exception:
logger.warning(
"_LearnerThread._in_queue.task_done() failed during stop handling."
)
# Signal `run` to exit.
return False
# Extract the multi-agent batch and the timesteps dictionary.
ma_batch_on_gpu, timesteps = item
# Update the `RLModule`, but do not reduce metrics.
with self.learner.metrics.log_time((ALL_MODULES, LEARNER_THREAD_UPDATE_TIMER)):
with TimerAndPrometheusLogger(
self._metrics_learner_impala_thread_step_update
):
self._update_method(
self=self.learner,
training_data=TrainingData(batch=ma_batch_on_gpu),
timesteps=timesteps,
_no_metrics_reduce=True,
# Include the learner update kwargs set in the main thread.
**self.learner._update_kwargs,
)
# Signal queue done (unblocks producers put when bounded)
try:
self._in_queue.task_done()
finally:
# Set the Aggregation counter and signal this event (atomic).
with self.learner._num_updates_lock:
self.learner._num_updates += 1
# Check, if we need to aggregate.
do_agg = self.learner._num_updates == BATCHES_PER_AGGREGATION
if do_agg:
# Reset the update counter inside the lock.
self.learner._num_updates = 0
# If we need to aggregate, reduce metrics and queue them.
if do_agg:
# If in multi-learner setup, safeguard state retrieval within barriers.
if torch.distributed.is_initialized():
torch.distributed.barrier()
# Only the first rank retrieves the state.
if self.learner._rank == 0:
with self.learner._model_io_lock, torch.inference_mode():
learner_state = self.learner.get_state(
# Only return the state of those RLModules that are trainable.
components=[
COMPONENT_RL_MODULE + "/" + mid
for mid in self.learner.module.keys()
if self.learner.should_module_be_updated(mid)
],
inference_only=True,
)
learner_state[COMPONENT_RL_MODULE] = ray.put(
learner_state[COMPONENT_RL_MODULE]
)
try:
if (self.learner._submitted_updates & ~0xFF) != (
(self.learner._submitted_updates - BATCHES_PER_AGGREGATION)
& ~0xFF
):
with self.learner._learner_state_lock:
self.learner.metrics.log_value(
(ALL_MODULES, "learner_thread_state_queue_size"),
self.learner._learner_state_queue.qsize(),
window=1,
)
# Remove any old learner state in the queue.
self.learner._learner_state_queue.get_nowait()
except queue.Empty:
logger.debug("No old learner state to remove from the queue.")
# Pass the learner state into the queue to the main process.
self.learner._learner_state_queue.put_nowait(learner_state)
self.learner.metrics.log_value(
(ALL_MODULES, "learner_thread_out_queue_size"),
self._out_queue.qsize(),
window=1,
)
# Reduce metrics and pass them into the queue for the main process.
self._out_queue.put(self.learner.metrics.reduce())
# Notify all listeners that aggregation is done and results can be
# retrieved.
self.learner._agg_event.set()
if torch.distributed.is_initialized():
torch.distributed.barrier()
# Keep running (see `run` method).
return True
@staticmethod
def enqueue(
learner_queue: "queue.Queue[tuple[Any, Dict[str, Any]]]",
batch_with_ts,
metrics: MetricsLogger,
):
# Put the batch into the queue (blocking if thread is updating).
learner_queue.put(batch_with_ts, block=True)