Files
Gahow Wang 8829928fc5 Cache-size sweep: build_meta is O(|cache|), +85.6 μs / 1k blocks
Follow-up to Microbench 3 that finally tests H5 (cache-size
dependence) and instruments worker-side connector callbacks the
original patch missed.

Patch v2 (apply_step_timing_v2.py) adds:
  scheduler: `cache_size` field in engine_step.jsonl
  worker:    `get_finished_us` + `start_load_kv_us` in worker_step.r0.jsonl
  uses BLOCK_BEGIN/END sentinels for safe multi-line revert
  (the original v1 patch survives this v2's apply/revert cycle)

Driver: continuous open-loop (1.5 req/s, 4096x256 random per req)
that lets APC fill from 0 → ceiling within one vLLM lifetime so a
single run produces the full cache_size sweep. Decode-only steps
are filtered post-hoc to remove prefill-mix variance.

Findings (H20 96GB, ceiling reached ~17.5k blocks; n=15-18k decode
steps per config):

  config         | slope (μs / 1k blocks) | step_dur p50 @ |cache|=16.6k
  ---------------|------------------------|-----------------------------
  mooncake_both  | +85.6                  | 1528 μs (build_meta=1442, 94%)
  noop_connector | -0.8 (≈0)              |  79 μs
  plain          | +1.0 (≈0)              |  84 μs

  Worker-side get_finished p50/p90/p99 (μs/step):
    mooncake_both:  180 / 257 / 333
    noop_connector:   0 /   0 /   2

H5 PASSES. mooncake_both step_duration scales linearly with |cache|
because build_connector_meta walks set(cache.keys()) every step
(`mooncake_connector.py:434-450`). plain and noop are flat.

The previously-uninstrumented get_finished() adds a constant
180 μs/step on top — two `run_coroutine_threadsafe(...).result()`
blocking waits in kv_both mode (`mooncake_connector.py:1107-1137`)
fire every step even when no transfer is pending.

Trace-replay reconciliation (APC ≈ 79% → |cache| ≈ 13k blocks):
  build_meta @ 13k ≈ 1060 μs + get_finished ≈ 180 μs = 1.24 ms/step
  On ~7 ms decode forward → +15-20% TPOT per step.
  This explains most of the trace-replay +25% TPOT p90 gap from
  single-instance per-step cost alone, leaving a smaller residual
  for multi-instance coupling than originally assumed.

Two clear fixes pointed out in REPORT.md:
  1. replace O(|cache|) per-step walk with incremental delta
     listener using block_pool's add/remove callbacks
  2. short-circuit get_finished() when both producer/consumer
     queues are empty in kv_both

Heavy raw artifacts (engine_step.jsonl, vllm_stdout/stderr,
.vllm.pid) are .gitignored — they re-derive from `bash run_all.sh`
and SUMMARY.md / per_config.json fully capture the conclusions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 23:34:21 +08:00

4.3 KiB
Raw Permalink Blame History

Cache-size Sweep — testing H5 from connector_tax DESIGN.md

Hypothesis under test

H5: MooncakeConnectorScheduler.build_connector_meta() walks set(self._block_pool.cached_block_hash_to_block._cache.keys()) every scheduler step, so step_duration_us and build_meta_us should grow linearly with |cache| (= the number of cached block-hash entries in the block pool). The +45 % trace-replay tax is hypothesised to come from running this O(|cache|) loop at APC ≈ 79 %, which the prior microbench never tested (random content → cache stays small).

What we instrument

The original apply_step_timing.py only recorded step_duration_us and build_meta_us. This sweep adds:

Field Source Why
cache_size len(scheduler.kv_cache_manager.block_pool.cached_block_hash_to_block._cache) The exact dict that set(...) walks
get_finished_us wraps kv_connector.get_finished(...) in worker mixin The other suspected cost (two run_coroutine_threadsafe(...).result() blocking waits for kv_both)
start_load_kv_us wraps kv_connector.start_load_kv(...) in worker mixin Mostly fast for kv_both w/o transfers, but include for completeness

Scheduler-side fields go to engine_step.jsonl (existing channel). Worker-side timings go to worker_step.jsonl (one file per worker process).

Method

For each config in {plain, noop_connector, mooncake_both}:

  1. Launch one fresh vLLM (TP=1, H20, max_model_len=200000, gpu-memory-utilization=0.9, enable_prefix_caching).
  2. Read /metrics once to record kv_cache_max_blocks (the dict ceiling).
  3. Drive an open-loop stream:
    • shape = 4096 in / 256 out
    • rate = 2 req/s (kept below saturation to keep step duration dominated by scheduler-not-queueing)
    • content random per request (UUID + hash), zero prefix-cache hit ⇒ |cache| grows monotonically until hit by LRU eviction
    • duration = until cache fills (≤ 12 min)
  4. Collect engine_step.jsonl + worker_step.jsonl + the per-request metrics from bench_loop.py.
  5. Tear down vLLM, wait for GPU release.

After all three configs:

  • Apply LWESS-style binning on (cache_size, step_duration_us) to show the curve per config.
  • Compute linear fit per config: step_duration_us ≈ a + b · cache_size.
  • Connector-attributable per-step tax at a given |cache|: tax_us(cache_size) = mc_step(cache_size) plain_step(cache_size).
  • Same decomposition for build_meta_us (only mooncake / noop have non-zero values; plain is 0 by construction).
  • For worker side: get_finished_us distribution per config; in kv_both mode this should be non-zero even when no transfer fires.

What "passes" or "fails" H5

  • PASS: step_duration_us (mooncake_both) grows roughly linearly with |cache|, with slope > 5 μs / 1 000 blocks so that at |cache| ≈ 200 k it is ≥ 1 ms of per-step overhead. plain shows no slope. This matches the source code reading.
  • FAIL: no measurable slope, or slope is similar for plain and mooncake_both → the O(|cache|) walk is not the actual cost driver and we should look elsewhere (e.g. get_finished blocking waits, delay_free overhead).

Either outcome is informative.

What this sweep does not answer

  • Multi-instance coupling (8 schedulers running the walk concurrently vs proxy load-balancing).
  • Agentic session structure (long prefix reuse + short uncached tail).
  • The 8-instance trace-replay 45 % figure can only be reconciled once we know the slope and combine with concurrency / coupling measurements. This sweep is a necessary input, not the full reconciliation.

Files

cache_sweep/
├── DESIGN.md                # this file
├── apply_step_timing_v2.py  # extends apply_step_timing.py with cache_size + worker timings
├── run_cache_sweep.py       # bench driver: per-config continuous open-loop
├── analyze.py               # join engine_step + worker_step, plot, fit
├── run_all.sh               # orchestrator (apply patch → run 3 configs → revert → analyze)
└── results/<date>/          # one subdir per run
    └── <config>/
        ├── engine_step.jsonl
        ├── worker_step.jsonl
        ├── requests.jsonl
        ├── summary.json
        ├── vllm_stdout.log
        └── vllm_stderr.log