bynn-intelligence's picture
DecisionNode Edge Mini 0.9.1
47dda17 verified
Raw History Blame Contribute Delete
9.17 kB
"""Build the files a faster backend loads, from a model directory (only read) into a new directory.
python -m decisionnode_edge.build openvino path/to/model --out path/to/model-openvino [--lengths 512,2048]
python -m decisionnode_edge.build gguf path/to/model --out path/to/model-gguf --llama-cpp path/to/llama.cpp
openvino The backbone's text path (the token embedding, the layers, the final norm: input_ids [1, L] to the hidden
states [1, L, hidden]) exported with torch.onnx's dynamo exporter in float32 at each static length, then
saved as OpenVINO IR with float32 weights (the bf16 weights, exactly); the runtime runs it in bf16. One IR per
length, since the export unrolls the delta rule over the length: each takes 15 to 20 minutes, and lengths
above a few thousand tokens take hours. Then each IR is run once against torch. Needs the `openvino` extra
(openvino, onnx, onnxscript); `--device cuda` (the default when a GPU is present) keeps the weights on the card.
gguf llama.cpp's converter (convert_hf_to_gguf.py --no-mtp) on links to the backbone files, Q8_0 by default: 8-bit
keeps the answers, 4-bit does not. Needs a llama.cpp checkout and its converter's Python requirements, in the
environment of `--python` (default: this one).
Both write decisionnode_edge_build.json (the backend, the files, the model's fingerprint, versions, times), which
EdgeModel.load reads from `artifacts`.
"""
from __future__ import annotations
import argparse
import importlib.metadata
import json
import os
import shutil
import subprocess
import sys
import time
from .backends import BUILD_FILE, BUILD_FORMAT, fingerprint, link_model_files
DEFAULT_LENGTHS = (512, 2048)
def versions(*names: str) -> dict:
out = {}
for name in names:
try:
out[name] = importlib.metadata.version(name)
except importlib.metadata.PackageNotFoundError:
out[name] = None
return out
def new_directory(out: str) -> None:
if os.path.exists(out):
raise SystemExit(f"{out} exists: the build writes a new directory")
os.makedirs(out)
def write_record(out: str, record: dict) -> None:
with open(os.path.join(out, BUILD_FILE), "w", encoding="utf-8") as f:
json.dump(record, f, indent=2)
f.write("\n")
def model_name(model: str) -> str:
with open(os.path.join(model, "decisionnode_edge.json"), encoding="utf-8") as f:
return json.load(f)["name"]
def build_openvino(model: str, out: str, lengths: list[int], device: str | None) -> dict:
import numpy as np
import openvino as ov
import torch
from transformers import AutoModelForImageTextToText, AutoTokenizer
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
record = {"format": BUILD_FORMAT, "backend": "openvino", "model": model_name(model), "model_fingerprint": fingerprint(model), "files": {},
"weights": "float32 (the bf16 weights, exactly); run in bf16", "export_device": device,
"versions": versions("torch", "transformers", "onnx", "onnxscript", "openvino"), "seconds": {}, "check": {}} # fmt: skip
tokenizer = AutoTokenizer.from_pretrained(model)
record["pad_token_id"] = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else 0
class LastHidden(torch.nn.Module):
def __init__(self, text):
super().__init__()
self.text = text
def forward(self, input_ids):
return self.text(input_ids=input_ids, use_cache=False).last_hidden_state
full = AutoModelForImageTextToText.from_pretrained(model, dtype=torch.float32).to(device).eval()
text = LastHidden(full.model.language_model).eval()
generator = torch.Generator().manual_seed(0)
for length in lengths:
work = os.path.join(out, f"onnx-{length}")
os.makedirs(work)
onnx_file = os.path.join(work, "edge.onnx")
ids = torch.randint(10, 200000, (1, length), generator=generator).to(device)
t0 = time.perf_counter()
with torch.inference_mode():
torch.onnx.export(text, (ids,), onnx_file, dynamo=True, external_data=True, input_names=["input_ids"], output_names=["last_hidden_state"], optimize=True)
t1 = time.perf_counter()
ir = ov.convert_model(onnx_file)
name = f"openvino-{length}.xml"
ov.save_model(ir, os.path.join(out, name), compress_to_fp16=False)
del ir
shutil.rmtree(work)
record["files"][str(length)] = name
record["seconds"][str(length)] = {"export": round(t1 - t0, 1), "convert": round(time.perf_counter() - t1, 1)}
print(json.dumps({"length": length, **record["seconds"][str(length)]}), flush=True)
# each IR against torch (float32) on a prompt shorter than its length, so the padding is read past as at run time
core = ov.Core()
for length in lengths:
n = length - 37
ids = torch.randint(10, 200000, (1, n), generator=generator)
with torch.inference_mode():
want = text(ids.to(device))[0, -1].float().cpu()
compiled = core.compile_model(os.path.join(out, record["files"][str(length)]), "CPU", {"INFERENCE_PRECISION_HINT": "bf16"})
x = np.full((1, length), record["pad_token_id"], dtype=np.int64)
x[0, :n] = ids[0].numpy()
got = torch.from_numpy(np.asarray(compiled.create_infer_request().infer({0: x})[compiled.output(0)][0, n - 1], dtype=np.float32))
cosine = float(torch.nn.functional.cosine_similarity(got, want, dim=0))
record["check"][str(length)] = {"tokens": n, "cosine_to_torch_float32": round(cosine, 6), "largest_difference": round(float((got - want).abs().max()), 4)}
print(json.dumps({"check": length, **record["check"][str(length)]}), flush=True)
del compiled
if not torch.isfinite(got).all() or cosine < 0.99:
raise SystemExit(f"the IR at {length} tokens does not reproduce the backbone (cosine {cosine:.4f}): {out} is incomplete")
return record
def build_gguf(model: str, out: str, llama_cpp: str, outtype: str, python: str) -> dict:
converter = os.path.join(llama_cpp, "convert_hf_to_gguf.py")
if not os.path.isfile(converter):
raise SystemExit(f"{converter} not found: --llama-cpp is the llama.cpp checkout")
source = os.path.join(out, "source") # the converter reads links to the backbone files, never the readout
os.makedirs(source)
link_model_files(model, source)
name = f"model-{outtype}.gguf"
t0 = time.perf_counter()
subprocess.run([python, converter, source, "--no-mtp", "--outfile", os.path.join(out, name), "--outtype", outtype], check=True)
shutil.rmtree(source)
commit = subprocess.run(["git", "-C", llama_cpp, "rev-parse", "--short", "HEAD"], capture_output=True, text=True).stdout.strip() or None
return {"format": BUILD_FORMAT, "backend": "llamacpp", "model": model_name(model), "model_fingerprint": fingerprint(model), "files": {"gguf": name},
"type": outtype, "bytes": os.path.getsize(os.path.join(out, name)), "llama_cpp": commit, "seconds": round(time.perf_counter() - t0, 1)} # fmt: skip
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="python -m decisionnode_edge.build", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("backend", choices=("openvino", "gguf"))
parser.add_argument("model", help="the model directory (only read)")
parser.add_argument("--out", required=True, help="the directory to write (must not exist)")
parser.add_argument("--lengths", default=",".join(map(str, DEFAULT_LENGTHS)), help="openvino: the static lengths in tokens (default 512,2048)")
parser.add_argument("--device", default=None, help="openvino: where the export runs, cuda or cpu (default: cuda when present)")
parser.add_argument("--llama-cpp", help="gguf: the llama.cpp checkout (its convert_hf_to_gguf.py)")
parser.add_argument("--type", default="q8_0", choices=("q8_0", "bf16"), help="gguf: the weight type (default q8_0)")
parser.add_argument("--python", default=sys.executable, help="gguf: the Python that runs llama.cpp's converter (default: this one)")
args = parser.parse_args(argv)
for name in ("config.json", "model.safetensors", "decisionnode_edge.json"):
if not os.path.isfile(os.path.join(args.model, name)):
raise SystemExit(f"{args.model} is not a DecisionNode Edge model directory (no {name})")
if args.backend == "gguf" and not args.llama_cpp:
raise SystemExit("gguf needs --llama-cpp")
new_directory(args.out)
if args.backend == "openvino":
lengths = sorted({int(x) for x in args.lengths.split(",") if x.strip()})
record = build_openvino(args.model, args.out, lengths, args.device)
else:
record = build_gguf(args.model, args.out, args.llama_cpp, args.type, args.python)
write_record(args.out, record)
print(json.dumps({k: v for k, v in record.items() if k != "versions"}), flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())