From 7e41d2838944d0df12df931d3fdf834620ad79ff Mon Sep 17 00:00:00 2001 From: tangxinyao Date: Mon, 24 Aug 2026 20:10:16 +0800 Subject: [PATCH 1/3] feat(serve): add SSE streaming support for /v1/chat/completions - Support stream=true in the chat completions endpoint - CUDA and MLX paths both use true streaming via the engine async API - CUDA path uses multiprocessing.Pipe from worker decode loop for per-token delivery - MLX path uses engine async call directly for per-token streaming - Handle client disconnect cancellation inline in both paths - Add ChatCompletionStreamDelta and ChatCompletionStreamChoice Pydantic models - Add generate_rollout_stream_async to engine for real-time token streaming - Add StreamTokenStep protocol message for pipe-based token delivery - Add CPU test suite for streaming behavior - Multi-choice (n>1) and tool-call requests fall back to full-response SSE Co-Authored-By: Claude --- areno/cli/serve.py | 387 +++++++++++++++++++++++++++++- areno/engine/api.py | 75 +++++- areno/engine/data/__init__.py | 11 +- areno/engine/data/batch.py | 14 ++ areno/engine/inference.py | 42 ++++ areno/engine/protocol.py | 77 ++++++ areno/engine/worker.py | 44 +++- tests/test_serve_streaming_cpu.py | 379 +++++++++++++++++++++++++++++ 8 files changed, 1019 insertions(+), 10 deletions(-) create mode 100644 tests/test_serve_streaming_cpu.py diff --git a/areno/cli/serve.py b/areno/cli/serve.py index b8d5666b..2ca76be9 100644 --- a/areno/cli/serve.py +++ b/areno/cli/serve.py @@ -9,15 +9,20 @@ import asyncio import base64 +import contextlib import io +import json as _json import time +import uuid import warnings +from collections.abc import AsyncGenerator from dataclasses import dataclass, field from typing import Any, Literal from urllib.parse import urlparse import click from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from areno.api import MLX, BackendType, MlxConfig, SamplingParams, Trainer, default_backend_type @@ -121,6 +126,22 @@ class ChatCompletionResponse(BaseModel): usage: ChatCompletionUsage +class ChatCompletionStreamDelta(BaseModel): + """Streaming delta payload -- one or more fields populated per chunk.""" + + role: str | None = None + content: str | None = None + tool_calls: list[dict[str, Any]] | None = None + + +class ChatCompletionStreamChoice(BaseModel): + """One streaming choice within a chunk, carrying a delta instead of a message.""" + + index: int + delta: ChatCompletionStreamDelta + finish_reason: str | None = None + + @dataclass(frozen=True, slots=True) class BatchKey: """Hashable bundle of fields that must match for two requests to share a rollout. @@ -427,11 +448,9 @@ def models() -> dict[str, Any]: ], } - @app.post("/v1/chat/completions", response_model=ChatCompletionResponse) - async def chat_completions(raw_request: Request, request: ChatCompletionRequest) -> ChatCompletionResponse: + @app.post("/v1/chat/completions") + async def chat_completions(raw_request: Request, request: ChatCompletionRequest): """Validate the request, encode the prompt, run rollout, and await the response.""" - if request.stream: - raise HTTPException(status_code=400, detail="stream=true is not supported") if not request.messages: raise HTTPException(status_code=400, detail="messages must be non-empty") @@ -462,9 +481,29 @@ async def chat_completions(raw_request: Request, request: ChatCompletionRequest) ) if state.closing: raise HTTPException(status_code=503, detail="server is shutting down") + + if request.stream: + # Streaming: either CUDA true-streaming (pipe) or MLX synthetic SSE. + # No background task — cancellation is handled inline in each path. + stream_handler = ( + _stream_chat_completions_cuda + if isinstance(state.engine, _CudaServeRuntime) + else _stream_chat_completions + ) + return StreamingResponse( + stream_handler(state, raw_request, pending), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + task = asyncio.create_task(_run_request_task(app, pending)) state.active_tasks.add(task) task.add_done_callback(state.active_tasks.discard) + return await _await_pending_response(state, raw_request, pending) return app @@ -550,7 +589,7 @@ async def _run_request_rollout(app: FastAPI, item: PendingRequest) -> ChatComple return _build_response(state, item.request, item.prompt, rollout.response_ids, rollout.finish_reason) -def _set_future_result(future: asyncio.Future, response: ChatCompletionResponse) -> None: +def _set_future_result(future: asyncio.Future, response: ChatCompletionResponse | None) -> None: """Resolve `future` with `response` unless something else got there first.""" if response is not None and not future.done(): future.set_result(response) @@ -603,6 +642,230 @@ def _build_cancelled_response(state: ServeState, item: PendingRequest) -> ChatCo return _build_response(state, item.request, item.prompt, response_ids, finish_reasons) +async def _stream_chat_completions_cuda( + state: ServeState, raw_request: Request, pending: PendingRequest +) -> AsyncGenerator[str, None]: + """True SSE streaming for the CUDA engine path. + + Calls ``ArenoEngine.generate_rollout_stream_async``, which pushes + :class:`StreamTokenStep` objects through a ``multiprocessing.Pipe`` from + the worker decode loop. Tokens are decoded one-by-one and emitted as SSE + chunks as soon as they arrive — no synthetic prefix-decode pass needed. + + Multi-choice (``n > 1``) and tool-call requests fall back to the synthetic + SSE path via ``_await_pending_response``. + """ + request = pending.request + n_choices = int(request.n) + key = pending.key + tokenizer = state.tokenizer + + # Cancel handling: poll the underlying HTTP request directly in the + # stream loop so a client disconnect stops SSE emission promptly. + cancel_event = asyncio.Event() + + async def _watch_disconnect_for_stream() -> None: + while not cancel_event.is_set(): + if await raw_request.is_disconnected(): + cancel_event.set() + return + await asyncio.sleep(0.1) + + disconnect_task = asyncio.create_task(_watch_disconnect_for_stream()) + + # Multi-choice and tool-call requests still use the synthetic SSE path. + if n_choices != 1 or request.tools: + prompts = [pending.prompt for _ in range(n_choices)] + prompt_features = [pending.prompt_features for _ in prompts] if pending.prompt_features is not None else None + if not cancel_event.is_set(): + try: + rollout = await state.engine.generate_rollout_async( + prompts, + max_new_tokens=key.max_new_tokens, + max_running_prompts=max(state.max_running_prompts, len(prompts)), + max_prompt_len=max(state.max_model_len - key.max_new_tokens, len(pending.prompt)), + eos_token_id=key.eos_token_id, + sampling_params=SamplingParams( + temperature=key.temperature, + top_p=key.top_p, + top_k=key.top_k, + seed=key.seed, + stop_token_ids=key.stop_token_ids, + ), + prompt_features=prompt_features, + decode_progress_interval_s=0.0, + ) + except BaseException: + disconnect_task.cancel() + raise + response = _build_response(state, request, pending.prompt, rollout.response_ids, rollout.finish_reason) + async for chunk in _build_sse_chunks(response, rollout.response_ids, state.tokenizer): + yield chunk + disconnect_task.cancel() + return + + engine = state.engine._engine + prompts = [pending.prompt] + prompt_features = [pending.prompt_features] if pending.prompt_features is not None else None + + try: + stream = engine.generate_rollout_stream_async( + prompts, + max_new_tokens=key.max_new_tokens, + max_running_prompts=max(state.max_running_prompts, len(prompts)), + max_prompt_len=max(state.max_model_len - key.max_new_tokens, len(pending.prompt)), + eos_token_id=key.eos_token_id, + sampling_params=SamplingParams( + temperature=key.temperature, + top_p=key.top_p, + top_k=key.top_k, + seed=key.seed, + stop_token_ids=key.stop_token_ids, + ), + prompt_features=prompt_features, + decode_progress_interval_s=0.0, + ) + except BaseException: + disconnect_task.cancel() + raise + + response_id = f"chatcmpl-{uuid.uuid4().hex}" + created = int(time.time()) + model = request.model or state.model_path + + def _emit(chunk: dict[str, Any]) -> str: + return f"data: {_json.dumps(chunk, ensure_ascii=False)}\n\n" + + # Initial role chunk. + yield _emit( + { + "id": response_id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": [ + { + "index": 0, + "delta": {"role": "assistant", "content": ""}, + "finish_reason": None, + } + ], + } + ) + + # Content chunks — one per token as they arrive from the worker. + collected: list[int] = [] + prev_text = "" + finish_reason: str | None = None + try: + async for step in stream: + if cancel_event.is_set(): + finish_reason = "cancelled" + break + collected.append(step.token_id) + cur_text = tokenizer.decode(collected, skip_special_tokens=False) + delta = cur_text[len(prev_text):] if cur_text.startswith(prev_text) else cur_text + prev_text = cur_text + if step.finish_reason is not None: + finish_reason = step.finish_reason + if delta: + yield _emit( + { + "id": response_id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": [ + { + "index": 0, + "delta": {"content": delta}, + "finish_reason": None, + } + ], + } + ) + finally: + disconnect_task.cancel() + # Clean up the stream generator (closes the underlying pipe). + with contextlib.suppress(Exception): + await stream.aclose() + + finish_reason = finish_reason or "stop" + yield _emit( + { + "id": response_id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": finish_reason, + } + ], + "usage": { + "prompt_tokens": len(pending.prompt), + "completion_tokens": len(collected), + "total_tokens": len(pending.prompt) + len(collected), + }, + } + ) + yield "data: [DONE]\n\n" + + +async def _stream_chat_completions( + state: ServeState, raw_request: Request, pending: PendingRequest +) -> AsyncGenerator[str, None]: + """Synthetic SSE streaming for the MLX path. + + Calls the engine directly (no background task), decodes the full token list + via incremental prefix-decoding, and emits OpenAI-compatible SSE chunks. + Disconnect is handled inline so a dropped client stops emission promptly. + """ + request = pending.request + key = pending.key + n_choices = int(request.n) + prompts = [pending.prompt for _ in range(n_choices)] + prompt_features = [pending.prompt_features for _ in prompts] if pending.prompt_features is not None else None + + cancel_event = asyncio.Event() + + async def _watch_disconnect_for_mlx() -> None: + while not cancel_event.is_set(): + if await raw_request.is_disconnected(): + cancel_event.set() + return + await asyncio.sleep(0.1) + + disconnect_task = asyncio.create_task(_watch_disconnect_for_mlx()) + try: + if not cancel_event.is_set(): + rollout = await state.engine.generate_rollout_async( + prompts, + max_new_tokens=key.max_new_tokens, + max_running_prompts=max(state.max_running_prompts, len(prompts)), + max_prompt_len=max(state.max_model_len - key.max_new_tokens, len(pending.prompt)), + eos_token_id=key.eos_token_id, + sampling_params=SamplingParams( + temperature=key.temperature, + top_p=key.top_p, + top_k=key.top_k, + seed=key.seed, + stop_token_ids=key.stop_token_ids, + ), + prompt_features=prompt_features, + decode_progress_interval_s=0.0, + ) + if cancel_event.is_set(): + return + response = _build_response(state, request, pending.prompt, rollout.response_ids, rollout.finish_reason) + async for chunk in _build_sse_chunks(response, rollout.response_ids, state.tokenizer): + yield chunk + finally: + disconnect_task.cancel() + + def _build_response( state: ServeState, request: ChatCompletionRequest, @@ -641,6 +904,120 @@ def _build_response_from( return ChatCompletionResponse(**data) +async def _build_sse_chunks( + response: ChatCompletionResponse, + response_ids: list[list[int]], + tokenizer: Any, +) -> AsyncGenerator[str, None]: + """Yield SSE chunks with token-level deltas via incremental partial decode. + + Each content chunk carries the delta text contributed by one additional + token, matching OpenAI's per-token streaming granularity. Multi-choice + and tool-call responses fall back to per-choice full-content chunks. + """ + + created = response.created + model = response.model + n_choices = len(response.choices) + + def _emit(chunk: dict[str, Any]) -> str: + return f"data: {_json.dumps(chunk, ensure_ascii=False)}\n\n" + + # -- Initial role chunk (all choices at once) ------------------------------------ + yield _emit( + { + "id": response.id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": [ + { + "index": c.index, + "delta": {"role": "assistant", "content": ""}, + "finish_reason": None, + } + for c in response.choices + ], + } + ) + + # -- Content chunks --------------------------------------------------------------- + if n_choices == 1 and response.choices[0].finish_reason != "tool_calls": + # Single-choice text response: emit token-level deltas. + choice = response.choices[0] + token_ids = response_ids[0] + prev_text = "" + for k in range(1, len(token_ids) + 1): + cur_text = tokenizer.decode(token_ids[:k], skip_special_tokens=False) + delta = cur_text[len(prev_text):] if cur_text.startswith(prev_text) else cur_text + prev_text = cur_text + if delta: + yield _emit( + { + "id": response.id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": [ + { + "index": choice.index, + "delta": {"content": delta}, + "finish_reason": None, + } + ], + } + ) + else: + # Multi-choice or tool-call response: emit per-choice deltas at once. + for c in response.choices: + delta: dict[str, Any] = {} + if c.message.get("content"): + delta["content"] = c.message["content"] + if c.message.get("tool_calls"): + delta["tool_calls"] = c.message["tool_calls"] + if delta: + yield _emit( + { + "id": response.id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": [ + { + "index": c.index, + "delta": delta, + "finish_reason": None, + } + ], + } + ) + + # -- Finish chunk (all choices + usage) ------------------------------------------- + yield _emit( + { + "id": response.id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": [ + { + "index": c.index, + "delta": {}, + "finish_reason": c.finish_reason, + } + for c in response.choices + ], + "usage": { + "prompt_tokens": response.usage.prompt_tokens, + "completion_tokens": response.usage.completion_tokens, + "total_tokens": response.usage.total_tokens, + }, + } + ) + + yield "data: [DONE]\n\n" + + def _encode_messages( tokenizer: Any, messages: list[ChatMessage], *, tools: list[dict[str, Any]] | None = None ) -> list[int]: diff --git a/areno/engine/api.py b/areno/engine/api.py index 8c3d96fe..da2a235c 100644 --- a/areno/engine/api.py +++ b/areno/engine/api.py @@ -20,7 +20,7 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import AsyncGenerator, Callable from itertools import count from typing import Any @@ -28,7 +28,7 @@ from areno.engine.checkpoints.io import resolve_model_path from areno.engine.config import EngineConfig, OptimizerConfig, RuntimeConfig -from areno.engine.data import RolloutOutput, SamplingParams, TrainStats, to_cpu +from areno.engine.data import RolloutOutput, SamplingParams, StreamTokenStep, TrainStats, to_cpu from areno.engine.protocol import ( EnsureRolesPayload, Op, @@ -400,6 +400,77 @@ async def generate_rollout_async( cancel_flags=cancel_flags, ) + async def generate_rollout_stream_async( + self, + prompts: list[list[int]], + *, + max_new_tokens: int, + max_running_prompts: int, + max_prompt_len: int | None = None, + eos_token_id: int | None = None, + sampling_params: SamplingParams | None = None, + prompt_features: list[dict[str, Any] | None] | None = None, + decode_progress_interval_s: float = 0.0, + cancel_flags: torch.Tensor | None = None, + ) -> AsyncGenerator[StreamTokenStep, None]: + """Streaming rollout: yield tokens incrementally through a pipe. + + Constructs the same ``RolloutPayload`` used by the blocking path, then + delegates to ``TPCluster.stream_call_async`` which creates a + ``multiprocessing.Pipe`` and wraps the worker-side callback into an + async generator. Each yielded :class:`StreamTokenStep` carries one + token for one prompt row. + + Only single-chunk prompts are supported (no prefill-budget splitting). + """ + from collections.abc import AsyncGenerator as _AsyncGenerator + + if not prompts: + raise ValueError("prompts must be non-empty") + if prompt_features is not None and len(prompt_features) != len(prompts): + raise ValueError("prompt_features must have the same length as prompts") + if max_running_prompts < 1: + raise ValueError("max_running_prompts must be >= 1") + sampling_params = sampling_params or SamplingParams() + rollout_max_prompt_len = ( + int(max_prompt_len) if max_prompt_len is not None else max(len(prompt) for prompt in prompts) + ) + rollout_max_cache_len = rollout_max_prompt_len + max_new_tokens + dp_size = int(self.config.dp_size) + prompts_by_dp = split_list_by_dp(prompts, int(self.config.dp_size)) + prompt_features_by_dp = None + if prompt_features is not None: + prompt_features_by_dp = split_list_by_dp(prompt_features, int(self.config.dp_size)) + prompt_indices_by_dp = split_list_by_dp( + list(range(len(prompts))), int(self.config.dp_size) + ) + local_running = max(max((len(rows) for rows in prompts_by_dp), default=0), 1) + cache_len = max(max(len(prompt) + max_new_tokens for prompt in prompts), rollout_max_cache_len) + max_blocks_per_seq = ceil_div(cache_len, self.config.runtime.kv_block_size) + payload = RolloutPayload( + prompts_by_dp=prompts_by_dp, + prompt_indices_by_dp=prompt_indices_by_dp, + prompt_features_by_dp=prompt_features_by_dp, + max_new_tokens=max_new_tokens, + eos_token_id=eos_token_id, + sampling_params=sampling_params, + max_running_seqs=local_running, + max_cache_len=cache_len, + max_blocks_per_seq=max_blocks_per_seq, + max_prefill_tokens=local_running * rollout_max_prompt_len, + num_blocks=local_running * max_blocks_per_seq, + block_size=self.config.runtime.kv_block_size, + decode_progress_interval_s=decode_progress_interval_s, + cancel_flags=cancel_flags, + cancel_indices_by_dp=split_list_by_dp( + list(range(len(prompts))), int(self.config.dp_size) + ) + if cancel_flags is not None + else None, + ) + async for step in self.cluster.stream_call_async(payload): + yield step + async def _generate_rollout_async_once( self, prompts: list[list[int]], diff --git a/areno/engine/data/__init__.py b/areno/engine/data/__init__.py index 2e6880a5..35253daa 100644 --- a/areno/engine/data/__init__.py +++ b/areno/engine/data/__init__.py @@ -11,7 +11,14 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from areno.engine.data.batch import RolloutOutput, SamplingParams, TrainStats, to_cpu, to_device + from areno.engine.data.batch import ( + RolloutOutput, + SamplingParams, + StreamTokenStep, + TrainStats, + to_cpu, + to_device, + ) def __getattr__(name: str): @@ -27,4 +34,4 @@ def __getattr__(name: str): return value -__all__ = ["RolloutOutput", "SamplingParams", "TrainStats", "to_cpu", "to_device"] +__all__ = ["RolloutOutput", "SamplingParams", "StreamTokenStep", "TrainStats", "to_cpu", "to_device"] diff --git a/areno/engine/data/batch.py b/areno/engine/data/batch.py index bfdd3536..0559e1a6 100644 --- a/areno/engine/data/batch.py +++ b/areno/engine/data/batch.py @@ -38,6 +38,20 @@ class SamplingParams: suppress_special_tokens: bool = True +@dataclass(slots=True) +class StreamTokenStep: + """Incremental token data pushed during a streaming rollout. + + Each step carries one token for one prompt row, plus an optional + finish reason when that token ends the sequence (``"stop"``, + ``"length"``, or ``"cancelled"``). + """ + + prompt_idx: int + token_id: int + finish_reason: str | None = None + + @dataclass(slots=True) class RolloutOutput: """Padded rollout tensors plus per-sequence Python token lists.""" diff --git a/areno/engine/inference.py b/areno/engine/inference.py index 260a1fb0..942dd1c0 100644 --- a/areno/engine/inference.py +++ b/areno/engine/inference.py @@ -37,6 +37,10 @@ FinishedRowsCallback = Callable[[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, str, tuple[int, ...]], None] RolloutRefillCallback = Callable[[InferenceBatchState], list[int]] +# (prompt_indices, token_ids, finish_reasons) — three parallel lists. +# finish_reasons entries are ``None`` for intermediate steps, ``"stop"``, +# ``"length"``, or ``"cancelled"`` when the token ends the sequence. +StreamTokenCallback = Callable[[list[int], list[int], list[str | None]], None] def _cancel_stop_token(stop_token_ids: list[int], eos_token_id: int | tuple[int, ...] | None) -> int: @@ -202,6 +206,7 @@ def infer_rollout( payload: RolloutPayload, finished_callback: FinishedRowsCallback | None = None, refill_callback: RolloutRefillCallback | None = None, + stream_callback: StreamTokenCallback | None = None, ) -> RolloutOutput | None: """Top-level rollout entry: prepare cache, generate, return on rank 0. @@ -253,6 +258,7 @@ def infer_rollout( prompt_indices=prompt_indices, finished_callback=finished_callback, refill_callback=refill_callback, + stream_callback=stream_callback, ) if ctx.is_rank0: return state.to_rollout() @@ -276,6 +282,7 @@ def _generate_rollout_tokens_no_sync( prompt_indices: list[int] | None = None, finished_callback: FinishedRowsCallback | None = None, refill_callback: RolloutRefillCallback | None = None, + stream_callback: StreamTokenCallback | None = None, ) -> None: """Prefill all prompts then decode up to `max_new_tokens` without DP-sync. @@ -366,6 +373,7 @@ def _generate_rollout_tokens_no_sync( stop_token_tensor, finished_callback, tuple(truncate_stop_token_ids), + stream_callback=stream_callback, ) if admitted is not None: ( @@ -451,6 +459,24 @@ def _generate_rollout_tokens_no_sync( cancelled = self._cancel_mask_for_active_rows(active_rows, cancel_flags, cancel_indices_tensor) if cancelled is not None: remove |= cancelled + # ---- stream callback -------------------------------------------------- + if stream_callback is not None and ctx.is_rank0: + prompt_ids = [prompt_indices_list[int(row)] for row in active_rows.detach().cpu().tolist()] + tokens_cpu = next_tokens.detach().cpu().tolist() + reasons: list[str | None] = [None] * active_count + if finished is not None: + for i in range(active_count): + if bool(finished[i].item()): + reasons[i] = "stop" + for i in range(active_count): + if bool(full_length[i].item()): + reasons[i] = "length" + if cancelled is not None: + for i in range(active_count): + if bool(cancelled[i].item()): + reasons[i] = "cancelled" + stream_callback(prompt_ids, tokens_cpu, reasons) + # ----------------------------------------------------------------------- if bool(remove.any().item()): if finished is not None and bool(finished.any().item()): self._mark_rollout_finished_rows( @@ -694,6 +720,7 @@ def _admit_pending_rollout_rows( stop_token_tensor: torch.Tensor | None, finished_callback: FinishedRowsCallback | None, truncate_stop_token_ids: tuple[int, ...], + stream_callback: StreamTokenCallback | None = None, ) -> ( tuple[ torch.Tensor, @@ -758,6 +785,21 @@ def _admit_pending_rollout_rows( remove |= finished full_length = response_lens[new_rows] >= state.max_new_tokens remove |= full_length + # ---- stream callback (prefill tokens) ---------------------------------- + ctx = get_tp_context() + if stream_callback is not None and ctx.is_rank0: + prompt_ids = [prompt_indices[int(row)] for row in new_rows.detach().cpu().tolist()] + tokens_cpu = new_tokens.detach().cpu().tolist() + reasons: list[str | None] = [None] * int(new_rows.numel()) + if finished is not None: + for i in range(int(new_rows.numel())): + if bool(finished[i].item()): + reasons[i] = "stop" + for i in range(int(new_rows.numel())): + if bool(full_length[i].item()): + reasons[i] = "length" + stream_callback(prompt_ids, tokens_cpu, reasons) + # ----------------------------------------------------------------------- if bool(remove.any().item()): if finished is not None and bool(finished.any().item()): self._mark_rollout_finished_rows( diff --git a/areno/engine/protocol.py b/areno/engine/protocol.py index abed8c4b..c0b75ae1 100644 --- a/areno/engine/protocol.py +++ b/areno/engine/protocol.py @@ -16,6 +16,7 @@ import socket import threading import traceback +from collections.abc import AsyncGenerator from dataclasses import dataclass from enum import Enum, auto from itertools import count @@ -33,6 +34,7 @@ class Op(Enum): TRAIN = auto() INFER_ROLLOUT = auto() + INFER_ROLLOUT_STREAM = auto() PROBE_ROLLOUT_CACHE = auto() ENSURE_ROLES = auto() SCORE_LOGPROBS = auto() @@ -92,6 +94,19 @@ class RolloutPayload: cancel_indices_by_dp: list[list[int]] | None = None +@dataclass(slots=True) +class StreamTokenPayload: + """Typed payload for Op.INFER_ROLLOUT_STREAM. + + Carries the rollout parameters plus the send-end of a + ``multiprocessing.Pipe`` that the worker uses to push incremental + :class:`~areno.engine.data.StreamTokenStep` objects. + """ + + rollout: RolloutPayload + stream_conn: Any # multiprocessing.Connection (send end, picklable) + + @dataclass(slots=True) class RolloutCacheProbePayload: """Typed payload for Op.PROBE_ROLLOUT_CACHE.""" @@ -539,6 +554,63 @@ def _dead_pending_workers(self, pending: set[int]) -> list[tuple[int, int | None proc.join(timeout=0) return dead + async def stream_call_async( + self, + payload: RolloutPayload, + *, + timeout: float | None = None, + ) -> AsyncGenerator[StreamTokenStep, None]: + """Submit a streaming rollout and yield tokens incrementally. + + Creates a :class:`multiprocessing.Pipe`, sends the send-end to every + worker alongside the rollout command, then reads + :class:`~areno.engine.data.StreamTokenStep` objects from the recv-end + as they arrive. The generator stops when the worker closes its end + of the pipe. + + Only TP rank-0 of each DP group writes to the pipe; other ranks + receive the command but do not emit. + """ + from areno.engine.data.batch import StreamTokenStep as _StreamTokenStep + + recv_conn, send_conn = mp.Pipe(duplex=False) + stream_payload = StreamTokenPayload(rollout=payload, stream_conn=send_conn) + + request_id = next(self._request_ids) + loop = asyncio.get_running_loop() + ack_future: asyncio.Future = loop.create_future() + # Only wait for TP rank-0 of each DP group (they are the ones that + # actually write to the pipe). For dp_size == 1 this is just rank 0. + dp_size = int(self.config.dp_size) + tp_size = self.config.tp_size + result_ranks = {dp_rank * tp_size for dp_rank in range(dp_size)} + self._submit_call( + Op.INFER_ROLLOUT_STREAM, stream_payload, + request_id=request_id, future=ack_future, loop=loop, result_ranks=result_ranks, + ) + try: + await asyncio.wait_for(ack_future, timeout=timeout) + except BaseException: + with self._pending_lock: + self._pending_calls.pop(request_id, None) + recv_conn.close() + raise + + # Read StreamTokenStep objects from the pipe in a thread so we never + # block the event loop on a blocking recv. + loop = asyncio.get_running_loop() + try: + while True: + step = await loop.run_in_executor(None, recv_conn.recv) + if isinstance(step, _StreamTokenStep): + yield step + else: + break + except EOFError: + pass + finally: + recv_conn.close() + def close(self) -> None: """Request shutdown and terminate workers that do not exit promptly.""" @@ -646,6 +718,11 @@ def _worker_entry( result_q.put((rank, WorkerResult(ok=True, payload=payload, request_id=request_id))) worker._current_request_ids = [] continue + if cmd.op is Op.INFER_ROLLOUT_STREAM: + worker._current_request_ids = [cmd.request_id] + worker.handle_stream_rollout(cmd) + worker._current_request_ids = [] + continue payload = worker.handle(cmd) result_q.put((rank, WorkerResult(ok=True, payload=payload, request_id=cmd.request_id))) except (KeyboardInterrupt, SystemExit): diff --git a/areno/engine/worker.py b/areno/engine/worker.py index 2c182500..69825668 100644 --- a/areno/engine/worker.py +++ b/areno/engine/worker.py @@ -22,7 +22,7 @@ from areno.api.backend.cuda.roles import RoleManager, WorkerRole from areno.engine.config import EngineConfig -from areno.engine.data import RolloutOutput +from areno.engine.data import RolloutOutput, StreamTokenStep from areno.engine.data.sampling import _truncate_generated from areno.engine.inference import InferCacheSpec, InferenceManager from areno.engine.modeling import build_model_on_device, build_optimizer, configure_multimodal_training, param_grad @@ -35,6 +35,7 @@ RolloutCacheProbePayload, RolloutPayload, SaveCheckpointPayload, + StreamTokenPayload, WorkerResult, ) from areno.engine.runtime.common import pad_rollout_rows @@ -369,6 +370,47 @@ def refill_waiting(state) -> list[int]: if idx not in sent ] + def handle_stream_rollout(self, cmd: Command) -> None: + """Run a streaming rollout, pushing tokens through the attached pipe. + + Extracts the pipe send-end from the ``StreamTokenPayload``, wraps it in + a ``stream_callback``, and drives ``infer_rollout``. The pipe is closed + after decode completes so the coordinator sees ``EOFError`` on the + recv-end. Only TP rank-0 of each DP group writes to the pipe. + + An early ack is sent via ``result_queue`` so the coordinator can start + reading the pipe before the decode loop finishes — this keeps + time-to-first-token low. + """ + payload = cmd.payload + stream_conn = payload.stream_conn + rollout = payload.rollout + ctx = get_tp_context() + + # Ack immediately so the coordinator unblocks and starts consuming the + # pipe while the decode loop is still running. + if ctx.is_rank0: + self._result_queue.put( + (self._rank, WorkerResult(ok=True, payload="ack", request_id=cmd.request_id)), + ) + + def _stream_callback(prompt_indices: list[int], token_ids: list[int], finish_reasons: list[str | None]) -> None: + for pi, tid, fr in zip(prompt_indices, token_ids, finish_reasons): + try: + stream_conn.send(StreamTokenStep(prompt_idx=pi, token_id=tid, finish_reason=fr)) + except Exception: + # Pipe may be closed if the client disconnected; we still + # let decode finish so the worker batch stays valid. + pass + + try: + self.inference.infer_rollout(rollout, stream_callback=_stream_callback) + finally: + try: + stream_conn.close() + except Exception: + pass + def _next_refill_command(self) -> Command | None: """Fetch the next queued command consistently across TP ranks.""" diff --git a/tests/test_serve_streaming_cpu.py b/tests/test_serve_streaming_cpu.py new file mode 100644 index 00000000..f746a9ae --- /dev/null +++ b/tests/test_serve_streaming_cpu.py @@ -0,0 +1,379 @@ +"""CPU integration tests for SSE streaming via the /v1/chat/completions endpoint. + +These tests exercise the full HTTP → StreamingResponse → SSE pipeline with a mocked +engine, verifying that the serve module produces OpenAI-compatible SSE output. +""" + +from __future__ import annotations + +import asyncio +import json +from contextlib import contextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx + +from areno.cli import serve as serve_mod +from areno.cli.serve import ( + ChatCompletionChoice, + ChatCompletionResponse, + ChatCompletionUsage, + _ServeRollout, +) + + +# -- Helpers ------------------------------------------------------------------ + + +def _text_response(content: str, finish_reason: str = "stop") -> ChatCompletionResponse: + """Build a single-choice text ChatCompletionResponse.""" + return ChatCompletionResponse( + id="chatcmpl-test", + object="chat.completion", + created=1234567890, + model="test-model", + choices=[ + ChatCompletionChoice( + index=0, + message={"role": "assistant", "content": content}, + finish_reason=finish_reason, + ) + ], + usage=ChatCompletionUsage(prompt_tokens=10, completion_tokens=5, total_tokens=15), + ) + + +def _tool_call_response() -> ChatCompletionResponse: + """Build a single-choice tool-call ChatCompletionResponse.""" + return ChatCompletionResponse( + id="chatcmpl-tool", + object="chat.completion", + created=1234567890, + model="test-model", + choices=[ + ChatCompletionChoice( + index=0, + message={ + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"city":"Beijing"}'}, + } + ], + }, + finish_reason="tool_calls", + ) + ], + usage=ChatCompletionUsage(prompt_tokens=8, completion_tokens=12, total_tokens=20), + ) + + +# -- Mock infrastructure ------------------------------------------------------ + + +@contextmanager +def _mock_serve(response: ChatCompletionResponse, *, response_ids: list[list[int]] | None = None): + """Mock serve-module internals so streaming requests resolve with *response*. + + When *response_ids* is given, the mock engine returns a ``_ServeRollout`` so + the SSE path exercises token-level incremental decode. The mock tokenizer + decodes ``[id_1, id_2, …]`` as ``"T1T2…"``, allowing per-token delta diffs. + """ + mock_tokenizer = MagicMock() + mock_tokenizer.decode = lambda ids, **kw: "".join(f"T{i}" for i in ids) + + mock_engine = MagicMock() + mock_engine.max_model_len = 4096 + mock_engine.tokenizer = mock_tokenizer + mock_engine.processor = None + if response_ids is not None: + mock_engine.generate_rollout_async = AsyncMock( + return_value=_ServeRollout( + response_ids=response_ids, + finish_reason=[response.choices[0].finish_reason for _ in response_ids], + ) + ) + else: + mock_engine.generate_rollout_async = AsyncMock( + return_value=_ServeRollout(response_ids=[[1]], finish_reason=["stop"]) + ) + # Store the raw engine so non-streaming _run_request_rollout can reach it. + mock_engine._engine = mock_engine + + mock_tokenizer.chat_template = None + + _create_app_patches = [ + patch.object(serve_mod, "default_backend_type", return_value=serve_mod.BackendType.MLX), + patch.object(serve_mod, "load_tokenizer", return_value=mock_tokenizer), + patch.object(serve_mod, "load_processor", return_value=None), + patch.object(serve_mod, "configure_chat_template_enable_thinking"), + patch.object(serve_mod, "_resolve_serve_attn_backend", return_value=("native", None)), + patch.object(serve_mod, "_create_serve_runtime", return_value=mock_engine), + patch.object(serve_mod, "get_tool_call_parser", return_value=MagicMock()), + patch.object(serve_mod, "infer_tool_call_parser_name", return_value=""), + ] + for p in _create_app_patches: + p.start() + + _orig_encode = serve_mod._encode_messages_with_features + _orig_stop_ids = serve_mod._stop_token_ids + _orig_eos_id = serve_mod._first_eos_token_id + + serve_mod._encode_messages_with_features = MagicMock(return_value=([1, 2, 3], None)) + serve_mod._stop_token_ids = MagicMock(return_value=()) + serve_mod._first_eos_token_id = MagicMock(return_value=None) + + try: + yield + finally: + serve_mod._encode_messages_with_features = _orig_encode + serve_mod._stop_token_ids = _orig_stop_ids + serve_mod._first_eos_token_id = _orig_eos_id + for p in _create_app_patches: + p.stop() + + +def _build_streaming_app(): + """Build a real FastAPI app via ``create_app`` with all heavy deps mocked. + + Must be called inside a ``_mock_serve`` context. + """ + return serve_mod.create_app( + model_path="/mock/model", + tp_size=1, + world_size=1, + max_running_prompts=4, + default_max_tokens=256, + decode_progress_interval_s=0.0, + ) + + +def _collect_sse_events(app, request_body: dict) -> list: + """Make a streaming HTTP request; return parsed SSE events. + + Each element is a ``dict`` (JSON data chunk) or ``"[DONE]"`` sentinel. + """ + + async def _run(): + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with client.stream("POST", "/v1/chat/completions", json=request_body) as resp: + assert resp.status_code == 200 + assert "text/event-stream" in resp.headers["content-type"] + + events: list = [] + async for line in resp.aiter_lines(): + if not line.startswith("data:"): + continue + payload = line[len("data:"):].strip() + if payload == "[DONE]": + events.append("[DONE]") + else: + events.append(json.loads(payload)) + return events + + return asyncio.run(_run()) + + +def _content_deltas(events: list) -> list[str]: + """Extract non-empty content delta strings from parsed SSE events.""" + return [ + c["choices"][0]["delta"]["content"] + for c in events + if c != "[DONE]" + and c.get("choices") + and c["choices"][0]["delta"].get("content", "") != "" + ] + + +def _without_sentinel(events: list) -> list[dict]: + """Return only the JSON data chunks, dropping ``[DONE]``.""" + return [e for e in events if e != "[DONE]"] + + +# -- Tests -------------------------------------------------------------------- + + +class TestStreamingEndpoint: + """Integration tests for the ``/v1/chat/completions`` streaming endpoint. + + Every test goes through the full HTTP stack with mocked engine and + tokenizer — no GPU or model weights needed. + """ + + @staticmethod + def _stream(n_tokens: int = 3, *, finish_reason: str = "stop") -> list: + """Stream a text response with *n_tokens* and return parsed SSE events.""" + response_ids = [[i + 1 for i in range(n_tokens)]] + with _mock_serve( + _text_response("ignored — SSE deltas come from tokenizer decode", finish_reason=finish_reason), + response_ids=response_ids, + ): + app = _build_streaming_app() + return _collect_sse_events( + app, + {"messages": [{"role": "user", "content": "Hi"}], "stream": True}, + ) + + # -- tests ----------------------------------------------------------------- + + def test_first_chunk_contains_role(self): + """The very first SSE data chunk must set ``delta.role = 'assistant'``.""" + events = self._stream() + assert events[0]["choices"][0]["delta"]["role"] == "assistant" + + def test_starts_with_chat_completion_chunk_object(self): + """Every data chunk must have ``object = 'chat.completion.chunk'``.""" + events = self._stream() + for chunk in _without_sentinel(events): + assert chunk["object"] == "chat.completion.chunk" + + def test_final_chunk_is_done(self): + """The stream must end with a ``[DONE]`` sentinel.""" + events = self._stream() + assert events[-1] == "[DONE]" + + def test_token_level_streaming(self): + """Each content delta corresponds to exactly one token. + + The mock tokenizer produces ``"T{i}"`` for token id *i*, so deltas + are ``["T1", "T2", ...]`` and concatenate back to ``"T1T2..."``. + """ + n = 7 + events = self._stream(n_tokens=n) + + deltas = _content_deltas(events) + assert deltas == [f"T{i}" for i in range(1, n + 1)] + assert "".join(deltas) == "".join(f"T{i}" for i in range(1, n + 1)) + + def test_empty_content_still_streams_role_and_done(self): + """Empty token list still emits role chunk and ``[DONE]``, no content deltas.""" + events = self._stream(n_tokens=0) + data_events = _without_sentinel(events) + + assert len(data_events) >= 2 # role + finish + assert data_events[0]["choices"][0]["delta"]["role"] == "assistant" + assert events[-1] == "[DONE]" + assert _content_deltas(events) == [] + + def test_finish_reason_in_final_data_chunk(self): + """Only the last data payload (before ``[DONE]``) carries ``finish_reason``.""" + events = self._stream() + data_events = _without_sentinel(events) + + for chunk in data_events[:-1]: + for choice in chunk["choices"]: + assert choice["finish_reason"] is None + + last = data_events[-1] + for choice in last["choices"]: + assert choice["finish_reason"] == "stop" + assert choice["delta"] == {} + + def test_usage_in_final_chunk(self): + """The final data chunk must include usage token counts.""" + events = self._stream(n_tokens=5) + last = _without_sentinel(events)[-1] + + # Prompt is mocked as [1, 2, 3], so prompt_tokens == 3. + assert last["usage"]["prompt_tokens"] == 3 + assert last["usage"]["completion_tokens"] == 5 + assert last["usage"]["total_tokens"] == 8 + + def test_tool_calls_in_delta(self): + """Tool-call response emits ``tool_calls`` in delta with correct finish_reason.""" + tool_response = _tool_call_response() + with _mock_serve(tool_response, response_ids=[[1, 2, 3]]): + # The MLX fallback rebuilds the response from token ids, but the mock + # tokenizer can't produce tool-call JSON. Patch _build_response so the + # pre-built tool-call response is used directly. + with patch.object(serve_mod, "_build_response", return_value=tool_response): + app = _build_streaming_app() + events = _collect_sse_events( + app, + {"messages": [{"role": "user", "content": "Hi"}], "stream": True}, + ) + + data_events = _without_sentinel(events) + + assert data_events[0]["choices"][0]["delta"]["role"] == "assistant" + + tool_call_chunks = [ + c for c in data_events if c["choices"] and c["choices"][0]["delta"].get("tool_calls") + ] + assert len(tool_call_chunks) >= 1 + tc = tool_call_chunks[0]["choices"][0]["delta"]["tool_calls"] + assert tc[0]["function"]["name"] == "get_weather" + assert '"city"' in tc[0]["function"]["arguments"] + + assert data_events[-1]["choices"][0]["finish_reason"] == "tool_calls" + assert events[-1] == "[DONE]" + + def test_multi_choice_response(self): + """Multi-choice (n > 1) emits per-choice deltas with correct indices.""" + response = ChatCompletionResponse( + id="chatcmpl-multi", + object="chat.completion", + created=1234567890, + model="test-model", + choices=[ + ChatCompletionChoice(index=0, message={"role": "assistant", "content": "Hi"}, finish_reason="stop"), + ChatCompletionChoice(index=1, message={"role": "assistant", "content": "Hello"}, finish_reason="stop"), + ], + usage=ChatCompletionUsage(prompt_tokens=5, completion_tokens=6, total_tokens=11), + ) + + with _mock_serve(response, response_ids=[[1, 2], [3, 4, 5, 6, 7]]): + app = _build_streaming_app() + events = _collect_sse_events( + app, + {"messages": [{"role": "user", "content": "Hi"}], "stream": True}, + ) + + data_events = _without_sentinel(events) + assert events[-1] == "[DONE]" + + # Role chunk covers both choices. + role_chunk = data_events[0] + assert len(role_chunk["choices"]) == 2 + for c in role_chunk["choices"]: + assert c["delta"]["role"] == "assistant" + + # Content appears for both choices. + content_seen: set[int] = set() + for chunk in data_events[1:-1]: + for c in chunk["choices"]: + if c["delta"].get("content"): + content_seen.add(c["index"]) + assert content_seen == {0, 1} + + # Finish chunk covers both choices. + finish_chunk = data_events[-1] + assert len(finish_chunk["choices"]) == 2 + for c in finish_chunk["choices"]: + assert c["finish_reason"] == "stop" + assert c["delta"] == {} + + def test_non_streaming_request(self): + """A ``stream=False`` request returns a complete JSON response, not SSE.""" + with _mock_serve(_text_response("Hi"), response_ids=[[1]]): + app = _build_streaming_app() + + async def _run(): + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + return await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "Hi"}], "stream": False}, + ) + + resp = asyncio.run(_run()) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/json" + data = resp.json() + assert data["object"] == "chat.completion" + assert data["choices"][0]["message"]["role"] == "assistant" + assert data["choices"][0]["message"]["content"] == "T1" \ No newline at end of file From 54929a4e26f0d6a0f77c6a979ebb919372c0cd3f Mon Sep 17 00:00:00 2001 From: tangxinyao Date: Tue, 25 Aug 2026 00:24:23 +0800 Subject: [PATCH 2/3] feat(serve): unify generate_rollout_stream_async interface across CUDA and MLX paths - Add _ServeStreamStep (backend-agnostic type) and _ServeRuntime Protocol - Add generate_rollout_stream_async to _CudaServeRuntime (wrapper pattern) - Update _MlxServeRuntime.generate_rollout_stream_async to yield _ServeStreamStep - Merge _stream_chat_completions_cuda and _stream_chat_completions into one function - Update ServeState.engine type from Any to _CudaServeRuntime | _MlxServeRuntime - Remove isinstance(state.engine, _CudaServeRuntime) dispatch - Fix test mock to yield _ServeStreamStep instead of raw tuples Co-Authored-By: Claude --- areno/api/backend/mlx/generation.py | 61 ++++++- areno/cli/serve.py | 267 +++++++++++++++++----------- tests/test_serve_streaming_cpu.py | 31 +++- 3 files changed, 246 insertions(+), 113 deletions(-) diff --git a/areno/api/backend/mlx/generation.py b/areno/api/backend/mlx/generation.py index 9bca1157..6f1c3919 100644 --- a/areno/api/backend/mlx/generation.py +++ b/areno/api/backend/mlx/generation.py @@ -45,6 +45,8 @@ class _Request: expanded_prompts: list[list[int]] = field(default_factory=list) expanded_features: list[dict | None] = field(default_factory=list) next_insert: int = 0 + stream_queue: queue.Queue | None = None + _handle_to_prompt_idx: dict[tuple[object, int], int] = field(default_factory=dict) @dataclass(slots=True) @@ -231,6 +233,38 @@ async def submit_async( ) -> list[RolloutResult]: return await asyncio.wrap_future(self.submit(prompt_tokens, n_samples, sampling_params, prompt_features)) + def submit_stream( + self, + prompt_tokens: list[list[int]], + n_samples: int, + sampling_params: SamplingParams, + prompt_features: list[dict | None] | None = None, + ) -> queue.Queue: + """Submit a streaming rollout request. + + Returns a thread-safe :class:`queue.Queue` that receives + ``(prompt_idx, token_id, finish_reason)`` tuples as tokens are + generated by the decode thread, followed by a ``None`` sentinel + when all sequences complete. On scheduler failure, an exception + object is placed on the queue before the sentinel. + """ + if self._closed: + raise RuntimeError("MLX rollout scheduler is closed") + if self._failure is not None: + raise RuntimeError("MLX rollout scheduler failed") from self._failure + if n_samples < 1: + raise ValueError("n_samples must be positive") + if prompt_features is not None and len(prompt_features) != len(prompt_tokens): + raise ValueError("prompt_features must align with prompt_tokens") + if not prompt_tokens: + q: queue.Queue = queue.Queue() + q.put(None) + return q + request = _Request(prompt_tokens, n_samples, sampling_params, prompt_features) + request.stream_queue = queue.Queue() + self._commands.put(request) + return request.stream_queue + def drop_state(self) -> None: """Release completed KV and allocator caches without replacing the scheduler.""" @@ -369,21 +403,37 @@ def _insert(self, request: _Request, start: int, end: int) -> None: request.handles.extend(handles) request.tokens.update((handle, []) for handle in handles) request.logprobs.update((handle, []) for handle in handles) - for handle in handles: + for i, handle in enumerate(handles): self._requests_by_handle[handle] = request + if request.stream_queue is not None: + request._handle_to_prompt_idx[handle] = (start + i) // request.n_samples def _record_response(self, key: object, generator: Any, response: Any) -> None: handle = (key, int(response.uid)) request = self._requests_by_handle[handle] - if response.finish_reason != "stop": - request.tokens[handle].append(int(response.token)) + + # Determine if a real token was generated and whether the sequence finishes now. + is_real_token = response.finish_reason != "stop" + is_finished = response.finish_reason is not None + + if is_real_token: + token_id = int(response.token) + request.tokens[handle].append(token_id) request.logprobs[handle].append(generator.token_logprob(response)) - if response.finish_reason is None: + if request.stream_queue is not None: + prompt_idx = request._handle_to_prompt_idx.get(handle, 0) + stream_fr: str | None = response.finish_reason if is_finished else None + request.stream_queue.put((prompt_idx, token_id, stream_fr)) + + if not is_finished: return + request.finished.add(handle) self._requests_by_handle.pop(handle, None) if len(request.finished) == len(request.expanded_prompts): request.future.set_result(_request_result(request)) + if request.stream_queue is not None: + request.stream_queue.put(None) def _record_decode_progress(self, token_delta: int) -> None: """Emit the same throttled decode progress line as the CUDA backend.""" @@ -428,6 +478,9 @@ def _fail_all(self, exc: BaseException) -> None: for request in requests.values(): if not request.future.done(): request.future.set_exception(exc) + if request.stream_queue is not None: + request.stream_queue.put(exc) + request.stream_queue.put(None) def _request_result(request: _Request) -> list[RolloutResult]: diff --git a/areno/cli/serve.py b/areno/cli/serve.py index 2ca76be9..bfe315e2 100644 --- a/areno/cli/serve.py +++ b/areno/cli/serve.py @@ -17,7 +17,7 @@ import warnings from collections.abc import AsyncGenerator from dataclasses import dataclass, field -from typing import Any, Literal +from typing import Any, Literal, Protocol from urllib.parse import urlparse import click @@ -177,6 +177,50 @@ class PendingRequest: cancelled: bool = False +class _ServeStreamStep: + """Backend-agnostic streaming token step. + + Both :class:`_CudaServeRuntime` and :class:`_MlxServeRuntime` yield + these objects from ``generate_rollout_stream_async``, giving the serve + layer a single attribute-based interface regardless of backend. + """ + + __slots__ = ("prompt_idx", "token_id", "finish_reason") + + def __init__(self, prompt_idx: int, token_id: int, finish_reason: str | None = None) -> None: + self.prompt_idx = prompt_idx + self.token_id = token_id + self.finish_reason = finish_reason + + +class _ServeRuntime(Protocol): + """Protocol satisfied by ``_CudaServeRuntime`` and ``_MlxServeRuntime``.""" + + max_model_len: int + + async def begin_rollout_session_async(self) -> None: ... + async def end_rollout_session_async(self) -> None: ... + def close(self) -> None: ... + async def generate_rollout_async( + self, + prompts, + *, + max_new_tokens: int, + sampling_params: SamplingParams, + prompt_features=None, + **kwargs, + ) -> _ServeRollout: ... + async def generate_rollout_stream_async( + self, + prompts, + *, + max_new_tokens: int, + sampling_params: SamplingParams, + prompt_features=None, + **kwargs, + ) -> AsyncGenerator[_ServeStreamStep, None]: ... + + @dataclass(slots=True) class ServeState: """Process-wide serving state held on `app.state.areno_serve`. @@ -187,7 +231,7 @@ class ServeState: model_path: str tokenizer: Any processor: Any - engine: Any + engine: _CudaServeRuntime | _MlxServeRuntime max_running_prompts: int default_max_tokens: int max_model_len: int @@ -263,6 +307,46 @@ async def generate_rollout_async(self, prompts, *, sampling_params: SamplingPara ) return await self._engine.generate_rollout_async(prompts, sampling_params=cuda_sampling, **kwargs) + async def generate_rollout_stream_async( + self, + prompts, + *, + max_new_tokens: int, + sampling_params: SamplingParams, + prompt_features=None, + **kwargs, + ) -> AsyncGenerator[_ServeStreamStep, None]: + """Stream tokens from the CUDA engine via multiprocessing.Pipe. + + Translates :class:`areno.api.SamplingParams` into the engine-native + :class:`areno.engine.data.SamplingParams`, then delegates to the + underlying ``ArenoEngine.generate_rollout_stream_async``. Each + yielded :class:`StreamTokenStep` is adapted into a + :class:`_ServeStreamStep` so the serve layer sees a uniform type. + """ + from areno.engine.data import SamplingParams as CudaSamplingParams + + cuda_sampling = CudaSamplingParams( + temperature=0.0 if sampling_params.greedy else sampling_params.temperature, + top_p=sampling_params.top_p, + top_k=max(sampling_params.top_k, 0), + seed=getattr(sampling_params, "seed", None), + stop_token_ids=tuple(sampling_params.stop_token_ids or ()), + ) + stream = self._engine.generate_rollout_stream_async( + prompts, + max_new_tokens=max_new_tokens, + sampling_params=cuda_sampling, + prompt_features=prompt_features, + **kwargs, + ) + try: + async for step in stream: + yield _ServeStreamStep(step.prompt_idx, step.token_id, step.finish_reason) + finally: + with contextlib.suppress(Exception): + await stream.aclose() + class _MlxServeRuntime: """Serve adapter over the same public Trainer lifecycle used by training.""" @@ -316,6 +400,50 @@ async def generate_rollout_async( finish_reason = ["length" if len(tokens) >= max_new_tokens else "stop" for tokens in response_ids] return _ServeRollout(response_ids=response_ids, finish_reason=finish_reason) + async def generate_rollout_stream_async( + self, + prompts, + *, + max_new_tokens: int, + sampling_params: SamplingParams, + prompt_features=None, + **kwargs, + ) -> AsyncGenerator[_ServeStreamStep, None]: + """Stream tokens incrementally from the MLX continuous-batch scheduler. + + Yields :class:`_ServeStreamStep` objects as the decode thread + produces tokens. The generator stops when all sequences complete + or the scheduler fails. + """ + del kwargs + import queue as _queue + + params = sampling_params.model_copy(update={"max_new_tokens": int(max_new_tokens)}) + scheduler = self._trainer._backend._rollout_scheduler + stream_queue = scheduler.submit_stream( + prompts, + n_samples=1, + sampling_params=params, + prompt_features=prompt_features, + ) + loop = asyncio.get_running_loop() + try: + while True: + item = await loop.run_in_executor(None, stream_queue.get) + if item is None: + break + if isinstance(item, BaseException): + raise item + prompt_idx, token_id, finish_reason = item + yield _ServeStreamStep(prompt_idx, token_id, finish_reason) + except BaseException: + try: + while True: + stream_queue.get_nowait() + except _queue.Empty: + pass + raise + def _create_serve_runtime( *, @@ -483,15 +611,11 @@ async def chat_completions(raw_request: Request, request: ChatCompletionRequest) raise HTTPException(status_code=503, detail="server is shutting down") if request.stream: - # Streaming: either CUDA true-streaming (pipe) or MLX synthetic SSE. - # No background task — cancellation is handled inline in each path. - stream_handler = ( - _stream_chat_completions_cuda - if isinstance(state.engine, _CudaServeRuntime) - else _stream_chat_completions - ) + # Streaming: true per-token (pipe on CUDA, queue on MLX) for + # single-choice no-tool requests; synthetic SSE fallback otherwise. + # No background task — cancellation is handled inline. return StreamingResponse( - stream_handler(state, raw_request, pending), + _stream_chat_completions(state, raw_request, pending), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", @@ -642,43 +766,38 @@ def _build_cancelled_response(state: ServeState, item: PendingRequest) -> ChatCo return _build_response(state, item.request, item.prompt, response_ids, finish_reasons) -async def _stream_chat_completions_cuda( +async def _stream_chat_completions( state: ServeState, raw_request: Request, pending: PendingRequest ) -> AsyncGenerator[str, None]: - """True SSE streaming for the CUDA engine path. + """SSE streaming for the /v1/chat/completions endpoint. - Calls ``ArenoEngine.generate_rollout_stream_async``, which pushes - :class:`StreamTokenStep` objects through a ``multiprocessing.Pipe`` from - the worker decode loop. Tokens are decoded one-by-one and emitted as SSE - chunks as soon as they arrive — no synthetic prefix-decode pass needed. - - Multi-choice (``n > 1``) and tool-call requests fall back to the synthetic - SSE path via ``_await_pending_response``. + Single-choice requests without tools use true per-token streaming + through the backend engine (``mp.Pipe`` on CUDA, ``queue.Queue`` on + MLX). Multi-choice (``n > 1``) and tool-call requests fall back to + synthetic SSE via incremental prefix-decoding on the full response. """ request = pending.request - n_choices = int(request.n) key = pending.key + n_choices = int(request.n) tokenizer = state.tokenizer - # Cancel handling: poll the underlying HTTP request directly in the - # stream loop so a client disconnect stops SSE emission promptly. cancel_event = asyncio.Event() - async def _watch_disconnect_for_stream() -> None: + async def _watch_disconnect() -> None: while not cancel_event.is_set(): if await raw_request.is_disconnected(): cancel_event.set() return await asyncio.sleep(0.1) - disconnect_task = asyncio.create_task(_watch_disconnect_for_stream()) + disconnect_task = asyncio.create_task(_watch_disconnect()) - # Multi-choice and tool-call requests still use the synthetic SSE path. + # -- Fallback: n > 1 or has tools → synthetic SSE ------------------------- if n_choices != 1 or request.tools: prompts = [pending.prompt for _ in range(n_choices)] prompt_features = [pending.prompt_features for _ in prompts] if pending.prompt_features is not None else None - if not cancel_event.is_set(): - try: + try: + if not cancel_event.is_set(): rollout = await state.engine.generate_rollout_async( prompts, max_new_tokens=key.max_new_tokens, @@ -695,26 +814,23 @@ async def _watch_disconnect_for_stream() -> None: prompt_features=prompt_features, decode_progress_interval_s=0.0, ) - except BaseException: - disconnect_task.cancel() - raise + if cancel_event.is_set(): + return response = _build_response(state, request, pending.prompt, rollout.response_ids, rollout.finish_reason) async for chunk in _build_sse_chunks(response, rollout.response_ids, state.tokenizer): yield chunk - disconnect_task.cancel() + finally: + disconnect_task.cancel() return - engine = state.engine._engine + # -- True streaming: n=1, no tools ----------------------------------------- prompts = [pending.prompt] prompt_features = [pending.prompt_features] if pending.prompt_features is not None else None try: - stream = engine.generate_rollout_stream_async( + stream = state.engine.generate_rollout_stream_async( prompts, max_new_tokens=key.max_new_tokens, - max_running_prompts=max(state.max_running_prompts, len(prompts)), - max_prompt_len=max(state.max_model_len - key.max_new_tokens, len(pending.prompt)), - eos_token_id=key.eos_token_id, sampling_params=SamplingParams( temperature=key.temperature, top_p=key.top_p, @@ -723,6 +839,10 @@ async def _watch_disconnect_for_stream() -> None: stop_token_ids=key.stop_token_ids, ), prompt_features=prompt_features, + # CUDA-only parameters — absorbed by **kwargs on MLX adapters. + max_running_prompts=max(state.max_running_prompts, len(prompts)), + max_prompt_len=max(state.max_model_len - key.max_new_tokens, len(pending.prompt)), + eos_token_id=key.eos_token_id, decode_progress_interval_s=0.0, ) except BaseException: @@ -744,16 +864,12 @@ def _emit(chunk: dict[str, Any]) -> str: "created": created, "model": model, "choices": [ - { - "index": 0, - "delta": {"role": "assistant", "content": ""}, - "finish_reason": None, - } + {"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None} ], } ) - # Content chunks — one per token as they arrive from the worker. + # Content chunks — one per token as they arrive from the backend. collected: list[int] = [] prev_text = "" finish_reason: str | None = None @@ -776,19 +892,12 @@ def _emit(chunk: dict[str, Any]) -> str: "created": created, "model": model, "choices": [ - { - "index": 0, - "delta": {"content": delta}, - "finish_reason": None, - } + {"index": 0, "delta": {"content": delta}, "finish_reason": None} ], } ) finally: disconnect_task.cancel() - # Clean up the stream generator (closes the underlying pipe). - with contextlib.suppress(Exception): - await stream.aclose() finish_reason = finish_reason or "stop" yield _emit( @@ -798,11 +907,7 @@ def _emit(chunk: dict[str, Any]) -> str: "created": created, "model": model, "choices": [ - { - "index": 0, - "delta": {}, - "finish_reason": finish_reason, - } + {"index": 0, "delta": {}, "finish_reason": finish_reason} ], "usage": { "prompt_tokens": len(pending.prompt), @@ -814,58 +919,6 @@ def _emit(chunk: dict[str, Any]) -> str: yield "data: [DONE]\n\n" -async def _stream_chat_completions( - state: ServeState, raw_request: Request, pending: PendingRequest -) -> AsyncGenerator[str, None]: - """Synthetic SSE streaming for the MLX path. - - Calls the engine directly (no background task), decodes the full token list - via incremental prefix-decoding, and emits OpenAI-compatible SSE chunks. - Disconnect is handled inline so a dropped client stops emission promptly. - """ - request = pending.request - key = pending.key - n_choices = int(request.n) - prompts = [pending.prompt for _ in range(n_choices)] - prompt_features = [pending.prompt_features for _ in prompts] if pending.prompt_features is not None else None - - cancel_event = asyncio.Event() - - async def _watch_disconnect_for_mlx() -> None: - while not cancel_event.is_set(): - if await raw_request.is_disconnected(): - cancel_event.set() - return - await asyncio.sleep(0.1) - - disconnect_task = asyncio.create_task(_watch_disconnect_for_mlx()) - try: - if not cancel_event.is_set(): - rollout = await state.engine.generate_rollout_async( - prompts, - max_new_tokens=key.max_new_tokens, - max_running_prompts=max(state.max_running_prompts, len(prompts)), - max_prompt_len=max(state.max_model_len - key.max_new_tokens, len(pending.prompt)), - eos_token_id=key.eos_token_id, - sampling_params=SamplingParams( - temperature=key.temperature, - top_p=key.top_p, - top_k=key.top_k, - seed=key.seed, - stop_token_ids=key.stop_token_ids, - ), - prompt_features=prompt_features, - decode_progress_interval_s=0.0, - ) - if cancel_event.is_set(): - return - response = _build_response(state, request, pending.prompt, rollout.response_ids, rollout.finish_reason) - async for chunk in _build_sse_chunks(response, rollout.response_ids, state.tokenizer): - yield chunk - finally: - disconnect_task.cancel() - - def _build_response( state: ServeState, request: ChatCompletionRequest, diff --git a/tests/test_serve_streaming_cpu.py b/tests/test_serve_streaming_cpu.py index f746a9ae..75d12f55 100644 --- a/tests/test_serve_streaming_cpu.py +++ b/tests/test_serve_streaming_cpu.py @@ -74,6 +74,25 @@ def _tool_call_response() -> ChatCompletionResponse: # -- Mock infrastructure ------------------------------------------------------ +def _make_stream_generator(response_ids: list[list[int]]): + """Return an async callable that yields ``_ServeStreamStep`` objects. + + Each invocation creates a fresh async generator that replays the given token + sequences for prompt index 0, appending ``"stop"`` as the finish reason for + the last token of each sequence. + """ + + from areno.cli.serve import _ServeStreamStep + + async def _generate(*args, **kwargs): + for ids in response_ids: + for j, token_id in enumerate(ids): + is_last = j == len(ids) - 1 + yield _ServeStreamStep(0, token_id, "stop" if is_last else None) + + return _generate + + @contextmanager def _mock_serve(response: ChatCompletionResponse, *, response_ids: list[list[int]] | None = None): """Mock serve-module internals so streaming requests resolve with *response*. @@ -96,10 +115,14 @@ def _mock_serve(response: ChatCompletionResponse, *, response_ids: list[list[int finish_reason=[response.choices[0].finish_reason for _ in response_ids], ) ) + # Also wire up generate_rollout_stream_async so the true-streaming + # path (n=1, no tools) can be exercised with the same token data. + mock_engine.generate_rollout_stream_async = _make_stream_generator(response_ids) else: mock_engine.generate_rollout_async = AsyncMock( return_value=_ServeRollout(response_ids=[[1]], finish_reason=["stop"]) ) + mock_engine.generate_rollout_stream_async = _make_stream_generator([[1]]) # Store the raw engine so non-streaming _run_request_rollout can reach it. mock_engine._engine = mock_engine @@ -294,7 +317,11 @@ def test_tool_calls_in_delta(self): app = _build_streaming_app() events = _collect_sse_events( app, - {"messages": [{"role": "user", "content": "Hi"}], "stream": True}, + { + "messages": [{"role": "user", "content": "Hi"}], + "stream": True, + "tools": [{"type": "function", "function": {"name": "get_weather"}}], + }, ) data_events = _without_sentinel(events) @@ -330,7 +357,7 @@ def test_multi_choice_response(self): app = _build_streaming_app() events = _collect_sse_events( app, - {"messages": [{"role": "user", "content": "Hi"}], "stream": True}, + {"messages": [{"role": "user", "content": "Hi"}], "stream": True, "n": 2}, ) data_events = _without_sentinel(events) From 9ed73b6f8fb4d442b0738c80d058d621e3153763 Mon Sep 17 00:00:00 2001 From: tangxinyao Date: Tue, 25 Aug 2026 16:27:37 +0800 Subject: [PATCH 3/3] feat(serve): stream continuous batches over shared result_queue Reuse the single Op.INFER_ROLLOUT command and result_queue IPC channel for streaming; drop the dedicated stream op + pass-through pipe so there is no separate IPC execution path. - Route streaming tokens by batch row id through a per-request _StreamTokenRouter; a streaming request admitted via refill into a non-streaming batch now keeps its tokens instead of silently losing them (two refilled requests must not each renumber prompts from zero) - Gate per-step token emission on the router's enabled flag so fully non-streaming batches (incl. training rollouts) skip the decode-loop sync - Reserve streaming batch capacity from max_running_prompts (mirrors the blocking path) so concurrent requests continuous-batch in one decode loop - Propagate worker failures out of TPCluster.call_async_stream via future.result() instead of silently ending the SSE stream with "stop" - Match serve streaming/fallback decode_progress_interval_s to the blocking path so stream and non-stream requests can share a worker batch - Fold stream_loop into the reused loop field (single per-request queue) - Add CPU regression tests for mixed-batch refill routing, capacity sizing and stream error propagation Co-Authored-By: Claude --- areno/api/backend/mlx/generation.py | 10 +- areno/cli/serve.py | 28 ++-- areno/engine/api.py | 44 ++++--- areno/engine/data/batch.py | 4 +- areno/engine/inference.py | 85 ++++++++---- areno/engine/protocol.py | 178 +++++++++++++++----------- areno/engine/worker.py | 124 +++++++++++------- tests/test_inference_scheduler_cpu.py | 124 +++++++++++++++++- tests/test_protocol_cpu.py | 51 ++++++++ 9 files changed, 462 insertions(+), 186 deletions(-) diff --git a/areno/api/backend/mlx/generation.py b/areno/api/backend/mlx/generation.py index 6f1c3919..4a1fa546 100644 --- a/areno/api/backend/mlx/generation.py +++ b/areno/api/backend/mlx/generation.py @@ -39,6 +39,7 @@ class _Request: features: list[dict | None] | None future: Future[list[RolloutResult]] = field(default_factory=Future) handles: list[tuple[object, int]] = field(default_factory=list) + handle_to_prompt_idx: dict[tuple[object, int], int] = field(default_factory=dict) tokens: dict[tuple[object, int], list[int]] = field(default_factory=dict) logprobs: dict[tuple[object, int], list[float]] = field(default_factory=dict) finished: set[tuple[object, int]] = field(default_factory=set) @@ -46,7 +47,6 @@ class _Request: expanded_features: list[dict | None] = field(default_factory=list) next_insert: int = 0 stream_queue: queue.Queue | None = None - _handle_to_prompt_idx: dict[tuple[object, int], int] = field(default_factory=dict) @dataclass(slots=True) @@ -406,22 +406,20 @@ def _insert(self, request: _Request, start: int, end: int) -> None: for i, handle in enumerate(handles): self._requests_by_handle[handle] = request if request.stream_queue is not None: - request._handle_to_prompt_idx[handle] = (start + i) // request.n_samples + request.handle_to_prompt_idx[handle] = (start + i) // request.n_samples def _record_response(self, key: object, generator: Any, response: Any) -> None: handle = (key, int(response.uid)) request = self._requests_by_handle[handle] - # Determine if a real token was generated and whether the sequence finishes now. - is_real_token = response.finish_reason != "stop" is_finished = response.finish_reason is not None - if is_real_token: + if response.finish_reason != "stop": token_id = int(response.token) request.tokens[handle].append(token_id) request.logprobs[handle].append(generator.token_logprob(response)) if request.stream_queue is not None: - prompt_idx = request._handle_to_prompt_idx.get(handle, 0) + prompt_idx = request.handle_to_prompt_idx.get(handle, 0) stream_fr: str | None = response.finish_reason if is_finished else None request.stream_queue.put((prompt_idx, token_id, stream_fr)) diff --git a/areno/cli/serve.py b/areno/cli/serve.py index bfe315e2..7c95c601 100644 --- a/areno/cli/serve.py +++ b/areno/cli/serve.py @@ -316,13 +316,15 @@ async def generate_rollout_stream_async( prompt_features=None, **kwargs, ) -> AsyncGenerator[_ServeStreamStep, None]: - """Stream tokens from the CUDA engine via multiprocessing.Pipe. + """Stream tokens from the CUDA engine via shared IPC. Translates :class:`areno.api.SamplingParams` into the engine-native :class:`areno.engine.data.SamplingParams`, then delegates to the - underlying ``ArenoEngine.generate_rollout_stream_async``. Each - yielded :class:`StreamTokenStep` is adapted into a - :class:`_ServeStreamStep` so the serve layer sees a uniform type. + underlying ``ArenoEngine.generate_rollout_stream_async``. The + engine reuses the existing ``Op.INFER_ROLLOUT`` command and + ``result_queue`` channel — no separate pipe. Each yielded + :class:`StreamTokenStep` is adapted into a :class:`_ServeStreamStep` + so the serve layer sees a uniform type. """ from areno.engine.data import SamplingParams as CudaSamplingParams @@ -611,8 +613,9 @@ async def chat_completions(raw_request: Request, request: ChatCompletionRequest) raise HTTPException(status_code=503, detail="server is shutting down") if request.stream: - # Streaming: true per-token (pipe on CUDA, queue on MLX) for - # single-choice no-tool requests; synthetic SSE fallback otherwise. + # Streaming: true per-token (shared result_queue on CUDA, + # queue.Queue on MLX) for single-choice no-tool requests; + # synthetic SSE fallback otherwise. # No background task — cancellation is handled inline. return StreamingResponse( _stream_chat_completions(state, raw_request, pending), @@ -772,9 +775,10 @@ async def _stream_chat_completions( """SSE streaming for the /v1/chat/completions endpoint. Single-choice requests without tools use true per-token streaming - through the backend engine (``mp.Pipe`` on CUDA, ``queue.Queue`` on - MLX). Multi-choice (``n > 1``) and tool-call requests fall back to - synthetic SSE via incremental prefix-decoding on the full response. + through the backend engine (shared ``result_queue`` IPC on CUDA, + ``queue.Queue`` on MLX). Multi-choice (``n > 1``) and tool-call + requests fall back to synthetic SSE via incremental prefix-decoding on + the full response. """ request = pending.request key = pending.key @@ -812,7 +816,7 @@ async def _watch_disconnect() -> None: stop_token_ids=key.stop_token_ids, ), prompt_features=prompt_features, - decode_progress_interval_s=0.0, + decode_progress_interval_s=raw_request.app.state.decode_progress_interval_s, ) if cancel_event.is_set(): return @@ -843,7 +847,9 @@ async def _watch_disconnect() -> None: max_running_prompts=max(state.max_running_prompts, len(prompts)), max_prompt_len=max(state.max_model_len - key.max_new_tokens, len(pending.prompt)), eos_token_id=key.eos_token_id, - decode_progress_interval_s=0.0, + # Match the non-streaming path so stream and non-stream requests + # can share one worker batch via continuous-batch refill. + decode_progress_interval_s=raw_request.app.state.decode_progress_interval_s, ) except BaseException: disconnect_task.cancel() diff --git a/areno/engine/api.py b/areno/engine/api.py index da2a235c..97e6ed6b 100644 --- a/areno/engine/api.py +++ b/areno/engine/api.py @@ -413,17 +413,20 @@ async def generate_rollout_stream_async( decode_progress_interval_s: float = 0.0, cancel_flags: torch.Tensor | None = None, ) -> AsyncGenerator[StreamTokenStep, None]: - """Streaming rollout: yield tokens incrementally through a pipe. + """Streaming rollout: yield tokens incrementally via shared IPC. - Constructs the same ``RolloutPayload`` used by the blocking path, then - delegates to ``TPCluster.stream_call_async`` which creates a - ``multiprocessing.Pipe`` and wraps the worker-side callback into an - async generator. Each yielded :class:`StreamTokenStep` carries one - token for one prompt row. + Uses the same ``Op.INFER_ROLLOUT`` command and ``result_queue`` + channel as the non-streaming path — no separate pipe. Delegates to + ``TPCluster.call_async_stream`` which manages the ``asyncio.Queue`` + and result-pump routing internally. + + Batching capacity is reserved from *max_running_prompts* (mirroring + ``_generate_rollout_async_once``) so waiting requests can join this + decode loop via continuous-batch refill; sizing the batch to the + current call alone would cap it at that call's rows. Only single-chunk prompts are supported (no prefill-budget splitting). """ - from collections.abc import AsyncGenerator as _AsyncGenerator if not prompts: raise ValueError("prompts must be non-empty") @@ -437,14 +440,19 @@ async def generate_rollout_stream_async( ) rollout_max_cache_len = rollout_max_prompt_len + max_new_tokens dp_size = int(self.config.dp_size) - prompts_by_dp = split_list_by_dp(prompts, int(self.config.dp_size)) + prompts_by_dp = split_list_by_dp(prompts, dp_size) prompt_features_by_dp = None if prompt_features is not None: - prompt_features_by_dp = split_list_by_dp(prompt_features, int(self.config.dp_size)) + prompt_features_by_dp = split_list_by_dp(prompt_features, dp_size) prompt_indices_by_dp = split_list_by_dp( - list(range(len(prompts))), int(self.config.dp_size) + list(range(len(prompts))), dp_size ) local_running = max(max((len(rows) for rows in prompts_by_dp), default=0), 1) + # Reserve refill capacity up-front so the worker can admit waiting + # requests into this decode loop; `local_running` alone would cap the + # batch at this call's rows and serialize concurrent streaming calls. + local_max_running = max(ceil_div(int(max_running_prompts), dp_size), 1) + capacity_running = local_max_running if cancel_flags is None else local_running cache_len = max(max(len(prompt) + max_new_tokens for prompt in prompts), rollout_max_cache_len) max_blocks_per_seq = ceil_div(cache_len, self.config.runtime.kv_block_size) payload = RolloutPayload( @@ -454,21 +462,27 @@ async def generate_rollout_stream_async( max_new_tokens=max_new_tokens, eos_token_id=eos_token_id, sampling_params=sampling_params, - max_running_seqs=local_running, + max_running_seqs=capacity_running, max_cache_len=cache_len, max_blocks_per_seq=max_blocks_per_seq, - max_prefill_tokens=local_running * rollout_max_prompt_len, - num_blocks=local_running * max_blocks_per_seq, + max_prefill_tokens=local_max_running * rollout_max_prompt_len, + num_blocks=capacity_running * max_blocks_per_seq, block_size=self.config.runtime.kv_block_size, decode_progress_interval_s=decode_progress_interval_s, cancel_flags=cancel_flags, cancel_indices_by_dp=split_list_by_dp( - list(range(len(prompts))), int(self.config.dp_size) + list(range(len(prompts))), dp_size ) if cancel_flags is not None else None, + streaming=True, ) - async for step in self.cluster.stream_call_async(payload): + + tp_size = self.config.tp_size + result_ranks = {dp_rank * tp_size for dp_rank in range(dp_size)} + async for step in self.cluster.call_async_stream( + Op.INFER_ROLLOUT, payload, result_ranks=result_ranks, + ): yield step async def _generate_rollout_async_once( diff --git a/areno/engine/data/batch.py b/areno/engine/data/batch.py index 0559e1a6..cea36106 100644 --- a/areno/engine/data/batch.py +++ b/areno/engine/data/batch.py @@ -44,7 +44,9 @@ class StreamTokenStep: Each step carries one token for one prompt row, plus an optional finish reason when that token ends the sequence (``"stop"``, - ``"length"``, or ``"cancelled"``). + ``"length"``, or ``"cancelled"``). ``prompt_idx`` indexes the prompt + within the submitting request's own prompt list; requests merged into + one worker batch are demultiplexed by ``WorkerResult.request_id``. """ prompt_idx: int diff --git a/areno/engine/inference.py b/areno/engine/inference.py index 942dd1c0..c014eab9 100644 --- a/areno/engine/inference.py +++ b/areno/engine/inference.py @@ -37,12 +37,55 @@ FinishedRowsCallback = Callable[[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, str, tuple[int, ...]], None] RolloutRefillCallback = Callable[[InferenceBatchState], list[int]] -# (prompt_indices, token_ids, finish_reasons) — three parallel lists. +# (row_ids, token_ids, finish_reasons) — three parallel lists. Row ids are +# positions in the rollout batch state, unique across every request merged +# into the batch (two refilled requests may both number their own prompts +# from zero, so prompt indices cannot be used as routing keys). # finish_reasons entries are ``None`` for intermediate steps, ``"stop"``, # ``"length"``, or ``"cancelled"`` when the token ends the sequence. +# Implementations may expose an ``enabled`` attribute (see +# :func:`_is_stream_enabled`); the decode loop consults it each step so +# fully non-streaming batches never pay the token host sync. StreamTokenCallback = Callable[[list[int], list[int], list[str | None]], None] +def _is_stream_enabled(stream_callback: StreamTokenCallback) -> bool: + """Return whether *stream_callback* currently wants per-step tokens. + + The worker's router flips ``enabled`` on once any request in the merged + batch streams — possibly only when a refill admits one mid-decode. + Plain callbacks without the attribute are always enabled. + """ + + return bool(getattr(stream_callback, "enabled", True)) + + +def _get_stream_finish_reasons( + finished: torch.Tensor | None, + full_length: torch.Tensor, + cancelled: torch.Tensor | None = None, +) -> list[str | None]: + """Build per-row stream finish reasons (``None`` = sequence continues). + + Each mask is converted to a Python list once; per-entry ``.item()`` + calls would sync the device once per row. + """ + + reasons: list[str | None] = [None] * int(full_length.numel()) + if finished is not None: + for idx, hit in enumerate(finished.tolist()): + if hit: + reasons[idx] = "stop" + for idx, hit in enumerate(full_length.tolist()): + if hit: + reasons[idx] = "length" + if cancelled is not None: + for idx, hit in enumerate(cancelled.tolist()): + if hit: + reasons[idx] = "cancelled" + return reasons + + def _cancel_stop_token(stop_token_ids: list[int], eos_token_id: int | tuple[int, ...] | None) -> int: """Choose a token id to write when a row is cancelled mid-decode.""" @@ -460,22 +503,14 @@ def _generate_rollout_tokens_no_sync( if cancelled is not None: remove |= cancelled # ---- stream callback -------------------------------------------------- - if stream_callback is not None and ctx.is_rank0: - prompt_ids = [prompt_indices_list[int(row)] for row in active_rows.detach().cpu().tolist()] + if stream_callback is not None and _is_stream_enabled(stream_callback) and ctx.is_rank0: + rows_cpu = active_rows.detach().cpu().tolist() tokens_cpu = next_tokens.detach().cpu().tolist() - reasons: list[str | None] = [None] * active_count - if finished is not None: - for i in range(active_count): - if bool(finished[i].item()): - reasons[i] = "stop" - for i in range(active_count): - if bool(full_length[i].item()): - reasons[i] = "length" - if cancelled is not None: - for i in range(active_count): - if bool(cancelled[i].item()): - reasons[i] = "cancelled" - stream_callback(prompt_ids, tokens_cpu, reasons) + stream_callback( + rows_cpu, + tokens_cpu, + _get_stream_finish_reasons(finished, full_length, cancelled), + ) # ----------------------------------------------------------------------- if bool(remove.any().item()): if finished is not None and bool(finished.any().item()): @@ -787,18 +822,14 @@ def _admit_pending_rollout_rows( remove |= full_length # ---- stream callback (prefill tokens) ---------------------------------- ctx = get_tp_context() - if stream_callback is not None and ctx.is_rank0: - prompt_ids = [prompt_indices[int(row)] for row in new_rows.detach().cpu().tolist()] + if stream_callback is not None and ctx.is_rank0 and _is_stream_enabled(stream_callback): + rows_cpu = new_rows.detach().cpu().tolist() tokens_cpu = new_tokens.detach().cpu().tolist() - reasons: list[str | None] = [None] * int(new_rows.numel()) - if finished is not None: - for i in range(int(new_rows.numel())): - if bool(finished[i].item()): - reasons[i] = "stop" - for i in range(int(new_rows.numel())): - if bool(full_length[i].item()): - reasons[i] = "length" - stream_callback(prompt_ids, tokens_cpu, reasons) + stream_callback( + rows_cpu, + tokens_cpu, + _get_stream_finish_reasons(finished, full_length), + ) # ----------------------------------------------------------------------- if bool(remove.any().item()): if finished is not None and bool(finished.any().item()): diff --git a/areno/engine/protocol.py b/areno/engine/protocol.py index c0b75ae1..2c857a9f 100644 --- a/areno/engine/protocol.py +++ b/areno/engine/protocol.py @@ -34,7 +34,6 @@ class Op(Enum): TRAIN = auto() INFER_ROLLOUT = auto() - INFER_ROLLOUT_STREAM = auto() PROBE_ROLLOUT_CACHE = auto() ENSURE_ROLES = auto() SCORE_LOGPROBS = auto() @@ -92,19 +91,7 @@ class RolloutPayload: decode_progress_interval_s: float = 0.0 cancel_flags: torch.Tensor | None = None cancel_indices_by_dp: list[list[int]] | None = None - - -@dataclass(slots=True) -class StreamTokenPayload: - """Typed payload for Op.INFER_ROLLOUT_STREAM. - - Carries the rollout parameters plus the send-end of a - ``multiprocessing.Pipe`` that the worker uses to push incremental - :class:`~areno.engine.data.StreamTokenStep` objects. - """ - - rollout: RolloutPayload - stream_conn: Any # multiprocessing.Connection (send end, picklable) + streaming: bool = False @dataclass(slots=True) @@ -218,6 +205,11 @@ class _PendingClusterCall: future: asyncio.Future | None = None loop: asyncio.AbstractEventLoop | None = None error: BaseException | None = None + # Per-request token delivery queue: the result-pump thread hands + # StreamTokenStep payloads to `loop.call_soon_threadsafe(...)` on this + # queue; the streaming async generator drains it. `loop` above is the + # event loop that owns both `future` and `stream_queue`. + stream_queue: asyncio.Queue | None = None class ClusterCallHandle: @@ -443,7 +435,10 @@ async def call_async( request_id = next(self._request_ids) loop = asyncio.get_running_loop() future: asyncio.Future = loop.create_future() - self._submit_call(op, payload, request_id=request_id, future=future, loop=loop, result_ranks=result_ranks) + self._submit_call( + op, payload, + request_id=request_id, future=future, loop=loop, result_ranks=result_ranks, + ) try: return await asyncio.wait_for(future, timeout=timeout) except BaseException: @@ -451,6 +446,66 @@ async def call_async( self._pending_calls.pop(request_id, None) raise + async def call_async_stream( + self, + op: Op, + payload: Any = None, + result_ranks: set[int] | None = None, + timeout: float = 0.05, + ) -> AsyncGenerator[Any, None]: + """Async streaming variant of :meth:`call_async`. + + Submits one command via the shared ``_submit_call`` path and yields + intermediate results (``StreamTokenStep`` objects pushed by the + worker through the ``result_queue``) as they arrive. The generator + stops when the final result resolves the underlying ``Future``; + a failed rank raises from the generator instead of ending the + stream silently. + + *timeout* is the maximum interval (seconds) between polls of the + internal token queue; it bounds how long the caller waits between + tokens before checking whether the final result has arrived. + + This reuses the same ``result_queue`` channel and continuous-batching + machinery as the non-streaming path — no separate pipe. + """ + + stream_queue: asyncio.Queue = asyncio.Queue() + loop = asyncio.get_running_loop() + future: asyncio.Future = loop.create_future() + + request_id = next(self._request_ids) + self._submit_call( + op, payload, + request_id=request_id, future=future, loop=loop, + result_ranks=result_ranks, + stream_queue=stream_queue, + ) + + try: + while not future.done(): + try: + step = await asyncio.wait_for(stream_queue.get(), timeout=timeout) + yield step + except asyncio.TimeoutError: + pass + # Drain any tokens that arrived just before the future resolved. + while not stream_queue.empty(): + yield stream_queue.get_nowait() + # Propagate worker errors like the non-streaming path's + # ``await future`` would. The successful final payload is + # deliberately discarded: every token was already yielded as a + # step carrying its finish reason. + future.result() + finally: + while not stream_queue.empty(): + try: + stream_queue.get_nowait() + except asyncio.QueueEmpty: + break + with self._pending_lock: + self._pending_calls.pop(request_id, None) + def _submit_call( self, op: Op, @@ -460,6 +515,7 @@ def _submit_call( future: asyncio.Future | None = None, loop: asyncio.AbstractEventLoop | None = None, result_ranks: set[int] | None = None, + stream_queue: asyncio.Queue | None = None, ) -> _PendingClusterCall: if not self.started: self.start() @@ -472,6 +528,7 @@ def _submit_call( event=threading.Event(), future=future, loop=loop, + stream_queue=stream_queue, ) with self._pending_lock: self._pending_calls[request_id] = pending @@ -500,13 +557,29 @@ def _result_pump_loop(self) -> None: self._apply_result(request_id, rank, result, pending) def _apply_result(self, request_id: int, rank: int, result: WorkerResult, pending: _PendingClusterCall) -> None: - """Apply one worker result to a pending call and complete it if done.""" + """Apply one worker result to a pending call and complete it if done. + + Intermediate streaming results (``StreamTokenStep`` payloads) are + pushed into ``pending.stream_queue`` directly and do *not* mark the + rank as complete. Final (non-stream) results follow the usual + collect-per-rank-then-resolve path. + """ if not result.ok: self._finish_pending_call( request_id, pending, RuntimeError(f"rank {rank} failed during {pending.op}:\n{result.error}") ) return + # Push intermediate streaming tokens into the per-request queue + # via call_soon_threadsafe — _apply_result runs in the result-pump + # thread, not the event loop. When no stream_queue is configured + # (non-streaming request), the token is silently dropped. + if _is_stream_step(result.payload): + if pending.stream_queue is not None and pending.loop is not None: + pending.loop.call_soon_threadsafe( + pending.stream_queue.put_nowait, result.payload, + ) + return pending.results[rank] = result.payload pending.pending.discard(rank) if not pending.pending: @@ -554,63 +627,6 @@ def _dead_pending_workers(self, pending: set[int]) -> list[tuple[int, int | None proc.join(timeout=0) return dead - async def stream_call_async( - self, - payload: RolloutPayload, - *, - timeout: float | None = None, - ) -> AsyncGenerator[StreamTokenStep, None]: - """Submit a streaming rollout and yield tokens incrementally. - - Creates a :class:`multiprocessing.Pipe`, sends the send-end to every - worker alongside the rollout command, then reads - :class:`~areno.engine.data.StreamTokenStep` objects from the recv-end - as they arrive. The generator stops when the worker closes its end - of the pipe. - - Only TP rank-0 of each DP group writes to the pipe; other ranks - receive the command but do not emit. - """ - from areno.engine.data.batch import StreamTokenStep as _StreamTokenStep - - recv_conn, send_conn = mp.Pipe(duplex=False) - stream_payload = StreamTokenPayload(rollout=payload, stream_conn=send_conn) - - request_id = next(self._request_ids) - loop = asyncio.get_running_loop() - ack_future: asyncio.Future = loop.create_future() - # Only wait for TP rank-0 of each DP group (they are the ones that - # actually write to the pipe). For dp_size == 1 this is just rank 0. - dp_size = int(self.config.dp_size) - tp_size = self.config.tp_size - result_ranks = {dp_rank * tp_size for dp_rank in range(dp_size)} - self._submit_call( - Op.INFER_ROLLOUT_STREAM, stream_payload, - request_id=request_id, future=ack_future, loop=loop, result_ranks=result_ranks, - ) - try: - await asyncio.wait_for(ack_future, timeout=timeout) - except BaseException: - with self._pending_lock: - self._pending_calls.pop(request_id, None) - recv_conn.close() - raise - - # Read StreamTokenStep objects from the pipe in a thread so we never - # block the event loop on a blocking recv. - loop = asyncio.get_running_loop() - try: - while True: - step = await loop.run_in_executor(None, recv_conn.recv) - if isinstance(step, _StreamTokenStep): - yield step - else: - break - except EOFError: - pass - finally: - recv_conn.close() - def close(self) -> None: """Request shutdown and terminate workers that do not exit promptly.""" @@ -718,11 +734,6 @@ def _worker_entry( result_q.put((rank, WorkerResult(ok=True, payload=payload, request_id=request_id))) worker._current_request_ids = [] continue - if cmd.op is Op.INFER_ROLLOUT_STREAM: - worker._current_request_ids = [cmd.request_id] - worker.handle_stream_rollout(cmd) - worker._current_request_ids = [] - continue payload = worker.handle(cmd) result_q.put((rank, WorkerResult(ok=True, payload=payload, request_id=cmd.request_id))) except (KeyboardInterrupt, SystemExit): @@ -767,6 +778,17 @@ def _set_async_exception(future: asyncio.Future, exc: BaseException) -> None: future.set_exception(exc) +def _is_stream_step(payload: Any) -> bool: + """Return ``True`` when *payload* is a ``StreamTokenStep``. + + Uses a duck-type check (``hasattr(payload, \"prompt_idx\") and + hasattr(payload, \"token_id\")``) to avoid importing ``areno.engine.data`` + inside the result-pump thread. + """ + + return hasattr(payload, "prompt_idx") and hasattr(payload, "token_id") + + def start_partitioned_clusters( train_cluster: TPCluster, rollout_cluster: TPCluster, diff --git a/areno/engine/worker.py b/areno/engine/worker.py index 69825668..e3635ab1 100644 --- a/areno/engine/worker.py +++ b/areno/engine/worker.py @@ -16,6 +16,7 @@ from __future__ import annotations import queue +from typing import Any import torch import torch.distributed as dist @@ -35,7 +36,6 @@ RolloutCacheProbePayload, RolloutPayload, SaveCheckpointPayload, - StreamTokenPayload, WorkerResult, ) from areno.engine.runtime.common import pad_rollout_rows @@ -45,6 +45,64 @@ from areno.models.registry import load_model_weights, save_model_weights +class _StreamTokenRouter: + """Forward decode-loop tokens to per-request entries on the shared result_queue. + + The router is keyed by rollout batch row id (one slot per prompt row in + the merged batch); row ids stay unique across refilled requests, while + each request numbers its own prompts from zero. A request only receives + tokens when it was submitted with ``streaming=True`` — rows of + non-streaming requests in the same batch are skipped. + + ``enabled`` starts out ``True`` only when the initial rollout request + streams. Admitting a streaming request via refill flips it on so those + tokens are not silently dropped; the decode loop checks it each step and + skips the token host sync entirely while the batch has no streaming + request. + """ + + __slots__ = ("_rank", "_result_queue", "_row_to_request", "_request_ids", "_request_streaming", "enabled") + + def __init__(self, rank: int, result_queue: Any) -> None: + self._rank = rank + self._result_queue = result_queue + self._row_to_request: dict[int, tuple[int, int]] = {} + self._request_ids: list[int | None] = [] + self._request_streaming: list[bool] = [] + self.enabled = False + + def register(self, request_id: int | None, rows: list[int], streaming: bool) -> None: + """Map one request's rollout rows onto its request slot.""" + + request_idx = len(self._request_ids) + self._request_ids.append(request_id) + self._request_streaming.append(bool(streaming)) + for prompt_idx, row in enumerate(rows): + self._row_to_request[int(row)] = (request_idx, prompt_idx) + self.enabled = self.enabled or bool(streaming) + + def __call__(self, row_ids: list[int], token_ids: list[int], finish_reasons: list[str | None]) -> None: + if not self.enabled: + return + for row, token_id, finish_reason in zip(row_ids, token_ids, finish_reasons, strict=True): + entry = self._row_to_request.get(int(row)) + if entry is None: + continue + request_idx, prompt_idx = entry + if not self._request_streaming[request_idx]: + continue + self._result_queue.put( + ( + self._rank, + WorkerResult( + ok=True, + payload=StreamTokenStep(prompt_idx=prompt_idx, token_id=token_id, finish_reason=finish_reason), + request_id=self._request_ids[request_idx], + ), + ) + ) + + class ArenoWorker: """Single-rank executor for model work. @@ -258,7 +316,16 @@ def run_continuous_rollout_payload( request_ids: list[int | None], counts: list[int], ) -> list[tuple[int | None, RolloutOutput | None]]: - """Run one rollout and append compatible queued requests while decoding.""" + """Run one rollout and append compatible queued requests while decoding. + + A :class:`_StreamTokenRouter` is installed on the decode loop so + requests submitted with ``streaming=True`` — including ones admitted + later via refill into a non-streaming batch — receive incremental + :class:`StreamTokenStep` objects through the shared ``result_queue`` + keyed by ``request_id``. This reuses the existing IPC channel and + the continuous-batching machinery rather than opening a separate + pipe. + """ ctx = get_tp_context() request_rows = _rollout_request_rows(counts) @@ -266,6 +333,12 @@ def run_continuous_rollout_payload( finish_reasons = [""] * sum(counts) sent: set[int] = set() + # Per-request streaming: rows (batch-state slots) → request routing. + # Refill registers newly admitted requests below. + router = _StreamTokenRouter(self._rank, self._result_queue) + for req_idx in range(len(request_ids)): + router.register(request_ids[req_idx], request_rows[req_idx], bool(payload.streaming)) + def send_empty_requests() -> None: for request_idx, count in enumerate(counts): if request_idx in sent or count != 0: @@ -360,9 +433,13 @@ def refill_waiting(state) -> list[int]: new_prompt_indices.extend(prompt_indices) finished.extend(False for _ in prompts) finish_reasons.extend("" for _ in prompts) + # Route the admitted request's rows for streaming output. + router.register(new_request_ids[0], appended_rows, bool(new_payload.streaming)) return new_prompt_indices - output = self.infer_rollout(payload, finished_callback=send_finished, refill_callback=refill_waiting) + output = self.infer_rollout( + payload, finished_callback=send_finished, refill_callback=refill_waiting, stream_callback=router, + ) parts = _split_rollout_output_by_rows(output, request_rows) return [ (request_id, part) @@ -370,47 +447,6 @@ def refill_waiting(state) -> list[int]: if idx not in sent ] - def handle_stream_rollout(self, cmd: Command) -> None: - """Run a streaming rollout, pushing tokens through the attached pipe. - - Extracts the pipe send-end from the ``StreamTokenPayload``, wraps it in - a ``stream_callback``, and drives ``infer_rollout``. The pipe is closed - after decode completes so the coordinator sees ``EOFError`` on the - recv-end. Only TP rank-0 of each DP group writes to the pipe. - - An early ack is sent via ``result_queue`` so the coordinator can start - reading the pipe before the decode loop finishes — this keeps - time-to-first-token low. - """ - payload = cmd.payload - stream_conn = payload.stream_conn - rollout = payload.rollout - ctx = get_tp_context() - - # Ack immediately so the coordinator unblocks and starts consuming the - # pipe while the decode loop is still running. - if ctx.is_rank0: - self._result_queue.put( - (self._rank, WorkerResult(ok=True, payload="ack", request_id=cmd.request_id)), - ) - - def _stream_callback(prompt_indices: list[int], token_ids: list[int], finish_reasons: list[str | None]) -> None: - for pi, tid, fr in zip(prompt_indices, token_ids, finish_reasons): - try: - stream_conn.send(StreamTokenStep(prompt_idx=pi, token_id=tid, finish_reason=fr)) - except Exception: - # Pipe may be closed if the client disconnected; we still - # let decode finish so the worker batch stays valid. - pass - - try: - self.inference.infer_rollout(rollout, stream_callback=_stream_callback) - finally: - try: - stream_conn.close() - except Exception: - pass - def _next_refill_command(self) -> Command | None: """Fetch the next queued command consistently across TP ranks.""" diff --git a/tests/test_inference_scheduler_cpu.py b/tests/test_inference_scheduler_cpu.py index 0693045b..9475c69d 100644 --- a/tests/test_inference_scheduler_cpu.py +++ b/tests/test_inference_scheduler_cpu.py @@ -8,7 +8,7 @@ import areno.engine.inference as inference_mod import areno.engine.worker as worker_mod from areno.engine.api import ArenoEngine, _chunk_prompts_for_prefill_budget, _merge_async_dp_rollouts -from areno.engine.data import SamplingParams +from areno.engine.data import SamplingParams, StreamTokenStep from areno.engine.data.rollout_state import InferenceBatchState from areno.engine.inference import InferenceManager from areno.engine.protocol import Command, Op, RolloutPayload @@ -265,6 +265,52 @@ async def call_async(self, op, payload, **kwargs): assert cluster.payload.max_running_seqs == 16 +def test_stream_rollout_reserves_refill_capacity_from_max_running_prompts(): + """Streaming payloads must size the worker batch for continuous refill. + + Sizing ``max_running_seqs`` to the current call's rows alone would cap the + batch at one row and serialize concurrent streaming requests. + """ + + class ClusterStub: + def __init__(self): + self.payload = None + + async def call_async_stream(self, op, payload, **kwargs): + del kwargs + assert op is Op.INFER_ROLLOUT + self.payload = payload + yield StreamTokenStep(prompt_idx=0, token_id=5) + + cluster = ClusterStub() + engine = object.__new__(ArenoEngine) + engine.cluster = cluster + engine.config = SimpleNamespace(tp_size=1, dp_size=2, runtime=SimpleNamespace(kv_block_size=16)) + + async def run(): + return [ + step + async for step in engine.generate_rollout_stream_async( + [[1]], + max_new_tokens=16, + max_running_prompts=32, + max_prompt_len=32, + eos_token_id=None, + sampling_params=SamplingParams(), + ) + ] + + steps = asyncio.run(run()) + + assert [step.token_id for step in steps] == [5] + # 32 max_running_prompts / 2 dp ranks = 16 rows of reserved capacity, + # while the call itself only carries a single prompt. + assert cluster.payload.streaming is True + assert cluster.payload.max_running_seqs == 16 + assert cluster.payload.num_blocks == 16 * cluster.payload.max_blocks_per_seq + assert cluster.payload.max_prefill_tokens == 16 * 32 + + def test_async_single_prompt_requests_round_robin_across_dp_ranks(): """Independent serve requests should not all land on DP rank 0.""" @@ -457,7 +503,7 @@ def append_prompts(self, prompts, prompt_features=None): self.prompts.extend(prompts) return list(range(start, start + len(prompts))) - def infer_rollout(payload, finished_callback=None, refill_callback=None): + def infer_rollout(payload, finished_callback=None, refill_callback=None, stream_callback=None): assert refill_callback is not None refill_callback(AliasedState(payload.prompts_by_dp[0])) finished_callback(torch.tensor([0]), generated, logprobs, response_lens, "stop", ()) @@ -486,6 +532,76 @@ def infer_rollout(payload, finished_callback=None, refill_callback=None): assert remaining[0][1].response_ids == [[20, 21]] +def test_worker_routes_stream_tokens_when_streaming_request_refills_into_non_streaming_batch(): + """A streaming request admitted via refill must still receive its tokens. + + The initial batch is non-streaming, so the router starts disabled; + admitting the second, streaming request mid-decode must flip it on. + Only the streaming request's rows are forwarded — the non-streaming + request's token is filtered out instead of being routed by row id. + """ + + first = _rollout_command(1, [[1]], target=2) + second = _rollout_command(2, [[2]], target=2) + second.payload.streaming = True + generated = torch.tensor([[10, 11], [20, 21]], dtype=torch.long) + logprobs = torch.tensor([[-0.1, -0.2], [-0.3, -0.4]], dtype=torch.float32) + response_lens = torch.tensor([2, 2], dtype=torch.long) + worker = object.__new__(worker_mod.ArenoWorker) + worker._rank = 0 + worker._result_queue = _ResultQueueDouble() + worker._current_request_ids = [1] + ctx = SimpleNamespace(dp_rank=0, is_rank0=True, world_size=1, device=torch.device("cpu")) + + class AliasedState: + def __init__(self, prompts): + self.prompts = prompts + + def append_prompts(self, prompts, prompt_features=None): + del prompt_features + start = len(self.prompts) + self.prompts.extend(prompts) + return list(range(start, start + len(prompts))) + + def infer_rollout(payload, finished_callback=None, refill_callback=None, stream_callback=None): + assert stream_callback is not None + assert stream_callback.enabled is False # batch starts non-streaming + refill_callback(AliasedState(payload.prompts_by_dp[0])) + assert stream_callback.enabled is True # refill admitted a streaming request + # Row 0 belongs to request 1 (non-streaming), row 1 to request 2. + stream_callback([0, 1], [101, 201], [None, None]) + finished_callback(torch.tensor([0]), generated, logprobs, response_lens, "stop", ()) + return worker_mod._build_rollout_from_tensor_rows( + payload.prompts_by_dp[0], + generated, + logprobs, + response_lens, + ["stop", "length"], + 0, + len(payload.prompts_by_dp[0]), + (), + ) + + worker.infer_rollout = infer_rollout + worker._cmd_queue = _QueueDouble([second]) + worker._deferred_commands = [] + + with PatchedContext(worker_mod, get_tp_context=lambda: ctx): + remaining = worker.run_rollout_command(first) + + items = worker._result_queue.items + stream_items = [item for item in items if isinstance(item[1].payload, StreamTokenStep)] + finished_items = [item for item in items if not isinstance(item[1].payload, StreamTokenStep)] + + assert [item[1].request_id for item in stream_items] == [2] + step = stream_items[0][1].payload + assert (step.prompt_idx, step.token_id, step.finish_reason) == (0, 201, None) + assert [item[1].request_id for item in finished_items] == [1] + assert finished_items[0][1].payload.response_ids == [[10, 11]] + assert [request_id for request_id, _ in remaining] == [2] + assert remaining[0][1].response_ids == [[20, 21]] + + def test_worker_refill_does_not_double_append_aliased_payload_prompts(): """InferenceBatchState owns the payload prompt list, so refill must not append it twice.""" @@ -513,7 +629,7 @@ def append_prompts(self, prompts, prompt_features=None): self.prompts.extend(prompts) return list(range(start, start + len(prompts))) - def infer_rollout(payload, finished_callback=None, refill_callback=None): + def infer_rollout(payload, finished_callback=None, refill_callback=None, stream_callback=None): assert refill_callback is not None refill_callback(AliasedState(payload.prompts_by_dp[0])) assert payload.prompts_by_dp[0] == [[1], [2], [3]] @@ -551,7 +667,7 @@ def test_worker_sends_empty_ack_for_requests_without_local_dp_rows(): worker._current_request_ids = [1] ctx = SimpleNamespace(dp_rank=1, is_rank0=True) - def infer_rollout(payload, finished_callback=None, refill_callback=None): + def infer_rollout(payload, finished_callback=None, refill_callback=None, stream_callback=None): del payload, finished_callback, refill_callback return worker_mod._empty_rollout() diff --git a/tests/test_protocol_cpu.py b/tests/test_protocol_cpu.py index d3ab0b3e..ff1e6646 100644 --- a/tests/test_protocol_cpu.py +++ b/tests/test_protocol_cpu.py @@ -1,6 +1,8 @@ from __future__ import annotations +import asyncio import importlib.util +import itertools import sys import threading import unittest @@ -110,6 +112,55 @@ def test_async_call_can_wait_for_user_visible_rollout_ranks_only(self): self.assertEqual(pending.results[0], "dp0") self.assertEqual(pending.results[2], "dp1") + def test_call_async_stream_yields_steps_and_propagates_rank_errors(self): + """Streaming calls route intermediate steps and raise on rank failure.""" + + cluster = object.__new__(TPCluster) + cluster.config = SimpleNamespace(tp_size=1, dp_size=1) + cluster.started = True + cluster.cmd_queues = [FakeQueue()] + cluster._pending_lock = threading.Lock() + cluster._send_lock = threading.Lock() + cluster._pending_calls = {} + cluster._request_ids = itertools.count(1) + cluster._pump_stop = threading.Event() + cluster._pump_thread = None + + async def scenario(): + steps = [] + stream = cluster.call_async_stream(Op.INFER_ROLLOUT, None, result_ranks={0}, timeout=0.01) + try: + first_step = asyncio.ensure_future(stream.__anext__()) + await asyncio.sleep(0.05) + (request_id, pending), = cluster._pending_calls.items() + + # Intermediate stream step: routed into the stream queue + # without completing the call. + cluster._apply_result( + request_id, + 0, + WorkerResult(ok=True, payload=SimpleNamespace(prompt_idx=2, token_id=7), request_id=request_id), + pending, + ) + steps.append(await first_step) + self.assertFalse(pending.event.is_set()) + + # Rank failure: the generator must raise instead of ending + # the stream silently. + second_step = asyncio.ensure_future(stream.__anext__()) + await asyncio.sleep(0.05) + cluster._apply_result(request_id, 0, WorkerResult(ok=False, error="boom"), pending) + with self.assertRaises(RuntimeError): + await second_step + return steps + finally: + await stream.aclose() + + steps = asyncio.run(scenario()) + + self.assertEqual([(step.prompt_idx, step.token_id) for step in steps], [(2, 7)]) + self.assertEqual(cluster._pending_calls, {}) + if __name__ == "__main__": unittest.main()