Skip to content

[Fix][Relax][Frontend][Torch] Validate flatten dims in from_fx - #20245

Merged
tlopex merged 1 commit into
apache:mainfrom
hiyufan:fix/relax-torch-flatten-dim-validation
Sep 8, 2026
Merged

tlopex merged 1 commit into
apache:mainfrom
hiyufan:fix/relax-torch-flatten-dim-validation

Conversation

@hiyufan

@hiyufan hiyufan commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes: #20227

Summary

tvm.relax.frontend.torch.from_fx crashed with an internal
TypeError: reduce() of empty iterable with no initial value when a traced model
contained a flatten whose start_dim comes after its end_dim.

torch rejects such a flatten with a clear RuntimeError, but only when the model is
executed. fx.symbolic_trace does not execute the model, so the invalid node reaches
the frontend as a perfectly traceable graph and has to be rejected there. This PR
validates the dims in _flatten_impl, mirroring what from_onnx now does for
Flatten (#20145).

Only the from_fx / TorchFXImporter path is affected. from_exported_program runs
run_decompositions() by default, which lowers aten.flatten.using_ints to
aten.view, so _flatten_impl is never reached there.

Root cause

_flatten_impl in python/tvm/relax/frontend/torch/base_fx_graph_translator.py
normalized negative dims but never checked their range or their ordering:

start_dim = start_dim if start_dim >= 0 else len(shape) + start_dim
end_dim = end_dim if end_dim >= 0 else len(shape) + end_dim
flattened = reduce(lambda x, y: x * y, [shape[i] for i in range(start_dim, end_dim + 1)])

For start_dim=2, end_dim=1 the range is empty, so functools.reduce is called over
an empty iterable with no initial value. Out-of-range dims (flatten(x, 0, 3) on a
rank-3 input) leaked an IndexError out of shape[i] instead.

Both entry points that reach this helper are affected: torch.flatten (dispatched via
_flatten) and torch.nn.Flatten (via _flatten_module).

Fix

Normalize both dims against max(rank, 1), validate each against [-r, r-1], and
reject start_dim > end_dim with torch's own wording:

dim_post_expr = max(rank, 1)
norm_start_dim = start_dim + dim_post_expr if start_dim < 0 else start_dim
norm_end_dim = end_dim + dim_post_expr if end_dim < 0 else end_dim
...
if norm_start_dim > norm_end_dim:
    raise ValueError("flatten() has invalid args: start_dim cannot come after end_dim")

A 0-d input is handled explicitly: torch normalizes flatten dims against a rank of at
least one, so torch.flatten(scalar) is valid and returns a 1-d tensor holding the
single element. The old code hit the same empty-reduce crash for that input.

Validation

Built from source (CPU-only, USE_LLVM=OFF) on Linux, torch 2.13.0+cpu, Python 3.14.

Behavior on the reported cases, measured before and after the change:

Input shape dims Before After
(2,3,4) (2,1) TypeError: reduce() of empty iterable with no initial value ValueError: flatten() has invalid args: start_dim cannot come after end_dim
(2,3,4) (0,3) IndexError: ShapeExpr index out of range ValueError: flatten end_dim 3 is out of range [-3, 2] for an input of rank 3
(2,3,4) (-4,2) ValueError: Reshape expects the new shape to be convertible from the old shape ValueError: flatten start_dim -4 is out of range [-3, 2] for an input of rank 3
() (0-d) (0,-1) TypeError: reduce() of empty iterable with no initial value converts, output (1,)

The (-4,2) row is worth calling out: an out-of-range negative start_dim did not fail in
_flatten_impl at all. It normalized to -1, so range(-1, 3) silently folded the last
dimension into the product and emitted a reshape to (96,) for a 24-element tensor, which
only failed later inside relax.op.reshape. That is a mis-computation, not just a crash.

Valid dims are unaffected — (1,3,10,10) with (2,-1), and (2,3,4) with (1,2), (0,-1),
(-3,-1) and (2,2), all convert to the same shapes as before.

Regression run of the whole from_fx suite, base commit vs. this branch:

base (HEAD~1) this branch
passed 164 166 (+2 new tests)
failed 16 16
skipped 1 1

The failure sets are identical line for line — the 16 failures (test_dtypes[*], test_round)
are pre-existing on the base commit in this environment and unrelated to this change.

Tests added to tests/python/relax/test_frontend_from_fx.py:

  • test_flatten_invalid_dims — start_dim > end_dim through both torch.flatten and
    torch.nn.Flatten, plus an out-of-range end_dim
  • test_flatten_scalar_input — 0-d input flattens to shape (1,)
$ pytest tests/python/relax/test_frontend_from_fx.py -k flatten -v
test_flatten PASSED
test_flatten_invalid_dims PASSED
test_flatten_scalar_input PASSED
3 passed, 180 deselected

$ pre-commit run --files python/tvm/relax/frontend/torch/base_fx_graph_translator.py \
                    tests/python/relax/test_frontend_from_fx.py
ruff check ....... Passed
ruff format ...... Passed
(all hooks passed)

Files changed

  • python/tvm/relax/frontend/torch/base_fx_graph_translator.py — _flatten_impl:
    normalize and validate dims, handle a 0-d input.
  • tests/python/relax/test_frontend_from_fx.py — regression tests.

Note for reviewers

The 0-d handling (dim_post_expr = max(rank, 1) plus the rank == 0 branch) is
separable from the reported bug. It fixes the same empty-reduce crash for scalar
inputs and keeps the new range check from rejecting a valid torch.flatten(scalar),
but if you would rather keep this PR to exactly the reported case I am happy to drop
that hunk and its test.


This change was developed with AI assistance. The build, the before/after comparison
and the test runs reported above were all executed locally against this branch as
submitted.

Fixes: #20227

`_flatten_impl` normalized negative flatten dims but never checked their
range or their ordering, so `from_fx` crashed with an internal
`TypeError: reduce() of empty iterable with no initial value` on a traced
model whose `start_dim` came after its `end_dim`. Out-of-range dims leaked
an `IndexError`, and an out-of-range negative `start_dim` silently computed
a wrong shape that only failed later inside `relax.op.reshape`.

Normalize both dims against `max(rank, 1)`, validate each against
`[-r, r-1]`, and reject `start_dim > end_dim` with a message matching
torch's own. A 0-d input is now handled explicitly, matching
`torch.flatten` on a scalar.

Co-authored-by: Claude <noreply@anthropic.com>

@cchung100m cchung100m left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM ,thanks to @hiyufan

@tlopex
tlopex merged commit b3f51d1 into apache:main Sep 8, 2026
5 checks passed
tlopex pushed a commit that referenced this pull request Sep 15, 2026
…0255)

### 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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

3 participants