Files
laya/scripts/export_onnx.py
Aashish 21cb46074d feat(onnx): opt-in INT8 quantized copy in the ONNX export
Add --quantize to scripts/export_onnx.py: dynamic per-channel INT8
weight-only quantization of the MatMul layers, written as a sidecar
(<output>.int8.onnx) next to the fp32 export so A/B stays possible.
Measured on the English checkpoint (CPU, 20 ticket states x
choice/noul/score): 1.6 GB -> 581 MB, p50 ~340 ms -> ~250 ms, zero
decision changes (max probability drift 0.09); per-tensor scales
flipped 3 of 20 states, so per-channel is what ships. Weight-free
suite on a synthetic MatMul graph, run in a new CI lane that installs
onnx.
2026-09-25 20:34:56 +05:45

131 lines
5.0 KiB
Python

import argparse
import os
import torch
from laya.agent import Agent
def quantize_model(model_path: str, output_path: str) -> str:
"""Write an INT8 weight-only dynamically quantized copy of `model_path`.
Dynamic quantization converts the weights of every `MatMul` (the attention and MLP linear
layers) to int8 while leaving activations in fp32; the quantization scales are computed per
output channel at load time, so no calibration dataset is needed. The graph structure and
the input/output names are unchanged, which is what lets `ONNXAgent` load the result by
pointing `onnx_path` at it. It is CPU-only: ONNX Runtime has no INT8 MatMul kernel on the
CUDAExecutionProvider, so an int8 graph on GPU falls back to CPU.
"""
from onnxruntime.quantization import QuantType, quantize_dynamic
import onnx
model = onnx.load(model_path)
# The torch exporter leaves intermediate `value_info` shapes that disagree with what the
# quantizer's own shape-inference pass re-derives ("Inferred shape and existing shape
# differ"). The declarations are informational only, so drop them and let quantization
# recompute whatever it needs.
del model.graph.value_info[:]
quantize_dynamic(
model_input=model,
model_output=output_path,
op_types_to_quantize=["MatMul"],
weight_type=QuantType.QInt8,
# One scale per output channel rather than one per tensor. On the English checkpoint
# measured on 20 support-ticket states x choice/noul/score, per-tensor int8 flipped 3
# of 20 decisions (max probability drift 0.29); per-channel flipped none (max 0.09)
# at the same size and speed.
per_channel=True,
)
return output_path
def int8_output_path(output_path: str) -> str:
"""`laya.onnx` -> `laya.int8.onnx`, next to the fp32 export it was quantized from."""
root, ext = os.path.splitext(output_path)
return "%s.int8%s" % (root, ext or ".onnx")
def export_to_onnx(model_id_or_path: str, output_path: str):
print(f"Loading PyTorch Agent from: {model_id_or_path}")
agent = Agent(model_id_or_path, compile=False, device="cpu")
print("Creating dummy input tensors...")
# 1. Dummy tensors for tracing
# (batch_size=1, seq_len=16)
dummy_input_ids = torch.randint(0, 100, (1, 16), dtype=torch.long)
dummy_attention_mask = torch.ones((1, 16), dtype=torch.long)
# (batch_size=1, num_markers=2)
dummy_marker_pos = torch.tensor([[1, 5]], dtype=torch.long)
dummy_marker_mask = torch.tensor([[True, True]], dtype=torch.bool)
# (batch_size=1)
dummy_qtype = torch.tensor([0], dtype=torch.long)
inputs = (
dummy_input_ids,
dummy_attention_mask,
dummy_marker_pos,
dummy_marker_mask,
dummy_qtype,
)
# 2. Define dynamic axes so the model can accept variable batch sizes and sequence lengths
dynamic_axes = {
"input_ids": {0: "batch_size", 1: "seq_len"},
"attention_mask": {0: "batch_size", 1: "seq_len"},
"marker_pos": {0: "batch_size", 1: "num_markers"},
"marker_mask": {0: "batch_size", 1: "num_markers"},
"qtype": {0: "batch_size"},
"logits": {0: "batch_size", 1: "num_markers"},
"act_logits": {0: "batch_size"},
}
input_names = [
"input_ids",
"attention_mask",
"marker_pos",
"marker_mask",
"qtype",
]
output_names = ["logits", "act_logits"]
print(f"Exporting to {output_path} (this may take a minute)...")
out_dir = os.path.dirname(os.path.abspath(output_path))
if out_dir:
os.makedirs(out_dir, exist_ok=True)
# We must detach the encoder because ONNX export runs the model in trace mode.
# The `detach_encoder` flag in forward() just detaches the hidden state gradient,
# but we don't even need to pass it since kwargs are ignored by tracing.
torch.onnx.export(
agent.model,
inputs,
output_path,
export_params=True,
opset_version=18,
do_constant_folding=True,
input_names=input_names,
output_names=output_names,
dynamic_axes=dynamic_axes,
)
print(f"Successfully exported ONNX model to: {output_path}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Export a Laya model to ONNX format")
parser.add_argument("--model", type=str, default="convaiinnovations/laya", help="HuggingFace Hub ID or local path")
parser.add_argument("--output", type=str, default="laya.onnx", help="Output path for the ONNX file")
parser.add_argument("--quantize", action="store_true",
help="Also write an INT8 weight-only quantized copy (CPU-only speed and "
"size win) next to --output, named <output>.int8.onnx")
args = parser.parse_args()
export_to_onnx(args.model, args.output)
if args.quantize:
int8_path = quantize_model(args.output, int8_output_path(args.output))
print(f"Successfully wrote INT8 quantized model to: {int8_path}")