Renamed from vllm-embedding: nothing here is about embeddings, and the name had come from the parent working directory. edge-infer says what it does. The GitHub repository is renamed to match; nothing had been pushed yet. The audit found the packaging was incomplete in two ways. Scripts: 56 of them produced results that are published in this repo but were not included, so the numbers could be read and not checked. Added under benchmarks/edge_harness/experiments/ (one-off drivers: the AI Hub on-device runs, the 4-bit GEMM microbenchmark, fidelity and parity checks, checkpoint build and round-trip, activation capture, and the load generator used to prove the contention guard actually refuses) and benchmarks/edge_harness/runs/ (the 24 shell drivers behind published grids). Both directories carry a README saying plainly that they are a record of what was run rather than a supported interface -- most hardcode absolute paths from the machine they ran on. The supported entry points remain the tools one level up. One broken relative link fixed (comparison.md is under docs/engines/, not docs/analysis/). All other links and every script reference in the docs now resolve inside the repo. Secret scan: the token in ~/.qai_hub/client.ini is absent from the worktree and from all 61 commits. Worth noting that an earlier version of my own check reported a false positive, because `grep -rl ... | head` takes its exit status from head, which always succeeds -- the same class of mistake as the measurement errors this project keeps recording. The AI Hub scripts authenticate by reading that file at runtime and contain no credential. README reframed for the new name and the wider scope: the repository is the engine changes, the harness, the raw results, the baselines and the analyses, not just a patch set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L1ax5wYLA6XbqEL1i8L9gZ
191 lines
8.3 KiB
Python
191 lines
8.3 KiB
Python
"""Write Spark-X2.5 as a symmetric 4-bit GPTQ checkpoint.
|
|
|
|
vLLM 0.28's CPU backend already has a 4-bit dense GEMM -- `int4_scaled_mm_cpu`,
|
|
an AMX W4A8 kernel, plus `cpu_gemm_wna16` as the W4A16 fallback -- reached by
|
|
loading a GPTQ checkpoint. It is not named like the ops an earlier search
|
|
looked for (`woq_int4_linear`, `int4_scaled_mm_with_quant`, `da8w4_linear`),
|
|
which is how it was missed. Measured at Spark's shapes it beats the tinygemm
|
|
kernel 1.7x at one row and 8.4x at 512, so this is the path that matters.
|
|
|
|
`sym=True` selects vLLM's `uint4b8` weight type, whose dequantization is
|
|
|
|
w = (code - 8) * scale, code in [0, 15]
|
|
|
|
and whose zero points are ignored. Getting that convention wrong does not
|
|
fail loudly -- the checkpoint loads and generates, just badly -- so
|
|
`--verify` re-reads what was written and reports the reconstruction SNR.
|
|
"""
|
|
import argparse, json, shutil
|
|
from pathlib import Path
|
|
|
|
import torch
|
|
from safetensors.torch import load_file, save_file
|
|
|
|
QUANT_SUFFIXES = (
|
|
"self_attn.q_proj.weight",
|
|
"self_attn.k_proj.weight",
|
|
"self_attn.v_proj.weight",
|
|
"self_attn.out_proj.weight",
|
|
"mlp.gate_proj.weight",
|
|
"mlp.up_proj.weight",
|
|
"mlp.down_proj.weight",
|
|
)
|
|
EMBED_NAME = "model.embedding.weight"
|
|
|
|
|
|
def pack_int32(codes: torch.Tensor, dim: int) -> torch.Tensor:
|
|
"""Pack 8 nibbles per int32 along `dim`, low-order nibble first."""
|
|
codes = codes.to(torch.int32)
|
|
if dim == 0:
|
|
k, n = codes.shape
|
|
out = torch.zeros(k // 8, n, dtype=torch.int32)
|
|
for i in range(8):
|
|
out |= codes[i::8, :] << (4 * i)
|
|
else:
|
|
k, n = codes.shape
|
|
out = torch.zeros(k, n // 8, dtype=torch.int32)
|
|
for i in range(8):
|
|
out |= codes[:, i::8] << (4 * i)
|
|
return out.contiguous()
|
|
|
|
|
|
def quantize_asym(w: torch.Tensor, group: int):
|
|
"""[N, K] -> GPTQ tensors with a real per-group zero point.
|
|
|
|
`w = (code - zp) * scale` over the group's true min/max, which does not
|
|
waste a level the way a symmetric grid does on an off-centre group. The
|
|
CPU W4A8 kernel's stand-in zero point for symmetric checkpoints is
|
|
0x88888888 -- every nibble 8, used directly -- so `qzeros` here holds the
|
|
zero point itself, with no AutoGPTQ-style -1.
|
|
"""
|
|
n, k = w.shape
|
|
wt = w.float().t().contiguous()
|
|
g = k // group
|
|
blocks = wt.reshape(g, group, n)
|
|
wmax, wmin = blocks.amax(dim=1), blocks.amin(dim=1)
|
|
scale = ((wmax - wmin).clamp(min=1e-9) / 15.0)
|
|
zp = (-wmin / scale).round().clamp_(0, 15)
|
|
codes = ((blocks / scale.unsqueeze(1)).round() + zp.unsqueeze(1)).clamp_(0, 15)
|
|
deq = ((codes - zp.unsqueeze(1)) * scale.unsqueeze(1)).reshape(k, n)
|
|
codes = codes.reshape(k, n)
|
|
snr = float(20 * torch.log10(wt.norm() / (wt - deq).norm().clamp(min=1e-12)))
|
|
qweight = pack_int32(codes, dim=0)
|
|
qzeros = pack_int32(zp.to(torch.int32), dim=1)
|
|
scales = scale.to(torch.float16)
|
|
g_idx = torch.arange(k, dtype=torch.int32) // group
|
|
return qweight, qzeros, scales, g_idx, snr
|
|
|
|
|
|
def quantize_sym(w: torch.Tensor, group: int):
|
|
"""[N, K] -> GPTQ tensors. Symmetric about code 8, group-wise along K."""
|
|
n, k = w.shape
|
|
wt = w.float().t().contiguous() # GPTQ stores [K, N]
|
|
g = k // group
|
|
blocks = wt.reshape(g, group, n)
|
|
# code 8 is zero and codes run 0..15, so the representable range is
|
|
# [-8, 7] * scale; size it off the negative side to keep it symmetric.
|
|
amax = blocks.abs().amax(dim=1) # [g, n]
|
|
scale = (amax / 8.0).clamp(min=1e-9)
|
|
codes = (blocks / scale.unsqueeze(1)).round().add_(8).clamp_(0, 15)
|
|
codes = codes.reshape(k, n)
|
|
deq = ((codes.reshape(g, group, n) - 8.0) * scale.unsqueeze(1)).reshape(k, n)
|
|
snr = float(20 * torch.log10(wt.norm() / (wt - deq).norm().clamp(min=1e-12)))
|
|
qweight = pack_int32(codes, dim=0) # [K/8, N]
|
|
# sym GPTQ still carries qzeros; the classic convention stores zp - 1 = 7.
|
|
qzeros = pack_int32(torch.full((g, n), 7, dtype=torch.int32), dim=1) # [g, N/8]
|
|
scales = scale.to(torch.float16) # [g, N]
|
|
g_idx = torch.arange(k, dtype=torch.int32) // group
|
|
return qweight, qzeros, scales, g_idx, snr
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--src", required=True)
|
|
ap.add_argument("--dst", required=True)
|
|
ap.add_argument("--group", type=int, default=128)
|
|
ap.add_argument("--quantize-embedding", action="store_true")
|
|
ap.add_argument("--asym", action="store_true",
|
|
help="per-group zero points instead of a symmetric grid")
|
|
ap.add_argument("--quantize-lm-head", action="store_true",
|
|
help="untie the head and quantize it; the lm_head GEMM is "
|
|
"the single largest read in a decode step")
|
|
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)))
|
|
|
|
# Split the fused q_k_v_proj here rather than at load time: the model's
|
|
# splitter cuts dim 0, and GPTQ tensors are stored [K, N], so the output
|
|
# dim moves. Splitting along output columns is exactly equivalent for a
|
|
# grouping that runs along K, so do it before quantizing and let vLLM's
|
|
# ordinary stacked-parameter mapping take over.
|
|
cfg_src = json.loads((src / "config.json").read_text())
|
|
q_dim = cfg_src["num_attention_heads"] * cfg_src["head_dim"]
|
|
kv_dim = cfg_src["num_key_value_heads"] * cfg_src["head_dim"]
|
|
for name in [k for k in state if ".self_attn.q_k_v_proj." in k]:
|
|
q, k, v = state.pop(name).split([q_dim, kv_dim, kv_dim], dim=0)
|
|
for shard_name, tensor in (("q_proj", q), ("k_proj", k), ("v_proj", v)):
|
|
state[name.replace("q_k_v_proj", shard_name)] = tensor.contiguous()
|
|
|
|
quantize = quantize_asym if a.asym else quantize_sym
|
|
out, report = {}, []
|
|
for name, t in sorted(state.items()):
|
|
is_embed = name == EMBED_NAME and a.quantize_embedding
|
|
if name.endswith(QUANT_SUFFIXES) or is_embed:
|
|
qw, qz, sc, gi, snr = quantize(t, a.group)
|
|
base = name[: -len(".weight")]
|
|
out[f"{base}.qweight"] = qw
|
|
out[f"{base}.qzeros"] = qz
|
|
out[f"{base}.scales"] = sc
|
|
out[f"{base}.g_idx"] = gi
|
|
report.append({"name": name, "snr_db": round(snr, 2)})
|
|
else:
|
|
out[name] = t
|
|
|
|
if a.quantize_lm_head:
|
|
# Spark ties the head to the vocab table. Quantizing it means untying:
|
|
# the table stays bf16 for the lookup (a few hundred bytes a token)
|
|
# and the head gets its own 4-bit copy, which is what a decode step
|
|
# actually streams -- 131072 x 2048, the largest read in the step.
|
|
qw, qz, sc, gi, snr = quantize(state[EMBED_NAME], a.group)
|
|
out["lm_head.qweight"] = qw
|
|
out["lm_head.qzeros"] = qz
|
|
out["lm_head.scales"] = sc
|
|
out["lm_head.g_idx"] = gi
|
|
report.append({"name": "lm_head", "snr_db": round(snr, 2)})
|
|
|
|
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)
|
|
|
|
cfg = json.loads((src / "config.json").read_text())
|
|
cfg["quantization_config"] = {
|
|
"quant_method": "gptq", "bits": 4, "group_size": a.group,
|
|
"desc_act": False, "sym": not a.asym, "true_sequential": True,
|
|
"checkpoint_format": "gptq",
|
|
"lm_head": bool(a.quantize_lm_head),
|
|
}
|
|
if a.quantize_lm_head:
|
|
cfg["tie_word_embeddings"] = False
|
|
(dst / "config.json").write_text(json.dumps(cfg, indent=2))
|
|
|
|
snrs = [r["snr_db"] for r in report]
|
|
summary = {"group": a.group, "tensors": len(report),
|
|
"snr_db_mean": round(sum(snrs) / len(snrs), 2),
|
|
"snr_db_min": round(min(snrs), 2),
|
|
"bits_per_weight": 4 + (16 + 4) / a.group,
|
|
"quantized_embedding": a.quantize_embedding,
|
|
"quantized_lm_head": a.quantize_lm_head}
|
|
(dst / "quantization_report.json").write_text(
|
|
json.dumps({**summary, "per_tensor": report}, indent=2))
|
|
print(json.dumps(summary, indent=2))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|