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)
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# Copyright 2023-2026 SGLang Team
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ==============================================================================
|
||||
"""Inkling SConv kernels must accept a STRIDED (page-major / unified) conv-state.
|
||||
|
||||
Bug regression (fixed by relaxing 7 TensorMatcher sites): every conv-state cache
|
||||
matcher in ``kernels/jit/csrc/inkling/*.cuh`` used the bare
|
||||
``TensorMatcher({-1, W1s, D})`` form, whose default is a hard
|
||||
``view.is_contiguous()`` RuntimeCheck (``sgl_kernel/tensor.h``). The kernel
|
||||
BODIES are already stride-aware — they index via ``cache.stride(0)`` /
|
||||
``cache.stride(1)`` and only require the channel dim contiguous — so the matcher
|
||||
was strictly stronger than the kernel's real contract. Under the unified
|
||||
tri-pool the conv-state is served as a page-major envelope view (slot pitch
|
||||
spans all layers), which is non-contiguous, and the matcher rejection kills the
|
||||
forward.
|
||||
|
||||
The fix chains ``.with_strides({-1, -1, 1})``: slot/window strides wildcarded,
|
||||
channel stride pinned to 1 (the one contract the vectorized loads rely on).
|
||||
|
||||
Two layers of guard:
|
||||
|
||||
1. SOURCE SCAN (CPU, always runs — the portable red/green, same precedent as
|
||||
``test_unified_free_no_host_sync.py``): every ``.verify(cache)`` matcher in
|
||||
the inkling kernel sources must carry the stride relaxation. Fails the
|
||||
moment a site is reverted to the contiguity-default form or a new
|
||||
conv-state matcher lands without it.
|
||||
|
||||
2. FUNCTIONAL (CUDA + JIT, skipped elsewhere): drive the real
|
||||
``update_sconv_cache`` kernel with a page-major strided cache view; on
|
||||
pre-fix sources this raises ``Tensor is not contiguous as expected``;
|
||||
post-fix it must run AND be bit-identical to the same op on a contiguous
|
||||
clone.
|
||||
|
||||
python -m pytest test/registered/unit/mem_cache/test_inkling_sconv_strided_conv_state.py -v
|
||||
"""
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
|
||||
import sglang.kernels.jit as _jit_pkg
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
|
||||
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
|
||||
|
||||
_KERNEL_DIR = Path(_jit_pkg.__file__).parent / "csrc" / "inkling"
|
||||
|
||||
# The 7 known conv-state cache matcher sites (file -> expected count). A new
|
||||
# file/site is still caught: the scan sweeps every *.cuh, and any
|
||||
# `.verify(cache)` without the relaxation fails regardless of this table.
|
||||
_KNOWN_SITES = {
|
||||
"update_sconv_cache.cuh": 1,
|
||||
"causal_conv1d.cuh": 1,
|
||||
"draft_extend_sconv.cuh": 1,
|
||||
"fused_decode_update.cuh": 1,
|
||||
"gather_scatter_sconv.cuh": 1,
|
||||
"inkling_ar_fused_decode.cuh": 2,
|
||||
}
|
||||
|
||||
_RELAXATION = "with_strides({-1, -1, 1})"
|
||||
|
||||
|
||||
def _cache_matcher_lines():
|
||||
"""Every TensorMatcher line that verifies a tensor named `cache`."""
|
||||
hits = []
|
||||
for cuh in sorted(_KERNEL_DIR.glob("*.cuh")):
|
||||
for lineno, line in enumerate(cuh.read_text().splitlines(), 1):
|
||||
if "TensorMatcher" in line and re.search(r"\.verify\(cache\)", line):
|
||||
hits.append((cuh.name, lineno, line.strip()))
|
||||
return hits
|
||||
|
||||
|
||||
class TestConvStateMatchersAcceptStrided(unittest.TestCase):
|
||||
def test_every_cache_matcher_carries_the_stride_relaxation(self):
|
||||
hits = _cache_matcher_lines()
|
||||
bad = [(f, n, l) for f, n, l in hits if _RELAXATION not in l]
|
||||
self.assertEqual(
|
||||
bad,
|
||||
[],
|
||||
msg=(
|
||||
"conv-state cache matcher(s) without the stride relaxation "
|
||||
f"{_RELAXATION!r} — the TensorMatcher default enforces "
|
||||
"is_contiguous(), which rejects the unified/page-major "
|
||||
f"conv-state view the stride-aware kernel bodies accept: {bad}"
|
||||
),
|
||||
)
|
||||
|
||||
def test_all_known_sites_still_present(self):
|
||||
"""Completeness guard: the relaxation must not be 'fixed' by deleting
|
||||
the matcher (losing shape/dtype/device verification entirely)."""
|
||||
by_file = {}
|
||||
for f, _, _ in _cache_matcher_lines():
|
||||
by_file[f] = by_file.get(f, 0) + 1
|
||||
for fname, expected in _KNOWN_SITES.items():
|
||||
self.assertGreaterEqual(
|
||||
by_file.get(fname, 0),
|
||||
expected,
|
||||
msg=f"{fname}: conv-state matcher site(s) disappeared",
|
||||
)
|
||||
|
||||
def test_channel_dim_stays_pinned_contiguous(self):
|
||||
"""The relaxation must wildcard ONLY slot/window: a fully-wildcarded
|
||||
stride spec ({-1, -1, -1}) would drop the channel-contiguity contract
|
||||
the vectorized state loads rely on."""
|
||||
for f, n, line in _cache_matcher_lines():
|
||||
self.assertNotIn(
|
||||
"with_strides({-1, -1, -1})",
|
||||
line,
|
||||
msg=f"{f}:{n} wildcards the channel stride",
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipUnless(torch.cuda.is_available(), "needs CUDA + JIT for the real kernel")
|
||||
class TestUpdateSconvCacheStridedFunctional(unittest.TestCase):
|
||||
"""The real kernel on a page-major strided view == on a contiguous clone.
|
||||
|
||||
Red on pre-fix sources: the matcher raises
|
||||
'Tensor is not contiguous as expected' for the strided view.
|
||||
"""
|
||||
|
||||
_SLOTS, _LAYERS, _W1, _D = 4, 2, 3, 64
|
||||
|
||||
def _run(self, cache: torch.Tensor) -> torch.Tensor:
|
||||
from sglang.kernels.ops.mamba.inkling_sconv import update_sconv_cache
|
||||
|
||||
torch.manual_seed(0)
|
||||
dev = cache.device
|
||||
tokens = 10
|
||||
x = torch.randn(tokens, self._D, dtype=cache.dtype, device=dev)
|
||||
# 2 sequences: [0:6) -> slot 1 (has state), [6:10) -> slot 3 (fresh)
|
||||
cache_indices = torch.tensor([1, 3], dtype=torch.int32, device=dev)
|
||||
has_initial_state = torch.tensor([True, False], device=dev)
|
||||
query_start_loc = torch.tensor([0, 6, tokens], dtype=torch.int32, device=dev)
|
||||
update_sconv_cache(x, cache, cache_indices, has_initial_state, query_start_loc)
|
||||
return cache
|
||||
|
||||
def test_strided_view_matches_contiguous(self):
|
||||
dev = "cuda"
|
||||
torch.manual_seed(1)
|
||||
# Page-major envelope: (slots, LAYERS, W1, D); the per-layer view
|
||||
# cache = env[:, 1] has stride(0) = LAYERS*W1*D != W1*D -> non-contiguous.
|
||||
env = torch.randn(
|
||||
self._SLOTS,
|
||||
self._LAYERS,
|
||||
self._W1,
|
||||
self._D,
|
||||
dtype=torch.bfloat16,
|
||||
device=dev,
|
||||
)
|
||||
strided = env[:, 1]
|
||||
self.assertFalse(strided.is_contiguous(), "precondition: view is strided")
|
||||
contiguous = strided.clone()
|
||||
self.assertTrue(contiguous.is_contiguous())
|
||||
|
||||
out_c = self._run(contiguous)
|
||||
out_s = self._run(strided) # pre-fix: matcher rejection raises here
|
||||
|
||||
self.assertTrue(
|
||||
torch.equal(out_s, out_c),
|
||||
"strided-view kernel result differs from the contiguous reference",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,319 @@
|
||||
# Copyright 2023-2026 SGLang Team
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ==============================================================================
|
||||
"""Locked-full SWA tombstone-recovery under the unified pool (action handler).
|
||||
|
||||
`RecoverSWAWithLockedFull` recovers a tombstoned SWA node whose full value is
|
||||
LOCKED: the node cannot adopt the incoming request's ids wholesale, so the
|
||||
static-pool recipe hands the node the INCOMING ids' swa pages, frees only their
|
||||
FULL pages, and re-points the locked ids through `full_to_swa_index_mapping`.
|
||||
|
||||
The unified composite has no mapping tensor — the swa sub-pool's v2p IS the
|
||||
mapping — and its `set_full_to_swa_mapping` is an explicit no-op stub. The
|
||||
pre-fix handler therefore raised AttributeError on `full_to_swa_index_mapping`
|
||||
(and, had that line been removed, would have silently skipped the rebind while
|
||||
line 1 freed swa pages the kept ids still referenced). The fix expresses the
|
||||
same move as a page-ownership REBIND: bind the node's virtual pages to the
|
||||
incoming pages' physical pages, tombstone the incoming ones, then free the
|
||||
incoming ids through the composite — whose `swa_v2p_pages > 0` filter skips the
|
||||
tombstoned swa side, releasing ONLY the full side.
|
||||
|
||||
Why the recovery must succeed rather than decline (the v1 lesson, still true on
|
||||
this branch): the TreeCore insert walk counts the node in `prefix_len`
|
||||
regardless of component consumption, while the SWA match validator rejects a
|
||||
`value is None` node — a declined recovery makes `insert` report a prefix the
|
||||
follow-up `match_prefix` cannot honor, tripping
|
||||
`new_prefix_len <= len(new_indices)` in `cache_unfinished_req`.
|
||||
|
||||
python -m pytest test/registered/unit/mem_cache/test_swa_locked_full_recover_unified.py -v
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
import torch
|
||||
from test_multi_ended_allocator import _FakeUnifiedSWAKVPool # sibling fixture
|
||||
|
||||
from sglang.srt.mem_cache.multi_ended_allocator import UnifiedSWATokenToKVPoolAllocator
|
||||
from sglang.srt.mem_cache.unified_cache.cache_action import RecoverSWAWithLockedFull
|
||||
from sglang.srt.mem_cache.unified_cache.component_type import ComponentType
|
||||
from sglang.srt.mem_cache.unified_cache.components.swa_component import SWAComponent
|
||||
from sglang.srt.mem_cache.unified_memory_pool import MHASubPoolSpec, UnifiedKVPool
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
|
||||
register_cpu_ci(est_time=20, suite="base-a-test-cpu")
|
||||
|
||||
_DEV = "cpu"
|
||||
_SWA = ComponentType.SWA
|
||||
|
||||
|
||||
def _build_swa_composite(n_full=64, n_swa=64):
|
||||
full_spec = MHASubPoolSpec(
|
||||
name="full",
|
||||
layer_num=4,
|
||||
head_num=2,
|
||||
head_dim=4,
|
||||
store_dtype=torch.float16,
|
||||
grow_direction="up",
|
||||
)
|
||||
swa_spec = MHASubPoolSpec(
|
||||
name="swa",
|
||||
layer_num=2,
|
||||
head_num=2,
|
||||
head_dim=4,
|
||||
store_dtype=torch.float16,
|
||||
grow_direction="down",
|
||||
)
|
||||
total = n_full * full_spec.entry_bytes() + n_swa * swa_spec.entry_bytes()
|
||||
pool = UnifiedKVPool(
|
||||
total_bytes=total,
|
||||
sub_pool_specs=[full_spec, swa_spec],
|
||||
device=_DEV,
|
||||
enable_memory_saver=False,
|
||||
)
|
||||
kvcache = _FakeUnifiedSWAKVPool(pool)
|
||||
allocator = UnifiedSWATokenToKVPoolAllocator(
|
||||
unified_buffer=pool,
|
||||
kvcache=kvcache,
|
||||
device=_DEV,
|
||||
full_max_total_num_tokens=n_full,
|
||||
swa_max_total_num_tokens=n_swa,
|
||||
need_sort=False,
|
||||
forward_stream=None,
|
||||
)
|
||||
return allocator
|
||||
|
||||
|
||||
class _StubTreeCore:
|
||||
"""Just what the handler touches: page_size + the device-value setter."""
|
||||
|
||||
def __init__(self, page_size=1):
|
||||
self.page_size = page_size
|
||||
self.set_calls = []
|
||||
|
||||
def set_component_device_value(self, node_id, component_type, value):
|
||||
self.set_calls.append((node_id, component_type, value))
|
||||
|
||||
|
||||
class _Cache:
|
||||
def __init__(self, allocator):
|
||||
self.token_to_kv_pool_allocator = allocator
|
||||
|
||||
|
||||
class _Probe(SWAComponent):
|
||||
"""SWAComponent wired to the real allocator and stub tree core."""
|
||||
|
||||
def __init__(self, allocator):
|
||||
self.cache = _Cache(allocator)
|
||||
self.tree_core = _StubTreeCore()
|
||||
|
||||
|
||||
class _StaticAllocRecorder:
|
||||
"""Stands in for the STATIC SWATokenToKVPoolAllocator: has the mapping
|
||||
tensor and a real set_full_to_swa_mapping. The handler must keep routing
|
||||
static pools through the original recipe."""
|
||||
|
||||
def __init__(self, n=16):
|
||||
self.full_to_swa_index_mapping = torch.arange(n, dtype=torch.int64)
|
||||
self.mapping_calls = []
|
||||
self.clear_calls = []
|
||||
self.freed_full = []
|
||||
self.freed_via_inner = []
|
||||
self.full_attn_allocator = self
|
||||
|
||||
def set_full_to_swa_mapping(self, full, swa):
|
||||
# Honour the write like the real static allocator: the handler routes
|
||||
# every mapping write THROUGH the API (never by indexing the tensor),
|
||||
# so the fake must apply it for the mapping asserts to observe it.
|
||||
self.mapping_calls.append((full, swa))
|
||||
self.full_to_swa_index_mapping[full.to(torch.int64)] = swa.to(torch.int64)
|
||||
|
||||
def clear_full_to_swa_mapping(self, full):
|
||||
self.clear_calls.append(full)
|
||||
self.full_to_swa_index_mapping[full.to(torch.int64)] = 0
|
||||
|
||||
def free_full(self, indices):
|
||||
self.freed_full.append(indices)
|
||||
|
||||
def free(self, indices):
|
||||
# The handler must not reach the inner allocator: that skips the
|
||||
# free-group defer.
|
||||
self.freed_via_inner.append(indices)
|
||||
|
||||
def translate_loc_from_full_to_swa(self, full_indices):
|
||||
return self.full_to_swa_index_mapping[full_indices.to(torch.int64)]
|
||||
|
||||
|
||||
class _RecoverTestBase(unittest.TestCase):
|
||||
def _probe(self):
|
||||
allocator = _build_swa_composite()
|
||||
self.assertIsInstance(allocator, UnifiedSWATokenToKVPoolAllocator)
|
||||
return _Probe(allocator), allocator
|
||||
|
||||
def _two_ranges(self, allocator, n=4):
|
||||
kept = allocator.alloc(n)
|
||||
incoming = allocator.alloc(n)
|
||||
self.assertIsNotNone(kept)
|
||||
self.assertIsNotNone(incoming)
|
||||
return kept, incoming
|
||||
|
||||
|
||||
class TestPagePairing(_RecoverTestBase):
|
||||
def test_pairs_positionally_not_by_sorted_id(self):
|
||||
"""Allocation hands out virtual ids in no particular order; deduping
|
||||
with `torch.unique` (which sorts) would bind the node's page k to an
|
||||
unrelated incoming page — silent wrong-KV."""
|
||||
probe, _ = self._probe()
|
||||
kept = torch.tensor([9, 7, 5], dtype=torch.int64) # descending
|
||||
incoming = torch.tensor([2, 4, 6], dtype=torch.int64) # ascending
|
||||
kept_pages, incoming_pages = probe._page_pairs(kept, incoming)
|
||||
self.assertEqual(kept_pages.tolist(), [9, 7, 5])
|
||||
self.assertEqual(incoming_pages.tolist(), [2, 4, 6])
|
||||
|
||||
def test_length_mismatch_is_rejected(self):
|
||||
probe, _ = self._probe()
|
||||
with self.assertRaises(AssertionError):
|
||||
probe._page_pairs(
|
||||
torch.tensor([1, 2, 3], dtype=torch.int64),
|
||||
torch.tensor([4, 5], dtype=torch.int64),
|
||||
)
|
||||
|
||||
|
||||
class TestOwnershipTransfer(_RecoverTestBase):
|
||||
def test_node_ids_end_up_owning_the_incoming_physical_pages(self):
|
||||
probe, allocator = self._probe()
|
||||
swa = allocator.swa_attn_allocator
|
||||
kept, incoming = self._two_ranges(allocator)
|
||||
donated = swa.virtual_to_physical[incoming.to(torch.int64)].clone()
|
||||
|
||||
probe._transfer_swa_pages(allocator, kept, incoming)
|
||||
|
||||
self.assertEqual(
|
||||
swa.virtual_to_physical[kept.to(torch.int64)].tolist(),
|
||||
donated.tolist(),
|
||||
"the node's ids must now resolve to the donated physical pages",
|
||||
)
|
||||
self.assertTrue(
|
||||
bool((swa.virtual_to_physical[incoming.to(torch.int64)] == -1).all()),
|
||||
"the incoming ids' swa side must be tombstoned",
|
||||
)
|
||||
self.assertEqual(
|
||||
swa.physical_to_virtual[donated].tolist(),
|
||||
kept.to(torch.int64).tolist(),
|
||||
"the inverse map must follow, or a later free credits the wrong id",
|
||||
)
|
||||
|
||||
def test_sink_or_dead_donor_fails_loud(self):
|
||||
"""Handing the node the padding sink would serve zeros; refuse."""
|
||||
probe, allocator = self._probe()
|
||||
kept, incoming = self._two_ranges(allocator)
|
||||
allocator.free_swa(incoming) # donor no longer owns anything
|
||||
with self.assertRaises(AssertionError):
|
||||
probe._transfer_swa_pages(allocator, kept, incoming)
|
||||
|
||||
|
||||
class TestRecoverActionHandler(_RecoverTestBase):
|
||||
def test_recovery_sets_a_live_device_value_and_frees_only_the_full_side(self):
|
||||
"""End-to-end through apply_component_action — the pre-fix handler
|
||||
raises AttributeError (`full_to_swa_index_mapping`) on this exact
|
||||
call. Post-fix: the node gets a LIVE swa value, the HANDLER neither
|
||||
allocates nor frees any swa page (ownership only moves), and the
|
||||
incoming ids' FULL side returns to the pool."""
|
||||
probe, allocator = self._probe()
|
||||
swa = allocator.swa_attn_allocator
|
||||
kept, incoming = self._two_ranges(allocator)
|
||||
allocator.free_swa(kept) # what eviction does when it tombstones
|
||||
# Snapshot AFTER the setup traffic: the invariant under test is that
|
||||
# the recovery handler itself moves ownership without moving capacity.
|
||||
swa_live = swa.allocated_count()
|
||||
full_avail = allocator.full_attn_allocator.available_size()
|
||||
|
||||
probe.apply_component_action(
|
||||
RecoverSWAWithLockedFull(node_id=7, kept_full=kept, incoming_full=incoming)
|
||||
)
|
||||
|
||||
((node_id, ct, value),) = probe.tree_core.set_calls
|
||||
self.assertEqual((node_id, ct), (7, _SWA))
|
||||
self.assertEqual(len(value), len(kept))
|
||||
self.assertTrue(
|
||||
bool((value > 0).all()),
|
||||
"recovered value must address live swa pages, not the sink",
|
||||
)
|
||||
self.assertEqual(
|
||||
swa.allocated_count(),
|
||||
swa_live,
|
||||
"no swa page may be released or gained — ownership only moved",
|
||||
)
|
||||
self.assertEqual(
|
||||
allocator.full_attn_allocator.available_size(),
|
||||
full_avail + len(incoming),
|
||||
"the incoming ids' FULL side must come back",
|
||||
)
|
||||
|
||||
def test_recovered_ids_translate_to_live_pages_not_the_sink(self):
|
||||
"""The tombstoned range translates to the clamped sink before the
|
||||
recovery and to real pages after — recovering from the node's OWN
|
||||
already-freed ids (instead of the donated ones) reintroduces the sink."""
|
||||
probe, allocator = self._probe()
|
||||
kept, incoming = self._two_ranges(allocator)
|
||||
allocator.free_swa(kept)
|
||||
self.assertTrue(
|
||||
bool((allocator.translate_loc_from_full_to_swa(kept) == 0).all()),
|
||||
"precondition: a tombstoned range translates to the sink",
|
||||
)
|
||||
probe.apply_component_action(
|
||||
RecoverSWAWithLockedFull(node_id=1, kept_full=kept, incoming_full=incoming)
|
||||
)
|
||||
self.assertTrue(
|
||||
bool((allocator.translate_loc_from_full_to_swa(kept) > 0).all()),
|
||||
"after recovery the node's ids must address live swa pages",
|
||||
)
|
||||
|
||||
|
||||
class TestStaticPoolPathUnchanged(unittest.TestCase):
|
||||
def test_static_allocator_keeps_the_mapping_recipe(self):
|
||||
"""A static SWA allocator (has the mapping tensor) must keep the
|
||||
original recipe — the unified branch must not hijack it."""
|
||||
static = _StaticAllocRecorder()
|
||||
probe = _Probe.__new__(_Probe)
|
||||
probe.cache = _Cache(static)
|
||||
probe.tree_core = _StubTreeCore()
|
||||
|
||||
kept = torch.tensor([1, 2], dtype=torch.int64)
|
||||
incoming = torch.tensor([5, 6], dtype=torch.int64)
|
||||
probe.apply_component_action(
|
||||
RecoverSWAWithLockedFull(node_id=3, kept_full=kept, incoming_full=incoming)
|
||||
)
|
||||
|
||||
# Both mapping writes go through the allocator API -- the kept remap
|
||||
# via set_full_to_swa_mapping, the incoming tombstone via
|
||||
# clear_full_to_swa_mapping -- never by indexing
|
||||
# `full_to_swa_index_mapping` (the tensor is absent on the unified
|
||||
# composite by design).
|
||||
self.assertEqual(len(static.mapping_calls), 1, "static recipe must run")
|
||||
self.assertEqual(len(static.clear_calls), 1, "incoming must be tombstoned")
|
||||
self.assertTrue(
|
||||
bool(
|
||||
(static.full_to_swa_index_mapping[incoming.to(torch.int64)] == 0).all()
|
||||
),
|
||||
"incoming ids' mapping entries must be zeroed (static recipe)",
|
||||
)
|
||||
# Through free_full, not the inner allocator: the latter skips the
|
||||
# free-group defer.
|
||||
self.assertEqual(len(static.freed_full), 1)
|
||||
self.assertEqual(static.freed_via_inner, [])
|
||||
((node_id, ct, _),) = probe.tree_core.set_calls
|
||||
self.assertEqual((node_id, ct), (3, _SWA))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,124 @@
|
||||
# Copyright 2023-2026 SGLang Team
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ==============================================================================
|
||||
"""`SWAKVPool.get_v_head_dim()` — the pool method a mambaish + hybrid-SWA
|
||||
model reaches on boot.
|
||||
|
||||
`TritonAttnBackend.__init__` picks its `v_head_dim` from one of three
|
||||
branches, and the middle one asks the POOL:
|
||||
|
||||
if sliding_window_size is not None and swa_v_head_dim != v_head_dim:
|
||||
... from model_config ... # asymmetric hybrid SWA
|
||||
elif mambaish_config(model_config) is not None:
|
||||
v_head_dim = token_to_kv_pool.get_v_head_dim() # <-- this one
|
||||
else:
|
||||
... from get_value_buffer(start_layer) ...
|
||||
|
||||
A model that is BOTH mambaish AND hybrid-SWA with MATCHING full/SWA value
|
||||
head dims (Inkling-class) skips the first branch and lands in the second —
|
||||
where its pool is an SWA-shaped pool, which had no `get_v_head_dim`. The
|
||||
server died at backend construction with
|
||||
|
||||
AttributeError: 'SWAKVPool' object has no attribute 'get_v_head_dim'
|
||||
|
||||
on the STATIC pool and, identically, on `UnifiedSWAKVPool`. Neither the
|
||||
mamba-hybrid pools (`HybridLinearKVPool` has the method) nor pure hybrid-SWA
|
||||
models (not mambaish, so the branch is never taken) can reach it, which is
|
||||
why it went unnoticed.
|
||||
|
||||
python -m pytest test/registered/unit/mem_cache/test_swa_pool_v_head_dim.py -v
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import unittest
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.mem_cache.memory_pool import HybridLinearKVPool
|
||||
from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool
|
||||
from sglang.srt.mem_cache.unified_memory_pool import UnifiedSWAKVPool
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
|
||||
register_cpu_ci(est_time=15, suite="base-a-test-cpu")
|
||||
|
||||
_DEV = "cpu"
|
||||
_FULL_V_HEAD_DIM = 8
|
||||
_SWA_V_HEAD_DIM = 8 # MATCHING — this is what routes Inkling into the branch
|
||||
|
||||
|
||||
def _swa_pool():
|
||||
"""A static SWAKVPool with the Inkling-class layer split: full and SWA
|
||||
layers interleaved, layer 0 NOT a full-attention layer (which is exactly
|
||||
why the backend asks the pool instead of indexing layer 0)."""
|
||||
return SWAKVPool(
|
||||
size=32,
|
||||
size_swa=16,
|
||||
page_size=1,
|
||||
dtype=torch.float16,
|
||||
head_num=2,
|
||||
head_dim=_FULL_V_HEAD_DIM,
|
||||
swa_attention_layer_ids=[0, 2],
|
||||
full_attention_layer_ids=[1, 3],
|
||||
device=_DEV,
|
||||
enable_memory_saver=False,
|
||||
)
|
||||
|
||||
|
||||
class TestSWAPoolVHeadDim(unittest.TestCase):
|
||||
def test_static_pool_reports_the_full_side_value_head_dim(self):
|
||||
"""Red before the fix with AttributeError; the value must be the FULL
|
||||
side's, since that is the geometry the caller means."""
|
||||
pool = _swa_pool()
|
||||
self.assertEqual(pool.get_v_head_dim(), _FULL_V_HEAD_DIM)
|
||||
|
||||
def test_answer_matches_the_full_pool_buffer_not_layer_zero(self):
|
||||
"""Layer 0 is an SWA layer here, so a naive `get_value_buffer(0)`
|
||||
would read the SWA side. Pin that the method routes through the FULL
|
||||
sub-pool at its own start_layer — the property that makes it correct
|
||||
under pipeline parallelism too."""
|
||||
pool = _swa_pool()
|
||||
want = pool.full_kv_pool.get_value_buffer(pool.full_kv_pool.start_layer).shape[
|
||||
-1
|
||||
]
|
||||
self.assertEqual(pool.get_v_head_dim(), want)
|
||||
# And layer 0 really is the SWA side in this fixture.
|
||||
_, is_swa = pool.layers_mapping[0]
|
||||
self.assertTrue(is_swa, "fixture must keep layer 0 on the SWA side")
|
||||
|
||||
def test_unified_swa_pool_inherits_it(self):
|
||||
"""`UnifiedSWAKVPool` subclasses `SWAKVPool`, so the unified tri-pool
|
||||
path (mambaish + hybrid SWA in one buffer) is covered by the same
|
||||
method — no second implementation to drift."""
|
||||
self.assertTrue(issubclass(UnifiedSWAKVPool, SWAKVPool))
|
||||
self.assertIs(
|
||||
UnifiedSWAKVPool.get_v_head_dim,
|
||||
SWAKVPool.get_v_head_dim,
|
||||
"the unified pool must inherit the method, not shadow it",
|
||||
)
|
||||
|
||||
def test_signature_matches_the_hybrid_linear_precedent(self):
|
||||
"""The backend calls this method on whichever pool it holds, so every
|
||||
pool reachable from the mambaish branch must expose the SAME
|
||||
zero-argument shape. `HybridLinearKVPool` is the precedent this one
|
||||
mirrors; a future pool added to that branch has to match too."""
|
||||
for cls in (SWAKVPool, HybridLinearKVPool):
|
||||
sig = inspect.signature(cls.get_v_head_dim)
|
||||
self.assertEqual(
|
||||
[p for p in sig.parameters if p != "self"],
|
||||
[],
|
||||
f"{cls.__name__}.get_v_head_dim must take no arguments",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,306 @@
|
||||
# Copyright 2023-2026 SGLang Team
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ==============================================================================
|
||||
"""The unified free path must not move anything across the PCIe bus.
|
||||
|
||||
Two independent host syncs lived in `MultiEndedAllocator`'s free path:
|
||||
|
||||
1. Tombstone scatters written as ``t[idx] = -1``. The scalar RHS makes torch
|
||||
materialise ``-1`` as a CPU tensor and copy it H2D, and a pageable H2D
|
||||
copy BLOCKS the host until the stream drains. Invisible on decode-shaped
|
||||
work; ~16 ms per free behind an 8192-token prefill.
|
||||
|
||||
2. `torch.unique` recovering distinct PAGE ids from freed TOKEN ids. Its
|
||||
output shape is data-dependent, so it must D2H the count
|
||||
(``_unique2 -> item -> _local_scalar_dense -> cudaStreamSynchronize``).
|
||||
`PagedTokenToKVPoolAllocator` already solved this with `free_segment`:
|
||||
a page's tokens sit consecutively in the kv row, so given `start_pos` the
|
||||
page representatives are stride slices. The unified allocators simply
|
||||
never implemented it and so were permanently on the syncing path.
|
||||
|
||||
These tests mirror `test_paged_free_segment.py` -- the same sweep against the
|
||||
`torch.unique` reference, the same free-group deferral -- because the unified
|
||||
allocators now mirror that allocator's design rather than a parallel one.
|
||||
|
||||
python -m pytest test/registered/unit/mem_cache/test_unified_free_no_host_sync.py -v
|
||||
"""
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
import textwrap
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import torch
|
||||
from test_multi_ended_allocator import TestPagedMultiEndedAllocator as _PagedFixture
|
||||
|
||||
from sglang.srt.mem_cache import multi_ended_allocator as mea
|
||||
from sglang.srt.mem_cache.allocator.base import BaseTokenToKVPoolAllocator
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
|
||||
register_cpu_ci(est_time=20, suite="base-a-test-cpu")
|
||||
|
||||
PAGE_SIZE = _PagedFixture.PAGE_SIZE
|
||||
|
||||
|
||||
def _paged_allocator(lazy: bool):
|
||||
"""A real paged `MultiEndedAllocator` from the sibling fixture."""
|
||||
inst = _PagedFixture([m for m in dir(_PagedFixture) if m.startswith("test_")][0])
|
||||
_pool, full, _swa, _fkv, _skv = inst._build()
|
||||
full.lazy_compaction = lazy
|
||||
return full
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. tombstone scatters
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_TOMBSTONE_METHODS = [
|
||||
(mea.MultiEndedAllocator, "_free_lazy"),
|
||||
(mea.MultiEndedAllocator, "free"),
|
||||
(mea.MultiEndedAllocator, "_commit_move_batch"),
|
||||
]
|
||||
_TABLES = {"virtual_to_physical", "physical_to_virtual"}
|
||||
|
||||
|
||||
def _scalar_index_assignments(fn):
|
||||
"""`self.<table>[<tensor idx>] = <scalar>` occurrences in fn's source.
|
||||
|
||||
Slice assignments (``t[a:b] = -1``) are excluded: a slice is a view, so the
|
||||
fill needs no index tensor. Tensor-valued scatters are excluded too -- only
|
||||
the scalar RHS materialises a CPU value tensor.
|
||||
"""
|
||||
|
||||
def _is_scalar_literal(node):
|
||||
# NOTE: `-1` parses as UnaryOp(USub, Constant(1)), NOT Constant. Testing
|
||||
# only for Constant silently skips every negative literal -- i.e. every
|
||||
# tombstone this scan exists to find.
|
||||
if isinstance(node, ast.Constant):
|
||||
return True
|
||||
return isinstance(node, ast.UnaryOp) and isinstance(node.operand, ast.Constant)
|
||||
|
||||
tree = ast.parse(textwrap.dedent(inspect.getsource(fn)))
|
||||
bad = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Assign) or not _is_scalar_literal(node.value):
|
||||
continue
|
||||
for tgt in node.targets:
|
||||
if not isinstance(tgt, ast.Subscript):
|
||||
continue
|
||||
val = tgt.value
|
||||
if not (isinstance(val, ast.Attribute) and val.attr in _TABLES):
|
||||
continue
|
||||
if isinstance(tgt.slice, ast.Slice):
|
||||
continue
|
||||
bad.append(ast.unparse(node))
|
||||
return bad
|
||||
|
||||
|
||||
class TestTombstonesDoNotCrossTheBus(unittest.TestCase):
|
||||
def test_no_scalar_index_assignment(self):
|
||||
for cls, name in _TOMBSTONE_METHODS:
|
||||
with self.subTest(method=f"{cls.__name__}.{name}"):
|
||||
bad = _scalar_index_assignments(getattr(cls, name))
|
||||
self.assertEqual(
|
||||
bad,
|
||||
[],
|
||||
msg=(
|
||||
f"{cls.__name__}.{name} writes a tombstone with a scalar "
|
||||
f"RHS: {bad}. That materialises -1 as a CPU tensor and "
|
||||
f"copies it H2D, blocking the scheduler thread until the "
|
||||
f"stream drains. Use `.index_fill_(0, idx, -1)`."
|
||||
),
|
||||
)
|
||||
|
||||
def test_the_scan_detects_the_scalar_form_it_guards(self):
|
||||
"""Self-check. The scan is only as good as its AST matching, and it
|
||||
silently missed every tombstone until `-1` was recognised as
|
||||
UnaryOp(USub, Constant) rather than Constant. Pin that."""
|
||||
|
||||
def _offender(self):
|
||||
self.virtual_to_physical[free_v_pages] = -1 # noqa: F821
|
||||
|
||||
self.assertEqual(len(_scalar_index_assignments(_offender)), 1)
|
||||
|
||||
def test_free_paths_actually_use_index_fill(self):
|
||||
"""Positive form, so deleting the scatter entirely cannot pass."""
|
||||
for cls, name in _TOMBSTONE_METHODS:
|
||||
with self.subTest(method=f"{cls.__name__}.{name}"):
|
||||
self.assertIn("index_fill_", inspect.getsource(getattr(cls, name)))
|
||||
|
||||
def test_index_fill_matches_scalar_assign_semantics(self):
|
||||
"""Behaviour-preserving, including the edge cases the free path hands
|
||||
it: empty index, duplicate pages, full table."""
|
||||
for idx in (
|
||||
torch.tensor([], dtype=torch.int64),
|
||||
torch.tensor([1, 3, 5], dtype=torch.int64),
|
||||
torch.tensor([2, 2, 3], dtype=torch.int64), # duplicates
|
||||
torch.arange(6, dtype=torch.int64),
|
||||
):
|
||||
with self.subTest(n=int(idx.numel())):
|
||||
a = torch.arange(6, dtype=torch.int64)
|
||||
b = a.clone()
|
||||
a[idx] = -1
|
||||
b.index_fill_(0, idx, -1)
|
||||
self.assertTrue(torch.equal(a, b))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. free_segment: stride page extraction instead of torch.unique
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFreeSegment(unittest.TestCase):
|
||||
"""Mirrors `test_paged_free_segment.TestFreeSegment`."""
|
||||
|
||||
def test_matches_unique_over_alignments(self):
|
||||
"""Sweep (start, end) so segments cover aligned/unaligned head and
|
||||
tail, a single partial page, and the full row."""
|
||||
for num_tokens in (1, PAGE_SIZE, PAGE_SIZE + 1, 3 * PAGE_SIZE - 1):
|
||||
for start in range(0, num_tokens, max(1, num_tokens // 4)):
|
||||
for end in (start + 1, num_tokens):
|
||||
if end <= start:
|
||||
continue
|
||||
alloc = _paged_allocator(lazy=True)
|
||||
row = alloc.alloc(3 * PAGE_SIZE)
|
||||
seg = row[start:end]
|
||||
expected = torch.unique(seg // PAGE_SIZE)
|
||||
alloc.free_segment(seg, start_pos=start)
|
||||
freed = torch.sort(alloc._free_phys_pages)[0]
|
||||
with self.subTest(n=num_tokens, start=start, end=end):
|
||||
# v2p is identity-ish here, so freed physical pages map
|
||||
# 1:1 onto the expected virtual pages.
|
||||
self.assertEqual(freed.numel(), expected.numel())
|
||||
|
||||
def test_never_calls_unique(self):
|
||||
"""The decisive check -- make `torch.unique` explode. A textual guard
|
||||
can be fooled; this cannot."""
|
||||
for start in (0, 1, PAGE_SIZE - 1, PAGE_SIZE, PAGE_SIZE + 3):
|
||||
alloc = _paged_allocator(lazy=True)
|
||||
row = alloc.alloc(3 * PAGE_SIZE)
|
||||
with self.subTest(start_pos=start):
|
||||
with mock.patch.object(
|
||||
torch, "unique", side_effect=AssertionError("sync path taken")
|
||||
):
|
||||
alloc.free_segment(row[start : start + PAGE_SIZE], start_pos=start)
|
||||
|
||||
def test_empty_segment_is_noop(self):
|
||||
alloc = _paged_allocator(lazy=True)
|
||||
before = alloc._free_phys_pages.numel()
|
||||
alloc.free_segment(torch.empty(0, dtype=torch.int64), start_pos=0)
|
||||
self.assertEqual(alloc._free_phys_pages.numel(), before)
|
||||
|
||||
def test_page_size_one_takes_the_plain_path(self):
|
||||
"""token == page: nothing to dedup, so `free_segment` must not invent
|
||||
a stride slice that would drop tokens."""
|
||||
alloc = _paged_allocator(lazy=True)
|
||||
alloc.page_size = 1
|
||||
v = alloc.alloc(PAGE_SIZE)
|
||||
n = v.numel()
|
||||
alloc.free_segment(v, start_pos=0)
|
||||
self.assertEqual(alloc._free_phys_pages.numel(), n)
|
||||
|
||||
|
||||
class TestFreeGroupKeepsPositions(unittest.TestCase):
|
||||
"""Mirrors `test_paged_free_segment.test_group_defers_until_group_end`.
|
||||
|
||||
Bug regression: buffering RAW tokens and `torch.cat`-ing them at
|
||||
`free_group_end` destroys each segment's shape, so the merged tensor has no
|
||||
recoverable page structure and falls back to `torch.unique`. Measured as 71
|
||||
of 77 `_free_lazy` calls still syncing on gpt-oss and Qwen3.5 ps=256
|
||||
(eval_429), all attributed to `free_group_end` via the decode path. The fix
|
||||
buffers page REPRESENTATIVES, so the merge concatenates page ids.
|
||||
"""
|
||||
|
||||
def test_group_defers_until_group_end(self):
|
||||
alloc = _paged_allocator(lazy=True)
|
||||
row = alloc.alloc(2 * PAGE_SIZE)
|
||||
before = alloc._free_phys_pages.numel()
|
||||
alloc.free_group_begin()
|
||||
alloc.free_segment(row, start_pos=0)
|
||||
self.assertEqual(
|
||||
alloc._free_phys_pages.numel(), before, "must defer inside the group"
|
||||
)
|
||||
alloc.free_group_end()
|
||||
self.assertEqual(alloc._free_phys_pages.numel(), before + 2)
|
||||
|
||||
def test_group_end_does_not_sync(self):
|
||||
"""The property the whole fix exists for: a grouped segment free must
|
||||
complete with `torch.unique` disabled."""
|
||||
alloc = _paged_allocator(lazy=True)
|
||||
row = alloc.alloc(3 * PAGE_SIZE)
|
||||
alloc.free_group_begin()
|
||||
alloc.free_segment(row[:PAGE_SIZE], start_pos=0)
|
||||
alloc.free_segment(
|
||||
row[PAGE_SIZE + 3 : 2 * PAGE_SIZE + 3], start_pos=PAGE_SIZE + 3
|
||||
)
|
||||
with mock.patch.object(
|
||||
torch, "unique", side_effect=AssertionError("sync path taken")
|
||||
):
|
||||
alloc.free_group_end()
|
||||
self.assertGreater(alloc._free_phys_pages.numel(), 0)
|
||||
|
||||
def test_positionless_group_still_uses_the_unique_path(self):
|
||||
"""Plain `free()` inside a group has no position to keep, so it must
|
||||
still go through the (syncing) dedup -- correctness over speed."""
|
||||
alloc = _paged_allocator(lazy=True)
|
||||
row = alloc.alloc(2 * PAGE_SIZE)
|
||||
alloc.free_group_begin()
|
||||
alloc.free(row)
|
||||
with self.assertRaises(AssertionError):
|
||||
with mock.patch.object(
|
||||
torch, "unique", side_effect=AssertionError("expected")
|
||||
):
|
||||
alloc.free_group_end()
|
||||
|
||||
|
||||
class TestEveryUnifiedAllocatorOverridesFreeSegment(unittest.TestCase):
|
||||
"""Completeness guard. The base `free_segment` DISCARDS `start_pos` and
|
||||
calls plain `free`, so an allocator that inherits it sends every segment
|
||||
free into the syncing dedup -- silently, with no error and no wrong answer,
|
||||
just a stalled scheduler thread. That is exactly what happened: the SWA
|
||||
composite was overridden and the Mamba composite was not, and 77 of 77
|
||||
`_free_lazy` calls on Qwen3.5 ps=256 still synced (eval_428).
|
||||
"""
|
||||
|
||||
def test_all_overridden(self):
|
||||
for cls in (
|
||||
mea.MultiEndedAllocator,
|
||||
mea.UnifiedMambaTokenToKVPoolAllocator,
|
||||
mea.UnifiedSWATokenToKVPoolAllocator,
|
||||
):
|
||||
with self.subTest(cls=cls.__name__):
|
||||
self.assertIsNot(
|
||||
cls.free_segment,
|
||||
BaseTokenToKVPoolAllocator.free_segment,
|
||||
msg=(
|
||||
f"{cls.__name__} inherits the base `free_segment`, which "
|
||||
f"discards `start_pos` -- every segment free will take the "
|
||||
f"host-syncing dedup."
|
||||
),
|
||||
)
|
||||
|
||||
def test_composites_buffer_reps_not_tokens_in_a_group(self):
|
||||
"""The group buffer must exist on every allocator that can receive a
|
||||
segment free, or `free_segment` raises inside a group."""
|
||||
for cls in (
|
||||
mea.MultiEndedAllocator,
|
||||
mea.UnifiedMambaTokenToKVPoolAllocator,
|
||||
mea.UnifiedSWATokenToKVPoolAllocator,
|
||||
):
|
||||
with self.subTest(cls=cls.__name__):
|
||||
self.assertIn("free_page_reps_group", inspect.getsource(cls))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -7656,6 +7656,9 @@ class TestUnifiedRadixCacheActionRouting(CustomTestCase):
|
||||
# the incoming full's stale mapping is cleared, then its slot freed (full-only)
|
||||
alloc.clear_full_to_swa_mapping.assert_called_once_with(incoming_full)
|
||||
alloc.free_full.assert_called_once_with(incoming_full)
|
||||
# Never by indexing the tensor: the unified composite has no
|
||||
# `full_to_swa_index_mapping` to index into.
|
||||
alloc.full_to_swa_index_mapping.__setitem__.assert_not_called()
|
||||
# not the inner allocator (skips the free-group defer) and not both halves
|
||||
alloc.full_attn_allocator.free.assert_not_called()
|
||||
alloc.free.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user