Skip to content

[None][feat] Attribute KV-cache hits to storage tiers in metrics (gpu/host/disk/remote) - #18584

Draft
zheyuf wants to merge 3 commits into
NVIDIA:mainfrom
zheyuf:zheyu/feat/kv-cache-tier-metrics
Draft

[None][feat] Attribute KV-cache hits to storage tiers in metrics (gpu/host/disk/remote)#18584
zheyuf wants to merge 3 commits into
NVIDIA:mainfrom
zheyuf:zheyu/feat/kv-cache-tier-metrics

Conversation

@zheyuf

@zheyuf zheyuf commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

[None][feat] Attribute KV-cache hits to storage tiers in metrics (gpu / host / disk / remote)

Description

Problem. With host offloading (host_cache_size) a prefix-cache hit can be served from GPU
memory or copied back from host memory (or disk / an external store), but kv_cache_hit_rate,
kv_cache_iter_reused_blocks_total and prompt_cached_tokens_total report one aggregate.
kv_cache_host_utilization says how full the host pool is, not whether it served anything, and
kv_cache_onboard_bytes_total cannot be used to reconstruct hits because blocks also move on
resume after preemption and on prefetch. Operators cannot answer "is CPU offloading paying off",
and benchmark harnesses that price cached vs. uncached tokens get no tier information.

Solution. The KV cache manager attributes every reused block to the tier it was served from
at the moment the block is matched, before any onboard copy, and the split is exported end to end:

  • Schema: KvCacheStats::reusedBlocks{Gpu,Host,Disk,Remote},
    KvCacheIterationStats::iterReusedBlocks{Gpu,Host,Disk,Remote}, the same four fields on
    executor::KvCacheStats (JSON + binary serialization, nanobind), KvCacheTier enum shared
    with Python (tensorrt_llm/metrics/enums.py::CACHE_TIER_LABELS).
  • KVCacheManager (C++): tier decided in onboardAndAllocateBlocks before onboardBlock()
    (isPrimary() on the matched block / partial-copy source; secondary is host for transfer mode
    DRAM and disk for GDS/POSIX). Connector-matched whole blocks move from missed to
    reused(remote) so aggregate hit rate and tier split agree. Per request the manager records
    ordered (tier, tokens) runs for the window that bounds the prepopulated length
    (LlmRequest.reuse_tier_segments).
  • KVCacheManagerV2: per attention life cycle, reused blocks are split by page.cache_level; the
    request segments use the worst level among the life cycles that load a block, and none for
    blocks outside every sliding window (skipped by reuse, nothing loaded).
  • Python: LlmRequest.cached_tokens_by_tier derives the request-level split from the segments
    and the latched cached_tokens, so the two request counters agree by construction. The split
    travels with the response (LlmResult.cached_tokens_by_tier) into
    usage.prompt_tokens_details.cached_tokens_details, including through disaggregated serving
    (ctx_usage and the KV-transfer aux buffer).
  • Prometheus (MetricsCollector):
    • trtllm_kv_cache_reused_blocks_by_tier_total{cache_tier} — sums to
      trtllm_kv_cache_iter_reused_blocks_total
    • trtllm_prompt_cached_tokens_by_tier_total{cache_tier} — sums to
      trtllm_prompt_cached_tokens_total for every request that carried an attribution
    • all cache_tier values pre-seeded to 0; no existing metric changes shape.
  • Warmup: per-iteration KV deltas accumulated during warmup are drained after warmup, so the
    iteration counters start from the same point as the already-baselined cumulative counters.
  • Docs: docs/source/features/kvcache.md, "Cache Hit Metrics by Storage Tier" (semantics,
    invariants, warmup, disagg, per-manager tier meaning).

Behavior changes to note: (1) with a KV cache connector, whole connector-filled blocks now count
as reuse (remote) instead of misses, so kv_cache_hit_rate reflects avoided recomputation;
(2) kv_cache_iter_* counters no longer include warmup dummy requests.

Test Coverage

  • tests/unittest/metrics/test_collector.py::TestCacheTierMetrics — pre-seeding, request split
    sums to aggregate, no guessing without attribution, none tier, V1 window and V2 lifecycle
    block aggregation.
  • tests/unittest/_torch/executor/test_kv_cache_tier_split.py — segment-to-dict split, prefix
    trimming, sum invariant.
  • tests/unittest/kv_cache_manager_v2_tests/test_kv_cache_tier_attribution.py — V2 gpu / host /
    none attribution against the runtime manager (host hits cross-checked with onboard traffic).
  • cpp/tests/unit_tests/batch_manager/kvCacheManagerTest.cpp
    KVCacheManagerReuseTierAttributionGpuTest, KVCacheManagerReuseTierAttributionHostTest.
  • End-to-end (manual, script in the PR discussion): trtllm-serve with a 4096-token GPU pool and a
    host cache, prompt A -> fillers -> prompt A again shows cache_tier="host" hits for both
    managers and matching cached_tokens_details in the response usage.

PR Checklist

  • PR description clearly explains what and why.
  • Follows TRT-LLM CODING GUIDELINES.
  • Test cases provided for new code paths.
  • Documentation updated.

🤖 Generated with Claude Code

…/host/disk/remote)

Prefix-cache hits are reported as one aggregate today (kv_cache_hit_rate,
kv_cache_iter_reused_blocks_total, prompt_cached_tokens_total), so with host
offloading enabled operators cannot tell whether reused blocks were resident on
the GPU or copied back from host memory. Transfer volume cannot reconstruct it
either: blocks also move on resume after preemption and on prefetch.

Every KV cache manager now attributes a reused block to the tier that held it
when the prefix was matched, before any onboard copy, and the split is exported
end to end:

* Schema: KvCacheStats::reusedBlocks{Gpu,Host,Disk,Remote},
  KvCacheIterationStats::iterReusedBlocks{Gpu,Host,Disk,Remote}, the same four
  fields on executor::KvCacheStats (JSON + binary serialization, nanobind) and
  a KvCacheTier enum whose values are shared with Python
  (tensorrt_llm/metrics/enums.py, CACHE_TIER_LABELS = gpu,host,disk,remote,none).
* KVCacheManager (C++): WindowBlockManager::onboardAndAllocateBlocks decides the
  tier from isPrimary() of the matched block (partial-copy source) before
  onboardBlock(); secondary is host for transfer mode DRAM and disk for
  GDS/POSIX. Connector-matched whole blocks move from missed to reused(remote)
  so the aggregate hit rate and the tier split agree. Per request the manager
  records ordered (tier, tokens) runs for the window that bounds the
  prepopulated length (LlmRequest::getReuseTierSegments, bound as
  reuse_tier_segments).
* KVCacheManagerV2 (C++ backend and Python backend, kept in sync): reused
  blocks are split per attention life cycle by page cache level; the request
  segments use the worst level among the life cycles that load a block and
  "none" for blocks outside every sliding window (skipped by reuse, nothing
  loaded). Exposed as _KVCache.reuse_tier_segments.
* Python: LlmRequest.cached_tokens_by_tier derives the request split from the
  segments and the latched cached_tokens, so the two request-level counters
  agree by construction; it travels on LlmResult/GenerationResult into
  usage.prompt_tokens_details.cached_tokens_details, including through
  disaggregated serving (ctx_usage and the KV-transfer aux buffer).
* Prometheus (MetricsCollector):
  - trtllm_kv_cache_reused_blocks_by_tier_total{cache_tier}: sums to
    trtllm_kv_cache_iter_reused_blocks_total
  - trtllm_prompt_cached_tokens_by_tier_total{cache_tier}: sums to
    trtllm_prompt_cached_tokens_total for every attributed request
  All tier series are pre-seeded to 0; no existing metric changes shape.
* Warmup: per-iteration KV deltas accumulated during warmup are drained after
  warmup so the iteration counters start from the same point as the baselined
  cumulative counters (the V1 baseline also covers the new fields).
* Docs: docs/source/features/kvcache.md, "Cache Hit Metrics by Storage Tier".

Tests: kvCacheManagerTest (ReuseTierAttribution{Gpu,Host}Test),
tests/unittest/metrics/test_collector.py::TestCacheTierMetrics,
tests/unittest/_torch/executor/test_kv_cache_tier_split.py,
tests/unittest/kv_cache_manager_v2_tests/test_kv_cache_tier_attribution.py
(both V2 backends), test_stats_serializer.py and test_kv_cache_stats_api.py
updated for the new fields. Verified end to end with trtllm-serve
(Qwen2.5-0.5B, 4096-token GPU pool + host cache) on B300 for both managers:
the second pass of an evicted prompt reports 1998 cached tokens all in
cache_tier="host" and 63 host blocks == iter_reused_blocks.

Signed-off-by: Zheyu Fu <zheyuf@nvidia.com>
- drop the per-request reusedByTierDelta (computed, never read) and the unused
  kNumKvCacheTiers constant
- copy the cumulative per-tier reused-block counts into the executor
  KvCacheStats so the get_stats() JSON actually carries the new fields
- remove the unreachable 'none' fallback in split_cached_tokens_by_tier:
  cached_tokens and the segments both derive from prepopulated_prompt_len
- inline the one-line tier index helper in the V2 Python backend; drop a
  defensive getattr on a validated UsageInfo

Signed-off-by: Zheyu Fu <zheyuf@nvidia.com>
- kvCacheManagerV2StatsTest: pass the per-tier split to recordReuse (build break)
- V1: attribute with the largest attention window's runs clipped to the
  prepopulated length, so hybrid Mamba placeholders and SWA anchors no longer
  hide the real tier; guard the connector reclassification on block reuse and
  count only whole blocks beyond the block-aligned local prefix
- LlmRequest: manager segments are ignored once cached_tokens is latched
  (preempted sequences re-added) and for disaggregated generation requests,
  and a breakdown that does not cover the cached prefix is never reported
- usage: cached_tokens_details is omitted from the wire when absent, fakes
  without the attribute are tolerated, harmony (gpt-oss) responses carry it
- style: kGpu/kHost/kDisk/kRemote/kNone enumerators, guard getNumReusedBlocksByTier
  against kNone, collector derives the iteration keys from the tier labels,
  test_aux unpacks the 4-tuple, copyright years

Signed-off-by: Zheyu Fu <zheyuf@nvidia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant