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:
- Fuse up — apply fusion rules (GQA, BiasGelu, PackedAttention) for supported fusions
- 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
- Precision/dtype selection (3 BPs): EP determines I/O dtype (CPU forces FP32 for INT4, WebGPU optional FP32, CUDA/TRT-RTX BF16)
- Attention op selection (6 BPs): EP+dtype determines GQA vs MHA, packed vs separate QKV, fused RoPE
- Graph structure (7 BPs): EP determines If/Shape/int64 support → different subgraph topologies
- Quantization (5 BPs): EP determines block size, zero points, weight layout, accuracy levels
- Operator selection (3 BPs): EP determines fused vs decomposed MoE, LayerNorm variants
- Config generation (2 BPs): EP-specific provider_options and sliding window config
- 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())
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:
Cons:
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:
Cons:
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:
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: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)
op.Attentionwithq_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).use_concrete_dimsflag whenep='webgpu', producing[0, 0, N, H]reshape targets. ~3 lines inCausalLMTask._optimize()which runs atomically.op.Attentioncarriesq_num_heads/kv_num_headsattributes. Graph topology encodes packed-vs-separate QKV (one MatMul vs three). Rules checkq.producer.op_type.packed_qkv: boolattribute toop.Attentionif needed — minimal School A touch point.School A cons (secondary approach — 3 build-time cases)
CausalLMTask). Not 80 models × 5 EPs — just 1 task file. Everything else is School B.CausalLMTask.build()), following the same precedent asstatic_cache=True._get_optimization_passes(ep, dtype). School A component adds no new rules and doesn't block existing ones.ep_dml.pyrule file + support matrices in_builder.py. Zero DML logic in any component or model.Background: ORT GenAI EP Logic Analysis
Analyzed all 20 files in
onnxruntime-genai/src/python/py/models/. Found:EP Capability Matrix
Branch Point Categories
Proposed Design
Core Principle: Hybrid approach
API
Architecture
Layer responsibilities:
Rewrite Rule Registry
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 fusionThese rules are NOT wired into
_optimize()today. Activating them conditionally is the highest-value quick win.Implementation Phases
Phase 1: EP parameter + rule activation
execution_providerparameter tobuild()andbuild_from_module()_optimize(model, ep, dtype)a factory that selects passes based on EP_optimize()conditionallyep="cpu"— zero behavioral change for existing callersPhase 2: EP-conditional rule registry + (ep, dtype) support matrix
_EP_DTYPE_RULE_SUPPORTmatrix from ORT GenAI analysisep_cuda.py,ep_dml.py,ep_webgpu.py, etc.apply_ep_rewrites()as public API for power usersPhase 3: Structural variants + quantization
build()variants for WebGPU (concrete dims, int32 inputs) and TRT-RTX (split-If, sliding window)quantize(model, strategy, block_size)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_cacheprecedent 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
onnxruntime-genai/src/python/py/models/src/mobius/rewrite_rules/src/mobius/_builder.py(_optimize())