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
4 changes: 4 additions & 0 deletions python/sglang/srt/arg_groups/fields/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ class Memory(msgspec.Struct):
int,
"The size of host KV cache memory pool in gigabytes. Overrides --hicache-ratio in either host memory mode.",
] = 0
hicache_host_memory_fraction: A[
Optional[float],
"Fraction of the available host memory, bounded by visible cgroup memory.max/memory.high or v1 memory limits (after a 10 GiB reserve) that the HiCache host pools of all ranks on this machine may use. Applies only when neither --hicache-ratio nor --hicache-size is set: the default ratio is then reduced until the pools fit. Lower it when several engines share a memory cgroup.",
] = 0.8
hicache_write_policy: A[
str,
Arg(
Expand Down
19 changes: 13 additions & 6 deletions python/sglang/srt/arg_groups/hicache_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,23 @@ def handle_hicache_ratio_default(server_args: Any):
A decode server keeps the ratio unset here: kv_cache_builder resolves
it against the retraction-backup backend (1.0 for host_pool, else 2.0).
An explicit --hicache-ratio or --hicache-size is honored as given, so it
resolves --hicache-host-memory-fraction to None (auto-sizing off).
"""
cfg = resolving_view(server_args)
fraction = cfg.hicache_host_memory_fraction
if fraction is not None and not 0 < fraction <= 1:
raise ValueError("--hicache-host-memory-fraction must be in (0, 1].")
fields = {}
if cfg.hicache_ratio is None and cfg.disaggregation_mode != "decode":
declare_resolution(
server_args,
"_handle_hicache_ratio_default",
hicache_ratio=(
1.2 if cfg.hicache_host_memory_mode == "buffer_only" else 2.0
),
fields["hicache_ratio"] = (
1.2 if cfg.hicache_host_memory_mode == "buffer_only" else 2.0
)
if cfg.hicache_ratio is not None or cfg.hicache_size > 0:
fields["hicache_host_memory_fraction"] = None
if fields:
declare_resolution(server_args, "_handle_hicache_ratio_default", **fields)


def resolve_hicache_dcp_compatibility(server_args: Any):
Expand Down
123 changes: 123 additions & 0 deletions python/sglang/srt/mem_cache/hicache_auto_size.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
from __future__ import annotations

import logging
from contextlib import contextmanager
from typing import TYPE_CHECKING

import torch

from sglang.srt.mem_cache.base_swa_memory_pool import BaseSWAKVPool
from sglang.srt.mem_cache.memory_pool import (
HybridLinearKVPool,
HybridReqToTokenPool,
MHATokenToKVPool,
MiniMaxSparseKVPool,
MLATokenToKVPool,
)
from sglang.srt.mem_cache.pool_host.base import (
host_memory_budget_bytes,
host_memory_budget_scope,
ranks_per_host,
)
from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool
from sglang.srt.runtime_context import get_context, get_memory, get_parallel

if TYPE_CHECKING:
from sglang.srt.mem_cache.cache_init_params import CacheInitParams
from sglang.srt.speculative.base_spec_worker import HiCacheDraftPlan

logger = logging.getLogger(__name__)

# Page rounding, allocator metadata and staging buffers are outside the device
# byte counts the ratio is derived from.
_ALLOCATION_SLACK_FRACTION = 0.05

_SIZEABLE_POOLS = (
MHATokenToKVPool,
MLATokenToKVPool,
SWAKVPool,
HybridLinearKVPool,
MiniMaxSparseKVPool,
)


def _pool_bytes(pool) -> int:
if isinstance(pool, SWAKVPool):
return _pool_bytes(pool.full_kv_pool) + _pool_bytes(pool.swa_kv_pool)
if isinstance(pool, HybridLinearKVPool):
return _pool_bytes(pool.full_kv_pool)
sizes = pool.get_kv_size_bytes()
return sum(sizes) if isinstance(sizes, tuple) else sizes


def _draft_bytes(target, draft) -> int:
if isinstance(draft, BaseSWAKVPool):
# Match sidecar construction: only SWA drafts follow target SWA slots.
target, draft = target.swa_kv_pool, draft.swa_kv_pool
# A sidecar has one host slot per target slot, however few slots the draft has.
return _pool_bytes(draft) * target.size // draft.size


def _estimate_hicache_bytes(
params: CacheInitParams, draft_plan: HiCacheDraftPlan | None
) -> int:
"""Device bytes whose host mirrors scale with the HiCache ratio."""
pool = params.token_to_kv_pool_allocator.get_kvcache()
if not isinstance(pool, _SIZEABLE_POOLS):
raise ValueError(
f"HiCache auto-sizing does not support {type(pool).__name__}; "
"set --hicache-ratio or --hicache-size explicitly."
)
total = _pool_bytes(pool)
if isinstance(params.req_to_token_pool, HybridReqToTokenPool):
total += _pool_bytes(params.req_to_token_pool.mamba_pool)
drafts = params.mtp_draft_device_pools
if draft_plan is not None and draft_plan.mode == "sidecar":
drafts = draft_plan.device_pools
return total + sum(_draft_bytes(pool, draft) for draft in drafts)


@contextmanager
def auto_size_hicache(
params: CacheInitParams, draft_plan: HiCacheDraftPlan | None, *, enabled: bool
):
"""Reduce the default HiCache ratio until this machine's host pools fit.

Resolution nulls the fraction for an explicit --hicache-ratio/--hicache-size.
"""
fraction = get_memory().hicache_host_memory_fraction
if not enabled or fraction is None:
yield
return
requested = get_memory().hicache_ratio
device_bytes = _estimate_hicache_bytes(params, draft_plan)
budget = int(host_memory_budget_bytes() * fraction)
ratio = min(requested, budget * (1 - _ALLOCATION_SLACK_FRACTION) / device_bytes)
# One collective before any pool is built: PP stages own different pool
# counts, so a per-pool collective could deadlock.
if torch.distributed.is_initialized():
value = torch.tensor([ratio], dtype=torch.float64)
torch.distributed.all_reduce(
value,
op=torch.distributed.ReduceOp.MIN,
group=get_parallel().world_group.cpu_group,
)
ratio = value.item()
if ratio <= 0:
raise ValueError(
"No host memory is left for HiCache after the 10 GiB reserve; "
"set --hicache-ratio or --hicache-size explicitly."
)
get_context().override("hicache.auto_size", hicache_ratio=ratio)
logger.info(
"HiCache auto-sizing: ratio %.3f -> %.3f; %.1f GiB host memory per rank "
"(fraction %.2f, %d ranks on this host), host pools %.1f GiB.",
requested,
ratio,
budget / 1024**3,
fraction,
ranks_per_host(),
device_bytes * ratio / 1024**3,
)
with host_memory_budget_scope(budget):
yield
104 changes: 104 additions & 0 deletions python/sglang/srt/mem_cache/host_memory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Host-memory headroom bounded by the process's visible cgroup hierarchy."""

from __future__ import annotations

import logging
import re
from pathlib import Path, PurePosixPath

import psutil

logger = logging.getLogger(__name__)


def _unescape_mount_path(value: str) -> str:
return re.sub(r"\\([0-7]{3})", lambda m: chr(int(m[1], 8)), value)


def _cgroup_memory_headroom(proc_root: Path = Path("/proc")) -> int | None:
memberships = {}
try:
cgroups = (proc_root / "self/cgroup").read_text()
mounts = (proc_root / "self/mountinfo").read_text()
except FileNotFoundError:
# Non-Linux systems need not expose procfs.
return None
for line in cgroups.splitlines():
_, controllers, path = line.split(":", 2)
if not controllers:
memberships["cgroup2"] = PurePosixPath(path)
elif "memory" in controllers.split(","):
memberships["cgroup"] = PurePosixPath(path)

headroom = None
resolved = False
for line in mounts.splitlines():
before, after = line.split(" - ", 1)
filesystem, _, options = after.split()[:3]
if filesystem not in memberships:
continue
if filesystem == "cgroup" and "memory" not in options.split(","):
continue
fields = before.split()
root = PurePosixPath(_unescape_mount_path(fields[3]))
mount = Path(_unescape_mount_path(fields[4]))
membership = memberships[filesystem]
if membership.is_relative_to(root):
relative = membership.relative_to(root)
elif root != PurePosixPath("/"):
# A cgroup namespace can expose membership relative to its root,
# while mountinfo still identifies the host-side subtree.
relative = membership.relative_to("/")
else:
continue
if ".." in relative.parts:
raise ValueError(f"Cannot resolve cgroup memory path: {membership}")
directory = mount / relative
if not directory.is_dir():
continue
resolved = True
limits = (
("memory.max", "memory.high")
if filesystem == "cgroup2"
else ("memory.limit_in_bytes",)
)
usage_name = (
"memory.current" if filesystem == "cgroup2" else "memory.usage_in_bytes"
)
while True:
for name in limits:
try:
value = (directory / name).read_text().strip()
except FileNotFoundError:
# The hierarchy root may not have memory controller files.
continue
if value == "max":
continue
limit = int(value)
# Do not silently ignore an unreadable usage file for a known
# limit: falling back to host RAM could overrun the container.
usage = int((directory / usage_name).read_text())
remaining = max(0, limit - usage)
headroom = remaining if headroom is None else min(headroom, remaining)
if directory == mount:
break
directory = directory.parent
if memberships and not resolved:
raise RuntimeError(
"Cannot locate the process memory cgroup in mounted cgroup filesystems"
)
return headroom


def available_host_memory_bytes() -> int:
"""Conservative allocatable RAM; charged file cache is not assumed reclaimable."""
available = psutil.virtual_memory().available
cgroup_headroom = _cgroup_memory_headroom()
if cgroup_headroom is not None:
logger.info(
"HiCache memory headroom: host %.1f GiB, cgroup %.1f GiB",
available / 1024**3,
cgroup_headroom / 1024**3,
)
available = min(available, cgroup_headroom)
return available
55 changes: 30 additions & 25 deletions python/sglang/srt/mem_cache/kv_cache_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class KVCacheBuildResult:
from sglang.srt.managers.mm_schedule import init_mm_embedding_cache
from sglang.srt.mem_cache.cache_init_params import CacheInitParams
from sglang.srt.mem_cache.deepseek_v4_memory_pool import DeepSeekV4TokenToKVPool
from sglang.srt.mem_cache.hicache_auto_size import auto_size_hicache
from sglang.srt.mem_cache.memory_pool import MHATokenToKVPool
from sglang.srt.mem_cache.registry import TreeCacheBuildContext, create_tree_cache
from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool
Expand Down Expand Up @@ -327,32 +328,36 @@ def build_kv_cache(
mtp_draft_device_pools=mtp_draft_device_pools,
)

tree_cache = create_tree_cache(
TreeCacheBuildContext(
server_args=server_args,
params=params,
is_hybrid_swa=is_hybrid_swa,
full_tokens_per_layer=full_tokens_per_layer,
is_hybrid_ssm=is_hybrid_ssm,
is_dsa=is_dsa,
enable_hierarchical_cache=enable_hierarchical_cache,
disable_radix_cache=disable_radix_cache,
effective_chunked_prefill_size=effective_chunked_prefill_size,
tp_worker=tp_worker,
model_config=model_config,
tp_size=ps.tp_size,
tp_rank=ps.tp_rank,
tp_group=tp_group,
)
tree_context = TreeCacheBuildContext(
server_args=server_args,
params=params,
is_hybrid_swa=is_hybrid_swa,
full_tokens_per_layer=full_tokens_per_layer,
is_hybrid_ssm=is_hybrid_ssm,
is_dsa=is_dsa,
enable_hierarchical_cache=enable_hierarchical_cache,
disable_radix_cache=disable_radix_cache,
effective_chunked_prefill_size=effective_chunked_prefill_size,
tp_worker=tp_worker,
model_config=model_config,
tp_size=ps.tp_size,
tp_rank=ps.tp_rank,
tp_group=tp_group,
)

if (
enable_hierarchical_cache or retraction_backup == "host_pool"
) and hicache_draft_plan is not None:
maybe_register_hicache_draft(
tree_cache=tree_cache,
draft_plan=hicache_draft_plan,
)
with auto_size_hicache(
params,
hicache_draft_plan,
enabled=enable_hierarchical_cache or retraction_backup == "host_pool",
):
tree_cache = create_tree_cache(tree_context)

if (
enable_hierarchical_cache or retraction_backup == "host_pool"
) and hicache_draft_plan is not None:
maybe_register_hicache_draft(
tree_cache=tree_cache,
draft_plan=hicache_draft_plan,
)

if retraction_backup == "host_pool":
if not isinstance(tree_cache, UnifiedRadixCache):
Expand Down
Loading
Loading