59a0a3844c
PR Test AMD / cancel-on-close (push) Has been skipped
PR Test NVIDIA ARM / scan (push) Has been skipped
PR Test NVIDIA / cancel-on-close (push) Has been skipped
PR Test AMD / scan (push) Has been skipped
PR Test NVIDIA ARM / cancel-on-close (push) Has been skipped
PR Test NVIDIA / scan (push) Has been skipped
Release Docker Images / build (cu129-torch-2.11.0) (push) Has been skipped
Release Docker Images / build (cu130-torch-2.11.0) (push) Has been skipped
Release PyPI / publish (push) Has been skipped
Scheduler Python Test / test (push) Successful in 27m19s
Docs / build (push) Successful in 28m8s
Scheduler C++ Test / test (push) Successful in 28m19s
Scheduler C++ Test / test-flat (push) Successful in 28m18s
Docs / deploy (push) Has been cancelled
PR Test AMD / finish (push) Has been cancelled
PR Test NVIDIA / finish (push) Has been cancelled
PR Test NVIDIA ARM / finish (push) Has been cancelled
PR Test NVIDIA ARM / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test AMD / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test NVIDIA / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
412 lines
18 KiB
Python
412 lines
18 KiB
Python
# Copyright (c) 2026 LightSeek Foundation
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
# of this software and associated documentation files (the "Software"), to deal
|
|
# in the Software without restriction, including without limitation the rights
|
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
# copies of the Software, and to permit persons to whom the Software is
|
|
# furnished to do so, subject to the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be included in
|
|
# all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
# SOFTWARE.
|
|
|
|
"""Per-request output state and batch-output handling for the async frontend.
|
|
|
|
Hosts:
|
|
|
|
* ``ReqState`` — per-request bookkeeping that ``AsyncLLM`` keeps in
|
|
its ``rid_to_state`` map.
|
|
* ``OutputProcessor`` — owns the hot-path translation from scheduler
|
|
output frames (``BatchStrOut`` / ``BatchTokenIDOut`` /
|
|
``BatchEmbeddingOut``) into the dict-
|
|
shaped payload the per-request ``RequestOutputCollector`` merges.
|
|
Also owns logprob detokenization, per-request streaming metrics,
|
|
and request dumping. Stop authority stays with the scheduler —
|
|
finish reasons are consumed as input flags, not invented here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import dataclasses
|
|
import logging
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from tokenspeed.runtime.engine.collector import RequestOutputCollector
|
|
from tokenspeed.runtime.engine.detokenizer import IncrementalDetokenizer
|
|
from tokenspeed.runtime.engine.io_struct import (
|
|
BatchEmbeddingOut,
|
|
BatchStrOut,
|
|
BatchTokenIDOut,
|
|
)
|
|
from tokenspeed.runtime.engine.logprobs import LogprobsProcessor
|
|
from tokenspeed.runtime.metrics.collector import RequestFinishStats
|
|
|
|
if TYPE_CHECKING:
|
|
from tokenspeed.runtime.engine.async_llm import AsyncLLM
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class ReqState:
|
|
"""Store the state a request."""
|
|
|
|
collector: RequestOutputCollector
|
|
finished: bool
|
|
event: asyncio.Event
|
|
obj: Any
|
|
|
|
# For metrics
|
|
created_time: float
|
|
tokenized_time: float = 0.0
|
|
finished_time: float = 0.0
|
|
first_token_time: float = 0.0
|
|
first_completion_tokens: int = 1
|
|
last_time: float = 0.0
|
|
last_pure_time: float = 0.0
|
|
last_completion_tokens: int = 1
|
|
|
|
# For streaming output
|
|
last_output_offset: int = 0
|
|
|
|
# For incremental state update.
|
|
text: str = ""
|
|
output_ids: list[int] = dataclasses.field(default_factory=list)
|
|
logprobs_info: dict = dataclasses.field(default_factory=dict)
|
|
|
|
# Inline detokenizer: lazily constructed on the first
|
|
# BatchTokenIDOut frame for this request. Stays None for
|
|
# raw-token mode (skip_tokenizer_init or tokenizer absent).
|
|
# See runtime/engine/detokenizer.py::IncrementalDetokenizer.
|
|
inline_detokenizer: IncrementalDetokenizer | None = None
|
|
|
|
|
|
class OutputProcessor:
|
|
"""Translate scheduler output frames into per-request collector payloads.
|
|
|
|
Owns the batch-output dispatch, logprob detokenization, streaming
|
|
metrics collection, and request dumping. The engine reference lets
|
|
this class read ``rid_to_state``, ``tokenizer``, ``server_args``,
|
|
and the metrics / dump-state fields that live on ``AsyncLLM``
|
|
without cloning them here.
|
|
"""
|
|
|
|
def __init__(self, engine: AsyncLLM):
|
|
self.engine = engine
|
|
self.logprobs_processor = LogprobsProcessor(engine)
|
|
|
|
def handle_batch_output(
|
|
self,
|
|
recv_obj: BatchStrOut | BatchEmbeddingOut | BatchTokenIDOut,
|
|
):
|
|
for i, rid in enumerate(recv_obj.rids):
|
|
state: ReqState = self.engine.rid_to_state.get(rid, None)
|
|
if state is None:
|
|
logger.error(
|
|
"Received output for rid=%r but the state was deleted in AsyncLLM.",
|
|
rid,
|
|
)
|
|
continue
|
|
|
|
# Build meta_info and return value
|
|
meta_info = {
|
|
"id": rid,
|
|
"finish_reason": recv_obj.finished_reasons[i],
|
|
"prompt_tokens": recv_obj.prompt_tokens[i],
|
|
}
|
|
logprobs_info = state.logprobs_info if not state.obj.stream else {}
|
|
|
|
obj = state.obj
|
|
sp = getattr(obj, "sampling_params", None) or {}
|
|
vllm_req = sp.get("logprobs") is not None
|
|
sglang_req = bool(getattr(obj, "return_logprob", False))
|
|
if vllm_req or sglang_req:
|
|
# Render the dialect the request asked for; default = match the
|
|
# request (vLLM via sampling_params.logprobs, else SGLang).
|
|
fmt = getattr(obj, "logprob_format", None) or (
|
|
"vllm" if vllm_req else "sglang"
|
|
)
|
|
try:
|
|
self.logprobs_processor.convert_logprob_style(
|
|
logprobs_info,
|
|
fmt,
|
|
getattr(obj, "top_logprobs_num", 0) or 0,
|
|
getattr(obj, "token_ids_logprob", None),
|
|
bool(getattr(obj, "return_text_in_logprobs", False)),
|
|
recv_obj,
|
|
i,
|
|
)
|
|
meta_info.update(logprobs_info)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Failed to attach logprobs for rid=%s: %s. Returning response without logprobs.",
|
|
rid,
|
|
exc,
|
|
)
|
|
|
|
if not isinstance(recv_obj, BatchEmbeddingOut):
|
|
meta_info.update(
|
|
{
|
|
"completion_tokens": recv_obj.completion_tokens[i],
|
|
"cached_tokens": recv_obj.cached_tokens[i],
|
|
}
|
|
)
|
|
|
|
if getattr(recv_obj, "output_hidden_states", None):
|
|
meta_info["hidden_states"] = recv_obj.output_hidden_states[i]
|
|
|
|
if isinstance(recv_obj, BatchStrOut):
|
|
if len(recv_obj.batch_accept_draft_tokens) > 0:
|
|
meta_info.update(
|
|
{"accept_draft_tokens": recv_obj.batch_accept_draft_tokens[i]}
|
|
)
|
|
state.text += recv_obj.output_strs[i]
|
|
if state.obj.stream:
|
|
state.logprobs_info = logprobs_info
|
|
state.output_ids.extend(recv_obj.output_ids[i])
|
|
output_token_ids = state.output_ids[state.last_output_offset :]
|
|
state.last_output_offset = len(state.output_ids)
|
|
else:
|
|
state.logprobs_info.update(logprobs_info)
|
|
state.output_ids.extend(recv_obj.output_ids[i])
|
|
output_token_ids = state.output_ids.copy()
|
|
|
|
out_dict = {
|
|
"text": state.text,
|
|
"output_ids": output_token_ids,
|
|
"meta_info": meta_info,
|
|
}
|
|
if len(recv_obj.output_extra_infos):
|
|
out_dict["output_extra_info"] = recv_obj.output_extra_infos[i]
|
|
elif isinstance(recv_obj, BatchTokenIDOut):
|
|
if (
|
|
self.engine.server_args.enable_inline_detokenizer
|
|
and self.engine.tokenizer is not None
|
|
):
|
|
# Inline detokenizer path: run
|
|
# IncrementalDetokenizer per request and produce
|
|
# a BatchStrOut-shaped out_dict that
|
|
# RequestOutputCollector merges.
|
|
if state.inline_detokenizer is None:
|
|
state.inline_detokenizer = IncrementalDetokenizer(
|
|
decoded_text=recv_obj.decoded_texts[i],
|
|
read_offset=recv_obj.read_offsets[i],
|
|
)
|
|
incremental_emit = state.inline_detokenizer.process(
|
|
self.engine.tokenizer,
|
|
new_decode_ids=recv_obj.decode_ids[i],
|
|
finished_reason=recv_obj.finished_reasons[i],
|
|
no_stop_trim=recv_obj.no_stop_trim[i],
|
|
skip_special_tokens=recv_obj.skip_special_tokens[i],
|
|
spaces_between_special_tokens=recv_obj.spaces_between_special_tokens[
|
|
i
|
|
],
|
|
)
|
|
if len(recv_obj.batch_accept_draft_tokens) > 0:
|
|
meta_info.update(
|
|
{
|
|
"accept_draft_tokens": recv_obj.batch_accept_draft_tokens[
|
|
i
|
|
]
|
|
}
|
|
)
|
|
state.text += incremental_emit
|
|
if state.obj.stream:
|
|
state.logprobs_info = logprobs_info
|
|
state.output_ids.extend(recv_obj.decode_ids[i])
|
|
output_token_ids = state.output_ids[state.last_output_offset :]
|
|
state.last_output_offset = len(state.output_ids)
|
|
else:
|
|
state.logprobs_info.update(logprobs_info)
|
|
state.output_ids.extend(recv_obj.decode_ids[i])
|
|
output_token_ids = state.output_ids.copy()
|
|
|
|
out_dict = {
|
|
"text": state.text,
|
|
"output_ids": output_token_ids,
|
|
"meta_info": meta_info,
|
|
}
|
|
if len(recv_obj.output_extra_infos):
|
|
out_dict["output_extra_info"] = recv_obj.output_extra_infos[i]
|
|
else:
|
|
# Raw-token path: skip_tokenizer_init, or
|
|
# ``enable_inline_detokenizer`` is on but
|
|
# ``self.tokenizer is None`` unexpectedly. Keep the
|
|
# response shape aligned with the BatchStrOut path by
|
|
# always populating ``text`` from the accumulated state.
|
|
if (
|
|
self.engine.server_args.enable_inline_detokenizer
|
|
and self.engine.tokenizer is None
|
|
and not self.engine.server_args.skip_tokenizer_init
|
|
):
|
|
logger.warning(
|
|
"AsyncLLM raw-token branch fired with "
|
|
"enable_inline_detokenizer=True and "
|
|
"skip_tokenizer_init=False; "
|
|
"self.tokenizer is unexpectedly None. "
|
|
"Output text will be empty for rid=%s.",
|
|
rid,
|
|
)
|
|
|
|
output_multi_ids = None
|
|
if self.engine.server_args.stream_output and state.obj.stream:
|
|
state.output_ids.extend(recv_obj.output_ids[i])
|
|
output_token_ids = state.output_ids[state.last_output_offset :]
|
|
if recv_obj.output_multi_ids is not None:
|
|
output_multi_ids = recv_obj.output_multi_ids[i][
|
|
state.last_output_offset :
|
|
]
|
|
state.last_output_offset = len(state.output_ids)
|
|
else:
|
|
state.output_ids.extend(recv_obj.output_ids[i])
|
|
output_token_ids = state.output_ids.copy()
|
|
if recv_obj.output_multi_ids is not None:
|
|
output_multi_ids = recv_obj.output_multi_ids[i]
|
|
|
|
if len(recv_obj.batch_accept_draft_tokens) > 0:
|
|
meta_info.update(
|
|
{
|
|
"accept_draft_tokens": recv_obj.batch_accept_draft_tokens[
|
|
i
|
|
]
|
|
}
|
|
)
|
|
|
|
out_dict = {
|
|
"text": state.text,
|
|
"output_ids": output_token_ids,
|
|
"meta_info": meta_info,
|
|
}
|
|
if len(recv_obj.output_extra_infos):
|
|
out_dict["output_extra_info"] = recv_obj.output_extra_infos[i]
|
|
if output_multi_ids is not None:
|
|
out_dict["output_multi_ids"] = output_multi_ids
|
|
else:
|
|
out_dict = {
|
|
"embedding": recv_obj.embeddings[i],
|
|
"meta_info": meta_info,
|
|
}
|
|
|
|
state.finished = recv_obj.finished_reasons[i] is not None
|
|
if state.finished:
|
|
if self.engine.server_args.speculative_algorithm:
|
|
meta_info["spec_verify_ct"] = recv_obj.spec_verify_ct[i]
|
|
state.finished_time = time.time()
|
|
meta_info["e2e_latency"] = state.finished_time - state.created_time
|
|
|
|
state.collector.put(
|
|
out_dict, stream=bool(getattr(state.obj, "stream", False))
|
|
)
|
|
state.event.set()
|
|
|
|
# Log metrics and dump
|
|
if self.engine.enable_metrics and not isinstance(
|
|
recv_obj, BatchEmbeddingOut
|
|
):
|
|
self.collect_metrics(state, recv_obj, i)
|
|
if (
|
|
self.engine.dump_requests_folder
|
|
and state.finished
|
|
and state.obj.log_metrics
|
|
):
|
|
self.dump_requests(state, out_dict)
|
|
|
|
def collect_metrics(self, state: ReqState, recv_obj, i: int):
|
|
completion_tokens = (
|
|
recv_obj.completion_tokens[i]
|
|
if getattr(recv_obj, "completion_tokens", None)
|
|
else 0
|
|
)
|
|
|
|
if state.first_token_time == 0.0:
|
|
state.first_token_time = state.last_time = time.time()
|
|
state.last_pure_time = recv_obj.generated_time
|
|
state.last_completion_tokens = completion_tokens
|
|
state.first_completion_tokens = completion_tokens
|
|
self.engine.metrics.observe_time_to_first_token(
|
|
state.first_token_time - state.created_time
|
|
)
|
|
else:
|
|
num_new_tokens = completion_tokens - state.last_completion_tokens
|
|
if num_new_tokens:
|
|
new_time = time.time()
|
|
interval = new_time - state.last_time
|
|
pure_interval = recv_obj.generated_time - state.last_pure_time
|
|
self.engine.metrics.observe_inter_token_latency(
|
|
interval,
|
|
num_new_tokens,
|
|
)
|
|
self.engine.metrics.observe_inter_token_latency(
|
|
pure_interval, num_new_tokens
|
|
)
|
|
state.last_pure_time = recv_obj.generated_time
|
|
state.last_time = new_time
|
|
state.last_completion_tokens = completion_tokens
|
|
|
|
if state.finished:
|
|
fr = recv_obj.finished_reasons[i]
|
|
# TODO: consolidate the return type of fr.
|
|
finished_ok = not (
|
|
fr.get("type") == "abort"
|
|
if isinstance(fr, dict)
|
|
else getattr(fr, "is_error", False)
|
|
)
|
|
cached_prompt = (
|
|
recv_obj.cached_tokens[i]
|
|
if getattr(recv_obj, "cached_tokens", None) is not None
|
|
else 0
|
|
)
|
|
self.engine.metrics.record_request_finish(
|
|
RequestFinishStats(
|
|
prompt_tokens=recv_obj.prompt_tokens[i],
|
|
generation_tokens=completion_tokens,
|
|
e2e_latency=state.finished_time - state.created_time,
|
|
cached_prompt_tokens=cached_prompt,
|
|
finished_ok=finished_ok,
|
|
)
|
|
)
|
|
if (completion_tokens - state.first_completion_tokens) > 0:
|
|
self.engine.metrics.observe_inter_token_latency(
|
|
state.finished_time - state.first_token_time,
|
|
completion_tokens - state.first_completion_tokens,
|
|
)
|
|
|
|
def dump_requests(self, state: ReqState, out_dict: dict):
|
|
import pickle as _pickle
|
|
|
|
self.engine.dump_request_list.append(
|
|
(state.obj, out_dict, state.created_time, time.time())
|
|
)
|
|
|
|
if len(self.engine.dump_request_list) >= self.engine.dump_requests_threshold:
|
|
dump_folder = Path(self.engine.dump_requests_folder)
|
|
filename = dump_folder / (
|
|
datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + ".pkl"
|
|
)
|
|
logger.info(
|
|
"Dump %s requests to %s", len(self.engine.dump_request_list), filename
|
|
)
|
|
|
|
to_dump = self.engine.dump_request_list
|
|
self.engine.dump_request_list = []
|
|
|
|
def background_task():
|
|
dump_folder.mkdir(parents=True, exist_ok=True)
|
|
with filename.open("wb") as dump_file:
|
|
_pickle.dump(to_dump, dump_file)
|
|
|
|
# Schedule the task to run in the background without awaiting it
|
|
asyncio.create_task(asyncio.to_thread(background_task))
|