Skip to content

Execution Provider (EP) aware model building #99

Description

@justinchuby

Summary

Mobius currently builds EP-agnostic ONNX graphs. To produce optimized models for specific execution providers (CUDA, DML, WebGPU, TensorRT, CPU), we need EP-aware graph construction and optimization.

This issue proposes a design based on analysis of all 28 EP-specific branch points in onnxruntime-genai's model builders, mapped against mobius's 4-layer architecture.

Design Analysis: Two Schools of Thought

There are two schools of thought on how to handle EP differences:

School A: Generate EP differences in modeling code

EP-aware graph construction — components/models emit different ops at build time based on the target EP.

Pros:

  • Correctness by construction — graph is always valid for target EP
  • Simpler debugging — look at forward(), not rewrite rule chains
  • Only viable option for constraints like WebGPU no-Shape (need concrete dims at build time)
  • No pattern fragility — no risk of rules silently failing to match

Cons:

  • O(models × EPs) maintenance — 80+ models × 5 EPs = scattered if/else everywhere
  • Components lose model-agnostic guarantee (couples reusable components to deployment infra)
  • Testing burden multiplies (models × EPs test matrix)
  • ORT GenAI tried this approach — 14/20 model files ended up with ZERO EP logic because it does not scale

Best for: Structural decisions that cannot be derived from the graph (concrete dims, static shapes) — ~3 of 28 ORT GenAI branch points

School B: Rewrite after generation

Build a single generic graph, then apply EP-specific rewrite rules to transform it.

Pros:

  • N graphs + M rule sets instead of N×M code paths — scales with new models and new EPs
  • Preserves 4-layer architecture invariant (components and models stay EP-agnostic)
  • Each rule is independently testable with clear input→output contract
  • Mobius already has 18 rewrite rules with tests — activation is the quick win
  • Users can inspect the canonical generic graph for debugging

Cons:

  • Pattern fragility — rules silently fail if graph pattern varies slightly
  • Information loss — harder to detect packed vs unpacked projections from graph alone
  • Cannot handle WebGPU no-Shape — cannot rewrite unknown shape to known constant
  • Rule ordering sensitivity — rule A output may break rule B matching

Best for: Op substitution, fusion, decomposition — ~20 of 28 ORT GenAI branch points

Recommendation: Principled Hybrid with "Fuse Up, Lower Down"

The boundary principle: if the information needed exists in the completed graph → rewrite after (School B). If the information must be injected during construction → generate differently (School A).

This gives:

  • 20/28 branch points → School B (post-processing rewrite rules)
  • 3/28 branch points → School A (build-time: WebGPU concrete dims, TRT-RTX sliding window)
  • 5/28 → Neither school (dtype parameters, quantization config, runtime metadata)

Alternative: EP Capability Dialect

Instead of if ep == "dml" scattered through the codebase, declare each EP as a formal dialect — the set of ops it supports with dtype constraints. The optimization pipeline becomes:

  1. Fuse up — apply fusion rules (GQA, BiasGelu, PackedAttention) for supported fusions
  2. Lower down — decompose any unsupported ops into primitives the EP supports

This is how MLIR handles target lowering. It sidesteps the rewrite-vs-build tradeoff because it is declarative: adding EP #6 = declaring what it supports. No scattered if/else, no pattern fragility for decompositions.

Phased approach: Start with rewrite rules (School B) for pragmatism in Phases 1-2. Evolve to formal dialect lowering as the internal implementation matures in Phase 3+. The public API stays build(model_id, execution_provider="cuda") throughout all phases.

How the Proposal Mitigates Each Con

School B cons (primary approach — rewrite after generation)

Con Mitigation Residual Risk
Pattern fragility — rules silently fail if graph varies Mobius emits high-level ops (op.Attention with q_num_heads/kv_num_heads) not raw MatMul+Softmax. Rules match stable ops, not fragile multi-node patterns. Phase 3 dialect lowering walks every node (no pattern matching for decompositions). Partially mitigated. Fusion rules still pattern-match. Mitigated by: integration tests per EP that verify expected fused ops appear; rule-coverage assertions that warn if expected rules didn't fire.
Cannot handle WebGPU no-Shape Explicitly handled by the School A escape hatch. Task-level use_concrete_dims flag when ep='webgpu', producing [0, 0, N, H] reshape targets. ~3 lines in CausalLMTask. Fully mitigated. Cost is 3 lines of EP-aware code in the task layer only.
Rule ordering sensitivity The fuse-up/lower-down pipeline makes ordering principled: (1) base cleanup → (2) fusion → (3) lowering/decomposition → (4) constant folding. Fusion rules never see decomposed ops. Lowering never sees unfused patterns. Mostly mitigated. Within a stage, cross-rule dependencies could exist. Mitigated by keeping rules independent (each matches a distinct op type) and documenting ordering constraints.
Intermediate invalid state Generic graph is always valid ONNX — it runs on CPU (universal fallback). The 'invalid for target EP' state only exists transiently inside _optimize() which runs atomically. Fully mitigated. Users who skip optimization get a working (slower) model, not a crash.
Information loss (packed vs unpacked QKV) op.Attention carries q_num_heads/kv_num_heads attributes. Graph topology encodes packed-vs-separate QKV (one MatMul vs three). Rules check q.producer.op_type. Partially mitigated. Topology inference is more brittle than explicit flags. Fallback: add packed_qkv: bool attribute to op.Attention if needed — minimal School A touch point.

School A cons (secondary approach — 3 build-time cases)

Con Mitigation Residual Risk
O(models × EPs) maintenance School A is scoped to exactly 1 decision point (WebGPU concrete dims in CausalLMTask). Not 80 models × 5 EPs — just 1 task file. Everything else is School B. Fully mitigated. Scope-limited to 1 location.
Components lose model-agnostic guarantee Hard rule: EP logic is banned from components and models. The 1 School A touch point lives in the task layer (CausalLMTask.build()), following the same precedent as static_cache=True. Fully mitigated. Architectural constraint enforced by layer boundaries.
Testing burden multiplies Only 1 task variant needs EP-axis testing. 1 task × School A + 80 models × School B = 1 extra test config, not 400. Fully mitigated.
Ignores existing rewrite rules The hybrid activates all 18 existing rules via _get_optimization_passes(ep, dtype). School A component adds no new rules and doesn't block existing ones. Fully mitigated. Existing rules are the primary implementation.
DML restrictions scatter across code All DML restrictions live in ep_dml.py rule file + support matrices in _builder.py. Zero DML logic in any component or model. Fully mitigated.

Background: ORT GenAI EP Logic Analysis

Analyzed all 20 files in onnxruntime-genai/src/python/py/models/. Found:

  • 6 files with EP logic (base.py, builder.py, gptoss.py, phi.py, qwen.py, whisper.py)
  • 14 files with zero EP logic (gemma, llama, mistral, etc.)
  • 28 distinct branch points across 7 categories
  • 5 EPs supported: cpu, cuda, dml, webgpu, trt-rtx

EP Capability Matrix

Capability cpu cuda dml webgpu trt-rtx
GQA FP32 FP16,BF16 FP16 FP16,FP32 FP16,BF16
Packed QKV FP32 All FP16,FP32 FP16,FP32 All
RoPE in attention
Fused MoE
If operator Std only Split
Graph capture Optional N/A Optional Default
Fused LayerNorm Skip/Simple: ❌
Sliding window

Branch Point Categories

  1. Precision/dtype selection (3 BPs): EP determines I/O dtype (CPU forces FP32 for INT4, WebGPU optional FP32, CUDA/TRT-RTX BF16)
  2. Attention op selection (6 BPs): EP+dtype determines GQA vs MHA, packed vs separate QKV, fused RoPE
  3. Graph structure (7 BPs): EP determines If/Shape/int64 support → different subgraph topologies
  4. Quantization (5 BPs): EP determines block size, zero points, weight layout, accuracy levels
  5. Operator selection (3 BPs): EP determines fused vs decomposed MoE, LayerNorm variants
  6. Config generation (2 BPs): EP-specific provider_options and sliding window config
  7. Forced overrides (2 BPs): Model-specific EP constraints (Phi3MoE→CUDA-only)

Proposed Design

Core Principle: Hybrid approach

  • Post-processing rewrite rules handle ~80% of EP differences (op substitution, control flow elimination)
  • Task build() variants handle structural differences requiring different graph topologies
  • Components and models remain EP-agnostic — preserves the 4-layer architecture invariant

API

# Simple case
pkg = build("Qwen/Qwen2.5-7B", execution_provider="cuda")

# Advanced case
pkg = build_from_module(module, config, task, execution_provider="cuda")

# Power user — explicit control
pkg = build_from_module(module, config)
for name, model in pkg.items():
    model = apply_ep_rewrites(model, ep="dml", dtype=ir.DataType.FLOAT16)

Architecture

build(model_id, execution_provider="cuda")
  → Registry lookup (model + task + config)
  → task.build(module, config)              # EP-agnostic for standard tasks
  → _optimize(model, ep="cuda", dtype=...)  # EP-aware rewrite rules
  → ModelPackage

Layer responsibilities:

Layer EP awareness Responsibility
Components None Emit generic ONNX ops
Models None Architecture-only
Tasks Minimal Structural variants (WebGPU no-Shape, TRT-RTX split-If)
Builder/_optimize Full EP-conditional rewrite rule activation

Rewrite Rule Registry

def _get_optimization_passes(ep: str, dtype: ir.DataType) -> list:
    passes = list(_BASE_PASSES)  # current 9 default passes
    for pass_cls, support_set in _EP_DTYPE_RULE_SUPPORT.items():
        if (ep, dtype) in support_set:
            passes.append(pass_cls())
    return passes

_EP_DTYPE_RULE_SUPPORT = {
    GQAFusionPass: {("cuda", FLOAT16), ("cuda", BFLOAT16), ("dml", FLOAT16), ...},
    PackedAttentionPass: {("cuda", FLOAT16), ...},
    IfToSelectPass: {("dml", ALL), ("webgpu", ALL)},  # no-If EPs
    ...
}

Existing Assets

src/mobius/rewrite_rules/ already has 18 fusion rule files with tests:

  • _group_query_attention.py — GQA fusion
  • _packed_attention.py — Packed attention
  • _layer_norm_fusion.py — LayerNorm fusion
  • _bias_gelu.py, _gelu_fusion.py — Activation fusions
  • _fused_matmul.py — MatMul fusion
  • _skip_layer_norm.py, _skip_norm.py — Skip+norm fusion
  • And 10 more

These rules are NOT wired into _optimize() today. Activating them conditionally is the highest-value quick win.

Implementation Phases

Phase 1: EP parameter + rule activation

  • Add execution_provider parameter to build() and build_from_module()
  • Make _optimize(model, ep, dtype) a factory that selects passes based on EP
  • Wire existing 18 rewrite rules into _optimize() conditionally
  • Default ep="cpu" — zero behavioral change for existing callers
  • Scope: 2-3 files changed, no breaking changes

Phase 2: EP-conditional rule registry + (ep, dtype) support matrix

  • Implement _EP_DTYPE_RULE_SUPPORT matrix from ORT GenAI analysis
  • Add EP-specific rule sets: ep_cuda.py, ep_dml.py, ep_webgpu.py, etc.
  • Implement If→Select rewrite rule for DML/WebGPU
  • Implement Shape elimination rewrite rule for WebGPU graph capture
  • Expose apply_ep_rewrites() as public API for power users

Phase 3: Structural variants + quantization

  • Task build() variants for WebGPU (concrete dims, int32 inputs) and TRT-RTX (split-If, sliding window)
  • Quantization as orthogonal pass: quantize(model, strategy, block_size)
  • genai_config.json provider_options generation
  • Model-specific EP constraints (registry-level validation)

Design Rationale

Why not pure build-time EP (Option B)?

Scatters EP logic into every layer. Components grow if ep == "cuda" branches. Recreates ORT GenAI's scattered if/else problem inside the graph builder. Maintenance scales with models × EPs.

Why not full EP profile objects (Option C)?

Premature abstraction. ORT GenAI has had 5 EPs for years and uses a simple string + extra_options dict. A dict of rewrite rule lists achieves the same separation without new abstractions.

Why hybrid instead of pure post-processing (Option A)?

Post-processing handles ~80% of cases. But structural decisions like concrete dims for WebGPU or split-If for TRT-RTX are cleaner at build time. Tasks already have the static_cache precedent for structural variants.

Key insight from ORT GenAI analysis

14 of 20 model files have ZERO EP logic. EP awareness belongs in the builder/task layer, not in model code. This validates mobius's 4-layer architecture — components and models should stay EP-agnostic.

References

  • ORT GenAI model builders: onnxruntime-genai/src/python/py/models/
  • Mobius rewrite rules: src/mobius/rewrite_rules/
  • Mobius optimization pipeline: src/mobius/_builder.py (_optimize())

Metadata

Metadata

Assignees

Labels

aiCreated by an AI agent

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions