[unified-memory] PD disaggregation for every unified pool shape (#37506)

This commit is contained in:
Cheng Wan
2026-09-13 19:16:43 -07:00
committed by GitHub
parent 6388b6cfb1
commit 2ec4bbcbd4
18 changed files with 816 additions and 89 deletions
@@ -82,8 +82,10 @@ class Memory(msgspec.Struct):
"Replace the statically-partitioned hybrid-model pools (full-attn KV + "
"SWA/Mamba state) with one byte buffer split dynamically between "
"sub-pools. Requires the Triton attention / linear-attn / Mamba "
"backends; not yet compatible with PD disaggregation or speculative "
"decoding.",
"backends. PD disaggregation is supported over mooncake at equal "
"attention TP with pp=1; not yet compatible with hierarchical / "
"host-tiered KV cache, prefill cuda-graph capture, or speculative "
"decoding other than DSPARK.",
] = False
enable_session_radix_cache: A[
bool,
+1 -1
View File
@@ -1416,7 +1416,7 @@ class DecodePreallocQueue(DecodeHiCachePreallocMixin):
decode_req.req.kv.req_pool_idx, window_start:seq_len
]
window_kv_indices_swa = (
self.token_to_kv_pool_allocator.translate_loc_from_full_to_swa(
self.token_to_kv_pool_allocator.translate_swa_indices_for_transfer(
window_kv_indices_full
)
)
@@ -957,6 +957,13 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
prefill_data_indices=prefill_kv_indices,
dst_data_indices=dst_kv_indices,
executor=executor,
# The unified pool registers ONE region holding every layer's K and
# V inside each page envelope. The MHA branch would half-split that
# single region into K and V halves and compute num_kv_layers = 0,
# transferring nothing at all; the flat branch addresses the region
# as-is. MLA-unified already reaches the flat branch via
# is_mla_backend, so this only adds the MHA-unified peer.
force_flat=get_memory().enable_unified_memory,
src_layer_ids=self.kv_args.kv_layer_ids,
dst_layer_ids=dst_layer_ids,
dst_device_data_indices=dst_device_kv_indices,
@@ -1598,8 +1605,16 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
dst_data_indices=np.array(dst_indices_local, dtype=np.int32),
executor=executor,
state_type=st,
force_flat=st
in (StateType.QSA_PENDING, StateType.QSA_COMPRESSED),
# Two independent reasons to keep the flat layout.
# QSA's per-layer list must not be half-split into K/V;
# neither must a unified sub-pool's single region, which
# holds every layer's K and V per slot envelope -- the
# MHA branch would compute zero layers and ship nothing
# (same reason as in `send_kvcache`).
force_flat=(
st in (StateType.QSA_PENDING, StateType.QSA_COMPRESSED)
or get_memory().enable_unified_memory
),
src_layer_ids=src_state_layer_ids,
dst_layer_ids=dst_state_layer_ids,
)
+1 -1
View File
@@ -1328,7 +1328,7 @@ class SchedulerDisaggregationPrefillMixin:
req.kv.req_pool_idx, window_start:seq_len
]
window_kv_indices_swa = (
self.token_to_kv_pool_allocator.translate_loc_from_full_to_swa(
self.token_to_kv_pool_allocator.translate_swa_indices_for_transfer(
window_kv_indices_full
)
)
@@ -346,6 +346,17 @@ class DeepSeekV4HiSparseTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
def translate_loc_from_full_to_swa(self, kv_indices: torch.Tensor):
return self.logical_attn_allocator.translate_loc_from_full_to_swa(kv_indices)
def translate_swa_indices_for_transfer(
self, kv_indices: torch.Tensor
) -> torch.Tensor:
# Delegated like the read-path translate above: this composite is not a
# SWA allocator itself, so it inherits neither the default nor an
# override, and the PD payload path calls this on whatever allocator
# the scheduler holds.
return self.logical_attn_allocator.translate_swa_indices_for_transfer(
kv_indices
)
def full_available_size(self):
return min(
self.logical_attn_allocator.full_available_size(),
@@ -203,6 +203,19 @@ class SWATokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
assert self._kvcache.full_to_swa_index_mapping is not None
return self._kvcache.translate_loc_from_full_to_swa(kv_indices)
def translate_swa_indices_for_transfer(
self, kv_indices: torch.Tensor
) -> torch.Tensor:
"""Sliding-window token ids as the PD transfer engine addresses them.
The sibling of `translate_kv_indices_for_transfer` for the SWA state
component. On a static pool the sliding-window buffers are indexed by
the same ids the kernels use, so the read-path translate IS the answer.
A virtual-id pool must override: the transfer addresses raw bytes and
needs PHYSICAL ids, not kernel-facing ones.
"""
return self.translate_loc_from_full_to_swa(kv_indices)
def alloc(self, need_size: int):
assert self.page_size == 1
if need_size > self.full_attn_allocator.available_size():
@@ -17,7 +17,7 @@ sub-pools of one `UnifiedKVPool`, and the tri-pool variant that adds mamba state
from __future__ import annotations
import logging
from typing import List, Optional, Sequence
from typing import Callable, List, Optional, Sequence, Tuple
import torch
from torch.profiler import record_function
@@ -34,6 +34,7 @@ from sglang.srt.mem_cache.allocator.unified_sub_pool import (
_flush_deferred_free_group,
_full_tokens_before_mamba_recheck,
_relieve_for_alloc,
install_move_gate,
)
from sglang.srt.mem_cache.unified_memory_pool import UnifiedKVPool
from sglang.srt.utils.common import get_num_new_pages
@@ -320,6 +321,46 @@ class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
"""Page-level physical->virtual table of the full sub-pool."""
return self.full_attn_allocator.physical_to_virtual
def translate_kv_indices_for_transfer(
self, kv_indices: torch.Tensor
) -> torch.Tensor:
"""Virtual TOKEN ids -> full-sub-pool PHYSICAL token ids for the PD
transfer engine.
PHYSICAL, not kernel-facing: the transfer registers page ENVELOPES (see
`UnifiedMHATokenToKVPool.get_contiguous_buf_infos`). Without this
override the base identity would put VIRTUAL ids on the wire, which
address real bytes and so corrupt silently rather than fail.
"""
return self.full_attn_allocator.translate_kv_loc(kv_indices.to(torch.int64))
def translate_swa_indices_for_transfer(
self, kv_indices: torch.Tensor
) -> torch.Tensor:
"""Virtual TOKEN ids -> swa-sub-pool PHYSICAL token ids.
The SWA counterpart of the above. `translate_loc_from_full_to_swa`
cannot serve here: it returns KERNEL-FACING ids (the physical page
scaled by the sub-pool's per-page block count), which index the
per-layer views, whereas the SWA state component is registered as whole
page envelopes and addressed by physical page.
"""
return self.swa_attn_allocator.translate_kv_loc(kv_indices.to(torch.int64))
def _move_gate_targets(self):
"""Every member a compaction gate must cover. A subclass that adds an
end overrides THIS, and every gate widens with it."""
return (self.full_attn_allocator, self.swa_attn_allocator)
def set_disagg_move_gate(self, gate: Callable[[], bool]) -> None:
install_move_gate(
self._move_gate_targets(),
slot="disagg_move_gate",
gate=gate,
feature="PD disaggregation",
lazy_compaction=self.lazy_compaction,
)
def translate_kv_loc_for_kernel(
self,
loc: torch.Tensor,
@@ -374,6 +415,52 @@ class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
self.swa_attn_allocator.alloc_with_virtual(new_virtual_pages)
return v_tokens
def _extend_in_virtual_space(
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[Tuple[torch.Tensor, torch.Tensor]]:
"""Run the full side's paged extend and report which virtual PAGES it
newly took. Returns (virtual TOKEN ids, new virtual PAGE ids), or None
when the joint capacity check cannot fund the allocation.
Both extend entries share this; they differ only in which of those pages
the sliding-window side then binds.
"""
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 _relieve_for_alloc(self, 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: full.alloc_extend returned None after joint pre-check "
"passed — internal-state inconsistency"
)
return out_indices, new_virtual_pages
def alloc_extend(
self,
prefix_lens: torch.Tensor,
@@ -386,37 +473,75 @@ class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
"""Paged extend; returns virtual TOKEN ids. The same virtual page maps to
full- and swa-physical, so swa binds exactly what the full kernel consumed."""
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 _relieve_for_alloc(self, 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(
extended = self._extend_in_virtual_space(
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"
)
if extended is None:
return None
out_indices, new_virtual_pages = extended
self.swa_attn_allocator.alloc_with_virtual(new_virtual_pages)
return out_indices # virtual TOKEN ids
def alloc_extend_swa_tail(
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,
swa_tail_len: int,
) -> Optional[torch.Tensor]:
"""Decode-node prealloc: full KV for the whole sequence, sliding-window
KV for the live window tail only.
The static composite allocates the two sides independently and records
a full->swa index mapping. That is not representable here: the two
sides SHARE one virtual id space (a virtual page names a full-physical
page and, if bound, a swa-physical one), which is why
`set_full_to_swa_mapping` is a no-op on this allocator and
`translate_loc_from_full_to_swa` derives the swa id from the virtual id
instead of a table. Running the static body would call `alloc_extend`
on the swa sub-allocator, which asserts it is not the id owner.
The tail is expressed by binding swa for the TAIL's virtual pages only.
A new page left unbound has no swa-physical page, which reads as the
sink and is skipped by `free`'s `swa_v2p_page > 0` mask -- exactly the
out-of-window state the ratchet produces via `free_swa`.
Admission is priced at the FULL side's page count, as plain
`alloc_extend` is: pessimistic when the tail is short, but it reuses
the composite's audited joint capacity path, and the bytes actually
held still follow the tail.
"""
assert len(prefix_lens_cpu) == 1
assert 0 <= swa_tail_len <= extend_num_tokens
with record_function("UnifiedSWAAlloc.alloc_extend_swa_tail"):
extended = self._extend_in_virtual_space(
prefix_lens,
prefix_lens_cpu,
seq_lens,
seq_lens_cpu,
last_loc,
extend_num_tokens,
)
if extended is None:
return None
out_indices, new_virtual_pages = extended
if swa_tail_len > 0 and new_virtual_pages.numel() > 0:
tail_pages = torch.unique(out_indices[-swa_tail_len:] // self.page_size)
# Only NEW pages need binding; a tail page carried in from the
# prefix is already bound on the swa side.
to_bind = new_virtual_pages[torch.isin(new_virtual_pages, tail_pages)]
if to_bind.numel() > 0:
self.swa_attn_allocator.alloc_with_virtual(to_bind)
return out_indices # virtual TOKEN ids
def alloc_decode(
self,
seq_lens: torch.Tensor,
@@ -769,16 +894,9 @@ class UnifiedMambaSWATokenToKVPoolAllocator(UnifiedSWATokenToKVPoolAllocator):
binary search; the order matches the alloc path (full takes the high band).
"""
fa, sa = self.full_attn_allocator, self.swa_attn_allocator
e_f, e_s = fa.entry_bytes_per_page, sa.entry_bytes_per_page
e_f = fa.entry_bytes_per_page
# full is grow-down: its chain gap IS the high band.
b_high = fa._current_gap_bytes()
if sa._is_frontier_transparent():
b_low = 0
else:
b_low = max(
0,
sa._byte_low_frontier() - sa._chain_high_frontier_below_bytes(),
)
h_f = len(fa._free_phys_pages) if fa.lazy_compaction else 0
h_s = sa._hole_pages()
r_f = fa.num_pages - fa.min_page_index - fa._allocated_pages()
@@ -819,6 +937,16 @@ class UnifiedMambaSWATokenToKVPoolAllocator(UnifiedSWATokenToKVPoolAllocator):
hi_n = mid - 1
return lo_n * self.page_size
def _move_gate_targets(self):
"""All three members. The mamba end compacts independently and its slot
envelopes move as `StateType.MAMBA`, so leaving it out of a gate would
let a conv/SSM slot relocate under an in-flight transfer."""
return (
self.full_attn_allocator,
self.swa_attn_allocator,
self.mamba_allocator,
)
def _flush_targets(self):
"""All three members, float FIRST: its zero-copy boundary absorption must
land before the deficit math prices a relocation it already covered."""
@@ -31,6 +31,7 @@ from sglang.srt.mem_cache.allocator.unified_sub_pool import (
_flush_deferred_free_group,
_full_tokens_before_mamba_recheck,
_relieve_for_alloc,
install_move_gate,
)
from sglang.srt.mem_cache.unified_memory_pool import UnifiedKVPool
from sglang.srt.runtime_context import get_parallel
@@ -306,15 +307,20 @@ class UnifiedMambaTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
)
return self.full_attn_allocator.translate_kv_loc(kv_indices.to(torch.int64))
def _move_gate_targets(self):
"""Every member a compaction gate must cover. The mamba end is gated
even where its state is not itself transferred: the gate is about the
MOVER, and the two ends compact as peers."""
return (self.full_attn_allocator, self.mamba_allocator)
def set_disagg_move_gate(self, gate: Callable[[], bool]) -> None:
"""Install the PD-disaggregation move gate on both sub-allocators."""
assert self.lazy_compaction, (
"PD disaggregation with the unified memory pool requires lazy "
"compaction (eager free-path compaction moves pages under "
"in-flight transfers)."
install_move_gate(
self._move_gate_targets(),
slot="disagg_move_gate",
gate=gate,
feature="PD disaggregation",
lazy_compaction=self.lazy_compaction,
)
self.full_attn_allocator.disagg_move_gate = gate
self.mamba_allocator.disagg_move_gate = gate
def is_slot_allocated(self, slot: int) -> bool:
return self.full_attn_allocator.is_slot_allocated(slot)
@@ -239,6 +239,31 @@ def _full_tokens_before_mamba_recheck(
return -(-minimum_missing_bytes * dcp_size // full_allocator.entry_bytes)
def install_move_gate(
targets,
*,
slot: str,
gate: Callable[[], bool],
feature: str,
lazy_compaction: bool,
) -> None:
"""Point every member of a composite at one compaction gate.
A gate that reaches only some members is not a weaker gate, it is no gate:
the ungated end relocates its own pages under the same in-flight transfer.
So the member list is stated once per composite (`_move_gate_targets`) and
every gate installs over it, rather than each setter naming the members it
happens to remember.
"""
assert lazy_compaction, (
f"{feature} with the unified memory pool requires lazy compaction "
"(eager free-path compaction moves pages under in-flight transfers)."
)
assert slot in ("disagg_move_gate", "host_transfer_move_gate"), slot
for target in targets:
setattr(target, slot, gate)
class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
"""Allocator for one sub-pool over a `UnifiedKVPool`."""
@@ -155,11 +155,21 @@ def resolve_decode_retraction_backup(*, tp_worker: BaseTpWorker) -> str:
)
# Host-pool retraction transfers full and sliding-window components
# only, so a model with recurrent state stays on cpu_tensor.
supports_host_pool = not uses_ssm_state(
tp_worker.model_runner.model_config
) and (
isinstance(kv_cache, MHATokenToKVPool)
or (isinstance(kv_cache, SWAKVPool) and full_tokens_per_layer > 0)
#
# The unified pool is excluded for the same reason hierarchical cache is
# (see `handle_unified_memory_pool`): the host-transfer path indexes the
# device buffers with the ids it is handed, and under the unified pool
# those are VIRTUAL. It also cannot be sized from `kv_cache.size`, which
# is a KERNEL-FACING row count (`num_pages * 2 * layer_num * page_size`)
# rather than a token capacity -- gpt-oss-20b reports 85M "tokens" and
# asks for 418 GB of host memory per component.
supports_host_pool = (
not uses_ssm_state(tp_worker.model_runner.model_config)
and not memory.enable_unified_memory
and (
isinstance(kv_cache, MHATokenToKVPool)
or (isinstance(kv_cache, SWAKVPool) and full_tokens_per_layer > 0)
)
)
schedule = get_schedule()
priority_preemption = (
@@ -446,24 +446,12 @@ class KVCacheConfigurator:
# from one byte buffer, then return. Gated to the target worker
# (req_to_token_pool is None); supports hybrid Mamba and hybrid SWA (not DSV4).
if get_memory().enable_unified_memory and req_to_token_pool is None:
pd_enabled = get_disagg().disaggregation_mode != "null"
is_dsv4 = is_deepseek_v4(self.model_config.hf_config)
# Order matters: an Inkling-class model is BOTH mambaish and
# hybrid-SWA, and the mamba pair would store every SWA layer's KV at
# FULL lifetime -- its branch reads the HF config's
# full_attention_layer_ids, which for Inkling is ALL layers.
if self.mambaish_config is not None and self.is_hybrid_swa and not is_dsv4:
if pd_enabled:
# Same limitation as the 2-pool SWA branch below: the
# tri-pool carries an SWA sub-pool, and there is no
# whole-envelope transfer scheme for it.
raise ValueError(
"--enable-unified-memory with PD disaggregation does "
"not support hybrid-SWA models yet (no whole-envelope "
"transfer scheme for the SWA sub-pool); this model "
"routes to the mamba+SWA tri-pool, which has one. Drop "
"--enable-unified-memory or run without PD."
)
bundle = self._init_unified_mamba_swa_pools(
max_num_reqs=sizes.max_running_requests,
full_max_total_num_tokens=sizes.full_max_total_num_tokens,
@@ -471,27 +459,12 @@ class KVCacheConfigurator:
unified_total_bytes=sizes.unified_total_bytes,
)
elif self.mambaish_config is not None:
if pd_enabled and not self.use_mla_backend:
raise ValueError(
"--enable-unified-memory with PD disaggregation "
"currently supports only MLA hybrid-Mamba models "
"(e.g. kimi-linear); this model uses the MHA full-"
"attention pool. Drop --enable-unified-memory or run "
"without PD disaggregation."
)
bundle = self._init_unified_mamba_pools(
max_num_reqs=sizes.max_running_requests,
max_total_num_tokens=sizes.max_total_num_tokens,
unified_total_bytes=sizes.unified_total_bytes,
)
elif self.is_hybrid_swa and not is_dsv4:
if pd_enabled:
raise ValueError(
"--enable-unified-memory with PD disaggregation does "
"not support hybrid-SWA models yet (no whole-envelope "
"transfer scheme for the SWA sub-pool). Drop "
"--enable-unified-memory or run without PD."
)
bundle = self._init_unified_swa_pools(
max_num_reqs=sizes.max_running_requests,
full_max_total_num_tokens=sizes.full_max_total_num_tokens,
@@ -835,6 +808,13 @@ class KVCacheConfigurator:
unified_total_bytes=(None if self.is_draft_worker else unified_total_bytes),
# bs=1 feasibility floor input (context len is already passed).
sliding_window_size=self.model_config.sliding_window_size,
# Decode nodes hand out request rows to PREALLOCATED transfers on
# top of the running set; the 2-pool mamba factory takes the same.
decode_pre_alloc_size=(
get_disagg().disaggregation_decode_extra_slots
if get_disagg().disaggregation_mode == "decode"
else 0
),
)
def _init_unified_swa_pools(
@@ -863,12 +843,28 @@ class KVCacheConfigurator:
extra_max_context_len = 4
if get_spec().speculative_num_draft_tokens is not None:
extra_max_context_len += get_spec().speculative_num_draft_tokens
req_to_token_pool = ReqToTokenPool(
size=max_num_reqs,
max_context_len=self.model_config.context_len + extra_max_context_len,
device=self.device,
enable_memory_saver=get_exec().features.enable_memory_saver,
)
if get_disagg().disaggregation_mode == "decode":
# A decode node hands out request rows to PREALLOCATED transfers on
# top of its running set, so it needs the extra-slot pool (and the
# `pre_alloc_size` the scheduler's invariant checker reads). Mirrors
# `_build_req_to_token_pool`'s decode branch; the mamba composite
# already takes `decode_pre_alloc_size` the same way.
from sglang.srt.disaggregation.decode import DecodeReqToTokenPool
req_to_token_pool = DecodeReqToTokenPool(
size=max_num_reqs,
max_context_len=self.model_config.context_len + extra_max_context_len,
device=self.device,
enable_memory_saver=get_exec().features.enable_memory_saver,
pre_alloc_size=get_disagg().disaggregation_decode_extra_slots,
)
else:
req_to_token_pool = ReqToTokenPool(
size=max_num_reqs,
max_context_len=self.model_config.context_len + extra_max_context_len,
device=self.device,
enable_memory_saver=get_exec().features.enable_memory_saver,
)
head_num = self.model_config.get_num_kv_heads(
get_parallel().attn_tp_size, get_parallel().attn_dcp_size
@@ -624,10 +624,19 @@ class UnifiedMHATokenToKVPool(MHATokenToKVPool):
env[tgt_pages] = env[src_pages]
def get_contiguous_buf_infos(self):
raise NotImplementedError(
"unified layout has no per-layer contiguous regions; "
"KV transfer / disaggregation is unsupported."
)
"""PD-transfer registration: ONE entry, the raw buffer, addressed as
``raw_ptr + physical_page_id * page_envelope_bytes``.
Same whole-envelope contract as `UnifiedMLATokenToKVPool`: the transfer
item is one page across ALL layers and both K and V, because the
per-layer views overlap inside the envelope and index in kernel-facing
ids. A peer must therefore build an identical spec -- enforced on the
wire by `_validate_envelope_kv_layout`.
"""
# The address formula omits the anchor; a nonzero one would mis-address.
assert self._unified_buffer.anchor_bytes(self._sub_pool_name) == 0
raw = self._unified_buffer._raw
return [raw.data_ptr()], [raw.numel()], [self._page_bytes]
def get_cpu_copy(self, indices, mamba_indices=None):
raise NotImplementedError(
@@ -1882,6 +1891,7 @@ def init_unified_mamba_swa_pools(
lazy_compaction: bool = False,
unified_total_bytes: Optional[int] = None,
sliding_window_size: Optional[int] = None,
decode_pre_alloc_size: int = 0,
) -> UnifiedPoolBundle:
"""Build the TRI-pool unified-memory-pool stack for models with full KV +
SWA KV + mamba/conv state (Inkling-class: `mambaish_config` AND
@@ -2007,6 +2017,7 @@ def init_unified_mamba_swa_pools(
speculative_num_draft_tokens=speculative_num_draft_tokens,
enable_overlap_schedule=not disable_overlap_schedule,
start_layer=start_layer,
pre_alloc_size=decode_pre_alloc_size,
)
allocator = UnifiedMambaSWATokenToKVPoolAllocator(
unified_buffer=shared_pool,