Unlimited-OCR / scripts /export_onnx.py
Yehor's picture
Update scripts/export_onnx.py
4292aa4 verified
Raw
History Blame Contribute Delete
19.5 kB
"""Export Unlimited-OCR model forward paths to ONNX.
This exports tensor forward graphs, not the Python autoregressive generate()
loop or PIL-based image preprocessing used by model.infer().
"""
import argparse
import math
import os
import sys
import warnings
from pathlib import Path
# Colab/Jupyter can leak a notebook-only backend into the export environment.
# Transformers validates remote-code imports before loading the model and may
# import matplotlib even though export does not use it.
if os.environ.get("MPLBACKEND", "").startswith("module://matplotlib_inline"):
os.environ["MPLBACKEND"] = "Agg"
import torch
from transformers import AutoModel
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from test_inference import validate_local_model_files # noqa: E402
IMAGE_TOKEN_ID = 128815
BOS_TOKEN_ID = 0
class TextLogitsWrapper(torch.nn.Module):
def __init__(self, model: torch.nn.Module) -> None:
super().__init__()
self.model = model
def forward(
self,
input_ids: torch.LongTensor,
attention_mask: torch.Tensor,
) -> torch.Tensor:
outputs = self.model(
input_ids=input_ids,
attention_mask=attention_mask,
use_cache=False,
return_dict=False,
)
return outputs[0]
class TextDecodeWithCacheWrapper(torch.nn.Module):
def __init__(self, model: torch.nn.Module, num_layers: int) -> None:
super().__init__()
self.model = model
self.num_layers = num_layers
def forward(
self,
input_ids: torch.LongTensor,
position_ids: torch.LongTensor,
*past_key_values: torch.Tensor,
) -> tuple[torch.Tensor, ...]:
outputs = self.model(
input_ids=input_ids,
attention_mask=None,
position_ids=position_ids,
past_key_values=unflatten_past_key_values(
past_key_values,
self.num_layers,
),
use_cache=True,
return_dict=False,
)
return (outputs[0], *flatten_past_key_values(outputs[1]))
class ImagePrefillLogitsWrapper(torch.nn.Module):
def __init__(self, model: torch.nn.Module) -> None:
super().__init__()
self.model = model
def forward(
self,
input_ids: torch.LongTensor,
attention_mask: torch.Tensor,
images_crop: torch.Tensor,
images_ori: torch.Tensor,
images_seq_mask: torch.Tensor,
images_spatial_crop: torch.LongTensor,
) -> torch.Tensor:
outputs = self.model(
input_ids=input_ids,
attention_mask=attention_mask,
images=[(images_crop, images_ori)],
images_seq_mask=images_seq_mask,
images_spatial_crop=images_spatial_crop,
use_cache=False,
return_dict=False,
)
return outputs[0]
class ImagePrefillWithCacheWrapper(torch.nn.Module):
def __init__(self, model: torch.nn.Module) -> None:
super().__init__()
self.model = model
def forward(
self,
input_ids: torch.LongTensor,
attention_mask: torch.Tensor,
images_crop: torch.Tensor,
images_ori: torch.Tensor,
images_seq_mask: torch.Tensor,
images_spatial_crop: torch.LongTensor,
) -> tuple[torch.Tensor, ...]:
outputs = self.model(
input_ids=input_ids,
attention_mask=attention_mask,
images=[(images_crop, images_ori)],
images_seq_mask=images_seq_mask,
images_spatial_crop=images_spatial_crop,
use_cache=True,
return_dict=False,
)
return (outputs[0], *flatten_past_key_values(outputs[1]))
def flatten_past_key_values(past_key_values) -> tuple[torch.Tensor, ...]:
if past_key_values is None:
return ()
if hasattr(past_key_values, "to_legacy_cache"):
past_key_values = past_key_values.to_legacy_cache()
values = []
for layer_past in past_key_values:
key, value = layer_past[:2]
values.extend((key, value))
return tuple(values)
def unflatten_past_key_values(
past_key_values: tuple[torch.Tensor, ...],
num_layers: int,
) -> tuple[tuple[torch.Tensor, torch.Tensor], ...]:
expected_tensors = num_layers * 2
if len(past_key_values) != expected_tensors:
raise ValueError(
f"expected {expected_tensors} flattened past tensors, "
f"got {len(past_key_values)}"
)
return tuple(
(past_key_values[layer_idx * 2], past_key_values[layer_idx * 2 + 1])
for layer_idx in range(num_layers)
)
def select_export_device(requested_device: str) -> torch.device:
if requested_device != "auto":
return torch.device(requested_device)
if torch.cuda.is_available():
return torch.device("cuda")
return torch.device("cpu")
def select_export_dtype(requested_dtype: str, device: torch.device) -> torch.dtype:
if requested_dtype == "auto":
return torch.float16 if device.type == "cuda" else torch.float32
dtype_name = requested_dtype.removeprefix("torch.")
dtype = getattr(torch, dtype_name, None)
if not isinstance(dtype, torch.dtype):
raise ValueError(f"Unsupported dtype: {requested_dtype}")
if dtype is torch.bfloat16:
raise SystemExit(
"ONNX Runtime does not support bfloat16 Conv in this graph. "
"Use --dtype auto, --dtype float16 on CUDA, or --dtype float32 on CPU."
)
return dtype
def image_token_count(image_size: int) -> int:
patch_size = 16
downsample_ratio = 4
num_queries = math.ceil((image_size // patch_size) / downsample_ratio)
return (num_queries + 1) * num_queries + 1
def build_text_inputs(
sequence_length: int,
device: torch.device,
) -> tuple[torch.Tensor, torch.Tensor]:
input_ids = torch.full(
(1, sequence_length),
fill_value=IMAGE_TOKEN_ID,
dtype=torch.long,
device=device,
)
input_ids[:, 0] = BOS_TOKEN_ID
attention_mask = torch.ones_like(input_ids)
return input_ids, attention_mask
def cache_head_dim(model: torch.nn.Module) -> int:
if hasattr(model.config, "v_head_dim") and model.config.v_head_dim:
return int(model.config.v_head_dim)
return int(model.config.hidden_size // model.config.num_attention_heads)
def cache_num_heads(model: torch.nn.Module) -> int:
return int(
getattr(
model.config,
"num_key_value_heads",
model.config.num_attention_heads,
)
)
def build_text_decode_cache_inputs(
model: torch.nn.Module,
sequence_length: int,
past_sequence_length: int,
device: torch.device,
dtype: torch.dtype,
) -> tuple[torch.Tensor, ...]:
if getattr(model.config, "use_mla", False):
raise ValueError(
"--kv-cache text decode export currently supports MHA cache shapes; "
"this model config uses MLA cache shapes"
)
if past_sequence_length < 1:
raise ValueError("--past-sequence-length must be at least 1 for cache decode")
input_ids = torch.zeros((1, sequence_length), dtype=torch.long, device=device)
position_ids = torch.arange(
past_sequence_length,
past_sequence_length + sequence_length,
dtype=torch.long,
device=device,
).unsqueeze(0)
cache_shape = (
1,
cache_num_heads(model),
past_sequence_length,
cache_head_dim(model),
)
past_key_values = tuple(
torch.zeros(cache_shape, dtype=dtype, device=device)
for _ in range(model.config.num_hidden_layers * 2)
)
return (input_ids, position_ids, *past_key_values)
def build_image_prefill_inputs(
image_size: int,
sequence_length: int,
device: torch.device,
dtype: torch.dtype,
) -> tuple[torch.Tensor, ...]:
token_count = image_token_count(image_size)
min_sequence_length = token_count + 1
if sequence_length < min_sequence_length:
raise ValueError(
f"--image-sequence-length must be at least {min_sequence_length} "
f"for image_size={image_size}"
)
input_ids = torch.zeros((1, sequence_length), dtype=torch.long, device=device)
input_ids[:, 0] = BOS_TOKEN_ID
input_ids[:, 1 : token_count + 1] = IMAGE_TOKEN_ID
attention_mask = torch.zeros_like(input_ids)
attention_mask[:, :min_sequence_length] = 1
images_seq_mask = torch.zeros_like(input_ids, dtype=torch.bool)
images_seq_mask[:, 1 : token_count + 1] = True
images_crop = torch.zeros(
(1, 3, image_size, image_size),
dtype=dtype,
device=device,
)
images_ori = torch.ones(
(1, 3, image_size, image_size),
dtype=dtype,
device=device,
)
images_spatial_crop = torch.tensor([[1, 1]], dtype=torch.long, device=device)
return (
input_ids,
attention_mask,
images_crop,
images_ori,
images_seq_mask,
images_spatial_crop,
)
def cache_input_names(num_layers: int) -> list[str]:
names = []
for layer_idx in range(num_layers):
names.extend(
[
f"past_key_values.{layer_idx}.key",
f"past_key_values.{layer_idx}.value",
]
)
return names
def cache_output_names(num_layers: int) -> list[str]:
names = []
for layer_idx in range(num_layers):
names.extend(
[
f"present.{layer_idx}.key",
f"present.{layer_idx}.value",
]
)
return names
def cache_dynamic_axes(
input_names: list[str],
output_names: list[str],
) -> dict[str, dict[int, str]]:
axes = {
"input_ids": {0: "batch", 1: "sequence"},
"position_ids": {0: "batch", 1: "sequence"},
"logits": {0: "batch", 1: "sequence"},
}
for name in input_names:
if name.startswith("past_key_values."):
axes[name] = {0: "batch", 2: "past_sequence"}
for name in output_names:
if name.startswith("present."):
axes[name] = {0: "batch", 2: "total_sequence"}
return axes
def export_onnx(
wrapper: torch.nn.Module,
example_inputs: tuple[torch.Tensor, ...],
output_path: Path,
input_names: list[str],
output_names: list[str],
opset: int,
dynamo: bool,
dynamic_axes: dict[str, dict[int, str]] | None,
) -> None:
def run_export() -> None:
torch.onnx.export(
wrapper,
example_inputs,
str(output_path),
input_names=input_names,
output_names=output_names,
opset_version=opset,
dynamo=dynamo,
external_data=True,
dynamic_axes=dynamic_axes,
export_params=True,
do_constant_folding=True,
)
if dynamo or dynamic_axes is not None:
run_export()
return
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
message="You are using the legacy TorchScript-based ONNX export.*",
category=DeprecationWarning,
)
warnings.filterwarnings("ignore", category=torch.jit.TracerWarning)
run_export()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--model",
default=".",
help="Model path or Hugging Face model id. Defaults to current directory.",
)
parser.add_argument(
"--output",
default="onnx/unlimited_ocr.onnx",
help="Output ONNX path. Large weights are written as external data.",
)
parser.add_argument(
"--target",
choices=("image-prefill", "text"),
default="image-prefill",
help=(
"Export image-prefill for OCR vision+LM logits, or text for the "
"language-model logits path only."
),
)
parser.add_argument(
"--device",
default="auto",
help="Export device. auto prefers CUDA and otherwise uses CPU.",
)
parser.add_argument(
"--dtype",
default="auto",
help=(
"Model dtype for export. auto uses float16 on CUDA and float32 "
"on CPU. bfloat16 is not supported by ONNX Runtime for this graph."
),
)
parser.add_argument(
"--opset",
type=int,
default=18,
help="ONNX opset version.",
)
parser.add_argument(
"--sequence-length",
type=int,
default=16,
help=(
"Dummy sequence length for --target text. Cache decode usually "
"uses 1."
),
)
parser.add_argument(
"--past-sequence-length",
type=int,
default=1,
help="Dummy past KV length for --target text --kv-cache.",
)
parser.add_argument(
"--image-size",
type=int,
default=1024,
help="Dummy square image size for --target image-prefill.",
)
parser.add_argument(
"--image-sequence-length",
type=int,
default=512,
help=(
"Fixed sequence length for --target image-prefill. Increase this "
"if prompt tokens plus generated tokens exceed the default."
),
)
parser.add_argument(
"--dynamic-text",
action="store_true",
help="Mark batch and sequence axes dynamic for --target text.",
)
parser.add_argument(
"--dynamic-image",
action="store_true",
help=(
"Deprecated for this MoE model. Image-prefill export uses a fixed "
"sequence length; use --image-sequence-length to set capacity."
),
)
parser.add_argument(
"--dynamo",
action="store_true",
help="Use the torch.export-based ONNX exporter. Legacy tracing is default.",
)
parser.add_argument(
"--kv-cache",
action="store_true",
help=(
"Export cache-aware graph outputs. For image-prefill this returns "
"present.* tensors. For text this exports a decode graph that "
"accepts flattened past_key_values.* tensors and returns updated "
"present.* tensors."
),
)
return parser.parse_args()
def main() -> None:
args = parse_args()
if args.dynamic_text and args.target != "text":
raise SystemExit("--dynamic-text is only supported with --target text")
if args.dynamic_image and args.target != "image-prefill":
raise SystemExit(
"--dynamic-image is only supported with --target image-prefill"
)
device = select_export_device(args.device)
dtype = select_export_dtype(args.dtype, device)
print(f"Validating model files in {args.model!r}")
validate_local_model_files(args.model)
print(f"Loading model from {args.model!r} on {device} with {dtype}")
model = AutoModel.from_pretrained(
args.model,
trust_remote_code=True,
use_safetensors=True,
dtype=dtype,
)
model.config.device = str(device)
model.config.inference_dtype = dtype
model.config.use_cache = args.kv_cache
model.config.output_attentions = False
model.config.output_hidden_states = False
if hasattr(model.config, "sliding_window"):
model.config.sliding_window = None
model = model.eval().to(device)
output_names = ["logits"]
if args.target == "text" and args.kv_cache:
wrapper = TextDecodeWithCacheWrapper(
model,
num_layers=model.config.num_hidden_layers,
).eval()
example_inputs = build_text_decode_cache_inputs(
model,
args.sequence_length,
args.past_sequence_length,
device,
dtype,
)
input_names = [
"input_ids",
"position_ids",
*cache_input_names(model.config.num_hidden_layers),
]
output_names = [
"logits",
*cache_output_names(model.config.num_hidden_layers),
]
dynamic_axes = cache_dynamic_axes(input_names, output_names)
elif args.target == "text":
wrapper = TextLogitsWrapper(model).eval()
example_inputs = build_text_inputs(args.sequence_length, device)
input_names = ["input_ids", "attention_mask"]
dynamic_axes = (
{
"input_ids": {0: "batch", 1: "sequence"},
"attention_mask": {0: "batch", 1: "sequence"},
"logits": {0: "batch", 1: "sequence"},
}
if args.dynamic_text
else None
)
elif args.kv_cache:
wrapper = ImagePrefillWithCacheWrapper(model).eval()
example_inputs = build_image_prefill_inputs(
args.image_size,
args.image_sequence_length,
device,
dtype,
)
input_names = [
"input_ids",
"attention_mask",
"images_crop",
"images_ori",
"images_seq_mask",
"images_spatial_crop",
]
output_names = [
"logits",
*cache_output_names(model.config.num_hidden_layers),
]
dynamic_axes = None
if args.dynamic_image:
print(
"--dynamic-image is ignored for this MoE model; exporting fixed "
f"sequence length {args.image_sequence_length}."
)
else:
wrapper = ImagePrefillLogitsWrapper(model).eval()
example_inputs = build_image_prefill_inputs(
args.image_size,
args.image_sequence_length,
device,
dtype,
)
input_names = [
"input_ids",
"attention_mask",
"images_crop",
"images_ori",
"images_seq_mask",
"images_spatial_crop",
]
dynamic_axes = None
if args.dynamic_image:
print(
"--dynamic-image is ignored for this MoE model; exporting fixed "
f"sequence length {args.image_sequence_length}."
)
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
print(f"Exporting {args.target!r} graph to {output_path}")
print(
"Large checkpoints are stored with ONNX external data next to the .onnx file."
)
try:
with torch.no_grad():
export_onnx(
wrapper,
example_inputs,
output_path,
input_names,
output_names,
args.opset,
args.dynamo,
dynamic_axes=dynamic_axes,
)
except ModuleNotFoundError as error:
if error.name in {"onnx", "onnxscript"}:
raise SystemExit(
"Missing ONNX export dependency. Install it with:\n"
" uv add --group export onnx onnxscript\n"
"or run once with:\n"
" uv run --with onnx --with onnxscript python scripts/export_onnx.py"
) from error
raise
print(f"Export complete: {output_path}")
if __name__ == "__main__":
main()