Repository: cupy/cupy

Issue:
Possibility of making `cufft.Plan1d` and `cufft.PlanNd` be a context manager?
This discussion begins in PR #2355, in which a new module `scipy.fft` is introduced to work with new upstream changes. It seems that it is impossible to deviate from the SciPy's backend API, see https://github.com/cupy/cupy/pull/2355#issuecomment-517325388:
> You won't be able to pass a `plan` argument through `scipy.fft` unless the argument is added to `scipy` itself.

@grlee77 @peterbell10 @asi1024 this makes me wonder whether we should turn cuFFT plans into a context manager for the long run to deal with strict API policies from upstream (i.e., SciPy). Perhaps I can make the following snippet possible:
```python
import cupy
import cupyx.scipy.fft as cu_fft
import scipy.fft
from cupy.fft import get_fft_plan  # move get_fft_plan() to this module 

x = cupy.arange(10)
fftplan = get_fft_plan(x)

with scipy.fft.set_backend(cu_fft), fftplan:
    scipy.fft.fft(x)  # Calls into cupyx, use fftplan, and returns a cupy array
```
A small change to `cupy/cuda/cufft.pyx`, which backs `cupy.fft`, `cupyx.scipy.fftpack`, and now `cupyx.scipy.fft`, to pick up the current plan (or to generate one if none) should be sufficient. (To be clearer, I expect that this works by examining if a cuFFT plan pointer is stored under the hood, similar to choosing a device using `with cupy.cuda.Device(0).use()`.)  

Note that the support of passing explicit cuFFT plans to `cupyx.scipy.fftpack` functions (as well as `get_fft_plan()` itself) is a deviation from `scipy.fftpack` API and was intentionally marked "experimental", so I'm not particularly worrying about breaking backward compatibility for now. 
Set the stream for cuFFT plans as late as possible
This probably qualifies as a ***bug report*** for an edge case below.

Regardless whether it's `Plan1d` or `PlanNd`, currently the stream is set right after the cuFFT plan is created:
https://github.com/cupy/cupy/blob/dc62460c59eec5dbfca043a1ffcc5994dbfd298f/cupy/cuda/cufft.pyx#L83-L87
https://github.com/cupy/cupy/blob/dc62460c59eec5dbfca043a1ffcc5994dbfd298f/cupy/cuda/cufft.pyx#L204-L208
While this is fine in most use cases of `cupy.fft` and `cupyx.scipy.fftpack` (without explicit plans), when working with `cupyx.scipy.fftpack.get_fft_plan()` this might not work as expected, as the plan generation is not necessarily immediately followed by execution/actual computation:
```python
import cupy as cp
import cupyx.scipy.fftpack as fftpack

x = cp.random.random(10).astype(cp.complex128)
plan = fftpack.get_fft_plan(x)
stream = cp.cuda.Stream()

with stream:
    x = fftpack.fftn(x, overwrite_x=True, plan=plan)  # "stream" is not used!
```  
A straightforward solution is to move `cufftSetStream()` to `Plan1d.fft()` (or `PlanNd.fft()`) and set the stream right before executing the plan. Thoughts?  

attn: @grlee77 @asi1024 @not522 


Discussion:
We were actually considering adding a `plan` argument before in scipy/scipy#10302 but the issue was closed after an internal plan cache was added to our backend. It may take some work to add this to SciPy's own backend but I think it would be worthwhile.
Thanks for bringing up the SciPy discussion, @peterbell10. Is it possible to just add `plan=None` in SciPy as a placeholder, and let backend providers like pyFFTW or CuPy to handle it? This would require only minimum code change everywhere.
The idea has been brought up before but it could be quite surprising to users that the plans don't do anything by default. I can always float the idea on the SciPy mailing list though.


Resolving patch:
diff --git a/cupy/fft/fft.py b/cupy/fft/fft.py
--- a/cupy/fft/fft.py
+++ b/cupy/fft/fft.py
@@ -83,6 +83,13 @@ def _exec_fft(a, direction, value_type, norm, axis, overwrite_x,
         out_size = a.shape[-1]
 
     batch = a.size // a.shape[-1]
+    curr_plan = cufft.get_current_plan()
+    if curr_plan is not None:
+        if plan is None:
+            plan = curr_plan
+        else:
+            raise RuntimeError('Use the cuFFT plan either as a context manager'
+                               ' or as an argument.')
     if plan is None:
         plan = cufft.Plan1d(out_size, fft_type, batch)
     else:
@@ -327,6 +334,10 @@ def _exec_fftn(a, direction, value_type, norm, axes, overwrite_x,
     else:
         raise ValueError('a must be contiguous')
 
+    curr_plan = cufft.get_current_plan()
+    if curr_plan is not None:
+        plan = curr_plan
+        # don't check repeated usage; it's done in _default_fft_func()
     if plan is None:
         # generate a plan
         plan = _get_cufft_plan_nd(a.shape, fft_type, axes=axes, order=order)
@@ -435,6 +446,14 @@ def _default_plan_type(a, s=None, axes=None):
 
 
 def _default_fft_func(a, s=None, axes=None, plan=None):
+    curr_plan = cufft.get_current_plan()
+    if curr_plan is not None:
+        if plan is None:
+            plan = curr_plan
+        else:
+            raise RuntimeError('Use the cuFFT plan either as a context manager'
+                               ' or as an argument.')
+
     if isinstance(plan, cufft.PlanNd):  # a shortcut for using _fftn
         return _fftn
     elif isinstance(plan, cufft.Plan1d):  # a shortcut for using _fft
diff --git a/cupyx/scipy/fftpack/fft.py b/cupyx/scipy/fftpack/fft.py
--- a/cupyx/scipy/fftpack/fft.py
+++ b/cupyx/scipy/fftpack/fft.py
@@ -25,8 +25,32 @@ def get_fft_plan(a, shape=None, axes=None, value_type='C2C'):
             Currently only complex-to-complex transforms are supported.
 
     Returns:
-        plan: a cuFFT plan for either 1D transform (cupy.cuda.cufft.Plan1d)
-            or N-D transform (cupy.cuda.cufft.PlanNd).
+        a cuFFT plan for either 1D transform (``cupy.cuda.cufft.Plan1d``) or
+        N-D transform (``cupy.cuda.cufft.PlanNd``).
+
+    .. note::
+        The returned plan can not only be passed as one of the arguments of
+        the functions in ``cupyx.scipy.fftpack``, but also be used as a
+        context manager for both ``cupy.fft`` and ``cupyx.scipy.fftpack``
+        functions:
+
+        .. code-block:: python
+
+            x = cupy.random.random(16).reshape(4, 4).astype(cupy.complex)
+            plan = cupyx.scipy.fftpack.get_fft_plan(x)
+            with plan:
+                y = cupy.fft.fftn(x)
+                # alternatively:
+                y = cupyx.scipy.fftpack.fftn(x)  # no explicit plan is given!
+            # alternatively:
+            y = cupyx.scipy.fftpack.fftn(x, plan=plan)  # pass plan explicitly
+
+        In the first case, no cuFFT plan will be generated automatically,
+        even if ``cupy.fft.config.enable_nd_planning = True`` is set.
+
+    .. warning::
+        This API is a deviation from SciPy's, is currently experimental, and
+        may be changed in the future version.
     """
     # check input array
     if a.flags.c_contiguous:


Test patch:
diff --git a/tests/cupy_tests/fft_tests/test_fft.py b/tests/cupy_tests/fft_tests/test_fft.py
--- a/tests/cupy_tests/fft_tests/test_fft.py
+++ b/tests/cupy_tests/fft_tests/test_fft.py
@@ -1,5 +1,6 @@
 import functools
 import unittest
+import pytest
 
 import numpy as np
 
@@ -274,6 +275,166 @@ def test_ifftn(self, xp, dtype, enable_nd):
         return out
 
 
+@testing.parameterize(
+    {'shape': (3, 4), 's': None, 'axes': None, 'norm': None},
+    {'shape': (3, 4), 's': (1, 5), 'axes': None, 'norm': None},
+    {'shape': (3, 4), 's': None, 'axes': (-2, -1), 'norm': None},
+    {'shape': (3, 4), 's': None, 'axes': (-1, -2), 'norm': None},
+    {'shape': (3, 4), 's': None, 'axes': (0,), 'norm': None},
+    {'shape': (3, 4), 's': None, 'axes': None, 'norm': 'ortho'},
+    {'shape': (2, 3, 4), 's': None, 'axes': None, 'norm': None},
+    {'shape': (2, 3, 4), 's': (1, 4, 10), 'axes': None, 'norm': None},
+    {'shape': (2, 3, 4), 's': None, 'axes': (-3, -2, -1), 'norm': None},
+    {'shape': (2, 3, 4), 's': None, 'axes': (-1, -2, -3), 'norm': None},
+    {'shape': (2, 3, 4), 's': None, 'axes': (0, 1), 'norm': None},
+    {'shape': (2, 3, 4), 's': None, 'axes': None, 'norm': 'ortho'},
+    {'shape': (2, 3, 4), 's': (2, 3), 'axes': (0, 1, 2), 'norm': 'ortho'},
+)
+@testing.gpu
+@testing.with_requires('numpy>=1.10.0')
+class TestPlanCtxManagerFftn(unittest.TestCase):
+
+    @nd_planning_states()
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False)
+    def test_fftn(self, xp, dtype, enable_nd):
+        assert config.enable_nd_planning == enable_nd
+        a = testing.shaped_random(self.shape, xp, dtype)
+        if xp == cupy:
+            from cupyx.scipy.fftpack import get_fft_plan
+            plan = get_fft_plan(a, self.s, self.axes)
+            with plan:
+                out = xp.fft.fftn(a, s=self.s, axes=self.axes, norm=self.norm)
+        else:
+            out = xp.fft.fftn(a, s=self.s, axes=self.axes, norm=self.norm)
+
+        if xp == np and dtype is np.complex64:
+            out = out.astype(np.complex64)
+
+        return out
+
+    @nd_planning_states()
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False)
+    def test_ifftn(self, xp, dtype, enable_nd):
+        assert config.enable_nd_planning == enable_nd
+        a = testing.shaped_random(self.shape, xp, dtype)
+        if xp == cupy:
+            from cupyx.scipy.fftpack import get_fft_plan
+            plan = get_fft_plan(a, self.s, self.axes)
+            with plan:
+                out = xp.fft.ifftn(a, s=self.s, axes=self.axes, norm=self.norm)
+        else:
+            out = xp.fft.ifftn(a, s=self.s, axes=self.axes, norm=self.norm)
+
+        if xp == np and dtype is np.complex64:
+            out = out.astype(np.complex64)
+
+        return out
+
+    @nd_planning_states()
+    @testing.for_complex_dtypes()
+    def test_fftn_error_on_wrong_plan(self, dtype, enable_nd):
+        # This test ensures the context manager plan is picked up
+
+        from cupyx.scipy.fftpack import get_fft_plan
+        from cupy.fft import fftn
+        assert config.enable_nd_planning == enable_nd
+
+        # can't get a plan, so skip
+        if self.axes is not None:
+            if self.s is not None:
+                if len(self.s) != len(self.axes):
+                    return
+            elif len(self.shape) != len(self.axes):
+                return
+
+        a = testing.shaped_random(self.shape, cupy, dtype)
+        bad_in_shape = tuple(2*i for i in self.shape)
+        if self.s is None:
+            bad_out_shape = bad_in_shape
+        else:
+            bad_out_shape = tuple(2*i for i in self.s)
+        b = testing.shaped_random(bad_in_shape, cupy, dtype)
+        plan_wrong = get_fft_plan(b, bad_out_shape, self.axes)
+
+        with pytest.raises(ValueError) as ex, plan_wrong:
+            fftn(a, s=self.s, axes=self.axes, norm=self.norm)
+        # targeting a particular error
+        assert 'The CUFFT plan and a.shape do not match' in str(ex.value)
+
+
+@testing.parameterize(*testing.product({
+    'n': [None, 5, 10, 15],
+    'shape': [(10,), ],
+    'norm': [None, 'ortho'],
+}))
+@testing.gpu
+@testing.with_requires('numpy>=1.10.0')
+class TestPlanCtxManagerFft(unittest.TestCase):
+
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False)
+    def test_fft(self, xp, dtype):
+        a = testing.shaped_random(self.shape, xp, dtype)
+        if xp == cupy:
+            from cupyx.scipy.fftpack import get_fft_plan
+            shape = (self.n,) if self.n is not None else None
+            plan = get_fft_plan(a, shape=shape)
+            assert isinstance(plan, cupy.cuda.cufft.Plan1d)
+            with plan:
+                out = xp.fft.fft(a, n=self.n, norm=self.norm)
+        else:
+            out = xp.fft.fft(a, n=self.n, norm=self.norm)
+
+        # np.fft.fft alway returns np.complex128
+        if xp == np and dtype is np.complex64:
+            out = out.astype(np.complex64)
+
+        return out
+
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False)
+    def test_ifft(self, xp, dtype):
+        a = testing.shaped_random(self.shape, xp, dtype)
+        if xp == cupy:
+            from cupyx.scipy.fftpack import get_fft_plan
+            shape = (self.n,) if self.n is not None else None
+            plan = get_fft_plan(a, shape=shape)
+            assert isinstance(plan, cupy.cuda.cufft.Plan1d)
+            with plan:
+                out = xp.fft.ifft(a, n=self.n, norm=self.norm)
+        else:
+            out = xp.fft.ifft(a, n=self.n, norm=self.norm)
+
+        if xp == np and dtype is np.complex64:
+            out = out.astype(np.complex64)
+
+        return out
+
+    @testing.for_complex_dtypes()
+    def test_fft_error_on_wrong_plan(self, dtype):
+        # This test ensures the context manager plan is picked up
+
+        from cupyx.scipy.fftpack import get_fft_plan
+        from cupy.fft import fft
+
+        a = testing.shaped_random(self.shape, cupy, dtype)
+        bad_shape = tuple(5*i for i in self.shape)
+        b = testing.shaped_random(bad_shape, cupy, dtype)
+        plan_wrong = get_fft_plan(b)
+        assert isinstance(plan_wrong, cupy.cuda.cufft.Plan1d)
+
+        with pytest.raises(ValueError) as ex, plan_wrong:
+            fft(a, n=self.n, norm=self.norm)
+        # targeting a particular error
+        assert 'Target array size does not match the plan.' in str(ex.value)
+
+
 @testing.parameterize(
     {'shape': (3, 4), 's': None, 'axes': None, 'norm': None},
     {'shape': (3, 4), 's': None, 'axes': (-2, -1), 'norm': None},
diff --git a/tests/cupyx_tests/scipy_tests/fftpack_tests/test_fftpack.py b/tests/cupyx_tests/scipy_tests/fftpack_tests/test_fftpack.py
--- a/tests/cupyx_tests/scipy_tests/fftpack_tests/test_fftpack.py
+++ b/tests/cupyx_tests/scipy_tests/fftpack_tests/test_fftpack.py
@@ -1,4 +1,5 @@
 import unittest
+import pytest
 
 from cupy import testing
 import cupyx.scipy.fftpack  # NOQA
@@ -71,6 +72,28 @@ def test_fft_overwrite_plan(self, xp, scp, dtype):
                                 overwrite_x=True)
         return x
 
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False, scipy_name='scp')
+    def test_fft_plan_manager(self, xp, scp, dtype):
+        x = testing.shaped_random(self.shape, xp, dtype)
+        # hack: avoid testing the cases when the output array is of size 0
+        # because cuFFT and numpy raise different kinds of exceptions
+        if self.n == 0:
+            return x
+        x_orig = x.copy()
+        if scp is cupyx.scipy:
+            from cupy.cuda.cufft import get_current_plan
+            plan = scp.fftpack.get_fft_plan(x, shape=self.n, axes=self.axis)
+            with plan:
+                assert id(plan) == id(get_current_plan())
+                out = scp.fftpack.fft(x, n=self.n, axis=self.axis)
+            assert get_current_plan() is None
+        else:  # scipy
+            out = scp.fftpack.fft(x, n=self.n, axis=self.axis)
+        testing.assert_array_equal(x, x_orig)
+        return out
+
     @testing.for_all_dtypes()
     @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
                                  contiguous_check=False, scipy_name='scp')
@@ -125,6 +148,42 @@ def test_ifft_overwrite_plan(self, xp, scp, dtype):
                                  overwrite_x=True)
         return x
 
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False, scipy_name='scp')
+    def test_ifft_plan_manager(self, xp, scp, dtype):
+        x = testing.shaped_random(self.shape, xp, dtype)
+        # hack: avoid testing the cases when the output array is of size 0
+        # because cuFFT and numpy raise different kinds of exceptions
+        if self.n == 0:
+            return x
+        x_orig = x.copy()
+        if scp is cupyx.scipy:
+            from cupy.cuda.cufft import get_current_plan
+            plan = scp.fftpack.get_fft_plan(x, shape=self.n, axes=self.axis)
+            with plan:
+                assert id(plan) == id(get_current_plan())
+                out = scp.fftpack.ifft(x, n=self.n, axis=self.axis)
+            assert get_current_plan() is None
+        else:  # scipy
+            out = scp.fftpack.ifft(x, n=self.n, axis=self.axis)
+        testing.assert_array_equal(x, x_orig)
+        return out
+
+    @testing.for_complex_dtypes()
+    def test_fft_multiple_plan_error(self, dtype):
+        # hack: avoid testing the cases when the output array is of size 0
+        # because cuFFT and numpy raise different kinds of exceptions
+        if self.n == 0:
+            return
+        import cupy
+        import cupyx.scipy.fftpack as fftpack
+        x = testing.shaped_random(self.shape, cupy, dtype)
+        plan = fftpack.get_fft_plan(x, shape=self.n, axes=self.axis)
+        with pytest.raises(RuntimeError) as ex, plan:
+            fftpack.fft(x, n=self.n, axis=self.axis, plan=plan)
+        assert 'Use the cuFFT plan either as' in str(ex.value)
+
 
 @testing.parameterize(
     {'shape': (3, 4), 's': None, 'axes': None},
@@ -199,6 +258,25 @@ def test_fft2_overwrite_plan(self, xp, scp, dtype):
                                  overwrite_x=True)
         return x
 
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False, scipy_name='scp')
+    def test_fft2_plan_manager(self, xp, scp, dtype):
+        x = testing.shaped_random(self.shape, xp, dtype)
+        # hack: avoid testing the cases when getting a cuFFT plan is impossible
+        if _default_plan_type(x, s=self.s, axes=self.axes) != 'nd':
+            return x
+        if scp is cupyx.scipy:
+            from cupy.cuda.cufft import get_current_plan
+            plan = scp.fftpack.get_fft_plan(x, shape=self.s, axes=self.axes)
+            with plan:
+                assert id(plan) == id(get_current_plan())
+                out = scp.fftpack.fft2(x, shape=self.s, axes=self.axes)
+            assert get_current_plan() is None
+        else:  # scipy
+            out = scp.fftpack.fft2(x, shape=self.s, axes=self.axes)
+        return out
+
     @testing.for_all_dtypes()
     @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
                                  contiguous_check=False, scipy_name='scp')
@@ -255,6 +333,25 @@ def test_ifft2_overwrite_plan(self, xp, scp, dtype):
                                   overwrite_x=True)
         return x
 
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False, scipy_name='scp')
+    def test_ifft2_plan_manager(self, xp, scp, dtype):
+        x = testing.shaped_random(self.shape, xp, dtype)
+        # hack: avoid testing the cases when getting a cuFFT plan is impossible
+        if _default_plan_type(x, s=self.s, axes=self.axes) != 'nd':
+            return x
+        if scp is cupyx.scipy:
+            from cupy.cuda.cufft import get_current_plan
+            plan = scp.fftpack.get_fft_plan(x, shape=self.s, axes=self.axes)
+            with plan:
+                assert id(plan) == id(get_current_plan())
+                out = scp.fftpack.ifft2(x, shape=self.s, axes=self.axes)
+            assert get_current_plan() is None
+        else:  # scipy
+            out = scp.fftpack.ifft2(x, shape=self.s, axes=self.axes)
+        return out
+
 
 @testing.parameterize(
     {'shape': (3, 4), 's': None, 'axes': None},
@@ -329,6 +426,25 @@ def test_fftn_overwrite_plan(self, xp, scp, dtype):
                                  overwrite_x=True)
         return x
 
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False, scipy_name='scp')
+    def test_fftn_plan_manager(self, xp, scp, dtype):
+        x = testing.shaped_random(self.shape, xp, dtype)
+        # hack: avoid testing the cases when getting a cuFFT plan is impossible
+        if _default_plan_type(x, s=self.s, axes=self.axes) != 'nd':
+            return x
+        if scp is cupyx.scipy:
+            from cupy.cuda.cufft import get_current_plan
+            plan = scp.fftpack.get_fft_plan(x, shape=self.s, axes=self.axes)
+            with plan:
+                assert id(plan) == id(get_current_plan())
+                out = scp.fftpack.fftn(x, shape=self.s, axes=self.axes)
+            assert get_current_plan() is None
+        else:  # scipy
+            out = scp.fftpack.fftn(x, shape=self.s, axes=self.axes)
+        return out
+
     @testing.for_all_dtypes()
     @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
                                  contiguous_check=False, scipy_name='scp')
@@ -385,6 +501,38 @@ def test_ifftn_overwrite_plan(self, xp, scp, dtype):
                                   overwrite_x=True)
         return x
 
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False, scipy_name='scp')
+    def test_ifftn_plan_manager(self, xp, scp, dtype):
+        x = testing.shaped_random(self.shape, xp, dtype)
+        # hack: avoid testing the cases when getting a cuFFT plan is impossible
+        if _default_plan_type(x, s=self.s, axes=self.axes) != 'nd':
+            return x
+        if scp is cupyx.scipy:
+            from cupy.cuda.cufft import get_current_plan
+            plan = scp.fftpack.get_fft_plan(x, shape=self.s, axes=self.axes)
+            with plan:
+                assert id(plan) == id(get_current_plan())
+                out = scp.fftpack.ifftn(x, shape=self.s, axes=self.axes)
+            assert get_current_plan() is None
+        else:  # scipy
+            out = scp.fftpack.ifftn(x, shape=self.s, axes=self.axes)
+        return out
+
+    @testing.for_complex_dtypes()
+    def test_fftn_multiple_plan_error(self, dtype):
+        import cupy
+        import cupyx.scipy.fftpack as fftpack
+        x = testing.shaped_random(self.shape, cupy, dtype)
+        # hack: avoid testing the cases when getting a cuFFT plan is impossible
+        if _default_plan_type(x, s=self.s, axes=self.axes) != 'nd':
+            return
+        plan = fftpack.get_fft_plan(x, shape=self.s, axes=self.axes)
+        with pytest.raises(RuntimeError) as ex, plan:
+            fftpack.fftn(x, shape=self.s, axes=self.axes, plan=plan)
+        assert 'Use the cuFFT plan either as' in str(ex.value)
+
 
 @testing.parameterize(*testing.product({
     'n': [None, 5, 10, 15],