Skip to content

Cast vision/audio inputs f32 to model dtype for GenAI compatibility - #265

Merged
justinchuby merged 3 commits into
mainfrom
fix-bf16-per-component-dtype
May 6, 2026
Merged

Cast vision/audio inputs f32 to model dtype for GenAI compatibility#265
justinchuby merged 3 commits into
mainfrom
fix-bf16-per-component-dtype

Conversation

@justinchuby

@justinchuby justinchuby commented May 5, 2026

Copy link
Copy Markdown
Member

Summary

Keep vision/audio encoder graph inputs as float32 (matching GenAI image/audio processor output) and add a Cast(f32→model_dtype) at the graph entry. Encoder weights use the requested f16/bf16 dtype for memory efficiency.

Problem

GenAI multimodal processor outputs float32 pixel values and audio features. When building with --dtype f16 or --dtype bf16, the vision/audio encoder graph inputs were also cast to f16/bf16, creating a type mismatch: GenAI sends f32, encoder expects f16, ORT produces all-zero output (silent failure).

Solution: Cast at graph entry

Instead of keeping entire vision/audio encoders at f32 (wasteful), add a Cast op at the start of each encoder graph:

pixel_values (f32, from GenAI) → Cast(f32→f16) → vision encoder (f16 weights) → image_features

This gives both GenAI compatibility (f32 input) and memory efficiency (f16/bf16 weights).

Changes

  • _gemma4.py: Vision/audio encoder inputs always ir.DataType.FLOAT, with op.Cast to config.dtype when dtype ≠ f32
  • _builder.py: No changes needed — all parameters cast to requested dtype as before

Testing

13 gemma4 tests pass. Verified f16 build: vision input=f32, first node=Cast, weights=f16.

@github-actions

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown

🏗️ Architecture Diff

Comparing 3523cbbcaa4c27

Model Sub-model Changes Status
bert (feature-extraction) model 0
falcon model 0
gemma2 model 0
gemma4 (gemma4) decoder 0
gemma4 (gemma4) embedding 0
gemma4 (gemma4) vision_encoder 0
gemma4_text model 0
gpt2 model 0
llama model 0
llama (static-cache) model 0
mamba (ssm-text-generation) model 0
phi3 model 0
phi3 (static-cache) model 0
qwen model 0
qwen (static-cache) model 0
qwen2 model 0
qwen2 (static-cache) model 0
qwen2_moe model 0
qwen2_moe (static-cache) model 0
qwen3 model 0
qwen3 (static-cache) model 0
qwen3_5_moe (hybrid-text-generation) model 0
qwen3_5_text (hybrid-text-generation) model 0
qwen3_5_vl (hybrid-qwen-vl) decoder 0
qwen3_5_vl (hybrid-qwen-vl) embedding 0
qwen3_5_vl (hybrid-qwen-vl) vision_encoder 0
qwen3_moe model 0
qwen3_moe (static-cache) model 0
qwen3_next (hybrid-text-generation) model 0
t5 (seq2seq) decoder 0
t5 (seq2seq) encoder 0
whisper (speech-to-text) decoder 0
whisper (speech-to-text) encoder 0

No architecture changes detected.


Legend: ⚪ No change · 🔵 Minor (attrs/inits) · 🟡 Moderate (nodes added/removed) · 🔴 Major (interface changed)

@github-actions

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown

Performance Comparison

Comparing 3523cbbcaa4c27

Model Metric Baseline Current Delta
bert (feature-extraction) model_size_bytes 359 KB 359 KB +0.0%
bert (feature-extraction) num_nodes 60 60 +0.0%
falcon model_size_bytes 364 KB 364 KB +0.0%
falcon num_nodes 66 66 +0.0%
gemma2 model_size_bytes 428 KB 428 KB +0.0%
gemma2 num_nodes 107 107 +0.0%
gpt2 model_size_bytes 388 KB 388 KB +0.0%
gpt2 num_nodes 53 53 +0.0%
llama model_size_bytes 425 KB 425 KB +0.0%
llama num_nodes 61 61 +0.0%
llama (static-cache) model_size_bytes 425 KB 425 KB +0.0%
llama (static-cache) num_nodes 58 58 +0.0%
mamba (ssm-text-generation) model_size_bytes 296 KB 296 KB +0.0%
mamba (ssm-text-generation) num_nodes 98 98 +0.0%
phi3 model_size_bytes 421 KB 421 KB +0.0%
phi3 num_nodes 59 59 +0.0%
phi3 (static-cache) model_size_bytes 421 KB 421 KB +0.0%
phi3 (static-cache) num_nodes 56 56 +0.0%
qwen2 model_size_bytes 425 KB 425 KB +0.0%
qwen2 num_nodes 61 61 +0.0%
qwen2 (static-cache) model_size_bytes 425 KB 425 KB +0.0%
qwen2 (static-cache) num_nodes 58 58 +0.0%
qwen3_5_moe (hybrid-text-generation) model_size_bytes 506 KB 506 KB +0.0%
qwen3_5_moe (hybrid-text-generation) num_nodes 275 275 +0.0%
qwen3_5_text (hybrid-text-generation) model_size_bytes 458 KB 458 KB +0.0%
qwen3_5_text (hybrid-text-generation) num_nodes 129 129 +0.0%
qwen3_5_vl (hybrid-qwen-vl) model_size_bytes 977 KB 977 KB +0.0%
qwen3_5_vl (hybrid-qwen-vl) num_nodes 408 408 +0.0%
t5 (seq2seq) model_size_bytes 836 KB 836 KB +0.0%
t5 (seq2seq) num_nodes 166 166 +0.0%
whisper (speech-to-text) model_size_bytes 1008 KB 1008 KB +0.0%
whisper (speech-to-text) num_nodes 128 128 +0.0%

No performance regressions.

@codecov

codecov Bot commented May 5, 2026

Copy link
Copy Markdown

@titaiwangms titaiwangms left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

Reviewed by Opus 4.7 + GPT-5.3-Codex (code) + GPT-5.5 (adversarial) + Claude Sonnet 4.6 (readability). Consensus: the direction is right, but the fix is incomplete and may not actually produce a loadable model in BF16/F16 multimodal builds.

The PR keeps selected encoder parameters at float32, but does not change the encoder graph's I/O dtype or the downstream embedding model's input dtype. That's three independent problems.


🔴 Major — Encoder graph I/O still uses config.dtype, mixing types with f32 weights

The tasks declare encoder graph inputs with dtype=config.dtype (e.g. _vision_language_3model.py:86-91, _phi4mm_multimodal.py:122-125, _gemma4.py:301-305). After this PR, in a bf16 build:

  • pixel_values / input_features graph input → bf16
  • First Conv/MatMul/Gemm weight in the encoder → float32

ONNX numeric op type constraints generally require matching tensor types. The likely failure mode is an invalid graph or ORT load-time type-check failure — not a safe fallback to f32 compute.

Suggested direction: make precision a per-component contract, not a parameter-only exclusion. Encoder graph inputs, constants, and outputs should all be FLOAT, with an explicit Cast at the next-component boundary.

🔴 Major — Embedding model still expects bf16/f16 multimodal features

Even if the encoder is fully corrected to float32, ORT GenAI passes its outputs as image_features / audio_features into a separate embedding session whose input is declared at config.dtype (_phi4mm_multimodal.py:166-174, _gemma4.py:394-407). genai_config.json (integrations/ort_genai/genai_config.py:279-296) only wires names; it does not insert dtype conversions.

Suggested fix: either (a) declare embedding *_features inputs as FLOAT and Cast to decoder dtype inside the embedding graph before token-merge, or (b) keep embedding in float32 and Cast inputs_embeds at the decoder boundary.

🔴 Major — Hardcoded ("vision_encoder", "audio_encoder") misses real models

The exclude list only matches literal nn.Module attribute names. Several existing multimodal/speech models bypass the fix:

Model Module attr Excluded?
Gemma4 / Qwen-VL / Gemma3 / MLLaMA / LLaVA / DeepSeek-OCR2 / InternVL / BLIP2 vision_encoder, audio_encoder
Phi4-MM speech_encoder (models/phi.py:981, remapped via tasks/_phi4mm_multimodal.py:59)
Qwen3-ASR audio_tower (models/qwen3_asr.py:348, remapped via tasks/_speech_language.py:51)
FunASR audio_tower (models/fun_asr.py:511, remapped via tasks/_fun_asr_speech_language.py:54)

Suggested fix: derive exclusions from the resolved task's component-attribute mapping (or have each task declare a component_dtypes / encoder-attrs policy) instead of guessing module layout.

🟡 Minor — No tests covering the new behavior

Existing _cast_module_dtype tests (_exporter_test.py:181) only exercise full-module casting. Please add tests asserting (per affected model class) that encoder initializer dtypes remain FLOAT while decoder initializers are BF16/F16. Phi4-MM, Qwen3-ASR, and FunASR are the highest-value cases since they would have silently regressed.

🟡 Minor — Silent precision-policy override

Passing --dtype bf16 previously implied uniform bf16. After this PR it silently means "mixed precision". Worth at least a logger.info(...) listing which sub-modules were kept at float32, and ideally a named policy flag (keep_multimodal_encoders_fp32=True) exposed in the public API + docs.

🟢 Minor / Nit — Readability

  • Hardcoded tuple ("vision_encoder", "audio_encoder") duplicates information already in _MODEL_ROLE_MAP (lines 111–115). Either co-locate as _ENCODER_SUBMODULE_ATTRS = frozenset(...) or derive from the role map.
  • exclude=exclude_from_cast or None is a no-op: _cast_module_dtype already guards with if exclude: (truthy check passes both None and set()). Drop the or None, or change the signature to exclude: set[str] = frozenset().
  • Parameter name exclude is weaker than the call-site exclude_from_cast. Consider excluded_attrs.
  • Docstring on exclude mixes contract + specific use case. Keep the rationale at the call site.
  • exclude silently ignores typos in attr names — consider validating or warning.

✅ What's right

  • Casting before graph construction (vs post-export mutation) is the right architectural layer.
  • id(param) set is the correct way to identify shared parameters under weight tying.
  • keyword-only (*) marker on the new exclude arg is good API hygiene.
  • Inline comment at the call site cites the concrete ORT op gap (OneHot/Mul/ReduceMean) — exactly the "why" the project style guide asks for.
  • Confirmed (_optimizations.py:285-291): GQA / QKV-packing fusions are gated to model_role == "decoder", so passing bf16 to optimize_model() for the f32 vision graph does not trigger decoder-only fusions on it.

Recommendation

Request changes. The two BF16 graph-boundary issues (encoder I/O dtype, embedding-input dtype) are likely to make exported BF16 multimodal models fail to load in ORT, which would silently invalidate the PR's "BF16 export succeeds" claim once the embedding/vision sessions are actually run end-to-end. Worth verifying with an ORT GenAI smoke test on Gemma4-bf16 (load all three sub-models in one session pipeline) before merging.

GenAI's image/audio processors output f32 data. Instead of keeping
entire vision/audio encoders at f32 (wasteful), keep only the graph
input at f32 and add a Cast to the model dtype at the graph entry.
Vision/audio weights use the requested f16/bf16 for memory efficiency.

Reverts the exclude-based approach in favor of Cast-at-input:
- _cast_module_dtype: simplified back to original (no exclude param)
- Gemma4 task: vision/audio inputs always f32 + Cast to config.dtype
- Result: f32 input → Cast → f16/bf16 weights → f16/bf16 compute

13 gemma4 tests pass.

Signed-off-by: Justin Chu <justinchu@microsoft.com>
@justinchuby
justinchuby force-pushed the fix-bf16-per-component-dtype branch from daebaf3 to 10ccf59 Compare May 5, 2026 23:41
Address review feedback on PR #265: extend the f32 encoder input
pattern (FLOAT input + Cast at graph entry) to all multimodal task
files, not just Gemma4.

Affected tasks:
- VisionLanguageTask (base _build_vision for LLaVA, Aya Vision, etc.)
- QwenVLTask (Qwen2.5-VL, Qwen3-VL, Qwen3.5-VL)
- PixtralVLTask (Pixtral)
- Phi4MMMultiModalTask (vision + speech encoders)
- SpeechLanguageTask (Qwen3-ASR)
- FunASRSpeechLanguageTask (Fun-ASR)

Encoder graph inputs (pixel_values, input_features, audio_embeds) are
declared as FLOAT because ORT GenAI's image/audio processors output
f32. A Cast at graph entry converts to the target dtype for the
encoder's internal computation with cast weights.

Add 4 new tests verifying encoder inputs stay FLOAT in f16/bf16 builds
for both generic VL (LLaVA) and Gemma4 (vision + audio).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
@justinchuby

Copy link
Copy Markdown
Member Author

Addressed the review feedback in commit 76e37fa:

What was fixed

Extended the f32 encoder input pattern (FLOAT input + Cast at graph entry) to all multimodal task files:

Task Encoder inputs fixed
VisionLanguageTask (base) pixel_values
QwenVLTask pixel_values
PixtralVLTask pixel_values
Phi4MMMultiModalTask pixel_values + audio_embeds
SpeechLanguageTask input_features
FunASRSpeechLanguageTask input_features
Gemma4Task (already fixed in original commit)

Embedding boundary dtype

Embedding model image_features/audio_features inputs remain at config.dtype. This is correct because:

  1. Encoder weights are cast to config.dtype → encoder computation runs in config.dtype → encoder outputs are config.dtype
  2. ORT GenAI passes encoder output directly to embedding input — dtypes match
  3. No dtype conversion needed at the boundary

Tests added

4 new tests verify encoder inputs stay FLOAT in f16/bf16 builds:

  • test_multimodal_encoder_inputs_are_float32[f16/bf16] — generic VL (LLaVA 3-model split)
  • test_gemma4_encoder_inputs_are_float32[f16/bf16] — Gemma4 (vision + audio encoders)

Test results

  • 1204 passed, 40 skipped, 0 failed
  • All lint checks pass

@justinchuby
justinchuby requested a review from apsonawane May 6, 2026 00:47
@justinchuby justinchuby changed the title Keep vision/audio encoders at float32 for bf16/f16 builds Cast vision/audio inputs f32 to model dtype for GenAI compatibility May 6, 2026
@justinchuby

Copy link
Copy Markdown
Member Author

@titaiwangms PTAL

@justinchuby
justinchuby merged commit f027a8e into main May 6, 2026
19 of 23 checks passed
@justinchuby
justinchuby deleted the fix-bf16-per-component-dtype branch May 6, 2026 15:29
@justinchuby

Copy link
Copy Markdown
Member Author

Merged LMK if follow ups needed

justinchuby added a commit that referenced this pull request May 6, 2026
…ill (#271)

Update the multimodal-models skill to document why vision/audio encoder
graphs accept f32 inputs and Cast to model dtype at graph entry.

## What this adds

New section in `.agents/skills/multimodal-models/SKILL.md`:

- **Why:** ORT GenAI image/audio processors always output f32,
regardless of model dtype
- **How:** Encoder graph adds `Cast(f32 → model_dtype)` at entry;
weights remain in f16/bf16
- **When:** Handled automatically by mobius with `--runtime ort-genai`
- **Error without it:** `Type Error: Type parameter (T) bound to
different types`

This documents the pattern introduced in PR #265.

---------

Signed-off-by: Justin Chu <justinchu@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants