cfahlgren1 HF Staff commited on
Commit
4dece7f
·
verified ·
1 Parent(s): e5dde63

export the compressed browser format from train.py instead of int8 activations

Browse files
Files changed (2) hide show
  1. v2/export.py +6 -2
  2. v2/train.py +39 -29
v2/export.py CHANGED
@@ -6,11 +6,13 @@
6
  # "tokenizers>=0.20",
7
  # "onnx>=1.17",
8
  # "onnxruntime>=1.20",
 
9
  # "safetensors>=0.4",
10
  # "huggingface_hub>=0.30",
11
  # ]
12
  # ///
13
- """Re-exports a trained tinynext checkpoint to browser ONNX (float and int8) with train.py's export, without retraining.
 
14
 
15
  uv run export.py --weights v2-release/v2 --out v2-release/browser
16
  """
@@ -49,7 +51,9 @@ def main():
49
  model = train.TinyNext(config["vocab"], config["dim"], config["layers"], config["heads"], config["ffn"])
50
  model.load_state_dict(load_file(weights / "model.safetensors"))
51
  tokenizer = Tokenizer.from_file(str(weights / "tokenizer.json"))
52
- report = train.export(model, out, torch.device("cpu"), train.parity_prompts(ev, tokenizer))
 
 
53
  (out / "export.json").write_text(json.dumps(report, indent=1))
54
  print(json.dumps(report, indent=1))
55
 
 
6
  # "tokenizers>=0.20",
7
  # "onnx>=1.17",
8
  # "onnxruntime>=1.20",
9
+ # "onnx-ir",
10
  # "safetensors>=0.4",
11
  # "huggingface_hub>=0.30",
12
  # ]
13
  # ///
14
+ """Re-exports a trained tinynext checkpoint with train.py's export (float ONNX and the compressed browser format in
15
+ web/), without retraining.
16
 
17
  uv run export.py --weights v2-release/v2 --out v2-release/browser
18
  """
 
51
  model = train.TinyNext(config["vocab"], config["dim"], config["layers"], config["heads"], config["ffn"])
52
  model.load_state_dict(load_file(weights / "model.safetensors"))
53
  tokenizer = Tokenizer.from_file(str(weights / "tokenizer.json"))
54
+ (out / "tokenizer.json").write_bytes((weights / "tokenizer.json").read_bytes())
55
+ report = train.export(model, out, torch.device("cpu"), train.parity_prompts(ev, tokenizer), load("compress"))
56
+ (out / "web" / "config.json").write_bytes((weights / "config.json").read_bytes())
57
  (out / "export.json").write_text(json.dumps(report, indent=1))
58
  print(json.dumps(report, indent=1))
59
 
v2/train.py CHANGED
@@ -61,15 +61,28 @@ def cpus() -> int:
61
  # ---- Shared eval code ------------------------------------------------------------------------------------------
62
 
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  def load_eval_module(repo: str, folder: str):
65
  """Uses eval.py and eval_set.json next to this script, or fetches them from the repo, so every model is scored
66
  by one implementation on the same text. Registered as a module so worker processes can use it too."""
 
67
  path = HERE / "eval.py"
68
- if not path.exists():
69
- from huggingface_hub import hf_hub_download
70
-
71
- for name in ("eval.py", "eval_set.json"):
72
- shutil.copy(hf_hub_download(repo, f"{folder}/{name}"), HERE / name)
73
  spec = importlib.util.spec_from_file_location("tinynext_eval", path)
74
  module = importlib.util.module_from_spec(spec)
75
  sys.modules["tinynext_eval"] = module
@@ -437,20 +450,22 @@ def greedy_tokens(step, ids: list[int], empty, steps: int) -> list[int]:
437
  return tops
438
 
439
 
440
- def export(model: TinyNext, output: Path, device, prompts: list[list[int]]) -> dict:
441
- """Float and int8 ONNX for the browser, checked against PyTorch on real prompts over several cached steps."""
 
 
 
 
442
  import onnxruntime as ort
443
- from onnxruntime.quantization import QuantType, quantize_dynamic
444
 
445
  model = model.float().eval().to("cpu")
446
- model.head = nn.Parameter(model.embed.weight.detach().T.contiguous().clone())
447
  cfg = model.config
448
  head_dim = cfg["dim"] // cfg["heads"]
449
  empty = np.zeros((cfg["layers"], 1, cfg["heads"], 0, head_dim), np.float32)
450
  # Trace with a non-empty cache so no shape gets fixed at zero; the checks below start from an empty one.
451
  with torch.no_grad():
452
  _, trace_k, trace_v = model(torch.tensor([[1, 2]]))
453
- float_path, int8_path = output / "model.onnx", output / "model_int8.onnx"
454
  torch.onnx.export(
455
  BrowserStep(model), (torch.tensor([[5, 6, 7]]), trace_k, trace_v), float_path, opset_version=17,
456
  input_names=["input_ids", "past_k", "past_v"], output_names=["logits", "present_k", "present_v"],
@@ -458,7 +473,9 @@ def export(model: TinyNext, output: Path, device, prompts: list[list[int]]) -> d
458
  "present_k": {3: "total"}, "present_v": {3: "total"}},
459
  dynamo=False,
460
  )
461
- quantize_dynamic(float_path, int8_path, weight_type=QuantType.QInt8, op_types_to_quantize=["MatMul", "Gather"], per_channel=True)
 
 
462
 
463
  @torch.no_grad()
464
  def torch_step(ids, k, v):
@@ -467,27 +484,18 @@ def export(model: TinyNext, output: Path, device, prompts: list[list[int]]) -> d
467
  return logits[0, -1].numpy(), nk.numpy(), nv.numpy()
468
 
469
  expected = [greedy_tokens(torch_step, ids, empty, 4) for ids in prompts]
470
- report = {"parity_prompts": len(prompts), "parity_steps_per_prompt": 5}
471
- for name, path in (("float", float_path), ("int8", int8_path)):
472
  session = ort.InferenceSession(str(path), providers=["CPUExecutionProvider"])
473
  onnx_step = lambda ids, k, v: (lambda out: (out[0][0], out[1], out[2]))(
474
  session.run(None, {"input_ids": ids.astype(np.int64), "past_k": k, "past_v": v}))
475
  got = [greedy_tokens(onnx_step, ids, empty, 4) for ids in prompts]
476
- first = np.mean([g[0] == e[0] for g, e in zip(got, expected)])
477
- all_steps = np.mean([g == e for g, e in zip(got, expected)])
478
- _, pk, pv = onnx_step(np.array([prompts[0]]), empty, empty)
479
- timings = []
480
- for _ in range(200):
481
- began = time.perf_counter()
482
- onnx_step(np.array([[8]]), pk, pv)
483
- timings.append((time.perf_counter() - began) * 1000)
484
  report[name] = {
485
- "megabytes": round(path.stat().st_size / 1e6, 2),
486
- "same_first_token": round(float(first), 4),
487
- "same_five_greedy_tokens": round(float(all_steps), 4),
488
- "cached_step_ms_p50_cpu": round(float(np.median(timings)), 3),
489
  }
490
- model.head = None
491
  model.to(device)
492
  return report
493
 
@@ -655,13 +663,15 @@ def main():
655
  (output / "metrics").mkdir(exist_ok=True)
656
  (output / "metrics" / f"{args.name}.json").write_text(json.dumps(scored, indent=1))
657
  tracker.log(headline(scored))
658
- exported = export(model, output, device, parity_prompts(ev, tokenizer))
 
 
659
 
660
- config = {
661
  **model.config, "parameters": parameters, "seq": args.seq, "special_tokens": dict(zip(SPECIAL, range(len(SPECIAL)))),
662
  "prompt": "No text after the cursor: the text before it. Otherwise: <|suf|> after <|pre|> before <|mid|>.",
663
  }
664
  (output / "config.json").write_text(json.dumps(config, indent=1))
 
665
  report = {"args": vars(args), "parameters": parameters, "data": counts, "data_minutes": round(data_minutes, 1),
666
  "train_minutes": round(train_minutes, 1), "history": history, "exports": exported}
667
  (output / "report.json").write_text(json.dumps(report, indent=1))
@@ -669,7 +679,7 @@ def main():
669
  print(ev.table([output / "metrics" / f"{args.name}.json"]))
670
  tracker.finish()
671
 
672
- for name in ("train.py", "eval.py", "eval_set.json"):
673
  shutil.copy(HERE / name, output / name)
674
  if (HERE / "metrics").exists():
675
  for path in (HERE / "metrics").glob("*.json"):
 
61
  # ---- Shared eval code ------------------------------------------------------------------------------------------
62
 
63
 
64
+ def fetch_shared(repo: str, folder: str, *names: str) -> None:
65
+ """Shared files (eval.py, eval_set.json, compress.py) come from next to this script or from the repo."""
66
+ from huggingface_hub import hf_hub_download
67
+
68
+ for name in names:
69
+ if not (HERE / name).exists():
70
+ shutil.copy(hf_hub_download(repo, f"{folder}/{name}"), HERE / name)
71
+
72
+
73
+ def load_compress_module(repo: str, folder: str):
74
+ fetch_shared(repo, folder, "compress.py")
75
+ spec = importlib.util.spec_from_file_location("tinynext_compress", HERE / "compress.py")
76
+ module = importlib.util.module_from_spec(spec)
77
+ spec.loader.exec_module(module)
78
+ return module
79
+
80
+
81
  def load_eval_module(repo: str, folder: str):
82
  """Uses eval.py and eval_set.json next to this script, or fetches them from the repo, so every model is scored
83
  by one implementation on the same text. Registered as a module so worker processes can use it too."""
84
+ fetch_shared(repo, folder, "eval.py", "eval_set.json")
85
  path = HERE / "eval.py"
 
 
 
 
 
86
  spec = importlib.util.spec_from_file_location("tinynext_eval", path)
87
  module = importlib.util.module_from_spec(spec)
88
  sys.modules["tinynext_eval"] = module
 
450
  return tops
451
 
452
 
453
+ def export(model: TinyNext, output: Path, device, prompts: list[list[int]], compressor) -> dict:
454
+ """Float ONNX, and the browser format: the same graph with int8 weights (one scale per 64 values) that the page
455
+ expands back to float32. Both are checked against PyTorch on real prompts over several cached steps.
456
+
457
+ onnxruntime-web's int8 kernels were 8-11x slower than its float ones for this model and int8 activations changed
458
+ the top word too often, so only the download is compressed."""
459
  import onnxruntime as ort
 
460
 
461
  model = model.float().eval().to("cpu")
 
462
  cfg = model.config
463
  head_dim = cfg["dim"] // cfg["heads"]
464
  empty = np.zeros((cfg["layers"], 1, cfg["heads"], 0, head_dim), np.float32)
465
  # Trace with a non-empty cache so no shape gets fixed at zero; the checks below start from an empty one.
466
  with torch.no_grad():
467
  _, trace_k, trace_v = model(torch.tensor([[1, 2]]))
468
+ float_path, web = output / "model.onnx", output / "web"
469
  torch.onnx.export(
470
  BrowserStep(model), (torch.tensor([[5, 6, 7]]), trace_k, trace_v), float_path, opset_version=17,
471
  input_names=["input_ids", "past_k", "past_v"], output_names=["logits", "present_k", "present_v"],
 
473
  "present_k": {3: "total"}, "present_v": {3: "total"}},
474
  dynamo=False,
475
  )
476
+ sizes = compressor.compress(float_path, web)
477
+ expanded = compressor.expand(web)
478
+ shutil.copy(output / "tokenizer.json", web / "tokenizer.json")
479
 
480
  @torch.no_grad()
481
  def torch_step(ids, k, v):
 
484
  return logits[0, -1].numpy(), nk.numpy(), nv.numpy()
485
 
486
  expected = [greedy_tokens(torch_step, ids, empty, 4) for ids in prompts]
487
+ report = {"parity_prompts": len(prompts), "parity_steps_per_prompt": 5, "web_sizes": sizes}
488
+ for name, path in (("float", float_path), ("web_q8", web / "graph.onnx")):
489
  session = ort.InferenceSession(str(path), providers=["CPUExecutionProvider"])
490
  onnx_step = lambda ids, k, v: (lambda out: (out[0][0], out[1], out[2]))(
491
  session.run(None, {"input_ids": ids.astype(np.int64), "past_k": k, "past_v": v}))
492
  got = [greedy_tokens(onnx_step, ids, empty, 4) for ids in prompts]
 
 
 
 
 
 
 
 
493
  report[name] = {
494
+ "megabytes": round(sizes["weights_q8_mb"] if name == "web_q8" else path.stat().st_size / 1e6, 2),
495
+ "same_first_token": round(float(np.mean([g[0] == e[0] for g, e in zip(got, expected)])), 4),
496
+ "same_five_greedy_tokens": round(float(np.mean([g == e for g, e in zip(got, expected)])), 4),
 
497
  }
498
+ expanded.unlink() # the browser rebuilds this; only weights.q8 is published
499
  model.to(device)
500
  return report
501
 
 
663
  (output / "metrics").mkdir(exist_ok=True)
664
  (output / "metrics" / f"{args.name}.json").write_text(json.dumps(scored, indent=1))
665
  tracker.log(headline(scored))
666
+ exported = export(model, output, device, parity_prompts(ev, tokenizer), load_compress_module(args.repo, args.eval_folder))
667
+
668
+ config = { # also written next to the browser files, which need it
669
 
 
670
  **model.config, "parameters": parameters, "seq": args.seq, "special_tokens": dict(zip(SPECIAL, range(len(SPECIAL)))),
671
  "prompt": "No text after the cursor: the text before it. Otherwise: <|suf|> after <|pre|> before <|mid|>.",
672
  }
673
  (output / "config.json").write_text(json.dumps(config, indent=1))
674
+ shutil.copy(output / "config.json", output / "web" / "config.json")
675
  report = {"args": vars(args), "parameters": parameters, "data": counts, "data_minutes": round(data_minutes, 1),
676
  "train_minutes": round(train_minutes, 1), "history": history, "exports": exported}
677
  (output / "report.json").write_text(json.dumps(report, indent=1))
 
679
  print(ev.table([output / "metrics" / f"{args.name}.json"]))
680
  tracker.finish()
681
 
682
+ for name in ("train.py", "eval.py", "eval_set.json", "compress.py"):
683
  shutil.copy(HERE / name, output / name)
684
  if (HERE / "metrics").exists():
685
  for path in (HERE / "metrics").glob("*.json"):