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 7ff8d08b6c [Fix][Relax][Frontend][Torch] Support `aten.diagonal` from 
decomposed repeated-subscript einsum (#20237)
7ff8d08b6c is described below

commit 7ff8d08b6c5f0433ec18d16caadee2590f4f302a
Author: HuEnwei <[email protected]>
AuthorDate: Fri Sep 11 03:10:34 2026 +0800

    [Fix][Relax][Frontend][Torch] Support `aten.diagonal` from decomposed 
repeated-subscript einsum (#20237)
    
    Fixes: #20228
    
    ## Summary
    
    `from_exported_program` runs `exported_program.run_decompositions()` by
    default, and PyTorch's decomposition lowers `torch.einsum` with repeated
    subscripts (diagonal / trace, e.g. `"ii->i"`, `"ii->"`, `"...ii->...i"`)
    to
    `aten.diagonal` + `permute` (+ `sum` for the trace).
    `aten.diagonal.default`
    was missing from the torch frontend `convert_map`, so **every** such
    valid
    model failed with:
    
    ```
    AssertionError: Unsupported function types ['diagonal.default']
    ```
    
    This PR adds an `aten.diagonal` converter and registers it in both the
    exported-program and `from_fx` convert maps, so repeated-subscript
    einsum —
    and the directly-affected ops `torch.diagonal` / `torch.trace` — convert
    and
    run. Verified failing equations from the issue all convert with
    `max|diff| = 0`
    vs PyTorch.
    
    ## Root cause
    
    `BaseFXGraphImporter._check_unsupported_func_type` asserts when a
    `call_function` node's target is not in `convert_map`. For the einsum
    family
    above, `run_decompositions` introduces `aten.diagonal.default` nodes
    that the
    torch frontend had no handler for, so conversion aborts at the
    assertion. This
    is the same root cause for the direct ops `torch.diagonal` (lowered to
    `diagonal.default` as-is) and `torch.trace` (lowered to `diagonal` +
    `clone` +
    `sum`). Skipping decomposition (`run_ep_decomposition=False`) keeps the
    einsum
    node intact and works — confirming the defect is the missing `diagonal`
    handling, not `relax.op.einsum` semantics.
    
    ## Fix
    
    Add `BaseFXGraphImporter._diagonal` in `base_fx_graph_translator.py`,
    lowering
    `diagonal(input, offset=0, dim1=0, dim2=1)` as:
    
    1. `relax.op.permute_dims` — move `dim1` / `dim2` to the trailing two
    axes;
    2. two `relax.op.strided_slice` — crop each trailing axis to the
    diagonal
       length `min(extent1, extent2 ± offset)` (offset-adjusted), so the two
       trailing extents are equal;
    3. `relax.op.einsum([x], "...zz->...z")` — the repeated `z` label runs
    over
       both trailing axes simultaneously, extracting the diagonal.
    
    The lowering handles static and dynamic (symbolic) shapes,
    positive/negative
    offsets, and arbitrary `dim1` / `dim2` (including negative indices).
    Register
    `"diagonal.default"` in `ExportedProgramImporter.create_convert_map` and
    `"diagonal"` in `TorchFXImporter.create_convert_map`.
    
    ## Validation
    
    ### In-tree regression test (added)
    
    `test_einsum_repeated_subscript` in
    `tests/python/relax/test_frontend_from_exported_program.py`:
    
    - `verify_model` against the exact lowering IR for `"ii->i"` on the
    default
      decomposition path (this case used to raise the assertion);
    - `verify_model_numerically` for `"ii->"` (trace), `"...ii->...i"`
    (batched
    diagonal), the attention-style two-operand `"abca,abcb->c"`, and the
    direct
      ops `torch.diagonal(x, offset, 0, 1)` and `torch.trace`.
    
    ### Differential test
    
    `verify_patch.py` runs on the locked build and simulates the pre-fix
    behavior
    at runtime (popping `diagonal.default` from the generated convert map):
    
    - **Baseline (pre-fix)**: all 21 diagonal-producing cases (10 issue
    einsum
    equations + 11 direct `torch.diagonal`/`torch.trace`/`torch.diag`)
    reproduce
    the exact `AssertionError: Unsupported function types
    ['diagonal.default']`;
    1 case (`torch.diag` on a 1-D input, which goes through `diag_embed`) is
      unaffected and stays correct in baseline.
    - **Post-fix**: all 22 issue + direct-op cases convert and match PyTorch
    with
      `max|diff| = 0`.
    - **Dynamic shapes**: `"ii->i"` and `"...ii->...i"` with symbolic dims
    (both
      diagonal dims sharing one `Dim`) match PyTorch exactly.
    - **Regression**: the regular einsum family (matmul, transpose, dot,
    outer,
    batch matmul, ellipsis broadcasting/summation, 3-operand, implicit
    output) —
      15 cases — all still match with `max|diff| = 0`.
    
    Run:
    
    ```bash
    TVM_LIBRARY_PATH=<tvm>/build/lib PYTHONPATH=<tvm 源码>/python \
      /home/shenqingchao/miniconda3/envs/tvm23/bin/python \
      results/TVM/deepseek-v4-flash/prove_hum/torch_einsum/verify_patch.py
    ```
    
    ## Files changed
    
    - `python/tvm/relax/frontend/torch/base_fx_graph_translator.py` — add
    `_diagonal` (permute_dims + strided_slice crop + einsum `...zz->...z`).
    - `python/tvm/relax/frontend/torch/exported_program_translator.py` —
    register
      `"diagonal.default"` in the exported-program `convert_map`.
    - `python/tvm/relax/frontend/torch/fx_translator.py` — register
    `"diagonal"` in
      the `from_fx` `convert_map`.
    - `tests/python/relax/test_frontend_from_exported_program.py` — add
      `test_einsum_repeated_subscript` regression coverage.
---
 .../frontend/torch/base_fx_graph_translator.py     | 101 ++++++++++++++
 .../frontend/torch/exported_program_translator.py  |   1 +
 python/tvm/relax/frontend/torch/fx_translator.py   |   1 +
 .../relax/test_frontend_from_exported_program.py   | 151 +++++++++++++++++++++
 4 files changed, 254 insertions(+)

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 f6781f026f..d42590c256 100644
--- a/python/tvm/relax/frontend/torch/base_fx_graph_translator.py
+++ b/python/tvm/relax/frontend/torch/base_fx_graph_translator.py
@@ -34,6 +34,29 @@ from tvm.ir import PrimType
 from tvm.runtime import DataTypeCode
 
 
+def _diagonal_einsum_subscripts(ndim: int, dim1: int, dim2: int) -> str:
+    """Return explicit einsum subscripts that extract the ``dim1``/``dim2`` 
diagonal.
+
+    This is the fast-path lowering for :meth:`BaseFXGraphImporter._diagonal`
+    when ``offset == 0`` and both diagonal axes have the same extent. The
+    non-diagonal axes keep their natural order in the output while the diagonal
+    axis is appended last, matching ``aten.diagonal``. Non-diagonal axes are
+    labelled ``a``, ``b``, ... and the repeated (diagonal) label is ``z``, e.g.
+    an ``N x N`` input with ``dim1 == 0``, ``dim2 == 1`` gives ``"zz->z"``.
+    """
+    labels = [None] * ndim
+    # Non-diagonal axes are labelled from ``a`` to ``y``; ``z`` is reserved for
+    # the repeated (diagonal) label so the two never collide.
+    letters = iter(ch for ch in "abcdefghijklmnopqrstuvwxyz" if ch != "z")
+    for i in range(ndim):
+        if i != dim1 and i != dim2:
+            labels[i] = next(letters)
+    labels[dim1] = "z"
+    labels[dim2] = "z"
+    leading = "".join(labels[i] for i in range(ndim) if i != dim1 and i != 
dim2)
+    return f"{''.join(labels)}->{leading}z"
+
+
 class BaseFXGraphImporter(metaclass=abc.ABCMeta):
     """Base class for FX Graph Importer."""
 
@@ -1264,6 +1287,84 @@ class BaseFXGraphImporter(metaclass=abc.ABCMeta):
         operands = args[1] if isinstance(args[1], torch.Size | tuple | list) 
else args[1:]
         return self.block_builder.emit(relax.op.einsum(operands, args[0]))
 
+    def _diagonal(self, node: fx.Node) -> relax.Var:
+        """Convert ``aten.diagonal`` / ``torch.diagonal`` to Relax.
+
+        ``diagonal(input, offset=0, dim1=0, dim2=1)`` extracts the elements
+        ``input[..., i, i + offset]`` along the ``dim1`` / ``dim2`` axes. It
+        shows up in the exported graph through ``run_decompositions`` of
+        ``torch.einsum`` with repeated subscripts (e.g. ``"ii->i"``,
+        ``"ii->"``, ``"...ii->...i"``), which lower to an ``aten.diagonal``
+        followed by a ``sum`` reduction.
+
+        We lower it as: when ``offset == 0`` and both diagonal axes have equal
+        extent (the common ``torch.einsum("ii->i")`` case), a single einsum
+        whose subscript repeats one label over the two axes extracts the
+        diagonal directly, so no full-size permute / slice intermediate is
+        materialized. Otherwise we permute ``dim1`` / ``dim2`` to the trailing
+        two axes, slice each trailing axis to the diagonal length (min of the
+        two extents, adjusted by ``offset``), and take the diagonal with an
+        einsum contraction ``...zz->...z`` (the repeated ``z`` label runs over
+        both trailing axes simultaneously).
+        """
+
+        args = self.retrieve_args(node)
+        x = args[0]
+        offset = args[1] if len(args) > 1 else node.kwargs.get("offset", 0)
+        dim1 = args[2] if len(args) > 2 else node.kwargs.get("dim1", 0)
+        dim2 = args[3] if len(args) > 3 else node.kwargs.get("dim2", 1)
+
+        shape = self.shape_of(x)
+        ndim = len(shape.values)
+        dim1 = dim1 if dim1 >= 0 else ndim + dim1
+        dim2 = dim2 if dim2 >= 0 else ndim + dim2
+        if dim1 == dim2:
+            raise ValueError(f"diagonal requires dim1 != dim2, got {dim1} == 
{dim2}")
+
+        offset = int(offset)
+
+        n = shape.values[dim1]
+        m = shape.values[dim2]
+        # Fast path for the common ``offset == 0`` case with equal extents on 
the
+        # diagonal axes (e.g. torch.einsum("ii->i") on an N x N input). The
+        # diagonal can be read with a single einsum that repeats one subscript
+        # label over the two axes (``relax.op.einsum`` lowers it to one O(N)
+        # loop), avoiding the identity permute / strided-slice that would each
+        # materialize a full-size O(N^2) copy. Non-diagonal axes must still fit
+        # in the single-letter einsum label alphabet.
+        if offset == 0 and ndim - 2 <= 25 and tvm_ffi.structural_equal(n, m):
+            subscripts = _diagonal_einsum_subscripts(ndim, dim1, dim2)
+            return self.block_builder.emit(relax.op.einsum([x], subscripts))
+
+        # Move dim1, dim2 to the trailing two axes.
+        perm = [i for i in range(ndim) if i != dim1 and i != dim2] + [dim1, 
dim2]
+        permuted = self.block_builder.emit(relax.op.permute_dims(x, perm))
+
+        if offset >= 0:
+            diag_len = tirx.max(0, tirx.min(n, m - offset))
+            begin1, end1 = 0, diag_len
+            begin2, end2 = offset, offset + diag_len
+        else:
+            diag_len = tirx.max(0, tirx.min(n + offset, m))
+            begin1, end1 = -offset, -offset + diag_len
+            begin2, end2 = 0, diag_len
+
+        # Crop both diagonal axes to the diagonal length so the einsum ``z``
+        # label sees equal extents on both trailing axes.
+        cropped = self.block_builder.emit(
+            relax.op.strided_slice(
+                permuted, axes=[ndim - 2], begin=[begin1], end=[end1], 
strides=[1]
+            )
+        )
+        cropped = self.block_builder.emit(
+            relax.op.strided_slice(
+                cropped, axes=[ndim - 1], begin=[begin2], end=[end2], 
strides=[1]
+            )
+        )
+
+        # ``...zz -> ...z``: keep every leading axis, contract the diagonal 
pair.
+        return self.block_builder.emit(relax.op.einsum([cropped], 
"...zz->...z"))
+
     def _embedding_impl(
         self,
         x,
diff --git a/python/tvm/relax/frontend/torch/exported_program_translator.py 
b/python/tvm/relax/frontend/torch/exported_program_translator.py
index ced0aa7b28..e6df019c5c 100644
--- a/python/tvm/relax/frontend/torch/exported_program_translator.py
+++ b/python/tvm/relax/frontend/torch/exported_program_translator.py
@@ -1894,6 +1894,7 @@ class ExportedProgramImporter(BaseFXGraphImporter):
             "conv3d.default": self._conv3d,
             "convolution.default": self._convolution,
             "cross_entropy_loss.default": self._cross_entropy_default,
+            "diagonal.default": self._diagonal,
             "einsum.default": self._einsum,
             "embedding.default": lambda node: self._embedding_impl(
                 self.env[node.args[1]], self.env[node.args[0]]
diff --git a/python/tvm/relax/frontend/torch/fx_translator.py 
b/python/tvm/relax/frontend/torch/fx_translator.py
index bef0b58f08..4517650112 100644
--- a/python/tvm/relax/frontend/torch/fx_translator.py
+++ b/python/tvm/relax/frontend/torch/fx_translator.py
@@ -952,6 +952,7 @@ class TorchFXImporter(BaseFXGraphImporter):
             "conv2d": self._conv2d,
             "conv3d": self._conv3d,
             "cross_entropy": self._cross_entropy,
+            "diagonal": self._diagonal,
             "einsum": self._einsum,
             "interpolate": self._interpolate,
             "layer_norm": self._layer_norm,
diff --git a/tests/python/relax/test_frontend_from_exported_program.py 
b/tests/python/relax/test_frontend_from_exported_program.py
index 53e8a3a314..75962826f9 100644
--- a/tests/python/relax/test_frontend_from_exported_program.py
+++ b/tests/python/relax/test_frontend_from_exported_program.py
@@ -3327,6 +3327,157 @@ def test_einsum():
     verify_model(Einsum2(), example_args, {}, Expected2, 
run_ep_decomposition=False)
 
 
+def test_einsum_repeated_subscript():
+    """einsum with repeated subscripts (diagonal / trace) on the default
+    decomposition path.
+
+    ``run_decompositions`` (default) lowers repeated-subscript einsum to
+    ``aten.diagonal`` + ``permute`` (+ ``sum`` for the trace), which the
+    frontend converts with the ``_diagonal`` lowering. For the zero-offset
+    square case (e.g. ``torch.einsum("ii->i")`` on an ``N x N`` input) the
+    frontend emits a single repeated-subscript einsum that reads the diagonal
+    directly; otherwise it permutes the diagonal dims to the trailing two axes,
+    slices each to the diagonal length, and runs an einsum ``...zz->...z``.
+    This used to raise ``AssertionError: Unsupported function types
+    ['diagonal.default']``.
+    """
+
+    class EinsumDiag(Module):
+        def __init__(self):
+            super().__init__()
+
+        def forward(self, x):
+            return torch.einsum("ii->i", x)
+
+    @tvm.script.ir_module
+    class Expected:
+        @R.function
+        def main(x: R.Tensor((3, 3), dtype="float32")) -> 
R.Tuple(R.Tensor((3,), dtype="float32")):
+            with R.dataflow():
+                lv: R.Tensor((3,), dtype="float32") = R.einsum((x,), 
subscripts="zz->z")
+                lv1: R.Tensor((3,), dtype="float32") = R.permute_dims(lv, 
axes=[0])
+                lv2: R.Tensor((3,), dtype="float32") = R.permute_dims(lv1, 
axes=[0])
+                gv: R.Tuple(R.Tensor((3,), dtype="float32")) = (lv2,)
+                R.output(gv)
+            return gv
+
+    example_args = (torch.randn(3, 3, dtype=torch.float32),)
+    verify_model(EinsumDiag(), example_args, {}, Expected)
+
+    class TraceEinsum(Module):
+        def forward(self, x):
+            return torch.einsum("ii->", x)
+
+    class BatchedDiagEinsum(Module):
+        def forward(self, x):
+            return torch.einsum("...ii->...i", x)
+
+    class AttentionEinsum(Module):
+        def forward(self, x, y):
+            return torch.einsum("abca,abcb->c", x, y)
+
+    verify_model_numerically(TraceEinsum(), (torch.randn(4, 4),))
+    verify_model_numerically(BatchedDiagEinsum(), (torch.randn(2, 3, 3),))
+    verify_model_numerically(AttentionEinsum(), (torch.randn(3, 3, 4, 3), 
torch.randn(3, 3, 4, 3)))
+
+    class DirectDiagonal(Module):
+        def __init__(self):
+            super().__init__()
+            self.offset = 1
+
+        def forward(self, x):
+            return torch.diagonal(x, self.offset, 0, 1)
+
+    class DirectTrace(Module):
+        def forward(self, x):
+            return torch.trace(x)
+
+    verify_model_numerically(DirectDiagonal(), (torch.randn(3, 4),))
+    verify_model_numerically(DirectTrace(), (torch.randn(4, 4),))
+
+    # Out-of-range offsets (|offset| >= max(extent1, extent2)) are valid in
+    # PyTorch and yield an empty diagonal of shape (0,); the lowering must
+    # clamp the diagonal length to zero instead of producing negative slice
+    # extents or a wrong non-empty shape.
+    class DirectDiagonalOutOfRange(Module):
+        def __init__(self, offset):
+            super().__init__()
+            self.offset = offset
+
+        def forward(self, x):
+            return torch.diagonal(x, self.offset, 0, 1)
+
+    for offset in [4, 5, 6, -3, -4, -5, -6]:
+        verify_model_numerically(DirectDiagonalOutOfRange(offset), 
(torch.randn(3, 4),))
+
+
+def test_einsum_diagonal_lowers_without_full_size_intermediate():
+    """Regression test: a zero-offset square diagonal must not materialize
+    full-size intermediates.
+
+    ``torch.einsum("ii->i")`` on an ``N x N`` input is decomposed to
+    ``aten.diagonal`` by ``run_decompositions``. Lowering that diagonal by
+    permuting the diagonal dims to the trailing axes, slicing each to the
+    diagonal length, and running the ``...zz->...z`` einsum materializes three
+    full-size ``N x N`` intermediates (an identity permute and two identity
+    strided slices) and hence three O(N^2) copy loops before the final O(N)
+    diagonal loop. The ``_diagonal`` fast path instead emits a single
+    repeated-subscript einsum that reads the diagonal directly, so no full-size
+    intermediate exists in the frontend graph (and therefore neither in the
+    lowered TIR). Assert that every intermediate produced by a call is at most
+    O(N), both before and after legalization.
+    """
+
+    class EinsumDiag(Module):
+        def forward(self, x):
+            return torch.einsum("ii->i", x)
+
+    n = 8
+    exported_program = export(EinsumDiag(), args=(torch.randn(n, n),))
+    mod = from_exported_program(exported_program)
+
+    def rank2_call_results(ir_mod):
+        """Names of calls whose result is a rank-2 (full-size) tensor."""
+        results = []
+        for func in ir_mod.functions.values():
+            if not isinstance(func, relax.Function):
+                continue
+            for block in func.body.blocks:
+                for binding in block.bindings:
+                    if not (
+                        isinstance(binding.value, relax.Call)
+                        and isinstance(binding.value.op, tvm.ir.Op)
+                    ):
+                        continue
+                    if isinstance(binding.var.ty, relax.TensorType) and 
binding.var.ty.ndim == 2:
+                        results.append(binding.value.op.name)
+        return results
+
+    # The diagonal must be the only full-size (N x N) tensor touched: it is the
+    # function input read directly by a single repeated-subscript einsum. No
+    # call may produce a rank-2 intermediate.
+    assert rank2_call_results(mod) == []
+
+    # Sanity check that the graph really performs the diagonal: exactly one
+    # einsum on the N x N input producing an N-vector.
+    einsum_calls = []
+    for block in mod["main"].body.blocks:
+        for binding in block.bindings:
+            if (
+                isinstance(binding.value, relax.Call)
+                and isinstance(binding.value.op, tvm.ir.Op)
+                and binding.value.op.name == "relax.einsum"
+            ):
+                einsum_calls.append(binding.var)
+    assert len(einsum_calls) == 1
+    assert einsum_calls[0].ty.ndim == 1
+
+    # Legalize and check again on the lowered graph.
+    with tvm.target.Target("llvm"):
+        lowered = relax.transform.LegalizeOps()(mod)
+    assert rank2_call_results(lowered) == []
+
+
 def test_outer():
     class Outer(torch.nn.Module):
         def forward(self, x, y):

Reply via email to