diff --git a/README.md b/README.md index e746380..794dbd8 100644 --- a/README.md +++ b/README.md @@ -121,8 +121,14 @@ Only the combinations listed above are provided as pre-built wheels. The `cp312- and are compatible with Python 3.12 and later. Other Python, CUDA, or Jetson combinations require an [installation from source](#install-from-source). -**Prerequisite**: [CUDA Toolkit 12 or 13](https://developer.nvidia.com/cuda/toolkit) must be installed separately -(not included in the wheels). Its major version must match the wheel's `cu12` or `cu13` tag. +**Prerequisite**: the wheels link against CUDA and the CUDA math libraries (cuBLAS, cuSOLVER, cuSPARSE) but do not +bundle them, so the environment has to provide them in one of two ways: + +- a [CUDA Toolkit 12 or 13](https://developer.nvidia.com/cuda/toolkit) installation whose major version matches the + wheel's `cu12` or `cu13` tag, or +- the wheel's matching `cu12`/`cu13` extra, which pulls the math libraries in as `nvidia-*` pip packages + (see step 3 below). A CUDA driver is still required; only the toolkit installation is not. This is meant for x86_64 + systems without a toolkit — on Jetson, CUDA comes with JetPack, so use that rather than the extra. Official wheels include cuNLS support for `Multisensor` mode; no separate cuNLS installation is required. @@ -135,6 +141,12 @@ To install (virtual environment is recommended): ```bash pip install cuvslam-*.whl +# ...or, without a CUDA Toolkit installation, with the CUDA math libraries from pip. The extra has to match the +# wheel's CUDA major: every wheel declares both cu12 and cu13, so pip accepts a mismatched one without an error +# and installs math libraries of the wrong major. Reading the extra off the wheel's own +cu12/+cu13 tag rather +# than typing it keeps the two in step: +wheel=$(echo cuvslam-*.whl) +pip install "$wheel[$(echo "$wheel" | grep -oE '\+cu[0-9]+' | tr -d '+')]" ``` If a pre-built wheel is not available for your system, see [Install from Source](#install-from-source) below. diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt index c77f436..71b4896 100644 --- a/python/CMakeLists.txt +++ b/python/CMakeLists.txt @@ -68,6 +68,7 @@ install(FILES ${CUVSLAM_LIBRARY} DESTINATION .) install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/__init__.py + ${CMAKE_CURRENT_SOURCE_DIR}/_cuda_libs.py ${CMAKE_CURRENT_SOURCE_DIR}/tracker.py ${CMAKE_CURRENT_SOURCE_DIR}/utils.py DESTINATION . diff --git a/python/__init__.py b/python/__init__.py index 9048259..70dd3ee 100644 --- a/python/__init__.py +++ b/python/__init__.py @@ -9,6 +9,20 @@ """cuVSLAM Python bindings.""" +from . import _cuda_libs + +# Make the CUDA math libraries this package links against, but does not bundle, loadable when they come from the +# nvidia-* pip packages instead of a CUDA Toolkit installation. No-op otherwise. +_cuda_libs.preload() + +try: + # Import all bindings under core namespace + from . import pycuvslam as core +except ImportError as error: + if 'cannot open shared object file' not in str(error): + raise + raise ImportError('{}\n\n{}'.format(error, _cuda_libs.MISSING_LIBRARY_HINT)) from error + # Import select bindings for the main namespace from .pycuvslam import ( get_version, @@ -25,8 +39,6 @@ PoseEstimate, Observation, Landmark) -# Import all bindings under core namespace -from . import pycuvslam as core # Import the wrapper class from .tracker import Tracker diff --git a/python/_cuda_libs.py b/python/_cuda_libs.py new file mode 100644 index 0000000..0849edf --- /dev/null +++ b/python/_cuda_libs.py @@ -0,0 +1,86 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +# +# NVIDIA software released under the NVIDIA Community License is intended to be used to enable +# the further development of AI and robotics technologies. Such software has been designed, tested, +# and optimized for use with NVIDIA hardware, and this License grants permission to use the software +# solely with such hardware. +# Subject to the terms of this License, NVIDIA confirms that you are free to commercially use, +# modify, and distribute the software with NVIDIA hardware. NVIDIA does not claim ownership of any +# outputs generated using the software or derivative works thereof. Any code contributions that you +# share with NVIDIA are licensed to NVIDIA as feedback under this License and may be incorporated +# in future releases without notice or attribution. +# By using, reproducing, modifying, distributing, performing, or displaying any portion or element +# of the software or derivative works thereof, you agree to be bound by this License. + +"""Resolution of the CUDA math libraries that PyCuVSLAM links against but does not bundle. + +``libcuvslam.so`` links against cuBLAS, cuSOLVER, cuSPARSE and their dependencies. Wheels deliberately exclude them +during ``auditwheel repair`` (see ``scripts/build_pycuvslam_in_docker.sh``) because they are large, so they have to be +provided by the environment: either by a CUDA Toolkit installation, or by the ``nvidia-*`` pip packages that the +``cu12``/``cu13`` extras of this package declare. + +Pip installs those packages into ``/nvidia//lib``, a location the dynamic loader does not +search. Loading them here by absolute path, before the extension module is imported, registers their sonames with the +loader so ``libcuvslam.so`` resolves against them without the caller having to set ``LD_LIBRARY_PATH``. +""" + +import ctypes +import os +from glob import glob + +# Components of the pip CUDA layout that PyCuVSLAM needs, ordered so that a library is loaded after the libraries it +# depends on (cuSOLVER needs cuBLAS and cuSPARSE, which in turn need nvJitLink). preload() does not rely on the order +# being exactly right, it retries, but a good order keeps the common case to a single pass. +CUDA_COMPONENTS = ('nvjitlink', 'cublas', 'cusparse', 'cusolver') + +# Appended to the ImportError raised when the extension module cannot be loaded because of a missing library. +MISSING_LIBRARY_HINT = ( + "PyCuVSLAM links against the CUDA math libraries (cuBLAS, cuSOLVER, cuSPARSE) but does not bundle them.\n" + "Provide them either by installing a CUDA Toolkit whose major version matches this wheel's cu12/cu13 tag, or by\n" + "installing the matching pip packages:\n" + " pip install 'cuvslam[cu12]' # CUDA 12 wheels\n" + " pip install 'cuvslam[cu13]' # CUDA 13 wheels") + + +def nvidia_root(package_root=None): + """Return the directory the nvidia-* pip packages install into, next to this package.""" + if package_root is None: + package_root = os.path.dirname(os.path.abspath(__file__)) + return os.path.join(os.path.dirname(package_root), 'nvidia') + + +def candidate_libraries(package_root=None): + """Return the pip-provided CUDA libraries to preload, in dependency order.""" + root = nvidia_root(package_root) + if not os.path.isdir(root): + return [] + candidates = [] + for component in CUDA_COMPONENTS: + candidates.extend(sorted(glob(os.path.join(root, component, 'lib', 'lib*.so.*')))) + return candidates + + +def preload(package_root=None): + """Load the pip-provided CUDA math libraries, if any, and return the paths that were loaded. + + Does nothing when the nvidia-* packages are not installed: the libraries are then expected to come from a system + CUDA Toolkit, which the loader finds on its own. + """ + pending = candidate_libraries(package_root) + loaded = [] + # A library whose own dependencies are not loaded yet fails to load, so sweep the list until a full pass makes no + # progress. Whatever is left unloaded is not reported here; the extension import below produces the actionable + # error message. + while pending: + remaining = [] + for path in pending: + try: + ctypes.CDLL(path, mode=ctypes.RTLD_GLOBAL) + except OSError: + remaining.append(path) + else: + loaded.append(path) + if len(remaining) == len(pending): + break + pending = remaining + return loaded diff --git a/python/pyproject.toml b/python/pyproject.toml index fc5effb..c59086a 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -24,6 +24,25 @@ classifiers = [ ] dependencies = ["pyyaml>=5.3.1"] +# libcuvslam.so links against the CUDA math libraries, but wheels exclude them during auditwheel repair +# (see scripts/build_pycuvslam_in_docker.sh) because of their size. They normally come from a CUDA Toolkit +# installation; these extras provide them from pip instead. Install the one matching the wheel's cu12/cu13 tag: +# `pip install "cuvslam[cu12]"`. CUDA 12 packages carry the -cu12 suffix; from CUDA 13 on, NVIDIA publishes the +# unsuffixed names and bumps them per CUDA release, so those are constrained to the matching major version. +[project.optional-dependencies] +cu12 = [ + "nvidia-cublas-cu12", + "nvidia-cusolver-cu12", + "nvidia-cusparse-cu12", + "nvidia-nvjitlink-cu12", +] +cu13 = [ + "nvidia-cublas>=13,<14", + "nvidia-cusolver>=12,<13", + "nvidia-cusparse>=12,<13", + "nvidia-nvjitlink>=13,<14", +] + [tool.scikit-build] minimum-version = "build-system.requires" cmake.version = "CMakeLists.txt" diff --git a/python/test/test_cuda_libs.py b/python/test/test_cuda_libs.py new file mode 100644 index 0000000..c01813b --- /dev/null +++ b/python/test/test_cuda_libs.py @@ -0,0 +1,114 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +# +# NVIDIA software released under the NVIDIA Community License is intended to be used to enable +# the further development of AI and robotics technologies. Such software has been designed, tested, +# and optimized for use with NVIDIA hardware, and this License grants permission to use the software +# solely with such hardware. +# Subject to the terms of this License, NVIDIA confirms that you are free to commercially use, +# modify, and distribute the software with NVIDIA hardware. NVIDIA does not claim ownership of any +# outputs generated using the software or derivative works thereof. Any code contributions that you +# share with NVIDIA are licensed to NVIDIA as feedback under this License and may be incorporated +# in future releases without notice or attribution. +# By using, reproducing, modifying, distributing, performing, or displaying any portion or element +# of the software or derivative works thereof, you agree to be bound by this License. + +import importlib.util +import os +import subprocess +import sys +import tempfile +import unittest + + +def load_cuda_libs(): + """Load ``cuvslam/_cuda_libs.py`` as a standalone module, without importing ``cuvslam``. + + Importing the package runs ``preload()`` and loads the extension module, which is exactly the side effect + test_import_resolved_the_cuda_math_libraries has to observe in an interpreter that has not done it yet. + """ + package_root = importlib.util.find_spec('cuvslam').submodule_search_locations[0] + spec = importlib.util.spec_from_file_location('_cuda_libs_under_test', + os.path.join(package_root, '_cuda_libs.py')) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +_cuda_libs = load_cuda_libs() + +# Run in a fresh interpreter by test_import_resolved_the_cuda_math_libraries. +IMPORT_SIDE_EFFECT_PROBE = """ +import cuvslam # noqa: F401 (imported for its side effect on the process' loaded libraries) + +with open('/proc/self/maps') as maps_file: + maps = maps_file.read() + +missing = [soname for soname in ('libcublas.so.', 'libcusolver.so.', 'libcusparse.so.') if soname not in maps] +if missing: + raise SystemExit('not loaded after "import cuvslam": ' + ', '.join(missing)) +""" + + +def make_pip_cuda_layout(root, components): + """Create a fake /nvidia layout next to a fake cuvslam package directory.""" + package_root = os.path.join(root, 'cuvslam') + os.makedirs(package_root) + for component, libraries in components.items(): + lib_dir = os.path.join(root, 'nvidia', component, 'lib') + os.makedirs(lib_dir) + for library in libraries: + # Not valid ELF: enough to be discovered, never loadable. + open(os.path.join(lib_dir, library), 'wb').close() + return package_root + + +class TestCudaLibs(unittest.TestCase): + def test_no_pip_cuda_packages_installed(self): + # The libraries then come from the system CUDA Toolkit and there is nothing to preload. + with tempfile.TemporaryDirectory() as root: + package_root = os.path.join(root, 'cuvslam') + os.makedirs(package_root) + self.assertEqual(_cuda_libs.candidate_libraries(package_root), []) + self.assertEqual(_cuda_libs.preload(package_root), []) + + def test_candidates_are_discovered_in_dependency_order(self): + with tempfile.TemporaryDirectory() as root: + package_root = make_pip_cuda_layout(root, { + 'cusolver': ['libcusolver.so.11'], + 'cublas': ['libcublas.so.12', 'libcublasLt.so.12'], + 'cusparse': ['libcusparse.so.12'], + 'nvjitlink': ['libnvJitLink.so.12']}) + found = [os.path.basename(path) for path in _cuda_libs.candidate_libraries(package_root)] + self.assertEqual(found, [ + 'libnvJitLink.so.12', 'libcublas.so.12', 'libcublasLt.so.12', 'libcusparse.so.12', + 'libcusolver.so.11']) + + def test_unrelated_components_are_ignored(self): + with tempfile.TemporaryDirectory() as root: + package_root = make_pip_cuda_layout(root, { + 'cudnn': ['libcudnn.so.9'], + 'cusparse': ['libcusparse.so.12']}) + found = [os.path.basename(path) for path in _cuda_libs.candidate_libraries(package_root)] + self.assertEqual(found, ['libcusparse.so.12']) + + def test_unloadable_libraries_do_not_raise(self): + # A library that cannot be loaded is left to the extension import to report, and the retry loop that + # tolerates an unknown load order must still terminate. + with tempfile.TemporaryDirectory() as root: + package_root = make_pip_cuda_layout(root, { + 'cublas': ['libcublas.so.12'], + 'cusolver': ['libcusolver.so.11']}) + self.assertEqual(_cuda_libs.preload(package_root), []) + + def test_import_resolved_the_cuda_math_libraries(self): + # Whatever provided them, the CUDA math libraries libcuvslam.so links against are loaded once cuvslam + # imports: this is the regression guard for wheels that neither bundle nor declare them. It runs in a + # fresh interpreter, because a cuvslam already imported by another test would load them regardless of + # whether importing it still does. + probe = subprocess.run([sys.executable, '-c', IMPORT_SIDE_EFFECT_PROBE], + capture_output=True, text=True, check=False) + self.assertEqual(probe.returncode, 0, probe.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify_pycuvslam_wheel_in_docker.sh b/scripts/verify_pycuvslam_wheel_in_docker.sh index e34ca86..8932c5a 100755 --- a/scripts/verify_pycuvslam_wheel_in_docker.sh +++ b/scripts/verify_pycuvslam_wheel_in_docker.sh @@ -7,6 +7,8 @@ if [ "$#" -ne 1 ] && [ "$#" -ne 3 ]; then echo " fresh environment and imports cuvslam, verifying the wheel filename is valid" echo " (pip-installable) and the auditwheel-repaired extension loads with the" echo " excluded CUDA libraries resolved from the system." + echo " Then reinstalls it with its cu12/cu13 extra in a clean environment and verifies that" + echo " the excluded CUDA libraries also resolve from the nvidia-* pip packages alone." echo " When expected_version and expected_git_sha are provided, also verifies that" echo " get_version() identifies that clean source revision without '-modified'." exit 1 @@ -42,6 +44,23 @@ fi WHEEL_NAME=$(basename "${WHEELS[0]}") +# Wheels are versioned +cu12/+cu13 by build_pycuvslam_in_docker.sh; the same tag names the extra +# that provides the CUDA math libraries excluded from the wheel. Empty for wheels built without the tag. +CUDA_EXTRA=$(echo "$WHEEL_NAME" | grep -oE "\+cu[0-9]+" | tr -d "+" || true) +if [ -z "$CUDA_EXTRA" ]; then + echo "Note: $WHEEL_NAME carries no +cuNN version tag; skipping the pip-provided CUDA libraries check." +fi + +# The extras target x86_64 systems without a CUDA Toolkit. The aarch64 entries in the wheel matrix are Jetson, where +# CUDA comes from JetPack and the aarch64 nvidia-* wheels are server-ARM (SBSA) builds, so installing those over a +# Tegra CUDA is not the configuration this check certifies. Decide that from the architecture, before touching pip: a +# resolver failure is what a broken extras declaration looks like too, and that must fail the verification. +HOST_ARCH=$(uname -m) +if [ -n "$CUDA_EXTRA" ] && [ "$HOST_ARCH" != "x86_64" ]; then + echo "SKIPPED: no pip-provided CUDA libraries check on $HOST_ARCH; the $CUDA_EXTRA extra targets x86_64 systems." + CUDA_EXTRA="" +fi + TTY_FLAG="" [ -t 0 ] && TTY_FLAG="-it" @@ -52,6 +71,7 @@ docker run --runtime=nvidia --gpus all --rm $TTY_FLAG --network host \ --user "$(id -u):$(id -g)" --group-add video -e HOME=/tmp \ -v "$OUTPUT_DIR:/output:ro" \ -e WHEEL_NAME="$WHEEL_NAME" \ + -e CUDA_EXTRA="$CUDA_EXTRA" \ -e EXPECTED_VERSION="$EXPECTED_VERSION" \ -e EXPECTED_GIT_SHA="$EXPECTED_GIT_SHA" \ cuvslam:local bash -c ' @@ -86,5 +106,45 @@ if expected_version: ) print("cuvslam wheel import OK, version:", version_info) +PY + + if [ -z "$CUDA_EXTRA" ]; then + exit 0 + fi + + # The wheel does not bundle cuBLAS/cuSOLVER/cuSPARSE, and this image provides them system-wide, so the check + # above cannot tell a wheel that declares them from one that silently depends on the CUDA Toolkit being + # installed. Install the wheel with its CUDA extra into a clean environment instead, and require that the + # libraries actually loaded are the pip-provided ones. + echo "--- Verifying the [$CUDA_EXTRA] extra provides the CUDA math libraries ---" + python3 -m venv /tmp/wheel_venv_cuda_extra + . /tmp/wheel_venv_cuda_extra/bin/activate + # A resolution failure here means the extra does not describe an installable set of packages, which is the very + # thing this stage exists to catch, so it fails the verification. + pip install --no-cache-dir "/output/wheel/$WHEEL_NAME[$CUDA_EXTRA]" + cd /tmp + python3 - <