[Feature] Add graceful scheduler shutdown; free hisparse host buffer on exit (#28779)
This commit is contained in:
@@ -189,6 +189,13 @@ class HiSparseCoordinator:
|
|||||||
def set_decode_producer_stream(self, stream) -> None:
|
def set_decode_producer_stream(self, stream) -> None:
|
||||||
self.decode_producer_stream = stream
|
self.decode_producer_stream = stream
|
||||||
|
|
||||||
|
def destroy(self) -> None:
|
||||||
|
# Drain in-flight transfers so the buffer is idle, then unregister it.
|
||||||
|
# See HostKVCache.destroy for why the explicit unregister matters.
|
||||||
|
self.write_staging_stream.synchronize()
|
||||||
|
self.decode_backup_stream.synchronize()
|
||||||
|
self.mem_pool_host.destroy()
|
||||||
|
|
||||||
def get_token_stats(self) -> HiSparseTokenStats:
|
def get_token_stats(self) -> HiSparseTokenStats:
|
||||||
device_allocator = self.token_to_kv_pool_allocator.hisparse_attn_allocator
|
device_allocator = self.token_to_kv_pool_allocator.hisparse_attn_allocator
|
||||||
device_capacity = device_allocator.size
|
device_capacity = device_allocator.size
|
||||||
|
|||||||
@@ -1814,6 +1814,13 @@ class FreezeGCReq(BaseReq):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ShutdownReq(BaseReq):
|
||||||
|
# Broadcast across TP ranks via the normal recv path, so all ranks break
|
||||||
|
# the scheduler loop on the same iteration.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ConfigureLoggingReq(BaseReq):
|
class ConfigureLoggingReq(BaseReq):
|
||||||
log_requests: Optional[bool] = None
|
log_requests: Optional[bool] = None
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ from sglang.srt.managers.io_struct import (
|
|||||||
SendWeightsToRemoteInstanceReqOutput,
|
SendWeightsToRemoteInstanceReqOutput,
|
||||||
SetInternalStateReq,
|
SetInternalStateReq,
|
||||||
SetInternalStateReqOutput,
|
SetInternalStateReqOutput,
|
||||||
|
ShutdownReq,
|
||||||
SlowDownReqInput,
|
SlowDownReqInput,
|
||||||
SlowDownReqOutput,
|
SlowDownReqOutput,
|
||||||
TokenizedEmbeddingReqInput,
|
TokenizedEmbeddingReqInput,
|
||||||
@@ -352,6 +353,9 @@ class Scheduler(
|
|||||||
self.enable_hisparse = server_args.enable_hisparse
|
self.enable_hisparse = server_args.enable_hisparse
|
||||||
self.hisparse_coordinator: Optional[HiSparseCoordinator] = None
|
self.hisparse_coordinator: Optional[HiSparseCoordinator] = None
|
||||||
|
|
||||||
|
# Set by the ShutdownReq handler to break the event loop for graceful shutdown.
|
||||||
|
self.gracefully_exit = False
|
||||||
|
|
||||||
# Distributed rank info
|
# Distributed rank info
|
||||||
attn_tp_rank, attn_tp_size, attn_dp_rank, attn_dp_size = (
|
attn_tp_rank, attn_tp_size, attn_dp_rank, attn_dp_size = (
|
||||||
compute_dp_attention_world_info(
|
compute_dp_attention_world_info(
|
||||||
@@ -1389,6 +1393,7 @@ class Scheduler(
|
|||||||
lambda req: self.profiler_manager._profile(req),
|
lambda req: self.profiler_manager._profile(req),
|
||||||
),
|
),
|
||||||
(FreezeGCReq, self.handle_freeze_gc),
|
(FreezeGCReq, self.handle_freeze_gc),
|
||||||
|
(ShutdownReq, self.handle_shutdown),
|
||||||
(GetInternalStateReq, self.get_internal_state),
|
(GetInternalStateReq, self.get_internal_state),
|
||||||
(SetInternalStateReq, self.set_internal_state),
|
(SetInternalStateReq, self.set_internal_state),
|
||||||
(RpcReqInput, self.handle_rpc_request),
|
(RpcReqInput, self.handle_rpc_request),
|
||||||
@@ -1445,6 +1450,12 @@ class Scheduler(
|
|||||||
|
|
||||||
return result_dict
|
return result_dict
|
||||||
|
|
||||||
|
def release_host_resources(self) -> None:
|
||||||
|
# Release pinned host buffers in userspace on graceful shutdown; see
|
||||||
|
# HostKVCache.destroy. Called from run_scheduler_process's finally.
|
||||||
|
if self.hisparse_coordinator is not None:
|
||||||
|
self.hisparse_coordinator.destroy()
|
||||||
|
|
||||||
def run_event_loop(self) -> None:
|
def run_event_loop(self) -> None:
|
||||||
"""Run the scheduler's event loop.
|
"""Run the scheduler's event loop.
|
||||||
|
|
||||||
@@ -1473,6 +1484,9 @@ class Scheduler(
|
|||||||
def event_loop_normal(self):
|
def event_loop_normal(self):
|
||||||
"""A normal scheduler loop."""
|
"""A normal scheduler loop."""
|
||||||
while True:
|
while True:
|
||||||
|
if self.gracefully_exit:
|
||||||
|
break
|
||||||
|
|
||||||
# Receive requests
|
# Receive requests
|
||||||
recv_reqs = self.request_receiver.recv_requests()
|
recv_reqs = self.request_receiver.recv_requests()
|
||||||
self.process_input_requests(recv_reqs)
|
self.process_input_requests(recv_reqs)
|
||||||
@@ -1509,6 +1523,9 @@ class Scheduler(
|
|||||||
self.process_batch_result(tmp_batch, tmp_result)
|
self.process_batch_result(tmp_batch, tmp_result)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
|
if self.gracefully_exit:
|
||||||
|
break
|
||||||
|
|
||||||
# Receive requests
|
# Receive requests
|
||||||
recv_reqs = self.request_receiver.recv_requests()
|
recv_reqs = self.request_receiver.recv_requests()
|
||||||
self.process_input_requests(recv_reqs)
|
self.process_input_requests(recv_reqs)
|
||||||
@@ -4002,6 +4019,11 @@ class Scheduler(
|
|||||||
self.ipc_channels.send_to_detokenizer.send_output(recv_req, recv_req)
|
self.ipc_channels.send_to_detokenizer.send_output(recv_req, recv_req)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def handle_shutdown(self, recv_req: ShutdownReq):
|
||||||
|
# Break the event loop; the finally in run_scheduler_process releases resources.
|
||||||
|
self.gracefully_exit = True
|
||||||
|
return None
|
||||||
|
|
||||||
def configure_logging(self, recv_req: ConfigureLoggingReq):
|
def configure_logging(self, recv_req: ConfigureLoggingReq):
|
||||||
if recv_req.log_level is not None:
|
if recv_req.log_level is not None:
|
||||||
logging.getLogger().setLevel(recv_req.log_level.upper())
|
logging.getLogger().setLevel(recv_req.log_level.upper())
|
||||||
@@ -4182,7 +4204,7 @@ def run_scheduler_process(
|
|||||||
# Send initialization info back to the parent process
|
# Send initialization info back to the parent process
|
||||||
pipe_writer.send(scheduler.get_init_info())
|
pipe_writer.send(scheduler.get_init_info())
|
||||||
|
|
||||||
# Run the event loop (blocks until shutdown)
|
# Run the event loop (blocks until a ShutdownReq sets gracefully_exit)
|
||||||
scheduler.run_event_loop()
|
scheduler.run_event_loop()
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -4201,3 +4223,7 @@ def run_scheduler_process(
|
|||||||
# FPM has a background ZMQ publisher thread that needs explicit
|
# FPM has a background ZMQ publisher thread that needs explicit
|
||||||
# teardown to flush queued metrics and close the socket cleanly.
|
# teardown to flush queued metrics and close the socket cleanly.
|
||||||
scheduler.metrics_reporter._shutdown_fpm()
|
scheduler.metrics_reporter._shutdown_fpm()
|
||||||
|
# Graceful path only: on the exception path the GPU may be wedged
|
||||||
|
# and the synchronize() in destroy() could itself hang.
|
||||||
|
if scheduler.gracefully_exit:
|
||||||
|
scheduler.release_host_resources()
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ from sglang.srt.managers.io_struct import (
|
|||||||
OpenSessionReqOutput,
|
OpenSessionReqOutput,
|
||||||
PauseGenerationReqInput,
|
PauseGenerationReqInput,
|
||||||
SessionParams,
|
SessionParams,
|
||||||
|
ShutdownReq,
|
||||||
TokenizedEmbeddingReqInput,
|
TokenizedEmbeddingReqInput,
|
||||||
TokenizedGenerateReqInput,
|
TokenizedGenerateReqInput,
|
||||||
UpdateWeightFromDiskReqInput,
|
UpdateWeightFromDiskReqInput,
|
||||||
@@ -2642,6 +2643,15 @@ class TokenizerManager(TokenizerControlMixin, TokenizerManagerScoreMixin):
|
|||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Stop the watchdog: child exits are expected during shutdown, not crashes.
|
||||||
|
if self._subprocess_watchdog is not None:
|
||||||
|
self._subprocess_watchdog.stop()
|
||||||
|
# Ask schedulers to release resources in userspace and exit (see
|
||||||
|
# ShutdownReq), then wait for them before hard-killing the rest.
|
||||||
|
self.send_to_scheduler.send_pyobj(ShutdownReq())
|
||||||
|
deadline = time.monotonic() + 15
|
||||||
|
while time.monotonic() < deadline and collect_scheduler_processes():
|
||||||
|
time.sleep(0.1)
|
||||||
kill_process_tree(os.getpid(), include_parent=True)
|
kill_process_tree(os.getpid(), include_parent=True)
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,17 @@ import psutil
|
|||||||
import torch
|
import torch
|
||||||
|
|
||||||
from sglang.srt.mem_cache.memory_pool import KVCache
|
from sglang.srt.mem_cache.memory_pool import KVCache
|
||||||
from sglang.srt.mem_cache.pool_host.common import get_allocator_from_storage
|
from sglang.srt.mem_cache.pool_host.common import (
|
||||||
|
_cuda_host_unregister,
|
||||||
|
get_allocator_from_storage,
|
||||||
|
)
|
||||||
|
from sglang.srt.utils import is_cuda, is_hip
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_is_cuda = is_cuda()
|
||||||
|
_is_hip = is_hip()
|
||||||
|
|
||||||
# Host RAM to leave free when sizing HiCache pools (OS, other processes).
|
# Host RAM to leave free when sizing HiCache pools (OS, other processes).
|
||||||
HICACHE_HOST_MEMORY_RESERVE_BYTES: int = 10 * (1024**3)
|
HICACHE_HOST_MEMORY_RESERVE_BYTES: int = 10 * (1024**3)
|
||||||
|
|
||||||
@@ -86,6 +93,26 @@ class HostKVCache(abc.ABC):
|
|||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
self.clear()
|
self.clear()
|
||||||
|
|
||||||
|
def destroy(self):
|
||||||
|
"""Unregister pinned host buffers in userspace before process exit.
|
||||||
|
|
||||||
|
Large cudaHostRegister'd buffers are otherwise unpinned by the kernel
|
||||||
|
during SIGKILL reclaim, which can stall teardown in uninterruptible
|
||||||
|
sleep for tens of seconds. Idempotent. (Only the host_register path
|
||||||
|
needs this; npu/musa pin_memory buffers are freed by torch.)
|
||||||
|
"""
|
||||||
|
if getattr(self, "_destroyed", False):
|
||||||
|
return
|
||||||
|
self._destroyed = True
|
||||||
|
buffers = getattr(self, "kv_buffer", None)
|
||||||
|
if buffers is not None and self.pin_memory and (_is_cuda or _is_hip):
|
||||||
|
if not isinstance(buffers, (list, tuple)):
|
||||||
|
buffers = [buffers]
|
||||||
|
for buf in buffers:
|
||||||
|
if buf is not None:
|
||||||
|
_cuda_host_unregister(buf)
|
||||||
|
self.kv_buffer = None
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def get_size_per_token(self):
|
def get_size_per_token(self):
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|||||||
@@ -57,6 +57,19 @@ def _cuda_host_register(buffer: torch.Tensor) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cuda_host_unregister(buffer: torch.Tensor) -> None:
|
||||||
|
cudart = torch.cuda.cudart()
|
||||||
|
rc = cudart.cudaHostUnregister(buffer.data_ptr())
|
||||||
|
if int(rc) != 0:
|
||||||
|
# Best-effort on shutdown: warn, don't raise -- a leak is reclaimed at exit.
|
||||||
|
logger.warning(
|
||||||
|
"cudaHostUnregister failed (rc=%d, %s) for ptr=%#x",
|
||||||
|
int(rc),
|
||||||
|
cudart.cudaGetErrorString(rc),
|
||||||
|
buffer.data_ptr(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def alloc_with_host_register(
|
def alloc_with_host_register(
|
||||||
dims: tuple,
|
dims: tuple,
|
||||||
dtype: torch.dtype,
|
dtype: torch.dtype,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import subprocess
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
from sglang.srt.utils import kill_process_tree
|
||||||
from sglang.test.ci.ci_register import register_cuda_ci
|
from sglang.test.ci.ci_register import register_cuda_ci
|
||||||
from sglang.test.kits.eval_accuracy_kit import GSM8KMixin
|
from sglang.test.kits.eval_accuracy_kit import GSM8KMixin
|
||||||
from sglang.test.server_fixtures.default_fixture import DefaultServerBase
|
from sglang.test.server_fixtures.default_fixture import DefaultServerBase
|
||||||
@@ -49,6 +52,18 @@ class TestGLM5HiSparse(DefaultServerBase, GSM8KMixin):
|
|||||||
gsm8k_num_threads = 100
|
gsm8k_num_threads = 100
|
||||||
gsm8k_num_shots = 24
|
gsm8k_num_shots = 24
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
# HiSparse's large pinned host buffer stalls an external SIGKILL teardown
|
||||||
|
# (kernel unpin). Drive the server's own graceful shutdown so each rank
|
||||||
|
# unregisters in userspace; hard-kill as a fallback.
|
||||||
|
cls.process.terminate()
|
||||||
|
try:
|
||||||
|
cls.process.wait(timeout=90)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
kill_process_tree(cls.process.pid, wait_timeout=60)
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user