Skip to content

Commit 3c67a49

Browse files
committed
Hold only a run that trained every rollout to the run-end store check
The run-end sweep failed every synchronous --ci-test run that left an object, and raised on a failed sweep in any run. A run that stopped before training a rollout still holds that rollout's objects, so a crash ended in the sweep's AssertionError, with the real error only as its context. A --partial-rollout run ends with aborted groups back in the data buffer, so a correct run failed. And a Mooncake failure at teardown turned a finished non-CI run into a failed one, though a failed delete only logs. get() now files every rollout until its release, so dispose can tell a run that stopped early. The sweep holds a CI run to leaving nothing, and to a working sweep, only when no rollout is left unreleased; a partial-rollout run only logs what it left, like a fully-async one; outside CI a failed sweep only logs. A dispose test checks the sweep runs once, after the rest of teardown.
1 parent 1a9ce70 commit 3c67a49

5 files changed

Lines changed: 115 additions & 15 deletions

File tree

‎docs/advanced/mooncake-rollout-transfer.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -287,10 +287,11 @@ Requirements:
287287

288288
Known defect: an object stored by a request attempt that was then retried, or whose delete
289289
failed (Miles logs it), leaks and stays until the run ends. When the run ends, Miles removes
290-
every object left under the run's key prefix and logs how many keys it removed; under
291-
`--ci-test`, a synchronous run that left any fails. A fully-async run ends with requests in
292-
flight, and an engine can still store an object for one after that. A periodic sweep during the
293-
run is planned (TODO).
290+
every object left under the run's key prefix and logs how many keys it removed, or that the
291+
removal failed. Under `--ci-test`, a run that trained every rollout it generated fails if the
292+
removal failed or found any object, unless it is fully async or uses `--partial-rollout`: those
293+
end with groups still buffered. A fully-async run ends with requests in flight, and an engine
294+
can still store an object for one after that. A periodic sweep during the run is planned (TODO).
294295

295296
Rejected at launch: no replay array enabled, `--cluster-backend kubernetes`, PD
296297
disaggregation, external rollout engines, the legacy rollout, a custom convert function,

‎miles/ray/rollout/rollout_executor.py‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ def __init__(
9797
self._inference_controller_provider = inference_controller_provider
9898
self._output_snapshotter = _RolloutExecutorOutputSnapshotter(args=args)
9999
# Output store refs of each rollout's trained and dropped samples, deleted once it has trained.
100+
# Every rollout get() began is here until its release, so one left at dispose was never trained.
100101
self._unreleased_store_refs: dict[int, dict[str, Any]] = {}
101102
self._delete_store_refs = None
102103

@@ -189,11 +190,23 @@ async def dispose(self) -> None:
189190

190191
def _sweep_output_store(self) -> None:
191192
"""Remove what the run left in the output store, which nothing reads once it ends."""
192-
if not (left := sweep_output_store(self.args)):
193+
# Only a run that trained every rollout it generated answers to CI: one that stopped early (a crash, a debug
194+
# exit) still holds that rollout's objects, and a crash already has its own error.
195+
strict = self.args.ci_test and not self._unreleased_store_refs
196+
try:
197+
left = sweep_output_store(self.args)
198+
except Exception:
199+
if strict:
200+
raise
201+
# Like a failed delete, a failed sweep only leaks objects nothing reads.
202+
logger.exception("Failed to sweep the output store; the run's objects stay in Mooncake")
203+
return
204+
if not left:
193205
return
194206
message = f"Swept {left} output store keys the run left behind"
195-
# A fully-async run ends with groups buffered or in flight; a synchronous one has released every rollout.
196-
if self.args.ci_test and not self.args.fully_async:
207+
# A fully-async run ends with groups buffered or in flight, a partial rollout with aborted groups back in
208+
# the data buffer; any other run that trained every rollout it generated has released them all.
209+
if strict and not (self.args.fully_async or self.args.partial_rollout):
197210
raise AssertionError(f"{message}: a retried request, a failed delete or a missed release")
198211
logger.warning(message)
199212

@@ -202,6 +215,7 @@ def _sweep_output_store(self) -> None:
202215
@event_logger_context(lambda _self, rollout_id, trainer_model_id=None: dict(rollout_id=rollout_id))
203216
async def get(self, rollout_id: int, trainer_model_id: str | None = None) -> RolloutDataPack:
204217
self.last_get_rollout_id_of_model_id[trainer_model_id] = rollout_id
218+
self._unreleased_store_refs[rollout_id] = {}
205219
event_analyzer.run_sample_ownership_analysis(args=self.args)
206220
replay = self._output_snapshotter.get(trainer_model_id=trainer_model_id, rollout_id=rollout_id)
207221
if replay is None:
@@ -216,8 +230,7 @@ async def get(self, rollout_id: int, trainer_model_id: str | None = None) -> Rol
216230
else:
217231
data, metadata = replay.data, replay.metadata
218232
# The dp schedule and a custom convert may leave rows out of the train data: collect from the samples.
219-
if store_refs := store_refs_of(data) | take_dropped_refs():
220-
self._unreleased_store_refs[rollout_id] = store_refs
233+
self._unreleased_store_refs[rollout_id] = store_refs_of(data) | take_dropped_refs()
221234

222235
with SampleOwnershipRecorder.suppress_drop_logging() if replay is not None else nullcontext():
223236
train_data = convert_samples_to_train_data(

‎tests/fast/ray/rollout/test_rollout_executor.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,33 @@ async def test_synchronous_train_and_eval_rollout_disposers_are_accepted(
174174

175175
assert disposed == ["train", "eval"]
176176

177+
async def test_a_run_with_the_output_store_sweeps_it_once_after_the_rest_of_dispose(
178+
self, monkeypatch: pytest.MonkeyPatch
179+
) -> None:
180+
"""Last, so the producers have stopped writing and a failed CI check raises after the other teardown."""
181+
disposed: list[str] = []
182+
executor = RolloutExecutor.__new__(RolloutExecutor)
183+
executor._disposed = False
184+
executor.use_legacy_rollout_v1 = False
185+
executor.generate_rollout = _SynchronousDisposable(disposed, "train")
186+
executor.eval_generate_rollout = _SynchronousDisposable(disposed, "eval")
187+
executor.data_source = object()
188+
executor.args = make_args(ci_test=True, fully_async=False, partial_rollout=False)
189+
executor._metric_checker = None
190+
executor._delete_store_refs = lambda refs: None
191+
executor._unreleased_store_refs = {}
192+
monkeypatch.setattr(rollout_executor_module, "CheckpointEvalFn", _SynchronousDisposable)
193+
monkeypatch.setattr(rollout_executor_module.event_analyzer, "run_analysis_from_args", lambda _args: None)
194+
monkeypatch.setattr(
195+
rollout_executor_module.event_analyzer, "run_sample_ownership_analysis", lambda *, args: None
196+
)
197+
monkeypatch.setattr(rollout_executor_module, "sweep_output_store", lambda args: disposed.append("sweep") or 0)
198+
199+
await executor.dispose()
200+
await executor.dispose()
201+
202+
assert disposed == ["train", "eval", "sweep"]
203+
177204
async def test_the_final_ownership_check_runs_before_the_run_ends(self, monkeypatch: pytest.MonkeyPatch) -> None:
178205
"""Violations that only mature after the last get would otherwise never be checked."""
179206
checked: list[Namespace] = []
@@ -320,6 +347,7 @@ def _make_executor(tmp_path: Path, rollout_fn: _CountingRolloutFn) -> RolloutExe
320347
executor._train_parallel_configs_of_model_id = {None: make_train_parallel_config()}
321348
executor._weight_versions_of_model_id = {}
322349
executor.last_get_rollout_id_of_model_id = {}
350+
executor._unreleased_store_refs = {}
323351
executor.custom_convert_samples_to_train_data_func = None
324352
executor.custom_reward_post_process_func = None
325353
executor._output_snapshotter = _RolloutExecutorOutputSnapshotter(args=executor.args)

‎tests/fast/ray/rollout/test_rollout_executor_multi_policy.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ def _make_executor() -> RolloutExecutor:
6969
executor._train_parallel_configs_of_model_id = {}
7070
executor._rollouts_since_publish_of_model_id = defaultdict(int)
7171
executor.last_get_rollout_id_of_model_id = {}
72+
executor._unreleased_store_refs = {}
7273
executor._eval_lock = asyncio.Lock()
7374
executor._eval_fleet = None
7475
executor._metric_checker = None

‎tests/fast/ray/rollout/test_rollout_executor_store_refs.py‎

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,14 +110,23 @@ async def test_refs_without_a_store_client_fail_loudly():
110110
await executor.release(5)
111111

112112

113+
def run_end_args(*, ci_test=True, fully_async=False, partial_rollout=False):
114+
return make_args(ci_test=ci_test, fully_async=fully_async, partial_rollout=partial_rollout)
115+
116+
113117
@pytest.mark.parametrize(
114-
("ci_test", "fully_async", "fails"),
115-
[(True, False, True), (True, True, False), (False, False, False)],
118+
("args", "fails"),
119+
[
120+
(run_end_args(), True),
121+
(run_end_args(fully_async=True), False),
122+
(run_end_args(partial_rollout=True), False),
123+
(run_end_args(ci_test=False), False),
124+
],
116125
)
117-
def test_keys_left_at_the_end_fail_only_a_synchronous_ci_run(monkeypatch, caplog, ci_test, fully_async, fails):
118-
"""A fully-async run ends with groups buffered or in flight, so only a synchronous one must leave none."""
126+
def test_keys_left_at_the_end_fail_only_a_synchronous_ci_run(monkeypatch, caplog, args, fails):
127+
"""A fully-async run ends with groups buffered or in flight, a partial rollout with aborted groups in its buffer."""
119128
executor, _ = make_executor({})
120-
executor.args = make_args(ci_test=ci_test, fully_async=fully_async)
129+
executor.args = args
121130
monkeypatch.setattr(rollout_executor_module, "sweep_output_store", lambda args: 4)
122131

123132
if fails:
@@ -130,9 +139,57 @@ def test_keys_left_at_the_end_fail_only_a_synchronous_ci_run(monkeypatch, caplog
130139

131140
def test_a_run_that_left_nothing_logs_nothing(monkeypatch, caplog):
132141
executor, _ = make_executor({})
133-
executor.args = make_args(ci_test=True, fully_async=False)
142+
executor.args = run_end_args()
134143
monkeypatch.setattr(rollout_executor_module, "sweep_output_store", lambda args: 0)
135144

136145
executor._sweep_output_store()
137146

138147
assert "Swept" not in caplog.text
148+
149+
150+
def _fail_the_generation(rollout_id):
151+
raise RuntimeError("engine died")
152+
153+
154+
@pytest.mark.parametrize("stopped_in", ["train", "generation"])
155+
async def test_a_run_that_stopped_before_training_a_rollout_only_logs_what_it_left(monkeypatch, caplog, stopped_in):
156+
"""Its error is the crash itself; the untrained rollout's objects are expected, not a leak."""
157+
if stopped_in == "train":
158+
executor, _ = make_executor({5: [sample_owning("a")]})
159+
await executor.get(rollout_id=5)
160+
else:
161+
executor, _ = make_executor({5: [sample_owning("a")]}, during_get=_fail_the_generation)
162+
with pytest.raises(RuntimeError, match="engine died"):
163+
await executor.get(rollout_id=5)
164+
executor.args = run_end_args()
165+
monkeypatch.setattr(rollout_executor_module, "sweep_output_store", lambda args: 4)
166+
167+
executor._sweep_output_store()
168+
169+
assert "Swept 4 output store keys" in caplog.text
170+
171+
172+
def _fail_the_sweep(args):
173+
raise RuntimeError("Mooncake store setup failed: -1")
174+
175+
176+
@pytest.mark.parametrize(
177+
("args", "stopped_early", "fails"),
178+
[(run_end_args(), False, True), (run_end_args(ci_test=False), False, False), (run_end_args(), True, False)],
179+
)
180+
async def test_a_failed_sweep_fails_only_a_ci_run_that_trained_every_rollout(
181+
monkeypatch, caplog, args, stopped_early, fails
182+
):
183+
"""Like a failed delete, a failed sweep only leaks objects nothing reads; CI still wants to see it."""
184+
executor, _ = make_executor({5: [sample_owning("a")]})
185+
if stopped_early:
186+
await executor.get(rollout_id=5)
187+
executor.args = args
188+
monkeypatch.setattr(rollout_executor_module, "sweep_output_store", _fail_the_sweep)
189+
190+
if fails:
191+
with pytest.raises(RuntimeError, match="setup failed"):
192+
executor._sweep_output_store()
193+
else:
194+
executor._sweep_output_store()
195+
assert "Failed to sweep the output store" in caplog.text

0 commit comments

Comments
 (0)