Faiss 的线程与异步调用
线程安全性(Thread Safety)
Faiss 的 CPU 索引(Index)在进行并发检索(同时多线程查找)以及其他不会修改索引的数据操作时,是线程安全的(Thread-safe)。 如果要多线程地进行“会改变索引内容”的操作,您需要实现互斥锁(Mutual Exclusion,又称互斥量、Mutex)来保护索引。
GPU 索引并不具备线程安全性。即使只是读取操作,也不能在多线程下安全调用。
原因在于,GPU Faiss 所使用的 StandardGpuResources(标准GPU资源管理器)不是线程安全的。这个资源管理器负责管理一块 GPU 上的临时内存,该内存同一时刻只能被一个线程使用。
- 每个活跃运行 GPU Faiss 索引的 CPU 线程,都应单独创建一个
StandardGpuResources对象。 - 同一 CPU 线程下管理的多个 GPU 索引可以共享同一个
StandardGpuResources(推荐这样做,以便复用同一块 GPU 临时内存)。 - 一个
GpuResources对象可以同时支持多个 GPU 设备,但前提是只被一个 CPU 线程调用。
通过 index_cpu_to_gpu_multiple 方法构建的多 GPU Faiss,会在内部用不同的线程分配和管理不同的 GPU 索引。
Faiss 内部多线程机制(Internal Threading)
Faiss 在内部有多种多线程实现方式。
对于 CPU 版本的 Faiss 来说,三种常用操作(训练、添加数据、检索)都用到了多线程。主要依赖 OpenMP 以及多线程的 BLAS 库实现。
Faiss 本身不会显式指定线程数量,您可以通过环境变量 OMP_NUM_THREADS 或直接调用 omp_set_num_threads(10) 进行设置。Python 中可以直接通过 faiss 调用该函数。
import faiss
faiss.omp_set_num_threads(10)
add和search方法的多线程,会在向量(vector)维度分配。- 添加或查询单个向量时,不会启用多线程。
对于单张 GPU 的 GPU Faiss,内部并没有多 CPU 线程的实现。
检索性能影响因素(Performance of Search)
若想获得每秒查询数(QPS,Queries Per Second)最优性能,建议批量提交(batch)查询任务。
- 如果一次只提交一个查询,实际上就在当前线程执行。理论上,多个线程各自提交单个查询也是高效的。
- Python 接口在所有 Faiss 调用期间会释放全局解释器锁(GIL, Global Interpreter Lock),因此 Python 多线程能够有效利用多核 CPU。
对批量查询使用多线程并不高效,这会导致线程数量远超实际 CPU 核心数,从而影响总体性能。
如果仅仅因为链接了如 Faiss 这类支持 OpenMP 的库,每次用 pthread_create 启动新线程时会有一定的运行时开销。
对于运行时间很短的批量任务来说,这个开销会更加明显。
如果出现上述效率问题,可以考虑在编译 Faiss 时去掉 -openmp 选项(即不启用 OpenMP),并且链接到单线程的 BLAS 实现(比如 Intel MKL 库的串行版本,MKL 串行版本选择指引)。
内部线程性能调优(OpenMP)
对于 OpenMP(开放多处理器接口)线程数量的选择,不同机器的最优配置也不同。有些 CPU 上,线程数设得比核心数少,反而更高效。
例如:在 Intel E5-2680 v2 服务器上,指定 20 个线程比默认的 40 个线程效果更好。
当配合 OpenBLAS 用作 BLAS 实现时,建议设置 OpenMP 策略为 PASSIVE:
export OMP_WAIT_POLICY=PASSIVE
更多讨论请参考 issue #53。
多线程下的结果复现性(Reproducibility)
Faiss 设计上默认固定了随机种子(seed),以获得可复现的计算结果。
多线程运算时,大多采用静态分配(omp schedule static),保证同样线程数、同样操作下的最终结果完全相同。
个别功能因为底层依赖不完全可复现,或算法本身实现顺序不确定,可能导致每次运行结果稍有差异。
已知的例外情况:
- 依赖 LAPACK 特征值分解(
esyev函数)时,结果不可完全复现(如 MKL BLAS),涉及的 API 包括PCAMatrix和OPQMatrix的训练过程。不同运行之间的误差大约是机器精度级别。 详情见有关可控数值复现性(CNR)文档。 HNSW的添加操作(add)并非严格顺序实现。尝试用静态线程分配会降低效率,但其检索(search)操作是确定性的(deterministic)。
对于以上两种情况,多次运行结果将有可能不完全一致。
异步检索(Asynchronous Search)
在以下场景中,可考虑将 Faiss Index(索引)检索与其他任务并行处理,这样可以让程序整体更加高效:
- 单线程计算任务
- 等待 I/O 数据
- GPU 相关的操作
如果是 CPU Faiss,并不建议与其他多线程任务(如其他并发检索)并行,这只会制造过多线程反而降低速度。 针对来自不同线程的多个检索任务,请由业务方 将任务聚合成批量 或 排队后再交给 Faiss 处理。
当然,也可以在多张 GPU 上并行检索——每个 CPU 线程管理一张 GPU,分别启动内核执行。这也是 IndexProxy 和 IndexShards 的实现机制。
如何在不同语言中开启异步检索线程
-
C++:使用
pthread_create搭配pthread_join创建和同步线程。 -
Python:可用
thread.start_new_thread搭配线程锁(Lock),或使用multiprocessing.pool.ThreadPool。 Faiss 所有的 search/add/train 方法在 Python 层都已经自动释放 GIL,因此不必担心 Python 线程调度性能。示例代码如下:
import threading
import faiss
def search_index(index, query):
D, I = index.search(query, k=10)
print(D, I)
index = faiss.IndexFlatL2(128)
# 添加数据到 index ...
# 创建新线程进行检索
t = threading.Thread(target=search_index, args=(index, query_vec))
t.start()
t.join()
- 针对 Lua 用户,Faiss 提供了
AsyncIndex对象,用于在内部 C++ 线程异步执行检索操作。示例代码可见 test_async.lua。