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 730459c4ce [Relax][Frontend][TFLite] Support dynamic RANGE scalar 
bounds (#19867)
730459c4ce is described below

commit 730459c4cecdd628d43b09a363d43685375bc88a
Author: Hongyi Wu <[email protected]>
AuthorDate: Wed Jun 24 14:19:31 2026 +0800

    [Relax][Frontend][TFLite] Support dynamic RANGE scalar bounds (#19867)
    
    ## Summary
    
    This PR adds Relax TFLite frontend support for dynamic (runtime) scalar
    bounds
    in the `RANGE` operator, addressing the `RANGE` "fix partial
    implementations"
    item from #19412 section C.
    
    `convert_range` previously lowered only **constant** `start`, `limit`,
    and
    `delta` to `relax.op.arange` and raised `OpNotImplemented` for runtime
    scalar
    bounds (the guard added in #19401). Models that compute RANGE bounds at
    runtime
    could therefore not be imported. This PR makes the dynamic path work for
    both
    integer and float bounds, ascending or descending, without adding a new
    Relax
    op. The change is limited to the `RANGE` converter and its test.
    
    #19813 added a batch of missing TFLite operator mappings but did not
    touch this
    partial-implementation item; this PR closes it.
    
    ## Design
    
    ### Dynamic scalar bounds via count-lift
    
    `relax.op.arange` only accepts compile-time `PrimExpr` bounds. The
    frontend
    already has a runtime-scalar -> symbolic-dimension bridge
    (`relax.op.tensor_to_shape` + `match_cast`, as used by
    `_get_shape_expr_from_tensor`), so no new op is needed.
    
    Rather than feed symbolic bounds straight into `arange`, the converter
    computes
    the element **count** in-graph and lifts that single value to one
    symbolic
    output dimension `L`, then rebuilds the values as `arange(0, L) * delta
    + start`.
    Lifting the count (instead of the bounds) keeps the declared and runtime
    output
    lengths equal by construction: `arange`'s struct-info length formula
    (`InferTypeArange`) has no negative-step branch, so feeding symbolic
    bounds
    directly would mis-declare descending ranges relative to the TOPI
    runtime
    length.
    
    The count is `max(0, ceil((limit - start) / delta))`, computed per
    dtype:
    
    - **integer**: `-floor_divide(start - limit, delta)` — exact,
    sign-agnostic, and
    free of float-precision loss; equal to `ceil((limit - start) / delta)`.
    - **float**: `ceil((limit - start) / delta)`.
    
    Constant (all-bounds-constant) RANGE keeps the existing direct-`arange`
    path
    unchanged.
    
    ## Operator Support
    
    | Operator | TFLite inputs | Relax lowering | Supported subset |
    |---|---|---|---|
    | `RANGE` | scalar `start`, `limit`, `delta` | `relax.op.arange`
    (constant bounds); count-lift + `arange(0, L) * delta + start` (dynamic
    bounds) | int and float, constant or runtime scalar bounds, ascending or
    descending |
    
    ## Tests
    
    The dynamic test compiles the imported module and runs it on the Relax
    VM,
    comparing the output against `numpy.arange`. The constant-bound
    structural test
    is unchanged.
    
    | Test | Coverage |
    |---|---|
    | `test_range` | constant scalar bounds (existing, unchanged) |
    | `test_range_dynamic_scalar_inputs` | runtime scalar bounds: int and
    float, ascending and descending |
    
    Local validation:
    
    ```bash
    python -m ruff format --check \
      python/tvm/relax/frontend/tflite/tflite_frontend.py \
      tests/python/relax/test_frontend_tflite.py
    
    python -m ruff check \
      python/tvm/relax/frontend/tflite/tflite_frontend.py \
      tests/python/relax/test_frontend_tflite.py
    
    python -m pytest \
      tests/python/relax/test_frontend_tflite.py -k range -q
    
    python -m pytest \
      tests/python/relax/test_frontend_tflite.py -q
    ```
    
    Result:
    
    ```text
    ruff format --check: 2 files already formatted
    ruff check: All checks passed
    range tests: 12 passed, 536 deselected
    full TFLite pytest: 548 passed
    ```
    
    ## References
    
    - Issue #19412 section C: fix partial TFLite operator implementations
    (`RANGE`)
    - PR #19401: added the `RANGE` dynamic-scalar guard and its test
    - PR #18868: introduced the Relax TFLite frontend and `convert_range`
---
 .../tvm/relax/frontend/tflite/tflite_frontend.py   | 100 ++++++++++++++++-----
 tests/python/relax/test_frontend_tflite.py         |  41 +++++++--
 2 files changed, 110 insertions(+), 31 deletions(-)

diff --git a/python/tvm/relax/frontend/tflite/tflite_frontend.py 
b/python/tvm/relax/frontend/tflite/tflite_frontend.py
index 4bf74fe340..c6fb45597c 100644
--- a/python/tvm/relax/frontend/tflite/tflite_frontend.py
+++ b/python/tvm/relax/frontend/tflite/tflite_frontend.py
@@ -1542,7 +1542,13 @@ class OperatorConverter:
         return out
 
     def convert_range(self, op):
-        """Convert TFLite Range"""
+        """Convert TFLite Range.
+
+        Constant bounds lower directly to ``relax.op.arange``. Runtime 
(dynamic)
+        scalar bounds are handled by computing the element count in-graph,
+        lifting it to a symbolic output dimension, and rebuilding the values as
+        ``arange(0, count) * delta + start`` (see ``_convert_dynamic_range``).
+        """
 
         from tflite.TensorType import TensorType
 
@@ -1551,38 +1557,86 @@ class OperatorConverter:
 
         start, limit, delta = input_tensors[0], input_tensors[1], 
input_tensors[2]
 
-        def get_scalar_value(tensor):
+        # out type inference
+        if delta.tensor.Type() == TensorType.FLOAT32:
+            out_type = self.get_tensor_type_str(delta.tensor.Type())
+        else:
+            out_type = self.get_tensor_type_str(start.tensor.Type())
+
+        def is_dynamic(tensor):
+            return self.has_expr(tensor.tensor_idx) and not isinstance(
+                self.get_expr(tensor.tensor_idx), relax.Constant
+            )
+
+        def static_scalar(tensor):
             if self.has_expr(tensor.tensor_idx):
-                expr = self.get_expr(tensor.tensor_idx)
-                if isinstance(expr, relax.Constant):
-                    value = expr.data.numpy()
-                else:
-                    # relax.op.arange currently expects scalar-like values 
here.
-                    # Keep dynamic scalar RANGE explicit until frontend 
support is added.
-                    raise tvm.error.OpNotImplemented(
-                        "TFLite RANGE with dynamic scalar inputs is not 
supported in"
-                        "Relax frontend yet."
-                    )
+                value = self.get_expr(tensor.tensor_idx).data.numpy()
             else:
                 value = self.get_tensor_value(tensor)
-
             # TFLite RANGE operands are scalar tensors in the flatbuffer.
             assert value.size == 1, "RANGE scalar input must have exactly one 
element"
             return value.item()
 
-        start_value = get_scalar_value(start)
-        limit_value = get_scalar_value(limit)
-        delta_value = get_scalar_value(delta)
+        if not (is_dynamic(start) or is_dynamic(limit) or is_dynamic(delta)):
+            return relax.op.arange(
+                static_scalar(start), static_scalar(limit), 
static_scalar(delta), out_type
+            )
+
+        return self._convert_dynamic_range(start, limit, delta, out_type)
 
-        # out type inference
-        if delta.tensor.Type() == TensorType.FLOAT32:
-            out_type = self.get_tensor_type_str(delta.tensor.Type())
-        else:
-            out_type = self.get_tensor_type_str(start.tensor.Type())
+    def _scalar_tensor_to_dim(self, expr, name):
+        """Lift a runtime scalar Relax expr to a symbolic ``tirx.Var`` 
dimension.
 
-        out = relax.op.arange(start_value, limit_value, delta_value, out_type)
+        Mirrors the ``tensor_to_shape`` + ``match_cast`` bridge used by
+        ``_get_shape_expr_from_tensor`` so a data-dependent scalar can be used 
as
+        a ``PrimExpr`` (e.g. an output length). The scalar is cast to int64 
first.
+        """
+        expr = self.bb.normalize(relax.op.astype(expr, "int64"))
+        expr = self.bb.normalize(relax.op.reshape(expr, (1,)))
+        expr = self.bb.match_cast(expr, relax.TensorType([1], "int64"))
+        shape_var = self.bb.emit(relax.op.tensor_to_shape(expr))
+        dim = tirx.Var(name, "int64")
+        self.bb.match_cast(shape_var, relax.ShapeType([dim]))
+        return dim
+
+    def _convert_dynamic_range(self, start, limit, delta, out_type):
+        """RANGE with dynamic (runtime) scalar bounds, for int and float 
dtypes.
+
+        ``relax.op.arange`` only accepts compile-time ``PrimExpr`` bounds, and 
its
+        struct-info length formula lacks a negative-step branch, so feeding
+        symbolic bounds directly would mis-declare descending ranges. Instead 
the
+        element count ``max(0, ceil((limit - start) / delta))`` is computed
+        in-graph and lifted to one symbolic dimension ``L`` (so the declared 
and
+        runtime lengths match by construction); values are rebuilt as
+        ``arange(0, L) * delta + start``.
+        """
+        # int ranges work in int64 for an exact, sign-agnostic count; float
+        # ranges work in the output float dtype.
+        work_type = out_type if out_type.startswith("float") else "int64"
 
-        return out
+        def scalar_expr(tensor):
+            return 
self.bb.normalize(relax.op.astype(self.get_tensor_expr(tensor), work_type))
+
+        start_e = scalar_expr(start)
+        limit_e = scalar_expr(limit)
+        delta_e = scalar_expr(delta)
+
+        if work_type.startswith("float"):
+            count = relax.op.ceil(relax.op.divide(relax.op.subtract(limit_e, 
start_e), delta_e))
+        else:
+            # ceil((limit - start) / delta) == -floordiv(start - limit, delta),
+            # which stays exact and handles negative delta without a float 
cast.
+            count = relax.op.negative(
+                relax.op.floor_divide(relax.op.subtract(start_e, limit_e), 
delta_e)
+            )
+        count = relax.op.maximum(count, relax.const(0, work_type))
+        dim = self._scalar_tensor_to_dim(count, "range_len")
+
+        positions = self.bb.normalize(
+            relax.op.astype(relax.op.arange(0, dim, 1, "int64"), work_type)
+        )
+        out = relax.op.add(relax.op.multiply(positions, delta_e), start_e)
+        return out if work_type == out_type else relax.op.astype(out, out_type)
 
     def convert_rank(self, op):
         """Convert TFLite RANK."""
diff --git a/tests/python/relax/test_frontend_tflite.py 
b/tests/python/relax/test_frontend_tflite.py
index 9962732e08..4d3a27dfc3 100644
--- a/tests/python/relax/test_frontend_tflite.py
+++ b/tests/python/relax/test_frontend_tflite.py
@@ -717,22 +717,47 @@ def test_range(start, limit, delta, dtype):
     verify(Range)
 
 
-def test_range_dynamic_scalar_inputs_not_supported():
-    """RANGE conversion currently rejects dynamic scalar inputs."""
[email protected](
+    "start, limit, delta, dtype",
+    [
+        (2, 13, 3, tf.int32),
+        (8, 0, -2, tf.int32),
+        (0.0, 1.0, 0.25, tf.float32),
+        (1.0, -1.0, -0.5, tf.float32),
+    ],
+)
+def test_range_dynamic_scalar_inputs(start, limit, delta, dtype):
+    """RANGE lowers dynamic (runtime) scalar bounds for both int and float 
dtypes."""
 
     class RangeDynamic(tf.Module):
         @tf.function(
             input_signature=[
-                tf.TensorSpec(shape=(), dtype=tf.int32),
-                tf.TensorSpec(shape=(), dtype=tf.int32),
-                tf.TensorSpec(shape=(), dtype=tf.int32),
+                tf.TensorSpec(shape=(), dtype=dtype),
+                tf.TensorSpec(shape=(), dtype=dtype),
+                tf.TensorSpec(shape=(), dtype=dtype),
             ]
         )
         def func(self, start, limit, delta):
-            return tf.range(start, limit, delta, dtype=tf.int32)
+            return tf.range(start, limit, delta)
+
+    cf = RangeDynamic().func.get_concrete_function()
+    mod = _get_mod_from_cfunc(cf)
+
+    np_dtype = np.int32 if dtype == tf.int32 else np.float32
+    inputs = [
+        np.array(start, np_dtype),
+        np.array(limit, np_dtype),
+        np.array(delta, np_dtype),
+    ]
+
+    ex = tvm.compile(mod, tvm.target.Target("llvm"))
+    vm = relax.VirtualMachine(ex, tvm.cpu())
+    vm.set_input("main", *inputs)
+    vm.invoke_stateful("main")
+    tvm_out = vm.get_outputs("main").numpy()
 
-    with pytest.raises(tvm.error.OpNotImplemented, match="dynamic scalar 
inputs"):
-        verify(RangeDynamic)
+    expected = np.arange(start, limit, delta, dtype=np_dtype)
+    np.testing.assert_allclose(tvm_out, expected, rtol=1e-5, atol=1e-5)
 
 
 def test_tile_ir():

Reply via email to