Skip to content

Commit ea8b3b5

Browse files
committed
[Relax][ONNX] Add three TOPI-level tests to verify resize behavior without scales
1 parent 5e90808 commit ea8b3b5

2 files changed

Lines changed: 210 additions & 3 deletions

File tree

‎python/tvm/topi/image/resize.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,12 @@ def resize1d(
452452
Shape to return. If left None will be inferred
453453
(If shape is determined dynamically, pass out_dtype.shape as output_shape)
454454
455+
scales: tuple or None
456+
Explicit scale factors for coordinate transformation.
457+
If not None, scales=[scale_w] for 1D.
458+
When provided, overrides the scale derived from input and output sizes.
459+
Used by the ONNX frontend when a Resize node is driven by a "scales" input.
460+
455461
Returns
456462
-------
457463
output : tvm.te.Tensor
@@ -829,6 +835,12 @@ def resize2d(
829835
Shape to return. If left None will be inferred
830836
(If shape is determined dynamically, pass out_dtype.shape as output_shape)
831837
838+
scales: tuple or None
839+
Explicit scale factors for coordinate transformation.
840+
If not None, scales=[scale_h, scale_w] for 2D.
841+
When provided, overrides the scale derived from input and output sizes.
842+
Used by the ONNX frontend when a Resize node is driven by a "scales" input.
843+
832844
Returns
833845
-------
834846
output : tvm.te.Tensor

‎tests/python/relax/test_frontend_onnx.py‎

Lines changed: 198 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040

4141
import tvm
4242
import tvm.testing
43-
from tvm import relax
43+
from tvm import relax, te, topi
4444
from tvm.relax.frontend.onnx import from_onnx
4545
from tvm.script import ir as I
4646
from tvm.script import relax as R
@@ -8401,8 +8401,9 @@ def test_resize_asymmetric_nearest_noninteger_scales_2d():
84018401
def test_resize_asymmetric_nearest_integer_scales_baseline():
84028402
"""Baseline: asymmetric+nearest+floor with integer scale should work correctly.
84038403
8404-
This ensures the guarded optimization (when scale_override is None) still produces
8405-
correct results for integer scales and doesn't regress existing behavior.
8404+
This verifies the correctness of the float fallback path when an integer scale override
8405+
is provided (scale_override is not None). The optimization case (when scale_override is
8406+
None) is tested separately in TOPI-level tests.
84068407
"""
84078408
resize_node = helper.make_node(
84088409
"Resize",
@@ -8448,6 +8449,200 @@ def test_resize_integer_scales_regression(input_shape, scales, output_shape):
84488449
check_correctness(helper.make_model(graph), opset=18)
84498450

84508451

8452+
def _build_topi_resize2d_nearest_asymmetric_prim_func(input_shape, size, scales=None):
8453+
"""
8454+
Parameters
8455+
----------
8456+
input_shape : tuple
8457+
NCHW input shape, e.g. (1, 1, 2, 2).
8458+
size: tuple
8459+
Target (out_h, out_w) spatial size
8460+
scales: tuple or None
8461+
If not None, an explicit (scale_h, scale_w) override -- mirroring
8462+
what the ONNX frontend passes when the ONNX "scales" input (rather
8463+
than "sizes") drives the resize. Per the guard in `_resize_2d`,
8464+
supplying this disable the integer-division fast path regardless
8465+
of whether size[i] / input_shape[i] is itself an integer.
8466+
8467+
Returns
8468+
-------
8469+
(resized_te_tensor, prim_func) : tuple
8470+
The TE output tensor (used to read the concrete output shape) and
8471+
the lowered TIR PrimFunc (used both to run the kernel and to
8472+
inspect which code path was generated).
8473+
"""
8474+
data = te.placeholder(input_shape, dtype="float32", name="data")
8475+
roi = (0.0, 0.0, 0.0, 0.0)
8476+
resized = topi.image.resize2d(
8477+
data,
8478+
roi,
8479+
size=size,
8480+
layout="NCHW",
8481+
method="nearest_neighbor",
8482+
coordinate_transformation_mode="asymmetric",
8483+
rounding_method="",
8484+
scales=scales,
8485+
)
8486+
prim_func = te.create_prim_func([data, resized])
8487+
return resized, prim_func
8488+
8489+
8490+
def _run_prim_func(prim_func, input_np, output_shape):
8491+
"""Compile a 1-input/1-output PrimFunc for LLVM and run it on `input_np`."""
8492+
built = tvm.compile(prim_func, target="llvm")
8493+
data_nd = tvm.runtime.tensor(input_np.astype("float32"))
8494+
out_nd = tvm.runtime.tensor(np.zeros(output_shape, dtype="float32"))
8495+
built(data_nd, out_nd)
8496+
return out_nd.numpy()
8497+
8498+
8499+
def _fast_path_used(prim_func):
8500+
"""Return True if the lowered TIR took the integer-division fast path.
8501+
8502+
The fast path computes the source index purely with integer division
8503+
(`T.Div`, no rounding needed) and never calls `T.floor`. The float
8504+
fallback always computes `floor(scale * out_index + eps)` and casts it
8505+
to int, so it always contains `T.floor` and never emits a bare `T.Div`
8506+
for the index computation. Checking both signals (rather than only the
8507+
absence of one) keeps the assertion meaningful in both directions.
8508+
"""
8509+
script = prim_func.script()
8510+
has_int_div = "T.Div(" in script
8511+
has_float_floor = "T.floor(" in script
8512+
assert has_int_div != has_float_floor, (
8513+
"expected exactly one of the integer-division or floating-point "
8514+
f"code paths to appear in the lowered TIR, got:\n{script}"
8515+
)
8516+
return has_int_div
8517+
8518+
8519+
@pytest.mark.parametrize(
8520+
"input_hw,output_hw",
8521+
[
8522+
((2, 2), (4, 4)),
8523+
((3, 3), (6, 6)),
8524+
],
8525+
)
8526+
def test_topi_resize2d_int_div_optimization_fires_without_scale_override(input_hw, output_hw):
8527+
"""scale_override=None + integer size ratio => int-div fast path fires.
8528+
8529+
This is the primary positive case for the optimization: when
8530+
`topi.image.resize2d` is called without an explicit `scales` override
8531+
(i.e. `scales=None`, as happens whenever the ONNX Resize node's output
8532+
shape is derived from a "sizes" input rather than a "scales" input) and
8533+
the output/input size ratio is a whole number, `_resize_2d` should
8534+
select the integer-division fast path (`can_convert_multiply_to_intdiv`
8535+
returns True and `scale_h`/`scale_w` are both None).
8536+
8537+
We verify this two ways:
8538+
1. The lowered TIR contains `T.Div` and not `T.floor`, proving the
8539+
fast path -- and not the float fallback -- was actually chosen.
8540+
2. The numeric output matches ONNX's nearest/asymmetric/floor
8541+
reference formula `in_x = floor(out_x * (in_size / out_size))`,
8542+
so the optimization is not merely "fast" but also correct.
8543+
"""
8544+
in_h, in_w = input_hw
8545+
out_h, out_w = output_hw
8546+
input_shape = (1, 1, in_h, in_w)
8547+
8548+
resized, prim_func = _build_topi_resize2d_nearest_asymmetric_prim_func(
8549+
input_shape, (out_h, out_w), scales=None
8550+
)
8551+
8552+
assert _fast_path_used(prim_func), (
8553+
"expected the integer-division fast path to fire when scale_override "
8554+
"is None and the size ratio is an integer"
8555+
)
8556+
8557+
input_np = np.arange(np.prod(input_shape), dtype="float32").reshape(input_shape)
8558+
actual = _run_prim_func(prim_func, input_np, tuple(int(s) for s in resized.shape))
8559+
8560+
# Reference: ONNX asymmetric + nearest_neighbor + floor rounding is
8561+
# `in_idx = floor(out_idx * in_size / out_size)`, clamped to
8562+
# [0, in_size - 1]. Since in_size / out_size is an integer ratio here,
8563+
# this is exactly `out_idx // (out_size // in_size)`.
8564+
def ref_index(out_size, in_size):
8565+
ratio = out_size // in_size
8566+
idx = np.arange(out_size) // ratio
8567+
return np.clip(idx, 0, in_size - 1)
8568+
8569+
y_idx = ref_index(out_h, in_h)
8570+
x_idx = ref_index(out_w, in_w)
8571+
expected = input_np[:, :, y_idx][:, :, :, x_idx]
8572+
8573+
np.testing.assert_array_equal(actual, expected)
8574+
8575+
8576+
@pytest.mark.parametrize(
8577+
"input_hw,output_hw,scale_override",
8578+
[
8579+
((2, 2), (4, 4), (2.4, 2.4)),
8580+
((3, 3), (6, 6), (2.5, 2.5)),
8581+
],
8582+
)
8583+
def test_topi_resize2d_int_div_optimization_disabled_by_scale_override(
8584+
input_hw, output_hw, scale_override
8585+
):
8586+
"""A non-integer scale_override disables the int-div fast path."""
8587+
in_h, in_w = input_hw
8588+
out_h, out_w = output_hw
8589+
input_shape = (1, 1, in_h, in_w)
8590+
8591+
resized, prim_func = _build_topi_resize2d_nearest_asymmetric_prim_func(
8592+
input_shape, (out_h, out_w), scales=scale_override
8593+
)
8594+
8595+
assert not _fast_path_used(prim_func), (
8596+
"expected the integer-division fast path to be disabled when an "
8597+
"explicit (non-integer) scale_override is supplied"
8598+
)
8599+
8600+
input_np = np.arange(np.prod(input_shape), dtype="float32").reshape(input_shape)
8601+
actual = _run_prim_func(prim_func, input_np, tuple(int(s) for s in resized.shape))
8602+
8603+
epsilon = 1e-5
8604+
8605+
def ref_index(out_size, in_size, scale):
8606+
idx = np.floor(np.arange(out_size) / scale + epsilon).astype(np.int64)
8607+
return np.clip(idx, 0, in_size - 1)
8608+
8609+
y_idx = ref_index(out_h, in_h, scale_override[0])
8610+
x_idx = ref_index(out_w, in_w, scale_override[1])
8611+
expected = input_np[:, :, y_idx][:, :, :, x_idx]
8612+
8613+
np.testing.assert_array_equal(actual, expected)
8614+
8615+
ratio = out_h // in_h
8616+
naive_idx = np.clip(np.arange(out_h) // ratio, 0, in_h - 1)
8617+
assert not np.array_equal(naive_idx, y_idx), (
8618+
"test setup issue: chosen scale_override does not actually differ "
8619+
"from the naive integer-ratio index mapping"
8620+
)
8621+
8622+
8623+
def test_topi_resize2d_int_div_fast_path_matches_integer_scale_override():
8624+
"""An *integer* scale_override disables the fast path but agrees with it."""
8625+
input_shape = (1, 1, 3, 3)
8626+
output_hw = (6, 6)
8627+
input_np = np.arange(np.prod(input_shape), dtype="float32").reshape(input_shape)
8628+
8629+
fast_resized, fast_prim_func = _build_topi_resize2d_nearest_asymmetric_prim_func(
8630+
input_shape, output_hw, scales=None
8631+
)
8632+
assert _fast_path_used(fast_prim_func)
8633+
fast_out_shape = tuple(int(s) for s in fast_resized.shape)
8634+
fast_actual = _run_prim_func(fast_prim_func, input_np, fast_out_shape)
8635+
8636+
slow_resized, slow_prim_func = _build_topi_resize2d_nearest_asymmetric_prim_func(
8637+
input_shape, output_hw, scales=(2.0, 2.0)
8638+
)
8639+
assert not _fast_path_used(slow_prim_func)
8640+
slow_out_shape = tuple(int(s) for s in slow_resized.shape)
8641+
slow_actual = _run_prim_func(slow_prim_func, input_np, slow_out_shape)
8642+
8643+
np.testing.assert_array_equal(fast_actual, slow_actual)
8644+
8645+
84518646
def test_einsum():
84528647
eqn = "ij->i"
84538648
einsum_node = helper.make_node("Einsum", ["x"], ["y"], equation=eqn)

0 commit comments

Comments
 (0)