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 2dfd3cc200 [Fix][Relax][Frontend][PyTorch] Fix `x.split(int)` with a 
non-divisible `split_size` (#20240)
2dfd3cc200 is described below

commit 2dfd3cc200ace10e56264abcb9aa3ecd048849f8
Author: HuEnwei <[email protected]>
AuthorDate: Wed Sep 2 02:55:32 2026 +0800

    [Fix][Relax][Frontend][PyTorch] Fix `x.split(int)` with a non-divisible 
`split_size` (#20240)
    
    # [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:
    
    ```python
    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.py` — `test_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]`).
---
 .../frontend/torch/base_fx_graph_translator.py     | 10 ++-
 .../relax/test_frontend_from_exported_program.py   | 80 ++++++++++++++++++++++
 tests/python/relax/test_frontend_from_fx.py        |  2 +-
 3 files changed, 90 insertions(+), 2 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 d600987cdd..2afbd009e7 100644
--- a/python/tvm/relax/frontend/torch/base_fx_graph_translator.py
+++ b/python/tvm/relax/frontend/torch/base_fx_graph_translator.py
@@ -2330,7 +2330,15 @@ class BaseFXGraphImporter(metaclass=abc.ABCMeta):
                 cum_sum = 0 if not n_section else n_section[-1]
                 n_section.append(s + cum_sum)
         else:
-            n_section = (self.shape_of(x)[dim].value + split_size - 1) // 
split_size
+            # 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)]
         return self.block_builder.emit(relax.op.split(x, n_section, dim))
 
     def _squeeze(self, node: fx.Node) -> relax.Var:
diff --git a/tests/python/relax/test_frontend_from_exported_program.py 
b/tests/python/relax/test_frontend_from_exported_program.py
index 7dc3c73564..53e8a3a314 100644
--- a/tests/python/relax/test_frontend_from_exported_program.py
+++ b/tests/python/relax/test_frontend_from_exported_program.py
@@ -5842,6 +5842,86 @@ def test_split():
     verify_model(Chunk(), example_args, {}, Expected)
 
 
+def test_split_int_split_size():
+    """x.split(int, dim) must produce chunks of size `split_size` (the last one
+    smaller when the dimension is not divisible), matching PyTorch.
+
+    The frontend used to convert the int per-chunk size into a section count 
and
+    pass it as relax.op.split's int argument, which means "split into N equal
+    sections"; that yields wrong chunk shapes whenever
+    ceil(D / ceil(D / split_size)) != split_size (e.g. split_size > D/2). The
+    int branch now builds cumulative cut positions, the same as the list/tuple
+    form.
+    """
+
+    class Split6(Module):
+        def forward(self, input):
+            return input.split(6, dim=0)
+
+    @tvm.script.ir_module
+    class Expected:
+        @R.function
+        def main(input: R.Tensor((10,), dtype="float32")) -> R.Tuple(
+            R.Tensor((6,), dtype="float32"),
+            R.Tensor((4,), dtype="float32"),
+        ):
+            with R.dataflow():
+                lv: R.Tuple(
+                    R.Tensor((6,), dtype="float32"),
+                    R.Tensor((4,), dtype="float32"),
+                ) = R.split(input, indices_or_sections=[6], axis=0)
+                lv1: R.Tensor((6,), dtype="float32") = lv[0]
+                lv2: R.Tensor((4,), dtype="float32") = lv[1]
+                gv: R.Tuple(
+                    R.Tensor((6,), dtype="float32"),
+                    R.Tensor((4,), dtype="float32"),
+                ) = (lv1, lv2)
+                R.output(gv)
+            return gv
+
+    example_args = (torch.arange(10, dtype=torch.float32) + 1,)
+    verify_model(Split6(), example_args, {}, Expected)
+
+    # Differential check against native PyTorch for non-divisible sizes and 
dims.
+    class SplitModel(Module):
+        def __init__(self, split_size, dim):
+            super().__init__()
+            self.split_size = split_size
+            self.dim = dim
+
+        def forward(self, input):
+            return input.split(self.split_size, dim=self.dim)
+
+    def run_tvm(model, args):
+        exported_program = export(model, args=args)
+        mod = from_exported_program(exported_program)
+        ex = relax.build(mod, target="llvm")
+        vm = relax.VirtualMachine(ex, tvm.cpu())
+        out = vm["main"](*[tvm.runtime.tensor(a.numpy()) for a in args])
+        if hasattr(out, "numpy"):
+            return [out.numpy()]
+        return [o.numpy() for o in out]
+
+    for shape, split_size, dim in [
+        ((10,), 6, 0),
+        ((10,), 7, 0),
+        ((10,), 8, 0),
+        ((10,), 9, 0),
+        ((12,), 7, 0),
+        ((12, 8), 5, 1),
+        ((3, 10), 6, -1),
+    ]:
+        x = torch.arange(1, int(np.prod(shape)) + 1, 
dtype=torch.float32).reshape(shape)
+        refs = [r.numpy() for r in x.split(split_size, dim)]
+        outs = run_tvm(SplitModel(split_size, dim), (x,))
+        assert [r.shape for r in refs] == [o.shape for o in outs], (
+            f"split shape={shape} s={split_size} dim={dim}: "
+            f"torch {[r.shape for r in refs]} vs tvm {[o.shape for o in outs]}"
+        )
+        for r, o in zip(refs, outs):
+            tvm.testing.assert_allclose(o, r, rtol=1e-7, atol=1e-7)
+
+
 def test_squeeze():
     class Squeeze1(Module):
         def forward(self, input):
diff --git a/tests/python/relax/test_frontend_from_fx.py 
b/tests/python/relax/test_frontend_from_fx.py
index a489977958..1d086cf043 100644
--- a/tests/python/relax/test_frontend_from_fx.py
+++ b/tests/python/relax/test_frontend_from_fx.py
@@ -4070,7 +4070,7 @@ def test_split():
                     R.Tensor((1, 1, 10, 10), dtype="float32"),
                     R.Tensor((1, 1, 10, 10), dtype="float32"),
                     R.Tensor((1, 1, 10, 10), dtype="float32"),
-                ) = R.split(input_1, indices_or_sections=3, axis=1)
+                ) = R.split(input_1, indices_or_sections=[1, 2], axis=1)
                 gv: R.Tuple(
                     R.Tensor((1, 1, 10, 10), dtype="float32"),
                     R.Tensor((1, 1, 10, 10), dtype="float32"),

Reply via email to