Skip to content

Commit 58dfc1c

Browse files
[tinker] Continuous sampling: per-request future completion under colocate_all
The engine's serial loop batched all pending sample requests and completed every future only when the whole batch finished, so with ~64 lockstep agentic rollouts each wave's turn latency was max(generation in wave): a single 200s turn stalled 63 finished agents for its full tail. The batch semantics existed only to serialize sampling against ops that sleep the colocated engines. Keep that safety property with admission control instead of serialization: - _ContinuousSampler: daemon thread + asyncio loop; each admitted sample runs as its own coroutine (backend.sample_batch_async -> per-request vLLM HTTP; vLLM continuous-batches internally) and its future is written the moment its own generation completes. - Engine loop admits pending samples whenever no blocking work exists; engines are brought up/woken on the main thread first (backend.prepare_for_sampling). Blocking work (forward/forward_backward, single requests, stale-session model unloads) drains in-flight samples first -- generations keep decoding while the blocking op waits, then run with the engines quiesced exactly as before. Deferred samples stay PENDING and are admitted next iteration, never as a convoy batch. - Admission runs _filter_valid_requests first (serial-path parity): stale sample requests left over from a previous server fail with "model not loaded" instead of driving prepare_for_sampling into an engine build with no model registered. - No-op session-cleanup sweeps don't drain (only sweeps that actually unload models do), so the periodic reaper can't reintroduce a stall. - backend: prepare_for_sampling + sample_batch_async (validation shared with the serial path; the async core reuses the loop-aware HTTP client, keeping the session open on the sampler's persistent loop). RemoteInferenceClient._get_session closes the stale loop's connector synchronously on loop handoff so sockets release immediately. - SKYRL_TINKER_CONTINUOUS_SAMPLING=0 restores the serial loop; backends without the async surface (jax) keep it automatically. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8b0a909 commit 58dfc1c

4 files changed

Lines changed: 497 additions & 43 deletions

File tree

skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,19 @@ async def _get_session(self) -> aiohttp.ClientSession:
289289
# aiohttp.ClientSession is tied to the event loop.
290290
current_loop = asyncio.get_running_loop()
291291
if self._session is not None and not self._session.closed and self._session.loop != current_loop:
292-
# Event loop changed - the old session is unusable (bound to a dead loop).
292+
# Event loop changed - the old session is unusable (bound to another,
293+
# possibly dead, loop) and cannot be aclose()'d from here. Close its
294+
# connector synchronously (sync API in aiohttp 3.x) so the sockets are
295+
# released now and GC doesn't log "Unclosed client session" errors —
296+
# this handoff happens routinely between the engine main thread's
297+
# transient loops (wake/sleep/adapter loads) and the continuous
298+
# sampler's persistent loop.
299+
old_connector = getattr(self._session, "_connector", None)
300+
try:
301+
if old_connector is not None:
302+
old_connector.close()
303+
except Exception:
304+
pass
293305
self._session = None
294306
if self._session is None or self._session.closed:
295307
# keepalive_timeout must be shorter than the server's timeout_keep_alive

skyrl/backends/skyrl_train_backend.py

Lines changed: 67 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,16 +1049,38 @@ def sample(
10491049
have been updated.
10501050
"""
10511051
# 1. Ensure inference engines are initialized and awake
1052+
self.prepare_for_sampling()
1053+
1054+
# 2. Validate model ids, 3. dispatch to the sampling path.
1055+
errors = self._validate_sample_models(prepared_batch)
1056+
if errors is not None:
1057+
return errors
1058+
return self._sample_with_remote_client(prepared_batch)
1059+
1060+
def prepare_for_sampling(self) -> None:
1061+
"""Ensure inference engines exist and are awake for sampling.
1062+
1063+
Idempotent and cheap when the engines are already up and awake. The
1064+
Tinker engine's continuous sampler calls this from its main loop
1065+
before admitting sample requests to the background executor, so the
1066+
executor's coroutines never have to touch engine lifecycle (which is
1067+
not thread-safe) — they only issue data-plane HTTP calls.
1068+
"""
10521069
self._ensure_inference_engines()
10531070
self._wake_inference_engines_for_sampling()
10541071

1055-
# 2. Validate every model_id in the batch is a known policy. Multi-LoRA
1056-
# mixes adapters in one batched sample call (the engine batches across
1057-
# model_ids in find_batchable_sample); we route each request via the
1058-
# `model` field in _sample_with_remote_client below. An empty model_id
1059-
# is base-model sampling (create_sampling_client(base_model=...): the
1060-
# API maps it to model_id "") and must not be treated as unknown --
1061-
# _sample_with_remote_client routes it to the served base model name.
1072+
def _validate_sample_models(
1073+
self, prepared_batch: types.PreparedSampleBatch
1074+
) -> dict[str, types.ErrorResponse] | None:
1075+
"""Validate every model_id in the batch is a known policy; None if OK.
1076+
1077+
Multi-LoRA mixes adapters in one batched sample call (the engine
1078+
batches across model_ids in find_batchable_sample); we route each
1079+
request via the `model` field in _sample_with_remote_client. An empty
1080+
model_id is base-model sampling (create_sampling_client(base_model=...):
1081+
the API maps it to model_id "") and must not be treated as unknown --
1082+
_sample_with_remote_client routes it to the served base model name.
1083+
"""
10621084
unique_models = set(prepared_batch.all_model_ids)
10631085
unknown = [mid for mid in unique_models if mid and mid not in self._model_ids_to_role]
10641086
if unknown:
@@ -1073,15 +1095,45 @@ def sample(
10731095
status="error",
10741096
)
10751097
return {req_id: error for req_id, *_ in prepared_batch.request_batch_slices}
1098+
return None
10761099

1077-
# 3. Dispatch to the sampling path
1078-
return self._sample_with_remote_client(prepared_batch)
1100+
async def sample_batch_async(
1101+
self, prepared_batch: types.PreparedSampleBatch
1102+
) -> dict[str, types.SampleOutput | types.ErrorResponse]:
1103+
"""Awaitable sample path for the engine's continuous sampler.
1104+
1105+
The caller (engine main loop) must have called prepare_for_sampling()
1106+
before scheduling this, and must not sleep the engines, swap adapters,
1107+
or tear down the runtime while calls are in flight (the engine drains
1108+
the sampler before such ops). This coroutine runs on the sampler
1109+
thread's event loop and touches only read-only backend state plus the
1110+
data-plane HTTP client, which recreates its session/semaphores per
1111+
event loop.
1112+
"""
1113+
errors = self._validate_sample_models(prepared_batch)
1114+
if errors is not None:
1115+
return errors
1116+
return await self._sample_with_remote_client_async(prepared_batch, close_client=False)
10791117

10801118
def _sample_with_remote_client(
10811119
self,
10821120
prepared_batch: types.PreparedSampleBatch,
10831121
) -> dict[str, types.SampleOutput | types.ErrorResponse]:
1084-
"""Sample using RemoteInferenceClient, forwarding model input chunks directly."""
1122+
"""Sync wrapper over the async sampling core (serial engine-loop path)."""
1123+
return asyncio.run(self._sample_with_remote_client_async(prepared_batch, close_client=True))
1124+
1125+
async def _sample_with_remote_client_async(
1126+
self,
1127+
prepared_batch: types.PreparedSampleBatch,
1128+
close_client: bool,
1129+
) -> dict[str, types.SampleOutput | types.ErrorResponse]:
1130+
"""Sample using RemoteInferenceClient, forwarding model input chunks directly.
1131+
1132+
``close_client`` closes the HTTP session when done: the serial path
1133+
runs each batch on a throwaway event loop (asyncio.run), so its
1134+
session must not outlive the loop. The continuous sampler runs on a
1135+
persistent loop and reuses the session across requests instead.
1136+
"""
10851137

10861138
# Resolve the inference-engine model name per request. With multi-LoRA
10871139
# the adapter name on vLLM IS the Tinker model_id (registered by
@@ -1141,10 +1193,11 @@ async def sample_all():
11411193
try:
11421194
return await asyncio.gather(*tasks, return_exceptions=True)
11431195
finally:
1144-
await self._inference_engine_client.aclose()
1196+
if close_client:
1197+
await self._inference_engine_client.aclose()
11451198

1146-
sample_outputs = asyncio.run(sample_all())
1147-
logger.info(f"Collected {len(sample_outputs)} sample outputs")
1199+
sample_outputs = await sample_all()
1200+
logger.debug(f"Collected {len(sample_outputs)} sample outputs")
11481201
return self._aggregate_sample_results(prepared_batch, sample_outputs)
11491202

11501203
def _aggregate_sample_results(
@@ -1153,7 +1206,7 @@ def _aggregate_sample_results(
11531206
sample_outputs: list,
11541207
) -> dict[str, types.SampleOutput | types.ErrorResponse]:
11551208
"""Convert sample outputs to Tinker format."""
1156-
logger.info(f"Aggregating sample results for {len(sample_outputs)} samples")
1209+
logger.debug(f"Aggregating sample results for {len(sample_outputs)} samples")
11571210

11581211
def _extract_sequences(output):
11591212
"""Yield (tokens, logprobs, stop_reason) from a single sample output."""

0 commit comments

Comments
 (0)