"""CPU unit tests for the fully-async rollout worker's queue contract. The module docstring of ``slime.rollout.fully_async_rollout`` promises that the worker's output queue "stays warm" across ``generate_rollout`` calls: each call takes ``rollout_batch_size`` completed groups and leaves the rest queued. Three behaviours are pinned here: 1. ``_generate_rollout_async`` consumes exactly ``rollout_batch_size`` groups and leaves the surplus in the queue. (It used to drain the whole queue and slice — throwing away fully generated, reward-scored groups whose prompts had already been consumed from the data buffer.) 2. The task done-callback never blocks. It runs on the event-loop thread, so a bounded queue that filled up would freeze every in-flight generation. 3. Backpressure exists anyway: ``_loop`` stops pulling new prompts while a full pool of completed groups is already waiting to be consumed. """ from __future__ import annotations import asyncio import sys import threading import time import types from collections import deque from types import SimpleNamespace # ``fully_async_rollout`` imports ``sglang_rollout``, which needs sglang_router # and (transitively) transformers — both deliberately absent from the CPU CI # env. The tests below never dial a server or touch a tokenizer, so stub the # imports, same as tests/test_agent/test_agent_rollout_cpu.py. try: import sglang_router # noqa: F401 except ImportError: _router_stub = types.ModuleType("sglang_router") _router_stub.__version__ = "0.2.3" sys.modules["sglang_router"] = _router_stub try: import transformers # noqa: F401 except ImportError: _tf_stub = types.ModuleType("transformers") for _name in ("AutoProcessor", "AutoTokenizer", "PreTrainedTokenizerBase", "ProcessorMixin"): setattr(_tf_stub, _name, type(_name, (), {})) sys.modules["transformers"] = _tf_stub import pytest import slime.rollout.fully_async_rollout as fa from slime.rollout.filter_hub.base_types import DynamicFilterOutput from slime.utils.staleness import compute_staleness_metrics, sample_staleness from slime.utils.types import Sample NUM_GPUS = 0 def test_custom_data_source_keeps_original_entrypoint(monkeypatch): args = SimpleNamespace() # A custom data source may have its own unrelated scheduler attribute. source = SimpleNamespace(scheduler=object()) expected = object() async def generate(received_args, rollout_id, received_source): assert received_args is args and received_source is source assert rollout_id == 3 return expected monkeypatch.setattr(fa, "_get_worker", lambda args, source: _make_worker(monkeypatch)) monkeypatch.setattr(fa, "_generate_rollout_async", generate) monkeypatch.setattr(fa, "run", asyncio.run) assert fa.generate_rollout_fully_async(args, 3, source) is expected class _FakeGenerateState: def __init__(self, args): self.sampling_params = {} class _FakeDataBuffer: """Finite fuel: one group per ``get_samples`` call until exhausted.""" def __init__(self, groups): self._groups = deque(groups) self.requeued = [] def get_samples(self, n): assert n == 1 if not self._groups: return [] return [self._groups.popleft()] def add_samples(self, groups): self.requeued.extend(groups) def _make_group(index: int) -> list[Sample]: sample = Sample(index=index, prompt=f"p{index}") sample.status = Sample.Status.COMPLETED return [sample] def _make_worker(monkeypatch, data_buffer=None, concurrency=4) -> fa.AsyncRolloutWorker: monkeypatch.setattr(fa, "GenerateState", _FakeGenerateState) args = SimpleNamespace(rollout_batch_size=4, n_samples_per_prompt=1) return fa.AsyncRolloutWorker(args, data_buffer or _FakeDataBuffer([]), concurrency=concurrency) @pytest.mark.unit def test_rollout_takes_target_groups_and_leaves_surplus_queued(monkeypatch): worker = _make_worker(monkeypatch) for gid in range(10): worker.output_queue.put((gid, _make_group(gid))) monkeypatch.setattr(fa, "_get_worker", lambda args, data_buffer: worker) args = SimpleNamespace( rollout_batch_size=4, rollout_data_transport="object-store", rollout_sample_filter_path=None ) out = asyncio.run(fa._generate_rollout_async(args, rollout_id=0, data_buffer=None)) assert len(out) == 4 # FIFO: the oldest four groups ship first. assert [group[0].index for group in out] == [0, 1, 2, 3] # The other six are still queued for the next rollout, not thrown away. assert worker.queue_size() == 6 assert [gid for gid, _ in worker.get_completed_groups()] == [4, 5, 6, 7, 8, 9] @pytest.mark.unit def test_get_completed_groups_limit(monkeypatch): worker = _make_worker(monkeypatch) for gid in range(5): worker.output_queue.put((gid, _make_group(gid))) assert [gid for gid, _ in worker.get_completed_groups(limit=2)] == [0, 1] assert [gid for gid, _ in worker.get_completed_groups()] == [2, 3, 4] assert worker.get_completed_groups(limit=3) == [] @pytest.mark.unit def test_concurrency_is_scaled_by_samples_per_prompt(monkeypatch): monkeypatch.setattr(fa, "GenerateState", _FakeGenerateState) args = SimpleNamespace(n_samples_per_prompt=4) assert fa.AsyncRolloutWorker(args, _FakeDataBuffer([]), concurrency=10).concurrency == 2 assert fa.AsyncRolloutWorker(args, _FakeDataBuffer([]), concurrency=2).concurrency == 1 @pytest.mark.unit def test_dynamic_filter_drops_groups_and_refills(monkeypatch): worker = _make_worker(monkeypatch) for gid in range(8): group = _make_group(gid) group[0].reward = float(gid % 2) worker.output_queue.put((gid, group)) monkeypatch.setattr(fa, "_get_worker", lambda args, data_buffer: worker) def keep_odd(args, group): return DynamicFilterOutput(keep=bool(group[0].reward), reason="even") monkeypatch.setattr(fa, "load_function", lambda path: keep_odd) args = SimpleNamespace( rollout_batch_size=3, rollout_data_transport="object-store", rollout_sample_filter_path=None, dynamic_sampling_filter_path="test.keep_odd", ) result = asyncio.run(fa._generate_rollout_async(args, rollout_id=7, data_buffer=None)) assert [group[0].index for group in result.samples] == [1, 3, 5] assert "_dropped_samples" not in result.metrics assert result.metrics["rollout/dynamic_filter/dropped_groups"] == 3 assert result.metrics["rollout/dynamic_filter/dropped_ratio"] == 0.5 assert result.metrics["rollout/dynamic_filter/drop_even"] == 3 assert [gid for gid, _ in worker.get_completed_groups()] == [6, 7] @pytest.mark.unit @pytest.mark.parametrize( "versions,expected", [(["10"], 0), (["8"], 2), (["3", "10"], 7), ([], None), (["bad"], None), (["11"], None), (["²"], None)], ) def test_sample_staleness_uses_oldest_valid_version(versions, expected): assert sample_staleness(SimpleNamespace(weight_versions=versions), 10) == expected @pytest.mark.unit def test_staleness_metrics_use_serving_snapshot(): samples = [ SimpleNamespace(weight_versions=["2"]), SimpleNamespace(weight_versions=["3"]), SimpleNamespace(weight_versions=[]), ] assert compute_staleness_metrics(samples, 10) == { "staleness/unknown_count": 1, "staleness/mean": 7.5, "staleness/max": 8, } assert compute_staleness_metrics(samples, None) == {} @pytest.mark.unit def test_done_callback_never_blocks_event_loop_thread(monkeypatch): """The callback runs on the loop thread; blocking there freezes every in-flight generation. Push more results than the old bounded-queue cap (1000) through it and require completion.""" worker = _make_worker(monkeypatch) class _DoneTask: def __init__(self, gid): self._result = _make_group(gid) def result(self): return self._result def _push_all(): for gid in range(1001): worker._make_done_cb(gid)(_DoneTask(gid)) pusher = threading.Thread(target=_push_all, daemon=True) pusher.start() pusher.join(timeout=30) assert not pusher.is_alive(), "done-callback blocked on a full output queue" assert worker.queue_size() == 1001 @pytest.mark.unit def test_loop_backpressure_stops_topping_up_when_queue_is_full(monkeypatch): """With instantly-completing generations and plenty of fuel, the queue must plateau around ``concurrency`` instead of absorbing the whole dataset.""" concurrency = 3 fuel = 60 data_buffer = _FakeDataBuffer([_make_group(i) for i in range(fuel)]) async def _instant_generate(args, group, sampling_params, evaluation): return group monkeypatch.setattr(fa, "generate_and_rm_group", _instant_generate) worker = _make_worker(monkeypatch, data_buffer=data_buffer, concurrency=concurrency) worker.poll_interval = 0.01 worker.start() try: # Give the loop ample iterations to overshoot if it is going to. deadline = time.time() + 3.0 max_seen = 0 while time.time() < deadline: max_seen = max(max_seen, worker.queue_size()) if max_seen > 2 * concurrency: break time.sleep(0.02) finally: worker.close() # In-flight tasks may still land after the gate check, so allow one pool # beyond the gate — but nothing near the unthrottled fuel size. assert 0 < max_seen <= 2 * concurrency, f"queue grew to {max_seen} with concurrency={concurrency}" @pytest.mark.parametrize("transport", ["object-store", "straw"]) def test_local_worker_lifecycle_is_scoped_to_source(monkeypatch, transport): from slime.data.data_source import DataSource monkeypatch.setattr(fa, "GenerateState", _FakeGenerateState) monkeypatch.setattr(fa, "get_rollout_num_engines", lambda args: 1) async def generate(args, group, sampling_params, evaluation): await asyncio.sleep(0.01) return group monkeypatch.setattr(fa, "generate_and_rm_group", generate) args = SimpleNamespace(n_samples_per_prompt=1, sglang_server_concurrency=2, rollout_data_transport=transport) sources = [_FakeDataBuffer([_make_group(i) for i in range(20)]) for _ in range(2)] workers = [fa._get_worker(args, source) for source in sources] try: assert workers[0] is not workers[1] deadline = time.monotonic() + 5 while not all(worker.queue_size() for worker in workers): assert time.monotonic() < deadline time.sleep(0.01) for worker in workers: assert worker.pause() is False saved = worker.state_dict() queued = worker.queue_size() time.sleep(0.03) assert worker.queue_size() == queued and not worker.active replacement = fa.AsyncRolloutWorker(args, worker.data_buffer, concurrency=2) replacement.load_state_dict(saved) assert replacement.get_completed_groups() == worker.get_completed_groups() finally: for source in sources: DataSource.close(source) assert all(not worker.worker_thread.is_alive() for worker in workers) if __name__ == "__main__": raise SystemExit(pytest.main([__file__]))