Repository: cupy/cupy

Issue:
Mark explicit `plan` in `cupyx.scipy.fftpack.*` a stable API?
The explicit `plan` argument in `cupyx.scipy.fftpack.*` and the `get_fft_plan()` function there were added in v6. I have used it extensively in production since then, and I also heard positive feedbacks (via private communication). The plans (`Plan1d` or `PlanNd`) returned by the `get_fft_plan()` handle can be reused either explicitly as an argument, or implicitly as a context manager. In other words, the plans can be used
- implicitly in `cupy.fft.fft.*` (and later `cupyx.scipy.fft.*`)
- explicitly in `cupyx.scipy.fftpack.*`

Given where we are now, and that the `cupyx.scipy.fft` API will be added soon (#2355) which do not take a `plan` as argument, I wonder if this nice feature can be removed from the "experimental" status and become a stable API. Thanks.


Discussion:
Current `cupyx.scipy.fftpack.fft` have `plan` keyword argument, but I think it might be better to support this feature as a method of `plan`. (That means an API like as `plan.fft(x, ...)` seems better to me.) So, I'm hesitating to make it as stable support. How do you think?
I consider the methods in `Plan1d` and `PlanNd` private APIs that are subject to change as deemed necessary anytime, so I really do not like the idea of exposing them to average users. It takes much more work to make them robust, easily accessible, and stable, than just marking `plan` stable. 

In addition, using the low-level APIs directly means we lose all the benefits and conveniences that high-level API provides (require a lot of code change, extra work to prepare and validate input & output arrays, set the batch and stride sizes correctly for the plans, etc). 👎 from me.

Since the master supports solely PY3 now, I suggest we can make `plan` a [keyword-only argument](https://www.python.org/dev/peps/pep-3102/).
I am throwing in some random notes while refreshing my memory on pyFFTW. @grlee77 as one of the core maintainers for pyFFTW, please correct me if I am wrong 🙂 

I think there is 1-to-1 correspondence between pyFFTW and CuPy's FFT support:
- `pyfftw.interfaces` <-> `cupy.fft`/`cupyx.scipy.fftpack`
- `pyfftw.FFTW`        <-> `cupy.cuda.cufft.Plan1d`/`PlanNd`
- `pyfftw.builders`    <-> `cupyx.scipy.fftpack.get_fft_plan`

So, for keeping a similar interface with the best Python FFT library on CPU (does `pocketfft` win now?), `get_fft_plan()` should definitely be a public API. 

Next, if we decide to polish and maintain `Plan1d`/`PlanNd` as noted in my earlier comment, they can also be made as public APIs, but it's a harder route that requires some work. 

Finally, to counteract my standpoint, I note that `pyfftw.interfaces` does not accept any `FFTW` (i.e., plan) object, but it's only because there plans can be cached for arbitrarily long time. If we're also willing to invest efforts into a plan cache, which was in fact part of the original discussion that led to the current plan support (#1669), then as a compromise -- I still think it's a good API and couldn't understand why pyFFTW didn't do a similar job -- I'll be on board with removing the `plan` argument from `cupyx.scipy.fftpack.*`.

@grlee77 I remember you had a prototype for plan cache. How did it go?
>I think there is 1-to-1 correspondence between pyFFTW and CuPy's FFT support:
>
>    pyfftw.interfaces <-> cupy.fft/cupyx.scipy.fftpack
>    pyfftw.FFTW <-> cupy.cuda.cufft.Plan1d/PlanNd
>    pyfftw.builders <-> cupyx.scipy.fftpack.get_fft_plan

Yeah, this is pretty close. The pyFFTW builders also return a `pyfftw.FFTW` object, it is just a more convenient interface than calling `pyfftw.FFTW` directly.  In either case, the FFTW object has a `__call__` method implemented so that the end-user can use it like a function.

e.g.
fft = pyfftw.builders.fft(...)
y = fft(x)  # applys a pre-planned transform

So I think the main difference between this FFTW object and the cupy `PlanNd` `.fft` method call that @asi1024 suggested above is that is uses the `__call__` method so it looks more like a function to the end-user.


> Since the master supports solely PY3 now, I suggest we can make plan a keyword-only argument.

I am +1 on making it keyword-only.

The need for the separate `plan` argument has been reduced somewhat by the ability to use the plans as a context manger, but I think it is convenient to still have the option to explicitly pass in a plan.

I think one issue with calling a method from a Plan object itself is that this is even less like the style of NumPy or SciPy fft code. 

> @grlee77 I remember you had a prototype for plan cache. How did it go?

I should revisit to recall the full details. I know I had a working implementation that was basically a copy of the pyFFTW one, but wanted to instead use a non time-based cache. I did start that somewhere, but haven't looked at it recently and never got around to making a PR for it. 

In one recent application I have just been explicitly storing a given PlanNd object and using it as a context manager rather than relying on a plan cache. I think having a cache could still be useful, though.

> does pocketfft win now?

It can be faster in some cases than non-preplanned pyFFTW or for small transform sizes due to lower overhead. I think pyFFTW still wins in general when using pre-planned transforms, but the relative benefit is much less than previously. For near-prime sizes, FFTPack could be orders of magnitude slower than pyFFTW, but pocketfft does not have that same weakness. 
Thanks a lot for your detailed feedback, @grlee77!!!

> I know I had a working implementation that was basically a copy of the pyFFTW one, but wanted to instead use a non time-based cache. 

I like a non-time-based cache. Before we migrated to GPUs and used cuFFT, we used pyFFTW a lot with a practically infinite timeout. Could you share with us just briefly why pyFFTW would like a timed one?

I just started looking into how PyTorch implements its cuFFT plan cache. Seems like they do not have any timeout:
https://github.com/pytorch/pytorch/tree/master/torch/backends/cuda (Python level)
https://github.com/pytorch/pytorch/blob/master/aten/src/ATen/native/cuda/CuFFTPlanCache.h (C++ level)
I will report back if I have any comments or thoughts for their approach.
> Could you share with us just briefly why pyFFTW would like a timed one?

I don't know what the reasoning was for that choice. It has been that way since before I joined the project. I did see that there was some suggestion to change it to LRU a few years back, but it was not accepted at the time: pyFFTW/pyFFTW#129
`scipy.fftpack.*`s are not guaranteed never to support `plan` argument in the future, so we cannot assure you that we keep the backward compatibility of `plan` keyword argument of `cupy.scipy.fftpack.*`.
@asi1024 Don't worry too much 🙂 

First, with `scipy.fft` being added in v1.4.0 and in preference to `scipy.fftpack`, the latter is considered as "long term legacy" (https://github.com/cupy/cupy/pull/2355#issuecomment-517325388) and maintained only for backward compatibility. I doubt there will be any change over its API. 

Second, in the event that a `plan` argument is to be added to `scipy.fftpack`, we can and will try to coordinate the efforts (with @grlee77 and @peterbell10). For example, [this SciPy issue](https://github.com/scipy/scipy/issues/10302#issuecomment-517351532) title is renamed exactly for the need of CuPy discussed here, see https://github.com/scipy/scipy/issues/10302#issuecomment-517351532. 
I think the discussion is not necessarily limted to `fft`, but about general policy on how CuPy supports a feature that is not in NumPy (or SciPy). It doesn't seem a good idea to add a CuPy-specific argument to NumPy- or SciPy-compatible functions in general.

If `scipy.fftpack` is "long term legacy", I have a feeling that we have no motivation to improve `cupyx.scipy.fftpack`.

Also, as there's an ongoing issue to add `plan` in `scipy.fft` as @leofang pointed out, I think we should wait for the discussion there before making our own interface stable.

> I think the discussion is not necessarily limted to `fft`, but about general policy on how CuPy supports a feature that is not in NumPy (or SciPy). 

Agreed.

> It doesn't seem a good idea to add a CuPy-specific argument to NumPy- or SciPy-compatible functions in general.
> 

If it is made a keyword-only argument as discussed above, there is no compatibility issue and I don’t see why it is a concern. Could you elaborate more?

> If `scipy.fftpack` is "long term legacy", I have a feeling that we have no motivation to improve `cupyx.scipy.fftpack`.

I don’t get it. What exactly do you mean?

> 
> Also, as there's an ongoing issue to add `plan` in `scipy.fft` as @leofang pointed out, I think we should wait for the discussion there before making our own interface stable.

@niboshi This leads to a deadlock. The discussion there is on hold largely because we need it here, yet we have not reached any consensus that can be communicated with upstream. As I said we need and can do a coordinated effort, but we need to land somewhere in order to move on. From what I perceive where we are heading to, this starts getting frustrating...😥
>If it is made a keyword-only argument as discussed above, there is no compatibility issue and I don’t see why it is a concern. Could you elaborate more?

If we have our own argument (even if it's keyword-only), it becomes problematic when other libraries adds the same argument with different meaning.
We would have to change the meaning of that argument, which would cause backward incompatibility.
Likeliness does not matter because this is about the general policy throughout CuPy.

>> If `scipy.fftpack` is "long term legacy", I have a feeling that we have no motivation to improve `cupyx.scipy.fftpack`.
>
>I don’t get it. What exactly do you mean here?

I don't know what exactly "long term legacy" means, so I might be wrong, but I feel SciPy does not recommend using these functions. Why should we add new features to them, instead of new ones?

>@niboshi It means a deadlock here. The discussion there is on hold largely because we need it here, yet we have not reached any consensus that can be communicated with upstream. As I said we need and can do a coordinated effort, but we need to land somewhere in order to move on. From what I perceive where we are heading to, this starts getting frustrating...😥

Hmm, I don't think they need our consensus here.
We don't expect them to follow our API, but we follow them.

> If we have our own argument (even if it's keyword-only), it becomes problematic when other libraries adds the same argument with different meaning.
We would have to change the meaning of that argument, which would cause backward incompatibility.

@niboshi You are making the same argument as @asi1024 did earlier. I am not convinced that it is the scenario we need to worry about. In particular, 

> We don't expect them to follow our API, but we follow them.

this is a kinda passive view to me. We have SciPy-FFT folks @grlee77 @peterbell10 here & in the linked issue, and @grlee77 and I are familiar enough with CuPy. A coordinated effort is entirely possible (for whether and/or how to support a `plan` argument in `scipy.fftpack`). The preservation of backward ***and forward*** compatibilities will definitely be part of the decision making process to ensure neither `scipy.fftpack` nor `cupyx.scipy.fftpack` is broken. Perhaps you can put more faith in external contributors and be less worried. 🙂

Moreover,

> Likeliness does not matter because this is about the general policy throughout CuPy

this is an argument I have seen more than once, but — correct me if I am mistaken — I cannot find any exact reference to this "policy". For example, it’s not stated in the API Compatibility Policy here: https://docs-cupy.chainer.org/en/latest/compatibility.html.

Finally, there are three more technicalities that will make the situation not as complex as you guys insisted. First, as you reiterated in another PR, Python is a duck-typing language. So if `plan` is added by SciPy, it must have a different type than our `Plan1d` and `PlanNd` here, and we can just detect the type and split the code path. Second, SciPy now switches to pocketfft (implemented in C), which seems to keep `plan` ***not*** exposed to the Python space. Plus, my understanding is that a pocketfft plan can be generated very quickly (compared to an FFTW plan). Third, any SciPy FFT plan will just not work with GPUs, so if it’s added, we still need to check and error out when a user supplies a SciPy plan, and only accept our own counterparts in `cupyx.scipy.fftpack`. This API deviation will be necessary in such an unlikely event.

In short, SciPy really has no motivation to add an explicit `plan` argument to support pre-generated plans, unless 3rd-party FFT libraries like us and pyFFTW request it.

> I don't know what exactly "long term legacy" means, so I might be wrong, but I feel SciPy does not recommend using these functions. Why should we add new features to them, instead of new ones?

The `plan` argument is not new. As I mentioned in the beginning, in CuPy we’ve added it since v6. The discussion here is to remove the "experimental" tag from the docs.

By long term legacy there, they encourage the usage of the new `scipy.fft` module over the old `scipy.fftpack`, but will still maintain the latter I believe. It is not deprecated nor removed, and will likely live there for a very very long time for backward compatibility given that SciPy has been so popular over the years.

@grlee77 @peterbell10 It would be nice if you could comment briefly on any of the SciPy parts in the above discussions. 😅
For those who follow the issue via email notification: I modified my response above to make my points clearer.
Hi @leofang. I think your summary of the SciPy side of things sounds correct.

I understand wanting to keep APIs consistent, but I also think there are practical considerations that warrant exceptions. For example IIRC, `cupy.fft.fft` has always preserved single precision dtypes while NumPy's version explicitly casts to double, so apparently an exception was already made in that case. When there is a clear performance or utility benefit to allowing something, then I think it makes sense to do so as long as it is clearly documented. Perhaps there could even be a specific documentation page that collects these exceptions in a central location for easier reference.

I was also under the impression that the `cupyx` module was perhaps a bit more accepting of such deviations than the core cupy API.








> I think your summary of the SciPy side of things sounds correct.

Glad to confirm my understanding! Thank you, @grlee77.

> I understand wanting to keep APIs consistent, but I also think there are practical considerations that warrant exceptions. For example IIRC, `cupy.fft.fft` has always preserved single precision dtypes while NumPy's version explicitly casts to double, so apparently an exception was already made in that case. 

I can throw in more examples of such exceptions that were just vetted and made quite recently:
- `argmin`/`argmax` have different signatures (#2595)
- `allclose` does not return bool (#2799)
- stride incompatibility (#2717)

which echo what you said:
> When there is a clear performance or utility benefit to allowing something, then I think it makes sense to do so as long as it is clearly documented.  

As for documentation: 
> Perhaps there could even be a specific documentation page that collects these exceptions in a central location for easier reference.

I think in addition to do so in the docstrings, we could also add to https://docs-cupy.chainer.org/en/stable/reference/difference.html.

> I was also under the impression that the `cupyx` module was perhaps a bit more accepting of such deviations than the core cupy API.

This is my impression too. Perhaps it was the reason we added `plan` to `cupyx.scipy.fftpack` in the first place.

Resolving patch:
diff --git a/cupyx/scipy/fft/fft.py b/cupyx/scipy/fft/fft.py
--- a/cupyx/scipy/fft/fft.py
+++ b/cupyx/scipy/fft/fft.py
@@ -1,4 +1,5 @@
 from numbers import Number
+import warnings
 
 import numpy as np
 
@@ -25,6 +26,15 @@ def __getattr__(self, name):
 
     _scipy_fft = _DummyModule()
 
+try:
+    from _scipy_fft._lib._pep440 import Version
+except ImportError:
+    # either _scipy_fft is _DummyModule, or scipy < 1.4.0
+    _scipy_150 = False
+else:
+    _scipy_150 = Version(_scipy_fft.version.version) >= Version('1.5.0')
+    del Version
+
 # Backend support for scipy.fft
 
 __ua_domain__ = 'numpy.scipy.fft'
@@ -53,6 +63,8 @@ def __ua_function__(method, args, kwargs):
     fn = _implemented.get(method, None)
     if fn is None:
         return NotImplemented
+    if 'plan' in kwargs and not _scipy_150:
+        warnings.warn('The \'plan\' argument is supported in SciPy v1.5.0+')
     return fn(*args, **kwargs)
 
 
@@ -73,7 +85,7 @@ def _assequence(x):
 
 
 @_implements(_scipy_fft.fft)
-def fft(x, n=None, axis=-1, norm=None, overwrite_x=False):
+def fft(x, n=None, axis=-1, norm=None, overwrite_x=False, *, plan=None):
     """Compute the one-dimensional FFT.
 
     Args:
@@ -84,6 +96,13 @@ def fft(x, n=None, axis=-1, norm=None, overwrite_x=False):
         axis (int): Axis over which to compute the FFT.
         norm (None or ``'ortho'``): Normalization mode.
         overwrite_x (bool): If True, the contents of ``x`` can be destroyed.
+        plan (:class:`~cupy.cuda.cufft.Plan1d`) a cuFFT plan for transforming
+            ``x`` over ``axis``, which can be obtained using::
+
+                plan = cupyx.scipy.fftpack.get_fft_plan(x, n, axis)
+
+            Note that ``plan`` is defaulted to ``None``, meaning CuPy will use
+            an auto-generated plan behind the scene.
 
     Returns:
         cupy.ndarray:
@@ -93,11 +112,11 @@ def fft(x, n=None, axis=-1, norm=None, overwrite_x=False):
     .. seealso:: :func:`scipy.fft.fft`
     """
     return _fft(x, (n,), (axis,), norm, cufft.CUFFT_FORWARD,
-                overwrite_x=overwrite_x)
+                overwrite_x=overwrite_x, plan=plan)
 
 
 @_implements(_scipy_fft.ifft)
-def ifft(x, n=None, axis=-1, norm=None, overwrite_x=False):
+def ifft(x, n=None, axis=-1, norm=None, overwrite_x=False, *, plan=None):
     """Compute the one-dimensional inverse FFT.
 
     Args:
@@ -108,6 +127,13 @@ def ifft(x, n=None, axis=-1, norm=None, overwrite_x=False):
         axis (int): Axis over which to compute the FFT.
         norm (None or ``'ortho'``): Normalization mode.
         overwrite_x (bool): If True, the contents of ``x`` can be destroyed.
+        plan (:class:`~cupy.cuda.cufft.Plan1d`) a cuFFT plan for transforming
+            ``x`` over ``axis``, which can be obtained using::
+
+                plan = cupyx.scipy.fftpack.get_fft_plan(x, n, axis)
+
+            Note that ``plan`` is defaulted to ``None``, meaning CuPy will use
+            an auto-generated plan behind the scene.
 
     Returns:
         cupy.ndarray:
@@ -117,11 +143,11 @@ def ifft(x, n=None, axis=-1, norm=None, overwrite_x=False):
     .. seealso:: :func:`scipy.fft.ifft`
     """
     return _fft(x, (n,), (axis,), norm, cufft.CUFFT_INVERSE,
-                overwrite_x=overwrite_x)
+                overwrite_x=overwrite_x, plan=plan)
 
 
 @_implements(_scipy_fft.fft2)
-def fft2(x, s=None, axes=(-2, -1), norm=None, overwrite_x=False):
+def fft2(x, s=None, axes=(-2, -1), norm=None, overwrite_x=False, *, plan=None):
     """Compute the two-dimensional FFT.
 
     Args:
@@ -132,6 +158,13 @@ def fft2(x, s=None, axes=(-2, -1), norm=None, overwrite_x=False):
         axes (tuple of ints): Axes over which to compute the FFT.
         norm (None or ``'ortho'``): Normalization mode.
         overwrite_x (bool): If True, the contents of ``x`` can be destroyed.
+        plan (:class:`~cupy.cuda.cufft.PlanNd`) a cuFFT plan for transforming
+            ``x`` over ``axis``, which can be obtained using::
+
+                plan = cupyx.scipy.fftpack.get_fft_plan(x, s, axes)
+
+            Note that ``plan`` is defaulted to ``None``, meaning CuPy will use
+            an auto-generated plan behind the scene.
 
     Returns:
         cupy.ndarray:
@@ -140,11 +173,12 @@ def fft2(x, s=None, axes=(-2, -1), norm=None, overwrite_x=False):
 
     .. seealso:: :func:`scipy.fft.fft2`
     """
-    return fftn(x, s, axes, norm, overwrite_x)
+    return fftn(x, s, axes, norm, overwrite_x, plan=plan)
 
 
 @_implements(_scipy_fft.ifft2)
-def ifft2(x, s=None, axes=(-2, -1), norm=None, overwrite_x=False):
+def ifft2(x, s=None, axes=(-2, -1), norm=None, overwrite_x=False, *,
+          plan=None):
     """Compute the two-dimensional inverse FFT.
 
     Args:
@@ -155,6 +189,13 @@ def ifft2(x, s=None, axes=(-2, -1), norm=None, overwrite_x=False):
         axes (tuple of ints): Axes over which to compute the FFT.
         norm (None or ``'ortho'``): Normalization mode.
         overwrite_x (bool): If True, the contents of ``x`` can be destroyed.
+        plan (:class:`~cupy.cuda.cufft.PlanNd`) a cuFFT plan for transforming
+            ``x`` over ``axis``, which can be obtained using::
+
+                plan = cupyx.scipy.fftpack.get_fft_plan(x, s, axes)
+
+            Note that ``plan`` is defaulted to ``None``, meaning CuPy will use
+            an auto-generated plan behind the scene.
 
     Returns:
         cupy.ndarray:
@@ -163,11 +204,11 @@ def ifft2(x, s=None, axes=(-2, -1), norm=None, overwrite_x=False):
 
     .. seealso:: :func:`scipy.fft.ifft2`
     """
-    return ifftn(x, s, axes, norm, overwrite_x)
+    return ifftn(x, s, axes, norm, overwrite_x, plan=plan)
 
 
 @_implements(_scipy_fft.fftn)
-def fftn(x, s=None, axes=None, norm=None, overwrite_x=False):
+def fftn(x, s=None, axes=None, norm=None, overwrite_x=False, *, plan=None):
     """Compute the N-dimensional FFT.
 
     Args:
@@ -178,6 +219,13 @@ def fftn(x, s=None, axes=None, norm=None, overwrite_x=False):
         axes (tuple of ints): Axes over which to compute the FFT.
         norm (None or ``'ortho'``): Normalization mode.
         overwrite_x (bool): If True, the contents of ``x`` can be destroyed.
+        plan (:class:`~cupy.cuda.cufft.PlanNd`) a cuFFT plan for transforming
+            ``x`` over ``axis``, which can be obtained using::
+
+                plan = cupyx.scipy.fftpack.get_fft_plan(x, s, axes)
+
+            Note that ``plan`` is defaulted to ``None``, meaning CuPy will use
+            an auto-generated plan behind the scene.
 
     Returns:
         cupy.ndarray:
@@ -189,11 +237,12 @@ def fftn(x, s=None, axes=None, norm=None, overwrite_x=False):
     s = _assequence(s)
     axes = _assequence(axes)
     func = _default_fft_func(x, s, axes)
-    return func(x, s, axes, norm, cufft.CUFFT_FORWARD, overwrite_x=overwrite_x)
+    return func(x, s, axes, norm, cufft.CUFFT_FORWARD, overwrite_x=overwrite_x,
+                plan=plan)
 
 
 @_implements(_scipy_fft.ifftn)
-def ifftn(x, s=None, axes=None, norm=None, overwrite_x=False):
+def ifftn(x, s=None, axes=None, norm=None, overwrite_x=False, *, plan=None):
     """Compute the N-dimensional inverse FFT.
 
     Args:
@@ -204,6 +253,13 @@ def ifftn(x, s=None, axes=None, norm=None, overwrite_x=False):
         axes (tuple of ints): Axes over which to compute the FFT.
         norm (None or ``'ortho'``): Normalization mode.
         overwrite_x (bool): If True, the contents of ``x`` can be destroyed.
+        plan (:class:`~cupy.cuda.cufft.PlanNd`) a cuFFT plan for transforming
+            ``x`` over ``axis``, which can be obtained using::
+
+                plan = cupyx.scipy.fftpack.get_fft_plan(x, s, axes)
+
+            Note that ``plan`` is defaulted to ``None``, meaning CuPy will use
+            an auto-generated plan behind the scene.
 
     Returns:
         cupy.ndarray:
@@ -215,11 +271,12 @@ def ifftn(x, s=None, axes=None, norm=None, overwrite_x=False):
     s = _assequence(s)
     axes = _assequence(axes)
     func = _default_fft_func(x, s, axes)
-    return func(x, s, axes, norm, cufft.CUFFT_INVERSE, overwrite_x=overwrite_x)
+    return func(x, s, axes, norm, cufft.CUFFT_INVERSE, overwrite_x=overwrite_x,
+                plan=plan)
 
 
 @_implements(_scipy_fft.rfft)
-def rfft(x, n=None, axis=-1, norm=None, overwrite_x=False):
+def rfft(x, n=None, axis=-1, norm=None, overwrite_x=False, *, plan=None):
     """Compute the one-dimensional FFT for real input.
 
     The returned array contains the positive frequency components of the
@@ -233,6 +290,7 @@ def rfft(x, n=None, axis=-1, norm=None, overwrite_x=False):
         axis (int): Axis over which to compute the FFT.
         norm (None or ``'ortho'``): Normalization mode.
         overwrite_x (bool): If True, the contents of ``x`` can be destroyed.
+        plan (None): This argument is currently not supported.
 
     Returns:
         cupy.ndarray:
@@ -241,12 +299,15 @@ def rfft(x, n=None, axis=-1, norm=None, overwrite_x=False):
     .. seealso:: :func:`scipy.fft.rfft`
 
     """
+    # TODO(leofang): support R2C & C2R plans
+    if plan is not None:
+        raise NotImplementedError('rfft plan is currently not yet supported')
     return _fft(x, (n,), (axis,), norm, cufft.CUFFT_FORWARD, 'R2C',
                 overwrite_x=overwrite_x)
 
 
 @_implements(_scipy_fft.irfft)
-def irfft(x, n=None, axis=-1, norm=None, overwrite_x=False):
+def irfft(x, n=None, axis=-1, norm=None, overwrite_x=False, *, plan=None):
     """Compute the one-dimensional inverse FFT for real input.
 
     Args:
@@ -257,6 +318,7 @@ def irfft(x, n=None, axis=-1, norm=None, overwrite_x=False):
         axis (int): Axis over which to compute the FFT.
         norm (None or ``'ortho'``): Normalization mode.
         overwrite_x (bool): If True, the contents of ``x`` can be destroyed.
+        plan (None): This argument is currently not supported.
 
     Returns:
         cupy.ndarray:
@@ -264,12 +326,16 @@ def irfft(x, n=None, axis=-1, norm=None, overwrite_x=False):
 
     .. seealso:: :func:`scipy.fft.irfft`
     """
+    # TODO(leofang): support R2C & C2R plans
+    if plan is not None:
+        raise NotImplementedError('irfft plan is currently not yet supported')
     return _fft(x, (n,), (axis,), norm, cufft.CUFFT_INVERSE, 'C2R',
                 overwrite_x=overwrite_x)
 
 
 @_implements(_scipy_fft.rfft2)
-def rfft2(x, s=None, axes=(-2, -1), norm=None, overwrite_x=False):
+def rfft2(x, s=None, axes=(-2, -1), norm=None, overwrite_x=False, *,
+          plan=None):
     """Compute the two-dimensional FFT for real input.
 
     Args:
@@ -280,6 +346,7 @@ def rfft2(x, s=None, axes=(-2, -1), norm=None, overwrite_x=False):
         axes (tuple of ints): Axes over which to compute the FFT.
         norm (None or ``"ortho"``): Keyword to specify the normalization mode.
         overwrite_x (bool): If True, the contents of ``x`` can be destroyed.
+        plan (None): This argument is currently not supported.
 
     Returns:
         cupy.ndarray:
@@ -289,11 +356,15 @@ def rfft2(x, s=None, axes=(-2, -1), norm=None, overwrite_x=False):
 
     .. seealso:: :func:`scipy.fft.rfft2`
     """
+    # TODO(leofang): support R2C & C2R plans
+    if plan is not None:
+        raise NotImplementedError('rfft2 plan is currently not yet supported')
     return rfftn(x, s, axes, norm, overwrite_x)
 
 
 @_implements(_scipy_fft.irfft2)
-def irfft2(x, s=None, axes=(-2, -1), norm=None, overwrite_x=False):
+def irfft2(x, s=None, axes=(-2, -1), norm=None, overwrite_x=False, *,
+           plan=None):
     """Compute the two-dimensional inverse FFT for real input.
 
     Args:
@@ -304,6 +375,7 @@ def irfft2(x, s=None, axes=(-2, -1), norm=None, overwrite_x=False):
         axes (tuple of ints): Axes over which to compute the FFT.
         norm (None or ``"ortho"``): Keyword to specify the normalization mode.
         overwrite_x (bool): If True, the contents of ``x`` can be destroyed.
+        plan (None): This argument is currently not supported.
 
     Returns:
         cupy.ndarray:
@@ -315,11 +387,14 @@ def irfft2(x, s=None, axes=(-2, -1), norm=None, overwrite_x=False):
 
     .. seealso:: :func:`scipy.fft.irfft2`
     """
+    # TODO(leofang): support R2C & C2R plans
+    if plan is not None:
+        raise NotImplementedError('irfft2 plan is currently not yet supported')
     return irfftn(x, s, axes, norm, overwrite_x)
 
 
 @_implements(_scipy_fft.rfftn)
-def rfftn(x, s=None, axes=None, norm=None, overwrite_x=False):
+def rfftn(x, s=None, axes=None, norm=None, overwrite_x=False, *, plan=None):
     """Compute the N-dimensional FFT for real input.
 
     Args:
@@ -330,6 +405,7 @@ def rfftn(x, s=None, axes=None, norm=None, overwrite_x=False):
         axes (tuple of ints): Axes over which to compute the FFT.
         norm (None or ``"ortho"``): Keyword to specify the normalization mode.
         overwrite_x (bool): If True, the contents of ``x`` can be destroyed.
+        plan (None): This argument is currently not supported.
 
     Returns:
         cupy.ndarray:
@@ -339,13 +415,16 @@ def rfftn(x, s=None, axes=None, norm=None, overwrite_x=False):
 
     .. seealso:: :func:`scipy.fft.rfftn`
     """
+    # TODO(leofang): support R2C & C2R plans
+    if plan is not None:
+        raise NotImplementedError('rfftn plan is currently not yet supported')
     s = _assequence(s)
     axes = _assequence(axes)
     return _fft(x, s, axes, norm, cufft.CUFFT_FORWARD, 'R2C', overwrite_x)
 
 
 @_implements(_scipy_fft.irfftn)
-def irfftn(x, s=None, axes=None, norm=None, overwrite_x=False):
+def irfftn(x, s=None, axes=None, norm=None, overwrite_x=False, *, plan=None):
     """Compute the N-dimensional inverse FFT for real input.
 
     Args:
@@ -356,6 +435,7 @@ def irfftn(x, s=None, axes=None, norm=None, overwrite_x=False):
         axes (tuple of ints): Axes over which to compute the FFT.
         norm (None or ``"ortho"``): Keyword to specify the normalization mode.
         overwrite_x (bool): If True, the contents of ``x`` can be destroyed.
+        plan (None): This argument is currently not supported.
 
     Returns:
         cupy.ndarray:
@@ -367,13 +447,16 @@ def irfftn(x, s=None, axes=None, norm=None, overwrite_x=False):
 
     .. seealso:: :func:`scipy.fft.irfftn`
     """
+    # TODO(leofang): support R2C & C2R plans
+    if plan is not None:
+        raise NotImplementedError('irfftn plan is currently not yet supported')
     s = _assequence(s)
     axes = _assequence(axes)
     return _fft(x, s, axes, norm, cufft.CUFFT_INVERSE, 'C2R', overwrite_x)
 
 
 @_implements(_scipy_fft.hfft)
-def hfft(x, n=None, axis=-1, norm=None, overwrite_x=False):
+def hfft(x, n=None, axis=-1, norm=None, overwrite_x=False, *, plan=None):
     """Compute the FFT of a signal that has Hermitian symmetry.
 
     Args:
@@ -385,6 +468,7 @@ def hfft(x, n=None, axis=-1, norm=None, overwrite_x=False):
         axis (int): Axis over which to compute the FFT.
         norm (None or ``"ortho"``): Keyword to specify the normalization mode.
         overwrite_x (bool): If True, the contents of ``x`` can be destroyed.
+        plan (None): This argument is currently not supported.
 
     Returns:
         cupy.ndarray:
@@ -395,11 +479,14 @@ def hfft(x, n=None, axis=-1, norm=None, overwrite_x=False):
 
     .. seealso:: :func:`scipy.fft.hfft`
     """
+    # TODO(leofang): support R2C & C2R plans
+    if plan is not None:
+        raise NotImplementedError('hfft plan is currently not yet supported')
     return _hfft(x, n, axis, norm)
 
 
 @_implements(_scipy_fft.ihfft)
-def ihfft(x, n=None, axis=-1, norm=None, overwrite_x=False):
+def ihfft(x, n=None, axis=-1, norm=None, overwrite_x=False, *, plan=None):
     """Compute the FFT of a signal that has Hermitian symmetry.
 
     Args:
@@ -410,6 +497,7 @@ def ihfft(x, n=None, axis=-1, norm=None, overwrite_x=False):
         axis (int): Axis over which to compute the FFT.
         norm (None or ``"ortho"``): Keyword to specify the normalization mode.
         overwrite_x (bool): If True, the contents of ``x`` can be destroyed.
+        plan (None): This argument is currently not supported.
 
     Returns:
         cupy.ndarray:
@@ -419,4 +507,7 @@ def ihfft(x, n=None, axis=-1, norm=None, overwrite_x=False):
 
     .. seealso:: :func:`scipy.fft.ihfft`
     """
+    # TODO(leofang): support R2C & C2R plans
+    if plan is not None:
+        raise NotImplementedError('ihfft plan is currently not yet supported')
     return _ihfft(x, n, axis, norm)
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
@@ -60,32 +60,31 @@ def get_fft_plan(a, shape=None, axes=None, value_type='C2C'):
     else:
         raise ValueError('Input array a must be contiguous')
 
+    if isinstance(shape, int):
+        shape = (shape,)
+    if isinstance(axes, int):
+        axes = (axes,)
+    if (shape is not None) and (axes is not None) and len(shape) != len(axes):
+        raise ValueError('Shape and axes have different lengths.')
+
     # check axes
     # n=1: 1d (need axis1D); n>1: Nd
     if axes is None:
-        n = a.ndim
-        axes = tuple(i for i in range(n))
+        n = a.ndim if shape is None else len(shape)
+        axes = tuple(i for i in range(-n, 0))
         if n == 1:
             axis1D = 0
-    elif isinstance(axes, int):
-        n = 1
-        axis1D = axes
-        axes = (axes,)
-        if axis1D >= a.ndim or axis1D < -a.ndim:
-            raise ValueError('The chosen axis ({0}) exceeds the number of '
-                             'dimensions of a ({1})'.format(axis1D, a.ndim))
     else:  # axes is a tuple
         n = len(axes)
         if n == 1:
             axis1D = axes[0]
+            if axis1D >= a.ndim or axis1D < -a.ndim:
+                err = 'The chosen axis ({0}) exceeds the number of '\
+                      'dimensions of a ({1})'.format(axis1D, a.ndim)
+                raise ValueError(err)
         elif n > 3:
             raise ValueError('Only up to three axes is supported')
 
-    # check shape
-    if isinstance(shape, int):
-        shape = (shape,)
-    if (shape is not None) and len(shape) != n:
-        raise ValueError('Shape and axes have different lengths.')
     # Note that "shape" here refers to the shape along trasformed axes, not
     # the shape of the output array, and we need to convert it to the latter.
     # The result is as if "a=_cook_shape(a); return a.shape" is called.


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
@@ -309,6 +309,7 @@ def test_ifftn(self, xp, dtype, enable_nd):
     {'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': None, 'norm': 'ortho'},
     {'shape': (2, 3, 4), 's': (2, 3), 'axes': (0, 1, 2), 'norm': 'ortho'},
 )
 @testing.gpu
diff --git a/tests/cupyx_tests/scipy_tests/fft_tests/test_fft.py b/tests/cupyx_tests/scipy_tests/fft_tests/test_fft.py
--- a/tests/cupyx_tests/scipy_tests/fft_tests/test_fft.py
+++ b/tests/cupyx_tests/scipy_tests/fft_tests/test_fft.py
@@ -1,6 +1,7 @@
 import unittest
 
 from cupy import testing
+from cupy.fft.fft import _default_fft_func, _fftn
 import cupyx.scipy.fft as cp_fft
 import numpy as np
 import cupy as cp
@@ -56,6 +57,67 @@ def test_fft_overwrite(self, xp, dtype):
                                   **overwrite_kw)
         return _correct_np_dtype(xp, dtype, out)
 
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False)
+    def test_fft_plan(self, xp, 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 xp is cp:
+            overwrite_kw = {'plan': _fft_module(xp).get_fft_plan(
+                x, shape=self.n, axes=self.axis)}
+        else:
+            overwrite_kw = {}
+        out = _fft_module(xp).fft(x, n=self.n, axis=self.axis, norm=self.norm,
+                                  **overwrite_kw)
+        testing.assert_array_equal(x, x_orig)
+        return _correct_np_dtype(xp, dtype, out)
+
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False)
+    def test_fft_overwrite_plan(self, xp, 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
+        if xp is cp:
+            overwrite_kw = {'plan': _fft_module(xp).get_fft_plan(
+                x, shape=self.n, axes=self.axis), 'overwrite_x': True}
+        else:
+            overwrite_kw = {}
+        out = _fft_module(xp).fft(x, n=self.n, axis=self.axis, norm=self.norm,
+                                  **overwrite_kw)
+        return _correct_np_dtype(xp, dtype, out)
+
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False)
+    def test_fft_plan_manager(self, xp, 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 xp is cp:
+            from cupy.cuda.cufft import get_current_plan
+            plan = _fft_module(xp).get_fft_plan(x, shape=self.n,
+                                                axes=self.axis)
+            with plan:
+                assert id(plan) == id(get_current_plan())
+                out = _fft_module(xp).fft(x, n=self.n, axis=self.axis)
+            assert get_current_plan() is None
+        else:
+            out = _fft_module(xp).fft(x, n=self.n, axis=self.axis)
+        testing.assert_array_equal(x, x_orig)
+        return _correct_np_dtype(xp, dtype, out)
+
     @testing.for_all_dtypes()
     @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
                                  contiguous_check=False)
@@ -76,6 +138,67 @@ def test_ifft_overwrite(self, xp, dtype):
                                    **overwrite_kw)
         return _correct_np_dtype(xp, dtype, out)
 
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False)
+    def test_ifft_plan(self, xp, 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 xp is cp:
+            overwrite_kw = {'plan': _fft_module(xp).get_fft_plan(
+                x, shape=self.n, axes=self.axis)}
+        else:
+            overwrite_kw = {}
+        out = _fft_module(xp).ifft(x, n=self.n, axis=self.axis, norm=self.norm,
+                                   **overwrite_kw)
+        testing.assert_array_equal(x, x_orig)
+        return _correct_np_dtype(xp, dtype, out)
+
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False)
+    def test_ifft_overwrite_plan(self, xp, 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
+        if xp is cp:
+            overwrite_kw = {'plan': _fft_module(xp).get_fft_plan(
+                x, shape=self.n, axes=self.axis), 'overwrite_x': True}
+        else:
+            overwrite_kw = {}
+        out = _fft_module(xp).ifft(x, n=self.n, axis=self.axis, norm=self.norm,
+                                   **overwrite_kw)
+        return _correct_np_dtype(xp, dtype, out)
+
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False)
+    def test_ifft_plan_manager(self, xp, 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 xp is cp:
+            from cupy.cuda.cufft import get_current_plan
+            plan = _fft_module(xp).get_fft_plan(x, shape=self.n,
+                                                axes=self.axis)
+            with plan:
+                assert id(plan) == id(get_current_plan())
+                out = _fft_module(xp).ifft(x, n=self.n, axis=self.axis)
+            assert get_current_plan() is None
+        else:
+            out = _fft_module(xp).ifft(x, n=self.n, axis=self.axis)
+        testing.assert_array_equal(x, x_orig)
+        return _correct_np_dtype(xp, dtype, out)
+
 
 @testing.parameterize(*(
     testing.product({
@@ -114,6 +237,64 @@ def test_fft2_overwrite(self, xp, dtype):
                                    norm=self.norm, **overwrite_kw)
         return _correct_np_dtype(xp, dtype, out)
 
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False)
+    def test_fft2_plan(self, xp, dtype):
+        x = testing.shaped_random(self.shape, xp, dtype)
+        # hack: avoid testing the cases when getting a cuFFT plan is impossible
+        if _default_fft_func(x, s=self.s, axes=self.axes) is not _fftn:
+            return x
+        x_orig = x.copy()
+        if xp is cp:
+            overwrite_kw = {'plan': _fft_module(xp).get_fft_plan(
+                x, shape=self.s, axes=self.axes)}
+        else:
+            overwrite_kw = {}
+        out = _fft_module(xp).fft2(x, s=self.s, axes=self.axes, norm=self.norm,
+                                   **overwrite_kw)
+        testing.assert_array_equal(x, x_orig)
+        return _correct_np_dtype(xp, dtype, out)
+
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False)
+    def test_fft2_overwrite_plan(self, xp, dtype):
+        x = testing.shaped_random(self.shape, xp, dtype)
+        # hack: avoid testing the cases when getting a cuFFT plan is impossible
+        if _default_fft_func(x, s=self.s, axes=self.axes) is not _fftn:
+            return x
+        if xp is cp:
+            overwrite_kw = {'plan': _fft_module(xp).get_fft_plan(
+                x, shape=self.s, axes=self.axes), 'overwrite_x': True}
+        else:
+            overwrite_kw = {}
+        out = _fft_module(xp).fft2(x, s=self.s, axes=self.axes, norm=self.norm,
+                                   **overwrite_kw)
+        return _correct_np_dtype(xp, dtype, out)
+
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False)
+    def test_fft2_plan_manager(self, xp, dtype):
+        x = testing.shaped_random(self.shape, xp, dtype)
+        # hack: avoid testing the cases when getting a cuFFT plan is impossible
+        if _default_fft_func(x, s=self.s, axes=self.axes) is not _fftn:
+            return x
+        x_orig = x.copy()
+        if xp is cp:
+            from cupy.cuda.cufft import get_current_plan
+            plan = _fft_module(xp).get_fft_plan(x, shape=self.s,
+                                                axes=self.axes)
+            with plan:
+                assert id(plan) == id(get_current_plan())
+                out = _fft_module(xp).fft2(x, s=self.s, axes=self.axes)
+            assert get_current_plan() is None
+        else:
+            out = _fft_module(xp).fft2(x, s=self.s, axes=self.axes)
+        testing.assert_array_equal(x, x_orig)
+        return _correct_np_dtype(xp, dtype, out)
+
     @testing.for_all_dtypes()
     @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
                                  contiguous_check=False)
@@ -135,6 +316,64 @@ def test_ifft2_overwrite(self, xp, dtype):
                                     norm=self.norm, **overwrite_kw)
         return _correct_np_dtype(xp, dtype, out)
 
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False)
+    def test_ifft2_plan(self, xp, dtype):
+        x = testing.shaped_random(self.shape, xp, dtype)
+        # hack: avoid testing the cases when getting a cuFFT plan is impossible
+        if _default_fft_func(x, s=self.s, axes=self.axes) is not _fftn:
+            return x
+        x_orig = x.copy()
+        if xp is cp:
+            overwrite_kw = {'plan': _fft_module(xp).get_fft_plan(
+                x, shape=self.s, axes=self.axes)}
+        else:
+            overwrite_kw = {}
+        out = _fft_module(xp).ifft2(x, s=self.s, axes=self.axes,
+                                    norm=self.norm, **overwrite_kw)
+        testing.assert_array_equal(x, x_orig)
+        return _correct_np_dtype(xp, dtype, out)
+
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False)
+    def test_ifft2_overwrite_plan(self, xp, dtype):
+        x = testing.shaped_random(self.shape, xp, dtype)
+        # hack: avoid testing the cases when getting a cuFFT plan is impossible
+        if _default_fft_func(x, s=self.s, axes=self.axes) is not _fftn:
+            return x
+        if xp is cp:
+            overwrite_kw = {'plan': _fft_module(xp).get_fft_plan(
+                x, shape=self.s, axes=self.axes), 'overwrite_x': True}
+        else:
+            overwrite_kw = {}
+        out = _fft_module(xp).ifft2(x, s=self.s, axes=self.axes,
+                                    norm=self.norm, **overwrite_kw)
+        return _correct_np_dtype(xp, dtype, out)
+
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False)
+    def test_ifft2_plan_manager(self, xp, dtype):
+        x = testing.shaped_random(self.shape, xp, dtype)
+        # hack: avoid testing the cases when getting a cuFFT plan is impossible
+        if _default_fft_func(x, s=self.s, axes=self.axes) is not _fftn:
+            return x
+        x_orig = x.copy()
+        if xp is cp:
+            from cupy.cuda.cufft import get_current_plan
+            plan = _fft_module(xp).get_fft_plan(x, shape=self.s,
+                                                axes=self.axes)
+            with plan:
+                assert id(plan) == id(get_current_plan())
+                out = _fft_module(xp).ifft2(x, s=self.s, axes=self.axes)
+            assert get_current_plan() is None
+        else:
+            out = _fft_module(xp).ifft2(x, s=self.s, axes=self.axes)
+        testing.assert_array_equal(x, x_orig)
+        return _correct_np_dtype(xp, dtype, out)
+
 
 @testing.parameterize(*(
     testing.product({
@@ -180,6 +419,64 @@ def test_fftn_overwrite(self, xp, dtype):
                                    norm=self.norm, **overwrite_kw)
         return _correct_np_dtype(xp, dtype, out)
 
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False)
+    def test_fftn_plan(self, xp, dtype):
+        x = testing.shaped_random(self.shape, xp, dtype)
+        # hack: avoid testing the cases when getting a cuFFT plan is impossible
+        if _default_fft_func(x, s=self.s, axes=self.axes) is not _fftn:
+            return x
+        x_orig = x.copy()
+        if xp is cp:
+            overwrite_kw = {'plan': _fft_module(xp).get_fft_plan(
+                x, shape=self.s, axes=self.axes)}
+        else:
+            overwrite_kw = {}
+        out = _fft_module(xp).fftn(x, s=self.s, axes=self.axes, norm=self.norm,
+                                   **overwrite_kw)
+        testing.assert_array_equal(x, x_orig)
+        return _correct_np_dtype(xp, dtype, out)
+
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False)
+    def test_fftn_overwrite_plan(self, xp, dtype):
+        x = testing.shaped_random(self.shape, xp, dtype)
+        # hack: avoid testing the cases when getting a cuFFT plan is impossible
+        if _default_fft_func(x, s=self.s, axes=self.axes) is not _fftn:
+            return x
+        if xp is cp:
+            overwrite_kw = {'plan': _fft_module(xp).get_fft_plan(
+                x, shape=self.s, axes=self.axes), 'overwrite_x': True}
+        else:
+            overwrite_kw = {}
+        out = _fft_module(xp).fftn(x, s=self.s, axes=self.axes, norm=self.norm,
+                                   **overwrite_kw)
+        return _correct_np_dtype(xp, dtype, out)
+
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False)
+    def test_fftn_plan_manager(self, xp, dtype):
+        x = testing.shaped_random(self.shape, xp, dtype)
+        # hack: avoid testing the cases when getting a cuFFT plan is impossible
+        if _default_fft_func(x, s=self.s, axes=self.axes) is not _fftn:
+            return x
+        x_orig = x.copy()
+        if xp is cp:
+            from cupy.cuda.cufft import get_current_plan
+            plan = _fft_module(xp).get_fft_plan(x, shape=self.s,
+                                                axes=self.axes)
+            with plan:
+                assert id(plan) == id(get_current_plan())
+                out = _fft_module(xp).fftn(x, s=self.s, axes=self.axes)
+            assert get_current_plan() is None
+        else:
+            out = _fft_module(xp).fftn(x, s=self.s, axes=self.axes)
+        testing.assert_array_equal(x, x_orig)
+        return _correct_np_dtype(xp, dtype, out)
+
     @testing.for_all_dtypes()
     @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
                                  contiguous_check=False)
@@ -201,6 +498,64 @@ def test_ifftn_overwrite(self, xp, dtype):
                                     norm=self.norm, **overwrite_kw)
         return _correct_np_dtype(xp, dtype, out)
 
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False)
+    def test_ifftn_plan(self, xp, dtype):
+        x = testing.shaped_random(self.shape, xp, dtype)
+        # hack: avoid testing the cases when getting a cuFFT plan is impossible
+        if _default_fft_func(x, s=self.s, axes=self.axes) is not _fftn:
+            return x
+        x_orig = x.copy()
+        if xp is cp:
+            overwrite_kw = {'plan': _fft_module(xp).get_fft_plan(
+                x, shape=self.s, axes=self.axes)}
+        else:
+            overwrite_kw = {}
+        out = _fft_module(xp).ifftn(x, s=self.s, axes=self.axes,
+                                    norm=self.norm, **overwrite_kw)
+        testing.assert_array_equal(x, x_orig)
+        return _correct_np_dtype(xp, dtype, out)
+
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False)
+    def test_ifftn_overwrite_plan(self, xp, dtype):
+        x = testing.shaped_random(self.shape, xp, dtype)
+        # hack: avoid testing the cases when getting a cuFFT plan is impossible
+        if _default_fft_func(x, s=self.s, axes=self.axes) is not _fftn:
+            return x
+        if xp is cp:
+            overwrite_kw = {'plan': _fft_module(xp).get_fft_plan(
+                x, shape=self.s, axes=self.axes), 'overwrite_x': True}
+        else:
+            overwrite_kw = {}
+        out = _fft_module(xp).ifftn(x, s=self.s, axes=self.axes,
+                                    norm=self.norm, **overwrite_kw)
+        return _correct_np_dtype(xp, dtype, out)
+
+    @testing.for_complex_dtypes()
+    @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
+                                 contiguous_check=False)
+    def test_ifftn_plan_manager(self, xp, dtype):
+        x = testing.shaped_random(self.shape, xp, dtype)
+        # hack: avoid testing the cases when getting a cuFFT plan is impossible
+        if _default_fft_func(x, s=self.s, axes=self.axes) is not _fftn:
+            return x
+        x_orig = x.copy()
+        if xp is cp:
+            from cupy.cuda.cufft import get_current_plan
+            plan = _fft_module(xp).get_fft_plan(x, shape=self.s,
+                                                axes=self.axes)
+            with plan:
+                assert id(plan) == id(get_current_plan())
+                out = _fft_module(xp).ifftn(x, s=self.s, axes=self.axes)
+            assert get_current_plan() is None
+        else:
+            out = _fft_module(xp).ifftn(x, s=self.s, axes=self.axes)
+        testing.assert_array_equal(x, x_orig)
+        return _correct_np_dtype(xp, dtype, out)
+
 
 @testing.parameterize(*testing.product({
     'n': [None, 5, 10, 15],
@@ -232,6 +587,14 @@ def test_rfft_overwrite(self, xp, dtype):
                                    norm=self.norm, **overwrite_kw)
         return _correct_np_dtype(xp, dtype, out)
 
+    # TODO(leofang): rewrite this test when we support R2C/C2R cuFFT plans
+    @testing.for_all_dtypes(no_complex=True)
+    def test_rfft_plan(self, dtype):
+        x = testing.shaped_random(self.shape, cp, dtype)
+        with pytest.raises(NotImplementedError, match='not yet supported'):
+            _fft_module(cp).rfft(x, n=self.n, axis=self.axis,
+                                 norm=self.norm, plan='abc')
+
     @testing.for_all_dtypes(no_complex=True)
     @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
                                  contiguous_check=False)
@@ -253,6 +616,14 @@ def test_irfft_overwrite(self, xp, dtype):
                                     norm=self.norm, **overwrite_kw)
         return _correct_np_dtype(xp, dtype, out)
 
+    # TODO(leofang): rewrite this test when we support R2C/C2R cuFFT plans
+    @testing.for_all_dtypes(no_complex=True)
+    def test_irfft_plan(self, dtype):
+        x = testing.shaped_random(self.shape, cp, dtype)
+        with pytest.raises(NotImplementedError, match='not yet supported'):
+            _fft_module(cp).irfft(x, n=self.n, axis=self.axis,
+                                  norm=self.norm, plan='abc')
+
 
 @testing.parameterize(
     {'shape': (3, 4), 's': None, 'axes': None, 'norm': None},
@@ -282,7 +653,8 @@ class TestRfft2(unittest.TestCase):
     def test_rfft2(self, xp, dtype):
         x = testing.shaped_random(self.shape, xp, dtype)
         x_orig = x.copy()
-        out = _fft_module(xp).rfft2(x, s=self.s, norm=self.norm)
+        out = _fft_module(xp).rfft2(x, s=self.s, axes=self.axes,
+                                    norm=self.norm)
         testing.assert_array_equal(x, x_orig)
         return _correct_np_dtype(xp, dtype, out)
 
@@ -292,17 +664,26 @@ def test_rfft2(self, xp, dtype):
     def test_rfft2_overwrite(self, xp, dtype):
         x = testing.shaped_random(self.shape, xp, dtype)
         overwrite_kw = {} if xp == np else {'overwrite_x': True}
-        out = _fft_module(xp).rfft2(x, s=self.s, norm=self.norm,
-                                    **overwrite_kw)
+        out = _fft_module(xp).rfft2(x, s=self.s, axes=self.axes,
+                                    norm=self.norm, **overwrite_kw)
         return _correct_np_dtype(xp, dtype, out)
 
+    # TODO(leofang): rewrite this test when we support R2C/C2R cuFFT plans
+    @testing.for_all_dtypes(no_complex=True)
+    def test_rfft2_plan(self, dtype):
+        x = testing.shaped_random(self.shape, cp, dtype)
+        with pytest.raises(NotImplementedError, match='not yet supported'):
+            _fft_module(cp).rfft2(x, s=self.s, axes=self.axes,
+                                  norm=self.norm, plan='abc')
+
     @testing.for_all_dtypes()
     @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
                                  contiguous_check=False)
     def test_irfft2(self, xp, dtype):
         x = testing.shaped_random(self.shape, xp, dtype)
         x_orig = x.copy()
-        out = _fft_module(xp).irfft2(x, s=self.s, norm=self.norm)
+        out = _fft_module(xp).irfft2(x, s=self.s, axes=self.axes,
+                                     norm=self.norm)
         testing.assert_array_equal(x, x_orig)
         return _correct_np_dtype(xp, dtype, out)
 
@@ -312,10 +693,18 @@ def test_irfft2(self, xp, dtype):
     def test_irfft2_overwrite(self, xp, dtype):
         x = testing.shaped_random(self.shape, xp, dtype)
         overwrite_kw = {} if xp == np else {'overwrite_x': True}
-        out = _fft_module(xp).irfft2(x, s=self.s, norm=self.norm,
-                                     **overwrite_kw)
+        out = _fft_module(xp).irfft2(x, s=self.s, axes=self.axes,
+                                     norm=self.norm, **overwrite_kw)
         return _correct_np_dtype(xp, dtype, out)
 
+    # TODO(leofang): rewrite this test when we support R2C/C2R cuFFT plans
+    @testing.for_all_dtypes(no_complex=True)
+    def test_irfft2_plan(self, dtype):
+        x = testing.shaped_random(self.shape, cp, dtype)
+        with pytest.raises(NotImplementedError, match='not yet supported'):
+            _fft_module(cp).irfft2(x, s=self.s, axes=self.axes,
+                                   norm=self.norm, plan='abc')
+
 
 @testing.parameterize(
     {'shape': (3, 4), 's': None, 'axes': None, 'norm': None},
@@ -360,6 +749,14 @@ def test_rfftn_overwrite(self, xp, dtype):
                                     norm=self.norm, **overwrite_kw)
         return _correct_np_dtype(xp, dtype, out)
 
+    # TODO(leofang): rewrite this test when we support R2C/C2R cuFFT plans
+    @testing.for_all_dtypes(no_complex=True)
+    def test_rfftn_plan(self, dtype):
+        x = testing.shaped_random(self.shape, cp, dtype)
+        with pytest.raises(NotImplementedError, match='not yet supported'):
+            _fft_module(cp).rfftn(x, s=self.s, axes=self.axes,
+                                  norm=self.norm, plan='abc')
+
     @testing.for_all_dtypes()
     @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
                                  contiguous_check=False)
@@ -381,6 +778,14 @@ def test_irfftn_overwrite(self, xp, dtype):
                                      norm=self.norm, **overwrite_kw)
         return _correct_np_dtype(xp, dtype, out)
 
+    # TODO(leofang): rewrite this test when we support R2C/C2R cuFFT plans
+    @testing.for_all_dtypes(no_complex=True)
+    def test_irfftn_plan(self, dtype):
+        x = testing.shaped_random(self.shape, cp, dtype)
+        with pytest.raises(NotImplementedError, match='not yet supported'):
+            _fft_module(cp).irfftn(x, s=self.s, axes=self.axes,
+                                   norm=self.norm, plan='abc')
+
 
 @testing.parameterize(*testing.product({
     'n': [None, 5, 10, 15],
@@ -412,6 +817,14 @@ def test_hfft_overwrite(self, xp, dtype):
                                    **overwrite_kw)
         return _correct_np_dtype(xp, dtype, out)
 
+    # TODO(leofang): rewrite this test when we support R2C/C2R cuFFT plans
+    @testing.for_all_dtypes()
+    def test_hfft_plan(self, dtype):
+        x = testing.shaped_random(self.shape, cp, dtype)
+        with pytest.raises(NotImplementedError, match='not yet supported'):
+            _fft_module(cp).hfft(x, n=self.n, axis=self.axis,
+                                 norm=self.norm, plan='abc')
+
     @testing.for_all_dtypes(no_complex=True)
     @testing.numpy_cupy_allclose(rtol=1e-4, atol=1e-7, accept_error=ValueError,
                                  contiguous_check=False)
@@ -432,6 +845,14 @@ def test_ihfft_overwrite(self, xp, dtype):
                                     **overwrite_kw)
         return _correct_np_dtype(xp, dtype, out)
 
+    # TODO(leofang): rewrite this test when we support R2C/C2R cuFFT plans
+    @testing.for_all_dtypes(no_complex=True)
+    def test_ihfft_plan(self, dtype):
+        x = testing.shaped_random(self.shape, cp, dtype)
+        with pytest.raises(NotImplementedError, match='not yet supported'):
+            _fft_module(cp).ihfft(x, n=self.n, axis=self.axis,
+                                  norm=self.norm, plan='abc')
+
 
 @testing.gpu
 @pytest.mark.parametrize('func', [