hnsw 学习笔记

2022/06/20 算法与数据结构 共 3033 字,约 9 分钟 Read:

hnsw: 一个简单而高效的搜索算法,学习笔记

论文原文: Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs, 其中第三章 motivation 写得格外不错,讲得非常清楚。

Hnsw 基于 Nsw 方法而来,这里不追溯,尝试简单讲解一下这个算法的特点。

任何一个 ANN 算法,核心都是在超大规模数据情景下,同时保证精度/速度和内存资源占用。 召回速度 (logN) 是算法基本要求(要不就暴力搜索)。和传统的 kd-Tree 方法类似,两者本质的思想都是在查找过程中, 如果 anchor 点为 A,当得到搜索点 B 和 dist(A, B) 之后,对于 B 附近的点 C,如果 B 和 C 距离不太远,那么 C 和 A 的距离,并不会比 B 到 A 的距离近太多。翻译成数学语言就是 abs(dist(A, C) - dist(A, B)) < dist(B, C), 这是由距离本身的性质(三角不等式)决定的。 有时候自定义一些奇怪的距离,但不符合距离的定义,使用 KNN/ANN 进行搜索,肯能在算法原理层面会有点小问题。

NSW 算法的核心想法,对于 anchor A,从 B 开始搜索。依次遍历 B 的临近点,去临近点列表中寻找更近点, 找到则更新,直到收敛。选取合适的临接边数目,可以使得算法非常快,但可能会存在的问题是,容易落到最小局部点。

Hnsw 方法,和 Nsw 相比,核心改动是分层结构(Hierarchical), 类似传统数据结构中的跳表。 对图结构进行分层,0层包含所有点,上层节点数依次减少,每层点数目遵循指数衰减概率分布。上层点在下层依旧存在。 搜索时候从上层依次向下搜索。上层点相当于 nsw 高速路。和 nsw 类似,构建图时,插入点的随机性保证了上层高速路的存在。

简单写一下几个基础子算法

子算法

SEARCH-LAYER(q, ep, ef, lc)

给定anchor point,和搜索入口点 ep,在指定层(lc)搜索最近点。算法实现和 nsw 基本相同。 因为每层点数随指数衰减,除了 0层以外,其他层速度都会非常快。 实际使用时,ep从最高层开始,逐层向下,直到 0 层。

代码中,维护一个 visit queue,来避免访问重复元素。

:), ef stands for ensure factor.

SELECT-NEIGHBORS-HEURISTIC(q, C, M, lc, extendCandidates, keepPrunedConnections)

构建图时,启发时获得当前点的相邻点。一个 vanilla 的思路是,选取当前点的所有最近点作为相邻点,即论文中的 SELECT-NEIGHBORS-SIMPLE(q, C, M) 算法。 但这样做可能会存在一个问题是,可能会导致搜索时落入局部最优解。例如下图

绿点可能所有的最近临点都出于左下方都簇内,显然如果能和远方都 e2 相邻(即nsw中的高速路),搜索效果会更好。 实际上,因为插入点的随机性,如果e2 和绿点是先插入的两个点,也会形成之间的相邻边,但毕竟不太保险。

论文里提出了启发式加相邻边的方法: M是每个点相邻点数目,选择绿点(A)最近的ef个点(ef > M),距离从小到大依次验证, 如果当前点 B 距离 A点 小于B 距离 A 的所有相邻点,那么 B 点也是相邻点。 这意味着,每个点的相邻点,不会聚集在一个小簇中。例如,对于绿点左下这一簇点,可能只会连接少数几个点,其他点到绿点相邻点的距离会更近。 很容易看出,对于一个二维平面空间,大概最多就只能找到6/7个相邻点。因此,当M大一点时,绿点大概率可以找到 e2 作为相邻点(只要 e2 位于 ef 候选内)。

INSERT(hnsw, q, M, Mmax, efConstruction, mL)

插入点/构建图算法,所有点依次插入,对于每个点 q

  1. 按照指定分布,q 落入相应层内。
  2. 找到 q 的最近点,作为入口点 ep
  3. 使用 SELECT-NEIGHBORS-HEURISTIC 方法(使用2中获得的入口点 ep),获得该点的对应 相邻点集合
  4. 更新该点的对应边,以及更新该点相邻点对应的边

K-NN-SEARCH(hnsw, q, K, ef)

搜索 topk 结果,和 SEARCH-LAYER 很像,只不过此时选取 topk,而非 top1。 维护一个候选队列来存储所有结果。实际代码中,其他层的ef 是和构建时的ef相同, 但 searchKnn 时的 ef_0 可以手动指定,且不小于 topk,来保证召回所有结果。

代码层面的一些要点和注意事项

代码实现: https://github.com/nmslib/hnswlib

  1. hpp / header-only 的代码架构,多个 cpp file include 同一份 hpp 代码时,会被多次编译。 当这些文件被同一入口文件链接时会出问题。可以使用命名空间 or pimpl-mode 进行规避。
  2. 快速计算距离,计算距离是整个代码调用次数最多/瓶颈最严重的代码块,任何微小的优化都是必要的。 作者采用 ssh/avx 进行来对应优化。注意循环展开的写法。

     #if defined(USE_SSE)
     static float L2SqrSIMD4Ext(const void *pVect1v, const void *pVect2v,
                                const void *qty_ptr) {
       float PORTABLE_ALIGN32 TmpRes[8];
       float *pVect1 = (float *)pVect1v;
       float *pVect2 = (float *)pVect2v;
       size_t qty = *((size_t *)qty_ptr);
        
       size_t qty4 = qty >> 2;
        
       const float *pEnd1 = pVect1 + (qty4 << 2);
        
       __m128 diff, v1, v2;
       __m128 sum = _mm_set1_ps(0);
        
       while (pVect1 < pEnd1) {
         v1 = _mm_loadu_ps(pVect1);
         pVect1 += 4;
         v2 = _mm_loadu_ps(pVect2);
         pVect2 += 4;
         diff = _mm_sub_ps(v1, v2);
         sum = _mm_add_ps(sum, _mm_mul_ps(diff, diff));
       }
       _mm_store_ps(TmpRes, sum);
       return TmpRes[0] + TmpRes[1] + TmpRes[2] + TmpRes[3];
     }#endif
    
  3. 增删改查,删和改的实现。增和查已经在论文中详细的介绍。 删除结点,作者采用了 mask-delete-node的方法,并没有改变整个图结构。改结点的实现也在代码中完成。

  4. prefetch 使用。代码里将可能要频繁读取的数据,提前load到cpu缓存里(当然要开ssh优化)。

     _mm_prefetch(getDataByInternalId(*datal), _MM_HINT_T0);
    
  5. 多线程读取,在 cpp 内并没有更多细节,但是在 pybind 内进行里优化。代码在: https://github.com/nmslib/hnswlib/blob/master/python_bindings/bindings.cpp#L50

以及,update/repair 部分是使用多线程处理,格外注意多线程访问同一数据时的 lock。

  1. 一个疑问: 建图的时候,add point 是否是 线程安全的,可以多线程加点吗? 看issue 好像之前是可以的,后来取消了??这里可能还需要更多细节??

先写这么多了,搜索小白慢慢学习ing

Search

    Table of Contents

    本站总访问量