[diffusion] fix: shut down diffusion workers on serve exit (#30110)
This commit is contained in:
@@ -6,6 +6,7 @@ import os
|
|||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
import uvicorn
|
import uvicorn
|
||||||
@@ -15,7 +16,9 @@ from sglang.multimodal_gen.runtime.disaggregation.orchestrator import (
|
|||||||
)
|
)
|
||||||
from sglang.multimodal_gen.runtime.disaggregation.roles import RoleType
|
from sglang.multimodal_gen.runtime.disaggregation.roles import RoleType
|
||||||
from sglang.multimodal_gen.runtime.entrypoints.http_server import create_app
|
from sglang.multimodal_gen.runtime.entrypoints.http_server import create_app
|
||||||
|
from sglang.multimodal_gen.runtime.entrypoints.utils import ShutdownReq
|
||||||
from sglang.multimodal_gen.runtime.managers.gpu_worker import run_scheduler_process
|
from sglang.multimodal_gen.runtime.managers.gpu_worker import run_scheduler_process
|
||||||
|
from sglang.multimodal_gen.runtime.scheduler_client import SchedulerClient
|
||||||
from sglang.multimodal_gen.runtime.server_args import (
|
from sglang.multimodal_gen.runtime.server_args import (
|
||||||
ServerArgs,
|
ServerArgs,
|
||||||
prepare_server_args,
|
prepare_server_args,
|
||||||
@@ -24,6 +27,12 @@ from sglang.multimodal_gen.runtime.server_args import (
|
|||||||
from sglang.multimodal_gen.runtime.utils.common import is_port_available
|
from sglang.multimodal_gen.runtime.utils.common import is_port_available
|
||||||
from sglang.multimodal_gen.runtime.utils.logging_utils import configure_logger, logger
|
from sglang.multimodal_gen.runtime.utils.logging_utils import configure_logger, logger
|
||||||
from sglang.multimodal_gen.runtime.utils.trace_wrapper import init_diffusion_tracing
|
from sglang.multimodal_gen.runtime.utils.trace_wrapper import init_diffusion_tracing
|
||||||
|
from sglang.multimodal_gen.utils import kill_itself_when_parent_died
|
||||||
|
|
||||||
|
_SCHEDULER_SHUTDOWN_TIMEOUT_MS = 5000
|
||||||
|
_WORKER_JOIN_TIMEOUT_S = 10
|
||||||
|
_WORKER_TERMINATE_TIMEOUT_S = 1
|
||||||
|
_WORKER_KILL_TIMEOUT_S = 1
|
||||||
|
|
||||||
|
|
||||||
def _find_available_port(
|
def _find_available_port(
|
||||||
@@ -83,6 +92,82 @@ def kill_process_tree(parent_pid, include_parent: bool = True, skip_pid: int = N
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _process_names(processes) -> str:
|
||||||
|
return ", ".join(getattr(p, "name", repr(p)) for p in processes)
|
||||||
|
|
||||||
|
|
||||||
|
def _join_processes_with_deadline(processes, timeout_s: float) -> None:
|
||||||
|
deadline = time.monotonic() + timeout_s
|
||||||
|
for process in processes:
|
||||||
|
remaining_s = max(0.0, deadline - time.monotonic())
|
||||||
|
process.join(timeout=remaining_s)
|
||||||
|
|
||||||
|
|
||||||
|
def _terminate_alive_processes(processes, timeout_s: float) -> list:
|
||||||
|
alive = [p for p in processes if p.is_alive()]
|
||||||
|
if not alive:
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Worker process(es) did not exit in time; terminating: %s",
|
||||||
|
_process_names(alive),
|
||||||
|
)
|
||||||
|
for process in alive:
|
||||||
|
process.terminate()
|
||||||
|
_join_processes_with_deadline(alive, timeout_s)
|
||||||
|
return [p for p in alive if p.is_alive()]
|
||||||
|
|
||||||
|
|
||||||
|
def _kill_alive_processes(processes, timeout_s: float) -> None:
|
||||||
|
alive = [p for p in processes if p.is_alive()]
|
||||||
|
if not alive:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Worker process(es) did not terminate in time; killing: %s",
|
||||||
|
_process_names(alive),
|
||||||
|
)
|
||||||
|
for process in alive:
|
||||||
|
process.kill()
|
||||||
|
_join_processes_with_deadline(alive, timeout_s)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_http_server_process(server_args: ServerArgs) -> None:
|
||||||
|
kill_itself_when_parent_died()
|
||||||
|
launch_http_server_only(server_args)
|
||||||
|
|
||||||
|
|
||||||
|
def _request_monolithic_scheduler_shutdown(server_args: ServerArgs) -> None:
|
||||||
|
if server_args.disagg_role != RoleType.MONOLITHIC:
|
||||||
|
return
|
||||||
|
|
||||||
|
client = SchedulerClient()
|
||||||
|
try:
|
||||||
|
client.initialize(server_args)
|
||||||
|
client.forward(ShutdownReq(), timeout_ms=_SCHEDULER_SHUTDOWN_TIMEOUT_MS)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to request graceful scheduler shutdown: %s", e)
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def shutdown_scheduler_processes(
|
||||||
|
server_args: ServerArgs | None,
|
||||||
|
processes: list,
|
||||||
|
*,
|
||||||
|
request_shutdown: bool = True,
|
||||||
|
) -> None:
|
||||||
|
if not processes:
|
||||||
|
return
|
||||||
|
|
||||||
|
if request_shutdown and server_args is not None:
|
||||||
|
_request_monolithic_scheduler_shutdown(server_args)
|
||||||
|
|
||||||
|
_join_processes_with_deadline(processes, _WORKER_JOIN_TIMEOUT_S)
|
||||||
|
alive = _terminate_alive_processes(processes, _WORKER_TERMINATE_TIMEOUT_S)
|
||||||
|
_kill_alive_processes(alive, _WORKER_KILL_TIMEOUT_S)
|
||||||
|
|
||||||
|
|
||||||
def launch_server(server_args: ServerArgs, launch_http_server: bool = True):
|
def launch_server(server_args: ServerArgs, launch_http_server: bool = True):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
@@ -198,14 +283,17 @@ def launch_server(server_args: ServerArgs, launch_http_server: bool = True):
|
|||||||
if server_args.webui:
|
if server_args.webui:
|
||||||
logger.info("Launch FastAPI server in another process because of webui.")
|
logger.info("Launch FastAPI server in another process because of webui.")
|
||||||
http_server_process = mp.Process(
|
http_server_process = mp.Process(
|
||||||
target=launch_http_server_only,
|
target=_run_http_server_process,
|
||||||
args=(server_args,),
|
args=(server_args,),
|
||||||
name="sglang-diffusion-webui",
|
name="sglang-diffusion-webui",
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
http_server_process.start()
|
http_server_process.start()
|
||||||
else:
|
else:
|
||||||
launch_http_server_only(server_args)
|
try:
|
||||||
|
launch_http_server_only(server_args)
|
||||||
|
finally:
|
||||||
|
shutdown_scheduler_processes(server_args, processes)
|
||||||
|
|
||||||
return processes
|
return processes
|
||||||
|
|
||||||
@@ -408,7 +496,13 @@ def launch_pool_disagg_server(
|
|||||||
"Starting FastAPI server (connected to DiffusionServer at port %d).",
|
"Starting FastAPI server (connected to DiffusionServer at port %d).",
|
||||||
server_args.scheduler_port,
|
server_args.scheduler_port,
|
||||||
)
|
)
|
||||||
launch_http_server_only(server_args)
|
try:
|
||||||
|
launch_http_server_only(server_args)
|
||||||
|
finally:
|
||||||
|
diffusion_server.stop()
|
||||||
|
shutdown_scheduler_processes(
|
||||||
|
server_args, all_processes, request_shutdown=False
|
||||||
|
)
|
||||||
|
|
||||||
return all_processes
|
return all_processes
|
||||||
|
|
||||||
@@ -538,7 +632,10 @@ def launch_disagg_server(server_args: ServerArgs):
|
|||||||
"Starting HTTP server (connected to DiffusionServer at port %d).",
|
"Starting HTTP server (connected to DiffusionServer at port %d).",
|
||||||
base_port,
|
base_port,
|
||||||
)
|
)
|
||||||
launch_http_server_only(server_args)
|
try:
|
||||||
|
launch_http_server_only(server_args)
|
||||||
|
finally:
|
||||||
|
diffusion_server.stop()
|
||||||
|
|
||||||
|
|
||||||
def launch_disagg_role(server_args: ServerArgs):
|
def launch_disagg_role(server_args: ServerArgs):
|
||||||
@@ -659,6 +756,8 @@ def launch_disagg_role(server_args: ServerArgs):
|
|||||||
p.join()
|
p.join()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
logger.info("Role %s shutting down.", role_type.value)
|
logger.info("Role %s shutting down.", role_type.value)
|
||||||
|
finally:
|
||||||
|
shutdown_scheduler_processes(role_args, processes, request_shutdown=False)
|
||||||
|
|
||||||
|
|
||||||
def dispatch_launch(server_args: ServerArgs):
|
def dispatch_launch(server_args: ServerArgs):
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ from sglang.multimodal_gen.runtime.utils.trace_wrapper import (
|
|||||||
init_diffusion_tracing,
|
init_diffusion_tracing,
|
||||||
trace_slice,
|
trace_slice,
|
||||||
)
|
)
|
||||||
|
from sglang.multimodal_gen.utils import kill_itself_when_parent_died
|
||||||
from sglang.srt.utils.network import NetworkAddress
|
from sglang.srt.utils.network import NetworkAddress
|
||||||
|
|
||||||
logger = init_logger(__name__)
|
logger = init_logger(__name__)
|
||||||
@@ -999,6 +1000,7 @@ def run_scheduler_process(
|
|||||||
Rank 0 acts as the master, handling ZMQ requests and coordinating slaves.
|
Rank 0 acts as the master, handling ZMQ requests and coordinating slaves.
|
||||||
Ranks > 0 act as slaves, waiting for tasks from the master.
|
Ranks > 0 act as slaves, waiting for tasks from the master.
|
||||||
"""
|
"""
|
||||||
|
kill_itself_when_parent_died()
|
||||||
configure_logger(server_args)
|
configure_logger(server_args)
|
||||||
globally_suppress_loggers()
|
globally_suppress_loggers()
|
||||||
if current_platform.is_cuda():
|
if current_platform.is_cuda():
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from sglang.multimodal_gen.runtime import launch_server as ls
|
||||||
|
from sglang.multimodal_gen.runtime.disaggregation.roles import RoleType
|
||||||
|
from sglang.multimodal_gen.runtime.entrypoints.utils import ShutdownReq
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeProcess:
|
||||||
|
name = "fake-worker"
|
||||||
|
|
||||||
|
def __init__(self, *, exit_on_join: bool = False):
|
||||||
|
self.alive = True
|
||||||
|
self.exit_on_join = exit_on_join
|
||||||
|
self.join_timeouts = []
|
||||||
|
self.terminated = False
|
||||||
|
self.killed = False
|
||||||
|
|
||||||
|
def join(self, timeout=None):
|
||||||
|
self.join_timeouts.append(timeout)
|
||||||
|
if self.exit_on_join:
|
||||||
|
self.alive = False
|
||||||
|
|
||||||
|
def is_alive(self):
|
||||||
|
return self.alive
|
||||||
|
|
||||||
|
def terminate(self):
|
||||||
|
self.terminated = True
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
self.killed = True
|
||||||
|
self.alive = False
|
||||||
|
|
||||||
|
|
||||||
|
class TestLaunchServerShutdown(unittest.TestCase):
|
||||||
|
def test_monolithic_shutdown_requests_scheduler_then_forces_worker(self):
|
||||||
|
process = _FakeProcess()
|
||||||
|
server_args = SimpleNamespace(disagg_role=RoleType.MONOLITHIC)
|
||||||
|
client = Mock()
|
||||||
|
|
||||||
|
with patch.object(ls, "SchedulerClient", return_value=client):
|
||||||
|
ls.shutdown_scheduler_processes(server_args, [process])
|
||||||
|
|
||||||
|
client.initialize.assert_called_once_with(server_args)
|
||||||
|
request = client.forward.call_args.args[0]
|
||||||
|
self.assertIsInstance(request, ShutdownReq)
|
||||||
|
self.assertEqual(client.forward.call_args.kwargs, {"timeout_ms": 5000})
|
||||||
|
client.close.assert_called_once_with()
|
||||||
|
|
||||||
|
self.assertTrue(process.terminated)
|
||||||
|
self.assertTrue(process.killed)
|
||||||
|
self.assertAlmostEqual(process.join_timeouts[0], 10, delta=0.1)
|
||||||
|
self.assertAlmostEqual(process.join_timeouts[1], 1, delta=0.1)
|
||||||
|
self.assertAlmostEqual(process.join_timeouts[2], 1, delta=0.1)
|
||||||
|
|
||||||
|
def test_scheduler_shutdown_error_still_forces_worker(self):
|
||||||
|
process = _FakeProcess()
|
||||||
|
server_args = SimpleNamespace(disagg_role=RoleType.MONOLITHIC)
|
||||||
|
client = Mock()
|
||||||
|
client.forward.side_effect = TimeoutError("blocked")
|
||||||
|
|
||||||
|
with patch.object(ls, "SchedulerClient", return_value=client):
|
||||||
|
ls.shutdown_scheduler_processes(server_args, [process])
|
||||||
|
|
||||||
|
client.close.assert_called_once_with()
|
||||||
|
self.assertTrue(process.terminated)
|
||||||
|
self.assertTrue(process.killed)
|
||||||
|
|
||||||
|
def test_disagg_role_does_not_send_monolithic_shutdown_request(self):
|
||||||
|
process = _FakeProcess(exit_on_join=True)
|
||||||
|
server_args = SimpleNamespace(disagg_role=RoleType.ENCODER)
|
||||||
|
|
||||||
|
with patch.object(ls, "SchedulerClient") as scheduler_client:
|
||||||
|
ls.shutdown_scheduler_processes(server_args, [process])
|
||||||
|
|
||||||
|
scheduler_client.assert_not_called()
|
||||||
|
self.assertFalse(process.terminated)
|
||||||
|
self.assertFalse(process.killed)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -527,21 +527,18 @@ def shallow_asdict(obj) -> dict[str, Any]:
|
|||||||
return {f.name: getattr(obj, f.name) for f in fields(obj)}
|
return {f.name: getattr(obj, f.name) for f in fields(obj)}
|
||||||
|
|
||||||
|
|
||||||
# TODO: validate that this is fine
|
|
||||||
def kill_itself_when_parent_died() -> None:
|
def kill_itself_when_parent_died() -> None:
|
||||||
# if sys.platform == "linux":
|
if sys.platform != "linux":
|
||||||
# sigkill this process when parent worker manager dies
|
return
|
||||||
PR_SET_PDEATHSIG = 1
|
|
||||||
import platform
|
|
||||||
|
|
||||||
if platform.system() == "Linux":
|
# keep GPU workers tied to the CLI process even if the parent is SIGKILLed
|
||||||
libc = ctypes.CDLL("libc.so.6")
|
PR_SET_PDEATHSIG = 1
|
||||||
libc.prctl(PR_SET_PDEATHSIG, signal.SIGKILL)
|
libc = ctypes.CDLL("libc.so.6", use_errno=True)
|
||||||
# elif platform.system() == "Darwin":
|
if libc.prctl(PR_SET_PDEATHSIG, signal.SIGKILL) != 0:
|
||||||
# libc = ctypes.CDLL("libc.dylib")
|
err = ctypes.get_errno()
|
||||||
# logger.warning("kill_itself_when_parent_died is only supported in linux.")
|
raise OSError(err, os.strerror(err))
|
||||||
else:
|
if os.getppid() == 1:
|
||||||
logger.warning("kill_itself_when_parent_died is only supported in linux.")
|
os.kill(os.getpid(), signal.SIGKILL)
|
||||||
|
|
||||||
|
|
||||||
def get_exception_traceback() -> str:
|
def get_exception_traceback() -> str:
|
||||||
|
|||||||
Reference in New Issue
Block a user