From becb1a4a4b137452d7e490b314fcd5801e3a9137 Mon Sep 17 00:00:00 2001 From: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> Date: Sat, 18 May 2024 08:20:51 +0530 Subject: [PATCH] Use wider-compatible LLVM-OpenMP, bundle `libgfortran` dylibs for macOS wheels (#4092) * Set `MACOSX_DEPLOYMENT_TARGET` for prerequisites * Remove previously set environment variables * Remove Homebrew libomp installations * Ignore `pybamm-requires` OpenMP downloads * Rework docs to add OpenMP instructions * Download LLVM-OpenMP on macOS * Remove unused compiler argument * Fix rpath for libomp dylib at wheel repair * Fix a failing link * Revert "Fix rpath for libomp dylib at wheel repair" This reverts commit 6fb8ef02ef144d225bec2c177a2905f8fc912645. * Fix RPATHs for SuiteSparse and libomp * 11.1 for arm64 macOS and 11.0 for amd64 macOS * Set up Fortran compiler via GHA * Downgrade to gcc 12 * Try fix for macOS amd64 wheels first * Build on macos-13 image instead * Debug further, try building against 11.0 * And now build for macOS arm64 * Try building arm64 against 11.0 * Rename wheel from 11_1 to 11_0 * Cleanup and fix up commands * Rename wheel in dest dir instead * Cleanup plus references to SciPy license --- .github/workflows/publish_pypi.yml | 115 ++++++++++++---- .github/workflows/run_periodic_tests.yml | 2 +- .github/workflows/test_on_push.yml | 4 +- .gitignore | 4 + .lycheeignore | 1 + .../installation/install-from-source.rst | 17 +-- scripts/install_KLU_Sundials.py | 125 ++++++++++++++---- 7 files changed, 210 insertions(+), 58 deletions(-) diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index fb4a203e5d..01bf7ad95f 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -99,7 +99,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-12] + os: [ubuntu-latest, macos-13] steps: - uses: actions/checkout@v4 name: Check out PyBaMM repository @@ -112,15 +112,6 @@ jobs: - name: Clone pybind11 repo (no history) run: git clone --depth 1 --branch v2.11.1 https://github.com/pybind/pybind11.git - # sometimes gfortran cannot be found, so reinstall gcc just to be sure - - name: Install SuiteSparse and SUNDIALS on macOS - if: matrix.os == 'macos-12' - run: | - brew install graphviz libomp - brew reinstall gcc - python -m pip install cmake wget - python scripts/install_KLU_Sundials.py - - name: Build wheels on Linux run: pipx run cibuildwheel --output-dir wheelhouse if: matrix.os == 'ubuntu-latest' @@ -134,11 +125,50 @@ jobs: CIBW_TEST_COMMAND: python -c "import pybamm; pybamm.IDAKLUSolver()" - name: Build wheels on macOS amd64 - if: matrix.os == 'macos-12' + if: matrix.os == 'macos-13' run: pipx run cibuildwheel --output-dir wheelhouse env: - CIBW_BEFORE_BUILD_MACOS: > - python -m pip install --upgrade cmake casadi setuptools wheel + MACOSX_DEPLOYMENT_TARGET: 11.0 + # Sourced from + # License: BSD-3-Clause + # https://github.com/scipy/scipy/blob/f2d4775e7762fad984f8f0acd8227c725ff21630/tools/wheels/cibw_before_build_macos.sh#L23-L49 + CIBW_BEFORE_ALL_MACOS: | + set -e -x + + # download gfortran with proper macos minimum version (11.0) + curl -L https://github.com/isuruf/gcc/releases/download/gcc-11.3.0-2/gfortran-darwin-x86_64-native.tar.gz -o gfortran.tar.gz + + GFORTRAN_SHA256=$(shasum --algorithm 256 gfortran.tar.gz) + KNOWN_SHA256="981367dd0ad4335613e91bbee453d60b6669f5d7e976d18c7bdb7f1966f26ae4 gfortran.tar.gz" + if [ "$GFORTRAN_SHA256" != "$KNOWN_SHA256" ]; then + echo "SHA256 mismatch for gfortran.tar.gz" + echo "expected: $KNOWN_SHA256" + echo "got: $GFORTRAN_SHA256" + exit 1 + fi + + mkdir -p gfortran_installed/ + tar -xv -C gfortran_installed/ -f gfortran.tar.gz + + export FC=$(pwd)/gfortran_installed/gfortran-darwin-x86_64-native/bin/gfortran + export PATH=$(pwd)/gfortran_installed/gfortran-darwin-x86_64-native/bin:$PATH + + # link libgfortran.5.dylib, libgfortran.dylib, libquadmath.0.dylib, libquadmath.dylib, libgcc_s.1.dylib, libgcc_s.1.1.dylib + # and place them in $HOME/.local/lib, and then change rpath + # to $HOME/.local/lib + + mkdir -p $HOME/.local/lib + lib_dir=$(pwd)/gfortran_installed/gfortran-darwin-x86_64-native/lib + for lib in libgfortran.5.dylib libgfortran.dylib libquadmath.0.dylib libquadmath.dylib libgcc_s.1.dylib libgcc_s.1.1.dylib; do + cp $lib_dir/$lib $HOME/.local/lib/ + install_name_tool -id $HOME/.local/lib/$lib $HOME/.local/lib/$lib + done + + export SDKROOT=${SDKROOT:-$(xcrun --show-sdk-path)} + + python -m pip install cmake wget + python scripts/install_KLU_Sundials.py + CIBW_BEFORE_BUILD_MACOS: python -m pip install --upgrade pip cmake casadi setuptools wheel delocate CIBW_REPAIR_WHEEL_COMMAND_MACOS: delocate-listdeps {wheel} && delocate-wheel -v -w {dest_dir} {wheel} CIBW_TEST_COMMAND: python -c "import pybamm; pybamm.IDAKLUSolver()" @@ -162,18 +192,57 @@ jobs: - name: Clone pybind11 repo (no history) run: git clone --depth 1 --branch v2.11.1 https://github.com/pybind/pybind11.git - - name: Install SuiteSparse and SUNDIALS on macOS - run: | - brew install graphviz libomp - brew reinstall gcc - python -m pip install cmake pipx - python scripts/install_KLU_Sundials.py - - name: Build wheels on macOS arm64 - run: python -m pipx run cibuildwheel --output-dir wheelhouse + run: | + python -m pip install cibuildwheel + python -m cibuildwheel --output-dir wheelhouse env: - CIBW_BEFORE_BUILD: python -m pip install cmake casadi setuptools wheel delocate - CIBW_REPAIR_WHEEL_COMMAND: delocate-listdeps {wheel} && delocate-wheel -v -w {dest_dir} {wheel} + MACOSX_DEPLOYMENT_TARGET: 11.0 + # Sourced from + # License: BSD-3-Clause + # https://github.com/scipy/scipy/blob/f2d4775e7762fad984f8f0acd8227c725ff21630/tools/wheels/cibw_before_build_macos.sh#L23-L49 + CIBW_BEFORE_ALL_MACOS: | + set -e -x + + # download gfortran with proper macos minimum version (11.0) + curl -L https://github.com/isuruf/gcc/releases/download/gcc-11.3.0-2/gfortran-darwin-arm64-native.tar.gz -o gfortran.tar.gz + + GFORTRAN_SHA256=$(shasum --algorithm 256 gfortran.tar.gz) + KNOWN_SHA256="84364eee32ba843d883fb8124867e2bf61a0cd73b6416d9897ceff7b85a24604 gfortran.tar.gz" + if [ "$GFORTRAN_SHA256" != "$KNOWN_SHA256" ]; then + echo "SHA256 mismatch for gfortran.tar.gz" + echo "expected: $KNOWN_SHA256" + echo "got: $GFORTRAN_SHA256" + exit 1 + fi + + mkdir -p gfortran_installed/ + tar -xv -C gfortran_installed/ -f gfortran.tar.gz + + export FC=$(pwd)/gfortran_installed/gfortran-darwin-arm64-native/bin/gfortran + export PATH=$(pwd)/gfortran_installed/gfortran-darwin-arm64-native/bin:$PATH + + # link libgfortran.5.dylib, libgfortran.dylib, libquadmath.0.dylib, libquadmath.dylib, libgcc_s.1.1.dylib + # and place them in $HOME/.local/lib, and then change rpath + # note: libgcc_s.1.dylib not present for arm64, skip for now + # to $HOME/.local/lib + + mkdir -p $HOME/.local/lib + lib_dir=$(pwd)/gfortran_installed/gfortran-darwin-arm64-native/lib + for lib in libgfortran.5.dylib libgfortran.dylib libquadmath.0.dylib libquadmath.dylib libgcc_s.1.1.dylib; do + cp $lib_dir/$lib $HOME/.local/lib/ + install_name_tool -id $HOME/.local/lib/$lib $HOME/.local/lib/$lib + done + + export SDKROOT=${SDKROOT:-$(xcrun --show-sdk-path)} + + python -m pip install cmake wget + python scripts/install_KLU_Sundials.py + CIBW_BEFORE_BUILD_MACOS: python -m pip install --upgrade pip cmake casadi setuptools wheel delocate + # Use higher macOS target for now: https://github.com/casadi/casadi/issues/3698 + CIBW_REPAIR_WHEEL_COMMAND_MACOS: | + delocate-listdeps {wheel} && delocate-wheel -v -w {dest_dir} {wheel} --require-target-macos-version 11.1 + for file in {dest_dir}/*.whl; do mv "$file" "${file//macosx_11_1/macosx_11_0}"; done CIBW_TEST_COMMAND: python -c "import pybamm; pybamm.IDAKLUSolver()" - name: Upload wheels for macOS arm64 diff --git a/.github/workflows/run_periodic_tests.yml b/.github/workflows/run_periodic_tests.yml index 671d2b371d..848297241e 100644 --- a/.github/workflows/run_periodic_tests.yml +++ b/.github/workflows/run_periodic_tests.yml @@ -68,7 +68,7 @@ jobs: if: matrix.os == 'macos-12' || matrix.os == 'macos-14' run: | brew analytics off - brew install graphviz libomp + brew install graphviz brew reinstall gcc - name: Install Windows system dependencies diff --git a/.github/workflows/test_on_push.yml b/.github/workflows/test_on_push.yml index f79bc61ddf..2f56f501eb 100644 --- a/.github/workflows/test_on_push.yml +++ b/.github/workflows/test_on_push.yml @@ -74,7 +74,7 @@ jobs: # sometimes gfortran cannot be found, so reinstall gcc just to be sure run: | brew analytics off - brew install graphviz libomp + brew install graphviz brew reinstall gcc - name: Install Windows system dependencies @@ -209,7 +209,7 @@ jobs: # sometimes gfortran cannot be found, so reinstall gcc just to be sure run: | brew analytics off - brew install graphviz libomp + brew install graphviz brew reinstall gcc - name: Install Windows system dependencies diff --git a/.gitignore b/.gitignore index 03750e18b2..6f1f201617 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,7 @@ results/ # tests test_callback.log + +# openmp downloads +install_KLU_Sundials/openmp-* +install_KLU_Sundials/usr/ diff --git a/.lycheeignore b/.lycheeignore index 338303b4ba..1543b69f03 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -1,6 +1,7 @@ # a list of links/files to be ignored by lychee link checker (see workflow file) https://github.com/DrTimothyAldenDavis/SuiteSparse/archive/v%7BSUITESPARSE_VERSION%7D.tar.gz https://github.com/LLNL/sundials/releases/download/v%7BSUNDIALS_VERSION%7D/sundials-%7BSUNDIALS_VERSION%7D.tar.gz +https://mac.r-project.org/openmp/openmp-%7BOPENMP_VERSION%7D-darwin20-Release.tar.gz # Errors in docs/source/user_guide/getting_started.md file:///home/runner/work/PyBaMM/PyBaMM/docs/source/user_guide/api_docs diff --git a/docs/source/user_guide/installation/install-from-source.rst b/docs/source/user_guide/installation/install-from-source.rst index ea664b4a5b..a2a7637c9e 100644 --- a/docs/source/user_guide/installation/install-from-source.rst +++ b/docs/source/user_guide/installation/install-from-source.rst @@ -43,11 +43,11 @@ You can install the above with Where ``X`` is the version sub-number. -.. tab:: MacOS +.. tab:: macOS .. code:: bash - brew install python openblas gcc gfortran graphviz libomp cmake pandoc + brew install python openblas gcc gfortran graphviz cmake pandoc .. note:: @@ -82,8 +82,9 @@ If you are running windows, you can simply skip this section and jump to :ref:`p # in the PyBaMM/ directory nox -s pybamm-requires -This will download, compile and install the SuiteSparse and SUNDIALS libraries. -Both libraries are installed in ``~/.local``. +This will download, compile and install the SuiteSparse and SUNDIALS (with OpenMP) libraries +and the ``pybind11`` headers. +SuiteSparse and SUNDIALS are installed in ``~/.local`` by default. For users requiring more control over the installation process, the ``pybamm-requires`` session supports additional command-line arguments: @@ -110,7 +111,7 @@ If you'd rather do things yourself, 1. Make sure you have CMake installed 2. Compile and install SuiteSparse (PyBaMM only requires the ``KLU`` component). -3. Compile and install SUNDIALS. +3. Compile and install SUNDIALS with `OpenMP support `_. 4. Clone the pybind11 repository in the ``PyBaMM/`` directory (make sure the directory is named ``pybind11``). @@ -315,12 +316,12 @@ source files to your current directory. ``ValueError: Integrator name ida does not exist``, or ``ValueError: Integrator name cvode does not exist``. -**Solution:** This could mean that you have not installed -``scikits.odes`` correctly, check the instructions given above and make +**Solution:** This could mean that you have not linked to +SUNDIALS correctly, check the instructions given above and make sure each command was successful. One possibility is that you have not set your ``LD_LIBRARY_PATH`` to -point to the sundials library, type ``echo $LD_LIBRARY_PATH`` and make +point to the SUNDIALS library, type ``echo $LD_LIBRARY_PATH`` and make sure one of the directories printed out corresponds to where the SUNDIALS libraries are located. diff --git a/scripts/install_KLU_Sundials.py b/scripts/install_KLU_Sundials.py index 6224e40776..c0e046c48d 100755 --- a/scripts/install_KLU_Sundials.py +++ b/scripts/install_KLU_Sundials.py @@ -14,18 +14,35 @@ SUITESPARSE_VERSION = "6.0.3" SUNDIALS_VERSION = "6.5.0" + SUITESPARSE_URL = f"https://github.com/DrTimothyAldenDavis/SuiteSparse/archive/v{SUITESPARSE_VERSION}.tar.gz" SUNDIALS_URL = f"https://github.com/LLNL/sundials/releases/download/v{SUNDIALS_VERSION}/sundials-{SUNDIALS_VERSION}.tar.gz" + SUITESPARSE_CHECKSUM = ( "7111b505c1207f6f4bd0be9740d0b2897e1146b845d73787df07901b4f5c1fb7" ) SUNDIALS_CHECKSUM = "4e0b998dff292a2617e179609b539b511eb80836f5faacf800e688a886288502" + +# universal binaries for macOS 11.0 and later; sourced from https://mac.r-project.org/openmp/ +OPENMP_VERSION = "16.0.4" +OPENMP_URL = ( + f"https://mac.r-project.org/openmp/openmp-{OPENMP_VERSION}-darwin20-Release.tar.gz" +) +OPENMP_CHECKSUM = "a763f0bdc9115c4f4933accc81f514f3087d56d6528778f38419c2a0d2231972" + + DEFAULT_INSTALL_DIR = os.path.join(os.getenv("HOME"), ".local") def safe_remove_dir(path): - if os.path.exists(path): - shutil.rmtree(path) + """Remove a directory or file if it exists.""" + try: + if os.path.isfile(path): + os.remove(path) + elif os.path.isdir(path): + shutil.rmtree(path) + except Exception as e: + print(f"Error while removing {path}: {e}") def install_suitesparse(download_dir): @@ -51,11 +68,11 @@ def install_suitesparse(download_dir): env = os.environ.copy() for libdir in ["SuiteSparse_config", "AMD", "COLAMD", "BTF", "KLU"]: build_dir = os.path.join(suitesparse_src, libdir) - # We want to ensure that libsuitesparseconfig.dylib is not repeated in - # multiple paths at the time of wheel repair. Therefore, it should not be - # built with an RPATH since it is copied to the install prefix. + # Set an RPATH in order for libsuitesparseconfig.dylib to find libomp.dylib if libdir == "SuiteSparse_config": - env["CMAKE_OPTIONS"] = f"-DCMAKE_INSTALL_PREFIX={install_dir}" + env["CMAKE_OPTIONS"] = ( + f"-DCMAKE_INSTALL_PREFIX={install_dir} -DCMAKE_INSTALL_RPATH={install_dir}/lib" + ) else: # For AMD, COLAMD, BTF and KLU; do not set a BUILD RPATH but use an # INSTALL RPATH in order to ensure that the dynamic libraries are found @@ -93,20 +110,9 @@ def install_sundials(download_dir, install_dir): # try to find OpenMP on mac if platform.system() == "Darwin": # flags to find OpenMP on mac - if platform.processor() == "arm": - OpenMP_C_FLAGS = ( - "-Xpreprocessor -fopenmp -I/opt/homebrew/opt/libomp/include" - ) - OpenMP_C_LIB_NAMES = "omp" - OpenMP_omp_LIBRARY = "/opt/homebrew/opt/libomp/lib/libomp.dylib" - elif platform.processor() == "i386": - OpenMP_C_FLAGS = "-Xpreprocessor -fopenmp -I/usr/local/opt/libomp/include" - OpenMP_C_LIB_NAMES = "omp" - OpenMP_omp_LIBRARY = "/usr/local/opt/libomp/lib/libomp.dylib" - else: - raise NotImplementedError( - f"Unsupported processor architecture: {platform.processor()}. Only 'arm' and 'i386' architectures are supported." - ) + OpenMP_C_FLAGS = f"-Xpreprocessor -fopenmp -lomp -L{os.path.join(KLU_LIBRARY_DIR)} -I{os.path.join(KLU_INCLUDE_DIR)}" + OpenMP_C_LIB_NAMES = "omp" + OpenMP_omp_LIBRARY = os.path.join(KLU_LIBRARY_DIR, "libomp.dylib") cmake_args += [ "-DOpenMP_C_FLAGS=" + OpenMP_C_FLAGS, @@ -130,6 +136,47 @@ def install_sundials(download_dir, install_dir): subprocess.run(make_cmd, cwd=build_dir, check=True) +# Relevant for macOS only because recent Xcode Clang versions do not include OpenMP headers. +# Other compilers (e.g. GCC) include the OpenMP specification by default. +def set_up_openmp(download_dir, install_dir): + print("-" * 10, "Extracting OpenMP archive", "-" * 40) + + openmp_dir = f"openmp-{OPENMP_VERSION}" + openmp_src = os.path.join(download_dir, openmp_dir) + + # extract OpenMP archive + with tarfile.open( + os.path.join(download_dir, f"{openmp_dir}-darwin20-Release.tar.gz") + ) as tar: + tar.extractall(openmp_src) + + # create directories + os.makedirs(os.path.join(install_dir, "lib"), exist_ok=True) + os.makedirs(os.path.join(install_dir, "include"), exist_ok=True) + + # copy files + shutil.copy( + os.path.join(openmp_src, "usr", "local", "lib", "libomp.dylib"), + os.path.join(install_dir, "lib"), + ) + for file in os.listdir(os.path.join(openmp_src, "usr", "local", "include")): + shutil.copy( + os.path.join(openmp_src, "usr", "local", "include", file), + os.path.join(install_dir, "include"), + ) + + # fix rpath; for some reason the downloaded dylib has an absolute path + # to /usr/local/lib/, so use self-referential rpath + subprocess.check_call( + [ + "install_name_tool", + "-id", + "@rpath/libomp.dylib", + f"{os.path.join(install_dir, 'lib', 'libomp.dylib')}", + ] + ) + + def check_libraries_installed(install_dir): # Define the directories to check for SUNDIALS and SuiteSparse libraries lib_dirs = [install_dir] @@ -177,7 +224,7 @@ def check_libraries_installed(install_dir): suitesparse_files = [file + ".dylib" for file in suitesparse_files] else: raise NotImplementedError( - f"Unsupported operating system: {platform.system()}. This script currently supports only Linux and macOS." + f"Unsupported operating system: {platform.system()}. This script supports only Linux and macOS." ) suitesparse_lib_found = True @@ -199,6 +246,16 @@ def check_libraries_installed(install_dir): return sundials_lib_found, suitesparse_lib_found +def check_openmp_installed_on_macos(install_dir): + openmp_lib_found = isfile(join(install_dir, "lib", "libomp.dylib")) + openmp_headers_found = isfile(join(install_dir, "include", "omp.h")) + if not openmp_lib_found or not openmp_headers_found: + print("libomp.dylib or omp.h not found. Proceeding with OpenMP installation.") + else: + print(f"libomp.dylib and omp.h found in {install_dir}.") + return openmp_lib_found + + def calculate_sha256(file_path): sha256_hash = hashlib.sha256() with open(file_path, "rb") as f: @@ -274,7 +331,7 @@ def parallel_download(urls, download_dir): # Get installation location parser = argparse.ArgumentParser( - description="Download, compile and install Sundials and SuiteSparse." + description="Download, compile and install SUNDIALS and SuiteSparse." ) parser.add_argument( "--force", @@ -296,10 +353,20 @@ def parallel_download(urls, download_dir): safe_remove_dir(os.path.join(download_dir, "build_sundials")) safe_remove_dir(os.path.join(download_dir, f"SuiteSparse-{SUITESPARSE_VERSION}")) safe_remove_dir(os.path.join(download_dir, f"sundials-{SUNDIALS_VERSION}")) - sundials_found, suitesparse_found = False, False + if platform.system() == "Darwin": + safe_remove_dir(os.path.join(install_dir, "lib", "libomp.dylib")) + safe_remove_dir(os.path.join(install_dir, "include", "omp.h")) + sundials_found, suitesparse_found, openmp_found = False, False, False + else: + sundials_found, suitesparse_found = False, False else: # Check whether the libraries are installed - sundials_found, suitesparse_found = check_libraries_installed(install_dir) + if platform.system() == "Darwin": + sundials_found, suitesparse_found = check_libraries_installed(install_dir) + openmp_found = check_openmp_installed_on_macos(install_dir) + else: # Linux + sundials_found, suitesparse_found = check_libraries_installed(install_dir) + if __name__ == "__main__": # Determine which libraries to download based on whether they were found @@ -312,12 +379,22 @@ def parallel_download(urls, download_dir): ], download_dir, ) + + if platform.system() == "Darwin" and not openmp_found: + download_extract_library(OPENMP_URL, OPENMP_CHECKSUM, download_dir) + set_up_openmp(download_dir, install_dir) + install_suitesparse(download_dir) install_sundials(download_dir, install_dir) + else: if not sundials_found: # Only SUNDIALS is missing, download and install it parallel_download([(SUNDIALS_URL, SUNDIALS_CHECKSUM)], download_dir) + if platform.system() == "Darwin" and not openmp_found: + download_extract_library(OPENMP_URL, OPENMP_CHECKSUM, download_dir) + set_up_openmp(download_dir, install_dir) + # openmp needed for SUNDIALS on macOS install_sundials(download_dir, install_dir) if not suitesparse_found: # Only SuiteSparse is missing, download and install it