138 lines
5.1 KiB
Python
138 lines
5.1 KiB
Python
"""Write a `cpu_int4` W4A16 checkpoint for Spark-X2.5.
|
|||
|
|
|
||
|
|
Group-wise 4-bit weights at the same bits-per-weight budget as llama.cpp's
|
||
|
|
Q4_K_M (4.5 at group 64), so the CPU decode comparison is precision-matched
|
||
|
|
rather than a quantization contest.
|
||
|
|
|
||
|
|
Round-to-nearest over the group's min/max is the obvious grid, but it is not
|
||
|
|
the best one: an outlier stretches the range and costs every other weight in
|
||
|
|
the group. `--search` sweeps a shrink factor per group and keeps the one with
|
||
|
|
the lowest squared error, which is the same idea as llama.cpp's
|
||
|
|
`make_qkx2_quants` and buys about a dB.
|
||
|
|
|
||
|
|
The head-wise gate (g_proj) stays in full precision: eight outputs that each
|
||
|
|
scale a whole attention head, 0.001% of the weights.
|
||
|
|
"""
|
||
|
|
import argparse, json, shutil
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import torch
|
||
|
|
from safetensors.torch import load_file, save_file
|
||
|
|
|
||
|
|
QUANT_SUFFIXES = (
|
||
|
|
"self_attn.q_k_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"
|
||
|
|
SHRINKS = (1.0, 0.98, 0.96, 0.94, 0.92, 0.90, 0.88, 0.85)
|
||
|
|
|
||
|
|
|
||
|
|
def _pack(q: torch.Tensor) -> torch.Tensor:
|
||
|
|
q = q.to(torch.uint8)
|
||
|
|
return (q[:, 0::2] | (q[:, 1::2] << 4)).contiguous()
|
||
|
|
|
||
|
|
|
||
|
|
def quantize(w: torch.Tensor, group: int, search: bool):
|
||
|
|
n, k = w.shape
|
||
|
|
wf = w.float().reshape(n, k // group, group)
|
||
|
|
wmin, wmax = wf.amin(dim=-1), wf.amax(dim=-1)
|
||
|
|
span = (wmax - wmin).clamp(min=1e-9)
|
||
|
|
|
||
|
|
best_err = None
|
||
|
|
best = None
|
||
|
|
for f in (SHRINKS if search else (1.0,)):
|
||
|
|
# Shrink the grid symmetrically about the group mid-point: the
|
||
|
|
# extremes lose a little, everything between them gains.
|
||
|
|
scale = span * f / 15.0
|
||
|
|
lo = wmin + span * (1.0 - f) * 0.5
|
||
|
|
q = ((wf - lo.unsqueeze(-1)) / scale.unsqueeze(-1)).round().clamp_(0, 15)
|
||
|
|
err = ((q * scale.unsqueeze(-1) + lo.unsqueeze(-1)) - wf).pow_(2).sum(-1)
|
||
|
|
if best_err is None:
|
||
|
|
best_err, best = err, (q, scale, lo)
|
||
|
|
else:
|
||
|
|
take = err < best_err
|
||
|
|
best_err = torch.where(take, err, best_err)
|
||
|
|
t = take.unsqueeze(-1)
|
||
|
|
best = (
|
||
|
|
torch.where(t, q, best[0]),
|
||
|
|
torch.where(take, scale, best[1]),
|
||
|
|
torch.where(take, lo, best[2]),
|
||
|
|
)
|
||
|
|
q, scale, lo = best
|
||
|
|
zero = lo + 8.0 * scale
|
||
|
|
deq = (q - 8.0) * scale.unsqueeze(-1) + zero.unsqueeze(-1)
|
||
|
|
ref = wf
|
||
|
|
snr = float(20 * torch.log10(ref.norm() / (ref - deq).norm().clamp(min=1e-12)))
|
||
|
|
return _pack(q.reshape(n, k)), scale.to(torch.bfloat16), zero.to(torch.bfloat16), 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=64)
|
||
|
|
ap.add_argument("--no-search", action="store_true")
|
||
|
|
ap.add_argument("--keep-embedding", action="store_true",
|
||
|
|
help="leave the tied vocab table in bf16")
|
||
|
|
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)))
|
||
|
|
|
||
|
|
targets = list(QUANT_SUFFIXES)
|
||
|
|
out, report = {}, []
|
||
|
|
for name, t in sorted(state.items()):
|
||
|
|
is_embed = name == EMBED_NAME and not a.keep_embedding
|
||
|
|
if name.endswith(tuple(targets)) or is_embed:
|
||
|
|
packed, scale, zero, snr = quantize(t, a.group, not a.no_search)
|
||
|
|
base = name[: -len(".weight")]
|
||
|
|
out[f"{base}.weight_packed"] = packed
|
||
|
|
out[f"{base}.weight_scale"] = scale
|
||
|
|
out[f"{base}.weight_zero"] = zero
|
||
|
|
report.append({"name": name, "shape": list(t.shape), "snr_db": round(snr, 2)})
|
||
|
|
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)
|
||
|
|
|
||
|
|
cfg = json.loads((src / "config.json").read_text())
|
||
|
|
cfg["quantization_config"] = {
|
||
|
|
"quant_method": "cpu_int4",
|
||
|
|
"bits": 4,
|
||
|
|
"group_size": a.group,
|
||
|
|
"quantize_embedding": not a.keep_embedding,
|
||
|
|
"prefill_dequant": True,
|
||
|
|
# Only single-row steps may take the tinygemm kernel; see the AMX
|
||
|
|
# tile-state note in vllm_omni/.../cpu_int4.py.
|
||
|
|
"dequant_threshold": 2,
|
||
|
|
"ignore": [],
|
||
|
|
}
|
||
|
|
(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": 4 + 2 * 16 / a.group,
|
||
|
|
"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()
|