File size: 9,858 Bytes
439d3c0 fbb1758 439d3c0 fbb1758 439d3c0 fbb1758 439d3c0 fbb1758 439d3c0 fbb1758 439d3c0 fbb1758 439d3c0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 | #!/usr/bin/env python3
"""Convert a Qwen2.5-0.5B-architecture model (e.g. the merged Devseis auditor) to WebLLM/MLC weights, q4f16_1 or q4f32_1.
Why this exists: MLC's own converter could not run on this Mac (the Intel-Mac nightly mlc_llm and TVM builds do not
match, and the stable Linux release needs an apache-tvm-ffi build that was never published). This script writes the
same format MLC's group quantization produces (4-bit symmetric, groups of 32 along the input dimension, 8 values per
uint32, one scale per group), reusing the tensor layout of MLC's published Qwen2.5-0.5B-Instruct build so WebLLM's
prebuilt runtime library for that model can run it.
It is verified, not assumed: `--verify` converts the original Qwen2.5-0.5B-Instruct and compares it with
mlc-ai/Qwen2.5-0.5B-Instruct-<quant>-MLC: identical layout, bit-identical scales, 99.6% identical 4-bit values (the rest
differ by one step at exact rounding ties, most likely because mlc-ai quantized slightly different source weights).
Usage:
training/.venv/bin/python training/mlc_quantize.py --verify --quant q4f16_1
training/.venv/bin/python training/mlc_quantize.py --model training/models/devseis-auditor-0.5b-v0.1 --quant q4f16_1 \\
--out training/models/mlc/devseis-auditor-0.5b-v0.1-q4f16_1-MLC
"""
import argparse
import hashlib
import json
import os
import shutil
import numpy as np
import torch
from safetensors.torch import load_file
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MODELS = os.path.join(ROOT, "training", "models")
GROUP = 32
MAX_INT = 7 # 4-bit symmetric: values 0..14 stored, 7 = zero
COPY = ["mlc-chat-config.json", "tokenizer.json", "tokenizer_config.json", "vocab.json", "merges.txt"]
def load_hf(model_dir):
weights = load_file(os.path.join(model_dir, "model.safetensors"))
return {k: v.to(torch.float32).numpy() for k, v in weights.items()}
def quantize(w, dtype, mode=None):
"""Group quantization along the last axis. Returns (q_weight uint32 [out, in/8], q_scale [out, in/32]).
Matches MLC: scale = max|w| * (1/7) in the model dtype (bit-exact against mlc-ai's build), values = round(w / scale)
computed in float32 (99.6% identical to mlc-ai's build; the rest differ by one step at exact rounding ties)."""
w = w.astype(dtype)
out, inp = w.shape
g = w.reshape(out, inp // GROUP, GROUP)
scale = (np.abs(g).max(axis=-1, keepdims=True) * dtype(1 / MAX_INT)).astype(dtype)
with np.errstate(divide="ignore", invalid="ignore"):
x = np.where(scale == 0, 0, g.astype(np.float32) / scale.astype(np.float32))
x = np.sign(x) * np.floor(np.abs(x) + 0.5) # round half away from zero
q = np.clip(x + MAX_INT, 0, 2 * MAX_INT).astype(np.uint32).reshape(out, inp // 8, 8)
packed = np.zeros((out, inp // 8), dtype=np.uint32)
for k in range(8):
packed |= q[..., k] << np.uint32(4 * k)
return packed, scale.reshape(out, inp // GROUP).astype(dtype)
def build(hf, dtype, mode=None, quantized=True):
"""Tensors named as in MLC's Qwen2 model (q0f16: unquantized float16, same fused names)."""
t = {}
def linear(name, w):
if quantized:
t[name + ".q_weight"], t[name + ".q_scale"] = quantize(w, dtype, mode)
else:
t[name + ".weight"] = w.astype(dtype)
linear("model.embed_tokens", hf["model.embed_tokens.weight"])
t["model.norm.weight"] = hf["model.norm.weight"].astype(dtype)
layer = 0
while f"model.layers.{layer}.input_layernorm.weight" in hf:
p = f"model.layers.{layer}."
t[p + "input_layernorm.weight"] = hf[p + "input_layernorm.weight"].astype(dtype)
t[p + "post_attention_layernorm.weight"] = hf[p + "post_attention_layernorm.weight"].astype(dtype)
qkv = np.concatenate([hf[p + f"self_attn.{x}_proj.weight"] for x in "qkv"], axis=0)
linear(p + "self_attn.c_attn", qkv)
t[p + "self_attn.c_attn.bias"] = np.concatenate([hf[p + f"self_attn.{x}_proj.bias"] for x in "qkv"]).astype(dtype)
linear(p + "self_attn.o_proj", hf[p + "self_attn.o_proj.weight"])
gate_up = np.concatenate([hf[p + "mlp.gate_proj.weight"], hf[p + "mlp.up_proj.weight"]], axis=0)
linear(p + "mlp.gate_up_proj", gate_up)
linear(p + "mlp.down_proj", hf[p + "mlp.down_proj.weight"])
layer += 1
return t
def stored(rec):
"""MLC stores float32 tensors as bfloat16 when the record's format is f32-to-bf16 (WebLLM expands them on load)."""
return rec["dtype"] == "float32" and rec.get("format") == "f32-to-bf16"
def to_bytes(arr, rec):
if stored(rec):
return torch.from_numpy(np.ascontiguousarray(arr, dtype=np.float32)).to(torch.bfloat16).view(torch.int16).numpy().tobytes()
return np.ascontiguousarray(arr).tobytes()
def from_bytes(data, rec):
if stored(rec):
return (np.frombuffer(data, dtype=np.uint16).astype(np.uint32) << np.uint32(16)).view(np.float32).reshape(rec["shape"])
return np.frombuffer(data, dtype=rec["dtype"]).reshape(rec["shape"])
def records(ref_dir):
cache = json.load(open(os.path.join(ref_dir, "tensor-cache.json")))
return cache, [(shard, rec) for shard in cache["records"] for rec in shard["records"]]
def check_layout(tensors, recs):
for _, rec in recs:
arr = tensors[rec["name"]]
assert list(arr.shape) == rec["shape"], (rec["name"], arr.shape, rec["shape"])
assert str(arr.dtype) == rec["dtype"], (rec["name"], arr.dtype, rec["dtype"])
assert (arr.nbytes // 2 if stored(rec) else arr.nbytes) == rec["nbytes"], rec["name"]
assert len(tensors) == len(recs), (len(tensors), len(recs))
def write(tensors, ref_dir, out_dir):
cache, recs = records(ref_dir)
check_layout(tensors, recs)
os.makedirs(out_dir, exist_ok=True)
for shard in cache["records"]:
data = bytearray(shard["nbytes"])
for rec in shard["records"]:
data[rec["byteOffset"]:rec["byteOffset"] + rec["nbytes"]] = to_bytes(tensors[rec["name"]], rec)
with open(os.path.join(out_dir, shard["dataPath"]), "wb") as handle:
handle.write(data)
shard["md5sum"] = hashlib.md5(data).hexdigest()
for name in ("tensor-cache.json", "ndarray-cache.json"):
with open(os.path.join(out_dir, name), "w") as handle:
json.dump(cache, handle)
for name in COPY:
shutil.copy(os.path.join(ref_dir, name), out_dir)
def verify(quant, ref_root=None, base=None):
"""Convert the base model and compare with mlc-ai's build: layout must match exactly, scales bit-exactly, values >= 99%."""
ref = os.path.join(ref_root or os.path.join(MODELS, "mlc-ref"), quant)
dtype = np.float32 if quant == "q4f32_1" else np.float16
_, recs = records(ref)
tensors = build(load_hf(base or os.path.join(MODELS, "Qwen2.5-0.5B-Instruct")), dtype, quantized=not quant.startswith("q0"))
check_layout(tensors, recs)
if quant.startswith("q0"):
present = [(s, r) for s, r in recs if os.path.exists(os.path.join(ref, s["dataPath"]))]
same = sum(open(os.path.join(ref, s["dataPath"]), "rb").read()[r["byteOffset"]:r["byteOffset"] + r["nbytes"]] == to_bytes(tensors[r["name"]], r) for s, r in present)
print(f"{quant}: layout identical ({len(recs)} tensors); {same}/{len(present)} tensors in the downloaded shards byte-identical")
return same == len(present) and len(present) > 0
shards, scale_same, scale_total, val_same, val_total, exact = {}, 0, 0, 0, 0, 0
for shard, rec in recs:
if shard["dataPath"] not in shards:
shards[shard["dataPath"]] = open(os.path.join(ref, shard["dataPath"]), "rb").read()
want = from_bytes(shards[shard["dataPath"]][rec["byteOffset"]:rec["byteOffset"] + rec["nbytes"]], rec)
got = from_bytes(to_bytes(tensors[rec["name"]], rec), rec)
exact += bool(np.array_equal(got, want))
if rec["name"].endswith("q_scale"):
scale_same += int((got == want).sum()); scale_total += got.size
elif rec["name"].endswith("q_weight"):
a = np.stack([(got >> np.uint32(4 * k)) & np.uint32(15) for k in range(8)], -1)
b = np.stack([(want >> np.uint32(4 * k)) & np.uint32(15) for k in range(8)], -1)
val_same += int((a == b).sum()); val_total += a.size
assert int(np.abs(a.astype(np.int32) - b.astype(np.int32)).max()) <= 1, rec["name"]
print(f"{quant}: layout identical ({len(recs)} tensors, {exact} byte-identical); scales {scale_same / scale_total:.4%} identical; "
f"4-bit values {val_same / val_total:.4%} identical, all others within one step")
return scale_same == scale_total and val_same / val_total >= 0.99
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--quant", choices=["q4f16_1", "q4f32_1", "q0f16"], required=True)
parser.add_argument("--verify", action="store_true", help="compare a conversion of the base model with mlc-ai's build")
parser.add_argument("--model", help="merged model folder (model.safetensors)")
parser.add_argument("--out", help="output folder")
parser.add_argument("--ref", help="folder with mlc-ai reference builds per quantization (default training/models/mlc-ref, 0.5B)")
parser.add_argument("--base", help="--verify: original base model folder matching --ref")
args = parser.parse_args()
if args.verify:
raise SystemExit(0 if verify(args.quant, args.ref, args.base) else 1)
dtype = np.float32 if args.quant == "q4f32_1" else np.float16
tensors = build(load_hf(args.model), dtype, quantized=not args.quant.startswith("q0"))
write(tensors, os.path.join(args.ref or os.path.join(MODELS, "mlc-ref"), args.quant), args.out)
print(f"wrote {args.out}")
if __name__ == "__main__":
main()
|