Customize Engine¶
The Engine orchestrates the reinforcement learning training loop: rollout → update dataset → train → sync weights. RLightning provides several built-in engines (see Engine), but you can customize the loop at three levels — from lightweight lifecycle hooks to full custom engine subclasses.
flowchart LR
subgraph Epoch["Each Epoch"]
direction LR
R["Rollout"] --> U["Update Dataset"] --> T["Train"] --> S["Sync Weights"]
end
PreR["_pre_rollout_hook"] -.-> R
R -.-> PostR["_post_rollout_hook"]
PreU["_pre_update_dataset_hook"] -.-> U
U -.-> PostU["_post_update_dataset_hook"]
PreT["_pre_train_hook"] -.-> T
T -.-> PostT["_post_train_hook"]
PreS["_pre_sync_weights_hook"] -.-> S
S -.-> PostS["_post_sync_weights_hook"]
This guide covers the three customization levels and shows how to wire custom engines into the training pipeline.
Customization Overview¶
Level |
Approach |
When to Use |
What You Override |
|---|---|---|---|
1 |
Lifecycle hooks |
Add logging, LR scheduling, or other side effects around existing phases |
|
2 |
Override core methods |
Change how rollout, training, or dataset update works while reusing the rest |
|
3 |
Full custom engine |
Entirely new training loop or coordination pattern |
Subclass |
Level 1: Lifecycle Hooks¶
The easiest way to customize engine behavior is to override hook methods. Each phase of the training loop is wrapped by an internal method that calls a pre-hook and post-hook around the user-facing method:
_rollout() = _pre_rollout_hook() → rollout() → _post_rollout_hook()
_update_dataset() = _pre_update_dataset_hook() → update_dataset() → _post_update_dataset_hook()
_train() = _pre_train_hook() → train() → _post_train_hook()
_sync_weights() = _pre_sync_weights_hook() → sync_weights() → _post_sync_weights_hook()
Override any hook to inject custom logic before or after a phase. The
default hook implementations handle colocated-mode offload/reload; if
you don’t need that behavior, you can skip calling super().
Available Hooks¶
Hook Method |
Default Behavior |
|---|---|
|
Reloads eval model and environments when colocated offload is enabled |
|
Offloads eval model and environments when colocated offload is enabled |
|
No-op ( |
|
No-op |
|
Reloads model parameters, gradients, and optimizer when colocated offload is enabled |
|
No-op |
|
Offloads optimizer when colocated offload is enabled |
|
Offloads model parameters and gradients when colocated offload is enabled |
Note
If you use colocated mode (cluster.enable_offload: true), call
super() in your hook override so the offload/reload logic is
preserved.
Example: Logging After Each Training Step¶
from rlightning.engine.sync_rl_engine import SyncRLEngine
from rlightning.utils.registry import ENGINE
from rlightning.utils.logger import get_logger
logger = get_logger(__name__)
@ENGINE.register("syncrl_with_logging")
class LoggingSyncRLEngine(SyncRLEngine):
"""SyncRLEngine with custom post-train logging."""
def _post_train_hook(self) -> None:
super()._post_train_hook()
logger.info(f"Epoch {self.epoch}: training step completed")
logger.info(f"Buffer size: {len(self.buffer)}")
Example: Learning Rate Scheduling¶
from rlightning.engine.sync_rl_engine import SyncRLEngine
from rlightning.utils.registry import ENGINE
from rlightning.utils.logger import get_logger
logger = get_logger(__name__)
@ENGINE.register("syncrl_lr_schedule")
class LRScheduleSyncRLEngine(SyncRLEngine):
"""SyncRLEngine with linear learning rate warmup."""
def _pre_train_hook(self) -> None:
super()._pre_train_hook()
warmup_epochs = 10
if self.epoch < warmup_epochs:
scale = (self.epoch + 1) / warmup_epochs
for policy in self.policy_group.train_list:
for param_group in policy.optimizer.param_groups:
param_group["lr"] = self.config.train.lr * scale
logger.info(f"Epoch {self.epoch}: LR warmup scale = {scale:.2f}")
Level 2: Overriding Core Methods¶
For deeper changes, subclass an existing engine and override one or more
core methods. The training loop in SyncRLEngine.run() calls the
internal wrapper methods in sequence:
def run(self) -> None:
for self.epoch in self.iter_epochs(num_epochs=self.config.train.max_epochs):
self._rollout(obj_set="train", prefix="rollout")
self._update_dataset()
self._train()
self._sync_weights()
# ... periodic evaluation and checkpointing
Override the unwrapped versions (rollout(), train(),
update_dataset()) — the internal wrappers (_rollout(),
_train(), etc.) handle profiling and hook invocation automatically.
Tip
You can also override run() itself to change the epoch structure
(e.g., add extra rollout phases, change evaluation frequency, or
implement curriculum learning).
Real-World Example: RSLRLEngine¶
RSLRLEngine extends SyncRLEngine by overriding rollout(),
update_dataset(), train(), and warm_up(). It adds
asynchronous environment stepping (step_async() /
collect_async()) and policy post-processing to the rollout loop while
keeping the same epoch structure.
Key differences from SyncRLEngine:
rollout(): Uses
step_async()/collect_async()for non-blocking environment interaction, and callspolicy_group.postprocess()after each inference step.update_dataset(): For on-policy updates, batch size depends on sampler type: if
buffer.sampler.type == "all", use full-buffer sampling; otherwise usenum_envs * num_train_policies.train(): Adds metric aggregation and logging.
warm_up(): Simplified — just syncs weights without a dummy run.
from rlightning.engine.sync_rl_engine import SyncRLEngine
from rlightning.utils.registry import ENGINE
from rlightning.utils.logger import get_logger
logger = get_logger(__name__)
@ENGINE.register("rsl")
class RSLRLEngine(SyncRLEngine):
def warm_up(self):
self.sync_weights()
logger.info("Warm up done, ready to run.")
def rollout(self, obj_set, prefix="", is_eval=False):
# Use async env stepping + policy postprocessing
...
batched_policy_resp = self.policy_group.rollout_batch(batched_env_ret)
processed = self.policy_group.postprocess(batched_env_ret, batched_policy_resp)
self.buffer.add_batched_data_async(processed)
...
def update_dataset(self):
# Adjust batch size for on-policy training
if self.config.buffer.sampler.type == "all":
batch_size = len(self.buffer)
else:
batch_size = self.num_envs * len(self.policy_group.train_list)
data = self.buffer.sample(batch_size=batch_size)
self.policy_group.update_dataset(data)
def train(self):
training_info = self.policy_group.train()
# Aggregate and log metrics
...
Example: Adding Evaluation After Each Training Step¶
from rlightning.engine.sync_rl_engine import SyncRLEngine
from rlightning.utils.registry import ENGINE
from rlightning.utils.logger import get_logger
logger = get_logger(__name__)
@ENGINE.register("syncrl_eval_every_epoch")
class EvalEverySyncRLEngine(SyncRLEngine):
"""SyncRLEngine that evaluates after every training step."""
def run(self) -> None:
for self.epoch in self.iter_epochs(num_epochs=self.config.train.max_epochs):
self._rollout(obj_set="train", prefix="rollout")
self._update_dataset()
self._train()
self._sync_weights()
# Evaluate every epoch
logger.info(f"Evaluating at epoch {self.epoch}")
self._rollout(obj_set="train", prefix="eval", is_eval=True)
if (self.epoch + 1) % self.config.train.save_interval == 0:
ckpt_path = f"{self.config.train.save_dir}/epoch_{self.epoch}.pt"
self.policy_group.save_checkpoint(path=ckpt_path)
# Final checkpoint
ckpt_path = f"{self.config.train.save_dir}/epoch_last.pt"
self.policy_group.save_checkpoint(path=ckpt_path)
logger.info("Done.")
Level 3: Custom Engine from BaseEngine¶
For completely custom training loops, subclass BaseEngine directly.
You must implement all five abstract methods:
warm_up() — initialization and optional dummy run
run() — the top-level training loop
rollout() — collect experience from environments
update_dataset() — move data from buffer to policy
train() — perform a training step
Register your engine with @ENGINE.register("name") so it can be
selected via configuration.
Note
launch() converts YAML into MainConfig and currently validates
engine against built-in literals only (syncrl, asyncrl,
rsl, async_rsl, eval). To use a custom engine name in YAML,
extend the MainConfig.engine type in your project first.
from rlightning.engine.base_engine import BaseEngine
from rlightning.utils.registry import ENGINE
from rlightning.utils.logger import get_logger, log_metric
logger = get_logger(__name__)
@ENGINE.register("curriculum")
class CurriculumEngine(BaseEngine):
"""Custom engine with curriculum-based rollout."""
def __init__(self, config, env_group=None, policy_group=None, buffer=None):
super().__init__(config, env_group, policy_group, buffer)
# Initialize components
env_meta_list = self.env_group.init()
self.policy_group.init_train(self.config.train, env_meta_list[0])
self.policy_group.init_eval(env_meta=env_meta_list[0])
env_ids = self.env_group.env_ids
self.buffer.init(env_meta_list, env_ids)
self.sync_weights()
# Curriculum: define difficulty stages
self.stages = [
{"obj_set": "easy", "epochs": 50},
{"obj_set": "medium", "epochs": 30},
{"obj_set": "hard", "epochs": 20},
]
def warm_up(self) -> None:
self.sync_weights()
logger.info("Curriculum engine warmed up.")
def rollout(self, obj_set: str) -> None:
batched_policy_resp = None
max_steps = self.config.train.max_rollout_steps
with self.env_group.auto_reset(max_episode_steps=max_steps):
for _ in range(max_steps + 1):
if batched_policy_resp is None:
batched_env_ret, truncations = self.env_group.reset(
options={"obj_set": obj_set},
)
else:
batched_env_ret, truncations = self.env_group.step(
batched_policy_resp,
)
batched_policy_resp = self.policy_group.rollout_batch(
batched_env_ret,
)
self.buffer.add_batched_transition(
batched_env_ret, batched_policy_resp, truncations,
)
env_stats = self.buffer.get_env_stats(reset=True)
log_metric(env_stats, step=self.epoch, prefix="rollout")
def update_dataset(self) -> None:
data = self.buffer.sample(batch_size=self.config.train.batch_size)
self.policy_group.update_dataset(data)
def train(self) -> None:
training_info = self.policy_group.train()
if training_info is not None:
log_metric(training_info, step=self.epoch, prefix="train")
def run(self) -> None:
global_epoch = 0
for stage in self.stages:
obj_set = stage["obj_set"]
num_epochs = stage["epochs"]
logger.info(f"Starting curriculum stage: {obj_set} ({num_epochs} epochs)")
for _ in range(num_epochs):
self.epoch = global_epoch
self._rollout(obj_set=obj_set)
self._update_dataset()
self._train()
self._sync_weights()
if (global_epoch + 1) % self.config.train.save_interval == 0:
ckpt_path = f"{self.config.train.save_dir}/epoch_{global_epoch}.pt"
self.policy_group.save_checkpoint(path=ckpt_path)
global_epoch += 1
ckpt_path = f"{self.config.train.save_dir}/epoch_last.pt"
self.policy_group.save_checkpoint(path=ckpt_path)
logger.info("Curriculum training done.")
Wiring a Custom Engine¶
To use a custom engine, configure the engine field and make sure the
module is imported at startup:
Note
If you use launch() + MainConfig, custom engine names require
extending MainConfig.engine first. Otherwise, config validation fails
before registry lookup.
imports:
- my_project.engines # Module containing @ENGINE.register(...)
engine: curriculum
train:
max_epochs: 100
max_rollout_steps: 200
batch_size: 256
The imports field ensures your module is imported before
build_engine() looks up the registry, so that the
@ENGINE.register(...) decorator has run.
Extending AsyncRLEngine¶
AsyncRLEngine runs four threads coordinated by AsyncCoordinator:
flowchart LR
subgraph Threads
R["Rollout Thread<br/>(continuous)"]
D["Dataset Thread<br/>(waits for data)"]
T["Train Thread<br/>(epoch loop)"]
W["Sync Thread<br/>(waits for train)"]
end
R -- "buffer" --> D -- "dataset ready" --> T -- "train done" --> W
W -- "weights updated" --> T
Key points for async customization:
rollout() runs in its own thread and loops until the coordinator signals stop (
coordinator.is_running()). It does not follow the epoch structure._pre_update_dataset_hook() is overridden to wait until the buffer has enough data (
buffer.size() >= batch_size)._train_loop() is the epoch driver — it waits for dataset-ready and weights-updated signals before each training step.
_sync_weights_loop() waits for the train step to finish before syncing.
Real-World Example: AsyncRSLRLEngine¶
AsyncRSLRLEngine extends AsyncRLEngine with RSL-style
customizations:
rollout(): Adds
policy_group.postprocess()after inference, same as the synchronousRSLRLEngine.train(): Adds metric logging (training info, rollout stats, performance metrics) after each training step.
warm_up(): Simplified — just syncs weights.
from rlightning.engine import AsyncRLEngine
from rlightning.utils.logger import log_metric
from rlightning.utils.registry import ENGINE
@ENGINE.register("async_rsl")
class AsyncRSLRLEngine(AsyncRLEngine):
def rollout(self, *args, **kwargs):
batched_policy_resp = None
with self.env_group.auto_reset():
while self.coordinator.is_running():
if batched_policy_resp is None:
batched_env_ret, truncations = self.env_group.reset(seed=0)
else:
self.env_group.step_async(batched_policy_resp)
batched_env_ret, truncations = self.env_group.collect_async()
batched_policy_resp = self.policy_group.rollout_batch(batched_env_ret)
self.buffer.add_batched_data_async(batched_env_ret)
# RSL-style post-processing
processed = self.policy_group.postprocess(
batched_env_ret, batched_policy_resp,
)
self.buffer.add_batched_data_async(processed, truncations)
def train(self) -> None:
training_info = self.policy_group.train()
# Log training, rollout, and performance metrics
log_metric(training_info, step=self.epoch, prefix="Train")
rollout_metrics = self.buffer.get_env_stats()
if len(rollout_metrics):
log_metric(rollout_metrics, step=self.epoch, prefix="Rollout")
Note
When customizing async engines, be mindful of thread safety. The
buffer is shared across threads, and the coordinator handles
synchronization. Avoid accessing shared state (e.g., self.epoch,
self.policy_group) from the rollout thread without proper
coordination.
Full Example¶
This end-to-end example shows a custom engine that adds per-epoch
evaluation and custom metric logging. It extends SyncRLEngine to
keep the standard rollout and training logic.
1. Custom engine (my_project/engines.py):
from rlightning.engine.sync_rl_engine import SyncRLEngine
from rlightning.utils.registry import ENGINE
from rlightning.utils.logger import get_logger, log_metric
logger = get_logger(__name__)
@ENGINE.register("custom_eval_engine")
class CustomEvalEngine(SyncRLEngine):
"""Engine with per-epoch evaluation and custom metric logging."""
def _post_train_hook(self) -> None:
super()._post_train_hook()
logger.info(f"Epoch {self.epoch}: train step done, buffer size = {len(self.buffer)}")
def run(self) -> None:
for self.epoch in self.iter_epochs(num_epochs=self.config.train.max_epochs):
# Standard training cycle
self._rollout(obj_set="train", prefix="rollout")
self._update_dataset()
self._train()
self._sync_weights()
# Per-epoch evaluation
logger.info(f"Epoch {self.epoch}: running evaluation")
self._rollout(obj_set="train", prefix="eval", is_eval=True)
# Periodic OOD evaluation and checkpointing
if (self.epoch + 1) % self.config.train.eval_interval == 0:
self._rollout(obj_set="test", prefix="eval_ood", is_eval=True)
if (self.epoch + 1) % self.config.train.save_interval == 0:
ckpt_path = f"{self.config.train.save_dir}/epoch_{self.epoch}.pt"
self.policy_group.save_checkpoint(path=ckpt_path)
# Final evaluation and checkpoint
self._rollout(obj_set="train", prefix="eval", is_eval=True)
self._rollout(obj_set="test", prefix="eval_ood", is_eval=True)
ckpt_path = f"{self.config.train.save_dir}/epoch_last.pt"
self.policy_group.save_checkpoint(path=ckpt_path)
logger.info("Done.")
2. Training script (train.py):
from pathlib import Path
from rlightning.utils.launch import launch
from rlightning.utils.config import MainConfig
from rlightning.utils.builders import (
build_engine,
build_env_group,
build_policy_group,
build_data_buffer,
)
def main(config: MainConfig):
env_group = build_env_group(config.env)
policy_group = build_policy_group(
config.policy.type, config.policy, config.cluster,
)
buffer = build_data_buffer(config.buffer.type, config.buffer)
engine = build_engine(config, env_group, policy_group, buffer)
engine.run()
if __name__ == "__main__":
launch(main_func=main, config_path=Path(__file__).parent / "conf")
3. Configuration (conf/train.yaml):
defaults:
- buffer: rollout_buffer
- env: my_env
- train: train
- policy: my_policy
- log: tensorboard
- cluster: default
- _self_
imports:
- my_project.engines
# requires MainConfig.engine to include "custom_eval_engine"
engine: custom_eval_engine
train:
max_epochs: 100
max_rollout_steps: 200
batch_size: 256
eval_interval: 10
save_interval: 50
save_dir: ./checkpoints