Skip to content
Open
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
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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 '+')]"
Comment on lines +148 to +149

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the relevant README section with line numbers.
sed -n '136,160p' README.md | cat -n

echo
echo '--- shell behavior probe ---'
tmpdir="$(mktemp -d)"
cd "$tmpdir"

# Case 1: no matches
printf 'case 1 (no matches)\n'
shopt -u nullglob
wheel=$(echo cuvslam-*.whl)
printf 'wheel=<%s>\n' "$wheel"
printf 'tags=<%s>\n' "$(grep -oE '\+cu[0-9]+' <<<"$wheel" | tr -d '+')"

# Case 2: one match
printf '\ncase 2 (one match)\n'
touch 'cuvslam-a+cu12.whl'
wheel=$(echo cuvslam-*.whl)
printf 'wheel=<%s>\n' "$wheel"
printf 'tags=<%s>\n' "$(grep -oE '\+cu[0-9]+' <<<"$wheel" | tr -d '+')"
printf 'pip arg=<%s>\n' "$wheel[$(grep -oE '\+cu[0-9]+' <<<"$wheel" | tr -d '+')]"

# Case 3: two matches
printf '\ncase 3 (two matches)\n'
touch 'cuvslam-b+cu13.whl'
wheel=$(echo cuvslam-*.whl)
printf 'wheel=<%s>\n' "$wheel"
printf 'tags raw:\n'
grep -oE '\+cu[0-9]+' <<<"$wheel" | cat -n
printf 'tags stripped:\n'
grep -oE '\+cu[0-9]+' <<<"$wheel" | tr -d '+' | cat -n
printf 'pip arg=<%s>\n' "$wheel[$(grep -oE '\+cu[0-9]+' <<<"$wheel" | tr -d '+')]"

Repository: nvidia-isaac/cuVSLAM

Length of output: 1787


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '140,155p' README.md | cat -n

Repository: nvidia-isaac/cuVSLAM

Length of output: 946


Require a single wheel match before deriving the CUDA extra. wheel=$(echo cuvslam-*.whl) can expand to the literal glob when nothing matches, or to multiple filenames when more than one wheel is present; in the latter case the grep output is concatenated into an invalid pip install argument. Guard for exactly one wheel before building the extra.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 148 - 149, Update the README installation command
around wheel discovery to require exactly one matching cuvslam wheel before
deriving the CUDA extra. Reject both no-match cases, including the literal glob,
and multiple matches; only then extract the CUDA suffix and pass the single
wheel with its extra to pip.

```

If a pre-built wheel is not available for your system, see [Install from Source](#install-from-source) below.
Expand Down
1 change: 1 addition & 0 deletions python/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
Expand Down
16 changes: 14 additions & 2 deletions python/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
86 changes: 86 additions & 0 deletions python/_cuda_libs.py
Original file line number Diff line number Diff line change
@@ -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 ``<site-packages>/nvidia/<component>/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
19 changes: 19 additions & 0 deletions python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
114 changes: 114 additions & 0 deletions python/test/test_cuda_libs.py
Original file line number Diff line number Diff line change
@@ -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 <site-packages>/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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
self.assertEqual(probe.returncode, 0, probe.stderr)


if __name__ == "__main__":
unittest.main()
60 changes: 60 additions & 0 deletions scripts/verify_pycuvslam_wheel_in_docker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -42,6 +44,23 @@ fi

WHEEL_NAME=$(basename "${WHEELS[0]}")

# Wheels are versioned <version>+cu12/<version>+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"

Expand All @@ -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 '
Expand Down Expand Up @@ -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 - <<PY
import os

# Imported for its side effect: the preloading this stage verifies happens while cuvslam is imported.
import cuvslam
from cuvslam import _cuda_libs

# Require the set the package itself declares it needs rather than a copy of it, and resolve it the same way the
# import-time preload does. A component listed in _cuda_libs.CUDA_COMPONENTS with no matching entry in the extras of
# pyproject.toml is precisely the drift this stage exists to catch, and the two lists stay independent that way.
nvidia_root = _cuda_libs.nvidia_root()
with open("/proc/self/maps") as maps_file:
maps = maps_file.read()

unresolved = [name for name in _cuda_libs.CUDA_COMPONENTS
if os.path.join(nvidia_root, name, "lib") not in maps]
if unresolved:
raise SystemExit(
"cuvslam did not load " + ", ".join(unresolved) + " from " + nvidia_root +
": the CUDA extra does not cover every CUDA library excluded from the wheel"
)

print("cuvslam wheel import OK with CUDA libraries from", nvidia_root)
PY
'