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

2475 lines
108 KiB
Python

# 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.
# ==============================================================================
"""MultiEndedAllocator: one allocator per sub-pool over a `UnifiedKVPool`.
`alloc*` run the upstream kernels ONCE in virtual space using `free_virtual_ids`
as the free-page pointer, then bind consumed virtual pages to physical pages so
`translate_kv_loc` resolves. Public methods take/return TOKEN-granular tensors;
`free_virtual_ids` and the v2p/p2v tables are page-granular. For `page_size == 1`
page math collapses to slot math byte-identically.
"""
from __future__ import annotations
import inspect
import logging
import os
from typing import Dict, List, Optional, Set, Tuple
import torch
from torch.profiler import record_function
from sglang.kernels.ops.memory.virtual_slot import alloc_bind_inplace
from sglang.srt.environ import envs
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
from sglang.srt.mem_cache.allocator.paged import (
alloc_decode_kernel,
alloc_extend_kernel,
)
from sglang.srt.mem_cache.allocator.swa import SWATokenToKVPoolAllocator
from sglang.srt.mem_cache.unified_memory_pool import UnifiedKVPool
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
import weakref
_LAZY_COMPACTION_STATS_ENABLED = envs.SGLANG_LOG_LAZY_COMPACTION_STATS.get()
_LAZY_COMPACTION_STATS_INTERVAL_SEC = float(
envs.SGLANG_LOG_LAZY_COMPACTION_STATS_INTERVAL_SEC.get()
)
# Signal handler emits each instance's final counters (atexit misses signal exits).
_STATS_INSTANCES: weakref.WeakSet[MultiEndedAllocator] = weakref.WeakSet()
_SIGNAL_HANDLERS_INSTALLED = False
def _emit_all_final_stats(reason: str) -> None:
for inst in list(_STATS_INSTANCES):
try:
inst._emit_stats_final(reason=reason)
except Exception:
pass
def _signal_handler(signum, frame):
try:
sig_name = signal.Signals(signum).name
except (ValueError, AttributeError):
sig_name = str(signum)
_emit_all_final_stats(reason=sig_name)
signal.signal(signum, signal.SIG_DFL)
os.kill(os.getpid(), signum)
def _install_signal_handlers_once() -> None:
global _SIGNAL_HANDLERS_INSTALLED
if _SIGNAL_HANDLERS_INSTALLED:
return
_SIGNAL_HANDLERS_INSTALLED = True
# Only override the default handler (the scheduler subprocess installs none).
for sig in (signal.SIGTERM, signal.SIGINT):
try:
prev = signal.getsignal(sig)
if prev in (signal.SIG_DFL, signal.SIG_IGN, None):
signal.signal(sig, _signal_handler)
except (ValueError, OSError):
# Raises off the main thread — skip.
pass
class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
"""Allocator for one sub-pool over a `UnifiedKVPool`."""
def __init__(
self,
*,
kvcache,
unified_buffer: UnifiedKVPool,
sub_pool_name: str,
device: str,
is_id_owner: bool,
page_size: int = 1,
need_sort: bool = False,
forward_stream: Optional[torch.cuda.Stream] = None,
lazy_compaction: bool = False,
):
spec = unified_buffer.spec(sub_pool_name)
max_slots = unified_buffer.max_slots(sub_pool_name)
super().__init__(
size=max_slots,
page_size=page_size,
dtype=spec.get_dtype(),
device=device,
kvcache=kvcache,
need_sort=need_sort,
)
self.unified_buffer = unified_buffer
self.sub_pool_name = sub_pool_name
self.spec = spec
self.max_slots = max_slots
self.grow_direction = spec.grow_direction
self.entry_bytes = spec.entry_bytes()
self.min_slot_index = unified_buffer.min_slot_index(sub_pool_name)
self.is_id_owner = is_id_owner
# Overlap mode: `free` drops a wait_stream(forward_stream) barrier so its
# v2p writes + move kernel serialize after the in-flight forward.
self.forward_stream = forward_stream
# --- Page-aware bookkeeping ---
# `min_page_index` = ceil(min_slot_index / page_size), keeping the
# reserved-sink invariant (min_page_index * entry_bytes_per_page >= entry_max).
self.page_size = page_size
self.num_pages = max_slots // page_size
self.min_page_index = (self.min_slot_index + page_size - 1) // page_size
self.entry_bytes_per_page = self.entry_bytes * page_size
# v2p / p2v sized by PAGES. Page 0 is the padding anchor; trailing row is
# the -1 sentinel.
self.virtual_to_physical = torch.full(
(self.num_pages + 1,),
-1,
dtype=torch.int64,
device=device,
)
self.physical_to_virtual = torch.full(
(self.num_pages + 1,),
-1,
dtype=torch.int64,
device=device,
)
# Back-compat alias (count of virtual PAGES) consulted by is_slot_allocated.
self.num_virtual_ids = self.num_pages
self._peer: Optional[MultiEndedAllocator] = None
# Inverse history of relocations (spec rollback), at PAGE granularity.
self._inverse_history: List[Tuple[torch.Tensor, torch.Tensor, torch.Tensor]] = (
[]
)
# --- Lazy compaction state (all unused when lazy_compaction=False) ---
# `_free_phys_pages`: GPU free list of physical PAGE ids, sorted at `_flush`.
# `_pending_reuse`: compaction-src pages whose remap completed but whose
# reader event hasn't fired — can't re-enter the free list until the read
# settles (else a future alloc's WRITE races the READ).
# `live_page_count`: CPU slot-conservation counter, invariant under compaction.
# KV copy and v2p/p2v remap both run on `schedule_stream`, so single-stream
# ordering serializes them — no separate copy-done event needed.
self.lazy_compaction = lazy_compaction
self._free_phys_pages: torch.Tensor = torch.empty(
0, dtype=torch.int64, device=device
)
# Keyed by Event, ONE entry per BATCH. `(cpu_list, gpu_tensor)`: cpu_list
# drives the Set update (no sync); gpu_tensor is the SAME tensor
# `_commit_move_batch` remapped, kept alive so drain cats it without an H2D.
self._pending_reuse: Dict[
torch.cuda.Event,
Tuple[List[int], torch.Tensor],
] = {}
# CPU mirror of `_pending_reuse` for O(1) membership in the survivor walk.
self._pending_reuse_pages_cpu: Set[int] = set()
# Cumulative observability counters (NOT reset at clear()).
self._stats_n_free_lazy: int = 0
self._stats_n_release_batch: int = 0
self._stats_n_drain_calls: int = 0
self._stats_n_drain_did_work: int = 0
self._stats_n_drained_pages_total: int = 0
self._stats_n_flush_calls: int = 0
self._stats_n_flush_did_work: int = 0
self._stats_n_flush_moves: int = 0
self._stats_n_pages_absorbed: int = 0
self._stats_peak_free_list_len: int = 0
self._stats_peak_pending_pages: int = 0
self._stats_n_emits: int = 0
self._stats_last_emit_ts: float = _time_mod.monotonic()
self._stats_final_emitted: bool = False
if _LAZY_COMPACTION_STATS_ENABLED:
atexit.register(self._emit_stats_final, reason="atexit")
_STATS_INSTANCES.add(self)
_install_signal_handlers_once()
self.live_page_count = 0
self._latest_forward_done_event: Optional[torch.cuda.Event] = None
# Most-recent forward's (done_event, out_cache_loc_virtual) for `_flush`'s
# write-race check. Single slot: at most ONE forward in flight per call site.
# Only the tensor reference is stored; `_flush` materializes the write-set
# lazily, avoiding a launch-time sync.
self._inflight_forward: Optional[Tuple[torch.cuda.Event, torch.Tensor]] = None
# Per-call move cap on NON-urgent `_flush`: bounds work per `on_idle()` so a
# large backlog doesn't block ZMQ IPC; the next flush picks up the rest.
# Urgent (alloc-shortfall retry) is uncapped — must drain everything.
self._lazy_max_moves_per_call = int(
os.environ.get("SGLANG_LAZY_COMPACTION_MAX_MOVES_PER_CALL", "4096")
)
self.clear()
logger.info(
"[unified-memory-pool] MultiEndedAllocator(%r) ready: grow=%s, max_slots=%d, "
"min_slot_index=%d, page_size=%d, num_pages=%d, min_page_index=%d, "
"entry_bytes=%d, entry_bytes_per_page=%d, is_id_owner=%s, "
"initial_watermark_page=%d, allocatable_pages=%d",
self.sub_pool_name,
self.grow_direction,
self.max_slots,
self.min_slot_index,
self.page_size,
self.num_pages,
self.min_page_index,
self.entry_bytes,
self.entry_bytes_per_page,
self.is_id_owner,
self.watermark_physical,
self.num_pages - self.min_page_index,
)
# -- peer binding --
def bind_peer(self, peer: MultiEndedAllocator) -> None:
self._peer = peer
@property
def peer(self) -> Optional[MultiEndedAllocator]:
return self._peer
# -- state --
def clear(self) -> None:
"""Reset to initial state. Pages in `[0, min_page_index)` are reserved."""
if self.grow_direction == "up":
self.watermark_physical = self.min_page_index
else:
self.watermark_physical = self.num_pages - 1
self.virtual_to_physical.fill_(-1)
# Virtual page 0 <-> physical page 0 (padding sink).
self.virtual_to_physical[0] = 0
self.virtual_to_physical[-1] = -1 # trailing sentinel
self.physical_to_virtual.fill_(-1)
self.physical_to_virtual[0] = 0
self.physical_to_virtual[-1] = -1
if self.is_id_owner:
self.free_virtual_ids = torch.arange(
self.min_page_index,
self.num_pages,
dtype=torch.int64,
device=self.device,
)
else:
self.free_virtual_ids = None
self.is_not_in_free_group = True
self.free_group: List[torch.Tensor] = []
self._inverse_history.clear()
self._free_phys_pages = torch.empty(0, dtype=torch.int64, device=self.device)
self._pending_reuse.clear()
self._pending_reuse_pages_cpu.clear()
self.live_page_count = 0
self._inflight_forward = None
self._latest_forward_done_event = None
def backup_state(self):
# Spec-decode allocates only inside a backup window (no free), so
# `_inverse_history` doesn't grow under correct usage.
return (
self.watermark_physical,
(len(self.free_virtual_ids) if self.is_id_owner else None),
len(self._inverse_history),
)
def restore_state(self, state):
watermark, n_free_virtual, n_inverse = state
self.watermark_physical = watermark
if self.is_id_owner and n_free_virtual is not None:
pass # spec asserted off; no free-list rollback.
new_entries = self._inverse_history[n_inverse:]
if new_entries:
logger.warning(
"MultiEndedAllocator.restore_state: %d relocation(s) recorded inside "
"a backup window (sub_pool=%s). Eager compaction is not fully "
"reversible; SGLang's spec path should not produce a free() inside a "
"backup window.",
len(new_entries),
self.sub_pool_name,
)
del self._inverse_history[n_inverse:]
return new_entries
def clear_inverse_history(self) -> None:
self._inverse_history.clear()
# -- size reporting --
def _allocated_pages(self) -> int:
"""Number of allocated PAGES (TOKEN callers use `allocated_count()`)."""
if self.grow_direction == "up":
return max(0, self.watermark_physical - self.min_page_index)
return max(0, self.num_pages - 1 - self.watermark_physical)
def allocated_count(self) -> int:
"""LIVE allocated TOKENS (excludes lazy holes / pending).
TOKENS, not pages — the leak checker's invariant is in tokens. Lazy mode
uses `live_page_count` (invariant under compaction); the watermark span
over-counts because holes/pending sit inside it but aren't live.
"""
if self.lazy_compaction:
return self.live_page_count * self.page_size
return self._allocated_pages() * self.page_size
def is_slot_allocated(self, slot: int) -> bool:
"""Whether the PAGE containing this virtual id is in use."""
virt_page = slot // self.page_size
if virt_page < 0 or virt_page >= self.num_pages:
return False
return int(self.virtual_to_physical[virt_page].item()) != -1
def allocator_state_str(self) -> str:
return (
f"sub_pool={self.sub_pool_name!r}, grow_direction={self.grow_direction}, "
f"is_id_owner={self.is_id_owner}, page_size={self.page_size}, "
f"min_page_index={self.min_page_index}, "
f"num_pages={self.num_pages}, "
f"watermark_physical={self.watermark_physical}, "
f"allocated_pages={self._allocated_pages()}"
)
def _byte_high_frontier(self) -> int:
"""Byte just past this side's last-allocated page (grow-up) / buffer top (grow-down)."""
if self.grow_direction == "up":
return self.watermark_physical * self.entry_bytes_per_page
return self.num_pages * self.entry_bytes_per_page
def _byte_low_frontier(self) -> int:
"""Byte starting this side's allocatable range (grow-up) / just below its lowest live page (grow-down)."""
if self.grow_direction == "up":
return self.min_page_index * self.entry_bytes_per_page
return (self.watermark_physical + 1) * self.entry_bytes_per_page
def _current_gap_bytes(self) -> int:
"""Free byte band between this side's frontier and the peer's CURRENT frontier."""
if self.grow_direction == "up":
my_high = self._byte_high_frontier()
peer_low = (
self._peer._byte_low_frontier()
if self._peer is not None
else self.unified_buffer.total_bytes
)
return max(0, peer_low - my_high)
my_low = self._byte_low_frontier()
peer_high = self._peer._byte_high_frontier() if self._peer is not None else 0
return max(0, my_low - peer_high)
def _available_tokens(self, extra_gap_bytes: int = 0) -> int:
"""Tokens allocatable given `extra_gap_bytes` of ADDED gap room
(0 == current realizable; >0 == post-peer-compaction).
`pages_by_index_space` is OWN index headroom, unaffected by
`extra_gap_bytes`: peer bytes can't add page indices to our own table.
"""
gap_bytes = self._current_gap_bytes() + extra_gap_bytes
pages_by_bytes = gap_bytes // self.entry_bytes_per_page
pages_by_index_space = (
self.num_pages - self.min_page_index - self._allocated_pages()
)
pages_extend = min(pages_by_bytes, pages_by_index_space)
# Lazy: drainable holes don't consume new bytes.
pages_drain = len(self._free_phys_pages) if self.lazy_compaction else 0
return (pages_extend + pages_drain) * self.page_size
def available_size(self) -> int:
"""Tokens allocatable RIGHT NOW (no peer compaction).
Alloc shortfall gates consult this to decide whether to peer-flush, so it
MUST NOT fold in peer holes (use `schedulable_available_size()` for that).
"""
return self._available_tokens()
def _peer_drainable_hole_bytes(self) -> int:
"""Gap bytes a peer urgent flush would release. Only `_free_phys_pages`
count — NOT `_pending_reuse` (awaiting an event) — so the credit is realizable.
"""
peer = self._peer
if peer is None or not peer.lazy_compaction:
return 0
return len(peer._free_phys_pages) * peer.entry_bytes_per_page
def schedulable_available_size(self) -> int:
"""Tokens allocatable AFTER a peer urgent-flush (realizable-with-compaction).
Used by composite views; alloc gates use `available_size()`.
"""
return self._available_tokens(extra_gap_bytes=self._peer_drainable_hole_bytes())
def _flush_peer_for_alloc(self, need_tokens: int) -> bool:
"""One urgent peer-flush on alloc shortfall; returns whether THIS side now
has enough. Only PEER compaction releases gap bytes (own compaction is net 0).
"""
if not (self.lazy_compaction and self._peer is not None):
return False
self._peer._flush(urgent=True)
return need_tokens <= self.available_size()
# -- physical-slot / physical-page primitives --
def take_physical(self, need_size: int) -> Optional[torch.Tensor]:
"""Reserve `need_size` TOKENS (multiple of page_size), returning backing
physical PAGE ids, or `None` on shortfall.
Eager: pure watermark advance. Lazy: drain `_free_phys_pages` holes first,
then extend the watermark (extend first so state is untouched on failure).
"""
with record_function("MultiEndedAlloc.take_physical"):
if need_size <= 0:
return torch.empty(0, dtype=torch.int64, device=self.device)
assert need_size % self.page_size == 0, (
f"take_physical: need_size={need_size} must be a multiple of "
f"page_size={self.page_size}"
)
num_pages = need_size // self.page_size
if not self.lazy_compaction:
return self._take_physical_eager(num_pages)
# Lazy: slice the GPU free list (no D2H). sort ON: take deepest-in-band
# per direction (greedy clustering). sort OFF: take from front.
n_drain = min(num_pages, int(self._free_phys_pages.shape[0]))
need_more = num_pages - n_drain
# Extend first (state untouched on failure), then drain holes.
if need_more > 0:
if not self._extend_watermark(need_more):
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:]
else:
drained_t = None
self.live_page_count += num_pages
if drained_t is None:
return self._take_physical_arange(num_pages)
# Pure drain — clone off the free-list view so rebindings don't pin it.
if need_more == 0:
return drained_t.clone()
# Mixed: drained holes ++ extended pages (`bind` is order-agnostic).
if self.grow_direction == "up":
new_wm = self.watermark_physical
extended_t = torch.arange(
new_wm - need_more,
new_wm,
dtype=torch.int64,
device=self.device,
)
else:
new_wm = self.watermark_physical
extended_t = torch.arange(
new_wm + need_more,
new_wm,
-1,
dtype=torch.int64,
device=self.device,
)
return torch.cat([drained_t, extended_t])
def _take_physical_eager(self, num_pages: int) -> Optional[torch.Tensor]:
"""Eager-mode take_physical — contiguous range."""
if self.grow_direction == "up":
start = self.watermark_physical
end_exclusive = start + num_pages
if end_exclusive > self.num_pages:
return None
phys_pages = torch.arange(
start, end_exclusive, dtype=torch.int64, device=self.device
)
self.watermark_physical = end_exclusive
return phys_pages
else:
end = self.watermark_physical
start = end - num_pages + 1
if start < self.min_page_index:
return None
phys_pages = torch.arange(
start, end + 1, dtype=torch.int64, device=self.device
)
self.watermark_physical -= num_pages
return phys_pages
def _extend_watermark(self, num_pages: int) -> bool:
"""Advance the watermark by `num_pages` (lazy-path helper). Returns False
on index-space overflow OR crossing the PEER's byte frontier.
"""
if self.grow_direction == "up":
new_wm = self.watermark_physical + num_pages
if new_wm > self.num_pages:
return False
# Peer (grow-down) sits ABOVE; don't extend past its low frontier.
if self._peer is not None:
peer_low_pages = (
self._peer._byte_low_frontier() // self.entry_bytes_per_page
)
if new_wm > peer_low_pages:
return False
self.watermark_physical = new_wm
else:
new_wm = self.watermark_physical - num_pages
if new_wm < self.min_page_index - 1:
return False
# Peer (grow-up) sits BELOW; `new_wm + 1` (our new lowest live page)
# must stay strictly above the peer's high frontier.
if self._peer is not None:
peer_high_pages = (
self._peer._byte_high_frontier() // self.entry_bytes_per_page
)
if new_wm + 1 < peer_high_pages:
return False
self.watermark_physical = new_wm
return True
def _take_physical_arange(self, num_pages: int) -> torch.Tensor:
"""Contiguous arange for an already-applied watermark extension."""
if self.grow_direction == "up":
return torch.arange(
self.watermark_physical - num_pages,
self.watermark_physical,
dtype=torch.int64,
device=self.device,
)
return torch.arange(
self.watermark_physical + 1,
self.watermark_physical + num_pages + 1,
dtype=torch.int64,
device=self.device,
)
def take_physical_pages(self, num_pages: int) -> Optional[torch.Tensor]:
"""Page-granular wrapper around ``take_physical``."""
with record_function("MultiEndedAlloc.take_physical_pages"):
return self.take_physical(num_pages * self.page_size)
def bind(self, virtual_ids: torch.Tensor, physical_ids: torch.Tensor) -> None:
"""Bind page-granular virtual ids to physical ids."""
with record_function("MultiEndedAlloc.bind"):
self.virtual_to_physical[virtual_ids] = physical_ids
self.physical_to_virtual[physical_ids] = virtual_ids
def bind_pages(
self, virtual_pages: torch.Tensor, physical_pages: torch.Tensor
) -> None:
"""Page-granular alias of ``bind``."""
with record_function("MultiEndedAlloc.bind_pages"):
self.bind(virtual_pages, physical_pages)
# -- fused take_physical_pages + bind_pages --
def _alloc_bind_fast_or_slow(
self, v_pages: torch.Tensor, N: int
) -> Optional[torch.Tensor]:
"""Fuse `take_physical_pages` + `bind` into ONE Triton kernel when no
holes need draining; fall through to the slow path (drains holes first)
when holes exist. Returns physical page ids [N], or None on shortfall.
"""
with record_function("MultiEndedAlloc._alloc_bind_fast_or_slow"):
if N == 0:
return torch.empty(0, dtype=torch.int64, device=self.device)
# FAST PATH: eager, or lazy with no current holes.
if not self.lazy_compaction or self._free_phys_pages.numel() == 0:
start_wm = self.watermark_physical # kernel's `start_phys`
# Lazy uses `_extend_watermark` (index + peer checks); eager
# inlines the index-only check to match `_take_physical_eager`.
if self.lazy_compaction:
if not self._extend_watermark(N):
return None
else:
if self.grow_direction == "up":
new_wm = start_wm + N
if new_wm > self.num_pages:
return None
self.watermark_physical = new_wm
else:
new_wm = start_wm - N
if new_wm < self.min_page_index - 1:
return None
self.watermark_physical = new_wm
# Lowest physical id of the new range (both directions yield
# ascending `[start_phys, start_phys + N)`).
if self.grow_direction == "up":
start_phys = start_wm
else:
start_phys = start_wm - N + 1
phys_pages = alloc_bind_inplace(
v_pages,
self.virtual_to_physical,
self.physical_to_virtual,
start_phys,
)
if self.lazy_compaction: # live_page_count tracked only in lazy mode
self.live_page_count += N
return phys_pages
# SLOW PATH: holes exist — drain them first, then bind.
phys_pages = self.take_physical_pages(N)
if phys_pages is None:
return None
self.bind(v_pages, phys_pages)
return phys_pages
# -- translate (virtual TOKEN ids -> physical TOKEN ids) --
def translate_kv_loc(
self,
virt_tokens: torch.Tensor,
*,
out: Optional[torch.Tensor] = None,
) -> torch.Tensor:
"""Translate token-granular virtual ids to physical ids.
``out=`` writes in-place into a caller-owned buffer — required under
cuda-graph capture for buffer-stability (the captured graph records the
gather against a fixed ``data_ptr``).
"""
if out is not None:
assert out.dtype == torch.int64, (
f"translate_kv_loc: out= dtype must be int64 (matches v2p), "
f"got {out.dtype}"
)
assert out.shape == virt_tokens.shape, (
f"translate_kv_loc: out= shape {tuple(out.shape)} must match "
f"virt_tokens shape {tuple(virt_tokens.shape)}"
)
with record_function("MultiEndedAlloc.translate_kv_loc"):
return self._translate_kv_loc_impl(virt_tokens, out)
def _translate_kv_loc_impl(
self,
virt_tokens: torch.Tensor,
out: Optional[torch.Tensor],
) -> torch.Tensor:
# Tombstone-safety clamp: tombstoned v2p entries (-1) must not reach
# `k_buffer[-1]` (illegal access under captured graph replay). Clamp to 0
# routes any tombstoned read/write to physical slot 0 — reserved
# padding-sink space by the `min_slot_index` invariant (bytes [0, entry_max)
# across all sub-pools hold no real data).
if self.page_size == 1:
if out is not None:
# `index_select(out=out)` forbids index/out aliasing, but the
# canonical caller does in-place `translate(kv_indices, out=kv_indices)`.
# Route through a transient gather + `copy_` to satisfy that contract.
tmp = torch.index_select(self.virtual_to_physical, 0, virt_tokens)
tmp = torch.clamp_min(tmp, 0)
out.copy_(tmp)
return out
result = torch.index_select(self.virtual_to_physical, 0, virt_tokens)
return torch.clamp_min(result, 0)
# page_size > 1: page math. `virt_pages`/`offsets` are fresh, so they
# cannot alias `out` — `index_select(out=out)` is safe.
virt_pages = virt_tokens // self.page_size
offsets = virt_tokens % self.page_size
if out is not None:
torch.index_select(self.virtual_to_physical, 0, virt_pages, out=out)
out.mul_(self.page_size)
out.add_(offsets)
out.clamp_(min=0) # tombstoned page: -1*ps + offset in [-ps, -1]
return out
phys_pages = self.virtual_to_physical[virt_pages]
result = phys_pages * self.page_size + offsets
return torch.clamp_min(result, 0)
# -- alloc --
def alloc(self, need_size: int) -> Optional[torch.Tensor]:
"""Allocate `need_size` virtual TOKEN ids (id-owner only). Returns
token-granular, page-structured ids, or None on shortfall.
`need_size` MUST be a multiple of `page_size`. All allocator GPU ops run
on `schedule_stream`; `alloc` needs no `wait_stream` barrier because its
v2p/p2v writes are picked up by the forward via the existing
`forward_stream.wait_stream(schedule_stream)` at the top of `run_batch`.
"""
with record_function("MultiEndedAlloc.alloc"):
assert self.is_id_owner, (
f"MultiEndedAllocator({self.sub_pool_name!r}).alloc called on a "
"non-id-owner allocator; use alloc_with_virtual instead"
)
if need_size <= 0:
return torch.empty(0, dtype=torch.int64, device=self.device)
assert need_size % self.page_size == 0, (
f"MultiEndedAllocator({self.sub_pool_name!r}).alloc: need_size="
f"{need_size} must be a multiple of page_size={self.page_size}"
)
if need_size > self.available_size():
# Shortfall: flush the PEER, not own. Own compaction is net 0
# (each move trades 1 hole for +1 gap byte); only peer compaction
# releases bytes into the shared gap that own extension consumes.
if not self._flush_peer_for_alloc(need_size):
return None
num_pages = need_size // self.page_size
v_pages = self.free_virtual_ids[:num_pages]
self.free_virtual_ids = self.free_virtual_ids[num_pages:]
phys_pages = self._alloc_bind_fast_or_slow(v_pages, num_pages)
if phys_pages is None:
self.free_virtual_ids = torch.cat([v_pages, self.free_virtual_ids])
return None
if self.page_size == 1:
return v_pages # v_pages already IS the token id list
# Expand page ids to token ids: (P, 1) * S + (S,) → (P, S) → (P*S,).
return (
v_pages[:, None] * self.page_size
+ torch.arange(self.page_size, device=self.device)
).reshape(-1)
def alloc_with_virtual(self, virtual_pages: torch.Tensor) -> None:
"""Take physical PAGES for caller-supplied virtual PAGE ids
(physical-holding non-owner; the SWA `swa` sub-allocator).
Input is virtual PAGE ids (not token ids): the composite snapshots the
virtual pages before the id-owner consumes them from its free-list.
"""
with record_function("MultiEndedAlloc.alloc_with_virtual"):
if virtual_pages.numel() == 0:
return
phys_pages = self._alloc_bind_fast_or_slow(
virtual_pages, int(virtual_pages.numel())
)
assert phys_pages is not None, (
f"MultiEndedAllocator({self.sub_pool_name!r}).alloc_with_virtual: out of "
"physical room (the composite's byte-budget check should have caught this)"
)
# -- paged alloc surface --
def alloc_extend(
self,
prefix_lens: torch.Tensor,
prefix_lens_cpu: torch.Tensor,
seq_lens: torch.Tensor,
seq_lens_cpu: torch.Tensor,
last_loc: torch.Tensor,
extend_num_tokens: int,
num_new_pages: Optional[int] = None,
) -> Optional[torch.Tensor]:
"""Allocate ``extend_num_tokens`` new tokens across ``bs`` requests,
preserving the tail-page-reuse contract.
Runs the kernel in VIRTUAL space (``free_page_ptr == free_virtual_ids``),
so ``out_indices`` are virtual token ids. Each consumed virtual page is
then bound to a physical page on THIS sub-allocator; without that binding
v2p stays -1 and translation yields negative ids → CUDA OOB.
"""
with record_function("MultiEndedAlloc.alloc_extend"):
assert (
self.is_id_owner
), f"alloc_extend on a non-id-owner allocator ({self.sub_pool_name!r})"
if num_new_pages is None:
num_new_pages = get_num_new_pages(
seq_lens=seq_lens_cpu,
page_size=self.page_size,
prefix_lens=prefix_lens_cpu,
)
if num_new_pages > len(self.free_virtual_ids):
return None
# Lazy: physical-capacity pre-check; on shortfall flush the PEER (own
# compaction is internal — see `alloc`).
need_tokens = num_new_pages * self.page_size
if need_tokens > self.available_size():
if not self._flush_peer_for_alloc(need_tokens):
return None
bs = len(prefix_lens)
if self.need_sort and extend_num_tokens // self.page_size + bs + 1 > len(
self.free_virtual_ids
):
self.merge_and_sort_free()
# Snapshot the virtual pages the kernel will consume, to bind them to
# physical pages afterward (else v2p stays -1 → CUDA OOB).
if num_new_pages > 0:
new_virtual_pages = self.free_virtual_ids[:num_new_pages].clone()
else:
new_virtual_pages = None
out_indices = torch.empty(
(extend_num_tokens,), dtype=torch.int64, device=self.device
)
# `free_virtual_ids` passed as `free_page_ptr`: the kernel does
# `page_id * page_size + offset` regardless of virtual vs physical.
with record_function("MultiEndedAlloc.alloc_extend.kernel"):
alloc_extend_kernel[(bs,)](
prefix_lens,
seq_lens,
last_loc,
self.free_virtual_ids,
out_indices,
next_power_of_2(bs),
self.page_size,
)
# Bind the consumed virtual pages to fresh physical pages here. The
# peer (swa side) binds the same pages via `alloc_with_virtual`.
if new_virtual_pages is not None:
phys_pages = self._alloc_bind_fast_or_slow(
new_virtual_pages, num_new_pages
)
if phys_pages is None:
return None # defensive; pre-check should have prevented it
self.free_virtual_ids = self.free_virtual_ids[num_new_pages:]
return out_indices # virtual token ids
def alloc_decode(
self,
seq_lens: torch.Tensor,
seq_lens_cpu: torch.Tensor,
last_loc: torch.Tensor,
) -> Optional[torch.Tensor]:
"""Allocate one new token per request (decode), preserving the
tail-page-reuse contract. Runs in virtual space; binds each consumed
virtual page on THIS sub-allocator (else v2p stays -1 → CUDA OOB).
"""
with record_function("MultiEndedAlloc.alloc_decode"):
assert (
self.is_id_owner
), f"alloc_decode on a non-id-owner allocator ({self.sub_pool_name!r})"
bs = len(seq_lens)
# CPU-only count BEFORE the kernel, to snapshot the exact slice the
# kernel will consume.
num_new_pages = get_num_new_pages(
seq_lens=seq_lens_cpu, page_size=self.page_size, decode=True
)
if num_new_pages > len(self.free_virtual_ids):
return None
# Lazy: physical-capacity pre-check; on shortfall flush PEER.
need_tokens = num_new_pages * self.page_size
if need_tokens > self.available_size():
if not self._flush_peer_for_alloc(need_tokens):
return None
if self.need_sort and bs > len(self.free_virtual_ids):
self.merge_and_sort_free()
# Most decode steps reuse the prefix's tail page → num_new_pages == 0.
if num_new_pages > 0:
new_virtual_pages = self.free_virtual_ids[:num_new_pages].clone()
else:
new_virtual_pages = None
out_indices = torch.empty((bs,), dtype=torch.int64, device=self.device)
with record_function("MultiEndedAlloc.alloc_decode.kernel"):
alloc_decode_kernel[(bs,)](
seq_lens,
last_loc,
self.free_virtual_ids,
out_indices,
next_power_of_2(bs),
self.page_size,
)
if new_virtual_pages is not None:
phys_pages = self._alloc_bind_fast_or_slow(
new_virtual_pages, num_new_pages
)
if phys_pages is None:
return None
self.free_virtual_ids = self.free_virtual_ids[num_new_pages:]
return out_indices # virtual token ids
# -- free with eager compaction --
def free(self, free_index: torch.Tensor) -> None:
"""Free virtual TOKEN ids: recover virtual PAGE ids, un-map v2p/p2v,
(if id-owner) recycle the page ids, trigger eager compaction.
`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
barrier (a freed `v` has no live reader, so the scatters are
disjoint-element from any forward read, atomic on Ampere+/Hopper) and
defers compaction to `_flush`.
"""
with record_function("MultiEndedAlloc.free"):
if free_index is None or free_index.numel() == 0:
return
if not self.is_not_in_free_group:
self.free_group.append(free_index)
return
if self.lazy_compaction:
self._free_lazy(free_index)
return
# --- EAGER path ---
# Near-no-op in normal mode (sampling's CPU sync already drained
# forward_stream); in overlap mode it serializes free+compaction with
# the in-flight forward.
if self.forward_stream is not None:
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
)
freed_p_pages = self.virtual_to_physical[free_v_pages]
with record_function("MultiEndedAlloc.free.sync_check"):
# `.item()` forces a CPU/GPU sync — own trace region to measure it.
if bool((freed_p_pages < 0).any().item()):
self._raise_stale_slot_assertion(
free_v=free_v_pages, freed_p=freed_p_pages
)
self.virtual_to_physical[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:
"""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`.
ps==1 skips `torch.unique` (token == page and `free_index` is already
unique per caller contract); ps>1 needs it to dedup same-page tokens.
Callers must not double-free: a tombstone (-1) here would be cat'd onto
the free list.
"""
self._stats_n_free_lazy += 1
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:
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
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`
at END to merge event-fired compaction-srcs (`released_fired`) AFTER the
trailing dst-slice, keeping `_free_phys_pages == holes_cpu` during the walk.
No watermark / `live_page_count` change — these are vacated src positions
re-entering as PURE storage, not freshly-freed live pages.
"""
if pages.numel() == 0:
return
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
vacated band (K pages adjacent to the watermark) into the holes in the kept
band, advance the watermark, remap the tables. `src`/`dst` are disjoint by
construction, so the batched copy is order-independent. The caller's
`wait_stream` barrier already serialized us with the in-flight forward.
"""
with record_function("MultiEndedAlloc._compact_pending"):
self._compact_pending_impl(freed_physical_pages)
def _compact_pending_impl(self, freed_physical_pages: torch.Tensor) -> None:
freed_set = set(int(x) for x in freed_physical_pages.tolist())
if not freed_set:
return
K = len(freed_set)
if self.grow_direction == "up":
# allocated == [min_page_index, old_wm); after the free == [min_page_index, new_wm)
old_wm = self.watermark_physical
new_wm = old_wm - K
assert new_wm >= self.min_page_index, (
f"_compact_pending({self.sub_pool_name!r}): freeing {K} pages "
f"would push the watermark below min_page_index "
f"({new_wm} < {self.min_page_index})"
)
assert all(self.min_page_index <= h < old_wm for h in freed_set), (
f"_compact_pending({self.sub_pool_name!r}): freed physical pages "
f"{sorted(freed_set)} not all within allocated range "
f"[{self.min_page_index}, {old_wm})"
)
# vacated band = [new_wm, old_wm); kept band = [min_page_index, new_wm)
src_list = [s for s in range(new_wm, old_wm) if s not in freed_set]
dst_list = sorted(h for h in freed_set if h < new_wm)
self.watermark_physical = new_wm
vacated_lo, vacated_hi = new_wm, old_wm
else:
# allocated == (old_wm, num_pages); after the free == (new_wm, num_pages)
old_wm = self.watermark_physical
new_wm = old_wm + K
assert new_wm <= self.num_pages - 1, (
f"_compact_pending({self.sub_pool_name!r}): freeing {K} pages "
f"would push the watermark above num_pages "
f"({new_wm} > {self.num_pages - 1})"
)
assert all(old_wm < h < self.num_pages for h in freed_set), (
f"_compact_pending({self.sub_pool_name!r}): freed physical pages "
f"{sorted(freed_set)} not all within allocated range "
f"({old_wm}, {self.num_pages})"
)
# vacated band = (old_wm, new_wm] = [old_wm+1, new_wm+1); kept band = (new_wm, num_pages)
src_list = [s for s in range(old_wm + 1, new_wm + 1) if s not in freed_set]
dst_list = sorted(h for h in freed_set if h > new_wm)
self.watermark_physical = new_wm
vacated_lo, vacated_hi = old_wm + 1, new_wm + 1
assert len(src_list) == len(dst_list), (
f"_compact_pending({self.sub_pool_name!r}): {len(src_list)} survivors vs "
f"{len(dst_list)} holes — corrupt allocator state"
)
if src_list:
src_pages = torch.tensor(src_list, dtype=torch.int64, device=self.device)
dst_pages = torch.tensor(dst_list, dtype=torch.int64, device=self.device)
v_moved = self.physical_to_virtual[
src_pages
].clone() # read before clearing
# Expand page ids to token ids for the token-granular move kernel.
if self.page_size == 1:
src_t, dst_t = src_pages, dst_pages
else:
offsets = torch.arange(
self.page_size, dtype=torch.int64, device=self.device
)
src_t = (src_pages[:, None] * self.page_size + offsets).reshape(-1)
dst_t = (dst_pages[:, None] * self.page_size + offsets).reshape(-1)
# Un-translated copy: the public copy_from translates virtual ids,
# which we must NOT do here.
move_fn = getattr(self._kvcache, "move_kv_cache", None)
if move_fn is not None:
move_fn(dst_t, src_t)
else:
copy_phys = getattr(self._kvcache, "_copy_from_physical", None)
assert copy_phys is not None, (
f"sub-pool {self.sub_pool_name!r} supports neither move_kv_cache "
"nor _copy_from_physical"
)
copy_phys(src_t, dst_t)
# Clear the vacated band, then re-bind the relocated dst pages.
self.physical_to_virtual[vacated_lo:vacated_hi] = -1
self.virtual_to_physical[v_moved] = dst_pages
self.physical_to_virtual[dst_pages] = v_moved
self._inverse_history.append((src_pages, dst_pages, v_moved))
else:
self.physical_to_virtual[vacated_lo:vacated_hi] = -1
# -- lazy compaction primitives --
def set_latest_forward_done_event(self, event: Optional[torch.cuda.Event]) -> None:
"""Stash the most-recent forward's `forward_done` event; `_pending_reuse`
uses it to gate src reuse on read-path settling. None = no in-flight forward.
"""
with record_function("MultiEndedAlloc.set_latest_forward_done_event"):
self._latest_forward_done_event = event
def set_inflight_forward(
self,
forward_done: torch.cuda.Event,
out_cache_loc_virtual: Optional[torch.Tensor],
) -> None:
"""Stash the just-launched forward's `forward_done` event + virtual
`out_cache_loc` for `_flush`'s write-race check.
No GPU work — only references; `_flush` materializes the write-set lazily
on `schedule_stream`, avoiding a launch-time sync. Pass
`out_cache_loc_virtual=None` when the forward doesn't write this pool
(e.g. Mamba state, written by mamba kernels not `set_kv_buffer`). No-op
in eager mode.
"""
with record_function("MultiEndedAlloc.set_inflight_forward"):
if not self.lazy_compaction:
return
if out_cache_loc_virtual is None or out_cache_loc_virtual.numel() == 0:
# No write race on this pool — clear the slot so `_flush`
# short-circuits and the prior tensor reference can be GC'd.
self._inflight_forward = None
return
self._inflight_forward = (forward_done, out_cache_loc_virtual)
def _materialize_inflight_write_set(self) -> Optional[Set[int]]:
"""Materialize the in-flight forward's write-set (physical PAGE ids it is
about to write), or `None` if no in-flight forward / already completed.
Called inside `_flush` on `schedule_stream`. Pays a bs-sized D2H sync, but
only once per call and only when a survivor needs classifying.
"""
inflight = self._inflight_forward
if inflight is None:
return None
event, oclv = inflight
# Forward completed → no write race. Clear so later flushes in the same
# tick don't re-check the fired event.
if event.query():
self._inflight_forward = None
return None
# `oclv` is non-None here (set_inflight_forward clears the slot otherwise).
with record_function("MultiEndedAlloc._materialize_inflight_write_set"):
phys_tokens = self.translate_kv_loc(oclv)
if self.page_size > 1:
phys_pages = (phys_tokens // self.page_size).unique()
else:
phys_pages = phys_tokens
return set(phys_pages.tolist()) # .tolist() syncs schedule_stream
def _maybe_emit_stats(self) -> None:
"""Env-gated periodic stats emit (at most once per interval) at `_flush` end.
Disabled unless `SGLANG_LOG_LAZY_COMPACTION_STATS=1`.
"""
if not _LAZY_COMPACTION_STATS_ENABLED:
return
now = _time_mod.monotonic()
if now - self._stats_last_emit_ts < _LAZY_COMPACTION_STATS_INTERVAL_SEC:
return
self._stats_last_emit_ts = now
self._stats_n_emits += 1
cur_holes = int(self._free_phys_pages.shape[0])
cur_pending = len(self._pending_reuse_pages_cpu)
self._stats_peak_free_list_len = max(self._stats_peak_free_list_len, cur_holes)
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} "
f"flush={self._stats_n_flush_calls} "
f"(work={self._stats_n_flush_did_work} "
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} "
f"live={self.live_page_count} wm={self.watermark_physical}"
)
def _emit_stats_final(self, reason: str = "exit") -> None:
"""Force-emit final counters at shutdown (bypasses the interval gate).
Idempotent (signal handler + atexit may both fire); best-effort.
"""
if not _LAZY_COMPACTION_STATS_ENABLED:
return
if self._stats_final_emitted:
return
try:
cur_holes = int(self._free_phys_pages.shape[0])
cur_pending = len(self._pending_reuse_pages_cpu)
self._stats_peak_free_list_len = max(
self._stats_peak_free_list_len, cur_holes
)
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}] "
f"free_lazy={self._stats_n_free_lazy} "
f"flush={self._stats_n_flush_calls} "
f"(work={self._stats_n_flush_did_work} "
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} "
f"live={self.live_page_count} wm={self.watermark_physical} "
f"n_emits={self._stats_n_emits}"
)
except Exception:
pass
def _drain_pending_reuse(self, *, urgent: bool) -> None:
"""Move ready `_pending_reuse` entries back into `_free_phys_pages` via
pure-GPU `torch.cat`.
* non-urgent: release only entries whose event is None or has fired.
* urgent: `stream.wait_event` (stream-side dep, not host block) on
unfired events, then release.
ONE dict entry per BATCH (keyed by Event); cpu_list drives the Set update,
gpu_tensor is cat'd directly. No watermark / `live_page_count` change.
"""
self._stats_n_drain_calls += 1
if not self._pending_reuse:
return
with record_function("MultiEndedAlloc._drain_pending_reuse"):
ready_tensors: List[torch.Tensor] = []
ready_entries: List[Tuple[torch.cuda.Event, List[int]]] = []
for event, (cpu_list, gpu_tensor) in self._pending_reuse.items():
if event is None or event.query():
ready_tensors.append(gpu_tensor)
ready_entries.append((event, cpu_list))
elif urgent:
torch.cuda.current_stream().wait_event(event)
ready_tensors.append(gpu_tensor)
ready_entries.append((event, cpu_list))
for event, cpu_list in ready_entries:
del self._pending_reuse[event]
self._pending_reuse_pages_cpu.difference_update(cpu_list)
if ready_tensors:
self._free_phys_pages = torch.cat(
[self._free_phys_pages] + ready_tensors
)
self._stats_n_drain_did_work += 1
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
back into `_free_phys_pages` for immediate reuse without waiting for `_flush`.
"""
if not self.lazy_compaction:
return
if not self._pending_reuse:
return
self._drain_pending_reuse(urgent=False)
def _topmost_survivor(
self,
start_hint: Optional[int] = None,
*,
holes_cpu: Optional[List[int]] = None,
j_in: Optional[int] = None,
) -> Tuple[Optional[int], Optional[int]]:
"""Topmost live PAGE in the allocated band (largest `p < watermark` for
grow-up / smallest `p > watermark` for grow-down), excluding holes
(`holes_cpu`, the sorted-ASCENDING snapshot) and `_pending_reuse_pages_cpu`.
Two-pointer: `p` is monotonic and `holes_cpu` is sorted, so the hole cursor
`j` (threaded back via the returns) advances alongside for O(1) membership;
no exclude-set needed because uncommitted dsts have p2v=-1 and are correctly
reported by the snapshot. Returns `(p, j)`, or `(None, j)` if none.
`holes_cpu`/`j_in` are optional only for test fixtures (else a `.tolist()`
sync); `_flush` always passes them.
"""
if holes_cpu is None:
holes_cpu = self._free_phys_pages.tolist()
if self.grow_direction == "up":
if start_hint is None or start_hint >= self.watermark_physical:
p = self.watermark_physical - 1
else:
p = start_hint
j = j_in if j_in is not None else len(holes_cpu) - 1
while p >= self.min_page_index:
while j >= 0 and holes_cpu[j] > p:
j -= 1
is_hole = j >= 0 and holes_cpu[j] == p
if is_hole or p in self._pending_reuse_pages_cpu:
if is_hole:
j -= 1
p -= 1
continue
return p, j
return None, j
else:
if start_hint is None or start_hint <= self.watermark_physical:
p = self.watermark_physical + 1
else:
p = start_hint
j = j_in if j_in is not None else 0
while p < self.num_pages:
while j < len(holes_cpu) and holes_cpu[j] < p:
j += 1
is_hole = j < len(holes_cpu) and holes_cpu[j] == p
if is_hole or p in self._pending_reuse_pages_cpu:
if is_hole:
j += 1
p += 1
continue
return p, j
return None, j
def _absorb_boundary_holes(self, all_cpu: List[int]) -> Tuple[int, List[int]]:
"""Retreat the watermark past free slots ALREADY contiguous with it, slice
them off `_free_phys_pages`, return ``(new_watermark, interior_holes_cpu)``.
`all_cpu` is the sorted-ascending snapshot; interior holes feed the survivor
walk.
"""
M = len(all_cpu)
wm = self.watermark_physical
n = 0
if self.grow_direction == "up":
while n < M and all_cpu[M - 1 - n] == wm - 1 - n:
n += 1
new_wm = wm - n
holes_cpu = all_cpu[: M - n]
self._free_phys_pages = self._free_phys_pages[: M - n]
else:
while n < M and all_cpu[n] == wm + 1 + n:
n += 1
new_wm = wm + n
holes_cpu = all_cpu[n:]
self._free_phys_pages = self._free_phys_pages[n:]
self.watermark_physical = new_wm
self._stats_n_pages_absorbed += n
return new_wm, holes_cpu
def _settle_inflight_forward(self) -> None:
"""Stream-wait the in-flight forward's done event so freed slots are safe
to MOVE (write settled) and REUSE (read settled). The event is recorded
after the WHOLE forward, so one wait covers both hazards; drop the write-set.
"""
ev = self._latest_forward_done_event
if ev is not None:
torch.cuda.current_stream().wait_event(ev)
self._inflight_forward = None
def _flush(self, *, urgent: bool) -> int:
"""One batched compaction pass; returns the number of survivor moves.
Pipeline (one D2H total, at step 3):
1. `_drain_pending_reuse` — return read-settled prior srcs.
2. sort the free list (or skip via env knob; either way ascending after).
3. `.tolist()` snapshot → `all_cpu` *(the one sync)*.
4-5. `_absorb_boundary_holes` — retreat past boundary-contiguous holes;
`holes_cpu` = interior holes. After this `_free_phys_pages==holes_cpu`.
6. (urgent) `_settle_inflight_forward` — wait once so the walk is race-free.
7. survivor walk — TWO-POINTER: move topmost live slot into the next hole,
STOPPING when the pointers cross (band packed); batch into one
`move_kv_cache` + one v2p/p2v scatter at `_commit_move_batch`.
8-9. exit: urgent → FULL-PACK reclaim (retreat past ALL holes, empty list);
non-urgent → slice consumed dsts, merge freed srcs back.
Two hazards per survivor (both keyed on the single `forward_done` event):
* WRITE race — forward overwrites KV[src]; a compaction read corrupts
KV[dst]. Non-urgent STOPS at such a src; urgent settles up front (step 6).
* READ race — forward READS KV[src]; src REUSE must wait the reader event.
`_commit_move_batch` routes such srcs to `_pending_reuse`; urgent's
settle makes them immediately reusable.
`_topmost_survivor` excludes all p2v=-1 pages, so a `v_moved < 0` in the
loop is a corrupt-state bug and raises.
"""
if not self.lazy_compaction:
return 0
self._stats_n_flush_calls += 1
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:
self._free_phys_pages, _ = torch.sort(self._free_phys_pages)
all_cpu = self._free_phys_pages.tolist() # the ONE D2H sync per flush
# `holes_cpu` = interior holes; `_free_phys_pages == holes_cpu` after.
new_wm, holes_cpu = self._absorb_boundary_holes(all_cpu)
latest_event = self._latest_forward_done_event
# Single-pass FULL-PACK (urgent only): the crossing-checked walk packs
# all live below the frontier so the exit can retreat past every
# interior hole at once — but only if each freed src is reuse-safe.
# `_latest_forward_done_event` is recorded after the WHOLE forward, so
# waiting it once settles BOTH hazards; then every src is event-fired
# and the walk runs race-free (empty write_set, no `_pending_reuse`).
single_pass_absorb = urgent and len(holes_cpu) > 0
if single_pass_absorb:
self._settle_inflight_forward()
latest_event = None # reads/writes settled → srcs are fired
# write_set: None = not yet materialized (do it inline on the first
# survivor needing the check); set() = no write race; else materialized.
write_set: Optional[Set[int]] = set() if single_pass_absorb else None
srcs: List[int] = []
dsts: List[int] = []
v_moveds: List[int] = []
# Flush-scoped accumulator for event-FIRED srcs. `_commit_move_batch`
# appends here instead of catting onto `_free_phys_pages`; the merge is
# 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-
# PENDING srcs still route to `_pending_reuse` (read-race gating).
released_fired: List[torch.Tensor] = []
cursor: Optional[int] = None
j_cursor: Optional[int] = None
# Dst cursor reads `holes_cpu` directly (no per-dst sync): grow-up from
# the front, grow-down from the back. Consumed prefix/suffix is sliced
# off in one GPU op at exit.
if self.grow_direction == "up":
dst_cursor = 0
else:
dst_cursor = len(holes_cpu) - 1
n_dst_consumed = 0
move_cap = self._lazy_max_moves_per_call if not urgent else None
n_moves = 0
while n_dst_consumed < len(holes_cpu):
src, j_cursor = self._topmost_survivor(
start_hint=cursor,
holes_cpu=holes_cpu,
j_in=j_cursor,
)
if src is None:
break
# Case A: write race.
if write_set is None:
materialized = self._materialize_inflight_write_set()
write_set = materialized if materialized is not None else set()
if write_set and src in write_set:
if urgent:
# Commit accumulated moves, then wait the forward so the
# rest of the walk is race-free.
self._commit_move_batch(
srcs, dsts, v_moveds, latest_event, released_fired
)
n_moves += len(srcs)
srcs.clear()
dsts.clear()
v_moveds.clear()
inflight = self._inflight_forward
if inflight is not None:
torch.cuda.current_stream().wait_event(inflight[0])
self._inflight_forward = None
write_set = set() # forward drained → no race
latest_event = None
# DO NOT reset cursor/j_cursor: rewinding would re-pick the
# just-committed srcs (now p2v=-1, not in holes_cpu) and
# trip the p2v=-1 assertion. Preserving cursor resumes at
# the blocker itself, which now passes under empty write_set.
continue
else:
break # non-urgent: top blocker stops the walk
# Case B/C: no write race. dst from holes_cpu by cursor (no sync).
dst = holes_cpu[dst_cursor]
# Two-pointer crossing check: once src and dst cross, the band is
# packed. Moving further would shuffle a hole back toward the
# frontier and block the watermark retreat, so stop — this is what
# lets one urgent pass reclaim ALL holes (not just a contiguous run).
if (self.grow_direction == "up" and src < dst) or (
self.grow_direction == "down" and src > dst
):
break
if self.grow_direction == "up":
dst_cursor += 1
else:
dst_cursor -= 1
n_dst_consumed += 1
v_moved = int(self.physical_to_virtual[src].item())
if v_moved < 0:
# `_topmost_survivor` excludes all p2v=-1 pages — corrupt state.
raise AssertionError(
f"MultiEndedAllocator({self.sub_pool_name!r})."
f"_flush: topmost survivor p={src} has p2v=-1; "
"this should be impossible (`_topmost_survivor` "
"excludes `holes_cpu` and `_pending_reuse_pages_cpu`)."
f" State: {self.allocator_state_str()}, "
f"#holes={len(holes_cpu)}, "
f"#pending_reuse={len(self._pending_reuse_pages_cpu)}"
)
srcs.append(src)
dsts.append(dst)
v_moveds.append(v_moved)
# Advance cursor strictly past the picked src.
if self.grow_direction == "up":
cursor = src - 1
else:
cursor = src + 1
if move_cap is not None and len(srcs) >= move_cap:
break
self._commit_move_batch(srcs, dsts, v_moveds, latest_event, released_fired)
n_moves += len(srcs)
if single_pass_absorb:
# FULL-PACK reclaim (urgent): all interior holes now sit above the
# frontier, so retreat past the whole lot and EMPTY the free list —
# those pages are beyond-frontier free space (reclaimed by the next
# extension), so `released_fired` is simply dropped too.
n_reclaimed = len(holes_cpu)
if self.grow_direction == "up":
self.watermark_physical = new_wm - n_reclaimed
else:
self.watermark_physical = new_wm + n_reclaimed
self._stats_n_pages_absorbed += n_reclaimed
self._free_phys_pages = self._free_phys_pages[:0]
else:
# Non-urgent partial pass: watermark stays; a later flush absorbs the
# now-top holes. `_free_phys_pages` is still == holes_cpu, so the
# consumed dsts are exactly the front (grow-up) / back (grow-down)
# `n_dst_consumed` entries; slice them, then merge freed srcs in one cat.
if n_dst_consumed > 0:
if self.grow_direction == "up":
self._free_phys_pages = self._free_phys_pages[n_dst_consumed:]
else:
self._free_phys_pages = self._free_phys_pages[:-n_dst_consumed]
if released_fired:
self._release_phys_pages_batch(
released_fired[0]
if len(released_fired) == 1
else torch.cat(released_fired)
)
if n_moves > 0:
self._stats_n_flush_did_work += 1
self._stats_n_flush_moves += n_moves
self._maybe_emit_stats()
return n_moves
def _commit_move_batch(
self,
srcs: List[int],
dsts: List[int],
v_moveds: List[int],
latest_event: Optional[torch.cuda.Event],
released_fired: List[torch.Tensor],
) -> None:
"""Issue ONE `move_kv_cache` + ONE bulk v2p/p2v remap for the accumulated
`(src, dst, v_moved)` triples. Fired srcs accumulate in `released_fired`
(merged by `_flush` AFTER its dst-slice, keeping the free list == holes_cpu);
event-pending srcs route to `_pending_reuse` (read-race gating).
"""
if not srcs:
return
with record_function("MultiEndedAlloc._commit_move_batch"):
src_pages_t = torch.tensor(srcs, dtype=torch.int64, device=self.device)
dst_pages_t = torch.tensor(dsts, dtype=torch.int64, device=self.device)
v_moveds_t = torch.tensor(v_moveds, dtype=torch.int64, device=self.device)
# Expand to token granularity (the move kernel is token-granular).
if self.page_size == 1:
src_t, dst_t = src_pages_t, dst_pages_t
else:
offsets = torch.arange(
self.page_size,
dtype=torch.int64,
device=self.device,
)
src_t = (src_pages_t[:, None] * self.page_size + offsets).reshape(-1)
dst_t = (dst_pages_t[:, None] * self.page_size + offsets).reshape(-1)
move_fn = getattr(self._kvcache, "move_kv_cache", None)
if move_fn is not None:
move_fn(dst_t, src_t)
else:
copy_phys = getattr(self._kvcache, "_copy_from_physical", None)
assert copy_phys is not None, (
f"sub-pool {self.sub_pool_name!r} supports neither "
"move_kv_cache nor _copy_from_physical"
)
copy_phys(src_t, dst_t)
# 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._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).
event_fired = latest_event is None or latest_event.query()
if event_fired:
released_fired.append(src_pages_t)
else:
srcs_copy: List[int] = list(srcs) # caller mutates `srcs`
self._pending_reuse[latest_event] = (srcs_copy, src_pages_t)
self._pending_reuse_pages_cpu.update(srcs_copy)
def flush_opportunistic(self) -> int:
"""Public, non-urgent flush at quiescent points; never blocks
`schedule_stream`. No-op if `lazy_compaction=False`.
Empty-set fast-path: the scheduler triggers this very often and ~99% hit
the empty state. Skip whenever there is no possible work — no holes AND no
pending entries (the in-flight write-set only matters when compacting).
"""
with record_function("MultiEndedAlloc.flush_opportunistic"):
if not self.lazy_compaction:
return 0
if self._free_phys_pages.numel() == 0 and not self._pending_reuse:
return 0
return self._flush(urgent=False)
def _raise_stale_slot_assertion(self, *, free_v, freed_p) -> None:
bad = free_v[freed_p < 0].tolist()
frames = inspect.stack()[1:9]
callers = " <- ".join(f"{f.filename.split('/')[-1]}:{f.lineno}" for f in frames)
raise AssertionError(
f"MultiEndedAllocator({self.sub_pool_name!r}).free: virtual id(s) {bad} have "
f"virtual_to_physical == -1 (double-free or never-allocated). "
f"State: {self.allocator_state_str()}. free_index unique={free_v.tolist()}. "
f"recent _inverse_history (last 3): "
f"{[(s.tolist(), d.tolist()) for s, d, _ in self._inverse_history[-3:]]}. "
f"Caller: {callers}."
)
# -- free-group --
def free_group_begin(self) -> None:
self.is_not_in_free_group = False
self.free_group = []
def free_group_end(self) -> None:
self.is_not_in_free_group = True
if self.free_group:
merged = torch.cat(self.free_group)
self.free_group = []
self.free(merged)
class UnifiedMambaTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
"""Composite allocator for the MHA (full-attn) + Mamba hybrid pair.
The token-slot surface delegates to the full-attn side (`alloc(N)` →
MHA token slots). The Mamba sub-pool's per-request `alloc(1)` is driven
separately by `UnifiedHybridReqToTokenPool`. Both sub-allocators are id-owners
of their own (independent) virtual-id spaces.
"""
def __init__(
self,
*,
unified_buffer: UnifiedKVPool,
kvcache, # HybridLinearKVPool
device: str,
page_size: int = 1,
need_sort: bool = False,
forward_stream: Optional[torch.cuda.Stream] = None,
lazy_compaction: bool = False,
):
full_max = unified_buffer.max_slots("full")
super().__init__(
size=full_max - 1,
page_size=page_size,
dtype=unified_buffer.mha_spec("full").store_dtype,
device=device,
kvcache=kvcache,
need_sort=need_sort,
)
self.unified_buffer = unified_buffer
self._kvcache = kvcache
self.page_size = page_size
self.lazy_compaction = lazy_compaction
# FULL is page-aware; MAMBA stays page_size=1 (state is per-request,
# orthogonal to the full side's per-token paging).
self.full_attn_allocator = MultiEndedAllocator(
kvcache=kvcache.full_kv_pool,
unified_buffer=unified_buffer,
sub_pool_name="full",
device=device,
is_id_owner=True,
page_size=page_size,
need_sort=need_sort,
forward_stream=forward_stream,
lazy_compaction=lazy_compaction,
)
self.mamba_allocator = MultiEndedAllocator(
kvcache=kvcache.mamba_pool,
unified_buffer=unified_buffer,
sub_pool_name="mamba",
device=device,
is_id_owner=True,
page_size=1, # Mamba state stays slot-granular (1-per-req)
need_sort=need_sort,
forward_stream=forward_stream,
lazy_compaction=lazy_compaction,
)
self.full_attn_allocator.bind_peer(self.mamba_allocator)
self.mamba_allocator.bind_peer(self.full_attn_allocator)
# The mamba slot allocator (PHYSICAL view) is built later by
# `init_unified_mamba_pools`, which wraps `self.mamba_allocator` in a
# `UnifiedMambaSlotAllocator` owning the v2p translate; the mamba pool is a
# pure PHYSICAL store. The full-attn KV pool needs no allocator either —
# write locations are resolved in the attention metadata.
self.is_not_in_free_group = True
self.free_group: List[torch.Tensor] = []
# 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)
logger.info(
"[unified-memory-pool] UnifiedMambaTokenToKVPoolAllocator ready: "
"full max_slots=%d (min_slot_index=%d, page_size=%d, "
"num_pages=%d), mamba max_slots=%d (min_slot_index=%d), "
"full_available=%d, mamba_available=%d",
self.full_attn_allocator.max_slots,
self.full_attn_allocator.min_slot_index,
self.full_attn_allocator.page_size,
self.full_attn_allocator.num_pages,
self.mamba_allocator.max_slots,
self.mamba_allocator.min_slot_index,
self.full_attn_allocator.available_size(),
self.mamba_allocator.available_size(),
)
# -- size: dynamic --
@property
def size(self) -> int:
# TOKENS. MUST use the SAME available view as `available_size()` so the
# leak invariant self-cancels (available term cancels → check reduces to
# `evictable + ... == allocated`, independent of peer-hole credit).
return (
self.full_attn_allocator.schedulable_available_size()
+ self.full_attn_allocator.allocated_count()
)
@size.setter
def size(self, value) -> None:
pass # base init writes here; computed dynamically
# -- token-slot surface: MHA side --
# Realizable-with-compaction view so the retract gate / evict / schedule_policy
# don't over-retract when the mamba peer holds drainable holes an urgent flush
# would convert into shared-gap room. Per-side alloc gates still use the
# un-credited `available_size()` so they flush before extending.
def available_size(self) -> int:
return self.full_attn_allocator.schedulable_available_size()
def full_available_size(self) -> int:
return self.full_attn_allocator.schedulable_available_size()
def mamba_slot_full_token_cost(self) -> int:
"""Full-token-equivalents of shared-gap bytes ONE mamba state consumes.
full and mamba share one byte buffer, so a mamba slot removes that many
full-KV tokens from the gap; the prefill planner reserves this so admission
stays inside the JOINT budget. = mamba bytes/slot ÷ full bytes/token, rounded
UP (conservative). Only on the shared composite (non-shared pools are separate,
so the planner sources this via `getattr(..., None)`).
"""
return -(
-self.mamba_allocator.entry_bytes_per_page
// self.full_attn_allocator.entry_bytes
)
@property
def size_full(self) -> int:
return self.full_attn_allocator.max_slots - 1
@property
def size_mamba(self) -> int:
return self.mamba_allocator.max_slots - 1
def debug_print(self) -> str:
return (
f"#full-available={self.full_attn_allocator.available_size()}, "
f"#mamba-available={self.mamba_allocator.available_size()}"
)
def get_kvcache(self):
return self._kvcache
def alloc(self, need_size: int) -> Optional[torch.Tensor]:
with record_function("UnifiedMambaAlloc.alloc"):
return self.full_attn_allocator.alloc(need_size)
def alloc_extend(
self,
prefix_lens: torch.Tensor,
prefix_lens_cpu: torch.Tensor,
seq_lens: torch.Tensor,
seq_lens_cpu: torch.Tensor,
last_loc: torch.Tensor,
extend_num_tokens: int,
num_new_pages: Optional[int] = None,
) -> Optional[torch.Tensor]:
"""Paged extend. Mamba state is per-request (doesn't advance per-token),
so forward only to the full sub-allocator."""
with record_function("UnifiedMambaAlloc.alloc_extend"):
return self.full_attn_allocator.alloc_extend(
prefix_lens,
prefix_lens_cpu,
seq_lens,
seq_lens_cpu,
last_loc,
extend_num_tokens,
num_new_pages=num_new_pages,
)
def alloc_decode(
self,
seq_lens: torch.Tensor,
seq_lens_cpu: torch.Tensor,
last_loc: torch.Tensor,
) -> Optional[torch.Tensor]:
"""Paged decode. Mamba side stays untouched per-decode."""
with record_function("UnifiedMambaAlloc.alloc_decode"):
return self.full_attn_allocator.alloc_decode(
seq_lens, seq_lens_cpu, last_loc
)
def translate_kv_loc(
self,
loc: torch.Tensor,
*,
out: Optional[torch.Tensor] = None,
) -> torch.Tensor:
"""Full-pool virtual TOKEN ids -> physical TOKEN ids. Delegates to the
full-side sub-allocator. Supports ``out=`` for cuda-graph buffer stability.
`-1` inputs map to `-1` (treated as padding downstream).
"""
result = self.full_attn_allocator.translate_kv_loc(loc, out=out)
return result
def is_slot_allocated(self, slot: int) -> bool:
return self.full_attn_allocator.is_slot_allocated(slot)
def allocator_state_str(self) -> str:
return self.full_attn_allocator.allocator_state_str()
def free(self, free_index: torch.Tensor) -> None:
with record_function("UnifiedMambaAlloc.free"):
if free_index is None or free_index.numel() == 0:
return
if not self.is_not_in_free_group:
self.free_group.append(free_index)
return
self.full_attn_allocator.free(free_index)
self.full_attn_allocator.clear_inverse_history()
self.mamba_allocator.clear_inverse_history()
def free_group_begin(self) -> None:
self.is_not_in_free_group = False
self.free_group = []
def free_group_end(self) -> None:
self.is_not_in_free_group = True
if self.free_group:
merged = torch.cat(self.free_group)
self.free_group = []
self.full_attn_allocator.free(merged)
self.full_attn_allocator.clear_inverse_history()
self.mamba_allocator.clear_inverse_history()
def backup_state(self):
return [
self.full_attn_allocator.backup_state(),
self.mamba_allocator.backup_state(),
]
def restore_state(self, state):
assert len(state) == 2
full_rollback = self.full_attn_allocator.restore_state(state[0])
mamba_rollback = self.mamba_allocator.restore_state(state[1])
return full_rollback + mamba_rollback
def clear(self) -> None:
self.full_attn_allocator.clear()
self.mamba_allocator.clear()
self.is_not_in_free_group = True
self.free_group = []
# -- Lazy compaction hooks --
def set_latest_forward_done_event(self, event: Optional[torch.cuda.Event]) -> None:
"""Forward the per-batch `forward_done` event to BOTH sub-allocators."""
with record_function("UnifiedMambaAlloc.set_latest_forward_done_event"):
self.full_attn_allocator.set_latest_forward_done_event(event)
self.mamba_allocator.set_latest_forward_done_event(event)
def set_inflight_forward(
self,
forward_done: torch.cuda.Event,
out_cache_loc_virtual: Optional[torch.Tensor],
) -> None:
"""Hand the forward's metadata to BOTH sub-pools. Full derives its write-set
from `out_cache_loc`; the Mamba state pool isn't written via `out_cache_loc`
(mamba kernels, not `set_kv_buffer`), so it gets `None`.
"""
with record_function("UnifiedMambaAlloc.set_inflight_forward"):
self.full_attn_allocator.set_inflight_forward(
forward_done, out_cache_loc_virtual
)
self.mamba_allocator.set_inflight_forward(forward_done, None)
def flush_opportunistic(self) -> int:
"""Non-urgent flush of BOTH sub-allocators; sync-free. Composite empty-set
fast-path skips both calls when neither side has work.
"""
with record_function("UnifiedMambaAlloc.flush_opportunistic"):
fa = self.full_attn_allocator
ma = self.mamba_allocator
if (
fa._free_phys_pages.numel() == 0
and not fa._pending_reuse
and ma._free_phys_pages.numel() == 0
and not ma._pending_reuse
):
return 0
return fa.flush_opportunistic() + ma.flush_opportunistic()
class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
"""Composite allocator for the hybrid SWA pair (full + swa MHA sub-pools).
Inherits from `SWATokenToKVPoolAllocator` only for the isinstance contract;
we call grand-parent `BaseTokenToKVPoolAllocator.__init__` directly to skip
the parent's static-partition sub-pool allocation (which unified-memory-pool
replaces).
Capacity views:
- `available_size()`: joint byte-budget, the only safe `alloc(N)` pre-check
(N slots cost N*(entry_full + entry_swa) shared-gap bytes).
- `_conserve_*`: slot-conservation, for the LEAK invariant only.
- `schedulable_*`: byte-coordinated, realizable-with-compaction.
- `full_available_size()` / `swa_available_size()`: per-side scheduler view
= min(conserve, schedulable).
"""
# Parent's `size` property has no setter but base init does `self.size = size`;
# override with a no-op setter. Reading returns `min(_size_full, _size_swa)`.
@property
def size(self) -> int:
return min(self._size_full, self._size_swa)
@size.setter
def size(self, value) -> None:
pass
def __init__(
self,
*,
unified_buffer: UnifiedKVPool,
kvcache, # UnifiedSWAKVPool
device: str,
full_max_total_num_tokens: int,
swa_max_total_num_tokens: int,
page_size: int = 1,
need_sort: bool = False,
forward_stream: Optional[torch.cuda.Stream] = None,
lazy_compaction: bool = False,
):
# Set _size_full / _size_swa BEFORE base init (read during it). STATIC
# partition caps — the slot-conservation value the leak invariant expects.
self._size_full = full_max_total_num_tokens
self._size_swa = swa_max_total_num_tokens
self._full_max_total_num_tokens = full_max_total_num_tokens
self._swa_max_total_num_tokens = swa_max_total_num_tokens
self.page_size = page_size
# Skip SWATokenToKVPoolAllocator.__init__; call grand-parent base init
# directly (its `self.size = size` is absorbed by our no-op setter).
BaseTokenToKVPoolAllocator.__init__(
self,
size=full_max_total_num_tokens,
page_size=page_size,
dtype=unified_buffer.mha_spec("full").store_dtype,
device=device,
kvcache=kvcache,
need_sort=need_sort,
)
self.unified_buffer = unified_buffer
self._kvcache = kvcache
self.lazy_compaction = lazy_compaction
self.full_attn_allocator = MultiEndedAllocator(
kvcache=kvcache.full_kv_pool,
unified_buffer=unified_buffer,
sub_pool_name="full",
device=device,
is_id_owner=True,
page_size=page_size,
need_sort=need_sort,
forward_stream=forward_stream,
lazy_compaction=lazy_compaction,
)
self.swa_attn_allocator = MultiEndedAllocator(
kvcache=kvcache.swa_kv_pool,
unified_buffer=unified_buffer,
sub_pool_name="swa",
device=device,
is_id_owner=False, # non-owner; consumes virtuals minted by full
page_size=page_size,
need_sort=need_sort,
forward_stream=forward_stream,
lazy_compaction=lazy_compaction,
)
self.full_attn_allocator.bind_peer(self.swa_attn_allocator)
self.swa_attn_allocator.bind_peer(self.full_attn_allocator)
# The full/SWA KV pools need no allocator wiring (write locations resolved
# in attention metadata); the composite keeps allocators for read-path translates.
kvcache.attach_allocators(
full_allocator=self.full_attn_allocator,
swa_allocator=self.swa_attn_allocator,
)
self.is_not_in_free_group = True
self.free_group: List[torch.Tensor] = []
# 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)
logger.info(
"[unified-memory-pool] UnifiedSWATokenToKVPoolAllocator ready: "
"full max_slots=%d (min_slot_index=%d, entry_bytes=%d), "
"swa max_slots=%d (min_slot_index=%d, entry_bytes=%d), "
"static caps full=%d swa=%d, joint available=%d",
self.full_attn_allocator.max_slots,
self.full_attn_allocator.min_slot_index,
self.full_attn_allocator.entry_bytes,
self.swa_attn_allocator.max_slots,
self.swa_attn_allocator.min_slot_index,
self.swa_attn_allocator.entry_bytes,
self._full_max_total_num_tokens,
self._swa_max_total_num_tokens,
self.available_size(),
)
# -- capacity reporting (three-way split) --
def available_size(self) -> int:
"""Tokens available for `alloc(N)` / `alloc_extend(N)` (TOKENS).
Joint byte-budget: each composite alloc(1) consumes one full-side AND one
swa-side page (same virtual id). The 3-phase lazy formula consumes both
sides' holes maximally before extending toward the gap (H_f/H_s = holes,
e_f/e_s = bytes/page, R_f/R_s = extension room, G = byte gap):
Phase 1 (both drain, free): K1 = min(H_f, H_s)
Phase 2 (fewer-holes side extends): K2 limited by remaining holes & G
Phase 3 (both extend): K3 = G // (e_f + e_s)
Total capped by index-space rooms (H_f + R_f, H_s + R_s). ps==1 collapses
to slot math. Eager has no holes → original joint formula.
"""
fa, sa = self.full_attn_allocator, self.swa_attn_allocator
e_f = fa.entry_bytes_per_page
e_s = sa.entry_bytes_per_page
# Direction-agnostic shared gap: the free byte band between the two pools.
if fa.grow_direction == "up":
gap_bytes = max(0, sa._byte_low_frontier() - fa._byte_high_frontier())
else:
gap_bytes = max(0, fa._byte_low_frontier() - sa._byte_high_frontier())
R_f = fa.num_pages - fa.min_page_index - fa._allocated_pages()
R_s = sa.num_pages - sa.min_page_index - sa._allocated_pages()
if not self.lazy_compaction:
pages_by_bytes = gap_bytes // (e_f + e_s)
return min(pages_by_bytes, R_f, R_s) * self.page_size
H_f = len(fa._free_phys_pages)
H_s = len(sa._free_phys_pages)
K1 = min(H_f, H_s) # Phase 1: both drain
# Phase 2: fewer-holes side extends; more-holes side keeps draining.
if H_f <= H_s:
e_phase2 = e_f
K_phase2_max = H_s
else:
e_phase2 = e_s
K_phase2_max = H_f
K2_room = K_phase2_max - K1
K2 = min(K2_room, gap_bytes // e_phase2) if e_phase2 > 0 else K2_room
gap_bytes -= K2 * e_phase2
K3 = gap_bytes // (e_f + e_s) # Phase 3: both extend
K_total = K1 + K2 + K3
K_total = min(K_total, H_f + R_f, H_s + R_s) # index-space caps
return K_total * self.page_size
# Slot-conservation views — the ONLY views the leak invariant should see
# (returning the byte-coordinated value would flag spurious leaks).
# `allocated_count()` is in TOKENS (the unit the leak check expects).
def _conserve_full_available_size(self) -> int:
return (
self._full_max_total_num_tokens - self.full_attn_allocator.allocated_count()
)
def _conserve_swa_available_size(self) -> int:
return (
self._swa_max_total_num_tokens - self.swa_attn_allocator.allocated_count()
)
# PHYSICAL per-side views read by scheduling / eviction consumers. The
# `min(...)` is sound under dynamic borrowing: the static-conserve cap bounds
# the lending side, the byte-coordinated `schedulable_*` bounds the side that
# has grown into the shared gap; whichever is tighter wins.
def full_available_size(self) -> int:
return min(
self._conserve_full_available_size(),
self.schedulable_full_available_size(),
)
def swa_available_size(self) -> int:
return min(
self._conserve_swa_available_size(),
self.schedulable_swa_available_size(),
)
# Byte-coordinated, realizable-with-compaction views (peer drainable holes
# credited — see `MultiEndedAllocator.schedulable_available_size`).
def schedulable_full_available_size(self) -> int:
return self.full_attn_allocator.schedulable_available_size()
def schedulable_swa_available_size(self) -> int:
return self.swa_attn_allocator.schedulable_available_size()
def _flush_both_for_alloc(self, need_tokens: int) -> bool:
"""SWA analogue of `_flush_peer_for_alloc`. Each composite alloc consumes a
full AND a swa page and either side's compaction opens gap for the other,
so flush BOTH (one urgent pass each).
"""
if not self.lazy_compaction:
return need_tokens <= self.available_size()
self.full_attn_allocator._flush(urgent=True)
self.swa_attn_allocator._flush(urgent=True)
return need_tokens <= self.available_size()
# `size_full` / `size_swa` are inherited; they read `_size_full`/`_size_swa`
# (set to the static caps). We do NOT report `max_slots - 1`: under unified
# memory pool that ~= full_max + swa_max and would over-promise.
def debug_print(self) -> str:
return (
f"#full-available={self.full_attn_allocator.available_size()}, "
f"#swa-available={self.swa_attn_allocator.available_size()}, "
f"#joint-available={self.available_size()}"
)
def get_kvcache(self):
return self._kvcache
def translate_kv_loc(
self,
loc: torch.Tensor,
*,
out: Optional[torch.Tensor] = None,
) -> torch.Tensor:
"""Full-layer read path: virtual TOKEN ids -> full-physical TOKEN ids.
Delegates to the full-side sub-allocator. Supports ``out=`` for cuda-graph.
"""
result = self.full_attn_allocator.translate_kv_loc(loc, out=out)
return result
def translate_loc_from_full_to_swa(
self,
kv_indices: torch.Tensor,
*,
out: Optional[torch.Tensor] = None,
) -> torch.Tensor:
"""SWA-layer read path: virtual TOKEN ids -> swa-physical TOKEN ids (int32,
matching the non-shared API). Page math against the swa side's v2p table.
Supports ``out=`` (int32, same shape) for cuda-graph buffer stability.
"""
if out is not None:
assert out.dtype == torch.int32, (
f"translate_loc_from_full_to_swa: out= dtype must be int32 "
f"(matches SWA Triton kernel contract), got {out.dtype}"
)
assert out.shape == kv_indices.shape, (
f"translate_loc_from_full_to_swa: out= shape "
f"{tuple(out.shape)} must match kv_indices shape "
f"{tuple(kv_indices.shape)}"
)
# Tombstone-safety clamp (mirrors the full-side clamp): tombstoned (-1)
# v2p_swa entries must not reach `swa_k_buffer[-1]` (illegal under replay).
# Clamp to 0 routes them to the reserved padding sink (slot 0).
if self.swa_attn_allocator.page_size == 1:
if out is not None:
# Gather into a transient int64, then cast into out (`out.copy_`).
tmp = torch.index_select(
self.swa_attn_allocator.virtual_to_physical, 0, kv_indices
)
tmp = torch.clamp_min(tmp, 0)
out.copy_(tmp.to(torch.int32))
return out
result = self.swa_attn_allocator.virtual_to_physical[kv_indices]
result = torch.clamp_min(result, 0)
return result.to(torch.int32)
ps = self.swa_attn_allocator.page_size
virt_pages = kv_indices // ps
offsets = kv_indices % ps
swa_phys_pages = self.swa_attn_allocator.virtual_to_physical[virt_pages]
result = (swa_phys_pages * ps + offsets).to(torch.int32)
result = torch.clamp_min(result, 0)
if out is not None:
out.copy_(result)
return out
return result
# -- alloc --
def alloc(self, need_size: int) -> Optional[torch.Tensor]:
with record_function("UnifiedSWAAlloc.alloc"):
# Joint pre-check. Both sides are mutual peers (each side's compaction
# opens gap for the other), so flush BOTH on shortfall.
if need_size > self.available_size():
if not self._flush_both_for_alloc(need_size):
return None
# Snapshot the virtual PAGES full will consume, to bind them on swa too.
num_pages = need_size // self.page_size
fa = self.full_attn_allocator
new_virtual_pages = fa.free_virtual_ids[:num_pages].clone()
v_tokens = fa.alloc(need_size)
# Post-pre-check failure can only be internal-state inconsistency.
assert v_tokens is not None, (
"UnifiedSWA.alloc: full.alloc returned None after joint "
"pre-check passed — internal-state inconsistency"
)
self.swa_attn_allocator.alloc_with_virtual(new_virtual_pages)
return v_tokens
def alloc_extend(
self,
prefix_lens: torch.Tensor,
prefix_lens_cpu: torch.Tensor,
seq_lens: torch.Tensor,
seq_lens_cpu: torch.Tensor,
last_loc: torch.Tensor,
extend_num_tokens: int,
) -> Optional[torch.Tensor]:
"""Paged extend. Runs the kernel ONCE in virtual space, then binds the
consumed virtual PAGES on the swa side via `alloc_with_virtual`. Returns
virtual TOKEN ids respecting the tail-page-reuse contract and the
cross-sub-pool identity (same virtual page maps to full- and swa-physical).
"""
with record_function("UnifiedSWAAlloc.alloc_extend"):
num_new_pages = get_num_new_pages(
seq_lens=seq_lens_cpu,
page_size=self.page_size,
prefix_lens=prefix_lens_cpu,
)
need_tokens = num_new_pages * self.page_size
if need_tokens > self.available_size():
if not self._flush_both_for_alloc(need_tokens):
return None
# Snapshot the virtual PAGES the kernel will consume; clone so swa keeps
# its view after the slice is consumed.
fa = self.full_attn_allocator
new_virtual_pages = fa.free_virtual_ids[:num_new_pages].clone()
out_indices = fa.alloc_extend(
prefix_lens,
prefix_lens_cpu,
seq_lens,
seq_lens_cpu,
last_loc,
extend_num_tokens,
num_new_pages=num_new_pages,
)
assert out_indices is not None, (
"UnifiedSWA.alloc_extend: full.alloc_extend returned None "
"after joint pre-check passed — internal-state inconsistency"
)
self.swa_attn_allocator.alloc_with_virtual(new_virtual_pages)
return out_indices # virtual TOKEN ids
def alloc_decode(
self,
seq_lens: torch.Tensor,
seq_lens_cpu: torch.Tensor,
last_loc: torch.Tensor,
) -> Optional[torch.Tensor]:
"""Paged decode. One new token per request (a page is consumed iff the
decode wraps). Same one-kernel-in-virtual-space discipline as ``alloc_extend``.
"""
with record_function("UnifiedSWAAlloc.alloc_decode"):
num_new_pages = get_num_new_pages(
seq_lens=seq_lens_cpu, page_size=self.page_size, decode=True
)
need_tokens = num_new_pages * self.page_size
if need_tokens > self.available_size():
if not self._flush_both_for_alloc(need_tokens):
return None
fa = self.full_attn_allocator
new_virtual_pages = fa.free_virtual_ids[:num_new_pages].clone()
out_indices = fa.alloc_decode(seq_lens, seq_lens_cpu, last_loc)
assert out_indices is not None, (
"UnifiedSWA.alloc_decode: full.alloc_decode returned None "
"after joint pre-check passed — internal-state inconsistency"
)
if new_virtual_pages.numel() > 0:
self.swa_attn_allocator.alloc_with_virtual(new_virtual_pages)
return out_indices # virtual TOKEN ids
def is_slot_allocated(self, slot: int) -> bool:
"""Token-slot surface = the full side (which owns the virtual ids)."""
return self.full_attn_allocator.is_slot_allocated(slot)
def allocator_state_str(self) -> str:
return self.full_attn_allocator.allocator_state_str()
# -- free --
def free(self, free_index: torch.Tensor) -> None:
with record_function("UnifiedSWAAlloc.free"):
if free_index is None or free_index.numel() == 0:
return
if not self.is_not_in_free_group:
self.free_group.append(free_index)
return
# Free both peers; the per-sub-pool v2p IS the mapping, so order isn't
# load-bearing. Filter the swa side to skip already-tombstoned virtuals
# (`swa.v2p_page == -1` from an earlier `free_swa`); the full side needs
# no filter (it's the lifecycle owner, so every value is still bound).
v = free_index.detach().to(torch.int64)
v_pages = v // self.page_size
swa_v2p_pages = self.swa_attn_allocator.virtual_to_physical[v_pages]
# `> 0` strict: -1 = tombstoned, 0 = padding-sink page; both skipped.
live_token_mask = swa_v2p_pages > 0
live_tokens = v[live_token_mask]
if live_tokens.numel() > 0:
self.swa_attn_allocator.free(live_tokens)
self.full_attn_allocator.free(v)
self.full_attn_allocator.clear_inverse_history()
self.swa_attn_allocator.clear_inverse_history()
def free_swa(self, free_index: torch.Tensor) -> None:
"""SWA tombstone path: release swa-physical, leave virtual id and
full-physical live. Called by `SWARadixCache._evict_swa_only` when a node
ages past the sliding-window horizon. `swa.v2p_page[v_page] = -1` IS the
tombstone.
"""
if free_index is None or free_index.numel() == 0:
return
# Keep only tokens whose virtual PAGE is still bound on swa (calling
# `swa.free` on an already-tombstoned one would assert).
v = free_index.detach().to(torch.int64)
v_pages = v // self.page_size
# `> 0` strict: -1 = tombstoned, page 0 = padding sink (never freeable).
swa_v2p_pages = self.swa_attn_allocator.virtual_to_physical[v_pages]
live = v[swa_v2p_pages > 0]
if live.numel() == 0:
return
self.swa_attn_allocator.free(live)
self.swa_attn_allocator.clear_inverse_history()
def set_full_to_swa_mapping(
self, full_indices: torch.Tensor, swa_indices: torch.Tensor
) -> None:
"""No-op stub for HiCache load-back compatibility. In shared mode there is
no mapping tensor (the swa v2p IS the mapping); HiCache for shared SWA is
out of scope.
"""
return
# -- free-group --
def free_group_begin(self) -> None:
self.is_not_in_free_group = False
self.free_group = []
def free_group_end(self) -> None:
self.is_not_in_free_group = True
if self.free_group:
merged = torch.cat(self.free_group)
self.free_group = []
self.free(merged)
# -- spec-decode hooks (asserted off; preserved for future use) --
def backup_state(self):
return [
self.full_attn_allocator.backup_state(),
self.swa_attn_allocator.backup_state(),
]
def restore_state(self, state):
assert len(state) == 2
full_rollback = self.full_attn_allocator.restore_state(state[0])
swa_rollback = self.swa_attn_allocator.restore_state(state[1])
return full_rollback + swa_rollback
def clear(self) -> None:
self.full_attn_allocator.clear()
self.swa_attn_allocator.clear()
self.is_not_in_free_group = True
self.free_group = []
# -- Lazy compaction hooks --
def set_latest_forward_done_event(self, event: Optional[torch.cuda.Event]) -> None:
"""Forward the per-batch `forward_done` event to BOTH sub-allocators."""
with record_function("UnifiedSWAAlloc.set_latest_forward_done_event"):
self.full_attn_allocator.set_latest_forward_done_event(event)
self.swa_attn_allocator.set_latest_forward_done_event(event)
def set_inflight_forward(
self,
forward_done: torch.cuda.Event,
out_cache_loc_virtual: Optional[torch.Tensor],
) -> None:
"""Hand the forward's metadata to BOTH sub-pools. Each materializes its
write-set via its OWN v2p; the forward writes both sides per new token,
so both get a non-empty in-flight tensor.
"""
with record_function("UnifiedSWAAlloc.set_inflight_forward"):
self.full_attn_allocator.set_inflight_forward(
forward_done, out_cache_loc_virtual
)
self.swa_attn_allocator.set_inflight_forward(
forward_done, out_cache_loc_virtual
)
def flush_opportunistic(self) -> int:
"""Non-urgent flush of BOTH sub-allocators; sync-free. Composite empty-set
fast-path skips both calls when neither side has work.
"""
with record_function("UnifiedSWAAlloc.flush_opportunistic"):
fa = self.full_attn_allocator
sa = self.swa_attn_allocator
if (
fa._free_phys_pages.numel() == 0
and not fa._pending_reuse
and sa._free_phys_pages.numel() == 0
and not sa._pending_reuse
):
return 0
return fa.flush_opportunistic() + sa.flush_opportunistic()