08 · Rollouts & scale¶
Run one conversation recipe many times — across many GPUs, checkpointed and resumable — with per-conversation failure isolation (one OOM/bad row can't take down the batch). There is one object: a Conversation is the recipe, the live dialogue, and the rollout driver. No templates, specs, or configs.
conv.rollout — many runs of one recipe¶
from interlens import Conversation, AutoModelParticipant
conv = (Conversation(
participants=[
AutoModelParticipant.from_pretrained("Qwen/Qwen2.5-3B-Instruct", name="a", temperature=0.9),
AutoModelParticipant.from_pretrained("Qwen/Qwen2.5-3B-Instruct", name="b", temperature=0.9)],
shared_context="Debate: tabs vs spaces.") # scenario framing is a constructor field
.turns(6)) # rollout/data fields use dot-modifier sugar
report = conv.rollout(
n=64,
devices=None, # None → all visible GPUs (see available_devices()); or e.g. ["cuda:0","cuda:1"]
out_dir="runs/tabs_spaces", # checkpoints each conversation → resumable
seed=0, # per-rollout seeds are seed, seed+1, …
# batched=True is the DEFAULT: local models co-step in one batched generate (max throughput). Pass
# batched=False only for the DETERMINISTIC/interp escape hatch (see below).
)
for job_id, res in report.results.items():
if res.error is None:
print(job_id, "→", res.transcript[-1].content[:80])
print("failed:", report.failed, "skipped(resumed):", report.skipped)
Rollout copies, it does not mutate.
rolloutmakes an independent copy-on-write clone of the conversation per sample/row and runs those; the originalconvstays the unrun recipe. Finished conversations live on the report —report.results[job_id].conversation(with.transcript,.analysis,.tokens_generated). That is where tosample()or inspect afterwards, not on the original (callingconv.sample()on an unrun recipe raises).
Modifiers are copy-on-write too: conv.turns(6) returns a new conversation, so conv.turns(12).rollout() re-runs at a new length without touching conv. Use .set(field=value) for fields without a dot-modifier.
Data-driven rollout — one conversation per dataset row¶
A benchmark is N different scenarios, not N copies. Template a field with dataset_field and attach the data — rollout expands to one conversation per row, resolving the field for each:
from interlens import Conversation, AutoModelParticipant, dataset_field
from datasets import load_dataset
solver = AutoModelParticipant.from_pretrained("Qwen/Qwen2.5-1.5B-Instruct", name="solver",
system_prompt="Solve it; end with 'Final answer: <n>'.")
report = (Conversation(participants=[solver],
shared_context=("Solve this problem.\n\n", dataset_field("question"))) # per-row
.turns(1)
.data(load_dataset("openai/gsm8k", "main", split="test").select(range(200)))
.analyzer(lambda conv: {"answer": conv.transcript[-1].content})
.rollout(out_dir="runs/gsm8k")) # n defaults to len(data); job ids row_00000…
shared_context, shared_system_prompt, and each participant's system_prompt may be templated. A template value is a str (as-is), dataset_field("col"), a callable(row) -> str, a tuple of those joined (("Q: ", dataset_field("q"))), or a PEP 750 t-string on Python 3.14+. Resolution happens once at expansion (row i ⟷ job i), so resume stays correct.
The data is streamed, never materialized — rows are pulled one at a time, so a map-style Dataset stays Arrow-backed on disk and a load_dataset(..., streaming=True) IterableDataset (a corpus too big for memory) works unchanged; n defaults to len(data) when known, else all rows.
Per-row side data reaches the analyzer via conv.row — the dataset row that produced a conversation is available (as a dict) on conv.row, so an analyzer can grade against a label/gold WITHOUT templating it into the model's view (where it would leak). The runnable examples/gsm8k_benchmark_rollout.py is the full end-to-end pattern — it adds a gold column with a lazy .map and grades conv.row["gold"] in the analyzer.
interlens.run — many lineups in one pool¶
When you have different lineups (a ladder of model pairs × conditions), hand them all to run so GPUs stay packed across lineups (no idle tail between sequential rollouts), under one out_dir/resume namespace:
from interlens import run
report = run(
[conv_a.name("pair_7b"), conv_b.name("solo_7b"), conv_c.data(gpqa).name("gpqa")],
devices=None, out_dir="runs/ladder", resume=True,
) # job ids are namespaced by each conversation's name(): pair_7b, solo_7b, gpqa/row_00000, …
Each conversation expands to its own jobs (one per data() row, else a single job); all jobs share one pool. Mixing lineups is safe: batched co-stepping groups by schedule signature (turns + per-position model identity), so each distinct lineup forms its own batch group and a one-off lineup runs as a singleton. conv.rollout(...) is the single-lineup sugar for this.
- Parallel by default on two axes: one worker process per device, AND batched co-stepping within each device. No flag needed.
- Single device → runs in this process (the path where an
analyzermay be a plain callable/closure). - Multiple devices → one process per GPU (
torch.multiprocessing, since fork+CUDA is broken). Conversations pickle cheaply — lazy participants ship no weights, each worker loads on its own GPU. A closure analyzer won't survive pickling, so register analyzers by name for the multi-GPU path (below).
RunReport: .results ({job_id: RunResult}), .failed, .skipped, plus .transcripts()/.analyses()/.conversations(). RunResult: .conversation, .transcript, .analysis, .error, .device, .tokens_generated.
analyzer — measure each run while models are resident¶
An analyzer runs inside the worker right after the conversation finishes, while the models are still on-GPU — so it can sample, branch, or read activations on the live conversation. Only its serializable return value is kept (in RunResult.analysis). Attach it with .analyzer(...):
def summarize(conv):
return {"n_turns": len(conv.transcript),
"a_stance": conv.sample("a", "One word: your final stance?").content.strip()}
report = conv.analyzer(summarize).rollout(n=32, out_dir="runs/x") # in-process: a callable is fine
Multi-GPU: register analyzers by name¶
Spawned workers inherit no parent state, so an analyzer that must run in the pool has to be registered by name at import time (a closure over parent locals can't cross the process boundary):
from interlens import register_analyzer
def stance_probe(conv):
return {"a": conv.sample("a", "One word stance?").content.strip()}
register_analyzer("stance_probe", stance_probe) # at module import
report = conv.analyzer("stance_probe").rollout(n=64, devices=None, out_dir="runs/x")
For heavy per-worker setup (e.g. loading a probe/classifier once per GPU), use register_worker_init.
Matched compute — TokenBudget¶
To compare a solo model against a pair fairly, give the solo run the same token budget as the pair. TokenBudget is a StopCondition that stops each conversation once ITS OWN generated tokens hit per_conversation, and can spread the budget across turns with per_turn:
from interlens import TokenBudget
pair = conv # 2 models, turns=6
solo = conv.set(participants=[solver]).turns(None).run_until(TokenBudget(per_conversation=pair_spend, per_turn=512))
solo_report = solo.rollout(n=64)
# or ambiently over any run/rollout in the block — the budget is PER conversation, not a shared pool:
with TokenBudget(per_conversation=2048):
conv.rollout(n=200) # every one of the 200 conversations gets its own 2048-token budget
Counting is free (summed from each turn's recorded metadata['n_tokens'], never re-tokenized) and per-conversation (read from the conversation's own transcript). Verify realized spend off the report: report.results[jid].tokens_generated.
Large API rollouts (batch mode)¶
For hosted models (APIParticipant), set batch=True to route each round's turns through the provider's asynchronous batch API — ~50% cost and much higher throughput, at the price of batch-window latency:
from interlens import Conversation, APIParticipant
report = (Conversation(participants=[
APIParticipant(name="pro", provider="openai", model_id="gpt-5", batch=True, system_prompt="Argue YES."),
APIParticipant(name="con", provider="anthropic", model_id="claude-sonnet-5", batch=True, system_prompt="Argue NO.")],
shared_context="Is a hot dog a sandwich?")
.turns(4)
.rollout(n=200)) # each round → one Anthropic/OpenAI batch per speaker
- Batch APIs exist only for
provider="anthropic"andprovider="openai". Requestingbatch=Trueonprovider="openrouter"raises (OpenRouter has no batch endpoint) — it never silently degrades to serial calls. - With
batch=False(default) API turns run as ordinary concurrent single requests, bounded by the shared client's max-in-flight semaphore.
See participants & models for provider setup + API keys.
Execution modes & reproducibility¶
- Batched co-stepping is the default (
batched=True): localModelParticipants (every family) co-step, trading exact per-row reproducibility for throughput — batch composition + a single global RNG perturb rows, so only distributional reproducibility holds. Batched turns are marked inmetadata["batched"]; the shared-prefill fast path (turn 1 of ann-sample rollout off one scenario) inmetadata["shared_prefill"]. batched=Falseis theExecutionMode.DETERMINISTICescape hatch: each rollout runs independently and is token-identical on the same hardware (pin per-participantseed=andkv_reuse=False). It is the only path that supports per-turn interp (capture/steering/probes) — batched generation cannot.
Next: advanced interp pipelines.