Skip to content

Commit e9bd579

Browse files
committed
Decompose antialiased resizes in the CUDA backend
F.interpolate(x, mode="bilinear" or "bicubic", antialias=True) becomes aten._upsample_bilinear2d_aa or aten._upsample_bicubic2d_aa. Inductor has no lowering for either, so it calls ATen's kernel, and AOTInductor has no C shim for them. Lowering to the CUDA backend failed with "missing fallback kernels". Image preprocessing often does this. The backend already rewrites ops like this in get_decomposition_table (conv1d as conv2d, for example), so this adds the bilinear and bicubic antialiased resizes there, for float32, float16 and bfloat16 inputs. The backend has no float64, and ATen's CUDA kernel has no integer version. An antialiased resize is separable, so the rewrite resizes the width, then the height. Along each, it computes every output's window and weights with ATen's formulas: the filter widened by the downscale factor, cut to its support, and normalized. Then it sums the window's inputs with those weights. Taps outside the window have zero weight and are masked out before the sum, so a NaN or inf reaches only the outputs whose window holds it, and the work grows with the window size, not the input size. Where a window edge falls exactly on an input, float rounding can put that input in or out of the window differently from ATen; its weight there is zero or close to it. Symbolic shape math keeps dynamic input and output sizes working. The number of taps per output is a shape, so it is computed with integers: in float32 the generated kernel can round a ratio such as 21 / 7 just above 3 and count more taps than the host allocated. For align_corners, the ratio for an output size of 1 comes from min and max, not a conditional, because torch.compile cannot handle a conditional size expression. The result keeps the input's memory format. The .vec overloads reuse torch's decomposition. The rewrite computes positions and weights in float32 with its own rounding, so its float32 results are not bit-identical to ATen's kernel: for inputs in [0, 1) they stayed within 2e-5 of it on the sizes tried. Half and bfloat16 inputs are computed in float and rounded once at the end, so they can differ slightly from ATen, which keeps its weights in the input type. The tests are in a new file, test_antialiased_resize.py, because they run programs through the Python runtime. They lower bilinear and bicubic resizes whose input and output heights are dynamic, run them for heights that shrink, grow, go to 1, and have whole ratios, with a NaN in the input, and compare with eager CUDA. Other cases cover an uneven scale_factor, float16 and bfloat16 with a channels_last input, the rewrite's memory format, and torch.compile of an align_corners resize with dynamic sizes.
1 parent 7829c5a commit e9bd579

2 files changed

Lines changed: 296 additions & 0 deletions

File tree

‎backends/cuda/cuda_backend.py‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import copy
1010
import functools
1111
import logging
12+
import math
1213
import os
1314
import shutil
1415
import threading
@@ -50,7 +51,9 @@
5051
from executorch.exir._warnings import experimental
5152
from executorch.exir.backend.backend_details import BackendDetails, PreprocessResult
5253
from executorch.exir.backend.compile_spec_schema import CompileSpec
54+
from torch._decomp import decomposition_table
5355
from torch._inductor.decomposition import conv1d_to_conv2d
56+
from torch._prims_common import suggest_memory_format
5457
from torch.nn.attention import SDPBackend
5558

5659
# ---------------------------------------------------------------------------
@@ -548,6 +551,117 @@ def _conv_transpose_as_conv(
548551
return conv(x, weight, bias, [1] * n, [0] * n, dilation, groups)
549552

550553

554+
def _linear_kernel(x: torch.Tensor) -> torch.Tensor:
555+
return torch.clamp(1 - x.abs(), min=0)
556+
557+
558+
def _cubic_kernel(x: torch.Tensor) -> torch.Tensor:
559+
# Keys' cubic with a = -0.5, the one ATen's antialiased bicubic uses.
560+
a = -0.5
561+
x = x.abs()
562+
near = ((a + 2) * x - (a + 3)) * x * x + 1
563+
far = (((x - 5) * x + 8) * x - 4) * a
564+
return torch.where(x < 1, near, torch.where(x < 2, far, torch.zeros_like(x)))
565+
566+
567+
def _antialias_taps(
568+
in_size: int,
569+
out_size: int,
570+
align_corners: bool,
571+
scale: Optional[float],
572+
support: int,
573+
kernel: typing.Callable[[torch.Tensor], torch.Tensor],
574+
device: torch.device,
575+
) -> typing.Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
576+
"""
577+
Taps of an antialiased resize along one dim, computed as ATen does:
578+
output o reads input index[o, k] with weight[o, k], and inside[o, k]
579+
marks the taps in its window. The window is the kernel's support widened
580+
by the downscale factor, and the weights in it are normalized.
581+
"""
582+
if align_corners or scale is None or scale <= 0:
583+
if align_corners:
584+
# ATen uses a ratio of 0 for an output size of 1. min and max give
585+
# that without a conditional, which torch.compile cannot handle for
586+
# dynamic sizes.
587+
num = (in_size - 1) * torch.sym_min(out_size - 1, 1)
588+
den = torch.sym_max(out_size - 1, 1)
589+
else:
590+
num, den = in_size, out_size
591+
ratio = num / den
592+
# The tap count is a shape that both the host and the kernel work out.
593+
# In float32 the kernel can round num / den just past a whole number
594+
# and count more taps than the host allocated, so use integers.
595+
reach = torch.sym_max((support * num + den - 1) // den, support)
596+
else:
597+
ratio = 1.0 / scale
598+
reach = math.ceil(support * max(ratio, 1.0))
599+
widen = torch.sym_max(ratio, 1.0)
600+
dtype = torch.float32
601+
center = ratio * (torch.arange(out_size, dtype=dtype, device=device) + 0.5)
602+
first = torch.clamp(torch.trunc(center - support * widen + 0.5), min=0)
603+
end = torch.clamp(torch.trunc(center + support * widen + 0.5), max=in_size)
604+
taps = 2 * reach + 1
605+
tap = torch.arange(taps, dtype=dtype, device=device)
606+
position = first[:, None] + tap
607+
inside = position < end[:, None]
608+
weights = kernel((tap + (first - center)[:, None] + 0.5) * (1.0 / widen))
609+
weights = torch.where(inside, weights, 0)
610+
total = weights.sum(1, keepdim=True)
611+
weights = torch.where(total != 0, weights / total, weights)
612+
# Clamp after the cast: in float32, in_size - 1 rounds up to in_size for
613+
# very long axes.
614+
index = position.to(torch.int64).clamp(max=in_size - 1)
615+
return index, weights, inside
616+
617+
618+
def _antialiased_resize(
619+
support: int,
620+
kernel: typing.Callable[[torch.Tensor], torch.Tensor],
621+
input: torch.Tensor,
622+
output_size: List[int],
623+
align_corners: bool,
624+
scales_h: Optional[float] = None,
625+
scales_w: Optional[float] = None,
626+
) -> torch.Tensor:
627+
"""
628+
Rewrite an antialiased bilinear or bicubic resize as a weighted sum over
629+
each output's taps, one dim at a time. Inductor has no lowering for these
630+
resizes, so it would call ATen's kernel, which this backend does not ship.
631+
"""
632+
# The backend's tensors have no float64, and ATen's CUDA kernel has no
633+
# integer version.
634+
if input.dtype not in (torch.float32, torch.float16, torch.bfloat16):
635+
return NotImplemented
636+
# Half types are computed in float and rounded once, so they can differ
637+
# slightly from ATen, which keeps its weights in the input type.
638+
result = input.float()
639+
for dim, scale in ((3, scales_w), (2, scales_h)):
640+
index, weights, inside = _antialias_taps(
641+
input.shape[dim],
642+
output_size[dim - 2],
643+
align_corners,
644+
scale,
645+
support,
646+
kernel,
647+
input.device,
648+
)
649+
rows, taps = index.shape
650+
trailing = [1] * (3 - dim)
651+
gathered = result.index_select(dim, index.flatten()).unflatten(
652+
dim, (rows, taps)
653+
)
654+
# Taps outside the window already have zero weight, but NaN * 0 is NaN,
655+
# so mask them out to keep a NaN or inf local, as in ATen.
656+
weighted = torch.where(
657+
inside.view(rows, taps, *trailing),
658+
gathered * weights.view(rows, taps, *trailing),
659+
0,
660+
)
661+
result = weighted.sum(dim + 1)
662+
return result.to(input.dtype).contiguous(memory_format=suggest_memory_format(input))
663+
664+
551665
@final
552666
@experimental(
553667
"This API and all of cuda backend related functionality are experimental."
@@ -862,6 +976,15 @@ def get_decomposition_table(cls) -> Dict[Any, Any]:
862976
# for it and the shim no fallback kernel.
863977
torch.ops.aten._is_all_true.default: torch.ops.aten.all.default,
864978
}
979+
for resize, support, kernel in (
980+
(torch.ops.aten._upsample_bilinear2d_aa, 1, _linear_kernel),
981+
(torch.ops.aten._upsample_bicubic2d_aa, 2, _cubic_kernel),
982+
):
983+
table[resize.default] = functools.partial(
984+
_antialiased_resize, support, kernel
985+
)
986+
# The vec form works out the output size and calls the default form.
987+
table[resize.vec] = decomposition_table[resize.vec]
865988
# Inductor disables its transposed conv2d Triton template only on CUDA
866989
# (https://github.com/pytorch/pytorch/issues/187081).
867990
if torch.version.hip is None:
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
"""Antialiased bilinear and bicubic resizes on the CUDA backend.
8+
9+
Inductor has no lowering for these resizes, so the backend rewrites them into
10+
plain tensor math. These tests lower real programs, run them through the Python
11+
runtime, and compare with eager PyTorch.
12+
"""
13+
14+
import functools
15+
import os
16+
import tempfile
17+
import unittest
18+
from typing import Optional, Sequence, Tuple
19+
20+
import torch
21+
from executorch.backends.cuda.cuda_backend import CudaBackend
22+
from executorch.backends.cuda.cuda_partitioner import CudaPartitioner
23+
from executorch.exir import EdgeCompileConfig, to_edge_transform_and_lower
24+
from torch.export import Dim, export
25+
26+
27+
class Resize(torch.nn.Module):
28+
"""Resizes x by scale_factor, or else to like's height and a width of 56."""
29+
30+
def __init__(
31+
self,
32+
mode: str,
33+
align_corners: bool = False,
34+
scale_factor: Optional[Tuple[float, float]] = None,
35+
) -> None:
36+
super().__init__()
37+
self.mode = mode
38+
self.align_corners = align_corners
39+
self.scale_factor = scale_factor
40+
41+
def forward(self, x: torch.Tensor, like: torch.Tensor) -> torch.Tensor:
42+
size = None if self.scale_factor else (like.shape[2], 56)
43+
return torch.nn.functional.interpolate(
44+
x,
45+
size=size,
46+
scale_factor=self.scale_factor,
47+
mode=self.mode,
48+
align_corners=self.align_corners,
49+
antialias=True,
50+
)
51+
52+
53+
# Dynamic heights let one program shrink, grow, and resize to 1.
54+
DYNAMIC_HEIGHTS = (
55+
{2: Dim("in_height", min=4, max=64)},
56+
{2: Dim("out_height", min=1, max=32)},
57+
)
58+
59+
60+
@unittest.skipUnless(torch.cuda.is_available(), "requires CUDA")
61+
@unittest.skipIf(
62+
torch.version.hip is not None,
63+
"loads through the Python runtime, which the ROCm build does not include",
64+
)
65+
class TestAntialiasedResize(unittest.TestCase):
66+
def _run(
67+
self,
68+
model: Resize,
69+
cases: Sequence[Tuple[torch.Tensor, torch.Tensor]],
70+
dynamic_shapes=None,
71+
**tolerance: float,
72+
) -> None:
73+
"""Lower model for cases[0], then run every case and compare with eager."""
74+
exported = export(model, cases[0], dynamic_shapes=dynamic_shapes)
75+
spec = CudaBackend.generate_method_name_compile_spec("forward")
76+
lowered = to_edge_transform_and_lower(
77+
exported,
78+
partitioner=[CudaPartitioner([spec])],
79+
compile_config=EdgeCompileConfig(_check_ir_validity=False),
80+
)
81+
from executorch.runtime import Runtime
82+
83+
with tempfile.TemporaryDirectory() as directory:
84+
path = os.path.join(directory, "model.pte")
85+
with open(path, "wb") as f:
86+
lowered.to_executorch().write_to_file(f)
87+
method = Runtime.get().load_program(path).load_method("forward")
88+
for x, like in cases:
89+
(output,) = method.execute([x, like])
90+
# The rewrite stands in for ATen's CUDA kernel.
91+
expected = model(x.cuda().float(), like.cuda()).to(x.dtype).cpu()
92+
torch.testing.assert_close(
93+
output.cpu(), expected, equal_nan=True, **tolerance
94+
)
95+
96+
def _check_dynamic_heights(self, mode: str, align_corners: bool) -> None:
97+
cases = []
98+
# 21 to 7 and 22 to 8 give whole ratios, with and without align_corners.
99+
heights = ((48, 20), (64, 20), (10, 20), (10, 1), (21, 7), (22, 8))
100+
for in_height, out_height in heights:
101+
x = torch.randn(1, 3, in_height, 32)
102+
# As in ATen, a NaN reaches only the outputs whose window has it.
103+
x[0, 0, -1, -1] = float("nan")
104+
cases.append((x, torch.empty(1, 1, out_height)))
105+
self._run(
106+
Resize(mode, align_corners),
107+
cases,
108+
DYNAMIC_HEIGHTS,
109+
atol=1e-5,
110+
rtol=1e-4,
111+
)
112+
113+
def test_bilinear_antialias_resize(self):
114+
self._check_dynamic_heights("bilinear", align_corners=False)
115+
116+
def test_bicubic_antialias_resize(self):
117+
self._check_dynamic_heights("bicubic", align_corners=True)
118+
119+
def test_antialias_resize_with_uneven_scale_factor(self):
120+
# 37 * 1.75 and 51 * 0.45 are not whole, so the scale the resize uses
121+
# differs from output size / input size.
122+
x = torch.randn(1, 3, 37, 51)
123+
self._run(
124+
Resize("bicubic", scale_factor=(1.75, 0.45)),
125+
[(x, torch.empty(0))],
126+
atol=1e-5,
127+
rtol=1e-4,
128+
)
129+
130+
def test_half_precision_antialias_resize(self):
131+
for dtype in (torch.float16, torch.bfloat16):
132+
with self.subTest(dtype=dtype):
133+
x = torch.randn(1, 3, 48, 32).to(
134+
dtype=dtype, memory_format=torch.channels_last
135+
)
136+
self._run(
137+
Resize("bilinear"),
138+
[(x, torch.empty(1, 1, 20))],
139+
atol=1e-2,
140+
rtol=1e-2,
141+
)
142+
143+
144+
@unittest.skipUnless(torch.cuda.is_available(), "requires CUDA")
145+
class TestAntialiasedResizeDecomposition(unittest.TestCase):
146+
def test_keeps_memory_format(self):
147+
# The program's output layout is fixed at export, so only a direct call
148+
# shows the rewrite's own layout.
149+
resize = CudaBackend.get_decomposition_table()[
150+
torch.ops.aten._upsample_bilinear2d_aa.default
151+
]
152+
x = torch.randn(1, 3, 48, 32, device="cuda")
153+
output = resize(x.to(memory_format=torch.channels_last), [20, 56], False)
154+
self.assertTrue(output.is_contiguous(memory_format=torch.channels_last))
155+
156+
def test_align_corners_compiles_with_dynamic_sizes(self):
157+
from torch._inductor.compile_fx import compile_fx
158+
159+
model = Resize("bicubic", align_corners=True)
160+
compiled = torch.compile(
161+
model,
162+
backend=functools.partial(
163+
compile_fx, decompositions=CudaBackend.get_decomposition_table()
164+
),
165+
dynamic=True,
166+
fullgraph=True,
167+
)
168+
for in_height, out_height in ((48, 20), (10, 1)):
169+
x = torch.randn(1, 3, in_height, 32, device="cuda")
170+
like = torch.empty(1, 1, out_height, device="cuda")
171+
torch.testing.assert_close(
172+
compiled(x, like), model(x, like), atol=1e-5, rtol=1e-4
173+
)

0 commit comments

Comments
 (0)