|
40 | 40 |
|
41 | 41 | import tvm |
42 | 42 | import tvm.testing |
43 | | -from tvm import relax |
| 43 | +from tvm import relax, te, topi |
44 | 44 | from tvm.relax.frontend.onnx import from_onnx |
45 | 45 | from tvm.script import ir as I |
46 | 46 | from tvm.script import relax as R |
@@ -8401,8 +8401,9 @@ def test_resize_asymmetric_nearest_noninteger_scales_2d(): |
8401 | 8401 | def test_resize_asymmetric_nearest_integer_scales_baseline(): |
8402 | 8402 | """Baseline: asymmetric+nearest+floor with integer scale should work correctly. |
8403 | 8403 |
|
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. |
8406 | 8407 | """ |
8407 | 8408 | resize_node = helper.make_node( |
8408 | 8409 | "Resize", |
@@ -8448,6 +8449,200 @@ def test_resize_integer_scales_regression(input_shape, scales, output_shape): |
8448 | 8449 | check_correctness(helper.make_model(graph), opset=18) |
8449 | 8450 |
|
8450 | 8451 |
|
| 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 | + |
8451 | 8646 | def test_einsum(): |
8452 | 8647 | eqn = "ij->i" |
8453 | 8648 | einsum_node = helper.make_node("Einsum", ["x"], ["y"], equation=eqn) |
|
0 commit comments