[Observability] Break down prefill #cached-token into device / host / storage tiers#26976
[Observability] Break down prefill #cached-token into device / host / storage tiers#26976hxieustc wants to merge 10 commits into
Conversation
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
ishandhanani
left a comment
There was a problem hiding this comment.
Tested this branch with HiCache enabled (--enable-hierarchical-cache --hicache-write-policy write_through, page-size 64, a small KV pool so prefixes get evicted to host and then loaded back).
The device/host split itself is correct. On a host-tier hit (1408-token prefix served entirely from host):
Prefill batch, #new-token: 64, #cached-token: 1408, #cached-token-device: 0, #cached-token-host: 1408, ...
and after the block is promoted back to device, a re-send flips to #cached-token-device: 1408, #cached-token-host: 0. No-hit traffic reports all zeros, so it's backward compatible.
Two things noted inline: a WARN that fires on every host hit, and a breakdown that's already computed elsewhere. Also one stray unrelated file. Thanks for adding this — the tiering is genuinely useful for HiCache tuning.
| storage_hit_tokens = min(storage_hit_tokens, host_hit_tokens_raw) | ||
| host_hit_tokens = host_hit_tokens_raw - storage_hit_tokens | ||
|
|
||
| if device_hit_tokens + host_hit_tokens + storage_hit_tokens != prefix_len: |
There was a problem hiding this comment.
This invariant fires on essentially every request with a host-tier hit. At this point init_load_back hasn't run yet, so prefix_len = len(req.prefix_indices) is the device-only count, while host_hit_tokens comes from req.host_hit_length. When the prefix is served entirely from host, prefix_len == 0 but host_hit_tokens > 0, so device + host + storage != prefix_len.
Observed (1408-token prefix fully evicted to host):
WARN schedule_policy.add_one_req: Cached-token split mismatch: total=0, device=0, host=1408, storage=0, rid=...
The split values themselves are right (the prefill log line correctly shows device=0/host=1408) — it's the invariant baseline that's off, and the total=0 in the message is misleading. Suggest comparing against prefix_len + host_hit_length (the post-load-back total), or running the check after init_load_back. As is, it emits a WARN on every load-back.
There was a problem hiding this comment.
Good catch. The warning baseline was wrong because this block runs before init_load_back, so len(req.prefix_indices) is still device-only while host_hit_length already includes host-tier hits. I removed this pre-load-back split/check and now rely on the post-load-back request-level accounting instead.
| @@ -851,6 +888,35 @@ def add_one_req( | |||
| real_input_tokens = self.ceil_paged_tokens(real_input_tokens) | |||
| prefix_len = len(req.prefix_indices) | |||
|
|
|||
| # Split cached prefix hits by source before L2 load-back mutates | |||
| # req.prefix_indices. | |||
| device_hit_tokens = len(req.prefix_indices) | |||
There was a problem hiding this comment.
Heads up: the cached-token accounting in schedule_batch.py already derives the same per-tier split after load-back (device_portion = len(prefix_indices) - host_hit_length) and writes req.cached_tokens_device/host/storage. This block recomputes it pre-load-back and those fields get overwritten there later. Might be cleaner to compute it once and reuse, to avoid two sources of truth that can drift.
There was a problem hiding this comment.
Agreed. The main branch had evolved and schedule_batch.py already computes req.cached_tokens_device/host/storage after load-back, which is the right point in the flow. I removed the recomputation in schedule_policy.py and changed PrefillStats.from_adder() to aggregate from those request fields after prepare_for_extend().
| log_hit_tokens_storage: int = 0 | ||
|
|
||
| new_token_ratio: float = 0.0 | ||
| num_running_reqs: QueueCount = None |
There was a problem hiding this comment.
Minor: inserting the defaulted log_hit_tokens_* fields forces defaults onto the fields below, and num_running_reqs: QueueCount = None is a None default on a non-Optional type. It only works because from_adder always passes it — a little fragile if another constructor path is added.
There was a problem hiding this comment.
Thanks, fixed. I reordered PrefillStats so the required fields stay non-default (new_token_ratio, num_running_reqs, num_new_seqs) and the new tier fields remain optional defaults afterward. This avoids the QueueCount = None workaround.
2ee0427 to
847adab
Compare
|
Re-tested the updated branch (
One remaining bug: with chunked prefill, the tier split is re-counted on every continuation chunk — chunks 2..N log Fix + before/after evidence: hxieustc#1. With it applied, the aggregate over a full load-test log holds exactly: |
77394e6 to
a4be49d
Compare
ishandhanani
left a comment
There was a problem hiding this comment.
Re-tested the latest revision (ca5c53bac, Qwen3-0.6B on 1x L40S, HiCache + write_through + page_size 64). The chunked-prefill re-count from the previous round is fixed, and the cleaner adder-side accumulation (_accumulate_hit_token_split guarded on prefix_len > 0, with compute_cache_hit_split summing to prefix_len by construction) holds up empirically:
- Chunked prefill (
--chunked-prefill-size 512, 1344-token cached prefix): chunk 1 logs#cached-token: 1344, #cached-token-device: 1344; continuation chunks log0 / device 0— no more per-chunk re-count. - Tier attribution: host load-back
#cached-token: 1536, device: 0, host: 1536, flipping todevice: 1536, host: 0after promotion. Usingloaded_host_hit_length(actual restored tokens) is the right call. - Invariant under load (aiperf c=16, 256 reqs, 1024-token prompts vs 512 chunk size, live host hits): aggregated over the full server log,
sum #cached-token = 133952 == device 122560 + host 11392. Holds exactly. - No
Cached-token split mismatchwarnings anywhere.
LGTM. One non-blocking suggestion: compute_cache_hit_split and the adder accounting are pure arithmetic — a small CPU unit test asserting log_hit_tokens == device + host + storage (including the chunked continuation case) would lock in the invariant cheaply, since there's currently no test coverage for the split.
|
/tag-and-rerun-ci |
386cb7c to
64b72b4
Compare
|
/tag-and-rerun-ci |
7e5373c to
2ab2c41
Compare
|
/tag-and-rerun-ci |
2ab2c41 to
f520b04
Compare
516d152 to
d45be44
Compare
|
/tag-and-rerun-ci |
d45be44 to
6ae79bb
Compare
…-prefill overcounting PrefillStats.from_adder was summing req.cached_tokens_device/host/storage from can_run_list. These per-request attributes are set once (on the first chunk) and never cleared, so chunks 2+ of chunked-prefill reported non-zero split values even though log_hit_tokens was 0 — breaking the stated invariant and producing a misleading log line. Similarly, retracted requests had their breakdown counted in log_hit_tokens_* even though their cache hits are tracked separately in reprocessed_log_hit_tokens. Fix: add log_hit_tokens_device/host/storage accumulators to PrefillAdder and populate them via a new _accumulate_hit_token_split() helper, called only for non-retracted requests at the same points log_hit_tokens is incremented (add_one_req non-chunked, add_one_req chunked, _add_dllm_req). PrefillStats.from_adder now reads directly from the adder accumulators.
1. Remove retracted_stain guard from all three _accumulate_hit_token_split call sites. _update_prefill_budget always increments log_hit_tokens for both retracted and non-retracted requests, so the split must follow suit. Without this fix, any batch containing a retracted request with prefix_len>0 produces device+host+storage < log_hit_tokens. 2. Cap host_total at prefix_len in _accumulate_hit_token_split. If init_load_back partially fails (e.g. allocation failure), prefix_len is reassigned to the actual loaded length while host_hit_length remains at the stale match-time value. Without the cap, the split sum can exceed log_hit_tokens. With min(host_hit_length, prefix_len) the invariant device+host+storage == prefix_len holds unconditionally.
…attr Extract the device/host/storage token split formula into compute_cache_hit_split() in schedule_batch.py, shared by both prepare_for_extend and _accumulate_hit_token_split. This eliminates duplicated logic that could silently diverge if one site was updated without the other. As a bonus, prepare_for_extend now picks up the min(host_hit_length, prefix_len) cap introduced in the previous fix. Also replace getattr(self.scheduler, enable_hicache_storage, False) in metrics_reporter.py with a direct attribute access — the attribute is always set in Scheduler.__init__, so the defensive default was unnecessary and would silently suppress the storage log column if the attribute were ever renamed.
6ae79bb to
68c830b
Compare
Motivation
The prefill log reports a single aggregate #cached-token for prefix-cache hits. With HiCache (L1 GPU / L2 host / L3 storage), this hides where hits come from — so you can't tell from the logs whether load-back from L2 or storage reuse is happening at all.
Modifications
Split cached-token accounting in
PrefillAdderinto three tiers and surface them in the scheduler log:log_hit_tokens_device/_host/_storage(aggregatelog_hit_tokenskept).add_one_req, the hit is split by source before L2 load-back mutatesreq.prefix_indices: device = current prefix_indices; host/storage fromreq.host_hit_length/req.storage_hit_length. Kept mutually exclusive (device + host + storage == total), warning on mismatch; per-requestcached_tokens_{device,host,storage}recorded._update_prefill_budget/_add_dllm_reqtake optional keyword splits; default (device_hit_tokens=None means all device) keeps existing call sites correct.PrefillStatscarries the new fields; the log line emits#cached-token-device/-host/-storage(storage shown only when enabled or non-zero).How to enable
No flag required — the device/host tier split is emitted in the existing prefill log line automatically. The values are only non-trivial when the corresponding tiers exist.
#cached-token-device/#cached-token-hostfields always appear in the prefill log (visible at the default INFO log level).#cached-token-storageis appended only when storage is enabled (enable_hicache_storage) or a non-zero storage hit is observed.Benefits
Expected output
Prefill log line — without L3 storage
For example, HiCache L1/L2, 2048-token prefix served partly from host after load-back:
Before:
After:
Prefill log line — with L3 storage enabled (
#cached-token-storageappended):Before:
After:
Non-HiCache (backward compatibility)
host/storage are 0, device equals the old aggregate:
Correctness
device_hit_tokensdefaults to full prefix length, so without HiCache device == old aggregate, host/storage == 0.getattr(..., default)fallbacks on req and adder keep older/partial paths valid.Caveats
req.host_hit_length/req.storage_hit_lengthbeing populated; backends that don't set them report all hits as device (same as today).min(storage, host)(common case: storage materialized into host first); exotic tiering may need refinement.Accuracy Tests
It does not affect model outputs.
Speed Tests and Profiling
Negligible overhead.
Checklist
CI States
Latest PR Test (Base): ❌ Run #28265459028
Latest PR Test (Extra): ❌ Run #28265458955