Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- `remove_dropout_nodes` deleted nodes by `NodeProto.name`, which ONNX makes
optional. A single unnamed `Dropout` put the empty string into the delete set
and took every other unnamed node in the graph with it — on a fully unnamed
three-node fixture the pass emptied the graph. It also deleted malformed
`Dropout` nodes it had skipped rewiring. Now removes only the nodes it
actually rewired, by index.
- `eliminate_squeeze_unsqueeze_pairs` could accept overlapping inverse pairs
`(i, i+1)` and `(i+1, i+2)` from a chain such as `Squeeze -> Unsqueeze ->
Squeeze`. The reverse-removal loop then deleted already-shifted indices,
taking unrelated nodes with them. Overlapping matches are now skipped.

### Changed

- Optimizer passes no longer rebuild the runtime index individually. Nine of
the twelve passes called `rebuild_runtime_index()` on exit, so a single
`optimize_onnx_graph` re-ran execution-plan construction and weight
prepacking about ten times. The driver now rebuilds once after the whole
pipeline; the public per-pass entry points still rebuild for standalone
callers.

### Removed

- `run_onnx_model_sequential`, the ~600-line per-inference fusion scanner in
`runner/execute.rs`. It re-derived Conv+BN+Relu / Conv+SiLU / Conv+Add
patterns on every inference and was reachable only when the load-time
execution plan was empty, which no graph with nodes produces. Verified
unreachable across the workspace suite before removal. Its now-orphaned
helpers went with it: `find_relu_after_identity_chain`, `mark_skip_indices`,
`exec_reshape_zerocopy`, and the `use_counts`-taking
`try_reshape_nhwc_passthrough` (the plan path's `_inner` variant, and the
NHWC-passthrough optimization itself, are unaffected).

## [0.1.11] — 2026-07-26

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,16 @@ use crate::loader::OnnxNode;
/// Squeeze(Unsqueeze(x, axes=A), axes=A) and Unsqueeze(Squeeze(x, axes=A), axes=A).
pub fn eliminate_squeeze_unsqueeze_pairs(model: &mut OnnxModel) {
let mut remove_pairs: Vec<(usize, usize, String, String)> = Vec::new();
// Index of the first node not already claimed by an accepted pair. A chain
// like Squeeze -> Unsqueeze -> Squeeze matches at both `i` and `i + 1`;
// accepting both would make the reverse-removal loop below delete shifted
// indices, taking unrelated nodes with them.
let mut next_free = 0usize;

for i in 0..model.nodes.len().saturating_sub(1) {
if i < next_free {
continue;
}
let first = &model.nodes[i];
let second = &model.nodes[i + 1];
let is_pair = (first.op_type == "Squeeze" && second.op_type == "Unsqueeze")
Expand Down Expand Up @@ -38,6 +46,7 @@ pub fn eliminate_squeeze_unsqueeze_pairs(model: &mut OnnxModel) {
continue;
}
remove_pairs.push((i, i + 1, first.inputs[0].clone(), second.outputs[0].clone()));
next_free = i + 2;
}

for &(first_idx, second_idx, ref producer_input, ref consumer_output) in
Expand All @@ -58,7 +67,6 @@ pub fn eliminate_squeeze_unsqueeze_pairs(model: &mut OnnxModel) {
model.nodes.remove(second_idx);
model.nodes.remove(first_idx);
}
model.rebuild_runtime_index();
}

fn node_axes(model: &OnnxModel, node: &OnnxNode) -> Option<Vec<i64>> {
Expand Down
14 changes: 13 additions & 1 deletion crates/yscv-onnx/src/optimizer/fold_constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ use crate::runner::run_onnx_model;
///
/// Iterates until a fixed point is reached (no more foldable nodes).
pub fn fold_constants(model: &mut OnnxModel) {
if run(model) {
model.rebuild_runtime_index();
}
}

/// Folds without rebuilding the caller's runtime index; returns whether the
/// graph changed. The driver calls this and rebuilds once for the whole
/// pipeline. The throwaway `mini_model` built per folded node still rebuilds
/// its own index — it has to, since it is executed to produce the constant.
pub(super) fn run(model: &mut OnnxModel) -> bool {
let mut changed = false;
loop {
let foldable = model.nodes.iter().enumerate().find(|(_, node)| {
!node.inputs.is_empty()
Expand Down Expand Up @@ -85,11 +96,12 @@ pub fn fold_constants(model: &mut OnnxModel) {
model.initializers.insert(name, tensor);
}
model.nodes.remove(idx);
changed = true;
}
Err(_) => {
break;
}
}
}
model.rebuild_runtime_index();
changed
}
8 changes: 6 additions & 2 deletions crates/yscv-onnx/src/optimizer/fold_conv_add_const.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ use super::fold_conv_mul::broadcast_scale_to_oc;
/// Residual (non-constant) Adds are handled at runtime by `NodeAction::ConvAdd`
/// and are NOT touched here — we only fold when the second Add operand is a
/// graph initializer broadcastable to OC.
pub fn fold_conv_add_const(model: &mut OnnxModel) {
/// Folds without rebuilding the runtime index; returns whether the graph
/// changed. The driver rebuilds once for the whole pipeline.
pub(super) fn run(model: &mut OnnxModel) -> bool {
let mut changed = false;
let mut fuse_pairs: Vec<(usize, usize, usize)> = Vec::new();

for i in 0..model.nodes.len().saturating_sub(1) {
Expand Down Expand Up @@ -119,6 +122,7 @@ pub fn fold_conv_add_const(model: &mut OnnxModel) {

model.nodes[conv_idx].outputs[0] = add_out;
model.nodes.remove(add_idx);
changed = true;
}
model.rebuild_runtime_index();
changed
}
12 changes: 11 additions & 1 deletion crates/yscv-onnx/src/optimizer/fold_conv_bn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ use crate::loader::OnnxModel;
/// `b_fused[c] = (b[c] - mean_c) * scale_c + beta_c`
/// The Conv initializers are replaced and the BN node is removed.
pub fn fold_conv_bn(model: &mut OnnxModel) {
if run(model) {
model.rebuild_runtime_index();
}
}

/// Folds without rebuilding the runtime index; returns whether the graph
/// changed. The driver calls this and rebuilds once for the whole pipeline.
pub(super) fn run(model: &mut OnnxModel) -> bool {
let mut changed = false;
let mut fuse_pairs: Vec<(usize, usize)> = Vec::new();

for i in 0..model.nodes.len().saturating_sub(1) {
Expand Down Expand Up @@ -166,6 +175,7 @@ pub fn fold_conv_bn(model: &mut OnnxModel) {
model.nodes[conv_idx].outputs[0] = bn_output;

model.nodes.remove(bn_idx);
changed = true;
}
model.rebuild_runtime_index();
changed
}
8 changes: 6 additions & 2 deletions crates/yscv-onnx/src/optimizer/fold_conv_mul.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ fn scale_weight_inplace(
/// Conv output is only consumed by the Mul. After fusion:
/// W'[c] = W[c] · s[c]
/// b'[c] = b[c] · s[c]
pub fn fold_conv_mul(model: &mut OnnxModel) {
/// Folds without rebuilding the runtime index; returns whether the graph
/// changed. The driver rebuilds once for the whole pipeline.
pub(super) fn run(model: &mut OnnxModel) -> bool {
let mut changed = false;
let mut fuse_pairs: Vec<(usize, usize, usize)> = Vec::new();

for i in 0..model.nodes.len().saturating_sub(1) {
Expand Down Expand Up @@ -171,6 +174,7 @@ pub fn fold_conv_mul(model: &mut OnnxModel) {

model.nodes[conv_idx].outputs[0] = mul_out;
model.nodes.remove(mul_idx);
changed = true;
}
model.rebuild_runtime_index();
changed
}
10 changes: 9 additions & 1 deletion crates/yscv-onnx/src/optimizer/fuse_bn_relu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ use crate::loader::OnnxModel;
/// (annotation-only; execution still handles them separately, but this
/// reduces graph traversal overhead for large models).
pub fn fuse_bn_relu(model: &mut OnnxModel) {
if run(model) {
model.rebuild_runtime_index();
}
}

/// Fuses without rebuilding the runtime index; returns whether the graph
/// changed. The driver calls this and rebuilds once for the whole pipeline.
pub(super) fn run(model: &mut OnnxModel) -> bool {
let mut fuse_pairs: Vec<(usize, usize)> = Vec::new();
for i in 0..model.nodes.len().saturating_sub(1) {
if model.nodes[i].op_type == "BatchNormalization"
Expand Down Expand Up @@ -31,5 +39,5 @@ pub fn fuse_bn_relu(model: &mut OnnxModel) {
model.nodes[bn_idx].op_type = "BatchNormalization_Relu".to_string();
model.nodes.remove(relu_idx);
}
model.rebuild_runtime_index();
!fuse_pairs.is_empty()
}
10 changes: 9 additions & 1 deletion crates/yscv-onnx/src/optimizer/fuse_conv_relu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ use crate::loader::OnnxModel;

/// Fuse `Conv` -> `Relu` node pairs into a single fused-activation Conv.
pub fn fuse_conv_relu(model: &mut OnnxModel) {
if run(model) {
model.rebuild_runtime_index();
}
}

/// Fuses without rebuilding the runtime index; returns whether the graph
/// changed. The driver calls this and rebuilds once for the whole pipeline.
pub(super) fn run(model: &mut OnnxModel) -> bool {
let mut fuse_pairs: Vec<(usize, usize)> = Vec::new();
for i in 0..model.nodes.len().saturating_sub(1) {
if model.nodes[i].op_type == "Conv"
Expand Down Expand Up @@ -29,5 +37,5 @@ pub fn fuse_conv_relu(model: &mut OnnxModel) {
model.nodes[conv_idx].op_type = "Conv_Relu".to_string();
model.nodes.remove(relu_idx);
}
model.rebuild_runtime_index();
!fuse_pairs.is_empty()
}
26 changes: 15 additions & 11 deletions crates/yscv-onnx/src/optimizer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,7 @@ use crate::{

pub use analyze_nchwc::analyze_nchwc;
pub use fold_constants::fold_constants;
pub use fold_conv_add_const::fold_conv_add_const;
pub use fold_conv_bn::fold_conv_bn;
pub use fold_conv_mul::fold_conv_mul;
pub use fuse_bn_relu::fuse_bn_relu;
pub use fuse_conv_relu::fuse_conv_relu;
pub use graph_cost::{
Expand All @@ -41,6 +39,9 @@ pub use strip_qdq_within_fusion_chains::strip_qdq_within_fusion_chains;
/// Optimizes an ONNX model graph in-place for inference.
///
/// Applies load-time passes modeled after ORT's Level-1 optimizer:
/// - Node reordering (topological re-sort so producers sit immediately before
/// consumers; the fusion passes below match on adjacency, so this is a
/// correctness prerequisite for them, not a nicety)
/// - Dropout removal (inference-only, rewire consumers to Dropout input)
/// - ConvTranspose(k==s) rewrite to Conv1x1 + DepthToSpace (GEMM-backed path,
/// also unlocks backends without a ConvTranspose kernel)
Expand All @@ -49,25 +50,28 @@ pub use strip_qdq_within_fusion_chains::strip_qdq_within_fusion_chains;
/// - Conv-Add(const) bias absorption (absorb per-channel Add into Conv bias)
/// - Constant folding (execute nodes with all-initializer inputs at load)
/// - Squeeze/Unsqueeze pair elimination (drop inverse pairs left by PyTorch export)
/// - Conv+Clip(0,max) fusion (ReLU6-style clamped activation)
/// - Conv+Relu / BN+Relu fusion (annotation-only; kernel dispatches on op_type)
/// - Dead code elimination (iterate to fixpoint)
///
/// Order matters: `fold_conv_bn` runs before Conv-Mul/Conv-Add because BN
/// usually absorbs into Conv already; only stray scale/bias left over fall
/// through. Constant folding runs before Relu/Clip fusions because folded
/// Relus may turn Clip-style patterns into plain activations.
/// through. Constant folding runs before the activation fusions because a
/// folded constant can turn a data-dependent activation into a plain one.
///
/// The runtime index is rebuilt once here, after every pass has run. Individual
/// passes must not rebuild it themselves — doing so re-runs plan construction
/// and weight prepacking once per pass.
pub fn optimize_onnx_graph(model: &mut OnnxModel) {
reorder_nodes_for_fusion(model);
remove_dropout_nodes(model);
rewrite_convtranspose_dts(model);
fold_conv_bn(model);
fold_conv_mul(model);
fold_conv_add_const(model);
fold_constants(model);
fold_conv_bn::run(model);
fold_conv_mul::run(model);
fold_conv_add_const::run(model);
fold_constants::run(model);
eliminate_squeeze_unsqueeze_pairs(model);
fuse_conv_relu(model);
fuse_bn_relu(model);
fuse_conv_relu::run(model);
fuse_bn_relu::run(model);
eliminate_dead_code(model);
model.rebuild_runtime_index();

Expand Down
21 changes: 11 additions & 10 deletions crates/yscv-onnx/src/optimizer/remove_dropout_nodes.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
use rustc_hash::FxHashSet;

use crate::loader::OnnxModel;

/// Removes Dropout nodes by rewiring their consumers to the Dropout's input.
pub fn remove_dropout_nodes(model: &mut OnnxModel) {
let mut rewire: Vec<(String, String)> = Vec::new();
// Indices of the Dropouts we actually rewired, ascending. Deleting by node
// name instead would be wrong twice over: `NodeProto.name` is optional in
// ONNX, so one unnamed Dropout would take every other unnamed node in the
// graph with it, and a malformed Dropout that was skipped above would be
// deleted without its consumers ever being rewired.
let mut drop_indices: Vec<usize> = Vec::new();

for node in &model.nodes {
for (idx, node) in model.nodes.iter().enumerate() {
if node.op_type == "Dropout" && !node.inputs.is_empty() && !node.outputs.is_empty() {
rewire.push((node.outputs[0].clone(), node.inputs[0].clone()));
drop_indices.push(idx);
}
}

Expand All @@ -31,11 +36,7 @@ pub fn remove_dropout_nodes(model: &mut OnnxModel) {
}
}

let dropout_names: FxHashSet<String> = model
.nodes
.iter()
.filter(|n| n.op_type == "Dropout")
.map(|n| n.name.clone())
.collect();
model.nodes.retain(|n| !dropout_names.contains(&n.name));
for &idx in drop_indices.iter().rev() {
model.nodes.remove(idx);
}
}
12 changes: 11 additions & 1 deletion crates/yscv-onnx/src/optimizer/strip_qdq_within_fusion_chains.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ use crate::loader::{OnnxModel, OnnxNode};
///
/// Returns the number of Q+DQ pairs removed.
pub fn strip_qdq_within_fusion_chains(model: &mut OnnxModel) -> usize {
let removed = run(model);
if removed != 0 {
model.rebuild_runtime_index();
}
removed
}

/// Strips without rebuilding the runtime index; returns the number of pairs
/// removed. This pass is not in the driver pipeline — the quantization tools
/// call the public wrapper above — but it follows the same contract.
pub(super) fn run(model: &mut OnnxModel) -> usize {
let conv_like = |op: &str| matches!(op, "Conv" | "Conv_Relu" | "MatMul" | "Gemm");

let mut to_remove: Vec<usize> = Vec::new();
Expand Down Expand Up @@ -96,7 +107,6 @@ pub fn strip_qdq_within_fusion_chains(model: &mut OnnxModel) -> usize {
for &idx in to_remove.iter().rev() {
model.nodes.remove(idx);
}
model.rebuild_runtime_index();
removed
}

Expand Down
Loading
Loading