diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a7530ba..83338611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [dev] - YYYY-MM-DD ### Added +* Added CLI for persistent and ephemeral NumPy FFT patching: `python -m mkl_fft patch install/uninstall/status` for persistent patching across all Python sessions, and `python -m mkl_fft with_patch ` for one-shot execution with MKL acceleration ### Changed * Removed `numpy-base` dependency and `USE_NUMPY_BASE` environment variable from conda recipe [gh-318](https://github.com/IntelPython/mkl_fft/pull/318) diff --git a/README.md b/README.md index 769aed7e..b1bc6d42 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,61 @@ numpy.allclose(mkl_res, np_res) # True ``` +--- +# Patching Mechanisms + +`mkl_fft` provides convenient patch methods to enable MKL-accelerated FFT operations in NumPy with or without modifying your code. + +## CLI Quickstart + +### Persistent patch (all Python sessions) + +```bash +# Install +python -m mkl_fft --patch install + +# Status (exit code: 0 = installed, 1 = not installed) +python -m mkl_fft --patch status + +# Remove +python -m mkl_fft --patch uninstall +``` + +### Verify current FFT backend + +```bash +python -c "import numpy; print(f'numpy.fft.fft.__module__: {numpy.fft.fft.__module__}')" +``` + +### One-shot patch (single command only) + +```bash +# Script +python -m mkl_fft --with-numpy-patch my_script.py + +# Pytest +python -m mkl_fft --with-numpy-patch -m pytest tests/ + +# One-liner +python -m mkl_fft --with-numpy-patch -c "import numpy; print(f\"numpy.fft.fft.__module__: {numpy.fft.fft.__module__}\")" + +# Non-Python command +python -m mkl_fft --with-numpy-patch -- [args...] +``` + +## Programmatic Quickstart + +```python +import mkl_fft + +mkl_fft.patch_numpy_fft() +print(mkl_fft.is_patched()) +mkl_fft.restore_numpy_fft() + +with mkl_fft.mkl_fft(): + pass +``` + --- # Building from source diff --git a/mkl_fft/__main__.py b/mkl_fft/__main__.py new file mode 100644 index 00000000..8a174b2c --- /dev/null +++ b/mkl_fft/__main__.py @@ -0,0 +1,85 @@ +# Copyright (c) 2026, Intel Corporation +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of Intel Corporation nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Command-line interface for mkl_fft.""" + +import argparse +import sys + + +def main(): + """Entry point for the CLI.""" + parser = argparse.ArgumentParser( + prog="python -m mkl_fft", + description="MKL-accelerated FFT for NumPy", + ) + parser.add_argument( + "-v", "--verbose", action="store_true", help="Enable verbose output" + ) + parser.add_argument( + "--patch", + choices=["install", "uninstall", "status"], + help="Manage persistent NumPy FFT patching", + ) + parser.add_argument( + "--with-numpy-patch", + dest="with_numpy_patch", + nargs=argparse.REMAINDER, + help="Run command with temporary NumPy FFT patch", + ) + + args = parser.parse_args() + + if args.patch: + from mkl_fft.patch import ( + PatchOperationError, + check_status, + install_patch, + uninstall_patch, + ) + + try: + if args.patch == "install": + install_patch(verbose=args.verbose) + elif args.patch == "uninstall": + uninstall_patch(verbose=args.verbose) + elif args.patch == "status": + sys.exit(0 if check_status(verbose=args.verbose) else 1) + except PatchOperationError as exc: + print(exc, file=sys.stderr) + sys.exit(1) + + elif args.with_numpy_patch is not None: + from mkl_fft.with_patch import run_with_numpy_patch + + run_with_numpy_patch(args.with_numpy_patch) + + else: + parser.print_help() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/mkl_fft/_patch_startup.py b/mkl_fft/_patch_startup.py new file mode 100644 index 00000000..a5ad8d5f --- /dev/null +++ b/mkl_fft/_patch_startup.py @@ -0,0 +1,33 @@ +# Copyright (c) 2026, Intel Corporation +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of Intel Corporation nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Helper module for .pth-based persistent patching with error handling.""" + +try: + import mkl_fft + + mkl_fft.patch_numpy_fft() +except Exception: + pass diff --git a/mkl_fft/patch.py b/mkl_fft/patch.py new file mode 100644 index 00000000..f5967548 --- /dev/null +++ b/mkl_fft/patch.py @@ -0,0 +1,142 @@ +# Copyright (c) 2026, Intel Corporation +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of Intel Corporation nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Persistent patch management for NumPy FFT submodule.""" + +import site +import sys +import warnings +from pathlib import Path + + +class PatchOperationError(RuntimeError): + """Raised when a persistent patch operation cannot be completed.""" + + +def get_pth_path(): + """Get the path to mkl_fft_patch.pth in the appropriate site-packages.""" + site_packages = site.getsitepackages() + if site_packages: + target_site = site_packages[0] + else: + target_site = site.getusersitepackages() + return Path(target_site) / "mkl_fft_patch.pth" + + +PTH_CONTENT = """import mkl_fft._patch_startup""" + + +def install_patch(verbose=False): + """Install persistent NumPy FFT patch using .pth file.""" + pth_path = get_pth_path() + + if pth_path.exists(): + if verbose: + warnings.warn( + f"Persistent patch already installed at {pth_path}", + UserWarning, + stacklevel=2, + ) + return + + try: + pth_path.parent.mkdir(parents=True, exist_ok=True) + pth_path.write_text(PTH_CONTENT) + if verbose: + print(f"Persistent patch installed at {pth_path}") + print() + print( + "NumPy FFT will now use MKL-accelerated implementations in all" + ) + print("Python sessions. To disable, run:") + print(" python -m mkl_fft patch uninstall") + except OSError as e: + raise PatchOperationError( + f"Error installing patch at {pth_path}: {e}\n\n" + "You may need to run with appropriate permissions or install to " + "a user site-packages directory." + ) from e + + +def uninstall_patch(verbose=False): + """Uninstall persistent NumPy FFT patch.""" + pth_path = get_pth_path() + + if not pth_path.exists(): + if verbose: + print("No persistent patch found.") + return + + try: + pth_path.unlink() + if verbose: + print(f"Persistent patch removed from {pth_path}") + print() + print("NumPy FFT will now use the default implementations.") + except OSError as e: + raise PatchOperationError( + f"Error removing patch at {pth_path}: {e}" + ) from e + + +def check_status(verbose=False): + """Check if persistent patch is installed.""" + pth_path = get_pth_path() + + if pth_path.exists(): + if verbose: + print(f"Persistent patch is installed at {pth_path}") + print() + print( + "NumPy FFT is configured to use MKL-accelerated implementations." + ) + return True + else: + if verbose: + print("No persistent patch installed") + print() + print("To enable MKL-accelerated NumPy FFT globally, run:") + print(" python -m mkl_fft patch install") + return False + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python -m mkl_fft.patch ") + sys.exit(1) + + command = sys.argv[1] + try: + if command == "install": + install_patch(verbose=True) + elif command == "uninstall": + uninstall_patch(verbose=True) + elif command == "status": + sys.exit(0 if check_status(verbose=True) else 1) + else: + print(f"Unknown command: {command}") + sys.exit(1) + except PatchOperationError as exc: + print(exc, file=sys.stderr) + sys.exit(1) diff --git a/mkl_fft/tests/test_cli.py b/mkl_fft/tests/test_cli.py new file mode 100644 index 00000000..d195663d --- /dev/null +++ b/mkl_fft/tests/test_cli.py @@ -0,0 +1,160 @@ +# Copyright (c) 2026, Intel Corporation +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of Intel Corporation nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import site + +import pytest + +import mkl_fft +from mkl_fft.patch import ( + PatchOperationError, + check_status, + install_patch, + uninstall_patch, +) + + +@pytest.fixture +def mock_pth_path(tmp_path, monkeypatch): + """Mock the .pth file path to use a temporary directory.""" + pth_file = tmp_path / "mkl_fft_patch.pth" + + def mock_get_pth_path(): + return pth_file + + monkeypatch.setattr("mkl_fft.patch.get_pth_path", mock_get_pth_path) + return pth_file + + +def test_install_patch(mock_pth_path, capsys): + """Test installing persistent patch.""" + install_patch(verbose=True) + + assert mock_pth_path.exists() + content = mock_pth_path.read_text() + assert "import mkl_fft._patch_startup" in content + + captured = capsys.readouterr() + assert "Persistent patch installed" in captured.out + + +def test_install_patch_already_installed(mock_pth_path): + """Test installing patch when already installed.""" + install_patch() + with pytest.warns(UserWarning, match="already installed"): + install_patch(verbose=True) + + +def test_uninstall_patch(mock_pth_path, capsys): + """Test uninstalling persistent patch.""" + install_patch() + assert mock_pth_path.exists() + + uninstall_patch(verbose=True) + assert not mock_pth_path.exists() + + captured = capsys.readouterr() + assert "Persistent patch removed" in captured.out + + +def test_uninstall_patch_not_installed(mock_pth_path, capsys): + """Test uninstalling patch when not installed.""" + uninstall_patch(verbose=True) + + captured = capsys.readouterr() + assert "No persistent patch found" in captured.out + + +def test_patch_status_check_function(mock_pth_path): + """Test check_status function return values.""" + assert not check_status() + + install_patch() + assert check_status() + + uninstall_patch() + assert not check_status() + + +def test_install_patch_enables_runtime_patch_via_pth(mock_pth_path): + """Test that .pth activation results in patched NumPy FFT runtime state.""" + install_patch() + + preexisting_patch_state = mkl_fft.is_patched() + try: + site.addsitedir(str(mock_pth_path.parent)) + assert mkl_fft.is_patched() + finally: + if mkl_fft.is_patched() and not preexisting_patch_state: + mkl_fft.restore_numpy_fft() + + +def test_install_patch_raises_patch_operation_error_on_oserror( + mock_pth_path, monkeypatch +): + """Test install_patch raises a typed error for filesystem failures.""" + + def mock_write_text(*args, **kwargs): + raise OSError("mock write failure") + + monkeypatch.setattr("pathlib.Path.write_text", mock_write_text) + + with pytest.raises(PatchOperationError, match="Error installing patch"): + install_patch() + + +def test_uninstall_patch_raises_patch_operation_error_on_oserror( + mock_pth_path, monkeypatch +): + """Test uninstall_patch raises a typed error for filesystem failures.""" + install_patch() + + def mock_unlink(*args, **kwargs): + raise OSError("mock unlink failure") + + monkeypatch.setattr("pathlib.Path.unlink", mock_unlink) + + with pytest.raises(PatchOperationError, match="Error removing patch"): + uninstall_patch() + + +def test_cli_patch_install_exits_with_error_on_patch_operation_error( + monkeypatch, capsys +): + """Test CLI maps patch operation failures to exit code 1.""" + from mkl_fft import __main__ as cli_main + + def mock_install_patch(*args, **kwargs): + raise PatchOperationError("mock cli install failure") + + monkeypatch.setattr("mkl_fft.patch.install_patch", mock_install_patch) + monkeypatch.setattr("sys.argv", ["python", "--patch", "install"]) + + with pytest.raises(SystemExit) as exc_info: + cli_main.main() + + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "mock cli install failure" in captured.err diff --git a/mkl_fft/with_patch.py b/mkl_fft/with_patch.py new file mode 100644 index 00000000..5262b370 --- /dev/null +++ b/mkl_fft/with_patch.py @@ -0,0 +1,99 @@ +# Copyright (c) 2026, Intel Corporation +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of Intel Corporation nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Run Python commands with temporary NumPy FFT patch.""" + +import os +import subprocess +import sys +import tempfile + + +def run_with_numpy_patch(args): + """Run a command with mkl_fft NumPy patch enabled. + + Automatically prepends 'python' unless args start with '--'. + """ + if not args: + print( + "Usage: python -m mkl_fft --with-numpy-patch " + ) + print() + print("Examples:") + print(" python -m mkl_fft --with-numpy-patch script.py") + print( + " python -m mkl_fft --with-numpy-patch -c 'import numpy; print(numpy.fft.fft.__module__)'" + ) + print(" python -m mkl_fft --with-numpy-patch -m pytest tests/") + print(" python -m mkl_fft --with-numpy-patch -- any command here") + sys.exit(1) + + # If args start with '--', strip it and run as-is (arbitrary command) + if args[0] == "--": + args = args[1:] + # Otherwise, prepend 'python' for convenience + else: + args = [sys.executable] + args + + sitecustomize_content = """# mkl_fft temporary patch +try: + import mkl_fft + mkl_fft.patch_numpy_fft() +except Exception: + pass +""" + + temp_dir = tempfile.mkdtemp(prefix="mkl_fft_patch_") + sitecustomize_path = os.path.join(temp_dir, "sitecustomize.py") + + try: + with open(sitecustomize_path, "w") as f: + f.write(sitecustomize_content) + + env = os.environ.copy() + + existing_pythonpath = env.get("PYTHONPATH", "") + if existing_pythonpath: + env["PYTHONPATH"] = f"{temp_dir}{os.pathsep}{existing_pythonpath}" + else: + env["PYTHONPATH"] = temp_dir + + result = subprocess.run(args, env=env) + sys.exit(result.returncode) + finally: + try: + os.unlink(sitecustomize_path) + os.rmdir(temp_dir) + except OSError: + pass + + +def main(args=None): + """Deprecated entry point. Use run_with_numpy_patch() instead.""" + run_with_numpy_patch(args if args else sys.argv[1:]) + + +if __name__ == "__main__": + main()