Skip to content

Commit fcaab5d

Browse files
committed
[train] Async batch collation (double-buffering) for the SFT trainer
# What does this PR do? The SFT training loop builds each step's batch with a CPU-side slice + collate on the main thread, which blocks the GPU. The per-step slice is fully deterministic from `(global_step, batch_size, len(tokenized))` *given the current data order*, and the order only changes at epoch boundaries (`rng.shuffle`). So step `N+1`'s batch can be collated on a background thread while step `N`'s forward/backward runs on the GPU, hiding the collate latency under GPU compute. Note this overlaps the in-memory **slice + collation** that assembles each training batch from the already-tokenized dataset; it is not disk/network data prefetching (that is a separate, orthogonal optimization). This is **default-safe and byte-identical to the serial path**: the collated-ahead batch is exactly what the synchronous loop would have produced for the same step. ## Changes - **`AsyncBatchCollator`, `skyrl/train/utils/async_batch_collator.py`** — a generic single-slot async double-buffer: a `max_workers=1` `ThreadPoolExecutor` with a one-slot future and a strict `submit`/`get` contract that asserts the in-flight step matches the step the loop expects, turning any mis-wiring into a loud failure rather than a silent training-order corruption. Teardown is exception-safe: `clear()` discards the in-flight batch (and any exception the orphaned worker raised) on a reset/teardown path so it cannot mask the caller's own unwind, and `shutdown()` always joins the executor even if draining the in-flight batch raises. - **`SFTTrainer.train()`, `skyrl/train/sft_trainer.py`** — wire the async collator into the data-loading block. - **`SFTConfig.async_batch_collation` (default `True`), `skyrl/train/config/sft_config.py`** — set `False` to A/B against the serial batch-building path. Defaulting to `True` flips the default SFT batch-building path to threaded; it is behavior-preserving (byte-identical batches; see tests) and only changes timing. ### Correctness guarantees - **Cross-epoch collate-ahead is withheld**: `_can_collate_ahead` mirrors the loop's own reshuffle predicate, so step `N+1` is never collated against the pre-shuffle order. The first step of each new epoch is collated synchronously against the post-shuffle order. - **Drain before reshuffle**: the in-flight slot is drained (`clear()`) before any reshuffle, so a worker thread can never read `tokenized` while it is being shuffled. - **No leaked thread, even on exception**: the executor is shut down in the loop's `finally` block (drains the in-flight batch + joins the worker). Teardown is double-fault-safe — if the loop body raises *and* an in-flight collation worker also raised, the original loop exception is what propagates (the worker's exception is swallowed during the reset), and the worker thread is still joined. ## Scope This is a controller-side **SFT-trainer** optimization. The other trainers do not need it: - In **synchronous RL** (`trainer.py`), the per-step training batch is built from the *just-generated* rollout (`convert_to_training_input`), so it cannot be collated ahead of generation. - The **fully async RL** trainer (`fully_async_trainer.py`) already overlaps generation with training via its `asyncio` queue. - `SFTTrainer.run_eval()` has the same collate-then-dispatch shape that `train()` optimizes here, and (since the eval slice has no wrap-around/reshuffle) would be an even cleaner `AsyncBatchCollator` fit, but it is **intentionally left serial**: eval is off by default (`eval_interval=0`) and infrequent, and the per-batch model forward dominates the small eval collate, so the payoff is marginal. It can adopt the async collator later without API changes. `AsyncBatchCollator` is written as a generic, reusable component (it holds no trainer state), so a future deterministic per-step producer can reuse it. ## Test plan - [x] `tests/train/test_async_batch_collation.py` — **7 integration cases + 5 `AsyncBatchCollator` unit tests**: - The integration cases run the real `SFTTrainer.train()` loop twice over the same dummy dataset/seed (async collation OFF vs ON) across **two epoch boundaries** and assert every `batch` handed to `train_step` is tensor-equal step-for-step — for **both** `DefaultCollator` (FSDP left-pad) and `PackedDataCollator` (Megatron FFD packing), plus an uneven wrap-around case, a mid-epoch resume case, an eval-enabled case, and a `num_epochs`-derived `num_steps` case. Examples have distinct token ids, so a stale pre-shuffle batch would diverge from the baseline and fail. (Mutation-checked: disabling either the cross-epoch withhold or the epoch-boundary drain makes the integration tests fail.) - A teardown double-fault case: a loop-body `train_step` failure coincides with an in-flight collation worker that *also* raises, asserting the original loop exception propagates (not the worker's re-raised error) and that no `sft-batch-collate` worker thread survives after `train()` returns. - `AsyncBatchCollator` invariant unit tests: step-mismatch assertion, single-slot guard, worker-exception propagation through `get()`, and `clear()` drain. - The collate path is pure Python + torch (no numpy), so the packed-collator test needs no Megatron runtime. - [x] `ruff==0.11.9` + `black==24.10.0` clean. ```bash uv run --isolated --extra dev --extra fsdp pytest tests/train/test_async_batch_collation.py -v ```
1 parent e1b995f commit fcaab5d

4 files changed

Lines changed: 814 additions & 108 deletions

File tree

skyrl/train/config/sft_config.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,15 @@ def from_cli_overrides(cls, args: Union[List[str], dict]) -> "SFTConfig":
199199
# ---- Data loading ----
200200
num_workers: int = 8
201201
"""Number of worker processes for parallel tokenization during dataset loading. Set to 0 for single-threaded."""
202+
async_batch_collation: bool = True
203+
"""Async double-buffer the per-step batch slice + collate. When True, the
204+
CPU-side slice+collate that builds step N+1's training batch runs on a single
205+
background thread while step N's forward/backward runs on the GPU, hiding the
206+
collation latency under GPU compute. The slice+collate is deterministic
207+
within an epoch, so the collated-ahead batch is byte-identical to the
208+
synchronous path; at an epoch boundary (data reshuffle) the collate-ahead is
209+
skipped and the next batch is collated synchronously on the post-shuffle
210+
order. Set to False to A/B against the serial batch-building path."""
202211

203212
# ---- Tokenized dataset caching ----
204213
cache_dir: str = os.path.join(

skyrl/train/sft_trainer.py

Lines changed: 185 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
get_response_ids_and_loss_mask_from_messages,
5656
)
5757
from skyrl.train.utils import get_ray_pg_ready_with_timeout
58+
from skyrl.train.utils.async_batch_collator import AsyncBatchCollator
5859
from skyrl.train.utils.callbacks import (
5960
CallbackHandler,
6061
CallbackInput,
@@ -1553,118 +1554,194 @@ def train(self):
15531554
self._fire("on_epoch_start")
15541555
epoch_in_progress = True
15551556

1556-
while self.global_step <= num_steps:
1557-
all_timings: dict[str, float] = {}
1558-
1559-
with Timer("step", all_timings):
1560-
1561-
# Data loading with wrap-around
1562-
with Timer("data_loading", all_timings):
1563-
start_idx = (self.global_step * batch_size) % len(tokenized)
1564-
end_idx = start_idx + batch_size
1565-
if end_idx > len(tokenized):
1566-
batch_examples = tokenized[start_idx:] + tokenized[: end_idx - len(tokenized)]
1567-
else:
1568-
batch_examples = tokenized[start_idx:end_idx]
1569-
batch = self.collator(batch_examples, batch_size=batch_size)
1570-
1571-
self._fire("on_step_start", batch=batch)
1572-
1573-
# Training step
1574-
step_result = self.train_step(batch, self.global_step)
1575-
all_timings.update(step_result["timings"])
1576-
1577-
# Compute throughput using actual (non-padding) tokens
1578-
batch_padded_seq_len = batch["sequences"].shape[1]
1579-
actual_num_tokens = batch["attention_mask"].sum().item()
1580-
self._total_tokens_processed += actual_num_tokens
1581-
tokens_per_second = actual_num_tokens / all_timings["step"]
1582-
1583-
# Build log dict
1584-
log_dict = {
1585-
"train/loss": step_result["loss"],
1586-
"train/grad_norm": step_result["grad_norm"],
1587-
"train/tokens_per_second": tokens_per_second,
1588-
"train/tokens_per_second_per_gpu": tokens_per_second / self._num_training_gpus,
1589-
"train/actual_num_tokens": actual_num_tokens,
1590-
"train/batch_padded_seq_len": batch_padded_seq_len,
1591-
"train/total_tokens_processed": self._total_tokens_processed,
1592-
}
1593-
log_dict.update({f"timing/{k}": v for k, v in all_timings.items()})
1594-
if self._ray_gpu_monitor is not None:
1595-
log_dict.update(self._ray_gpu_monitor.flush())
1596-
1597-
self._fire("on_step_end", batch=batch, metrics=step_result)
1598-
1599-
# Capture callback-driven triggers, then reset so they only fire once.
1600-
force_save = self._training_control.should_save
1601-
force_eval = self._training_control.should_evaluate
1602-
self._training_control.should_save = False
1603-
self._training_control.should_evaluate = False
1604-
1605-
# Checkpoint: interval-driven or callback-requested.
1606-
interval_save = (
1607-
self.sft_cfg.ckpt_interval > 0
1608-
and self.global_step > 0
1609-
and self.global_step % self.sft_cfg.ckpt_interval == 0
1610-
)
1611-
did_save_last_step = force_save or interval_save
1612-
if did_save_last_step:
1613-
with Timer("save_checkpoint", all_timings):
1614-
ckpt_path = self.save_checkpoint()
1615-
log_dict["timing/save_checkpoint"] = all_timings["save_checkpoint"]
1616-
self._fire("on_save", ckpt_path=ckpt_path)
1617-
1618-
# HF export at regular intervals
1619-
if self.sft_cfg.hf_save_interval > 0 and self.global_step % self.sft_cfg.hf_save_interval == 0:
1620-
with Timer("save_hf_model", all_timings):
1621-
self.save_hf_model()
1622-
log_dict["timing/save_hf_model"] = all_timings["save_hf_model"]
1623-
1624-
eval_metrics = None
1625-
num_eval_batches: int | None = None
1626-
# Eval fires at step N where N % eval_interval == 0 and N > 0, OR
1627-
# whenever a callback set ``control.should_evaluate``.
1628-
interval_eval = self.sft_cfg.eval_interval > 0 and self.global_step % self.sft_cfg.eval_interval == 0
1629-
if eval_tokenized is not None and (force_eval or interval_eval):
1630-
self._fire("on_eval_start")
1631-
with Timer("eval", all_timings):
1632-
eval_metrics, num_eval_batches = self.run_eval(eval_tokenized)
1633-
self._fire("on_eval_end", metrics=eval_metrics)
1634-
if eval_metrics:
1635-
log_dict.update({f"eval/{k}": v for k, v in eval_metrics.items()})
1636-
log_dict["timing/eval"] = all_timings["eval"]
1637-
1638-
log_dict.update({"train/epoch": current_epoch, "train/global_step": self.global_step})
1639-
# Callbacks may mutate log_dict in place via on_log.
1640-
self._fire("on_log", logs=log_dict)
1641-
self.tracker.log(log_dict, step=self.global_step, commit=True)
1557+
# ------------------------------------------------------------------
1558+
# Async batch collation (double-buffering) setup
1559+
# ------------------------------------------------------------------
1560+
# Two consecutive steps in the same epoch see the SAME ``tokenized``
1561+
# order (it only changes at an epoch boundary reshuffle), so step N+1's
1562+
# slice is knowable while step N runs. ``_slice_examples`` reproduces the
1563+
# loop's deterministic wrap-around slice against the *current* order, and
1564+
# ``_collate_batch`` is the producer run on the background thread (the
1565+
# heavy collate releases the GIL, so it overlaps the GPU step).
1566+
n_examples = len(tokenized)
1567+
1568+
def _epoch_of(step: int) -> int:
1569+
return (step * batch_size) // n_examples
1570+
1571+
def _slice_examples(step: int) -> list:
1572+
start_idx = (step * batch_size) % n_examples
1573+
end_idx = start_idx + batch_size
1574+
if end_idx > n_examples:
1575+
return tokenized[start_idx:] + tokenized[: end_idx - n_examples]
1576+
return tokenized[start_idx:end_idx]
1577+
1578+
def _collate_batch(step: int) -> TrainingInputBatch:
1579+
return self.collator(_slice_examples(step), batch_size=batch_size)
1580+
1581+
collate_ahead_enabled = self.sft_cfg.async_batch_collation
1582+
async_collator: Optional[AsyncBatchCollator] = (
1583+
AsyncBatchCollator(_collate_batch, thread_name_prefix="sft-batch-collate")
1584+
if collate_ahead_enabled
1585+
else None
1586+
)
1587+
collation_state = "ENABLED" if collate_ahead_enabled else "disabled"
1588+
logger.info(f"SFT async batch collation (double-buffering): {collation_state}")
1589+
1590+
# Whether the step about to run can collate its successor ahead. The loop
1591+
# reshuffles ``tokenized`` after step N iff ``_epoch_of(N) > cur_epoch``
1592+
# (the same predicate the epoch-boundary block below uses). When a
1593+
# reshuffle would occur, step N+1 reads a DIFFERENT order than step N, so
1594+
# it must not be collated ahead against the pre-shuffle order. Mirroring the
1595+
# loop's reshuffle decision via ``cur_epoch`` (the authoritative loop
1596+
# state) keeps the predicate exact regardless of wrap-around alignment.
1597+
def _can_collate_ahead(step: int, cur_epoch: int) -> bool:
1598+
if async_collator is None or step + 1 > num_steps:
1599+
return False
1600+
reshuffle_after_step = _epoch_of(step) > cur_epoch
1601+
return not reshuffle_after_step
16421602

1643-
if self.global_step % 5 == 0:
1644-
logger.info(
1645-
f"Step {self.global_step}: loss={step_result['loss']:.4f}, " f"grad_norm={step_result['grad_norm']}"
1603+
try:
1604+
while self.global_step <= num_steps:
1605+
all_timings: dict[str, float] = {}
1606+
1607+
with Timer("step", all_timings):
1608+
1609+
# Data loading with wrap-around. With async batch collation
1610+
# enabled this measures only the (ideally ~0) wait for the
1611+
# already-running background collate; otherwise the full serial collate.
1612+
with Timer("data_loading", all_timings):
1613+
if async_collator is not None and async_collator.pending_step() == self.global_step:
1614+
# Consume the batch collated ahead during the previous step.
1615+
# ``get`` asserts the in-flight step matches, so a
1616+
# stale/mismatched batch fails loudly.
1617+
batch = async_collator.get(self.global_step)
1618+
else:
1619+
# No valid in-flight batch (first step, or the first
1620+
# step after an epoch reshuffle): collate synchronously
1621+
# against the live order.
1622+
batch = _collate_batch(self.global_step)
1623+
1624+
# Kick off the NEXT step's collate on the background thread so
1625+
# it overlaps this step's GPU work — only when the successor
1626+
# is in the same epoch (no reshuffle between them).
1627+
if _can_collate_ahead(self.global_step, current_epoch):
1628+
async_collator.submit(self.global_step + 1)
1629+
1630+
self._fire("on_step_start", batch=batch)
1631+
1632+
# Training step
1633+
step_result = self.train_step(batch, self.global_step)
1634+
all_timings.update(step_result["timings"])
1635+
1636+
# Compute throughput using actual (non-padding) tokens
1637+
batch_padded_seq_len = batch["sequences"].shape[1]
1638+
actual_num_tokens = batch["attention_mask"].sum().item()
1639+
self._total_tokens_processed += actual_num_tokens
1640+
tokens_per_second = actual_num_tokens / all_timings["step"]
1641+
1642+
# Build log dict
1643+
log_dict = {
1644+
"train/loss": step_result["loss"],
1645+
"train/grad_norm": step_result["grad_norm"],
1646+
"train/tokens_per_second": tokens_per_second,
1647+
"train/tokens_per_second_per_gpu": tokens_per_second / self._num_training_gpus,
1648+
"train/actual_num_tokens": actual_num_tokens,
1649+
"train/batch_padded_seq_len": batch_padded_seq_len,
1650+
"train/total_tokens_processed": self._total_tokens_processed,
1651+
}
1652+
log_dict.update({f"timing/{k}": v for k, v in all_timings.items()})
1653+
if self._ray_gpu_monitor is not None:
1654+
log_dict.update(self._ray_gpu_monitor.flush())
1655+
1656+
self._fire("on_step_end", batch=batch, metrics=step_result)
1657+
1658+
# Capture callback-driven triggers, then reset so they only fire once.
1659+
force_save = self._training_control.should_save
1660+
force_eval = self._training_control.should_evaluate
1661+
self._training_control.should_save = False
1662+
self._training_control.should_evaluate = False
1663+
1664+
# Checkpoint: interval-driven or callback-requested.
1665+
interval_save = (
1666+
self.sft_cfg.ckpt_interval > 0
1667+
and self.global_step > 0
1668+
and self.global_step % self.sft_cfg.ckpt_interval == 0
16461669
)
1670+
did_save_last_step = force_save or interval_save
1671+
if did_save_last_step:
1672+
with Timer("save_checkpoint", all_timings):
1673+
ckpt_path = self.save_checkpoint()
1674+
log_dict["timing/save_checkpoint"] = all_timings["save_checkpoint"]
1675+
self._fire("on_save", ckpt_path=ckpt_path)
1676+
1677+
# HF export at regular intervals
1678+
if self.sft_cfg.hf_save_interval > 0 and self.global_step % self.sft_cfg.hf_save_interval == 0:
1679+
with Timer("save_hf_model", all_timings):
1680+
self.save_hf_model()
1681+
log_dict["timing/save_hf_model"] = all_timings["save_hf_model"]
1682+
1683+
eval_metrics = None
1684+
num_eval_batches: int | None = None
1685+
# Eval fires at step N where N % eval_interval == 0 and N > 0, OR
1686+
# whenever a callback set ``control.should_evaluate``.
1687+
interval_eval = self.sft_cfg.eval_interval > 0 and self.global_step % self.sft_cfg.eval_interval == 0
1688+
if eval_tokenized is not None and (force_eval or interval_eval):
1689+
self._fire("on_eval_start")
1690+
with Timer("eval", all_timings):
1691+
eval_metrics, num_eval_batches = self.run_eval(eval_tokenized)
1692+
self._fire("on_eval_end", metrics=eval_metrics)
1693+
if eval_metrics:
1694+
log_dict.update({f"eval/{k}": v for k, v in eval_metrics.items()})
1695+
log_dict["timing/eval"] = all_timings["eval"]
1696+
1697+
log_dict.update({"train/epoch": current_epoch, "train/global_step": self.global_step})
1698+
# Callbacks may mutate log_dict in place via on_log.
1699+
self._fire("on_log", logs=log_dict)
1700+
self.tracker.log(log_dict, step=self.global_step, commit=True)
1701+
1702+
if self.global_step % 5 == 0:
1703+
logger.info(
1704+
f"Step {self.global_step}: loss={step_result['loss']:.4f}, "
1705+
f"grad_norm={step_result['grad_norm']}"
1706+
)
16471707

1648-
if eval_metrics:
1649-
logger.info(
1650-
f"Step {self.global_step}: eval_loss={eval_metrics.get('eval_loss', float('nan')):.4f} "
1651-
f"over {num_eval_batches} batches"
1652-
)
1708+
if eval_metrics:
1709+
logger.info(
1710+
f"Step {self.global_step}: eval_loss={eval_metrics.get('eval_loss', float('nan')):.4f} "
1711+
f"over {num_eval_batches} batches"
1712+
)
16531713

1654-
# Check for epoch boundary and reshuffle
1655-
epoch = (self.global_step * batch_size) // len(tokenized)
1656-
if epoch > current_epoch:
1657-
self._fire("on_epoch_end")
1658-
epoch_in_progress = False
1659-
for _ in range(epoch - current_epoch):
1660-
rng.shuffle(tokenized)
1661-
current_epoch = epoch
1662-
self._current_epoch = epoch
1663-
if self.global_step + 1 <= num_steps:
1664-
self._fire("on_epoch_start")
1665-
epoch_in_progress = True
1666-
1667-
self.global_step += 1
1714+
# Check for epoch boundary and reshuffle. Uses the single-sourced
1715+
# ``_epoch_of`` predicate (``tokenized`` is shuffled in place so
1716+
# ``len(tokenized) == n_examples`` is invariant).
1717+
epoch = _epoch_of(self.global_step)
1718+
if epoch > current_epoch:
1719+
self._fire("on_epoch_end")
1720+
epoch_in_progress = False
1721+
# Drain any in-flight batch BEFORE reshuffling so a background
1722+
# collate can never read ``tokenized`` while it is being
1723+
# shuffled, and so the next epoch's first step is collated
1724+
# synchronously against the post-shuffle order. ``_can_collate_ahead``
1725+
# already withholds cross-epoch submits, so this is normally a
1726+
# no-op — it's defense in depth against the reshuffle/collate-ahead race.
1727+
if async_collator is not None:
1728+
async_collator.clear()
1729+
for _ in range(epoch - current_epoch):
1730+
rng.shuffle(tokenized)
1731+
current_epoch = epoch
1732+
self._current_epoch = epoch
1733+
if self.global_step + 1 <= num_steps:
1734+
self._fire("on_epoch_start")
1735+
epoch_in_progress = True
1736+
1737+
self.global_step += 1
1738+
finally:
1739+
# Always tear down the async batch collation thread (drains any
1740+
# in-flight batch and joins the worker) so neither the background
1741+
# thread nor the dataset reference is leaked, even on exception.
1742+
# No-op when async batch collation is disabled.
1743+
if async_collator is not None:
1744+
async_collator.shutdown()
16681745
self.global_step = min(self.global_step, num_steps)
16691746

16701747
# Pair the leading on_epoch_start: fire on_epoch_end if we exited the

0 commit comments

Comments
 (0)