[NPU] Support DSV4 host memory cache management (#37382)

This commit is contained in:
Cheng Xin
2026-09-14 10:38:50 +08:00
committed by GitHub
parent 7b4972a9cb
commit ca8ecc6a6f
12 changed files with 1010 additions and 94 deletions
@@ -6,7 +6,7 @@ radix tree; partial tail pages remain request-owned.
from __future__ import annotations
from typing import TYPE_CHECKING, Callable, Optional
from typing import TYPE_CHECKING, Callable, Optional, Sequence
import torch
@@ -16,14 +16,21 @@ from sglang.srt.mem_cache.base_prefix_cache import (
MatchPrefixParams,
MatchResult,
)
from sglang.srt.mem_cache.hicache_storage import (
PoolName,
PoolTransfer,
PoolTransferResult,
)
from sglang.srt.mem_cache.unified_cache.cache_action import (
FreeComponentDeviceSlot,
SWARebuild,
)
from sglang.srt.mem_cache.unified_cache.components import (
BASE_COMPONENT_TYPE,
CacheTransferPhase,
ComponentType,
EvictLayer,
PrepareLoadBackResult,
TreeComponent,
)
@@ -33,14 +40,16 @@ if TYPE_CHECKING:
CacheAction,
ComponentAction,
)
from sglang.srt.mem_cache.unified_radix_cache import (
UnifiedTreeNode,
)
from sglang.srt.mem_cache.unified_radix_cache import UnifiedTreeNode
class C128SidecarComponent(TreeComponent):
component_type = ComponentType.C128
# Bound by _apply_stack_result (hybrid_pool_assembler) on the NPU path:
# C128 is an independent-index pool whose host values live in this pool.
_c128_kv_pool_host = None
@property
def allocator(self):
return self.cache.token_to_kv_pool_allocator
@@ -97,8 +106,31 @@ class C128SidecarComponent(TreeComponent):
def create_match_validator(
self, match_device_only: bool = False
) -> Callable[[UnifiedTreeNode], bool]:
# A page is attached only to the node ending its full physical group.
return lambda node: node.component_data[self.component_type].value is not None
# Pages attach only to full-group endpoints. A host-backed endpoint is a
# valid match unless match_device_only requires device residency.
def _valid(node: UnifiedTreeNode) -> bool:
cd = node.component_data[self.component_type]
if match_device_only:
return cd.value is not None
return cd.value is not None or cd.host_value is not None
return _valid
def _collect_device_pages(self, node_id: int) -> torch.Tensor:
chunks = []
node = self.tree_core.node_by_id(node_id)
root = self.tree_core.root_node
while node is not root:
value = node.component_data[self.component_type].value
if value is not None:
chunks.append(value)
node = node.parent
chunks.reverse()
return (
torch.cat(chunks)
if chunks
else self.allocator.c128_attn_allocator.free_pages.new_empty((0,))
)
def finalize_match_result_in_cache(
self, params: MatchPrefixParams, result: MatchResult
@@ -107,31 +139,52 @@ class C128SidecarComponent(TreeComponent):
if req is None:
return result
chunks = []
node = self.tree_core.node_by_id(result.best_match_node)
root = self.tree_core.root_node
while node is not root:
value = node.component_data[self.component_type].value
if value is not None:
chunks.append(value)
node = node.parent
chunks.reverse()
pages = (
torch.cat(chunks)
if chunks
else self.allocator.c128_attn_allocator.free_pages.new_empty((0,))
)
group_tokens = 128 * self.allocator.c128_attn_allocator.page_size
assert pages.numel() == len(result.device_indices) // group_tokens
pages = self._collect_device_pages(result.best_match_node)
self.cache.req_to_token_pool.set_c128_prefix_pages(req, pages)
return result
def finalize_match_result_in_tree_core(
self,
result: MatchResult,
params: MatchPrefixParams,
value_chunks: list[torch.Tensor],
best_value_len: int,
) -> MatchResult:
branching_seqlen = result.swa_branching_seqlen
if branching_seqlen is None:
return result
# SWA initially derives its branching point from the Full-KV hit and the
# radix page size. C128 can only resume from a complete compression-group
# endpoint, so constrain that candidate to the nearest C128 boundary.
group_tokens = 128 * self.allocator.c128_attn_allocator.page_size
branching_seqlen = branching_seqlen // group_tokens * group_tokens
current_boundary = len(result.device_indices) + result.host_hit_length
return result._replace(
swa_branching_seqlen=(
branching_seqlen if branching_seqlen > current_boundary else None
)
)
def finalize_load_back(
self, req: Optional[Req], prep: PrepareLoadBackResult, success: bool
) -> None:
if not success or req is None:
return
# match_prefix runs before load-back, so a host-only C128 endpoint is
# absent from the request-local page table built by the match finalizer.
# Refresh it after commit_load_back attaches the restored page to the tree.
pages = self._collect_device_pages(req.best_match_node)
self.cache.req_to_token_pool.set_c128_prefix_pages(req, pages)
def recover_after_unevict(
self,
node: UnifiedTreeNode,
prefix_len: int,
total_prefix_len: int,
params: InsertParams,
result: InsertResult,
cache_actions: list[CacheAction | ComponentAction],
) -> None:
pages = params.c128_value
@@ -236,13 +289,26 @@ class C128SidecarComponent(TreeComponent):
) -> tuple[int, int]:
cd = node.component_data[self.component_type]
if EvictLayer.DEVICE in target and cd.value is not None:
# Device pages use retain/release_c128_pages refcounts.
# _drain_device_frees converts these IDs into release actions.
device_frees[self.component_type].append(cd.value)
self.tree_core.component_evictable_size_[self.component_type] -= len(
cd.value
)
cd.value = None
# C128 pages are auxiliary to Full tokens and must not inflate the
# public token-eviction count.
# A device tombstone with a host copy makes the node host-only.
# Promote it so every host-only node remains in host_lru.
if cd.host_value is not None:
host_lru = self.tree_core.host_lru_lists[self.component_type]
if not host_lru.in_list(node):
host_lru.insert_mru(node)
if EvictLayer.HOST in target and cd.host_value is not None:
# Host values have no refcount; free_host_values returns them directly.
host_frees[self.component_type].append(cd.host_value)
cd.host_value = None
# C128 pages are auxiliary to Full tokens and must not inflate the public
# token-eviction count; the host return also stays 0 so a FULL host-leaf
# eviction's tracker only counts FULL tokens (C128 is a required payload).
return 0, 0
def prepare_for_caching_req(
@@ -285,10 +351,151 @@ class C128SidecarComponent(TreeComponent):
pass
def acquire_component_lock(self, node, result, lock_host=False):
# Device path is a no-op: C128 device pages are owned via refcount, not
# the FULL path-lock. Host path mirrors FULL's single-node host lock.
if lock_host:
cd = node.component_data[self.component_type]
# write_back mode: the anchor may be device-only (no host_value);
# pin it anyway.
if cd.host_value is None and not self.tree_core.is_write_back:
return result
cd.host_lock_ref += 1
self.tree_core._update_evictable_leaf_sets(node)
return result
def release_component_lock(self, node, params, lock_host=False) -> None:
pass
if lock_host:
cd = node.component_data[self.component_type]
if cd.host_lock_ref == 0:
return
# Mirror of `acquire`. write_back uses a pure counter.
if cd.host_value is None and not self.tree_core.is_write_back:
return
cd.host_lock_ref -= 1
self.tree_core._update_evictable_leaf_sets(node)
def free_host_values(self, host_values) -> None:
pass
if self._c128_kv_pool_host is None:
return
for host_value in host_values:
self._c128_kv_pool_host.free(host_value)
# ---- HiCache Hooks ----
@staticmethod
def _expand_page_indices(page_ids: torch.Tensor, page_size: int) -> torch.Tensor:
"""Expand each page ID to ``page_id * page_size + arange(page_size)``."""
page_ids = page_ids.view(-1)
if page_ids.numel() == 0:
return page_ids.new_empty((0,), dtype=torch.int64)
return (
page_ids[:, None] * page_size
+ torch.arange(page_size, device=page_ids.device)
).flatten()
def build_hicache_transfers(
self,
node: UnifiedTreeNode,
phase: CacheTransferPhase,
*,
mamba_pool_idx: Optional[torch.Tensor] = None,
host_indices: Optional[torch.Tensor] = None,
token_ids: Optional[Sequence[int]] = None,
prefetch_tokens: int = 0,
last_hash: Optional[str] = None,
) -> Optional[list[PoolTransfer]]:
ct = self.component_type
page_size = self.allocator.c128_attn_allocator.page_size
if phase == CacheTransferPhase.BACKUP_HOST:
# Back up the C128 pages attached to this node (its group endpoints).
# The transfer is independent (indices_from_pool=None): the controller
# allocates C128 host slots of len(device_indices) = groups * P.
page_ids = node.component_data[ct].value
if page_ids is None or page_ids.numel() == 0:
return None
return [
PoolTransfer(
name=PoolName.DEEPSEEK_V4_C128,
indices_from_pool=None,
device_indices=self._expand_page_indices(page_ids, page_size),
nodes_to_load=[node.id],
)
]
if phase == CacheTransferPhase.LOAD_BACK:
# Collect host values from complete-group endpoints on the evicted path.
# For example, G groups produce G * page_size host and device indices.
backed_up: list[torch.Tensor] = []
nodes: list[UnifiedTreeNode] = []
cur = node
while cur is not self.tree_core.root_node and cur.evicted:
cd = cur.component_data[ct]
if cd.host_value is not None:
backed_up.append(cd.host_value)
nodes.append(cur)
cur = cur.parent
if not backed_up:
return None
backed_up.reverse()
nodes.reverse()
return [
PoolTransfer(
name=PoolName.DEEPSEEK_V4_C128,
indices_from_pool=None,
host_indices=torch.cat(backed_up),
device_indices=None,
nodes_to_load=[n.id for n in nodes],
)
]
return None
def commit_hicache_transfer(
self,
node: UnifiedTreeNode,
phase: CacheTransferPhase,
transfers: list[PoolTransfer] = (),
*,
cache_actions: list[CacheAction | ComponentAction],
insert_result: Optional[InsertResult] = None,
pool_storage_result: Optional[PoolTransferResult] = None,
) -> None:
ct = self.component_type
page_size = self.allocator.c128_attn_allocator.page_size
if phase == CacheTransferPhase.BACKUP_HOST:
# Publish the controller-allocated host slots as this node's C128
# host residency (L2). The device value stays until demote/evict.
if transfers and transfers[0].host_indices is not None:
node.component_data[ct].host_value = transfers[0].host_indices.clone()
# An evict-then-backup race can make this node host-only.
# Insert it now so every host-only node remains in host_lru.
if node.component_data[ct].value is None:
host_lru = self.tree_core.host_lru_lists[ct]
if not host_lru.in_list(node):
host_lru.insert_mru(node)
return
if phase == CacheTransferPhase.LOAD_BACK:
if not transfers or transfers[0].device_indices is None:
return
xfer = transfers[0]
device_indices = xfer.device_indices
offset = 0
for nid in xfer.nodes_to_load or []:
n = self.tree_core.node_by_id(nid)
cd = n.component_data[ct]
n_len = len(cd.host_value)
# Each page occupies P consecutive expanded slots; ``// P`` yields
# P copies of the page id, so unique() recovers the distinct page
# ids (the allocator's retain_c128_pages does NOT dedup).
page_ids = torch.unique(
device_indices[offset : offset + n_len] // page_size
)
# Once retained, tree eviction releases these pages.
# The controller frees them only on rollback before commit.
self.allocator.retain_c128_pages(page_ids)
self.tree_core.set_component_device_value(nid, ct, page_ids.clone())
offset += n_len
return
@@ -30,7 +30,12 @@ from sglang.srt.hardware_backend.npu.dsv4.dsv4_common_hooks import (
maybe_write_dsv4_extend,
)
from sglang.srt.mem_cache.allocation import alloc_paged_token_slots_extend
from sglang.srt.mem_cache.allocation_sizing import (
get_alloc_reserve_per_decode,
page_aligned_decode_alloc_lens,
)
from sglang.srt.mem_cache.allocator.swa import SWATokenToKVPoolAllocator
from sglang.srt.mem_cache.base_prefix_cache import BasePrefixCache, EvictParams
from sglang.srt.model_executor.forward_batch_info import DSV4OutCacheLoc
@@ -262,14 +267,80 @@ class DSV4NPUTokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
)
return result
def c128_num_pages_needed(
self, prefix_lens_cpu: torch.Tensor, seq_lens_cpu: torch.Tensor
) -> int:
"""Return the number of new C128 physical pages needed by this step."""
if prefix_lens_cpu is None or seq_lens_cpu is None:
return 0
ratio = 128
page_size = self.c128_attn_allocator.page_size
prefix_pages = (prefix_lens_cpu // ratio + page_size - 1) // page_size
seq_pages = (seq_lens_cpu // ratio + page_size - 1) // page_size
return int((seq_pages - prefix_pages).clamp(min=0).sum().item())
def ensure_c128_capacity(
self, tree_cache: Optional[BasePrefixCache], num_pages: int
) -> bool:
"""Reclaim C128 pages through FULL leaf eviction.
Evicting FULL also reclaims its C128 payload without interior holes."""
if num_pages <= 0:
return True
page_size = self.c128_attn_allocator.page_size
def available_pages() -> int:
return self.c128_attn_allocator.available_size() // page_size
if available_pages() >= num_pages:
return True
if tree_cache is None or tree_cache.is_chunk_cache():
return False
while available_pages() < num_pages:
result = tree_cache.evict(EvictParams(num_tokens=self.page_size))
if result.num_tokens_evicted == 0:
break
return available_pages() >= num_pages
def check_decode_capacity(
self,
*,
num_tokens: int,
tree_cache,
requests=None,
spec_algorithm=None,
) -> bool:
"""Check FULL/SWA and exact per-request C128 demand for one decode."""
if requests is None:
return super().check_decode_capacity(
num_tokens=num_tokens, tree_cache=tree_cache
)
self.evict_to_free_tokens(tree_cache, num_tokens)
if spec_algorithm is not None and spec_algorithm.is_some():
prefix_lens, seq_lens, _ = page_aligned_decode_alloc_lens(
requests,
reserve=get_alloc_reserve_per_decode(),
page_size=self.page_size,
)
else:
prefix_lens = [req.kv.kv_allocated_len for req in requests]
seq_lens = [length + 1 for length in prefix_lens]
prefix_lens_cpu = torch.tensor(prefix_lens, dtype=torch.int64)
seq_lens_cpu = torch.tensor(seq_lens, dtype=torch.int64)
c128_num_pages = self.c128_num_pages_needed(prefix_lens_cpu, seq_lens_cpu)
c128_ok = self.ensure_c128_capacity(tree_cache, c128_num_pages)
full_swa_ok = self.full_swa_available_size() >= num_tokens
return full_swa_ok and c128_ok
def _has_c128_sidecar_capacity(
self, prefix_lens_cpu: torch.Tensor, seq_lens_cpu: torch.Tensor
) -> bool:
ratio = 128
need = self.c128_num_pages_needed(prefix_lens_cpu, seq_lens_cpu)
page_size = self.c128_attn_allocator.page_size
prefix_groups = (prefix_lens_cpu // ratio + page_size - 1) // page_size
seq_groups = (seq_lens_cpu // ratio + page_size - 1) // page_size
need = int((seq_groups - prefix_groups).clamp(min=0).sum().item())
return need <= self.c128_attn_allocator.available_size() // page_size
def _alloc_compressed_kv(
@@ -481,6 +552,9 @@ class DSV4NPUTokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
self.c128_attn_allocator.available_size() * 128,
)
def full_swa_available_size(self):
return super().available_size()
def resize(self, config) -> None:
self.c128_attn_allocator.size = int(config.c128_max_total_num_tokens)
self.c128_attn_allocator.num_pages = (
@@ -76,6 +76,11 @@ class NPUDeepSeekV4SingleKVPool(DeepSeekV4SingleKVPool):
kv_dim = self.qk_nope_head_dim + self.qk_rope_head_dim
kv_dtype = torch.bfloat16
self.kv_cache_total_dim = kv_dim
# The HiCache assembler uses bytes_per_page_padded as host item_bytes.
# For example: kernel_page_size * kv_dim * sizeof(bf16).
self.bytes_per_page_padded = (
self.kernel_page_size * kv_dim * torch.bfloat16.itemsize
)
# Writes are flat-indexed by loc; kernel_page_size controls the physical
# page layout exposed to the NPU operators.
npu_num_pages = (self.size + self.kernel_page_size + 1) // self.kernel_page_size
@@ -464,6 +469,7 @@ class DSV4NPUTokenToKVPool(DeepSeekV4TokenToKVPool):
# ------------------------------------------------------------------
def get_key_buffer(self, layer_id: int) -> torch.Tensor:
self.wait_layer_transfer(layer_id)
item = self.layer_mapping[layer_id]
ratio = item.compress_ratio
if ratio == 0:
@@ -490,6 +496,7 @@ class DSV4NPUTokenToKVPool(DeepSeekV4TokenToKVPool):
flatten across (num_pages, page_size) and gather the matching tokens —
shape becomes (num_tokens, 1, dim).
"""
self.wait_layer_transfer(layer_id)
# Index by RAW layer_id, not compress_layer_id (a per-bucket counter that
# would collide across ratios). swa_kv_pool is sized layer_num=total_layers.
kv = self.swa_kv_pool.kv_buffer[layer_id]
@@ -510,6 +517,7 @@ class DSV4NPUTokenToKVPool(DeepSeekV4TokenToKVPool):
from_indexer=True branch returns the dedicated quantized K buffer that
``torch.ops.custom.npu_quant_lightning_indexer`` consumes.
"""
self.wait_layer_transfer(layer_id)
item = self.layer_mapping[layer_id]
if item.compress_ratio == 0:
return None
+10 -5
View File
@@ -99,10 +99,7 @@ from sglang.srt.managers.embed_types import PositionalEmbeds
from sglang.srt.managers.scheduler_components.new_token_ratio_tracker import (
NewTokenRatioTracker,
)
from sglang.srt.mem_cache.allocation import (
alloc_for_decode,
alloc_for_extend,
)
from sglang.srt.mem_cache.allocation import alloc_for_decode, alloc_for_extend
from sglang.srt.mem_cache.allocation_sizing import get_alloc_reserve_per_decode
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
from sglang.srt.mem_cache.base_prefix_cache import (
@@ -3097,8 +3094,16 @@ class ScheduleBatch(ScheduleBatchDisaggregationDecodeMixin):
shortfalls retract gracefully instead of tripping fail-loud alloc
errors."""
num_tokens = self.new_tokens_required_next_decode(selected_indices)
requests = (
self.reqs
if selected_indices is None
else [self.reqs[i] for i in selected_indices]
)
return self.token_to_kv_pool_allocator.check_decode_capacity(
num_tokens=num_tokens, tree_cache=self.tree_cache
num_tokens=num_tokens,
tree_cache=self.tree_cache,
requests=requests,
spec_algorithm=self.spec_algorithm,
)
def retract_decode(self) -> Tuple[List[Req], float, List[Req]]:
@@ -201,6 +201,8 @@ def alloc_paged_token_slots_extend(
)
extra_alloc_kwargs["rotation_bases"] = kv_shard_rotation_bases
if is_dsv4:
c128_num_pages = allocator.c128_num_pages_needed(prefix_lens_cpu, seq_lens_cpu)
allocator.ensure_c128_capacity(tree_cache, c128_num_pages)
extra_alloc_kwargs["req_pool_indices"] = req_pool_indices
# Per-call per-req table for the C128 KV last_loc lookup.
if batch is not None:
@@ -549,6 +551,10 @@ def alloc_paged_token_slots_decode(
is_dsv4 = req_pool_indices is not None and hasattr(allocator, "c128_attn_allocator")
extra_alloc_kwargs = {}
if is_dsv4:
c128_num_pages = allocator.c128_num_pages_needed(
(seq_lens_cpu - 1).clamp(min=0), seq_lens_cpu
)
allocator.ensure_c128_capacity(tree_cache, c128_num_pages)
extra_alloc_kwargs["req_pool_indices"] = req_pool_indices
# Per-call per-req C128 table for the last_loc lookup.
if batch is not None:
+11 -2
View File
@@ -76,10 +76,19 @@ class BaseTokenToKVPoolAllocator(abc.ABC):
evict_from_tree_cache(tree_cache, num_tokens)
def check_decode_capacity(self, *, num_tokens: int, tree_cache) -> bool:
def check_decode_capacity(
self,
*,
num_tokens: int,
tree_cache,
requests=None,
spec_algorithm=None,
) -> bool:
"""Whether the next decode step's ``num_tokens`` allocation fits after
evicting reclaimable cache. The retract loop converges on this same
check, so a shortfall here retracts instead of failing in alloc."""
check, so a shortfall here retracts instead of failing in alloc.
``requests`` and ``spec_algorithm`` provide optional request-level context for allocators
whose demand cannot be represented by a single token count."""
self.evict_to_free_tokens(tree_cache, num_tokens)
return self.available_size() >= num_tokens
@@ -27,11 +27,7 @@ from sglang.srt.mem_cache.pool_host.mha import (
)
from sglang.srt.mem_cache.pool_host.mla import MLATokenToKVPoolHost
from sglang.srt.mem_cache.unified_cache.component_type import ComponentType
from sglang.srt.runtime_context import (
get_memory,
get_parallel,
get_serving,
)
from sglang.srt.runtime_context import get_memory, get_parallel, get_serving
if TYPE_CHECKING:
import torch
@@ -441,7 +437,7 @@ def _deepseek_v4_num_host_pages(
kvcache: Any,
page_size: int,
swa_page_size: int,
) -> tuple[int, int]:
) -> tuple[int, int, int]:
allocator = params.token_to_kv_pool_allocator
device_full_size = getattr(allocator, "size_full", kvcache.size)
device_full_pages = (device_full_size + page_size - 1) // page_size
@@ -456,7 +452,15 @@ def _deepseek_v4_num_host_pages(
ratio = get_memory().hicache_ratio
full_host_pages = int(device_full_pages * ratio)
swa_host_pages = int(device_swa_pages * ratio)
return full_host_pages, swa_host_pages
# NPU sizes the independent C128 host pool from its device page count.
# For example, host pages = device pages * hicache_ratio.
# GPU keeps the FULL page count because C128 is a KV-derived sidecar.
c128_host_pages = full_host_pages
c128_attn_allocator = getattr(allocator, "c128_attn_allocator", None)
if c128_attn_allocator is not None:
c128_host_pages = int(c128_attn_allocator.num_pages * ratio)
return full_host_pages, swa_host_pages, c128_host_pages
def _dsv4_compressed_region_buffers(kvcache: Any, ratio: int) -> tuple[list, int]:
@@ -477,6 +481,7 @@ class _IndexerRegion:
name: PoolName
device_buffers: list
item_bytes: int
slot_page_size: int
# FP4 page rows group their slots instead of laying tokens out flat, so the
# fused-row token-granular copy does not apply and transfers must be whole
# pages. The fused FP8 row has no such restriction.
@@ -492,6 +497,31 @@ def _dsv4_indexer_regions(kvcache: Any, page_size: int) -> list[_IndexerRegion]:
import torch
pool = kvcache.c4_indexer_kv_pool
if getattr(pool, "has_npu_storage", False):
# NPU stores C4 int8 K and fp16 scale in separate PA_ND buffers. Keep
# the FULL logical page geometry for HiCache indices. The transfer path
# derives the native C4 page geometry from these device buffers.
k_buffers = pool.index_k_buffer
scale_buffers = pool.index_scale_buffer
return [
_IndexerRegion(
name=PoolName.DEEPSEEK_V4_C4_INDEXER,
device_buffers=k_buffers,
item_bytes=int(k_buffers[0][0].numel() * k_buffers[0].element_size()),
slot_page_size=page_size,
page_aligned_only=False,
),
_IndexerRegion(
name=PoolName.DEEPSEEK_V4_C4_INDEXER_SCALE,
device_buffers=scale_buffers,
item_bytes=int(
scale_buffers[0][0].numel() * scale_buffers[0].element_size()
),
slot_page_size=page_size,
page_aligned_only=False,
),
]
fused = pool.index_k_with_scale_buffer
if fused is not None:
return [
@@ -499,6 +529,7 @@ def _dsv4_indexer_regions(kvcache: Any, page_size: int) -> list[_IndexerRegion]:
name=PoolName.DEEPSEEK_V4_C4_INDEXER,
device_buffers=fused,
item_bytes=fused[0].shape[1] * fused[0].element_size(),
slot_page_size=page_size,
page_aligned_only=False,
)
]
@@ -525,12 +556,14 @@ def _dsv4_indexer_regions(kvcache: Any, page_size: int) -> list[_IndexerRegion]:
name=PoolName.DEEPSEEK_V4_C4_INDEXER,
device_buffers=payload,
item_bytes=payload[0].shape[1],
slot_page_size=page_size,
page_aligned_only=True,
),
_IndexerRegion(
name=PoolName.DEEPSEEK_V4_C4_INDEXER_SCALE,
device_buffers=scale,
item_bytes=scale[0].shape[1],
slot_page_size=page_size,
page_aligned_only=True,
),
]
@@ -544,6 +577,7 @@ def build_deepseek_v4_hicache_stack(
storage_backend: Optional[str],
host_swa_evict_fn: Optional[Callable[[int], Any]] = None,
device_swa_evict_fn: Optional[Callable[[int], Any]] = None,
host_c128_evict_fn: Optional[Callable[[int], Any]] = None,
prefetch_threshold: int = 256,
model_name: Optional[str] = None,
storage_backend_extra_config: Optional[dict] = None,
@@ -587,7 +621,11 @@ def build_deepseek_v4_hicache_stack(
c128_layer_mapping = layer_mappings.c128
c4_state_mapping = layer_mappings.c4_state
c4_state_global_layers = layer_mappings.c4_state_global_layers
num_host_pages, swa_num_host_pages = _deepseek_v4_num_host_pages(
(
num_host_pages,
swa_num_host_pages,
c128_num_host_pages,
) = _deepseek_v4_num_host_pages(
params=params,
kvcache=kvcache,
page_size=page_size,
@@ -639,6 +677,9 @@ def build_deepseek_v4_hicache_stack(
if c4_layer_mapping:
c4_device_buffers, c4_item_bytes = _dsv4_compressed_region_buffers(kvcache, 4)
# C4 is KV-derived, so its HiCache indices stay in the FULL logical
# coordinate space. NPU transfer code derives the native C4 page size
# (for example 32 rather than 128) from the device-buffer shape.
c4_host_pool = DeepSeekV4PagedHostPool(
pool_name=str(PoolName.DEEPSEEK_V4_C4),
device_buffers=c4_device_buffers,
@@ -666,7 +707,7 @@ def build_deepseek_v4_hicache_stack(
device_buffers=region.device_buffers,
item_bytes=region.item_bytes,
num_host_pages=num_host_pages,
slot_page_size=page_size,
slot_page_size=region.slot_page_size,
layout=get_memory().hicache_mem_layout,
allocator_type=_get_allocator_type(),
page_aligned_only=region.page_aligned_only,
@@ -723,12 +764,20 @@ def build_deepseek_v4_hicache_stack(
c128_device_buffers, c128_item_bytes = _dsv4_compressed_region_buffers(
kvcache, 128
)
# NPU C128 host views use kernel_page_size, for example 16 instead of 128.
# GPU pools lack this attribute and fall back to the global page_size.
_c128_pool = getattr(kvcache, "c128_kv_pool", None)
c128_slot_page_size = getattr(_c128_pool, "kernel_page_size", page_size)
# NPU derives the independent C128 host budget from its device pool.
c128_attn_allocator = getattr(
params.token_to_kv_pool_allocator, "c128_attn_allocator", None
)
c128_host_pool = DeepSeekV4PagedHostPool(
pool_name=str(PoolName.DEEPSEEK_V4_C128),
device_buffers=c128_device_buffers,
item_bytes=c128_item_bytes,
num_host_pages=num_host_pages,
slot_page_size=page_size,
num_host_pages=c128_num_host_pages,
slot_page_size=c128_slot_page_size,
layout=get_memory().hicache_mem_layout,
allocator_type=_get_allocator_type(),
)
@@ -742,6 +791,20 @@ def build_deepseek_v4_hicache_stack(
device_pool=kvcache.c128_kv_pool,
layer_mapping=c128_layer_mapping,
transfer_layer_num=transfer_layer_num,
# NPU C128 uses bare allocator callbacks for independent indices.
# FULL leaf eviction also frees each attached C128 host value.
# GPU keeps its KV-derived sidecar callbacks unset.
host_evict_fn=host_c128_evict_fn,
device_alloc_fn=(
c128_attn_allocator.alloc
if c128_attn_allocator is not None
else None
),
device_free_fn=(
c128_attn_allocator.free
if c128_attn_allocator is not None
else None
),
),
]
)
@@ -1088,10 +1151,7 @@ def build_full_draft_pools(
tree_cache: Any,
) -> tuple[list[SidecarPoolSpec], list[PoolEntry]]:
"""Build draft KV/DSA sidecars whose indices follow target full KV."""
from sglang.srt.mem_cache.memory_pool import (
DSATokenToKVPool,
HybridLinearKVPool,
)
from sglang.srt.mem_cache.memory_pool import DSATokenToKVPool, HybridLinearKVPool
pool = draft_kv_pool
if isinstance(pool, HybridLinearKVPool):
@@ -1235,6 +1295,7 @@ _COMPONENT_HOST_ATTR: dict[ComponentType, tuple[str, str]] = {
ComponentType.FULL: ("full_kv_pool_host", "_full_kv_pool_host"),
ComponentType.SWA: ("swa_kv_pool_host", "_swa_kv_pool_host"),
ComponentType.MAMBA: ("mamba_pool_host", "_mamba_pool_host"),
ComponentType.C128: ("c128_kv_pool_host", "_c128_kv_pool_host"),
}
@@ -1284,16 +1345,21 @@ class StackStrategy:
raise NotImplementedError
def _delegate_c128_host_evict(cache, n: int) -> int:
"""Delegate C128 host pressure to FULL leaf eviction.
For example, ``n`` C128 slots request ``n * 128`` FULL tokens."""
return cache.evict_host(n * 128, ComponentType.FULL)
class _DeepSeekV4Strategy(StackStrategy):
def matches(self, kvcache, components):
from sglang.srt.mem_cache.deepseek_v4_memory_pool import (
DeepSeekV4TokenToKVPool,
)
from sglang.srt.mem_cache.deepseek_v4_memory_pool import DeepSeekV4TokenToKVPool
return isinstance(kvcache, DeepSeekV4TokenToKVPool) and components == {
ComponentType.FULL,
ComponentType.SWA,
}
return isinstance(kvcache, DeepSeekV4TokenToKVPool) and components in (
{ComponentType.FULL, ComponentType.SWA},
{ComponentType.FULL, ComponentType.SWA, ComponentType.C128},
)
def build_direct_linker_pool_group(self, *, kvcache, params, page_size):
from sglang.srt.mem_cache.hybrid_cache.linker_pool_assembler import (
@@ -1326,12 +1392,31 @@ class _DeepSeekV4Strategy(StackStrategy):
storage_backend=storage_backend,
host_swa_evict_fn=lambda n: cache.evict_host(n, ComponentType.SWA),
device_swa_evict_fn=lambda n: _evict_swa_for_device_alloc(cache, n),
# NPU delegates C128 host pressure to FULL host-leaf eviction.
# _delegate_c128_host_evict converts C128 slots to FULL tokens.
host_c128_evict_fn=(
(lambda n: _delegate_c128_host_evict(cache, n))
if ComponentType.C128 in cache.components
else None
),
prefetch_threshold=prefetch_threshold,
model_name=model_name,
storage_backend_extra_config=storage_backend_extra_config,
enable_storage_metrics=enable_storage_metrics,
layer_mappings=layer_mappings,
)
# NPU drives C128 as an independent tree component, so adding a KV-derived
# sidecar would duplicate transfers. Add that sidecar only on GPU.
_sidecar_srcs = [
(PoolName.DEEPSEEK_V4_C4, PoolName.KV),
(PoolName.DEEPSEEK_V4_C4_INDEXER, PoolName.KV),
(PoolName.DEEPSEEK_V4_C4_INDEXER_SCALE, PoolName.KV),
(PoolName.DEEPSEEK_V4_C4_STATE, PoolName.SWA),
(PoolName.DEEPSEEK_V4_C4_INDEXER_STATE, PoolName.SWA),
(PoolName.DEEPSEEK_V4_C128_STATE, PoolName.SWA),
]
if ComponentType.C128 not in cache.components:
_sidecar_srcs.append((PoolName.DEEPSEEK_V4_C128, PoolName.KV))
sidecars = [
SidecarPoolSpec(
pool_name=name,
@@ -1342,15 +1427,7 @@ class _DeepSeekV4Strategy(StackStrategy):
else PoolHitPolicy.ALL_PAGES
),
)
for name, src in (
(PoolName.DEEPSEEK_V4_C4, PoolName.KV),
(PoolName.DEEPSEEK_V4_C4_INDEXER, PoolName.KV),
(PoolName.DEEPSEEK_V4_C4_INDEXER_SCALE, PoolName.KV),
(PoolName.DEEPSEEK_V4_C128, PoolName.KV),
(PoolName.DEEPSEEK_V4_C4_STATE, PoolName.SWA),
(PoolName.DEEPSEEK_V4_C4_INDEXER_STATE, PoolName.SWA),
(PoolName.DEEPSEEK_V4_C128_STATE, PoolName.SWA),
)
for name, src in _sidecar_srcs
if name in host_pool_group.entry_map
]
component_host_pools = {
@@ -1360,6 +1437,13 @@ class _DeepSeekV4Strategy(StackStrategy):
component_host_pools[ComponentType.SWA] = host_pool_group.get_pool(
PoolName.SWA
)
if (
ComponentType.C128 in cache.components
and PoolName.DEEPSEEK_V4_C128 in host_pool_group.entry_map
):
component_host_pools[ComponentType.C128] = host_pool_group.get_pool(
PoolName.DEEPSEEK_V4_C128
)
return StackBuildResult(
host_pool_group=host_pool_group,
@@ -1435,9 +1519,7 @@ def _swa_layer_mappings(kvcache) -> tuple[dict[int, int], dict[int, int]]:
class _SwaStrategy(StackStrategy):
def matches(self, kvcache, components):
from sglang.srt.mem_cache.deepseek_v4_memory_pool import (
DeepSeekV4TokenToKVPool,
)
from sglang.srt.mem_cache.deepseek_v4_memory_pool import DeepSeekV4TokenToKVPool
from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool
return (
@@ -1491,9 +1573,7 @@ class _SwaStrategy(StackStrategy):
class _MambaSwaStrategy(StackStrategy):
def matches(self, kvcache, components):
from sglang.srt.mem_cache.deepseek_v4_memory_pool import (
DeepSeekV4TokenToKVPool,
)
from sglang.srt.mem_cache.deepseek_v4_memory_pool import DeepSeekV4TokenToKVPool
from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool
return (
@@ -1690,9 +1770,7 @@ class _MiniMaxSparseStrategy(StackStrategy):
class _PlainKvStrategy(StackStrategy):
def matches(self, kvcache, components):
from sglang.srt.mem_cache.deepseek_v4_memory_pool import (
DeepSeekV4TokenToKVPool,
)
from sglang.srt.mem_cache.deepseek_v4_memory_pool import DeepSeekV4TokenToKVPool
from sglang.srt.mem_cache.memory_pool import (
DSATokenToKVPool,
HybridLinearKVPool,
+189 -2
View File
@@ -31,6 +31,9 @@ if _is_cuda or _is_hip:
transfer_kv_per_layer_mla_pf_lf,
)
if _is_npu:
from sgl_kernel_npu.kvcacheio import TransferDirection, transfer_kv_dim_exchange
logger = logging.getLogger(__name__)
@@ -220,7 +223,15 @@ class DeepSeekV4PagedHostPool(HiSparseHostPoolMixin, HostKVCache):
f"{available_bytes / 1e9:.2f} GB free."
)
alloc_func = ALLOC_MEMORY_FUNCS[self.gpu_device]
# ALLOC_MEMORY_FUNCS is keyed by device *type* string ("npu"/"musa"/...),
# not torch.device objects; a torch.device key silently falls back to
# cudaHostRegister, which fails on NPU. Resolve the alloc func by type str.
_alloc_key = (
self.gpu_device.type
if isinstance(self.gpu_device, torch.device)
else str(self.gpu_device)
)
alloc_func = ALLOC_MEMORY_FUNCS[_alloc_key]
self.data_refs = []
if self.layout == "layer_first":
self.kv_buffer = [
@@ -300,6 +311,29 @@ class DeepSeekV4PagedHostPool(HiSparseHostPoolMixin, HostKVCache):
device=self.gpu_device,
)
def _host_page_view(self, l: int) -> torch.Tensor:
"""View a host layer as ``[pages, 1, P, 1, dim]``."""
if self.layout == "layer_first":
layer_buffer = self.kv_buffer[l]
elif self.layout == "page_first":
layer_buffer = self.kv_buffer[:, l, :]
elif self.layout == "page_first_direct":
layer_buffer = self.kv_buffer[:, l, 0, :]
else:
raise ValueError(
f"{self.pool_name} _host_page_view: kernel_ascend requires "
"layer_first/page_first/page_first_direct layout, "
f"got {self.layout!r}"
)
device_buffer = self.device_buffers[l]
return layer_buffer.view(device_buffer.dtype).view(
self.num_host_pages,
1,
device_buffer.shape[1],
1,
device_buffer.shape[-1],
)
def get_contiguous_buf_infos(self):
"""Return per-layer page-row buffers for PD direct-to-host transfer."""
data_ptrs = [int(self.data_ptrs[i].item()) for i in range(self.layer_num)]
@@ -310,6 +344,14 @@ class DeepSeekV4PagedHostPool(HiSparseHostPoolMixin, HostKVCache):
def _to_page_indices(self, indices: torch.Tensor) -> torch.Tensor:
return indices.reshape(-1, self.slot_page_size)[:, 0] // self.slot_page_size
def _to_native_page_indices(
self, indices: torch.Tensor, native_page_size: int
) -> torch.Tensor:
"""Expand logical page indices into a device buffer's native slots."""
rows = self._to_page_indices(indices)
offsets = torch.arange(native_page_size, dtype=rows.dtype, device=rows.device)
return (rows[:, None] * native_page_size + offsets).reshape(-1)
def _unaligned_transfer_error(
self, host_indices: torch.Tensor, device_indices: torch.Tensor
) -> ValueError:
@@ -446,6 +488,33 @@ class DeepSeekV4PagedHostPool(HiSparseHostPoolMixin, HostKVCache):
dst_indices=host_rows,
page_size=1,
)
elif io_backend == "kernel_ascend":
# HiCache keeps KV-derived pools in the FULL logical coordinate
# space. Ascend consumes the native slots of the shaped device
# buffer, so translate the already resolved page rows only at this
# backend boundary.
native_page_size = int(self.device_buffers[0].shape[1])
native_device_indices = self._to_native_page_indices(
device_indices, native_page_size
)
native_host_indices = self._to_native_page_indices(
host_indices, native_page_size
)
for l in range(self.layer_num):
dev_view = self.device_buffers[l].unsqueeze(0)
# dev_view: [1, dev_pages, native_page_size, 1, kv_dim]
host_view = self._host_page_view(l)
# host_view: [num_host_pages, 1, native_page_size, 1, kv_dim]
transfer_kv_dim_exchange(
device_k=dev_view,
host_k=host_view,
device_v=torch.empty(0, device=dev_view.device),
host_v=torch.empty(0, device="cpu"),
device_indices=native_device_indices,
host_indices=native_host_indices,
page_size=native_page_size,
direction=TransferDirection.D2H,
)
else:
raise ValueError(
f"Unsupported V4 paged host layout/backend: {self.layout}/{io_backend}"
@@ -516,6 +585,29 @@ class DeepSeekV4PagedHostPool(HiSparseHostPoolMixin, HostKVCache):
layer_id=layer_id,
page_size=1,
)
elif io_backend == "kernel_ascend":
# NPU whole-page H2D via Ascend dim-exchange op, for layer_id only.
native_page_size = int(self.device_buffers[layer_id].shape[1])
native_device_indices = self._to_native_page_indices(
device_indices, native_page_size
)
native_host_indices = self._to_native_page_indices(
host_indices, native_page_size
)
dev_view = self.device_buffers[layer_id].unsqueeze(0)
# dev_view: [1, dev_pages, native_page_size, 1, kv_dim]
host_view = self._host_page_view(layer_id)
# host_view: [num_host_pages, 1, native_page_size, 1, kv_dim]
transfer_kv_dim_exchange(
device_k=dev_view,
host_k=host_view,
device_v=torch.empty(0, device=dev_view.device),
host_v=torch.empty(0, device="cpu"),
device_indices=native_device_indices,
host_indices=native_host_indices,
page_size=native_page_size,
direction=TransferDirection.H2D,
)
else:
raise ValueError(
f"Unsupported V4 paged host layout/backend: {self.layout}/{io_backend}"
@@ -638,7 +730,14 @@ class DeepSeekV4StateHostPool(HostKVCache):
f"{available_bytes / 1e9:.2f} GB free."
)
alloc_func = ALLOC_MEMORY_FUNCS[self.gpu_device]
# ALLOC_MEMORY_FUNCS is keyed by device *type* string ("npu"/"musa"/...),
# not torch.device objects; resolve the key the same way PagedHostPool does.
_state_alloc_key = (
self.gpu_device.type
if isinstance(self.gpu_device, torch.device)
else str(self.gpu_device)
)
alloc_func = ALLOC_MEMORY_FUNCS[_state_alloc_key]
self.data_refs = []
if self.layout == "layer_first":
self.kv_buffer = [
@@ -756,6 +855,35 @@ class DeepSeekV4StateHostPool(HostKVCache):
)
return indices.reshape(-1, self.swa_page_size)[:, 0] // self.swa_page_size
def _ring_op_indices(self, rows: torch.Tensor) -> torch.Tensor:
"""Expand each SWA page row into ``ring_size`` operator indices.
For example, row ``r`` maps to ``r * ring_size + arange(ring_size)``."""
ar = torch.arange(self.ring_size, device=rows.device, dtype=rows.dtype)
return (rows.reshape(-1, 1) * self.ring_size + ar).reshape(-1)
def _state_host_page_view(self, l: int) -> torch.Tensor:
"""View host state layer ``l`` as ``[pages, 1, R, 1, last_dim]``.
``last_dim = state_page_bytes // R // state_dtype.itemsize``."""
state_dtype = self.state_pools[l].kv_score_buffer.kv_score.dtype
last_dim = self.state_page_bytes // self.ring_size // state_dtype.itemsize
if self.layout == "layer_first":
layer_buffer = self.kv_buffer[l]
elif self.layout == "page_first":
layer_buffer = self.kv_buffer[:, l, :]
elif self.layout == "page_first_direct":
layer_buffer = self.kv_buffer[:, l, 0, :]
else:
raise ValueError(
f"{self.pool_name} _state_host_page_view: kernel_ascend requires "
"layer_first/page_first/page_first_direct layout, "
f"got {self.layout!r}"
)
return layer_buffer.view(state_dtype).view(
self.num_host_pages, 1, self.ring_size, 1, last_dim
)
def get_size_per_token(self):
return self.state_page_bytes
@@ -842,6 +970,36 @@ class DeepSeekV4StateHostPool(HostKVCache):
dst_indices=host_rows,
page_size=1,
)
elif io_backend == "kernel_ascend":
# Ascend copies ring_size state slots for each page-aligned SWA row.
# _ring_op_indices expands device and host rows into operator indices.
dev_op_indices = self._ring_op_indices(device_rows)
host_op_indices = self._ring_op_indices(host_rows)
for l in range(self.layer_num):
state_dtype = self.state_pools[l].kv_score_buffer.kv_score.dtype
last_dim = (
self.state_page_bytes // self.ring_size // state_dtype.itemsize
)
num_dev_pages = self.device_page_views[l].shape[0]
dev_view = (
self.device_page_views[l]
.view(state_dtype)
.view(num_dev_pages, self.ring_size, 1, last_dim)
.unsqueeze(0)
)
# dev_view: [1, num_dev_pages, R, 1, last_dim]
host_view = self._state_host_page_view(l)
# host_view: [num_host_pages, 1, R, 1, last_dim]
transfer_kv_dim_exchange(
device_k=dev_view,
host_k=host_view,
device_v=torch.empty(0, device=dev_view.device),
host_v=torch.empty(0, device="cpu"),
device_indices=dev_op_indices,
host_indices=host_op_indices,
page_size=self.ring_size,
direction=TransferDirection.D2H,
)
else:
raise ValueError(
f"Unsupported V4 state host layout/backend: {self.layout}/{io_backend}"
@@ -896,6 +1054,35 @@ class DeepSeekV4StateHostPool(HostKVCache):
layer_id=layer_id,
page_size=1,
)
elif io_backend == "kernel_ascend":
# NPU whole-page H2D via Ascend dim-exchange op, for layer_id only.
# See backup_from_device_all_layer: indices must be ring-row indices
# (ring_size entries per SWA page), not raw SWA locs.
R = self.ring_size
dev_op_indices = self._ring_op_indices(device_rows)
host_op_indices = self._ring_op_indices(host_rows)
state_dtype = self.state_pools[layer_id].kv_score_buffer.kv_score.dtype
last_dim = self.state_page_bytes // R // state_dtype.itemsize
num_dev_pages = self.device_page_views[layer_id].shape[0]
dev_view = (
self.device_page_views[layer_id]
.view(state_dtype)
.view(num_dev_pages, R, 1, last_dim)
.unsqueeze(0)
)
# dev_view: [1, num_dev_pages, R, 1, last_dim]
host_view = self._state_host_page_view(layer_id)
# host_view: [num_host_pages, 1, R, 1, last_dim]
transfer_kv_dim_exchange(
device_k=dev_view,
host_k=host_view,
device_v=torch.empty(0, device=dev_view.device),
host_v=torch.empty(0, device="cpu"),
device_indices=dev_op_indices,
host_indices=host_op_indices,
page_size=R,
direction=TransferDirection.H2D,
)
else:
raise ValueError(
f"Unsupported V4 state host layout/backend: {self.layout}/{io_backend}"
@@ -778,9 +778,7 @@ class MooncakeStore(HiCacheStorage, MooncakeBaseStore):
f"_{self.mha_suffix}_{PoolName.DRAFT}_v",
]
elif pool_name == PoolName.DRAFT_SWA:
from sglang.srt.mem_cache.memory_pool_host import (
DeepSeekV4PagedHostPool,
)
from sglang.srt.mem_cache.memory_pool_host import DeepSeekV4PagedHostPool
from sglang.srt.mem_cache.pool_host.mha import MHATokenToKVPoolHost
if isinstance(
@@ -37,11 +37,7 @@ from sglang.srt.mem_cache.buffer_mode.storage_existence_cache import (
StorageExistenceCache,
)
from sglang.srt.mem_cache.common import RetractionBackup
from sglang.srt.mem_cache.hicache_storage import (
PoolName,
PoolTransfer,
SidecarPoolSpec,
)
from sglang.srt.mem_cache.hicache_storage import PoolName, PoolTransfer, SidecarPoolSpec
from sglang.srt.mem_cache.hybrid_cache.hybrid_cache_controller import (
HybridCacheController,
)
@@ -89,11 +85,7 @@ from sglang.srt.observability.metrics_collector import (
StorageMetrics,
StorageMetricsCollector,
)
from sglang.srt.runtime_context import (
get_memory,
get_model,
get_observability,
)
from sglang.srt.runtime_context import get_memory, get_model, get_observability
from sglang.srt.session.streaming_session import StreamingSession
from sglang.srt.utils.common import ceil_align
@@ -111,6 +103,21 @@ from sglang.srt.utils.rank_consensus_checker import rank_consensus
T = TypeVar("T")
def _c128_transfer_num_pages(transfers: Sequence[PoolTransfer], page_size: int) -> int:
num_pages = 0
for transfer in transfers:
if transfer.host_indices is None:
continue
num_slots = len(transfer.host_indices)
assert num_slots % page_size == 0, (
f"C128 load-back transfers must contain complete physical pages: "
f"{num_slots=}, {page_size=}"
)
num_pages += num_slots // page_size
return num_pages
COMPONENT_REGISTRY: dict[ComponentType, type[TreeComponent]] = {
ComponentType.FULL: FullComponent,
ComponentType.MAMBA: MambaComponent,
@@ -1686,6 +1693,21 @@ class UnifiedRadixCache(BasePrefixCache):
self.dec_host_lock_ref(node_id, host_anchor_params)
return False
c128_allocator = getattr(
self.token_to_kv_pool_allocator, "c128_attn_allocator", None
)
if c128_allocator is not None:
c128_num_pages = _c128_transfer_num_pages(
comp_xfers.get(ComponentType.C128, ()),
c128_allocator.page_size,
)
if not self.token_to_kv_pool_allocator.ensure_c128_capacity(
self, c128_num_pages
):
self.dec_lock_ref(node_id, ancestor_lock_params)
self.dec_host_lock_ref(node_id, host_anchor_params)
return False
avail = self._component_available_size(ComponentType.FULL)
if avail < kv_tokens:
needed = kv_tokens - avail