[HiCache] 测试脚本 - shun8686/sglang GitHub Wiki

脚本

先设置变量
HICACHE_TEST_URL={sglang推理url,如http://127.0.0.1:2000}
HICACHE_TEST_SERVER_URL={对于PD分离设置为prefill的url, 不是router的,对于PD混部无需设置}
HICACHE_TEST_MODEL={qwen3.6-hybrid-mamba、deepseek-v4、glm5.2-dsa、kimi-k3-kda}
测试L3:

python -m unittest test_hicache.TestNPUHiCache.test_npu_restores_from_l3
"""
NPU HiCache L2/L3 coverage

Server requirements (see launch script):
  --max-total-tokens 65536          (L1 device KV pool: 65536 / 128 = 512 pages)
  --chunked-prefill-size 65536
  --hicache-ratio 2                 (L2 host pool: 131200 tokens)
  --enable-hierarchical-cache
  --hicache-storage-backend npu_memcache
  --hicache-write-policy write_through
  --hicache-io-backend kernel_ascend

Select the model profile with the HICACHE_TEST_MODEL env var (default
"qwen3.6-hybrid-mamba"):
    HICACHE_TEST_URL=http://127.0.0.1:8869 HICACHE_TEST_MODEL=deepseek-v4 python3 test_npu_hicache_npu_memcache.py

For PD-disaggregated runs behind a router, HICACHE_TEST_URL is the router
(/generate, /flush_cache) and HICACHE_TEST_SERVER_URL must point at a prefill
engine for the engine-only endpoints (/tokenize, /hicache/storage-backend/clear):
    HICACHE_TEST_URL=http://127.0.0.1:2000 HICACHE_TEST_SERVER_URL=http://127.0.0.1:8869 ...

Everything model-specific lives in MODEL_PRESETS below; the shared server
knobs are the constants right above it. Per-model behaviours covered:

1. Cacheable-prefix alignment. The radix cache is a UnifiedRadixCache whose
   effective cache length is floored by the stateful sidecar component:
   - qwen3.6-hybrid-mamba: the Mamba checkpoint grid (mamba_track_interval)
     floors a 3200-token prompt to 3072 tokens (24 device pages).
   - deepseek-v4: the c128 sparse-attention sidecar groups 128 * c128_page_size
     (= 2048) tokens, flooring a 3200-token prompt to 2048 tokens (16 pages).
   `_prime_on_device` asserts the exact aligned length, so a wrong
   `cache_align_tokens` in the profile fails fast.

2. Commit lag (mamba only). A node only matches when its FULL pages AND its
   sidecar state are resident. Under the mamba profile the checkpoint of a
   finished request is committed to the radix node only in that request's
   closing event round, which the NEXT request's prefix match has already
   passed, so a fresh prefix needs TWO priming requests and reports its first
   device hit only on the THIRD request. Verified identical under
   SGLANG_NPU_HICACHE_MAMBA_IO=sync and =async: that env var only selects the
   device<->host state-copy operator, not the commit/match ordering.
   Pure-attention profiles (deepseek-v4) commit synchronously: the repeat
   already hits on the second request.

3. Prompt distribution matters for the equality checks (mamba only). Full
   prefill and checkpoint replay are not bit-identical in bf16 on the Ascend
   kernels, so greedy decoding of out-of-distribution random token ids
   (where every token is a near-tie) frequently flips after a load-back. The
   prompts here are therefore 3200-token windows of real natural language
   tokenized through the server, on which greedy decoding is stable and every
   load-back reproduces the resident output exactly. Pure-attention profiles
   copy KV bit-exactly and do not need natural text, but all profiles use it
   alike for uniformity.

4. Capacity. The L1 pool holds the base prefix plus N unique pressure peers.
   Eviction is driven by the SMALLEST sub-pool, not the FULL pool. The mamba
   profile only has the 512-page FULL pool (threshold: 20 peers; 22/17
   measured on hardware). deepseek-v4 is a FULL+SWA+C4+C128 four-pool model
   where SWA = full * swa_full_tokens_ratio (~0.2-0.25, i.e. ~102-128 pages)
   is the bottleneck: every peer parks ~25 SWA pages (window >= prompt), so
   eviction starts at just 3 peers. The deepseek-v4 counts below are
   measured on hardware (65536 FULL pool); re-measure if SWA sizing or the
   pool budget changes.
"""

import os
import time
import unittest

import requests
from sglang.test.kl_test_utils import (
    _flush_cache,
    _generate,
)
from sglang.test.test_utils import (
    CustomTestCase,
)

# ---------------------------------------------------------------------------
# Shared server knobs — must match the launch script of the server under test.
# ---------------------------------------------------------------------------
URL_FOR_TEST = os.environ.get("HICACHE_TEST_URL", "http://127.0.0.1:8869")
# Engine URL for engine-only endpoints (/tokenize, /hicache/storage-backend/
# clear). In PD-disaggregated deployments HICACHE_TEST_URL points at the
# router, which only proxies /generate and /flush_cache — set this to any
# prefill engine's URL. Defaults to HICACHE_TEST_URL for single-server runs.
SERVER_URL_FOR_TEST = os.environ.get("HICACHE_TEST_SERVER_URL", URL_FOR_TEST)
# model name: qwen3.6-hybrid-mamba、deepseek-v4、glm5.2-dsa、kimi-k3-kda
MODEL_NAME = os.environ.get("HICACHE_TEST_MODEL", "qwen3.6-hybrid-mamba")

PAGE_SIZE = 128  # --page-size
PROMPT_LEN = 3200  # pressure prompt length in tokens
MAX_TOTAL_TOKENS = 65536  # --max-total-tokens (L1 device KV pool)

# ---------------------------------------------------------------------------
# Per-model profiles — the ONLY block to touch when porting to a new model.
#
# page_size              --page-size of the service. The radix matcher floors
#                         the cacheable prefix to a multiple of this
#                         (radix_cache.page_aligned), so expected cached len =
#                         PROMPT_LEN // page_size * page_size unless a sidecar
#                         component floors further.
# cache_align_tokens      effective floor granularity of the cacheable prefix:
#                         max(page_size, sidecar grid). For models without a
#                         stateful sidecar this equals page_size; for mamba
#                         it's the track grid; for c128 it's 128*c128_page_size.
# prime_requests          requests sent before asserting a device hit:
#                         3 for mamba (commit lag), 2 for pure attention.
# commit_lag              True if the sidecar commit lags one event round
#                         (mamba); drives the warm-request assertions.
# pressure_peers_evict    unique pressure prompts for the L2 eviction case;
#                         must push the base prefix out of the L1 pool.
# pressure_peers_l1_only  unique pressure prompts for the L1-only case; must
#                         leave the base prefix inside the L1 pool.
#                         Both counts are measured on hardware per model: the
#                         eviction threshold is set by the smallest KV sub-pool
#                         (FULL for mamba, SWA for deepseek-v4), not by the
#                         FULL pool alone.
# ---------------------------------------------------------------------------
MODEL_PRESETS = {
    "qwen3.6-hybrid-mamba": dict(
        page_size=128,
        cache_align_tokens=256,  # mamba_track_interval grid
        prime_requests=3,
        commit_lag=True,
        pressure_peers_evict=22,  # measured: eviction starts at 20 peers
        pressure_peers_l1_only=17,  # measured: 19 peers stay on L1
    ),
    "deepseek-v4": dict(
        page_size=128,
        cache_align_tokens=2048,  # c128 sidecar: 128 * c128_page_size(16)
        prime_requests=2,
        commit_lag=False,
        pressure_peers_evict=4,  # measured: eviction starts at 3 peers (SWA pool)
        pressure_peers_l1_only=2,  # measured: 2 peers stay on L1 (no margin)
    ),
    "glm5.2-dsa": dict(
        # GlmMoeDsaForCausalLM (MLA+DSA): FULL component only (server.log
        # confirms "Components: (FULL,)"). page_size=128 (NPU default). Measured
        # cached=3072 for a 3200-token prompt — root cause unclear (not
        # page_aligned, not FULL floor_cache_len, not sidecar); possibly DSA
        # backend reports cached_tokens differently. cache_align_tokens=256
        # matches the measured value. No commit lag, prime hits on 2nd request.
        # Pool = 65536/128 = 512 pages; each peer = 3072/128 = 24 pages;
        # threshold ~512/24 ≈ 21 -> 22/17 (not yet calibrated on hardware).
        page_size=128,
        cache_align_tokens=256,  # measured, root cause TBD
        prime_requests=2,
        commit_lag=False,
        pressure_peers_evict=22,
        pressure_peers_l1_only=17,
    ),
    "kimi-k3-kda": dict(
        # KimiK3 (MLA + KDA linear attention): mambaish via kimi_linear_config
        # (hybrid_arch.py), so the radix cache carries the MAMBA component —
        # same commit-lag timing (hit on the 3rd request) and checkpoint-grid
        # flooring as the Qwen hybrid profile. KDA chunk is the FLA 64, so
        # grid = lcm(64->128, page 128, track_interval 256) = 256.
        page_size=128,
        cache_align_tokens=256,
        prime_requests=3,
        commit_lag=True,
        # Computed: single FULL MLA pool (512 pages, 24-page peers ->
        # threshold ~20); not yet calibrated on hardware.
        pressure_peers_evict=22,
        pressure_peers_l1_only=17,
    ),
}

# Natural-language corpus, sized dynamically in _build_prompts to cover
# base + pressure peers as disjoint PROMPT_LEN-token windows.
_CORPUS_PARAGRAPH = (
    "The history of artificial intelligence began in antiquity, with myths "
    "and stories of artificial beings endowed with intelligence by master "
    "craftsmen. Modern artificial intelligence research began in the middle "
    "of the twentieth century and has since transformed industry, science, "
    "medicine, transportation, education, finance, agriculture, energy, "
    "manufacturing, entertainment, communication, security, logistics, law, "
    "public administration, scientific discovery, creative work, software "
    "engineering, weather forecasting, space exploration, and everyday life. "
)


class TestNPUHiCache(CustomTestCase):
    """Exercise L2/L3 load-back on NPU across model profiles."""

    page_size = MODEL_PRESETS[MODEL_NAME].get("page_size", PAGE_SIZE)
    prompt_len = PROMPT_LEN
    # L1 device KV pool size, must match the server's --max-total-tokens.
    # 65536 tokens / page_size = device page count.
    max_total_tokens = MAX_TOTAL_TOKENS
    routed_dp_rank = 0

    @classmethod
    def _clear_storage_backend(cls):
        requests.post(
            f"{cls.admin_url}/hicache/storage-backend/clear", timeout=60
        ).raise_for_status()

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.base_url = URL_FOR_TEST
        cls.admin_url = SERVER_URL_FOR_TEST

        preset = MODEL_PRESETS[MODEL_NAME]
        cls.model_name = MODEL_NAME
        cls.cache_align_tokens = preset["cache_align_tokens"]
        cls.prime_requests = preset["prime_requests"]
        cls.commit_lag = preset["commit_lag"]
        cls.pressure_peers_evict = preset["pressure_peers_evict"]
        cls.pressure_peers_l1_only = preset["pressure_peers_l1_only"]
        # Cacheable prefix of one prompt after sidecar alignment, and the
        # device pages each cached peer parks in the L1 pool.
        cls.expected_cached_len = (
            cls.prompt_len // cls.cache_align_tokens * cls.cache_align_tokens
        )
        cls.peer_pages = cls.expected_cached_len // cls.page_size

        cls.prompts = cls._build_prompts()

    def setUp(self):
        # /flush_cache only clears L1/L2 (radix tree + host pool); the L3
        # storage backend survives it. Clear both per test method so each
        # case starts from a true cold state — otherwise write-through data
        # left by a previous test makes the L3 cold request a storage hit.
        super().setUp()
        _flush_cache(self.base_url)
        self._clear_storage_backend()

    @classmethod
    def _build_prompts(cls):
        """Tokenize one natural-language corpus and slice it into disjoint
        prompt_len-token windows: prompts[0] is the base prefix, the rest are
        unique pressure prompts. The corpus is sized dynamically to cover
        base + pressure_peers_evict windows."""
        probe = requests.post(
            f"{cls.admin_url}/tokenize", json={"prompt": _CORPUS_PARAGRAPH},
            timeout=60,
        )
        probe.raise_for_status()
        tokens_per_para = len(probe.json()["tokens"])

        need = 1 + cls.pressure_peers_evict
        min_tokens = need * cls.prompt_len
        # Tokenizer merges across paragraph boundaries, so the estimate can
        # fall slightly short; grow the corpus until it covers the need.
        repeats = -(-min_tokens // tokens_per_para)
        while True:
            resp = requests.post(
                f"{cls.admin_url}/tokenize",
                json={"prompt": _CORPUS_PARAGRAPH * repeats},
                timeout=120,
            )
            resp.raise_for_status()
            token_ids = resp.json()["tokens"]
            if len(token_ids) >= min_tokens:
                break
            repeats += max(1, repeats // 16)
        prompts = [
            token_ids[i * cls.prompt_len : (i + 1) * cls.prompt_len]
            for i in range(need)
        ]
        assert all(len(p) == cls.prompt_len for p in prompts), (
            f"Corpus too short: only {len(token_ids)} tokens tokenized, "
            f"need {need * cls.prompt_len}"
        )
        return prompts

    def _generate_one(self, input_ids, max_new_tokens, return_logprob=False):
        results = _generate(
            self.base_url,
            [input_ids],
            max_new_tokens=max_new_tokens,
            return_logprob=return_logprob,
            temperature=0,
            routed_dp_rank=self.routed_dp_rank,
        )
        self.assertEqual(len(results), 1)
        return results[0]

    @staticmethod
    def _tier_details(meta_info):
        details = meta_info.get("cached_tokens_details") or {}
        return (
            int(meta_info.get("cached_tokens", 0) or 0),
            int(details.get("device", 0) or 0),
            int(details.get("host", 0) or 0),
            int(details.get("storage", 0) or 0),
        )

    def _prime_on_device(self, base_prompt):
        """Make base_prompt device-resident and return the first device-hit
        response (the deterministic-output reference).

        Mamba profiles: the sidecar checkpoint of a finished request lands
        one event round after the next request's prefix match, so
        prime_requests (3) are required before the device hit shows up.
        Pure-attention profiles commit synchronously and hit on the second
        request (prime_requests = 2, one spare round).
        """
        for _ in range(self.prime_requests - 1):
            self._generate_one(base_prompt, max_new_tokens=16)
        resident = self._generate_one(base_prompt, max_new_tokens=16)
        cached, device, host, storage = self._tier_details(resident["meta_info"])
        print(
            f"[prime] prompt_tokens={len(base_prompt)} "
            f"cached={cached} device={device} host={host} storage={storage}"
        )
        self.assertEqual(
            cached,
            self.expected_cached_len,
            f"Cacheable prefix {cached} != expected {self.expected_cached_len} "
            f"(prompt_len={self.prompt_len} aligned to "
            f"{self.cache_align_tokens}); check cache_align_tokens of the "
            f"'{self.model_name}' profile",
        )
        self.assertGreater(
            device,
            0,
            "Base prefix did not become device-resident after the priming "
            f"requests: {resident['meta_info']}",
        )
        self.assertEqual(host, 0, f"Unexpected L2 contribution while priming: {resident['meta_info']}")
        self.assertEqual(storage, 0, f"Unexpected L3 contribution while priming: {resident['meta_info']}")
        return resident

    def test_npu_restores_from_l2(
        self,
        pressure_prompts_num=None,
        expect_evict: bool = True,
    ):
        """L2 evict and restore test"""
        if pressure_prompts_num is None:
            pressure_prompts_num = self.pressure_peers_evict
        base_prompt = self.prompts[0]
        pressure_prompts = self.prompts[1 : 1 + pressure_prompts_num]

        _flush_cache(self.base_url)

        # The priming rounds absorb the mamba sidecar commit lag (profiles
        # without it simply run one spare round); the final priming request
        # is the reference served entirely from L1 device.
        first = self._prime_on_device(base_prompt)

        # Fill the L1 pool ({self.max_total_tokens // self.page_size} pages)
        # with unique pressure prompts and evict the oldest prefix
        # (base_prompt) to the host tier. After sidecar alignment each cached
        # peer parks self.peer_pages device pages; the eviction pressure
        # count comes from the model profile.
        for prompt in pressure_prompts:
            self._generate_one(prompt, max_new_tokens=1)

        after_pressure = self._generate_one(
            base_prompt, max_new_tokens=16, return_logprob=True
        )
        meta_info = after_pressure["meta_info"]
        cached_tokens, device, host, storage = self._tier_details(meta_info)
        print(
            f"[L2] n={pressure_prompts_num} expect_evict={expect_evict} "
            f"cached={cached_tokens} device={device} host={host} storage={storage}"
        )
        self.assertGreater(
            cached_tokens,
            0,
            f"Expected cached_tokens to be greater than 0, got {cached_tokens}",
        )
        self.assertEqual(
            storage,
            0,
            f"Expected no L3 contribution: {meta_info.get('cached_tokens_details')}",
        )
        if expect_evict:
            self.assertEqual(
                device,
                0,
                "Expected the base prefix gone from L1 after pressure; "
                f"got cached_tokens_details={meta_info.get('cached_tokens_details')}. "
                "If it stayed on L1, raise pressure_peers_evict for this profile.",
            )
            self.assertGreater(
                host,
                0,
                "Expected the original prefix to be restored from the HiCache host tier; "
                f"got cached_tokens={cached_tokens}, "
                f"cached_tokens_details={meta_info.get('cached_tokens_details')}",
            )
        else:
            self.assertGreater(
                device,
                0,
                "Expected the base prefix to stay on L1 under the pressure; "
                f"got cached_tokens_details={meta_info.get('cached_tokens_details')}. "
                "If it was evicted, lower pressure_peers_l1_only for this profile.",
            )
            self.assertEqual(
                host,
                0,
                "Expected the original prefix not to be restored from the HiCache host tier; "
                f"got cached_tokens={cached_tokens}, "
                f"cached_tokens_details={meta_info.get('cached_tokens_details')}",
            )
        self.assertEqual(
            after_pressure["text"],
            first["text"],
            "L2 restore changed deterministic generation output.",
        )

    def test_npu_only_l1(self):
        # pressure_peers_l1_only leaves the base prefix comfortably inside
        # the L1 pool (per-profile value, see MODEL_PRESETS), so it is never
        # demoted to the host tier.
        self.test_npu_restores_from_l2(
            pressure_prompts_num=self.pressure_peers_l1_only, expect_evict=False
        )

    def test_npu_restores_from_l3(self):
        """L3 restore test"""

        _flush_cache(self.base_url)        
        base_prompt = self.prompts[0]

        # First request is a cold miss.
        cold = self._generate_one(base_prompt, max_new_tokens=16)
        cold_meta = cold["meta_info"]
        print(f"[L3][1] cold_meta.cached_tokens_details={cold_meta.get('cached_tokens_details')}")
        self.assertEqual(int(cold_meta.get("cached_tokens", 0)), 0)

        # The second request is the "warm" request that starts the
        # L1 -> L2 -> L3 write-through. Under mamba profiles its checkpoint
        # is not matchable yet (the commit lands one event round later), so
        # it still reports zero cached tokens; under pure-attention profiles
        # it is already a device hit.
        warm = self._generate_one(base_prompt, max_new_tokens=16)
        warm_cached, warm_device, _, _ = self._tier_details(warm["meta_info"])
        print(f"[L3][2] warm_meta.cached_tokens_details={warm['meta_info'].get('cached_tokens_details')}")
        if self.commit_lag:
            self.assertEqual(
                warm_cached,
                0,
                "Mamba profiles report the first device hit only on the "
                f"third request; got cached_tokens={warm_cached}",
            )
        else:
            self.assertGreater(
                warm_device,
                0,
                "Pure-attention profiles should hit on the second request; "
                f"got cached_tokens_details={warm['meta_info'].get('cached_tokens_details')}",
            )

        # The third request is a device hit for every profile and is used as
        # the deterministic-output reference below (mamba full-prefill and
        # checkpoint-replay paths differ slightly in bf16, so the cold output
        # is not a valid reference).
        first = self._generate_one(base_prompt, max_new_tokens=16)
        first_meta = first["meta_info"]
        _, device, host, storage = self._tier_details(first_meta)
        print(f"[L3][3] resident cached_tokens_details={first_meta.get('cached_tokens_details')}")
        self.assertGreater(device, 0, f"Expected an L1 device hit: {first_meta}")
        self.assertEqual(host, 0, f"Expected no L2 contribution: {first_meta}")
        self.assertEqual(storage, 0, f"Expected no L3 contribution: {first_meta}")

        time.sleep(5)

        # Remove L1/L2 residency without clearing the storage (L3) backend.
        _flush_cache(self.base_url)

        restored = self._generate_one(base_prompt, max_new_tokens=16)
        restored_meta = restored["meta_info"]
        restored_details = restored_meta.get("cached_tokens_details") or {}
        print(f"[L3][4] restored_meta.cached_tokens_details={restored_details}")

        device = int(restored_details.get("device", 0))
        host = int(restored_details.get("host", 0))
        storage = int(restored_details.get("storage", 0))
        self.assertEqual(
            device,
            0,
            f"Expected no L1 device contribution: {restored_details}",
        )
        self.assertEqual(
            host,
            0,
            f"Expected no L2 contribution: {restored_details}",
        )
        self.assertGreater(
            storage,
            0,
            f"Expected an L3 storage hit; got meta_info={restored_meta}",
        )
        self.assertEqual(
            restored["text"],
            first["text"],
            "L3 restore changed deterministic generation output.",
        )

        # The load-back repopulates L1, so the next repeat is a device hit.
        restored = self._generate_one(base_prompt, max_new_tokens=16)
        restored_meta = restored["meta_info"]
        details = restored_meta.get("cached_tokens_details") or {}
        print(f"[L3][5] restored_meta.cached_tokens_details={details}")
        self.assertGreater(
            int(details.get("device", 0) or 0),
            0,
            f"Expected an L1 device hit; got meta_info={restored_meta}",
        )


if __name__ == "__main__":
    unittest.main()


NPU HiCache 分层缓存测试设计经验总结

来源:test_design/test_npu_hicache_npu_memcache.py 的编写与调通过程。 覆盖模型:Qwen3.6-35B-A3B(FULL+MAMBA 混合 MoE)、DeepSeek V4(MLA + SWA 稀疏四池)。 服务:SGLang NPU(Ascend),--enable-hierarchical-cache + --hicache-storage-backend npu_memcache。


1. 三层缓存模型与观测手段

层 位置 触达条件
L1 device NPU KV 池(--max-total-tokens) 前缀匹配直接命中
L2 host CPU 侧镜像池(--hicache-ratio × L1) L1 淘汰后(write_through 下 insert 时即备份)
L3 storage npu_memcache(SDMA 到外部存储) L2 也淘汰后,或手动 flush

观测方法(最重要):/generate 返回的 meta_info.cached_tokens_details 含 device / host / storage 三元组,测试的核心断言全部建立在这三个数上:

def _tier_details(meta_info):
    d = meta_info.get("cached_tokens_details") or {}
    return (meta_info["cached_tokens"], d.get("device", 0),
            d.get("host", 0), d.get("storage", 0))

注意 /generate 的 JSON 是 list(取 r.json()[0]),不是 dict。

2. 核心机制发现(均为实测,勿凭直觉假设)

2.1 命中时序:有 sidecar 状态的模型要 prime 多次

  • 混合 Mamba(SGLANG_NPU_HICACHE_MAMBA_IO=sync/async 均一致): 请求结束时 prepare_for_caching_req 才把 mamba checkpoint 挂到 radix 节点 (unified_cache/components/mamba.py),与下一请求的前缀匹配在调度器 event loop 里错开一轮 → 新前缀要 3 次请求才报 device 命中(#1/#2 cached=0)。
  • 纯注意力(DSV4):KV 同步入树,第 2 次即命中。
  • SGLANG_NPU_HICACHE_MAMBA_IO 只选 device↔host 拷贝算子(torch fallback vs sgl-kernel-npu 直传 kernel),不影响可见性时序。
  • sleep 无效:这是轮次顺序问题,不是时间延迟问题。

测试用 _prime_on_device 连发 prime_requests 次,取最后一次为 resident 基准输出。

2.2 cache_align_tokens:可缓存前缀会被向下取整

带状态 sidecar 的组件会把有效缓存长度 floor 到自己的粒度:

模型 公式 3200-token prompt 实际 cached
Qwen3.6(mamba) lcm(max(64, page), page, mamba_track_interval) = lcm(128,128,256) = 256 3072(24 页)
Kimi K3(KDA,mambaish 路径) 同上公式(KDA chunk=FLA 64)= 256 3072(24 页)
DeepSeek V4(c128 sidecar) 128 × c128_page_size(16) = 2048 2048(16 页)
GLM5.2(GlmMoeDsa) 实测 cached=3072,page_size=128(NPU 默认),FULL 组件无 floor,根因未定 = 256 3072(24 页)
  • mamba_track_interval 默认 256(不设也是它);c128_page_size 默认 16。
  • sidecar 组件注册条件见 mem_cache/registry.py:mambaish → MAMBA (mambaish_config 含 KDA/KimiLinear/GDN/glm5_next 等,见 hybrid_arch.py),req_to_token_pool.req_to_c128_sidecar 存在 → C128(DSV4 专属),其余模型只有 FULL。
  • 定新模型值:实测最可靠——同一 prompt 发两次看 cached_tokens; 测试里对 prime 的断言就是 cached == PROMPT_LEN // align * align, 填错会直接报出实际值。

2.3 淘汰阈值:由最小子池决定,而非 FULL 池

  • 淘汰触发:某子池分配失败 → evict_for_alloc 从 LRU 逐出 radix 节点 (write_through 下节点早已备份到 host → 表现为 host 命中)。
  • Qwen3.6:只有 FULL 池(65536/128 = 512 页),每 peer 占 24 页 → 阈值 20 个 peer(19 留 L1,20 淘汰)。
  • DSV4:FULL+SWA+C4+C128 四池(pool_configurator.py): swa = full × swa_full_tokens_ratio(~0.2-0.25)≈ 102-128 页 是瓶颈, 每 peer 占 ~25 SWA 页 → 阈值仅 3 个 peer!
  • GLM5.2(GlmMoeDsa):FULL 组件 only(server.log 确认), page_size=128(NPU 默认),每 peer 24 页,池 512 页 → 推算阈值 ~21 (profile 暂用 22/17,待实测校准)。
  • Kimi K3(KDA):mambaish 路径但 FULL 池与 Qwen 同为 512 页、 每 peer 24 页 → 推算阈值 ~20(profile 暂用 22/17,待实测校准; KDA state 槽位可能额外压低 max_running_requests,不影响本用例)。
  • 教训:--max-total-tokens 只定 FULL 池;多池模型的阈值可能比 按 FULL 池推算小一个数量级。所有压力数必须实测 sweep,并留 1~2 的裕量。

2.4 文本保真:load-back 不总是位精确

  • mamba(bf16 checkpoint 重放):随机 token id prompt 的输出在 load-back 后高频翻转(6/8 seed 在第 4~19 token 分歧,L2/L3 分歧位置相同 → 是 checkpoint 重放路径的通病,非某层存储的缺陷)。原因是 OOD 随机 token 上每步 logprob 近平局,重放的微小数值差被放大。
  • 对策:用 /tokenize 切真实自然语言语料做 prompt——自然文本下 8/8 严格一致。
  • 纯注意力(DSV4):load-back 是 KV 位拷贝(无重放),理论上随机 prompt 也可,统一用自然语料保持一致。
  • 对比基准:cold 全 prefill 输出与 checkpoint 重放本就不同(混合模型 路径差异),不能拿第一次 cold 输出当期望值;必须用 resident (device 命中那次)输出做基准。

2.5 清理接口的坑

  • POST /flush_cache 不清 L3 storage;L3 清空要用 POST /clear_hicache_storage。
  • 原 _clear_storage_backend 有 f" {url}"(URL 前导空格)bug → 请求直接 404 且静默失败。URL 拼接要 strip。

3. 可复用的测试结构

setUpClass
  ├─ 读 MODEL_PRESETS[profile](环境变量 HICACHE_TEST_MODEL 切换)
  ├─ 派生 expected_cached_len / peer_pages
  └─ /tokenize 生成互不相交的自然语料 prompt(数量按 peer 数动态补)

test_npu_only_l1            → 调 restores_from_l2(少, expect_evict=False)
test_npu_restores_from_l2   → prime → 压力 → 断言 host>0 / device=0 → 文本=resident
test_npu_restores_from_l3   → prime → /flush_cache → 断言 storage>0/device=host=0
                              → 再请求 → 断言回到 device>0,文本=resident

断言写法要点:

  1. 每个断言携带完整三元组:assertGreater(cached, 0, f"cached={c} device={d} host={h} storage={s}") —— 失败时一眼看出 淘汰过头/不足。
  2. 淘汰边界断言成对(evict=阈值+裕量 / l1_only=阈值-裕量), 并把实测阈值写进注释。
  3. profile 参数化:cache_align_tokens / prime_requests / commit_lag / pressure_peers_* 是唯一换模型要动的地方;常量必须有"实测 or 推算"标注。

4. 调通过程中的误判记录(避免重蹈)

误判 排除手段
以为不命中是容量不够 极小 peer 数复现 → 与容量无关
怀疑 routed_dp_rank 影响匹配 带/不带 rank 矩阵实验 → 行为一致
怀疑 async mamba IO 导致 3 次命中 sync 下完全一致 → 是提交滞后一轮
按 FULL 池推 DSV4 阈值 ~32 实测 n=3 就淘汰 → SWA 池才是瓶颈
用 cold 输出做文本基准 resident 与 L2/L3 恢复严格一致、cold 不一致 → 换基准
随机 token 导致文本断言随机失败 换自然语料 → 8/8 稳定

5. 新模型适配清单(checklist)

  1. 确定 cache_align_tokens(实测两次请求的 cached_tokens,或按 §2.2 公式推)。
  2. 确定 prime_requests / commit_lag(有状态 sidecar → 3/True)。
  3. 实测淘汰阈值:从 n=1 起步逐个加压,记录 [cached device host] 由 device→host 的拐点;注意先查 server.log 里各子池大小 (DSV4 找 DSV4 SWA sizing 行),判断哪个池最小。
  4. 检查输出保真路径(位拷贝 or 状态重放),决定 prompt 语料与断言严格度。
  5. 确认 --page-size、--max-total-tokens、--hicache-ratio 与共享常量一致。
  6. 切换服务/模型不改代码:HICACHE_TEST_URL(服务地址,默认 127.0.0.1:8869)+ HICACHE_TEST_MODEL(profile)。