Skip to content

[tinker] 15/n towards Kimi K2.6: continuous sampling for the Tinker API engine under colocate_all - #2066

Open
casper-hansen wants to merge 4 commits into
NovaSky-AI:mainfrom
casper-hansen:casper/kimi-15-continuous-sampling
Open

[tinker] 15/n towards Kimi K2.6: continuous sampling for the Tinker API engine under colocate_all#2066
casper-hansen wants to merge 4 commits into
NovaSky-AI:mainfrom
casper-hansen:casper/kimi-15-continuous-sampling

Conversation

@casper-hansen

Copy link
Copy Markdown
Contributor

Part of the Kimi K2.6/K2.7 series. Stacked on #2031 (9/n) — its commits appear in this diff; review the last commit ("Continuous sampling: per-request future completion") for this PR's own changes.

What

Replaces the Tinker API engine's batch-completion sampling with per-request future completion under colocate_all, keeping the engine-lifecycle safety property via admission control instead of serialization:

  • _ContinuousSampler (engine): a daemon thread running an asyncio loop. Each admitted sample request executes 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.
  • The engine main loop admits pending samples whenever no blocking work exists, waking/bringing up engines on the main thread first (backend.prepare_for_sampling, so the sampler coroutines never touch engine lifecycle). Blocking work (forward/forward_backward, single requests, session cleanup that actually unloads models) drains in-flight samples first, then runs with the engines quiesced exactly as before. Admission filters stale requests first (serial-path parity), and no-op cleanup sweeps don't drain.
  • Backend: prepare_for_sampling + sample_batch_async split out of the serial sample() path (shared validation; the async core reuses the loop-aware HTTP client with a persistent session on the sampler loop). RemoteInferenceClient._get_session closes the stale loop's connector synchronously on loop handoff.
  • SKYRL_TINKER_CONTINUOUS_SAMPLING=0 restores the serial loop; backends without the async surface (jax) keep it automatically via hasattr feature detection.

CPU unit tests in tests/tinker/test_engine.py cover admission/drain ordering, stale-request filtering, and the env-var opt-out.

Why

Measured on our Kimi K2.7-Code agentic RL runs through the Tinker API (2x8xB300, colocated vLLM, ~64 lockstep rollouts): with batch-completion semantics each wave's turn latency was max(generation in wave) — a single 200s generation stalled 63 finished agents for its full tail, and GPU utilization between waves collapsed. The batching existed only to serialize sampling against ops that sleep the colocated engines; admission control preserves that property while letting every finished agent proceed immediately. The stale-request admission filter fixes a real bring-up failure we hit after a server restart: leftover sample requests from the previous server drove prepare_for_sampling into an engine build with no model registered, instead of failing fast with "model not loaded".

Made with Cursor

casper-hansen and others added 4 commits August 13, 2026 08:11
…ine wake/offload

Four fixes that make sampling work reliably on the SkyRL-Train backend
outside the train->save_weights->sample happy path:

- create_sampling_client(base_model=...) maps to model_id "" on the API
  side, but sample() validated every model_id against the registered
  adapters and rejected "" as unknown. Treat falsy model_ids as
  base-model requests.
- Under LoRA weight sync (megatron + merge_lora=false),
  resolve_policy_model_name() returns the skyrl-lora adapter alias, so
  base-model sampling 404'd on vLLM: the alias only exists after the
  first sampler-weight save, and applying adapter deltas to a base-model
  request would be wrong anyway. Resolve falsy model_ids to
  generator.inference_engine.served_model_name / the policy model path.
- Colocated engines are slept right after init and around every training
  op, and only save_weights_for_sampler woke them -- so a cold sample
  (base model, or an already-synced adapter) queued against sleeping
  engines and hung forever. Track engine sleep state on the backend,
  wake (weights + KV cache) on the sample path after offloading any
  GPU-resident trainer via the new WorkerDispatch.offload_for_sampling,
  and normalize to the asleep state before save_weights_for_sampler's
  wake->broadcast->wake dance.
- Lazy engine bring-up runs on the first sampling-related call, which in
  a multi-tenant service can land right after another tenant's
  forward/forward_backward left the trainer GPU-resident; under
  colocate_all the engines' startup allocation then fails ("Engine core
  initialization failed"). Offload the trainer first, matching the build
  path's build -> offload -> engines order.

Co-authored-by: Cursor <cursoragent@cursor.com>
…engines

offload_for_sampling only offloaded the named role (callers passed
"policy"), so a preceding critic forward/forward_backward left the
critic GPU-resident on the cold-sample and lazy engine bring-up paths
and could OOM the engines' startup allocation. Offload every tracked
GPU-resident model instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ocate_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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces continuous sampling support in the Tinker training engine, allowing individual sample requests to complete independently on a background daemon thread instead of waiting for a full batch wave. It also manages GPU memory more efficiently by offloading trainer models before waking or initializing inference engines. The review feedback highlights a critical thread-safety and loop-safety issue with the shared _session in RemoteInferenceClient, recommending a loop-safe dictionary approach. Additionally, the reviewer noted potential AttributeError crashes in skyrl_train_backend.py due to missing None checks on self._dispatch.

Comment on lines 291 to 305
if self._session is not None and not self._session.closed and self._session.loop != current_loop:
# Event loop changed - the old session is unusable (bound to a dead loop).
# Event loop changed - the old session is unusable (bound to another,
# possibly dead, loop) and cannot be aclose()'d from here. Close its
# connector synchronously (sync API in aiohttp 3.x) so the sockets are
# released now and GC doesn't log "Unclosed client session" errors —
# this handoff happens routinely between the engine main thread's
# transient loops (wake/sleep/adapter loads) and the continuous
# sampler's persistent loop.
old_connector = getattr(self._session, "_connector", None)
try:
if old_connector is not None:
old_connector.close()
except Exception:
pass
self._session = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Thread-Safety and Loop-Safety Issue with Shared _session

The RemoteInferenceClient is a shared instance across threads (the main thread and the background tinker-sampler thread). Accessing and mutating self._session concurrently without any synchronization is highly prone to race conditions.

Furthermore, closing and recreating self._session on loop handoff can disrupt active requests on other loops or cause RuntimeError: Task got Future attached to a different loop if any delayed callback or background task attempts to use the session.

Solution

Instead of sharing a single _session and constantly closing/recreating it, we can maintain a loop-safe dictionary of sessions using weakref.WeakKeyDictionary. This ensures:

  1. Each event loop gets its own dedicated ClientSession.
  2. Complete thread-safety and loop-safety without any race conditions.
  3. Automatic cleanup of sessions when transient loops are garbage collected.
  4. No need for fragile synchronous connector closing.

Note: A similar loop-safe pattern should also be applied to _get_semaphores to prevent similar event loop mismatch errors.

        if "_sessions" not in self.__dict__:
            import weakref
            self.__dict__["_sessions"] = weakref.WeakKeyDictionary()
        session = self._sessions.get(current_loop)
        if session is None or session.closed:
            connector = aiohttp.TCPConnector(
                limit=SKYRL_HTTP_CONNECTION_LIMIT,
                keepalive_timeout=2,
            )
            session = aiohttp.ClientSession(connector=connector, timeout=aiohttp.ClientTimeout(total=None))
            self._sessions[current_loop] = session
        self._session = session


self._create_new_inference_client()

self._dispatch.set_inference_engine_client(self._inference_engine_client)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Potential AttributeError when self._dispatch is None

At line 430, a defensive None check is performed on self._dispatch before calling offload_for_sampling(). However, at line 435, self._dispatch.set_inference_engine_client(...) is called without a None check. If self._dispatch is indeed None, this will raise an AttributeError and crash the engine initialization.

Please wrap this call in a None check as well.

Suggested change
self._dispatch.set_inference_engine_client(self._inference_engine_client)
if self._dispatch is not None:
self._dispatch.set_inference_engine_client(self._inference_engine_client)

return
if not self._engines_asleep:
return
self._dispatch.offload_for_sampling()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Potential AttributeError when self._dispatch is None

In _wake_inference_engines_for_sampling, self._dispatch.offload_for_sampling() is called directly. To prevent potential AttributeError crashes (consistent with the defensive checks in _ensure_inference_engines), please add a None check for self._dispatch before calling offload_for_sampling().

Suggested change
self._dispatch.offload_for_sampling()
if self._dispatch is not None:
self._dispatch.offload_for_sampling()

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 58dfc1c. Configure here.

if old_connector is not None:
old_connector.close()
except Exception:
pass

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Session handoff does not close connectors

Medium Severity

Loop handoff in _get_session calls TCPConnector.close() without awaiting it. In aiohttp 3.14 that method is a coroutine, so the old connector is not closed; the exception handler also swallows failures from closing a live sampler-loop connector on the engine thread's throwaway loop. Continuous sampling leaves the session open (close_client=False) and sleep/wake plus the two asyncio.run wake calls hit this path every sample/train cycle, leaking sockets and still producing unclosed-session warnings.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 58dfc1c. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant