Skip to content

Add vllm:kv_offload_cpu_total_blocks capacity metric - #49307

Open
yanburman wants to merge 2 commits into
vllm-project:mainfrom
yanburman:cpu_blocks_metric
Open

yanburman wants to merge 2 commits into
vllm-project:mainfrom
yanburman:cpu_blocks_metric

Conversation

@yanburman

@yanburman yanburman commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

The native CPU OffloadingConnector reported usage fractions but exposed no metric for the offload tier's static configuration or capacity. Add vllm:kv_offload_cpu_config_info, a cache_config_info-style Info gauge (value always 1; config carried in the labels) reporting num_blocks, blocks_per_chunk (num_gpu_blocks-equivalent capacity = num_blocks * blocks_per_chunk; multiply by the cache block_size for a token count), kv_bytes_per_chunk, cpu_page_size_per_worker and eviction_policy. Emit it once at engine startup via a new KVConnectorBase_V1.get_config_info() hook and the EngineCoreReadyResponse handshake, covering standalone, MultiConnector-nested (PD) and tiering-primary connectors, and cover it with manager and startup-emission unit tests.

Purpose

The native CPU KV OffloadingConnector reports usage-fraction gauges
(vllm:kv_offload_cpu_cache_usage_perc, vllm:kv_offload_cpu_cache_write_usage_perc,
vllm:kv_offload_cpu_cache_read_usage_perc) but exposes no metric for the offload
tier's static configuration or capacity, so none of it is observable on /metrics.

This PR adds an Info gauge, vllm:kv_offload_cpu_config_info, whose value is always 1
and whose labels carry the tier's static config: num_blocks (offload capacity in
offload blocks), blocks_per_chunk (GPU blocks per offload block — num_gpu_blocks
equivalents = num_blocks * blocks_per_chunk; multiply by the cache block_size for
a token count), kv_bytes_per_chunk, cpu_page_size_per_worker and eviction_policy.
This mirrors how the GPU tier's config, including num_gpu_blocks, is exposed via
vllm:cache_config_info.

The metric is emitted once at engine startup (before any request) and deliberately
not on the per-interval connector-stats path — re-serializing a constant gauge over
IPC every interval is wasted work, and the Prometheus gauge persists once set. A new
generic KVConnectorBase_V1.get_config_info() hook exposes a connector's static
config, the values ride the EngineCoreReadyResponse handshake into the frontend
(stored on KVTransferConfig), and PrometheusStatLogger emits them in
log_engine_initialized. MultiConnector aggregates its nested children's config
(and its prom fans the emission to each child), and TieringOffloadingManager
surfaces its CPU primary tier's config — so an OffloadingConnector standalone,
nested in a MultiConnector (PD), or as a tiering primary tier all emit at startup.
Offloading gauges default to multiprocess_mode="mostrecent", so the static gauge
(and the existing per-engine usage gauges) collapse to one series per engine under
data-parallel + api_server_count > 1 instead of one duplicate row per API-server
process; per-process transfer counters and histograms stay summed.

Scope: the native OffloadingConnector only (standalone, MultiConnector-nested,
tiering primary tier). No behavior change beyond the added metric and the
multiprocess_mode correction on the existing offloading gauges.

Test Plan

  • Unit tests: pytest tests/v1/kv_offload/cpu/test_manager.py tests/v1/kv_offload/test_factory.py
    • New test_cpu_manager_reports_config_info asserts get_config_info() returns
      {CPU_CONFIG_INFO: {...}} and that config_info is not placed on the per-interval
      stats path.
    • New test_cpu_config_info_wiring_matches_declared_labels, test_cpu_config_info_reflects_tensor_parallel_sizing,
      test_kv_offload_cpu_config_info_startup_emission and ..._via_multi_connector
      cover label/dict anti-drift, TP/PP sizing, and end-to-end startup emission
      (standalone and MultiConnector-nested) with the gauge built as mostrecent.
  • Lint / type (pre-commit): pre-commit run --files $(git diff --name-only origin/main...HEAD)
  • (Optional, manual e2e) Serve with --kv-offloading-size <GiB> and confirm
    vllm:kv_offload_cpu_config_info appears on /metrics at startup with value 1 and
    labels whose num_blocks * blocks_per_chunk scales with the configured size.

Test Result

Unit tests:

$ pytest tests/v1/kv_offload/cpu/test_manager.py
......................... [100%]
25 passed, 1 warning in 2.05s

$ pytest tests/v1/kv_offload/test_factory.py
.................................. [100%]
34 passed, 15 warnings in 12.38s

pre-commit (ruff check, ruff format, typos, mypy, SPDX headers, forbidden-imports,
config-docstring validation): all hooks passed.


Essential Elements of an Effective PR Description Checklist
  • The purpose of the PR, such as "Fix some issue (link existing issues this PR will resolve)".
  • The test plan, such as providing test command.
  • The test results, such as pasting the results comparison before and after, or e2e results
  • (Optional) The necessary documentation update — n/a: the sibling CPU-offload connector gauges are not listed in docs/design/metrics.md, so this metric follows the existing (undocumented) pattern.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@mergify mergify Bot added the v1 label Jul 21, 2026
@orozery

orozery commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Why do we need to expose the number of CPU blocks?
For the GPU tier I don't see we do that as well.

@yanburman

Copy link
Copy Markdown
Contributor Author

Why do we need to expose the number of CPU blocks?
For the GPU tier I don't see we do that as well.

GPU is exposed - num_gpu_blocks is a label on vllm:cache_config_info. For CPU there is only usage %, so this fills that gap. I kept it as a gauge in kv_offload_cpu_* next to usage, rather than a cache_config_info label like GPU, since the offload buffer belongs to the connector rather than the engine's cache config IMO.

@orozery

orozery commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

GPU is exposed - num_gpu_blocks is a label on vllm:cache_config_info. For CPU there is only usage %, so this fills that gap. I kept it as a gauge in kv_offload_cpu_* next to usage, rather than a cache_config_info label like GPU, since the offload buffer belongs to the connector rather than the engine's cache config IMO.

I see. Then let's follow a similar pattern:
Introduce vllm:kv_offload_cpu_config_info that will hold all static runtime information for the CPU tier.
cc @varun-sundar-rabindranath @Srinivasoo7 WDYT?

@varun-sundar-rabindranath

Copy link
Copy Markdown
Contributor

Agree that adding vllm:kv_offload_cpu_config_info for static information will be useful and extensible.

@Srinivasoo7

Copy link
Copy Markdown
Contributor

GPU is exposed - num_gpu_blocks is a label on vllm:cache_config_info. For CPU there is only usage %, so this fills that gap. I kept it as a gauge in kv_offload_cpu_* next to usage, rather than a cache_config_info label like GPU, since the offload buffer belongs to the connector rather than the engine's cache config IMO.

I see. Then let's follow a similar pattern: Introduce vllm:kv_offload_cpu_config_info that will hold all static runtime information for the CPU tier. cc @varun-sundar-rabindranath @Srinivasoo7 WDYT?

Yup, agree on this. Besides I understand that this has the similar fuctionality as trasnfer_type metric and is consistent with existing cache_config_info from GPU

@mergify

mergify Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @yanburman.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Jul 26, 2026

@orozery orozery left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest we avoid the changes outside the offloading connector / kv_offload.
I guess this means we observe on the first scheduler step instead of on startup.

prometheus_client.Gauge defaults to multiprocess_mode="all", which in
multiprocess mode (api_server_count > 1) exports one series per pid. The
offloading gauges -- vllm:kv_offload_cpu_cache_usage_perc and its read/write
variants -- are point-in-time values for a single engine that every API-server
process records from whatever snapshot it happened to see, so /metrics ends up
with one duplicate row per process and a dashboard summing by engine can
exceed 1.0.

Every other point-in-time gauge in PrometheusStatLogger already sets
"mostrecent" (vllm:num_requests_running, vllm:engine_sleep_state and the
GPU-tier analogue vllm:kv_cache_usage_perc); the offloading gauges were the
only ones left on the default. Default OffloadingGaugeMetadata to
multiprocess_mode="mostrecent" and pass it through when the gauge is created.
Counters and histograms are per-process cumulative and must stay summed, so
they keep the default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yan Burman <yanburman@users.noreply.github.com>
The CPU KV OffloadingConnector exposed only the usage fraction, with no
way to observe the tier's static configuration or capacity. Add
vllm:kv_offload_cpu_config_info, a cache_config_info-style Info metric whose
value is always 1 and whose labels carry the static config: num_blocks,
blocks_per_chunk, kv_bytes_per_chunk, cpu_page_size_per_worker and
eviction_policy (num_gpu_blocks-equivalent capacity = num_blocks *
blocks_per_chunk). This mirrors how the GPU tier's config, including
num_gpu_blocks, is exposed via vllm:cache_config_info.

Without it a mis-sized tier is invisible: when cpu_bytes_to_use is smaller
than one aligned chunk, num_blocks becomes 0 and the usage gauges report 0.0
forever -- indistinguishable from a healthy idle tier. Emitting the config at
startup surfaces that before any traffic is routed to the engine.

The metric is emitted once at engine startup (before any request) and
deliberately NOT on the per-interval connector-stats path -- the Prometheus
gauge persists once set, and a capacity metric that only appears after the
first scheduler step cannot be used to validate a deployment before it serves
traffic. A new generic KVConnectorBase_V1.get_config_info() hook exposes a
connector's static config, the values ride the EngineCoreReadyResponse
handshake into the frontend (stored on KVTransferConfig, alongside the
num_gpu_blocks/kv_cache_size_tokens that already cross it for
cache_config_info), and PrometheusStatLogger emits them in
log_engine_initialized. MultiConnector aggregates its nested children's config
(and its prom fans the emission to each child), and TieringOffloadingManager
surfaces its CPU primary tier's config -- so an OffloadingConnector standalone,
nested in a MultiConnector (PD), or as a tiering primary tier all emit at
startup. The gauge inherits multiprocess_mode="mostrecent" from
OffloadingGaugeMetadata, so it stays one series per engine under data
parallelism with api_server_count > 1.

Covered by manager, spec-wiring, Prometheus-emission and
MultiConnector-aggregation unit tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yan Burman <yanburman@users.noreply.github.com>
@yanburman

Copy link
Copy Markdown
Contributor Author

I suggest we avoid the changes outside the offloading connector / kv_offload. I guess this means we observe on the first scheduler step instead of on startup.

The outside change is not introducing a new mechanism. It rides on existing pattern that is in use by num_gpu_blocks and is quite small if you look only at production code. Making it only populate on first request makes monitoring a bit awkward and diverges from current gpu blocks implementation

@mergify mergify Bot removed the needs-rebase label Aug 3, 2026
@orozery

orozery commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

The outside change is not introducing a new mechanism. It rides on existing pattern that is in use by num_gpu_blocks and is quite small if you look only at production code. Making it only populate on first request makes monitoring a bit awkward and diverges from current gpu blocks implementation

@NickLucche @ivanium your thoughts on this?

TLDR adding a dedicated connector API for exposing runtime level config:

  def get_config_info(self) -> dict[str, dict[str, str]] | None:
        """
        Static, per-connector configuration to emit once at engine startup as
        Info-style gauges, mapping ``metric_name -> {label: value}``. Mirrors
        ``vllm:cache_config_info`` so a connector's static config is observable
        on ``/metrics`` before any request. Returns None when there is nothing
        to expose.
        """
        return None

@nilig

nilig commented Aug 8, 2026

Copy link
Copy Markdown

Wonder if we could expose the CPU offload capacity directly in tokens, not only as num_blocks.

CPUOffloadingSpec.num_blocks counts offload chunks rather than GPU blocks. The PR says token capacity can be calculated as num_blocks * blocks_per_chunk * block_size, but that is exact only for a single KV-cache group. The CPU pool uses a group-specific OffloadKey, so with multiple groups the same logical token range consumes several slots. The formula therefore overstates capacity, even when the groups have equal block spans.

llm-d router #2292 is a concrete consumer. It needs token capacity to size its approximate per-endpoint cache, and already reads SGLang's sglang:hicache_host_total_tokens. If vLLM publishes only blocks and chunk configuration, every consumer must reproduce this conversion and handle grouped models correctly.

Could we add a safely computed vllm:kv_offload_cpu_capacity_tokens alongside the config info? Initially, we could emit it only when there is one KV-cache group and omit it otherwise. The calculation and group handling can remain within offloading/ and kv_offload/, while the value can use whichever emission path this PR settles on.

I have a small prototype with tests for one group, multiple equal-span groups, and multiple differing-span groups. Would you prefer folding it into this PR or handling it as a follow-up after this lands?

@nilig

nilig commented Aug 10, 2026

Copy link
Copy Markdown

I think this can be split into two independent pieces:

  1. The router-facing token-capacity metric.
  2. The broader config_info and startup-emission API.

I opened #51615 as a concrete example of the first piece. It adds vllm:kv_offload_cpu_capacity_tokens using the existing connector-stats path. The production change is 28 lines, entirely under vllm/v1/kv_offload/, with no new connector or engine API. The metric appears after the first scheduler step rather than at startup.

It emits only for a single KV-cache group. With multiple groups, the CPU pool stores group-specific OffloadKeys, so the documented num_blocks * blocks_per_chunk * block_size conversion overstates the available token capacity.

This draft is not intended to replace the full static configuration proposed here. It demonstrates how the token-capacity piece required by llm-d router #2292 can be unblocked while the generic startup/configuration plumbing is reviewed separately.

The implementation can be folded into this PR if preferred; otherwise the narrow metric can land first and the broader config_info work can follow independently.

@mergify

mergify Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @yanburman.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants