415 lines
15 KiB
Python
415 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Export purpose-lite to fixed-shape fp16 and int8-QDQ ONNX artifacts."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import math
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
from collections import Counter, defaultdict
|
|
from pathlib import Path
|
|
from typing import Any, Sequence
|
|
|
|
import numpy as np
|
|
|
|
from purpose_data import (
|
|
DataError,
|
|
load_jsonl,
|
|
normalize_prompt,
|
|
prompt_hash,
|
|
write_json,
|
|
)
|
|
from train import (
|
|
HEAD_TOKENS,
|
|
MAX_LENGTH,
|
|
TAIL_TOKENS,
|
|
encode_fixed_shape,
|
|
)
|
|
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
DEFAULT_MODEL_DIR = SCRIPT_DIR / "outputs" / "purpose-lite-v1" / "model"
|
|
DEFAULT_CALIBRATION = SCRIPT_DIR / "outputs" / "purpose-lite-v1" / "calibration.json"
|
|
DEFAULT_VALIDATION = SCRIPT_DIR / ".artifacts" / "dataset-v1" / "validation.jsonl"
|
|
DEFAULT_OUTPUT_DIR = SCRIPT_DIR / "outputs" / "purpose-lite-v1" / "export"
|
|
MODEL_VERSION = "purpose-lite-v1"
|
|
SHIPPING_BUDGET_BYTES = 25 * 1024 * 1024
|
|
GOLDEN_PROMPTS = (
|
|
"Fix the typo in the README.",
|
|
" Cafe\u0301\tdeploy\nnow ",
|
|
(
|
|
"EXPLAIN ANALYZE shows a sequential scan before the incident notes. "
|
|
+ "context " * 180
|
|
+ "Find the root cause and explain which query plan evidence proves it."
|
|
),
|
|
"レビューだけして、コードは変更しないでください。",
|
|
)
|
|
|
|
|
|
def stratified_calibration_sample(
|
|
records: Sequence[dict[str, Any]],
|
|
count: int,
|
|
*,
|
|
seed: int,
|
|
) -> list[dict[str, Any]]:
|
|
"""Select an exact, deterministic purpose/slice/language calibration sample."""
|
|
|
|
if not records:
|
|
raise DataError("cannot calibrate quantization from an empty validation split")
|
|
if count <= 0:
|
|
raise DataError("calibration record count must be positive")
|
|
target = min(count, len(records))
|
|
groups: dict[tuple[str, str, str], list[dict[str, Any]]] = defaultdict(list)
|
|
for record in records:
|
|
language = str(record.get("lang", "unknown")).split("-", 1)[0].casefold()
|
|
key = (
|
|
str(record.get("purpose", "unknown")),
|
|
str(record.get("slice", "unknown")),
|
|
language,
|
|
)
|
|
groups[key].append(record)
|
|
|
|
allocations = {}
|
|
remainders = []
|
|
allocated = 0
|
|
for key in sorted(groups):
|
|
quota = len(groups[key]) * target / len(records)
|
|
base = math.floor(quota)
|
|
allocations[key] = base
|
|
allocated += base
|
|
tie_break = hashlib.sha256(f"{seed}\0{key}".encode("utf-8")).hexdigest()
|
|
remainders.append((quota - base, tie_break, key))
|
|
for _, _, key in sorted(remainders, reverse=True)[: target - allocated]:
|
|
allocations[key] += 1
|
|
|
|
selected = []
|
|
for key in sorted(groups):
|
|
ranked = sorted(
|
|
groups[key],
|
|
key=lambda record: hashlib.sha256(
|
|
f"{seed}\0{prompt_hash(record['prompt'])}".encode("utf-8")
|
|
).hexdigest(),
|
|
)
|
|
selected.extend(ranked[: allocations[key]])
|
|
return sorted(
|
|
selected,
|
|
key=lambda record: hashlib.sha256(
|
|
f"{seed + 1}\0{prompt_hash(record['prompt'])}".encode("utf-8")
|
|
).hexdigest(),
|
|
)
|
|
|
|
|
|
def _sha256(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _fixed_shape_inputs(model: Any) -> list[str]:
|
|
names = ["input_ids", "attention_mask"]
|
|
if getattr(model.config, "type_vocab_size", 0) > 1:
|
|
names.append("token_type_ids")
|
|
return names
|
|
|
|
|
|
def _validate_graph(path: Path, input_names: Sequence[str]) -> dict[str, Any]:
|
|
import onnx
|
|
|
|
model = onnx.load(path)
|
|
onnx.checker.check_model(model)
|
|
opset = max(
|
|
item.version for item in model.opset_import if item.domain in ("", "ai.onnx")
|
|
)
|
|
if opset < 17:
|
|
raise DataError(f"{path}: ONNX opset {opset} is below 17")
|
|
shapes = {}
|
|
for value in model.graph.input:
|
|
dimensions = [item.dim_value for item in value.type.tensor_type.shape.dim]
|
|
shapes[value.name] = dimensions
|
|
expected = {name: [1, MAX_LENGTH] for name in input_names}
|
|
if shapes != expected:
|
|
raise DataError(f"{path}: expected fixed inputs {expected}, got {shapes}")
|
|
custom_domains = sorted(
|
|
{
|
|
node.domain
|
|
for node in model.graph.node
|
|
if node.domain not in ("", "ai.onnx")
|
|
}
|
|
)
|
|
if custom_domains:
|
|
raise DataError(f"{path}: custom ONNX op domains are not allowed: {custom_domains}")
|
|
return {
|
|
"opset": opset,
|
|
"inputs": shapes,
|
|
"nodes": len(model.graph.node),
|
|
"operators": sorted({node.op_type for node in model.graph.node}),
|
|
}
|
|
|
|
|
|
def _write_tokenizer_contract(
|
|
output_dir: Path, model_dir: Path, tokenizer: Any, torch: Any
|
|
) -> dict[str, Any]:
|
|
vocab_destination = output_dir / "vocab.txt"
|
|
vocabulary = tokenizer.get_vocab()
|
|
ordered_vocabulary = sorted(vocabulary.items(), key=lambda item: item[1])
|
|
if [token_id for _, token_id in ordered_vocabulary] != list(
|
|
range(len(ordered_vocabulary))
|
|
):
|
|
raise DataError("tokenizer vocabulary IDs are not contiguous")
|
|
vocab_destination.write_text(
|
|
"".join(f"{token}\n" for token, _ in ordered_vocabulary),
|
|
encoding="utf-8",
|
|
)
|
|
tokenizer_json_source = model_dir / "tokenizer.json"
|
|
if not tokenizer_json_source.exists():
|
|
raise DataError(f"{tokenizer_json_source}: tokenizer JSON is missing")
|
|
tokenizer_json_destination = output_dir / "tokenizer.json"
|
|
shutil.copy2(tokenizer_json_source, tokenizer_json_destination)
|
|
|
|
golden_values = []
|
|
for prompt in GOLDEN_PROMPTS:
|
|
encoded = encode_fixed_shape(tokenizer, [prompt], torch)
|
|
golden_values.append(
|
|
{
|
|
"prompt": prompt,
|
|
"normalizedPrompt": normalize_prompt(prompt),
|
|
"inputIds": encoded["input_ids"][0].tolist(),
|
|
"attentionMask": encoded["attention_mask"][0].tolist(),
|
|
"tokenTypeIds": encoded.get(
|
|
"token_type_ids", torch.zeros_like(encoded["input_ids"])
|
|
)[0].tolist(),
|
|
}
|
|
)
|
|
write_json(output_dir / "tokenizer-goldens.json", golden_values)
|
|
contract = {
|
|
"schemaVersion": 1,
|
|
"modelVersion": MODEL_VERSION,
|
|
"tokenizer": {
|
|
"family": "BERT WordPiece",
|
|
"vocabFile": vocab_destination.name,
|
|
"vocabSha256": _sha256(vocab_destination),
|
|
"tokenizerJSON": tokenizer_json_destination.name,
|
|
"tokenizerJSONSha256": _sha256(tokenizer_json_destination),
|
|
"lowercase": bool(getattr(tokenizer, "do_lower_case", True)),
|
|
},
|
|
"normalization": ["Unicode NFKC", "collapse Unicode whitespace", "trim"],
|
|
"input": {
|
|
"shape": [1, MAX_LENGTH],
|
|
"padding": "right",
|
|
"longPromptStrategy": "BERT sentence pair: head and tail",
|
|
"headTokens": HEAD_TOKENS,
|
|
"tailTokens": TAIL_TOKENS,
|
|
"specialTokenLayout": "[CLS] head [SEP] tail [SEP]",
|
|
},
|
|
"specialTokenIds": {
|
|
"padding": tokenizer.pad_token_id,
|
|
"unknown": tokenizer.unk_token_id,
|
|
"classification": tokenizer.cls_token_id,
|
|
"separator": tokenizer.sep_token_id,
|
|
},
|
|
"goldens": "tokenizer-goldens.json",
|
|
}
|
|
write_json(output_dir / "tokenizer-spec.json", contract)
|
|
return contract
|
|
|
|
|
|
def export(args: argparse.Namespace) -> dict[str, Any]:
|
|
try:
|
|
import onnx
|
|
import torch
|
|
from onnxconverter_common import float16
|
|
from onnxruntime.quantization import (
|
|
CalibrationDataReader,
|
|
QuantFormat,
|
|
QuantType,
|
|
quantize_static,
|
|
)
|
|
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
|
except ImportError as exc:
|
|
raise DataError(
|
|
"export dependencies are missing; install requirements.txt"
|
|
) from exc
|
|
|
|
output_dir: Path = args.output_dir
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
tokenizer = AutoTokenizer.from_pretrained(args.model_dir, local_files_only=True)
|
|
model = AutoModelForSequenceClassification.from_pretrained(
|
|
args.model_dir, local_files_only=True
|
|
)
|
|
model.eval()
|
|
input_names = _fixed_shape_inputs(model)
|
|
example = encode_fixed_shape(tokenizer, ["Plan a safe cache migration."], torch)
|
|
|
|
class LogitsModel(torch.nn.Module):
|
|
def __init__(self, inner: Any) -> None:
|
|
super().__init__()
|
|
self.inner = inner
|
|
|
|
def forward(self, *values: Any) -> Any:
|
|
inputs = dict(zip(input_names, values))
|
|
return self.inner(**inputs).logits
|
|
|
|
fp16_path = output_dir / f"{MODEL_VERSION}-fp16.onnx"
|
|
int8_path = output_dir / f"{MODEL_VERSION}-int8-qdq.onnx"
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
fp32_path = Path(temporary) / f"{MODEL_VERSION}-fp32.onnx"
|
|
torch.onnx.export(
|
|
LogitsModel(model),
|
|
tuple(example[name] for name in input_names),
|
|
fp32_path,
|
|
input_names=input_names,
|
|
output_names=["logits"],
|
|
opset_version=args.opset,
|
|
do_constant_folding=True,
|
|
dynamo=False,
|
|
)
|
|
fp32_model = onnx.load(fp32_path)
|
|
fp16_model = float16.convert_float_to_float16(
|
|
fp32_model,
|
|
keep_io_types=True,
|
|
disable_shape_infer=False,
|
|
)
|
|
onnx.save(fp16_model, fp16_path)
|
|
|
|
validation = load_jsonl(args.validation)
|
|
calibration_samples = stratified_calibration_sample(
|
|
validation,
|
|
args.calibration_records,
|
|
seed=args.calibration_seed,
|
|
)
|
|
|
|
class Reader(CalibrationDataReader):
|
|
def __init__(self) -> None:
|
|
self.index = 0
|
|
self.samples = calibration_samples
|
|
|
|
def get_next(self) -> dict[str, np.ndarray] | None:
|
|
if self.index >= len(self.samples):
|
|
return None
|
|
record = self.samples[self.index]
|
|
self.index += 1
|
|
encoded = encode_fixed_shape(tokenizer, [record["prompt"]], torch)
|
|
return {
|
|
name: encoded[name].numpy().astype(np.int64, copy=False)
|
|
for name in input_names
|
|
}
|
|
|
|
quantize_static(
|
|
fp32_path,
|
|
int8_path,
|
|
Reader(),
|
|
quant_format=QuantFormat.QDQ,
|
|
activation_type=QuantType.QUInt8,
|
|
weight_type=QuantType.QInt8,
|
|
per_channel=True,
|
|
# Gather is essential: MiniLM's 30k x 384 embedding table is more than half
|
|
# the checkpoint. Quantizing only MatMul/Gemm leaves a ~47 MB fp32 table and
|
|
# cannot meet the 25 MB parity-floor artifact budget.
|
|
op_types_to_quantize=["Gather", "MatMul", "Gemm"],
|
|
extra_options={
|
|
"ActivationSymmetric": False,
|
|
"WeightSymmetric": True,
|
|
},
|
|
)
|
|
|
|
graph_reports = {
|
|
"fp16": _validate_graph(fp16_path, input_names),
|
|
"int8QDQ": _validate_graph(int8_path, input_names),
|
|
}
|
|
int8_size = int8_path.stat().st_size
|
|
if int8_size > args.shipping_budget_bytes:
|
|
raise DataError(
|
|
f"{int8_path}: {int8_size} bytes exceeds the "
|
|
f"{args.shipping_budget_bytes}-byte shipping budget"
|
|
)
|
|
|
|
tokenizer_contract = _write_tokenizer_contract(
|
|
output_dir, args.model_dir, tokenizer, torch
|
|
)
|
|
calibration_destination = output_dir / "calibration.json"
|
|
shutil.copy2(args.calibration, calibration_destination)
|
|
artifacts = {}
|
|
for name, path in (("fp16", fp16_path), ("int8QDQ", int8_path)):
|
|
artifacts[name] = {
|
|
"path": path.name,
|
|
"bytes": path.stat().st_size,
|
|
"sha256": _sha256(path),
|
|
}
|
|
report = {
|
|
"schemaVersion": 1,
|
|
"modelVersion": MODEL_VERSION,
|
|
"sourceModel": str(args.model_dir),
|
|
"opset": args.opset,
|
|
"fixedInputShape": [1, MAX_LENGTH],
|
|
"inputNames": input_names,
|
|
"calibrationRecords": len(calibration_samples),
|
|
"calibrationSeed": args.calibration_seed,
|
|
"calibrationSample": {
|
|
"strategy": "stratified by purpose, slice, and primary language",
|
|
"purposeCounts": dict(
|
|
sorted(Counter(item["purpose"] for item in calibration_samples).items())
|
|
),
|
|
"sliceCounts": dict(
|
|
sorted(Counter(item["slice"] for item in calibration_samples).items())
|
|
),
|
|
"promptHashes": sorted(
|
|
prompt_hash(item["prompt"]) for item in calibration_samples
|
|
),
|
|
},
|
|
"shippingArtifact": "int8QDQ",
|
|
"shippingBudgetBytes": args.shipping_budget_bytes,
|
|
"shippingBudgetPassed": int8_size <= args.shipping_budget_bytes,
|
|
"artifacts": artifacts,
|
|
"graphs": graph_reports,
|
|
"tokenizerSpecSha256": _sha256(output_dir / "tokenizer-spec.json"),
|
|
"tokenizerContract": tokenizer_contract["input"],
|
|
"calibrationSha256": _sha256(calibration_destination),
|
|
}
|
|
write_json(output_dir / "export-metrics.json", report)
|
|
return report
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--model-dir", type=Path, default=DEFAULT_MODEL_DIR)
|
|
parser.add_argument("--calibration", type=Path, default=DEFAULT_CALIBRATION)
|
|
parser.add_argument("--validation", type=Path, default=DEFAULT_VALIDATION)
|
|
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
|
|
parser.add_argument("--opset", type=int, default=17)
|
|
parser.add_argument("--calibration-records", type=int, default=256)
|
|
parser.add_argument("--calibration-seed", type=int, default=20260730)
|
|
parser.add_argument(
|
|
"--shipping-budget-bytes", type=int, default=SHIPPING_BUDGET_BYTES
|
|
)
|
|
return parser
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
parser = build_parser()
|
|
args = parser.parse_args(argv)
|
|
if (
|
|
args.opset < 17
|
|
or args.calibration_records <= 0
|
|
or args.shipping_budget_bytes <= 0
|
|
):
|
|
parser.error("opset must be >=17 and record/budget values must be positive")
|
|
try:
|
|
report = export(args)
|
|
except (DataError, OSError, ValueError) as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 1
|
|
print(
|
|
f"Exported {MODEL_VERSION}: fp16={report['artifacts']['fp16']['bytes']} bytes, "
|
|
f"int8-QDQ={report['artifacts']['int8QDQ']['bytes']} bytes."
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|