Skip to content

Replace the radix-tree KV allocator with vLLM's block pool - #60

Merged
JaehongCS20 merged 15 commits into
mainfrom
feat/blockpool-kv-cache
Aug 20, 2026
Merged

Replace the radix-tree KV allocator with vLLM's block pool#60
JaehongCS20 merged 15 commits into
mainfrom
feat/blockpool-kv-cache

Conversation

@JaehongCS20

Copy link
Copy Markdown
Collaborator

Summary

Replaces the radix-tree KV allocator with a port of vLLM v0.19.0's block pool,
and aligns the scheduler with vLLM V1's two-phase schedule(). Fixes #40 and
#52. Also corrects the attention latency lookup, which was interpolating on the
wrong scale.

Changes

KV cache & scheduler

  • serving/core/block_pool.py, kv_cache_manager.py: per-tier free-block
    queues, chained block hashes (hash(parent_hash, block_tokens)), eviction as
    a side effect of allocation. serving/core/radix_tree.py deleted.
  • One schedule() for prefix caching on and off. scheduler.py 1300 → ~510
    lines, memory_model.py 885 → ~545.
  • New: --npu-memory-utilization (default 0.9, per-instance
    npu_mem.mem_util), --reserve-full-isl (on, mirrors vLLM's
    scheduler_reserve_full_isl).
  • Removed: --prioritize-prefill (no equivalent in vLLM).

Attention latency model

  • _axis_bracket now blends linearly, not in log space. The profiler sweeps
    geometrically, but the kernel is linear per axis — profiled decode attention
    fits a + b·(n_decode·kv_decode) at R² = 1.0000. Leave-one-out on measured
    grid points: log-space +11.6…+14.4% mean error vs +2.3…+3.7% for linear,
    across all 7 bundles in profiler/perf/.
  • _ATTN_SKEW_ALPHA_FALLBACK 0.093 → 0. It applies only to bundles with no
    skew_fit, and 0.093 is a constant no bundle reproduces.

P/D

  • Submodule bump (chakra → astra-sim): the per-layer SEND/RECV now reads the
    trace's comm_size column instead of the qkv_proj output size, which shipped
    Q as well — 3× too large for Llama-3.1-8B, and blind to kv_cache_dtype.

bench

  • meta.json records vLLM's resolved config, including
    kv_cache.num_gpu_blocks.
  • Fixes kv_cache_pct always logging 0.0 in timeseries.csv.

Docs

  • adding-model-architecture documented four fields that do not exist
    (cls, category, tp_collective, ep_collective) and omitted within;
    its example failed pydantic validation. Rewritten and now checked against the
    schema. (Outdated model YAML format #52)
  • Sweep for the same drift elsewhere: the "two 4D lookups" claim in 5 places,
    the Workload.cc path, the PIM config paths.
  • outputs/* and astra-sim/inputs/runs/ are gitignored, which AGENTS.md
    already claimed.

Validation

All 11 example scenarios run clean (single / multi / P/D / CXL / prefix CPU pool
×2 / power / PIM / PIM sub-batch / MoE / MoE DP+EP agentic), no tracebacks.
Branch vs main, worst case −0.142% (moe_dp_ep), rest within −0.025%.

Against real vLLM runs, mean |Diff%| over 15 metrics:

main this PR
bench/examples/Llama-3.1-8B 1.23% 1.47%
bench/examples/Qwen3-32B 2.04% 1.49%
bench/examples/Qwen3-30B-A3B 3.22% 2.56%

The bench examples run on a 96 GB card at 17–36% KV occupancy, so they were
already accurate and barely move. The case under real KV pressure is the RTX 4090
24 GB run from #58/#59 — that is #40's regime, and where this PR is aimed. #59
reported TTFT mean −87.5% / TPOT mean +173.5% there. Now:

npu_mem.mem_util TTFT mean TPOT mean Latency mean mean |err| max |err|
0.9 (default) −8.5% +7.3% −4.0% 7.75% 23.1%
0.87 (matches the card's measured KV capacity) +0.9% +3.4% +1.6% 2.71% 10.4%

The gap between those two rows is the known limitation below, not a tuning knob
worth shipping: 0.87 is standing in for ~0.72 GiB the capacity model does not
account for.

outputs/example_*.csv are regenerated. Four with no generator are removed
(weight_offload, prefix_cpu_mem, ns3, prefix); they had been stale since
v1.1.0 and disagreed with the dataset they claim to replay.

Known limitation

KV capacity is mem_size · mem_util − weight. vLLM also subtracts its
activation peak and CUDA context, which are not modelled, so capacity is an
upper bound — on the 4090 that is ~0.72 GiB, and it is what the residual 7.75%
is made of. meta.json::kv_cache.num_gpu_blocks now records the real number, so
one bench run on any card turns that estimate into a measurement. Follow-up, not
a blocker.

SchedulerStats has no gpu_cache_usage field; it is kv_cache_usage
(vllm/v1/metrics/stats.py:181 in v0.19.0). The getattr default silently
returned 0.0, so the kv_cache_pct column in every timeseries.csv was
empty and sim-vs-vLLM comparisons had no way to see KV pressure.

Read the attribute directly so a future rename fails loudly instead of
zeroing the column again.
Port of vLLM v0.19.0's block_pool.py, the block/queue primitives in
kv_cache_utils.py, and kv_cache_manager.py, extended to a NPU/CPU/CXL
hierarchy. Nothing imports these modules yet, so this commit cannot
change any result.

Each pool is the single authority for its tier -- free list, prefix-cache
index and refcounts in one object -- so num_free_blocks is exact and an
allocation either succeeds or reports failure in the same call. The radix
tree could not do that: evictable_size_ counted every unlocked token
while evict() could only drop unlocked leaves, and memory was charged
later via tree events rather than in the call that can fail, so the
scheduler could believe in space that did not exist.

The tiers share one key space. Block hashes are chained once at the NPU
block size (hash(parent_hash, block_tokens)), and a lower tier whose
blocks are N times larger keys on every Nth hash -- the last fine hash of
each coarse block, which because the chain is cumulative identifies the
whole prefix up to that point. That is
offloading/scheduler.py::_get_block_hashes, and it keeps LMCache's
256-token chunk granularity without a second hash function. It also fixes
a latent collision: radix_tree.py hashed hash(tuple(page_tokens)), so two
different prefixes ending in the same 16 tokens collided.

Traffic: recall (lower tier -> NPU) is charged; the write-through is
reported for energy only, because vLLM's OffloadingConnector defers it to
the next engine step on a dedicated stream specifically so it cannot delay
token generation. Eviction from the NPU costs nothing -- the data is
either a finished request's cache or already has a copy below.

Both modules carry a self-test runnable without Docker:
  python3 -m serving.core.block_pool
  python3 -m serving.core.kv_cache_manager
Wires block_pool.py / kv_cache_manager.py in, deletes radix_tree.py, and
rewrites Scheduler to vLLM V1's schedule(). These have to land together:
the scheduler called the radix-era API (avail_size, lock_prefix,
cache_unfinished_req, ...), so removing it without the rewrite leaves the
tree unrunnable.

Scheduler
- one schedule() for prefix caching on and off, in two phases. Phase A
  serves the persistent self.running set, preempting only from its tail
  and retrying. Phase B admits from self.waiting while budget and
  sequence slots remain, breaking on the first allocation failure --
  admission never preempts -- and is skipped entirely on any step that
  preempted. That anti-thrash rule is load-bearing.
- no prefill phase and no decode phase: a request catches up to
  num_tokens_reached, so num_new = num_tokens_reached -
  num_computed_tokens, which is 1 in steady-state decode and the whole
  sequence for a resumed request. The trace classifies by scheduled token
  count instead (>1 = prefill chunk, ==1 = decode), which is what the
  attention profile axes expect and the only classification that survives
  a resumed request. Request.is_prefill() is deleted.
- num_computed_tokens advances when the batch is formed, as in vLLM's
  _update_after_schedule, with Batch.scheduled_tokens as the snapshot
  add_done works from. Advancing at completion let pp_size > 1 schedule
  the same tokens twice.
- preemption is vLLM verbatim, num_computed_tokens = 0 included. That is
  not re-prefill: free_blocks keeps the blocks' hashes, so on re-admission
  the still-resident prefix is found, a lower tier returns what was
  written down, and only the remainder is recomputed. Recovery comes from
  the tier hierarchy, not from a "preserve the decode state" special case.
- schedule_base and schedule_with_prefix collapse into one, taking with
  them a duplicate shadowed definition of schedule_with_prefix (228 dead
  lines), _rollback_locks, _get_reload_size, the STEP 2/3 shrink search,
  the commit-time reclaim/shrink/preempt loop, and
  get_first_arrival_time (which read an attribute never assigned and had
  no callers). 1307 -> ~510 lines.

Memory model
- KV capacity is npu_mem * gpu_memory_utilization - weight, divided into
  blocks, mirroring vLLM's requested_memory - non_kv_cache_memory. New
  --gpu-memory-utilization flag (default 0.9) with a per-instance
  cluster-config override. vLLM also subtracts the activation peak and
  CUDA context, which are not modelled, so this capacity is an upper
  bound at the same value.
- npu_used / cpu_used become properties over the pools, so there is one
  ledger per tier. The previous pair of ledgers (npu_used alongside
  RadixCache.capacity/total_memory_usage) is what let PR #59's mismatch
  through.
- the three prefix-cache modes now map onto three real vLLM
  configurations: --no-enable-prefix-caching behaves like vLLM with
  prefix caching off, where a resumed request recomputes its whole
  sequence; --enable-prefix-caching is default vLLM; adding
  --prefix-storage CPU/CXL is vLLM with LMCache or OffloadingConnector
  attached. The previous middle case billed a KV transfer against a tier
  that held nothing.
- a host offload tier now uses 256-token chunks (LMCache's default)
  uniformly. The non-shared second tier and the shared CXL pool used page
  size 1, which matched at token granularity and over-reported hits
  relative to any real offload tier.
- 885 -> ~545 lines.

Removed --prioritize-prefill and the per-instance prioritize_prefill key,
along with _merge_by_arrival_id (its only caller). vLLM v0.19.0 has no
equivalent: vllm/core/ -- the V0 scheduler whose _schedule_default served
prefills first -- no longer exists, and SchedulerPolicy is fcfs or
priority, which is request priority rather than prefill-vs-decode. The
only committed config that set the key paired it with
enable_chunked_prefill, where the branch could not fire anyway.

P/D disaggregation: the KV transfer is charged in convert_prefill's
per-layer SEND/RECV, but it was sized from the *v_proj layer's
output_size, i.e. the whole QKV activation, so it shipped Q as well -- a
factor of (q_dim + 2*kv_dim) / (2*kv_dim), 3x for Llama-3.1-8B -- and it
ignored kv_cache_dtype, making --kv-cache-dtype fp8 6x high. The frontend
now puts the per-layer, per-rank K+V bytes in the trace's comm_size
column via Batch.pd_kv_send_tokens, which also counts a request's
prefix-cache hit on its first step: the decode side needs that KV even
though the prefill side read it from cache. The converter half of this
lives in the chakra submodule and is not in this commit, so the new value
is inert until that is bumped.

Also fixed here:
- a P/D handoff left num_tokens_reached un-advanced, so the decode
  instance received a request with nothing to schedule and the run never
  terminated. The prefill instance ran lm_head and the sampler, so the
  first token exists. A guard now raises instead of spinning if a running
  request has nothing to schedule and no batch in flight.
- Total input tokens was reported as total_prompt - total_recompute, and
  those are not complements: a request preempted again mid-recompute is
  charged its full remaining work each time it is re-admitted. Summed
  from the requests' own original_input now.
- the per-tick Running column counted the in-flight batch, which is only
  the subset that fit in the step's token budget. len(running) is the
  exact analogue of vLLM's num_running_reqs, which is what bench compares
  it against.

Measured at 24 GiB / max_num_seqs 128 on the RTX 4090 ShareGPT replay
(gpu_memory_utilization 1.0, to stay comparable with the old baselines):
preemptions 20,540 -> 208, makespan 245.28 s -> 151.32 s, median TTFT
121,809 -> 49,078 ms, median TPOT 54.95 -> 45.32 ms, prefix hit ratio
1.91% -> 3.96%. That is issue #40; on a 24 GB card with an 8B bf16 model
it fires at any load, not only under overload.

Regressions: single_node_single_instance + example_trace stays
bit-identical at 1,665,077,255 ns; rtx4090 128 GiB msq 256 moves -0.027%
(106,668,561,596 -> 106,639,710,621), explained by the prefix hit ratio
rising 1.91% -> 7.59% from the chained hashes and from generated-token
blocks becoming recoverable; single_node_pd_instance,
single_node_pd_per_instance_config and single_node_moe_pd_instance are all
bit-identical. rtx4090 24 GiB --no-enable-prefix-caching is 6.7% slower on
purpose (163.33 -> 174.23 s), since CPU swap is replaced by vLLM's full
recompute, with 518,327 recomputed tokens accounting for it.
AGENTS.md: repository-structure table, and the "Scheduler and memory
model" section rewritten around the two-phase schedule, the absence of a
prefill/decode phase, schedule-time advancement of num_computed_tokens,
and the three prefix-cache modes as three real vLLM configurations. Adds
the trace-format note that comm_size on qkv_proj carries the P/D KV
amount, plus four pitfalls that exist because each has already cost a
wrong turn: do not reintroduce is_prefill(), do not special-case
"preserve the decode state" on preemption, do not derive sequence length
from num_computed_tokens, and note that kv_load/kv_evict fire only with
--prefix-storage.

docs site: prefix-caching.md largely rewritten (chained hashes, tiers
sharing one key space, lookup/insert/evict flow, what preemption costs);
continuous-batching.md's "two scheduling paths" replaced by the two
phases and the uniform tokens-to-catch-up rule; plus the smaller
references in architecture.mdx, request-lifecycle.md, reading-output.md,
codebase-tour.md, validating-changes.md, jsonl-format.md,
examples/memory-tiers/prefix-caching.mdx, and the two reference tables.
No RadixCache / RadixAttention / --prioritize-prefill references remain.

CHANGELOG: Added / Changed / Removed / Fixed entries for the redesign.
Re-ran all three examples on the block-pool scheduler and regenerated
summary.txt plus the throughput / latency / requests plots. The plots move
because the per-tick Running column now reports len(running) rather than
the in-flight batch size.

Accuracy against the recorded vLLM runs is preserved, and slightly better
in most places. Total clocks move by -0.03% (Llama-3.1-8B), -0.004%
(Qwen3-32B) and -0.14% (Qwen3-30B-A3B-Instruct-2507). Of the 45
vLLM-vs-sim Diff% figures across the three summaries, none moves by more
than 0.1pp except on the MoE example, where TTFT P90 improves +3.7% ->
+2.1% and P99 +4.7% -> +3.9%.

These configs have 96 GiB of NPU memory, so the eviction path is
essentially inert -- which is why the same defect that cost 20,540
preemptions on a 24 GB card was invisible here.
…ll-ISL gate

Naming: --gpu-memory-utilization / gpu_memory_utilization becomes
--npu-memory-utilization / npu_mem.mem_util. It was the only surface in
the repo that said GPU -- the cluster config says npu_mem 40 times and
num_npus 18 -- and the simulator models accelerators that are not GPUs.
The flag help names vLLM's --gpu-memory-utilization so the
correspondence is not lost.

Placement: the per-instance override now lives inside npu_mem as
mem_util, beside the mem_size it multiplies and matching its mem_*
siblings. Validated at load: a non-numeric value or one outside (0, 1]
is refused with a message saying it is a fraction, since mistyping 0.9
as 90 would silently size the cache 100x.

Admission gate: --reserve-full-isl (on by default, per-instance
reserve_full_isl) admits a request only if its whole sequence fits, not
merely its first chunk. Port of vLLM's scheduler_reserve_full_isl, True
there too, documented as preventing "over-admission and KV cache
thrashing with chunked prefill". allocate_slots' block arithmetic is
extracted into _num_blocks_to_allocate so the gate and the allocation
cannot drift apart -- two estimates disagreeing was the radix tree's
central defect.

Startup output: a KV Cache Initialization section between the input
config and the run loop, listing each instance's derived capacity:

  • Instance [0] : 54400 tokens / 3400 blocks (6.64 GiB/rank at util 0.90)

The utilization fraction alone does not tell you where memory pressure
will land; the token count does. It is per instance and only known once
the schedulers exist, so it cannot be a row in the input-config block.
Dropped that block's global utilization row, which would have lied as
soon as one instance overrode it. New logger helper print_heading()
centres a section title without drawing a second rule.

"▶ Starting simulation..." moves to just before the loop. It used to
print before config_builder, the prefix pools, the schedulers, the
controller and the router were built.

Docs: AGENTS.md gains the gate and npu_mem.mem_util; the KV-cache and
memory page is rewritten around the block pool, the capacity formula and
the gate; CONTRIBUTORS.md picks up #51, #53, #56, #57 and credits the
two reports that drove this work (#40's scheduler analysis, and #58/#59,
whose 24 GB profiling run was the first time the simulator saw real KV
pressure); the CHANGELOG says explicitly what replaced the radix tree
and that prefix caching's user-visible flags are unchanged.

bench/examples/configs/*.json state mem_util: 0.9 explicitly. At 96 GiB
those configs have 458k-617k tokens of KV, far past what the workload
wants, so the value is inert there and all three examples reproduce
their previous clocks exactly.
meta.json carried ten hand-picked engine kwargs, which is what we asked
vLLM for rather than what it ran with. Three things this investigation
needed were therefore unavailable: how many KV blocks vLLM actually
allocated, whether scheduler_reserve_full_isl was on, and which GPU the
run was on. The KV block count had to be inferred from the ratio of
running requests to token capacity.

Adds three keys, all optional and all read with meta.get() rather than
off schema_version, since they are absent from older runs and can be {}
if collection failed:

- kv_cache: num_gpu_blocks, block_size, num_kv_tokens,
  gpu_memory_utilization. num_gpu_blocks is the number a simulator has to
  match, and the only place the activation peak and CUDA context that
  vLLM subtracts from its budget become visible -- every other term in
  that budget is known up front.
- hardware: accelerator name, total memory, compute capability, CUDA and
  torch versions. Enough to match a run against a profiler/perf/<hw>/
  bundle without a separate host_metadata file.
- resolved_config: the whole VllmConfig, one key per sub-config, with
  defaults filled in and inferred values settled. Built by walking the
  config's own field list rather than a hand-kept list of interesting
  knobs, so a vLLM upgrade that adds one shows up without a change here.
  26 sections, ~296 leaf fields on v0.19.0.

Values that JSON cannot hold are replaced by a short type tag, so one HF
config object cannot bury the file -- meta.json goes from ~700 bytes to
~12 KB rather than megabytes. A property that raises while being read is
recorded as unreadable instead of aborting. The whole collection is
wrapped so that gathering metadata can never lose an otherwise finished
run.

META_SCHEMA_VERSION stays at 1: these are additions, so an existing
reader keeps working, and presence is better tested directly than
inferred from a version number. recorder.py documents all three, since
its module docstring already claims to be the single source of the
schema.
CHANGELOG and bench/README.md describe the three new meta.json keys and
why num_gpu_blocks matters: every other term in vLLM's KV budget is known
up front, so it is the only place the activation peak and CUDA context it
subtracts become visible.
Completes the half-landed change from 2cf3f12. The frontend already
writes the per-layer, per-rank K+V bytes into the trace's comm_size
column on qkv_proj; the Chakra converter that reads it lives in a nested
submodule, so until now the SEND/RECV pair still used the qkv_proj
output size and shipped Q along with K and V -- 3x too much for
Llama-3.1-8B, and blind to kv_cache_dtype.

Verified on single_node_pd_instance: the trace carries comm_size=40960
against output_size=122880 (exactly 3.0x), and the generated graphs hold
32 COMM_SEND_NODE of 40960 B on the prefill NPU against 32 COMM_RECV_NODE
of the same size and tags on the decode NPU, with no COMM_COLL_NODE
introduced -- collectives are gated on comm_type, which stays NONE.

All five reference scenarios stay bit-identical (single_node_pd_instance,
single_node_pd_per_instance_config, single_node_moe_pd_instance,
single_node_heterogeneous, single_node_single_instance): example_trace's
10-token prompts put the KV send 3 orders of magnitude below the layer
compute, so the clock cannot see it either way. A realistic-prompt
workload is what will show the difference.
_axis_bracket blended two profiled samples on a log scale. The profiler
sweeps every axis geometrically, which makes log space look like the
matching choice, but those are separate decisions: the grid spacing
decides where the kernel is sampled, the blend decides how two samples
are combined, and the kernel is linear in each axis.

Profiled decode attention depends on the total KV read, not on how it is
spread across requests. Fitting time_us = a + b * (n_decode * kv_decode)
on the RTX 4090 Llama-3.1-8B grid gives R^2 = 1.0000 at an implied
953 GB/s -- 95% of the card's 1008 GB/s spec, i.e. a pure KV-bandwidth
stream. At fixed n_decode * kv_decode, sweeping n_decode from 8 to 256
moves the measured time by only ~7%.

Blending a per-axis-linear function in log space is convex-biased
upward: up to +6.0% per axis on a doubling grid (worst at
query/x0 = 1/ln2 = 1.443), compounding across axes. Leave-one-out over
every profiled attention row -- predict a grid point from its two
neighbours, compare against what the GPU actually reported, no fitted
model anywhere -- puts log space at +11.6% to +14.4% mean error across
all seven bundles in the repo against +2.3% to +3.7% for linear, with
linear ahead on all four axes:

  axis            log      linear
  prefill_chunk   +12.59%  +3.81%
  kv_prefill       +6.67%  +0.23%
  n_decode        +18.44%  +4.31%
  kv_decode       +11.83%  +1.05%

At real batch shapes the runtime bracket spans 2x rather than the LOO
test's 4x, giving log/linear = 1.091 on attention, i.e. +3.0% of a
decode iteration.

End-to-end against the three real vLLM runs in bench/examples, mean
|Diff%| over 15 metrics each: TPOT improves on all 15 of 15 metrics
(1.54 -> 0.84, 1.82 -> 1.38, 1.78 -> 0.76) and Latency on 13 of 15
(0.74 -> 0.68, 2.02 -> 1.42, 0.88 -> 0.20). TTFT splits 8 improved / 6
worse: the change makes every iteration cheaper, so a run that was
already predicted too fast gets faster. Qwen3-32B, the one example with
no compensating error, improves on all 15 metrics.

Reference scenarios move by ~0.02%: single_node_single_instance
1,665,077,255 -> 1,664,699,047, single_node_pd_instance and
single_node_pd_per_instance_config 1,662,668,674 -> 1,662,366,658,
single_node_moe_pd_instance 2,082,386,488 -> 2,081,832,520,
single_node_heterogeneous 3,003,118,275 -> 3,002,576,771. example_trace's
10-token prompts sit at the bottom of the grid where the two bases nearly
coincide.
_ATTN_SKEW_ALPHA_FALLBACK was 0.093, a constant no bundle in the repo
reproduces. It only applies when a profile carries no skew_fit block at
all -- today that is RTX4090, profiled with SKIP_SKEW=1; bundles with a
real fit resolve alpha per bucket and are unaffected.

Guessing that constant is worse than omitting the correction:

- The measured pooled value for this same model on RTXPRO6000 is 0.0543,
  and resolving a saturated RTX 4090 run's own 3,853 decode batches
  against that bucket table gives alpha p50 0.059 (min -0.004, max
  0.068). 0.093 is ~1.6x too high.
- The parameter is badly conditioned. The endpoint gap
  (t_max - t_mean) * num_layers is ~12.6 ms on a ~29 ms iteration, so
  each 0.1 of alpha is ~4.3% of iteration time and alpha would have to
  be known to +/-0.023 to keep attention within 1%.
- alpha is not bounded to [0, 1] either. Across the 12,984 raw shots in
  RTXPRO6000/meta-llama/Llama-3.1-8B/bf16/tp1/skew.csv, where t_mean,
  t_max and t_skew are each measured directly on the GPU, alpha spans
  -68.6 to +9.28 with 16.6% negative and only 80.8% inside [0, 1].

alpha=0 returns t_mean, which is also the physically correct anchor:
attention cost tracks the total KV read Sigma_k, and a uniform batch at
the arithmetic mean has n * mean(k) = Sigma_k exactly. The median would
understate it -- the runtime kv distribution is right-skewed, measured
kv_max/kv_mean p50 = 2.61 on the ShareGPT replay. The profiler uses the
same definition (skew.py: kv_mean = total_kv // n). Measured
t_skew/t_mean at 4090-like shapes is 1.029, so the correction being
dropped is worth ~3%.

Also records why kv_decode_mean is the lookup coordinate, so the next
reader does not reach for a median.

On the RTX 4090 ShareGPT replay at the default mem_util 0.9 this moves
clocks 168,187,564,812 -> 163,114,307,308 ns and TPOT p50 36.85 -> 35.66 ms.
The five reference scenarios are unchanged: they all run on RTXPRO6000,
which has a real skew fit, so the fallback never fires.
Regenerated with ./bench/examples/run.sh followed by
./bench/examples/validate.sh, so paths and flags match the committed
layout. Clocks move by about a percent, all in the same direction --
the linear basis makes every attention lookup cheaper:

  Llama-3.1-8B     65,705,603,364 -> 65,049,871,040  (-1.00%)
  Qwen3-32B       163,422,751,517 -> 162,573,714,653 (-0.52%)
  Qwen3-30B-A3B    62,942,560,948 ->  62,060,744,672 (-1.40%)

Accuracy against the recorded vLLM runs, mean |Diff%| over each
example's 15 metrics:

                      before   after
  Llama-3.1-8B         1.18%   1.47%
  Qwen3-32B            2.03%   1.49%
  Qwen3-30B-A3B        3.06%   2.56%

TPOT improves on all 15 of 15 metrics across the three examples and
Latency on 13 of 15; TTFT splits 8 improved / 6 worse. A cheaper
iteration helps wherever the simulator was predicting too slow and hurts
where it was already too fast, so the sign of each example's existing
bias decides the outcome. Qwen3-32B, whose 15 metrics were uniformly
+1.4% to +2.8% before, improves on every one of them.

Two figures unchanged by this and worth chasing separately: the MoE
example's TTFT Median (-19.9%, identical before and after) and its
makespan (-9.0%). They account for most of that example's remaining
error -- Llama and Qwen3-32B now peak at 7.0% and 2.3%.
Updates AGENTS.md, the docs site, profiler/README.md and the changelog for
the two model changes, and corrects three descriptions that were already
wrong before them:

- The attention lookup was documented as "nearest-neighbour on
  (prefill_chunk, n_decode), bilinear on (kv_prefill, kv_decode)" in both
  AGENTS.md and the trace-generation page. It has always bracketed and
  interpolated all four axes; only the scale was ever in question.
- The trace file was called tab-separated. utils.py::_FMT emits
  fixed-width space-padded columns, and the converter splits on any
  whitespace. Parsing on '\t' silently yields one field per line.
- The SKIP_SKEW=1 fallback alpha was documented as "roughly 0.3 across
  observed hardware", a figure no bundle in the repo reproduces. The code
  constant was 0.093 and is now 0.

Also adds the gitignore rules AGENTS.md already claimed existed:
outputs/* with !outputs/example_*.csv, plus astra-sim/inputs/runs/.
Without them every run's scratch accumulated in git status, and
--no-cleanup-inputs left 1.3 GB of ASTRA-Sim inputs behind.

The AGENTS.md entry for _axis_bracket carries the evidence inline, so the
next reader does not "fix" the linear blend back to log space on the
grounds that the sweep grid is geometric. Those are separate choices.

Site builds clean (cd docs && pnpm build).
The model-architecture page did not match the YAML schema. `LayerEntry`
sets `extra="forbid"`, so the fields it documented are rejected at load
time:

  cls:            -> vllm:
  category: dense -> the catalog block the layer sits in
  tp_collective:  -> not a field; the simulator attaches TP ALLREDUCE
                     after o_proj / down_proj from the cluster config
  ep_collective:  -> likewise for EP ALLTOALL around moe
  (missing)       -> within:

`within` filters on an ancestor class, which is what lets one `RMSNorm`
entry serve the input norm, the post-attention norm and the final norm:
`within: LlamaDecoderLayer` for the two block-level ones,
`within: LlamaForCausalLM` for the last. The same filter separates
`o_proj` from `down_proj`, both `RowParallelLinear`. The page now also
records that `(vllm, within)` has to be globally unique.

Further corrections there: `attention` is listed explicitly in
`sequence.pre_attn`; `lm_head` maps to `LogitsProcessor`; Llama 3 uses
`Llama3RotaryEmbedding`; the MoE catalog names the sparse block
(`Qwen3MoeSparseMoeBlock`); and both dense and MoE models declare all six
sequence groups with the unused MLP group empty. The example is checked
against `Architecture` / `Catalog` / `Sequence`, and every layer its
sequence references exists in its catalog.

Same class of drift elsewhere:

- `_lookup_attention_with_skew` was described as always doing two 4D
  lookups (AGENTS.md, serving/README.md, its docstring,
  trace-generation.md, skew-alpha-fit.md). The second lookup is
  conditional: `t_mean` is returned after one lookup for `n_decode <= 1`,
  for a batch whose decode kv lengths are all equal, or for `alpha == 0`,
  which is the default without a skew profile.
- `output-bundle.md` described the attention lookup as nearest-neighbour
  plus bilinear, and `_attn_slice_lookup`'s docstring said "log on each
  axis".
- `codebase-tour.md` pointed at `astra-sim/astra-sim/system/Workload.cc`;
  the file is under `workload/`.
- Two example pages pointed at `configs/pim/DDR4_8GB_3200_pim/` as a
  directory. `configs/pim/*.ini` are files, one per device.

Drops the reasoning that landed on trace-generation.md alongside the
linear interpolation change: why the blend is linear, the range of the
fitted alpha, and the arithmetic-mean lookup coordinate. That belongs
with the code, in `_axis_bracket`'s docstring and AGENTS.md.

Adds @bui-thanh-lam to CONTRIBUTORS for the report, and brings the
report-only entries to one line each to match the rest of the list.

Checked mechanically and clean: canonical layer names against every
`profiler/models/*.yaml` catalog, cluster-config keys against
`config_builder.py`, the 11-column trace header against `utils.py::_FMT`,
model-config fields against what the simulator reads, and documented CLI
defaults against argparse.

Site builds clean (cd docs && pnpm build).
The committed outputs/example_*.csv were last regenerated in v1.1.0
(3012eb1) and had gone stale against `main`, not just against this
branch: re-running `main` today produces different numbers, and the
committed files disagree with the dataset they claim to replay.
`workloads/example_trace.jsonl` gives request 3 `output_toks: 12` and
request 0 `output_toks: 70`; the committed CSVs recorded 16 and 80.

Cause: `serving/run.sh` has ten of its eleven examples commented out, so
only `single` was ever refreshed. The others drifted unnoticed. That is
the intended shape of the file -- a menu you uncomment one line of -- so
it stays as it is; the eleven commands were checked flag by flag against
what was actually run here, and they match.

Its shebang was commented out too (`# #!/bin/bash`) while the file
carried the execute bit, so invoking it directly fell through to `sh`.

All eleven scenarios were run on this branch and on `origin/main` for
comparison. Every one completes with no traceback, and the branch differs
from `main` only by the size of the linear-interpolation change:

  scenario                     origin/main          branch      diff
  cxl                       12,113,387,362  12,113,387,362   +0.0000%
  single                     1,665,077,255   1,664,699,047   -0.0227%
  power                      1,665,077,255   1,664,699,047   -0.0227%
  multi                      1,665,607,238   1,665,338,118   -0.0162%
  prefix_cpu_mem_pool        1,665,607,238   1,665,338,118   -0.0162%
  dual_prefix_cpu_mem_pool   1,669,378,488   1,669,153,368   -0.0135%
  pd                         1,662,668,674   1,662,366,658   -0.0182%
  pim                        1,643,260,840   1,643,260,424   -0.0000%
  pim_sub_batch              2,634,146,962   2,634,146,546   -0.0000%
  moe                        1,946,195,383   1,945,725,751   -0.0241%
  moe_dp_ep                 36,636,490,356  36,584,504,388   -0.1419%

`cxl` is unchanged because it is CXL-bandwidth bound, and the two PIM
cases because PIM attention replaces the NPU attention lookup the change
touches. `moe_dp_ep` moves furthest simply by running the most
iterations: 52 ms over a 36.6 s simulation.

Also removes four tracked outputs that nothing regenerates:

- `example_weight_offload_run.csv` and `example_prefix_cpu_mem_run.csv`
  have no command in `run.sh` at all, so they could not be reproduced or
  checked.
- `example_ns3_run.csv` and `example_prefix_run.csv` belong to the
  commands under run.sh's "Deprecated examples" heading -- NS-3
  integration is still a work in progress, and the prefix-caching flag
  became the default, which is why that example was disabled.

What is left is eleven tracked outputs for the eleven scenarios that were
actually verified.
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.

Accuracy boundary report: throughput & queue depth diverge under severe overload

1 participant