microsoft / microsoft/onnxruntime

Constant folding silently disabled for GreaterOrEqual / LessOrEqual below opset 16 in 1.27 (regression from the new output-size guard)

Open
#32,130 1 comment 0 reactions 0 assignees View on GitHub
ep:VitisAI stale
Dominant language
C++
Stars
21.9k
Forks
4.2k
Avg merge
4d 11h
Merged PRs (30d)
184

Description

### Describe the issue

Constant folding silently disabled for GreaterOrEqual / LessOrEqual below opset 16 in 1.27 (regression from the new output-size guard)

The constant-folding output-size guard added in #28055 skips any node whose output size
cannot be pre-estimated. EstimateTensorSizeInBytes() returns -1 when the output
NodeArg has no shape, and ApplyImpl() treats -1 as "skip this node".

GreaterOrEqual and LessOrEqual are ONNX function ops that had no type-and-shape
inference function until opset 16
:


op | opset 12–15 has_type_and_shape_inference_function | opset 16+
-- | -- | --
GreaterOrEqual | False | True
LessOrEqual | False | True
Greater / Less / Equal / And / Where | True | True

So for any model at ai.onnx opset 12–15, these two ops have no inferred output shape,
the estimator returns -1, and they are never constant folded — even though their
outputs here are 3 bytes.

Two things make this worse than a tuned-threshold problem:

  1. No cap value avoids it. The guard is only entered when max_output_size > 0, and

    the -1 bail is inside it. Setting the cap to 2**62 still does not fold. The only
    value that works is the literal "0", which turns the security feature off entirely.
    Users therefore have to choose between correct optimization and the hardening that
    #28055 added.

  2. It cascades. An unfolded comparison node makes its consumers non-constant, so an

    entire downstream constant chain stops folding, and shapes that used to become static
    stay symbolic.

This is a behaviour change from 1.26 and earlier, is silent at default log level, and
is not mentioned in the config-key documentation
print()

Expected behavior

All 16 cases collapse to 1 node, as they do on 1.25.1 / 1.26.0:

onnxruntime 1.25.1, onnx 1.22.0

op opset default cap=2**62 cap=0 result
------------------------------------------------------------------
GreaterOrEqual 13 1 1 1 ok
GreaterOrEqual 15 1 1 1 ok
GreaterOrEqual 16 1 1 1 ok
GreaterOrEqual 17 1 1 1 ok

LessOrEqual 13 1 1 1 ok
LessOrEqual 15 1 1 1 ok
...

Actual behavior

onnxruntime 1.27.0, onnx 1.22.0

op opset default cap=2**62 cap=0 result
------------------------------------------------------------------
GreaterOrEqual 13 3 3 1 NOT FOLDED (only cap=0 helps)
GreaterOrEqual 15 3 3 1 NOT FOLDED (only cap=0 helps)
GreaterOrEqual 16 1 1 1 ok
GreaterOrEqual 17 1 1 1 ok

LessOrEqual 13 3 3 1 NOT FOLDED (only cap=0 helps)
LessOrEqual 15 3 3 1 NOT FOLDED (only cap=0 helps)
LessOrEqual 16 1 1 1 ok
LessOrEqual 17 1 1 1 ok

Greater 13 1 1 1 ok
Less 13 1 1 1 ok

Root cause

onnxruntime/core/optimizer/constant_folding.cc @ v1.27.0.

EstimateTensorSizeInBytes() returns -1 when the output NodeArg carries no shape:

static int64_t EstimateTensorSizeInBytes(const NodeArg& node_arg) {

...
const auto* shape = node_arg.Shape();
if (shape == nullptr) {
return -1; // Unknown shape
}

EstimateNodeOutputSizeInBytes() propagates that -1, and ApplyImpl() turns it into a
skip:

      if (max_output_size > 0) {

int64_t estimated_size = -1;
try {
estimated_size = EstimateNodeOutputSizeInBytes(*node);
} catch (const std::exception&) {
...
continue;
}

if (estimated_size > max_output_size) {
...
continue;
}
if (estimated_size < 0) {
LOGS(logger, INFO) << "Skipping constant folding for " << node->OpType()
<< " node '" << node->Name()
<< "' because output size could not be estimated before execution.";
continue; // <-- the regression
}
}

Because ONNX has no shape inference function for GreaterOrEqual-12 / LessOrEqual-12,
their outputs reach this code without a shape and are unconditionally skipped.

Note that the post-execution check further down already bounds the real allocation:

      // Post-execution size check: verify actual output sizes don't exceed the limit.

// This catches cases where pre-execution shape inference couldn't determine the output size.
if (max_output_size > 0) {
...
size_exceeded = actual_total_size > max_output_size;
...

Its own comment says it exists precisely to cover the un-estimable case, which makes the
pre-execution estimated_size < 0 bail redundant for correctness.

Suggested fix

Preferred: drop the estimated_size < 0 early-continue and let un-estimable nodes fall
through to execution, where the existing post-execution actual-size check already enforces
the cap. That preserves the mitigation from #28055 while restoring pre-1.27 folding.

If a pre-execution bound is considered necessary, a cheap conservative fallback would be to
estimate from the input sizes (element-wise / broadcasting ops cannot exceed the broadcast
of their inputs) rather than refusing to fold.

Separately, and independent of this guard, ORT could supply shape inference for
GreaterOrEqual / LessOrEqual below opset 16 — they are plain broadcasting element-wise
ops — so their outputs are shaped like every other comparison op.

Real-world impact

On a production quantized segmentation model at ai.onnx opset 15, an unfolded
GreaterOrEqual at the head of a constant guard chain
(GreaterOrEqual -> And -> Where -> Greater/Less -> And -> Where) stopped the whole chain
from folding. ORT_ENABLE_BASIC output went from 1146 nodes on 1.25.1 to 1186 nodes on
1.27.0
, and graph output shapes went from static ([1,3,1024,1024]) to symbolic. A
downstream plugin EP consuming the resulting OrtGraph then saw 932 rank-0 value_info
entries and rejected 28 Slice nodes with "Axes contains an out-of-bound index", falling
the entire model back to CPU.

Two independent changes both restore the 1.25.1 result on 1.27.0, which confirms the
diagnosis from either end:

  • Setting optimization.constant_folding_max_output_size_in_bytes = "0" produces a
    byte-identical optimized model to 1.25.1 (SHA-256
    0E020DD9A5DB702E18F058CF9B198F9F51848FC5DFA66A967A9BE6C4948C3E04).

  • Leaving all session options at their defaults and converting the model from opset 15 to
    opset 16 also yields 1146 nodes with an identical op histogram, identical node names and
    static output shapes.

Neither is a satisfying fix: the first disables the mitigation added by #28055, and the
second forces an opset migration purely to dodge an optimizer bug.

Affected versions

  • Not present in v1.26.0 and earlier.

  • Introduced in v1.27.0 by #28055 (fix(security): add SafeInt overflow protection in Expand and constant folding output size limit), merged 2026-05-14, 04f744032cf21e182b2d03391d64fa2a970a3aea.

### To reproduce

Build script

Stock PyPI wheels: pip install onnxruntime==1.27.0 vs onnxruntime==1.25.1, onnx==1.22.0

Reproduction instructions

"""ORT 1.27 never constant-folds GreaterOrEqual / LessOrEqual below opset 16."""

import os
import tempfile

import numpy as np
import onnx
from onnx import TensorProto, helper, numpy_helper
import onnxruntime as ort

TMP = tempfile.mkdtemp()
A = np.array([1.0, 2.0, 3.0], np.float32)
B = np.array([2.0, 2.0, 2.0], np.float32)

def make(op, opset):
"""all-constant `op` -> Cast -> Add(graph_input). Fully folded == 1 node."""
nodes = [
helper.make_node(op, ["a", "b"], ["t"], name="op"),
helper.make_node("Cast", ["t"], ["f"], to=TensorProto.FLOAT, name="cast"),
helper.make_node("Add", ["x", "f"], ["y"], name="sink"),
]
g = helper.make_graph(
nodes, "repro",
[helper.make_tensor_value_info("x", TensorProto.FLOAT, [3])],
[helper.make_tensor_value_info("y", TensorProto.FLOAT, [3])],
initializer=[numpy_helper.from_array(A, "a"), numpy_helper.from_array(B, "b")],
)
m = helper.make_model(g, opset_imports=[helper.make_opsetid("", opset)])
m.ir_version = 8
p = os.path.join(TMP, f"{op}_{opset}.onnx")
onnx.save(m, p)
return p

def folded_node_count(src, cap=None):
dst = src + (cap or "default") + ".opt.onnx"
so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_BASIC
so.optimized_model_filepath = dst
so.log_severity_level = 3
if cap is not None:
so.add_session_config_entry(
"optimization.constant_folding_max_output_size_in_bytes", cap)
ort.InferenceSession(src, so, providers=["CPUExecutionProvider"])
return len(onnx.load(dst).graph.node)

print(f"onnxruntime {ort.__version__}, onnx {onnx.__version__}")
print(f"\n{'op':16s} {'opset':>5s} {'default':>8s} {'cap=2**62':>10s} {'cap=0':>6s} result")
print("-" * 66)

for op in ["GreaterOrEqual", "LessOrEqual", "Greater", "Less"]:
for opset in (13, 15, 16, 17):
p = make(op, opset)
d = folded_node_count(p)
big = folded_node_count(p, str(2 ** 62))
zero = folded_node_count(p, "0")
note = "ok" if d == 1 else "NOT FOLDED (only cap=0 helps)"
print(f"{op:16s} {opset:5d} {d:8d} {big:10d} {zero:6d} {note}")

"""
"""
"""Minimal standalone repro for the ORT 1.27 constant-folding size-guard regression.
Builds a tiny model containing a Shape -> Slice -> Concat -> ConstantOfShape chain
whose operands are all constant, then runs ORT's Level-1 optimizer and reports how
many nodes survive. Every node in the chain is constant-foldable; the whole chain
should collapse to a single initializer.
Usage: python minrepro.py [path_to_ort_site_packages_to_prepend]
"""
import sys
if len(sys.argv) > 1:
sys.path.insert(0, sys.argv[1])
import os
import tempfile
import collections
import numpy as np
import onnx
from onnx import TensorProto, helper, numpy_helper
import onnxruntime as ort
def build_model(path):
init = [
numpy_helper.from_array(np.zeros((1, 8, 32, 32), np.float32), "src"),
numpy_helper.from_array(np.array([0], np.int64), "starts"),
numpy_helper.from_array(np.array([2], np.int64), "ends"),
numpy_helper.from_array(np.array([0], np.int64), "axes"),
numpy_helper.from_array(np.array([64, 64], np.int64), "tail"),
numpy_helper.from_array(np.array(1.0, np.float32), "one"),
]
nodes = [
helper.make_node("Shape", ["src"], ["shp"], name="Shape_0"),
helper.make_node("Slice", ["shp", "starts", "ends", "axes"], ["lead"], name="Slice_0"),
helper.make_node("Concat", ["lead", "tail"], ["full"], axis=0, name="Concat_0"),
helper.make_node("ConstantOfShape", ["full"], ["zeros"], name="COS_0",
value=numpy_helper.from_array(np.array([0.0], np.float32))),
helper.make_node("Mul", ["zeros", "one"], ["scaled"], name="Mul_0"),
helper.make_node("Add", ["x", "scaled"], ["y"], name="Add_0"),
]
graph = helper.make_graph(
nodes, "repro",
[helper.make_tensor_value_info("x", TensorProto.FLOAT, [1, 8, 64, 64])],
[helper.make_tensor_value_info("y", TensorProto.FLOAT, [1, 8, 64, 64])],
initializer=init,
)
model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 17)])
model.ir_version = 10
onnx.save(model, path)
return len(nodes)
def optimize(src, dst, cfg=None):
so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_BASIC
so.optimized_model_filepath = dst
so.log_severity_level = 3
for k, v in (cfg or {}).items():
so.add_session_config_entry(k, v)
ort.InferenceSession(src, so, providers=["CPUExecutionProvider"])
g = onnx.load(dst).graph
return len(g.node), collections.Counter(n.op_type for n in g.node)
tmp = tempfile.mkdtemp()
src = os.path.join(tmp, "repro.onnx")
n_in = build_model(src)
print(f"onnxruntime {ort.__version__} (input model: {n_in} nodes)")
n, hist = optimize(src, os.path.join(tmp, "opt_default.onnx"))
print(f" ORT_ENABLE_BASIC, default options -> {n} nodes {dict(hist)}")
n, hist = optimize(src, os.path.join(tmp, "opt_nocap.onnx"),
{"optimization.constant_folding_max_output_size_in_bytes": "0"})
print(f" ORT_ENABLE_BASIC, cf_max_output_size = 0 -> {n} nodes {dict(hist)}")

"""

### Urgency

Moderate. It silently de-optimizes any opset ≤ 15 model containing these ops, and the only escape is to disable a security mitigation.

### Platform

Windows

### OS Version

Windows 11 x64 (also reproduces independently of platform — pure graph-transform path)

### ONNX Runtime Installation

Released Package

### ONNX Runtime Version or Commit ID

1.27.0

### ONNX Runtime API

Python

### Architecture

X64

### Execution Provider

Vitis AI

### Execution Provider Library Version

_No response_

Contributor guide

Open the contributing guide

Research direction

Start in onnxruntime/core/optimizer/constant_folding.cc, focusing on EstimateTensorSizeInBytes(), EstimateNodeOutputSizeInBytes(), ApplyImpl(), and the existing post-execution size check. Run the supplied Python reproduction with ONNX Runtime 1.27. Done means GreaterOrEqual and LessOrEqual below opset 16 fold again without disabling the output-size cap, while the actual-size limit remains enforced.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp, python
Domain
machine-learning, performance
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
74/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.