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 f986a66336 [Fix][Relax][Frontend][Torch] Keep zero-sized dims when 
reshaping (#20255)
f986a66336 is described below

commit f986a66336bd3373c1d972e240feddc79b0552ba
Author: Chen Yufan <[email protected]>
AuthorDate: Wed Sep 16 02:40:40 2026 +0800

    [Fix][Relax][Frontend][Torch] Keep zero-sized dims when reshaping (#20255)
    
    ### Problem
    
    PyTorch reads a literal `0` in a target shape as a real zero-sized
    dimension. `relax.op.reshape` reads it as *"copy the corresponding input
    dimension"* — ONNX `Reshape` with `allowzero=0`. The torch frontend
    forwards torch's shape unchanged, so any target shape holding a literal
    `0` is silently reinterpreted.
    
    ```python
    import torch
    x = torch.randn(2, 0, 4)
    
    x.reshape(0, 4)           # torch (0, 4)
    x.view(0, 4)              # torch (0, 4)
    torch.flatten(x)          # torch (0,)
    torch.randn(2, 0).unflatten(0, (2, -1))   # torch (2, 1, 0)
    ```
    
    On `main` these import as:
    
    | expression | input | torch | frontend on `main` |
    | --- | --- | --- | --- |
    | `x.reshape(0, 4)` | `(2, 0, 4)` | `(0, 4)` | `ValueError: Reshape
    expects the new shape to be convertible…` |
    | `x.view(0, 4)` | `(2, 0, 4)` | `(0, 4)` | same `ValueError` |
    | `x.reshape(0)` | `(2, 0, 4)` | `(0,)` | same `ValueError` |
    | `x.reshape(3, 0)` | `(0, 3)` | `(3, 0)` | same `ValueError` |
    | `torch.flatten(x)` | `(2, 0, 4)` | `(0,)` | same `ValueError` |
    | `torch.flatten(x)` | `(2, 3, 0)` | `(0,)` | same `ValueError` |
    | `x.unflatten(0, (2, -1))` | `(2, 0)` | `(2, 1, 0)` | `IndexError:
    Index 2 out of bounds 2` |
    | `torch.flatten(x)` | `(0, 3)` | `(0,)` | `(0,)` — happens to work |
    
    The last row is why this is easy to miss: copying input dim 0 there
    gives back the same `0` the literal asked for, so the one case people
    usually try looks fine.
    
    The `IndexError` comes from the same rule. `ConvertNewShapeToExpr`
    resolves a zero with `array_ref.Set(i, shape_ty->values.value()[i])`,
    indexing the *input* shape at the new shape's position, so a target of
    higher rank than the input reads past the end.
    
    Zero-sized tensors are not exotic in exported models — a detector with
    no proposals, an empty batch, an empty mask — and they reach
    `reshape`/`view`/`flatten` on ordinary code paths.
    
    ### Fix
    
    When the input is statically empty, the dimension torch asks for can be
    written as `-1` instead, whose inference yields `0`.
    `_torch_reshape_dims` does that rewrite, applied where a torch-supplied
    target shape reaches `relax.op.reshape`: `_reshape`, `_reshape_as`,
    `_flatten_impl`, `_unflatten`, `_as_strided`.
    
    The other `relax.op.reshape` call sites in the frontend derive their
    target from the input's own shape, where "copy input dim" and the
    literal agree, so they are left alone.
    
    **The rewrite is deliberately narrow.** It only fires when the input is
    statically empty. For a non-empty input torch rejects a zero in the
    target outright, and rewriting it to `-1` there would turn an error into
    a silently wrong shape:
    
    ```
    input (2, 3), target [0, 2]     torch: rejects
      today                          raises ValueError          <- correct
      unconditional 0 -> -1          R.Tensor((3, 2))           <- wrong, and 
silent
      this PR (guard declines)       raises ValueError          <- unchanged
    ```
    
    ### Verification
    
    All 17 shape cases I exercised now agree with PyTorch (6 previously
    raised). Zero-sized behaviour of `squeeze`, `permute`, `expand`, `cat`
    and `sum` was already correct and is unchanged.
    
    Built with LLVM and ran the imported module: `x.reshape(0, 4)` on `(2,
    0, 4)` builds, runs, and returns shape `(0, 4)`.
    
    `tests/python/relax/test_frontend_from_fx.py` +
    `tests/python/relax/test_frontend_from_exported_program.py`:
    
    - clean `main`: 24 failed, 412 passed, 3 skipped
    - with this change: 24 failed, **415** passed, 3 skipped
    
    The 24 failures are pre-existing on `main` in my environment
    (`test_dtypes` and friends), identical before and after. The three
    additional passes are the new tests, which fail on `main` and pass with
    the fix.
    
    `ruff format --check` and `ruff check` are clean.
    
    ### One thing I want to flag
    
    The three tests run the imported module instead of comparing against an
    expected TVMScript module, because **the resulting `IRModule` cannot be
    written as TVMScript**. The frontend emits
    
    ```
    lv: R.Tensor((0, 4), dtype="float32") = R.reshape(x, R.shape([0, 4]))
    ```
    
    which executes correctly, but re-parsing it applies the copy rule again
    and infers `(2, 4)`, so the annotation no longer matches and the module
    is rejected as not well-formed. That round-trip gap lives in
    `relax.op.reshape`, not in the frontend, and this PR does not try to
    close it.
    
    So this fixes the observable behaviour but leaves the underlying
    ambiguity in place. **The more complete fix is probably an
    `allowzero`-style option on `relax.op.reshape`** (the ONNX frontend
    already carries `allowzero` and works around the same rule by routing
    through a dynamic shape expression), with the torch frontend opting in —
    that would also make the emitted IR round-trip. That is a change to a
    core op's interface, so I did not want to make that call unilaterally.
    **If you would prefer that shape, I am happy to implement it instead and
    close this.**
    
    Also worth noting: `_flatten_impl` is touched here and also by #20245.
    The hunks are independent and should merge cleanly; happy to rebase
    either way.
    
    ---
    
    This change was prepared with AI assistance (Claude). I have reviewed
    and verified it, and can speak to it in review.
    
    ---------
    
    Co-authored-by: Claude <[email protected]>
---
 .../frontend/torch/base_fx_graph_translator.py     | 100 ++++++++++++++++++++-
 .../frontend/torch/exported_program_translator.py  |   4 +-
 .../relax/transform/remove_redundant_reshape.py    |  25 +++++-
 .../relax/test_frontend_from_exported_program.py   |  94 ++++++++++++++++++-
 .../python/relax/test_remove_redundant_reshape.py  |  52 ++++++++++-
 5 files changed, 266 insertions(+), 9 deletions(-)

diff --git a/python/tvm/relax/frontend/torch/base_fx_graph_translator.py 
b/python/tvm/relax/frontend/torch/base_fx_graph_translator.py
index 5b723350cd..3ff3b596af 100644
--- a/python/tvm/relax/frontend/torch/base_fx_graph_translator.py
+++ b/python/tvm/relax/frontend/torch/base_fx_graph_translator.py
@@ -152,6 +152,100 @@ class BaseFXGraphImporter(metaclass=abc.ABCMeta):
             return tensor.shape
         raise ValueError(f"Unsupported type: {type(tensor)}")
 
+    @staticmethod
+    def _static_dim(value):
+        """Return ``value`` as a Python int when it is a compile-time 
constant, else ``None``."""
+        if isinstance(value, bool):
+            return None
+        if isinstance(value, int):
+            return value
+        const = getattr(value, "value", None)
+        if isinstance(const, int) and not isinstance(const, bool):
+            return const
+        return None
+
+    def _torch_reshape_chain(self, x, dims):
+        """Return the relax reshape targets that reproduce PyTorch's ``dims``.
+
+        PyTorch reads a literal ``0`` in a target shape as a real zero-sized 
dimension.
+        ``relax.op.reshape`` reads it as "copy the corresponding input 
dimension", which is
+        ONNX ``Reshape`` with ``allowzero=0``. A literal ``0`` therefore only 
survives at a
+        position whose input dimension is itself ``0``; anywhere else it 
silently becomes
+        that input dimension.
+
+        A position that does not survive can be written as ``-1``, whose 
inference yields
+        ``0`` for an empty input. Only one ``-1`` is allowed per reshape, so 
when several
+        positions need it the rewrite is split: each step turns one of them 
into a real
+        ``0``, which lets the next step spell that position as a literal. 
Targets with at
+        most one such position - every case seen in practice - stay a single 
reshape.
+
+        Only a literal is read as a copy, so a symbolic dimension in the 
target is carried
+        through untouched and does not stop the literal zeros beside it from 
being
+        rewritten. ``x.flatten(1, 2)`` on ``(batch, 2, 0, 4)`` asks for 
``(batch, 0, 4)``,
+        where the zero still has to survive.
+
+        Shapes that do not need the rewrite are returned unchanged. In 
particular, for a
+        non-empty input PyTorch rejects a zero in the target outright, and 
rewriting it
+        would produce a shape rather than surface that error.
+        """
+        dims = list(dims)
+        target = [self._static_dim(d) for d in dims]
+        if 0 not in target or -1 in target:
+            return [dims]
+        shape = self.shape_of(x)
+        if shape is None:
+            return [dims]
+        shape = list(shape)
+        current = [self._static_dim(d) for d in shape]
+        if 0 not in current:
+            # Without a statically known zero the input is not known to be 
empty, and
+            # PyTorch rejects a zero in the target for a non-empty input. A 
symbolic
+            # dimension elsewhere does not change that: one known zero already 
fixes the
+            # element count at zero whatever the symbols turn out to be.
+            return [dims]
+
+        steps = []
+        while True:
+            unusable = [
+                i
+                for i, t in enumerate(target)
+                if t == 0 and not (i < len(current) and current[i] == 0)
+            ]
+            if not unusable:
+                if not steps:
+                    # Nothing needed rewriting; emit the target as given.
+                    steps.append(dims)
+                # Otherwise the last step already produced the target shape, 
since every
+                # remaining zero now sits over an input dimension that is zero 
as well.
+                return steps
+            rewritten = unusable[0]
+            step, resulting = [], []
+            for i, t in enumerate(target):
+                if i == rewritten:
+                    step.append(-1)
+                    resulting.append(0)
+                elif t != 0:
+                    step.append(dims[i])
+                    resulting.append(t)
+                elif i < len(current) and current[i] == 0:
+                    step.append(0)
+                    resulting.append(0)
+                else:
+                    # Hold this position as it stands -- a symbolic dimension 
included, since
+                    # it is not a literal and so is not read as a copy -- and 
rewrite it in a
+                    # later step, once it has become a real zero.
+                    step.append(shape[i] if i < len(shape) else 1)
+                    resulting.append(current[i] if i < len(current) else 1)
+            steps.append(step)
+            shape = [0 if i == rewritten else step[i] for i in 
range(len(step))]
+            current = resulting
+
+    def _emit_torch_reshape(self, x, dims):
+        """Emit the reshape(s) giving ``dims`` PyTorch's meaning. See 
_torch_reshape_chain."""
+        for step in self._torch_reshape_chain(x, dims):
+            x = self.block_builder.emit(relax.op.reshape(x, step))
+        return x
+
     @staticmethod
     def _promote_common_dtype(lhs_dtype: str | None, rhs_dtype: str | None) -> 
str | None:
         """Return the promoted dtype following PyTorch rules, or None if 
unsupported."""
@@ -2125,7 +2219,7 @@ class BaseFXGraphImporter(metaclass=abc.ABCMeta):
             + [flattened]
             + [shape[i] for i in range(end_dim + 1, rank)]
         )
-        return self.block_builder.emit(relax.op.reshape(x, new_shape))
+        return self._emit_torch_reshape(x, new_shape)
 
     def _flatten(self, node: fx.Node) -> relax.Var:
         x = self.env[node.args[0]]
@@ -2465,14 +2559,14 @@ class BaseFXGraphImporter(metaclass=abc.ABCMeta):
         if current_shape is not None and list(current_shape) == list(dims):
             return x
 
-        return self.block_builder.emit(relax.op.reshape(x, dims))
+        return self._emit_torch_reshape(x, dims)
 
     def _reshape_as(self, node: fx.Node) -> relax.Var:
         args = self.retrieve_args(node)
         x = args[0]
         other = args[1]
         dims = self.shape_of(other)
-        return self.block_builder.emit(relax.op.reshape(x, dims))
+        return self._emit_torch_reshape(x, dims)
 
     def _scatter(self, node: fx.Node) -> relax.Var:
         x = self.env[node.args[0]]
diff --git a/python/tvm/relax/frontend/torch/exported_program_translator.py 
b/python/tvm/relax/frontend/torch/exported_program_translator.py
index 2029dcc121..18c0ef96d8 100644
--- a/python/tvm/relax/frontend/torch/exported_program_translator.py
+++ b/python/tvm/relax/frontend/torch/exported_program_translator.py
@@ -1191,7 +1191,7 @@ class ExportedProgramImporter(BaseFXGraphImporter):
             dim += len(x_shape)
 
         new_shape = x_shape[:dim] + sizes + x_shape[dim + 1 :]
-        return self.block_builder.emit(relax.op.reshape(x, new_shape))
+        return self._emit_torch_reshape(x, new_shape)
 
     ########## Creation ##########
 
@@ -1477,7 +1477,7 @@ class ExportedProgramImporter(BaseFXGraphImporter):
                         f"size {size} is not supported"
                     )
 
-        return self.block_builder.emit(relax.op.reshape(x, size))
+        return self._emit_torch_reshape(x, size)
 
     ########## Symbolic Shape Constraints ##########
 
diff --git a/python/tvm/relax/transform/remove_redundant_reshape.py 
b/python/tvm/relax/transform/remove_redundant_reshape.py
index 8d3709e167..0cc1e11900 100644
--- a/python/tvm/relax/transform/remove_redundant_reshape.py
+++ b/python/tvm/relax/transform/remove_redundant_reshape.py
@@ -27,6 +27,27 @@ from tvm.relax.dpl import is_op, rewrite_call, wildcard
 from . import function_pass
 
 
+def _can_reparent(arg: Expr, output_shape: Expr) -> bool:
+    """Whether ``reshape(arg, output_shape)`` still asks for ``output_shape``.
+
+    A literal ``0`` in a reshape target means "copy the corresponding input 
dimension",
+    and ``relax.op.reshape`` resolves it against the input it is handed. 
Moving such a
+    target onto a different input therefore changes what it asks for. For
+    ``x: (0, 3, 5)``, ``reshape(reshape(x, [0, 0, 5]), [0, 0, 0])`` is ``(0, 
0, 0)``,
+    while the combined ``reshape(x, [0, 0, 0])`` copies all three dimensions 
back and is
+    ``(0, 3, 5)``.
+
+    Rather than reason about which zeros are safe, re-resolve the target 
against the new
+    input and keep the rewrite only when it comes back unchanged.
+    """
+    try:
+        reparented = relax.op.reshape(arg, output_shape)
+    except Exception:  # pylint: disable=broad-except
+        # reshape cannot resolve a 0 or -1 without a known input shape.
+        return False
+    return tvm_ffi.structural_equal(reparented.args[1], output_shape)
+
+
 @function_pass(opt_level=0)
 class RemoveRedundantReshape:
     """
@@ -70,7 +91,9 @@ class RemoveRedundantReshape:
 
             if self.repeated_reshape in matches:
                 output_shape = matches[self.repeated_reshape].args[1]
-                return relax.op.reshape(arg, output_shape)
+                if _can_reparent(arg, output_shape):
+                    return relax.op.reshape(arg, output_shape)
+                return expr
 
             elif self.no_op_reshape in matches:
                 output_shape = matches[self.no_op_reshape].args[1]
diff --git a/tests/python/relax/test_frontend_from_exported_program.py 
b/tests/python/relax/test_frontend_from_exported_program.py
index b70774be65..43065d9c13 100644
--- a/tests/python/relax/test_frontend_from_exported_program.py
+++ b/tests/python/relax/test_frontend_from_exported_program.py
@@ -62,12 +62,12 @@ def verify_model(
     tvm.ir.assert_structural_equal(mod, expected, map_free_vars=map_free_vars)
 
 
-def verify_model_numerically(torch_model, example_args, rtol=1e-7, atol=1e-7):
+def verify_model_numerically(torch_model, example_args, rtol=1e-7, atol=1e-7, 
dynamic_shapes=None):
     """Verify model by comparing numerical outputs between PyTorch and TVM."""
     with torch.no_grad():
         pytorch_output = torch_model(*example_args)
 
-    exported_program = export(torch_model, args=example_args)
+    exported_program = export(torch_model, args=example_args, 
dynamic_shapes=dynamic_shapes)
     mod = from_exported_program(exported_program)
     target = tvm.target.Target("llvm")
     ex = relax.build(mod, target)
@@ -5315,6 +5315,15 @@ def test_flatten():
     verify_model(Flatten(), example_args, {}, expected1)
 
 
+def test_flatten_zero_sized_dim():
+    class Flatten(Module):
+        def forward(self, x):
+            return torch.flatten(x)
+
+    verify_model_numerically(Flatten(), (torch.randn(2, 0, 4, 
dtype=torch.float32),))
+    verify_model_numerically(Flatten(), (torch.randn(2, 3, 0, 
dtype=torch.float32),))
+
+
 def test_meshgrid():
     class Meshgrid1(Module):
         def forward(self, input1, input2):
@@ -5476,6 +5485,79 @@ def test_reshape_as():
     verify_model(ReshapeAs(), example_args, {}, expected1)
 
 
+def test_reshape_zero_sized_dim():
+    class Reshape(Module):
+        def forward(self, x):
+            return x.reshape(0, 4)
+
+    class ReshapeTrailing(Module):
+        def forward(self, x):
+            return x.reshape(3, 0)
+
+    verify_model_numerically(Reshape(), (torch.randn(2, 0, 4, 
dtype=torch.float32),))
+    verify_model_numerically(ReshapeTrailing(), (torch.randn(0, 3, 
dtype=torch.float32),))
+
+
+def test_reshape_multiple_zero_sized_dims():
+    # A literal zero only survives relax's copy rule at a position whose input 
dimension is
+    # itself zero, so targets holding several zeros need more than one 
position rewritten.
+    class TwoZeros(Module):
+        def forward(self, x):
+            return x.reshape(0, 0)
+
+    class ThreeZeros(Module):
+        def forward(self, x):
+            return x.reshape(0, 0, 0)
+
+    class ZeroPastInputRank(Module):
+        def forward(self, x):
+            return x.reshape(0, 0, 4)
+
+    verify_model_numerically(TwoZeros(), (torch.randn(0, 3, 
dtype=torch.float32),))
+    verify_model_numerically(TwoZeros(), (torch.randn(3, 0, 
dtype=torch.float32),))
+    verify_model_numerically(ThreeZeros(), (torch.randn(0, 3, 5, 
dtype=torch.float32),))
+    verify_model_numerically(ZeroPastInputRank(), (torch.randn(2, 0, 4, 
dtype=torch.float32),))
+
+
+def test_reshape_zero_sized_dim_dynamic_batch():
+    # One statically known zero fixes the element count at zero whatever the 
symbolic
+    # dimension turns out to be, so the literal zero in the target still has 
to survive.
+    # Reading it as "copy the batch" gives a non-empty shape that torch never 
produces.
+    class Reshape(Module):
+        def forward(self, x):
+            return x.reshape(0, 4)
+
+    batch = torch.export.Dim("batch", min=1, max=64)
+    verify_model_numerically(
+        Reshape(),
+        (torch.randn(3, 0, 4, dtype=torch.float32),),
+        dynamic_shapes={"x": {0: batch}},
+    )
+
+
+def test_reshape_zero_sized_dim_symbolic_target():
+    # Only a literal is read as "copy the input dimension", so a symbolic 
dimension in the
+    # target is not one: it is carried through, and the literal zero beside it 
still has to
+    # be rewritten. Skipping the rewrite because the target is not fully 
static reads that
+    # zero as a copy and gives a non-empty shape torch never produces.
+    class Flatten(Module):
+        def forward(self, x):
+            return x.flatten(1, 2)
+
+    class KeepBatch(Module):
+        def forward(self, x):
+            return x.reshape(x.shape[0], 0, 4)
+
+    class ZeroBeforeBatch(Module):
+        def forward(self, x):
+            return x.reshape(0, x.shape[0])
+
+    batch = torch.export.Dim("batch", min=1, max=64)
+    example_args = (torch.randn(3, 2, 0, 4, dtype=torch.float32),)
+    for model in (Flatten(), KeepBatch(), ZeroBeforeBatch()):
+        verify_model_numerically(model, example_args, dynamic_shapes={"x": {0: 
batch}})
+
+
 def test_roll():
     class Roll1(Module):
         def forward(self, x):
@@ -7265,6 +7347,14 @@ def test_unflatten():
     verify_model(Unflatten1(), example_args, {}, Expected)
 
 
+def test_unflatten_zero_sized_dim():
+    class Unflatten(Module):
+        def forward(self, x):
+            return x.unflatten(0, (2, -1))
+
+    verify_model_numerically(Unflatten(), (torch.randn(2, 0, 
dtype=torch.float32),))
+
+
 def test_gather():
     class Gather0(Module):
         def forward(self, data, indices):
diff --git a/tests/python/relax/test_remove_redundant_reshape.py 
b/tests/python/relax/test_remove_redundant_reshape.py
index f5a865940b..bf8c790e01 100644
--- a/tests/python/relax/test_remove_redundant_reshape.py
+++ b/tests/python/relax/test_remove_redundant_reshape.py
@@ -14,7 +14,6 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-# ruff: noqa: F401
 
 """
 Test relax transform - Eliminate redundant reshape operations
@@ -113,5 +112,56 @@ def test_remove_redundant_reshape_pass_three_arg():
     _run_pass_compare_output(Before, Expected)
 
 
+def _return_shape(mod):
+    ret = mod["main"].ret_ty
+    field = ret.fields[0] if hasattr(ret, "fields") else ret
+    return [int(dim) for dim in field.shape]
+
+
+def test_remove_redundant_reshape_pass_keeps_zero_sized_chain():
+    # A literal 0 in a reshape target means "copy the corresponding input 
dimension",
+    # and relax.op.reshape resolves it against the input it is handed. 
Combining these
+    # two calls re-reads the zeros against x and asks for (0, 3, 5) instead of 
the
+    # (0, 0, 0) the pair produces, so the pair has to survive the pass.
+    #
+    # Built with the block builder rather than TVMScript because reshape 
resolves its
+    # target at construction: the printed R.shape([0, 0, 5]) is the resolved 
shape, and
+    # parsing it back would resolve those zeros a second time.
+    bb = relax.BlockBuilder()
+    x = relax.Var("x", relax.TensorType([0, 3, 5], "float32"))
+    with bb.function("main", [x]):
+        with bb.dataflow():
+            lv = bb.emit(relax.op.reshape(x, [0, -1, 5]))
+            gv = bb.emit_output(relax.op.reshape(lv, [0, 0, -1]))
+        bb.emit_func_output(gv)
+    before = bb.get()
+    assert _return_shape(before) == [0, 0, 0]
+
+    after = DeadCodeElimination()(RemoveRedundantReshape()(before))
+    assert _return_shape(after) == [0, 0, 0], (
+        "combining the reshapes re-read the literal zeros against x and 
changed the shape"
+    )
+
+
+def test_remove_redundant_reshape_pass_still_combines_without_zero_dims():
+    # The guard above must not stop the pass doing its job on an ordinary 
chain.
+    bb = relax.BlockBuilder()
+    x = relax.Var("x", relax.TensorType([1, 1001, 1, 1], "float32"))
+    with bb.function("main", [x]):
+        with bb.dataflow():
+            lv = bb.emit(relax.op.reshape(x, [1, 1001, 1]))
+            gv = bb.emit_output(relax.op.reshape(lv, [1, 1001]))
+        bb.emit_func_output(gv)
+    after = DeadCodeElimination()(RemoveRedundantReshape()(bb.get()))
+    assert _return_shape(after) == [1, 1001]
+    reshapes = [
+        binding
+        for block in after["main"].body.blocks
+        for binding in block.bindings
+        if isinstance(binding.value, tvm.relax.Call)
+    ]
+    assert len(reshapes) == 1, "the chain without zeros should still collapse 
to one reshape"
+
+
 if __name__ == "__main__":
     tvm.testing.main()

Reply via email to