IR Optimizer Passes¶
The converter runs a lightweight, IR-only optimization sweep after lowering and before serialization. Passes must be structure-only (no op-specific math) and safe across onnx_ir variants. This guide documents the current canon and the invariants each pass must respect.
Pipeline Placement¶
The optimizer runs as Step 2 in the conversion pipeline (see architecture.md):
- Build raw IR (
to_onnx) optimize_graph← runs here- Late attribute overrides
- Finalize shapes
- Return from
conversion_api - Post-process (shape loosening, export prep)
This placement ensures: - Optimization sees the raw, unpatched graph for maximum benefit. - Late overrides only patch nodes that survived optimization. - Shape finalization operates on an already-optimized graph.
Optimizer failures are logged and skipped by default so ordinary exports can
continue. Set JAX2ONNX_STRICT_OPTIMIZER_FAILURES=1 when debugging or in CI to
re-raise the original optimizer exception.
Pass Registry¶
optimize_graph executes the _OPTIMIZER_PASSES registry in order. Each entry
declares its name plus whether it runs on the top-level model, the top-level
graph, and/or function body graphs. Function bodies deliberately skip
model-wide passes and prune_unused_graph_inputs so function signatures remain
stable.
Current order:
name_fixremove_redundant_castsremove_redundant_transpose_reduceremove_redundant_transpose_add_forestsremove_redundant_transpose_pairsremove_redundant_reshape_pairsremove_identity_reshapescommon_subexpression_eliminationlift_constants_to_initializersfold_conv_transpose_constant_biasrewrite_mul_sigmoid_as_swishrewrite_mul_rsqrt_as_divinline_dropout_training_mode_constantspropagate_elementwise_shapespropagate_unary_shapesremove_redundant_casts_after_propagationremove_dead_nodesremove_orphan_transposesprune_unused_graph_inputs
Only graph-scoped passes with function_bodies=True run inside imported ONNX
Function bodies. Model-wide passes (name_fix, CSE, constant lifting,
dead-node removal) and prune_unused_graph_inputs stay top-level only.
Graph Scope and Value Ownership¶
Passes receive an ir.Graph and traverse real ir.Value objects. Use the
shared functions in converter/optimizer_graph_utils.py for discovery:
producer = producer_in_graph(graph, value)
consumers = consumers_in_graph(graph, value)
belongs_here = node_in_graph(graph, node)
These functions use Value.producer(), Value.consumers(), and Node.graph.
They match object identity within the supplied graph. A captured outer value
can have consumers in a child graph while its producer belongs to the parent.
Consumer discovery therefore checks each consumer's ownership rather than
assuming that value.graph equals the consumer graph.
Keep list(graph) snapshots when a pass removes or inserts nodes during
iteration. Pass the live graph to discovery functions: a snapshot records an
iteration order, not current membership. Removed nodes may remain referenced
by their output values, so a producer reference alone does not prove that the
node is still in the graph.
The explicit legacy_producer_node, legacy_consumer_nodes, and
legacy_node_in_sequence adapters accept detached node sequences. The first
two retain historical name matching for raw names and disconnected value
wrappers; sequence membership always tests exact identity. Existing private
imports remain aliases for these adapters. New passes use the graph-scoped
functions so equal names in different scopes cannot create false edges.
An ir.Graph also implements the node-sequence interface, so legacy helpers can
receive the live graph as well as a detached list. Their historical name fallback
applies in both cases; a sequence snapshot cannot supply current graph ownership.
This compatibility rule does not change the stricter native graph helpers.
Read shape and dtype metadata directly from ir.Value.shape and
ir.Value.dtype. Missing metadata and symbolic dimensions still require
semantic checks; alternate object layouts do not. Register new initializers
with graph.register_initializer(value).
Graph Rewrite Reference¶
rewrite_mul_sigmoid_as_swish_ir in converter/ir_optimizations.py is a
complete executable reference. It matches x * Sigmoid(x) within the live
graph, requires opset 24+, and replaces the pair with Swish. This example
builds typed values, runs that production pass, and checks the output contract:
import onnx_ir as ir
from onnx_ir.tape import Tape
from jax2onnx.converter.ir_optimizations import rewrite_mul_sigmoid_as_swish_ir
from jax2onnx.converter.optimizer_graph_utils import producer_in_graph
def graph_rewrite_reference() -> ir.Graph:
x = ir.val("x", dtype=ir.DataType.FLOAT, shape=[3])
output = ir.val("y", dtype=ir.DataType.FLOAT, shape=[3])
output.metadata_props["example"] = "output metadata"
graph = ir.Graph([x], [output], nodes=[], opset_imports={"": 24})
tape = Tape(graph)
sigmoid = tape.op(
"Sigmoid",
[x],
output=ir.val("sigmoid", dtype=ir.DataType.FLOAT, shape=[3]),
)
tape.op(
"Mul",
[x, sigmoid],
output=output,
metadata_props={"example": "node metadata"},
)
rewrite_mul_sigmoid_as_swish_ir(graph)
rewritten = graph.outputs[0]
producer = producer_in_graph(graph, rewritten)
assert producer is not None and producer.op_type == "Swish"
assert producer.graph is graph and producer.inputs[0] is x
assert rewritten.name == "y"
assert rewritten.dtype == ir.DataType.FLOAT
assert rewritten.shape == ir.Shape([3])
assert rewritten.metadata_props["example"] == "output metadata"
assert producer.metadata_props["example"] == "node metadata"
assert producer_in_graph(graph, output) is None
assert [node.op_type for node in graph] == ["Swish"]
return graph
graph = graph_rewrite_reference()
The pass creates a replacement output with the original name, type, shape,
and value metadata, and copies node metadata to the replacement node. It uses
graph.insert_before, ir.convenience.replace_all_uses_with with
replace_graph_outputs=True, and graph.remove to keep use-def links and
outputs connected. A shared or observable Sigmoid output retains its node.
Discovery receives the live graph even while mutation iterates over a snapshot.
An IR rewrite operates on graph values directly; the JAX-var binding pattern
belongs to the canonical plugin example.
Pass Summary¶
| Pass | Scope | Purpose |
|---|---|---|
name_fix |
model | Normalize generated names through the ONNX Script pass stack. |
remove_redundant_casts |
graph + functions | Drop casts whose input already has the target dtype, and fold immediate cast pairs with no net dtype change. |
remove_redundant_transpose_reduce |
graph + functions | Remove transpose/reduce patterns where axes and shapes prove the transpose has no semantic effect. |
remove_redundant_transpose_add_forests |
graph + functions | Collapse broadcast-add forests introduced by layout conversions when every branch can be safely rewired. |
remove_redundant_transpose_pairs |
graph + functions | Fold inverse transpose pairs across shape-preserving elementwise chains. |
remove_redundant_reshape_pairs |
graph + functions | Fold flatten/unflatten pairs across shape-preserving elementwise chains. |
remove_identity_reshapes |
graph + functions | Drop reshapes whose requested static shape exactly matches the input shape. |
common_subexpression_elimination |
model | Run the ONNX Script CSE pass on the top-level model. |
lift_constants_to_initializers |
model | Lift eligible Constant nodes to graph initializers. |
fold_conv_transpose_constant_bias |
graph + functions | Attach a direct immutable channel bias as an absent ConvTranspose bias input. |
rewrite_mul_sigmoid_as_swish |
graph + functions | Replace an exact input-times-sigmoid pattern with Swish where the opset supports it. |
rewrite_mul_rsqrt_as_div |
graph + functions | Rewrite multiplication by reciprocal square root into direct division where the producer chain is exact. |
inline_dropout_training_mode_constants |
graph + functions | Inline static Dropout training_mode inputs so inference exports stay compact. |
propagate_elementwise_shapes |
graph + functions | Propagate known shape metadata through elementwise operators. |
propagate_unary_shapes |
graph + functions | Propagate known shape metadata through unary operators. |
remove_redundant_casts_after_propagation |
graph + functions | Re-run cast cleanup after shape/dtype propagation may have exposed new no-op casts. |
remove_dead_nodes |
model | Remove nodes that are unreachable from graph outputs. |
remove_orphan_transposes |
graph + functions | Remove layout transposes whose outputs no longer feed live consumers. |
prune_unused_graph_inputs |
graph only | Remove unused top-level graph inputs while preserving ONNX Function signatures. |
ConvTranspose Constant Bias Folding¶
Pattern: ConvTranspose(x, weights) → Add(channel_bias).
The pass attaches an immutable float32 or float64 channel bias as the third ConvTranspose input. The convolution must have no existing bias, and its raw output must have exactly one use and must not be a graph output. The Add must broadcast only across the NCHW channel dimension without changing the output shape. Constant reshape, squeeze, unsqueeze, identity, and transpose views of the bias can be unwrapped when their shapes prove that vector order is unchanged.
The equivalent layout pattern
ConvTranspose → Transpose → Add(channel_bias) → inverse Transpose is also
folded when the permutations are explicitly inverse, all intermediate shapes
and dtypes agree, and none of the intermediate outputs is shared or observable.
This exposes the native NCHW pair emitted by channels-last NNX models without
moving across an activation or padding operation.
The pass preserves the Add's output value, public name, dtype, and shape. Shared initializers remain unchanged, and function/subgraph constants remain in their own scope. It performs no parameter arithmetic, combines no existing bias, and does not cross a Pad or another intervening operator. FP16/BF16 are excluded because integrating the bias can change intermediate rounding.
This structure exposes eligible ConvTranspose → BatchNormalization pairs to
ONNX Runtime 1.31's BatchNorm fusion.
The rewrite itself uses standard ONNX inputs and does not depend on the installed
runtime. Runtime results can still differ slightly through floating-point
evaluation order; regression tests check dtype-appropriate numerical parity.
Transpose Pair Folding¶
Pattern
Transpose → [pure elementwise]* → Transpose
Condition The composed permutation of the Transpose nodes equals identity.
Allowed middle ops
Elementwise operators that do not reorder elements, including:
Relu, Gelu, Elu, Sigmoid, Tanh, LeakyRelu, Cast, CastLike, Identity, Not, etc.
Not folded
Anything that crosses non-elementwise operators such as AveragePool, Conv, or similar layout-sensitive ops.
Matching heuristics¶
- Follow the consumer chain by value identity within the current
ir.Graph. - Skip helper nodes on side branches (
Const,Shape, etc.) that do not consume the current tensor. - Require single consumer at each hop (no branching rewires).
- Read permutations from the
permattribute when available. - When
permis missing, treat the pair as cancellable only if the input and output shapes match and the middle segment is strictly elementwise.
Rewiring and deletion¶
- Use
Node.replace_input_with(index, value)to update individual inputs. - Rewire all uses of the second transpose's output with
ir.convenience.replace_all_uses_with(..., replace_graph_outputs=True). - Update graph/model outputs and the var→value map so no reference points at removed nodes.
- Remove replaced nodes through
graph.remove(...); IR maintains ownership and its internal node containers.
This pass is intentionally conservative, portable across onnx_ir variants, and oblivious to specific operator semantics.
Identity Reshape Removal¶
Pattern
Reshape(x, shape) where shape is a constant that exactly matches x’s known dimensions.
Condition
- The shape input is a constant tensor with no -1 or 0 entries.
- Every dimension of x is statically known and equal to the requested target.
- Output metadata (if present) already reflects the same shape.
Effect Rewire consumers of the Reshape output directly to the input and drop the node. Any now-unused shape initializers are left for later dead-code removal passes.
This trims redundant layout annotations generated by higher-level conversions (e.g., Equinox attention blocks) without touching dynamic reshape cases.
Redundant Cast Removal¶
Patterns
1. Cast(x, to=T) where x already has dtype T.
2. Cast(x, to=T) → Cast(y, to=S) where S equals the original dtype of x.
Effect
The Cast node(s) are removed and consumers are rewired to the original input x, assuming the net effect on dtype is identity.
Reshape Pair Folding¶
Pattern
Reshape(A) → [elementwise]* → Reshape(B)
Condition - The allowed elementwise ops are the same as in Transpose folding (shape-preserving). - The input shape of the first Reshape matches the output shape of the second Reshape.
Effect
Both Reshape nodes are removed. The elementwise ops are rewired to consume A directly, and the consumers of B are rewired to the output of the last elementwise op. This eliminates redundant flatten/unflatten pairs often emitted by high-level frameworks.
Authoring new passes¶
- Keep logic IR-only—never import ONNX protobuf utilities.
- Prefer
ir.Valueownership helpers (producer(),consumers(),ir.convenience.replace_all_uses_with,graph.remove(...)) over private mirror mutation. - Preserve graph outputs and function signatures explicitly when rewiring.
- Use shared optimizer utilities before adding new local graph-walking helpers.
- Add focused regression tests under
tests/extra_tests/framework/. - Document the new rule here and reference the guide from
architecture.md.