Created
April 1, 2026 17:12
-
-
Save justinchuby/5afa58593269e6bb35177e5ff9fbf28d to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python | |
| # Copyright (c) ONNX Project Contributors | |
| # SPDX-License-Identifier: Apache-2.0 | |
| r"""Phi-4 Multimodal output parity check: ONNX (mobius) vs HuggingFace. | |
| Compares the prefill logits of the ONNX 4-model pipeline (vision, | |
| speech, embedding, decoder) against HuggingFace's PyTorch reference | |
| for every modality combination: | |
| - **Text only**: No image or audio input. | |
| - **Text + image**: Vision encoder active. | |
| - **Text + audio (short)**: Speech encoder with short audio | |
| (audio_projection_mode=0). | |
| - **Text + audio (long)**: Speech encoder with 4096 mel frames | |
| (512 subsampled tokens), exercising the >500-token chunking | |
| code path in the Conformer (speech_conformer_encoder.py L2849). | |
| - **Text + image + audio**: Both encoders active | |
| (audio_projection_mode=1 for combined mode). | |
| For each test case the script reports: | |
| - Max absolute difference | |
| - Mean absolute difference | |
| - Top-1 token agreement (argmax match at every position) | |
| The 4-model split is: | |
| - **Vision** (``vision/model.onnx``): SigLIP encoder + projection | |
| - **Speech** (``speech/model.onnx``): Conformer encoder + projection | |
| - **Embedding** (``embedding/model.onnx``): token embed + InputMixer | |
| - **Decoder** (``model/model.onnx``): LoRA text decoder + lm_head | |
| Prerequisites:: | |
| pip install mobius-ai[transformers] torchaudio | |
| Usage:: | |
| # Full model (default) — all 32 text layers, 27 vision, 24 audio: | |
| python examples/phi4mm_parity.py | |
| # Quick smoke-test with reduced layer counts (2 each): | |
| python examples/phi4mm_parity.py --debug | |
| # Run a single mode: | |
| python examples/phi4mm_parity.py --mode text | |
| # Use fewer layers for faster development / CI testing: | |
| python examples/phi4mm_parity.py --num-text-layers 2 --num-vision-layers 2 --num-audio-blocks 2 | |
| # Provide external test data: | |
| python examples/phi4mm_parity.py \ | |
| --image ~/phi4mm_testdata/images/australia.jpg \ | |
| --audio ~/phi4mm_testdata/test_7_2.wav | |
| # Long audio test (exercises different Conformer sequence lengths): | |
| python examples/phi4mm_parity.py --mode audio-long \ | |
| --audio ~/phi4mm_testdata/TALK_GREENY_.wav | |
| # Also verify onnxruntime-genai works with the exported model: | |
| python examples/phi4mm_parity.py --debug --genai | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import glob as _glob | |
| import os | |
| import sys | |
| import time | |
| # Ensure we import mobius from the local src/ tree, not any installed | |
| # version, so audio/model fixes in this worktree are always active. | |
| # This is needed because this file is run as a standalone script (not via | |
| # `python -m`), so Python does not automatically add the package root to | |
| # sys.path. Without this, a `pip install -e .` from a different worktree | |
| # would shadow the local source. | |
| _SRC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src") | |
| if _SRC_DIR not in sys.path: | |
| sys.path.insert(0, _SRC_DIR) | |
| import numpy as np # noqa: E402 | |
| import onnx_ir as ir # noqa: E402 | |
| import transformers # noqa: E402 | |
| from mobius import build_from_module # noqa: E402 | |
| from mobius._configs import ArchitectureConfig # noqa: E402 | |
| from mobius._testing.ort_inference import OnnxModelSession # noqa: E402 | |
| from mobius._weight_loading import _download_weights # noqa: E402 | |
| from mobius.models.phi import Phi4MMMultiModalModel # noqa: E402 | |
| from mobius.tasks import Phi4MMMultiModalTask # noqa: E402 | |
| # --------------------------------------------------------------------------- | |
| # Configuration | |
| # --------------------------------------------------------------------------- | |
| MODEL_ID = "microsoft/Phi-4-multimodal-instruct" | |
| # Special token IDs for Phi-4 multimodal | |
| IMAGE_TOKEN_ID = 200010 # <|endoftext10|> — image placeholder | |
| AUDIO_TOKEN_ID = 200011 # <|endoftext11|> — audio placeholder | |
| # Audio preprocessing defaults (80-dim mel filterbank at 16 kHz) | |
| AUDIO_SAMPLE_RATE = 16000 | |
| AUDIO_N_MELS = 80 | |
| # Default layer counts: full microsoft/Phi-4-multimodal-instruct model. | |
| # Pass --num-text-layers / --num-vision-layers / --num-audio-blocks to | |
| # reduce these for faster development / CI testing, or use --debug for a | |
| # preset of small values. | |
| DEFAULT_NUM_TEXT_LAYERS = 32 # full decoder depth | |
| DEFAULT_NUM_VISION_LAYERS = 27 # full SigLIP encoder (uses layer_idx=-2 = 26) | |
| DEFAULT_NUM_AUDIO_BLOCKS = 24 # full Conformer encoder | |
| # Reduced counts used by --debug for quick pipeline smoke-tests. | |
| DEBUG_NUM_TEXT_LAYERS = 2 | |
| DEBUG_NUM_VISION_LAYERS = 2 | |
| DEBUG_NUM_AUDIO_BLOCKS = 2 | |
| # Short audio: 100 mel frames → 13 speech tokens after compression | |
| SHORT_AUDIO_FRAMES = 100 | |
| # Long audio: 4096 mel frames -> 512 subsampled tokens. | |
| # The conformer encoder chunks audio when seq_len > 500 subsampled tokens | |
| # (speech_conformer_encoder.py L2849). With 3 stages of stride-2 (8x), | |
| # we need >4000 mel frames to exceed the threshold. 4096 gives 512 tokens. | |
| LONG_AUDIO_FRAMES = 4096 | |
| # Number of new tokens to generate for the text preview (greedy decode). | |
| # Increased from 5 to 50 for more meaningful generation output. | |
| DEFAULT_MAX_NEW_TOKENS = 50 | |
| # Default testdata directory. If present, real files are used automatically | |
| # instead of random dummy tensors. Audio files longer than this are capped. | |
| TESTDATA_DIR = os.path.expanduser("~/phi4mm_testdata") | |
| MAX_AUDIO_SECONDS = 60.0 # cap to avoid very long CPU inference | |
| ALL_MODES = ["text", "vision", "audio-short", "audio-long", "vision-audio"] | |
| def _nemo_subsampling_output_len(num_frames: int, num_stages: int = 3) -> int: | |
| """Compute output token count after NeMo dw_striding subsampling. | |
| Each stage is a stride-2 Conv2d with kernel=3 and symmetric padding=1, | |
| giving ``out = (in - 1) // 2 + 1`` per stage (floor of ceil division). | |
| For 100 frames: 100 → 50 → 25 → 13 (not 100 // 8 = 12). | |
| For 200 frames: 200 → 100 → 50 → 25 (= 200 // 8 = 25, same). | |
| For 128 frames: 128 → 64 → 32 → 16 (= 128 // 8 = 16, same). | |
| """ | |
| for _ in range(num_stages): | |
| num_frames = (num_frames - 1) // 2 + 1 | |
| return num_frames | |
| def _discover_testdata(base_dir: str) -> dict[str, list[str]]: | |
| """Discover real test files in ``base_dir``. | |
| Looks for: | |
| - ``images/``: JPEG/PNG files for vision tests. | |
| - Top-level ``*.wav`` files for audio tests (excludes long files in | |
| sub-directories like ``validation_audios/`` and ``ReproChunkedAudio/``). | |
| Returns a dict with keys ``"images"`` and ``"audios"``, each a sorted list | |
| of absolute file paths. Returns empty lists if the directory does not exist. | |
| """ | |
| images: list[str] = [] | |
| audios: list[str] = [] | |
| if not os.path.isdir(base_dir): | |
| return {"images": images, "audios": audios} | |
| img_dir = os.path.join(base_dir, "images") | |
| if os.path.isdir(img_dir): | |
| for ext in ("*.jpg", "*.jpeg", "*.png"): | |
| images.extend(_glob.glob(os.path.join(img_dir, ext))) | |
| images.sort() | |
| # Only top-level WAV files — skip sub-directory collections which may | |
| # contain 30+ files designed for bulk validation runs. | |
| for ext in ("*.wav",): | |
| audios.extend(_glob.glob(os.path.join(base_dir, ext))) | |
| audios.sort() | |
| return {"images": images, "audios": audios} | |
| # --------------------------------------------------------------------------- | |
| # Model loading | |
| # --------------------------------------------------------------------------- | |
| def load_onnx_package( | |
| model_id: str, | |
| num_text_layers: int, | |
| num_vision_layers: int, | |
| num_audio_blocks: int, | |
| *, | |
| trust_remote_code: bool = False, | |
| ): | |
| """Build the Phi4MM 4-model ONNX package with real weights. | |
| Returns (pkg, config) where pkg has keys: | |
| vision, speech, embedding, model. | |
| """ | |
| hf_config = transformers.AutoConfig.from_pretrained( | |
| model_id, trust_remote_code=trust_remote_code | |
| ) | |
| text_config = hf_config if not hasattr(hf_config, "text_config") else hf_config.text_config | |
| text_config.num_hidden_layers = num_text_layers | |
| config = ArchitectureConfig.from_transformers(text_config) | |
| # Use float32 for ORT session compatibility | |
| config.dtype = ir.DataType.FLOAT | |
| if config.vision is not None: | |
| config.vision.num_hidden_layers = num_vision_layers | |
| if config.audio is not None: | |
| config.audio.num_blocks = num_audio_blocks | |
| module = Phi4MMMultiModalModel(config) | |
| pkg = build_from_module(module, config, task=Phi4MMMultiModalTask()) | |
| state_dict = _download_weights(model_id) | |
| state_dict = module.preprocess_weights(state_dict) | |
| pkg.apply_weights(state_dict) | |
| return pkg, config | |
| def _patch_cache_utils_for_phi4mm() -> None: | |
| """Patch transformers cache_utils for compatibility with Phi4MM's HF model code. | |
| Phi4MM's ``modeling_phi4mm.py`` was written against transformers 4.x and | |
| uses several cache APIs that were removed or renamed in 5.x. We patch them | |
| back onto the live classes so the dynamic module can import and run. | |
| Patches applied: | |
| - ``SlidingWindowCache``: removed in 5.x; stub with ``DynamicCache``. | |
| - ``DynamicCache.get_usable_length``: removed in 5.x, replaced by | |
| ``get_seq_length``. The two-argument signature | |
| ``(kv_seq_len, layer_idx)`` is compatible because ``get_seq_length`` | |
| ignores both arguments and returns the current stored sequence length. | |
| - ``DynamicCache.from_legacy_cache``: removed in 5.x; returns an empty | |
| ``DynamicCache`` (the legacy ``None`` path, which is the only remaining | |
| caller in Phi4MM). | |
| """ | |
| from transformers import cache_utils | |
| if not hasattr(cache_utils, "SlidingWindowCache"): | |
| cache_utils.SlidingWindowCache = cache_utils.DynamicCache | |
| dc = cache_utils.DynamicCache | |
| if not hasattr(dc, "get_usable_length"): | |
| # get_usable_length(kv_seq_len, layer_idx) → usable cached length. | |
| # In transformers 5.x the equivalent is get_seq_length() (no args). | |
| dc.get_usable_length = lambda self, kv_seq_len=None, layer_idx=None: ( | |
| self.get_seq_length() | |
| ) | |
| if not hasattr(dc, "from_legacy_cache"): | |
| # from_legacy_cache(past_key_values) → DynamicCache. | |
| # In Phi4MM the only call site passes None (no existing cache), so | |
| # returning an empty DynamicCache is always correct here. | |
| dc.from_legacy_cache = classmethod(lambda cls, past=None: cls()) | |
| if not hasattr(dc, "to_legacy_cache"): | |
| # to_legacy_cache() → tuple of (key, value) tuples per layer. | |
| # Transformers 5.x removed this method; the Phi4MM model only calls | |
| # it on the legacy-cache code path (when past_key_values was a tuple), | |
| # which newer transformers never triggers. Add a stub for safety. | |
| def _to_legacy_cache(self): | |
| if hasattr(self, "key_cache") and hasattr(self, "value_cache"): | |
| return tuple(zip(self.key_cache, self.value_cache)) | |
| return () | |
| dc.to_legacy_cache = _to_legacy_cache | |
| def _patch_num_logits_for_phi4mm(model) -> None: | |
| """Patch a loaded Phi4MM model to handle num_logits_to_keep=None. | |
| Newer transformers (5.x) passes ``num_logits_to_keep=None`` from | |
| ``prepare_inputs_for_generation`` into the model's ``forward``. | |
| Phi4MM's forward was written for 4.x which always passed an ``int`` | |
| (default 0). Passing ``None`` causes a ``TypeError`` at the slice | |
| ``hidden_states[:, -num_logits_to_keep:, :]``. | |
| We wrap the model's instance ``forward`` to coerce ``None`` → ``0`` | |
| (meaning: return logits for all tokens, the same as the old default). | |
| """ | |
| import functools | |
| _orig_forward = model.forward | |
| @functools.wraps(_orig_forward) | |
| def _patched(*args, num_logits_to_keep=0, **kwargs): | |
| if num_logits_to_keep is None: | |
| num_logits_to_keep = 0 | |
| return _orig_forward(*args, num_logits_to_keep=num_logits_to_keep, **kwargs) | |
| model.forward = _patched | |
| def _patch_no_meta_init() -> None: | |
| """Remove meta-device init context from PreTrainedModel.get_init_context. | |
| Transformers 5.x always wraps model construction in a ``torch.device("meta")`` | |
| context inside ``from_pretrained``. Phi4MM's ``NemoConvSubsampling.__init__`` | |
| calls ``int()`` on a tensor that was created with ``torch.tensor(feat_in, ...)`` | |
| inside this context — which is a meta tensor — causing a hard crash. | |
| Removing the meta-device context means the model is allocated with real weights | |
| immediately (slightly higher peak memory, but always correct). | |
| """ | |
| import torch | |
| import transformers | |
| _original = transformers.PreTrainedModel.get_init_context.__func__ # type: ignore[attr-defined] | |
| @classmethod # type: ignore[misc] | |
| def _no_meta_get_init_context( | |
| cls, dtype, is_quantized, _is_ds_init_called, allow_all_kernels | |
| ): | |
| contexts = _original(cls, dtype, is_quantized, _is_ds_init_called, allow_all_kernels) | |
| # Drop torch.device("meta") — Phi4MM conformer encoder is not meta-safe | |
| return [c for c in contexts if not (isinstance(c, torch.device) and str(c) == "meta")] | |
| transformers.PreTrainedModel.get_init_context = _no_meta_get_init_context | |
| def _patch_tied_weights_keys_for_phi4mm() -> None: | |
| """Fix get_expanded_tied_weights_keys for Phi4MM with transformers 5.x. | |
| Phi4MM defines ``_tied_weights_keys = ["lm_head.weight"]`` (transformers 4.x | |
| list format). Transformers 5.x ``get_expanded_tied_weights_keys`` expects a | |
| dict ``{target: source}``, and crashes with ``AttributeError: 'list' object | |
| has no attribute 'keys'`` when it finds a list. | |
| We patch the method to convert the list to the standard Phi4 dict mapping | |
| ``{"lm_head.weight": "model.embed_tokens.weight"}`` so that weight tying | |
| works correctly after loading from the checkpoint (where ``lm_head.weight`` | |
| is absent because it is tied). | |
| """ | |
| import transformers | |
| _original = transformers.PreTrainedModel.get_expanded_tied_weights_keys | |
| def _patched(self, all_submodels: bool = False) -> dict: | |
| if isinstance(self._tied_weights_keys, list): | |
| # Convert 4.x list format to 5.x dict format. | |
| # Standard Phi4 pattern: lm_head tied to embed_tokens. | |
| self._tied_weights_keys = {"lm_head.weight": "model.embed_tokens.weight"} | |
| return _original(self, all_submodels=all_submodels) | |
| transformers.PreTrainedModel.get_expanded_tied_weights_keys = _patched | |
| def _patch_peft_for_phi4mm() -> None: | |
| """Patch peft to handle Phi4MMModel missing prepare_inputs_for_generation. | |
| The HF Phi4MM wraps its inner ``Phi4MMModel`` with peft using | |
| ``task_type="CAUSAL_LM"``, but ``Phi4MMModel`` (the backbone) | |
| doesn't have ``prepare_inputs_for_generation``. Newer peft | |
| versions crash on this. We patch the PeftModelForCausalLM init | |
| to catch and ignore the missing attribute. | |
| """ | |
| try: | |
| import peft.peft_model as pm | |
| _orig_init = pm.PeftModelForCausalLM.__init__ | |
| def _patched_init(self, model, peft_config, adapter_name="default", **kwargs): | |
| # Add a dummy method so peft doesn't crash | |
| if not hasattr(model, "prepare_inputs_for_generation"): | |
| model.prepare_inputs_for_generation = lambda *a, **kw: {} | |
| _orig_init(self, model, peft_config, adapter_name=adapter_name, **kwargs) | |
| pm.PeftModelForCausalLM.__init__ = _patched_init | |
| except (ImportError, AttributeError): | |
| # peft is not installed, or the internal module structure has changed — | |
| # either way the patch is not needed and we can proceed without it. | |
| pass | |
| def load_hf_model( | |
| model_id: str, | |
| num_text_layers: int, | |
| num_audio_blocks: int | None = None, | |
| num_vision_layers: int | None = None, | |
| *, | |
| trust_remote_code: bool = False, | |
| ): | |
| """Load the HuggingFace Phi4MM model with merged LoRA adapters. | |
| Uses ``from_pretrained`` with a layer-count-reduced config so that | |
| only the first ``num_text_layers`` decoder layers are instantiated, | |
| matching the ONNX model. Extra checkpoint layers are silently | |
| skipped by transformers. | |
| If ``num_audio_blocks`` is provided the conformer encoder is truncated | |
| to that many blocks after loading, so the HF reference uses the same | |
| encoder depth as the ONNX model built with that value. | |
| If ``num_vision_layers`` is provided the SigLIP encoder is truncated | |
| to that many layers. The ONNX model with ``num_vision_layers=N`` | |
| runs ``N - 1`` encoder layers (HF uses ``layer_idx=-2``, i.e. the | |
| second-to-last hidden state); truncating HF to ``N`` layers ensures | |
| ``layer_idx=-2`` resolves to the same encoder depth as ONNX. | |
| Merging all LoRA adapters into the base weights ensures the HF | |
| model's behavior matches the ONNX model, which always applies all | |
| adapters regardless of input_mode. | |
| Returns (model, tokenizer). | |
| """ | |
| import torch | |
| _patch_cache_utils_for_phi4mm() | |
| _patch_peft_for_phi4mm() | |
| _patch_no_meta_init() | |
| _patch_tied_weights_keys_for_phi4mm() | |
| hf_config = transformers.AutoConfig.from_pretrained( | |
| model_id, | |
| # trust_remote_code is required for Phi4MM because its model class | |
| # is distributed in the HuggingFace repo as custom Python code | |
| # (not yet part of the transformers library core). Only use this | |
| # with model IDs you trust; do NOT set it for arbitrary user input. | |
| trust_remote_code=trust_remote_code, | |
| ) | |
| text_config = hf_config if not hasattr(hf_config, "text_config") else hf_config.text_config | |
| text_config.num_hidden_layers = num_text_layers | |
| text_config._attn_implementation = "eager" | |
| tokenizer = transformers.AutoTokenizer.from_pretrained( | |
| model_id, trust_remote_code=trust_remote_code | |
| ) | |
| # Use from_pretrained (not from_config) to avoid a transformers bug | |
| # where post_init() mishandles list-typed _tied_weights_keys. | |
| # Extra checkpoint layers (beyond num_text_layers) are silently ignored. | |
| # Do NOT use device_map: accelerate initialises on meta device first, | |
| # which breaks Phi4MM's conformer init (calls .item() on meta tensors). | |
| model = transformers.AutoModelForCausalLM.from_pretrained( | |
| model_id, | |
| config=hf_config, | |
| torch_dtype=torch.float32, | |
| trust_remote_code=trust_remote_code, # see note above | |
| ) | |
| model.eval() | |
| # Transformers 5.x fails to tie lm_head.weight → embed_tokens.weight when | |
| # the model is not initialised on meta device. Force the tie manually so | |
| # the output projection uses the correct learned embedding matrix. | |
| if hasattr(model, "lm_head") and hasattr(model, "model"): | |
| embed_weight = model.model.embed_tokens.weight | |
| if model.lm_head.weight.data_ptr() != embed_weight.data_ptr(): | |
| model.lm_head.weight = embed_weight | |
| print(" Manually tied lm_head.weight → model.embed_tokens.weight") | |
| # Patch num_logits_to_keep=None → 0 (newer transformers passes None) | |
| _patch_num_logits_for_phi4mm(model) | |
| # Merge all LoRA adapters into base weights | |
| _merge_all_lora_adapters(model) | |
| # Truncate conformer encoder to match ONNX audio block count | |
| if num_audio_blocks is not None: | |
| try: | |
| enc = model.model.embed_tokens_extend.audio_embed.encoder | |
| enc.encoders = enc.encoders[:num_audio_blocks] | |
| print(f" Truncated audio conformer to {num_audio_blocks} blocks") | |
| except AttributeError: | |
| # Model structure differs from expected — truncation not needed. | |
| pass | |
| # Truncate SigLIP encoder to match ONNX vision depth. | |
| # ONNX runs (num_vision_layers - 1) layers; HF uses layer_idx=-2. | |
| # Truncating HF to num_vision_layers total layers makes layer_idx=-2 | |
| # resolve to the same depth (index num_vision_layers - 2). | |
| if num_vision_layers is not None: | |
| try: | |
| enc = model.model.embed_tokens_extend.image_embed.img_processor.encoder | |
| enc.layers = enc.layers[:num_vision_layers] | |
| print(f" Truncated SigLIP encoder to {num_vision_layers} layers") | |
| except AttributeError: | |
| # Model structure differs from expected — truncation not needed. | |
| pass | |
| if tokenizer.pad_token is None: | |
| tokenizer.pad_token = tokenizer.eos_token | |
| return model, tokenizer | |
| def _merge_all_lora_adapters(model) -> None: | |
| """Merge all LoRA adapters into base weights and freeze. | |
| The ONNX model always applies all LoRA adapters (they are baked | |
| into the graph). HuggingFace's Phi4MM selectively activates | |
| adapters per input_mode. To get parity, we merge all adapter | |
| contributions into the base weights. | |
| """ | |
| try: | |
| from peft.tuners.lora.layer import LoraLayer | |
| except ImportError: | |
| print(" peft not installed — skipping LoRA merge") | |
| return | |
| merged_count = 0 | |
| for module in model.modules(): | |
| if not isinstance(module, LoraLayer): | |
| continue | |
| if not hasattr(module, "lora_A"): | |
| continue | |
| for adapter_name in list(module.lora_A.keys()): | |
| scaling = module.scaling[adapter_name] | |
| lora_a = module.lora_A[adapter_name].weight.data | |
| lora_b = module.lora_B[adapter_name].weight.data | |
| module.weight.data += scaling * lora_b @ lora_a | |
| lora_a.zero_() | |
| lora_b.zero_() | |
| merged_count += 1 | |
| # Prevent the forward pass from switching or disabling adapters | |
| model.set_lora_adapter = lambda adapter_name: None | |
| model.unset_lora_adapter = lambda: None | |
| print(f" Merged {merged_count} LoRA adapter weights into base model") | |
| # --------------------------------------------------------------------------- | |
| # ONNX pipeline helpers | |
| # --------------------------------------------------------------------------- | |
| def run_onnx_pipeline( | |
| pkg, | |
| config: ArchitectureConfig, | |
| input_ids: np.ndarray, | |
| *, | |
| pixel_values: np.ndarray | None = None, | |
| image_sizes: np.ndarray | None = None, | |
| audio_features: np.ndarray | None = None, | |
| audio_projection_mode: int = 0, | |
| ) -> np.ndarray: | |
| """Run the 4-model ONNX pipeline and return prefill logits. | |
| Chains: vision → speech → embedding → decoder (single-step prefill). | |
| """ | |
| hidden_size = config.hidden_size | |
| # Step 1: Vision encoder | |
| if pixel_values is not None: | |
| vision_session = OnnxModelSession(pkg["vision"]) | |
| if pixel_values.ndim == 5: | |
| n, crops, c, h, w = pixel_values.shape | |
| pixel_values = pixel_values.reshape(n * crops, c, h, w) | |
| if image_sizes is None: | |
| image_sizes = np.array( | |
| [[pixel_values.shape[-2], pixel_values.shape[-1]]], | |
| dtype=np.int64, | |
| ) | |
| vision_out = vision_session.run( | |
| {"pixel_values": pixel_values, "image_sizes": image_sizes} | |
| ) | |
| image_features = vision_out["image_features"] | |
| vision_session.close() | |
| if image_features.ndim == 3: | |
| image_features = image_features[0] | |
| else: | |
| image_features = np.zeros((0, hidden_size), dtype=np.float32) | |
| # Step 2: Speech encoder | |
| if audio_features is not None: | |
| speech_session = OnnxModelSession(pkg["speech"]) | |
| audio_sizes = np.array([audio_features.shape[1]], dtype=np.int64) | |
| speech_out = speech_session.run( | |
| { | |
| "audio_embeds": audio_features, | |
| "audio_sizes": audio_sizes, | |
| "audio_projection_mode": np.array(audio_projection_mode, dtype=np.int64), | |
| } | |
| ) | |
| speech_feats = speech_out["audio_features"] | |
| speech_session.close() | |
| if speech_feats.ndim == 3: | |
| speech_feats = speech_feats[0] | |
| else: | |
| speech_feats = np.zeros((0, hidden_size), dtype=np.float32) | |
| # Step 3: Embedding (fuse text + vision + speech) | |
| embedding_session = OnnxModelSession(pkg["embedding"]) | |
| embed_out = embedding_session.run( | |
| { | |
| "input_ids": input_ids, | |
| "image_features": image_features, | |
| "audio_features": speech_feats, | |
| } | |
| ) | |
| inputs_embeds = embed_out["inputs_embeds"] | |
| embedding_session.close() | |
| # Step 4: Decoder (single-step prefill, no KV cache history) | |
| seq_len = inputs_embeds.shape[1] | |
| decoder_feeds: dict[str, np.ndarray] = { | |
| "inputs_embeds": inputs_embeds, | |
| "attention_mask": np.ones((1, seq_len), dtype=np.int64), | |
| "position_ids": np.arange(seq_len, dtype=np.int64)[np.newaxis, :], | |
| } | |
| for i in range(config.num_hidden_layers): | |
| decoder_feeds[f"past_key_values.{i}.key"] = np.zeros( | |
| (1, config.num_key_value_heads, 0, config.head_dim), | |
| dtype=np.float32, | |
| ) | |
| decoder_feeds[f"past_key_values.{i}.value"] = np.zeros( | |
| (1, config.num_key_value_heads, 0, config.head_dim), | |
| dtype=np.float32, | |
| ) | |
| decoder_session = OnnxModelSession(pkg["model"]) | |
| decoder_out = decoder_session.run(decoder_feeds) | |
| decoder_session.close() | |
| return decoder_out["logits"] | |
| # --------------------------------------------------------------------------- | |
| # HuggingFace reference helpers | |
| # --------------------------------------------------------------------------- | |
| def run_hf_forward( | |
| model, | |
| tokenizer, | |
| prompt: str, | |
| *, | |
| pixel_values: np.ndarray | None = None, | |
| image_sizes: np.ndarray | None = None, | |
| audio_features: np.ndarray | None = None, | |
| num_image_tokens: int = 0, | |
| num_audio_tokens: int = 0, | |
| input_mode: int = 0, | |
| ) -> np.ndarray: | |
| """Run a single HuggingFace forward pass and return logits. | |
| Builds input_ids internally. ``num_audio_tokens`` must be the | |
| *compressed* token count (after the Conformer's 8x stride), | |
| matching the number of audio placeholder tokens the HF model | |
| expects. | |
| input_mode values (from HF Phi4MM): | |
| 0 = LANGUAGE (text only) | |
| 1 = VISION (text + image) | |
| 2 = SPEECH (text + audio) | |
| 3 = VISION_SPEECH (text + image + audio) | |
| """ | |
| import torch | |
| device = next(model.parameters()).device | |
| dtype = next(model.parameters()).dtype | |
| hf_input_ids = build_input_ids( | |
| tokenizer, | |
| prompt, | |
| num_image_tokens=num_image_tokens, | |
| num_audio_tokens=num_audio_tokens, | |
| ) | |
| seq_len = hf_input_ids.shape[1] | |
| kwargs: dict = { | |
| "input_ids": torch.from_numpy(hf_input_ids).to(device), | |
| "attention_mask": torch.ones(1, seq_len, dtype=torch.long, device=device), | |
| "position_ids": torch.arange(seq_len, device=device).unsqueeze(0), | |
| "input_mode": input_mode, | |
| "use_cache": False, # Parity check only needs logits, not KV cache state | |
| } | |
| if pixel_values is not None: | |
| kwargs["input_image_embeds"] = torch.from_numpy(pixel_values).to( | |
| device=device, dtype=dtype | |
| ) | |
| if image_sizes is not None: | |
| kwargs["image_sizes"] = torch.from_numpy(image_sizes).to(device) | |
| if audio_features is not None: | |
| kwargs["input_audio_embeds"] = torch.from_numpy(audio_features).to( | |
| device=device, dtype=dtype | |
| ) | |
| # audio_embed_sizes = compressed token count (must match | |
| # placeholder count in input_ids) | |
| kwargs["audio_embed_sizes"] = torch.tensor([num_audio_tokens], device=device) | |
| with torch.no_grad(): | |
| out = model(**kwargs) | |
| return out.logits.cpu().numpy() | |
| def generate_onnx( | |
| pkg, | |
| config: ArchitectureConfig, | |
| tokenizer, | |
| input_ids: np.ndarray, | |
| *, | |
| pixel_values: np.ndarray | None = None, | |
| image_sizes: np.ndarray | None = None, | |
| audio_features: np.ndarray | None = None, | |
| audio_projection_mode: int = 0, | |
| max_new_tokens: int = DEFAULT_MAX_NEW_TOKENS, | |
| ) -> list[int]: | |
| """Greedy decode up to max_new_tokens steps using the ONNX 4-model pipeline. | |
| Runs a full prefill then auto-regressively generates one token at a time | |
| using the decoder's KV cache outputs (``present.{i}.key/value``). Vision | |
| and audio features are only processed during the prefill step; subsequent | |
| decode steps pass empty feature tensors to the embedding model. | |
| """ | |
| hidden_size = config.hidden_size | |
| # ── Prefill: run vision + speech + embedding once ────────────────────── | |
| if pixel_values is not None: | |
| vision_session = OnnxModelSession(pkg["vision"]) | |
| pv = ( | |
| pixel_values.reshape(-1, *pixel_values.shape[-3:]) | |
| if pixel_values.ndim == 5 | |
| else pixel_values | |
| ) | |
| if image_sizes is None: | |
| image_sizes = np.array([[pv.shape[-2], pv.shape[-1]]], dtype=np.int64) | |
| image_features = vision_session.run({"pixel_values": pv, "image_sizes": image_sizes})[ | |
| "image_features" | |
| ] | |
| if image_features.ndim == 3: | |
| image_features = image_features[0] | |
| vision_session.close() | |
| else: | |
| image_features = np.zeros((0, hidden_size), dtype=np.float32) | |
| if audio_features is not None: | |
| speech_session = OnnxModelSession(pkg["speech"]) | |
| audio_sizes = np.array([audio_features.shape[1]], dtype=np.int64) | |
| speech_feats = speech_session.run( | |
| { | |
| "audio_embeds": audio_features, | |
| "audio_sizes": audio_sizes, | |
| "audio_projection_mode": np.array(audio_projection_mode, dtype=np.int64), | |
| } | |
| )["audio_features"] | |
| if speech_feats.ndim == 3: | |
| speech_feats = speech_feats[0] | |
| speech_session.close() | |
| else: | |
| speech_feats = np.zeros((0, hidden_size), dtype=np.float32) | |
| # Keep embedding session open for decode steps (single-token embedding) | |
| embedding_session = OnnxModelSession(pkg["embedding"]) | |
| inputs_embeds = embedding_session.run( | |
| { | |
| "input_ids": input_ids, | |
| "image_features": image_features, | |
| "audio_features": speech_feats, | |
| } | |
| )["inputs_embeds"] | |
| empty_features = np.zeros((0, hidden_size), dtype=np.float32) | |
| # ── Prefill decoder pass ─────────────────────────────────────────────── | |
| seq_len = inputs_embeds.shape[1] | |
| decoder_feeds: dict[str, np.ndarray] = { | |
| "inputs_embeds": inputs_embeds, | |
| "attention_mask": np.ones((1, seq_len), dtype=np.int64), | |
| "position_ids": np.arange(seq_len, dtype=np.int64)[np.newaxis, :], | |
| } | |
| for i in range(config.num_hidden_layers): | |
| decoder_feeds[f"past_key_values.{i}.key"] = np.zeros( | |
| (1, config.num_key_value_heads, 0, config.head_dim), dtype=np.float32 | |
| ) | |
| decoder_feeds[f"past_key_values.{i}.value"] = np.zeros( | |
| (1, config.num_key_value_heads, 0, config.head_dim), dtype=np.float32 | |
| ) | |
| decoder_session = OnnxModelSession(pkg["model"]) | |
| decoder_out = decoder_session.run(decoder_feeds) | |
| logits = decoder_out["logits"] | |
| # Extract KV cache for decode steps (output names: present.{i}.key/value) | |
| kv_cache: dict[str, np.ndarray] = {} | |
| for i in range(config.num_hidden_layers): | |
| kv_cache[f"past_key_values.{i}.key"] = decoder_out[f"present.{i}.key"] | |
| kv_cache[f"past_key_values.{i}.value"] = decoder_out[f"present.{i}.value"] | |
| # ── Decode loop ──────────────────────────────────────────────────────── | |
| eos_ids: set[int] = set() | |
| if tokenizer.eos_token_id is not None: | |
| eos_ids.add(int(tokenizer.eos_token_id)) | |
| # Phi4MM also uses <|end|> as a stop token | |
| end_id = tokenizer.convert_tokens_to_ids("<|end|>") | |
| if end_id is not None and end_id != tokenizer.unk_token_id: | |
| eos_ids.add(int(end_id)) | |
| generated_tokens: list[int] = [] | |
| current_seq_len = seq_len | |
| for _ in range(max_new_tokens): | |
| next_token_id = int(np.argmax(logits[0, -1, :])) | |
| generated_tokens.append(next_token_id) | |
| if next_token_id in eos_ids: | |
| break | |
| # Embed only the new token — no image/audio features at decode time | |
| new_ids = np.array([[next_token_id]], dtype=np.int64) | |
| new_embeds = embedding_session.run( | |
| { | |
| "input_ids": new_ids, | |
| "image_features": empty_features, | |
| "audio_features": empty_features, | |
| } | |
| )["inputs_embeds"] # [1, 1, hidden_size] | |
| current_seq_len += 1 | |
| step_feeds: dict[str, np.ndarray] = { | |
| "inputs_embeds": new_embeds, | |
| "attention_mask": np.ones((1, current_seq_len), dtype=np.int64), | |
| "position_ids": np.array([[current_seq_len - 1]], dtype=np.int64), | |
| **kv_cache, | |
| } | |
| step_out = decoder_session.run(step_feeds) | |
| logits = step_out["logits"] | |
| for i in range(config.num_hidden_layers): | |
| kv_cache[f"past_key_values.{i}.key"] = step_out[f"present.{i}.key"] | |
| kv_cache[f"past_key_values.{i}.value"] = step_out[f"present.{i}.value"] | |
| embedding_session.close() | |
| decoder_session.close() | |
| return generated_tokens | |
| def generate_hf( | |
| model, | |
| tokenizer, | |
| prompt: str, | |
| *, | |
| pixel_values: np.ndarray | None = None, | |
| image_sizes: np.ndarray | None = None, | |
| audio_features: np.ndarray | None = None, | |
| num_image_tokens: int = 0, | |
| num_audio_tokens: int = 0, | |
| input_mode: int = 0, | |
| max_new_tokens: int = DEFAULT_MAX_NEW_TOKENS, | |
| ) -> list[int]: | |
| """Greedy-decode up to max_new_tokens tokens using HuggingFace generate().""" | |
| import torch | |
| device = next(model.parameters()).device | |
| dtype = next(model.parameters()).dtype | |
| hf_input_ids = build_input_ids( | |
| tokenizer, | |
| prompt, | |
| num_image_tokens=num_image_tokens, | |
| num_audio_tokens=num_audio_tokens, | |
| ) | |
| generate_kwargs: dict = { | |
| "input_ids": torch.from_numpy(hf_input_ids).to(device), | |
| "attention_mask": torch.ones( | |
| 1, hf_input_ids.shape[1], dtype=torch.long, device=device | |
| ), | |
| "input_mode": input_mode, | |
| "max_new_tokens": max_new_tokens, | |
| "do_sample": False, # greedy | |
| "temperature": None, | |
| "top_p": None, | |
| } | |
| if pixel_values is not None: | |
| generate_kwargs["input_image_embeds"] = torch.from_numpy(pixel_values).to( | |
| device=device, dtype=dtype | |
| ) | |
| if image_sizes is not None: | |
| generate_kwargs["image_sizes"] = torch.from_numpy(image_sizes).to(device) | |
| if audio_features is not None: | |
| generate_kwargs["input_audio_embeds"] = torch.from_numpy(audio_features).to( | |
| device=device, dtype=dtype | |
| ) | |
| generate_kwargs["audio_embed_sizes"] = torch.tensor([num_audio_tokens], device=device) | |
| with torch.no_grad(): | |
| generated_ids = model.generate(**generate_kwargs) | |
| # Return only the newly generated token IDs (not the prompt) | |
| prompt_len = hf_input_ids.shape[1] | |
| return generated_ids[0, prompt_len:].cpu().tolist() | |
| def decode_generated(tokenizer, token_ids: list[int]) -> str: | |
| """Decode a list of token IDs to a human-readable string.""" | |
| if not token_ids: | |
| return "(empty)" | |
| return repr(tokenizer.decode(token_ids, skip_special_tokens=True)) | |
| # --------------------------------------------------------------------------- | |
| # Input construction helpers | |
| # --------------------------------------------------------------------------- | |
| def create_dummy_pixel_values( | |
| config: ArchitectureConfig, | |
| ) -> tuple[np.ndarray, np.ndarray, int]: | |
| """Create random pixel values processed through the HF image processor. | |
| Returns (pixel_values, image_sizes, num_img_tokens). | |
| """ | |
| from PIL import Image | |
| image_size = (config.vision.image_size if config.vision else None) or 448 | |
| rng = np.random.default_rng(42) | |
| img_data = rng.integers(0, 255, (image_size, image_size, 3), dtype=np.uint8) | |
| img = Image.fromarray(img_data) | |
| processor = transformers.AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True) | |
| inputs = processor.image_processor(images=[img], return_tensors="np") | |
| pixel_values = inputs["input_image_embeds"].astype(np.float32) | |
| image_sizes = inputs["image_sizes"].astype(np.int64) | |
| num_img_tokens = _compute_num_img_tokens_from_sizes(image_sizes) | |
| return pixel_values, image_sizes, num_img_tokens | |
| def create_dummy_audio_features( | |
| config: ArchitectureConfig, | |
| num_frames: int = SHORT_AUDIO_FRAMES, | |
| ) -> np.ndarray: | |
| """Create random audio mel features. | |
| Returns [1, num_frames, input_size] float32 array. | |
| """ | |
| input_size = (config.audio.input_size if config.audio else None) or 80 | |
| rng = np.random.default_rng(123) | |
| return rng.standard_normal((1, num_frames, input_size)).astype(np.float32) | |
| def load_real_audio( | |
| audio_path: str, | |
| max_seconds: float | None = MAX_AUDIO_SECONDS, | |
| ) -> np.ndarray: | |
| """Load audio file and compute mel spectrogram features. | |
| ``max_seconds`` caps the waveform length before feature extraction to | |
| avoid very long ONNX inference on CPU (default: ``MAX_AUDIO_SECONDS``). | |
| Pass ``None`` to process the full file. | |
| Returns [1, time_frames, n_mels] float32 array. | |
| """ | |
| import soundfile as sf | |
| data, sr = sf.read(audio_path, dtype="float32", always_2d=True) | |
| # data: (samples, channels) -> mono by averaging channels | |
| audio = data.mean(axis=1).astype(np.float32) | |
| if max_seconds is not None: | |
| audio = audio[: int(max_seconds * sr)] | |
| # Use Phi4MMAudioFeatureExtractor (no 30-second truncation limit) so | |
| # long-audio tests exercise the >500-token conformer chunking path. | |
| processor = transformers.AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True) | |
| audio_fe = processor.audio_processor | |
| out = audio_fe([(audio, sr)], return_tensors="np") | |
| # input_audio_embeds: (1, T, 80) | |
| return out["input_audio_embeds"].astype(np.float32) | |
| def _compute_num_img_tokens_from_sizes(image_sizes: np.ndarray, hp: int = 16) -> int: | |
| """Compute the ONNX vision model's actual token count from image_sizes. | |
| The processor's num_img_tokens accounts for padding masking, but the ONNX | |
| vision model always computes the full h x w grid (no masking). Using | |
| image_sizes to compute the expected count ensures ONNX and HF-without-mask | |
| both use the same number of image placeholder tokens. | |
| Formula: h*hp*(w*hp + 1) + 1 + hp*(hp + 1) | |
| where h = image_sizes[0][0] // 448 (crop rows) | |
| w = image_sizes[0][1] // 448 (crop cols) | |
| """ | |
| h_px = int(image_sizes[0][0]) | |
| w_px = int(image_sizes[0][1]) | |
| h = h_px // 448 | |
| w = w_px // 448 | |
| return h * hp * (w * hp + 1) + 1 + hp * (hp + 1) | |
| def load_real_image( | |
| image_path: str, | |
| ) -> tuple[np.ndarray, np.ndarray, int]: | |
| """Load an image file and process through the HF image processor. | |
| Returns (pixel_values, image_sizes, num_img_tokens). | |
| num_img_tokens is derived from image_sizes using the full-grid formula | |
| (not the processor's masked count) so it matches what the ONNX vision | |
| model and HF-without-attention-mask both produce. | |
| """ | |
| from PIL import Image | |
| img = Image.open(image_path).convert("RGB") | |
| processor = transformers.AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True) | |
| inputs = processor.image_processor(images=[img], return_tensors="np") | |
| pixel_values = inputs["input_image_embeds"].astype(np.float32) | |
| image_sizes = inputs["image_sizes"].astype(np.int64) | |
| num_img_tokens = _compute_num_img_tokens_from_sizes(image_sizes) | |
| return pixel_values, image_sizes, num_img_tokens | |
| def build_input_ids( | |
| tokenizer, | |
| prompt: str, | |
| *, | |
| num_image_tokens: int = 0, | |
| num_audio_tokens: int = 0, | |
| ) -> np.ndarray: | |
| """Tokenize prompt and insert image/audio placeholder tokens. | |
| Layout: BOS + [image_tokens] + [audio_tokens] + rest_of_prompt. | |
| Returns [1, seq_len] INT64 array. | |
| """ | |
| tokens = tokenizer(prompt, return_tensors="np") | |
| input_ids = tokens["input_ids"].astype(np.int64) | |
| parts = [input_ids[:, :1]] # BOS | |
| if num_image_tokens > 0: | |
| parts.append( | |
| np.full( | |
| (1, num_image_tokens), | |
| IMAGE_TOKEN_ID, | |
| dtype=np.int64, | |
| ) | |
| ) | |
| if num_audio_tokens > 0: | |
| parts.append( | |
| np.full( | |
| (1, num_audio_tokens), | |
| AUDIO_TOKEN_ID, | |
| dtype=np.int64, | |
| ) | |
| ) | |
| parts.append(input_ids[:, 1:]) # Rest of prompt | |
| return np.concatenate(parts, axis=1) | |
| # --------------------------------------------------------------------------- | |
| # Parity comparison | |
| # --------------------------------------------------------------------------- | |
| def skipped_result(label: str, reason: str) -> dict: | |
| """Return a result dict representing a skipped test.""" | |
| print(f"\n{'=' * 60}") | |
| print(f" {label}") | |
| print(f"{'=' * 60}") | |
| print(f" SKIPPED: {reason}") | |
| print(f"{'=' * 60}") | |
| return { | |
| "label": label, | |
| "skipped": True, | |
| "reason": reason, | |
| "max_diff": float("nan"), | |
| "mean_diff": float("nan"), | |
| "token_match": 0.0, | |
| "allclose": False, | |
| } | |
| def compare_logits( | |
| onnx_logits: np.ndarray, | |
| hf_logits: np.ndarray, | |
| label: str, | |
| *, | |
| onnx_generated: list[int] | None = None, | |
| hf_generated: list[int] | None = None, | |
| tokenizer=None, | |
| ) -> dict: | |
| """Compare two logit tensors and print parity metrics. | |
| When shapes differ (e.g. different audio token counts between | |
| ONNX compressed and HF raw), compares only the last-token logits | |
| which determine the next generated token. | |
| When ``onnx_generated``, ``hf_generated``, and ``tokenizer`` are all | |
| provided, the decoded generated text is printed alongside the metrics. | |
| Returns a dict with comparison results. | |
| """ | |
| if onnx_logits.shape == hf_logits.shape: | |
| # Full comparison when shapes match | |
| diff = np.abs(onnx_logits - hf_logits) | |
| max_diff = float(np.max(diff)) | |
| mean_diff = float(np.mean(diff)) | |
| onnx_tokens = np.argmax(onnx_logits, axis=-1) | |
| hf_tokens = np.argmax(hf_logits, axis=-1) | |
| token_match = float(np.mean(onnx_tokens == hf_tokens)) | |
| shape_note = f"{onnx_logits.shape}" | |
| else: | |
| # Shape mismatch — compare last-token logits only | |
| onnx_last = onnx_logits[:, -1:, :] | |
| hf_last = hf_logits[:, -1:, :] | |
| diff = np.abs(onnx_last - hf_last) | |
| max_diff = float(np.max(diff)) | |
| mean_diff = float(np.mean(diff)) | |
| onnx_tokens = np.argmax(onnx_last, axis=-1) | |
| hf_tokens = np.argmax(hf_last, axis=-1) | |
| token_match = float(np.mean(onnx_tokens == hf_tokens)) | |
| shape_note = ( | |
| f"ONNX {onnx_logits.shape} vs HF {hf_logits.shape} (comparing last token only)" | |
| ) | |
| # Check if all close within tolerance. | |
| # atol=1e-2 is intentionally looser than the project's standard 1e-4. | |
| # Reasons: (1) this is a 4-model pipeline — numerical error compounds | |
| # across speech encoder → projection → embedding → 32-layer decoder; | |
| # (2) all LoRA adapters are always merged unconditionally, introducing | |
| # a small systematic offset vs HF's per-mode adapter activation; | |
| # (3) float32 accumulation over 32 decoder layers yields ~1e-3 spread. | |
| # The argmax (token_match) is the primary correctness signal. | |
| atol = 1e-2 | |
| rtol = 1e-2 | |
| if onnx_logits.shape == hf_logits.shape: | |
| # Shapes match — compare the full tensor for a thorough parity check. | |
| is_close = bool(np.allclose(onnx_logits, hf_logits, atol=atol, rtol=rtol)) | |
| else: | |
| # Shape mismatch (e.g. different audio token counts) — fall back to | |
| # last-token comparison which determines the next generated token. | |
| is_close = bool( | |
| np.allclose( | |
| onnx_logits[:, -1:, :], | |
| hf_logits[:, -1:, :], | |
| atol=atol, | |
| rtol=rtol, | |
| ) | |
| ) | |
| status = "PASS" if is_close else "FAIL" | |
| token_status = "PASS" if token_match >= 1.0 - 1e-9 else "FAIL" | |
| print(f"\n{'=' * 60}") | |
| print(f" {label}") | |
| print(f"{'=' * 60}") | |
| print(f" Shape: {shape_note}") | |
| print(f" Max abs diff: {max_diff:.6e}") | |
| print(f" Mean abs diff: {mean_diff:.6e}") | |
| print(f" Token match: {token_match:.1%} [{token_status}]") | |
| print(f" Allclose: atol={atol}, rtol={rtol} [{status}]") | |
| if tokenizer is not None and onnx_generated is not None and hf_generated is not None: | |
| onnx_text = decode_generated(tokenizer, onnx_generated) | |
| hf_text = decode_generated(tokenizer, hf_generated) | |
| gen_match = "✅" if onnx_generated == hf_generated else "⚠️" | |
| print(f" HF generated: {hf_text}") | |
| print(f" ONNX generated: {onnx_text} {gen_match}") | |
| print(f"{'=' * 60}") | |
| return { | |
| "label": label, | |
| "max_diff": max_diff, | |
| "mean_diff": mean_diff, | |
| "token_match": float(token_match), | |
| "allclose": is_close, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Individual test cases | |
| # --------------------------------------------------------------------------- | |
| def test_text_only( | |
| pkg, | |
| config: ArchitectureConfig, | |
| hf_model, | |
| tokenizer, | |
| ) -> dict: | |
| """Text-only parity: no image, no audio.""" | |
| prompt = "The capital of France is" | |
| input_ids = build_input_ids(tokenizer, prompt) | |
| print("\n[ONNX] Running text-only prefill + generate ...") | |
| t0 = time.time() | |
| onnx_logits = run_onnx_pipeline(pkg, config, input_ids) | |
| onnx_generated = generate_onnx(pkg, config, tokenizer, input_ids) | |
| print(f" ONNX: {time.time() - t0:.1f}s") | |
| print("[HF] Running text-only prefill + generate ...") | |
| t0 = time.time() | |
| hf_logits = run_hf_forward(hf_model, tokenizer, prompt, input_mode=0) | |
| hf_generated = generate_hf(hf_model, tokenizer, prompt, input_mode=0) | |
| print(f" HF: {time.time() - t0:.1f}s") | |
| return compare_logits( | |
| onnx_logits, | |
| hf_logits, | |
| "Text Only", | |
| onnx_generated=onnx_generated, | |
| hf_generated=hf_generated, | |
| tokenizer=tokenizer, | |
| ) | |
| def test_vision( | |
| pkg, | |
| config: ArchitectureConfig, | |
| hf_model, | |
| tokenizer, | |
| image_path: str | None = None, | |
| ) -> dict: | |
| """Text + image parity. | |
| Compares the ONNX HD-transform vision pipeline against HuggingFace's | |
| Phi4MMImageEmbedding, which applies AvgPool2d spatial compression, | |
| glb_GN/sub_GN row separators, and sub-first ordering before the | |
| projection MLP. | |
| """ | |
| if image_path is not None: | |
| pixel_values, image_sizes, num_img_tokens = load_real_image(image_path) | |
| else: | |
| pixel_values, image_sizes, num_img_tokens = create_dummy_pixel_values(config) | |
| prompt = "Describe this image" | |
| print( | |
| f"\n[ONNX] Running vision prefill + generate" | |
| f" ({pixel_values.shape[0]} crops" | |
| f" → {num_img_tokens} image tokens) ..." | |
| ) | |
| input_ids = build_input_ids(tokenizer, prompt, num_image_tokens=num_img_tokens) | |
| t0 = time.time() | |
| onnx_logits = run_onnx_pipeline( | |
| pkg, | |
| config, | |
| input_ids, | |
| pixel_values=pixel_values, | |
| image_sizes=image_sizes, | |
| ) | |
| onnx_generated = generate_onnx( | |
| pkg, | |
| config, | |
| tokenizer, | |
| input_ids, | |
| pixel_values=pixel_values, | |
| image_sizes=image_sizes, | |
| ) | |
| print(f" ONNX: {time.time() - t0:.1f}s") | |
| print("[HF] Running vision prefill + generate ...") | |
| t0 = time.time() | |
| hf_logits = run_hf_forward( | |
| hf_model, | |
| tokenizer, | |
| prompt, | |
| pixel_values=pixel_values, | |
| image_sizes=image_sizes, | |
| num_image_tokens=num_img_tokens, | |
| input_mode=1, | |
| ) | |
| try: | |
| hf_generated = generate_hf( | |
| hf_model, | |
| tokenizer, | |
| prompt, | |
| pixel_values=pixel_values, | |
| image_sizes=image_sizes, | |
| num_image_tokens=num_img_tokens, | |
| input_mode=1, | |
| ) | |
| except Exception as e: | |
| print(f" Warning: HF generate failed ({e}); skipping generation comparison") | |
| hf_generated = None | |
| print(f" HF: {time.time() - t0:.1f}s") | |
| return compare_logits( | |
| onnx_logits, | |
| hf_logits, | |
| f"Text + Image ({os.path.basename(image_path)})" if image_path else "Text + Image", | |
| onnx_generated=onnx_generated, | |
| hf_generated=hf_generated, | |
| tokenizer=tokenizer, | |
| ) | |
| def test_audio( | |
| pkg, | |
| config: ArchitectureConfig, | |
| hf_model, | |
| tokenizer, | |
| audio_path: str | None = None, | |
| num_frames: int = SHORT_AUDIO_FRAMES, | |
| label: str = "Text + Audio (short)", | |
| max_audio_seconds: float | None = MAX_AUDIO_SECONDS, | |
| ) -> dict: | |
| """Text + audio parity. | |
| Uses random features by default, or loads from audio_path. | |
| ``num_frames`` controls the sequence length when using random data | |
| (short vs long exercises different code paths in the Conformer). | |
| ``max_audio_seconds`` caps real audio files to avoid slow CPU inference. | |
| """ | |
| if audio_path is not None: | |
| audio_features = load_real_audio(audio_path, max_seconds=max_audio_seconds) | |
| else: | |
| audio_features = create_dummy_audio_features(config, num_frames=num_frames) | |
| # Compute number of speech tokens after NeMo 3-stage stride-2 subsampling | |
| num_audio_tokens = _nemo_subsampling_output_len(audio_features.shape[1]) | |
| prompt = "Transcribe the audio" | |
| input_ids = build_input_ids(tokenizer, prompt, num_audio_tokens=num_audio_tokens) | |
| print( | |
| f"\n[ONNX] Running audio prefill + generate" | |
| f" ({audio_features.shape[1]} frames" | |
| f" → {num_audio_tokens} tokens) ..." | |
| ) | |
| t0 = time.time() | |
| onnx_logits = run_onnx_pipeline( | |
| pkg, | |
| config, | |
| input_ids, | |
| audio_features=audio_features, | |
| ) | |
| onnx_generated = generate_onnx( | |
| pkg, config, tokenizer, input_ids, audio_features=audio_features | |
| ) | |
| print(f" ONNX: {time.time() - t0:.1f}s") | |
| print("[HF] Running audio prefill + generate ...") | |
| t0 = time.time() | |
| hf_logits = run_hf_forward( | |
| hf_model, | |
| tokenizer, | |
| prompt, | |
| audio_features=audio_features, | |
| num_audio_tokens=num_audio_tokens, | |
| input_mode=2, | |
| ) | |
| hf_generated = generate_hf( | |
| hf_model, | |
| tokenizer, | |
| prompt, | |
| audio_features=audio_features, | |
| num_audio_tokens=num_audio_tokens, | |
| input_mode=2, | |
| ) | |
| print(f" HF: {time.time() - t0:.1f}s") | |
| return compare_logits( | |
| onnx_logits, | |
| hf_logits, | |
| label, | |
| onnx_generated=onnx_generated, | |
| hf_generated=hf_generated, | |
| tokenizer=tokenizer, | |
| ) | |
| def test_vision_audio( | |
| pkg, | |
| config: ArchitectureConfig, | |
| hf_model, | |
| tokenizer, | |
| image_path: str | None = None, | |
| audio_path: str | None = None, | |
| ) -> dict: | |
| """Text + image + audio parity (combined VISION_SPEECH mode). | |
| Uses dummy data by default; pass image_path / audio_path for real inputs. | |
| The combined mode uses audio_projection_mode=1 (vision branch of the speech | |
| projector) and HF input_mode=3 (VISION_SPEECH). | |
| """ | |
| if image_path is not None: | |
| pixel_values, image_sizes, num_img_tokens = load_real_image(image_path) | |
| else: | |
| pixel_values, image_sizes, num_img_tokens = create_dummy_pixel_values(config) | |
| if audio_path is not None: | |
| audio_features = load_real_audio(audio_path) | |
| else: | |
| audio_features = create_dummy_audio_features(config, num_frames=SHORT_AUDIO_FRAMES) | |
| num_audio_tokens = _nemo_subsampling_output_len(audio_features.shape[1]) | |
| prompt = "Describe what you see and hear" | |
| input_ids = build_input_ids( | |
| tokenizer, | |
| prompt, | |
| num_image_tokens=num_img_tokens, | |
| num_audio_tokens=num_audio_tokens, | |
| ) | |
| print( | |
| f"\n[ONNX] Running vision+audio prefill + generate" | |
| f" ({pixel_values.shape[0]} crops → {num_img_tokens} image tokens," | |
| f" {audio_features.shape[1]} frames → {num_audio_tokens} audio tokens) ..." | |
| ) | |
| t0 = time.time() | |
| onnx_logits = run_onnx_pipeline( | |
| pkg, | |
| config, | |
| input_ids, | |
| pixel_values=pixel_values, | |
| image_sizes=image_sizes, | |
| audio_features=audio_features, | |
| audio_projection_mode=1, # vision branch for combined mode | |
| ) | |
| onnx_generated = generate_onnx( | |
| pkg, | |
| config, | |
| tokenizer, | |
| input_ids, | |
| pixel_values=pixel_values, | |
| image_sizes=image_sizes, | |
| audio_features=audio_features, | |
| audio_projection_mode=1, | |
| ) | |
| print(f" ONNX: {time.time() - t0:.1f}s") | |
| print("[HF] Running vision+audio prefill + generate ...") | |
| t0 = time.time() | |
| hf_logits = run_hf_forward( | |
| hf_model, | |
| tokenizer, | |
| prompt, | |
| pixel_values=pixel_values, | |
| image_sizes=image_sizes, | |
| audio_features=audio_features, | |
| num_image_tokens=num_img_tokens, | |
| num_audio_tokens=num_audio_tokens, | |
| input_mode=3, # VISION_SPEECH | |
| ) | |
| try: | |
| hf_generated = generate_hf( | |
| hf_model, | |
| tokenizer, | |
| prompt, | |
| pixel_values=pixel_values, | |
| image_sizes=image_sizes, | |
| audio_features=audio_features, | |
| num_image_tokens=num_img_tokens, | |
| num_audio_tokens=num_audio_tokens, | |
| input_mode=3, | |
| ) | |
| except Exception as e: | |
| print(f" Warning: HF generate failed ({e}); skipping generation comparison") | |
| hf_generated = None | |
| print(f" HF: {time.time() - t0:.1f}s") | |
| return compare_logits( | |
| onnx_logits, | |
| hf_logits, | |
| "Text + Image + Audio", | |
| onnx_generated=onnx_generated, | |
| hf_generated=hf_generated, | |
| tokenizer=tokenizer, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Summary | |
| # --------------------------------------------------------------------------- | |
| def print_summary(results: list[dict]) -> bool: | |
| """Print a summary table and return True if all non-skipped tests passed.""" | |
| print("\n") | |
| print("=" * 70) | |
| print(" PARITY SUMMARY") | |
| print("=" * 70) | |
| header = f" {'Test':<30} {'Max Diff':>12} {'Mean Diff':>12} {'Token Match':>12}" | |
| print(header) | |
| print(" " + "-" * 66) | |
| all_pass = True | |
| for r in results: | |
| if r.get("skipped"): | |
| print(f" ⏭️ {r['label']:<28} [SKIPPED: {r['reason']}]") | |
| else: | |
| status = "✅" if r["allclose"] else "⚠️" | |
| print( | |
| f" {status} {r['label']:<28} " | |
| f"{r['max_diff']:>12.6e} " | |
| f"{r['mean_diff']:>12.6e} " | |
| f"{r['token_match']:>11.1%}" | |
| ) | |
| if not r["allclose"]: | |
| all_pass = False | |
| print("=" * 70) | |
| non_skipped = [r for r in results if not r.get("skipped")] | |
| skipped = [r for r in results if r.get("skipped")] | |
| if all_pass: | |
| print(f" All {len(non_skipped)} parity checks PASSED ✅", end="") | |
| else: | |
| print(" Some parity checks FAILED ⚠️", end="") | |
| if skipped: | |
| print(f" ({len(skipped)} skipped)") | |
| else: | |
| print() | |
| print("=" * 70) | |
| return all_pass | |
| # --------------------------------------------------------------------------- | |
| # ORT GenAI verification | |
| # --------------------------------------------------------------------------- | |
| # Known Phi-4 token IDs for genai_config.json | |
| _PHI4MM_BOS_TOKEN_ID = 199999 | |
| _PHI4MM_EOS_TOKEN_IDS = [199999, 200020] | |
| def run_genai_verification( | |
| pkg, | |
| config: ArchitectureConfig, | |
| model_id: str, | |
| tokenizer, | |
| hf_model, | |
| *, | |
| trust_remote_code: bool = False, | |
| max_new_tokens: int = 32, | |
| prompt: str = "The capital of France is", | |
| ) -> bool: | |
| """Save the ONNX package and verify text generation with onnxruntime-genai. | |
| Saves the already-built ``pkg`` to a temporary directory, writes | |
| ``genai_config.json``, copies tokenizer files, then runs text-only | |
| generation with ``onnxruntime_genai`` and compares the output against | |
| HuggingFace to confirm the exported model is functional. | |
| Args: | |
| pkg: The built ``ModelPackage`` (vision, speech, embedding, model). | |
| config: Architecture config used to build ``pkg``. | |
| model_id: HuggingFace model ID (for tokenizer download). | |
| tokenizer: Already-loaded HuggingFace tokenizer. | |
| hf_model: Already-loaded HuggingFace model for comparison generation. | |
| trust_remote_code: Passed to tokenizer download helpers. | |
| max_new_tokens: Maximum tokens to generate. | |
| prompt: Text prompt for the verification generation. | |
| Returns: | |
| ``True`` if the genai output matches HF, ``False`` otherwise. | |
| Also returns ``True`` if onnxruntime-genai is not installed | |
| (so the caller's ``all_pass`` is not penalised). | |
| """ | |
| try: | |
| import onnxruntime_genai as og | |
| except ImportError: | |
| print( | |
| "\n[genai] Skipping ORT GenAI verification: onnxruntime_genai not installed.\n" | |
| " Install with: pip install onnxruntime-genai" | |
| ) | |
| return True | |
| import tempfile | |
| import torch | |
| from mobius.integrations.ort_genai.genai_config import GenaiConfigGenerator | |
| print("\n" + "=" * 60) | |
| print(" ORT GenAI Verification") | |
| print("=" * 60) | |
| with tempfile.TemporaryDirectory(prefix="phi4mm_genai_") as tmp_dir: | |
| # ── 1. Save ONNX models ─────────────────────────────────────── | |
| print(f" Saving ONNX models to {tmp_dir} ...") | |
| pkg.save(tmp_dir, progress_bar=False) | |
| print(f" Saved components: {list(pkg.keys())}") | |
| # ── 2. Write genai_config.json ──────────────────────────────── | |
| gen = GenaiConfigGenerator.from_config( | |
| config, | |
| "phi4mm", | |
| bos_token_id=_PHI4MM_BOS_TOKEN_ID, | |
| eos_token_id=_PHI4MM_EOS_TOKEN_IDS, | |
| ) | |
| # Include vision/embedding/speech sections so ORT-GenAI loads the | |
| # full 4-model layout (even for text-only inference). | |
| gen.with_vision( | |
| image_token_id=IMAGE_TOKEN_ID, | |
| spatial_merge_size=None, | |
| config_filename="vision_processor.json", | |
| input_names={ | |
| "pixel_values": "pixel_values", | |
| "image_sizes": "image_sizes", | |
| }, | |
| ) | |
| gen.with_speech(audio_token_id=AUDIO_TOKEN_ID) | |
| gen.write(tmp_dir) | |
| # ── 3. Copy tokenizer files ─────────────────────────────────── | |
| try: | |
| proc = transformers.AutoProcessor.from_pretrained( | |
| model_id, trust_remote_code=trust_remote_code | |
| ) | |
| proc.save_pretrained(tmp_dir) | |
| print(" Tokenizer files copied.") | |
| except Exception as e: | |
| print(f" Warning: could not copy tokenizer files — {e}") | |
| # ── 4. Run ORT GenAI text generation ───────────────────────── | |
| print(f"\n Prompt: {prompt!r}") | |
| print(" Running ORT GenAI generation ...") | |
| try: | |
| model_og = og.Model(tmp_dir) | |
| tokenizer_og = og.Tokenizer(model_og) | |
| input_ids_og = tokenizer_og.encode(prompt) | |
| params = og.GeneratorParams(model_og) | |
| params.set_search_options( | |
| do_sample=False, | |
| max_length=len(input_ids_og) + max_new_tokens, | |
| ) | |
| generator = og.Generator(model_og, params) | |
| generator.append_tokens(input_ids_og) | |
| genai_tokens: list[int] = [] | |
| while not generator.is_done() and len(genai_tokens) < max_new_tokens: | |
| generator.generate_next_token() | |
| genai_tokens.append(int(generator.get_next_tokens()[0])) | |
| del generator | |
| del model_og | |
| genai_text = tokenizer.decode(genai_tokens, skip_special_tokens=True) | |
| except Exception as e: | |
| print(f" ORT GenAI generation FAILED: {e}") | |
| print("=" * 60) | |
| return False | |
| # ── 5. Run HuggingFace text generation for comparison ───────── | |
| print(" Running HuggingFace generation for comparison ...") | |
| hf_text: str | None = None | |
| try: | |
| tok_input = tokenizer(prompt, return_tensors="pt") | |
| input_ids_hf = tok_input["input_ids"] | |
| with torch.no_grad(): | |
| out_ids = hf_model.generate( | |
| input_ids=input_ids_hf, | |
| max_new_tokens=max_new_tokens, | |
| do_sample=False, | |
| ) | |
| new_ids = out_ids[:, input_ids_hf.shape[1] :] | |
| hf_text = tokenizer.decode(new_ids[0], skip_special_tokens=True) | |
| except Exception as e: | |
| print(f" Warning: HuggingFace comparison failed — {e}") | |
| # ── 6. Report ───────────────────────────────────────────────── | |
| print(f"\n {'=' * 56}") | |
| print(f" ORT GenAI: {genai_text!r}") | |
| if hf_text is not None: | |
| print(f" HuggingFace: {hf_text!r}") | |
| match = genai_text.strip() == hf_text.strip() | |
| status = "✅ MATCH" if match else "⚠️ MISMATCH" | |
| print(f" Result: {status}") | |
| else: | |
| print(" HuggingFace: (comparison unavailable)") | |
| print(" Result: ✅ Generation succeeded (no comparison)") | |
| match = True | |
| print(f" {'=' * 56}") | |
| print() | |
| return match | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description=( | |
| "Phi-4 Multimodal output parity: ONNX (mobius) vs HuggingFace transformers." | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--model-id", | |
| default=MODEL_ID, | |
| help="HuggingFace model ID (default: %(default)s).", | |
| ) | |
| parser.add_argument( | |
| "--trust-remote-code", | |
| action="store_true", | |
| default=False, | |
| help=( | |
| "Allow loading remote model code from HuggingFace Hub. " | |
| "Automatically enabled for the default Phi-4 model ID. " | |
| "Required for any model that ships custom Python code in its repo." | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--mode", | |
| choices=[*ALL_MODES, "all"], | |
| default="all", | |
| help=( | |
| "Which modality to test (default: %(default)s). 'all' runs all five test cases." | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--image", | |
| default=None, | |
| help=( | |
| "Path to image file. If not provided, random pixel data is used for vision tests." | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--audio", | |
| default=None, | |
| help=( | |
| "Path to audio file. If not provided, random mel " | |
| "features are used for audio tests." | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--num-text-layers", | |
| type=int, | |
| default=DEFAULT_NUM_TEXT_LAYERS, | |
| help=( | |
| "Number of decoder layers to use (default: %(default)s = full model). " | |
| "Pass a smaller value (e.g. 2) for faster development testing." | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--num-vision-layers", | |
| type=int, | |
| default=DEFAULT_NUM_VISION_LAYERS, | |
| help=( | |
| "Number of SigLIP vision encoder layers (default: %(default)s = full model). " | |
| "The ONNX model runs N-1 layers; HF uses layer_idx=-2." | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--num-audio-blocks", | |
| type=int, | |
| default=DEFAULT_NUM_AUDIO_BLOCKS, | |
| help=( | |
| "Number of Conformer audio encoder blocks (default: %(default)s = full model). " | |
| "Pass a smaller value for faster development testing." | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--long-audio-frames", | |
| type=int, | |
| default=LONG_AUDIO_FRAMES, | |
| help=("Number of mel frames for the long-audio test (default: %(default)s)."), | |
| ) | |
| parser.add_argument( | |
| "--debug", | |
| action="store_true", | |
| help=( | |
| f"Use reduced layer counts for fast pipeline smoke-testing " | |
| f"({DEBUG_NUM_TEXT_LAYERS} text, {DEBUG_NUM_VISION_LAYERS} vision, " | |
| f"{DEBUG_NUM_AUDIO_BLOCKS} audio). " | |
| f"Overrides --num-text-layers / --num-vision-layers / --num-audio-blocks." | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--genai", | |
| action="store_true", | |
| help=( | |
| "After parity tests, save the ONNX models to a temp directory and " | |
| "verify text-only generation matches HuggingFace using onnxruntime-genai. " | |
| "Skipped silently if onnxruntime-genai is not installed." | |
| ), | |
| ) | |
| args = parser.parse_args() | |
| # --debug overrides individual layer counts with small preset values. | |
| if args.debug: | |
| args.num_text_layers = DEBUG_NUM_TEXT_LAYERS | |
| args.num_vision_layers = DEBUG_NUM_VISION_LAYERS | |
| args.num_audio_blocks = DEBUG_NUM_AUDIO_BLOCKS | |
| # trust_remote_code is always required for the default Phi-4 model ID | |
| # (its model class lives in the HF repo, not transformers core). For | |
| # any other --model-id the user must pass --trust-remote-code explicitly. | |
| trust_remote_code: bool = args.trust_remote_code or (args.model_id == MODEL_ID) | |
| # ------------------------------------------------------------------ | |
| # Step 1: Load both models | |
| # ------------------------------------------------------------------ | |
| print(f"Building ONNX models from {args.model_id!r} ...") | |
| print( | |
| f" Layers: text={args.num_text_layers}, " | |
| f"vision={args.num_vision_layers}, " | |
| f"audio={args.num_audio_blocks}" | |
| ) | |
| pkg, config = load_onnx_package( | |
| args.model_id, | |
| args.num_text_layers, | |
| args.num_vision_layers, | |
| args.num_audio_blocks, | |
| trust_remote_code=trust_remote_code, | |
| ) | |
| print(f" ONNX package components: {list(pkg.keys())}") | |
| print(f"\nLoading HuggingFace model from {args.model_id!r} ...") | |
| hf_model, tokenizer = load_hf_model( | |
| args.model_id, | |
| args.num_text_layers, | |
| num_audio_blocks=args.num_audio_blocks, | |
| num_vision_layers=args.num_vision_layers, | |
| trust_remote_code=trust_remote_code, | |
| ) | |
| # ------------------------------------------------------------------ | |
| # Step 2: Discover real test data (if available) | |
| # ------------------------------------------------------------------ | |
| testdata = _discover_testdata(TESTDATA_DIR) | |
| if testdata["images"] or testdata["audios"]: | |
| print( | |
| f"\nUsing real test data from {TESTDATA_DIR}:" | |
| f" {len(testdata['images'])} image(s)," | |
| f" {len(testdata['audios'])} audio file(s)" | |
| ) | |
| else: | |
| print("\nNo testdata found — using synthetic dummy inputs.") | |
| # ------------------------------------------------------------------ | |
| # Step 3: Run parity tests | |
| # ------------------------------------------------------------------ | |
| modes = ALL_MODES if args.mode == "all" else [args.mode] | |
| results: list[dict] = [] | |
| for mode in modes: | |
| if mode == "text": | |
| results.append(test_text_only(pkg, config, hf_model, tokenizer)) | |
| elif mode == "vision": | |
| # Iterate over all discovered images; fall back to --image or dummy. | |
| image_paths: list[str | None] = testdata["images"] or ( | |
| [args.image] if args.image else [None] | |
| ) | |
| for img_path in image_paths: | |
| try: | |
| results.append( | |
| test_vision(pkg, config, hf_model, tokenizer, image_path=img_path) | |
| ) | |
| except Exception as e: | |
| name = os.path.basename(img_path) if img_path else "dummy" | |
| print(f"\n Warning: skipping {name!r} — {e}") | |
| results.append(skipped_result(f"Text + Image ({name})", f"error: {e}")) | |
| elif mode == "audio-short": | |
| # Short audio: dummy frames or explicit --audio. | |
| # When real testdata is present, use the first audio file capped | |
| # to 30 s so the subsampled token count stays below 500 (no chunking). | |
| if testdata["audios"] and not args.audio: | |
| short_path = testdata["audios"][0] | |
| short_label = f"Text + Audio (short): {os.path.basename(short_path)}" | |
| results.append( | |
| test_audio( | |
| pkg, | |
| config, | |
| hf_model, | |
| tokenizer, | |
| audio_path=short_path, | |
| max_audio_seconds=30.0, | |
| label=short_label, | |
| ) | |
| ) | |
| else: | |
| results.append( | |
| test_audio( | |
| pkg, | |
| config, | |
| hf_model, | |
| tokenizer, | |
| audio_path=args.audio, | |
| num_frames=SHORT_AUDIO_FRAMES, | |
| label="Text + Audio (short)", | |
| ) | |
| ) | |
| elif mode == "audio-long": | |
| # Long audio: iterate all discovered audio files (each capped at | |
| # MAX_AUDIO_SECONDS). Exercises the >500-token chunking path. | |
| audio_paths: list[str | None] = testdata["audios"] or ( | |
| [args.audio] if args.audio else [None] | |
| ) | |
| for aud_path in audio_paths: | |
| label = ( | |
| f"Text + Audio (long): {os.path.basename(aud_path)}" | |
| if aud_path | |
| else "Text + Audio (long)" | |
| ) | |
| results.append( | |
| test_audio( | |
| pkg, | |
| config, | |
| hf_model, | |
| tokenizer, | |
| audio_path=aud_path, | |
| num_frames=args.long_audio_frames, | |
| label=label, | |
| ) | |
| ) | |
| elif mode == "vision-audio": | |
| # Combined: first image + first audio (or explicit --image/--audio). | |
| img_path = args.image or (testdata["images"][0] if testdata["images"] else None) | |
| aud_path = args.audio or (testdata["audios"][0] if testdata["audios"] else None) | |
| results.append( | |
| test_vision_audio( | |
| pkg, | |
| config, | |
| hf_model, | |
| tokenizer, | |
| image_path=img_path, | |
| audio_path=aud_path, | |
| ) | |
| ) | |
| # ------------------------------------------------------------------ | |
| # Step 3: Summary | |
| # ------------------------------------------------------------------ | |
| all_pass = print_summary(results) | |
| # ------------------------------------------------------------------ | |
| # Step 4: ORT GenAI verification (optional, --genai flag) | |
| # ------------------------------------------------------------------ | |
| if args.genai: | |
| genai_pass = run_genai_verification( | |
| pkg, | |
| config, | |
| args.model_id, | |
| tokenizer, | |
| hf_model, | |
| trust_remote_code=trust_remote_code, | |
| ) | |
| all_pass = all_pass and genai_pass | |
| sys.exit(0 if all_pass else 1) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment