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()