Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 107 additions & 8 deletions .github/workflows/pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,38 +9,91 @@ 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

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/[email protected]
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/[email protected]
# to supply options, put them in 'env', like:
Expand All @@ -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:
Expand All @@ -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:
Expand Down
8 changes: 7 additions & 1 deletion cmake/ipc_toolkit/ipc_toolkit_warnings.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -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++ <cmath> 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
"$<$<AND:$<COMPILE_LANGUAGE:CUDA>,$<CUDA_COMPILER_ID:NVIDIA>,$<CXX_COMPILER_ID:MSVC>>:-diag-suppress=128,2417>")
"$<$<AND:$<COMPILE_LANGUAGE:CUDA>,$<CUDA_COMPILER_ID:NVIDIA>,$<CXX_COMPILER_ID:MSVC>>:-diag-suppress=128,221,2417>")
endif()
5 changes: 5 additions & 0 deletions docs/source/cpp-api/utils.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ Logger
.. doxygenfunction:: ipc::logger
.. doxygenfunction:: ipc::set_logger

CUDA Availability
-----------------

.. doxygenfunction:: ipc::cuda::is_available

Positive Semi-Definite Projection
---------------------------------

Expand Down
4 changes: 3 additions & 1 deletion docs/source/python-api/broad_phase.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
..
Expand Down
5 changes: 5 additions & 0 deletions docs/source/python-api/utils.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
---------------------------------

Expand Down
12 changes: 12 additions & 0 deletions python/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion python/src/bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions python/src/utils/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
set(SOURCES
cuda_is_available.cpp
eigen_ext.cpp
hessian_assembler.cpp
logger.cpp
Expand Down
1 change: 1 addition & 0 deletions python/src/utils/bindings.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

#include <pybind11/pybind11.h>

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);
Expand Down
20 changes: 20 additions & 0 deletions python/src/utils/cuda_is_available.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#include <common.hpp>

#include <ipc/utils/cuda/is_available.hpp>

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");
}
13 changes: 13 additions & 0 deletions python/tests/check_cuda_wheel.py
Original file line number Diff line number Diff line change
@@ -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")
40 changes: 40 additions & 0 deletions python/tests/test_cuda.py
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 2 additions & 0 deletions src/ipc/utils/cuda/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
set(SOURCES
device_buffer.cuh
device_utils.cuh
is_available.cu
is_available.hpp
)

target_sources(ipc_toolkit PRIVATE ${SOURCES})
21 changes: 21 additions & 0 deletions src/ipc/utils/cuda/is_available.cu
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#include "is_available.hpp"

#include <cuda_runtime.h>

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<void>(cudaGetLastError());
return false;
}
return device_count > 0;
}

} // namespace ipc::cuda
26 changes: 26 additions & 0 deletions src/ipc/utils/cuda/is_available.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#pragma once

#include <ipc/config.hpp>

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
1 change: 1 addition & 0 deletions tests/src/tests/utils/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
set(SOURCES
# Tests
test_cuda_is_available.cpp
test_local_to_global.cpp
test_matrixcache.cpp
test_profiler.cpp
Expand Down
Loading
Loading