Add faiss-gpu pip wheel packaging (#5131)

Summary:
Pull Request resolved: https://github.com/facebookresearch/faiss/pull/5131

Add pip wheel packaging for `faiss-gpu`, enabling `pip install faiss-gpu`
from PyPI. Builds GPU wheels for Linux x86_64, Python 3.10-3.13, with
CUDA 12.6 on manylinux_2_28.

**New files:**
- `pyproject-gpu.toml`: scikit-build-core config for faiss-gpu. Enables
  CUDA GPU support, uses Dynamic Dispatch, OpenBLAS, and excludes CUDA
  runtime libs (provided by nvidia-cuda-runtime-cu12 pip dependency).
- `.github/workflows/build-pip-gpu.yml`: CI workflow for GPU wheels using
  cibuildwheel on GPU runners (4-core-ubuntu-gpu-t4).
- `tests/test_wheel_smoke_gpu.py`: GPU smoke tests validating GPU
  detection, StandardGpuResources, GPU FlatL2/IP search, IVF+PQ on GPU,
  CPU<->GPU transfer roundtrip, and GPU serialization.

**Modified files:**
- `python/__init__.py`: Added `_preload_gpu_libs()` to pre-load CUDA
  shared libraries from pip packages before SWIG import. No-op on
  CPU-only installs.
- `.github/workflows/build.yml`: Added `build-pip-gpu` workflow call.
- `tests/BUCK`: Added `test_wheel_smoke_gpu` target for Buck CI.

Reviewed By: junjieqi

Differential Revision: D97507525

fbshipit-source-id: f8b5fa08405aa53267e791c2c0d263e2d6190741
This commit is contained in:
Alibek Zhakubayev
2026-05-28 10:46:55 -07:00
committed by meta-codesync[bot]
parent 09937153dc
commit 5d9aae6ee8
6 changed files with 522 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
name: Build GPU pip wheel
on:
workflow_call:
workflow_dispatch:
concurrency:
# One run per ref. Tag pushes are not cancelable — a half-canceled release
# build leaves PyPI in an inconsistent state. Branch/PR runs supersede.
group: gpu-pip-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') }}
jobs:
build-faiss-gpu:
name: Build & test faiss-gpu
runs-on: 4-core-ubuntu-gpu-t4
timeout-minutes: 90
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Activate GPU pyproject.toml
# cibuildwheel + scikit-build-core both require literal pyproject.toml.
# Workspace is fresh per job (actions/checkout creates new tree).
run: cp pyproject-gpu.toml pyproject.toml
- name: Set up Python
# Pin host Python explicitly: the 4-core-ubuntu-gpu-t4 runner image
# ships a broken Python 3.14.4 in its tool cache (libpython3.14.so.1.0
# missing), and pypa/cibuildwheel picks that up by default.
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: '3.12'
- name: Build wheel (cibuildwheel)
uses: pypa/cibuildwheel@ee02a1537ce3071a004a6b08c41e72f0fdc42d9a # v3.4.0
env:
CIBW_BUILD_VERBOSITY: 1
- name: Upload wheel
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: wheels-gpu
path: wheelhouse/*.whl
publish-gpu:
name: Publish faiss-gpu to PyPI
needs: [build-faiss-gpu]
runs-on: ubuntu-latest
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
environment:
name: pypi
url: https://pypi.org/p/faiss-gpu
permissions:
id-token: write
contents: read
steps:
- name: Download wheel
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: wheels-gpu
path: dist
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # release/v1
with:
verbose: true
attestations: false
publish-testpypi-gpu:
name: Publish faiss-gpu to TestPyPI
needs: [build-faiss-gpu]
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch'
environment:
name: testpypi
url: https://test.pypi.org/p/faiss-gpu
permissions:
id-token: write
contents: read
steps:
- name: Download wheel
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: wheels-gpu
path: dist
- name: Publish to TestPyPI
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # release/v1
with:
repository-url: https://test.pypi.org/legacy/
skip-existing: true
verbose: true
+2
View File
@@ -17,3 +17,5 @@ jobs:
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
build-pip:
uses: ./.github/workflows/build-pip.yml
build-pip-gpu:
uses: ./.github/workflows/build-pip-gpu.yml
+13
View File
@@ -442,6 +442,19 @@ install(FILES
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/__init__.pyi")
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/__init__.pyi" DESTINATION faiss)
endif()
# GPU build marker: only present in faiss-gpu wheels. Used by __init__.py to
# decide whether to preload CUDA shared libs from nvidia-*-cu12 pip packages.
# Gating on this marker (instead of importability of `nvidia.cuda_runtime`)
# prevents false positives in faiss-cpu environments where PyTorch or cupy
# have transitively pulled in the nvidia-* packages.
if(FAISS_ENABLE_GPU)
file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/_gpu_build.py"
"# Auto-generated when FAISS_ENABLE_GPU=ON.\n"
"IS_GPU_BUILD = True\n")
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/_gpu_build.py" DESTINATION faiss)
endif()
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/py.typed")
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/py.typed" DESTINATION faiss)
endif()
+78
View File
@@ -13,6 +13,84 @@ import logging
import sys
import inspect
def _preload_gpu_libs():
"""Pre-load CUDA shared libraries from nvidia-*-cu12 pip packages.
The libcudart.so / libcublas.so / libcurand.so files live in
site-packages/nvidia/{cuda_runtime,cublas,curand}/lib/ — not on the
default ld.so search path. Pre-load them with RTLD_GLOBAL before the
SWIG extension dlopen()s libfaiss.so (which has CUDA undefined symbols).
Gating: keyed on the `faiss._gpu_build` module, which CMake writes only
when FAISS_ENABLE_GPU=ON. We can't gate on `nvidia.cuda_runtime`
importability — PyTorch and cupy CUDA wheels pull that in transitively,
so a faiss-cpu user with PyTorch+CUDA would otherwise hit this path and
see a misleading error if any nvidia-* package were missing.
Behavior:
- faiss-cpu install (no `_gpu_build` marker): silent no-op.
- faiss-gpu install with missing nvidia-cuda-runtime / cublas / curand:
raises RuntimeError with fix-it message.
"""
try:
from . import _gpu_build # noqa: F401
except ImportError:
return # faiss-cpu install: marker absent, nothing to preload
import ctypes
import os
try:
import nvidia.cuda_runtime
except ImportError as e:
raise RuntimeError(
"faiss-gpu installed but nvidia-cuda-runtime-cu12 is missing — "
"pip install 'nvidia-cuda-runtime-cu12>=12.6,<13'"
) from e
try:
import nvidia.cublas
except ImportError as e:
raise RuntimeError(
"faiss-gpu installed but nvidia-cublas-cu12 is missing — "
"pip install 'nvidia-cublas-cu12>=12.6,<13'"
) from e
try:
import nvidia.curand
except ImportError as e:
raise RuntimeError(
"faiss-gpu installed but nvidia-curand-cu12 is missing — "
"pip install 'nvidia-curand-cu12>=10.3.7,<11'"
) from e
# Order matters: libcudart provides symbols that libcublas* / libcurand depend on.
_LIBS = [
(nvidia.cuda_runtime, "libcudart.so.12"),
(nvidia.cublas, "libcublas.so.12"),
(nvidia.cublas, "libcublasLt.so.12"),
(nvidia.curand, "libcurand.so.10"),
]
for pkg, soname in _LIBS:
# Use __path__[0] not __file__ — the nvidia-*-cu12 wheels are PEP 420
# implicit namespace packages, so pkg.__file__ is None.
so_path = os.path.join(pkg.__path__[0], "lib", soname)
try:
ctypes.CDLL(so_path, mode=ctypes.RTLD_GLOBAL)
except OSError as e:
raise RuntimeError(
f"faiss-gpu: failed to load {soname} from {so_path} — "
f"corrupt nvidia-*-cu12 wheel? Try: pip install --force-reinstall "
f"'nvidia-cuda-runtime-cu12>=12.6,<13' "
f"'nvidia-cublas-cu12>=12.6,<13' "
f"'nvidia-curand-cu12>=10.3.7,<11'"
) from e
_preload_gpu_libs()
del _preload_gpu_libs
# We import * so that the symbol foo can be accessed as faiss.foo.
from .loader import *
+135
View File
@@ -0,0 +1,135 @@
# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
[build-system]
requires = ["scikit-build-core>=0.10", "swig>=4.2,<5", "numpy>=2.0", "cmake>=3.24", "ninja"]
build-backend = "scikit_build_core.build"
[project]
name = "faiss-gpu"
dynamic = ["version"]
description = "A library for efficient similarity search and clustering of dense vectors (GPU support)."
readme = "README.md"
license = "MIT"
license-files = ["LICENSE", "THIRD_PARTY_NOTICES"]
requires-python = ">=3.10"
authors = [
{name = "Meta AI Research"},
]
keywords = ["search", "nearest-neighbors", "clustering", "vectors", "similarity", "gpu", "cuda"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Intended Audience :: Science/Research",
"Operating System :: POSIX :: Linux",
"Programming Language :: C++",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: Implementation :: CPython",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Topic :: Software Development :: Libraries :: Python Modules",
"Environment :: GPU :: NVIDIA CUDA :: 12",
]
dependencies = [
"numpy>=1.25",
"packaging",
"nvidia-cuda-runtime-cu12>=12.6,<13",
"nvidia-cublas-cu12>=12.6,<13",
"nvidia-curand-cu12>=10.3.7,<11",
]
[project.urls]
Homepage = "https://github.com/facebookresearch/faiss"
Documentation = "https://github.com/facebookresearch/faiss/wiki"
Repository = "https://github.com/facebookresearch/faiss"
Issues = "https://github.com/facebookresearch/faiss/issues"
[tool.scikit-build]
cmake.build-type = "Release"
# Pull version from CMakeLists.txt's project(VERSION ...) — single source of truth.
metadata.version.provider = "scikit_build_core.metadata.regex"
metadata.version.input = "CMakeLists.txt"
metadata.version.regex = '^\s+VERSION\s+(?P<value>[0-9]+\.[0-9]+\.[0-9]+)'
# Exclude C++ headers, cmake configs, static libs, and binaries from the wheel.
# Only the Python package (faiss/) and bundled shared libs remain.
wheel.exclude = ["include/**", "share/**", "lib/**", "lib64/**", "bin/**"]
[tool.scikit-build.cmake.define]
FAISS_OPT_LEVEL = "dd"
FAISS_ENABLE_GPU = "ON"
FAISS_ENABLE_CUVS = "OFF"
FAISS_ENABLE_PYTHON = "ON"
FAISS_ENABLE_MKL = "OFF"
FAISS_ENABLE_C_API = "OFF"
FAISS_ENABLE_EXTRAS = "OFF"
FAISS_ENABLE_SVS = "OFF"
FAISS_USE_LTO = "ON"
CMAKE_CUDA_ARCHITECTURES = "70-real;75-real;80-real;86-real;89-real;90"
CMAKE_CUDA_FLAGS_RELEASE = "-O3 -Xfatbin=-compress-all --threads 4 -Xcompiler=-fdata-sections,-ffunction-sections"
CMAKE_C_FLAGS = "-fdata-sections -ffunction-sections"
CMAKE_CXX_FLAGS = "-fdata-sections -ffunction-sections"
CMAKE_SHARED_LINKER_FLAGS = "-Wl,--gc-sections -Wl,--strip-all"
CMAKE_MODULE_LINKER_FLAGS = "-Wl,--gc-sections -Wl,--strip-all"
BUILD_TESTING = "OFF"
BUILD_SHARED_LIBS = "ON"
# Stable ABI (abi3): one cp310 wheel covers all 3.10+.
# GPU is Linux-only; free-threaded Python (cp3*t) cannot use abi3.
[[tool.scikit-build.overrides]]
if.platform-system = "linux"
if.abi-flags = "^$" # Free-threaded Python does not support stable ABI.
wheel.py-api = "cp310"
[tool.cibuildwheel]
# Pass through the host GPU so the smoke test can actually exercise CUDA.
# Without this, faiss.get_num_gpus() returns 0 inside the container and all
# GPU tests silently skip — masking real packaging / driver / SASS-arch problems.
container-engine = { name = "docker", create-args = ["--gpus", "all"] }
build = "cp310-manylinux_x86_64"
test-requires = [
"numpy>=1.25,<3.0",
"pytest",
"nvidia-cuda-runtime-cu12>=12.6,<13",
"nvidia-cublas-cu12>=12.6,<13",
"nvidia-curand-cu12>=10.3.7,<11",
]
test-command = "python -m pytest {project}/tests/test_wheel_smoke_gpu.py -v"
[tool.cibuildwheel.linux]
manylinux-x86_64-image = "manylinux_2_28"
# manylinux_2_28 ships gcc-toolset-14; CUDA 12.6 only supports GCC <=13, so install
# gcc-toolset-13 explicitly and force the build to use it via CC/CXX/CUDAHOSTCXX below.
before-all = "dnf install -y gcc-toolset-13 epel-release && dnf install -y openblas-devel openblas-openmp swig && dnf config-manager --add-repo https://developer.download.nvidia.com/compute/cuda/repos/rhel8/x86_64/cuda-rhel8.repo && dnf install -y --setopt=obsoletes=0 cuda-nvcc-12-6 cuda-cudart-devel-12-6 cuda-cccl-12-6 cuda-profiler-api-12-6 libcublas-devel-12-6 libcurand-devel-12-6"
# auditwheel: exclude libcuda.so.1 (host driver, not pip-installable)
# and the libcudart/libcublas* shipped via nvidia-*-cu12 wheels (preloaded at import time).
repair-wheel-command = "auditwheel repair -w {dest_dir} {wheel} --exclude libcudart.so.12 --exclude libcublas.so.12 --exclude libcublasLt.so.12 --exclude libcurand.so.10 --exclude libcuda.so.1"
# Canary: ensure the host GPU is actually visible to the container before tests
# run. Without this, smoke tests would silently skip on broken --gpus passthrough.
before-test = "nvidia-smi || (echo 'GPU not visible to container; check container-engine create-args' && exit 1)"
# Run abi3audit on Linux to guarantee Stable ABI compliance.
# We use 'append' so that the auditwheel repair-wheel-command from
# [tool.cibuildwheel.linux] (with the CUDA --exclude flags) still runs first.
[[tool.cibuildwheel.overrides]]
select = "*-manylinux*"
inherit.repair-wheel-command = "append"
repair-wheel-command = "pipx run abi3audit --strict --report {wheel}"
# Build-tool paths. CUDA from /usr/local/cuda-12.6 (NVIDIA RPM convention);
# GCC pinned to toolset-13 because CUDA 12.6 does not support GCC 14.
# CUDAHOSTCXX must match CXX or nvcc host TUs get an ABI-mismatched libstdc++.
[tool.cibuildwheel.linux.environment]
CUDA_HOME = "/usr/local/cuda-12.6"
CUDACXX = "/usr/local/cuda-12.6/bin/nvcc"
CC = "/opt/rh/gcc-toolset-13/root/usr/bin/gcc"
CXX = "/opt/rh/gcc-toolset-13/root/usr/bin/g++"
CUDAHOSTCXX = "/opt/rh/gcc-toolset-13/root/usr/bin/g++"
+197
View File
@@ -0,0 +1,197 @@
# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
"""
Wheel smoke tests for faiss-gpu and faiss-gpu-cuvs pip packages.
These tests validate that a pip-installed GPU wheel is correctly packaged:
- CUDA shared libraries load (cudart, cublas)
- GPU devices are detected
- GPU index creation and search works
- GPU <-> CPU index transfer works
- cuVS/CAGRA integration works (when compiled with cuVS)
They are intentionally minimal and fast. The full GPU test suite
runs separately in the cmake-based CI.
"""
import functools
import unittest
import numpy as np
import faiss
# This file is the faiss-gpu wheel smoke test. It also gets collected by
# `pytest tests/` in the CPU wheel CI and by Buck's GPU-less test runner.
# Skip every class via decorator (rather than `raise SkipTest` at module
# scope) so pytest still collects the tests — pytest exits with code 5
# ("no tests collected") otherwise, which Buck treats as a fatal.
_GPU_BUILD = "GPU" in faiss.get_compile_options()
_skip_unless_gpu_build = unittest.skipUnless(
_GPU_BUILD, "Skipping faiss-gpu smoke tests: built without GPU support"
)
def skip_if_no_gpu(test_func):
"""Skip test if no GPU is available."""
@functools.wraps(test_func)
def wrapper(*args, **kwargs):
if faiss.get_num_gpus() == 0:
raise unittest.SkipTest("No GPU available")
return test_func(*args, **kwargs)
return wrapper
@_skip_unless_gpu_build
class TestGPUAvailability(unittest.TestCase):
"""Catch: CUDA libs not loaded, GPU not detected."""
def test_compile_options_include_gpu(self):
opts = faiss.get_compile_options()
self.assertIn(
"GPU", opts, f"faiss-gpu wheel missing GPU support: {opts}"
)
def test_gpu_count(self):
n = faiss.get_num_gpus()
if n < 1:
raise unittest.SkipTest(f"No GPU available (count={n})")
self.assertGreaterEqual(n, 1)
@_skip_unless_gpu_build
class TestGPUResources(unittest.TestCase):
"""Catch: CUDA runtime init failure, StandardGpuResources construction."""
@skip_if_no_gpu
def test_standard_gpu_resources(self):
res = faiss.StandardGpuResources()
# Verify the resource object is usable
self.assertIsNotNone(res)
@_skip_unless_gpu_build
class TestGPUFlatSearch(unittest.TestCase):
"""Catch: GPU kernel launch failures, CUDA memory allocation."""
@skip_if_no_gpu
def test_gpu_flat_l2(self):
d, n = 64, 1000
np.random.seed(42)
xb = np.random.random((n, d)).astype("float32")
res = faiss.StandardGpuResources()
index = faiss.GpuIndexFlatL2(res, d)
index.add(xb)
self.assertEqual(index.ntotal, n)
D, I = index.search(xb[:5], 10)
self.assertEqual(I.shape, (5, 10))
# Nearest neighbor of each vector should be itself
np.testing.assert_array_equal(I[:, 0], np.arange(5))
# Self-distance should be ~0
np.testing.assert_allclose(D[:, 0], 0, atol=1e-5)
@skip_if_no_gpu
def test_gpu_flat_ip(self):
d, n = 64, 1000
np.random.seed(42)
xb = np.random.random((n, d)).astype("float32")
faiss.normalize_L2(xb)
res = faiss.StandardGpuResources()
index = faiss.GpuIndexFlatIP(res, d)
index.add(xb)
D, I = index.search(xb[:5], 10)
self.assertEqual(I.shape, (5, 10))
np.testing.assert_array_equal(I[:, 0], np.arange(5))
np.testing.assert_allclose(D[:, 0], 1.0, atol=1e-5)
@_skip_unless_gpu_build
class TestGPUIVFPQ(unittest.TestCase):
"""Catch: GPU training, IVF + PQ kernel issues."""
@skip_if_no_gpu
def test_gpu_ivfpq(self):
d, n = 64, 10000
np.random.seed(42)
xb = np.random.random((n, d)).astype("float32")
res = faiss.StandardGpuResources()
# Build on CPU first, then transfer to GPU
cpu_index = faiss.index_factory(d, "IVF32,PQ8")
cpu_index.train(xb)
gpu_index = faiss.index_cpu_to_gpu(res, 0, cpu_index)
gpu_index.add(xb)
self.assertEqual(gpu_index.ntotal, n)
D, I = gpu_index.search(xb[:5], 10)
self.assertEqual(I.shape, (5, 10))
@_skip_unless_gpu_build
class TestGPUCPUTransfer(unittest.TestCase):
"""Catch: GPU<->CPU index serialization, memory transfer."""
@skip_if_no_gpu
def test_cpu_to_gpu_and_back(self):
d, n = 64, 1000
np.random.seed(42)
xb = np.random.random((n, d)).astype("float32")
# Build CPU index
cpu_index = faiss.IndexFlatL2(d)
cpu_index.add(xb)
D_cpu, I_cpu = cpu_index.search(xb[:5], 10)
# Transfer to GPU
res = faiss.StandardGpuResources()
gpu_index = faiss.index_cpu_to_gpu(res, 0, cpu_index)
self.assertEqual(gpu_index.ntotal, n)
D_gpu, I_gpu = gpu_index.search(xb[:5], 10)
# Transfer back to CPU
cpu_index2 = faiss.index_gpu_to_cpu(gpu_index)
self.assertEqual(cpu_index2.ntotal, n)
D_back, I_back = cpu_index2.search(xb[:5], 10)
# Results should match
np.testing.assert_array_equal(I_cpu, I_gpu)
np.testing.assert_array_equal(I_cpu, I_back)
np.testing.assert_allclose(D_cpu, D_gpu, atol=1e-5)
np.testing.assert_allclose(D_cpu, D_back, atol=1e-5)
@_skip_unless_gpu_build
class TestGPUSerialization(unittest.TestCase):
"""Catch: GPU index serialization roundtrip."""
@skip_if_no_gpu
def test_gpu_serialize_roundtrip(self):
d = 32
np.random.seed(42)
xb = np.random.random((100, d)).astype("float32")
res = faiss.StandardGpuResources()
gpu_index = faiss.GpuIndexFlatL2(res, d)
gpu_index.add(xb)
# Transfer to CPU for serialization
cpu_index = faiss.index_gpu_to_cpu(gpu_index)
data = faiss.serialize_index(cpu_index)
self.assertIsInstance(data, np.ndarray)
# Deserialize and transfer back to GPU
cpu_index2 = faiss.deserialize_index(data)
gpu_index2 = faiss.index_cpu_to_gpu(res, 0, cpu_index2)
self.assertEqual(gpu_index2.ntotal, 100)
D1, I1 = gpu_index.search(xb[:3], 5)
D2, I2 = gpu_index2.search(xb[:3], 5)
np.testing.assert_array_equal(I1, I2)
np.testing.assert_allclose(D1, D2, atol=1e-5)