# 性能
本页记录已落地的性能改动、量过之后放弃的方向,以及测量本身的口径。数字都来自实测,不接受只读代码得出的断言。下文提到的「报告」指一次外部走查给出的优化建议清单——其中若干条被实测否证,理由都记在下面。
## 测量口径
**分阶段打点。** 注册事件 sink 后,每次检索产出一条事件,带总耗时与分阶段耗时:
| 阶段 | 覆盖什么 |
|---|---|
| `prepare` | 取库锁之前:图剪枝的建图与邻域展开 |
| `embed` | 库调宿主的嵌入回调(模型往返) |
| `text` | 全文路:索引查询、BM25 打分、候选取回 |
| `vector` | 向量路:分区载入与打分 |
| `fuse` | 两路 RRF 融合、`with_total` 的计数 |
| `fold` | 同篇切片折叠 |
| `rerank` | 库调宿主的重排回调(模型往返) |
| `count` | 最终命中条目各自的「这一篇命中多少片」 |
| `load` | 命中本体批量取回与装配 |
`embed` 与 `rerank` 是模型往返时间,不是库的开销。
**对照方法。** 改动前后用同一份数据、同一个探针各跑 3 次取中位。单次测量的噪声可达 2 倍,一次数字不足以支撑结论。
**数据来源。** 文本路与图路在真实库的只读副本上量;向量路用自建夹具(量的时候真实库没有向量)。探针脚本放 `.pai/temp/` 下,不进版本库。
**用例。** 每条优化都配一条用例,并撤掉修复验证过它会失败——否则分不清「改了」和「改对了」。
## 已落地
最近一轮性能走查落地了八处改动。
| 提交 | 问题 | 改法 |
|---|---|---|
| `3a90a6d` | 「这一篇命中多少片」对候选窗口里每一篇都数一遍,而该字段只出现在返回的命中里;同分的二级排序键走字符串快字段 | 只数最终返回的条目;`key` 改整数快字段,同分按记录 ID 升序 |
| `17b5a85` | 最终返回的每篇笔记各发一次「note = X AND 查询」数切片 | 一次查询先把匹配集收在目标笔记上,遍历时按 note 快字段分桶 |
| `1a388eb` | 删除路径不通知索引:删掉的记录、笔记改写后废弃的切片仍留在索引里,继续参与打分、占住候选配额 | sync 时把待办里库里已查不到的记录按 key 摘掉 |
| `95134be` | `list` / `chunks` / `ego` / `path` 手里已有一批 id 却逐条 `get`(limit=100 就是几百次回库);`bodies()` 每个 id 各发一次索引查询 | 前者走批量取回把过滤压成一条 SQL;后者用一次布尔查询收全部 key 再逐条解码 |
| `eb43707` | 任何写入都清空整张向量分区缓存:在别的领域写一条、或补向量线程每醒一次,已载入的分区跟着作废 | 按领域各记一个版本号,写路径在事务内登记动过的领域,只清这些 |
| `7f3eeac` | 分区载入的 `ORDER BY r.id` 让 SQLite 把整行(含向量)先搬进临时 B 树、排完再读回,而行序对结果没有意义 | 去掉排序(并列名次由 `retain` 按 key 显式破) |
| `4434cdb` | 图谱预设的事件路把整批事件装配出来再按字符预算截断;`events_between` 拿记录表当驱动表,对领域内每个事件逐条探测参与者表 | 先用 SQL 取正文长度定下要留哪几条再装配;驱动表换成参与者表 |
| `b499ecb` | 重排候选只按 token 预算截断:几十字符的短切片在 8192 预算下能装进 253 条,而重排成本由批次条数主导 | 加条数上限 `max_candidates`(默认 50),与 token 预算构成与门 |
实测都在真实库的只读副本上,同一探针在新旧代码上各跑一遍:
- **`3a90a6d`**:候选 1000 + limit 10 从 59-425 ms 降到 7-38 ms,limit 100 从 86-445 ms 降到 21-72 ms,只查名字列从 12-45 ms 降到 4-5 ms。分阶段看,`text` 50-254 → 1-34 ms、`fold` 100-206 → 0-4 ms,新增的 `count` 是 1-29 ms。
- **`17b5a85`**:`count` 阶段 limit 100 从 1-29 ms 降到 0.9-2.6 ms,limit 10 是 0-0.3 ms。对照实验:逐篇 70-84 次查询 5.3-21.7 ms,一次遍历全匹配集 0.9-2.8 ms。
- **`1a388eb`**:撤掉修复后两条用例都失败——删掉的记录在索引里残留 1 条,笔记改写后残留 8 片旧切片。
- **`95134be`**:`notes.list(limit=100)` 0.96 → 0.30 ms;`notes.chunks`(6216 片的那一篇)约 360 → 约 170 ms,剩余开销在 Python 侧的 JSON 序列化上。
- **`eb43707`**:10 万条记忆、单领域、4 维 `f32` 的夹具上,补齐一轮之后的向量检索 138.4 → 3.3 ms,在另一个领域写一条之后 138.5 → 2.2 ms;冷载入与紧接的命中不受影响(149.2 / 2.7 ms,旧 257.9 / 2.4 ms)。
- **`7f3eeac`**:5 万行、1024 维 `sq8` 的分区,冷载入加打分旧 708.7 / 814.0 / 1214.3 ms,新 320.4 / 351.0 / 383.3 ms;热检索旧 5.8 / 6.6 / 7.6 ms,新 4.7 / 6.3 / 6.6 ms;同一查询的 top-10 记录 id 与分数逐条一致。
- **`4434cdb`**:一次检索取回并装配 2510 条事件、最后只留 1 条;`events_between` 同一批 2510 行 56.6 → 3.9 ms。端到端图谱预设 196.6 → 122.1 ms、RAG 预设 195.4 → 115.2 ms,返回的实体 / 命中关系 / 铺开关系 / 铺开事件 id 逐条一致。
- **`b499ecb`**:同一份副本、同一个查询,无条数上限时送 253 条进回调(另有 55 条被 token 预算截掉),`max_candidates=50` 送 50 条,`30` 送 30 条。
两处要单独说:
**索引格式跟着变。** `3a90a6d` 把 `key` 改成整数快字段,格式升到 `p-memory-text-v12`;`17b5a85` 把 `note` 列改成索引加快字段,升到 `v13`。旧索引在下次打开时整体重建一次。
**`1a388eb` 不是纯性能问题。** 残留文档会参与打分、占住候选配额,严重时能把有效结果挤空。
## 量过但放弃
以下方向都动过探针,结论是不做。
**向量行标签整型化、去掉 `json_group_array`、内存平坦化。** 报告主张标签列的 JSON 解析与每行一个 `Vec<String>` 是载入瓶颈。实测逐层剥离(5 万行、1024 维 `sq8`、每行 3 个标签,各 3 轮):标签列不要、BLOB 借切片、完全不解码、只 `COUNT` 不物化,四种写法与现状都在 330-550 ms 的噪声里。真凶是 `ORDER BY r.id`(见上)。这条的着力点错了。
**AVX2 点积换多累加器展开。** 现状内核(`dot_codes_avx2`)是单累加器、16 码一轮,报告主张展开以隐藏依赖链。1024 维 `i8` 点积实测(200 行 × 20000 次,同机同编译目标):
| 写法 | 每行 |
|---|---|
| 标量(非 AVX2 目标编译,即老 CPU 与非 x86 走的路径) | 193-194 ns |
| AVX2 单累加器(现状) | 24-26 ns |
| AVX2 4 累加器展开 | 20-22 ns |
展开确实快 12-20%,但这是纯内核的账。库内热检索的实测单价是 108 ns/行,内核只占其中约两成,端到端落到几个百分点,在噪声内。不做。
**aarch64 NEON 内核。** 源码里没有 aarch64 路径,ARM 上走 `dot_codes_scalar`。影响面只有「部署到 ARM」,x86 上是零,且这条路天花板低:5 万行 1024 维的分区,纯点积只占 1.0 ms。x86 上标量对 AVX2 是 8 倍左右的差距(193 ns 对 24 ns),ARM 上的实际差距没在 ARM 机器上量过。
**图剪枝的短跳直查。** 现状是带 `prune` 的每次检索都全量建图(两万级实体、四万级关系),实测建图 45-54 ms,只为取 1~2 跳邻域;同一条查询不带 `prune` 32-37 ms,带 `prune` 81-95 ms——剪枝目前是负收益。直查方案量过:1 跳 4.7 ms、2 跳 20.8 ms。没做的原因是收益取决于向量规模:剪枝省的是向量打分(108 ns/行),分区不够大时省下的打分盖不过建图,做完可能仍是负收益。
**全文检索的严格轮早停。** 报告主张「严格轮命中数十条但候选窗口没满,于是被强制触发宽松的单字 OR 轮,几十毫秒全浪费」。打点结果相反:中文常用词在全档检索时严格轮直接打满候选窗口(`hits=500`),宽松轮根本没执行——`if !strict && result.len() >= limit { break; }` 本来就在早停。宽松轮只在严格命中少时跑,实测 1-12 ms。那 30 ms 全在严格轮里,是中文二元组匹配集的 BM25 打分遍历。顺带验了排序:把(分数, key)双字段排序换成只按分数,28.5 ms 对 29-32 ms,无差异。
**给 `events_between` 下推 `LIMIT`。** 报告主张 SQLite 收齐 N 条就会提前终止扫描与排序。实测否证:查询计划里有 `USE TEMP B-TREE FOR GROUP BY` 与 `USE TEMP B-TREE FOR count(DISTINCT)`,分组必须先做完;`LIMIT 40` 是 53.5 ms 对无 `LIMIT` 59.3 ms,只差 6 ms。有效的是换驱动表。
**候选关系先截断再装配。** 想照事件路的样子做,做不了:第二步的排序权重需要关系正文,先截断就没法排序。这 25 ms 只能留着。
**下调默认 `candidate_limit` 的兜底值。** 实测候选 500 涨到 2000 只多 8 ms——成本在遍历匹配集,不在收集条数。而且它决定重排候选集,改它是口径变更,不是提速。
**`rank_relations_by_query` 换判定方式。** 现在对候选关系逐条 `text::tokenize` 构造词元集合,实测 26-31 ms,成本几乎全在小字符串分配上。改成在归一化正文上直接查词元可以省下大部分,但 CJK 之外要补词边界判断(否则 `a` 会命中 `ab`),是语义改写。收益最小、风险最高,放弃。
## 已知未处理
- **`with_total` 的全量计数**:一次 `COUNT(*)` 扫过过滤后的全部记录,全档查询实测 65-113 ms。这是物理成本,不是实现问题。调用方按需开启;只为判断「还有没有下一页」时,用 `limit + 1` 更合适。
- **切片档的全文检索**:中文常用词在宽松单字 OR 下匹配集覆盖大半索引,实测 30-58 ms 且随库规模增长。目前的应对是不要在这个档上开大候选窗口。
- **向量分区冷载入**:与分区行数成正比(5 万行 320-383 ms)。连接目前没开 `mmap_size`;自建夹具上同一条载入 SQL 从 120.7 ms 降到 78.1 ms,但那是裸 SQL 的数字,库内载入路径没复测。同一个开关对其它路径实测无变化。
- **向量载入的剩余成本**:同一批行把领域与可见范围换写字面 id、去掉每行两次 `strings` 点查后,载入约 100 ms。探针量过,未落地。
## 口径提醒
- 并列名次由 `retain` 按 key 显式破,与行序无关;去掉分区载入的 `ORDER BY` 不影响结果,有用例锁住这个前提。
- 事件正文长度走 SQL 算,与 `record_text` 必须逐条一致——两者漂移会让字符预算截在别的地方,有用例锁住。
- 声称收益必须给实测数据与复现方式。单次测量不足以支撑结论。
- 端到端耗时并不全在库内:预设接口的端到端与库内分阶段打点相差约 12 ms,那是调用方构造返回对象的时间。