前言
本人是某双非大二计算机学生,参加这次比赛可以说是完全零基础,还没学数据库和操作系统。想着反正下学期要学数据库,就借这次比赛提前学习一下,尝试一下这种企业级的比赛,学习新知识同时也拓展下视野。从初赛到决赛答辩,从纯小白到入门,学到了很多东西。如果内容有误,欢迎大家指正。
决赛第一轮结果:
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,节省时间
算法设计
输入: 原始节点链表 head,Entry 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)优化
- 每次距离计算都需转换查询向量
解决方案
- 双累加器技术:使用两个
__m256累加器交替累加,打破数据依赖链 - dim=200 专用路径:200 = 192 + 8,精确展开避免运行时判断
- 查询向量预转换:在搜索开始时一次性将查询向量转为 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 搜索过程中存在两个效率问题:
- 每次搜索都分配新的堆结构和临时数组
- 搜索结果收敛后仍继续迭代
解决方案
- HnswSearchScratch 内存复用:预分配搜索所需的临时数据结构,跨搜索复用
- 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 bytes | 800 bytes | 高 |
| halfvec (float16) | 2 bytes | 400 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=200 | m=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 提升明显,索引构建时间也大幅减少。