跳到主要内容

Faiss 的线程与异步调用

线程安全性(Thread Safety)

Faiss 的 CPU 索引(Index)在进行并发检索(同时多线程查找)以及其他不会修改索引的数据操作时,是线程安全的(Thread-safe)。 如果要多线程地进行“会改变索引内容”的操作,您需要实现互斥锁(Mutual Exclusion,又称互斥量、Mutex)来保护索引

important

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)
  • addsearch 方法的多线程,会在向量(vector)维度分配。
    • 添加或查询单个向量时,不会启用多线程。

对于单张 GPU 的 GPU Faiss,内部并没有多 CPU 线程的实现。

若想获得每秒查询数(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 包括 PCAMatrixOPQMatrix 的训练过程。不同运行之间的误差大约是机器精度级别。 详情见有关可控数值复现性(CNR)文档。
  • HNSW 的添加操作(add)并非严格顺序实现。尝试用静态线程分配会降低效率,但其检索(search)操作是确定性的(deterministic)。

对于以上两种情况,多次运行结果将有可能不完全一致。

在以下场景中,可考虑将 Faiss Index(索引)检索与其他任务并行处理,这样可以让程序整体更加高效:

  • 单线程计算任务
  • 等待 I/O 数据
  • GPU 相关的操作

如果是 CPU Faiss,并不建议与其他多线程任务(如其他并发检索)并行,这只会制造过多线程反而降低速度。 针对来自不同线程的多个检索任务,请由业务方 将任务聚合成批量 或 排队后再交给 Faiss 处理。

当然,也可以在多张 GPU 上并行检索——每个 CPU 线程管理一张 GPU,分别启动内核执行。这也是 IndexProxyIndexShards 的实现机制。

如何在不同语言中开启异步检索线程

  • 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