Files
lightseekorg--tokenspeed/python/tokenspeed/runtime/entrypoints/engine.py
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:32:31 +08:00

589 lines
22 KiB
Python
Executable File

# SPDX-License-Identifier: MIT AND Apache-2.0
# SPDX-FileCopyrightText: Copyright (c) 2026 LightSeek Foundation
# SPDX-FileCopyrightText: Copyright 2023-2024 SGLang Team
#
# 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.
"""
The entry point of inference server.
This file implements python APIs for the inference engine.
"""
# ruff: noqa: E402
import asyncio
import atexit
import copy
import dataclasses
import multiprocessing as mp
import os
import signal
import threading
from collections.abc import AsyncIterator, Iterator
import zmq
import zmq.asyncio
from tokenspeed.runtime.engine.async_llm import AsyncLLM
from tokenspeed.runtime.engine.llm import LLM
def _ignore_threading_atexit(*args, **kwargs) -> None:
return None
# Fix a bug of Python threading
setattr(threading, "_register_atexit", _ignore_threading_atexit)
import torch
import uvloop
from tokenspeed.runtime.engine.data_parallel_controller import (
run_data_parallel_controller_process,
)
from tokenspeed.runtime.engine.event_loop import run_event_loop
from tokenspeed.runtime.engine.io_struct import (
GenerateReqInput,
GetWeightsByNameReqInput,
InitWeightsUpdateGroupReqInput,
ReleaseMemoryOccupationReqInput,
ResumeMemoryOccupationReqInput,
RpcReqInput,
RpcReqOutput,
UpdateWeightFromDiskReqInput,
UpdateWeightsFromDistributedReqInput,
UpdateWeightsFromTensorReqInput,
)
from tokenspeed.runtime.entrypoints.engine_base import EngineBase
from tokenspeed.runtime.utils import (
MultiprocessingSerializer,
configure_logger,
get_colorful_logger,
launch_dummy_health_check_server,
prepare_model_and_tokenizer,
set_prometheus_multiproc_dir,
set_ulimit,
)
from tokenspeed.runtime.utils.env import envs
from tokenspeed.runtime.utils.process import kill_process_tree
from tokenspeed.runtime.utils.server_args import PortArgs, ServerArgs
from tokenspeed.runtime.utils.torch_memory_saver_adapter import TorchMemorySaverAdapter
from tokenspeed.version import __version__
logger = get_colorful_logger(__name__)
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
class Engine(EngineBase):
"""
The entry point to the inference engine.
- The engine consists of three components:
1. TokenizerManager: Tokenizes the requests and sends them to the scheduler.
2. Scheduler (subprocess): Receives requests from the Tokenizer Manager, schedules batches, forwards them, and sends the output tokens to the Detokenizer Manager.
3. DetokenizerManager (subprocess): Detokenizes the output tokens and sends the result back to the Tokenizer Manager.
Note:
1. The HTTP server, Engine, and TokenizerManager both run in the main process.
2. Inter-process communication is done through ICP (each process uses a different port) via the ZMQ library.
"""
def __init__(self, **kwargs):
"""
The arguments of this function is the same as `tokenspeed/runtime/utils/server_args.py::ServerArgs`.
Please refer to `ServerArgs` for the documentation.
"""
if "server_args" in kwargs:
# Directly load server_args
server_args = kwargs["server_args"]
else:
# Construct server_args from kwargs
if "log_level" not in kwargs:
# Do not print logs by default
kwargs["log_level"] = "error"
server_args = ServerArgs(**kwargs)
# Shutdown the subprocesses automatically when the program exits
atexit.register(self.shutdown)
# Allocate ports for inter-process communications
self.port_args = PortArgs.init_new(server_args)
logger.info("server_args=%r", server_args)
# Launch subprocesses
tokenizer_manager, _, scheduler_info = _launch_subprocesses(
server_args=server_args,
port_args=self.port_args,
)
self.server_args = server_args
self.tokenizer_manager = tokenizer_manager
self.scheduler_info = scheduler_info
# Sync facade for blocking callers. Owns its own bg event-loop thread; see runtime/engine/llm.py
# for the queue-bridge semantics.
self.llm = LLM(self.tokenizer_manager)
def generate(
self,
# The input prompt. It can be a single prompt or a batch of prompts.
prompt: list[str] | str | None = None,
sampling_params: list[dict] | dict | None = None,
# The token ids for text; one can either specify text or input_ids.
input_ids: list[list[int]] | list[int] | None = None,
# SGLang-compatible logprob controls; vLLM-compatible requests use
# sampling_params["logprobs"].
return_logprob: list[bool] | bool | None = None,
logprob_start_len: list[int] | int | None = None,
top_logprobs_num: list[int] | int | None = None,
token_ids_logprob: list[list[int]] | list[int] | None = None,
return_text_in_logprobs: bool = False,
logprob_format: list[str | None] | str | None = None,
custom_logit_processor: list[str] | str | None = None,
return_hidden_states: bool = False,
stream: bool = False,
bootstrap_host: list[str] | str | None = None,
bootstrap_port: list[int] | int | None = None,
bootstrap_room: list[int] | int | None = None,
data_parallel_rank: int | None = None,
) -> dict | Iterator[dict]:
"""
The arguments of this function match
``tokenspeed.runtime.engine.io_struct.GenerateReqInput``.
Please refer to ``GenerateReqInput`` for the documentation.
"""
if self.server_args.mapping.has_attn_dp:
if data_parallel_rank is None:
logger.debug("data_parallel_rank not provided, using default dispatch")
elif data_parallel_rank < 0:
raise ValueError("data_parallel_rank must be non-negative")
elif data_parallel_rank >= self.server_args.mapping.attn.dp_size:
raise ValueError(
f"data_parallel_rank must be less than dp_size: {self.server_args.mapping.attn.dp_size}"
)
obj = GenerateReqInput(
text=prompt,
input_ids=input_ids,
sampling_params=sampling_params,
return_logprob=return_logprob,
logprob_start_len=logprob_start_len,
top_logprobs_num=top_logprobs_num,
token_ids_logprob=token_ids_logprob,
return_text_in_logprobs=return_text_in_logprobs,
logprob_format=logprob_format,
custom_logit_processor=custom_logit_processor,
return_hidden_states=return_hidden_states,
stream=stream,
bootstrap_host=bootstrap_host,
bootstrap_port=bootstrap_port,
bootstrap_room=bootstrap_room,
)
if stream:
return self.llm.generate_stream(obj)
else:
return self.llm.generate(obj)
async def async_generate(
self,
# The input prompt. It can be a single prompt or a batch of prompts.
prompt: list[str] | str | None = None,
sampling_params: list[dict] | dict | None = None,
# The token ids for text; one can either specify text or input_ids.
input_ids: list[list[int]] | list[int] | None = None,
input_embeds: torch.Tensor = None,
input_multi_ids: list[list[int]] = None,
input_extra_infos: list[dict] = None,
# Same legacy logprob controls as generate().
return_logprob: list[bool] | bool | None = None,
logprob_start_len: list[int] | int | None = None,
top_logprobs_num: list[int] | int | None = None,
token_ids_logprob: list[list[int]] | list[int] | None = None,
return_text_in_logprobs: bool = False,
logprob_format: list[str | None] | str | None = None,
custom_logit_processor: list[str] | str | None = None,
return_hidden_states: bool = False,
stream: bool = False,
bootstrap_host: list[str] | str | None = None,
bootstrap_port: list[int] | int | None = None,
bootstrap_room: list[int] | int | None = None,
user_rid: list[str] | str | None = None,
) -> dict | AsyncIterator[dict]:
"""
The arguments of this function match
``tokenspeed.runtime.engine.io_struct.GenerateReqInput``.
Please refer to ``GenerateReqInput`` for the documentation.
"""
obj = GenerateReqInput(
text=prompt,
input_ids=input_ids,
input_embeds=input_embeds,
input_multi_ids=input_multi_ids,
input_extra_infos=input_extra_infos,
sampling_params=sampling_params,
return_logprob=return_logprob,
logprob_start_len=logprob_start_len,
top_logprobs_num=top_logprobs_num,
token_ids_logprob=token_ids_logprob,
return_text_in_logprobs=return_text_in_logprobs,
logprob_format=logprob_format,
return_hidden_states=return_hidden_states,
stream=stream,
custom_logit_processor=custom_logit_processor,
bootstrap_host=bootstrap_host,
bootstrap_port=bootstrap_port,
bootstrap_room=bootstrap_room,
user_rid=user_rid,
)
generator = self.tokenizer_manager.generate_request(obj)
async def wrapped_output_generator(original_async_gen):
async for item in original_async_gen:
yield item
await asyncio.sleep(1)
self.tokenizer_manager.abort_request(obj.rid[0])
if stream is True:
return wrapped_output_generator(generator)
else:
return await generator.__anext__()
def shutdown(self):
"""Shutdown the engine"""
# Stop the sync-facade event loop before subprocess teardown so any
# in-flight blocking callers see a clean loop close instead of a
# stale-reference error.
if getattr(self, "llm", None) is not None:
self.llm.shutdown()
kill_process_tree(os.getpid(), include_parent=False)
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
self.shutdown()
return False
def flush_cache(self):
return self.llm.run(self.tokenizer_manager.flush_cache())
def pause_scheduler(self, mode: str = "abort"):
"""Pause generation (e.g. to swap weights). See AsyncLLM.pause_scheduler."""
return self.llm.run(self.tokenizer_manager.pause_scheduler(mode=mode))
def resume_scheduler(self):
"""Resume generation after :meth:`pause_scheduler`."""
return self.llm.run(self.tokenizer_manager.resume_scheduler())
def is_scheduler_paused(self):
"""Return whether the scheduler is currently paused."""
return self.llm.run(self.tokenizer_manager.is_scheduler_paused())
def start_profile(self):
self.llm.run(self.tokenizer_manager.start_profile())
def stop_profile(self):
self.llm.run(self.tokenizer_manager.stop_profile())
def start_expert_distribution_record(self):
self.llm.run(self.tokenizer_manager.start_expert_distribution_record())
def stop_expert_distribution_record(self):
self.llm.run(self.tokenizer_manager.stop_expert_distribution_record())
def dump_expert_distribution_record(self):
self.llm.run(self.tokenizer_manager.dump_expert_distribution_record())
def get_server_info(self):
internal_states = self.llm.run(self.tokenizer_manager.get_internal_state())
return {
**dataclasses.asdict(self.tokenizer_manager.server_args),
**self.scheduler_info,
"internal_states": internal_states,
"version": __version__,
}
def init_weights_update_group(
self,
master_address: str,
master_port: int,
rank_offset: int,
world_size: int,
group_name: str,
backend: str = "nccl",
):
"""Initialize parameter update group."""
obj = InitWeightsUpdateGroupReqInput(
master_address=master_address,
master_port=master_port,
rank_offset=rank_offset,
world_size=world_size,
group_name=group_name,
backend=backend,
)
return self.llm.run(self.tokenizer_manager.init_weights_update_group(obj))
def update_weights_from_distributed(
self,
names: list[str],
dtypes: list[str],
shapes: list[list[int]],
group_name: str = "weight_update_group",
flush_cache: bool = True,
):
"""Update weights from distributed source."""
obj = UpdateWeightsFromDistributedReqInput(
names=names,
dtypes=dtypes,
shapes=shapes,
group_name=group_name,
flush_cache=flush_cache,
)
return self.llm.run(self.tokenizer_manager.update_weights_from_distributed(obj))
def update_weights_from_tensor(
self,
named_tensors: list[tuple[str, torch.Tensor]],
load_format: str | None = None,
flush_cache: bool = True,
):
"""Update weights from distributed source. If there are going to be more updates, set `flush_cache` to be false
to avoid duplicated cache cleaning operation."""
obj = UpdateWeightsFromTensorReqInput(
serialized_named_tensors=[
MultiprocessingSerializer.serialize(named_tensors)
for _ in range(self.server_args.mapping.world_size)
],
load_format=load_format,
flush_cache=flush_cache,
)
return self.llm.run(self.tokenizer_manager.update_weights_from_tensor(obj))
def update_weights_from_disk(
self,
model_path: str,
load_format: str | None = None,
):
"""Update the weights from disk inplace without re-launching the engine.
This method allows updating the model weights from disk without restarting
the engine. It can be used to load a different model or update weights with
new training.
"""
obj = UpdateWeightFromDiskReqInput(
model_path=model_path,
load_format=load_format,
)
return self.llm.run(self.tokenizer_manager.update_weights_from_disk(obj))
def get_weights_by_name(self, name: str, truncate_size: int = 100):
"""Get weights by parameter name."""
obj = GetWeightsByNameReqInput(name=name, truncate_size=truncate_size)
return self.llm.run(self.tokenizer_manager.get_weights_by_name(obj))
def release_memory_occupation(self, tags: list[str] | None = None):
obj = ReleaseMemoryOccupationReqInput(tags=tags)
return self.llm.run(self.tokenizer_manager.release_memory_occupation(obj))
def resume_memory_occupation(self, tags: list[str] | None = None):
obj = ResumeMemoryOccupationReqInput(tags=tags)
return self.llm.run(self.tokenizer_manager.resume_memory_occupation(obj))
def is_sleeping(self) -> bool:
"""Return whether any GPU memory is currently released (data-plane sleep)."""
return self.llm.run(self.tokenizer_manager.is_sleeping())
"""
Execute an RPC call on all scheduler processes.
"""
def collective_rpc(self, method: str, **kwargs):
obj = RpcReqInput(method=method, parameters=kwargs)
self.send_to_rpc.send_pyobj(obj)
recv_req = self.send_to_rpc.recv_pyobj(zmq.BLOCKY)
if not isinstance(recv_req, RpcReqOutput):
raise TypeError(f"Expected RpcReqOutput, got {type(recv_req).__name__}.")
if not recv_req.success:
raise RuntimeError(recv_req.message)
def save_remote_model(self, **kwargs):
self.collective_rpc("save_remote_model", **kwargs)
def save_sharded_model(self, **kwargs):
self.collective_rpc("save_sharded_model", **kwargs)
def _set_envs_and_config(server_args: ServerArgs):
# Set global environments
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
os.environ["NCCL_CUMEM_ENABLE"] = str(int(server_args.enable_symm_mem))
if not server_args.enable_symm_mem:
os.environ["NCCL_NVLS_ENABLE"] = str(int(server_args.enable_nccl_nvls))
os.environ["CUDA_DEVICE_MAX_CONNECTIONS"] = "4"
os.environ["CUDA_MODULE_LOADING"] = "AUTO"
if not server_args.disable_tf32:
# Force TF32 on for cuBLAS/cuDNN matmuls. setdefault so a user's
# explicit env wins; --disable-tf32 is the documented opt-out.
os.environ.setdefault("NVIDIA_TF32_OVERRIDE", "1")
os.environ.setdefault("TORCH_ALLOW_TF32_CUBLAS_OVERRIDE", "1")
# Set prometheus env vars
if server_args.enable_metrics:
set_prometheus_multiproc_dir()
# Set ulimit
set_ulimit()
# Install a launch-phase SIGQUIT handler so a failing child tears down the
# whole local process tree instead of leaving orphaned workers behind.
# TokenizerManager may replace this handler later during steady-state
# serving.
def launch_phase_sigquit_handler(signum, frame):
logger.error(
"Received sigquit from a child process. It usually means the child failed."
)
kill_process_tree(os.getpid())
signal.signal(signal.SIGQUIT, launch_phase_sigquit_handler)
# Set mp start method
mp.set_start_method("spawn", force=True)
def _launch_subprocesses(
server_args: ServerArgs, port_args: PortArgs | None = None
) -> tuple[AsyncLLM, None, dict]:
"""
Launch the TokenizerManager in the main process, the Scheduler in a subprocess, and the DetokenizerManager in another subprocess.
"""
# Configure global environment
configure_logger(server_args)
_set_envs_and_config(server_args)
# Allocate ports for inter-process communications
if port_args is None:
port_args = PortArgs.init_new(server_args)
logger.info("server_args=%r", server_args)
# If using model from www.modelscope.cn, first download the model.
server_args.model, server_args.tokenizer = prepare_model_and_tokenizer(
server_args.model, server_args.tokenizer
)
scheduler_procs = []
if not server_args.mapping.attn.has_dp:
# Launch tensor parallel scheduler processes
memory_saver_adapter = TorchMemorySaverAdapter.create(
enable=server_args.enable_memory_saver
)
scheduler_pipe_readers = []
rank_start = server_args.mapping.nprocs_per_node * server_args.node_rank
rank_end = rank_start + server_args.mapping.nprocs_per_node
for rank in range(rank_start, rank_end):
# Create per-rank server_args with rank-initialized mapping
rank_server_args = copy.copy(server_args)
rank_server_args.mapping = copy.deepcopy(server_args.mapping)
rank_server_args.mapping.rank = rank
reader, writer = mp.Pipe(duplex=False)
proc = mp.Process(
target=run_event_loop,
args=(
rank_server_args,
port_args,
writer,
),
)
with memory_saver_adapter.configure_subprocess():
proc.start()
scheduler_procs.append(proc)
scheduler_pipe_readers.append(reader)
else:
# Launch the data parallel controller
reader, writer = mp.Pipe(duplex=False)
scheduler_pipe_readers = [reader]
proc = mp.Process(
target=run_data_parallel_controller_process,
args=(server_args, port_args, writer),
)
proc.start()
scheduler_procs.append(proc)
if server_args.node_rank >= 1:
# In multi-node cases, non-zero rank nodes do not need to run tokenizer or detokenizer,
# so they can just wait here.
for reader in scheduler_pipe_readers:
data = reader.recv()
if data.get("status") != "ready":
raise RuntimeError(
"Initialization failed. Please see the error messages above."
)
if not envs.TOKENSPEED_BLOCK_NONZERO_RANK_CHILDREN.get():
# When using `Engine` as a Python API, we don't want to block here.
return None, None, None
launch_dummy_health_check_server(
server_args.host, server_args.port, server_args.enable_metrics
)
for proc in scheduler_procs:
proc.join()
logger.error(
"Scheduler or DataParallelController %s terminated with %s",
proc.pid,
proc.exitcode,
)
return None, None, None
# Launch the main-process async frontend. The detokenizer runs
# inline inside AsyncLLM — no separate subprocess.
tokenizer_manager = AsyncLLM(server_args, port_args)
# Wait for the model to finish loading
scheduler_infos = []
for i in range(len(scheduler_pipe_readers)):
try:
data = scheduler_pipe_readers[i].recv()
except EOFError:
logger.error(
"Rank %s scheduler is dead. Please check if there are relevant logs.", i
)
scheduler_procs[i].join()
logger.error("Exit code: %s", scheduler_procs[i].exitcode)
raise
if data["status"] != "ready":
raise RuntimeError(
"Initialization failed. Please see the error messages above."
)
scheduler_infos.append(data)
# Assume all schedulers have the same scheduler_info
scheduler_info = scheduler_infos[0]
tokenizer_manager.max_req_input_len = scheduler_info["max_req_input_len"]
return tokenizer_manager, None, scheduler_info