Skip to content

[Performance] image.resize2d nearest (asymmetric, non-floor rounding) is ~2x slower on CUDA since v0.24.0 (int64 index math from #18838) #20956

Open

Description

@junghyunpark2001

Thanks for participating in the TVM community! We use https://discuss.tvm.ai for any general usage questions and discussions. The issue tracker is used for actionable items such as feature proposals discussion, roadmaps, and bug tracking. You are always welcomed to post on the forum first 😸

Issues that are inactive for a period of time may get closed. We adopt this policy so that we won't lose track of actionable issues that may fall at the bottom of the pile. Feel free to reopen a new one if you feel there is an additional problem that needs attention when an old one gets closed.

Expected behavior

image.resize2d with method="nearest_neighbor", coordinate_transformation_mode="asymmetric" and an integer
upscale factor should run about as fast for round / round_prefer_floor / round_prefer_ceil / ceil as it does
for floor: the source index is still x / factor rounded, which is cheap integer math.

Actual behavior

Since v0.24.0, the non-floor rounding modes run ~2x slower on CUDA than floor for this case, and ~1.9x slower
than v0.23.0. The cause is #18838 ("[BugFix][TOPI] Fix resize accuracy issue with non-floor rounding"). That fix is
correct and should stay
: before it, an integer upscale used the integer-division index path for every rounding mode,
so round, round_prefer_ceil and ceil silently computed floor (the repro below shows this on v0.23.0). After it,
only floor keeps the integer path. The other modes go through

closest_x_index = te.round(in_x).astype("int32")   # and floor/ceil(in_x ± 0.5) etc.

on a float coordinate, and in the generated kernel the whole index expression becomes int64_t with a
float conversion, nearbyintf/roundf/floorf, and a clamp per output element (int64_t appears 65 times in the
kernel on main, 4 times for floor). resize2d nearest is a pure copy kernel, so the index math is most of its time.

Kernel time on CUDA (RTX 4060 Ti, sm_89, CUDA 12; ncu gpu__time_duration.sum, median of 10 fresh processes,
x float32[1,8,56,56,8] NCHW8c → 112×112, rounding_method="round"):

build kernel time (µs) ratio
v0.23.0 14.78 —
v0.24.0 28.70 1.94x of v0.23.0
#18838 parent → #18838 (c1d32438a) 14.78 → 28.99 1.96x
main 1fa4b8627d 28.70 1.94x of v0.23.0
main + proposed fix 16.54 0.58x of main

The repro script below shows the same in wall-clock time_evaluator on the same GPU:

                     v0.23.0   v0.24.0   main      main + fix
round                10.32 us  24.68 us  24.69 us  11.90 us
round_prefer_floor   10.31 us  22.06 us  22.06 us  10.08 us
round_prefer_ceil    10.31 us  22.18 us  22.18 us  11.20 us
ceil                 10.31 us  22.18 us  22.18 us  11.20 us
floor                10.31 us   9.41 us   9.41 us  10.37 us
v0.23.0: every mode returns the floor output.  main + fix: bit-identical to main for all modes.

Proposed fix

When the upscale factor k = target_size / image_size is a power of two, in_x = x / k is exact in float32, so the
rounded index can be computed with one integer floordiv, with the same tie rules as get_closest_index:

def _is_pow2_upscale(origin_size, scaled_size):
    """Whether scaled_size == origin_size * 2**k (k >= 1) and the int-div path applies."""
    if not can_convert_multiply_to_intdiv(origin_size, scaled_size):
        return False
    factor = int(scaled_size) // int(origin_size)
    return factor > 1 and factor & (factor - 1) == 0


def _round_div(x, factor, rounding_method):
    """Round x / factor for a non-negative integer x and a power-of-two factor, with the same
    tie rules as get_closest_index: "round" is half-to-even (te.round), "round_prefer_floor"
    rounds ties down, "round_prefer_ceil" rounds ties up."""
    half = factor // 2
    if rounding_method == "round":
        # ties go up when x // factor is odd, down when it is even
        offset = half - 1 + te.floormod(te.floordiv(x, factor), 2)
    elif rounding_method == "round_prefer_floor":
        offset = half - 1
    elif rounding_method == "round_prefer_ceil":
        offset = half
    else:  # ceil
        offset = factor - 1
    return te.floordiv(x + offset, factor).astype("int32")

and in _resize_2d, for nearest_neighbor + asymmetric with one of these four rounding modes and a power-of-two
upscale, closest_x_index = _round_div(x, target_width // image_width, rounding_method) (same for y) instead of
get_closest_index(...). Everything else, including the existing edge clamp, is unchanged, and other factors keep the
float path.

Correctness: on main the patched outputs are bit-identical to unpatched main for all five rounding modes on the
benchmark input (GPU), and on an LLVM sweep of 90 settings (5 rounding modes × 6 shape pairs × 3 layouts) that also
checks the result against a float32 model of the rounding rule. test_transform_legalize_ops_image.py and
test_op_image.py pass (27/27); test_image_resize2d covers exactly this case, so its expected TIR is regenerated.
I restricted the integer path to power-of-two factors on purpose: an integer formula for every integer factor would
differ from the current float path at ties (5,279 of 64,168 settings checked), because there x / k is not exact in
float32.

The remaining ~12% over v0.23.0 is the edge clamp and one extra floormod for ties-to-even, which correct rounding needs
(v0.23.0 was faster because it computed floor for every mode).

I have the patch ready and I'll open a PR with the fix — filing this issue first to track it.

Environment

Steps to reproduce

Minimal script. For each rounding mode it builds a single image.resize2d relax function, compiles it with the relax
CUDA pipeline, counts int64_t and float rounding calls in the generated kernel, checks whether the output equals the
floor output, and times it. It runs standalone on one TVM version (v0.2x releases and current main) and prints
tvm.__version__, so the output is self-labeling.

repro_resize2d.py
# repro_resize2d.py
import os
import re
import numpy as np
import tvm
from tvm import relax
from tvm.relax.backend.cuda.pipeline import get_default_pipeline

ROUNDING = os.environ.get("ROUNDING", "round,round_prefer_floor,round_prefer_ceil,ceil,floor").split(",")
SHAPE = (1, 8, 56, 56, 8)  # NCHW8c, upsampled 56x56 -> 112x112

print(f"tvm.__version__ = {tvm.__version__}")
print(f"tvm.__file__    = {tvm.__file__}")

try:  # current main: target / runtime live in tvm_tirx
    import tvm_tirx

    # main compiles with NVRTC by default; TVM_CUDA_COMPILER=nvcc selects nvcc instead
    cc = os.environ.get("TVM_CUDA_COMPILER")
    target = tvm_tirx.target.Target(
        {"kind": "cuda", "backend_config": {"cuda": {"compiler": cc}}} if cc else "cuda")
    dev = tvm_tirx.runtime.cuda(0)
    to_nd = tvm_tirx.runtime.tensor
except ImportError:  # v0.2x releases
    target = tvm.target.Target("cuda")
    dev = tvm.cuda(0)
    to_nd = getattr(tvm.runtime, "tensor", None) or tvm.nd.array

rng = np.random.default_rng(0)  # fixed seed: same input across versions/patches
data = to_nd(rng.standard_normal(SHAPE).astype("float32"), dev)


def cuda_source(mod):
    # best effort: the generated CUDA source, searched through the module import tree
    try:
        src = mod.inspect_source() if hasattr(mod, "inspect_source") else mod.get_source()
    except Exception:
        src = ""
    if "__global__" in src:
        return src
    for m in getattr(mod, "imports", None) or getattr(mod, "imported_modules", None) or []:
        src = cuda_source(m)
        if src:
            return src
    return ""


def bench(rounding_method):
    bb = relax.BlockBuilder()
    tensor_type = getattr(relax, "TensorStructInfo", None) or relax.TensorType  # renamed on main
    x = relax.Var("x", tensor_type(SHAPE, "float32"))
    with bb.function("main", [x]):
        with bb.dataflow():
            out = bb.emit(relax.op.image.resize2d(
                x, (112, 112), layout="NCHW8c", method="nearest_neighbor",
                coordinate_transformation_mode="asymmetric", rounding_method=rounding_method))
            gv = bb.emit_output(out)
        bb.emit_func_output(gv)
    with target:
        ex = tvm.compile(get_default_pipeline(target)(bb.get()), target=target)
    src = cuda_source(ex.mod)
    vm = relax.VirtualMachine(ex, dev)
    out = vm["main"](data)  # warmup
    res = vm.time_evaluator("main", dev, number=200, repeat=20)(data)
    return res.mean * 1e6, out.numpy(), src.count("int64_t"), bool(re.search(r"roundf|nearbyintf|rintf|floorf|ceilf", src))


results = {r: bench(r) for r in ROUNDING}
print("\n===== resize2d nearest, asymmetric, NCHW8c 56x56 -> 112x112 =====")
floor_out = results["floor"][1] if "floor" in results else None
print(f"{'rounding':<20} {'time (us)':>10} {'int64_t in kernel':>18} {'float rounding':>15} {'same output as floor':>21}")
for r, (us, o, n64, fround) in results.items():
    same = "-" if floor_out is None or r == "floor" else str(bool(np.array_equal(o, floor_out)))
    print(f"{r:<20} {us:10.2f} {n64:18d} {str(fround):>15} {same:>21}")

# Save outputs so a stock-vs-patched run can verify bit-identical results.
out_path = os.environ.get("SAVE_OUT")
if out_path:
    np.savez(out_path, **{r: o for r, (_, o, _, _) in results.items()})
    print(f"\nsaved outputs -> {out_path}")

Run it once per TVM build, e.g.:

for V in v0.23.0 v0.24.0; do
  PYTHONPATH=/path/to/tvm-$V/python python repro_resize2d.py
done
# current main (NVRTC by default; TVM_CUDA_COMPILER=nvcc to use nvcc instead)
SAVE_OUT=main.npz python repro_resize2d.py

For clock-invariant numbers, wrap the same run in ncu --metrics gpu__time_duration.sum.

Triage

  • needs-triage
  • topi
  • performance

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions