Turns a branch of accumulated edge/CPU work into something someone else can
read, reproduce and continue.
README.md states what the fork is for (multimodal models on edge CPUs rather
than servers), the headline memory and speed results with their costs, and why
the numbers are worth reading -- five retracted conclusions, all measurement
errors rather than coding errors. Upstream's README is preserved at
docs/README.vllm-omni.md and NOTICE records the Apache-2.0 derivation and the
files this fork changes.
docs/edge/:
methodology.md the measurement discipline, which is the most transferable
thing here: what each of the five retractions was, and the
rule it left behind. Includes the two traps specific to this
stack -- vLLM's AOT compile cache keying on config so two
arms sharing an artifact load the wrong graph, and allocated
not being resident until mincore(2) says so.
results.md every measured figure with its spread and baseline, and an
explicit note that the four memory levers overlap and must
not be added.
cpu-4bit.md the two 4-bit paths, the packed layout as measured rather
than assumed, why the path choice is a config field, and the
GPTQ output-head trap that invalidated a whole round of W4A8
numbers.
harness.md how to run a comparison, and the two arguments that matter
more than they look (--context-len, --prompt-len), each of
which has produced a wrong conclusion by being left at its
default.
ROADMAP.md orders by whether the verification method is correct rather than by
expected size, lists what is done with results, what is next with expected
gains and how each would be verified, and -- at equal length -- what was
considered and rejected, including the CPU megakernel with the measurements
that rule it out for now.
benchmarks/edge_harness/ brings the tooling into the repo so the numbers are
reproducible: the grid runner, the single-run benchmarks, the memory ledger,
the fidelity metric, the Android agent suite, the quantizers, and the tests
that pin the harness's judgement. 82 tests pass from the new location.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L1ax5wYLA6XbqEL1i8L9gZ
124 lines
5.0 KiB
Python
124 lines
5.0 KiB
Python
"""Write a GPTQ-format W4A8 checkpoint for Spark-X2.5.
|
|
|
|
vLLM 0.28's CPU backend *does* have a 4-bit kernel -- it is just not reachable
|
|
under the names a quantization search turns up. `CPUWNA16LinearKernel`
|
|
(`vllm/model_executor/kernels/linear/mixed_precision/cpu.py`) dispatches GPTQ
|
|
and AWQ checkpoints to `int4_scaled_mm_cpu`, an AMX W4A8 GEMM, whenever the
|
|
activations are bf16, the ISA has AMX tiles and the checkpoint has no
|
|
activation reordering. Four-bit weights times eight-bit activations is
|
|
exactly the arithmetic llama.cpp's Q4_K_M does (Q4_K weights, Q8_K
|
|
activations), so this is the honest precision match.
|
|
|
|
The kernel pins the zero point at 8 regardless of what the checkpoint says
|
|
(`_process_gptq_weights_w4a8` builds its own), so the grid has to be
|
|
symmetric: w = (code - 8) * scale. `--search` sweeps the scale per group and
|
|
keeps the lowest squared error, which recovers most of what symmetry costs
|
|
against an asymmetric min/max grid.
|
|
|
|
Emitted per quantized linear, in the layout `AutoGPTQLinearMethod` creates:
|
|
|
|
<name>.qweight int32 [K // 8, N] 4-bit codes packed along the input dim
|
|
<name>.qzeros int32 [K // g, N // 8]
|
|
<name>.scales bf16 [K // g, N]
|
|
<name>.g_idx int32 [K]
|
|
|
|
Tied embeddings are untied here: the head is quantized (it is the single
|
|
largest read in a decode step) while the vocab table stays bf16, since an
|
|
embedding lookup reads one row and gains nothing from being 4-bit.
|
|
"""
|
|
import argparse, json, shutil
|
|
from pathlib import Path
|
|
|
|
from safetensors.torch import load_file, save_file
|
|
|
|
from vllm_omni.model_executor.layers.quantization.gptq_cpu_export import gptq_tensors
|
|
|
|
# Split the fused projection here rather than in the model: the GPTQ layout
|
|
# transposes N to dim 1, and the loader's stacked mapping already knows how to
|
|
# put q/k/v back together.
|
|
FUSED_QKV = "self_attn.q_k_v_proj.weight"
|
|
QUANT_SUFFIXES = (
|
|
"self_attn.out_proj.weight",
|
|
"mlp.gate_proj.weight",
|
|
"mlp.up_proj.weight",
|
|
"mlp.down_proj.weight",
|
|
)
|
|
EMBED_NAME = "model.embedding.weight"
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--src", required=True)
|
|
ap.add_argument("--dst", required=True)
|
|
ap.add_argument("--group", type=int, default=64)
|
|
ap.add_argument("--no-search", action="store_true")
|
|
ap.add_argument("--no-lm-head", action="store_true")
|
|
a = ap.parse_args()
|
|
src, dst = Path(a.src), Path(a.dst)
|
|
dst.mkdir(parents=True, exist_ok=True)
|
|
|
|
state = {}
|
|
for shard in sorted(src.glob("*.safetensors")):
|
|
state.update(load_file(str(shard)))
|
|
|
|
cfg = json.loads((src / "config.json").read_text())
|
|
q_dim = cfg["num_attention_heads"] * cfg["head_dim"]
|
|
kv_dim = cfg["num_key_value_heads"] * cfg["head_dim"]
|
|
|
|
def emit(out, base, w, report):
|
|
qw, qz, sc, gi, snr = gptq_tensors(w, a.group, not a.no_search)
|
|
out[f"{base}.qweight"] = qw
|
|
out[f"{base}.qzeros"] = qz
|
|
out[f"{base}.scales"] = sc
|
|
out[f"{base}.g_idx"] = gi
|
|
report.append({"name": base, "shape": list(w.shape), "snr_db": round(snr, 2)})
|
|
|
|
out, report = {}, []
|
|
for name, t in sorted(state.items()):
|
|
if name.endswith(FUSED_QKV):
|
|
base = name[: -len(".weight")].replace("q_k_v_proj", "")
|
|
for shard, part in zip(("q_proj", "k_proj", "v_proj"),
|
|
t.split([q_dim, kv_dim, kv_dim], dim=0)):
|
|
emit(out, base + shard, part, report)
|
|
elif name.endswith(QUANT_SUFFIXES):
|
|
emit(out, name[: -len(".weight")], t, report)
|
|
elif name == EMBED_NAME:
|
|
out[name] = t
|
|
if not a.no_lm_head:
|
|
emit(out, "lm_head", t, report)
|
|
else:
|
|
out[name] = t
|
|
|
|
save_file(out, str(dst / "model.safetensors"), metadata={"format": "pt"})
|
|
|
|
for f in ("tokenizer.json", "tokenizer_config.json", "special_tokens_map.json",
|
|
"vocab.json", "merges.txt", "generation_config.json",
|
|
"chat_template.jinja"):
|
|
if (src / f).exists():
|
|
shutil.copy(src / f, dst / f)
|
|
|
|
# The head is quantized separately from the table it used to be tied to,
|
|
# so the tie has to come off or vLLM would skip loading lm_head.*
|
|
cfg["tie_word_embeddings"] = a.no_lm_head
|
|
cfg["quantization_config"] = {
|
|
"quant_method": "gptq", "bits": 4, "group_size": a.group,
|
|
"desc_act": False, "sym": True, "lm_head": not a.no_lm_head,
|
|
"checkpoint_format": "gptq",
|
|
}
|
|
(dst / "config.json").write_text(json.dumps(cfg, indent=2))
|
|
|
|
snrs = [r["snr_db"] for r in report]
|
|
summary = {
|
|
"group": a.group, "search": not a.no_search,
|
|
"tensors_quantized": len(report),
|
|
"snr_db_min": round(min(snrs), 2), "snr_db_max": round(max(snrs), 2),
|
|
"snr_db_mean": round(sum(snrs) / len(snrs), 2),
|
|
"bits_per_weight": round(4 + 16 / a.group + 4 / a.group, 3),
|
|
"per_tensor": report,
|
|
}
|
|
(dst / "quantization_report.json").write_text(json.dumps(summary, indent=2))
|
|
print(json.dumps({k: v for k, v in summary.items() if k != "per_tensor"}, indent=2))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|