feat: add native gRPC sidecar module launcher (#31076)
Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com> Signed-off-by: Connor Carpenter <connorc@nvidia.com> Co-authored-by: Connor Carpenter <connorc@nvidia.com>
This commit is contained in:
co-authored by
Connor Carpenter
parent
74338e94f1
commit
21065bc862
@@ -265,6 +265,7 @@ async def init_multi_tokenizer() -> ServerArgs:
|
||||
@asynccontextmanager
|
||||
async def lifespan(fast_api_app: FastAPI):
|
||||
grpc_handle = None
|
||||
sidecar = None
|
||||
warmup_thread = None
|
||||
if getattr(fast_api_app, "is_single_tokenizer_mode", False):
|
||||
server_args = fast_api_app.server_args
|
||||
@@ -397,6 +398,10 @@ async def lifespan(fast_api_app: FastAPI):
|
||||
template_manager=_global_state.template_manager,
|
||||
scheduler_info=_global_state.scheduler_info,
|
||||
)
|
||||
if server_args.sidecar is not None:
|
||||
from sglang.srt.entrypoints.sidecar import start_sidecar
|
||||
|
||||
sidecar = start_sidecar(server_args)
|
||||
|
||||
# Execute the general warmup
|
||||
warmup_thread = threading.Thread(
|
||||
@@ -408,6 +413,11 @@ async def lifespan(fast_api_app: FastAPI):
|
||||
# Start the HTTP server
|
||||
yield
|
||||
finally:
|
||||
if sidecar is not None:
|
||||
try:
|
||||
sidecar.stop()
|
||||
except Exception:
|
||||
logger.exception("Failed to stop sidecar")
|
||||
_shutdown_native_grpc_server(grpc_handle)
|
||||
if tool_server is not None and hasattr(tool_server, "aclose"):
|
||||
await tool_server.aclose()
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# Copyright 2023-2026 SGLang Team
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""Lifecycle management for an optional local native gRPC sidecar."""
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import logging
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
|
||||
from sglang.srt.utils.common import kill_itself_when_parent_died, kill_process_tree
|
||||
from sglang.srt.utils.network import NetworkAddress
|
||||
from sglang.srt.utils.watchdog import SubprocessWatchdog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SGLANG_GRPC_ENDPOINT_ENV = "SGLANG_GRPC_ENDPOINT"
|
||||
_DEFAULT_SIDECAR_SHUTDOWN_TIMEOUT = 45.0
|
||||
|
||||
|
||||
def _loopback_host(host: str) -> str:
|
||||
if not host or host == "0.0.0.0":
|
||||
return "127.0.0.1"
|
||||
if host in ("::", "[::]"):
|
||||
return "::1"
|
||||
return host
|
||||
|
||||
|
||||
def build_sidecar_endpoint(server_args) -> str:
|
||||
return NetworkAddress(
|
||||
_loopback_host(server_args.host), server_args.grpc_port
|
||||
).to_url()
|
||||
|
||||
|
||||
def _parse_sidecar_args(args: list[str] | None) -> tuple[list[str], float]:
|
||||
parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
|
||||
parser.add_argument(
|
||||
"--sidecar-shutdown-timeout",
|
||||
type=float,
|
||||
default=_DEFAULT_SIDECAR_SHUTDOWN_TIMEOUT,
|
||||
)
|
||||
parsed, provider_args = parser.parse_known_args(args or [])
|
||||
if parsed.sidecar_shutdown_timeout <= 0:
|
||||
raise ValueError("--sidecar-shutdown-timeout must be greater than 0.")
|
||||
return provider_args, parsed.sidecar_shutdown_timeout
|
||||
|
||||
|
||||
def _run_sidecar(module_name: str, args: list[str], endpoint: str) -> None:
|
||||
kill_itself_when_parent_died()
|
||||
os.environ[SGLANG_GRPC_ENDPOINT_ENV] = endpoint
|
||||
try:
|
||||
main = getattr(importlib.import_module(module_name), "main")
|
||||
except (AttributeError, ImportError) as e:
|
||||
raise RuntimeError(
|
||||
f"--sidecar requires importable module {module_name!r} "
|
||||
"with a main(argv) function."
|
||||
) from e
|
||||
|
||||
if not callable(main):
|
||||
raise RuntimeError(
|
||||
f"--sidecar requires module {module_name!r} to expose "
|
||||
"a callable main(argv)."
|
||||
)
|
||||
|
||||
main(args)
|
||||
|
||||
|
||||
class Sidecar:
|
||||
def __init__(
|
||||
self,
|
||||
proc,
|
||||
module_name: str,
|
||||
shutdown_timeout: float,
|
||||
):
|
||||
self.proc = proc
|
||||
self.module_name = module_name
|
||||
self.shutdown_timeout = shutdown_timeout
|
||||
self._watchdog = SubprocessWatchdog(
|
||||
processes=[proc], process_names=[module_name]
|
||||
)
|
||||
|
||||
def start(self) -> None:
|
||||
self.proc.start()
|
||||
self._watchdog.start()
|
||||
logger.info(
|
||||
"Sidecar module %s started pid=%s",
|
||||
self.module_name,
|
||||
self.proc.pid,
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._watchdog.stop()
|
||||
if self.proc.is_alive():
|
||||
self.proc.terminate()
|
||||
self.proc.join(timeout=self.shutdown_timeout)
|
||||
else:
|
||||
self.proc.join(timeout=0)
|
||||
|
||||
if self.proc.is_alive():
|
||||
logger.warning("Sidecar module did not terminate; killing process tree")
|
||||
kill_process_tree(self.proc.pid, wait_timeout=self.shutdown_timeout)
|
||||
|
||||
|
||||
def start_sidecar(server_args) -> Sidecar:
|
||||
module_name = server_args.sidecar
|
||||
assert module_name is not None
|
||||
sidecar_args, shutdown_timeout = _parse_sidecar_args(server_args.sidecar_args)
|
||||
endpoint = build_sidecar_endpoint(server_args)
|
||||
proc = mp.get_context("spawn").Process(
|
||||
name=f"sglang_sidecar_{module_name}",
|
||||
target=_run_sidecar,
|
||||
args=(module_name, sidecar_args, endpoint),
|
||||
)
|
||||
sidecar = Sidecar(
|
||||
proc,
|
||||
module_name,
|
||||
shutdown_timeout=shutdown_timeout,
|
||||
)
|
||||
sidecar.start()
|
||||
return sidecar
|
||||
@@ -1181,6 +1181,24 @@ class ServerArgs:
|
||||
"defaults to --port + 10000.",
|
||||
NS("serving"),
|
||||
] = None
|
||||
sidecar: A[
|
||||
Optional[str],
|
||||
"Start a locally managed sidecar against the native gRPC server. "
|
||||
"The selected module must expose main(argv) and read the resolved "
|
||||
"native gRPC endpoint from SGLANG_GRPC_ENDPOINT. Requires --grpc-port "
|
||||
"or SGLANG_GRPC_PORT.",
|
||||
NS("serving"),
|
||||
] = None
|
||||
sidecar_args: A[
|
||||
Optional[List[str]],
|
||||
Arg(
|
||||
help="JSON array passed to the selected sidecar module's "
|
||||
"main(argv) function. --sidecar-shutdown-timeout SECONDS is "
|
||||
"consumed by SGLang.",
|
||||
type_parser=json_list_type,
|
||||
),
|
||||
NS("serving"),
|
||||
] = None
|
||||
skip_server_warmup: A[bool, "If set, skip warmup.", NS("serving")] = False
|
||||
warmups: A[
|
||||
Optional[str],
|
||||
@@ -3730,6 +3748,23 @@ class ServerArgs:
|
||||
# Native gRPC is incompatible with launch paths it doesn't wire into.
|
||||
# Legacy takes precedence over grpc_port, keeping re-runs idempotent.
|
||||
native_grpc = self.grpc_port is not None and not legacy_grpc
|
||||
if self.sidecar_args is not None:
|
||||
if self.sidecar is None:
|
||||
raise ValueError("--sidecar-args requires --sidecar.")
|
||||
if not isinstance(self.sidecar_args, list) or not all(
|
||||
isinstance(arg, str) for arg in self.sidecar_args
|
||||
):
|
||||
raise ValueError("--sidecar-args must be a JSON array of strings.")
|
||||
if self.sidecar is not None:
|
||||
if not self.sidecar.strip():
|
||||
raise ValueError("--sidecar must not be empty.")
|
||||
if legacy_grpc:
|
||||
raise ValueError(
|
||||
"--sidecar requires SGLang's native gRPC server; "
|
||||
"it cannot be combined with --smg-grpc-mode/--grpc-mode."
|
||||
)
|
||||
if self.grpc_port is None:
|
||||
raise ValueError("--sidecar requires --grpc-port or SGLANG_GRPC_PORT.")
|
||||
if native_grpc:
|
||||
if self.use_ray:
|
||||
raise ValueError(
|
||||
|
||||
Reference in New Issue
Block a user