refactor: unify rollout onto inference engine KV-cache path

- RolloutGenerator now delegates prefill/decode to InferenceScheduler.run_batch (sync API, no background thread), sharing one KV-cache code path with the inference server and eliminating O(n^2) recompute in rollout
- Add sample(return_logprobs=) and Executor.execute_decode(return_logprobs=) to expose behaviour-policy log-probs through the engine; Task gains output_logprobs
- RolloutResult now subclasses RawRollout (adds rewards only), removing duplicated fields
- RolloutRunner.__call__ returns (result, is_fresh) instead of relying on object identity, removing the fragile refresh-detection contract
- Remove O(n^2) generate_responses helper and dead code (_tokenize_prompts, unused old_model arg)
- train_context.py wires InferenceScheduler directly instead of hand-rolling SamplingPipeline
- Tests: +11 covering return_logprobs, run_batch, and KV-cache-backed rollout semantics; 404 pass
This commit is contained in:
2026-07-20 12:52:20 +08:00
parent 754624acf0
commit 95c43368ae
11 changed files with 662 additions and 303 deletions
+34 -1
View File
@@ -55,7 +55,23 @@ class Executor:
paged_cache=self.kv_cache.bind_tasks(task_ids, prompt_len, self.device), paged_cache=self.kv_cache.bind_tasks(task_ids, prompt_len, self.device),
) )
def execute_decode(self, tasks: List[Task]) -> List[int]: def execute_decode(
self, tasks: List[Task], return_logprobs: bool = False
) -> List[int]:
"""Decode next token for each task.
Args:
return_logprobs: When ``True``, also record (and return)
the log-probability of each sampled token under the
post-strategy sampling distribution. The logprob is
appended to ``task.output_logprobs`` and the return
list becomes ``List[Tuple[int, float]]``.
Returns:
``List[int]`` of sampled token IDs, or
``List[Tuple[int, float]]`` of ``(token_id, logprob)`` when
``return_logprobs`` is ``True``.
"""
if not tasks: if not tasks:
return [] return []
@@ -116,6 +132,23 @@ class Executor:
) )
logits = outputs["logits"][:, -1, :] logits = outputs["logits"][:, -1, :]
if return_logprobs:
tokens, logprobs = sample(
logits,
temperature=temperatures,
top_k=top_ks,
top_p=top_ps,
frequency_penalty=freq_penalties,
input_ids=padded_ids,
input_mask=padded_mask,
return_logprobs=True,
)
tokens_list = tokens.tolist()
logprobs_list = logprobs.tolist()
for t, lp in zip(tasks, logprobs_list):
t.output_logprobs.append(float(lp))
return list(zip(tokens_list, logprobs_list))
return sample( return sample(
logits, logits,
temperature=temperatures, temperature=temperatures,
+113
View File
@@ -1,8 +1,10 @@
import logging import logging
import threading import threading
import uuid
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import torch import torch
from torch import Tensor
from astrai.inference.core.cache import ContiguousCache, KVCache from astrai.inference.core.cache import ContiguousCache, KVCache
from astrai.inference.core.executor import Executor from astrai.inference.core.executor import Executor
@@ -194,6 +196,117 @@ class InferenceScheduler:
self._cache.task_free(task.task_id) self._cache.task_free(task.task_id)
for task in self._task_mgr.get_waiting_tasks(): for task in self._task_mgr.get_waiting_tasks():
self._task_mgr.invoke_callback(task.task_id, STOP) self._task_mgr.invoke_callback(task.task_id, STOP)
self._cache.task_free(task.task_id)
self._task_mgr.clear_queues() self._task_mgr.clear_queues()
if torch.cuda.is_available(): if torch.cuda.is_available():
torch.cuda.empty_cache() torch.cuda.empty_cache()
def run_batch(
self,
prompt_ids_list: List[List[int]],
*,
max_tokens: Optional[int] = None,
temperature: float = 1.0,
top_p: float = 1.0,
top_k: int = 50,
frequency_penalty: float = 0.0,
rep_window: int = 64,
return_logprobs: bool = False,
) -> List[List[int]]:
"""Synchronous batch generation without the scheduler thread.
Accepts already-tokenized prompts (no string round-trip) and runs
prefill + decode to completion on the calling thread. Designed for
RL rollout, where logprobs of the behaviour policy must be collected
alongside generated tokens.
Args:
prompt_ids_list: ``B`` prompts, each a list of token IDs.
max_tokens: Maximum tokens to generate per prompt. ``None``
uses ``self.max_seq_len - len(prompt_ids)``.
temperature/top_p/top_k/frequency_penalty/rep_window: Sampling
parameters (uniform across the batch).
return_logprobs: If ``True``, return ``(token_ids, logprobs)``
tuples per prompt (logprobs aligned 1-to-1 with token_ids).
Returns:
``List[List[int]]`` of generated token IDs per prompt, or —
when ``return_logprobs`` is ``True`` —
``List[Tuple[List[int], List[float]]]``.
"""
stop_ids = self._task_mgr.tokenizer.stop_ids
cache = self._cache
seq_cap = self.max_seq_len
tasks: List[Task] = []
for ids in prompt_ids_list:
if len(ids) >= seq_cap:
tasks.append(None)
continue
t_max = max_tokens
if t_max is None:
t_max = seq_cap - len(ids)
else:
t_max = min(t_max, seq_cap - len(ids))
task = Task(
task_id=f"batch_{uuid.uuid4().hex[:8]}",
prompt_ids=list(ids),
max_tokens=t_max,
temperature=temperature,
top_p=top_p,
top_k=top_k,
frequency_penalty=frequency_penalty,
rep_window=rep_window,
)
if not cache.task_alloc(task.task_id, task.prompt_ids):
tasks.append(None)
continue
task.input_tokens = len(task.prompt_ids)
tasks.append(task)
try:
live = [t for t in tasks if t is not None]
prefill_groups: Dict[Tuple[int, int], List[Task]] = {}
for t in live:
key = (len(t.prompt_ids), cache.task_cached(t.task_id))
prefill_groups.setdefault(key, []).append(t)
for (prompt_len, start_pos), group in prefill_groups.items():
self._executor.execute_prefill(group, prompt_len, start_pos)
while live:
valid: List[Task] = []
for t in sorted(live, key=lambda x: x.task_id):
if cache.task_extend(t.task_id, t.next_pos):
valid.append(t)
else:
t.status = TaskStatus.ABORTED
if not valid:
break
step_out = self._executor.execute_decode(
valid, return_logprobs=return_logprobs
)
if return_logprobs:
for t, (ntok, _lp) in zip(valid, step_out):
t.output_ids.append(ntok)
t.output_tokens += 1
else:
for t, ntok in zip(valid, step_out):
t.output_ids.append(ntok)
t.output_tokens += 1
live = [t for t in valid if not t.is_finished(stop_ids)]
finally:
for t in tasks:
if t is not None:
cache.task_free(t.task_id)
results: List[Any] = []
for t in tasks:
if t is None:
results.append(([], []) if return_logprobs else [])
elif return_logprobs:
results.append((list(t.output_ids), list(t.output_logprobs)))
else:
results.append(list(t.output_ids))
return results
+1
View File
@@ -81,6 +81,7 @@ class Task:
self.status = TaskStatus.PENDING self.status = TaskStatus.PENDING
self.output_ids: List[int] = [] self.output_ids: List[int] = []
self.output_logprobs: List[float] = []
self.input_tokens: int = 0 self.input_tokens: int = 0
self.output_tokens: int = 0 self.output_tokens: int = 0
self.arrival_time = time.time() self.arrival_time = time.time()
+28 -5
View File
@@ -313,7 +313,8 @@ def sample(
input_ids: Optional[Tensor] = None, input_ids: Optional[Tensor] = None,
input_mask: Optional[Tensor] = None, input_mask: Optional[Tensor] = None,
filter_value: float = -float("inf"), filter_value: float = -float("inf"),
) -> Tensor: return_logprobs: bool = False,
):
"""Apply sampling strategies then sample (softmax + multinomial). """Apply sampling strategies then sample (softmax + multinomial).
Shortcut for ``SamplingPipeline(...).sample(logits)``. Shortcut for ``SamplingPipeline(...).sample(logits)``.
@@ -327,17 +328,39 @@ def sample(
(0.0 disables, range -2.0~2.0). (0.0 disables, range -2.0~2.0).
input_ids: Previously generated token IDs ``[batch, seq_len]``. input_ids: Previously generated token IDs ``[batch, seq_len]``.
input_mask: Boolean mask for ``input_ids`` padding. input_mask: Boolean mask for ``input_ids`` padding.
return_logprobs: If ``True``, also return the log-probability
of each sampled token under the (post-strategy) sampling
distribution. Useful for RL rollout: the returned logprob
is the behaviour policy's log-prob used in PPO/GRPO
importance ratios.
Returns: Returns:
Sampled token IDs ``[batch]``. Sampled token IDs ``[batch]``, or — when ``return_logprobs`` is
``True`` — a ``(token_ids, chosen_logprobs)`` tuple where
``chosen_logprobs`` has shape ``[batch]``.
""" """
if SamplingPipeline._is_greedy(temperature): if SamplingPipeline._is_greedy(temperature):
return logits.argmax(dim=-1) tokens = logits.argmax(dim=-1)
return SamplingPipeline( if not return_logprobs:
return tokens
log_probs = torch.log_softmax(logits.float(), dim=-1)
chosen = torch.gather(log_probs, -1, tokens.unsqueeze(-1)).squeeze(-1)
return tokens, chosen
pipeline = SamplingPipeline(
[ [
TemperatureStrategy(temperature), TemperatureStrategy(temperature),
TopKStrategy(top_k), TopKStrategy(top_k),
TopPStrategy(top_p), TopPStrategy(top_p),
FrequencyPenaltyStrategy(frequency_penalty), FrequencyPenaltyStrategy(frequency_penalty),
] ]
).sample(logits, filter_value, input_ids, input_mask) )
if not return_logprobs:
return pipeline.sample(logits, filter_value, input_ids, input_mask)
transformed = pipeline.apply(logits, filter_value, input_ids, input_mask)
log_probs = torch.log_softmax(transformed.float(), dim=-1)
probs = torch.softmax(transformed, dim=-1)
tokens = torch.multinomial(probs, num_samples=1).squeeze(-1)
chosen = torch.gather(log_probs, -1, tokens.unsqueeze(-1)).squeeze(-1)
return tokens, chosen
+154 -161
View File
@@ -1,26 +1,35 @@
"""Online rollout runner for RL training. """Online rollout runner for RL training.
Provides: Provides:
- :class:`RolloutResult` — universal data container for online sampling - :class:`RawRollout` — generation output container (no reward yet)
- :class:`RolloutResult` — a :class:`RawRollout` with rewards attached
- :class:`BaseRewardModel` — pluggable reward interface - :class:`BaseRewardModel` — pluggable reward interface
- :class:`RolloutRunner` — generates + scores batches for any RL strategy - :class:`RolloutGenerator` — KV-cache-backed generation of grouped
responses + decoding (no reward); delegates the generation loop to
:class:`~astrai.inference.core.scheduler.InferenceScheduler.run_batch`
so rollout and the production inference server share one code path
- :class:`RolloutRunner` — orchestrates generation + scoring with a
step-driven cache; its ``__call__`` returns ``(RolloutResult, is_fresh)``
so callers do not need to rely on object identity to detect refreshes.
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Dict, List, Optional from typing import Dict, List, Optional, Tuple
import torch import torch
import torch.nn as nn import torch.nn as nn
import torch.nn.functional as F
from torch import Tensor from torch import Tensor
from astrai.inference.sample import SamplingPipeline from astrai.inference.core.scheduler import InferenceScheduler
@dataclass @dataclass(kw_only=True)
class RolloutResult: class RawRollout:
"""Universal container produced by :class:`RolloutRunner`. """Generation output before reward scoring.
Produced by :class:`RolloutGenerator`; consumed by :class:`RolloutRunner`
to assemble a :class:`RolloutResult` once rewards are attached.
Fields are designed to cover all common RL algorithms: Fields are designed to cover all common RL algorithms:
GRPO, PPO, Online DPO, Rejection Sampling, etc. GRPO, PPO, Online DPO, Rejection Sampling, etc.
@@ -35,9 +44,6 @@ class RolloutResult:
response_mask: Tensor response_mask: Tensor
"""Boolean mask for real (non-pad) response tokens, shape ``[B, G, R_max]``.""" """Boolean mask for real (non-pad) response tokens, shape ``[B, G, R_max]``."""
rewards: Tensor
"""Reward per response, shape ``[B, G]``."""
logprobs_old: Tensor logprobs_old: Tensor
"""Per-token log-probs under the behaviour policy, shape ``[B, G, R_max]``.""" """Per-token log-probs under the behaviour policy, shape ``[B, G, R_max]``."""
@@ -48,6 +54,18 @@ class RolloutResult:
"""Decoded response strings, shape ``[B, G]`` (for reward models).""" """Decoded response strings, shape ``[B, G]`` (for reward models)."""
@dataclass(kw_only=True)
class RolloutResult(RawRollout):
"""A :class:`RawRollout` with reward scoring attached.
Produced by :class:`RolloutRunner` once the :class:`BaseRewardModel`
has scored the decoded responses.
"""
rewards: Tensor
"""Reward per response, shape ``[B, G]``."""
class BaseRewardModel(ABC): class BaseRewardModel(ABC):
"""Pluggable reward model interface. """Pluggable reward model interface.
@@ -72,115 +90,142 @@ class BaseRewardModel(ABC):
... ...
def generate_responses( _PAD = 0
model: nn.Module,
input_ids: Tensor,
attention_mask: Tensor,
max_new_tokens: int,
sampling_pipeline: SamplingPipeline,
stop_ids: List[int],
) -> Dict[str, Tensor]:
"""Autoregressive generation with log-prob tracking.
Args:
model: Policy model (``forward`` returns ``{"logits": ...}``).
input_ids: ``[B, P_len]`` prompt token IDs.
attention_mask: ``[B, P_len]`` boolean mask.
max_new_tokens: Maximum tokens to generate.
sampling_pipeline: Composed sampling strategies.
stop_ids: Token IDs that stop generation (eos, etc.).
Returns: class RolloutGenerator:
``dict`` with keys: """Pure generation + decoding for a group of responses per prompt.
- ``generated_ids``: ``[B, max_new_tokens]`` (padded to same length)
- ``generated_mask``: ``[B, max_new_tokens]`` Delegates the prefill/decode loop to
- ``logprobs``: ``[B, max_new_tokens]`` per-token log-probs :meth:`~astrai.inference.core.scheduler.InferenceScheduler.run_batch`,
which uses a real KV cache (no O(n²) recompute). Has no dependency
on any reward model; can be reused in isolation for offline
generation, qualitative sampling, or eval pipelines.
""" """
_PAD = 0
B, P_len = input_ids.shape
device = input_ids.device
stop_ids_set = set(stop_ids)
done = torch.zeros(B, dtype=torch.bool, device=device)
all_ids = input_ids.clone()
all_mask = attention_mask.clone()
logprob_list: List[Tensor] = []
for _ in range(max_new_tokens): def __init__(
outputs = model(input_ids=all_ids, input_mask=all_mask) self,
logits = outputs["logits"][:, -1, :].float() scheduler: InferenceScheduler,
log_probs = F.log_softmax(logits, dim=-1) tokenizer,
max_tokens: int = 1024,
group_size: int = 8,
temperature: float = 1.0,
top_k: int = 0,
top_p: float = 1.0,
frequency_penalty: float = 0.0,
rep_window: int = 64,
):
self.scheduler = scheduler
self.tokenizer = tokenizer
self.max_tokens = max_tokens
self.group_size = group_size
self.temperature = temperature
self.top_k = top_k
self.top_p = top_p
self.frequency_penalty = frequency_penalty
self.rep_window = rep_window
logits = sampling_pipeline.apply(logits, input_ids=all_ids, input_mask=all_mask) @torch.no_grad()
probs = torch.softmax(logits, dim=-1) def generate(self, batch: Dict[str, Tensor]) -> RawRollout:
next_tokens = torch.multinomial(probs, num_samples=1).squeeze(-1) """Expand prompts by ``group_size`` and generate one response each."""
prompt_ids = batch["input_ids"] if "input_ids" in batch else batch["prompts"]
next_tokens[done] = _PAD prompt_mask = (
chosen_logprobs = torch.gather(log_probs, -1, next_tokens.unsqueeze(-1)) batch["attention_mask"] if "attention_mask" in batch else (prompt_ids != 0)
logprob_list.append(chosen_logprobs)
all_ids = torch.cat([all_ids, next_tokens.unsqueeze(1)], dim=-1)
all_mask = torch.cat([all_mask, (~done).unsqueeze(1)], dim=-1)
done = done | torch.tensor(
[t.item() in stop_ids_set for t in next_tokens],
device=device,
) )
if done.all(): B, _ = prompt_ids.shape
break G = self.group_size
logprobs = torch.cat(logprob_list, dim=-1) prompt_texts: List[str] = []
if logprobs.size(1) < max_new_tokens: flat_prompt_ids: List[List[int]] = []
pad_len = max_new_tokens - logprobs.size(1) for i in range(B):
logprobs = F.pad(logprobs, (0, pad_len), value=0.0) ids = prompt_ids[i, prompt_mask[i]].tolist()
text = self.tokenizer.decode(ids, skip_special_tokens=True)
for _ in range(G):
flat_prompt_ids.append(list(ids))
prompt_texts.append(text)
generated_ids = all_ids[:, P_len:] results = self.scheduler.run_batch(
if generated_ids.size(1) < max_new_tokens: flat_prompt_ids,
pad_len = max_new_tokens - generated_ids.size(1) max_tokens=self.max_tokens,
generated_ids = F.pad(generated_ids, (0, pad_len), value=_PAD) temperature=self.temperature,
top_k=self.top_k,
top_p=self.top_p,
frequency_penalty=self.frequency_penalty,
rep_window=self.rep_window,
return_logprobs=True,
)
generated_mask = generated_ids != _PAD # Each element is (token_ids, logprobs); pad to max length.
max_len = 0
for token_ids, _lp in results:
max_len = max(max_len, len(token_ids))
max_len = max(max_len, 1)
return { device = prompt_ids.device
"generated_ids": generated_ids, responses = torch.full((B, G, max_len), _PAD, dtype=torch.long, device=device)
"generated_mask": generated_mask, response_mask = torch.zeros((B, G, max_len), dtype=torch.bool, device=device)
"logprobs": logprobs, logprobs_old = torch.zeros((B, G, max_len), dtype=torch.float, device=device)
}
flat_idx = 0
response_texts: List[List[str]] = [[] for _ in range(B)]
for i in range(B):
for g in range(G):
token_ids, lps = results[flat_idx]
flat_idx += 1
n = len(token_ids)
if n:
responses[i, g, :n] = torch.tensor(
token_ids, dtype=torch.long, device=device
)
response_mask[i, g, :n] = True
logprobs_old[i, g, :n] = torch.tensor(
lps, dtype=torch.float, device=device
)
response_texts[i].append(
self.tokenizer.decode(token_ids, skip_special_tokens=True)
)
return RawRollout(
prompts=prompt_ids,
responses=responses,
response_mask=response_mask,
logprobs_old=logprobs_old,
prompt_texts=prompt_texts,
response_texts=response_texts,
)
class RolloutRunner: class RolloutRunner:
"""Produces :class:`RolloutResult` from a prompt batch. """Produces :class:`RolloutResult` from a prompt batch.
Maintains an internal cache so the same batch prompt can be replayed Composes a :class:`RolloutGenerator` (generation + decoding) with a
for multiple gradient steps. A new rollout is triggered every :class:`BaseRewardModel` (scoring). Maintains an internal cache so
``rollout_interval`` calls to :meth:`step`. the same batch prompt can be replayed for multiple gradient steps.
A new rollout is triggered every ``rollout_interval`` calls to
:meth:`step` (or after :meth:`clear_cache`).
The ``__call__`` contract returns a ``(RolloutResult, is_fresh)``
tuple — callers must use the boolean to detect a refreshed rollout
rather than relying on object identity.
Usage:: Usage::
runner = RolloutRunner(policy, old_policy, tokenizer, generator = RolloutGenerator(policy, tokenizer, pipeline, ...)
reward_model, sampling_pipeline, config) runner = RolloutRunner(generator, reward_model, rollout_interval=512)
result = runner(prompt_batch) result, is_fresh = runner(prompt_batch)
if is_fresh:
... # e.g. sync behaviour policy
""" """
def __init__( def __init__(
self, self,
policy_model: nn.Module, generator: RolloutGenerator,
old_model: Optional[nn.Module],
tokenizer,
reward_model: BaseRewardModel, reward_model: BaseRewardModel,
sampling_pipeline: SamplingPipeline,
max_tokens: int = 1024,
group_size: int = 8,
rollout_interval: int = 512, rollout_interval: int = 512,
): ):
self.policy_model = policy_model self.generator = generator
self.old_model = old_model
self.tokenizer = tokenizer
self.reward_model = reward_model self.reward_model = reward_model
self.sampling_pipeline = sampling_pipeline
self.max_tokens = max_tokens
self.group_size = group_size
self.rollout_interval = rollout_interval self.rollout_interval = rollout_interval
self.stop_ids = getattr(tokenizer, "stop_ids", []) or []
self._cache: Optional[RolloutResult] = None self._cache: Optional[RolloutResult] = None
self._steps_since_rollout: int = 0 self._steps_since_rollout: int = 0
@@ -193,80 +238,28 @@ class RolloutRunner:
"""Force next call to re-run rollout.""" """Force next call to re-run rollout."""
self._cache = None self._cache = None
def _tokenize_prompts(self, raw_texts: List[str]) -> Dict[str, Tensor]: def _score(self, raw: RawRollout) -> RolloutResult:
ids_list = self.tokenizer.encode(raw_texts, out_ids=True) rewards = self.reward_model.score(raw.prompt_texts, raw.response_texts)
B = len(ids_list) device = raw.prompts.device
P_max = max(len(ids) for ids in ids_list) if ids_list else 0
input_ids = torch.zeros(B, P_max, dtype=torch.long)
for i, ids in enumerate(ids_list):
input_ids[i, : len(ids)] = torch.tensor(ids[:P_max], dtype=torch.long)
attention_mask = input_ids != 0
return {"input_ids": input_ids, "attention_mask": attention_mask}
def _decode(self, token_ids: Tensor, mask: Tensor) -> List[List[str]]:
B, G, _ = token_ids.shape
texts = []
for i in range(B):
group_texts = []
for g in range(G):
ids = token_ids[i, g, mask[i, g]].tolist()
group_texts.append(self.tokenizer.decode(ids, skip_special_tokens=True))
texts.append(group_texts)
return texts
@torch.no_grad()
def _run(self, batch: Dict[str, Tensor]) -> RolloutResult:
"""Execute the actual generation + reward scoring."""
prompt_ids = batch["input_ids"] if "input_ids" in batch else batch["prompts"]
prompt_mask = (
batch["attention_mask"] if "attention_mask" in batch else (prompt_ids != 0)
)
B, P_len = prompt_ids.shape
G = self.group_size
device = prompt_ids.device
prompt_texts: List[str] = []
for i in range(B):
ids = prompt_ids[i, prompt_mask[i]].tolist()
prompt_texts.append(self.tokenizer.decode(ids, skip_special_tokens=True))
expanded_ids = prompt_ids.unsqueeze(1).expand(-1, G, -1).reshape(B * G, P_len)
expanded_mask = prompt_mask.unsqueeze(1).expand(-1, G, -1).reshape(B * G, P_len)
gen_out = generate_responses(
model=self.policy_model,
input_ids=expanded_ids,
attention_mask=expanded_mask,
max_new_tokens=self.max_tokens,
sampling_pipeline=self.sampling_pipeline,
stop_ids=self.stop_ids,
)
gen_ids = gen_out["generated_ids"].reshape(B, G, -1)
gen_mask = gen_out["generated_mask"].reshape(B, G, -1)
gen_logprobs = gen_out["logprobs"].reshape(B, G, -1)
response_texts = self._decode(gen_ids, gen_mask)
reward_tensor = self.reward_model.score(prompt_texts, response_texts)
rewards = reward_tensor.to(device=device)
return RolloutResult( return RolloutResult(
prompts=prompt_ids, prompts=raw.prompts,
responses=gen_ids, responses=raw.responses,
response_mask=gen_mask, response_mask=raw.response_mask,
rewards=rewards, rewards=rewards.to(device=device),
logprobs_old=gen_logprobs, logprobs_old=raw.logprobs_old,
prompt_texts=prompt_texts, prompt_texts=raw.prompt_texts,
response_texts=response_texts, response_texts=raw.response_texts,
) )
def __call__(self, batch: Dict[str, Tensor]) -> RolloutResult: def __call__(self, batch: Dict[str, Tensor]) -> Tuple[RolloutResult, bool]:
"""Return cached or fresh :class:`RolloutResult`. """Return ``(cached or fresh) RolloutResult`` plus an ``is_fresh`` flag.
Triggers a new rollout when ``_steps_since_rollout >= rollout_interval`` Triggers a new rollout when ``_steps_since_rollout >= rollout_interval``
or when the cache is empty. or when the cache is empty.
""" """
if self._cache is None or self._steps_since_rollout >= self.rollout_interval: if self._cache is None or self._steps_since_rollout >= self.rollout_interval:
self._cache = self._run(batch) raw = self.generator.generate(batch)
self._cache = self._score(raw)
self._steps_since_rollout = 0 self._steps_since_rollout = 0
return self._cache return self._cache, True
return self._cache, False
+2 -4
View File
@@ -109,7 +109,6 @@ class BaseStrategy(ABC):
self.executor = kwargs.pop("executor", None) self.executor = kwargs.pop("executor", None)
self.extra_kwargs = kwargs self.extra_kwargs = kwargs
self._rollout_runner = None self._rollout_runner = None
self._prev_rollout_result = None
@abstractmethod @abstractmethod
def compute_loss(self, batch: Dict[str, Tensor]) -> Tensor: def compute_loss(self, batch: Dict[str, Tensor]) -> Tensor:
@@ -159,10 +158,9 @@ class BaseStrategy(ABC):
if self._rollout_runner is None: if self._rollout_runner is None:
return self.compute_loss(batch) return self.compute_loss(batch)
result = self._rollout_runner(batch) result, is_fresh = self._rollout_runner(batch)
if result is not self._prev_rollout_result: if is_fresh:
self._on_rollout_refresh() self._on_rollout_refresh()
self._prev_rollout_result = result
if self.executor and self.executor.sync_gradients: if self.executor and self.executor.sync_gradients:
self._rollout_runner.step() self._rollout_runner.step()
+18 -18
View File
@@ -8,19 +8,14 @@ from torch.utils.data import DataLoader, random_split
from astrai.config.train_config import TrainConfig from astrai.config.train_config import TrainConfig
from astrai.dataset import RDSampler from astrai.dataset import RDSampler
from astrai.inference.sample import ( from astrai.inference.core.scheduler import InferenceScheduler
SamplingPipeline,
TemperatureStrategy,
TopKStrategy,
TopPStrategy,
)
from astrai.model.components.lora import inject_lora from astrai.model.components.lora import inject_lora
from astrai.parallel.executor import BaseExecutor, ExecutorFactory from astrai.parallel.executor import BaseExecutor, ExecutorFactory
from astrai.parallel.setup import get_current_device, get_rank, get_world_size from astrai.parallel.setup import get_current_device, get_rank, get_world_size
from astrai.protocols import OptimizerProtocol, SchedulerProtocol from astrai.protocols import OptimizerProtocol, SchedulerProtocol
from astrai.serialization import Checkpoint, load_json from astrai.serialization import Checkpoint, load_json
from astrai.tokenize import AutoTokenizer from astrai.tokenize import AutoTokenizer
from astrai.trainer.rollout import RolloutRunner from astrai.trainer.rollout import RolloutGenerator, RolloutRunner
from astrai.trainer.strategy import BaseStrategy, StrategyFactory, create_ref_model from astrai.trainer.strategy import BaseStrategy, StrategyFactory, create_ref_model
@@ -243,22 +238,27 @@ class TrainContextBuilder:
tokenizer = AutoTokenizer.from_pretrained(self._param_path) tokenizer = AutoTokenizer.from_pretrained(self._param_path)
reward_model = cfg.reward_model_fn() reward_model = cfg.reward_model_fn()
pipeline = SamplingPipeline( scheduler = InferenceScheduler(
[ model=context.model,
TemperatureStrategy(cfg.rollout_temperature), tokenizer=tokenizer,
TopKStrategy(cfg.rollout_top_k), max_batch_size=strategy_kwargs.get("group_size", 8)
TopPStrategy(cfg.rollout_top_p), * max(1, cfg.batch_size or 1),
] max_seq_len=getattr(context.model.config, "max_len", None),
max_prompt_len=getattr(context.model.config, "max_len", 4096),
) )
runner = RolloutRunner( generator = RolloutGenerator(
policy_model=context.model, scheduler=scheduler,
old_model=old_model,
tokenizer=tokenizer, tokenizer=tokenizer,
reward_model=reward_model,
sampling_pipeline=pipeline,
max_tokens=cfg.rollout_max_tokens, max_tokens=cfg.rollout_max_tokens,
group_size=strategy_kwargs.get("group_size", 8), group_size=strategy_kwargs.get("group_size", 8),
temperature=cfg.rollout_temperature,
top_k=cfg.rollout_top_k,
top_p=cfg.rollout_top_p,
)
runner = RolloutRunner(
generator=generator,
reward_model=reward_model,
rollout_interval=cfg.rollout_interval, rollout_interval=cfg.rollout_interval,
) )
context.strategy.set_rollout_runner(runner) context.strategy.set_rollout_runner(runner)
+51
View File
@@ -231,3 +231,54 @@ def test_sample_with_frequency_penalty():
) )
assert tokens.shape == (1,) assert tokens.shape == (1,)
assert 0 <= tokens[0] < logits.size(-1) assert 0 <= tokens[0] < logits.size(-1)
def test_sample_return_logprobs_shape():
"""``return_logprobs=True`` returns ``[batch]`` logprobs aligned to tokens."""
logits = torch.tensor([[1.0, 2.0, 3.0], [3.0, 2.0, 1.0]])
out = sample(logits, temperature=1.0, return_logprobs=True)
tokens, logprobs = out
assert tokens.shape == (2,)
assert logprobs.shape == (2,)
def test_sample_return_logprobs_nonpositive():
"""Probabilities never exceed 1, so logprobs are always ≤ 0."""
torch.manual_seed(0)
logits = torch.randn(4, 50)
_, logprobs = sample(
logits, temperature=0.8, top_k=20, top_p=0.9, return_logprobs=True
)
assert torch.all(logprobs <= 1e-5)
def test_sample_return_logprobs_greedy_path():
"""Greedy decode (temperature 0) also returns logprobs."""
logits = torch.tensor([[1.0, 5.0, 2.0]])
tokens, logprobs = sample(logits, temperature=0.0, return_logprobs=True)
assert tokens[0].item() == 1
# log p(token=1) should equal log_softmax(logits)[1]
expected = torch.log_softmax(logits.float(), dim=-1)[0, 1]
assert torch.allclose(logprobs[0], expected, atol=1e-5)
def test_sample_return_logprobs_matches_manual_computation():
"""Returned logprob equals log_softmax(transformed_logits)[token]."""
torch.manual_seed(1)
logits = torch.randn(2, 30)
tokens, logprobs = sample(logits, temperature=0.7, top_p=0.95, return_logprobs=True)
# Recompute with the same pipeline
from astrai.inference.sample import (
SamplingPipeline,
TemperatureStrategy,
TopPStrategy,
)
pipeline = SamplingPipeline([TemperatureStrategy(0.7), TopPStrategy(0.95)])
transformed = pipeline.apply(logits.clone())
expected = torch.gather(
torch.log_softmax(transformed.float(), dim=-1),
-1,
tokens.unsqueeze(-1),
).squeeze(-1)
assert torch.allclose(logprobs, expected, atol=1e-5)
+121
View File
@@ -191,3 +191,124 @@ def test_prefill_skips_fully_cached_tasks(mock_model_and_tokenizer):
task_id = scheduler.add_task("short prompt", stream_callback=lambda t: None) task_id = scheduler.add_task("short prompt", stream_callback=lambda t: None)
scheduler.stop() scheduler.stop()
assert task_id.startswith("task_") assert task_id.startswith("task_")
def _make_real_scheduler(device):
"""Build a scheduler backed by a tiny real model for run_batch tests."""
from astrai.config.model_config import AutoRegressiveLMConfig
from astrai.model.transformer import AutoRegressiveLM
class _Tok:
stop_ids = [2]
def encode(self, texts, **_):
if isinstance(texts, str):
texts = [texts]
return [[b for b in t.encode("utf-8")] for t in texts]
def decode(self, ids, skip_special_tokens=True):
return bytes(b for b in ids if b > 2 or not skip_special_tokens).decode(
"utf-8", errors="ignore"
)
cfg = AutoRegressiveLMConfig(
vocab_size=200,
dim=16,
n_heads=2,
n_kv_heads=1,
dim_ffn=32,
max_len=64,
n_layers=2,
norm_eps=1e-5,
)
model = AutoRegressiveLM(cfg).to(device=device).eval()
tokenizer = _Tok()
scheduler = InferenceScheduler(
model=model,
tokenizer=tokenizer,
max_batch_size=8,
max_seq_len=64,
max_prompt_len=64,
)
return scheduler, tokenizer, model
def test_run_batch_returns_token_sequences():
device = "cuda" if torch.cuda.is_available() else "cpu"
scheduler, _tok, _model = _make_real_scheduler(device)
try:
prompts = [[10, 20, 30], [5, 6, 7, 8]]
results = scheduler.run_batch(prompts, max_tokens=4, temperature=1.0)
assert len(results) == 2
for ids in results:
assert isinstance(ids, list)
assert len(ids) <= 4
assert all(0 <= i < 200 for i in ids)
finally:
scheduler.stop()
def test_run_batch_return_logprobs_aligned():
"""return_logprobs=True gives (token_ids, logprobs) tuples with equal len."""
device = "cuda" if torch.cuda.is_available() else "cpu"
scheduler, _tok, _model = _make_real_scheduler(device)
try:
prompts = [[10, 20, 30, 40]]
results = scheduler.run_batch(
prompts, max_tokens=5, temperature=1.0, return_logprobs=True
)
assert len(results) == 1
token_ids, logprobs = results[0]
assert len(token_ids) == len(logprobs)
assert all(lp <= 1e-5 for lp in logprobs) # logprobs ≤ 0
finally:
scheduler.stop()
def test_run_batch_respects_max_tokens():
device = "cuda" if torch.cuda.is_available() else "cpu"
scheduler, _tok, _model = _make_real_scheduler(device)
try:
prompts = [[10, 20, 30]]
results = scheduler.run_batch(prompts, max_tokens=3, temperature=1.0)
assert len(results[0]) <= 3
finally:
scheduler.stop()
def test_run_batch_stop_id_terminates():
"""A token matching stop_ids terminates generation for that prompt."""
device = "cuda" if torch.cuda.is_available() else "cpu"
scheduler, _tok, _model = _make_real_scheduler(device)
try:
prompts = [[10, 20, 30]]
results = scheduler.run_batch(prompts, max_tokens=32, temperature=1.0)
# If stop token 2 was produced, it is the last token
if results[0] and results[0][-1] == 2:
# No tokens after stop should exist (since we terminate)
assert 2 not in results[0][:-1]
finally:
scheduler.stop()
def test_run_batch_empty_prompts():
"""Empty prompt list yields empty result list."""
device = "cuda" if torch.cuda.is_available() else "cpu"
scheduler, _tok, _model = _make_real_scheduler(device)
try:
assert scheduler.run_batch([], max_tokens=4) == []
finally:
scheduler.stop()
def test_run_batch_too_long_prompt_skipped():
"""A prompt longer than max_seq_len yields an empty result slot."""
device = "cuda" if torch.cuda.is_available() else "cpu"
scheduler, _tok, _model = _make_real_scheduler(device)
try:
long = list(range(100)) # > max_seq_len=64
results = scheduler.run_batch([long, [10, 20]], max_tokens=2)
assert results[0] == []
assert len(results[1]) <= 2
finally:
scheduler.stop()
+11 -2
View File
@@ -71,22 +71,31 @@ def _make_rollout_result(B=2, G=4, P=6, R=8, device="cpu"):
class _RecordingRunner: class _RecordingRunner:
"""Fake RolloutRunner that returns a fixed result and tracks calls.""" """Fake RolloutRunner returning a fixed result with freshness tracking.
Freshness is ``True`` on the first call after construction or after
:meth:`swap_result`; ``False`` on subsequent cached calls mirroring
the real ``RolloutRunner`` contract without invoking generation.
"""
def __init__(self, result): def __init__(self, result):
self.result = result self.result = result
self.calls = 0 self.calls = 0
self.step_calls = 0 self.step_calls = 0
self._fresh = True
def __call__(self, batch): def __call__(self, batch):
self.calls += 1 self.calls += 1
return self.result fresh = self._fresh
self._fresh = False
return self.result, fresh
def step(self): def step(self):
self.step_calls += 1 self.step_calls += 1
def swap_result(self, result): def swap_result(self, result):
self.result = result self.result = result
self._fresh = True
@pytest.fixture @pytest.fixture
+129 -112
View File
@@ -1,26 +1,23 @@
"""Unit tests for the online rollout module. """Unit tests for the online rollout module.
Covers :class:`RolloutResult`, :class:`BaseRewardModel`, Covers :class:`RolloutResult` / :class:`RawRollout`, :class:`BaseRewardModel`,
:func:`generate_responses`, and :class:`RolloutRunner` including :class:`RolloutGenerator` (KV-cache-backed via :class:`InferenceScheduler.run_batch`)
its internal cache and rollout-interval trigger logic. and :class:`RolloutRunner` including its internal cache and rollout-interval
trigger logic.
""" """
import pytest import pytest
import torch import torch
from astrai.config.model_config import AutoRegressiveLMConfig from astrai.config.model_config import AutoRegressiveLMConfig
from astrai.inference.sample import ( from astrai.inference.core.scheduler import InferenceScheduler
SamplingPipeline,
TemperatureStrategy,
TopKStrategy,
TopPStrategy,
)
from astrai.model.transformer import AutoRegressiveLM from astrai.model.transformer import AutoRegressiveLM
from astrai.trainer.rollout import ( from astrai.trainer.rollout import (
BaseRewardModel, BaseRewardModel,
RawRollout,
RolloutGenerator,
RolloutResult, RolloutResult,
RolloutRunner, RolloutRunner,
generate_responses,
) )
@@ -57,10 +54,6 @@ class ConstantRewardModel(BaseRewardModel):
return torch.full((B, G), float(self.value)) return torch.full((B, G), float(self.value))
class _FakeOldModel:
"""Placeholder old-model; RolloutRunner stores but never calls it."""
def _make_config(vocab_size=200, max_len=128): def _make_config(vocab_size=200, max_len=128):
return AutoRegressiveLMConfig( return AutoRegressiveLMConfig(
vocab_size=vocab_size, vocab_size=vocab_size,
@@ -81,9 +74,13 @@ def _make_model(device):
return m, cfg return m, cfg
def _make_pipeline(): def _make_scheduler(model, tokenizer, max_batch_size=8, max_len=128):
return SamplingPipeline( return InferenceScheduler(
[TemperatureStrategy(1.0), TopKStrategy(0), TopPStrategy(1.0)] model=model,
tokenizer=tokenizer,
max_batch_size=max_batch_size,
max_seq_len=max_len,
max_prompt_len=max_len,
) )
@@ -93,14 +90,28 @@ def _make_prompt_batch(batch_size=2, prompt_len=6, device="cpu"):
return {"input_ids": ids, "attention_mask": mask} return {"input_ids": ids, "attention_mask": mask}
def test_rollout_result_fields(): def test_raw_rollout_fields():
r = RawRollout(
prompts=torch.zeros(2, 4, dtype=torch.long),
responses=torch.zeros(2, 3, 5, dtype=torch.long),
response_mask=torch.ones(2, 3, 5, dtype=torch.bool),
logprobs_old=torch.zeros(2, 3, 5),
)
assert r.prompts.shape == (2, 4)
assert r.responses.shape == (2, 3, 5)
assert r.prompt_texts == []
assert r.response_texts == []
def test_rollout_result_inherits_raw_rollout_fields():
r = RolloutResult( r = RolloutResult(
prompts=torch.zeros(2, 4, dtype=torch.long), prompts=torch.zeros(2, 4, dtype=torch.long),
responses=torch.zeros(2, 3, 5, dtype=torch.long), responses=torch.zeros(2, 3, 5, dtype=torch.long),
response_mask=torch.ones(2, 3, 5, dtype=torch.bool), response_mask=torch.ones(2, 3, 5, dtype=torch.bool),
rewards=torch.zeros(2, 3),
logprobs_old=torch.zeros(2, 3, 5), logprobs_old=torch.zeros(2, 3, 5),
rewards=torch.zeros(2, 3),
) )
assert r.rewards.shape == (2, 3)
assert r.prompts.shape == (2, 4) assert r.prompts.shape == (2, 4)
assert r.responses.shape == (2, 3, 5) assert r.responses.shape == (2, 3, 5)
assert r.prompt_texts == [] assert r.prompt_texts == []
@@ -119,94 +130,96 @@ def test_constant_reward_model_shape():
assert torch.all(out == 0.5) assert torch.all(out == 0.5)
def test_generate_responses_shapes(): @pytest.fixture
device = "cuda" if torch.cuda.is_available() else "cpu" def device():
return "cuda" if torch.cuda.is_available() else "cpu"
def _make_generator(device, **kw):
model, _ = _make_model(device) model, _ = _make_model(device)
pipeline = _make_pipeline() tokenizer = FakeTokenizer()
ids = torch.randint(3, 200, (2, 4), device=device) scheduler = _make_scheduler(
mask = torch.ones(2, 4, dtype=torch.bool, device=device) model,
tokenizer,
out = generate_responses( max_batch_size=kw.get("max_batch_size", 8),
model=model, max_len=kw.get("max_len", 128),
input_ids=ids,
attention_mask=mask,
max_new_tokens=8,
sampling_pipeline=pipeline,
stop_ids=[],
) )
assert out["generated_ids"].shape == (2, 8) generator = RolloutGenerator(
assert out["generated_mask"].shape == (2, 8) scheduler=scheduler,
assert out["logprobs"].shape == (2, 8) tokenizer=tokenizer,
max_tokens=kw.get("max_tokens", 8),
group_size=kw.get("group_size", 2),
def test_generate_responses_stops_on_stop_id(): temperature=kw.get("temperature", 1.0),
device = "cuda" if torch.cuda.is_available() else "cpu" top_k=kw.get("top_k", 0),
model, _ = _make_model(device) top_p=kw.get("top_p", 1.0),
pipeline = _make_pipeline()
ids = torch.randint(3, 200, (1, 3), device=device)
mask = torch.ones(1, 3, dtype=torch.bool, device=device)
out = generate_responses(
model=model,
input_ids=ids,
attention_mask=mask,
max_new_tokens=16,
sampling_pipeline=pipeline,
stop_ids=[7],
) )
gen = out["generated_ids"][0] return generator, model
mask = out["generated_mask"][0]
# If a 7 appeared, all tokens after it must be pad (mask False).
nonzero_stop = (gen == 7).nonzero()
if nonzero_stop.numel():
first = nonzero_stop[0].item()
assert mask[first + 1 :].sum() == 0
def test_generate_responses_logprobs_match_tokens(): def test_rollout_generator_shapes(device):
"""logprobs[i] must be the logprob of generated_ids[i].""" gen, _ = _make_generator(device, group_size=3, max_tokens=5)
device = "cuda" if torch.cuda.is_available() else "cpu" batch = _make_prompt_batch(batch_size=2, prompt_len=4, device=device)
model, _ = _make_model(device) r = gen.generate(batch)
pipeline = _make_pipeline() assert r.prompts.shape == (2, 4)
ids = torch.randint(3, 200, (1, 2), device=device) assert r.responses.shape == (2, 3, 5)
mask = torch.ones(1, 2, dtype=torch.bool, device=device) assert r.response_mask.shape == (2, 3, 5)
assert r.logprobs_old.shape == (2, 3, 5)
assert len(r.prompt_texts) == 2
assert len(r.response_texts) == 2
assert len(r.response_texts[0]) == 3
out = generate_responses(
model=model, def test_rollout_generator_mask_matches_responses(device):
input_ids=ids, """Positions beyond a response's length are pad (mask False)."""
attention_mask=mask, gen, _ = _make_generator(device, group_size=2, max_tokens=6)
max_new_tokens=4, batch = _make_prompt_batch(batch_size=2, prompt_len=4, device=device)
sampling_pipeline=pipeline, r = gen.generate(batch)
stop_ids=[], for i in range(2):
) for g in range(2):
gen = out["generated_ids"][0] real = r.response_mask[i, g].sum().item()
lp = out["logprobs"][0] # Pad positions should be 0
for i in range(4): assert r.responses[i, g, real:].sum() == 0
if gen[i] == 0 and not out["generated_mask"][0, i]: # logprobs after the real tokens are 0 (padding)
continue if real < r.logprobs_old.size(-1):
assert lp[i] <= 0.0 assert torch.all(r.logprobs_old[i, g, real:] == 0)
def test_rollout_generator_logprobs_are_nonpositive(device):
"""Behaviour-policy logprobs of sampled tokens should be ≤ 0."""
gen, _ = _make_generator(device, group_size=2, max_tokens=4)
batch = _make_prompt_batch(batch_size=1, prompt_len=3, device=device)
r = gen.generate(batch)
for i in range(1):
for g in range(2):
mask = r.response_mask[i, g]
lp = r.logprobs_old[i, g][mask]
assert torch.all(lp <= 1e-5)
def _make_runner(device, **kw): def _make_runner(device, **kw):
model, _ = _make_model(device) generator, model = _make_generator(
rm = ConstantRewardModel(1.0) device,
return RolloutRunner(
policy_model=model,
old_model=_FakeOldModel(),
tokenizer=FakeTokenizer(),
reward_model=rm,
sampling_pipeline=_make_pipeline(),
max_tokens=kw.get("max_tokens", 8),
group_size=kw.get("group_size", 2), group_size=kw.get("group_size", 2),
rollout_interval=kw.get("rollout_interval", 2), max_tokens=kw.get("max_tokens", 8),
), model max_batch_size=kw.get("max_batch_size", 8),
max_len=kw.get("max_len", 128),
)
rm = ConstantRewardModel(1.0)
return (
RolloutRunner(
generator=generator,
reward_model=rm,
rollout_interval=kw.get("rollout_interval", 2),
),
model,
)
def test_rollout_runner_shapes(): def test_rollout_runner_shapes(device):
device = "cuda" if torch.cuda.is_available() else "cpu"
runner, _ = _make_runner(device, group_size=3, max_tokens=5) runner, _ = _make_runner(device, group_size=3, max_tokens=5)
batch = _make_prompt_batch(batch_size=2, prompt_len=4, device=device) batch = _make_prompt_batch(batch_size=2, prompt_len=4, device=device)
r = runner(batch) r, is_fresh = runner(batch)
assert is_fresh
assert r.prompts.shape == (2, 4) assert r.prompts.shape == (2, 4)
assert r.responses.shape == (2, 3, 5) assert r.responses.shape == (2, 3, 5)
assert r.response_mask.shape == (2, 3, 5) assert r.response_mask.shape == (2, 3, 5)
@@ -217,48 +230,52 @@ def test_rollout_runner_shapes():
assert len(r.response_texts[0]) == 3 assert len(r.response_texts[0]) == 3
def test_rollout_runner_cache_returns_same_object(): def test_rollout_runner_cache_returns_stale_flag(device):
device = "cuda" if torch.cuda.is_available() else "cpu"
runner, _ = _make_runner(device, rollout_interval=10) runner, _ = _make_runner(device, rollout_interval=10)
batch = _make_prompt_batch(device=device) batch = _make_prompt_batch(device=device)
r1 = runner(batch) r1, fresh1 = runner(batch)
r2 = runner(batch) r2, fresh2 = runner(batch)
assert r1 is r2 assert r1 is r2
assert fresh1 is True
assert fresh2 is False
def test_rollout_runner_step_triggers_new_rollout(): def test_rollout_runner_step_triggers_new_rollout(device):
device = "cuda" if torch.cuda.is_available() else "cpu"
runner, _ = _make_runner(device, rollout_interval=2) runner, _ = _make_runner(device, rollout_interval=2)
batch = _make_prompt_batch(device=device) batch = _make_prompt_batch(device=device)
r1 = runner(batch) r1, fresh1 = runner(batch)
assert fresh1 is True
runner.step() runner.step()
# interval=2 means trigger when _steps_since_rollout >= 2; 1 step not enough # interval=2 means trigger when _steps_since_rollout >= 2; 1 step not enough
r2 = runner(batch) r2, fresh2 = runner(batch)
assert r1 is r2 assert r2 is r1
assert fresh2 is False
runner.step() runner.step()
# Now _steps_since_rollout == 2 -> re-rollout # Now _steps_since_rollout == 2 -> re-rollout
r3 = runner(batch) r3, fresh3 = runner(batch)
assert r3 is not r1 assert r3 is not r1
assert fresh3 is True
def test_rollout_runner_clear_cache_forces_rerun(): def test_rollout_runner_clear_cache_forces_rerun(device):
device = "cuda" if torch.cuda.is_available() else "cpu"
runner, _ = _make_runner(device, rollout_interval=100) runner, _ = _make_runner(device, rollout_interval=100)
batch = _make_prompt_batch(device=device) batch = _make_prompt_batch(device=device)
r1 = runner(batch) r1, _ = runner(batch)
runner.clear_cache() runner.clear_cache()
r2 = runner(batch) r2, fresh2 = runner(batch)
assert r2 is not r1 assert r2 is not r1
assert fresh2 is True
def test_rollout_runner_step_resets_counter(): def test_rollout_runner_step_resets_counter(device):
device = "cuda" if torch.cuda.is_available() else "cpu"
runner, _ = _make_runner(device, rollout_interval=1) runner, _ = _make_runner(device, rollout_interval=1)
batch = _make_prompt_batch(device=device) batch = _make_prompt_batch(device=device)
r1 = runner(batch) r1, _ = runner(batch)
runner.step() runner.step()
r2 = runner(batch) r2, fresh2 = runner(batch)
assert r2 is not r1 assert r2 is not r1
assert fresh2 is True
# Counter reset after rollout; second call w/o step should be cached. # Counter reset after rollout; second call w/o step should be cached.
r3 = runner(batch) r3, fresh3 = runner(batch)
assert r3 is r2 assert r3 is r2
assert fresh3 is False