Files
wehub-resource-sync 94057c3d3e
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:38:16 +08:00

487 lines
19 KiB
Python

from __future__ import annotations
import logging
from collections import deque
from dataclasses import dataclass, field
from typing import (
TYPE_CHECKING,
Callable,
Deque,
List,
Optional,
Tuple,
)
import torch
from sglang.srt.disaggregation.utils import DisaggregationMode
from sglang.srt.environ import envs
from sglang.srt.managers.scheduler_components.pool_stats_observer import (
PoolStats,
SchedulerPoolStatsObserver,
)
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
from sglang.srt.mem_cache.base_prefix_cache import BasePrefixCache
from sglang.srt.mem_cache.memory_pool import ReqToTokenPool
from sglang.srt.server_args import ServerArgs
from sglang.srt.utils.common import (
ceil_align,
raise_error_or_warn,
)
from sglang.srt.utils.watchdog import WatchdogRaw
if TYPE_CHECKING:
from sglang.srt.managers.scheduler import Scheduler
logger = logging.getLogger(__name__)
# Number of recent busy-check messages buffered for the level-1 dump-on-leak path.
BUSY_MEM_CHECK_LOG_RING_SIZE = 1000
@dataclass(kw_only=True, slots=True)
class SchedulerInvariantChecker:
is_hybrid_swa: bool
is_hybrid_ssm: bool
disaggregation_mode: DisaggregationMode
page_size: int
full_tokens_per_layer: Optional[int]
swa_tokens_per_layer: Optional[int]
max_total_num_tokens: int
server_args: ServerArgs
tree_cache: BasePrefixCache
token_to_kv_pool_allocator: BaseTokenToKVPoolAllocator
req_to_token_pool: ReqToTokenPool
pool_stats_observer: SchedulerPoolStatsObserver
get_last_batch: Callable
get_running_batch: Callable
count_req_pool_leak_warnings: int = 0
count_memory_leak_warnings: int = 0
recent_busy_msgs: Deque[str] = field(
default_factory=lambda: deque(maxlen=BUSY_MEM_CHECK_LOG_RING_SIZE)
)
@staticmethod
def _check_pool_invariant(
pool_name: str,
available: int,
evictable: int,
protected: int,
session_held: int,
total: int,
uncached: int = 0,
) -> Tuple[bool, str]:
"""Check: available + evictable + protected + session_held + uncached == total."""
total_accounted = available + evictable + protected + session_held + uncached
leak = total_accounted != total
msg = (
f"[{pool_name}] {total=}, {available=}, {evictable=}, "
f"{protected=}, {session_held=}, {uncached=}"
)
return leak, msg
def _check_full_pool(self, ps: PoolStats, uncached: int = 0) -> Tuple[bool, str]:
if self.is_hybrid_swa and not self.full_tokens_per_layer:
return False, ""
if self.is_hybrid_swa:
protected = self.tree_cache.full_protected_size()
session_held = self.pool_stats_observer.session_held_full_tokens()
total = self.full_tokens_per_layer
elif self.is_hybrid_ssm:
# Branch on cache type for the protected accessor (MambaRadixCache
# splits full/mamba; ChunkCache only has the single protected_size).
# Use the allocator's `.size` for `total`: static max_total_num_tokens for
# non-unified pools, the dynamic byte-coordinated cap (matching
# `available_size`) for the unified pool.
if self.tree_cache.supports_mamba():
protected = self.tree_cache.full_protected_size()
else:
protected = self.tree_cache.protected_size()
session_held = self.pool_stats_observer.session_held_tokens()
total = self.token_to_kv_pool_allocator.size
else:
protected = self.tree_cache.protected_size()
session_held = self.pool_stats_observer.session_held_tokens()
total = self.max_total_num_tokens
full_evictable_size = ps.full_evictable_size
allocator = self.token_to_kv_pool_allocator
if getattr(self.server_args, "dcp_size", 1) > 1 and allocator.page_size > 1:
# DCP stores logical tokens in widened physical pages. Prefix cache
# counters are logical-token based, while the allocator frees whole
# physical pages, so round cached tokens up to physical page units.
full_evictable_size = (
(full_evictable_size + allocator.page_size - 1)
// allocator.page_size
* allocator.page_size
)
leak, msg = self._check_pool_invariant(
"full",
ps.full_available_size,
full_evictable_size,
protected,
session_held,
total,
uncached,
)
if (
leak
and getattr(self.server_args, "dcp_size", 1) > 1
and allocator.page_size > 1
):
# Radix/Mamba cache accounting is logical-token based while DCP full
# KV allocation is physical-page based. Partial physical pages can
# leave a small page-level slack even when all pages are owned by
# either the allocator or the prefix cache.
return False, f"{msg}, dcp_physical_page_slack_allowed=True"
return leak, msg
def _check_swa_pool(self, ps: PoolStats, uncached: int = 0) -> Tuple[bool, str]:
return self._check_pool_invariant(
"swa",
ps.swa_available_size,
ps.swa_evictable_size,
self.tree_cache.swa_protected_size(),
self.pool_stats_observer.session_held_swa_tokens(),
self.swa_tokens_per_layer,
uncached,
)
def _check_mamba_pool(self, ps: PoolStats) -> Tuple[bool, str]:
ckpt_pool = getattr(self.req_to_token_pool, "mamba_ckpt_pool", None)
if ckpt_pool is not None:
return self._check_mamba_pool_with_int8(ps, ckpt_pool)
leak, msg = self._check_pool_invariant(
"mamba",
ps.mamba_available_size,
ps.mamba_evictable_size,
self.tree_cache.mamba_protected_size(),
self.pool_stats_observer.session_held_mamba_slots(),
self.req_to_token_pool.mamba_pool.size,
)
if leak:
# Page-level leak diagnosis for mamba
free_full_pages = set(
self.token_to_kv_pool_allocator.free_pages.tolist()
+ self.token_to_kv_pool_allocator.release_pages.tolist()
)
cached_full_pages = set(self.tree_cache.all_values_flatten().tolist())
expected_full_pages = set(
range(1, self.token_to_kv_pool_allocator.size + 1)
)
leaked_full_pages = (
expected_full_pages - free_full_pages - cached_full_pages
)
mamba_allocator = self.req_to_token_pool.mamba_allocator
free_mamba_pages = set(mamba_allocator.free_slots.tolist())
cached_mamba_pages = set(
self.tree_cache.all_mamba_values_flatten().tolist()
)
expected_mamba_pages = set(range(1, mamba_allocator.size + 1))
leaked_mamba_pages = (
expected_mamba_pages - free_mamba_pages - cached_mamba_pages
)
msg += (
f", leaked_full_pages={leaked_full_pages or None}"
f", leaked_mamba_pages={leaked_mamba_pages or None}"
)
return leak, msg
def _check_mamba_pool_with_int8(self, ps: PoolStats, ckpt_pool) -> Tuple[bool, str]:
"""Two-pool invariant for int8 mamba checkpoints.
The radix-cached states live in the int8 checkpoint pool, NOT the active
bf16 pool. So the single-pool equation (active.available + radix_cached ==
active.size) is wrong -- it double-counts the radix states against a pool
that does not hold them. Instead check the two pools independently:
* active bf16 pool: backs running requests only; the radix owns ZERO
active slots. Checked at idle (in-flight == 0) -> available == total.
* int8 checkpoint pool: backs the radix-cached states; its occupancy is
exactly the radix evictable + protected counts.
"""
active_leak, active_msg = self._check_pool_invariant(
"mamba-active",
ps.mamba_available_size,
ps.mamba_evictable_size, # 0 in int8 mode (radix owns no active slots)
0,
self.pool_stats_observer.session_held_mamba_slots(),
self.req_to_token_pool.mamba_pool.size,
)
int8_leak, int8_msg = self._check_pool_invariant(
"mamba-int8",
ckpt_pool.available_size(),
self.tree_cache.mamba_evictable_size(),
self.tree_cache.mamba_protected_size(),
0,
ckpt_pool.num_slots,
)
return active_leak or int8_leak, active_msg + "\n" + int8_msg
def _get_total_uncached_sizes(
self,
) -> Tuple[int, int]:
"""Sum uncached tokens for full and SWA pools across all active batches.
Returns (full_uncached, swa_uncached). For non-SWA models, swa_uncached is 0.
For full pool: uncached = allocated - cache_protected_len
For SWA pool: uncached = allocated - max(cache_protected_len, swa_evicted_seqlen)
"""
# After decode: running_batch IS last_batch (same object), count once.
# After prefill: they differ, both hold uncached tokens.
# Use identity (is / is not), not membership or ==: ScheduleBatch's
# dataclass __eq__ compares tensor fields and raises on ambiguous bools.
last_batch = self.get_last_batch()
running_batch = self.get_running_batch()
batches = [last_batch]
if (
running_batch is not None
and running_batch is not last_batch
and not running_batch.is_empty()
):
batches.append(running_batch)
full_uncached = 0
swa_uncached = 0
for batch in batches:
for req in batch.reqs:
assert req.kv_committed_freed == req.kv_overallocated_freed
if req.kv_committed_freed or req.req_pool_idx is None:
continue
allocated_len = req.kv_allocated_len
if self.page_size > 1:
allocated_len = ceil_align(allocated_len, self.page_size)
assert req.cache_protected_len % self.page_size == 0
full_uncached += allocated_len - req.cache_protected_len
if self.is_hybrid_swa:
swa_uncached += allocated_len - max(
req.cache_protected_len, req.swa_evicted_seqlen
)
return full_uncached, swa_uncached
def self_check_during_busy(self):
if self.get_last_batch() is None:
return
ps = self.pool_stats_observer.get_pool_stats()
full_uncached, swa_uncached = self._get_total_uncached_sizes()
full_leak, full_msg = self._check_full_pool(ps, uncached=full_uncached)
swa_leak, swa_msg = False, ""
if self.is_hybrid_swa:
swa_leak, swa_msg = self._check_swa_pool(ps, uncached=swa_uncached)
level = envs.SGLANG_ENABLE_STRICT_MEM_CHECK_DURING_BUSY.get()
full_line = f"[Mem Check (BUSY)] {full_msg}"
swa_line = f"[Mem Check (BUSY)] {swa_msg}" if swa_msg else None
if level > 1:
# Verbose: log every iteration.
logger.info(full_line)
if swa_line:
logger.info(swa_line)
elif level == 1:
# Quiet: buffer and stay silent; flush the recent ones only on a leak.
self.recent_busy_msgs.append(full_line)
if swa_line:
self.recent_busy_msgs.append(swa_line)
if full_leak or swa_leak:
for msg in self.recent_busy_msgs:
logger.info(msg)
assert not full_leak, f"Full Pool Mem Leak Detected! {full_msg}"
assert not swa_leak, f"SWA Pool Mem Leak Detected! {swa_msg}"
if envs.SGLANG_CHECK_KV_PAGE_INVARIANTS.get():
self._check_kv_page_invariants()
def _check_kv_page_invariants(self):
"""committed<=allocated for every req/slot, and no double free:
A. no owner references a page that is in the free pool (use-after-free).
B. the free pool has no duplicate pages (two owners freed the same page).
All heavy work runs on GPU to avoid per-token device->host sync."""
rtt = self.req_to_token_pool.req_to_token
row_width = rtt.shape[1]
def _add_owner(req_or_slot, label, rpi, committed, allocated):
assert 0 <= committed <= allocated <= row_width
owners.append((label, rpi, allocated))
owners: list[tuple[str, Optional[int], int]] = []
batch = self.get_last_batch()
if batch is not None:
for req in batch.reqs:
_add_owner(
req,
f"req {req.rid}",
req.req_pool_idx,
req.kv_committed_len,
req.kv_allocated_len,
)
sess = getattr(self.tree_cache, "slots", None)
if sess:
for sid, slot in sess.items():
if getattr(slot, "is_holding_kv", False):
_add_owner(
slot,
f"slot {sid[:8]}",
slot.req_pool_idx,
slot.kv_committed_len,
slot.kv_allocated_len,
)
active = [
(label, rpi, al) for label, rpi, al in owners if rpi is not None and al > 0
]
if not active:
return
idx = torch.as_tensor([rpi for _, rpi, _ in active], device=rtt.device)
allocs = torch.as_tensor([al for _, _, al in active], device=rtt.device)
mask = torch.arange(row_width, device=rtt.device)[None, :] < allocs[:, None]
owner_pages = rtt[idx][mask] // self.page_size
# Sub-allocators to check: a flat allocator is its own single sub; a
# hybrid-SWA wrapper exposes full_attn_allocator + swa_attn_allocator.
alloc = self.token_to_kv_pool_allocator
sub_allocs = (
[alloc]
if getattr(alloc, "free_pages", None) is not None
else [
sub
for n in ("full_attn_allocator", "swa_attn_allocator")
if (sub := getattr(alloc, n, None)) is not None
and getattr(sub, "free_pages", None) is not None
]
)
if not sub_allocs:
return
def _free_pages(a):
free = a.free_pages
release = getattr(a, "release_pages", None)
return (
torch.cat((free, release))
if release is not None and len(release) > 0
else free
)
# Check B: every sub-pool's free set has no duplicate pages.
for i, sub in enumerate(sub_allocs):
free = _free_pages(sub)
uniq = torch.unique(free)
if uniq.numel() != free.numel():
raise_error_or_warn(
self,
envs.SGLANG_ENABLE_STRICT_MEM_CHECK_DURING_IDLE.get(),
"count_memory_leak_warnings",
f"KV double free: sub-pool {i} has {free.numel() - uniq.numel()} duplicate pages.",
)
# Check A: owner pages (full-pool indices) must not be in the full free
# set (sub_allocs[0] is the full pool, even on hybrid-SWA).
full_unique = torch.unique(_free_pages(sub_allocs[0]))
stale = owner_pages[torch.isin(owner_pages, full_unique)]
if stale.numel() > 0:
raise_error_or_warn(
self,
envs.SGLANG_ENABLE_STRICT_MEM_CHECK_DURING_IDLE.get(),
"count_memory_leak_warnings",
f"KV page use-after-free: {stale.numel()} owner page refs are in "
f"the free pool, sample pages={torch.unique(stale)[:8].tolist()}.",
)
def _check_req_pool(self):
if self.disaggregation_mode == DisaggregationMode.DECODE:
req_total_size = (
self.req_to_token_pool.size + self.req_to_token_pool.pre_alloc_size
)
else:
req_total_size = self.req_to_token_pool.size
session_req_count = self.pool_stats_observer.session_held_req_count()
if len(self.req_to_token_pool.free_slots) + session_req_count != req_total_size:
msg = (
"req_to_token_pool memory leak detected!"
f"available_size={len(self.req_to_token_pool.free_slots)}, "
f"session_held={session_req_count}, "
f"total_size={self.req_to_token_pool.size}\n"
)
raise_error_or_warn(
self,
envs.SGLANG_ENABLE_STRICT_MEM_CHECK_DURING_IDLE.get(),
"count_req_pool_leak_warnings",
msg,
)
def _report_leak(self, pool_name: str, token_msg: str):
msg = f"{pool_name} memory leak detected! {token_msg}"
raise_error_or_warn(
self,
envs.SGLANG_ENABLE_STRICT_MEM_CHECK_DURING_IDLE.get(),
"count_memory_leak_warnings",
msg,
)
def _check_all_pools(
self, ps: PoolStats, uncached: int = 0
) -> Tuple[bool, List[str]]:
"""Check memory invariant across all pools. Returns (has_leak, messages)."""
has_leak = False
messages = []
full_leak, full_msg = self._check_full_pool(ps, uncached=uncached)
has_leak |= full_leak
messages.append(full_msg)
if self.is_hybrid_swa:
swa_leak, swa_msg = self._check_swa_pool(ps)
has_leak |= swa_leak
messages.append(swa_msg)
if self.is_hybrid_ssm and self.tree_cache.supports_mamba():
mamba_leak, mamba_msg = self._check_mamba_pool(ps)
has_leak |= mamba_leak
messages.append(mamba_msg)
return has_leak, messages
def _check_tree_cache(self):
if (
self.tree_cache.is_tree_cache()
and (self.is_hybrid_swa and self.tree_cache.supports_swa())
or (self.is_hybrid_ssm and self.tree_cache.supports_mamba())
):
self.tree_cache.sanity_check()
def create_scheduler_watchdog(
scheduler: Scheduler, watchdog_timeout: float, soft: bool = False
) -> WatchdogRaw:
def dump_info() -> str:
if scheduler.is_initializing:
return ""
_, messages = scheduler.invariant_checker._check_all_pools(
scheduler.pool_stats_observer.get_pool_stats(),
)
return (
f"{scheduler.cur_batch_for_debug.batch_size()=}\n"
f"{scheduler.cur_batch_for_debug.reqs=}\n" + "\n".join(messages)
)
return WatchdogRaw(
debug_name="Scheduler",
get_counter=lambda: scheduler.forward_ct,
is_active=lambda: (
scheduler.is_initializing or scheduler.cur_batch_for_debug is not None
),
watchdog_timeout=watchdog_timeout,
soft=soft,
dump_info=dump_info,
)