Skip to content
Open
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
11 changes: 11 additions & 0 deletions python/sglang/srt/layers/attention/triton_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -1279,6 +1279,17 @@ def forward_extend(
k_buffer, v_buffer = pool.get_kv_buffer(layer.layer_id)
k = k_buffer[cache_loc]
v = v_buffer[cache_loc]
# CAI (upstream PR #22615): KV-sharing layers pull K/V straight from
# the cache; with a quantized KV cache these arrive as fp8 and the
# extend kernel's tl.dot(bf16_q, fp8_k) fails to compile. Dequantize
# to the compute dtype (issue #22277).
if k.dtype != q.dtype:
k = k.to(q.dtype)
v = v.to(q.dtype)
if layer.k_scale_float is not None:
k.mul_(layer.k_scale_float)
if layer.v_scale_float is not None:
v.mul_(layer.v_scale_float)
elif k is None or v is None:
raise ValueError("Both k and v should be None or not None")
else:
Expand Down
32 changes: 29 additions & 3 deletions python/sglang/srt/managers/cache_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,11 @@ class HiCacheAck(NamedTuple):
node_ids: List[int]
num_tokens: int = 0
timing_enabled: bool = False
# Tokens transferred per host pool (PoolName value -> count).
num_tokens_by_pool: Optional[dict[str, int]] = None
# Total bytes moved by the op across all pools, including draft piggyback
# and sidecar transfers that the per-pool token counts exclude.
num_bytes: int = 0


class StorageOperation:
Expand Down Expand Up @@ -714,11 +719,12 @@ def start_writing(self) -> None:
self.write_queue.clear()

start_event = device_module.Event()
finish_event = device_module.Event()
ack_start_event, ack_finish_event, timing_enabled = make_timing_event_pair()

start_event.record()
with device_module.stream(self.write_stream):
start_event.wait(self.write_stream)
ack_start_event.record()
self.mem_pool_host.backup_from_device_all_layer(
self.mem_pool_device, host_indices, device_indices, self.io_backend
)
Expand All @@ -729,7 +735,7 @@ def start_writing(self) -> None:
device_indices,
self.io_backend,
)
finish_event.record()
ack_finish_event.record()
# NOTE: We must save the host indices and device indices here,
# this is because we need to guarantee that these tensors are
# still alive when the write stream is executing.
Expand All @@ -738,7 +744,25 @@ def start_writing(self) -> None:
if device_indices.is_cuda:
device_indices.record_stream(self.write_stream)

self.ack_write_queue.append(HiCacheAck(start_event, finish_event, op.node_ids))
self.ack_write_queue.append(
HiCacheAck(
start_event=ack_start_event,
finish_event=ack_finish_event,
node_ids=op.node_ids,
num_tokens=len(op.device_indices),
timing_enabled=timing_enabled,
num_tokens_by_pool={PoolName.KV.value: len(op.device_indices)},
num_bytes=self._transfer_num_bytes(op),
)
)

def _transfer_num_bytes(self, op: CacheOperation) -> int:
"""Total bytes moved by a merged transfer op (draft piggyback included)."""
num_tokens = len(op.device_indices)
num_bytes = num_tokens * self.mem_pool_host.size_per_token
if self.has_draft:
num_bytes += num_tokens * self.mem_pool_host_draft.size_per_token
return num_bytes

def load(
self,
Expand Down Expand Up @@ -830,6 +854,8 @@ def start_loading(self) -> int:
node_ids=op.node_ids,
num_tokens=len(op.device_indices),
timing_enabled=timing_enabled,
num_tokens_by_pool={PoolName.KV.value: len(op.device_indices)},
num_bytes=self._transfer_num_bytes(op),
)
)
return producer_id
Expand Down
43 changes: 26 additions & 17 deletions python/sglang/srt/managers/schedule_batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,21 @@ def sanity_check_mm_pad_shift_value(vocab_size: int) -> None:
)


def split_cached_prefix_by_tier(
prefix_len: int, host_hit_len: int, storage_hit_len: int
) -> tuple[int, int, int]:
"""Split a request's cached prefix into (device, host, storage) tokens.

prefix_len is len(prefix_indices) AFTER host load-back, so it contains the
host-loaded portion; host_hit_len in turn contains the storage-prefetched
portion (storage is clamped to it to handle edge cases).
"""
storage = min(host_hit_len, storage_hit_len)
host = host_hit_len - storage
device = max(0, prefix_len - host_hit_len)
return device, host, storage


def _compute_pad_value(hash: int) -> int:
"""Compute pad value from hash."""
return MM_PAD_SHIFT_VALUE + (hash % (1 << 30))
Expand Down Expand Up @@ -2393,24 +2408,17 @@ def prepare_for_extend(self):
# Only compute once on FIRST chunk - subsequent chunks in chunked prefill
# would incorrectly count previously computed tokens as cache hits.
if not req._cache_breakdown_computed:
# At this point, prefix_indices has been extended with host data
# via init_load_back in schedule_policy, so:
# - len(prefix_indices) = device_original + host_loaded
# - host_hit_length = total tokens from host cache (including storage-prefetched)
# - storage_hit_length = tokens loaded from storage backend (L3 hits)
# - device_portion = len(prefix_indices) - host_hit_length
#
# Storage hits are now tracked via scheduler after prefetch completes.
# storage_hit_length is set by scheduler.pop_prefetch_loaded_tokens()
host_total = req.host_hit_length
# Clamp storage to host_total to handle edge cases
storage_portion = min(host_total, req.storage_hit_length)
host_portion = host_total - storage_portion
device_portion = max(0, len(req.prefix_indices) - host_total)

req.cached_tokens_device = device_portion
req.cached_tokens_host = host_portion
req.cached_tokens_storage = storage_portion
# after prefetch completes.
(
req.cached_tokens_device,
req.cached_tokens_host,
req.cached_tokens_storage,
) = split_cached_prefix_by_tier(
prefix_len=len(req.prefix_indices),
host_hit_len=req.host_hit_length,
storage_hit_len=req.storage_hit_length,
)
req._cache_breakdown_computed = True

req.already_computed = seq_len
Expand Down Expand Up @@ -3271,6 +3279,7 @@ def _evict_swa(self, req: Req, pre_len: int):
req_to_token_pool=self.req_to_token_pool,
token_to_kv_pool_allocator=self.token_to_kv_pool_allocator,
is_chunk_cache=self.tree_cache.is_chunk_cache(),
retain_floor=self.tree_cache.swa_retain_floor(req),
)

def __str__(self):
Expand Down
26 changes: 25 additions & 1 deletion python/sglang/srt/managers/schedule_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@
from sglang.srt.dllm.config import DllmConfig
from sglang.srt.layers.attention.dsa.utils import is_dsa_prefill_cp_in_seq_split
from sglang.srt.layers.utils.cp_utils import is_prefill_context_parallel_enabled
from sglang.srt.managers.schedule_batch import Req, ScheduleBatch
from sglang.srt.managers.schedule_batch import (
Req,
ScheduleBatch,
split_cached_prefix_by_tier,
)
from sglang.srt.mem_cache.allocator.hisparse import (
DeepSeekV4HiSparseTokenToKVPoolAllocator,
)
Expand Down Expand Up @@ -483,6 +487,9 @@ def __init__(
self.new_chunked_req = None
self.log_hit_tokens = 0
self.reprocessed_log_hit_tokens = 0
self.log_device_hit_tokens = 0
self.log_host_hit_tokens = 0
self.log_storage_hit_tokens = 0
# TODO(lsyin): report the real input tokens excluding page alignment
self.log_input_tokens = 0
self.reprocessed_log_input_tokens = 0
Expand Down Expand Up @@ -745,6 +752,8 @@ def _update_prefill_budget(
max_new_tokens: int,
retracted_stain: bool,
mamba_gap_reserve: int = 0,
host_hit_len: int = 0,
storage_hit_len: int = 0,
):
# TODO(lsyin): check this workaround logic, which only ensures the prefill will not out of memory, and may be too conservative
extend_input_len = self.ceil_paged_tokens(extend_input_len)
Expand Down Expand Up @@ -784,6 +793,15 @@ def _update_prefill_budget(
if retracted_stain:
self.reprocessed_log_hit_tokens += prefix_len
self.reprocessed_log_input_tokens += extend_input_len
elif prefix_len > 0:
device_hit, host_hit, storage_hit = split_cached_prefix_by_tier(
prefix_len=prefix_len,
host_hit_len=host_hit_len,
storage_hit_len=storage_hit_len,
)
self.log_device_hit_tokens += device_hit
self.log_host_hit_tokens += host_hit
self.log_storage_hit_tokens += storage_hit

def _get_dllm_remain_tokens(self) -> int:
_rem_tokens = min(
Expand Down Expand Up @@ -816,6 +834,8 @@ def _add_dllm_req(self, req: Req, prefix_len: int):
0,
req.retracted_stain,
mamba_gap_reserve=self._mamba_gap_budget_for_req(req),
host_hit_len=req.host_hit_length,
storage_hit_len=req.storage_hit_length,
)

def _req_inc_lock_ref(self, req: Req):
Expand Down Expand Up @@ -1209,6 +1229,8 @@ def add_one_req(
),
req.retracted_stain,
mamba_gap_reserve=self._mamba_gap_budget_for_req(req),
host_hit_len=req.host_hit_length,
storage_hit_len=req.storage_hit_length,
)
else:
# Make sure at least one page is available
Expand Down Expand Up @@ -1250,6 +1272,8 @@ def add_one_req(
0,
req.retracted_stain,
mamba_gap_reserve=self._mamba_gap_budget_for_req(req),
host_hit_len=req.host_hit_length,
storage_hit_len=req.storage_hit_length,
)

return self.budget_state()
Expand Down
27 changes: 27 additions & 0 deletions python/sglang/srt/managers/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1135,6 +1135,8 @@ def init_running_status(self):

def init_chunked_prefill(self):
self.chunked_prefill_size = get_schedule().chunked_prefill_size
self.prefill_decode_interval = get_schedule().prefill_decode_interval
self._prefill_decode_interval_remaining = 0
uses_transformers_backend = (
get_resolved_model_impl(self.model_config) == ModelImpl.TRANSFORMERS
)
Expand Down Expand Up @@ -1171,6 +1173,28 @@ def init_chunked_prefill(self):
)
self.enable_dynamic_chunking = False

def _should_defer_prefill(self) -> bool:
if self._prefill_decode_interval_remaining == 0:
return False

self._prefill_decode_interval_remaining -= 1
return True

def _arm_prefill_decode_interval(self, batch: Optional[ScheduleBatch]) -> None:
if self.prefill_decode_interval == 0 or batch is None:
return

# DP attention synchronizes this flag across ranks. This keeps every
# rank on the same prefill/decode cadence even when only one rank has
# local prefill work. Non-DP scheduling can use the local mode directly.
is_extend = (
batch.is_extend_in_batch
if self.require_mlp_sync
else batch.forward_mode.is_extend()
)
if is_extend:
self._prefill_decode_interval_remaining = self.prefill_decode_interval

def init_metrics_reporter(
self, tp_rank: int, pp_rank: int, dp_rank: Optional[int]
) -> None:
Expand Down Expand Up @@ -3013,6 +3037,8 @@ def get_next_batch_to_run(

if self.dllm_config is not None:
new_batch = self.get_new_batch_dllm(running_batch)
elif self._should_defer_prefill():
new_batch = None
else:
prefill_plan = self.get_new_batch_prefill(running_batch)
new_batch = prefill_plan.batch_to_run
Expand Down Expand Up @@ -3046,6 +3072,7 @@ def get_next_batch_to_run(
ret = self.dp_attn_adapter.maybe_prepare_mlp_sync_batch(
ret, need_sync=need_mlp_sync
)
self._arm_prefill_decode_interval(ret)

# Handle ngram embedding
ret = self.ngram_embedding_manager.prepare_for_forward(
Expand Down
24 changes: 14 additions & 10 deletions python/sglang/srt/managers/scheduler_components/metrics_reporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,7 @@
import time
from collections import defaultdict
from dataclasses import dataclass
from typing import (
TYPE_CHECKING,
List,
Optional,
Tuple,
Union,
)
from typing import TYPE_CHECKING, List, Optional, Tuple, Union

from sglang.srt.disaggregation.utils import DisaggregationMode
from sglang.srt.environ import envs
Expand Down Expand Up @@ -64,6 +58,9 @@ class PrefillStats:
num_new_seqs: int # len(can_run_list)
reprocessed_log_input_tokens: int = 0
reprocessed_log_hit_tokens: int = 0
log_device_hit_tokens: int = 0
log_host_hit_tokens: int = 0
log_storage_hit_tokens: int = 0
num_pending_tokens: int = 0

@classmethod
Expand All @@ -79,6 +76,9 @@ def from_adder(
log_hit_tokens=adder.log_hit_tokens,
reprocessed_log_input_tokens=adder.reprocessed_log_input_tokens,
reprocessed_log_hit_tokens=adder.reprocessed_log_hit_tokens,
log_device_hit_tokens=adder.log_device_hit_tokens,
log_host_hit_tokens=adder.log_host_hit_tokens,
log_storage_hit_tokens=adder.log_storage_hit_tokens,
new_token_ratio=adder.new_token_ratio,
num_running_reqs=QueueCount.from_reqs(
running_reqs, enable_priority_scheduling
Expand Down Expand Up @@ -637,6 +637,12 @@ def report_prefill_stats(
cache_hit_rate = (
effective_hit_tokens / total_tokens if total_tokens > 0 else 0.0
)
self.metrics_collector.increment_effective_prefill_tokens(
input_tokens=effective_input_tokens,
device_hit_tokens=prefill_stats.log_device_hit_tokens,
host_hit_tokens=prefill_stats.log_host_hit_tokens,
storage_hit_tokens=prefill_stats.log_storage_hit_tokens,
)

# Basics
if (
Expand Down Expand Up @@ -970,9 +976,7 @@ def _emit_forward_pass_metrics(
if not self.scheduler.enable_fpm:
return

from sglang.srt.observability.forward_pass_metrics import (
ForwardPassMetrics,
)
from sglang.srt.observability.forward_pass_metrics import ForwardPassMetrics

if self.scheduler._fpm_uses_device_timer:
self.forward_pass_device_timer._report()
Expand Down
7 changes: 7 additions & 0 deletions python/sglang/srt/mem_cache/base_prefix_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,13 @@ def take_events(self):
def supports_swa(self) -> bool:
return False

def swa_retain_floor(self, req) -> int | None:
# A match lands on a state checkpoint rather than on the tail, so a cache
# that pairs SWA with mamba/conv checkpoints has to keep the window behind
# the last checkpoint. Those caches override this. Everyone else has
# nothing deeper than the tail to protect.
return None

def swa_reprefill_tail_tokens(self) -> int:
# Only the unified_kv compress-only HiCache layout needs to hold back a
# trailing sliding window for re-prefill; every other cache keeps SWA
Expand Down
7 changes: 7 additions & 0 deletions python/sglang/srt/mem_cache/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def free_swa_out_of_window_slots(
req_to_token_pool: ReqToTokenPool,
token_to_kv_pool_allocator: BaseTokenToKVPoolAllocator,
is_chunk_cache: bool = False,
retain_floor: int | None = None,
) -> None:
if req.kv is None:
return
Expand All @@ -76,6 +77,12 @@ def free_swa_out_of_window_slots(
# boundary (page_floor(seq_len)) so the last leaf is never all-tombstone.
# No extra page margin is needed.
evict_threshold = pre_len - max(sliding_window_size, page_size)
if retain_floor is not None and not is_chunk_cache:
# The caller owns where the floor is (see BasePrefixCache.swa_retain_floor);
# this only promises not to free past it. Chunk cache has no tree, so a
# retained checkpoint could never be matched and holding it is pure cost.
evict_threshold = min(evict_threshold, retain_floor)

new_swa_evicted_seqlen = max(
req.kv.swa_evicted_seqlen,
evict_threshold,
Expand Down
8 changes: 7 additions & 1 deletion python/sglang/srt/mem_cache/hi_mamba_radix_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,13 @@ def loading_check(self):
self.dec_lock_ref(end_node)

if self.metrics_collector is not None:
self.metrics_collector.increment_load_back_num_tokens(ack.num_tokens)
for pool, num_tokens in (ack.num_tokens_by_pool or {}).items():
if num_tokens > 0:
self.metrics_collector.increment_load_back_num_tokens(
num_tokens=num_tokens, pool=pool
)
if ack.num_bytes > 0:
self.metrics_collector.increment_load_back_num_bytes(ack.num_bytes)
if ack.timing_enabled:
duration_ms = ack.start_event.elapsed_time(ack.finish_event)
self.metrics_collector.observe_load_back_duration(
Expand Down
Loading