diff --git a/.github/workflows/build-with-clang.yml b/.github/workflows/build-with-clang.yml index d598ce3a..5a4aa317 100644 --- a/.github/workflows/build-with-clang.yml +++ b/.github/workflows/build-with-clang.yml @@ -43,13 +43,13 @@ jobs: sudo apt-get install intel-oneapi-mkl-devel - name: Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ matrix.python }} architecture: x64 - name: Checkout repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 diff --git a/.github/workflows/build_pip.yaml b/.github/workflows/build_pip.yaml index 6219dc0b..4dded434 100644 --- a/.github/workflows/build_pip.yaml +++ b/.github/workflows/build_pip.yaml @@ -27,7 +27,7 @@ jobs: sudo apt-get install jq - name: Checkout repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 diff --git a/.github/workflows/conda-package-cf.yml b/.github/workflows/conda-package-cf.yml index 5aadada0..9df7e33b 100644 --- a/.github/workflows/conda-package-cf.yml +++ b/.github/workflows/conda-package-cf.yml @@ -37,7 +37,7 @@ jobs: uses: styfle/cancel-workflow-action@d07a454dad7609a92316b57b23c9ccfd4f59af66 # 0.13.1 with: access_token: ${{ github.token }} - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 @@ -46,7 +46,7 @@ jobs: echo "pkgs_dirs: [~/.conda/pkgs]" >> ~/.condarc - name: Cache conda packages - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 env: CACHE_NUMBER: 0 # Increase to reset cache with: @@ -151,7 +151,7 @@ jobs: echo "pkgs_dirs: [~/.conda/pkgs]" >> ~/.condarc - name: Cache conda packages - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 env: CACHE_NUMBER: 0 # Increase to reset cache with: @@ -205,7 +205,7 @@ jobs: uses: styfle/cancel-workflow-action@d07a454dad7609a92316b57b23c9ccfd4f59af66 # 0.13.1 with: access_token: ${{ github.token }} - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 @@ -219,7 +219,7 @@ jobs: conda-build-version: ${{ env.CONDA_BUILD_VERSION }} - name: Cache conda packages - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 env: CACHE_NUMBER: 3 # Increase to reset cache with: @@ -346,7 +346,7 @@ jobs: run: Get-Content -Path .\lockfile - name: Cache conda packages - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 env: CACHE_NUMBER: 0 # Increase to reset cache with: diff --git a/.github/workflows/conda-package.yml b/.github/workflows/conda-package.yml index 4f22b81f..a6ef92c3 100644 --- a/.github/workflows/conda-package.yml +++ b/.github/workflows/conda-package.yml @@ -37,7 +37,7 @@ jobs: uses: styfle/cancel-workflow-action@d07a454dad7609a92316b57b23c9ccfd4f59af66 # 0.13.1 with: access_token: ${{ github.token }} - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 @@ -46,7 +46,7 @@ jobs: echo "pkgs_dirs: [~/.conda/pkgs]" >> ~/.condarc - name: Cache conda packages - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 env: CACHE_NUMBER: 0 # Increase to reset cache with: @@ -151,7 +151,7 @@ jobs: echo "pkgs_dirs: [~/.conda/pkgs]" >> ~/.condarc - name: Cache conda packages - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 env: CACHE_NUMBER: 0 # Increase to reset cache with: @@ -205,7 +205,7 @@ jobs: uses: styfle/cancel-workflow-action@d07a454dad7609a92316b57b23c9ccfd4f59af66 # 0.13.1 with: access_token: ${{ github.token }} - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 @@ -219,7 +219,7 @@ jobs: conda-build-version: ${{ env.CONDA_BUILD_VERSION }} - name: Cache conda packages - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 env: CACHE_NUMBER: 3 # Increase to reset cache with: @@ -346,7 +346,7 @@ jobs: run: Get-Content -Path .\lockfile - name: Cache conda packages - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 env: CACHE_NUMBER: 0 # Increase to reset cache with: diff --git a/.github/workflows/openssf-scorecard.yml b/.github/workflows/openssf-scorecard.yml index a05b17d3..07342237 100644 --- a/.github/workflows/openssf-scorecard.yml +++ b/.github/workflows/openssf-scorecard.yml @@ -36,7 +36,7 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -71,6 +71,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 with: sarif_file: results.sarif diff --git a/.github/workflows/pre-commit-autoupdate.yml b/.github/workflows/pre-commit-autoupdate.yml index af315865..4fb8fa7d 100644 --- a/.github/workflows/pre-commit-autoupdate.yml +++ b/.github/workflows/pre-commit-autoupdate.yml @@ -23,10 +23,10 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5.6.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v5.6.0 with: python-version: '3.14' diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 9cce09ab..cd810aec 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -13,13 +13,13 @@ jobs: timeout-minutes: 30 steps: - name: Checkout repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # use commit hash to make "no-commit-to-branch" check passing ref: ${{ github.sha }} - name: Set up python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.14' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4d1e3989..3898bd4a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -58,7 +58,7 @@ repos: args: ["-i"] - repo: https://github.com/MarcoGorelli/cython-lint - rev: v0.19.0 + rev: v0.21.0 hooks: - id: cython-lint - id: double-quote-cython-strings diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b89b18e..f6f52d87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [dev] - YYYY-MM-DD ### Added +* Added CLI patch management for NumPy umath with persistent install/status/uninstall and one-shot command patching via `python -m mkl_umath --patch ` and `python -m mkl_umath --with-numpy-patch ` [gh-216](https://github.com/IntelPython/mkl_umath/pull/216) ### Changed * Removed `numpy-base` dependency and `USE_NUMPY_BASE` environment variable from conda recipe [gh-200](https://github.com/IntelPython/mkl_umath/pull/200) ### Fixed +## [0.4.3] - 2026-06-30 + +### Changed +* Updated `mkl_umath` patching to work with changes to NumPy Cython API present in NumPy 2.5 [gh-226](https://github.com/IntelPython/mkl_umath/pull/226) + ## [0.4.2] - 2026-05-28 ### Added diff --git a/README.md b/README.md index 57f260e4..2dac9769 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,45 @@ Where `` should be the latest version from https://software.repos # Patching Mechanisms -`mkl_umath` provides a convenient programmatic patch method to enable MKL-accelerated umath operations in NumPy. +`mkl_umath` provides convenient patch methods to enable MKL-accelerated +umath operations in NumPy with or without modifying your code. + +## CLI Quickstart + +### Persistent patch (all Python sessions) + +```bash +# Install +python -m mkl_umath --patch install + +# Status (exit code: 0 = installed, 1 = not installed) +python -m mkl_umath --patch status + +# Remove +python -m mkl_umath --patch uninstall +``` + +### Verify patch state + +```bash +python -c "import mkl_umath; print(f'mkl_umath.is_patched(): {mkl_umath.is_patched()}')" +``` + +### One-shot patch (single command only) + +```bash +# Script +python -m mkl_umath --with-numpy-patch my_script.py + +# Pytest +python -m mkl_umath --with-numpy-patch -m pytest tests/ + +# One-liner +python -m mkl_umath --with-numpy-patch -c "import mkl_umath; print(f\"mkl_umath.is_patched(): {mkl_umath.is_patched()}\")" + +# Non-Python command +python -m mkl_umath --with-numpy-patch -- [args...] +``` ## Programmatic Quickstart diff --git a/benchmarks/benchmarks/micro/bench_micro.py b/benchmarks/benchmarks/micro/bench_micro.py index 5302f5bb..5b4b4ec5 100644 --- a/benchmarks/benchmarks/micro/bench_micro.py +++ b/benchmarks/benchmarks/micro/bench_micro.py @@ -1,8 +1,7 @@ """Micro-benchmarks for mkl_umath unary ufuncs. -Times each ufunc over a Cartesian product of - dtype in [float32, float64] - size in [10_000, 100_000, 1_000_000] +Each ufunc gets its own benchmark class (Bench_) so ASV renders a +separate grid tile per ufunc. Params are dtype x size only. Arrays are pre-allocated in setup() and reused across timing calls. Patching is applied once at package import via benchmarks._patch_setup. @@ -39,24 +38,35 @@ } -class BenchMicro: - params = ( - sorted(_UFUNC_CONFIGS.keys()), - ["float32", "float64"], - [10_000, 100_000, 1_000_000], - ) - param_names = ["ufunc", "dtype", "size"] - - def setup(self, ufunc, dtype, size): - cfg = _UFUNC_CONFIGS[ufunc] +def _make_bench(name, cfg): + def setup(self, dtype, size): rng = np.random.default_rng(42) self.x = rng.uniform(cfg["low"], cfg["high"], size).astype(dtype) self._func = cfg["func"] self._func(self.x) - def time_micro(self, ufunc, dtype, size): + def time_ufunc(self, dtype, size): self._func(self.x) + return type( + f"Bench_{name}", + (), + { + "params": (["float32", "float64"], [10_000, 100_000, 1_000_000]), + "param_names": ["dtype", "size"], + "setup": setup, + "time_ufunc": time_ufunc, + }, + ) + + +globals().update( + { + f"Bench_{name}": _make_bench(name, cfg) + for name, cfg in _UFUNC_CONFIGS.items() + } +) + class BenchArctan2: """Binary ufunc arctan2""" diff --git a/conda-recipe-cf/meta.yaml b/conda-recipe-cf/meta.yaml index 29752718..0e54f381 100644 --- a/conda-recipe-cf/meta.yaml +++ b/conda-recipe-cf/meta.yaml @@ -30,9 +30,7 @@ requirements: - python - python-gil # [py>=314] - mkl-devel - - numpy # [not win] - - numpy <2.4 # [win] - # TODO: remove win numpy pin when numpy 2.5 is released, see https://github.com/numpy/numpy/issues/31337 + - numpy - llvm-openmp run: - python diff --git a/mkl_umath/__main__.py b/mkl_umath/__main__.py new file mode 100644 index 00000000..72af9bbb --- /dev/null +++ b/mkl_umath/__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_umath.""" + +import argparse +import sys + + +def main(): + """Entry point for the CLI.""" + parser = argparse.ArgumentParser( + prog="python -m mkl_umath", + description="MKL-accelerated universal functions 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 umath patching", + ) + parser.add_argument( + "--with-numpy-patch", + dest="with_numpy_patch", + nargs=argparse.REMAINDER, + help="Run command with temporary NumPy umath patch", + ) + + args = parser.parse_args() + + if args.patch: + from mkl_umath.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_umath.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_umath/_patch_startup.py b/mkl_umath/_patch_startup.py new file mode 100644 index 00000000..b88891c5 --- /dev/null +++ b/mkl_umath/_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_umath + + mkl_umath.patch_numpy_umath() +except Exception: + pass diff --git a/mkl_umath/patch.py b/mkl_umath/patch.py new file mode 100644 index 00000000..a707e9c5 --- /dev/null +++ b/mkl_umath/patch.py @@ -0,0 +1,141 @@ +# 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 umath 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_umath_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_umath_patch.pth" + + +PTH_CONTENT = """import mkl_umath._patch_startup""" + + +def install_patch(verbose=False): + """Install persistent NumPy umath 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 umath will now use MKL-accelerated implementations in") + print("all Python sessions. To disable, run:") + print(" python -m mkl_umath --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 umath 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 umath 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 umath is configured to use MKL-accelerated " + "implementations." + ) + return True + else: + if verbose: + print("No persistent patch installed") + print() + print("To enable MKL-accelerated NumPy umath globally, run:") + print(" python -m mkl_umath --patch install") + return False + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python -m mkl_umath.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_umath/src/_patch_numpy.pyx b/mkl_umath/src/_patch_numpy.pyx index 598a0167..4338bf2d 100644 --- a/mkl_umath/src/_patch_numpy.pyx +++ b/mkl_umath/src/_patch_numpy.pyx @@ -40,6 +40,16 @@ from libc.stdlib cimport free, malloc cnp.import_umath() +cdef extern from *: + """ + #include "numpy/ufuncobject.h" + static inline char* _get_ufunc_types(PyObject *u) { + return (char *)((PyUFuncObject *)u)->types; + } + """ + char* _get_ufunc_types(object u) noexcept + + ctypedef struct function_info: cnp.PyUFuncGenericFunction original_function cnp.PyUFuncGenericFunction patch_function @@ -49,36 +59,53 @@ ctypedef struct function_info: cdef class _patch_impl: cdef int functions_count cdef function_info* functions - - functions_dict = dict() + cdef dict functions_dict def __cinit__(self): - cdef int pi, oi + self.functions_dict = {} + cdef int pi, oi, i, nargs + cdef int expected_count + cdef char* patch_types + cdef char* orig_types - umaths = [i for i in dir(mu) if isinstance(getattr(mu, i), np.ufunc)] + self.functions = NULL self.functions_count = 0 + + umaths = [x for x in dir(mu) if isinstance(getattr(mu, x), np.ufunc)] + expected_count = 0 for umath in umaths: mkl_umath_func = getattr(mu, umath) - self.functions_count += mkl_umath_func.ntypes - - self.functions = malloc( - self.functions_count * sizeof(function_info) - ) + expected_count += mkl_umath_func.ntypes + + if expected_count > 0: + self.functions = malloc( + expected_count * sizeof(function_info) + ) + if self.functions is NULL: + raise MemoryError( + "Failed to allocate memory for function_info array" + ) - func_number = 0 for umath in umaths: patch_umath = getattr(mu, umath) c_patch_umath = patch_umath c_orig_umath = getattr(np, umath) + # nargs must be >=0 as no ufuncs have no arguments nargs = c_patch_umath.nargs + if nargs <= 0: + raise RuntimeError( + f"Invalid number of arguments for ufunc {umath}: {nargs}" + ) + patch_types = _get_ufunc_types(c_patch_umath) + orig_types = _get_ufunc_types(c_orig_umath) for pi in range(c_patch_umath.ntypes): oi = 0 while oi < c_orig_umath.ntypes: found = True - for i in range(c_patch_umath.nargs): + for i in range(nargs): if ( - c_patch_umath.types[pi * nargs + i] - != c_orig_umath.types[oi * nargs + i] + patch_types[pi * nargs + i] + != orig_types[oi * nargs + i] ): found = False break @@ -86,23 +113,23 @@ cdef class _patch_impl: break oi = oi + 1 if oi < c_orig_umath.ntypes: - self.functions[func_number].original_function = ( + self.functions[self.functions_count].original_function = ( c_orig_umath.functions[oi] ) - self.functions[func_number].patch_function = ( + self.functions[self.functions_count].patch_function = ( c_patch_umath.functions[pi] ) - self.functions[func_number].signature = ( + self.functions[self.functions_count].signature = ( malloc(nargs * sizeof(int)) ) for i in range(nargs): - self.functions[func_number].signature[i] = ( - c_patch_umath.types[pi * nargs + i] + self.functions[self.functions_count].signature[i] = ( + patch_types[pi * nargs + i] ) self.functions_dict[(umath, patch_umath.types[pi])] = ( - func_number + self.functions_count ) - func_number = func_number + 1 + self.functions_count += 1 else: raise RuntimeError( f"Unable to find original function for: {umath} " @@ -110,9 +137,11 @@ cdef class _patch_impl: ) def __dealloc__(self): - for i in range(self.functions_count): - free(self.functions[i].signature) - free(self.functions) + if self.functions is not NULL: + for i in range(self.functions_count): + if self.functions[i].signature is not NULL: + free(self.functions[i].signature) + free(self.functions) cdef int _replace_loop( self, diff --git a/mkl_umath/tests/test_cli.py b/mkl_umath/tests/test_cli.py new file mode 100644 index 00000000..fe74a0b0 --- /dev/null +++ b/mkl_umath/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_umath +from mkl_umath.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_umath_patch.pth" + + def mock_get_pth_path(): + return pth_file + + monkeypatch.setattr("mkl_umath.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_umath._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 yields patched NumPy umath runtime state.""" + install_patch() + + preexisting_patch_state = mkl_umath.is_patched() + try: + site.addsitedir(str(mock_pth_path.parent)) + assert mkl_umath.is_patched() + finally: + if mkl_umath.is_patched() and not preexisting_patch_state: + mkl_umath.restore_numpy_umath() + + +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_umath import __main__ as cli_main + + def mock_install_patch(*args, **kwargs): + raise PatchOperationError("mock cli install failure") + + monkeypatch.setattr("mkl_umath.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_umath/with_patch.py b/mkl_umath/with_patch.py new file mode 100644 index 00000000..f40694d6 --- /dev/null +++ b/mkl_umath/with_patch.py @@ -0,0 +1,96 @@ +# 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 umath patch.""" + +import os +import subprocess +import sys +import tempfile + + +def run_with_numpy_patch(args): + """Run a command with mkl_umath NumPy patch enabled. + + Automatically prepends 'python' unless args start with '--'. + """ + if not args: + print( + "Usage: python -m mkl_umath --with-numpy-patch " + "" + ) + print() + print("Examples:") + print(" python -m mkl_umath --with-numpy-patch script.py") + print( + " python -m mkl_umath --with-numpy-patch -c 'import mkl_umath; " + "print(mkl_umath.is_patched())'" + ) + print(" python -m mkl_umath --with-numpy-patch -m pytest tests/") + print(" python -m mkl_umath --with-numpy-patch -- any command here") + sys.exit(1) + + # strip leading '--' and run as-is + if args[0] == "--": + args = args[1:] + # prepend 'python' for convenience + else: + args = [sys.executable] + args + + sitecustomize_content = """# mkl_umath temporary patch +try: + import mkl_umath + mkl_umath.patch_numpy_umath() +except Exception: + pass +""" + + temp_dir = tempfile.mkdtemp(prefix="mkl_umath_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 + + +if __name__ == "__main__": + run_with_numpy_patch(sys.argv[1:])