feat: build one client per rollout, share renderers process-wide - #2218
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Conflict in verifiers/v1/rollout.py: main renamed RolloutRun -> Rollout and made RolloutSession's limits argument required; this branch had added the per-rollout client as the session's second positional argument. Kept both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fixed 8-tokenizer pool was built lazily behind a threading lock, one asyncio.to_thread per caller: every rollout arriving before the build queued a task on the default executor and blocked there. At 2048 concurrent rollouts the first quartile of model calls averaged 23.4s against 3.6s for the rest, and the convoy starved the same executor episode writes use. ElasticRendererPool warms one tokenizer when a client is built and grows one per `multiplex` concurrent rollouts, mirroring ElasticInterceptionPool. Each slot is a size=1 RendererPool, so the lock that makes concurrent renders safe (and the thread offload) comes from renderers itself. The v0 bridge keeps its own static pool at the v0 default of 1, as on main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The join stripped any leading path segment the base happened to end with, so a base ending in `/chat` would swallow `/chat/completions`. The invariant is narrower — don't repeat the API version — so gate the dedup on a `v\d+` segment. Verified live: pinference serves Anthropic messages at `/api/v1/messages` and 404s `/api/v1/v1/messages`; bare-origin bases (`https://api.anthropic.com`) join unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lient Timeouts, limits, and the retry policy are client behavior, not endpoint schema; configs.client keeps only the config classes and API-key resolution. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`multiplex` is scaling policy, not a build input — keying the shared renderer pool on it would duplicate a model's tokenizers for configs that differ only there; the first client's value wins instead. Also restores one-line docstrings on ModelContext and RolloutSession.client. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pool outlives event loops (process-wide _shared), but its warm task is loop-bound: an asyncio.run() that builds a TrainClient and exits before any turn leaves the task cancelled. acquire()'s suppress(Exception) doesn't catch CancelledError and skipped clearing _warm_task, so every later acquire on a fresh loop re-raised forever. Distinguish the two cancellations: a cancelled warm task falls through to rebuilding under the lock; a cancelled acquire still re-raises its own cancellation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The endpoint is config, not a borrowed resource — its example was stranded under Borrowed Resources after the client= parameter went away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Conflicts: - tests/v1/conftest.py: keep the config-valued ModelContext.client, take main's CI model bump (deepseek-v4-flash-0731) - verifiers/v1/env.py: keep main's deep-merged agent sampling (#2226) plus this branch's per-agent client fallback Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 01f3a3e. Configure here.
ApprovabilityVerdict: Needs human review This PR introduces significant architectural changes including a new process-wide ElasticRendererPool with shared state, new concurrency primitives (threading and asyncio locks), and fundamentally different client lifecycle management. The complexity of these new abstractions and their runtime behavior changes warrant human review. You can customize Macroscope's approvability policy. Learn more. |
EnvServer.run() used to close the base class's client cache; the cache moved to LegacyEnvServer, so the close moves with it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| """Renders prompts to token ids and calls a vLLM `/inference/v1/generate` engine.""" | ||
| @dataclass | ||
| class RendererSlot: | ||
| """One renderer and the rollouts currently holding it. Encoding mutates a fast |
There was a problem hiding this comment.
this seems like it should be handled at the renderer level
| sampling_params.update(raw_sampling) | ||
| renderer = self._renderer_pool( | ||
| model, | ||
| pool = ElasticRendererPool( |
There was a problem hiding this comment.
ah, it's because you introduce the ElasticRendererPool here in verifiers, but perhaps it would still make sense to offload some of the code into renderers proper?
There was a problem hiding this comment.
not sure, i think elastic scaling of renderers seems like a vf concern. renderers imo should mainly provide the Renderer and RendererConfig
hallerite
left a comment
There was a problem hiding this comment.
some comments, but overall lgtm
…fload Reconciled train.py with main's process-shared renderer pool (#2218): adopted RendererSlot/ElasticRendererPool and the slot.run call structure (which subsumes this branch's _maybe_offload thread-hop), dropped main's multimodal bridging gate (raw refs make mm bridging safe — that is this PR's feature), and re-applied previous_multi_modal_data bridge kwargs inside the pooled bridge closure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ages Reconciled with main's renderer pool (#2218) the same way as the offload branch: adopted RendererSlot/ElasticRendererPool and slot.run, dropped the multimodal bridging gate (this PR's feature), re-applied previous_multi_modal_data bridge kwargs. graph.py keeps the raw-mm sidecar validators over main's BaseModel MessageNode; trace.py takes the already-reconciled shape (main upstreamed the sidecar EXCLUDE_FIELDS). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Since #2218 each rollout built and closed its own httpx client, so a wide run churns TCP connections at the rollout rate — the load pattern that wedges a hyper-based vllm-router. Move client ownership to the interception server: one client per distinct endpoint config, assigned to each session at register and closed with the server. Rollouts multiplexed onto a server (multiplex, default 32) now share one bounded keepalive pool, so connections are reused warm instead of reopened per rollout — shared resources without unbounded fan-in. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

Summary
Supersedes #2210. The rollout is now the client's owner:
ModelContext.clientcarries the endpoint config, and every rollout builds, uses, and closes its ownClient. This removes the 128-connection ceiling #2210 measured — in-flight capacity scales with rollout count structurally instead of against one shared pool — and fixes the training side's mirror-image problem at the same time.Clientper rollout.Rolloutresolves its client in__init__and closes it inclose()/abort(); the interception server callssession.client. No rollout shares transport, connection state, or retries with another.ElasticRendererPool(the renderer-side counterpart toElasticInterceptionPool): one warmed when a client is built, one more grown permultiplexconcurrent rollouts (TrainClientConfig.multiplex, default 256 — a render is milliseconds against a multi-second turn, so one tokenizer absorbs many rollouts). Renderers carry no rollout state, so pools are shared by build inputs (model, renderer config, template kwargs) while transports are not.DEFAULT_TIMEOUT,DEFAULT_LIMITS,MAX_RETRIES = 0inclients.base), so a rollout behaves the same whether its turns are relayed or rendered — and a failed call surfaces to the harness SDK and the trace instead of being silently reattempted.EnvServer._clients(a duplicate renderer pool per distinctmodelstring, per worker, with no eviction) andEnv._agent_clientsare deleted.claude_codeno longer mutatesctx.client.base_url— a shared-client corruption affecting every other rollout on the endpoint;clients.base.join_url()joins upstream URLs without duplicating the API version segment instead (probed live: pinference serves Anthropic messages at/api/v1/messages, 404s/api/v1/v1/messages).Scalability
Measured end to end on gsm8k-v1 against a local
Qwen/Qwen3-4B-Instruct-2507(uv run inference, dp=2 on 2× RTX PRO 6000, 4096 ctx). One wave per run —-n 1 -r C -c C— so every rollout starts at once and each run pays its own cold start; that shape is what exposes per-rollout construction costs. All runs 100%ok, zero errors.Wall time — the per-rollout eval client scales cleanly, and the train client now matches it:
Resource footprint is flat in concurrency. From 512 to 2048 concurrent rollouts, peak fds (~800–1050), sockets (~340–450), and threads (~300–350) stay in the same band, with zero
syn-sentbacklog — per-rollout clients are not the bottleneck at any tested level. Harness subprocess spawn, not the client, gates how many calls are in flight.The fixed pool's cost was a cold-start convoy, and the elastic pool eliminates it. The old pool was built lazily behind a
threading.Lockwith oneasyncio.to_threadper caller, so every rollout arriving before the build queued on the default executor — the same one episode persistence uses. Bucketing model calls by rollout start order at 2048 concurrency:Peak RSS drops with it (2169 → 1441 MB at c=512, 3303 → 2551 MB at c=2048): the elastic pool only ever grew to 1–2 tokenizers where the fixed pool always paid for 8.
Breaking
TrainClientConfig.pool_size→multiplex, with inverted meaning:pool_sizewas "how many renderers",multiplexis "concurrent rollouts per renderer". Carrying an old value across silently changes behaviour (pool_size = 8→multiplex = 8is a tokenizer per 8 rollouts). Drop it to take the default, or set it toconcurrency / desired_renderers.Agent(config, client=...)/make_agent(config, client=...)are removed — put the endpoint onAgentConfig.clientinstead. An agent no longer owns or closes a client, soasync with agentgoverns only its interception server.ctx.client:ModelContext.clientis aClientConfig, not a liveClient. A custom harness that read (or mutated) the live client must use the config fields; a live client isresolve_client(ctx.client), or the rollout's own atRolloutSession.client.Verification
uv run ruff checkandruff format --checkpass on the touched files. Directly exercised: two clients from one config are distinct objects; the Anthropic path resolves to/api/v1/messageswhile chat stays/api/v1/chat/completionsand thecount_tokensaux route stays correct; a bare-origin base (https://api.anthropic.com, theendpoints.tomlshape) keeps the dialect's/v1; the dedup is gated to version-shaped segments so a base ending in/chatdoesn't swallow/chat/completions.Elastic pool behaviour, directly asserted:
shared()returns without blocking while the first renderer warms in the background; 10 concurrent acquires atmultiplex=4grow to exactly 3 renderers with loads[4, 4, 2]; all release to 0; further acquires reuse slots without growing; configs differing only inmultiplexshare one pool (first client's policy wins); an acquired renderer renders.🤖 Generated with Claude Code
Note
Build one client per rollout and share renderers process-wide via an elastic pool
ModelContext.clientnow holds aClientConfig(endpoint config) instead of a liveClient; eachRolloutcallsresolve_client()at construction and closes the client on teardown.ElasticRendererPoolin train.py: renderer instances are keyed by model/config and shared across concurrent rollouts up to a configurablemultiplexlimit, replacing the oldpool_sizefield onTrainClientConfig.Agent,Env,EnvServer, and the eval/GEPA runners; all now pass endpoint config and defer client construction to rollout time.EvalClientandTrainClientare refactored to acceptBaseClientConfig/TrainClientConfigdirectly, usingbuild_async_openai()in base.py for explicit timeouts, connection limits, and URL joining.TrainClientConfig.pool_sizeis replaced bymultiplex(default 256); existing configs usingpool_sizewill break.Changes since #2218 opened
Macroscope summarized b6b0e0c.
Note
High Risk
Breaking changes to agent construction and
ModelContext.clienttyping affect all rollouts and custom harnesses; train configs must migratepool_sizetomultiplexwith inverted semantics.Overview
Model transport is owned by each rollout, not by agents or eval workers.
ModelContext.clientis now endpoint config (ClientConfig);Rolloutcallsresolve_client()at construction and closes the client inclose()/abort(). The interception path usesRolloutSession.clientinstead of a shared live client on the context.Breaking API:
make_agent/Agentno longer acceptclient=— setAgentConfig.client. Eval, GEPA, and env-server paths stop resolving and caching clients (Env._agent_clients,EnvServer._clients, eval-runnerclient.close()).Eval/train clients take config objects directly; shared httpx/OpenAI defaults and
join_url()live inclients/base.py.EvalClientuses explicit timeouts/limits and version-segment URL joining (replacing the claude_code harness mutatingbase_url).Training:
TrainClientConfig.pool_sizebecomesmultiplex(rollouts per renderer, default 256).ElasticRendererPoolshares renderers process-wide with threaded encode locking;TrainClientacquires a slot per turn.Docs/tests updated for config-on-agent and per-rollout clients.
Reviewed by Cursor Bugbot for commit c7c19d2. Bugbot is set up for automated code reviews on this repo. Configure here.