Files
lmcache--lmcache/tests/v1/test_kv_layer_groups_manager.py
2026-07-13 12:24:33 +08:00

678 lines
28 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# Standard
from collections.abc import Sequence
# Third Party
import pytest
import torch
# First Party
from lmcache.v1.kv_layer_groups import (
EXCLUDED_ENGINE_GROUP,
KernelGroupIdentity,
KernelGroupInfo,
KVLayerGroupInfo,
KVLayerGroupsManager,
LayerGroupIdentity,
ObjectGroupInfo,
format_kvcache_shape_spec,
group_layers_by_identity,
parse_kvcache_shape_spec,
)
from lmcache.v1.multiprocess.group_view import EngineGroupInfo
pytestmark = pytest.mark.skipif(
not torch.cuda.is_available(), reason="PageBufferShapeDesc requires CUDA build"
)
def _build_manager(
tensors: list[torch.Tensor],
*,
engine_group_infos: Sequence[EngineGroupInfo] = (),
separate_object_groups: bool = False,
) -> KVLayerGroupsManager:
"""Build a manager using the per-layer NHD format.
Tensors in these tests have shape ``[2, NB, BS, NH, HS]`` — the
canonical vLLM flash-attention per-layer NHD layout matched by
``GPUKVFormat.NL_X_TWO_NB_BS_NH_HS``. ``bs`` and ``nb`` are discovered
per-layer from the tensor shapes, so callers pass neither.
"""
# First Party
import lmcache.c_ops as lmc_ops
return KVLayerGroupsManager(
tensors,
engine_kv_formats=[lmc_ops.EngineKVFormat.NL_X_TWO_NB_BS_NH_HS] * len(tensors),
engine_group_infos=engine_group_infos,
separate_object_groups=separate_object_groups,
)
class TestKVLayerGroupsManager:
"""Tests for KVLayerGroupsManager construction and lookups."""
def test_build_empty(self):
manager = _build_manager([])
assert manager.kernel_groups == []
def test_build_single_layer(self):
tensors = [torch.randn(2, 32, 256, 8, 64, dtype=torch.float16)]
manager = _build_manager(tensors)
assert len(manager.kernel_groups) == 1
group = manager.kernel_groups[0]
assert isinstance(group, KVLayerGroupInfo)
assert group.layer_indices == [0]
assert group.shape_desc.kv_size == 2
assert group.shape_desc.nh == 8
assert group.shape_desc.hs == 64
assert group.shape_desc.nl == 1
assert group.shape_desc.nb == 32
assert group.shape_desc.bs == 256
assert group.dtype == torch.float16
def test_build_mixed_formats_per_group(self):
"""Mixed-format shape: a K+V group and a key-only MLA group are shaped
with their own per-layer formats (kv_size 2 and 1), not one shared
format -- the server-side per-group path."""
# First Party
import lmcache.c_ops as lmc_ops
tensors = [
torch.randn(2, 32, 256, 8, 64, dtype=torch.bfloat16), # K+V (rank-5)
torch.randn(32, 256, 128, dtype=torch.bfloat16), # MLA key-only (rank-3)
]
manager = KVLayerGroupsManager(
tensors,
engine_kv_formats=[
lmc_ops.EngineKVFormat.NL_X_TWO_NB_BS_NH_HS,
lmc_ops.EngineKVFormat.NL_X_NB_BS_HS,
],
engine_group_infos=[
EngineGroupInfo(0, (0,)),
EngineGroupInfo(1, (1,)),
],
)
groups = manager.kernel_groups
assert len(groups) == 2
by_group = {g.engine_group_idx: g for g in groups}
assert by_group[0].shape_desc.kv_size == 2 # K+V main cache
assert by_group[0].shape_desc.nh == 8
assert by_group[1].shape_desc.kv_size == 1 # key-only MLA index cache
assert by_group[1].shape_desc.nh == 1
assert by_group[1].shape_desc.hs == 128
# Each kernel group persists its own format for the transfer path.
assert (
by_group[0].engine_kv_format == lmc_ops.EngineKVFormat.NL_X_TWO_NB_BS_NH_HS
)
assert by_group[1].engine_kv_format == lmc_ops.EngineKVFormat.NL_X_NB_BS_HS
def test_build_multiple_layers_same_shape(self):
tensors = [
torch.randn(2, 32, 256, 8, 64, dtype=torch.float16) for _ in range(3)
]
manager = _build_manager(tensors)
assert len(manager.kernel_groups) == 1
group = manager.kernel_groups[0]
assert group.layer_indices == [0, 1, 2]
assert group.shape_desc.nl == 3
assert group.shape_desc.nh == 8
assert group.engine_group_idx == 0
def test_build_splits_same_shape_by_engine_group_idx(self):
tensors = [
torch.randn(2, 32, 256, 8, 64, dtype=torch.float16) for _ in range(4)
]
manager = _build_manager(
tensors,
engine_group_infos=[
EngineGroupInfo(0, (0, 2)),
EngineGroupInfo(1, (1, 3)),
],
)
assert len(manager.kernel_groups) == 2
groups_by_engine_group_idx = {
group.engine_group_idx: group for group in manager.kernel_groups
}
assert groups_by_engine_group_idx[0].layer_indices == [0, 2]
assert groups_by_engine_group_idx[1].layer_indices == [1, 3]
def test_build_rejects_bad_engine_group_infos(self):
tensors = [
torch.randn(2, 32, 256, 8, 64, dtype=torch.float16) for _ in range(2)
]
with pytest.raises(ValueError, match="outside registered layer"):
_build_manager(
tensors,
engine_group_infos=[EngineGroupInfo(0, (2,))],
)
def test_build_rejects_coarse_engine_group_infos(self):
# One info covering two layers that split into two kernel groups
# (different num_heads) violates the one-info-per-kernel-group
# contract.
tensors = [
torch.randn(2, 32, 256, 8, 64, dtype=torch.float16),
torch.randn(2, 32, 256, 16, 64, dtype=torch.float16),
]
with pytest.raises(ValueError, match="engine group info"):
_build_manager(
tensors,
engine_group_infos=[EngineGroupInfo(0, (0, 1))],
)
def test_build_different_shapes(self):
tensors = [
torch.randn(2, 32, 256, 8, 64, dtype=torch.float16),
torch.randn(2, 32, 256, 16, 64, dtype=torch.float16),
torch.randn(2, 32, 256, 8, 64, dtype=torch.float16),
]
manager = _build_manager(tensors)
assert len(manager.kernel_groups) == 2
group1, group2 = manager.kernel_groups
assert group1.layer_indices == [0, 2]
assert group1.shape_desc.nh == 8
assert group2.layer_indices == [1]
assert group2.shape_desc.nh == 16
def test_build_different_dtypes(self):
tensors = [
torch.randn(2, 32, 256, 8, 64, dtype=torch.float16),
torch.randn(2, 32, 256, 8, 64, dtype=torch.float32),
torch.randn(2, 32, 256, 8, 64, dtype=torch.float16),
]
manager = _build_manager(tensors)
assert len(manager.kernel_groups) == 2
group1, group2 = manager.kernel_groups
assert group1.layer_indices == [0, 2]
assert group1.dtype == torch.float16
assert group2.layer_indices == [1]
assert group2.dtype == torch.float32
def test_build_mixed_differences(self):
tensors = [
torch.randn(2, 32, 256, 8, 64, dtype=torch.float16), # nh=8, f16
torch.randn(2, 32, 256, 8, 64, dtype=torch.float32), # nh=8, f32
torch.randn(2, 32, 256, 16, 64, dtype=torch.float16), # nh=16, f16
torch.randn(2, 32, 256, 8, 64, dtype=torch.float16), # nh=8, f16
torch.randn(2, 32, 256, 16, 64, dtype=torch.float32), # nh=16, f32
]
manager = _build_manager(tensors)
assert len(manager.kernel_groups) == 4
groups_by_key = {(g.shape_desc.nh, g.dtype): g for g in manager.kernel_groups}
assert groups_by_key[(8, torch.float16)].layer_indices == [0, 3]
assert groups_by_key[(8, torch.float32)].layer_indices == [1]
assert groups_by_key[(16, torch.float16)].layer_indices == [2]
assert groups_by_key[(16, torch.float32)].layer_indices == [4]
def test_get_shape_desc_by_group_idx(self):
tensors = [
torch.randn(2, 32, 256, 8, 64, dtype=torch.float16),
torch.randn(2, 32, 256, 16, 64, dtype=torch.float16),
]
manager = _build_manager(tensors)
sd0 = manager.get_shape_desc(0)
assert sd0.nh == 8
assert sd0.hs == 64
assert sd0.nl == 1
sd1 = manager.get_shape_desc(1)
assert sd1.nh == 16
assert sd1.hs == 64
class TestParseKvcacheShapeSpec:
"""Test cases for parse_kvcache_shape_spec function."""
def test_single_group(self):
"""Test parsing a single group spec."""
groups = parse_kvcache_shape_spec("(2,1024,16,8,128):float16:32")
assert len(groups) == 1
g = groups[0]
assert g.num_layers == 32
assert g.shape_desc.kv_size == 2
assert g.shape_desc.nb == 1024
assert g.shape_desc.bs == 16
assert g.shape_desc.nh == 8
assert g.shape_desc.hs == 128
assert g.shape_desc.nl == 32
assert g.dtype == torch.float16
assert g.layer_indices == list(range(32))
# Bench bookkeeping groups carry no format (the server re-detects); the
# spec has no format enum and these never drive a transfer.
assert g.engine_kv_format is None
def test_multiple_groups(self):
"""Test parsing multiple groups separated by semicolons."""
spec = "(2,1024,16,8,128):float16:30;(2,1024,16,4,64):bfloat16:2"
groups = parse_kvcache_shape_spec(spec)
assert len(groups) == 2
# First group: 30 layers
assert groups[0].num_layers == 30
assert groups[0].dtype == torch.float16
assert groups[0].layer_indices == list(range(30))
# Second group: 2 layers, offset by 30
assert groups[1].num_layers == 2
assert groups[1].dtype == torch.bfloat16
assert groups[1].shape_desc.nh == 4
assert groups[1].shape_desc.hs == 64
assert groups[1].layer_indices == [30, 31]
def test_empty_spec_raises(self):
"""Test that empty spec raises ValueError."""
with pytest.raises(ValueError, match="cannot be empty"):
parse_kvcache_shape_spec("")
def test_invalid_format_raises(self):
"""Test that invalid format raises ValueError."""
with pytest.raises(ValueError, match="Invalid group spec"):
parse_kvcache_shape_spec("bad_format")
def test_unrecognized_dtype_raises(self):
"""Test that unrecognized dtype raises with helpful message."""
with pytest.raises(ValueError, match="Unrecognized dtype"):
parse_kvcache_shape_spec("(2,1024,16,8,128):float64:32")
def test_invalid_number_raises(self):
"""Test that non-numeric shape values raise ValueError."""
with pytest.raises(ValueError, match="Invalid number"):
parse_kvcache_shape_spec("(2,abc,16,8,128):float16:32")
def test_whitespace_handling(self):
"""Test that whitespace around group separators is handled."""
groups = parse_kvcache_shape_spec(
" (2,1024,16,8,128):float16:4 ; (2,1024,16,4,64):bfloat16:2 "
)
assert len(groups) == 2
assert groups[0].num_layers == 4
assert groups[1].num_layers == 2
def test_no_valid_groups_raises(self):
"""Test that spec with only separators raises."""
with pytest.raises(ValueError, match="No valid layer groups"):
parse_kvcache_shape_spec(";;;")
class TestFormatKvcacheShapeSpec:
"""Test cases for format_kvcache_shape_spec function."""
def test_single_group(self):
spec = "(2,1024,16,8,128):float16:32"
groups = parse_kvcache_shape_spec(spec)
assert format_kvcache_shape_spec(groups) == spec
def test_multiple_groups(self):
spec = "(2,1024,16,8,128):float16:30;(1,512,8,4,64):bfloat16:2"
groups = parse_kvcache_shape_spec(spec)
assert format_kvcache_shape_spec(groups) == spec
def test_uint8_dtype(self):
spec = "(2,1024,16,8,128):uint8:32"
groups = parse_kvcache_shape_spec(spec)
assert format_kvcache_shape_spec(groups) == spec
def test_round_trip_normalizes_whitespace(self):
"""format() always produces the canonical (whitespace-free) form."""
messy = " (2,1024,16,8,128):float16:4 ; (2,1024,16,4,64):bfloat16:2 "
canonical = "(2,1024,16,8,128):float16:4;(2,1024,16,4,64):bfloat16:2"
assert format_kvcache_shape_spec(parse_kvcache_shape_spec(messy)) == canonical
def test_empty_groups_raises(self):
with pytest.raises(ValueError, match="empty"):
format_kvcache_shape_spec([])
class TestValidateBlockChunkSizeConfig:
"""Construction-time validation of the block/chunk size configuration:
``tokens_per_block`` (engine KV cache spec) must pack whole
``slots_per_block`` (registered tensor batch dimension), an LMCache chunk
must span whole paged blocks, and a sub-chunk sliding window must cover
whole paged blocks.
"""
def _validate(
self, slots: int, tokens: int, chunk: int = 256, sw: int = -1
) -> None:
KVLayerGroupsManager._validate_block_chunk_size_config(
group_idx=0,
slots_per_block=slots,
tokens_per_block=tokens,
lmcache_tokens_per_chunk=chunk,
sw_size_tokens=sw,
)
def test_valid_configs_pass(self):
self._validate(slots=16, tokens=16)
# slots=8 packs 2 logical tokens per physical slot (DeepSeek V4 style).
self._validate(slots=8, tokens=16)
# Sub-chunk window aligned to whole paged blocks.
self._validate(slots=16, tokens=16, sw=64)
# Big window (>= chunk) needs no sub-chunk alignment.
self._validate(slots=16, tokens=16, sw=1000)
def test_not_divisible_raises(self):
# Divisibility is enforced loudly (e.g. slots=6 does not divide 16).
with pytest.raises(ValueError, match="must be a multiple of"):
self._validate(slots=6, tokens=16)
def test_chunk_not_divisible_by_ratio_raises(self):
with pytest.raises(ValueError, match="lmcache_tokens_per_chunk"):
self._validate(slots=1, tokens=96, chunk=256)
def test_subchunk_window_not_block_aligned_raises(self):
# A sub-chunk window of 100 tokens does not cover whole 16-token
# blocks, so the transfer slot count would disagree with the kept
# block IDs.
with pytest.raises(ValueError, match="sliding window"):
self._validate(slots=16, tokens=16, sw=100)
class TestKernelGroupIdentity:
"""The grouping key is a named tuple; ``LayerGroupIdentity`` is its alias."""
def test_fields_and_alias(self):
# First Party
import lmcache.c_ops as lmc_ops
fmt = lmc_ops.EngineKVFormat.NL_X_TWO_NB_BS_NH_HS
ident = KernelGroupIdentity(
kv_size=2,
num_heads=8,
head_size=64,
block_size=16,
engine_group_idx=0,
dtype=torch.float16,
engine_kv_format=fmt,
)
assert ident.kv_size == 2
assert ident.num_heads == 8
assert ident.head_size == 64
assert ident.block_size == 16
assert ident.engine_group_idx == 0
assert ident.dtype == torch.float16
assert ident.engine_kv_format == fmt
assert LayerGroupIdentity is KernelGroupIdentity
def test_hashable_as_dict_key(self):
# First Party
import lmcache.c_ops as lmc_ops
fmt = lmc_ops.EngineKVFormat.NL_X_TWO_NB_BS_NH_HS
ident = KernelGroupIdentity(2, 8, 64, 16, 0, torch.float16, fmt)
assert {ident: "x"}[ident] == "x"
def test_excluded_engine_group_sentinel(self):
assert EXCLUDED_ENGINE_GROUP == -1
def test_format_in_identity_splits_same_geometry(self):
"""Two layers with identical geometry but different layouts (NHD vs HND,
num_heads == block_size) must not merge into one kernel group: format is
part of the identity, so each gets its own kernel with the correct
layout instead of one transferring the other with the wrong axis order.
"""
# First Party
import lmcache.c_ops as lmc_ops
# NH == BS == 16, so NHD [.., BS, NH, ..] and HND [.., NH, BS, ..] yield
# the same kv_size/num_heads/head_size/block_size; only axis order differs.
tensors = [
torch.randn(2, 32, 16, 16, 64, dtype=torch.float16),
torch.randn(2, 32, 16, 16, 64, dtype=torch.float16),
]
groups = group_layers_by_identity(
tensors,
[
lmc_ops.EngineKVFormat.NL_X_TWO_NB_BS_NH_HS, # NHD
lmc_ops.EngineKVFormat.NL_X_TWO_NB_NH_BS_HS, # HND
],
)
# Without the format in the identity these share one geometry and would
# have merged into a single group; with it they split into two.
assert len(groups) == 2
assert {idxs[0] for _, idxs in groups} == {0, 1}
class TestKernelAndObjectGroups:
"""Kernel-group accessors, deprecated aliases, and the (currently single)
object-group layout."""
def test_kernel_groups_match_deprecated_alias(self):
tensors = [
torch.randn(2, 32, 256, 8, 64, dtype=torch.float16) for _ in range(3)
]
manager = _build_manager(tensors)
# The deprecated alias must still return the live list, not a bound
# method (regression guard for the @property/@deprecate ordering).
assert isinstance(manager.kv_layer_groups, list)
assert manager.kernel_groups is manager.kv_layer_groups
assert manager.num_kernel_groups == manager.num_groups
assert manager.num_kernel_groups == len(manager.kernel_groups)
assert all(isinstance(g, KernelGroupInfo) for g in manager.kernel_groups)
def test_single_object_group_covers_all_kernel_groups(self):
# Two distinct kernel groups (different num_heads) still share one
# object group under the current single-object-group assumption.
tensors = [
torch.randn(2, 32, 256, 8, 64, dtype=torch.float16),
torch.randn(2, 32, 256, 16, 64, dtype=torch.float16),
]
manager = _build_manager(tensors)
assert manager.num_kernel_groups == 2
assert manager.num_object_groups == 1
obj = manager.object_groups[0]
assert isinstance(obj, ObjectGroupInfo)
assert obj.kernel_group_indices == list(range(manager.num_kernel_groups))
assert obj.sw_size_chunks == -1
assert manager.get_attn_desc().num_chunks_in_sw == [-1]
def test_object_group_separation_disabled_merges_groups(self):
# With separation off (the default), a full-attention group and a
# sliding-window group still collapse into one full-attention object
# group, and get_attn_desc reports full attention.
tensors = [torch.randn(2, 32, 32, 8, 64, dtype=torch.float16) for _ in range(2)]
manager = _build_manager(
tensors,
engine_group_infos=[
EngineGroupInfo(0, (0,)),
EngineGroupInfo(1, (1,), sw_size_tokens=64),
],
separate_object_groups=False,
)
assert manager.num_kernel_groups == 2
assert manager.num_object_groups == 1
assert manager.object_groups[0].kernel_group_indices == [0, 1]
assert manager.get_attn_desc().num_chunks_in_sw == [-1]
def test_object_group_separation_enabled_buckets_by_window(self):
# With separation on, the full-attention and sliding-window kernel groups
# land in distinct object groups, ordered by first kernel group index,
# and get_attn_desc reports each group's real window.
tensors = [torch.randn(2, 32, 32, 8, 64, dtype=torch.float16) for _ in range(2)]
manager = _build_manager(
tensors,
engine_group_infos=[
EngineGroupInfo(0, (0,)),
EngineGroupInfo(1, (1,), sw_size_tokens=64),
],
separate_object_groups=True,
)
assert manager.num_kernel_groups == 2
assert manager.num_object_groups == 2
# Group 0: full attention (kernel group 0). Group 1: sliding window.
assert manager.object_groups[0].kernel_group_indices == [0]
assert manager.object_groups[0].sw_size_chunks == -1
attn_desc = manager.get_attn_desc()
assert attn_desc.num_chunks_in_sw[0] == -1
assert manager.object_groups[1].kernel_group_indices == [1]
assert manager.object_groups[1].sw_size_chunks >= 1
assert attn_desc.num_chunks_in_sw[1] == manager.object_groups[1].sw_size_chunks
def test_object_group_separation_enabled_non_hybrid_single_group(self):
# Even with separation on, a non-hybrid model (no sliding-window groups)
# yields a single full-attention object group.
tensors = [
torch.randn(2, 32, 256, 8, 64, dtype=torch.float16),
torch.randn(2, 32, 256, 16, 64, dtype=torch.float16),
]
manager = _build_manager(tensors, separate_object_groups=True)
assert manager.num_object_groups == 1
assert manager.get_attn_desc().num_chunks_in_sw == [-1]
def test_kernel_groups_carry_sw_size_tokens(self):
# Same-shape layers split by engine group; the sliding-window group's
# window size lands on its kernel group, the other stays -1.
tensors = [torch.randn(2, 32, 32, 8, 64, dtype=torch.float16) for _ in range(2)]
manager = _build_manager(
tensors,
engine_group_infos=[
EngineGroupInfo(0, (0,)),
EngineGroupInfo(1, (1,), sw_size_tokens=64),
],
)
assert [g.sw_size_tokens for g in manager.kernel_groups] == [-1, 64]
def test_subchunk_window_not_block_aligned_rejected(self):
# A 64-token window over 256-slot blocks does not cover whole blocks;
# construction fails loudly instead of mistransferring.
tensors = [torch.randn(2, 32, 256, 8, 64, dtype=torch.float16)]
with pytest.raises(ValueError, match="sliding window"):
_build_manager(
tensors,
engine_group_infos=[EngineGroupInfo(0, (0,), sw_size_tokens=64)],
)
def test_subchunk_sw_size_tokens(self):
# lmcache chunk size is 256 (default), 32-slot blocks. Sub-chunk
# window (64) is returned as-is; non-SW (-1) and big-SW (512) return
# the chunk size.
tensors = [
torch.randn(2, 32, 32, 8, 64, dtype=torch.float16),
torch.randn(2, 32, 32, 16, 64, dtype=torch.float16),
torch.randn(2, 32, 32, 32, 64, dtype=torch.float16),
]
manager = _build_manager(
tensors,
engine_group_infos=[
EngineGroupInfo(0, (0,)),
EngineGroupInfo(0, (1,), sw_size_tokens=64),
EngineGroupInfo(0, (2,), sw_size_tokens=512),
],
)
assert manager.get_subchunk_sw_size_tokens(0) == 256
assert manager.get_subchunk_sw_size_tokens(1) == 64
assert manager.get_subchunk_sw_size_tokens(2) == 256
# Transfer slots follow the sub-chunk window (ratio 1 here).
assert manager.get_slots_per_chunk_in_sw(0) == 256
assert manager.get_slots_per_chunk_in_sw(1) == 64
assert manager.get_slots_per_chunk_in_sw(2) == 256
def test_mixed_sw_kernel_groups_share_single_object_group(self):
# Object-level bucketing by sliding window size is not enabled yet:
# kernel groups with differing window sizes still land in ONE object
# group and get_attn_desc stays full attention.
tensors = [
torch.randn(2, 32, 32, 8, 64, dtype=torch.float16),
torch.randn(2, 32, 32, 16, 64, dtype=torch.float16),
torch.randn(2, 32, 32, 32, 64, dtype=torch.float16),
]
manager = _build_manager(
tensors,
engine_group_infos=[
EngineGroupInfo(0, (0,)),
EngineGroupInfo(0, (1,), sw_size_tokens=64),
EngineGroupInfo(0, (2,), sw_size_tokens=512),
],
)
assert manager.num_object_groups == 1
obj = manager.object_groups[0]
assert obj.kernel_group_indices == list(range(manager.num_kernel_groups))
assert obj.sw_size_chunks == -1
assert manager.get_attn_desc().num_chunks_in_sw == [-1]
def test_empty_manager_has_no_groups(self):
# Empty registration returns early in __init__; both group lists must
# still be initialized (regression guard for missing _object_groups).
manager = _build_manager([])
assert manager.kernel_groups == []
assert manager.num_kernel_groups == 0
assert manager.object_groups == []
assert manager.num_object_groups == 0
def test_excluded_layer_left_out_of_all_groups(self):
# Layer 2 is referenced by no engine group info, so it is excluded entirely.
tensors = [
torch.randn(2, 32, 256, 8, 64, dtype=torch.float16) for _ in range(3)
]
manager = _build_manager(
tensors,
engine_group_infos=[EngineGroupInfo(0, (0, 1))],
)
grouped = sorted(
idx for group in manager.kernel_groups for idx in group.layer_indices
)
assert grouped == [0, 1]
def test_calculate_num_blocks_uncompressed(self):
# bs=16, compress_ratio=1 -> 256 tokens span 16 blocks.
tensors = [torch.randn(2, 32, 16, 8, 64, dtype=torch.float16) for _ in range(2)]
manager = _build_manager(tensors)
assert manager.calculate_num_blocks(0, 256) == 16
def test_dsv4_flash_style_mixed_compression(self):
# Mirrors DeepSeek-V4-Flash: one 256-token engine group whose layers
# have 64- and 2-slot pages (declared compress ratios 4 and 128), one
# 64-token SWA group and one 4-token compressor-state group (ratio 1).
tensors = [
torch.randn(2, 8, 64, 1, 64, dtype=torch.float16),
torch.randn(2, 8, 2, 1, 64, dtype=torch.float16),
torch.randn(2, 8, 64, 1, 32, dtype=torch.float16),
torch.randn(2, 8, 4, 1, 128, dtype=torch.float32),
]
manager = _build_manager(
tensors,
engine_group_infos=[
EngineGroupInfo(0, (0,), tokens_per_block=256),
EngineGroupInfo(0, (1,), tokens_per_block=256),
EngineGroupInfo(1, (2,), tokens_per_block=64),
EngineGroupInfo(2, (3,), tokens_per_block=4),
],
)
by_layer = {g.layer_indices[0]: g for g in manager.kernel_groups}
assert by_layer[0].tokens_per_block // by_layer[0].slots_per_block == 4
assert by_layer[1].tokens_per_block // by_layer[1].slots_per_block == 128
assert by_layer[2].tokens_per_block // by_layer[2].slots_per_block == 1
assert by_layer[3].tokens_per_block // by_layer[3].slots_per_block == 1
# 256-token LMCache chunk -> 2 physical slots in the ratio-128 group.
assert by_layer[1].calculate_slots(256) == 2
assert by_layer[0].calculate_slots(256) == 64
def test_calculate_num_blocks_compressed(self):
# slots_per_block=8 (tensor), tokens_per_block=16 (engine spec) ->
# compress_ratio=2; 256 logical tokens -> 128 physical slots ->
# 128 // 8 = 16 blocks.
tensors = [torch.randn(2, 32, 8, 8, 64, dtype=torch.float16) for _ in range(2)]
manager = _build_manager(
tensors,
engine_group_infos=[
EngineGroupInfo(0, (0, 1), tokens_per_block=16),
],
)
group = manager.kernel_groups[0]
assert group.tokens_per_block == 16
assert group.slots_per_block == 8
assert group.tokens_per_block // group.slots_per_block == 2
assert manager.calculate_num_blocks(0, 256) == 16
if __name__ == "__main__":
pytest.main([__file__, "-v"])