Skip to content

[Fix][Relax][Frontend][PyTorch] Fix x.split(int) with a non-divisible split_size - #20240

Merged
tlopex merged 1 commit into
apache:mainfrom
siyiweigeHEW:fix/relax-torch-split-int-split-size
Sep 1, 2026
Merged

[Fix][Relax][Frontend][PyTorch] Fix x.split(int) with a non-divisible split_size#20240
tlopex merged 1 commit into
apache:mainfrom
siyiweigeHEW:fix/relax-torch-split-int-split-size

Conversation

@siyiweigeHEW

@siyiweigeHEW siyiweigeHEW commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

[Relax][Frontend][PyTorch] Fix x.split(int) with a non-divisible split_size

Fixes: #20232

Summary

torch.split(x, s, dim) splits dim into chunks of size s, with the
last chunk smaller when the dimension D is not divisible by s. The
Relax PyTorch frontend's _split converter
(base_fx_graph_translator.py) converted the per-chunk size into a
section count n_section = ceil(D / s) and passed it to
relax.op.split's integer argument — whose semantics are "split into
n_section equal sections" (split_len = ceil(D / n_section),
src/relax/op/tensor/manipulate.cc). Whenever
ceil(D / ceil(D / s)) != s (e.g. split_size > D/2 with a
non-divisible D), valid PyTorch models silently produced
differently-shaped chunks. For example x.split(6) on a (10,) tensor
yielded (5,), (5,) instead of (6,), (4,).

This PR converts the int per-chunk size into the cumulative cut
positions [s, 2s, ..., (ceil(D/s) - 1) * s] — the same indices form
the list/tuple branch already passes to relax.op.split — so both
forms produce PyTorch-identical chunk shapes.

Root cause

_split handles two aten ops: split.Tensor (int split_size) and
split_with_sizes.default (list/tuple). The list branch builds
cumulative cut positions and is correct. The int branch instead computed
n_section = ceil(D / split_size) and relied on relax.op.split's
integer "equal sections" semantics, which only coincide with PyTorch's
per-chunk-size semantics when ceil(D / ceil(D / s)) == s (divisible
sizes, or e.g. D=10, s=3). The bug is a semantic mismatch between
"chunks of size s" (PyTorch) and "ceil(D/s) equal sections"
(relax.op.split int), not a numerical issue.

Fix

python/tvm/relax/frontend/torch/base_fx_graph_translator.py
_split, int branch:

else:
    # torch.split(x, s, dim) splits dim into chunks of size s, with the
    # last chunk smaller if D % s != 0. relax.op.split's integer argument
    # is the number of *equal* sections, so passing ceil(D / s) yields
    # wrong shapes whenever ceil(D / ceil(D / s)) != s (e.g. s > D/2).
    # Convert the per-chunk size to the cumulative cut positions instead,
    # mirroring the list/tuple branch above.
    dim_size = self.shape_of(x)[dim].value
    num_chunks = (dim_size + split_size - 1) // split_size
    n_section = [split_size * i for i in range(1, num_chunks)]

The list/tuple branch and the split_with_sizes.default mapping are
unchanged. _split is shared by from_exported_program and from_fx
(via BaseFXGraphImporter), so both entry points are covered.

Validation

In-tree regression test (added)

test_split_int_split_size in
tests/python/relax/test_frontend_from_exported_program.py:

  • structural check: x.split(6, dim=0) on a (10,) input lowers to
    R.split(input, indices_or_sections=[6], axis=0) with output shapes
    (6,), (4,) (asserted via verify_model structural equality);
  • numerical check vs native PyTorch over non-divisible sizes and dims:
    (10,) s=6/7/8/9 dim=0, (12,) s=7 dim=0, (12,8) s=5 dim=1,
    (3,10) s=6 dim=-1 — shapes and values all match.

Differential test

verify_patch.py (in prove_hum/torch_split/) runs the full suite on
the fixed _split injected verbatim from this branch against the
pre-fix _split from origin/main (tvm-env 0.18, whose
_split is byte-identical to the current frontend):

  • before: 10/10 diverging cases (non-divisible s > D/2 across
    dim=0, dim=1, and negative dims) reproduce the bug, e.g.
    (10,) s=6: torch [(6,), (4,)] vs TVM [(5,), (5,)];
  • after: all 10 now match torch shapes and values; the divisible
    baseline (4/4) and the list/tuple control group (3/3) remain
    unchanged — no regression.

Known pre-existing limitation (unchanged by this PR, same family as the
ONNX single-output Split handling): split_size >= D or a
single-element split_with_sizes produces a single chunk, which older
relax versions cannot import as a 1-tuple (single-output struct-info
inference). On current main the empty-indices form already lowers to a
proper 1-tuple, and this PR keeps int split_size >= D consistent with
the existing single-element list behavior.

Files changed

  • python/tvm/relax/frontend/torch/base_fx_graph_translator.py — fix
    the int split_size branch of _split to pass cumulative cut
    positions instead of a section count.
  • tests/python/relax/test_frontend_from_exported_program.py — add
    test_split_int_split_size regression coverage.
  • tests/python/relax/test_frontend_from_fx.pytest_split's
    expected1 asserted the old int-section IR form
    (indices_or_sections=3 for x.split(1, dim=1)); updated to the
    correct list form (indices_or_sections=[1, 2]).

@siyiweigeHEW siyiweigeHEW changed the title [Relax][Frontend][PyTorch] Fix x.split(int) with a non-divisible split_size [Fix][Relax][Frontend][PyTorch] Fix x.split(int) with a non-divisible split_size Aug 30, 2026
@siyiweigeHEW
siyiweigeHEW force-pushed the fix/relax-torch-split-int-split-size branch 2 times, most recently from 809fca0 to e907c64 Compare August 30, 2026 19:47
…t_size

torch.split(x, s, dim) splits dim into chunks of size s, with the last
chunk smaller when D % s != 0. The frontend `_split` converter
(base_fx_graph_translator.py) converted the per-chunk size into a
section count n_section = ceil(D / s) and passed it to relax.op.split's
integer argument, whose semantics are "split into n_section equal
sections" (each of size ceil(D / n_section)). Whenever
ceil(D / ceil(D / s)) != s -- e.g. split_size > D/2 with a non-divisible
D -- valid PyTorch models silently produced differently-shaped chunks
(e.g. x.split(6) on a (10,) tensor yielded (5,), (5,) instead of
(6,), (4,)).

Convert the int per-chunk size to the cumulative cut positions
[s, 2s, ..., (ceil(D/s) - 1)*s] instead, the same list form the
list/tuple branch already passes to relax.op.split.

Differential testing vs native PyTorch (fixed `_split` injected
verbatim): the 10 previously-diverging cases (non-divisible
split_size > D/2 across dim=0, dim=1 and negative dims) all now match
torch shapes and values; the divisible-size baseline and the list/tuple
(split_with_sizes) control group remain unchanged. Added regression
test test_split_int_split_size covering the structural IR
(R.split(input, indices_or_sections=[6], axis=0) for x.split(6) on a
(10,) input) and numerical equivalence for non-divisible sizes/dims.
The pre-existing test_split expected IR in
tests/python/relax/test_frontend_from_fx.py asserted the old int-section
form (indices_or_sections=3 for x.split(1, dim=1)); it is updated to the
correct list form ([1, 2]).
@siyiweigeHEW
siyiweigeHEW force-pushed the fix/relax-torch-split-int-split-size branch from e907c64 to 6590e05 Compare August 30, 2026 21:30
@tlopex
tlopex merged commit 2dfd3cc into apache:main Sep 1, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants