File size: 7,621 Bytes
febb4fe 9438749 febb4fe 9438749 febb4fe | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 | #!/usr/bin/env python3
"""Exports vinai/PhoWhisper-<size> to the ONNX files mobile apps run with ONNX Runtime (Android and iOS).
Writes out/<size>/repo/, laid out as the Hugging Face repo that gets published:
onnx/encoder_model.onnx fp32 encoder: input_features [1, n_mels, 3000] -> last_hidden_state
onnx/decoder_model_merged.onnx fp32 decoder with KV cache (use_cache_branch selects the first step)
onnx/encoder_model_quantized.onnx int8 (dynamic, MatMul weights) versions of the two above:
onnx/decoder_model_merged_quantized.onnx the files mobile apps should download
config.json, generation_config.json, preprocessor_config.json, tokenizer files
Weights over 2 GB (medium, large) go to a sidecar <name>.onnx_data next to the graph.
Usage:
.venv/bin/python -I export.py tiny|base|small|medium|large
"""
import shutil
import sys
from pathlib import Path
import numpy as np
import onnx
from onnx import helper, numpy_helper
from onnxruntime.quantization import QuantType, quantize_dynamic
HERE = Path(__file__).resolve().parent
SIZES = ("tiny", "base", "small", "medium", "large")
KEEP = ("encoder_model", "decoder_model_merged")
COPIED = (
"config.json",
"generation_config.json",
"preprocessor_config.json",
"tokenizer.json",
"tokenizer_config.json",
"vocab.json",
"merges.txt",
"added_tokens.json",
"special_tokens_map.json",
"normalizer.json",
)
# Protobuf messages cannot exceed 2 GB; above this the weights go to a sidecar file.
EXTERNAL_DATA_THRESHOLD = int(1.8 * 1024**3)
def check_and_save_large(original):
"""Wraps Optimum's check_and_save_model so it also saves models over 2 GB (PhoWhisper-large).
It starts with model.ByteSize(), which current protobuf refuses to compute over 2 GB, so a
large model goes straight to an external-data save here instead.
"""
def check_and_save(model: onnx.ModelProto, save_path) -> None:
if sum(len(t.raw_data) for t in model.graph.initializer) < EXTERNAL_DATA_THRESHOLD:
return original(model, save_path)
path = Path(save_path)
save(model, path)
onnx.checker.check_model(str(path))
return check_and_save
def export(size: str, raw: Path) -> None:
"""Runs the Optimum export (encoder, decoder, decoder with past, merged decoder) into raw/."""
if (raw / "decoder_model_merged.onnx").exists():
print(f"reusing {raw}")
return
shutil.rmtree(raw, ignore_errors=True)
from optimum.exporters.onnx import convert, main_export
from optimum.onnx import graph_transformations
patched = check_and_save_large(graph_transformations.check_and_save_model)
graph_transformations.check_and_save_model = patched
convert.check_and_save_model = patched
main_export(
f"vinai/PhoWhisper-{size}",
output=raw,
task="automatic-speech-recognition-with-past",
opset=17,
)
def save(model: onnx.ModelProto, path: Path) -> None:
for stale in (path, path.with_name(path.name + "_data")):
stale.unlink(missing_ok=True)
weights = sum(len(t.raw_data) for t in model.graph.initializer)
if weights > EXTERNAL_DATA_THRESHOLD:
onnx.save_model(
model, str(path),
save_as_external_data=True, all_tensors_to_one_file=True,
location=path.name + "_data", size_threshold=1024,
)
else:
onnx.save_model(model, str(path))
def copy_fp32(raw: Path, onnx_dir: Path) -> None:
for name in KEEP:
# Re-saving gathers the weights into one sidecar (Optimum writes one file per tensor for large models).
model = onnx.load(str(raw / f"{name}.onnx"), load_external_data=True)
save(model, onnx_dir / f"{name}.onnx")
print(f"wrote {onnx_dir / name}.onnx")
def rename_input(graph: onnx.GraphProto, old: str, new: str) -> None:
for node in graph.node:
node.input[:] = [new if i == old else i for i in node.input]
for attr in node.attribute:
if attr.type == onnx.AttributeProto.GRAPH:
rename_input(attr.g, old, new)
def pretranspose_constants(graph: onnx.GraphProto, initializers: dict[str, onnx.TensorProto]) -> int:
"""Replaces Transpose(initializer) with a transposed initializer, in graph and its subgraphs.
The decoder's LM head shares the token embedding and multiplies by its transpose, computed at run
time. The quantizer skips a MatMul whose weight comes out of a node, which would leave the LM head
fp32 (80 MB for tiny), so the transpose is folded into a constant first.
"""
folded = 0
for node in list(graph.node):
for attr in node.attribute:
if attr.type == onnx.AttributeProto.GRAPH:
folded += pretranspose_constants(attr.g, initializers)
if node.op_type != "Transpose" or node.input[0] not in initializers:
continue
perm = next((list(a.ints) for a in node.attribute if a.name == "perm"), None)
folded_name = node.input[0] + "_transposed"
if folded_name not in initializers:
weight = numpy_helper.to_array(initializers[node.input[0]])
initializers[folded_name] = numpy_helper.from_array(np.ascontiguousarray(weight.transpose(perm)), folded_name)
graph.node.remove(node)
rename_input(graph, node.output[0], folded_name)
folded += 1
return folded
def quantize(onnx_dir: Path, tmp: Path) -> None:
tmp.mkdir(exist_ok=True)
for name in KEEP:
model = onnx.load(str(onnx_dir / f"{name}.onnx"), load_external_data=True)
initializers = {t.name: t for t in model.graph.initializer}
known = set(initializers)
if pretranspose_constants(model.graph, initializers):
model.graph.initializer.extend(t for n, t in initializers.items() if n not in known)
src = tmp / f"{name}.onnx"
save(model, src)
dst = onnx_dir / f"{name}_quantized.onnx"
# MatMul and Gather (the token embedding), also inside the merged decoder's If branches.
# The encoder's two Conv layers stay fp32, since ConvInteger has no kernel on
# some mobile builds. Signed weights, no reduce_range: ARM's int8 dot product kernels need neither.
quantize_dynamic(
src, dst,
op_types_to_quantize=["MatMul", "Gather"],
weight_type=QuantType.QInt8,
per_channel=False,
reduce_range=False,
use_external_data_format=src.with_name(src.name + "_data").exists(),
extra_options={"MatMulConstBOnly": True, "EnableSubgraph": True},
)
model = onnx.load(str(dst), load_external_data=True)
save(model, dst) # int8 weights of every size fit under 2 GB, so this inlines them again
dst.with_name(dst.name + ".data").unlink(missing_ok=True) # quantize_dynamic's sidecar, now inlined
print(f"wrote {dst}")
shutil.rmtree(tmp)
def copy_tokenizer(raw: Path, repo: Path) -> None:
for name in COPIED:
if (raw / name).exists():
shutil.copy2(raw / name, repo / name)
def main() -> None:
if len(sys.argv) != 2 or sys.argv[1] not in SIZES:
sys.exit(f"usage: {sys.argv[0]} {'|'.join(SIZES)}")
size = sys.argv[1]
work = HERE / "out" / size
raw, repo = work / "raw", work / "repo"
onnx_dir = repo / "onnx"
onnx_dir.mkdir(parents=True, exist_ok=True)
export(size, raw)
copy_fp32(raw, onnx_dir)
quantize(onnx_dir, work / "tmp")
copy_tokenizer(raw, repo)
if __name__ == "__main__":
main()
|