Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions areal/api/cli_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,10 @@ class GenerationHyperparameters:
)
},
)
seed: int | None = field(
default=None,
metadata={"help": "Per-request sampling seed sent to the inference backend."},
)
lora_name: str = field(
default="default_lora",
metadata={"help": "Lora name to be used for this generation."},
Expand Down Expand Up @@ -2098,6 +2102,7 @@ class SGLangConfig:
enable_memory_saver: bool = False
allow_auto_truncate: bool = False
attention_backend: str | None = "fa3"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

SGLang documents deterministic inference support only for the flashinfer, fa3,
and triton attention backends. At the moment enable_deterministic_inference is
forwarded for any configured backend, which can give users a false
determinism guarantee.

Could we emit a warning when deterministic inference is enabled with an
explicit attention_backend outside {flashinfer, fa3, triton}? None can remain
allowed because it delegates to the SGLang default.

enable_deterministic_inference: bool = False
enable_multimodal: bool = False
sampling_backend: str | None = None
context_length: int | None = 32768
Expand Down Expand Up @@ -2415,6 +2420,22 @@ class InferenceEngineConfig:
"help": "Whether to output verbose tracing messages for each generation request."
},
)
deterministic_sampling: bool = field(
default=False,
metadata={
Comment on lines +2423 to +2425

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could you clarify the determinism contract for
max_head_offpolicyness > 0?

If asynchronous staleness is supported, how does this implementation guarantee
a stable task-to-weight-version mapping across runs? The inference version is
read when a generation request is actually sent, so the same logical task may
use different model versions depending on scheduling timing. Stable seeds,
result ordering, and a membership frontier do not appear to fix that.

If end-to-end determinism is only supported with
max_head_offpolicyness=0, should the configuration emit a warning or document
that requirement explicitly?

"help": "Use stable OpenAI-proxy request seeds and canonical group "
"and batch ordering. Concurrent SGLang generation also requires "
"sglang.enable_deterministic_inference."
},
)
serialize_group_samples: bool = field(
default=False,
metadata={
"help": "Run RolloutControllerV2 samples within each group sequentially "
"instead of concurrently. This stabilizes SGLang batching for strict "
"reproducibility at the cost of rollout throughput."
},
)
check_trajectory_format: bool = field(
default=False,
metadata={
Expand Down
2 changes: 2 additions & 0 deletions areal/engine/sglang_remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ def build_generation_request(
}
if stop:
sample_params["stop"] = stop
if gconfig.seed is not None:
sample_params["sampling_seed"] = gconfig.seed

payload = {
"input_ids": req.input_ids.copy(),
Expand Down
6 changes: 6 additions & 0 deletions areal/experimental/openai/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,7 @@ async def create(
max_total_tokens: int | None | NotGiven = NOT_GIVEN,
metadata: Metadata | None | NotGiven = NOT_GIVEN,
n: int | None | NotGiven = NOT_GIVEN,
seed: int | None | NotGiven = NOT_GIVEN,
stop: str | None | list[str] | None | NotGiven = NOT_GIVEN,
store: bool | None | NotGiven = NOT_GIVEN,
temperature: float | None | NotGiven = NOT_GIVEN,
Expand All @@ -669,6 +670,7 @@ async def create(
max_total_tokens: int | None | NotGiven = NOT_GIVEN,
metadata: Metadata | None | NotGiven = NOT_GIVEN,
n: int | None | NotGiven = NOT_GIVEN,
seed: int | None | NotGiven = NOT_GIVEN,
stop: str | None | list[str] | None | NotGiven = NOT_GIVEN,
store: bool | None | NotGiven = NOT_GIVEN,
temperature: float | None | NotGiven = NOT_GIVEN,
Expand All @@ -691,6 +693,7 @@ async def create(
max_total_tokens: int | None | NotGiven = NOT_GIVEN,
metadata: Metadata | None | NotGiven = NOT_GIVEN,
n: int | None | NotGiven = NOT_GIVEN,
seed: int | None | NotGiven = NOT_GIVEN,
stop: str | None | list[str] | None | NotGiven = NOT_GIVEN,
store: bool | None | NotGiven = NOT_GIVEN,
temperature: float | None | NotGiven = NOT_GIVEN,
Expand Down Expand Up @@ -883,6 +886,7 @@ async def create(
greedy=temp == 0,
frequency_penalty=frequency_penalty,
lora_name=self.lora_name,
seed=None if is_omitted(seed) else seed,
stop_token_ids=list(
set([self.tokenizer.eos_token_id, self.tokenizer.pad_token_id])
),
Expand Down Expand Up @@ -1136,6 +1140,7 @@ async def create(
instructions: str | None | NotGiven = NOT_GIVEN,
max_output_tokens: int | None | NotGiven = NOT_GIVEN,
metadata: Metadata | None | NotGiven = NOT_GIVEN,
seed: int | None | NotGiven = NOT_GIVEN,
tool_choice: response_create_params.ToolChoice | NotGiven = NOT_GIVEN,
tools: Iterable[ToolParam] | NotGiven = NOT_GIVEN,
temperature: float | None | NotGiven = NOT_GIVEN,
Expand Down Expand Up @@ -1291,6 +1296,7 @@ async def create(
greedy=temp == 0,
frequency_penalty=frequency_penalty,
lora_name=self.lora_name,
seed=None if is_omitted(seed) else seed,
stop_token_ids=list(
set([self.tokenizer.eos_token_id, self.tokenizer.pad_token_id])
),
Expand Down
32 changes: 30 additions & 2 deletions areal/experimental/openai/proxy/proxy_rollout_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@
_warn_lock = threading.Lock()


def _deterministic_sampling_seed(session_id: str, request_index: int) -> int:
return seeding.derive_deterministic_seed(session_id, request_index)


def _warn_once(msg: str) -> None:
"""Log a warning message, optionally only once if AREAL_PROXY_WARN_ONCE=1."""
if not _warn_once_enabled:
Expand Down Expand Up @@ -126,6 +130,9 @@ def _warn_once(msg: str) -> None:
_allocated_ports: set[int] = set()
_port_alloc_lock = asyncio.Lock()

# Deterministic sampling (set from InferenceEngineConfig at setup time).
_deterministic_sampling: bool = False

# Server config (needed for name_resolve registration)
_experiment_name: str | None = None
_trial_name: str | None = None
Expand Down Expand Up @@ -270,8 +277,9 @@ async def alloc_ports(raw_request: Request):

def _setup_openai_client():
global _openai_client, _session_timeout_seconds, _admin_api_key
global _message_preprocessors, _prefix_matcher
global _message_preprocessors, _prefix_matcher, _deterministic_sampling
config = _engine.config
_deterministic_sampling = bool(getattr(config, "deterministic_sampling", False))
tokenizer = load_hf_tokenizer(config.tokenizer_path)
agent_cfg = config.agent
_openai_client = ArealOpenAI(
Expand Down Expand Up @@ -488,6 +496,7 @@ def start_session(request: StartSessionRequest) -> StartSessionResponse:
_session_cache[session_id] = SessionData(
session_id=session_id,
prefix_matcher=_prefix_matcher,
sampling_seed_identity=task_id,
)
_api_key_to_session[session_api_key] = session_id
_session_to_api_key[session_id] = session_api_key
Expand Down Expand Up @@ -574,8 +583,11 @@ async def _call_client_create(
status_code=410, detail=f"Session {session_id} already ended or expired"
)
session_data = _session_cache[session_id]
session_data.update_last_access()

session_data.update_last_access()
request_index = (
session_data.next_sampling_request_index() if _deterministic_sampling else None
)

sig = inspect.signature(create_fn)
areal_client_ignored_args = ["model"] + (extra_ignored_args or [])
Expand Down Expand Up @@ -621,6 +633,22 @@ def _is_default_value(k: str, v: Any) -> bool:
kwargs["top_p"] = 1.0
_warn_once("top_p not set in request, defaulting to 1.0")

if (
_deterministic_sampling
and kwargs.get("seed") is None
and "seed" in areal_client_allowed_args
):
assert request_index is not None
# The logical identity excludes the physical session collision suffix.
# Reserve request indices at ingress so concurrent requests remain
# distinct without holding a lock during inference.
# TODO(agent): Strict mapping of concurrent sibling requests to seeds
# requires a stable caller-provided request identity. Group samples use
# separate sessions, so their sample_idx-based identities are stable.
kwargs["seed"] = _deterministic_sampling_seed(
session_data.sampling_seed_identity, request_index
)

# Strip stream from request body to prevent it from bypassing the explicit
# `stream` parameter. Without this, a request with {"stream": true} would
# leak through kwargs and cause the client to return an AsyncGenerator even
Expand Down
16 changes: 15 additions & 1 deletion areal/experimental/openai/proxy/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,14 @@ class ExportTrajectoriesResponse(BaseModel):
class SessionData:
"""Data associated with a single RL session."""

def __init__(self, session_id: str, prefix_matcher=None):
def __init__(
self,
session_id: str,
prefix_matcher=None,
sampling_seed_identity: str | None = None,
):
self.session_id = session_id
self.sampling_seed_identity = sampling_seed_identity or session_id

self._completed = False
self._completions = InteractionCache(
Expand All @@ -80,6 +86,14 @@ def __init__(self, session_id: str, prefix_matcher=None):
self._last_access_time = time.time()
self._end_time = None
self._lock = threading.Lock()
self._next_sampling_request_index = 0

def next_sampling_request_index(self) -> int:
"""Reserve a unique request index without serializing request execution."""
with self._lock:
request_index = self._next_sampling_request_index
self._next_sampling_request_index += 1
return request_index

def update_last_access(self):
"""Update the last access time for this session."""
Expand Down
14 changes: 11 additions & 3 deletions areal/experimental/openai/proxy/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,15 @@ async def _grant_capacity(self, session: aiohttp.ClientSession) -> None:
async def arun_episode(
self, engine: TRolloutEngine, data: dict[str, Any]
) -> dict[str, InteractionWithTokenLogpReward] | None:
task_id = workflow_context.get().task_id
context = workflow_context.get()
task_id = context.task_id
# Qualify the proxy session with the group sample index so each group
# member owns a distinct, run-stable session namespace.
proxy_task_id = (
f"{task_id}:{context.sample_idx}"
if context.sample_idx is not None
else str(task_id)
)

http_session = await workflow_context.get_aiohttp_session()

Expand All @@ -190,7 +198,7 @@ async def arun_episode(
proxy_client = OpenAIProxyClient(
session=http_session,
base_url=self.proxy_addr,
task_id=str(task_id),
task_id=proxy_task_id,
admin_api_key=self._admin_api_key,
)
proxy_client.session_id = session_info.session_id
Expand Down Expand Up @@ -220,7 +228,7 @@ async def arun_episode(
proxy_client = OpenAIProxyClient(
session=http_session,
base_url=self.proxy_addr,
task_id=str(task_id),
task_id=proxy_task_id,
admin_api_key=self._admin_api_key,
)
async with proxy_client:
Expand Down
3 changes: 3 additions & 0 deletions areal/infra/controller/rollout_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ def initialize(
task_factory=self._create_submit_callback,
staleness_manager=self._staleness_manager,
enable_tracing=self.config.enable_rollout_tracing,
deterministic_order=getattr(self.config, "deterministic_sampling", False),
)
# Initialize the dispatcher's async task runner
self._dispatcher.initialize(logger=logger)
Expand Down Expand Up @@ -960,6 +961,8 @@ def submit(
# `arun_episode` should return None instead.
if task_id is None:
task_id = self._task_id_generator.next()
else:
self._task_id_generator.reserve_at_least(task_id)
task_input = _RemoteRolloutTaskInput(
data=data,
workflow=workflow_str,
Expand Down
31 changes: 26 additions & 5 deletions areal/infra/remote_inf_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,32 @@ async def arun_episode(
) -> dict[str, Any] | None:
from areal.experimental.openai import InteractionWithTokenLogpReward

results = await asyncio.gather(
*[self.workflow.arun_episode(engine, data) for _ in range(self.group_size)]
async def run_sample(sample_idx: int) -> tuple[int, Any]:
from areal.infra import workflow_context
from areal.infra.workflow_context import WorkflowContext

parent = workflow_context.get()
workflow_context.set(
WorkflowContext(
is_eval=parent.is_eval,
task_id=parent.task_id,
sample_idx=sample_idx,
)
)
result = await self.workflow.arun_episode(engine, data)
return sample_idx, result

indexed_results = await asyncio.gather(
*[run_sample(sample_idx) for sample_idx in range(self.group_size)]
)
indexed_results.sort(key=lambda item: item[0])
sample_indices = [sample_idx for sample_idx, _ in indexed_results]
if sample_indices != list(range(self.group_size)):
raise RuntimeError(
"Grouped rollout returned invalid sample indices: "
f"expected {list(range(self.group_size))}, got {sample_indices}"
)
results = [result for _, result in indexed_results]

valid_results = [r for r in results if r is not None]

Expand Down Expand Up @@ -1240,9 +1263,6 @@ def submit(
raise ValueError(
"workflow must be specified for submit (unless mode='online')"
)
if callback_addr:
self.workflow_executor.dispatcher.register_callback(task_id, callback_addr)

# Resolve workflow to a RolloutWorkflow instance
resolved_workflow = self._resolve_workflow(
workflow,
Expand All @@ -1260,6 +1280,7 @@ def submit(
should_accept_fn=resolved_should_accept_fn,
task_id=task_id,
is_eval=is_eval,
callback_addr=callback_addr,
)

def wait(
Expand Down
5 changes: 5 additions & 0 deletions areal/infra/workflow_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,15 @@ class WorkflowContext:
Whether the workflow is running in evaluation mode.
task_id : int | None
The task ID assigned by the workflow executor.
sample_idx : int | None
Index of this sample within its rollout group, when the workflow runs
under a grouped workflow. Gives group members a stable identity that
does not depend on completion order.
"""

is_eval: bool = False
task_id: int | None = None
sample_idx: int | None = None


_current_context: ContextVar[WorkflowContext] = ContextVar(
Expand Down
Loading
Loading