Repository: cupy/cupy

Issue:
`cupyx.scipy.ndimage.rotate()` saturates P100's 16G memory for 1G input data
We discovered this out-of-memory (OOM) issue in a recent GPU Hackathon with @jqyin and @dxm447, and I'm revisiting this. 

The implementation along the call path `cupyx.scipy.ndimage.rotate()` -> `affine_transform()` -> `map_coordinates()` is very memory-inefficient. The memory inflation from a 1G input to over 16G on a P100 as shown in the following example (extracted from a real-life workload) is not acceptable.
```python
>>> import cupy as cp
>>> from cupyx.scipy.ndimage import rotate
>>>
>>> a = cp.random.random((512, 512, 512))  # 1GB data
>>> b = rotate(a, 15, axes=(1,2))
/home/leofang/test/cupy2/cupyx/scipy/ndimage/interpolation.py:27: UserWarning: In the current feature the default order of rotate is 1. It is different from scipy.ndimage and can change in the future.
  'the future.'.format(func_name))
/home/leofang/test/cupy2/cupyx/scipy/ndimage/interpolation.py:27: UserWarning: In the current feature the default order of affine_transform is 1. It is different from scipy.ndimage and can change in the future.
  'the future.'.format(func_name))
/home/leofang/test/cupy2/cupyx/scipy/ndimage/interpolation.py:27: UserWarning: In the current feature the default order of map_coordinates is 1. It is different from scipy.ndimage and can change in the future.
  'the future.'.format(func_name))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/leofang/test/cupy2/cupyx/scipy/ndimage/interpolation.py", line 351, in rotate
    mode, cval, prefilter)
  File "/home/leofang/test/cupy2/cupyx/scipy/ndimage/interpolation.py", line 242, in affine_transform
    prefilter).reshape(output_shape)
  File "/home/leofang/test/cupy2/cupyx/scipy/ndimage/interpolation.py", line 112, in map_coordinates
    coordinates_floor = cupy.floor(coordinates).astype(cupy.int32)
  File "cupy/core/core.pyx", line 320, in cupy.core.core.ndarray.astype
  File "cupy/core/core.pyx", line 378, in cupy.core.core.ndarray.astype
  File "cupy/core/core.pyx", line 134, in cupy.core.core.ndarray.__init__
  File "cupy/cuda/memory.pyx", line 540, in cupy.cuda.memory.alloc
  File "cupy/cuda/memory.pyx", line 1186, in cupy.cuda.memory.MemoryPool.malloc
  File "cupy/cuda/memory.pyx", line 1207, in cupy.cuda.memory.MemoryPool.malloc
  File "cupy/cuda/memory.pyx", line 994, in cupy.cuda.memory.SingleDeviceMemoryPool.malloc
  File "cupy/cuda/memory.pyx", line 1009, in cupy.cuda.memory.SingleDeviceMemoryPool._malloc
  File "cupy/cuda/memory.pyx", line 743, in cupy.cuda.memory._try_malloc
cupy.cuda.memory.OutOfMemoryError: Out of memory allocating 2,415,384,576 bytes (allocated so far: 15,566,063,104 bytes).
```
Using `pdb` I was able to spot that the biggest memory consumer is in `affine_transform()`:
https://github.com/cupy/cupy/blob/93547cedd3a87074e42edda5e88922c22e5f04b4/cupyx/scipy/ndimage/interpolation.py#L237
which alone took about 4.5G (=3\*512\*627\*627\*8 bytes). 

Another inefficiency I could tell that contributes to OOM is also in `affine_transform()`: 
https://github.com/cupy/cupy/blob/93547cedd3a87074e42edda5e88922c22e5f04b4/cupyx/scipy/ndimage/interpolation.py#L240-L242
which shows that an output buffer `ret` is allocated but not passed into `map_coordinates()`, so in `map_coordinates()` yet another buffer `ret` is allocated:  
https://github.com/cupy/cupy/blob/93547cedd3a87074e42edda5e88922c22e5f04b4/cupyx/scipy/ndimage/interpolation.py#L91
But even if this is fixed, I still got OOM in `map_coordinates()` here:
https://github.com/cupy/cupy/blob/93547cedd3a87074e42edda5e88922c22e5f04b4/cupyx/scipy/ndimage/interpolation.py#L125 
At this point I am not sure what's the best strategy to proceed, so I thought it's better to pause and report back.

Our current workaround is to batch process the images along the first axis, which is OK but less efficient.


Discussion:
Just noticed and tested @not522's #1168, and unfortunately it doesn't help resolve the OOM either...
Thanks for the detailed report!
SciPy roughly used 2.5 GB including the original 1GB array for the same operation.
We definitely need optimization of memory usage here...
Thank you. My code of #1168 is buggy and outdated. I'll try to write smarter implementation.

Resolving patch:
diff --git a/cupyx/scipy/ndimage/_interp_kernels.py b/cupyx/scipy/ndimage/_interp_kernels.py
new file mode 100644
--- /dev/null
+++ b/cupyx/scipy/ndimage/_interp_kernels.py
@@ -0,0 +1,407 @@
+import cupy
+import cupy.core.internal
+
+from cupyx.scipy.ndimage import filters
+
+
+def _get_coord_map(ndim):
+    """Extract target coordinate from coords array (for map_coordinates).
+
+    Notes
+    -----
+    Assumes the following variables have been initialized on the device::
+
+        coords (ndarray): array of shape (ncoords, ndim) containing the target
+            coordinates.
+        c_j: variables to hold the target coordinates
+
+    computes::
+
+        c_j = coords[i + j * ncoords];
+
+    ncoords is determined by the size of the output array, y.
+    y will be indexed by the CIndexer, _ind.
+    Thus ncoords = _ind.size();
+
+    """
+    ops = []
+    ops.append('ptrdiff_t ncoords = _ind.size();')
+    for j in range(ndim):
+        ops.append(
+            """
+    W c_{j} = coords[i + {j} * ncoords];""".format(j=j))
+    return ops
+
+
+def _get_coord_zoom_and_shift(ndim):
+    """Compute target coordinate based on a shift followed by a zoom.
+
+    Notes
+    -----
+    Assumes the following variables have been initialized on the device::
+
+        in_coord[ndim]: array containing the source coordinate
+        zoom[ndim]: array containing the zoom for each axis
+        shift[ndim]: array containing the zoom for each axis
+
+    computes::
+
+        c_j = zoom[j] * (in_coord[j] - shift[j])
+
+    """
+    ops = []
+    for j in range(ndim):
+        ops.append(
+            """
+    W c_{j} = zoom[{j}] * ((W)in_coord[{j}] - shift[{j}]);""".format(j=j))
+    return ops
+
+
+def _get_coord_zoom(ndim):
+    """Compute target coordinate based on a zoom.
+
+    Notes
+    -----
+    Assumes the following variables have been initialized on the device::
+
+        in_coord[ndim]: array containing the source coordinate
+        zoom[ndim]: array containing the zoom for each axis
+
+    computes::
+
+        c_j = zoom[j] * in_coord[j]
+
+    """
+    ops = []
+    for j in range(ndim):
+        ops.append(
+            """
+    W c_{j} = zoom[{j}] * (W)in_coord[{j}];""".format(j=j))
+    return ops
+
+
+def _get_coord_shift(ndim):
+    """Compute target coordinate based on a shift.
+
+    Notes
+    -----
+    Assumes the following variables have been initialized on the device::
+
+        in_coord[ndim]: array containing the source coordinate
+        shift[ndim]: array containing the zoom for each axis
+
+    computes::
+
+        c_j = in_coord[j] - shift[j]
+
+    """
+    ops = []
+    for j in range(ndim):
+        ops.append(
+            """
+    W c_{j} = (W)in_coord[{j}] - shift[{j}];""".format(j=j))
+    return ops
+
+
+def _get_coord_affine(ndim):
+    """Compute target coordinate based on a homogeneous transformation matrix.
+
+    The homogeneous matrix has shape (ndim, ndim + 1). It corresponds to
+    affine matrix where the last row of the affine is assumed to be:
+    ``[0] * ndim + [1]``.
+
+    Notes
+    -----
+    Assumes the following variables have been initialized on the device::
+
+        mat(array): array containing the (ndim, ndim + 1) transform matrix.
+        in_coords(array): coordinates of the input
+
+    For example, in 2D:
+
+        c_0 = mat[0] * in_coords[0] + mat[1] * in_coords[1] + aff[2];
+        c_1 = mat[3] * in_coords[0] + mat[4] * in_coords[1] + aff[5];
+
+    """
+    ops = []
+    ncol = ndim + 1
+    for j in range(ndim):
+        ops.append("""
+            W c_{j} = (W)0.0;
+            """.format(j=j))
+        for k in range(ndim):
+            m_index = ncol * j + k
+            ops.append(
+                """
+            c_{j} += mat[{m_index}] * (W)in_coord[{k}];""".format(
+                    j=j, k=k, m_index=m_index))
+        ops.append(
+            """
+            c_{j} += mat[{m_index}];""".format(
+                j=j, m_index=ncol * j + ndim))
+    return ops
+
+
+def _unravel_loop_index(shape, uint_t='unsigned int'):
+    """
+    declare a multi-index array in_coord and unravel the 1D index, i into it.
+    This code assumes that the array is a C-ordered array.
+    """
+    ndim = len(shape)
+    code = [
+        """
+        {uint_t} in_coord[{ndim}];
+        {uint_t} s, t, idx = i;""".format(uint_t=uint_t, ndim=ndim)]
+    for j in range(ndim - 1, 0, -1):
+        code.append("""
+        s = {size};
+        t = idx / s;
+        in_coord[{j}] = idx - t * s;
+        idx = t;""".format(j=j, size=shape[j]))
+    code.append("""
+        in_coord[0] = idx;""")
+    return '\n'.join(code)
+
+
+def _generate_interp_custom(coord_func, ndim, large_int, yshape, mode, cval,
+                            order, name='', integer_output=False):
+    """
+    Args:
+        coord_func (function): generates code to do the coordinate
+            transformation. See for example, `_get_coord_shift`.
+        ndim (int): The number of dimensions.
+        large_int (bool): If true use Py_ssize_t instead of int for indexing.
+        yshape (tuple): Shape of the output array.
+        mode (str): Signal extension mode to use at the array boundaries
+        cval (float): constant value used when `mode == 'constant'`.
+        name (str): base name for the interpolation kernel
+        integer_output (bool): boolean indicating whether the output has an
+            integer type.
+
+    Returns:
+        operation (str): code body for the ElementwiseKernel
+        name (str): name for the ElementwiseKernel
+    """
+
+    ops = []
+    ops.append('double out = 0.0;')
+
+    if large_int:
+        uint_t = 'size_t'
+        int_t = 'ptrdiff_t'
+    else:
+        uint_t = 'unsigned int'
+        int_t = 'int'
+
+    # determine strides for x along each axis
+    for j in range(ndim):
+        ops.append(
+            'const {int_t} xsize_{j} = x.shape()[{j}];'.format(
+                int_t=int_t, j=j)
+        )
+    ops.append('const {uint_t} sx_{j} = 1;'.format(uint_t=uint_t, j=ndim - 1))
+    for j in range(ndim - 1, 0, -1):
+        ops.append(
+            'const {uint_t} sx_{jm} = sx_{j} * xsize_{j};'.format(
+                uint_t=uint_t, jm=j - 1, j=j,
+            )
+        )
+
+    # create in_coords array to store the unraveled indices
+    ops.append(_unravel_loop_index(yshape, uint_t))
+
+    # compute the transformed (target) coordinates, c_j
+    ops = ops + coord_func(ndim)
+
+    if mode == 'constant':
+        # use cval if coordinate is outside the bounds of x
+        _cond = ' || '.join(
+            ['(c_{j} < 0) || (c_{j} > xsize_{j} - 1)'.format(j=j)
+             for j in range(ndim)])
+        ops.append("""
+        if ({cond})
+        {{
+            out = (double){cval};
+        }}
+        else
+        {{""".format(cond=_cond, cval=cval))
+
+    if order == 0:
+        for j in range(ndim):
+            # determine nearest neighbor
+            ops.append("""
+            {int_t} cf_{j} = ({int_t})lrint((double)c_{j});
+            """.format(int_t=int_t, j=j))
+
+            # handle boundary
+            if mode != 'constant':
+                ixvar = 'cf_{j}'.format(j=j)
+                ops.append(
+                    filters._generate_boundary_condition_ops(
+                        mode, ixvar, 'xsize_{}'.format(j)))
+
+            # sum over ic_j will give the raveled coordinate in the input
+            ops.append("""
+            {int_t} ic_{j} = cf_{j} * sx_{j};
+            """.format(int_t=int_t, j=j))
+        _coord_idx = ' + '.join(['ic_{}'.format(j) for j in range(ndim)])
+        ops.append("""
+            out = x[{coord_idx}];""".format(coord_idx=_coord_idx))
+
+    elif order == 1:
+        for j in range(ndim):
+            # get coordinates for linear interpolation along axis j
+            ops.append("""
+            {int_t} cf_{j} = ({int_t})floor((double)c_{j});
+            {int_t} cc_{j} = cf_{j} + 1;
+            {int_t} n_{j} = (c_{j} == cf_{j}) ? 1 : 2;  // points needed
+            """.format(int_t=int_t, j=j))
+
+            # handle boundaries for extension modes.
+            ops.append("""
+            {int_t} cf_bounded_{j} = cf_{j};
+            {int_t} cc_bounded_{j} = cc_{j};
+            """.format(int_t=int_t, j=j))
+            if mode != 'constant':
+                ixvar = 'cf_bounded_{j}'.format(j=j)
+                ops.append(
+                    filters._generate_boundary_condition_ops(
+                        mode, ixvar, 'xsize_{}'.format(j)))
+                ixvar = 'cc_bounded_{j}'.format(j=j)
+                ops.append(
+                    filters._generate_boundary_condition_ops(
+                        mode, ixvar, 'xsize_{}'.format(j)))
+
+            ops.append("""
+            for (int s_{j} = 0; s_{j} < n_{j}; s_{j}++)
+                {{
+                    W w_{j};
+                    {int_t} ic_{j};
+                    if (s_{j} == 0)
+                    {{
+                        w_{j} = (W)cc_{j} - c_{j};
+                        ic_{j} = cf_bounded_{j} * sx_{j};
+                    }} else
+                    {{
+                        w_{j} = c_{j} - (W)cf_{j};
+                        ic_{j} = cc_bounded_{j} * sx_{j};
+                    }}""".format(int_t=int_t, j=j))
+
+        _weight = ' * '.join(['w_{j}'.format(j=j) for j in range(ndim)])
+        _coord_idx = ' + '.join(['ic_{j}'.format(j=j) for j in range(ndim)])
+        ops.append("""
+        X val = x[{coord_idx}];
+        out += val * ({weight});""".format(
+            coord_idx=_coord_idx, weight=_weight))
+        ops.append('}' * ndim)
+
+    if mode == 'constant':
+        ops.append('}')
+
+    if integer_output:
+        ops.append('y = (Y)rint((double)out);')
+    else:
+        ops.append('y = (Y)out;')
+    operation = '\n'.join(ops)
+
+    name = 'interpolate_{}_order{}_{}_{}d_y{}'.format(
+        name, order, mode, ndim, "_".join(["{}".format(j) for j in yshape]),
+    )
+    if uint_t == 'size_t':
+        name += '_i64'
+    return operation, name
+
+
+@cupy.util.memoize(for_each_device=True)
+def _get_map_kernel(ndim, large_int, yshape, mode, cval=0.0, order=1,
+                    integer_output=False):
+    in_params = 'raw X x, raw W coords'
+    out_params = 'Y y'
+    operation, name = _generate_interp_custom(
+        coord_func=_get_coord_map,
+        ndim=ndim,
+        large_int=large_int,
+        yshape=yshape,
+        mode=mode,
+        cval=cval,
+        order=order,
+        name='shift',
+        integer_output=integer_output,
+    )
+    return cupy.ElementwiseKernel(in_params, out_params, operation, name)
+
+
+@cupy.util.memoize(for_each_device=True)
+def _get_shift_kernel(ndim, large_int, yshape, mode, cval=0.0, order=1,
+                      integer_output=False):
+    in_params = 'raw X x, raw W shift'
+    out_params = 'Y y'
+    operation, name = _generate_interp_custom(
+        coord_func=_get_coord_shift,
+        ndim=ndim,
+        large_int=large_int,
+        yshape=yshape,
+        mode=mode,
+        cval=cval,
+        order=order,
+        name='shift',
+        integer_output=integer_output,
+    )
+    return cupy.ElementwiseKernel(in_params, out_params, operation, name)
+
+
+@cupy.util.memoize(for_each_device=True)
+def _get_zoom_shift_kernel(ndim, large_int, yshape, mode, cval=0.0, order=1,
+                           integer_output=False):
+    in_params = 'raw X x, raw W shift, raw W zoom'
+    out_params = 'Y y'
+    operation, name = _generate_interp_custom(
+        coord_func=_get_coord_zoom_and_shift,
+        ndim=ndim,
+        large_int=large_int,
+        yshape=yshape,
+        mode=mode,
+        cval=cval,
+        order=order,
+        name='zoom_shift',
+        integer_output=integer_output,
+    )
+    return cupy.ElementwiseKernel(in_params, out_params, operation, name)
+
+
+@cupy.util.memoize(for_each_device=True)
+def _get_zoom_kernel(ndim, large_int, yshape, mode, cval=0.0, order=1,
+                     integer_output=False):
+    in_params = 'raw X x, raw W zoom'
+    out_params = 'Y y'
+    operation, name = _generate_interp_custom(
+        coord_func=_get_coord_zoom,
+        ndim=ndim,
+        large_int=large_int,
+        yshape=yshape,
+        mode=mode,
+        cval=cval,
+        order=order,
+        name='zoom',
+        integer_output=integer_output,
+    )
+    return cupy.ElementwiseKernel(in_params, out_params, operation, name)
+
+
+@cupy.util.memoize(for_each_device=True)
+def _get_affine_kernel(ndim, large_int, yshape, mode, cval=0.0, order=1,
+                       integer_output=False):
+    in_params = 'raw X x, raw W mat'
+    out_params = 'Y y'
+    operation, name = _generate_interp_custom(
+        coord_func=_get_coord_affine,
+        ndim=ndim,
+        large_int=large_int,
+        yshape=yshape,
+        mode=mode,
+        cval=cval,
+        order=order,
+        name='affine',
+        integer_output=integer_output,
+    )
+    return cupy.ElementwiseKernel(in_params, out_params, operation, name)
diff --git a/cupyx/scipy/ndimage/interpolation.py b/cupyx/scipy/ndimage/interpolation.py
--- a/cupyx/scipy/ndimage/interpolation.py
+++ b/cupyx/scipy/ndimage/interpolation.py
@@ -1,8 +1,12 @@
-import itertools
 import math
 import warnings
 
 import cupy
+import numpy
+
+from cupyx.scipy.ndimage import _interp_kernels
+
+_prod = cupy.core.internal.prod
 
 
 def _get_output(output, input, shape=None):
@@ -88,66 +92,16 @@ def map_coordinates(input, coordinates, output=None, order=None,
         mode = 'constant'
 
     ret = _get_output(output, input, coordinates.shape[1:])
-
-    if mode == 'nearest':
-        for i in range(input.ndim):
-            coordinates[i] = coordinates[i].clip(0, input.shape[i] - 1)
-    elif mode == 'mirror':
-        for i in range(input.ndim):
-            length = input.shape[i] - 1
-            if length == 0:
-                coordinates[i] = 0
-            else:
-                coordinates[i] = cupy.remainder(coordinates[i], 2 * length)
-                coordinates[i] = 2 * cupy.minimum(
-                    coordinates[i], length) - coordinates[i]
+    integer_output = ret.dtype.kind in 'iu'
 
     if input.dtype.kind in 'iu':
         input = input.astype(cupy.float32)
 
-    if order == 0:
-        out = input[tuple(cupy.rint(coordinates).astype(cupy.int32))]
-    else:
-        coordinates_floor = cupy.floor(coordinates).astype(cupy.int32)
-        coordinates_ceil = coordinates_floor + 1
-
-        sides = []
-        for i in range(input.ndim):
-            # TODO(mizuno): Use array_equal after it is implemented
-            if cupy.all(coordinates[i] == coordinates_floor[i]):
-                sides.append([0])
-            else:
-                sides.append([0, 1])
-
-        out = cupy.zeros(coordinates.shape[1:], dtype=input.dtype)
-        if input.dtype in (cupy.float64, cupy.complex128):
-            weight = cupy.empty(coordinates.shape[1:], dtype=cupy.float64)
-        else:
-            weight = cupy.empty(coordinates.shape[1:], dtype=cupy.float32)
-        for side in itertools.product(*sides):
-            weight.fill(1)
-            ind = []
-            for i in range(input.ndim):
-                if side[i] == 0:
-                    ind.append(coordinates_floor[i])
-                    weight *= coordinates_ceil[i] - coordinates[i]
-                else:
-                    ind.append(coordinates_ceil[i])
-                    weight *= coordinates[i] - coordinates_floor[i]
-            out += input[ind] * weight
-        del weight
-
-    if mode == 'constant':
-        mask = cupy.zeros(coordinates.shape[1:], dtype=cupy.bool_)
-        for i in range(input.ndim):
-            mask += coordinates[i] < 0
-            mask += coordinates[i] > input.shape[i] - 1
-        out[mask] = cval
-        del mask
-
-    if ret.dtype.kind in 'iu':
-        out = cupy.rint(out)
-    ret[:] = out
+    large_int = max(_prod(input.shape), coordinates.shape[0]) > 1 << 31
+    kern = _interp_kernels._get_map_kernel(
+        input.ndim, large_int, yshape=coordinates.shape, mode=mode, cval=cval,
+        order=order, integer_output=integer_output)
+    kern(input, coordinates, ret)
     return ret
 
 
@@ -209,15 +163,17 @@ def affine_transform(input, matrix, offset=0.0, output_shape=None, output=None,
     if not hasattr(offset, '__iter__') and type(offset) is not cupy.ndarray:
         offset = [offset] * input.ndim
 
-    if matrix.ndim == 1:
-        # TODO(mizuno): Implement zoom_shift
-        matrix = cupy.diag(matrix)
-    elif matrix.shape[0] == matrix.shape[1] - 1:
-        offset = matrix[:, -1]
-        matrix = matrix[:, :-1]
-    elif matrix.shape[0] == input.ndim + 1:
-        offset = matrix[:-1, -1]
-        matrix = matrix[:-1, :-1]
+    if matrix.ndim not in [1, 2] or matrix.shape[0] < 1:
+        raise RuntimeError('no proper affine matrix provided')
+    if matrix.ndim == 2:
+        if matrix.shape[0] == matrix.shape[1] - 1:
+            offset = matrix[:, -1]
+            matrix = matrix[:, :-1]
+        elif matrix.shape[0] == input.ndim + 1:
+            offset = matrix[:-1, -1]
+            matrix = matrix[:-1, :-1]
+        if matrix.shape != (input.ndim, input.ndim):
+            raise RuntimeError("improper affine shape")
 
     if mode == 'opencv':
         m = cupy.zeros((input.ndim + 1, input.ndim + 1))
@@ -233,13 +189,32 @@ def affine_transform(input, matrix, offset=0.0, output_shape=None, output=None,
     if output_shape is None:
         output_shape = input.shape
 
-    coordinates = cupy.indices(output_shape, dtype=cupy.float64)
-    coordinates = cupy.dot(matrix, coordinates.reshape((input.ndim, -1)))
-    coordinates += cupy.expand_dims(cupy.asarray(offset), -1)
-    ret = _get_output(output, input, output_shape)
-    ret[:] = map_coordinates(input, coordinates, ret.dtype, order, mode, cval,
-                             prefilter).reshape(output_shape)
-    return ret
+    matrix = matrix.astype(cupy.float64, copy=False)
+    if order is None:
+        order = 1
+    ndim = input.ndim
+    output = _get_output(output, input, shape=output_shape)
+    if input.dtype.kind in 'iu':
+        input = input.astype(cupy.float32)
+
+    integer_output = output.dtype.kind in 'iu'
+    large_int = max(_prod(input.shape), _prod(output_shape)) > 1 << 31
+    if matrix.ndim == 1:
+        offset = cupy.asarray(offset, dtype=cupy.float64)
+        offset = -offset / matrix
+        kern = _interp_kernels._get_zoom_shift_kernel(
+            ndim, large_int, output_shape, mode, cval=cval, order=order,
+            integer_output=integer_output)
+        kern(input, offset, matrix, output)
+    else:
+        kern = _interp_kernels._get_affine_kernel(
+            ndim, large_int, output_shape, mode, cval=cval, order=order,
+            integer_output=integer_output)
+        m = cupy.zeros((ndim, ndim + 1), dtype=cupy.float64)
+        m[:, :-1] = matrix
+        m[:, -1] = cupy.asarray(offset, dtype=cupy.float64)
+        kern(input, m, output)
+    return output
 
 
 def _minmax(coor, minc, maxc):
@@ -295,56 +270,57 @@ def rotate(input, angle, axes=(1, 0), reshape=True, output=None, order=None,
     if mode == 'opencv':
         mode = '_opencv_edge'
 
+    input_arr = input
     axes = list(axes)
     if axes[0] < 0:
-        axes[0] += input.ndim
+        axes[0] += input_arr.ndim
     if axes[1] < 0:
-        axes[1] += input.ndim
+        axes[1] += input_arr.ndim
     if axes[0] > axes[1]:
-        axes = axes[1], axes[0]
-    if axes[0] < 0 or input.ndim <= axes[1]:
-        raise IndexError
+        axes = [axes[1], axes[0]]
+    if axes[0] < 0 or input_arr.ndim <= axes[1]:
+        raise ValueError('invalid rotation plane specified')
 
-    rad = cupy.deg2rad(angle)
+    ndim = input_arr.ndim
+    rad = numpy.deg2rad(angle)
     sin = math.sin(rad)
     cos = math.cos(rad)
 
-    matrix = cupy.identity(input.ndim)
+    # determine offsets and output shape as in scipy.ndimage.rotate
+    rot_matrix = numpy.array([[cos, sin],
+                              [-sin, cos]])
+
+    img_shape = numpy.asarray(input_arr.shape)
+    in_plane_shape = img_shape[axes]
+    if reshape:
+        # Compute transformed input bounds
+        iy, ix = in_plane_shape
+        out_bounds = rot_matrix @ [[0, 0, iy, iy],
+                                   [0, ix, 0, ix]]
+        # Compute the shape of the transformed input plane
+        out_plane_shape = (out_bounds.ptp(axis=1) + 0.5).astype(cupy.int64)
+    else:
+        out_plane_shape = img_shape[axes]
+
+    out_center = rot_matrix @ ((out_plane_shape - 1) / 2)
+    in_center = (in_plane_shape - 1) / 2
+
+    output_shape = img_shape
+    output_shape[axes] = out_plane_shape
+    output_shape = tuple(output_shape)
+
+    matrix = numpy.identity(ndim)
     matrix[axes[0], axes[0]] = cos
     matrix[axes[0], axes[1]] = sin
     matrix[axes[1], axes[0]] = -sin
     matrix[axes[1], axes[1]] = cos
 
     iy = input.shape[axes[0]]
-    ix = input.shape[axes[1]]
-    if reshape:
-        mtrx = cupy.array([[cos, sin], [-sin, cos]])
-        minc = [0, 0]
-        maxc = [0, 0]
-        coor = cupy.dot(mtrx, cupy.array([0, ix]))
-        minc, maxc = _minmax(coor, minc, maxc)
-        coor = cupy.dot(mtrx, cupy.array([iy, 0]))
-        minc, maxc = _minmax(coor, minc, maxc)
-        coor = cupy.dot(mtrx, cupy.array([iy, ix]))
-        minc, maxc = _minmax(coor, minc, maxc)
-        oy = int(maxc[0] - minc[0] + 0.5)
-        ox = int(maxc[1] - minc[1] + 0.5)
-    else:
-        oy = input.shape[axes[0]]
-        ox = input.shape[axes[1]]
-
-    offset = cupy.zeros(input.ndim)
-    offset[axes[0]] = oy / 2.0 - 0.5
-    offset[axes[1]] = ox / 2.0 - 0.5
-    offset = cupy.dot(matrix, offset)
-    tmp = cupy.zeros(input.ndim)
-    tmp[axes[0]] = iy / 2.0 - 0.5
-    tmp[axes[1]] = ix / 2.0 - 0.5
-    offset = tmp - offset
-
-    output_shape = list(input.shape)
-    output_shape[axes[0]] = oy
-    output_shape[axes[1]] = ox
+    offset = numpy.zeros(ndim, dtype=cupy.float64)
+    offset[axes] = in_center - out_center
+
+    matrix = cupy.asarray(matrix)
+    offset = cupy.asarray(offset)
 
     return affine_transform(input, matrix, offset, output_shape, output, order,
                             mode, cval, prefilter)
@@ -386,15 +362,37 @@ def shift(input, shift, output=None, order=None, mode='constant', cval=0.0,
 
     _check_parameter('shift', order, mode)
 
-    if mode == 'opencv':
-        mode = '_opencv_edge'
-
     if not hasattr(shift, '__iter__') and type(shift) is not cupy.ndarray:
         shift = [shift] * input.ndim
 
-    return affine_transform(input, cupy.ones(input.ndim, input.dtype),
-                            cupy.negative(cupy.asarray(shift)), None, output,
-                            order, mode, cval, prefilter)
+    if mode == 'opencv':
+        mode = '_opencv_edge'
+
+        output = affine_transform(
+            input,
+            cupy.ones(input.ndim, input.dtype),
+            cupy.negative(cupy.asarray(shift)),
+            None,
+            output,
+            order,
+            mode,
+            cval,
+            prefilter,
+        )
+    else:
+        if order is None:
+            order = 1
+        output = _get_output(output, input)
+        if input.dtype.kind in 'iu':
+            input = input.astype(cupy.float32)
+        integer_output = output.dtype.kind in 'iu'
+        large_int = _prod(input.shape) > 1 << 31
+        kern = _interp_kernels._get_shift_kernel(
+            input.ndim, large_int, input.shape, mode, cval=cval, order=order,
+            integer_output=integer_output)
+        shift = cupy.asarray(shift, dtype=cupy.float64)
+        kern(input, shift, output)
+    return output
 
 
 def zoom(input, zoom, output=None, order=None, mode='constant', cval=0.0,
@@ -436,6 +434,7 @@ def zoom(input, zoom, output=None, order=None, mode='constant', cval=0.0,
     output_shape = []
     for s, z in zip(input.shape, zoom):
         output_shape.append(int(round(s * z)))
+    output_shape = tuple(output_shape)
 
     if mode == 'opencv':
         zoom = []
@@ -448,14 +447,37 @@ def zoom(input, zoom, output=None, order=None, mode='constant', cval=0.0,
                 zoom.append(0)
                 offset.append(0)
         mode = 'nearest'
+
+        output = affine_transform(
+            input,
+            cupy.asarray(zoom),
+            offset,
+            output_shape,
+            output,
+            order,
+            mode,
+            cval,
+            prefilter,
+        )
     else:
+        if order is None:
+            order = 1
+
         zoom = []
         for in_size, out_size in zip(input.shape, output_shape):
             if out_size > 1:
                 zoom.append(float(in_size - 1) / (out_size - 1))
             else:
                 zoom.append(0)
-        offset = 0.0
 
-    return affine_transform(input, cupy.asarray(zoom), offset, output_shape,
-                            output, order, mode, cval, prefilter)
+        output = _get_output(output, input, shape=output_shape)
+        if input.dtype.kind in 'iu':
+            input = input.astype(cupy.float32)
+        integer_output = output.dtype.kind in 'iu'
+        large_int = max(_prod(input.shape), _prod(output_shape)) > 1 << 31
+        kern = _interp_kernels._get_zoom_kernel(
+            input.ndim, large_int, output_shape, mode, order=order,
+            integer_output=integer_output)
+        zoom = cupy.asarray(zoom, dtype=cupy.float64)
+        kern(input, zoom, output)
+    return output


Test patch:
diff --git a/tests/cupyx_tests/scipy_tests/ndimage_tests/test_interpolation.py b/tests/cupyx_tests/scipy_tests/ndimage_tests/test_interpolation.py
--- a/tests/cupyx_tests/scipy_tests/ndimage_tests/test_interpolation.py
+++ b/tests/cupyx_tests/scipy_tests/ndimage_tests/test_interpolation.py
@@ -1,6 +1,7 @@
 import unittest
 
 import numpy
+import pytest
 
 import cupy
 from cupy import testing
@@ -50,6 +51,15 @@ def test_map_coordinates_float(self, xp, scp, dtype):
         coordinates = testing.shaped_random((a.ndim, 100), xp, dtype)
         return self._map_coordinates(xp, scp, a, coordinates)
 
+    @testing.for_float_dtypes(no_float16=True)
+    @testing.numpy_cupy_allclose(atol=1e-5, scipy_name='scp')
+    def test_map_coordinates_fortran_order(self, xp, scp, dtype):
+        a = testing.shaped_random((100, 100), xp, dtype)
+        coordinates = testing.shaped_random((a.ndim, 100), xp, dtype)
+        a = xp.asfortranarray(a)
+        coordinates = xp.asfortranarray(coordinates)
+        return self._map_coordinates(xp, scp, a, coordinates)
+
     @testing.for_float_dtypes(no_float16=True)
     @testing.numpy_cupy_allclose(atol=1e-5, scipy_name='scp')
     def test_map_coordinates_float_nd_coords(self, xp, scp, dtype):
@@ -122,6 +132,15 @@ def test_affine_transform_float(self, xp, scp, dtype):
         matrix = testing.shaped_random(self.matrix_shape, xp, dtype)
         return self._affine_transform(xp, scp, a, matrix)
 
+    @testing.for_float_dtypes(no_float16=True)
+    @testing.numpy_cupy_allclose(atol=1e-5, scipy_name='scp')
+    def test_affine_transform_fortran_order(self, xp, scp, dtype):
+        a = testing.shaped_random((100, 100), xp, dtype)
+        a = xp.asfortranarray(a)
+        matrix = testing.shaped_random(self.matrix_shape, xp, dtype)
+        matrix = xp.asfortranarray(matrix)
+        return self._affine_transform(xp, scp, a, matrix)
+
     @testing.for_int_dtypes(no_bool=True)
     @testing.numpy_cupy_allclose(atol=1e-5, scipy_name='scp')
     def test_affine_transform_int(self, xp, scp, dtype):
@@ -141,6 +160,33 @@ def test_affine_transform_int(self, xp, scp, dtype):
         return out
 
 
+@testing.gpu
+@testing.with_requires('scipy')
+class TestAffineExceptions(unittest.TestCase):
+
+    def test_invalid_affine_ndim(self):
+        ndimage_modules = (scipy.ndimage, cupyx.scipy.ndimage)
+        for (xp, ndi) in zip((numpy, cupy), ndimage_modules):
+            x = xp.ones((8, 8, 8))
+            with pytest.raises(RuntimeError):
+                ndi.affine_transform(x, xp.ones((3, 3, 3)))
+            with pytest.raises(RuntimeError):
+                ndi.affine_transform(x, xp.ones(()))
+
+    def test_invalid_affine_shape(self):
+        ndimage_modules = (scipy.ndimage, cupyx.scipy.ndimage)
+        for (xp, ndi) in zip((numpy, cupy), ndimage_modules):
+            x = xp.ones((8, 8, 8))
+            with pytest.raises(RuntimeError):
+                ndi.affine_transform(x, xp.ones((0, 3)))
+            with pytest.raises(RuntimeError):
+                ndi.affine_transform(x, xp.eye(x.ndim - 1))
+            with pytest.raises(RuntimeError):
+                ndi.affine_transform(x, xp.eye(x.ndim + 2))
+            with pytest.raises(RuntimeError):
+                ndi.affine_transform(x, xp.eye(x.ndim)[:, :-1])
+
+
 @testing.gpu
 @testing.with_requires('opencv-python')
 class TestAffineTransformOpenCV(unittest.TestCase):
@@ -199,6 +245,13 @@ def test_rotate_float(self, xp, scp, dtype):
         a = testing.shaped_random((10, 10), xp, dtype)
         return self._rotate(xp, scp, a)
 
+    @testing.for_float_dtypes(no_float16=True)
+    @testing.numpy_cupy_allclose(atol=1e-5, scipy_name='scp')
+    def test_rotate_fortran_order(self, xp, scp, dtype):
+        a = testing.shaped_random((10, 10), xp, dtype)
+        a = xp.asfortranarray(a)
+        return self._rotate(xp, scp, a)
+
     @testing.for_int_dtypes(no_bool=True)
     @testing.numpy_cupy_allclose(atol=1e-5, scipy_name='scp')
     def test_rotate_int(self, xp, scp, dtype):
@@ -216,6 +269,22 @@ def test_rotate_int(self, xp, scp, dtype):
         return out
 
 
+@testing.gpu
+# Scipy older than 1.3.0 raises IndexError instead of ValueError
+@testing.with_requires('scipy>=1.3.0')
+class TestRotateExceptions(unittest.TestCase):
+
+    def test_rotate_invalid_plane(self):
+        ndimage_modules = (scipy.ndimage, cupyx.scipy.ndimage)
+        for (xp, ndi) in zip((numpy, cupy), ndimage_modules):
+            x = xp.ones((8, 8, 8))
+            angle = 15
+            with pytest.raises(ValueError):
+                ndi.rotate(x, angle, [0, x.ndim])
+            with pytest.raises(ValueError):
+                ndi.rotate(x, angle, [-(x.ndim + 1), 1])
+
+
 @testing.parameterize(
     {'axes': (-1, -2)},
     {'axes': (0, 1)},
@@ -286,6 +355,13 @@ def test_shift_float(self, xp, scp, dtype):
         a = testing.shaped_random((100, 100), xp, dtype)
         return self._shift(xp, scp, a)
 
+    @testing.for_float_dtypes(no_float16=True)
+    @testing.numpy_cupy_allclose(atol=1e-5, scipy_name='scp')
+    def test_shift_fortran_order(self, xp, scp, dtype):
+        a = testing.shaped_random((100, 100), xp, dtype)
+        a = xp.asfortranarray(a)
+        return self._shift(xp, scp, a)
+
     @testing.for_int_dtypes(no_bool=True)
     @testing.numpy_cupy_allclose(atol=1e-5, scipy_name='scp')
     def test_shift_int(self, xp, scp, dtype):
@@ -355,6 +431,13 @@ def test_zoom_float(self, xp, scp, dtype):
         a = testing.shaped_random((100, 100), xp, dtype)
         return self._zoom(xp, scp, a)
 
+    @testing.for_float_dtypes(no_float16=True)
+    @testing.numpy_cupy_allclose(atol=1e-5, scipy_name='scp')
+    def test_zoom_fortran_order(self, xp, scp, dtype):
+        a = testing.shaped_random((100, 100), xp, dtype)
+        a = xp.asfortranarray(a)
+        return self._zoom(xp, scp, a)
+
     @testing.for_int_dtypes(no_bool=True)
     @testing.numpy_cupy_allclose(atol=1e-5, scipy_name='scp')
     def test_zoom_int(self, xp, scp, dtype):