fix(unified-memory): four boot/correctness fixes on the hybrid model paths (#35154)
Co-authored-by: Caihua Li <caihua.li@bytedance.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Cheng Wan <cheng.wan@radixark.ai>
This commit is contained in:
co-authored by
Caihua Li
Claude Fable 5
Cheng Wan
parent
88cf5c9541
commit
961beee9e5
@@ -175,7 +175,7 @@ struct CausalConv1dKernel {
|
||||
// x may be a non-contiguous row view (stride_t arbitrary) but must be
|
||||
// channel-contiguous. cache_mask is torch-bool (verify shape/device only).
|
||||
TensorMatcher({T, D}).with_strides({-1, 1}).with_dtype<DType>().with_device(dev).verify(x);
|
||||
TensorMatcher({-1, Km1, D}).with_dtype<DType>().with_device(dev).verify(cache);
|
||||
TensorMatcher({-1, Km1, D}).with_strides({-1, -1, 1}).with_dtype<DType>().with_device(dev).verify(cache);
|
||||
TensorMatcher({NS}).with_dtype<int64_t>().with_device(dev).verify(safe_idx);
|
||||
TensorMatcher({NS, 1, 1}).with_device(dev).verify(cache_mask);
|
||||
TensorMatcher({D, Wd}).with_strides({-1, 1}).with_dtype<DType>().with_device(dev).verify(weight);
|
||||
|
||||
@@ -114,7 +114,7 @@ struct DraftExtendSconvKernel {
|
||||
W1s.set_value(W1);
|
||||
|
||||
TensorMatcher({BT, D}).with_strides({-1, 1}).with_dtype<DType>().with_device(dev).verify(hidden);
|
||||
TensorMatcher({-1, W1s, D}).with_dtype<DType>().with_device(dev).verify(cache);
|
||||
TensorMatcher({-1, W1s, D}).with_strides({-1, -1, 1}).with_dtype<DType>().with_device(dev).verify(cache);
|
||||
TensorMatcher({B}).with_dtype<int32_t>().with_device(dev).verify(cache_indices);
|
||||
TensorMatcher({B}).with_dtype<int32_t>().with_device(dev).verify(num_accepted);
|
||||
RuntimeCheck(sizeof(DType) == 2, "draft_extend: bf16x2 kernel requires 16-bit dtype");
|
||||
|
||||
@@ -149,7 +149,7 @@ struct FusedDecodeUpdateKernel {
|
||||
W1s.set_value(W - 1);
|
||||
|
||||
TensorMatcher({T, D}).with_strides({-1, 1}).with_dtype<DType>().with_device(dev).verify(x);
|
||||
TensorMatcher({-1, W1s, D}).with_dtype<DType>().with_device(dev).verify(cache);
|
||||
TensorMatcher({-1, W1s, D}).with_strides({-1, -1, 1}).with_dtype<DType>().with_device(dev).verify(cache);
|
||||
TensorMatcher({T}).with_dtype<int32_t>().with_device(dev).verify(cache_indices);
|
||||
TensorMatcher({T}).with_device(dev).verify(cache_mask);
|
||||
TensorMatcher({D, Wd}).with_strides({-1, 1}).with_dtype<DType>().with_device(dev).verify(weight);
|
||||
|
||||
@@ -75,7 +75,7 @@ struct GatherScatterSconvKernel {
|
||||
W1s.set_value(W1);
|
||||
|
||||
TensorMatcher({T, D}).with_strides({-1, 1}).with_dtype<DType>().with_device(dev).verify(hidden);
|
||||
TensorMatcher({-1, W1s, D}).with_dtype<DType>().with_device(dev).verify(cache);
|
||||
TensorMatcher({-1, W1s, D}).with_strides({-1, -1, 1}).with_dtype<DType>().with_device(dev).verify(cache);
|
||||
TensorMatcher({B, W1s}).with_dtype<int32_t>().with_device(dev).verify(track_idx);
|
||||
TensorMatcher({B}).with_device(dev).verify(mask);
|
||||
TensorMatcher({B}).with_dtype<int64_t>().with_device(dev).verify(dst);
|
||||
|
||||
@@ -627,7 +627,7 @@ struct ArSconvNormKernel {
|
||||
TensorMatcher({T, D}).with_strides({-1, 1}).with_dtype<DType>().with_device(dev).verify(residual_out);
|
||||
TensorMatcher({T, D}).with_strides({-1, 1}).with_dtype<DType>().with_device(dev).verify(hs_out);
|
||||
TensorMatcher({D}).with_dtype<DType>().with_device(dev).verify(norm_weight);
|
||||
TensorMatcher({-1, W1s, D}).with_dtype<DType>().with_device(dev).verify(cache);
|
||||
TensorMatcher({-1, W1s, D}).with_strides({-1, -1, 1}).with_dtype<DType>().with_device(dev).verify(cache);
|
||||
TensorMatcher({T}).with_dtype<int32_t>().with_device(dev).verify(cache_indices);
|
||||
TensorMatcher({T}).with_device(dev).verify(cache_mask);
|
||||
TensorMatcher({D, Wd}).with_strides({-1, 1}).with_dtype<DType>().with_device(dev).verify(conv_weight);
|
||||
@@ -752,7 +752,7 @@ struct ArSconvNormVerifyKernel {
|
||||
TensorMatcher({T, D}).with_strides({-1, 1}).with_dtype<DType>().with_device(dev).verify(residual_out);
|
||||
TensorMatcher({T, D}).with_strides({-1, 1}).with_dtype<DType>().with_device(dev).verify(hs_out);
|
||||
TensorMatcher({D}).with_dtype<DType>().with_device(dev).verify(norm_weight);
|
||||
TensorMatcher({-1, W1s, D}).with_dtype<DType>().with_device(dev).verify(cache);
|
||||
TensorMatcher({-1, W1s, D}).with_strides({-1, -1, 1}).with_dtype<DType>().with_device(dev).verify(cache);
|
||||
TensorMatcher({B}).with_dtype<int32_t>().with_device(dev).verify(cache_indices);
|
||||
TensorMatcher({B}).with_device(dev).verify(cache_mask);
|
||||
TensorMatcher({D, Wd}).with_strides({-1, 1}).with_dtype<DType>().with_device(dev).verify(conv_weight);
|
||||
|
||||
@@ -106,7 +106,7 @@ struct UpdateSconvCacheKernel {
|
||||
// x channel-contiguous (may be a non-contiguous row view); cache contiguous
|
||||
// [slots, W1, D]. cache_indices/qsl int32, has_state torch-bool (shape/device only).
|
||||
TensorMatcher({T, D}).with_strides({-1, 1}).with_dtype<DType>().with_device(dev).verify(x);
|
||||
TensorMatcher({-1, W1s, D}).with_dtype<DType>().with_device(dev).verify(cache);
|
||||
TensorMatcher({-1, W1s, D}).with_strides({-1, -1, 1}).with_dtype<DType>().with_device(dev).verify(cache);
|
||||
TensorMatcher({B}).with_dtype<int32_t>().with_device(dev).verify(cache_indices);
|
||||
TensorMatcher({B}).with_device(dev).verify(has_state);
|
||||
TensorMatcher({-1}).with_dtype<int32_t>().with_device(dev).verify(qsl);
|
||||
|
||||
@@ -530,8 +530,6 @@ class Envs:
|
||||
# fall back to the per-free eager compaction. Used for production
|
||||
# A/B and quick rollback. Default False (lazy compaction on).
|
||||
SGLANG_DISABLE_LAZY_COMPACTION = EnvBool(False)
|
||||
# Sort the multi-ended allocator's free list after a merge (perf A/B knob).
|
||||
SGLANG_SORT_FREE_LIST_AFTER_MERGE = EnvBool(False)
|
||||
# Periodically log lazy-compaction stats per sub-pool (observability only).
|
||||
SGLANG_LOG_LAZY_COMPACTION_STATS = EnvBool(False)
|
||||
SGLANG_LOG_LAZY_COMPACTION_STATS_INTERVAL_SEC = EnvInt(30)
|
||||
@@ -1724,6 +1722,9 @@ _DEPRECATED_ENVS: Dict[str, _DeprecatedEnv] = {
|
||||
# Superseded by the unified JIT per_token_group_quant, the default CUDA path.
|
||||
"SGLANG_OPT_USE_JIT_PER_TOKEN_GROUP_QUANT": _DeprecatedEnv(),
|
||||
"SGLANG_MASKED_GEMM_FAST_ACT": _DeprecatedEnv(),
|
||||
# The unified free list is kept unsorted between flushes by design; the
|
||||
# sort-after-merge A/B knob never left its off default and is gone.
|
||||
"SGLANG_SORT_FREE_LIST_AFTER_MERGE": _DeprecatedEnv(),
|
||||
"SGLANG_OPT_SWA_EVICT_DROP_PAGE_MARGIN": _DeprecatedEnv(),
|
||||
# sconv-family kernels always use the CUDA-JIT ports when supported; no toggle.
|
||||
"SGLANG_OPT_USE_CUDA_SCONV": _DeprecatedEnv(),
|
||||
|
||||
@@ -1030,7 +1030,6 @@ class AiterAttnBackend(AttentionBackend):
|
||||
req_pool_indices=forward_batch.req_pool_indices,
|
||||
seq_lens=forward_batch.seq_lens,
|
||||
seq_lens_sum=None if in_capture else forward_batch.seq_lens_sum,
|
||||
encoder_lens=forward_batch.encoder_lens,
|
||||
forward_mode=forward_batch.forward_mode,
|
||||
spec_info=forward_batch.spec_info,
|
||||
seq_lens_cpu=seq_lens_cpu,
|
||||
@@ -1667,7 +1666,6 @@ class AiterAttnBackend(AttentionBackend):
|
||||
req_pool_indices: torch.Tensor,
|
||||
seq_lens: torch.Tensor,
|
||||
seq_lens_sum: int,
|
||||
encoder_lens: Optional[torch.Tensor],
|
||||
forward_mode: ForwardMode,
|
||||
spec_info: Optional[SpecInput],
|
||||
seq_lens_cpu: Optional[torch.Tensor],
|
||||
|
||||
@@ -533,7 +533,6 @@ class FlashAttentionBackend(AttentionBackend):
|
||||
bs=bs,
|
||||
req_pool_indices=req_pool_indices,
|
||||
seq_lens=seq_lens,
|
||||
seq_lens_sum=None,
|
||||
encoder_lens=encoder_lens,
|
||||
forward_mode=forward_mode,
|
||||
spec_info=spec_info,
|
||||
@@ -579,7 +578,6 @@ class FlashAttentionBackend(AttentionBackend):
|
||||
bs=bs,
|
||||
req_pool_indices=req_pool_indices,
|
||||
seq_lens=seq_lens,
|
||||
seq_lens_sum=forward_batch.seq_lens_sum,
|
||||
encoder_lens=encoder_lens,
|
||||
forward_mode=forward_mode,
|
||||
spec_info=spec_info,
|
||||
@@ -2720,7 +2718,6 @@ class FlashAttentionBackend(AttentionBackend):
|
||||
bs: int,
|
||||
req_pool_indices: torch.Tensor,
|
||||
seq_lens: torch.Tensor,
|
||||
seq_lens_sum: int,
|
||||
encoder_lens: Optional[torch.Tensor],
|
||||
forward_mode: ForwardMode,
|
||||
spec_info: Optional[SpecInput],
|
||||
|
||||
@@ -25,7 +25,7 @@ from __future__ import annotations
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
from typing import Callable, Dict, List, Optional, Set, Tuple
|
||||
from typing import Callable, Dict, List, Optional, Sequence, Set, Tuple
|
||||
|
||||
import torch
|
||||
from torch.profiler import record_function
|
||||
@@ -47,10 +47,6 @@ from sglang.srt.utils.common import get_num_new_pages, next_power_of_2
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# OFF (default): cat unsorted, `_flush` sorts once. ON: sort after each cat.
|
||||
_SORT_FREE_LIST_AFTER_MERGE = envs.SGLANG_SORT_FREE_LIST_AFTER_MERGE.get()
|
||||
|
||||
|
||||
import atexit
|
||||
import signal
|
||||
import time as _time_mod # local alias so tests can patch
|
||||
@@ -292,6 +288,10 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
|
||||
else:
|
||||
self.free_virtual_ids = None
|
||||
self.free_group = None
|
||||
# Segment frees buffer page REPRESENTATIVES here, not whole token
|
||||
# ranges: `torch.cat` of the ranges destroys the per-segment shape the
|
||||
# stride derivation needs, forcing the position-less dedup back on.
|
||||
self.free_page_reps_group: Optional[List[torch.Tensor]] = None
|
||||
self._inverse_history.clear()
|
||||
self._free_phys_pages = torch.empty(0, dtype=torch.int64, device=self.device)
|
||||
self._pending_reuse.clear()
|
||||
@@ -452,16 +452,8 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
|
||||
return None
|
||||
|
||||
if n_drain > 0:
|
||||
if _SORT_FREE_LIST_AFTER_MERGE:
|
||||
if self.grow_direction == "up":
|
||||
drained_t = self._free_phys_pages[:n_drain]
|
||||
self._free_phys_pages = self._free_phys_pages[n_drain:]
|
||||
else:
|
||||
drained_t = self._free_phys_pages[-n_drain:].flip(0)
|
||||
self._free_phys_pages = self._free_phys_pages[:-n_drain]
|
||||
else:
|
||||
drained_t = self._free_phys_pages[:n_drain]
|
||||
self._free_phys_pages = self._free_phys_pages[n_drain:]
|
||||
drained_t = self._free_phys_pages[:n_drain]
|
||||
self._free_phys_pages = self._free_phys_pages[n_drain:]
|
||||
else:
|
||||
drained_t = None
|
||||
|
||||
@@ -958,10 +950,17 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
|
||||
|
||||
# -- free with eager compaction --
|
||||
|
||||
def free(self, free_index: torch.Tensor) -> None:
|
||||
def free(
|
||||
self, free_index: torch.Tensor, *, _pages: Optional[torch.Tensor] = None
|
||||
) -> None:
|
||||
"""Free virtual TOKEN ids: recover virtual PAGE ids, un-map v2p/p2v,
|
||||
(if id-owner) recycle the page ids, trigger eager compaction.
|
||||
|
||||
`_pages` carries virtual PAGE ids already derived by `free_segment`
|
||||
from `start_pos` arithmetic; when given, the data-dependent dedup is
|
||||
skipped. Dropped on the free-group path, which has its own
|
||||
representative buffer.
|
||||
|
||||
`free_index` is token-granular and need not be page-aligned. EAGER mode
|
||||
drops one `wait_stream(forward_stream)` barrier so v2p/p2v writes and the
|
||||
compaction move serialize with the in-flight forward. LAZY mode needs no
|
||||
@@ -976,7 +975,7 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
|
||||
self.free_group.append(self._copy_for_free_group(free_index))
|
||||
return
|
||||
if self.lazy_compaction:
|
||||
self._free_lazy(free_index)
|
||||
self._free_lazy(free_index, pages=_pages)
|
||||
return
|
||||
# --- EAGER path ---
|
||||
# Near-no-op in normal mode (sampling's CPU sync already drained
|
||||
@@ -986,8 +985,12 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
|
||||
with record_function("MultiEndedAlloc.free.wait_stream"):
|
||||
torch.cuda.current_stream().wait_stream(self.forward_stream)
|
||||
with record_function("MultiEndedAlloc.free.v2p_lookup"):
|
||||
free_v_pages = torch.unique(
|
||||
free_index.detach().to(torch.int64) // self.page_size
|
||||
free_v_pages = (
|
||||
_pages
|
||||
if _pages is not None
|
||||
else torch.unique(
|
||||
free_index.detach().to(torch.int64) // self.page_size
|
||||
)
|
||||
)
|
||||
freed_p_pages = self.virtual_to_physical[free_v_pages]
|
||||
with record_function("MultiEndedAlloc.free.sync_check"):
|
||||
@@ -996,12 +999,51 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
|
||||
self._raise_stale_slot_assertion(
|
||||
free_v=free_v_pages, freed_p=freed_p_pages
|
||||
)
|
||||
self.virtual_to_physical[free_v_pages] = -1
|
||||
self.virtual_to_physical.index_fill_(0, free_v_pages, -1)
|
||||
if self.is_id_owner:
|
||||
self.free_virtual_ids = torch.cat([self.free_virtual_ids, free_v_pages])
|
||||
self._compact_pending(freed_p_pages)
|
||||
|
||||
def _free_lazy(self, free_index: torch.Tensor) -> None:
|
||||
def _page_reps_pieces(
|
||||
self, free_index: torch.Tensor, start_pos: int
|
||||
) -> Tuple[torch.Tensor, ...]:
|
||||
"""Page-representative TOKEN slices of one kv-row segment.
|
||||
|
||||
Mirrors `PagedTokenToKVPoolAllocator.free_segment`: a page's tokens sit
|
||||
consecutively in the kv row, so with `start_pos` known on the host the
|
||||
representatives are stride slices -- no `torch.unique`, whose
|
||||
data-dependent output shape forces a device sync.
|
||||
|
||||
Exact for any segment shape: a partial head page is the `[:1]` term, a
|
||||
partial tail page the final stride step.
|
||||
"""
|
||||
ps = self.page_size
|
||||
offset = start_pos % ps
|
||||
if offset == 0:
|
||||
return (free_index[::ps],)
|
||||
return (free_index[:1], free_index[ps - offset :: ps])
|
||||
|
||||
def free_segment(self, free_index: torch.Tensor, *, start_pos: int) -> None:
|
||||
"""Fixed-shape counterpart of `free()`; see `_page_reps_pieces`.
|
||||
|
||||
Contract: see base; a page must be freed by only one call per group.
|
||||
"""
|
||||
if free_index is None or free_index.numel() == 0:
|
||||
return
|
||||
if self.page_size == 1:
|
||||
# token == page: nothing to dedup, the plain path is already exact.
|
||||
self.free(free_index)
|
||||
return
|
||||
pieces = self._page_reps_pieces(free_index.detach().to(torch.int64), start_pos)
|
||||
if self.free_page_reps_group is None:
|
||||
reps = pieces[0] if len(pieces) == 1 else torch.cat(pieces)
|
||||
self.free(reps, _pages=reps // self.page_size)
|
||||
else:
|
||||
self.free_page_reps_group.extend(pieces)
|
||||
|
||||
def _free_lazy(
|
||||
self, free_index: torch.Tensor, pages: Optional[torch.Tensor] = None
|
||||
) -> None:
|
||||
"""Lazy free path: disjoint-element scatters + ONE `torch.cat` onto
|
||||
`_free_phys_pages`. No sort, no boundary absorb, no watermark mutation,
|
||||
no D2H sync. Boundary absorption is deferred to `_flush`.
|
||||
@@ -1015,24 +1057,31 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
|
||||
with record_function("MultiEndedAlloc._free_lazy"):
|
||||
with record_function("MultiEndedAlloc._free_lazy.v2p_lookup"):
|
||||
free_v_pages_raw = free_index.detach().to(torch.int64)
|
||||
if self.page_size == 1:
|
||||
if pages is not None:
|
||||
# `free_segment` already derived these by stride slicing.
|
||||
free_v_pages = pages
|
||||
elif self.page_size == 1:
|
||||
free_v_pages = free_v_pages_raw
|
||||
else:
|
||||
free_v_pages = torch.unique(free_v_pages_raw // self.page_size)
|
||||
freed_p_pages = self.virtual_to_physical[free_v_pages]
|
||||
# Disjoint-element scatters — no barrier (a freed v has no live reader;
|
||||
# per-element scatter writes are atomic).
|
||||
self.virtual_to_physical[free_v_pages] = -1
|
||||
self.physical_to_virtual[freed_p_pages] = -1
|
||||
# `index_fill_`, NOT `t[idx] = -1`: the scalar form makes torch
|
||||
# materialise -1 as a CPU tensor and copy it H2D, and a pageable
|
||||
# H2D copy is host-BLOCKING -- the scheduler parks behind the
|
||||
# in-flight forward until the stream drains (~16 ms per free on an
|
||||
# 8192-token prefill). `index_fill_` takes the scalar through the
|
||||
# ATen Scalar overload: one device kernel, no host sync.
|
||||
self.virtual_to_physical.index_fill_(0, free_v_pages, -1)
|
||||
self.physical_to_virtual.index_fill_(0, freed_p_pages, -1)
|
||||
if self.is_id_owner:
|
||||
self.free_virtual_ids = torch.cat([self.free_virtual_ids, free_v_pages])
|
||||
self._free_phys_pages = torch.cat([self._free_phys_pages, freed_p_pages])
|
||||
if _SORT_FREE_LIST_AFTER_MERGE:
|
||||
self._free_phys_pages, _ = torch.sort(self._free_phys_pages)
|
||||
self.live_page_count -= int(freed_p_pages.shape[0])
|
||||
|
||||
def _release_phys_pages_batch(self, pages: torch.Tensor) -> None:
|
||||
"""Cat `pages` onto `_free_phys_pages` (+ optional sort). Called by `_flush`
|
||||
"""Cat `pages` onto `_free_phys_pages`. Called by `_flush`
|
||||
at END to merge event-fired compaction-srcs (`released_fired`) AFTER the
|
||||
trailing dst-slice, keeping `_free_phys_pages == holes_cpu` during the walk.
|
||||
|
||||
@@ -1044,8 +1093,6 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
|
||||
self._stats_n_release_batch += 1
|
||||
with record_function("MultiEndedAlloc._release_phys_pages_batch"):
|
||||
self._free_phys_pages = torch.cat([self._free_phys_pages, pages])
|
||||
if _SORT_FREE_LIST_AFTER_MERGE:
|
||||
self._free_phys_pages, _ = torch.sort(self._free_phys_pages)
|
||||
|
||||
def _compact_pending(self, freed_physical_pages: torch.Tensor) -> None:
|
||||
"""Eager compaction over the freed PHYSICAL pages: move survivors from the
|
||||
@@ -1221,7 +1268,6 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
|
||||
self._stats_peak_pending_pages = max(
|
||||
self._stats_peak_pending_pages, cur_pending
|
||||
)
|
||||
sort_tag = "ON" if _SORT_FREE_LIST_AFTER_MERGE else "OFF"
|
||||
logger.info(
|
||||
f"[lazy-stats sub={self.sub_pool_name!r}] "
|
||||
f"free_lazy={self._stats_n_free_lazy} "
|
||||
@@ -1230,7 +1276,6 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
|
||||
f"moves={self._stats_n_flush_moves} "
|
||||
f"abs={self._stats_n_pages_absorbed}) "
|
||||
f"drain={self._stats_n_drain_did_work}/{self._stats_n_drain_calls} "
|
||||
f"sort={sort_tag} "
|
||||
f"peak_holes={self._stats_peak_free_list_len} "
|
||||
f"peak_pending={self._stats_peak_pending_pages} "
|
||||
f"cur_holes={cur_holes} cur_pending={cur_pending} "
|
||||
@@ -1254,7 +1299,6 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
|
||||
self._stats_peak_pending_pages = max(
|
||||
self._stats_peak_pending_pages, cur_pending
|
||||
)
|
||||
sort_tag = "ON" if _SORT_FREE_LIST_AFTER_MERGE else "OFF"
|
||||
self._stats_final_emitted = True
|
||||
logger.info(
|
||||
f"[lazy-stats FINAL sub={self.sub_pool_name!r} reason={reason}] "
|
||||
@@ -1264,7 +1308,6 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
|
||||
f"moves={self._stats_n_flush_moves} "
|
||||
f"abs={self._stats_n_pages_absorbed}) "
|
||||
f"drain={self._stats_n_drain_did_work}/{self._stats_n_drain_calls} "
|
||||
f"sort={sort_tag} "
|
||||
f"peak_holes={self._stats_peak_free_list_len} "
|
||||
f"peak_pending={self._stats_peak_pending_pages} "
|
||||
f"cur_holes={cur_holes} cur_pending={cur_pending} "
|
||||
@@ -1312,8 +1355,6 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
|
||||
self._stats_n_drained_pages_total += sum(
|
||||
t.numel() for t in ready_tensors
|
||||
)
|
||||
if _SORT_FREE_LIST_AFTER_MERGE:
|
||||
self._free_phys_pages, _ = torch.sort(self._free_phys_pages)
|
||||
|
||||
def maybe_drain_pending_reuse(self) -> None:
|
||||
"""Public scheduler hook (once per step): flow fired compaction-src pages
|
||||
@@ -1452,8 +1493,8 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
|
||||
with record_function("MultiEndedAlloc._flush"):
|
||||
self._drain_pending_reuse(urgent=urgent)
|
||||
|
||||
# Sort ASCENDING (skip if the env knob keeps the list always-sorted).
|
||||
if not _SORT_FREE_LIST_AFTER_MERGE and self._free_phys_pages.numel() > 1:
|
||||
# Sort ASCENDING.
|
||||
if self._free_phys_pages.numel() > 1:
|
||||
self._free_phys_pages, _ = torch.sort(self._free_phys_pages)
|
||||
|
||||
all_cpu = self._free_phys_pages.tolist() # one batched D2H sync
|
||||
@@ -1486,8 +1527,8 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
|
||||
# deferred to AFTER the trailing dst-slice, keeping `_free_phys_pages`
|
||||
# byte-identical to `holes_cpu` for the whole walk. That invariant is
|
||||
# what makes the directional dst-slice correct in both directions
|
||||
# (catting srcs mid-flush would chop the wrong end / scramble under
|
||||
# sort=ON, leaving ghost p2v=-1 pages + double-bound dsts). Event-
|
||||
# (catting srcs mid-flush would chop the wrong end, leaving ghost
|
||||
# p2v=-1 pages + double-bound dsts). Event-
|
||||
# PENDING srcs still route to `_pending_reuse` (read-race gating).
|
||||
released_fired: List[torch.Tensor] = []
|
||||
|
||||
@@ -1655,7 +1696,7 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
|
||||
# ONE bulk remap (single-writer on schedule_stream).
|
||||
self.virtual_to_physical[v_moveds_t] = dst_pages_t
|
||||
self.physical_to_virtual[dst_pages_t] = v_moveds_t
|
||||
self.physical_to_virtual[src_pages_t] = -1
|
||||
self.physical_to_virtual.index_fill_(0, src_pages_t, -1)
|
||||
self._inverse_history.append((src_pages_t, dst_pages_t, v_moveds_t))
|
||||
# Src disposition — ONE entry per batch. `src_pages_t` is reused as the
|
||||
# `_pending_reuse` GPU tensor (no second H2D at drain).
|
||||
@@ -1695,6 +1736,19 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
|
||||
f"Caller: {callers}."
|
||||
)
|
||||
|
||||
# -- free-group --
|
||||
|
||||
def free_group_begin(self) -> None:
|
||||
super().free_group_begin()
|
||||
self.free_page_reps_group = []
|
||||
|
||||
def free_group_end(self) -> None:
|
||||
pending, self.free_page_reps_group = self.free_page_reps_group, None
|
||||
super().free_group_end()
|
||||
if pending:
|
||||
reps = torch.cat(pending)
|
||||
self.free(reps, _pages=reps // self.page_size)
|
||||
|
||||
|
||||
class UnifiedMambaTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
|
||||
"""Composite allocator for the MHA (full-attn) + Mamba hybrid pair.
|
||||
@@ -1764,6 +1818,7 @@ class UnifiedMambaTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
|
||||
# write locations are resolved in the attention metadata.
|
||||
|
||||
self.free_group = None
|
||||
self.free_page_reps_group: Optional[List[torch.Tensor]] = None
|
||||
# Base init left these None; we use watermark math, not free-lists.
|
||||
self.free_pages = torch.empty(0, dtype=torch.int64, device=device)
|
||||
self.release_pages = torch.empty(0, dtype=torch.int64, device=device)
|
||||
@@ -1962,6 +2017,47 @@ class UnifiedMambaTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
|
||||
self.mamba_allocator.clear()
|
||||
self.free_group = None
|
||||
|
||||
def free_segment(self, free_index: torch.Tensor, *, start_pos: int) -> None:
|
||||
"""Fixed-shape counterpart of `free()`; see
|
||||
`MultiEndedAllocator._page_reps_pieces`. The mamba sub-pool is
|
||||
slot-granular and untouched by a token free, so only the full side
|
||||
needs the representatives.
|
||||
"""
|
||||
if free_index is None or free_index.numel() == 0:
|
||||
return
|
||||
if self.page_size == 1:
|
||||
self.free(free_index)
|
||||
return
|
||||
pieces = self.full_attn_allocator._page_reps_pieces(
|
||||
free_index.detach().to(torch.int64), start_pos
|
||||
)
|
||||
if self.free_page_reps_group is None:
|
||||
self._release_page_reps(pieces)
|
||||
else:
|
||||
self.free_page_reps_group.extend(pieces)
|
||||
|
||||
def _release_page_reps(self, pieces: Sequence[torch.Tensor]) -> None:
|
||||
reps = pieces[0] if len(pieces) == 1 else torch.cat(tuple(pieces))
|
||||
self.full_attn_allocator.free(reps, _pages=reps // self.page_size)
|
||||
self.full_attn_allocator.clear_inverse_history()
|
||||
self.mamba_allocator.clear_inverse_history()
|
||||
|
||||
def free_group_begin(self) -> None:
|
||||
super().free_group_begin()
|
||||
self.free_page_reps_group = []
|
||||
|
||||
def free_group_end(self) -> None:
|
||||
pending, self.free_page_reps_group = self.free_page_reps_group, None
|
||||
super().free_group_end()
|
||||
if pending:
|
||||
self._release_page_reps(pending)
|
||||
|
||||
def clear(self) -> None:
|
||||
self.full_attn_allocator.clear()
|
||||
self.mamba_allocator.clear()
|
||||
self.free_group = None
|
||||
self.free_page_reps_group = None
|
||||
|
||||
# -- Lazy compaction hooks --
|
||||
|
||||
def set_latest_forward_done_event(self, event: Optional[torch.cuda.Event]) -> None:
|
||||
@@ -2098,6 +2194,7 @@ class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
|
||||
)
|
||||
|
||||
self.free_group = None
|
||||
self.free_page_reps_group: Optional[List[torch.Tensor]] = None
|
||||
# Empty (not None) for the leak checker.
|
||||
self.free_pages = torch.empty(0, dtype=torch.int64, device=device)
|
||||
self.release_pages = torch.empty(0, dtype=torch.int64, device=device)
|
||||
@@ -2462,10 +2559,54 @@ class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
|
||||
# Paired with set_full_to_swa_mapping: shared mode has no mapping tensor.
|
||||
return
|
||||
|
||||
# -- free-group --
|
||||
|
||||
def free_group_begin(self) -> None:
|
||||
super().free_group_begin()
|
||||
self.free_page_reps_group = []
|
||||
|
||||
def free_group_end(self) -> None:
|
||||
pending, self.free_page_reps_group = self.free_page_reps_group, None
|
||||
super().free_group_end()
|
||||
if pending:
|
||||
self._release_page_reps(pending)
|
||||
|
||||
def free_segment(self, free_index: torch.Tensor, *, start_pos: int) -> None:
|
||||
"""Fixed-shape counterpart of `free()`; see
|
||||
`MultiEndedAllocator._page_reps_pieces`. Both sides share one
|
||||
derivation -- neither repeats the position-less dedup.
|
||||
"""
|
||||
if free_index is None or free_index.numel() == 0:
|
||||
return
|
||||
if self.page_size == 1:
|
||||
self.free(free_index)
|
||||
return
|
||||
pieces = self.full_attn_allocator._page_reps_pieces(
|
||||
free_index.detach().to(torch.int64), start_pos
|
||||
)
|
||||
if self.free_page_reps_group is None:
|
||||
self._release_page_reps(pieces)
|
||||
else:
|
||||
self.free_page_reps_group.extend(pieces)
|
||||
|
||||
def _release_page_reps(self, pieces: Sequence[torch.Tensor]) -> None:
|
||||
reps = pieces[0] if len(pieces) == 1 else torch.cat(tuple(pieces))
|
||||
v_pages = reps // self.page_size
|
||||
# Same tombstone filter as `free`, but at PAGE granularity (page_size
|
||||
# times smaller): `> 0` strict -- -1 = tombstoned, 0 = padding sink.
|
||||
swa_v2p_pages = self.swa_attn_allocator.virtual_to_physical[v_pages]
|
||||
live_pages = v_pages[swa_v2p_pages > 0]
|
||||
if live_pages.numel() > 0:
|
||||
self.swa_attn_allocator.free(live_pages * self.page_size, _pages=live_pages)
|
||||
self.full_attn_allocator.free(reps, _pages=v_pages)
|
||||
self.full_attn_allocator.clear_inverse_history()
|
||||
self.swa_attn_allocator.clear_inverse_history()
|
||||
|
||||
def clear(self) -> None:
|
||||
self.full_attn_allocator.clear()
|
||||
self.swa_attn_allocator.clear()
|
||||
self.free_group = None
|
||||
self.free_page_reps_group = None
|
||||
|
||||
# -- Lazy compaction hooks --
|
||||
|
||||
|
||||
@@ -209,6 +209,15 @@ class SWAKVPool(BaseSWAKVPool):
|
||||
else:
|
||||
return self.full_kv_pool.get_value_buffer(layer_id_pool)
|
||||
|
||||
def get_v_head_dim(self):
|
||||
# The FULL side's dim, as HybridLinearKVPool.get_v_head_dim(): a caller
|
||||
# asking a pool for "the" v_head_dim wants the full-attention geometry.
|
||||
# `start_layer`, not 0, so pipeline parallelism (start_layer > 0) works,
|
||||
# and because layer 0 need not be a full-attention layer.
|
||||
return self.full_kv_pool.get_value_buffer(self.full_kv_pool.start_layer).shape[
|
||||
-1
|
||||
]
|
||||
|
||||
def get_kv_buffer(self, layer_id: int):
|
||||
self._wait_for_layer(layer_id)
|
||||
layer_id_pool, is_swa_layer = self.layers_mapping[layer_id]
|
||||
|
||||
@@ -178,6 +178,74 @@ class SWAComponent(TreeComponent):
|
||||
full_indices
|
||||
)
|
||||
|
||||
def _unified_allocator(self):
|
||||
"""The unified SWA composite, or None when running on the static pool."""
|
||||
from sglang.srt.mem_cache.multi_ended_allocator import (
|
||||
UnifiedSWATokenToKVPoolAllocator,
|
||||
)
|
||||
|
||||
allocator = self.cache.token_to_kv_pool_allocator
|
||||
if isinstance(allocator, UnifiedSWATokenToKVPoolAllocator):
|
||||
return allocator
|
||||
return None
|
||||
|
||||
def _page_pairs(
|
||||
self, full_value: torch.Tensor, incoming_full_value: torch.Tensor
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Page ids of two token ranges that address the SAME logical tokens.
|
||||
|
||||
Dedupes by FIRST OCCURRENCE with one shared mask rather than
|
||||
`torch.unique`: unique sorts by id value, and allocation hands out
|
||||
virtual ids in no particular order, so sorting would pair page k of one
|
||||
range with an unrelated page of the other. One mask keeps the pairing
|
||||
positional, hence logical.
|
||||
"""
|
||||
page_size = self.tree_core.page_size
|
||||
kept = full_value.detach().to(torch.int64) // page_size
|
||||
incoming = incoming_full_value.detach().to(torch.int64) // page_size
|
||||
assert kept.numel() == incoming.numel(), (
|
||||
f"locked-full recovery needs a 1:1 token correspondence, got "
|
||||
f"{kept.numel()} kept vs {incoming.numel()} incoming"
|
||||
)
|
||||
starts = torch.ones_like(kept, dtype=torch.bool)
|
||||
starts[1:] = kept[1:] != kept[:-1]
|
||||
incoming_starts = torch.ones_like(incoming, dtype=torch.bool)
|
||||
incoming_starts[1:] = incoming[1:] != incoming[:-1]
|
||||
assert torch.equal(starts, incoming_starts), (
|
||||
"the two ranges break into pages at different offsets, so no "
|
||||
"page-granular ownership transfer expresses the token mapping"
|
||||
)
|
||||
return kept[starts], incoming[starts]
|
||||
|
||||
def _transfer_swa_pages(
|
||||
self,
|
||||
allocator,
|
||||
full_value: torch.Tensor,
|
||||
incoming_full_value: torch.Tensor,
|
||||
) -> None:
|
||||
"""Move swa page OWNERSHIP from the incoming ids onto the node's ids.
|
||||
|
||||
The static recipe re-points the node's locked full ids at the incoming
|
||||
swa pages through `full_to_swa_index_mapping`. Under the unified pool
|
||||
the swa sub-pool's v2p IS that mapping, so the same move is a rebind:
|
||||
give the node's virtual pages the incoming pages' physical pages, then
|
||||
tombstone the incoming ones. No page is allocated or freed, so no
|
||||
capacity changes — only ownership does.
|
||||
"""
|
||||
swa = allocator.swa_attn_allocator
|
||||
kept_pages, incoming_pages = self._page_pairs(full_value, incoming_full_value)
|
||||
physical = swa.virtual_to_physical[incoming_pages]
|
||||
# `> 0` strict: -1 = tombstoned, 0 = the padding sink. The incoming ids
|
||||
# were just allocated by the in-flight request, so every page must be
|
||||
# live; a violation means we would hand the node the sink and serve
|
||||
# zeros, which is worth a hard failure rather than silent corruption.
|
||||
assert bool(
|
||||
(physical > 0).all()
|
||||
), f"incoming swa pages must all be live, got {physical.tolist()}"
|
||||
swa.bind(kept_pages, physical)
|
||||
swa.virtual_to_physical.index_fill_(0, incoming_pages, -1)
|
||||
swa.clear_inverse_history()
|
||||
|
||||
def refresh_lru(
|
||||
self,
|
||||
phase: LRURefreshPhase,
|
||||
@@ -1280,8 +1348,24 @@ class SWAComponent(TreeComponent):
|
||||
alloc.set_full_to_swa_mapping(full, swa)
|
||||
return
|
||||
if isinstance(action, RecoverSWAWithLockedFull):
|
||||
# Keep the locked full; remap it onto the incoming full's SWA translation,
|
||||
# Keep the locked full; hand the node the INCOMING ids' swa pages,
|
||||
# freeing only the incoming full, then store the swa on the node.
|
||||
unified = self._unified_allocator()
|
||||
if unified is not None:
|
||||
# No `full_to_swa_index_mapping` here: the swa sub-pool's v2p IS
|
||||
# the mapping. Rebind page ownership, then free through the
|
||||
# composite -- its `swa_v2p_pages > 0` filter skips the
|
||||
# just-tombstoned swa side, releasing only the full one.
|
||||
self._transfer_swa_pages(
|
||||
unified, action.kept_full, action.incoming_full
|
||||
)
|
||||
unified.free(action.incoming_full)
|
||||
self.tree_core.set_component_device_value(
|
||||
action.node_id,
|
||||
self.component_type,
|
||||
self._translate_full_to_swa(action.kept_full),
|
||||
)
|
||||
return
|
||||
swa_value = self._translate_full_to_swa(action.incoming_full)
|
||||
alloc.set_full_to_swa_mapping(action.kept_full, swa_value)
|
||||
alloc.clear_full_to_swa_mapping(action.incoming_full)
|
||||
|
||||
Reference in New Issue
Block a user