12 KiB
CacheBlend V3 Observability — Design
Status: Proposal · Scope: unify CB V3 tracing across the vLLM plugin (scheduler + worker) and the LMCache blend server into one distributed trace, plus the metrics each side exposes.
1. Goal
A single CB request touches three processes:
vLLM scheduler ──CB_UNIFIED_LOOKUP──▶ LMCache blend server
vLLM worker ──CB_RETRIEVE_V3─────▶ LMCache blend server
vLLM worker ── model forward (FULL_RECOMP → CHECK → PARTIAL)
Today these are observed by two disjoint systems:
| side | mechanism | output |
|---|---|---|
| LMCache blend server | EventBus → BlendTracingSubscriber (subscribers/tracing/cb_server.py) |
OTel spans (cb.request + children), OTLP export |
| vLLM plugin (connector/shim/attn) | _cb_span / _cb_stats_emit (lmcache_cacheblend/connector.py) |
ad-hoc JSONL (CB_PROFILE=1) |
They never share a trace: you cannot see, in one view, that a slow request's
time went into the server-side L2 load vs the worker-side scatter vs the PARTIAL
forward. Goal: one cb.request trace spanning all three processes, with
sub-spans owned by whichever process did the work, plus aligned metrics.
2. The unified trace model
One trace per request_id. Process owner in brackets; cross-process children
linked by trace-context propagation (§5).
cb.request [scheduler — root] request_id, model, world_size, n_prompt_tokens
│
├─ cb.schedule [scheduler] the get_num_new_matched_tokens defer loop
│ ├─ cb.lookup.rpc [scheduler] CB_UNIFIED_LOOKUP incl. N poll re-issues; attr: n_polls
│ │ └─ cb.lookup [SERVER] ← cross-process child; attr prefix_chunks
│ │ ├─ cb.fingerprint_match [server] n_probes, table_hits, matches (token-stride=1, any offset)
│ │ │ (cb.coordinator_match instead, when a coordinator is configured) matches, timed_out
│ │ │ (no cb.prefix_lookup span — prefix is traced by mp.lookup_prefetch)
│ │ ├─ cb.sparse_prefetch [server] n_keys, l1_hits, l2_misses
│ │ │ └─ cb.l2_load [server·IO] chunks, bytes, ms (coalesced L2→L1)
│ │ └─ cb.classify [server] found, stale, per_rank_ok
│ │ ↳ on end: stamp hit_rate / prefix_coverage_tokens / n_non_prefix_tokens on cb.request
│ └─ cb.build_meta [scheduler] broadcast metadata to workers
│
└─ cb.execute [worker]
├─ cb.start_load_kv [worker] submit + stream-wait (may fire 2×: partial→full block alloc)
│ └─ cb.retrieve [SERVER] ← cross-process child
│ └─ cb.scatter [server·GPU] scattered_tokens, n_prefix, n_shifted (re-RoPE'd), dropped
│ (re-RoPE folded in — interleaved per-batch, not a separate span)
└─ cb.model_forward [worker] the sliced forward
├─ cb.full_recomp [worker·GPU] layers 0..cl-1
├─ cb.check [worker·GPU] layer cl; imp_count, recomp_ratio
└─ cb.partial [worker·GPU] layers cl+1..L; dispatch=flex|unified, imp_empty
This is the contract both sides implement against. The server owns cb.lookup
and cb.retrieve subtrees; the plugin owns everything else.
2.1 V3 reuse is token-granular (#3582)
As of #3582, CB matches and scatters at token granularity, not vLLM-block / chunk granularity — which is what the spans/attrs below must reflect:
- Matching runs at
probe_stride=1, so the shared body is found at any token offset (cb_unified_lookupno longer filters non-prefix matches to a chunk-alignedcur_st).cb.fingerprint_matchreports token-offset matches, not aligned chunks. - Scatter writes per-token via
multi_layer_kv_transferwithslot_mapping = block_id[pos // bs] * bs + pos % bs. The reused token range is written slot-by-slot, so a partial vLLM block holding both matched and recomputed tokens is written correctly with no block-alignment trim on the write — the old whole-block scatter path and block-aligned drop checks are gone.cb.scatter's unit isscattered_tokens/slot_writes. - L2 storage stays chunk-granular (256-token chunks): a non-block-aligned
match still fetches whole chunks (
cb.l2_load= chunks/bytes), thencb.scatterwrites only the matched token sub-range. Socb.l2_loadis measured in chunks whilecb.scatteris measured in tokens/slots. cb.start_load_kvmay fire twice (vLLM allocates the request's blocks partial-then-full). The first pass writes only slots inside the already-allocated block table — a slot-bound guard (cur_ed > num_slots), not a block-alignment trim; the second writes the rest. Expect twocb.retrievechildren, the first withscattered_tokens< total.
3. LMCache-server side — what to expose
The server already emits cb.request / cb.lookup / cb.retrieve via the
EventBus. Two changes:
(a) Finer V3 events for the lookup/retrieve subtrees. V3 currently emits only
CB_LOOKUP_START/END and CB_RETRIEVE_START/END — too coarse for the subtree in
§2. Add paired events (CPU-sync for compute, publish_on_stream for GPU ops so
timing is GPU-accurate):
| new event pair | span | timing source |
|---|---|---|
CB_FINGERPRINT_MATCH_* |
cb.fingerprint_match |
CPU |
CB_COORDINATOR_MATCH_* |
cb.coordinator_match (fleet directory leg; mutually exclusive with the local matcher) |
CPU + IO |
| (prefix lookup) | mp.lookup_prefetch (reused; prefix_chunks attr on cb.lookup) |
CPU |
CB_SPARSE_PREFETCH_* |
cb.sparse_prefetch (+ existing L2 prefetch span as cb.l2_load) |
CPU + IO |
CB_SCATTER_* |
cb.scatter (re-RoPE folded in via n_shifted) |
publish_on_stream (GPU) |
BlendTracingSubscriber.SPAN_DEFS gains the matching entries; all nest under
cb.lookup / cb.retrieve via the existing SpanRegistry.
(b) Simplify the deferral logic for the V3 model. The current root-close
deferral (_waiting_for_store_final, the STORE_FINAL_SUBMITTED bridge) is V2-only
and inert under V3 (V3 never emits those). Under V3 the request ends at
CB_RETRIEVE_END (no async store-final after inference). Gate the cb.request
close on _pending_gpu_ops[sid] == 0 only, and drop the V2 store-final bridge
from the V3 path. (The V2-only event handlers stay for blend_legacy.)
Span attributes (server): request_id, prefix_coverage_tokens,
fingerprint_hits, storage_hits, stale_chunks, hit_tokens,
requested_tokens, hit_rate, prefix_hit_tokens, non_prefix_hit_tokens,
scatter_ms, scattered_tokens, slot_writes, partial_blocks,
n_shifted_tokens, n_prefix_tokens (token-granular per §2.1 — not chunks).
V3 hit rate. hit_rate = hit_tokens / requested_tokens, where the numerator
counts both reuse paths: hit_tokens = prefix_hit_tokens + non_prefix_hit_tokens. The two ranges are disjoint (the non-prefix complement
is cur_st >= prefix coverage), so they sum without double-counting. Both
components are also recorded individually on cb.request so a dashboard can
split prefix-reuse vs re-RoPE'd non-prefix reuse.
Metrics (already present, keep): the lmcache_blend.* counters
(lookup_requests, lookup_hit_tokens, lookup_storage_hits,
lookup_stale_chunks, retrieve_requests, retrieve_failures,
chunks_evicted, …). Note the V2-only store counters won't populate under V3 —
document, or recompute the "stored" notion from the unified path.
4. vLLM-plugin side — what to expose
The plugin's _cb_span spans (sched.gnnmt, gnnmt.cb_unified_submit/poll,
sched.build_meta, slk.*, shim.wrapper.{prepare,fwd}, flex.*,
cb_admission_check) already cover the §2 plugin subtree — but as JSONL, not
OTel. Make _cb_span dual-mode:
- when an OTel tracer is available → emit an OTel span (start/end, attributes);
- always (under
CB_PROFILE) → keep the JSONL line (cheap local profiling).
Mapping plugin span → unified name: sched.gnnmt→cb.schedule,
gnnmt.cb_unified_*→cb.lookup.rpc, sched.build_meta→cb.build_meta,
slk.*→cb.start_load_kv, shim.wrapper.fwd→cb.model_forward,
flex.* + the layer hooks → cb.full_recomp/cb.check/cb.partial.
Tracer source. vLLM has its own OTel (--otlp-traces-endpoint) and creates a
per-request span. Prefer to reuse vLLM's tracer so CB spans nest under vLLM's
request span intra-process; if vLLM tracing is off, the plugin owns a tracer
pointed at the same OTLP endpoint as the LMCache server. Gate on a single
CB_TRACING=1 (or reuse --enable-tracing semantics) so it's off by default.
5. Unification — linking the three processes
The blocker (from the surface map): the RPC envelope carries no trace-context
— IPCCacheServerKey and CBUnifiedLookupResult have no traceparent field. Two
ways to bridge:
Option A — propagate W3C trace-context through the RPC (recommended).
Add an optional trace_context: str | None (W3C traceparent) to the CB RPC
payloads (the lookup key + the retrieve args). The scheduler/worker inject
the current span's context; the server extracts it and starts cb.lookup /
cb.retrieve as remote children of it. Result: a true parent→child distributed
trace across processes. Cost: one optional protocol field (backward-compatible —
None when tracing off), an inject/extract at the two RPC boundaries.
Option B — deterministic trace-id from request_id (zero protocol change).
Both sides derive a 128-bit trace-id = hash(request_id) and tag every span with
it (+ request_id attribute). Backends group by trace-id, so the spans land in
one trace — but there are no cross-process parent links (sibling spans, not
nested). Use if the protocol field is undesirable short-term.
Recommendation: Option A. The field is tiny, optional, and gives real
parent/child causality (e.g. "the 90 ms gnnmt was 50 ms server L2-load + 40 ms
poll-wait"). Keep request_id as a span attribute regardless, so Option B is a
trivial fallback. The SpanRegistry already handles intra-process nesting on
each side; Option A only adds the cross-process edge.
6. Phasing
- Plugin OTel — make
_cb_spandual-mode (OTel + JSONL); reuse vLLM's tracer; gate withCB_TRACING. (plugin repo) - V3 server sub-spans — add the §3(a) events +
SPAN_DEFS; simplify the §3(b) V3 deferral. (LMCache) — DONE:cb.fingerprint_match/cb.sparse_prefetchnest undercb.lookup(prefix lookup reusesmp.lookup_prefetch;prefix_chunksis acb.lookupattr);cb.scatter(re-RoPE folded) nests undercb.retrieve;hit_rate= prefix + non-prefix.cb.l2_loadGB/s is already covered by the existingL2ThroughputSubscriber(L2_LOAD_TASK_*), correlated by request; nesting that span undercb.sparse_prefetchis a cross-subsystem follow-up. - Cross-process link — add the optional
trace_contextRPC field; inject on the plugin side, extract on the server side. (both repos, in lockstep) - Dashboards — one trace view + the
lmcache_blend.*/ plugin latency metrics aligned onrequest_id.
Each phase is independently useful (1 and 2 give per-process traces; 3 unifies).
7. Open questions
- Reuse vLLM's tracer/provider, or a CB-owned one? (affects nesting under vLLM's
request span vs a standalone
cb.requestroot) - Is the protocol field (Option A) acceptable for upstream, or start with the deterministic-trace-id fallback (Option B)?
- Sampling: per-request tracing is expensive at scale — head sampling at the
scheduler (propagated via the same
trace_context) so a sampled-out request is cheap on all three processes.