polardb数据库比赛优化方案

0 阅读17分钟

前言

本人是某双非大二计算机学生,参加这次比赛可以说是完全零基础,还没学数据库和操作系统。想着反正下学期要学数据库,就借这次比赛提前学习一下,尝试一下这种企业级的比赛,学习新知识同时也拓展下视野。从初赛到决赛答辩,从纯小白到入门,学到了很多东西。如果内容有误,欢迎大家指正。

image.png

决赛第一轮结果: d2931c9b4c9ca1c6eb61e3ab0a3d5d92.jpg

1.1 赛题

比赛的任务是优化 pgvector 扩展在 PolarDB 上的向量检索性能。

  • 评测指标:QPS(每秒查询数)
  • 约束条件:召回率 ≥ 0.85
  • 数据规模:1000万条 200维向量

1.2 技术背景

pgvector 是 PostgreSQL 的向量搜索扩展,支持 HNSW和IVF索引算法。HNSW 是目前向量检索领域最主流的算法之一,它通过构建多层图结构,实现高效的近似最近邻搜索。
对于刚开始的我来说,这些词汇全都很陌生,后来是看了b站关于向量数据库的相关视频才有了初步了解。

比赛分为分为两轮:初赛和决赛。初赛前100进决赛,决赛前20能到上海参加现场答辩。(这里是决赛第一轮成绩,初赛成绩没有截)

核心优化思路

目标问题与优化思路

本次比赛的目标是优化 pgvector 扩展在 PolarDB 上的向量检索性能,核心评测指标为 QPS索引构建时间,同时需保证召回率 ≥ 0.85

通过对 HNSW 向量检索算法的深入分析,我们识别出以下性能瓶颈:

瓶颈层次具体问题影响程度
存储层索引节点按插入顺序存储,与搜索访问顺序不一致,导致大量随机 I/O
计算层距离计算占用 85-95% CPU 时间,存在优化空间
内存层搜索过程中频繁进行内存分配/释放

核心发现:在比赛的大规模数据集(1000 万条 200 维向量)环境下,I/O 是真正的性能瓶颈。原始 pgvector 已具备较好的 SIMD 优化,因此计算层优化收益有限;而存储层的图重排优化是从无到有的改进,收益最为显著。

优化策略

我们主要从存储、计算、算法三个层次进行优化:

优化层次优化方法核心思想
存储层图重排优化BFS 遍历重排节点,使存储顺序与访问顺序一致
计算层SIMD 增强 + 查询向量预转换双累加器、dim=200 专用路径、减少重复转换
算法层内存复用 + 提前终止HnswSearchScratch 复用、patience 机制

预期效果与实际结果

优化项效果分析
图重排优化+10-15%I/O 瓶颈显著,顺序访问浪费时间
SIMD 距离计算增强+2-3%原始代码已有 F16C 优化,增量收益有限
查询向量预转换+1-2%增量收益有限
内存复用 + 提前终止+1-2%增量收益有限
综合提升+15-20%实际以图重排收益为主

核心设计与关键技术

设计一:图重排优化(Graph Reordering)

问题分析

HNSW 索引构建时,节点按插入顺序存储到磁盘,但搜索时从entry point开始,沿着图的边进行广度优先遍历。这导致访问时随机跳跃,极大降低效率:

插入顺序存储:
Page 1: [节点1] [节点2] [节点3] ...
Page 5: [节点500] ...
Page 8: [节点732] ...

搜索访问顺序(从 Entry Point 开始 BFS):
节点500 → 节点732 → 节点156 → 节点891 → ...
Page 5  → Page 8  → Page 2  → Page 9  → ...

优化前节点之间的跳跃需要到不同的page中找,而每个page之间隔了很远,从一个page到另一个page的过程花费很多时间

解决方案

在索引构建完成后、写入磁盘前,使用 BFS 遍历重排内存中的节点链表,使物理存储顺序与搜索访问顺序一致:

优化后存储:
Page 1: [节点500] [节点732] [节点156] [节点891] ...
从Entry Point 开始,按 BFS 顺序存储

搜索时:顺序访问 Page 1 → Page 2 → Page 3 → ...

优化后要访问的节点放在一个page里面,访问完一个page再跳到另一个page,避免频繁随机跳跃到其他page,节省时间

image.png

算法设计

输入: 原始节点链表 headEntry Point
输出: 重排后的节点链表

算法流程:
1. 初始化 BFS 队列,将 Entry Point 入队
2. 初始化 visited 哈希表,记录已访问节点
3. while (队列非空):
     a. 取出队头节点,加入新顺序数组 order[]
     b. 遍历该节点的 Layer 0 邻居
     c. 将未访问的邻居标记并入队
4. 遍历原链表,将未被 BFS 访问的孤立节点追加到 order[] 末尾
5. 按 order[] 顺序重建链表
6. 更新 graph->head 指向新的头节点

设计要点

  • 只遍历 Layer 0:所有节点都在 Layer 0,连接最密集,搜索主要在此进行
  • 处理孤立节点:保证图的完整性,孤立节点追加到末尾
  • 持久化状态:通过 reorderState 字段标记,支持旧索引兼容

性能收益

指标优化前优化后改善
缓存命中率~30%~70%+40%
每次查询 I/O 次数~50~20-60%
QPS 提升--+10-15%

设计二:SIMD 距离计算增强

问题分析

原始 pgvector 已有 F16C + AVX + FMA 优化,但存在以下可改进点:

  • 循环依赖链限制了指令级并行度
  • 通用实现未针对比赛数据维度(dim=200)优化
  • 每次距离计算都需转换查询向量

解决方案

  1. 双累加器技术:使用两个 __m256 累加器交替累加,打破数据依赖链
  2. dim=200 专用路径:200 = 192 + 8,精确展开避免运行时判断
  3. 查询向量预转换:在搜索开始时一次性将查询向量转为 float32

代码

// 双累加器 + dim=200 专用路径
if (dim == 200) {
    __m256 acc0 = _mm256_setzero_ps();
    __m256 acc1 = _mm256_setzero_ps();
    
    // 主循环:处理 192 维 (32 × 6)
    for (i = 0; i < 192; i += 32) {
        // 处理 32 维,交替累加到 acc0 和 acc1
        acc0 = _mm256_fmadd_ps(d0, d0, acc0);
        acc1 = _mm256_fmadd_ps(d1, d1, acc1);
        // ...
    }
    
    // 尾部:处理剩余 8 维
    // ...
    
    return HalfvecHorizontalSum8(_mm256_add_ps(acc0, acc1));
}

实际效果

由于原始 pgvector 已有较好的 SIMD 优化,本项改进属于边际优化,实际 QPS 提升约 2-3%。

设计三:HNSW 搜索路径优化

问题分析

HNSW 搜索过程中存在两个效率问题:

  1. 每次搜索都分配新的堆结构和临时数组
  2. 搜索结果收敛后仍继续迭代
解决方案
  1. HnswSearchScratch 内存复用:预分配搜索所需的临时数据结构,跨搜索复用
  2. Early Termination 机制:当结果堆连续多次未改善时提前终止
typedef struct HnswSearchScratch {
    pairingheap c;              // 候选堆(复用)
    pairingheap w;              // 结果堆(复用)
    pairingheap discarded;      // 丢弃堆(复用)
    bool initialized;           // 初始化标志
    HnswUnvisited unvisited[HNSW_MAX_LM];  // 未访问数组(复用)
    HnswNeighborBuf neighborhoodBuf;       // 邻居缓冲(复用)
} HnswSearchScratch;
// Early Termination 核心逻辑
int patience = 0, max_patience = 7;
double prev_worst_distance = DBL_MAX;

// 在搜索循环中
if (wlen >= ef && !pairingheap_is_empty(W)) {
    double current_worst = worst->distance;
    if (current_worst < prev_worst_distance - 1e-9) {
        patience = 0;  // 有改善,重置耐心
        prev_worst_distance = current_worst;
    } else {
        patience++;    // 无改善,增加计数
    }
}
if (patience >= max_patience) break;  // 提前终止

设计要点

  • Early Termination 仅在查询的 Layer 0 启用,避免影响索引构建质量
  • 设置max_patience = 7 ,平衡召回率和性能。该值设置太小会导致recall大幅降低,太大会没有效果

实际效果

QPS 提升约 1-2%

算法流程与系统架构

优化后的索引构建流程

┌─────────────────────────────────────────────────────────────────┐
│                     hnswbuild() 入口                            │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  BuildGraph()                                                   │
│  ├── 遍历堆表,逐条插入向量                                        │
│  ├── 每次插入调用 HnswFindElementNeighbors()                      │
│  │   └── 使用 HnswSearchScratch 复用内存                          │
│  └── 在内存中构建完整的 HNSW 图                                    │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  FlushPages()                                                   │
│  ├── CreateMetaPage()         设置 reorderState = 1              │
│  ├── HnswReorderGraph()      BFS 重排节点链表                     │
│  ├── CreateGraphPages()       按新顺序写入磁盘                     │
│  └── WriteNeighborTuples()    写入邻居关系                        │
└─────────────────────────────────────────────────────────────────┘

优化后的查询流程

┌─────────────────────────────────────────────────────────────────┐
│                      查询请求                                    │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  hnswbeginscan()                                                │
│  ├── HnswEnsureReordered()    检查索引是否已重排                 │
│  │   └── 旧索引自动触发重建                                      │
│  └── 初始化 HnswSearchScratch                                   │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  GetScanItems()                                                 │
│  ├── HnswPrepareQuery()       F16C 预转换查询向量                │
│  ├── 高层搜索 (Layer > 0)     快速定位入口                       │
│  └── Layer 0 搜索             使用 Early Termination            │
│      └── HnswSearchLayer()                                      │
│          ├── 距离计算: HnswGetDistanceFromQuery()               │
│          │   └── SIMD 优化 + qf32 预转换版本                    │
│          └── Early Termination 检查 (patience 机制)             │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  返回 Top-K 结果                                                 │
│  ├── 顺序访问优化后的索引页面 ★                                  │
│  └── 缓存命中率高,I/O 次数少                                    │
└─────────────────────────────────────────────────────────────────┘

代码架构改动概览

pgvector/src/
├── halfutils.c          [修改] SIMD 距离计算增强
│   ├── HalfvecL2SquaredDistanceF16c()      双累加器 + dim=200 专用
│   ├── HalfvecL2SquaredDistanceF16c_qf32() 查询向量预转换版本
│   └── HalfvecHorizontalSum8()             高效水平求和
│
├── hnsw.h               [修改] 数据结构扩展 
│   ├── HnswSearchScratch                   搜索内存复用结构
│   ├── HnswQuery.qf32                      预转换查询向量
│   └── HnswMetaPageData.reorderState       重排状态标记
│
├── hnswutils.c          [修改] 核心搜索逻辑
│   ├── HnswPrepareQuery()                  添加 F16C 预转换
│   ├── HnswGetDistanceFromQuery()          使用 qf32 版本
│   ├── HnswSearchLayer()                   Early Termination
│   └── HnswEnsureReordered()               重排检查与触发
│
├── hnswbuild.c          [修改] 索引构建 
│   ├── HnswReorderGraph()                  BFS 图重排
│   ├── FlushPages()                        调用重排
│   └── CreateMetaPage()                    写入 reorderState
│
├── hnswscan.c           [修改] 扫描入口
│   └── hnswbeginscan()                     调用 HnswEnsureReordered
│
├── hnswinsert.c         [修改] 插入逻辑
│   └── 使用 HnswSearchScratch
│
└── hnswvacuum.c         [修改] 清理逻辑
    └── 使用 HnswSearchScratch

实现细节

关键数据结构

1. HnswSearchScratch(搜索内存复用)
typedef struct HnswSearchScratch {
    pairingheap c;              // 候选堆
    pairingheap w;              // 结果堆
    pairingheap discarded;      // 丢弃堆
    bool initialized;           // 初始化标志
    HnswUnvisited unvisited[HNSW_MAX_LM];  // 未访问数组
    HnswNeighborBuf neighborhoodBuf;       // 邻居缓冲
} HnswSearchScratch;

作用:在多次 HnswSearchLayer 调用间复用临时数据结构,避免频繁的 palloc/pfree。

生命周期:

  • 查询场景:嵌入 HnswScanOpaqueData,生命周期 = scan 生命周期
  • 构建场景:嵌入 HnswBuildState,生命周期 = 整个构建过程
2. HnswQuery(查询向量封装)
typedef struct HnswQuery {
    Datum value;      // 原始查询向量
    float *qf32;      // 预转换的 float32 向量(新增)
    int dim;          // 向量维度(新增)
} HnswQuery;

作用:存储查询向量的预转换结果,避免每次距离计算时重复 FP16→FP32 转换。

3. HnswMetaPageData.reorderState(重排状态)
typedef struct HnswMetaPageData {
    // ... 其他字段
    uint8 reorderState;  // 0=未重排, 1=已重排(新增)
} HnswMetaPageData;

作用:持久化记录索引是否已完成图重排,支持旧索引自动升级。

核心方法/函数

1. HnswReorderGraph
项目说明
功能基于 BFS 遍历重排 HNSW 图节点链表
输入HnswBuildState(包含原始图)
输出重排后的图(原地修改 graph->head)
复杂度O(n + e),n 为节点数,e 为边数
调用时机FlushPages() 中,写入磁盘前
2. HnswEnsureReordered
项目说明
功能检查索引是否已重排,未重排则触发重建
输入Relation index
输出
调用时机hnswbeginscan() 开始时
并发控制AccessExclusiveLock + 双重检查
3. HalfvecL2SquaredDistanceF16c_qf32
项目说明
功能计算预转换查询向量与 halfvec 的 L2 距离平方
输入int dim, float ax (查询), half bx (候选)
输出float (L2 距离平方)
优化双累加器 + dim=200 专用路径 + 查询向量无需转换

性能优化点

优化手段具体实现效果
BFS 图重排按搜索访问顺序存储节点缓存命中率增加
双累加器两个 __m256 交替累加隐藏指令延迟
dim=200 专用精确展开 192+8消除循环判断
查询向量预转换一次转换多次使用减少转换指令
堆结构复用pairingheap_reset()减少palloc
提前终止patience 机制减少无效迭代

特殊处理技巧

1. 孤立节点处理
// BFS 可能无法遍历到的节点(图不连通的情况)
for (iter = head; !HnswPtrIsNull(base, iter);) {
    if (reorderhash_lookup(visited, key) == NULL) {
        order[out++] = iter;  // 追加到末尾
    }
    iter = element->next;
}

目的:保证所有节点都被写入,不丢失数据。

2. 旧索引兼容
void HnswEnsureReordered(Relation index) {
    if (HnswGetReorderState(index) != 0)
        return;  // 已重排,直接返回
    
    // 双重检查锁定
    LockRelationOid(indexOid, AccessExclusiveLock);
    if (HnswGetReorderState(index) != 0) {
        UnlockRelationOid(...);
        return;
    }
    
    // 触发重建
    HnswRebuildIndexForReorder(index);
}

目的:无缝支持旧版本索引,首次查询时自动升级。

3. Early Termination 安全性
bool use_early_term = (!inserting && lc == 0);

限制:仅在"查询的 Layer 0"启用

  • !inserting:插入时需要精确找邻居,不能提前终止
  • lc == 0:高层只用于快速定位,本身就很快

调参

默认参数:

DATA_TYPE=vector
STORAGE_TYPE=PLAIN

INDEX_TYPE=hnsw
OPS_TYPE=vector_l2_ops
WITH_PARAMS="m = 20, ef_construction = 200"

SEARCH_PARM=hnsw.ef_search
SEARCH_VALUES="10 20 30 40 50 60 70 80 90 100 110 120 130 140 150 160 170 180 190 200"
shared_buffers = 12GB
polar_xlog_queue_buffers = 2GB
maintenance_work_mem = 14GB
max_parallel_maintenance_workers = 8
max_parallel_workers = 8

我们的参数:

DATA_TYPE=halfvec
STORAGE_TYPE=PLAIN

INDEX_TYPE=hnsw
OPS_TYPE=halfvec_l2_ops
WITH_PARAMS="m = 10, ef_construction = 48"

SEARCH_PARM=hnsw.ef_search
SEARCH_VALUES="201 202 203 204 205 206 207 208 209 210 211 212 213"
shared_buffers = 16GB
polar_xlog_queue_buffers = 2GB
maintenance_work_mem = 10GB
max_parallel_maintenance_workers = 8
max_parallel_workers = 8

调参优化

1. 数据类型优化:vector → halfvec

变化内容
默认: DATA_TYPE=vector, OPS_TYPE=vector_l2_ops
优化: DATA_TYPE=halfvec, OPS_TYPE=halfvec_l2_ops
原理分析
类型每维度存储200维向量大小精度
vector (float32)4 bytes800 bytes
halfvec (float16)2 bytes400 bytes
对性能的影响
指标影响原因
索引大小-50%每个向量从 800 bytes 降到 400 bytes
索引构建时间-20~30%内存带宽减半,IO 减少
为什么选择 halfvec?
理论分析:
- 向量检索的精度要求通常不高,float16 的精度足够
- 比赛数据集的向量值范围在 float16 可表示范围内
- 内存带宽是主要瓶颈,减少数据量直接提升性能

实测效果:
- 索引构建时间明显减少
- QPS和recall变化不大

2. HNSW 索引参数优化:m 和 ef_construction

变化内容
默认: m = 20, ef_construction = 200
优化: m = 10, ef_construction = 48
参数含义
参数含义影响范围
m每个节点的最大邻居数索引大小、构建时间、查询性能、召回率
ef_construction构建时的候选队列大小构建时间、图质量、召回率
m 参数分析 (20 → 10)

思路:因为索引构建时间在得分占比中比较大,所以选择适当牺牲召回率来换取更小的索引构建时间

索引大小影响:
- 每个节点存储的邻居数减半
- 邻居元组大小: (level + 2) * m * 6 bytes

构建时间影响:
- 每次插入需要更新的邻居连接减少
- 构建时间减少

查询性能影响:
- 每层搜索访问的邻居减少 → 单次搜索更快
- 但图连通性降低 → 需要更大的 ef_search 补偿

召回率影响:
- 图的连通性降低
- 需要增加 ef_search 来补偿
ef_construction 参数分析 (200 → 48)
构建时间影响:
- 每次插入时搜索的候选数从 200 降到 48
- 构建时间减少

图质量影响:
- 构建时找到的邻居质量可能略低
综合效果
指标m=20, ef_c=200m=10, ef_c=48变化
索引构建时间~350s~210s-40%
索引大小~6.1GB~4.5GB-26%
基础召回率较高略低需要 ef_search 补偿

3. 搜索参数优化:ef_search

变化内容
默认: SEARCH_VALUES="10 20 30 40 50 60 70 80 90 100 110 120 130 140 150 160 170 180 190 200"
优化: SEARCH_VALUES="201 202 203 204 205 206 207 208 209 210 211 212 213"

设置较高的ef_search来补偿m和ef_construction调参造成的recall减小

优化策略:
1. 通过测试确定满足 recall ≥ 0.85 的最小 ef_search
2. 在该临界点附近密集搜索,找到最优值
为什么选择 201-213?
背景分析:
- m=10 的图连通性较低
- 需要较大的 ef_search 来补偿
- 经过测试,ef_search ≈ 200 左右刚好满足 0.85 召回率

4. PostgreSQL 参数优化

shared_buffers: 12GB → 16GB
作用: PostgreSQL 的共享缓冲区大小

优化原因:
- 测试环境有 32GB 内存
- 索引大小约 6GB,数据表约 4GB
- 16GB shared_buffers 可以将索引和数据完全缓存
- 避免磁盘 IO,全部在内存中完成

对性能的影响:
- QPS: +10-15% (消除磁盘 IO 瓶颈)
- 索引构建: 影响较小 (构建时主要用 maintenance_work_mem)
maintenance_work_mem: 14GB → 10GB
作用: 索引构建等维护操作的内存限制

优化原因:
- HNSW 构建在内存中完成
- 10GB 足够容纳 1000万向量的图结构
- 减少后可以给 shared_buffers 更多空间
- 如果设置太小会触发磁盘 flush,大幅降低构建速度
参数之间的权衡
总内存预算: 32GB
分配策略:
- shared_buffers: 16GB (50%)  → 查询性能
- maintenance_work_mem: 10GB  → 索引构建
- 系统和其他: 6GB            → OS 缓存、连接等

这个分配优先保证:
1. 查询时索引完全在内存中 (shared_buffers)
2. 索引构建不触发磁盘 flush (maintenance_work_mem)

5. 参数组合的协同效应

halfvec + 小 m + 大 ef_search 的组合逻辑

我们的思路:
halfvec + m=10 + ef_construction=48 + ef_search=~200
→ 低精度向量 + 轻量图 + 深度搜索
→ 索引小,构建快,QPS 高

核心洞察:
- halfvec 节省的计算量可以用来支持更深的搜索
- 小 m 节省的图遍历开销可以用更大的 ef_search 补偿
- 最终: 相同召回率下,QPS 更高

测试与验证

性能测试结果

测试环境:

  • CPU:8 核,支持 AVX2/AVX-512/F16C
  • 内存:32GB
  • 数据集:1000 万条 200 维 halfvec 向量
  • 索引参数:m=10, ef_construction=48, ef_search=204 205 206 207 208 209 210

各优化项效果分解

优化项独立测试 QPS 提升说明
图重排优化+12-15%核心贡献,解决 I/O 瓶颈
SIMD 距离计算增强+2-3%原始已有优化,边际收益
查询向量预转换+1-2%被 I/O 优化收益掩盖
内存复用 + 提前终止+1-2%内存开销占比本就不高

存在的问题与改进方向

问题改进方向
图重排仅在构建时执行可考虑增量插入后的局部重排
未使用 AVX-512 指令集在支持的平台上可利用 512-bit 寄存器进一步优化
Early Termination 参数固定可根据数据分布自适应调整 patience 参数
旧索引首次查询需重建可提供离线重建工具,避免查询时阻塞

总结

本方案通过分层优化策略,针对 pgvector 在大规模向量检索场景下的性能瓶颈进行了系统性改进。

核心优化:图重排优化(Graph Reordering)

  • 首次在 pgvector 中引入基于 BFS 的节点重排机制
  • 使物理存储顺序与搜索访问顺序一致
  • 显著提高缓存命中率,减少 I/O 次数

关键洞察:在大规模数据集下,I/O 是真正的性能瓶颈,存储层优化收益远大于计算层优化。

最终效果:QPS 提升明显,索引构建时间也大幅减少。