File size: 13,132 Bytes
71c20bd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Load the int8 safetensors export back into a working model. Ships inside the published repo.

    from cpu_model_loader import load_cpu_model
    model, processor = load_cpu_model("path-or-hub-id")

Nothing here unpickles anything. The skeleton is built from `config.json` on the `meta` device,
which allocates no storage at all, and real tensors are attached afterwards — so peak memory tracks
the finished 2.84 GB model instead of the 8.26 GB fp32 graph you would get by instantiating it
normally.

Rebuilding a quantized layer is the only fiddly part. `quantized::linear_dynamic` needs its weight
as a genuine quantized tensor, not an int8 one, so the stored `int_repr` and scale are recombined
with `_make_per_tensor_quantized_tensor` before being handed to the module.

Requires `transformers >= 5.4` (for `cohere_asr`) and a torch build whose quantized engine is
fbgemm, onednn or qnnpack — i.e. any normal CPU build.
"""
from __future__ import annotations

import os
import json

import torch
import torch.nn as nn

QLinear = torch.ao.nn.quantized.dynamic.Linear


def _resolve(path_or_id: str) -> str:
    """Local directory, or a Hub id to download."""
    if os.path.isdir(path_or_id):
        return path_or_id
    from huggingface_hub import snapshot_download
    return snapshot_download(path_or_id)


def _pick_engine():
    for e in ("onednn", "fbgemm", "qnnpack", "x86"):
        if e in torch.backends.quantized.supported_engines:
            torch.backends.quantized.engine = e
            return e
    raise RuntimeError("no quantized engine available in this torch build")


def load_cpu_model(path_or_id: str, attn: str = "sdpa", verbose: bool = True):
    """Returns (model, processor), ready for `generate`."""
    from safetensors.torch import load_file
    from transformers import AutoConfig, AutoProcessor, CohereAsrForConditionalGeneration

    d = _resolve(path_or_id)
    engine = _pick_engine()
    torch.set_grad_enabled(False)

    meta_json = json.load(open(os.path.join(d, "quant_map.json"), encoding="utf-8"))
    qmap = meta_json["quantized_modules"]
    nonpersistent = meta_json.get("nonpersistent_buffers", [])
    sd = load_file(os.path.join(d, "model.safetensors"))

    cfg = AutoConfig.from_pretrained(d)
    cfg._attn_implementation = attn
    with torch.device("meta"):                      # no allocation
        model = CohereAsrForConditionalGeneration(cfg)

    # 1. Attach the plain tensors FIRST, while the Linears are still ordinary meta modules.
    #    Order matters: a quantized Linear's own _load_from_state_dict insists on `scale` and
    #    `zero_point` entries, so running load_state_dict after the swap raises KeyError on the
    #    first quantized layer. assign=True replaces the meta tensors rather than copying into
    #    them, which is what keeps this from materializing an fp32 model first.
    rest = {k: v for k, v in sd.items()
            if not (k.startswith("nonpersistent.") or k.endswith(".weight_int8")
                    or k.endswith(".weight_scale")
                    or (k.endswith(".bias") and k[: -len(".bias")] in qmap))}
    missing, unexpected = model.load_state_dict(rest, strict=False, assign=True)

    # buffers state_dict() never reported, and which a meta skeleton therefore cannot supply
    by_name_all = dict(model.named_modules())
    for full in nonpersistent:
        owner, _, leaf = full.rpartition(".")
        by_name_all[owner].register_buffer(leaf, sd["nonpersistent." + full], persistent=False)

    # everything still missing must be a quantized layer's weight/bias, about to be supplied below
    expected_missing = {f"{n}.weight" for n in qmap} | {
        f"{n}.bias" for n, m in qmap.items() if m["has_bias"]}
    stray = set(missing) - expected_missing
    if stray:
        raise RuntimeError(f"{len(stray)} tensors are missing from the checkpoint, "
                           f"first: {sorted(stray)[:3]}")
    if unexpected:
        raise RuntimeError(f"unexpected tensors in the checkpoint: {unexpected[:3]}")

    # 2. swap every quantized Linear for a real one carrying the stored int8 weight
    by_name = dict(model.named_modules())
    for full, meta in qmap.items():
        parent_name, _, leaf = full.rpartition(".")
        parent = by_name[parent_name] if parent_name else model
        q = QLinear(meta["in_features"], meta["out_features"],
                    bias_=meta["has_bias"], dtype=torch.qint8)
        w = torch._make_per_tensor_quantized_tensor(
            sd[full + ".weight_int8"], float(sd[full + ".weight_scale"]), meta["zero_point"])
        q.set_weight_bias(w, sd.get(full + ".bias"))
        setattr(parent, leaf, q)

    still_meta = [n for n, p in list(model.named_parameters()) + list(model.named_buffers())
                  if p is not None and p.is_meta]
    if still_meta:
        raise RuntimeError(f"{len(still_meta)} tensors never got real storage, "
                           f"first: {still_meta[:3]}")

    # Constructing the class directly derives a GenerationConfig from config.json and never reads
    # generation_config.json, which is where decoder_start_token_id (13764) actually lives. Without
    # this the model loads, transcribes, and quietly decodes differently from the original.
    gc_path = os.path.join(d, "generation_config.json")
    if os.path.exists(gc_path):
        from transformers import GenerationConfig
        model.generation_config = GenerationConfig.from_pretrained(d)

    model.eval()
    proc = AutoProcessor.from_pretrained(d)
    if verbose:
        gb = (sum(p.numel() * p.element_size() for p in model.parameters())
              + sum(v.numel() for k, v in sd.items() if v.dtype == torch.int8)) / 1e9
        print(f"  loaded int8 model on '{engine}'  (~{gb:.2f} GB resident)", flush=True)
    return model, proc


def load_draft_head(path_or_id: str):
    """The Arabic CTC draft head from the same repo, or (None, None) if it is not there."""
    import sys
    d = _resolve(path_or_id)
    if not os.path.exists(os.path.join(d, "draft_head.safetensors")):
        return None, None
    sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
    from draft_head import DraftHead
    # config_dir=d: the head's adapter block is rebuilt from this repo's own config.json, so it
    # works on a machine that has never seen the original model.
    return DraftHead.from_checkpoint(d, config_dir=d)


def transcribe(model, processor, audio, language="ar", head=None, k=8, max_new_tokens=256):
    """Transcribe one clip, optionally with speculative decoding.

    `audio` is a path or a 16 kHz float32 array. Pass `head` (from `load_draft_head`) to use the
    draft head; without it this is plain greedy decoding.

    Only single-chunk audio is drafted: `transformers`' assisted generation is batch-1 only, and
    the head is trained on one language, so anything else falls through to greedy.
    """
    import sys
    import numpy as np
    from transformers.audio_utils import load_audio

    if isinstance(audio, str):
        audio = load_audio(audio, sampling_rate=16000)
    audio = np.asarray(audio, dtype="float32")

    inp = processor(audio=audio, sampling_rate=16000, return_tensors="pt",
                    language=language, punctuation=True)
    feats, mask = inp["input_features"].float(), inp["attention_mask"]
    pid, aci = inp["decoder_input_ids"], inp.get("audio_chunk_index")
    kw = dict(max_new_tokens=max_new_tokens, num_beams=1, do_sample=False, use_cache=True)

    single = feats.shape[0] == 1
    if head is not None and single:
        sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
        from draft_head import ctc_greedy
        from spec_decode import SequenceDraftCandidateGenerator, spec_generate

        enc = model.model.encoder(input_features=feats, attention_mask=mask)
        flen = (enc.attention_mask.sum(-1) if enc.attention_mask is not None
                else torch.tensor([enc.last_hidden_state.shape[1]]))
        (seq, conf), = ctc_greedy(
            head(enc.last_hidden_state.float(),
                 enc.attention_mask.bool() if enc.attention_mask is not None else None), flen)
        if seq:
            gen = SequenceDraftCandidateGenerator(
                torch.tensor(seq, dtype=torch.long), torch.tensor(conf, dtype=torch.float),
                k=k, eos_token_id=torch.tensor(
                    [model.generation_config.eos_token_id]).flatten(),
                max_length=10 + max_new_tokens)
            out = spec_generate(model, gen, trigger_k=k, input_features=feats,
                                attention_mask=mask, encoder_outputs=enc,
                                decoder_input_ids=pid, **kw)
            return processor.tokenizer.decode(out[0], skip_special_tokens=True)
        out = model.generate(input_features=feats, attention_mask=mask, encoder_outputs=enc,
                             decoder_input_ids=pid, **kw)
        return processor.tokenizer.decode(out[0], skip_special_tokens=True)

    out = model.generate(input_features=feats, attention_mask=mask,
                         decoder_input_ids=pid, **kw)
    parts = [processor.tokenizer.decode(o, skip_special_tokens=True) for o in out]
    if len(parts) == 1:
        return parts[0]
    return processor._reassemble_chunk_texts(parts, aci, " ")[0]


# --------------------------------------------------------------------- verify
def verify(export_dir: str, cache: str, n_layers: int = 8):
    """Does the reloaded export reproduce the source model exactly?

    Compares the dequantized weights of a sample of layers, then runs both models over the same
    audio and requires byte-identical token sequences. Weight equality alone is not enough: a
    mis-wired module could still be attached to the wrong parent.
    """
    import numpy as np
    from transformers.audio_utils import load_audio

    print("\nverifying the export against the source model", flush=True)
    model, proc = load_cpu_model(export_dir)

    src = torch.load(cache, map_location="cpu", weights_only=False)
    srcmods = {n: m for n, m in src.named_modules() if isinstance(m, QLinear)}
    newmods = {n: m for n, m in model.named_modules() if isinstance(m, QLinear)}
    print(f"  quantized modules: source {len(srcmods)}, reloaded {len(newmods)}")
    assert set(srcmods) == set(newmods), "module sets differ"

    names = list(srcmods)
    sample = names[:: max(1, len(names) // n_layers)][:n_layers]
    worst = 0.0
    for n in sample:
        a, b = srcmods[n].weight(), newmods[n].weight()
        assert a.q_scale() == b.q_scale(), f"{n}: scale {a.q_scale()} != {b.q_scale()}"
        assert torch.equal(a.int_repr(), b.int_repr()), f"{n}: int_repr differs"
        worst = max(worst, float((a.dequantize() - b.dequantize()).abs().max()))
    print(f"  {len(sample)} sampled layers: scales and int_repr identical, "
          f"max dequantized diff {worst:.1e}")

    clip = os.path.join(os.path.dirname(cache), "..", "examples", "sample2.wav")
    clip = os.path.normpath(clip)
    y = np.asarray(load_audio(clip, sampling_rate=16000), dtype="float32")
    inp = proc(audio=y, sampling_rate=16000, return_tensors="pt", language="ar", punctuation=True)
    kw = dict(input_features=inp["input_features"].float(),
              attention_mask=inp["attention_mask"],
              decoder_input_ids=inp["decoder_input_ids"],
              max_new_tokens=256, num_beams=1, do_sample=False, use_cache=True)
    out_new = model.generate(**kw)
    out_src = src.generate(**kw)
    same = torch.equal(out_new, out_src)
    print(f"  generated tokens identical: {same}  ({out_new.shape[1]} tokens)")
    if not same:
        t = proc.tokenizer
        print("    source  :", t.decode(out_src[0], skip_special_tokens=True)[:80])
        print("    reloaded:", t.decode(out_new[0], skip_special_tokens=True)[:80])
        raise SystemExit("EXPORT IS NOT FAITHFUL — do not publish it")
    print("  PASS: the export is the deployed model")
    return True


if __name__ == "__main__":
    import argparse
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("path", help="export directory or Hub id")
    ap.add_argument("--audio", help="transcribe this file as a smoke test")
    ap.add_argument("--language", default="ar", choices=["ar", "en"])
    a = ap.parse_args()
    m, p = load_cpu_model(a.path)
    if a.audio:
        import numpy as np
        from transformers.audio_utils import load_audio
        y = np.asarray(load_audio(a.audio, sampling_rate=16000), dtype="float32")
        i = p(audio=y, sampling_rate=16000, return_tensors="pt",
              language=a.language, punctuation=True)
        o = m.generate(input_features=i["input_features"].float(),
                       attention_mask=i["attention_mask"],
                       decoder_input_ids=i["decoder_input_ids"],
                       max_new_tokens=256, num_beams=1, do_sample=False, use_cache=True)
        print(p.tokenizer.decode(o[0], skip_special_tokens=True))