Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 128 additions & 5 deletions tensorrt_llm/commands/serve.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import atexit
import contextlib
import gc
import importlib
import inspect
Expand All @@ -11,6 +12,7 @@
import socket
import subprocess # nosec B404
import sys
import tempfile
import time
import uuid
from importlib.util import find_spec
Expand Down Expand Up @@ -347,6 +349,54 @@ def _build_llm_args_from_disagg_server_cfg(other_args: Dict) -> Dict:
return update_llm_args_with_extra_dict(llm_args, llm_args_extra_dict)


def _publish_bound_address(report_addr: Optional[str], host: str,
port: int) -> None:
"""Write the address this server actually bound to ``report_addr``.

Lets a launcher pass ``--port 0`` and learn the kernel-assigned port
afterwards, instead of picking a port up front and racing whoever grabs it
before this process binds. The write is atomic (temp file in the same
directory, then rename) so a reader never observes a partial line, which
matters on the shared filesystems multi-node tests coordinate through.

The caller is responsible for making the path unique per run: a stale file
from an earlier run points at a dead server, which fails far less obviously
than a port conflict.

A wildcard bind host is replaced by this machine's hostname, since readers
use the published value as a URL authority and cannot dial 0.0.0.0 or ::.
"""
if not report_addr:
return
if host in ("0.0.0.0", "::", ""): # nosec B104 - reporting, not binding
resolved = socket.gethostname()
logger.info(f"Reporting hostname {resolved} instead of wildcard bind "
f"address {host!r}, which a reader cannot dial")
host = resolved
report_addr = os.path.abspath(report_addr)
parent = os.path.dirname(report_addr)
if parent:
os.makedirs(parent, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(dir=parent or None,
prefix=os.path.basename(report_addr) + ".",
suffix=".tmp")
try:
with os.fdopen(fd, "w") as f:
# Bracket IPv6 literals so the value is a usable URL authority:
# readers build "http://<reported>/..." from it verbatim.
reported_host = f"[{host}]" if ":" in host else host
Comment thread
JunyiXu-nv marked this conversation as resolved.
f.write(f"{reported_host}:{port}\n")
f.flush()
os.fsync(f.fileno())
Comment thread
JunyiXu-nv marked this conversation as resolved.
os.replace(tmp_path, report_addr)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
finally:
# A successful replace already consumed tmp_path; this only cleans up
# after a failed write so a partial file is not left behind.
with contextlib.suppress(OSError):
os.unlink(tmp_path)
logger.info(f"Reported bound address {host}:{port} to {report_addr}")


def _diagnose_port_in_use(port: int) -> str:
"""Describe which process currently holds the given port, best effort."""
try:
Expand Down Expand Up @@ -544,12 +594,23 @@ def launch_server(
num_input_processor_workers: int = 8,
num_media_load_workers: int = 8,
multi_frontend_enabled: bool = True,
internal_disagg_auth_key: Optional[str] = None):
internal_disagg_auth_key: Optional[str] = None,
report_addr: Optional[str] = None):

backend = llm_args["backend"]
model = served_model_name or llm_args["model"]

multi_frontend = _init_multi_frontend_mode(llm_args, multi_frontend_enabled)
# Same hazard the disaggregated fleet guard covers: _spawn_attached_frontends
# re-execs this command line verbatim, so with port 0 every frontend binds
# its own kernel-assigned port instead of sharing one, and every frontend
# also re-runs _publish_bound_address, leaving the reader with whichever
# child wrote last.
if (port == 0 or report_addr) and multi_frontend.num_frontends > 1:
raise click.BadParameter(
"port 0 and --report_addr are only supported with a single serving "
f"frontend, but num_serve_frontends={multi_frontend.num_frontends}."
)
if multi_frontend.is_launcher or multi_frontend.is_attached_frontend:
# The Responses API store is per-process in-memory: with several
# frontends behind one SO_REUSEPORT port, a follow-up request may
Expand All @@ -567,8 +628,17 @@ def launch_server(
address_family = socket.AF_INET6 if all(
[info[0] == socket.AF_INET6 for info in addr_info]) else socket.AF_INET
with socket.socket(address_family, socket.SOCK_STREAM) as s:
# If disagg cluster config is provided and port is not specified, try to find a free port, otherwise try to bind to the specified port
assert port > 0 or disagg_cluster_config is not None, "Port must be specified if disagg cluster config is not provided"
# port == 0 lets the kernel pick the port; the caller then needs a way
# to learn it, either by service discovery or by report_addr.
assert port > 0 or disagg_cluster_config is not None or report_addr, (
"Port must be specified unless disagg cluster config or "
"--report_addr is provided")
# Without SO_REUSEADDR a restart is refused for the whole TIME_WAIT
# window (~60s) by the tombstones of connections this server accepted.
# The flag has to be set on the socket that owns the port first, since
# the TIME_WAIT entry inherits it -- setting it only on the later bind
# is not enough.
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
if multi_frontend.is_launcher or multi_frontend.is_attached_frontend:
# Every frontend process binds its own listening socket on the
# same port; the kernel load-balances accepts across them.
Expand All @@ -585,6 +655,10 @@ def launch_server(
raise RuntimeError(f"Failed to bind socket to {host}:{port}: {e}. "
f"Port holder(s): {holder}")

# Only now is the address final, and the socket stays bound from here
# until uvicorn takes it over, so no one can steal the port in between.
_publish_bound_address(report_addr, host, port)
Comment thread
JunyiXu-nv marked this conversation as resolved.

if backend == 'pytorch':
llm_args.pop("build_config", None)
llm = PyTorchLLM(**llm_args)
Expand Down Expand Up @@ -795,6 +869,9 @@ def launch_visual_gen_server(
address_family = socket.AF_INET6 if all(
[info[0] == socket.AF_INET6 for info in addr_info]) else socket.AF_INET
with socket.socket(address_family, socket.SOCK_STREAM) as s:
# See launch_server: without this, TIME_WAIT tombstones from the
# connections this server accepted refuse a restart for ~60s.
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
s.bind((host, port))
except OSError as e:
Expand Down Expand Up @@ -1167,6 +1244,15 @@ def launch_visual_gen_server(
help=
"Types of agents to schedule. Now Only Support Open Deep Research agent.",
status="prototype")
@stability_option(
"--report_addr",
type=str,
default=None,
help="Write the host:port this server actually bound to this file, "
"atomically, once the socket is bound. Lets --port 0 be used and have the "
"launcher read the kernel-assigned port back instead of reserving one up "
"front.",
status="prototype")
def serve(model: str, tokenizer: Optional[str], custom_tokenizer: Optional[str],
post_processor_hook: Optional[str], host: str, port: int,
log_level: str, backend: str, generation_config: str,
Expand All @@ -1191,7 +1277,8 @@ def serve(model: str, tokenizer: Optional[str], custom_tokenizer: Optional[str],
telemetry: bool, custom_module_dirs: list[Path],
chat_template: Optional[str], allow_request_chat_template: bool,
middleware: tuple[str, ...], grpc: bool, enable_visual_gen: bool,
served_model_name: Optional[str], visual_gen_args: Optional[str]):
served_model_name: Optional[str], visual_gen_args: Optional[str],
report_addr: Optional[str]):
"""Running an OpenAI API compatible server

MODEL: model name | HF checkpoint path | TensorRT engine path
Expand Down Expand Up @@ -1408,7 +1495,8 @@ def _serve_llm():
allow_request_chat_template=allow_request_chat_template,
num_input_processor_workers=num_input_processor_workers,
num_media_load_workers=num_media_load_workers,
internal_disagg_auth_key=internal_disagg_auth_key)
internal_disagg_auth_key=internal_disagg_auth_key,
report_addr=report_addr)

def _serve_visual_gen():
from tensorrt_llm.visual_gen.args import VisualGenArgs
Expand All @@ -1424,6 +1512,12 @@ def _serve_visual_gen():

is_visual_gen = (enable_visual_gen or visual_gen_args is not None
or get_is_diffusion_only_model(model))
# Only the OpenAI HTTP path publishes the bound address. Fail loudly rather
# than leaving a launcher waiting forever on a file nobody writes.
if report_addr and (grpc or is_visual_gen):
raise click.BadParameter(
"--report_addr is only supported for the OpenAI HTTP server, not "
f"the {'gRPC' if grpc else 'VisualGen'} server.")
if is_visual_gen:
_serve_visual_gen()
else:
Expand Down Expand Up @@ -1750,6 +1844,15 @@ def serve_embedding(
help="[Deprecated] The interval of logging metrics in seconds. "
"This option is not connected to any functionality and will be removed in a future release.",
status="deprecated")
@stability_option(
"--report_addr",
type=str,
default=None,
help="Write the host:port this server actually bound to this file, "
"atomically, once the socket is bound. Lets the config set port 0 and "
"have the launcher read the kernel-assigned port back instead of "
"reserving one up front.",
status="prototype")
def disaggregated(
config_file: Optional[str],
metadata_server_config_file: Optional[str],
Expand All @@ -1758,6 +1861,7 @@ def disaggregated(
log_level: str,
metrics_log_interval: int,
schedule_style: str,
report_addr: Optional[str],
):
"""Running server in disaggregated mode"""

Expand Down Expand Up @@ -1792,6 +1896,17 @@ def disaggregated(
num_workers = disagg_cfg.num_workers
coordinator_url = disagg_cfg.disagg_coordinator_url

# Only topology (c) below binds the public socket in this process. The fleet
# paths hand the port to N SO_REUSEPORT workers, which with port 0 would each
# get a *different* kernel-assigned port instead of sharing one, so reject
# the combination rather than publishing an address that serves 1/N requests.
if (disagg_cfg.port == 0 or report_addr) and (coordinator_url
or num_workers > 1):
raise click.BadParameter(
"port 0 and --report_addr are only supported for a single "
f"self-contained disaggregated server, but num_workers={num_workers} "
f"and disagg_coordinator_url={coordinator_url!r} select a fleet.")

if coordinator_url:
# (a) External coordinator: fork a fleet of delegating servers (or a
# single one) pointed at it; never start a coordinator in this process.
Expand All @@ -1812,8 +1927,13 @@ def disaggregated(
# (c) num_workers==1, no external coordinator: a single disagg server with an
# in-process (local) coordinator. Pre-bind the socket (validates port), serve.
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
# See launch_server: without this, TIME_WAIT tombstones from the
# connections this server accepted refuse a restart for ~60s.
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
s.bind((disagg_cfg.hostname, disagg_cfg.port))
if disagg_cfg.port == 0:
disagg_cfg.port = s.getsockname()[1]
except OSError as e:
holder = _diagnose_port_in_use(disagg_cfg.port)
logger.error(
Expand All @@ -1824,6 +1944,9 @@ def disaggregated(
f"Failed to bind socket to {disagg_cfg.hostname}:{disagg_cfg.port}: {e}. "
f"Port holder(s): {holder}")

_publish_bound_address(report_addr, disagg_cfg.hostname,
disagg_cfg.port)

server = OpenAIDisaggServer(
config=disagg_cfg,
req_timeout_secs=request_timeout,
Expand Down
64 changes: 43 additions & 21 deletions tests/integration/defs/accuracy/test_disaggregated_serving.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
import pytest
import requests
import yaml
from defs.common import get_free_port_in_ci as get_free_port
from defs.common import wait_for_reported_addr

from tensorrt_llm.executor.result import GenerationResultBase
from tensorrt_llm.llmapi import CompletionOutput, RequestOutput, SamplingParams
Expand Down Expand Up @@ -197,17 +197,18 @@ def _apply_perf_flags(cfg: Optional[Dict[str, Any]]):
_apply_perf_flags(ctx_server_config)
_apply_perf_flags(gen_server_config)

# Always assign free port dynamically for service discovery
serve_port = get_free_port()
disaggregated_server_config["port"] = serve_port
# Let the kernel assign the port inside trtllm-serve and report it back,
# rather than reserving one here and racing whoever takes it before the
# server binds.
disaggregated_server_config["port"] = 0
disagg_addr_path = os.path.join(temp_dir.name, "disagg_server.addr")

# Use HTTP service discovery
cluster_uri = f"http://localhost:{serve_port}"
print(f"Using HTTP service discovery at {cluster_uri}")

# Create service discovery config
# Create service discovery config. The server hosts the HTTP cluster
# storage on its own port, so the port in *its* copy of cluster_uri is
# never read (HttpClusterStorageServer.__init__ ignores the URI); only the
# workers dial it, and they get the resolved address below.
disagg_cluster = {
"cluster_uri": cluster_uri,
"cluster_uri": "http://localhost:0",
"cluster_name": "test_cluster",
"heartbeat_interval_sec": 5,
"inactive_timeout_sec": 10,
Expand All @@ -230,28 +231,35 @@ def _apply_perf_flags(cfg: Optional[Dict[str, Any]]):
disaggregated_server_config["internal_request_auth_key"] = (
internal_request_auth_key)

# Inject into worker configs
# Inject into worker configs. disagg_cluster is replaced in
# write_worker_configs below, once the server's real address is known.
ctx_server_config = {
**ctx_server_config,
"disagg_cluster": disagg_cluster,
"internal_request_auth_key": internal_request_auth_key,
}
gen_server_config = {
**gen_server_config,
"disagg_cluster": disagg_cluster,
"internal_request_auth_key": internal_request_auth_key,
}

with open(disaggregated_serving_config_path, "w") as f:
yaml.dump(disaggregated_server_config, f)
ctx_server_config_path = os.path.join(temp_dir.name,
"ctx_server_config.yaml")
with open(ctx_server_config_path, "w") as f:
yaml.dump(ctx_server_config, f)
gen_server_config_path = os.path.join(temp_dir.name,
"gen_server_config.yaml")
with open(gen_server_config_path, "w") as f:
yaml.dump(gen_server_config, f)

def write_worker_configs(cluster_uri):
"""Write the worker configs once the server's real address is known."""
worker_cluster = {**disagg_cluster, "cluster_uri": cluster_uri}
with open(ctx_server_config_path, "w") as f:
yaml.dump({
**ctx_server_config, "disagg_cluster": worker_cluster
}, f)
with open(gen_server_config_path, "w") as f:
yaml.dump({
**gen_server_config, "disagg_cluster": worker_cluster
}, f)

args = LlmArgs(model=model_name, tensor_parallel_size=tensor_parallel_size)

Expand Down Expand Up @@ -402,15 +410,29 @@ def multi_popen(server_configs, server_name="", enable_redirect_log=False):
server_cmd = [
trtllm_serve_path, "disaggregated", "-c",
disaggregated_serving_config_path, "--server_start_timeout",
str(server_waiting_timeout), "-r", "360000"
str(server_waiting_timeout), "-r", "360000", "--report_addr",
disagg_addr_path
]
# The disagg server must come up first: it owns the cluster storage the
# workers register with, and only it knows the port the kernel handed it.
with (
MyThreadPoolExecutor(max_workers=max_workers) as thread_pool,
temp_dir,
multi_popen(ctx_servers, "ctx") as ctx_processes,
multi_popen(gen_servers, "gen") as gen_processes,
multi_popen([(base_env, server_cmd)], "disagg") as server_processes,
contextlib.ExitStack() as server_stack,
):
server_processes = server_stack.enter_context(
multi_popen([(base_env, server_cmd)], "disagg"))
_, serve_port = wait_for_reported_addr(disagg_addr_path,
server_waiting_timeout,
server_processes[0])
print(f"Using HTTP service discovery at http://localhost:{serve_port}")
write_worker_configs(f"http://localhost:{serve_port}")

ctx_processes = server_stack.enter_context(
multi_popen(ctx_servers, "ctx"))
gen_processes = server_stack.enter_context(
multi_popen(gen_servers, "gen"))

start_time = time.time()
server_is_ready = False
while time.time() - start_time < server_waiting_timeout:
Expand Down
Loading
Loading