File size: 6,587 Bytes
7871e64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Generate the reduced-precision variants of the SHARP ONNX model.

Each variant is written to its own folder next to `fp32/`, as `model.onnx` plus
`model.onnx.data` (the external weight file).

    python quantize.py fp16
    python quantize.py int8
    python quantize.py int4
    python quantize.py fp8

Requires: onnx, onnxruntime, onnxconverter-common (fp16), ml_dtypes (fp8).
"""

import argparse
import os

import numpy as np
import onnx
from onnx import TensorProto, helper, numpy_helper

SRC_MODEL = os.path.join("fp32", "model.onnx")
DATA_FILE = "model.onnx.data"


def save_model(model: onnx.ModelProto, folder: str) -> str:
    os.makedirs(folder, exist_ok=True)
    path = os.path.join(folder, "model.onnx")
    # onnx.save appends to an existing external-data file instead of replacing it,
    # so any leftovers from an earlier run have to go first
    for stale in (path, os.path.join(folder, DATA_FILE)):
        if os.path.exists(stale):
            os.remove(stale)
    onnx.save(
        model,
        path,
        save_as_external_data=True,
        all_tensors_to_one_file=True,
        location=DATA_FILE,
        size_threshold=1024,
        convert_attribute=False,
    )
    return path


def _cast_resize_fp16_inputs(model: onnx.ModelProto) -> int:
    """Insert Cast nodes so Resize gets float32 roi/scales.

    Operator block lists do not apply to `Constant` nodes folded into the graph, so the
    fp16 scales have to be cast back explicitly.
    """
    initializers = {t.name: t for t in model.graph.initializer}
    producers = {out: node for node in model.graph.node for out in node.output}
    patched = 0
    nodes = []
    for node in model.graph.node:
        if node.op_type != "Resize":
            nodes.append(node)
            continue
        for index in range(1, len(node.input)):
            name = node.input[index]
            if not name:
                continue
            producer = producers.get(name)
            is_fp16 = initializers.get(name) is not None and initializers[name].data_type == TensorProto.FLOAT16
            if producer is not None and producer.op_type == "Constant":
                tensor = [a.t for a in producer.attribute if a.name == "value"][0]
                is_fp16 = tensor.data_type == TensorProto.FLOAT16
            if not is_fp16:
                continue
            cast_out = f"{name}_float32"
            nodes.append(helper.make_node("Cast", [name], [cast_out], name=f"{cast_out}_cast", to=TensorProto.FLOAT))
            node.input[index] = cast_out
            patched += 1
        nodes.append(node)
    if patched:
        model.graph.ClearField("node")
        model.graph.node.extend(nodes)
    return patched


def convert_fp16() -> str:
    # onnxruntime's converter inserts the casts needed to keep every node's operand
    # dtypes consistent, which onnxconverter-common does not do reliably.
    from onnxruntime.transformers.float16 import convert_float_to_float16

    model = onnx.load(SRC_MODEL)  # resolves the external .data file
    # use_symbolic_shape_infer=False: the in-memory proto is >2 GB and cannot be serialized
    model = convert_float_to_float16(model, keep_io_types=True, disable_shape_infer=True)
    print(f"fp16: cast {_cast_resize_fp16_inputs(model)} Resize inputs back to float32")
    return save_model(model, "fp16")


def convert_int8() -> str:
    from onnxruntime.quantization import QuantType, quantize_dynamic

    out = os.path.join("int8", "model.onnx")
    os.makedirs("int8", exist_ok=True)
    quantize_dynamic(
        SRC_MODEL,
        out,
        weight_type=QuantType.QInt8,
        per_channel=True,
        reduce_range=False,
        use_external_data_format=True,
        extra_options={"MatMulConstBOnly": False},
    )
    return out


def convert_int4() -> str:
    from onnxruntime.quantization.matmul_nbits_quantizer import MatMulNBitsQuantizer

    model = onnx.load(SRC_MODEL)
    quantizer = MatMulNBitsQuantizer(model, block_size=32, is_symmetric=True, bits=4)
    quantizer.process()
    # newer onnxruntime wraps the ModelProto in onnx_ir.Model
    quantized = quantizer.model.model if hasattr(quantizer.model, "model") else quantizer.model
    return save_model(quantized, "int4")


def convert_fp8() -> str:
    """Store weights as float8_e4m3fn with a per-output-channel scale.

    Each weight becomes `<name>`_fp8` plus a float32 scale, and a DequantizeLinear node
    restores `<name>`. float8 needs opset 19 and ONNX Runtime >= 1.25 for the CPU kernel.
    """
    import ml_dtypes

    model = onnx.load(SRC_MODEL)
    for opset in model.opset_import:
        if opset.domain in ("", "ai.onnx"):
            opset.version = max(opset.version, 19)

    dequantize = []
    new_initializers = []
    converted = 0
    for init in model.graph.initializer:
        array = numpy_helper.to_array(init)
        if array.dtype != np.float32 or array.ndim < 2 or array.size < 1024:
            continue
        name = init.name
        amax = np.abs(array).reshape(array.shape[0], -1).max(axis=1)
        scale = np.maximum(amax, 1e-9).astype(np.float32) / 448.0  # 448 = E4M3 max
        shape = (array.shape[0],) + (1,) * (array.ndim - 1)
        quantized = (array / scale.reshape(shape)).astype(ml_dtypes.float8_e4m3fn)
        # the fp8 tensor takes a new name; DequantizeLinear rebuilds the original one
        init.CopyFrom(numpy_helper.from_array(quantized, name + "_fp8"))
        new_initializers.append(numpy_helper.from_array(scale, name + "_scale"))
        dequantize.append(
            helper.make_node("DequantizeLinear", [name + "_fp8", name + "_scale"], [name],
                             name=f"{name}_dequantize", axis=0)
        )
        converted += 1

    # the DequantizeLinear nodes read only initializers, so they can run first
    nodes = list(dequantize) + list(model.graph.node)
    model.graph.ClearField("node")
    model.graph.node.extend(nodes)
    model.graph.initializer.extend(new_initializers)
    print(f"fp8: converted {converted} weight tensors")
    return save_model(model, "fp8")


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("variant", choices=["fp16", "int8", "int4", "fp8", "all"])
    args = parser.parse_args()

    steps = (
        [convert_fp16, convert_int8, convert_int4, convert_fp8]
        if args.variant == "all"
        else [globals()[f"convert_{args.variant}"]]
    )
    for step in steps:
        print(f"-> {step.__name__}")
        print("   ", step())


if __name__ == "__main__":
    main()