Masterx commited on
Commit
d6c745f
·
verified ·
1 Parent(s): a644925

Upload scripts/audio8_infinite_parity.py

Browse files
Files changed (1) hide show
  1. scripts/audio8_infinite_parity.py +352 -0
scripts/audio8_infinite_parity.py ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Parity + accuracy harness for the Audio8-ASR-Infinite ONNX bundle.
3
+
4
+ onnx --prec P --sets jfk jfk_long ... free-running ORT streaming decode -> results/onnx_P.json
5
+ torch --draft P --sets jfk jfk_long ... exact torch fp32 greedy (speculative: ORT tokens of
6
+ precision P are only a DRAFT, every token is verified
7
+ by a torch forward) -> results/torch.json (+ logits npz)
8
+ shards --sets jfk jfk_long fp32 ONNX decoder shards, teacher-forced on the torch
9
+ tokens, one shard resident at a time -> results/fp32.json
10
+ report parity / WER / CER table from results/*.json
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import re
18
+ import time
19
+ import unicodedata
20
+ from pathlib import Path
21
+
22
+ import numpy as np
23
+
24
+ import audio8_infinite_ref as a8i
25
+ import audio8_infinite_graph as a8i_onnx
26
+
27
+ ROOT = Path(__file__).parent
28
+ CKPT = Path(os.environ.get("A8I_CKPT", ROOT / "ckpt")) # HF snapshot of Edge0/Audio8-ASR-Infinite
29
+ BUNDLE = Path(os.environ.get("A8I_BUNDLE", ROOT / "bundle")) # audio8_infinite_export.py --out
30
+ RES = Path(os.environ.get("A8I_RESULTS", ROOT / "results"))
31
+ RES.mkdir(exist_ok=True)
32
+ BENCH = Path(__file__).resolve().parents[1] / "bench" / "audio"
33
+ DATA = Path(os.environ.get("A8I_DATA", ROOT / "data")) # ls_clean/*.f32+.txt, fleurs_cmn_hans_cn/*.f32+.txt
34
+
35
+
36
+ def load_f32(p):
37
+ return np.fromfile(p, dtype="<f4")
38
+
39
+
40
+ def audio_sets(names):
41
+ """name -> list of (utt_id, wav, ref_text|None, lang_token)."""
42
+ out = {}
43
+ for n in names:
44
+ if n == "jfk":
45
+ import soundfile as sf
46
+
47
+ x, sr = sf.read(BENCH / "jfk_16k_mono.wav", dtype="float32")
48
+ out[n] = [("jfk", x, "And so, my fellow Americans, ask not what your country can do for you, ask what you can do for your country.", a8i.LANG_EN)]
49
+ elif n == "jfk_long":
50
+ out[n] = [("jfk_long_66s", load_f32(BENCH / "jfk_long_66s.f32"), None, a8i.LANG_EN)]
51
+ elif n == "long_varied":
52
+ out[n] = [("long_varied", load_f32(BENCH / "long_varied.f32"), None, a8i.LANG_EN)]
53
+ elif n in ("ls_clean", "fleurs_zh"):
54
+ d = DATA / ("ls_clean" if n == "ls_clean" else "fleurs_cmn_hans_cn")
55
+ lang = a8i.LANG_EN if n == "ls_clean" else a8i.LANG_ZH
56
+ rows = []
57
+ for f in sorted(d.glob("*.f32")):
58
+ rows.append((f"{n}/{f.stem}", load_f32(f), f.with_suffix(".txt").read_text(encoding="utf-8").strip(), lang))
59
+ out[n] = rows
60
+ else:
61
+ raise SystemExit(f"unknown set {n}")
62
+ return out
63
+
64
+
65
+ # ---------------------------------------------------------------------------------- text metrics
66
+ def norm_en(s: str) -> str:
67
+ s = s.lower().replace("’", "'")
68
+ s = re.sub(r"[^a-z0-9' ]+", " ", s)
69
+ return " ".join(s.split())
70
+
71
+
72
+ def norm_zh(s: str) -> str:
73
+ s = unicodedata.normalize("NFKC", s)
74
+ s = "".join(ch for ch in s if not unicodedata.category(ch).startswith(("P", "Z", "S")) and not ch.isspace())
75
+ return s.lower()
76
+
77
+
78
+ def wer(refs, hyps):
79
+ import jiwer
80
+
81
+ return jiwer.wer([norm_en(r) for r in refs], [norm_en(h) or "<empty>" for h in hyps])
82
+
83
+
84
+ def cer(refs, hyps):
85
+ import jiwer
86
+
87
+ return jiwer.cer([norm_zh(r) for r in refs], [norm_zh(h) or "-" for h in hyps])
88
+
89
+
90
+ # ---------------------------------------------------------------------------------- loaders
91
+ def weights_and_dims():
92
+ cfg = json.loads((CKPT / "config.json").read_text())
93
+ return a8i.load_weights(CKPT), a8i.Dims.from_config(cfg)
94
+
95
+
96
+ def tokenizer():
97
+ from tokenizers import Tokenizer
98
+
99
+ return Tokenizer.from_file(str(CKPT / "tokenizer.json"))
100
+
101
+
102
+ def ort_backend(prec, threads=None, enc_prec=None):
103
+ sfx = "" if prec == "fp32" else f"_{prec}"
104
+ esfx = sfx if enc_prec is None else ("" if enc_prec == "fp32" else f"_{enc_prec}")
105
+ return a8i_onnx.OrtBackend(BUNDLE / f"audio_encoder{esfx}.onnx", [BUNDLE / f"decoder{sfx}.onnx"], threads=threads)
106
+
107
+
108
+ # ---------------------------------------------------------------------------------- commands
109
+ def cmd_onnx(args):
110
+ W, dims = weights_and_dims()
111
+ tok = tokenizer()
112
+ be = ort_backend(args.prec, args.threads, args.enc_prec)
113
+ tag = args.prec if args.enc_prec is None else f"{args.prec}_enc{args.enc_prec}"
114
+ path = RES / f"onnx_{tag}.json"
115
+ res = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
116
+ for set_name, rows in audio_sets(args.sets).items():
117
+ for uid, wav, ref, lang in rows:
118
+ if uid in res and not args.keep_logits:
119
+ continue
120
+ s = a8i.Session(be, W, dims, delay_tokens=6, frame_len=4, language=lang, rolling=True,
121
+ enc_batch=args.enc_batch, keep_logits=args.keep_logits)
122
+ t = time.perf_counter()
123
+ toks = s.run(wav)
124
+ dt = time.perf_counter() - t
125
+ text = a8i.visible_text(tok, toks)
126
+ eot = [round(a8i.eot_probability(v), 4) for v in s.log.vad]
127
+ res[uid] = {"tokens": toks, "text": text, "ref": ref, "seconds": len(wav) / 16000, "wall": dt,
128
+ "trims": s.trims, "eot": eot}
129
+ if args.keep_logits:
130
+ np.save(RES / f"logits_{tag}_{uid.replace('/', '_')}.npy", np.stack(s.log.logits).astype(np.float32))
131
+ print(f"[{tag}] {uid} {len(wav) / 16000:.1f}s wall={dt:.1f}s rtf={dt / (len(wav) / 16000):.2f} "
132
+ f"trims={s.trims} :: {text[:120]}", flush=True)
133
+ path.write_text(json.dumps(res, ensure_ascii=False), encoding="utf-8")
134
+
135
+
136
+ def cmd_torch(args):
137
+ import torch
138
+
139
+ torch.set_num_threads(args.threads or 16)
140
+ W, dims = weights_and_dims()
141
+ tok = tokenizer()
142
+ ref = a8i.TorchRef(W, dims)
143
+ draft_path = RES / f"onnx_{args.draft}.json"
144
+ drafts = json.loads(draft_path.read_text(encoding="utf-8")) if draft_path.exists() else {}
145
+ out_path = RES / "torch.json"
146
+ res = json.loads(out_path.read_text(encoding="utf-8")) if out_path.exists() else {}
147
+ for set_name, rows in audio_sets(args.sets).items():
148
+ for uid, wav, reftext, lang in rows:
149
+ if uid in res and not args.force:
150
+ continue
151
+ draft = drafts.get(uid, {}).get("tokens", [])
152
+ t = time.perf_counter()
153
+ with torch.no_grad():
154
+ toks, logits, vad, passes = speculative_greedy(ref, W, dims, wav, lang, draft)
155
+ dt = time.perf_counter() - t
156
+ text = a8i.visible_text(tok, toks)
157
+ res[uid] = {"tokens": toks, "text": text, "ref": reftext, "seconds": len(wav) / 16000,
158
+ "eot": [round(a8i.eot_probability(v), 4) for v in vad], "passes": passes, "wall": dt}
159
+ np.save(RES / f"torchlogits_{uid.replace('/', '_')}.npy", np.stack(logits).astype(np.float32))
160
+ print(f"[torch] {uid} steps={len(toks)} passes={passes} wall={dt:.0f}s :: {text[:120]}", flush=True)
161
+ out_path.write_text(json.dumps(res, ensure_ascii=False), encoding="utf-8")
162
+
163
+
164
+ def speculative_greedy(ref, W, dims, wav, lang, draft):
165
+ """Exact greedy decode with the torch reference; `draft` tokens only decide how many positions a
166
+ single torch forward may verify at once (accepted prefix + the corrected token)."""
167
+ s = a8i.Session(ref, W, dims, delay_tokens=6, frame_len=4, language=lang, rolling=True)
168
+ stream = s.stream_of(wav)
169
+ nwin = s.n_windows(len(stream))
170
+ prompt = [a8i.BOS, lang] + [a8i.STREAM_PAD] * (s.prefill - 2)
171
+ a, b = s.window_bounds(0)
172
+ a0 = s.encode([stream[a:b]], s.prefill)
173
+ audio = []
174
+ k = 1
175
+ while k < nwin:
176
+ kk = list(range(k, min(nwin, k + 24)))
177
+ audio.extend(list(s.encode([stream[s.window_bounds(j)[0]: s.window_bounds(j)[1]] for j in kk], 1)))
178
+ k += len(kk)
179
+ logits_all, vad_all = [], []
180
+
181
+ def pick(lg):
182
+ lg = np.asarray(lg, np.float32).reshape(-1).copy()
183
+ lg[a8i.EOS] = -np.inf
184
+ return int(lg.argmax())
185
+
186
+ lg, vd = s.decode(a8i.embed_rows(W, prompt) + a0)
187
+ out = [pick(lg)]
188
+ logits_all.append(np.asarray(lg).reshape(-1))
189
+ vad_all.append(np.asarray(vd).reshape(-1))
190
+ passes = 1
191
+ k = 1
192
+ while k < nwin:
193
+ s.maybe_trim(1)
194
+ room = a8i.ROLL_CONTEXT - s.dlen
195
+ m = min(room, nwin - k, 40)
196
+ # inputs for steps k..k+m-1: the token emitted by step j-1 (accepted output, then draft)
197
+ feed = [out[-1]] + [draft[j] if j < len(draft) else a8i.STREAM_PAD for j in range(k, k + m - 1)]
198
+ d0 = s.dlen
199
+ lg, vd = s.decode(a8i.embed_rows(W, feed) + np.stack(audio[k - 1: k - 1 + m]), all_logits=True)
200
+ passes += 1
201
+ lg, vd = np.asarray(lg)[0], np.asarray(vd)[0]
202
+ accepted = m
203
+ for j in range(m):
204
+ t_ = pick(lg[j])
205
+ out.append(t_)
206
+ logits_all.append(lg[j])
207
+ vad_all.append(vd[j].reshape(-1))
208
+ if j + 1 < m and t_ != feed[j + 1]:
209
+ accepted = j + 1
210
+ break
211
+ s.dlen = d0 + accepted
212
+ k += accepted
213
+ return out, logits_all, vad_all, passes
214
+
215
+
216
+ def cmd_shards(args):
217
+ """fp32 ONNX decoder, one shard resident at a time, teacher-forced on torch greedy tokens."""
218
+ import onnxruntime as ort
219
+
220
+ W, dims = weights_and_dims()
221
+ tref = json.loads((RES / "torch.json").read_text(encoding="utf-8"))
222
+ enc = a8i_onnx.OrtBackend(BUNDLE / "audio_encoder.onnx", [])
223
+ shards = sorted(BUNDLE.glob("decoder.part*.onnx"))
224
+ out = {}
225
+ for set_name, rows in audio_sets(args.sets).items():
226
+ for uid, wav, _, lang in rows:
227
+ toks = tref[uid]["tokens"]
228
+ s = a8i.Session(enc, W, dims, delay_tokens=6, frame_len=4, language=lang, rolling=True)
229
+ stream = s.stream_of(wav)
230
+ nwin = s.n_windows(len(stream))
231
+ a, b = s.window_bounds(0)
232
+ a0 = s.encode([stream[a:b]], s.prefill)
233
+ audio = []
234
+ k = 1
235
+ while k < nwin:
236
+ kk = list(range(k, min(nwin, k + 24)))
237
+ audio.extend(list(s.encode([stream[s.window_bounds(j)[0]: s.window_bounds(j)[1]] for j in kk], 1)))
238
+ k += len(kk)
239
+ prompt = [a8i.BOS, lang] + [a8i.STREAM_PAD] * (s.prefill - 2)
240
+ inputs = [a8i.embed_rows(W, prompt) + a0] + [a8i.embed_rows(W, [toks[j - 1]]) + audio[j - 1][None] for j in range(1, nwin)]
241
+ logits = None
242
+ for sp in shards:
243
+ be = a8i_onnx.OrtBackend(None, [sp])
244
+ ds = a8i.Session(be, W, dims, delay_tokens=6, frame_len=4, language=lang, rolling=True, ada=s.ada)
245
+ nxt, lgs = [], []
246
+ for x in inputs:
247
+ ds.maybe_trim(x.shape[0])
248
+ n = x.shape[0]
249
+ pos = np.arange(ds.dlen, ds.dlen + n)
250
+ cos, sin = a8i.rope_cos_sin(pos, dims.t_head_dim, dims.t_theta)
251
+ bias = np.full((n, ds.L + n), a8i.NEG, np.float32)
252
+ bias[:, : ds.dlen] = 0.0
253
+ bias[:, ds.L:] = np.where(np.tril(np.ones((n, n), bool)), 0.0, a8i.NEG)
254
+ sess = be.decs[0]
255
+ names_in = {i.name for i in sess.get_inputs()}
256
+ layers = sorted(int(q.split("_")[-1]) for q in names_in if q.startswith("past_key_"))
257
+ feed = {("inputs_embeds" if "inputs_embeds" in names_in else "hidden"): x[None].astype(np.float32),
258
+ "ada_scale": ds.ada, "cos": cos, "sin": sin, "attn_bias": bias}
259
+ for i in layers:
260
+ feed[f"past_key_{i}"] = ds.dk[i]
261
+ feed[f"past_value_{i}"] = ds.dv[i]
262
+ names = [o.name for o in sess.get_outputs()]
263
+ r = dict(zip(names, sess.run(names, feed)))
264
+ for i in layers:
265
+ ds.dk[i][:, :, ds.dlen: ds.dlen + n] = r[f"key_{i}"]
266
+ ds.dv[i][:, :, ds.dlen: ds.dlen + n] = r[f"value_{i}"]
267
+ ds.dlen += n
268
+ if "hidden" in r:
269
+ nxt.append(r["hidden"][0])
270
+ else:
271
+ lgs.append(r["logits"].reshape(-1))
272
+ del be
273
+ inputs = nxt
274
+ logits = lgs or logits
275
+ tl = np.load(RES / f"torchlogits_{uid.replace('/', '_')}.npy")
276
+ n = min(len(tl), len(logits))
277
+ diffs = [float(np.abs(tl[i] - logits[i]).max()) for i in range(n)]
278
+ am = []
279
+ for i in range(n):
280
+ q = logits[i].copy()
281
+ q[a8i.EOS] = -np.inf
282
+ am.append(int(q.argmax()))
283
+ out[uid] = {"steps": n, "max_abs_logit_diff": max(diffs), "mean_max_abs_logit_diff": float(np.mean(diffs)),
284
+ "argmax_equal": am == toks[:n], "argmax_mismatches": int(sum(x != y for x, y in zip(am, toks)))}
285
+ print("[fp32-shards]", uid, out[uid], flush=True)
286
+ (RES / "fp32.json").write_text(json.dumps(out, indent=1), encoding="utf-8")
287
+
288
+
289
+ def cmd_report(args):
290
+ torch_res = json.loads((RES / "torch.json").read_text(encoding="utf-8")) if (RES / "torch.json").exists() else {}
291
+ rows = []
292
+ for p in sorted(RES.glob("onnx_*.json")):
293
+ tag = p.stem[5:]
294
+ r = json.loads(p.read_text(encoding="utf-8"))
295
+ line = {"prec": tag}
296
+ for set_name, metric in (("ls_clean", wer), ("fleurs_zh", cer)):
297
+ keys = [k for k in r if k.startswith(set_name + "/")]
298
+ if keys:
299
+ line[set_name] = round(100 * metric([r[k]["ref"] for k in keys], [r[k]["text"] for k in keys]), 2)
300
+ line[set_name + "_n"] = len(keys)
301
+ line[set_name + "_rtf"] = round(sum(r[k]["wall"] for k in keys) / sum(r[k]["seconds"] for k in keys), 3)
302
+ for uid in ("jfk", "jfk_long_66s", "long_varied"):
303
+ if uid in r:
304
+ line[uid + "_rtf"] = round(r[uid]["wall"] / r[uid]["seconds"], 3)
305
+ if uid in torch_res:
306
+ a, b = r[uid]["tokens"], torch_res[uid]["tokens"]
307
+ fd = next((i for i in range(min(len(a), len(b))) if a[i] != b[i]), None)
308
+ line[uid + "_tok_eq"] = (a == b)
309
+ line[uid + "_first_div"] = fd
310
+ line[uid + "_agree"] = round(float(np.mean([x == y for x, y in zip(a, b)])), 4)
311
+ # token identity vs torch across all utterances that have a torch run
312
+ common = [k for k in r if k in torch_res]
313
+ if common:
314
+ line["tok_identical_utts"] = f"{sum(r[k]['tokens'] == torch_res[k]['tokens'] for k in common)}/{len(common)}"
315
+ rows.append(line)
316
+ for line in rows:
317
+ print(json.dumps(line, ensure_ascii=False))
318
+ if torch_res:
319
+ for set_name, metric in (("ls_clean", wer), ("fleurs_zh", cer)):
320
+ keys = [k for k in torch_res if k.startswith(set_name + "/")]
321
+ if keys:
322
+ print("torch", set_name, round(100 * metric([torch_res[k]["ref"] for k in keys], [torch_res[k]["text"] for k in keys]), 2), len(keys))
323
+
324
+
325
+ def main():
326
+ ap = argparse.ArgumentParser()
327
+ sub = ap.add_subparsers(dest="cmd", required=True)
328
+ a = sub.add_parser("onnx")
329
+ a.add_argument("--prec", required=True)
330
+ a.add_argument("--enc-prec", default=None)
331
+ a.add_argument("--sets", nargs="+", required=True)
332
+ a.add_argument("--threads", type=int, default=None)
333
+ a.add_argument("--enc-batch", type=int, default=16)
334
+ a.add_argument("--keep-logits", action="store_true")
335
+ a.set_defaults(fn=cmd_onnx)
336
+ b = sub.add_parser("torch")
337
+ b.add_argument("--draft", default="int8")
338
+ b.add_argument("--sets", nargs="+", required=True)
339
+ b.add_argument("--threads", type=int, default=None)
340
+ b.add_argument("--force", action="store_true")
341
+ b.set_defaults(fn=cmd_torch)
342
+ c = sub.add_parser("shards")
343
+ c.add_argument("--sets", nargs="+", required=True)
344
+ c.set_defaults(fn=cmd_shards)
345
+ d = sub.add_parser("report")
346
+ d.set_defaults(fn=cmd_report)
347
+ args = ap.parse_args()
348
+ args.fn(args)
349
+
350
+
351
+ if __name__ == "__main__":
352
+ main()