Files
vllm-omni/docs/analysis/optimization_plan.md
T
tzhouamandClaude Opus 5 9264ff81ab Include the baselines, comparisons and study documents, not just the fork
The packaging commit shipped the code and a summary of the numbers. It left out
everything the numbers are measured *against*, which is most of what makes them
checkable.

Added:

  baselines/                 llama.cpp runs (thread sweeps, NUMA sweeps, the
                             interleaved A/B passes behind the headline decode
                             figure) and the quality references: each build
                             against its own bf16 over 12 greedy prompts.
  benchmarks/.../results/    102 artifacts backing the published figures, each
                             with a provenance block naming the model,
                             quantization, thread set and build SHAs.
  docs/engines/              the engine analyses -- llama.cpp, ExecuTorch, MNN,
                             ncnn, vLLM, vLLM-Omni -- and the cross-engine and
                             device-simulation comparisons.
  docs/analysis/             the full study documents the summaries distil.
  docs/edge/comparisons.md   where this fork sits against llama.cpp and why:
                             decode is llama.cpp's, prefill is ours by ~2x, and
                             since neither engine exceeds 39% of the measured
                             222 GB/s ceiling the decode gap is the execution
                             model rather than the kernels.
  docs/edge/environment.md   pins and machine, and the correction below.

On "the modified vllm": there isn't one, and the doc says so plainly because it
is the obvious assumption. The vLLM checkout is clean upstream (0 commits
ahead, 0 uncommitted) and the installed wheel is stock 0.28.0+cpu. This fork
changes vLLM's CPU *behaviour* through vLLM's own extension points at import
time -- three CPU patches in vllm_omni/patch.py, the quantization-config
registry, the IR op registry, platform hooks -- specifically so the changes
survive a wheel upgrade rather than being lost on it. One of those patches is
why "KV sizing is not a memory lever" looked true for months: CpuPlatform
overwrote kv_cache_memory_bytes unconditionally, so the setting had no effect.
There is no patch series to apply; pin the version and let patch.py do the rest.

.gitignore gains explicit exceptions: upstream's blanket `*.json` and `results/`
rules were silently eating the measurement artifacts, which in this fork are the
substance rather than build output. Binary captures stay ignored.

No vLLM or llama.cpp source is redistributed -- only output produced with them.
NOTICE records both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L1ax5wYLA6XbqEL1i8L9gZ
2026-09-14 14:01:12 +08:00

586 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Optimization plan: inference and harness
Written 2026-09-12 against [spark_edge_support.md](spark_edge_support.md), then
revised after an independent review by `gpt-6-astra` through the Codex hook
(read-only against these checkouts; the sandbox could not start because user
namespaces are disabled on this host, so it ran unsandboxed under explicit
read-only instructions and the checkouts were verified clean afterwards — the
same procedure recorded in [README.md](README.md) for the 2026-09-08 study).
The review rejected the first draft's ordering and several of its expected
gains. Most of that was correct and is applied below; §"Review record" lists
what I accepted, what I corrected with new measurements, and why.
Every item starts from a measurement in this tree, states the expected gain,
and says how it would be verified — because on this host a claim without a
verification method is how three earlier conclusions turned out to be wrong.
**A fourth is now on the list**: the first draft of this plan mis-described the
740 MB it proposed to remove.
## Where the time actually goes
W4A16 g128 decode, 24 threads, per-op profile (reproducible to ~5%, where
throughput swings ~30%). The first draft built this table from two different
builds — 5.91 ms is the **W4A8** GEMM, 6.85 ms the W4A16 one
([support:193](spark_edge_support.md)) — so the step it described (11.8 ms) was
neither configuration. The W4A16 step is **12.51 ms**:
| | ms | share of 12.51 |
|---|---|---|
| int4 GEMM (tinygemm) | 6.85 | 55% |
| inductor elementwise (norms, rope, GEGLU, residuals) | 2.20 | 18% |
| vLLM attention ops (4 per layer × 28) | 1.31 | 10% |
| custom-op dispatch boundary (W4A16 only) | 0.55 | 4% |
| unattributed remainder (sampling, view/slice churn) | 1.60 | 13% |
Two corrections to how this table was read. The GEMM runs near this socket's
achievable bandwidth for *its own* shape, which says the GEMM is memory-bound;
it does **not** say the other 45% is framework overhead — attention, softmax,
norms and sampling contain real arithmetic. And the per-step savings of W4A8
over W4A16 do not add: 0.94 ms (GEMM) + 0.48 ms (dispatch) = 1.42 ms predicted
against 12.51 − 11.55 = **0.96 ms** measured. Something offsets them, and no
item below may assume its saving reaches the step time unchallenged.
## Where the memory actually goes
Anonymous RSS by phase ([support:324](spark_edge_support.md)):
| phase | W4A8 (GPTQ) | W4A16 (tinygemm) |
|---|---|---|
| before import | 12 MB | 12 MB |
| after `import vllm_omni` | 513 MB | 515 MB |
| after engine construction | **7338 MB** | 5023 MB |
| after releasing free arenas | 3510 MB | 3411 MB |
The first draft treated the post-release figure as the number to reduce. That
is the wrong target for an edge device: **7338 MB is the peak that has to fit**,
and `release_after_load()` runs only after `load_model` and warm-up have both
returned ([patch.py:730](../vllm-omni/vllm_omni/patch.py)). A device that cannot
survive the load never reaches the release. This reordered the plan.
The draft's weight breakdown ("0.86 GB quantized + 0.54 GB bf16 embedding") is
also wrong for the checkpoint actually measured: `spark2_5-int4-g128/config.json`
sets `quantize_embedding: true` with `tie_word_embeddings: true`, so there is no
bf16 embedding, and the persistent prefill duplicate was left out of the
breakdown entirely. There is therefore no trustworthy ledger to subtract from —
which is why the "unexplained 1.6 GB" item is now a prerequisite, not a
long-shot.
---
## Inference
### I0. Peak load-time memory — *now first*
7338 MB peak vs 3510 MB steady is the gap that decides whether a device can run
this at all. The peak comes from holding the checkpoint, the dequantized
intermediate and the repacked layouts alive at once, per layer, with freed
arena pages not yet returned.
**Do**: (a) release arenas *during* the load, per layer or per N-layer block,
rather than only after `load_model` returns; (b) repack blockwise so no
whole-tensor bf16 intermediate exists; (c) as the durable fix, write the packed
layout to a deployment artifact at build time so the device loads what it runs.
**Expect**: peak toward the steady 3.4–3.5 GB. Unquantified deliberately — the
mechanism is understood, the size is not.
**Verify**: sample `RssAnon` on a timer through load (0.2% reproducible), report
the maximum, not the endpoint. The harness measures endpoints today and would
not have caught this.
### I1. One packed weight layout, not two — *replaces the draft's I2*
The W4A16 build keeps a second copy costing **740 MB** (3542 → 2802 MB) and
buying **7.3× prefill** (3252 → 448 tok/s). The draft described this as "prefill
unpacks to bf16" and proposed a tiled dequant kernel to avoid the bf16 copy.
That was wrong about the artefact. `_finalize_int4` keeps `weight_halves` — a
**second packed 4-bit copy** — plus cloned `weight_group_scale` and
`weight_group_zero`; the bf16 matrix is already a per-call temporary and
`weight_packed` is deleted
([cpu_int4.py:344,375](../vllm-omni/vllm_omni/model_executor/layers/quantization/cpu_int4.py)).
So the cheap fix is not a new GEMM. It is to **dequantize directly from the
tinygemm layout** and delete `weight_halves` and the duplicated scale/zero,
keeping the existing single `F.linear`. The only reason that was not the
original design is that the tinygemm layout was believed opaque. It is not —
measured on this host (`torch._convert_weight_to_int4pack_for_cpu`, AVX-512):
- The packed buffer is `[N/64][K][32 bytes]`: **K is the middle axis**, so a
K-range is contiguous *within each n-block of 64*.
- Reassembling a K-tile from those per-n-block segments is **byte-identical** to
packing that K-slice directly (verified exactly at N=2048, K=2048 for tile
widths 128/256/512), and split-K through tinygemm reconstructs the single-call
result at **51.5 dB**.
- The lane order inside a 32-byte run is a **fixed 64-entry interleave** — low
nibble = output channel *d*, high nibble = *d+32* — identical across every k
and every n-block. So decoding is a deinterleave, not a gather or a
reverse-engineered ISA table.
**Do**: point the existing dequantizer at the tinygemm layout via that
deinterleave; drop `weight_halves`, `weight_group_scale`, `weight_group_zero`.
**Expect**: most of the 740 MB back **at unchanged prefill**, because the GEMM
shape and reduction order do not change — only where the nibbles are read from.
The largest single temporary stays one dequantized `gate_up` (N=13312, K=2048,
bf16 = **52 MiB**), which already exists today.
**Verify**: `RssAnon` (0.2%) plus pp512, three interleaved passes, and an SNR
assertion against the current dequant path — the check that caught the original
layout bug.
**Risk**: tails. N not divisible by 64 or K odd need the scalar path; the
deinterleave rule above is verified only for full blocks.
**Not doing first**: the draft's K-tiled dequant-and-accumulate. Even granting
the layout, calling oneDNN once per K-tile means the M×N accumulator is re-read
and re-written per tile — for `gate_up` at M=512 that is ~26 MiB of fp32
accumulator × 15 extra tiles ≈ 780 MiB per layer per prefill, which is not the
"few MB of scratch" the draft claimed. Avoiding that traffic means keeping the
accumulator panel in registers across the K loop, i.e. writing a blocked GEMM
microkernel rather than calling a library per tile. If temporaries still need
cutting after the item above, **N-blocking is the better experiment** — disjoint
output columns need no cross-tile accumulation at all.
### I2. Measure the native non-AMX 4-bit path before building anything
`weight_path.py:14` calls tinygemm "the only option" without AMX. That is false:
when `layer.use_w4a8` is off, vLLM 0.28 falls through to `ops.cpu_gemm_wna16`
([cpu.py:202](../vllm/vllm/model_executor/kernels/linear/mixed_precision/cpu.py)),
a vector-ISA W4A16 kernel that has never been measured here. Both the ARM story
and the "fidelity" weight path rest on an untested assertion.
**Do**: benchmark `cpu_gemm_wna16` against tinygemm at Spark's shapes, decode
and prefill. **Verify**: `bench_int4_kernels.py`. **Then** fix `weight_path.py`.
This is a day's work that could invalidate a kernel project, so it comes first
among the kernel items.
### I3. Asymmetric W4A8 — a fidelity experiment already supported by the kernel
The report treats W4A8's zero point pinned at 8 as a kernel limitation. It is
not: `_process_gptq_weights_w4a8` uses the checkpoint's zero points whenever
`config.zero_points` is set, and synthesizes the constant only otherwise
([cpu.py:98](../vllm/vllm/model_executor/kernels/linear/mixed_precision/cpu.py)).
Our exporter chooses `"sym": true`
([quantize_gptq_int4.py:102](experiments/spark_edge/quantize_gptq_int4.py)).
W4A8 is the fastest path and the worst on task completion (1/12 greedy; the
agent navigates correctly then fails to stop). An asymmetric export is a
config change, not a kernel project.
**Do**: re-export with `sym: false` and zero points, re-run fidelity and the
agent suite. **Expect**: unknown, but it is the cheapest fidelity experiment
available and it tests the *stated* cause of W4A8's error.
### I4. A better quantizer — only after I3, and on an honest bit budget
Ours reproduces 2/12 greedy prompts; llama.cpp Q4_K_M manages 4/12. The draft
called this "the same bit budget". It is not. Ours is **4.25 bits/weight**
(4 + two bf16 per 128-weight group; `weight_path.py` says 4.16, which is wrong);
plain Q4_K is **4.5 bits** over groups of 32, and Q4_K_M additionally promotes
some tensors to higher-bit types. Group size, per-tensor type selection and
rounding are all confounded, so "6-bit sub-block scales cause the gap" is an
attribution the experiment never isolated.
**Do**: first close the budget gap cheaply (g64 and/or fp16 scale+zero, which is
a config change) and re-measure; only then implement error-compensated rounding
(GPTQ-style, calibration data exists) or AWQ-style scaling.
**Verify**: greedy divergence *and* the scored agent suite — but only after H1
below, because today's suite cannot rank builds.
**Risk**: may produce nothing. Timebox it; a null result is worth recording.
### I5. Attention op-chain fusion — worthwhile, but not "highest confidence"
Four ops per layer, 112 dispatches, **1.3085 ms/step** measured
([profile_w4a16_g128_p2.json](experiments/spark_edge/profile_w4a16_g128_p2.json)):
`unified_attention_with_output` 0.4379, `unified_kv_cache_update` 0.3948,
`cpu_attention_with_kv_cache` 0.3471, `cpu_attn_reshape_and_cache` 0.1287.
These are **two wrapper/kernel pairs**, not four independent kernels. Wrapper
self time is 0.833 ms — 64% of the total — so overhead does dominate at this
shape: at batch 1 the cache-update OpenMP loop runs over tokens × KV heads, i.e.
**two iterations**, while both paths independently rebuild cache views and the
wrapper allocates a dummy tensor for compile ordering.
But the draft's supporting arithmetic was about a workload it never profiled.
"28 MB moved" assumes every layer attends 512 tokens; 7 of 28 layers are full
attention, and `profile_cpu_step.py` profiles a short prompt generating 64
tokens — nowhere near a filled window.
**Expect**: revised down. Eliminating *all* wrapper time saves 0.833 ms, and
some wrapper is required, so the draft's 0.6–0.9 ms demanded savings from the
C++ bodies too. Target **0.3–0.5 ms** (2–4% of step) from consolidating context
and cache-view handling through the existing `forward_includes_kv_cache_update`
interface, plus a serial path for the two-iteration cache update.
**Verify**: per-op profile **and** unprofiled step latency — one outer call that
still launches two threaded kernels removes neither thread launch, and fewer
profiler events alone make this look better than it is.
### I6. Import cost — an upper bound, not a saving
`import vllm_omni` costs 19.6 s against `import vllm`'s 5.2 s; auto
image-processing (4.6 s) and the `openai` client (1.3 s) are identifiable inside
it. The draft promised 4–6 s per process. That 5.9 s is an **upper bound on
removable import work**, not a demonstrated reduction in time-to-ready: deferred
imports often just move the cost later. The init timer starts *after* imports
([bench_init_memory.py:44](experiments/spark_edge/bench_init_memory.py)), so the
current harness cannot measure this at all. And `patch.py` uses `ModelConfig` at
import time to install a descriptor patch
([patch.py:54](../vllm-omni/vllm_omni/patch.py)) — deferring it means changing
how that patch installs, not moving a statement.
**Do**: first make the harness measure **process start → ready**; then defer.
**Verify**: that end-to-end number, with `-X importtime` as the diagnostic.
### I7. A real memory ledger
Prerequisite for any claim about a remainder. The draft's tools do not do the
job: `smaps_rollup` is an aggregate (use `smaps` per mapping), and `tracemalloc`
does not attribute native tensor storage or allocator arenas.
**Do**: count unique tensor storages (both embedding layouts included), then
`smaps` by mapping for the rest. **Expect**: a decision about whether the
remainder is reducible — the draft's "unexplained 1.6 GB" was a subtraction from
a breakdown now known to be wrong.
### I8. Resolve the `prefill_dequant: false` correctness hazard
`cpu_int4.py:55` records that running tinygemm on a multi-row batch and then
`cpu_attention_with_kv_cache` in the same forward dies with SIGILL — an AMX tile
configuration bug — and sets `dequant_threshold: 2` to avoid it. But
`prefill_dequant: false`, the 2802 MB build the report calls the smallest
resident configuration, routes multi-row batches to exactly that path. Either
the 448 tok/s measurement and the crash report disagree about build or shape, or
that configuration is not deployable. **Do**: reconcile before quoting 2802 MB
as a build anyone can use.
### I10. Megakernel / persistent thread pool -- the biggest measured headroom
Raised after the review. On GPU a megakernel (Hazy Research's low-latency
Llama, Mirage's MPK) fuses a whole forward pass into one launch, removing
per-kernel launch cost and the pipeline bubble where the next layer's weights
cannot start loading. There are no kernel launches on CPU, so the analogues
are **OpenMP fork/join per op**, **ATen dispatch**, and the same cross-op
prefetch bubble.
This is not a hypothetical for us: **llama.cpp already is one.**
`ggml_threadpool` starts its threads once and they walk the entire graph
together, synchronising at `ggml_barrier` through an atomic spin
(`ggml-cpu.c:490,576`). vLLM enters a separate `omp parallel` region inside
each kernel. So the comparison this whole study is built on is, in part, a
comparison of execution models.
Measured here, so the prize is sized before anything is built:
* **Parallel-region cost: ~1.2 us** at 24 threads (a just-above-grain-size op,
24 threads vs 1). Spin-waiting does not help -- `OMP_WAIT_POLICY=ACTIVE` plus
`GOMP_SPINCOUNT=infinite` moved it 1.23 -> 1.18 us, inside noise -- so this
is barrier and scheduling cost, not thread wake-up, and the one-env-var
approximation of a persistent pool buys nothing.
* **Regions per decode step: a few hundred.** 113 quantized GEMMs and 112
attention ops certainly parallelise; most elementwise tensors at batch 1 are
below PyTorch's grain size and run serially, so the 764 candidate calls in
the profile overstate it. At ~250-350 regions this is **0.3-0.4 ms, 2-3%** --
real, but the same money as I5, which is far cheaper.
* **The bubble is the prize, not the barriers.** The GEMM sustains 60% of the
measured read ceiling and the whole step 33%. A persistent kernel that keeps
the weight stream running across op boundaries is the mechanism that could
close part of a **2.76 ms** gap.
One of our own decisions works against this: `cpu_int4_linear` is deliberately
opaque to dynamo so the batch-size branch never becomes a guard on vLLM's
traced graph. That also stops inductor fusing anything across it. The guard
stability was worth it, but it is a cost worth naming.
**Do**: *not* build a megakernel next. The honest read is that the fork/join
half is small and already covered by I5, and the streaming half needs the
execution model replaced -- a persistent pool plus cross-op pipelining inside
vLLM's CPU backend, fighting its attention backends, KV cache management and
torch.compile integration. That is weeks, and it reimplements ggml.
What *is* worth doing, in order, because each one tests the same thesis for a
fraction of the cost:
1. **Find out why the GEMM is at 60%.** It is not obviously bandwidth. The
tinygemm kernel dequantizes once per activation row, so part of that time is
arithmetic, and each of the 113 calls streams only a few MB -- too little for
the prefetcher to reach steady state. Attribute it before optimising it.
2. **Measure `cpu_gemm_wna16`** (I2) and W4A8's 69% against the same ceiling.
If one kernel is materially closer, the answer is a better kernel, not a
different execution model.
3. **I5**, which is the cheap slice of the fork/join half.
**Verify**: per-op profile plus the streaming-bandwidth figure above, which is
now the denominator any kernel claim should be quoted against.
### I9. Explicitly not planned
- **More kernel fusion.** Four attempts, four losses: vLLM's fused CPU RMSNorm
is 3.6–4.5× faster in isolation and slower in situ (inductor already fuses the
native form with the residual add); fused `gelu_and_mul` 2.7× slower at
Spark's shape; `cpp.min_chunk_size` −6% decode/−19% prefill; an inline gate
reduction −44%. I5 is op-*count* reduction, a different mechanism.
- **MiniCPM-o CPU overlay.** Dropped. The 8 B thinker at bf16 is ~19 GB, so it
buys no edge deployability; and the draft's "three stages should beat the
two-stage 2.1×" is not how overlap works — the bound is ΣT/max(T), so stages
of 90/5/5 s give 1.11×. (That the measured two-stage ratio is 64.57/30.54 =
2.114× when the bound for two tasks is lower means something else changed too,
which the init work should explain before anyone quotes it.)
- **Thread count and warm-up batch size as memory levers** — 64 MB and 2 MB.
- **KV-cache sizing is *not* on this list any more.** The 188 MB that retired it
was measured generating 8 tokens. At 32 k context `kv_budget.py`'s own formula
gives 1792 MiB flat vs 469 MiB hybrid — a 1323 MiB difference. It was measured
at a context where no cache is touched, which is not evidence that it never
matters. (`CpuPlatform.support_hybrid_kv_cache()` returns `True` in vLLM 0.28,
so the report's flat accounting needs a configuration explanation too.)
- **Speculative decoding** for tg128: it would inflate a fixed-prompt
`ignore_eos` benchmark without representing anything. Measure it on the agent
suite or not at all.
---
## Harness
The review's sharpest finding is that the harness cannot currently support the
comparisons the inference work needs. Correctness first, statistics second.
### H1. Fix the scored suite before running it more
- **The alarm oracle is wrong.** `add_alarm` passes if any typed string contains
`"7"` ([tasks.py:101](experiments/spark_edge/android_agent/tasks.py)) — not
7:30, not AM, not that an alarm was saved. Typing `7` anywhere scores a pass.
- **The toggle task scores final state only**, so turning a setting off and back
on counts as success.
- **The scored suite is simulator-only**: task builders and predicates are typed
to `SimulatedDevice`. The real-device *episode* path exists; real-device
scoring and reset do not.
- **Repeats at temperature 0 over fixed fixtures measure repeatability, not
generalization** ([agent.py:48](experiments/spark_edge/android_agent/agent.py)).
**Do**: correct the oracles; add paired task/layout/state variations so N runs
are N trials; implement real-device scoring and reset.
This supersedes the draft's "N≥5 runs per task" — more repetitions of a wrong
oracle produce a confident wrong answer, which is the failure mode this project
already has three instances of.
### H2. Fix measurement semantics
- `run_grid`'s docstring says it rotates arms; it iterates `for pass: for arm in
arms`, i.e. the **same order every pass**
([bench_harness.py:160](experiments/spark_edge/bench_harness.py)). It
interleaves but does not rotate, so position bias survives.
- `summarize` reports `best = max(values)` for every metric
([bench_harness.py:123](experiments/spark_edge/bench_harness.py)) — wrong for
memory and init, where lower is better. Published "best" memory figures are
the worst pass.
- `profile_cpu_step.py` wraps all of `generate` (prefill + sampling) and divides
by `tg`, while the wall-clock path subtracts independent best-of-three
measurements. These are different quantities presented as one.
- Profile JSONs record no model path, context length or build; saved W4A8 and
W4A16 tags both read `bfloat16/None/eager=False`, and some saved W4A8 profiles
still contain the retracted bf16-head build with nothing marking them invalid.
**Do**: rotate arm order per pass; take `best` by metric direction; separate
prefill from decode in the profiler; stamp provenance (model path, config hash,
commit, thread set) into every artifact and mark the retracted ones.
### H3. Measure what the inference items need
- **Process start → ready**, not engine-construction time (required by I6).
- **Peak `RssAnon` through load**, sampled, not the endpoint (required by I0).
- **Memory at realistic occupied context**, not 8 generated tokens (required to
re-open KV sizing).
### H4. Contention guard
Two sessions on the same cores wrecked several measurements and one grid's
control drifted 30% mid-run. A cooperative lock file only excludes cooperating
benchmarks, and load average > 8 identifies neither *which* cores are busy nor
whether the load is ours.
**Do**: lock file plus a pre-flight check on per-core utilization for the
*target* core set, recorded in the artifact.
### H5. Regression gates on measured bands
The draft proposed an 8% throughput band. That 8% is a configured constant in
the harness, not an estimated interval, and it sits against acknowledged ~30%
run-to-run variation — it would fire constantly or never.
**Do**: a checked-in expectations file whose bands come from the observed
distribution per metric (`RssAnon` ~0.2%, per-op self time ~5%, end-to-end
throughput wide enough to be meaningless, which is itself the argument for
gating on per-op time and memory instead).
### H6. A denser fidelity metric
Greedy divergence over 12 prompts saturates and cannot separate "slightly worse"
from "much worse". Top-1 agreement and KL against the bf16 reference over a few
thousand teacher-forced tokens, symmetric with `llama-perplexity
--kl-divergence` so llama.cpp stays comparable. Needs `llama-perplexity` built.
Bring this forward: I3 and I4 are fidelity experiments and cannot be judged on a
saturated metric.
### H7. Make profiling the default for small differences
Per-op self time resolves ~5%; throughput on this host ~30%. Have
`bench_harness.py` say so when an arm difference falls inside the noise band,
and point at `profile_cpu_step.py`.
### H8. Purge retracted numbers from executable source
Retractions are still being shipped as documentation:
`kv_budget.py:9` advertises the withdrawn **8329 → 5254 MB**;
`weight_path.py` reports W4A16 as **4.16 bits** (it is 4.25), sets
`fused_cpu_norms=True` in three profiles against the recorded negative result
and the `"0"` default, and calls tinygemm "the only option" (see I2).
---
## Order
1. **H1, H2, H8** — the suite and harness cannot currently rank builds, and two
modules still assert retracted numbers. Everything downstream depends on this.
2. **H3** — add the three measurements the inference items are defined in terms
of (start→ready, peak RSS, memory at context). Then **H4**, **H5**, **H6**.
3. **I0** (peak load memory) — the actual edge blocker.
4. **I7** (memory ledger), **I6** (imports) — now measurable.
5. **I2** (measure `cpu_gemm_wna16`) — may invalidate a kernel project; cheap.
6. **I1** (single packed layout) — largest confirmed memory win at unchanged
prefill, and the layout facts it needs are now measured.
7. **I8** (SIGILL reconciliation), then **I3** (asymmetric W4A8) and **I4**
(quantizer) on a fixed fidelity metric.
8. **I5** (attention wrappers) — bounded overhead experiment, revised target.
The draft ordered by "whose verification method already exists". The revision
orders by **whether the verification method is correct**, which is not the same
question and is the one this project keeps getting wrong.
---
## Progress
Started 2026-09-13, in the plan's own order. Items are struck through here only
when measured, not when coded.
**Step 1 -- H1, H2, H8: done.** Commits `6424c69` (oracles and measurement
semantics), `27b94007` (retracted numbers out of `vllm-omni` source), `28aa63c`,
`b713d6e`, `4285f92`. Agent tests 27 -> 38, harness tests 9 -> 34, plus 7 for
the new fidelity metric.
**Step 2 -- H3 to H7: done.** And it found a fifth measurement problem, of the
same kind as the first four:
> **The memory benchmark was reading the wrong process.** It took
> `/proc/self/status`, which is the whole story only when the model is
> in-process. That happens when `VLLM_ENABLE_V1_MULTIPROCESSING=0` keeps the
> executor at `uni`; `vllm/platforms/cpu.py:273` promotes it to `mp` otherwise.
> Every earlier run in this study set that variable, so **every published
> memory figure is an in-process figure**. At vLLM's defaults the weights load
> into a worker the benchmark never looked at: a smoke run reported 796 MB
> resident for a model whose weights alone are 1.4 GB. Memory is now summed
> over the process tree.
First numbers from the rebuilt benchmark (W4A16 g128, 24 threads, 2048 ctx,
`VLLM_CPU_KVCACHE_SPACE=1`, 2 interleaved passes):
| | in-process (`uni`) | default (`mp`) |
|---|---|---|
| peak RssAnon, tree | 5722-5837 MB | 6068-6078 MB |
| after arena release | ~4480 MB | ~5544 MB |
| processes | 1 | 4 |
| `ready_s` (process start to serving) | 78.5-111 s | 88-153 s |
| of which imports | ~29-30 s | ~30 s |
Three things follow. **The peak is 1.2-1.3 GB above the settled figure**, which
is I0's premise and is now measured rather than asserted. **vLLM's default
process layout costs ~240 MB of peak and ~1.1 GB of settled memory** over the
in-process one, which no published number reflected. And **`ready_s` is far
above `init_s`** -- imports are ~30 s, not the 19.6 s the plan quotes, so I6 is
worth more than it claimed and was previously unmeasurable.
These are not comparable to the report's 3411 MB: that row was taken
in-process, with a sized KV budget, generating 8 tokens. Which is the argument
for the provenance stamping added in the same step.
**Step 3 -- I0 (peak load memory): done.** The driver was in our own repack
path, not in vLLM: `unpack_nibbles` expands 4-bit codes to int32, and doing
that a whole matrix at a time makes the 131072-row tied embedding a **1024 MiB
transient**. Repacking in n-blocks (the layout makes them independent) is
byte-identical and cuts the load-time transient from 550 to 105 MB, **489 MB
off the peak**, with settled memory and init time unchanged -- chunked
repacking is in fact faster than whole-matrix (79 ms vs 208 ms for the
embedding). Chunk size tuned from a sweep rather than guessed.
**Step 5 -- I2 (measure `cpu_gemm_wna16`): done, and it did not invalidate
anything.** The fallback is real, so `weight_path.py`'s "only option" was
false, but at 32.3 tok/s it is **2.2x slower than tinygemm** (71.6) and 2.3x
slower than W4A8 (74.4), all on the same cores with only the env var
differing. The routing was right; it now cites a measurement. Caveat recorded:
`_get_isa_hint` returns `"amx"` whenever AMX is present, so this measured the
kernel's best path on this host, not the `"vec"` path an AMX-less machine takes.
**Step 6 -- I1 (single packed layout): done.** The layout
facts the item needed are now established on this host rather than assumed
(see I1 above), and the reconstruction is **bit-exact** against the existing
dequantizer at 2048x2048, 3072x2048 and 256x512 -- including `gate_up`'s real
13312x2048. `weight_halves` and the duplicated scale/zero are no longer
allocated where N tiles the 64-channel block, which every Spark linear does
(2048, 3072, 13312, 131072); shapes like N=80 keep the copy rather than
mis-read the layout. `VLLM_OMNI_CPU_INT4_DIRECT_DEQUANT=0` restores the old
path, which is how the two are being A/B'd with identical code.
*Process note, since this study keeps being bitten by measurement: I edited
`cpu_int4.py` while an unrelated grid was running, so that grid's later passes
used different code from its earlier ones. Those rows were discarded and the
comparison re-run under the escape hatch instead.*
---
## Review record
**Accepted and applied** (each verified in source before applying): the step
table conflated W4A8 and W4A16 (W4A16 is 12.51 ms, and the remainder is 1.60 ms
not ~1.9); bandwidth saturation of the GEMM does not make the rest overhead;
per-step savings were added when the measured total contradicts the sum; the
740 MB is a second *packed* copy plus duplicated scale/zero, not a bf16 matrix,
so the cheap fix is direct dequant rather than a tiled kernel; per-tile oneDNN
calls incur accumulator traffic the draft ignored; 4.25 ≠ 4.16 bits and Q4_K_M
is 4.5 bits with mixed types, so the fidelity comparison was never
budget-matched; the import figure is an upper bound and the init timer starts
after imports; the MiniCPM-o stage-count extrapolation is invalid and the item
does not serve edge deployability; peak load memory, not steady memory, is the
edge blocker; KV sizing was retired on an 8-token measurement; W4A8 supports
asymmetric zero points, so the report mistook our export choice for a kernel
limitation; `cpu_gemm_wna16` is a real non-AMX path and `weight_path.py`'s "only
option" is false; `smaps_rollup`/`tracemalloc` cannot deliver the attribution
I7 needs; `prefill_dequant: false` bypasses the documented SIGILL guard; the
harness does not rotate arm order, takes `max` as "best" for every metric, mixes
prefill into per-step timing and stamps no provenance; the alarm oracle accepts
a bare `"7"`, the toggle task scores final state only, and the scored suite is
simulator-only.
**Corrected with new measurements.** The review held that the tinygemm layout
requires implementing "the ISA-specific lane mapping and tail handling" and that
a K interval "is not a plain column slice", concluding a tiled decoder is
expensive to get right. The first half is understated and the second is true but
not the obstacle it implies. Measured here: a K-tile is contiguous *within each
n-block of 64* and reassembling those segments is byte-identical to packing the
K-slice (N=2048, K=2048, tile widths 128/256/512), split-K reconstructs the
single call at 51.5 dB, and the lane order inside each 32-byte run is a fixed
interleave (low nibble → channel *d*, high → *d+32*) that is constant across
every k and n-block. So decoding needs a deinterleave, not a reverse-engineered
table — which is what makes I1's direct-dequant fix small enough to be the first
thing tried. The review's conclusion (do direct dequant, not the tiled kernel)
is unchanged and adopted; its reasoning about why is superseded.
The review's accumulator-traffic objection assumes K-outer with a full M×N
accumulator, which a register-blocked GEMM would not pay — but since the draft
proposed calling a library per tile, the objection lands against the draft as
written, and I restructured rather than defended it.