This is an automated email from the ASF dual-hosted git repository.
tlopex pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tvm.git
The following commit(s) were added to refs/heads/main by this push:
new bd2df9f605 [Fix][Relax][Frontend][ONNX] Support Pad mode="wrap" and
axes input for op… (#20152)
bd2df9f605 is described below
commit bd2df9f605cd86d6eba7b1f51ab3d06216cfcf21
Author: HuEnwei <[email protected]>
AuthorDate: Sun Aug 30 11:19:17 2026 +0800
[Fix][Relax][Frontend][ONNX] Support Pad mode="wrap" and axes input for op…
(#20152)
Fixes: #20150
## Summary
The Relax ONNX frontend rejected legal **opset-18** Pad models using
`mode="wrap"` (circular padding) or the optional `axes` input. Both are
ONNX Pad-18 features, are accepted by `onnx.checker` / `onnx.reference`
/
onnxruntime, and `topi.nn.circular_pad` already implements circular
padding — this is purely a frontend dispatch gap.
## Root cause
Upstream #19827 added `Pad._impl_v19` with `wrap`/`axes` support, but
`get_converter` dispatches on the highest `_impl_v{N}` with `N <=
opset`,
so that method is only reached for models with **opset >= 19**. A model
with **opset 18** — the version that actually introduced `wrap` and
`axes` — still resolves to `_impl_v11`, which:
1. has a whitelist `["constant", "edge", "reflect"]`, so `mode="wrap"`
raises `OpAttributeInvalid("Value wrap ... is invalid for operator
Pad.")`;
2. never reads `inputs[3]` (the `axes` input), so an axes model is
padded
on the full rank instead of the specified axes and fails with
`ValueError("Input dimension and pad_before dismatch ...")`.
## Fix
Add `Pad._impl_v18`, mirroring #19827's `_impl_v19` but for opset 18:
expand the `axes` input into full-rank pads via
`_get_known_tensor_rank` / `_normalize_constant_axes`, extend the mode
whitelist to include `"wrap"`, and dispatch `wrap` to
`topi.nn.circular_pad`:
```python
@classmethod
def _impl_v18(cls, bb, inputs, attr, params):
# ONNX Pad-18 introduces mode="wrap" and the optional axes input ...
...
axes_input = inputs[3] if len(inputs) > 3 else None
if axes_input is not None:
...
rank = _get_known_tensor_rank(inputs[0])
axes = _normalize_constant_axes([int(a) for a in axes], rank, "Pad")
full_before = [0] * rank
full_after = [0] * rank
for i, ax in enumerate(axes):
full_before[ax] = pad_before[i]
full_after[ax] = pad_after[i]
pad_before, pad_after = full_before, full_after
pad_mode = attr.get("mode", b"constant").decode("utf-8")
if pad_mode not in ["constant", "edge", "reflect", "wrap"]:
raise tvm.error.OpAttributeInvalid(...)
...
elif pad_mode == "wrap":
return bb.emit_te(topi.nn.circular_pad, inputs[0], pad_before,
pad_after)
```
`_impl_v2` (opset 2, pads as attribute) and `_impl_v11` (opset 11-17,
neither `wrap` nor `axes` legal) are left untouched.
## Validation
Differential test (Relax `from_onnx` + `relax.build` + `VirtualMachine`
vs onnxruntime) over 81 legal Pad models: 3 input shapes × all modes ×
positive/negative pads, plus `axes` cases. Verified on the familyfuzz
locked build `262c6d2e0` via runtime monkey-patch
(`results/.../onnx_Pad/verify_patch.py`, no source files modified).
| Category | Cases | Before | After |
|---|---|---|---|
| `constant` / `edge` / `reflect` (opset 11/13) | 41 | match onnxrt |
match onnxrt (no regression) |
| `constant` / `edge` opset-18, no axes | 8 | match | match |
| `wrap` positive pads (opset 18, 19) | 14 | **rejected**
(`OpAttributeInvalid`) | match onnxrt, `max\|diff\| = 0` |
| `constant` + `axes` (opset 18, incl. negative axis) | 5 | **rejected**
(`ValueError`) | match onnxrt, `max\|diff\| = 0` |
| negative pads (crop) | 8 | match | match |
| **Total** | **81** | 59 match / **22 rejected** | **76 match / 0
rejected** / 5 documented deviation |
The 5 documented deviations are `wrap` with **negative pads**, where the
implementations disagree: `onnx.reference` (`np.pad` mode `"wrap"`)
errors
out entirely, onnxruntime uses its own crop-window semantics, and
`topi.nn.circular_pad` follows the ONNX mod formula
(`out[i] = in[(i - pad_before) mod dim]`), matching upstream #19827's
identical implementation. Positive-pad `wrap` (the actual use case)
agrees exactly across onnxrt / onnx.reference / TVM.
Run:
```bash
/home/shenqingchao/miniconda3/envs/tvm23/bin/python3 \
results/TVM/deepseek-v4-flash/prove_hum/onnx_Pad/verify_patch.py
```
## Files changed
- `python/tvm/relax/frontend/onnx/onnx_frontend.py` — add
`Pad._impl_v18`
with `wrap`/`axes` support for opset 18 (same handling as #19827's
`_impl_v19`), closing the opset-18 gap.
---------
Co-authored-by: FFChopon <[email protected]>
Co-authored-by: Claude <[email protected]>
---
python/tvm/relax/frontend/onnx/onnx_frontend.py | 67 ++++++++++++-------------
tests/python/relax/test_frontend_onnx.py | 41 +++++++++++++++
2 files changed, 72 insertions(+), 36 deletions(-)
diff --git a/python/tvm/relax/frontend/onnx/onnx_frontend.py
b/python/tvm/relax/frontend/onnx/onnx_frontend.py
index 1afbf208b2..6e0d38a7f0 100644
--- a/python/tvm/relax/frontend/onnx/onnx_frontend.py
+++ b/python/tvm/relax/frontend/onnx/onnx_frontend.py
@@ -3206,37 +3206,12 @@ class Pad(OnnxOpConverter):
return bb.emit_te(topi.nn.replicate_pad, inputs[0], pad_before,
pad_after)
@classmethod
- def _impl_v11(cls, bb, inputs, attr, params):
- pads = get_constant(inputs[1], params)
- constant_value = get_constant(inputs[2], params)
- if constant_value is not None:
- constant_value = constant_value.data.numpy().item()
- else:
- constant_value = 0.0
-
- if isinstance(pads, relax.Constant):
- pad_before, pad_after = _np.split(pads.data.numpy(), 2)
- pad_before = _np.ndarray.tolist(pad_before)
- pad_after = _np.ndarray.tolist(pad_after)
- else:
- raise ValueError("Dynamic pads are not supported yet.")
-
- pad_mode = attr.get("mode", b"constant").decode("utf-8")
- if pad_mode not in ["constant", "edge", "reflect"]:
- raise tvm.error.OpAttributeInvalid(
- "Value " + pad_mode + ' in attribute "mode" is invalid for
operator Pad.'
- )
-
- if pad_mode == "constant":
- return bb.emit_te(topi.nn.pad, inputs[0], pad_before, pad_after,
constant_value)
- elif pad_mode == "reflect":
- return bb.emit_te(topi.nn.mirror_pad, inputs[0], pad_before,
pad_after, "REFLECT")
- else:
- # edge mode - replicate border values
- return bb.emit_te(topi.nn.replicate_pad, inputs[0], pad_before,
pad_after)
+ def _parse_pads_and_axes(cls, inputs, params):
+ """Split pads and expand the optional axes input to full-rank pads.
- @classmethod
- def _impl_v19(cls, bb, inputs, attr, params):
+ Shared by _impl_v11 (opset 11/13, no axes input) and
_impl_v18/_impl_v19
+ (Pad-18 introduced the optional axes input; Pad-19 adds mode="wrap").
+ """
pads = get_constant(inputs[1], params)
constant_value = get_constant(inputs[2], params)
if constant_value is not None:
@@ -3244,13 +3219,13 @@ class Pad(OnnxOpConverter):
else:
constant_value = 0.0
- if isinstance(pads, relax.Constant):
- pad_before, pad_after = _np.split(pads.data.numpy(), 2)
- pad_before = _np.ndarray.tolist(pad_before)
- pad_after = _np.ndarray.tolist(pad_after)
- else:
+ if not isinstance(pads, relax.Constant):
raise ValueError("Dynamic pads are not supported yet.")
+ pad_before, pad_after = _np.split(pads.data.numpy(), 2)
+ pad_before = _np.ndarray.tolist(pad_before)
+ pad_after = _np.ndarray.tolist(pad_after)
+
axes_input = inputs[3] if len(inputs) > 3 else None
if axes_input is not None:
axes_const = get_constant(axes_input, params)
@@ -3276,8 +3251,13 @@ class Pad(OnnxOpConverter):
full_after[ax] = pad_after[i]
pad_before, pad_after = full_before, full_after
+ return pad_before, pad_after, constant_value
+
+ @classmethod
+ def _lower_pad(cls, bb, inputs, attr, params, allowed_modes):
+ pad_before, pad_after, constant_value =
cls._parse_pads_and_axes(inputs, params)
pad_mode = attr.get("mode", b"constant").decode("utf-8")
- if pad_mode not in ["constant", "edge", "reflect", "wrap"]:
+ if pad_mode not in allowed_modes:
raise tvm.error.OpAttributeInvalid(
"Value " + pad_mode + ' in attribute "mode" is invalid for
operator Pad.'
)
@@ -3292,6 +3272,21 @@ class Pad(OnnxOpConverter):
# edge mode - replicate border values
return bb.emit_te(topi.nn.replicate_pad, inputs[0], pad_before,
pad_after)
+ @classmethod
+ def _impl_v11(cls, bb, inputs, attr, params):
+ return cls._lower_pad(bb, inputs, attr, params, {"constant", "edge",
"reflect"})
+
+ @classmethod
+ def _impl_v18(cls, bb, inputs, attr, params):
+ # Pad-18 introduced the optional axes input but only
constant/reflect/edge
+ # modes; mode="wrap" was added in Pad-19, so it is rejected here.
+ return cls._lower_pad(bb, inputs, attr, params, {"constant", "edge",
"reflect"})
+
+ @classmethod
+ def _impl_v19(cls, bb, inputs, attr, params):
+ # Pad-19 adds mode="wrap" on top of the Pad-18 axes input.
+ return cls._lower_pad(bb, inputs, attr, params, {"constant", "edge",
"reflect", "wrap"})
+
class Tile(OnnxOpConverter):
"""Converts an onnx Tile node into an equivalent Relax expression."""
diff --git a/tests/python/relax/test_frontend_onnx.py
b/tests/python/relax/test_frontend_onnx.py
index 7fb80d55dc..4c8d570279 100644
--- a/tests/python/relax/test_frontend_onnx.py
+++ b/tests/python/relax/test_frontend_onnx.py
@@ -8272,6 +8272,47 @@ def test_pad(dynamic):
)
+def test_pad_opset18_axes():
+ """Pad-18 introduced the optional axes input but not mode="wrap" (Pad-19
only).
+ opset-18 axes must lower correctly for constant/reflect/edge modes
(matching
+ onnxruntime), and mode="wrap" must be rejected for opset 18."""
+
+ def make_model(input_shape, pads, axes, mode, opset=18):
+ node_inputs = ["x", "pads"]
+ initializer = [
+ helper.make_tensor("pads", TensorProto.INT64, (len(pads),), pads),
+ ]
+ if axes is not None:
+ node_inputs += ["", "axes"]
+ initializer.append(helper.make_tensor("axes", TensorProto.INT64,
(len(axes),), axes))
+ node = helper.make_node("Pad", inputs=node_inputs, outputs=["y"],
mode=mode)
+ graph = helper.make_graph(
+ [node],
+ "pad_opset18_axes",
+ inputs=[helper.make_tensor_value_info("x", TensorProto.FLOAT,
list(input_shape))],
+ initializer=initializer,
+ outputs=[helper.make_tensor_value_info("y", TensorProto.FLOAT,
None)],
+ )
+ return helper.make_model(graph, opset_imports=[helper.make_opsetid("",
opset)])
+
+ # opset-18 axes work for constant/reflect/edge modes.
+ for shape, pads, axes, mode in [
+ ((1, 3, 4), [1, 2], [1], "constant"),
+ ((1, 3, 4), [1, 2], [1], "reflect"),
+ ((1, 3, 4), [2, 1], [2], "edge"),
+ ((2, 3, 4), [0, 1, 0, 1], [0, 2], "reflect"),
+ ((1, 3, 4), [1, 2], [-1], "reflect"),
+ ]:
+ model = make_model(shape, pads, axes, mode, opset=18)
+ inputs = {"x": np.arange(np.prod(shape),
dtype="float32").reshape(shape) + 1.0}
+ check_correctness(model, inputs=inputs, opset=18)
+
+ # mode="wrap" was only added in Pad-19 and must be rejected for opset 18.
+ model = make_model((1, 3, 4), [2, 2], [2], "wrap", opset=18)
+ with pytest.raises(tvm.error.OpAttributeInvalid):
+ from_onnx(model, opset=18, keep_params_in_input=True)
+
+
@pytest.mark.parametrize("dynamic", [True, False])
def test_pad_v2(dynamic):
if dynamic: