export the compressed browser format from train.py instead of int8 activations
Browse files- v2/export.py +6 -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
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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 |
-
|
|
|
|
|
|
|
| 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), ("
|
| 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(
|
| 487 |
-
"same_five_greedy_tokens": round(float(
|
| 488 |
-
"cached_step_ms_p50_cpu": round(float(np.median(timings)), 3),
|
| 489 |
}
|
| 490 |
-
|
| 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"):
|