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
31 KiB
Optimization plan: inference and harness
Written 2026-09-12 against 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 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) — 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):
| 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). 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).
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),
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).
Our exporter chooses "sym": true
(quantize_gptq_int4.py:102).
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):
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), 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) — 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=ACTIVEplusGOMP_SPINCOUNT=infinitemoved 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:
- 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.
- 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. - 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_mul2.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()returnsTruein 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_eosbenchmark 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_alarmpasses if any typed string contains"7"(tasks.py:101) — not 7:30, not AM, not that an alarm was saved. Typing7anywhere 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).
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 iteratesfor pass: for arm in arms, i.e. the same order every pass (bench_harness.py:160). It interleaves but does not rotate, so position bias survives.summarizereportsbest = max(values)for every metric (bench_harness.py:123) — wrong for memory and init, where lower is better. Published "best" memory figures are the worst pass.profile_cpu_step.pywraps all ofgenerate(prefill + sampling) and divides bytg, 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
RssAnonthrough 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
- H1, H2, H8 — the suite and harness cannot currently rank builds, and two modules still assert retracted numbers. Everything downstream depends on this.
- H3 — add the three measurements the inference items are defined in terms of (start→ready, peak RSS, memory at context). Then H4, H5, H6.
- I0 (peak load memory) — the actual edge blocker.
- I7 (memory ledger), I6 (imports) — now measurable.
- I2 (measure
cpu_gemm_wna16) — may invalidate a kernel project; cheap. - I1 (single packed layout) — largest confirmed memory win at unchanged prefill, and the layout facts it needs are now measured.
- I8 (SIGILL reconciliation), then I3 (asymmetric W4A8) and I4 (quantizer) on a fixed fidelity metric.
- 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 whenVLLM_ENABLE_V1_MULTIPROCESSING=0keeps the executor atuni;vllm/platforms/cpu.py:273promotes it tompotherwise. 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.