| """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 |
|
|
| |
| |
| |
| 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 |
|
|
|
|
| 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() |
|
|