File size: 13,931 Bytes
e5235fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
#!/usr/bin/env python3
"""T2.7 · GGUF export check: packed ternary students → llama.cpp Q2_0 (D-51: N-bit students → Q2_0 / Q4_0 /
Q3_K), round trip and greedy agreement.

    # template: llama.cpp's own conversion of the base model (metadata, tensor names and order)
    python scripts/t27_gguf.py build --template T.gguf --out S.gguf --packed <packed_dir>        # a student
    python scripts/t27_gguf.py build --template T.gguf --out S.gguf --rtn-dense <hf_dir>          # RTN Qwen3-0.6B
    python scripts/t27_gguf.py compare --gguf S.gguf (--packed <dir> | --rtn-dense <hf_dir>) --out R.json

`compare` runs both models greedy on the same prompt token ids (200 held-out prompts, chat template, ≤512 prompt
tokens, 64 new tokens, end-of-sequence suppressed in both) and reports position-wise token agreement, the first
divergence per prompt, and llama.cpp's own timings. PyTorch is the reference: the packed student dequantised to
bf16 (`load_packed_student`), on CPU. llama.cpp runs CPU-only (`llama-server`, GPU hidden: gen's GPU is busy).
"""
from __future__ import annotations

import argparse
import json
import os
import random
import subprocess
import sys
import time
import urllib.request
from pathlib import Path

import torch

REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO / "src"))
from lowbit.export import gguf as gx  # noqa: E402
from lowbit.quant.ternary import QuantSpec, dequant_packed, export_packed  # noqa: E402

LLAMA = Path(os.environ.get("MT_LLAMA", "/path/to/llama.cpp"))  # gguf-py, the converter (spark: third_party)
# the pegfix build: mainline's chat-output parser returns HTTP 500 on some outputs (invalid UTF-8 from a degraded
# model), which the fork's UTF-8 fix removes; same source base, so the same Q2_0 kernels
SERVER_LIBS = "/path/to/llama.cpp-fork/build-pegfix/bin"
SERVER = Path(SERVER_LIBS) / "llama-server"
HELDOUT = Path("/path/to/data/prompts/v2/heldout.jsonl")


def rtn_dense(hf_dir: str):
    """A dense model with every MLP projection ternarised by round-to-nearest (Q-A, g128): (dense, packed, model)."""
    from transformers import AutoModelForCausalLM
    model = AutoModelForCausalLM.from_pretrained(hf_dir, dtype=torch.bfloat16)
    dense, packed = {}, {}
    for n, p in model.named_parameters():
        if any(n.endswith(f"mlp.{k}_proj.weight") for k in ("gate", "up", "down")):
            pk = export_packed(p.detach().float(), QuantSpec(method="absmean", group_size=128))
            packed[n[: -len(".weight")]] = pk
            with torch.no_grad():
                p.copy_(dequant_packed(pk))  # the PyTorch reference holds exactly the exported weights
        else:
            dense[n] = p.detach()
    return dense, packed, model


def packed_student(packed_dir: str):
    """(plain tensors, packed experts); D-51's N-bit students carry their method per tensor (`read_packed`)."""
    from lowbit.model.student import read_packed
    sd, packed, _ = read_packed(packed_dir)
    return sd, packed


def build(a) -> None:
    g = gx.gguf_module(LLAMA)
    if a.packed:
        dense, packed = packed_student(a.packed)
    else:
        dense, packed, _ = rtn_dense(a.rtn_dense)
    names = {t.name for t in g.GGUFReader(a.template).tensors}
    if "output.weight" in names and "lm_head.weight" not in dense:  # tied: the converter still writes output
        dense["lm_head.weight"] = dense["model.embed_tokens.weight"]
    dense = {k: v for k, v in dense.items() if gx.gguf_name(k) in names}  # a tied lm_head absent from the GGUF
    t0 = time.time()
    meta = {}
    if a.name:  # a release: its own name, and the teacher recorded as its base model
        meta = {"general.name": a.name, "general.finetune": "QAT", "general.base_model.count": 1,
                "general.base_model.0.name": "Marco Nano Instruct", "general.base_model.0.organization": "AIDC-AI",
                "general.base_model.0.repo_url": "https://huggingface.co/AIDC-AI/Marco-Nano-Instruct"}
    info = gx.write_gguf(a.template, a.out, gx.student_tensors(dense, packed, g, a.ternary_as, a.dense_as), g, meta)
    info["write_s"] = round(time.time() - t0, 1)
    if a.ternary_as == "q2_0":
        info.update(gx.check_round_trip(a.out, packed, g))
    print(json.dumps(info))


def prompts(tok, n: int, max_len: int = 512) -> list[list[int]]:
    rows = [json.loads(l) for l in open(HELDOUT)]
    random.Random(0).shuffle(rows)
    out = []
    for r in rows:
        text = tok.apply_chat_template([{"role": "user", "content": r["text"]}], add_generation_prompt=True,
                                       tokenize=False)
        ids = tok(text, add_special_tokens=False)["input_ids"]
        if len(ids) <= max_len:
            out.append(list(ids))
        if len(out) == n:
            return out
    return out


@torch.no_grad()
def torch_greedy(model, tok, ps: list[list[int]], new: int, batch: int) -> list[list[int]]:
    tok.padding_side = "left"
    pad = tok.pad_token_id if tok.pad_token_id is not None else tok.eos_token_id
    out = []
    for i in range(0, len(ps), batch):
        chunk = ps[i:i + batch]
        L = max(len(p) for p in chunk)
        ids = torch.tensor([[pad] * (L - len(p)) + p for p in chunk])
        att = torch.tensor([[0] * (L - len(p)) + [1] * len(p) for p in chunk])
        g = model.generate(input_ids=ids, attention_mask=att, max_new_tokens=new, min_new_tokens=new,
                           do_sample=False, pad_token_id=pad)
        out += [row[L:].tolist() for row in g]
        print(json.dumps({"torch_greedy": i + len(chunk), "of": len(ps)}), flush=True)
    return out


def start_server(gguf: str, threads: int, port: int) -> subprocess.Popen:
    env = {**os.environ, "CUDA_VISIBLE_DEVICES": "", "LD_LIBRARY_PATH": SERVER_LIBS}
    # --reasoning-format none: plain completions, no think-block parsing (a parser hang stalled one request)
    proc = subprocess.Popen([str(SERVER), "-m", gguf, "--port", str(port), "--host", "127.0.0.1", "-ngl", "0",
                             "-t", str(threads), "-c", "8192", "--parallel", "1", "--no-webui",
                             "--reasoning-format", "none"],
                            env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    for _ in range(600):
        try:
            urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=2)
            return proc
        except Exception:
            if proc.poll() is not None:
                raise RuntimeError(f"llama-server exited with {proc.returncode}")
            time.sleep(1)
    raise RuntimeError("llama-server did not come up")


def stop_server(proc: subprocess.Popen) -> None:
    proc.terminate()  # it can sit on SIGTERM mid-request; a slow shutdown must not discard results (it did once)
    try:
        proc.wait(30)
    except subprocess.TimeoutExpired:
        proc.kill()
        proc.wait()


def server_greedy(gguf: str, ps: list[list[int]], new: int, threads: int, port: int,
                  timeout_s: int = 120) -> tuple[list, dict]:
    """Greedy tokens per prompt; a request over `timeout_s` is recorded as None and the server restarted."""
    proc = start_server(gguf, threads, port)
    outs, pp, tg, timeouts = [], [], [], 0
    try:
        for p in ps:
            body = {"prompt": p, "n_predict": new, "temperature": 0.0, "top_k": 1, "ignore_eos": True,
                    "cache_prompt": False, "return_tokens": True, "samplers": ["top_k"]}
            req = urllib.request.Request(f"http://127.0.0.1:{port}/completion", json.dumps(body).encode(),
                                         {"Content-Type": "application/json"})
            try:
                r = json.loads(urllib.request.urlopen(req, timeout=timeout_s).read())
                outs.append(r["tokens"])
                pp.append(r["timings"]["prompt_per_second"])
                tg.append(r["timings"]["predicted_per_second"])
            except (TimeoutError, OSError) as ex:
                outs.append(None)
                timeouts += 1
                print(json.dumps({"llama_timeout": len(outs) - 1, "error": repr(ex)[:120]}), flush=True)
                stop_server(proc)
                proc = start_server(gguf, threads, port)
            if len(outs) % 20 == 0:
                print(json.dumps({"llama_greedy": len(outs), "of": len(ps), "gen_tok_s": round(tg[-1], 1) if tg else None,
                                  "prompt_tok_s": round(pp[-1], 1) if pp else None}), flush=True)
        return outs, {"prompt_tok_s_median": sorted(pp)[len(pp) // 2], "gen_tok_s_median": sorted(tg)[len(tg) // 2],
                      "threads": threads, "timeouts": timeouts}
    finally:
        stop_server(proc)


def compare(a) -> None:
    from transformers import AutoTokenizer
    from lowbit.model import student as st
    torch.set_num_threads(a.threads)
    if a.packed:
        model = st.load_packed_student(a.packed, attn_impl="sdpa", experts_impl="grouped_mm")
        tok = AutoTokenizer.from_pretrained(a.tokenizer)
    else:
        _, _, model = rtn_dense(a.rtn_dense)
        tok = AutoTokenizer.from_pretrained(a.rtn_dense)
    model.eval()
    ps = prompts(tok, a.n)
    t0 = time.time()
    ref = torch_greedy(model, tok, ps, a.new, a.batch)
    torch_s = time.time() - t0
    del model
    got, timing = server_greedy(a.gguf, ps, a.new, a.threads, a.port)
    pairs = [(r, g) for r, g in zip(ref, got) if g is not None]  # timed-out requests are reported, not scored
    agree = sum(x == y for r, g in pairs for x, y in zip(r, g))
    first = [next((i for i, (x, y) in enumerate(zip(r, g)) if x != y), a.new) for r, g in pairs]
    res = {"prompts": len(pairs), "new_tokens": a.new, "token_agreement": agree / (len(pairs) * a.new),
           "identical_sequences": sum(f == a.new for f in first), "first_divergence_median": sorted(first)[len(first) // 2],
           "first_divergence_mean": sum(first) / len(first), "torch_cpu_s": round(torch_s), **timing,
           "gguf_bytes": Path(a.gguf).stat().st_size}
    Path(a.out).write_text(json.dumps({**res, "torch": ref, "llama": got}))
    print(json.dumps(res))


def forced(a) -> None:
    """For every position of PyTorch's greedy continuation, does llama.cpp pick the same next token given the same
    context? No cascade after a first difference; llama.cpp's prompt cache makes each step one new token."""
    from transformers import AutoTokenizer
    ref = json.loads(Path(a.compare).read_text())
    ps = prompts(AutoTokenizer.from_pretrained(a.tokenizer), ref["prompts"])
    proc = start_server(a.gguf, a.threads, a.port)
    hits = n = 0
    per_pos = [0] * ref["new_tokens"]
    try:
        for k, (p, cont) in enumerate(zip(ps, ref["torch"])):
            for i, want in enumerate(cont):
                body = {"prompt": p + cont[:i], "n_predict": 1, "temperature": 0.0, "top_k": 1, "ignore_eos": True,
                        "cache_prompt": True, "return_tokens": True, "samplers": ["top_k"]}
                req = urllib.request.Request(f"http://127.0.0.1:{a.port}/completion", json.dumps(body).encode(),
                                             {"Content-Type": "application/json"})
                got = json.loads(urllib.request.urlopen(req, timeout=120).read())["tokens"][0]
                hits += got == want
                per_pos[i] += got == want
                n += 1
            if (k + 1) % 25 == 0:
                print(json.dumps({"forced": k + 1, "of": len(ps), "agreement": round(hits / n, 4)}), flush=True)
    finally:
        stop_server(proc)
    res = {"gguf": a.gguf, "positions": n, "forced_top1_agreement": hits / n,
           "agreement_first_16": sum(per_pos[:16]) / (16 * len(ps)), "agreement_last_16": sum(per_pos[-16:]) / (16 * len(ps))}
    Path(a.out).write_text(json.dumps(res, indent=1))
    print(json.dumps(res))


def main() -> None:
    p = argparse.ArgumentParser()
    sub = p.add_subparsers(dest="op", required=True)
    b = sub.add_parser("build")
    b.add_argument("--template", required=True)
    b.add_argument("--out", required=True)
    src = b.add_mutually_exclusive_group(required=True)
    src.add_argument("--packed")
    src.add_argument("--rtn-dense")
    b.add_argument("--ternary-as", choices=["q2_0", "bf16"], default="q2_0",
                   help="q2_0: each packed tensor in its native type (ternary/int2 Q2_0, int4 Q4_0, int3 Q3_K + Q4_0 "
                        "down; D-51); bf16: the lossless control")
    b.add_argument("--name", help="a release name for general.name (and base-model metadata)")
    b.add_argument("--dense-as", choices=["bf16", "q8_0"], default="bf16",
                   help="attention and embedding: bf16 as trained, or q8_0 (post hoc, as the Q8 teacher; faster on CPU)")
    tf = sub.add_parser("forced", help="teacher-forced next-token agreement against a compare run's PyTorch tokens")
    tf.add_argument("--gguf", required=True)
    tf.add_argument("--compare", required=True, help="the compare run's JSON (its PyTorch greedy tokens)")
    tf.add_argument("--tokenizer", required=True)
    tf.add_argument("--out", required=True)
    tf.add_argument("--threads", type=int, default=16)
    tf.add_argument("--port", type=int, default=18190)
    c = sub.add_parser("compare")
    c.add_argument("--gguf", required=True)
    c.add_argument("--out", required=True)
    src = c.add_mutually_exclusive_group(required=True)
    src.add_argument("--packed")
    src.add_argument("--rtn-dense")
    c.add_argument("--tokenizer", default="/path/to/data/models/Marco-Nano-Instruct")
    c.add_argument("--n", type=int, default=200)
    c.add_argument("--new", type=int, default=64)
    c.add_argument("--batch", type=int, default=25)
    c.add_argument("--threads", type=int, default=16)
    c.add_argument("--port", type=int, default=18190)
    a = p.parse_args()
    {"build": build, "compare": compare, "forced": forced}[a.op](a)


if __name__ == "__main__":
    main()