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 a029ef2a77 [Fix][Relax][Frontend][Torch] Validate `num_classes` in the 
`one_hot` converters (#20320)
a029ef2a77 is described below

commit a029ef2a77713410b4c97dbc3c527910299b8179
Author: HuEnwei <[email protected]>
AuthorDate: Wed Sep 16 08:07:34 2026 +0800

    [Fix][Relax][Frontend][Torch] Validate `num_classes` in the `one_hot` 
converters (#20320)
    
    Fixes: #20319
    
    ## Summary
    
    The Relax PyTorch frontend's `_one_hot` converter reads the
    `num_classes`
    argument of an `F.one_hot` / `aten.one_hot` call and forwards it
    **verbatim** to
    `relax.op.one_hot`. It performs no validation at all, so a non-positive
    `num_classes` reaches the C++ op builder
    (`src/relax/op/tensor/manipulate.cc`), which asserts:
    
    ```
    InternalError: Check failed: (depth > 0) is false:
    one_hot: depth must be positive, but got 0
    ```
    
    The message never mentions `num_classes` and gives no hint about how to
    fix the
    model. This is reachable because `num_classes` is an ordinary constant:
    **both `torch.export.export` and `fx.symbolic_trace` accept it as-is**
    and record
    it in the graph, so the failure only appears once the graph is lowered
    through
    TVM.
    
    This PR makes the frontend reject a non-positive `num_classes` with a
    clear
    `ValueError` that names the argument, on **both** converter copies.
    
    ## Root cause
    
    `_one_hot` exists twice — once for the legacy `from_fx` path
    (`fx_translator.py`) and once for the modern `from_exported_program`
    path
    (`exported_program_translator.py`). Both validate only that
    `num_classes` was
    found, then pass it straight through:
    
    ```python
    num_classes = node.args[1] if len(node.args) > 1 else 
node.kwargs.get("num_classes")
    if num_classes is None:
        raise ValueError("num_classes not found in node.args or node.kwargs")
    ...
    return self.block_builder.emit(relax.op.one_hot(x, on_value, off_value, 
num_classes, axis))
    ```
    
    `num_classes` is a static attribute of `relax.op.one_hot` (it determines
    the
    output depth), so the frontend is the only place that can report the
    problem
    usefully. torch itself only rejects an invalid `num_classes` when the
    model is
    *executed*:
    
    | `num_classes` | native torch | `torch.export` | `fx.symbolic_trace` |
    TVM (before) |
    |---|---|---|---|---|
    | `5` | OK `(3, 5)` | OK | OK | OK `(3, 5)` |
    | `0` | `RuntimeError` | **OK** | **OK** | `InternalError: depth must be
    positive, but got 0` |
    | `-1` (explicit) | OK (infers `max+1`) | OK | OK | `InternalError:
    depth must be positive, but got -1` |
    | `-2` | `RuntimeError` | OK | OK | `InternalError: depth must be
    positive, but got -2` |
    
    `num_classes=-1` is torch's documented "infer the depth from the input"
    value.
    That cannot be honoured here — the depth would be data dependent and
    `relax.op.one_hot`'s depth is static — so it is rejected as well, with
    an error
    message that says why.
    
    ## Fix
    
    Both copies of `_one_hot`
    (`python/tvm/relax/frontend/torch/fx_translator.py` and
    `python/tvm/relax/frontend/torch/exported_program_translator.py`) gain
    the same
    check right after the existing "argument missing" guard:
    
    ```python
    # torch only rejects a non-positive num_classes when the model runs, and 
neither
    # fx tracing nor export runs it, so the invalid value reaches this 
converter.
    # num_classes is a static attribute of relax.op.one_hot, so it has to be 
rejected
    # here rather than by the C++ builder, whose `depth > 0` check never 
mentions it.
    if isinstance(num_classes, int) and num_classes <= 0:
        raise ValueError(
            f"one_hot num_classes must be a positive integer, but got 
{num_classes}. "
            "Inferring the depth from the input (torch's num_classes=-1) is not 
"
            "supported because the resulting depth is data dependent."
        )
    ```
    
    The `isinstance(num_classes, int)` guard keeps the change conservative:
    any
    non-literal `num_classes` that may legitimately be dynamic is left
    untouched, so
    only the reported constant case changes behaviour.
    
    This mirrors the existing frontend-side validation style already used
    elsewhere
    in the same files (e.g. `_flatten_impl`'s `start_dim`/`end_dim` checks
    in
    `base_fx_graph_translator.py`), and applies to both entry points, since
    `from_fx` uses `fx_translator` and `from_exported_program` uses
    `exported_program_translator`.
    
    ## Validation
    
    ### In-tree regression tests (added)
    
    - `test_one_hot_invalid_num_classes` in
    `tests/python/relax/test_frontend_from_fx.py` — `num_classes ∈ {0, -1,
    -2}`
    are rejected with `ValueError` before lowering; the valid case is
    already
      covered by the existing `test_one_hot`.
    - `test_one_hot_invalid_num_classes` in
      `tests/python/relax/test_frontend_from_exported_program.py` —
      `num_classes=0` is rejected on the `from_exported_program` path with
      `run_ep_decomposition=False`.
    
    Both tests fail without the fix (`tvm.error.InternalError: Check failed:
    (depth > 0) is false: one_hot: depth must be positive, but got 0`) and
    pass with
    it.
    
    Note on the modern path: `from_exported_program` decomposes
    `aten.one_hot` to
    `arange`/`equal`/`astype` by default (`run_ep_decomposition=True`), so
    the
    converter is normally bypassed and the test therefore passes
    `run_ep_decomposition=False`. Passing that flag is a supported,
    already-tested
    configuration (see `test_einsum`), and with it `_one_hot` is live code
    that hits
    exactly the same C++ check.
    
    ### Behaviour after the fix
    
    - valid `num_classes ∈ {3, 5, 10}` via `from_fx`: output shape and
    values match
      native PyTorch exactly (`max|diff| = 0`);
    - `num_classes ∈ {0, -1, -2}` via `from_fx`: `ValueError: one_hot
    num_classes
    must be a positive integer, but got 0. Inferring the depth from the
    input
    (torch's num_classes=-1) is not supported because the resulting depth is
    data
      dependent.`
    - `num_classes=0` via
    `from_exported_program(run_ep_decomposition=False)`: same
      `ValueError`.
    
    ### Full-suite run
    
    `tests/python/relax/test_frontend_from_fx.py` and
    `tests/python/relax/test_frontend_from_exported_program.py` were run in
    full
    with the change (390 tests: 372 passed, 16 failed, 2 skipped) and again
    with the
    change reverted (372 passed, 16 failed, 2 skipped, 2 deselected). The
    two
    failure sets are **identical**, so the change introduces no regressions.
    The 16
    failures are pre-existing and unrelated to `one_hot`
    (`test_extended_unary_ops`,
    `test_interpolate`, `test_select_slice`, `test_masked_select`,
    `test_to_copy`,
    `test_index_put`, `test_eye`, `test_cross_entropy`, the
    `test_dynamic_shape*`
    family, `test_sym_size_int`, `test_stochastic_depth`); they come from
    the local
    test tree being newer than the source/lib build used for the run, and
    were
    verified to fail identically before and after the change.
    
    ## Files changed
    
    - `python/tvm/relax/frontend/torch/fx_translator.py` — validate
    `num_classes`
      in `_one_hot`.
    - `python/tvm/relax/frontend/torch/exported_program_translator.py` —
    validate
      `num_classes` in `_one_hot`.
    - `tests/python/relax/test_frontend_from_fx.py` — add
      `test_one_hot_invalid_num_classes`.
    - `tests/python/relax/test_frontend_from_exported_program.py` — add
      `test_one_hot_invalid_num_classes`.
---
 .../frontend/torch/exported_program_translator.py    | 10 ++++++++++
 python/tvm/relax/frontend/torch/fx_translator.py     | 10 ++++++++++
 .../relax/test_frontend_from_exported_program.py     | 15 +++++++++++++++
 tests/python/relax/test_frontend_from_fx.py          | 20 ++++++++++++++++++++
 4 files changed, 55 insertions(+)

diff --git a/python/tvm/relax/frontend/torch/exported_program_translator.py 
b/python/tvm/relax/frontend/torch/exported_program_translator.py
index 18c0ef96d8..f3bfce230a 100644
--- a/python/tvm/relax/frontend/torch/exported_program_translator.py
+++ b/python/tvm/relax/frontend/torch/exported_program_translator.py
@@ -1200,6 +1200,16 @@ class ExportedProgramImporter(BaseFXGraphImporter):
         num_classes = node.args[1] if len(node.args) > 1 else 
node.kwargs.get("num_classes")
         if num_classes is None:
             raise ValueError("num_classes not found in node.args or 
node.kwargs")
+        # torch only rejects a non-positive num_classes when the model runs, 
and neither
+        # fx tracing nor export runs it, so the invalid value reaches this 
converter.
+        # num_classes is a static attribute of relax.op.one_hot, so it has to 
be rejected
+        # here rather than by the C++ builder, whose `depth > 0` check never 
mentions it.
+        if isinstance(num_classes, int) and num_classes <= 0:
+            raise ValueError(
+                f"one_hot num_classes must be a positive integer, but got 
{num_classes}. "
+                "Inferring the depth from the input (torch's num_classes=-1) 
is not "
+                "supported because the resulting depth is data dependent."
+            )
 
         on_value = node.args[2] if len(node.args) > 2 else 
node.kwargs.get("on_value", 1)
         off_value = node.args[3] if len(node.args) > 3 else 
node.kwargs.get("off_value", 0)
diff --git a/python/tvm/relax/frontend/torch/fx_translator.py 
b/python/tvm/relax/frontend/torch/fx_translator.py
index 4517650112..2e35ce6ce7 100644
--- a/python/tvm/relax/frontend/torch/fx_translator.py
+++ b/python/tvm/relax/frontend/torch/fx_translator.py
@@ -727,6 +727,16 @@ class TorchFXImporter(BaseFXGraphImporter):
         num_classes = node.args[1] if len(node.args) > 1 else 
node.kwargs.get("num_classes")
         if num_classes is None:
             raise ValueError("num_classes not found in node.args or 
node.kwargs")
+        # torch only rejects a non-positive num_classes when the model runs, 
and neither
+        # fx tracing nor export runs it, so the invalid value reaches this 
converter.
+        # num_classes is a static attribute of relax.op.one_hot, so it has to 
be rejected
+        # here rather than by the C++ builder, whose `depth > 0` check never 
mentions it.
+        if isinstance(num_classes, int) and num_classes <= 0:
+            raise ValueError(
+                f"one_hot num_classes must be a positive integer, but got 
{num_classes}. "
+                "Inferring the depth from the input (torch's num_classes=-1) 
is not "
+                "supported because the resulting depth is data dependent."
+            )
         on_value = node.args[2] if len(node.args) > 2 else 
node.kwargs.get("on_value", 1)
         off_value = node.args[3] if len(node.args) > 3 else 
node.kwargs.get("off_value", 0)
         axis = node.args[4] if len(node.args) > 4 else node.kwargs.get("axis", 
-1)
diff --git a/tests/python/relax/test_frontend_from_exported_program.py 
b/tests/python/relax/test_frontend_from_exported_program.py
index 43065d9c13..484c68bc66 100644
--- a/tests/python/relax/test_frontend_from_exported_program.py
+++ b/tests/python/relax/test_frontend_from_exported_program.py
@@ -7147,6 +7147,21 @@ def test_one_hot():
     verify_model(OneHot(), example_args, {}, Expected)
 
 
+def test_one_hot_invalid_num_classes():
+    class OneHot(Module):
+        def forward(self, indices):
+            return torch.nn.functional.one_hot(indices, num_classes=0)
+
+    example_args = (torch.randint(0, 5, (5,), dtype=torch.int64),)
+    exported_program = export(OneHot(), args=example_args)
+
+    # With the default decomposition, one_hot is rewritten to 
arange/equal/astype and never
+    # reaches this converter. Without it, the non-positive num_classes must be 
rejected by
+    # the frontend instead of failing an internal `depth > 0` check in 
relax.op.one_hot.
+    with pytest.raises(ValueError, match="num_classes must be a positive 
integer"):
+        from_exported_program(exported_program, run_ep_decomposition=False)
+
+
 def test_ones_like():
     class OnesLike(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 961d863dcc..b7074a5f94 100644
--- a/tests/python/relax/test_frontend_from_fx.py
+++ b/tests/python/relax/test_frontend_from_fx.py
@@ -5838,6 +5838,26 @@ def test_one_hot():
     verify_model(OneHot(), [([5], "int32")], {}, Expected)
 
 
+def test_one_hot_invalid_num_classes():
+    input_info = [([5], "int32")]
+
+    class OneHot(Module):
+        def __init__(self, num_classes):
+            super().__init__()
+            self.num_classes = num_classes
+
+        def forward(self, indices):
+            return torch.nn.functional.one_hot(indices, 
num_classes=self.num_classes)
+
+    # torch only rejects a non-positive num_classes when the model is 
executed, and
+    # fx.symbolic_trace does not execute it, so the invalid value reaches the 
frontend and
+    # has to be rejected there instead of failing an internal `depth > 0` 
check in
+    # relax.op.one_hot that never mentions num_classes.
+    for num_classes in (0, -1, -2):
+        with pytest.raises(ValueError, match="num_classes must be a positive 
integer"):
+            from_fx(fx.symbolic_trace(OneHot(num_classes)), input_info)
+
+
 def test_empty_like():
     class EmptyLike(Module):
         def forward(self, data):

Reply via email to