[Fix][Relax][Frontend][Torch] Keep zero-sized dims when reshaping - #20255
[Fix][Relax][Frontend][Torch] Keep zero-sized dims when reshaping#20255hiyufan wants to merge 3 commits into
Conversation
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`. The torch
frontend forwards torch's shape unchanged, so any target shape holding a
literal `0` is silently reinterpreted:
x = torch.randn(2, 0, 4)
x.reshape(0, 4) # torch (0, 4) -> relax raises
x.view(0, 4) # torch (0, 4) -> relax raises
torch.flatten(x) # torch (0,) -> relax raises
x.unflatten(0, ...) # rank grows -> IndexError from the zero-dim path
`torch.flatten` on a `(0, 3)` input happens to work, because copying
input dim 0 gives the same 0 the literal asked for. That coincidence is
what hides the rest.
When the input is statically empty, the dimension torch asks for can be
written as `-1`, whose inference yields 0. Add `_torch_reshape_dims` and
use it where a torch-supplied target shape reaches `relax.op.reshape`:
`_reshape`, `_reshape_as`, `_flatten_impl`, `_unflatten`, `_as_strided`.
Sites that derive the target from the input's own shape are unaffected,
since "copy input dim" and the literal agree there.
The rewrite is deliberately narrow. For a non-empty input torch rejects a
zero in the target outright, and rewriting it to `-1` would silently
produce a shape instead of surfacing that error, so those shapes are left
alone.
The three tests run the imported module rather than comparing against an
expected TVMScript module: an `IRModule` holding `R.reshape(x, R.shape([0,
4]))` cannot be written as TVMScript, because re-parsing it applies the
copy rule again and infers a different shape. That round-trip gap belongs
to `relax.op.reshape` itself and is not addressed here.
Co-authored-by: Claude <noreply@anthropic.com>
sylvesterkaczmarek
left a comment
There was a problem hiding this comment.
_torch_reshape_dims() only rewrites the first literal zero. PyTorch treats every zero in the target as a real zero, so an empty (0, 3) tensor can validly reshape(0, 0) to (0, 0). This helper produces [-1, 0]; Relax then interprets the remaining 0 as “copy dim 1”, yielding (0, 3). Could we preserve all zero positions, or otherwise handle multiple-zero targets, and add a regression for this case?
…dims
Review catch: rewriting only the first literal zero is not enough. A literal
zero survives relax's copy rule exactly at a position whose input dimension is
itself zero, so which zero needs rewriting depends on the input, and there can
be more than one.
(0, 3).reshape(0, 0) torch (0, 0) was (0, 3)
(0, 3, 5).reshape(0, 0, 0) torch (0, 0, 0) was (0, 0... 3, 5)
Both were silently wrong rather than an error.
Rewrite the helper to pick the positions that cannot survive rather than the
first zero, and to split the rewrite when several of them need `-1`: each step
turns one such position into a real zero, which lets the next step spell it as
a literal. Targets needing at most one rewrite -- every case seen in practice --
still emit a single reshape.
Checked against numpy over 2132 valid reshapes (7 input shapes, ranks 1-3):
no mismatches, longest chain 3 steps.
Co-authored-by: Claude <noreply@anthropic.com>
|
You are right, and it is worse than a missed case — both of these were silently wrong rather than an error: Working through your example sharpened the rule for me. It is not that only the first zero is rewritten — it is that which zero needs rewriting depends on the input. A literal That also explains why Pushed
Targets needing at most one rewrite — every case I have seen in practice — still emit a single reshape. Longest chain over everything I tested is 3. Verified against numpy over 2132 valid reshapes (7 input shapes including Regression added as (0, 3).reshape(0, 0) (3, 0).reshape(0, 0)
(0, 3, 5).reshape(0, 0, 0) (2, 0, 4).reshape(0, 0, 4)It fails against the previous head of this PR and passes now, so the specific hole you found is pinned. Full suites: 24 failed / 416 passed against 24 failed / 412 passed on clean This is the third distinct symptom of the same root cause, after the raise and the |
|
The For example, Please allow the rewrite when the input contains a known zero even if its other dimensions are symbolic, and add a dynamic-batch regression test for this case. |
…dims are known
Review catch: the guard declined the rewrite whenever any input dimension was
symbolic, so a statically known zero next to a dynamic batch went unhandled.
(batch, 0, 4).reshape(0, 4) torch (0, 4) was (s77, 4)
Silently wrong again, and the wrong shape is not even empty. One known zero fixes
the element count at zero whatever the symbols turn out to be, so require only
that -- symbolic dimensions elsewhere are held as they stand, since a non-literal
is not read as a copy, and rewritten in a later step once they have become real
zeros.
Also drop the trailing no-op reshape the loop emitted after a rewrite: once no
position needs rewriting, the previous step has already produced the target.
The dynamic case above now lowers to a single reshape rather than two.
Checked against torch over 63 combinations of symbolic and static dims carrying a
known zero (7 input layouts, 9 targets): 63 matched, against 27 before this
change. The 2132-case static sweep is unchanged at no mismatches.
Co-authored-by: Claude <noreply@anthropic.com>
|
Confirmed and fixed in The guard was The symbolic dimensions do still matter one level down, where a position that needs holding is filled in. A literal there would be re-read as a copy, so the position is held as the expression it already is, which is not a literal and so is not substituted; a later step rewrites it once it has become a real zero. I also removed a no-op the loop was emitting: after a rewrite it appended the target once more, so your case lowered to two reshapes. Once nothing needs rewriting the previous step has already produced the target, so the extra one is only emitted when no rewrite happened at all. Your case is a single reshape now: lv: R.Tensor((0, 4), dtype="float32") = R.reshape(x, R.shape([0, 4]))VerificationI swept it rather than checking your example alone, since hand-picked cases are what let the previous hole through — a case that passed by coincidence read as confirmation. 7 input layouts mixing symbolic and static dims around a known zero, against 9 targets, compared to torch: The 2132-case static sweep is unchanged at zero mismatches, so the symbolic path did not cost the static one anything. Test
Full suites: 24 failed / 417 passed against 24 failed / 412 passed on clean |
Problem
PyTorch reads a literal
0in a target shape as a real zero-sized dimension.relax.op.reshapereads it as "copy the corresponding input dimension" — ONNXReshapewithallowzero=0. The torch frontend forwards torch's shape unchanged, so any target shape holding a literal0is silently reinterpreted.On
mainthese import as:mainx.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)ValueErrorx.reshape(0)(2, 0, 4)(0,)ValueErrorx.reshape(3, 0)(0, 3)(3, 0)ValueErrortorch.flatten(x)(2, 0, 4)(0,)ValueErrortorch.flatten(x)(2, 3, 0)(0,)ValueErrorx.unflatten(0, (2, -1))(2, 0)(2, 1, 0)IndexError: Index 2 out of bounds 2torch.flatten(x)(0, 3)(0,)(0,)— happens to workThe last row is why this is easy to miss: copying input dim 0 there gives back the same
0the literal asked for, so the one case people usually try looks fine.The
IndexErrorcomes from the same rule.ConvertNewShapeToExprresolves a zero witharray_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/flattenon ordinary code paths.Fix
When the input is statically empty, the dimension torch asks for can be written as
-1instead, whose inference yields0._torch_reshape_dimsdoes that rewrite, applied where a torch-supplied target shape reachesrelax.op.reshape:_reshape,_reshape_as,_flatten_impl,_unflatten,_as_strided.The other
relax.op.reshapecall 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
-1there would turn an error into a silently wrong shape:Verification
All 17 shape cases I exercised now agree with PyTorch (6 previously raised). Zero-sized behaviour of
squeeze,permute,expand,catandsumwas 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:main: 24 failed, 412 passed, 3 skippedThe 24 failures are pre-existing on
mainin my environment (test_dtypesand friends), identical before and after. The three additional passes are the new tests, which fail onmainand pass with the fix.ruff format --checkandruff checkare 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
IRModulecannot be written as TVMScript. The frontend emitswhich 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 inrelax.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 onrelax.op.reshape(the ONNX frontend already carriesallowzeroand 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_implis 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.