diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index f9ccef82b..158a57df2 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -9,31 +9,53 @@ on: # - pypi # - main # - python - # pull_request: - # branches: - # - main + # Build (but never upload) the wheels when their build changes, so the CUDA + # wheels' no-GPU check runs before a release depends on it. + pull_request: + paths: + - '.github/workflows/pypi.yml' + - 'python/tests/check_cuda_wheel.py' + - 'python/tests/test_cuda.py' + - 'setup.py' + - 'pyproject.toml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + # Every GPU generation from Turing (sm_75) to Blackwell (sm_120) gets + # prebuilt device code (SASS), so no supported GPU has to JIT-compile on first + # use. We embed PTX only for the newest one, so GPUs newer than this list can + # still JIT from it. We build with CUDA 12.8 instead of 13.x because the + # toolkit sets the minimum driver: 12.x needs 525+, while 13.x needs 580+. + CUDA_ARCHITECTURES: "75-real;80-real;86-real;90-real;100-real;120-real;120-virtual" jobs: build_wheels: name: Build wheels on ${{ matrix.name }} (${{ matrix.python-version }} ${{ matrix.arch }}) runs-on: ${{ matrix.os }} strategy: - fail-fast: true + # A pull request run should report every platform's result, but a + # release should stop as soon as any wheel fails. + fail-fast: ${{ github.event_name != 'pull_request' }} matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + # windows-latest ships MSVC 19.5x (VS 2026), which CUDA 12.8 rejects, so + # the Windows wheels build on the VS 2022 image. + os: [ubuntu-latest, macos-latest, windows-2022] python-version: [cp310, cp311, cp312, cp313, cp314] arch: [x86_64, arm64] exclude: - os: ubuntu-latest arch: arm64 - - os: windows-latest + - os: windows-2022 arch: arm64 - os: macOS-latest arch: x86_64 include: - os: ubuntu-latest name: Linux - - os: windows-latest + - os: windows-2022 name: Windows - os: macOS-latest name: macOS @@ -41,6 +63,37 @@ jobs: steps: - uses: actions/checkout@v7 + # The Linux wheels install CUDA inside the manylinux container (see + # CIBW_BEFORE_ALL_LINUX), but cibuildwheel builds the Windows wheels on + # the runner itself, so the toolkit goes there. Install it before + # msvc-dev-cmd so its bin dir is on the PATH that msvc-dev-cmd snapshots. + # In 12.8, nvcc still bundles nvvm and crt, which 13.x (cuda.yml) splits + # into their own subpackages; naming them here would abort the install. + - name: Install CUDA Toolkit (Windows) + if: runner.os == 'Windows' + uses: Jimver/cuda-toolkit@v0.2.35 + with: + cuda: '12.8.1' + sub-packages: '["nvcc", "cudart", "thrust"]' + method: 'network' + use-github-cache: false + + # setup.py builds with the Visual Studio generator by default, which needs + # the toolkit's MSBuild integration. We use Ninja instead, as cuda.yml + # does, so we need MSVC's environment on PATH. check_language(CUDA) does + # not reliably find nvcc on this runner, so we name it with CUDACXX. + - name: Setup MSVC (Windows) + if: runner.os == 'Windows' + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + + - name: Use Ninja and nvcc (Windows) + if: runner.os == 'Windows' + run: | + "CMAKE_GENERATOR=Ninja" | Out-File -FilePath $env:GITHUB_ENV -Append + "CUDACXX=$env:CUDA_PATH\bin\nvcc.exe" | Out-File -FilePath $env:GITHUB_ENV -Append + - name: Build wheels uses: pypa/cibuildwheel@v4.1.0 # to supply options, put them in 'env', like: @@ -54,7 +107,51 @@ jobs: # and can contain AVX-512 that users' CPUs lack. x86-64-v3 is the baseline # the 1.6.0 wheels already required (AVX2, FMA, BMI1/2, LZCNT). The build # runs in a manylinux container, so it has to go through cibuildwheel. - CIBW_ENVIRONMENT_LINUX: CMAKE_ARGS="-DSIMD_CXX_FLAGS=-march=x86-64-v3" + # + # We keep CUDA's runtime static (the CMake default), so the wheel + # needs only the NVIDIA driver, and auditwheel has no libcudart to + # bundle. A machine without a driver still imports the module; its + # ipctk.cuda calls raise instead. + CIBW_ENVIRONMENT_LINUX: >- + CUDACXX=/usr/local/cuda-12.8/bin/nvcc + CMAKE_ARGS="-DSIMD_CXX_FLAGS=-march=x86-64-v3 + -DIPC_TOOLKIT_WITH_CUDA=ON + -DCMAKE_CUDA_ARCHITECTURES=${{ env.CUDA_ARCHITECTURES }} + -DSCALABLE_CCD_CUDA_ARCHITECTURES=${{ env.CUDA_ARCHITECTURES }}" + CIBW_ENVIRONMENT_WINDOWS: >- + CMAKE_ARGS="-DIPC_TOOLKIT_WITH_CUDA=ON + -DCMAKE_CUDA_ARCHITECTURES=${{ env.CUDA_ARCHITECTURES }} + -DSCALABLE_CCD_CUDA_ARCHITECTURES=${{ env.CUDA_ARCHITECTURES }}" + # The manylinux_2_28 image is AlmaLinux 8 with GCC 14 (gcc-toolset-14), + # which CUDA 12.8 supports as a host compiler. We install only the + # compiler, the runtime, and the Thrust/CUB headers. + CIBW_BEFORE_ALL_LINUX: >- + curl -fsSL -o /etc/yum.repos.d/cuda-rhel8.repo + https://developer.download.nvidia.com/compute/cuda/repos/rhel8/x86_64/cuda-rhel8.repo && + dnf install -y cuda-nvcc-12-8 cuda-cudart-devel-12-8 cuda-cccl-12-8 && + g++ --version && /usr/local/cuda-12.8/bin/nvcc --version + # The runners have no GPU or NVIDIA driver, which is the case a CUDA + # wheel must survive: it has to import, report CUDA as unavailable, + # and raise (not crash) when its GPU classes are used anyway. + CIBW_TEST_COMMAND_LINUX: python {project}/python/tests/check_cuda_wheel.py + CIBW_TEST_COMMAND_WINDOWS: python {project}/python/tests/check_cuda_wheel.py + + # PyPI rejects files over 100 MB unless the project has a raised limit. + # We use plain bash because not every runner has `python` on PATH. + - name: Report wheel size + shell: bash + run: | + status=0 + for wheel in wheelhouse/*.whl; do + size=$(wc -c < "$wheel" | tr -d ' ') + mb=$(awk -v s="$size" 'BEGIN { printf "%.1f", s / 1e6 }') + echo "- \`$(basename "$wheel")\`: $mb MB" | tee -a "$GITHUB_STEP_SUMMARY" + if [ "$size" -gt 100000000 ]; then + echo "::error::$(basename "$wheel") is over PyPI's 100 MB limit" + status=1 + fi + done + exit $status - uses: actions/upload-artifact@v7 with: @@ -79,6 +176,8 @@ jobs: name: Upload to PyPI needs: [build_wheels, build_sdist] runs-on: ubuntu-latest + # Pull requests only check that the wheels build. + if: github.event_name != 'pull_request' # upload to PyPI on every tag starting with 'v' # if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') # alternatively, to publish when a GitHub Release is created, use the following rule: diff --git a/cmake/ipc_toolkit/ipc_toolkit_warnings.cmake b/cmake/ipc_toolkit/ipc_toolkit_warnings.cmake index b95e87f10..b2343e791 100644 --- a/cmake/ipc_toolkit/ipc_toolkit_warnings.cmake +++ b/cmake/ipc_toolkit/ipc_toolkit_warnings.cmake @@ -263,6 +263,12 @@ if(IPC_TOOLKIT_WITH_CUDA) # all-warnings these fail the build, and we can't fix third-party code, so we # suppress them for MSVC hosts only. This is safe because the Linux build # still turns both into errors, so our own code stays covered. + # + # CUDA 12.x's libcu++ also trips #221 ("floating-point value does not + # fit in required floating-point type") because it uses INFINITY, which the + # Windows UCRT defines as an overflowing constant, (float)(1e+300 * 1e+300). + # CUDA 13 no longer does, but the Python wheels build with 12.8 for its + # older driver requirement, so we suppress #221 alongside the others. target_compile_options(ipc_toolkit_warnings INTERFACE - "$<$,$,$>:-diag-suppress=128,2417>") + "$<$,$,$>:-diag-suppress=128,221,2417>") endif() diff --git a/docs/source/cpp-api/utils.rst b/docs/source/cpp-api/utils.rst index b2769c0f9..fb14e5be0 100644 --- a/docs/source/cpp-api/utils.rst +++ b/docs/source/cpp-api/utils.rst @@ -7,6 +7,11 @@ Logger .. doxygenfunction:: ipc::logger .. doxygenfunction:: ipc::set_logger +CUDA Availability +----------------- + +.. doxygenfunction:: ipc::cuda::is_available + Positive Semi-Definite Projection --------------------------------- diff --git a/docs/source/python-api/broad_phase.rst b/docs/source/python-api/broad_phase.rst index a55fe3efe..043f448dc 100644 --- a/docs/source/python-api/broad_phase.rst +++ b/docs/source/python-api/broad_phase.rst @@ -59,7 +59,9 @@ LBVH (CUDA) ``ipctk.cuda.LBVH`` is the GPU counterpart of ``ipctk.LBVH`` (C++: ``ipc::cuda::LBVH``). The ``ipctk.cuda`` submodule mirrors the C++ ``ipc::cuda`` namespace; its classes exist only when ``ipctk`` is built with CUDA -(``IPC_TOOLKIT_WITH_CUDA``), which the documentation build is not. +(``IPC_TOOLKIT_WITH_CUDA``), which the documentation build is not. The CUDA +wheels also import on machines without a GPU, where using ``ipctk.cuda.LBVH`` +raises; check ``ipctk.cuda.is_available()`` first. .. .. autoclass:: ipctk.cuda.LBVH .. diff --git a/docs/source/python-api/utils.rst b/docs/source/python-api/utils.rst index ac423e05a..f4816e97b 100644 --- a/docs/source/python-api/utils.rst +++ b/docs/source/python-api/utils.rst @@ -13,6 +13,11 @@ Multi-Threading .. autofunction:: ipctk.get_num_threads .. autofunction:: ipctk.set_num_threads +CUDA Availability +----------------- + +.. autofunction:: ipctk.cuda.is_available + Positive Semi-Definite Projection --------------------------------- diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt index ca73632f7..422727b53 100755 --- a/python/CMakeLists.txt +++ b/python/CMakeLists.txt @@ -2,6 +2,18 @@ # Create Python module for IPC Toolkit include(pybind11) +# pybind11_add_module() adds -flto unless CMAKE_INTERPROCEDURAL_OPTIMIZATION is +# defined, and CMake passes that link flag on to nvcc's device-link step too. +# With GCC, the device-link stub then defines fatbinData in top-level asm that +# LTO cannot see, so the module fails to import with "undefined symbol: +# fatbinData". We turn LTO off for the module in CUDA builds. The tradeoff is +# small: only the binding code loses LTO, since ipc_toolkit is built without it. +# This is a normal (not cache) variable scoped to this directory, so it still +# respects a -DCMAKE_INTERPROCEDURAL_OPTIMIZATION the user passes. +if(IPC_TOOLKIT_WITH_CUDA AND NOT DEFINED CMAKE_INTERPROCEDURAL_OPTIMIZATION) + set(CMAKE_INTERPROCEDURAL_OPTIMIZATION OFF) +endif() + pybind11_add_module(ipctk) add_subdirectory(src) # Add sources to ipctk diff --git a/python/src/bindings.cpp b/python/src/bindings.cpp index 06206814d..914db38e8 100644 --- a/python/src/bindings.cpp +++ b/python/src/bindings.cpp @@ -33,9 +33,11 @@ PYBIND11_MODULE(ipctk, m) define_voxel_size_heuristic(m); // GPU implementations, mirroring the C++ ipc::cuda namespace. The submodule - // always exists; its classes are defined only in CUDA builds. + // always exists, as does is_available(); its classes are defined only in + // CUDA builds. py::module_ cuda = m.def_submodule( "cuda", "GPU (CUDA) implementations; populated only in CUDA builds."); + define_cuda_is_available(cuda); define_cuda_lbvh(cuda); // candidates diff --git a/python/src/utils/CMakeLists.txt b/python/src/utils/CMakeLists.txt index ea828fe0d..e4fc7d2f7 100644 --- a/python/src/utils/CMakeLists.txt +++ b/python/src/utils/CMakeLists.txt @@ -1,4 +1,5 @@ set(SOURCES + cuda_is_available.cpp eigen_ext.cpp hessian_assembler.cpp logger.cpp diff --git a/python/src/utils/bindings.hpp b/python/src/utils/bindings.hpp index db2de2056..3bce7e0b8 100644 --- a/python/src/utils/bindings.hpp +++ b/python/src/utils/bindings.hpp @@ -2,6 +2,7 @@ #include +void define_cuda_is_available(py::module_& cuda); // into ipctk.cuda void define_eigen_ext(py::module_& m); void define_hessian_assembler(py::module_& m); void define_logger(py::module_& m); diff --git a/python/src/utils/cuda_is_available.cpp b/python/src/utils/cuda_is_available.cpp new file mode 100644 index 000000000..a651b157a --- /dev/null +++ b/python/src/utils/cuda_is_available.cpp @@ -0,0 +1,20 @@ +#include + +#include + +void define_cuda_is_available(py::module_& m) // m is the ipctk.cuda submodule +{ + m.def( + "is_available", &ipc::cuda::is_available, + R"ipc_Qu8mg5v7( + Check whether the ipctk.cuda implementations can run here. + + A CUDA build still imports on machines without a GPU or an NVIDIA + driver; its ipctk.cuda calls then raise on first use. Call this first + to choose between the GPU and CPU implementations. + + Returns: + True if this is a CUDA build and the CUDA runtime finds at least + one usable device. + )ipc_Qu8mg5v7"); +} diff --git a/python/tests/check_cuda_wheel.py b/python/tests/check_cuda_wheel.py new file mode 100644 index 000000000..665f842ef --- /dev/null +++ b/python/tests/check_cuda_wheel.py @@ -0,0 +1,13 @@ +"""Check a CUDA wheel on a machine with no GPU or NVIDIA driver. + +CI runs this against the wheels built by pypi.yml. The runners have no GPU, so +the wheel must still import, report that CUDA is unavailable, and raise (not +crash) when its GPU classes are used anyway. +""" +from find_ipctk import ipctk +from test_cuda import test_cuda_is_available_predicts_gpu_broad_phase + +assert hasattr(ipctk.cuda, "LBVH"), "this wheel was built without CUDA" +assert not ipctk.cuda.is_available(), "expected no CUDA device on this machine" +test_cuda_is_available_predicts_gpu_broad_phase() +print("CUDA wheel imports without a GPU and raises on GPU use") diff --git a/python/tests/test_cuda.py b/python/tests/test_cuda.py new file mode 100644 index 000000000..51f381b48 --- /dev/null +++ b/python/tests/test_cuda.py @@ -0,0 +1,40 @@ +import numpy as np + +from find_ipctk import ipctk + + +def two_close_triangles(): + """Two parallel triangles 0.05 apart, so an inflation radius of 0.1 makes + every vertex of one overlap the face of the other.""" + V = np.array([ + [0, 0, 0], [1, 0, 0], [0, 1, 0], + [0, 0, 0.05], [1, 0, 0.05], [0, 1, 0.05], + ], dtype=float) + E = np.array([[0, 1], [1, 2], [2, 0], [3, 4], [4, 5], [5, 3]]) + F = np.array([[0, 1, 2], [3, 4, 5]]) + return V, E, F + + +def test_cuda_is_available_predicts_gpu_broad_phase(): + available = ipctk.cuda.is_available() + assert isinstance(available, bool) + + if not hasattr(ipctk.cuda, "LBVH"): + # A CPU-only build has no GPU classes, so it can never have CUDA. + assert not available + return + + V, E, F = two_close_triangles() + lbvh = ipctk.cuda.LBVH() + if available: + lbvh.build(V, E, F, inflation_radius=0.1) + assert len(lbvh.detect_collision_candidates()) > 0 + else: + # Without a device the first CUDA call fails, and we surface it as a + # Python exception rather than crashing the interpreter. + try: + lbvh.build(V, E, F, inflation_radius=0.1) + except RuntimeError: + pass + else: + assert False, "LBVH.build should raise without a CUDA device" diff --git a/src/ipc/utils/cuda/CMakeLists.txt b/src/ipc/utils/cuda/CMakeLists.txt index 26eb8ff94..3f40e89e1 100644 --- a/src/ipc/utils/cuda/CMakeLists.txt +++ b/src/ipc/utils/cuda/CMakeLists.txt @@ -1,6 +1,8 @@ set(SOURCES device_buffer.cuh device_utils.cuh + is_available.cu + is_available.hpp ) target_sources(ipc_toolkit PRIVATE ${SOURCES}) diff --git a/src/ipc/utils/cuda/is_available.cu b/src/ipc/utils/cuda/is_available.cu new file mode 100644 index 000000000..808dc5ace --- /dev/null +++ b/src/ipc/utils/cuda/is_available.cu @@ -0,0 +1,21 @@ +#include "is_available.hpp" + +#include + +namespace ipc::cuda { + +bool is_available() +{ + int device_count = 0; + if (cudaGetDeviceCount(&device_count) != cudaSuccess) { + // Without a driver or a device the query fails (e.g., + // cudaErrorInsufficientDriver or cudaErrorNoDevice). We clear the + // error so a later IPC_TOOLKIT_CUDA_CHECK(cudaGetLastError()) does not + // report it as its own. + static_cast(cudaGetLastError()); + return false; + } + return device_count > 0; +} + +} // namespace ipc::cuda diff --git a/src/ipc/utils/cuda/is_available.hpp b/src/ipc/utils/cuda/is_available.hpp new file mode 100644 index 000000000..f80d58fe2 --- /dev/null +++ b/src/ipc/utils/cuda/is_available.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include + +namespace ipc::cuda { + +#ifdef IPC_TOOLKIT_WITH_CUDA + +/// @brief Check whether the ipc::cuda implementations can run here. +/// +/// A CUDA build still runs on machines without a GPU or an NVIDIA driver; its +/// ipc::cuda calls then throw on first use. Call this first to choose between +/// the GPU and CPU implementations. +/// +/// @return True if the CUDA runtime finds at least one usable device. +bool is_available(); + +#else + +/// @brief Check whether the ipc::cuda implementations can run here. +/// @return Always false: this build has no CUDA support. +inline bool is_available() { return false; } + +#endif + +} // namespace ipc::cuda diff --git a/tests/src/tests/utils/CMakeLists.txt b/tests/src/tests/utils/CMakeLists.txt index c386e8915..c4386badf 100644 --- a/tests/src/tests/utils/CMakeLists.txt +++ b/tests/src/tests/utils/CMakeLists.txt @@ -1,5 +1,6 @@ set(SOURCES # Tests + test_cuda_is_available.cpp test_local_to_global.cpp test_matrixcache.cpp test_profiler.cpp diff --git a/tests/src/tests/utils/test_cuda_is_available.cpp b/tests/src/tests/utils/test_cuda_is_available.cpp new file mode 100644 index 000000000..ed720d822 --- /dev/null +++ b/tests/src/tests/utils/test_cuda_is_available.cpp @@ -0,0 +1,36 @@ +#include + +#include +#include +#include + +TEST_CASE( + "CUDA availability predicts whether the GPU broad phase runs", + "[utils][cuda]") +{ +#ifdef IPC_TOOLKIT_WITH_CUDA + // Two parallel triangles 0.05 apart, so an inflation radius of 0.1 makes + // every vertex of one overlap the face of the other. + Eigen::MatrixXd vertices(6, 3); + vertices << 0, 0, 0, 1, 0, 0, 0, 1, 0, // + 0, 0, 0.05, 1, 0, 0.05, 0, 1, 0.05; + Eigen::MatrixXi faces(2, 3); + faces << 0, 1, 2, 3, 4, 5; + Eigen::MatrixXi edges(6, 2); + edges << 0, 1, 1, 2, 2, 0, 3, 4, 4, 5, 5, 3; + + ipc::cuda::LBVH lbvh; + ipc::Candidates candidates; + if (ipc::cuda::is_available()) { + lbvh.build(vertices, edges, faces, /*inflation_radius=*/0.1); + lbvh.detect_collision_candidates(candidates); + CHECK(!candidates.fv_candidates.empty()); + } else { + // Without a device, the first CUDA call fails and we report it as an + // exception rather than crashing. + CHECK_THROWS(lbvh.build(vertices, edges, faces, 0.1)); + } +#else + CHECK_FALSE(ipc::cuda::is_available()); +#endif +}