[HiCache & HybridModel] mooncake backend support DSA & mamba model (#21259)

Signed-off-by: Vladislav Nosivskoy <vladnosiv@gmail.com>
Co-authored-by: hzh0425 <hzh0425@apache.org>
Co-authored-by: pansicheng <sicheng.pan.chn@gmail.com>
Co-authored-by: ispobock <ispobaoke@gmail.com>
Co-authored-by: Vladislav Nosivskoy <vladnosiv@gmail.com>
This commit is contained in:
huangtingwei
2026-04-13 18:47:36 -07:00
committed by GitHub
co-authored by hzh0425 pansicheng ispobock Vladislav Nosivskoy
parent cc449ac4e5
commit e9d6b9eb2d
8 changed files with 761 additions and 233 deletions
@@ -24,9 +24,11 @@ from sglang.srt.mem_cache.base_prefix_cache import (
)
from sglang.srt.mem_cache.hicache_storage import PoolHitPolicy, PoolName, PoolTransfer
from sglang.srt.mem_cache.hybrid_cache.hybrid_cache_controller import (
HybridCacheController,
PrefetchOperation,
)
from sglang.srt.mem_cache.hybrid_cache.hybrid_pool_assembler import (
build_mamba_hybrid_stack,
)
from sglang.srt.mem_cache.mamba_radix_cache import (
LRUList,
MambaRadixCache,
@@ -34,13 +36,6 @@ from sglang.srt.mem_cache.mamba_radix_cache import (
get_last_access_time,
)
from sglang.srt.mem_cache.memory_pool import HybridLinearKVPool, HybridReqToTokenPool
from sglang.srt.mem_cache.memory_pool_host import (
HostPoolGroup,
MambaPoolHost,
MHATokenToKVPoolHost,
MLATokenToKVPoolHost,
PoolEntry,
)
from sglang.srt.mem_cache.radix_cache import (
RadixKey,
compute_node_hash_values,
@@ -115,66 +110,6 @@ class HiMambaRadixCache(MambaRadixCache):
)
self.kvcache = self.hybrid_kv_cache.full_kv_pool
kv_host_pool_cls = (
MLATokenToKVPoolHost
if self.hybrid_kv_cache.use_mla
else MHATokenToKVPoolHost
)
self.full_kv_pool_host = kv_host_pool_cls(
self.kvcache,
server_args.hicache_ratio,
server_args.hicache_size,
params.page_size,
server_args.hicache_mem_layout,
allocator_type=server_args.hicache_storage_backend,
)
self.mamba_pool_host = MambaPoolHost(
params.req_to_token_pool.mamba_pool,
server_args.hicache_ratio,
server_args.hicache_size,
allocator_type=server_args.hicache_storage_backend,
layout=server_args.hicache_mem_layout,
)
full_layer_ids = sorted(
self.hybrid_kv_cache.full_attention_layer_id_mapping.keys()
)
mamba_layer_ids = sorted(params.req_to_token_pool.mamba_map.keys())
self.transfer_layer_num = len(set(full_layer_ids) | set(mamba_layer_ids))
full_layer_mapping = dict(self.hybrid_kv_cache.full_attention_layer_id_mapping)
mamba_layer_mapping = dict(params.req_to_token_pool.mamba_map)
transfer_layer_num = self.transfer_layer_num
def kv_layer_mapper(layer_id: int) -> Optional[int]:
if not 0 <= layer_id < transfer_layer_num:
return None
return full_layer_mapping.get(layer_id)
def mamba_layer_mapper(layer_id: int) -> Optional[int]:
if not 0 <= layer_id < transfer_layer_num:
return None
return mamba_layer_mapping.get(layer_id)
self.host_pool_group = HostPoolGroup(
[
PoolEntry(
name=PoolName.KV,
host_pool=self.full_kv_pool_host,
device_pool=self.kvcache,
layer_mapper=kv_layer_mapper,
is_primary_index_anchor=True,
),
PoolEntry(
name=PoolName.MAMBA,
host_pool=self.mamba_pool_host,
device_pool=params.req_to_token_pool.mamba_pool,
layer_mapper=mamba_layer_mapper,
host_evict_fn=self.evict_mamba_host,
device_evict_fn=self.evict_mamba,
),
]
)
self.tp_group = params.tp_cache_group
self.tp_world_size = (
@@ -200,27 +135,13 @@ class HiMambaRadixCache(MambaRadixCache):
self.prefetch_stop_policy = server_args.hicache_storage_prefetch_policy
self.load_cache_event = threading.Event()
self.cache_controller = HybridCacheController(
params.token_to_kv_pool_allocator,
self.host_pool_group,
params.page_size,
self.tp_group,
load_cache_event=self.load_cache_event,
write_policy=server_args.hicache_write_policy,
io_backend=server_args.hicache_io_backend,
storage_backend=server_args.hicache_storage_backend,
build_mamba_hybrid_stack(
self,
params,
server_args,
extra_config=extra_config,
prefetch_threshold=prefetch_threshold,
model_name=server_args.served_model_name,
storage_backend_extra_config=extra_config,
pp_rank=params.pp_rank,
pp_size=params.pp_size,
transfer_layer_num=self.transfer_layer_num,
)
params.req_to_token_pool.register_layer_transfer_counter(
self.cache_controller.layer_done_counter
)
self.hybrid_kv_cache.register_layer_transfer_counter(
self.cache_controller.layer_done_counter
load_cache_event=self.load_cache_event,
)
self._apply_storage_runtime_config(
storage_backend=server_args.hicache_storage_backend,
@@ -41,12 +41,13 @@ class PoolName(str, Enum):
KV = "kv"
MAMBA = "mamba"
INDEXER = "indexer"
class PoolHitPolicy(str, Enum):
"""Hit policy for batch_exists_v2 per-pool prefix matching.
ALL_PAGES : every page in [0, kv_hit) must exist (default).
ALL_PAGES : every page in [0, kv_hit) must exist (e.g. DSA).
TRAILING_PAGES : only the last N pages must exist (e.g. Mamba/SWA states).
"""
+83 -32
View File
@@ -25,6 +25,17 @@ from sglang.srt.mem_cache.base_prefix_cache import (
MatchPrefixParams,
MatchResult,
)
from sglang.srt.mem_cache.hicache_storage import (
PoolHitPolicy,
PoolName,
PoolTransfer,
)
from sglang.srt.mem_cache.hybrid_cache.hybrid_cache_controller import (
HybridCacheController,
)
from sglang.srt.mem_cache.hybrid_cache.hybrid_pool_assembler import (
build_nsa_hybrid_stack,
)
from sglang.srt.mem_cache.memory_pool import (
MHATokenToKVPool,
MLATokenToKVPool,
@@ -33,7 +44,6 @@ from sglang.srt.mem_cache.memory_pool import (
from sglang.srt.mem_cache.memory_pool_host import (
MHATokenToKVPoolHost,
MLATokenToKVPoolHost,
NSATokenToKVPoolHost,
)
from sglang.srt.mem_cache.radix_cache import (
RadixCache,
@@ -70,14 +80,8 @@ class HiRadixCache(RadixCache):
allocator_type=server_args.hicache_storage_backend,
)
elif isinstance(self.kv_cache, NSATokenToKVPool):
self.token_to_kv_pool_host = NSATokenToKVPoolHost(
self.kv_cache,
server_args.hicache_ratio,
server_args.hicache_size,
self.page_size,
server_args.hicache_mem_layout,
allocator_type=server_args.hicache_storage_backend,
)
# Filled by build_nsa_hybrid_stack after storage extra_config is parsed.
self.token_to_kv_pool_host = None
elif isinstance(self.kv_cache, MLATokenToKVPool):
self.token_to_kv_pool_host = MLATokenToKVPoolHost(
self.kv_cache,
@@ -88,7 +92,9 @@ class HiRadixCache(RadixCache):
allocator_type=server_args.hicache_storage_backend,
)
else:
raise ValueError(f"HiRadixCache only supports MHA and MLA yet")
raise ValueError(
"HiRadixCache only supports MHA, MLA, and NSA (DSA) models"
)
self.tp_group = params.tp_cache_group
self.tp_world_size = torch.distributed.get_world_size(group=self.tp_group)
@@ -114,24 +120,35 @@ class HiRadixCache(RadixCache):
self.prefetch_stop_policy = server_args.hicache_storage_prefetch_policy
self.load_cache_event = threading.Event()
self.cache_controller = HiCacheController(
params.token_to_kv_pool_allocator,
self.token_to_kv_pool_host,
self.page_size,
self.tp_group,
load_cache_event=self.load_cache_event,
write_policy=server_args.hicache_write_policy,
io_backend=server_args.hicache_io_backend,
storage_backend=server_args.hicache_storage_backend,
prefetch_threshold=prefetch_threshold,
model_name=server_args.served_model_name,
storage_backend_extra_config=extra_config,
pp_rank=self.pp_rank,
pp_size=self.pp_size,
attn_cp_rank=self.attn_cp_rank,
attn_cp_size=self.attn_cp_size,
enable_storage_metrics=self.enable_storage_metrics,
)
if isinstance(self.kv_cache, NSATokenToKVPool):
build_nsa_hybrid_stack(
self,
params,
server_args,
extra_config=extra_config,
prefetch_threshold=prefetch_threshold,
enable_storage_metrics=self.enable_storage_metrics,
load_cache_event=self.load_cache_event,
)
else:
self.cache_controller = HiCacheController(
params.token_to_kv_pool_allocator,
self.token_to_kv_pool_host,
self.page_size,
self.tp_group,
load_cache_event=self.load_cache_event,
write_policy=server_args.hicache_write_policy,
io_backend=server_args.hicache_io_backend,
storage_backend=server_args.hicache_storage_backend,
prefetch_threshold=prefetch_threshold,
model_name=server_args.served_model_name,
storage_backend_extra_config=extra_config,
pp_rank=self.pp_rank,
pp_size=self.pp_size,
attn_cp_rank=self.attn_cp_rank,
attn_cp_size=self.attn_cp_size,
enable_storage_metrics=self.enable_storage_metrics,
)
self._apply_storage_runtime_config(
storage_backend=server_args.hicache_storage_backend,
prefetch_threshold=prefetch_threshold,
@@ -326,6 +343,7 @@ class HiRadixCache(RadixCache):
prefetch_threshold=prefetch_threshold,
model_name=served_model_name,
storage_backend_extra_config=extra_config,
**self._get_hybrid_storage_attach_kwargs(),
)
except Exception as e:
logger.exception(
@@ -591,6 +609,24 @@ class HiRadixCache(RadixCache):
height += 1
return height
def _get_extra_pools(self) -> dict:
if not isinstance(self.cache_controller, HybridCacheController):
return {}
if isinstance(self.kv_cache, NSATokenToKVPool):
pool = PoolTransfer(
name=PoolName.INDEXER,
hit_policy=PoolHitPolicy.ALL_PAGES,
)
return {"extra_pools": [pool]}
else:
return {}
def _get_hybrid_storage_attach_kwargs(self) -> dict:
"""Extra kwargs for attach_storage_backend when controller is HybridCacheController."""
if isinstance(self.cache_controller, HybridCacheController):
return {"host_pools": self.cache_controller.mem_pool_host.entries}
return {}
def clear_storage_backend(self) -> bool:
if self.enable_storage:
try:
@@ -625,12 +661,14 @@ class HiRadixCache(RadixCache):
host_indices = self.cache_controller.write(
device_indices=node.value,
node_id=node.id,
**self._get_extra_pools(),
)
if host_indices is None:
self.evict_host(len(node.value))
host_indices = self.cache_controller.write(
device_indices=node.value,
node_id=node.id,
**self._get_extra_pools(),
)
if host_indices is not None:
node.host_value = host_indices.clone()
@@ -652,7 +690,11 @@ class HiRadixCache(RadixCache):
)
operation_id = self.cache_controller.write_storage(
node.host_value, node.key, node.hash_value, prefix_keys
node.host_value,
node.key,
node.hash_value,
prefix_keys,
**self._get_extra_pools(),
)
self.ongoing_backup[operation_id] = node
node.protect_host()
@@ -925,12 +967,16 @@ class HiRadixCache(RadixCache):
return None
device_indices = self.cache_controller.load(
host_indices=host_indices, node_id=last_hit_node.id
host_indices=host_indices,
node_id=last_hit_node.id,
**self._get_extra_pools(),
)
if device_indices is None:
self.evict(EvictParams(num_tokens=len(host_indices)))
device_indices = self.cache_controller.load(
host_indices=host_indices, node_id=last_hit_node.id
host_indices=host_indices,
node_id=last_hit_node.id,
**self._get_extra_pools(),
)
self.dec_lock_ref(ancester_node)
if device_indices is None:
@@ -1231,7 +1277,12 @@ class HiRadixCache(RadixCache):
# no sufficient host memory for prefetch
return
operation = self.cache_controller.prefetch(
req_id, host_indices, new_input_tokens, last_hash, prefix_keys
req_id,
host_indices,
new_input_tokens,
last_hash,
prefix_keys,
**self._get_extra_pools(),
)
self.ongoing_prefetch[req_id] = (
last_host_node,
@@ -163,6 +163,7 @@ class HybridCacheController(BaseHiCacheController):
pp_rank: int = 0,
pp_size: int = 1,
transfer_layer_num: Optional[int] = None,
enable_storage_metrics: bool = False,
):
startup_storage_backend = storage_backend
super().__init__(
@@ -179,6 +180,7 @@ class HybridCacheController(BaseHiCacheController):
storage_backend_extra_config=storage_backend_extra_config,
pp_rank=pp_rank,
pp_size=pp_size,
enable_storage_metrics=enable_storage_metrics,
)
# Override layer_num: hybrid models transfer all layers (For example, Linear Model (KV + Mamba)),
# not just the full attention layers reported by full_kv_pool.
@@ -230,7 +232,10 @@ class HybridCacheController(BaseHiCacheController):
if host_indices is None:
return None
pool_transfers = self._resolve_pool_transfers_allocation(
extra_pools, alloc_host=True
extra_pools,
alloc_host=True,
kv_device_indices=device_indices,
kv_host_indices=host_indices,
)
if pool_transfers is None and extra_pools:
self.mem_pool_host.free(host_indices)
@@ -289,7 +294,10 @@ class HybridCacheController(BaseHiCacheController):
device_indices = torch.empty((0,), dtype=torch.int64, device=self.device)
pool_transfers = self._resolve_pool_transfers_allocation(
extra_pools, alloc_host=False
extra_pools,
alloc_host=False,
kv_device_indices=device_indices,
kv_host_indices=host_indices,
)
if pool_transfers is None and extra_pools:
if need_load_kv:
@@ -415,6 +423,7 @@ class HybridCacheController(BaseHiCacheController):
def _page_transfer(self, operation):
# Transfer extra pools
if operation.pool_transfers and not operation.is_terminated():
self._resolve_shared_pool_transfers(operation)
results = self.storage_backend.batch_get_v2(operation.pool_transfers)
operation.pool_storage_result.update_extra_pool_hit_pages(results)
@@ -424,12 +433,20 @@ class HybridCacheController(BaseHiCacheController):
def _page_backup(self, operation):
# Backup extra pools
if operation.pool_transfers:
self._resolve_shared_pool_transfers(operation)
results = self.storage_backend.batch_set_v2(operation.pool_transfers)
operation.pool_storage_result.update_extra_pool_hit_pages(results)
# Backup kv pools
super()._page_backup(operation)
def _resolve_shared_pool_transfers(self, operation):
for transfer in operation.pool_transfers:
entry = self.mem_pool_host.entry_map.get(transfer.name)
if entry.share_indices_with_anchor:
transfer.keys = operation.hash_value
transfer.host_indices = operation.host_indices
def _sync_trailing_keys(
self,
pool_transfers: list[PoolTransfer],
@@ -454,6 +471,8 @@ class HybridCacheController(BaseHiCacheController):
self,
extra_pools: Optional[list[PoolTransfer]],
alloc_host: bool,
kv_device_indices: Optional[torch.Tensor] = None,
kv_host_indices: Optional[torch.Tensor] = None,
) -> Optional[list[PoolTransfer]]:
"""Auto-alloc host or device indices for PoolTransfers where they are None."""
if not extra_pools:
@@ -463,6 +482,10 @@ class HybridCacheController(BaseHiCacheController):
entry = self.mem_pool_host.entry_map.get(pool.name)
if entry is None:
continue
if entry.share_indices_with_anchor:
pool.device_indices = kv_device_indices
pool.host_indices = kv_host_indices
continue
if alloc_host:
if pool.host_indices is not None or pool.device_indices is None:
continue
@@ -0,0 +1,212 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Optional
from sglang.srt.mem_cache.hicache_storage import PoolName
from sglang.srt.mem_cache.hybrid_cache.hybrid_cache_controller import (
HybridCacheController,
)
from sglang.srt.mem_cache.memory_pool_host import (
HostPoolGroup,
MambaPoolHost,
MHATokenToKVPoolHost,
MLATokenToKVPoolHost,
NSAIndexerPoolHost,
PoolEntry,
)
if TYPE_CHECKING:
from sglang.srt.mem_cache.cache_init_params import CacheInitParams
from sglang.srt.mem_cache.hi_mamba_radix_cache import HiMambaRadixCache
from sglang.srt.mem_cache.hiradix_cache import HiRadixCache
from sglang.srt.server_args import ServerArgs
logger = logging.getLogger(__name__)
def build_nsa_hybrid_stack(
radix_cache: "HiRadixCache",
params: "CacheInitParams",
server_args: "ServerArgs",
*,
extra_config: dict,
prefetch_threshold: int,
enable_storage_metrics: bool,
load_cache_event,
) -> None:
"""HostPoolGroup (KV + indexer) + HybridCacheController for NSA (DSA)."""
try:
kv = radix_cache.kv_cache
mla_host = MLATokenToKVPoolHost(
kv,
server_args.hicache_ratio,
server_args.hicache_size,
radix_cache.page_size,
server_args.hicache_mem_layout,
allocator_type=server_args.hicache_storage_backend,
override_kv_cache_dim=kv.kv_cache_dim,
)
indexer_host = NSAIndexerPoolHost(
kv,
mla_host,
server_args.hicache_mem_layout,
allocator_type=server_args.hicache_storage_backend,
)
layer_num = kv.layer_num
def layer_mapper(layer_id: int):
if 0 <= layer_id < layer_num:
return layer_id
return None
host_pool_group = HostPoolGroup(
[
PoolEntry(
name=PoolName.KV,
host_pool=mla_host,
device_pool=kv,
layer_mapper=layer_mapper,
is_primary_index_anchor=True,
),
PoolEntry(
name=PoolName.INDEXER,
host_pool=indexer_host,
device_pool=kv,
layer_mapper=layer_mapper,
share_indices_with_anchor=True,
),
]
)
cache_controller = HybridCacheController(
params.token_to_kv_pool_allocator,
host_pool_group,
radix_cache.page_size,
radix_cache.tp_group,
load_cache_event=load_cache_event,
write_policy=server_args.hicache_write_policy,
io_backend=server_args.hicache_io_backend,
storage_backend=server_args.hicache_storage_backend,
prefetch_threshold=prefetch_threshold,
model_name=server_args.served_model_name,
storage_backend_extra_config=extra_config,
pp_rank=radix_cache.pp_rank,
pp_size=radix_cache.pp_size,
transfer_layer_num=layer_num,
enable_storage_metrics=enable_storage_metrics,
)
radix_cache.full_kv_pool_host = mla_host
radix_cache.token_to_kv_pool_host = host_pool_group
radix_cache.cache_controller = cache_controller
logger.info(
"Hybrid hierarchical cache: HostPoolGroup(KV + INDEXER), HybridCacheController, "
"transfer_layer_num=%s",
layer_num,
)
except Exception:
logger.exception("build_nsa_hybrid_stack failed")
raise
def build_mamba_hybrid_stack(
mamba_cache: "HiMambaRadixCache",
params: "CacheInitParams",
server_args: "ServerArgs",
*,
extra_config: dict,
prefetch_threshold: int,
load_cache_event,
enable_storage_metrics: bool = False,
) -> None:
"""HostPoolGroup (KV + Mamba) + HybridCacheController for hybrid SSM models."""
try:
hybrid_kv = mamba_cache.hybrid_kv_cache
kvcache = mamba_cache.kvcache
kv_host_pool_cls = (
MLATokenToKVPoolHost if hybrid_kv.use_mla else MHATokenToKVPoolHost
)
full_kv_pool_host = kv_host_pool_cls(
kvcache,
server_args.hicache_ratio,
server_args.hicache_size,
params.page_size,
server_args.hicache_mem_layout,
allocator_type=server_args.hicache_storage_backend,
)
mamba_pool_host = MambaPoolHost(
params.req_to_token_pool.mamba_pool,
server_args.hicache_ratio,
server_args.hicache_size,
allocator_type=server_args.hicache_storage_backend,
layout=server_args.hicache_mem_layout,
)
full_layer_ids = sorted(hybrid_kv.full_attention_layer_id_mapping.keys())
mamba_layer_ids = sorted(params.req_to_token_pool.mamba_map.keys())
transfer_layer_num = len(set(full_layer_ids) | set(mamba_layer_ids))
full_layer_mapping = dict(hybrid_kv.full_attention_layer_id_mapping)
mamba_layer_mapping = dict(params.req_to_token_pool.mamba_map)
def kv_layer_mapper(layer_id: int) -> Optional[int]:
if not 0 <= layer_id < transfer_layer_num:
return None
return full_layer_mapping.get(layer_id)
def mamba_layer_mapper(layer_id: int) -> Optional[int]:
if not 0 <= layer_id < transfer_layer_num:
return None
return mamba_layer_mapping.get(layer_id)
host_pool_group = HostPoolGroup(
[
PoolEntry(
name=PoolName.KV,
host_pool=full_kv_pool_host,
device_pool=kvcache,
layer_mapper=kv_layer_mapper,
is_primary_index_anchor=True,
),
PoolEntry(
name=PoolName.MAMBA,
host_pool=mamba_pool_host,
device_pool=params.req_to_token_pool.mamba_pool,
layer_mapper=mamba_layer_mapper,
host_evict_fn=mamba_cache.evict_mamba_host,
device_evict_fn=mamba_cache.evict_mamba,
),
]
)
cache_controller = HybridCacheController(
params.token_to_kv_pool_allocator,
host_pool_group,
params.page_size,
params.tp_cache_group,
load_cache_event=load_cache_event,
write_policy=server_args.hicache_write_policy,
io_backend=server_args.hicache_io_backend,
storage_backend=server_args.hicache_storage_backend,
prefetch_threshold=prefetch_threshold,
model_name=server_args.served_model_name,
storage_backend_extra_config=extra_config,
pp_rank=params.pp_rank,
pp_size=params.pp_size,
transfer_layer_num=transfer_layer_num,
enable_storage_metrics=enable_storage_metrics,
)
mamba_cache.full_kv_pool_host = full_kv_pool_host
mamba_cache.mamba_pool_host = mamba_pool_host
mamba_cache.transfer_layer_num = transfer_layer_num
mamba_cache.host_pool_group = host_pool_group
mamba_cache.cache_controller = cache_controller
params.req_to_token_pool.register_layer_transfer_counter(
cache_controller.layer_done_counter
)
hybrid_kv.register_layer_transfer_counter(cache_controller.layer_done_counter)
logger.info(
"Hybrid hierarchical cache: HostPoolGroup(KV + MAMBA), HybridCacheController, "
"transfer_layer_num=%s",
transfer_layer_num,
)
except Exception:
logger.exception("build_mamba_hybrid_stack failed")
raise
+231 -69
View File
@@ -64,6 +64,9 @@ if _is_npu:
logger = logging.getLogger(__name__)
# Host RAM to leave free when sizing HiCache pools (OS, other processes).
HICACHE_HOST_MEMORY_RESERVE_BYTES: int = 10 * (1024**3)
def synchronized(func):
@wraps(func)
@@ -187,9 +190,7 @@ class HostKVCache(abc.ABC):
# Verify there is enough available host memory.
host_mem = psutil.virtual_memory()
requested_bytes = self.size * self.size_per_token
# preserve at least 10GB for other usage
ten_gb = 10 * (1024**3)
available_bytes = host_mem.available - ten_gb
available_bytes = host_mem.available - HICACHE_HOST_MEMORY_RESERVE_BYTES
if requested_bytes > available_bytes:
raise ValueError(
f"Not enough host memory available. Requesting "
@@ -1213,6 +1214,10 @@ class MambaPoolHost(HostKVCache):
conv_state.shape[2:] for conv_state in device_pool.mamba_cache.conv
]
self.temporal_state_shape = device_pool.mamba_cache.temporal.shape[2:]
self.temporal_state_elem_size = int(np.prod(self.temporal_state_shape))
self.conv_state_elem_sizes = [
int(np.prod(conv_shape)) for conv_shape in self.conv_state_shapes
]
self.conv_dtype = device_pool.mamba_cache.conv[0].dtype
self.temporal_dtype = device_pool.mamba_cache.temporal.dtype
self.dtype = self.conv_dtype
@@ -1232,8 +1237,7 @@ class MambaPoolHost(HostKVCache):
host_mem = psutil.virtual_memory()
requested_bytes = self.size * self.size_per_token
ten_gb = 10 * (1024**3)
available_bytes = host_mem.available - ten_gb
available_bytes = host_mem.available - HICACHE_HOST_MEMORY_RESERVE_BYTES
if requested_bytes > available_bytes:
raise ValueError(
f"Not enough host memory available. Requesting "
@@ -1306,6 +1310,10 @@ class MambaPoolHost(HostKVCache):
)
)
def get_hybrid_pool_buffer(self):
# Expose all mamba host tensors that need Mooncake buffer registration.
return [self.temporal_buffer, *self.conv_buffer]
def _iter_page_tensors(self, index: int):
if self.layout in ["page_first", "page_first_direct"]:
yield self.temporal_buffer[index]
@@ -1347,12 +1355,11 @@ class MambaPoolHost(HostKVCache):
return len(indices)
def get_size_per_token(self):
conv_total_size = 0
for conv_shape in self.conv_state_shapes:
conv_total_size += int(np.prod(conv_shape)) * self.conv_dtype.itemsize
temporal_size = (
int(np.prod(self.temporal_state_shape)) * self.temporal_dtype.itemsize
conv_total_size = sum(
conv_elem_size * self.conv_dtype.itemsize
for conv_elem_size in self.conv_state_elem_sizes
)
temporal_size = self.temporal_state_elem_size * self.temporal_dtype.itemsize
return (conv_total_size + temporal_size) * self.num_mamba_layers
def get_ksize_per_token(self):
@@ -1588,6 +1595,65 @@ class MambaPoolHost(HostKVCache):
restored = tensor_bytes.view(dtype=tensor.dtype).reshape(tensor.shape)
tensor.copy_(restored)
def get_page_buffer_meta(self, indices):
"""Meta data for zero-copy storage I/O.
Only page-first layouts are supported for mamba storage zero-copy because
each page slot in temporal/conv buffers is directly addressable.
"""
assert len(indices) % self.page_size == 0
if self.layout not in ["page_first", "page_first_direct"]:
raise ValueError(
f"Mamba storage zero-copy requires page_first layout, got {self.layout}"
)
indices = indices.tolist()
ptr_list = []
element_size_list = []
# Compute base pointers once; each page pointer is offset from these bases.
temporal_base_ptr = self.temporal_buffer.data_ptr()
conv_base_ptrs = [buf.data_ptr() for buf in self.conv_buffer]
# Component sizes are constant across pages, so precompute once as well.
temporal_element_size = (
self.page_size
* self.num_mamba_layers
* self.temporal_dtype.itemsize
* self.temporal_state_elem_size
)
conv_element_sizes = [
(
self.page_size
* self.num_mamba_layers
* self.conv_dtype.itemsize
* self.conv_state_elem_sizes[i]
)
for i in range(len(self.conv_state_shapes))
]
for i in range(0, len(indices), self.page_size):
# Emit component pointers in stable order:
# temporal first, then conv_0..conv_n for this page.
temporal_ptr = (
temporal_base_ptr
+ indices[i]
* self.num_mamba_layers
* self.temporal_state_elem_size
* self.temporal_dtype.itemsize
)
ptr_list.append(temporal_ptr)
element_size_list.append(temporal_element_size)
for j in range(len(self.conv_buffer)):
conv_ptr = (
conv_base_ptrs[j]
+ indices[i]
* self.num_mamba_layers
* self.conv_state_elem_sizes[j]
* self.conv_dtype.itemsize
)
ptr_list.append(conv_ptr)
element_size_list.append(conv_element_sizes[j])
return ptr_list, element_size_list
@dataclass
class PoolEntry:
@@ -1596,6 +1662,9 @@ class PoolEntry:
device_pool: Any
layer_mapper: Callable[[int], Optional[int]]
is_primary_index_anchor: bool = False
# When True, host_pool uses the same logical slot indices as the anchor pool
# (e.g. DSA indexer); HostPoolGroup.free mirrors frees to this pool.
share_indices_with_anchor: bool = False
# Optional eviction callbacks for auto-alloc in HybridCacheController.
# host_evict_fn(n): evict n slots from the host pool (used by write()).
# device_evict_fn(n): evict n slots from the device pool (used by load()).
@@ -1619,15 +1688,54 @@ class HostPoolGroup:
self.device = self.anchor_entry.host_pool.device
self.size = self.anchor_entry.host_pool.size
@property
def kv_buffer(self):
return self.anchor_entry.host_pool.kv_buffer
@property
def size_per_token(self):
return self.anchor_entry.host_pool.size_per_token
@property
def allocator(self):
return self.anchor_entry.host_pool.allocator
@property
def dtype(self):
return self.anchor_entry.host_pool.dtype
@property
def start_layer(self):
return self.anchor_entry.host_pool.start_layer
@property
def end_layer(self):
return self.anchor_entry.host_pool.end_layer
def get_ksize_per_token(self):
return self.anchor_entry.host_pool.get_ksize_per_token()
def get_page_buffer_meta(self, indices):
return self.anchor_entry.host_pool.get_page_buffer_meta(indices)
def clear(self) -> None:
for entry in self.entries:
entry.host_pool.clear()
def available_size(self):
return self.anchor_entry.host_pool.available_size()
def alloc(self, need_size: int) -> Optional[torch.Tensor]:
return self.anchor_entry.host_pool.alloc(need_size)
def free(self, indices: torch.Tensor) -> int:
return self.anchor_entry.host_pool.free(indices)
n = self.anchor_entry.host_pool.free(indices)
for entry in self.entries:
if entry is self.anchor_entry:
continue
if getattr(entry, "share_indices_with_anchor", False):
entry.host_pool.free(indices)
return n
def get_data_page(self, index, flat: bool = True):
return self.anchor_entry.host_pool.get_data_page(index, flat)
@@ -1703,21 +1811,31 @@ class HostPoolGroup:
)
class NSATokenToKVPoolHost(MLATokenToKVPoolHost):
class NSAIndexerPoolHost(HostKVCache):
"""Host-side NSA index buffers only. Slot layout matches the anchor MLA host pool."""
device_pool: NSATokenToKVPool
def __init__(
self,
device_pool: NSATokenToKVPool,
host_to_device_ratio: float,
host_size: int,
page_size: int,
anchor_host: MLATokenToKVPoolHost,
layout: str,
pin_memory: bool = True,
device: str = "cpu",
allocator_type: str = "default",
):
# Initialize indexer metadata before HostKVCache.__init__ calls get_size_per_token.
self.device_pool = device_pool
self.page_size = anchor_host.page_size
self.layout = layout
self.pin_memory = pin_memory
self.device = device
self.allocator = get_allocator_from_storage(allocator_type)
self.dtype = device_pool.store_dtype
self.start_layer = device_pool.start_layer
self.end_layer = device_pool.end_layer
self.layer_num = device_pool.layer_num
self.index_head_dim = device_pool.index_head_dim
self.indexer_quant_block_size = device_pool.quant_block_size
self.indexer_dtype = NSATokenToKVPool.index_k_with_scale_buffer_dtype
@@ -1725,35 +1843,46 @@ class NSATokenToKVPoolHost(MLATokenToKVPoolHost):
self.index_head_dim
+ self.index_head_dim // self.indexer_quant_block_size * 4
)
super().__init__(
device_pool,
host_to_device_ratio,
host_size,
page_size,
layout,
pin_memory,
device,
allocator_type,
override_kv_cache_dim=device_pool.kv_cache_dim,
)
self.size = anchor_host.size
self.page_num = anchor_host.page_num
self.indexer_page_stride_size = (
self.indexer_size_per_token * self.page_size * self.indexer_dtype.itemsize
)
self.indexer_layout_dim = self.indexer_page_stride_size * self.layer_num
self.indexer_page_num = (self.size + self.page_size + 1) // self.page_size
self._init_indexer_buffers()
logger.info(
f"NSATokenToKVPoolHost initialized with indexer page stride size: {self.indexer_page_stride_size}, page num: {self.indexer_page_num}"
self.size_per_token = (
self.indexer_size_per_token * self.layer_num * self.indexer_dtype.itemsize
)
buf_elem_size = self.page_num * self.layer_num * self.indexer_page_stride_size
requested_bytes = buf_elem_size * self.indexer_dtype.itemsize
host_mem = psutil.virtual_memory()
available_bytes = host_mem.available - HICACHE_HOST_MEMORY_RESERVE_BYTES
if requested_bytes > available_bytes:
raise ValueError(
f"Not enough host memory for NSA indexer hierarchical cache. "
f"Requesting {requested_bytes / 1e9:.2f} GB but only have "
f"{available_bytes / 1e9:.2f} GB free."
)
logger.info(
"Allocating %.2f GB host memory for NSA indexer (layout=%s).",
requested_bytes / 1e9,
layout,
)
self.init_kv_buffer()
self.lock = threading.RLock()
self.clear()
def get_size_per_token(self):
base = super().get_size_per_token()
return (
base
+ self.indexer_size_per_token * self.layer_num * self.indexer_dtype.itemsize
self.indexer_size_per_token * self.layer_num * self.indexer_dtype.itemsize
)
def _init_indexer_buffers(self):
def get_ksize_per_token(self):
return self.get_size_per_token()
def init_kv_buffer(self):
alloc_func = ALLOC_MEMORY_FUNCS[self.device_pool.device]
self.index_k_device_ptrs = torch.tensor(
[x.data_ptr() for x in self.device_pool.index_k_with_scale_buffer],
@@ -1761,16 +1890,13 @@ class NSATokenToKVPoolHost(MLATokenToKVPoolHost):
device=self.device_pool.device,
)
if self.layout == "layer_first":
self.index_k_with_scale_buffer = [
alloc_func(
(self.indexer_page_num, self.indexer_page_stride_size),
dtype=self.indexer_dtype,
device=self.device,
pin_memory=self.pin_memory,
allocator=self.allocator,
)
for _ in range(self.layer_num)
]
self.index_k_with_scale_buffer = alloc_func(
(self.layer_num, self.indexer_page_num, self.indexer_page_stride_size),
dtype=self.indexer_dtype,
device=self.device,
pin_memory=self.pin_memory,
allocator=self.allocator,
)
self.index_k_data_refs = [
self.index_k_with_scale_buffer[i] for i in range(self.layer_num)
]
@@ -1795,6 +1921,9 @@ class NSATokenToKVPoolHost(MLATokenToKVPoolHost):
else:
raise ValueError(f"Unsupported layout: {self.layout}")
def get_hybrid_pool_buffer(self):
return [self.index_k_with_scale_buffer]
def _get_indexer_page_indices(self, host_indices, device_indices):
if host_indices.numel() == 0:
return host_indices, device_indices
@@ -1810,7 +1939,7 @@ class NSATokenToKVPoolHost(MLATokenToKVPoolHost):
)
return host_page_indices, device_page_indices
def _load_indexer_to_device_per_layer(
def load_to_device_per_layer(
self, device_pool, host_indices, device_indices, layer_id, io_backend
):
host_page_indices, device_page_indices = self._get_indexer_page_indices(
@@ -1861,7 +1990,7 @@ class NSATokenToKVPoolHost(MLATokenToKVPoolHost):
else:
raise ValueError(f"Unsupported IO backend: {io_backend}")
def _backup_indexer_from_device_all_layer(
def backup_from_device_all_layer(
self, device_pool, host_indices, device_indices, io_backend
):
host_page_indices, device_page_indices = self._get_indexer_page_indices(
@@ -1894,7 +2023,7 @@ class NSATokenToKVPoolHost(MLATokenToKVPoolHost):
if self.layout == "layer_first":
transfer_kv_direct(
src_layers=device_pool.index_k_with_scale_buffer,
dst_layers=self.index_k_with_scale_buffer,
dst_layers=self.index_k_data_refs,
src_indices=device_page_indices,
dst_indices=host_page_indices,
page_size=1,
@@ -1912,27 +2041,60 @@ class NSATokenToKVPoolHost(MLATokenToKVPoolHost):
else:
raise ValueError(f"Unsupported IO backend: {io_backend}")
def load_to_device_per_layer(
self,
device_pool,
host_indices,
device_indices,
layer_id,
io_backend,
):
super().load_to_device_per_layer(
device_pool, host_indices, device_indices, layer_id, io_backend
)
self._load_indexer_to_device_per_layer(
device_pool, host_indices, device_indices, layer_id, io_backend
)
def get_data_page(self, index, flat: bool = True) -> torch.Tensor:
page_idx = int(index) // self.page_size
if self.layout == "layer_first":
data_page = self.index_k_with_scale_buffer[:, page_idx : page_idx + 1, :]
elif self.layout in ["page_first", "page_first_direct"]:
data_page = self.index_k_with_scale_buffer[page_idx : page_idx + 1, :, :, :]
else:
raise ValueError(f"Unsupported layout: {self.layout}")
if flat:
data_page = data_page.flatten()
return data_page
def backup_from_device_all_layer(
self, device_pool, host_indices, device_indices, io_backend
):
super().backup_from_device_all_layer(
device_pool, host_indices, device_indices, io_backend
)
self._backup_indexer_from_device_all_layer(
device_pool, host_indices, device_indices, io_backend
def get_dummy_flat_data_page(self) -> torch.Tensor:
return torch.zeros(
(self.layer_num, self.indexer_page_stride_size),
dtype=self.indexer_dtype,
device=self.device,
pin_memory=self.pin_memory,
).flatten()
def set_from_flat_data_page(self, index: int, data_page: torch.Tensor) -> None:
page_idx = int(index) // self.page_size
if self.layout == "layer_first":
self.index_k_with_scale_buffer[:, page_idx : page_idx + 1, :] = (
data_page.reshape(
self.layer_num,
1,
self.indexer_page_stride_size,
)
)
elif self.layout in ["page_first", "page_first_direct"]:
self.index_k_with_scale_buffer[page_idx : page_idx + 1, :, :, :] = (
data_page.reshape(
1,
self.layer_num,
1,
self.indexer_page_stride_size,
)
)
else:
raise ValueError(f"Unsupported layout: {self.layout}")
def get_page_buffer_meta(self, indices):
"""Meta data for zero-copy storage I/O."""
assert len(indices) % self.page_size == 0
if self.layout not in ["page_first", "page_first_direct"]:
raise ValueError(f"Unsupported layout: {self.layout}")
ptr_list = []
indices = indices.tolist()
page_stride_bytes = (
self.layer_num * self.indexer_page_stride_size * self.indexer_dtype.itemsize
)
base_ptr = self.index_k_with_scale_buffer.data_ptr()
for i in range(0, len(indices), self.page_size):
page_index = int(indices[i]) // self.page_size
ptr_list.append(base_ptr + page_index * page_stride_bytes)
return ptr_list, [page_stride_bytes] * len(ptr_list)
@@ -5,7 +5,7 @@ import os
import time
import uuid
from dataclasses import dataclass
from typing import Any, List, Optional
from typing import Any, List, Optional, Tuple
import requests
import torch
@@ -15,6 +15,10 @@ from sglang.srt.mem_cache.hicache_storage import (
HiCacheStorage,
HiCacheStorageConfig,
HiCacheStorageExtraInfo,
PoolHitPolicy,
PoolName,
PoolTransfer,
PoolTransferResult,
)
from sglang.srt.mem_cache.memory_pool_host import HostKVCache, HostTensorAllocator
from sglang.srt.observability.metrics_collector import StorageMetrics
@@ -432,6 +436,8 @@ class MooncakeStore(HiCacheStorage, MooncakeBaseStore):
else:
self.mha_suffix = [f"{rank}" for rank in target_ranks]
self.registered_pools = {}
self.gb_per_page = None
self.prefetch_pgs = []
self.backup_pgs = []
@@ -502,6 +508,154 @@ class MooncakeStore(HiCacheStorage, MooncakeBaseStore):
bytes_per_page = mem_pool_host.get_ksize_per_token() * mem_pool_host.page_size
self.gb_per_page = bytes_per_page / (1 << 30)
def register_mem_host_pool_v2(self, host_pool: HostKVCache, host_pool_name):
# KV anchor memory is already registered via register_mem_pool_host().
# v2 here only registers additional hybrid pools.
if host_pool_name == PoolName.KV:
return
# Keep a name->pool mapping so batch v2 can resolve PoolTransfer.name to
# the corresponding host pool implementation at runtime.
self.registered_pools[host_pool_name] = host_pool
# Hybrid pools expose the tensors that Mooncake needs for zero-copy I/O.
# The storage backend only depends on this accessor, not concrete fields.
buf_list = host_pool.get_hybrid_pool_buffer()
for buf in buf_list:
super().register_buffer(buf)
def _tag_keys(self, keys: List[str]) -> List[str]:
if self.extra_backend_tag is None:
return keys
return [f"{ self.extra_backend_tag}_{key}" for key in keys]
def _get_hybrid_page_component_keys(
self, page_keys: List[str], transfer: PoolTransfer
) -> Tuple[List[str], int]:
# A logical "page" may map to multiple physical objects in storage.
# - INDEXER: one key per page
# - MAMBA : one temporal key + N conv keys per page
# key_multiplier records how many component keys are generated per page.
name = transfer.name
suffixes = []
if name == PoolName.INDEXER:
suffixes = [f"_{self.mla_suffix}_{PoolName.INDEXER}"]
elif name == PoolName.MAMBA:
pools = getattr(self, "registered_pools", {})
mamba_pool = pools.get(PoolName.MAMBA)
conv_num = len(getattr(mamba_pool, "conv_buffer", None) or [])
base_suffix = f"_{self.mha_suffix}"
suffixes = [f"{base_suffix}_temporal"] + [
f"{base_suffix}_conv_{i}" for i in range(conv_num)
]
key_multiplier = len(suffixes)
component_keys = [
f"{page_key}{suffix}" for page_key in page_keys for suffix in suffixes
]
return component_keys, key_multiplier
def batch_exists_v2(
self,
keys: List[str],
pool_transfers: Optional[List[PoolTransfer]] = None,
extra_info: Optional[HiCacheStorageExtraInfo] = None,
) -> PoolTransferResult:
qkeys = self._tag_keys(keys)
kv_pages = self.batch_exists(qkeys, extra_info)
hit_count: dict = {PoolName.KV: kv_pages} if kv_pages else {}
final_pages = kv_pages
for transfer in pool_transfers or []:
if final_pages == 0:
break
component_keys, key_multiplier = self._get_hybrid_page_component_keys(
qkeys, transfer
)
ex = self._batch_exist(component_keys)
if key_multiplier > 0:
page_exists = [
all(
r == 1
for r in ex[i * key_multiplier : (i + 1) * key_multiplier]
)
for i in range(kv_pages)
]
else:
page_exists = [False] * kv_pages
boundary = 0
if transfer.hit_policy == PoolHitPolicy.ALL_PAGES:
try:
boundary = page_exists.index(False)
except ValueError:
boundary = kv_pages
elif transfer.hit_policy == PoolHitPolicy.TRAILING_PAGES:
trailing = max(1, len(transfer.keys) if transfer.keys else 1)
for prefix_len in range(kv_pages, 0, -1):
if all(
page_exists[i]
for i in range(max(0, prefix_len - trailing), prefix_len)
):
boundary = prefix_len
break
if boundary:
hit_count[transfer.name] = boundary
final_pages = min(final_pages, boundary)
return PoolTransferResult(final_pages, hit_count)
def _batch_io_v2(self, transfers: List[PoolTransfer], is_set: bool):
# Unified v2 I/O path: each PoolTransfer can expand to one or more
# storage objects per logical page, but API still reports page-level result.
results: dict = {}
for transfer in transfers:
host_pool = getattr(self, "registered_pools", {}).get(transfer.name)
keys = transfer.keys
page_size = getattr(host_pool, "page_size", 1) or 1
host_indices = transfer.host_indices
assert len(keys) > 0
assert len(keys) == len(host_indices) // page_size
ptr_list, element_size_list = host_pool.get_page_buffer_meta(host_indices)
key_strs, key_multiplier = self._get_hybrid_page_component_keys(
keys, transfer
)
key_strs = self._tag_keys(key_strs)
if is_set:
exist_result = self._batch_exist(key_strs)
io_results = [0 if state == 1 else -1 for state in exist_result]
missing_idx = [i for i, state in enumerate(exist_result) if state != 1]
if missing_idx:
put_results = self._put_batch_zero_copy_impl(
[key_strs[i] for i in missing_idx],
[ptr_list[i] for i in missing_idx],
[element_size_list[i] for i in missing_idx],
)
for i, res in zip(missing_idx, put_results):
io_results[i] = res
else:
io_results = self._get_batch_zero_copy_impl(
key_strs, ptr_list, element_size_list
)
results[transfer.name] = self._batch_postprocess(
io_results, is_set_operate=is_set, key_multiplier=key_multiplier
)
return results
def batch_get_v2(
self,
transfers: List[PoolTransfer],
extra_info: Optional[HiCacheStorageExtraInfo] = None,
) -> dict:
return self._batch_io_v2(transfers, is_set=False)
def batch_set_v2(
self,
transfers: List[PoolTransfer],
extra_info: Optional[HiCacheStorageExtraInfo] = None,
) -> dict:
return self._batch_io_v2(transfers, is_set=True)
def _get_mha_split_heads_buffer_meta(self, keys, indices):
ptr_list, element_size_list = (
self.mem_pool_host.get_split_heads_page_buffer_meta(
@@ -544,7 +698,9 @@ class MooncakeStore(HiCacheStorage, MooncakeBaseStore):
else:
return self._get_mha_buffer_meta(keys, host_indices)
def _batch_postprocess(self, results: List[int], is_set_operate=False):
def _batch_postprocess(
self, results: List[int], is_set_operate=False, key_multiplier=None
):
"""
refer to https://github.com/kvcache-ai/Mooncake/blob/main/mooncake-store/include/pybind_client.h
for batch_get_into, results is Vector of integers,
@@ -552,32 +708,26 @@ class MooncakeStore(HiCacheStorage, MooncakeBaseStore):
for batch_put_from, results is Vector of integers,
where each element is 0 on success, or a negative value on error
"""
if self.is_mla_backend:
return [k_res == 0 if is_set_operate else k_res > 0 for k_res in results]
else:
if self.storage_config.should_split_heads:
kv_groups = [
results[i : i + self.split_factor * 2]
for i in range(0, len(results), self.split_factor * 2)
]
return [
(
all(res == 0 for res in kv_group)
if is_set_operate
else all(res > 0 for res in kv_group)
)
for kv_group in kv_groups
]
if key_multiplier is None:
if self.is_mla_backend:
key_multiplier = 1
else:
kv_pairs = zip(results[::2], results[1::2])
return [
(
(k_res == 0 and v_res == 0)
if is_set_operate
else (k_res > 0 and v_res > 0)
)
for k_res, v_res in kv_pairs
]
key_multiplier = 2
if self.storage_config.should_split_heads:
key_multiplier *= self.split_factor
result_groups = [
results[i : i + key_multiplier]
for i in range(0, len(results), key_multiplier)
]
return [
(
all(res == 0 for res in group)
if is_set_operate
else all(res > 0 for res in group)
)
for group in result_groups
]
def batch_get_v1(
self,
@@ -586,9 +736,7 @@ class MooncakeStore(HiCacheStorage, MooncakeBaseStore):
extra_info: Optional[HiCacheStorageExtraInfo] = None,
) -> List[bool]:
# Apply extra_backend_tag prefix if available
if self.extra_backend_tag is not None:
prefix = self.extra_backend_tag
keys = [f"{prefix}_{key}" for key in keys]
keys = self._tag_keys(keys)
key_strs, buffer_ptrs, buffer_sizes = self._batch_preprocess(keys, host_indices)
@@ -613,9 +761,7 @@ class MooncakeStore(HiCacheStorage, MooncakeBaseStore):
extra_info: Optional[HiCacheStorageExtraInfo] = None,
) -> List[bool]:
# Apply extra_backend_tag prefix if available
if self.extra_backend_tag is not None:
prefix = self.extra_backend_tag
keys = [f"{prefix}_{key}" for key in keys]
keys = self._tag_keys(keys)
key_strs, buffer_ptrs, buffer_sizes = self._batch_preprocess(keys, host_indices)
exist_result = self._batch_exist(key_strs)
@@ -780,9 +926,7 @@ class MooncakeStore(HiCacheStorage, MooncakeBaseStore):
self, keys, extra_info: Optional[HiCacheStorageExtraInfo] = None
) -> int:
# Apply extra_backend_tag prefix if available
if self.extra_backend_tag is not None:
prefix = self.extra_backend_tag
keys = [f"{prefix}_{key}" for key in keys]
keys = self._tag_keys(keys)
if self.is_mla_backend:
query_keys = [f"{key}_{self.mla_suffix}_k" for key in keys]