Skip to content

Commit 1bd63a8

Browse files
committed
Tighten async batch collation docs
1 parent 600557a commit 1bd63a8

4 files changed

Lines changed: 35 additions & 171 deletions

File tree

skyrl/train/config/sft_config.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -203,13 +203,10 @@ def from_cli_overrides(cls, args: Union[List[str], dict]) -> "SFTConfig":
203203
num_workers: int = 8
204204
"""Number of worker processes for parallel tokenization during dataset loading. Set to 0 for single-threaded."""
205205
async_batch_collation: bool = True
206-
"""Async double-buffer the per-step collate. When True, the CPU-side collate
207-
for step N+1 runs on a single background thread while step N's
208-
forward/backward runs on the GPU, hiding the collate latency. The collate is
209-
deterministic within an epoch, so the collated-ahead batch is byte-identical
210-
to the synchronous path; at an epoch boundary (data reshuffle) the
211-
collate-ahead is skipped and the next batch is collated synchronously on the
212-
post-shuffle order. Set to False to A/B against the serial data-loading path."""
206+
"""Overlap the next step's CPU collate with the current GPU step.
207+
208+
Collate-ahead is skipped across epoch reshuffles. Set to False for the
209+
serial data-loading path."""
213210

214211
# ---- Tokenized dataset caching ----
215212
cache_dir: str = os.path.join(

skyrl/train/sft_trainer.py

Lines changed: 5 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1682,15 +1682,8 @@ def train(self):
16821682
self._fire("on_epoch_start")
16831683
epoch_in_progress = True
16841684

1685-
# ------------------------------------------------------------------
1686-
# Async batch collation (double-buffering) setup
1687-
# ------------------------------------------------------------------
1688-
# Two consecutive steps in the same epoch see the SAME ``tokenized``
1689-
# order (it only changes at an epoch boundary reshuffle), so step N+1's
1690-
# slice is knowable while step N runs. ``_slice_examples`` reproduces the
1691-
# loop's deterministic wrap-around slice against the *current* order, and
1692-
# ``_collate_batch`` is the producer run on the background thread (the
1693-
# heavy collate releases the GIL, so it overlaps the GPU step).
1685+
# Collate step N+1 on a background thread while step N runs on GPU.
1686+
# Do not collate ahead across reshuffles; the tokenized order changes.
16941687
n_examples = len(tokenized)
16951688

16961689
def _epoch_of(step: int) -> int:
@@ -1716,13 +1709,6 @@ def _collate_batch(step: int):
17161709
f"SFT async batch collation (double-buffering): {'ENABLED' if collate_ahead_enabled else 'disabled'}"
17171710
)
17181711

1719-
# Whether the step about to run can collate its successor ahead. The loop
1720-
# reshuffles ``tokenized`` after step N iff ``_epoch_of(N) > cur_epoch``
1721-
# (the same predicate the epoch-boundary block below uses). When a
1722-
# reshuffle would occur, step N+1 reads a DIFFERENT order than step N, so
1723-
# it must not be collated ahead against the pre-shuffle order. Mirroring the
1724-
# loop's reshuffle decision via ``cur_epoch`` (the authoritative loop
1725-
# state) keeps the predicate exact regardless of wrap-around alignment.
17261712
def _can_collate_ahead(step: int, cur_epoch: int) -> bool:
17271713
if async_collator is None or step + 1 > num_steps:
17281714
return False
@@ -1737,24 +1723,14 @@ def _can_collate_ahead(step: int, cur_epoch: int) -> bool:
17371723

17381724
with Timer("step", all_timings):
17391725

1740-
# Data loading with wrap-around. With async batch collation
1741-
# enabled this measures only the (ideally ~0) wait for the
1742-
# already-running background collate; otherwise the full serial collate.
1726+
# With async enabled, this is usually just the wait for an
1727+
# already-running collate; otherwise it is the full collate.
17431728
with Timer("data_loading", all_timings):
17441729
if async_collator is not None and async_collator.pending_step() == self.global_step:
1745-
# Consume the batch collated ahead during the previous step.
1746-
# ``get`` asserts the in-flight step matches, so a
1747-
# stale/mismatched batch fails loudly.
17481730
batch = async_collator.get(self.global_step)
17491731
else:
1750-
# No valid in-flight batch (first step, or the first
1751-
# step after an epoch reshuffle): collate synchronously
1752-
# against the live order.
17531732
batch = _collate_batch(self.global_step)
17541733

1755-
# Kick off the NEXT step's collate on the background thread so
1756-
# it overlaps this step's GPU work — only when the successor
1757-
# is in the same epoch (no reshuffle between them).
17581734
if _can_collate_ahead(self.global_step, current_epoch):
17591735
async_collator.submit(self.global_step + 1)
17601736

@@ -1847,12 +1823,7 @@ def _can_collate_ahead(step: int, cur_epoch: int) -> bool:
18471823
if epoch > current_epoch:
18481824
self._fire("on_epoch_end")
18491825
epoch_in_progress = False
1850-
# Drain any in-flight batch BEFORE reshuffling so a background
1851-
# collate can never read ``tokenized`` while it is being
1852-
# shuffled, and so the next epoch's first step is collated
1853-
# synchronously against the post-shuffle order. ``_can_collate_ahead``
1854-
# already withholds cross-epoch submits, so this is normally a
1855-
# no-op — it's defense in depth against the reshuffle/collate-ahead race.
1826+
# Drain before mutating tokenized order.
18561827
if async_collator is not None:
18571828
async_collator.clear()
18581829
for _ in range(epoch - current_epoch):
@@ -1865,10 +1836,6 @@ def _can_collate_ahead(step: int, cur_epoch: int) -> bool:
18651836

18661837
self.global_step += 1
18671838
finally:
1868-
# Always tear down the async batch collation thread (drains any
1869-
# in-flight batch and joins the worker) so neither the background
1870-
# thread nor the dataset reference is leaked, even on exception.
1871-
# No-op when async batch collation is disabled.
18721839
if async_collator is not None:
18731840
async_collator.shutdown()
18741841
if self._torch_profiler_enabled:

skyrl/train/utils/async_batch_collator.py

Lines changed: 7 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,4 @@
1-
"""Single-slot async double-buffer for a deterministic per-step producer.
2-
3-
When a training loop's per-step input is built by a CPU-heavy producer that is
4-
deterministic given the current loop state (e.g. a slice + collate over an
5-
in-memory dataset), the producer for step ``N+1`` can run on a background
6-
thread while step ``N``'s forward/backward runs on the GPU, collating the next
7-
batch ahead of time and hiding the producer latency under GPU compute.
8-
9-
:class:`AsyncBatchCollator` is the generic mechanism: it owns a single-worker
10-
executor and a one-slot future, and exposes a strict submit/get contract that
11-
turns any mis-wiring into a loud failure rather than a silent corruption of the
12-
input order. The caller supplies a ``compute(step) -> batch`` producer and is
13-
responsible for only ever collating ahead a step whose input is knowable in
14-
advance (i.e. no state change happens between submit and consume).
15-
"""
1+
"""Single-slot async double-buffer for deterministic per-step collation."""
162

173
from __future__ import annotations
184

@@ -21,26 +7,10 @@
217

228

239
class AsyncBatchCollator:
24-
"""Single-slot async double-buffer for a deterministic per-step producer.
25-
26-
Correctness contract:
27-
28-
* The producer is a single function ``compute(step) -> batch`` supplied by
29-
the caller; it must read the loop's *current* state. The caller only ever
30-
submits a step whose input is knowable while the current step runs — i.e.
31-
a step for which no state change happens between submit and consume. If
32-
the loop is about to mutate the producer's inputs (e.g. a dataset
33-
reshuffle at an epoch boundary), it must :meth:`clear` the in-flight batch
34-
first and collate the next step synchronously.
35-
* At most one batch is in flight (``max_workers=1``). The submitted step is
36-
recorded so :meth:`get` can assert the retrieved batch matches the step
37-
the caller expects.
38-
* The producer must construct fresh outputs per call and mutate no shared
39-
state, so the worker thread genuinely overlaps with the GPU stream
40-
(NumPy/torch release the GIL during the heavy array ops).
10+
"""Run ``compute(step)`` in a one-worker, one-future buffer.
4111
42-
This component owns only its executor and a one-slot future; it holds no
43-
reference to caller state, which keeps the threading surface tiny.
12+
The caller may only submit steps whose inputs will not change before
13+
consumption. ``get`` checks the expected step so stale batches fail loudly.
4414
"""
4515

4616
def __init__(self, compute: Callable[[int], Any], thread_name_prefix: str = "batch-collate"):
@@ -50,11 +20,7 @@ def __init__(self, compute: Callable[[int], Any], thread_name_prefix: str = "bat
5020
self._pending_step: Optional[int] = None
5121

5222
def submit(self, step: int) -> None:
53-
"""Schedule the producer for ``step`` on the background thread.
54-
55-
Must not be called while a batch is already in flight (the single-slot
56-
invariant); drain via :meth:`get` before resubmitting.
57-
"""
23+
"""Schedule ``compute(step)``; call ``get`` before submitting again."""
5824
assert self._future is None, (
5925
f"collate-ahead slot already occupied (pending step {self._pending_step}); "
6026
f"call get() before submitting step {step}"
@@ -69,12 +35,7 @@ def pending_step(self) -> Optional[int]:
6935
return self._pending_step
7036

7137
def get(self, expected_step: int) -> Any:
72-
"""Block until the in-flight batch is ready and return it.
73-
74-
Asserts the in-flight batch was produced for ``expected_step`` so a
75-
mis-wired submit/get pairing fails loudly instead of silently feeding
76-
the wrong batch into training.
77-
"""
38+
"""Return the in-flight batch for ``expected_step``."""
7839
assert self._future is not None, "get() called with no in-flight batch"
7940
assert self._pending_step == expected_step, (
8041
f"collated-ahead step {self._pending_step} != expected step {expected_step}; "
@@ -87,13 +48,7 @@ def get(self, expected_step: int) -> Any:
8748
return future.result()
8849

8950
def clear(self) -> None:
90-
"""Drop any in-flight batch.
91-
92-
Waits for the worker to finish (so it cannot still be reading caller
93-
state) and discards the result. Used before the caller mutates the
94-
producer's inputs, so a batch collated against the old state can never
95-
be consumed.
96-
"""
51+
"""Drain and discard the in-flight batch, propagating worker errors."""
9752
if self._future is not None:
9853
self._future.result()
9954
self._future = None

0 commit comments

Comments
 (0)