Skip to content
Closed
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
49 changes: 49 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 @@ -2065,6 +2069,11 @@ def build_cmd(
return vLLMConfig.build_cmd_from_args(args)


# Keep this list aligned with SGLang's deterministic inference documentation:
# https://docs.sglang.ai/advanced_features/deterministic_inference.html
_SGLANG_DETERMINISTIC_ATTENTION_BACKENDS = frozenset({"flashinfer", "fa3", "triton"})


@dataclass
class SGLangConfig:
"""Configuration for SGLang runtime. Refer to:
Expand Down Expand Up @@ -2098,6 +2107,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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good point — addressed in efef650.

SGLangConfig.build_args() now emits a warning when enable_deterministic_inference=True and an explicitly configured attention_backend is outside {flashinfer, fa3, triton}. attention_backend=None remains allowed so SGLang can select its default backend.

I also added tests covering all documented backends, None, and an explicitly unsupported backend.

enable_deterministic_inference: bool = False
enable_multimodal: bool = False
sampling_backend: str | None = None
context_length: int | None = 32768
Expand Down Expand Up @@ -2181,6 +2191,19 @@ def build_args(
node_rank: int = 0,
pp_size: int = 1,
):
attention_backend = sglang_config.attention_backend
if (
sglang_config.enable_deterministic_inference
and attention_backend is not None
and attention_backend.lower()
not in _SGLANG_DETERMINISTIC_ATTENTION_BACKENDS
):
logger.warning(
"SGLang deterministic inference is only documented for attention "
"backends %s; configured attention_backend=%r may be non-deterministic.",
sorted(_SGLANG_DETERMINISTIC_ATTENTION_BACKENDS),
attention_backend,
)
# Map "all-linear" to "all"
args: dict = conf_as_dict(sglang_config)
if sglang_config.enable_multithread_load:
Expand Down Expand Up @@ -2415,6 +2438,25 @@ class InferenceEngineConfig:
"help": "Whether to output verbose tracing messages for each generation request."
},
)
deterministic_sampling: bool = field(
default=False,
metadata={
Comment on lines +2441 to +2443

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

You’re right — this implementation does not guarantee a stable task-to-weight-version mapping when max_head_offpolicyness > 0. End-to-end determinism is currently supported only with max_head_offpolicyness=0.

Addressed in efef650:

  • InferenceEngineConfig now emits a warning when deterministic_sampling=True and max_head_offpolicyness>0.
  • The CLI help explicitly documents the max_head_offpolicyness=0 requirement.
  • The membership frontier was removed because stable seeds and result ordering cannot fix scheduling-dependent version assignment.
  • Tests cover the warning and non-warning configurations.

Supporting deterministic asynchronous rollout would require a separate contract, likely binding each task to a model version at submission time and defining retry/rejection behavior around that binding. That is outside the scope of this PR.

"help": "Use stable request seeds for internal OpenAI-proxy/data-proxy "
"sessions, canonical group ordering, and task-ID ordering of completed "
"rollout results. Concurrent SGLang generation also requires "
"sglang.enable_deterministic_inference. End-to-end determinism is only "
"supported with max_head_offpolicyness=0."
},
)
serialize_group_samples: bool = field(
default=False,
metadata={
"help": "Run RolloutControllerV2 samples within each group sequentially "
"instead of concurrently. This provides stable within-group member "
"submission order at the cost of rollout throughput; it does not "
"serialize requests across groups."
},
)
check_trajectory_format: bool = field(
default=False,
metadata={
Expand Down Expand Up @@ -2559,6 +2601,13 @@ def __post_init__(self):
)
if not self.admin_api_key or not self.admin_api_key.strip():
raise ValueError("admin_api_key must not be empty or whitespace-only")
if self.deterministic_sampling and self.max_head_offpolicyness > 0:
logger.warning(
"deterministic_sampling=True with max_head_offpolicyness=%d does "
"not guarantee deterministic task-to-weight-version mapping; "
"set max_head_offpolicyness=0 for end-to-end determinism.",
self.max_head_offpolicyness,
)
if (
self._version == "v2"
and self.agent is not None
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
2 changes: 2 additions & 0 deletions areal/engine/vllm_remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ def build_generation_request(
}
if gconfig.stop:
payload["stop"] = gconfig.stop
if gconfig.seed is not None:
payload["seed"] = gconfig.seed

if with_lora:
lora_name = gconfig.lora_name
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