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:

Reply via email to