Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .github/workflows/perf-scale.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: Perf Scale

# 50k 规模压测 CI 复跑:手动触发(可调参)+ 每周日定时全量 50k。
# 结论阈值:并发 P50 / 顺序 P50(争用系数)不得超过 2.0,超标即本 run 失败。
# 报告 JSON 作为 artifact 上传:perf-scale-report-<docs>/perf_scale_ci_report.json
# 本地复跑命令见 docs/qa/PERF_SCALE_50K.md。

on:
workflow_dispatch:
inputs:
docs:
description: '文档数(默认 50000 全量;排查时可跑 10000/1000 快档)'
required: false
default: '50000'
queries:
description: '查询数'
required: false
default: '300'
max_contention_factor:
description: '争用系数门槛(默认 2.0)'
required: false
default: '2.0'
schedule:
- cron: '34 2 * * 0' # 每周日 02:34 UTC(避开整点洪峰)

concurrency:
group: perf-scale
# false:手动触发与周日定时 50k 并行时排队等待,而不是取消进行中的 2.5h 运行
# (与 desktop-release.yml 的发布型 job 先例一致)。
cancel-in-progress: false

permissions:
contents: read

jobs:
perf-scale:
name: perf-scale (docs=${{ github.event.inputs.docs || '50000' }})
runs-on: ubuntu-latest
# 50k 实测 ~2.5h(含建索引),留足编译与调度余量
timeout-minutes: 360

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Disk space check (50k DB 约 11GB,注意 runner 14GB 上限)
run: df -h .

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable

- name: Restore Rust cache
uses: Swatinem/rust-cache@v2

- name: Run perf scale (offline deterministic, no rerank)
shell: bash
run: |
cargo run --release -p memori-core --example perf_scale -- \
--docs "${{ github.event.inputs.docs || '50000' }}" \
--sections 50 \
--queries "${{ github.event.inputs.queries || '300' }}" \
--concurrency 8 \
--report target/perf_scale_ci_report.json \
--max-contention-factor "${{ github.event.inputs.max_contention_factor || '2.0' }}"

- name: Upload report artifact
# 断言失败时才是最需要看报告的时候:必须 always(),否则恰好在 run FAIL 时丢掉 artifact。
if: always()
uses: actions/upload-artifact@v4
with:
name: perf-scale-report-${{ github.event.inputs.docs || '50000' }}
path: target/perf_scale_ci_report.json
if-no-files-found: error
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ document routing -> chunk retrieval -> RRF/gating -> evidence/citation
- 图谱抽取与实体关系 API:已实现,图谱可视化 UI 仍有限。
- 跨语言检索(中文问→英文文档):基础覆盖,query 双语扩展未完整。
- Source preview 与 Markdown export。
- 50k 规模压测:harness 与验证已完成(顺序/并发 P50/P95/P99,争用系数 1.91× 达标 <2×,数据与结论见 `docs/qa/PERF_SCALE_50K.md`);但 50k 下**单查询绝对延迟仍高**(顺序 P50 ≈ 9.8s,`doc_recall` 占约 81%),文档级召回优化还在路上。

### 📐 设计中/待实现

Expand All @@ -280,7 +281,6 @@ document routing -> chunk retrieval -> RRF/gating -> evidence/citation
- API key 接 OS keychain(当前明文存 settings.json)。
- OCR(图片/扫描件目前不可检索)。
- Memory heat score、conflict resolver、lifecycle classifier。
- 50k 规模压测(P50/P95,大规模并发性能尚未验证)。
- 多租户隔离(当前 OIDC 登录后共享同一库)。

---
Expand Down
39 changes: 37 additions & 2 deletions docs/qa/PERF_SCALE_50K.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,44 @@
建索引(单写)也快约 2×。P1 单连接串行化问题已解决;剩余 1.72× 主要是 doc_recall 阶段
(仍 119ms/P50)本身的计算成本与池上限(4 < 并发 8),如需进一步可调大池或优化文档级召回。

## 规模验证:10k / 50k(2026-09-07)

harness 同参复跑(确定性 embedding + 内存合成语料):`--docs 10000` / `--docs 50000`
(50k = 50,000 docs × 50 sections = **5,050,000 chunks**),300 查询、并发 8。
报告:`target/perf_10k_validation.json` / `target/perf_50k_report.json`。

| 规模 | 顺序 P50 | 顺序 P95 | 并发 P50 | 并发 P95 | 争用系数 | 并发吞吐 |
|---|---|---|---|---|---|---|
| 1k(改后) | 167.1 ms | 179.5 ms | 286.6 ms | 434.8 ms | 1.72× | 26.7 qps |
| 10k | 1870.0 ms | 1904.6 ms | 3392.6 ms | 5502.0 ms | 1.81× | 2.20 qps |
| 50k | 9817.9 ms | 10020.7 ms | 18717.8 ms | 29792.1 ms | 1.91× | 0.41 qps(4.14× 顺序) |

- **争用系数**随规模亚线性上升(1.72 → 1.81 → 1.91×),保持在 <2× 目标内——P1 的
WAL + 只读连接池结论在 50k 规模成立,单 `Mutex<Connection>` 不再是瓶颈顾虑。
- **绝对延迟成为新一轮关注点**:50k 顺序 P50 9.82s 中 `doc_recall` 占 7.97s(81%);
顺序 P99 18.1s / **max 40.4s**,且出现在无争用的顺序场景——长尾与争用无关;
并发长尾 P99 36.0s / max 41.4s。规模 ×5(10k→50k)时 doc_recall 近似线性增长,
文档级召回(lexical+dense 候选扫描)是唯一的规模敏感主项。
- **建索引**:10k `index_ms` 3,171,595 ms;50k `index_ms` 1,861,605 ms(含 50k 续跑时的
FTS 重建开销;两次机器状态不同,数值不可直接对比)。
- 已知故障模式:50k 下偶发单查询 batch 延迟 40s+(对应 `chunk_lexical` max 23.9s),待观察。

## CI 复跑(2026-09-08 起)

workflow `.github/workflows/perf-scale.yml`(job `perf-scale`,ubuntu-latest):

- **触发**:`workflow_dispatch` 手动(Actions 页面,可改 `docs`/`queries` 参数跑排查快档)+
`schedule` 每周日 02:34 UTC 全量 50k;不挂在 PR 流程上(50k 一轮 ~2.5h)。
- **默认参数**:`--docs 50000 --sections 50 --queries 300 --concurrency 8`
- **断言**:`--max-contention-factor 2.0`——争用系数(并发 P50 / 顺序 P50)> 2.0 时
进程非零退出、run 标红(与 1k/10k/50k 实测 1.72→1.81→1.91× 的留量一致)。
- **产物**:`perf-scale-report-<docs>` artifact 下的 `perf_scale_ci_report.json`(完整
P50/P95/P99 与阶段分解),日志尾部有 PASS/FAIL 摘要行。

## 备注

- 顺序 P50 251 ms 中 `doc_recall` 185 ms 为大头——50k+ 规模下文档级召回(lexical+dense)是主成本,
与 chunk 级相比更敏感于规模,后续若再扩规模应优先观察此项。
- 顺序 P50 中 `doc_recall` 占比随规模上升(1k 74% → 50k 81%),且绝对值随规模近似线性
增长——文档级召回(lexical+dense)是唯一规模敏感主项;若产品态目标为 50k+ 文档规模,
优化应聚焦它(分阶段召回 / 文档级倒排截断)。
- 本测用确定性 embedding,**绝对延迟**反映存储/检索代码路径而非真实模型往返;用于纵向对比
(改造前后、规模前后),不与 live 端到端答题延迟混淆。
75 changes: 66 additions & 9 deletions memori-core/examples/perf_scale.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
//! 用法:
//! cargo run --release -p memori-core --example perf_scale -- \
//! --docs 1000 --sections 50 --queries 300 --concurrency 8 --report target/perf_50k.json
//! (CI 断言:追加 --max-contention-factor 2.0,超标即非零退出;--start-doc N 可断点续跑。)

use std::collections::HashSet;
use std::hash::{Hash, Hasher};
Expand All @@ -39,6 +40,10 @@ struct Args {
concurrency: usize,
db_path: PathBuf,
report_path: Option<PathBuf>,
/// >0 时续跑:保留现有 DB 数据,从该 doc 序号继续写(跳过 purge)。
start_doc: usize,
/// 竞用系数门槛:>0 时若 并发P50/顺序P50 超标则进程以非零退出码结束(供 CI 断言)。
max_contention_factor: Option<f64>,
}

fn parse_args() -> Result<Args, AnyError> {
Expand All @@ -47,6 +52,8 @@ fn parse_args() -> Result<Args, AnyError> {
let mut sections = 50usize;
let mut queries = 300usize;
let mut concurrency = 8usize;
let mut start_doc = 0usize;
let mut max_contention_factor = None;
let mut db_path = cwd.join("target").join("perf_scale.db");
let mut report_path = None;

Expand All @@ -59,6 +66,16 @@ fn parse_args() -> Result<Args, AnyError> {
"--concurrency" => {
concurrency = it.next().ok_or("--concurrency requires a value")?.parse()?
}
"--start-doc" => {
start_doc = it.next().ok_or("--start-doc requires a value")?.parse()?
}
"--max-contention-factor" => {
max_contention_factor = Some(
it.next()
.ok_or("--max-contention-factor requires a value")?
.parse()?,
)
}
"--db-path" => {
db_path = absolutize(&cwd, it.next().ok_or("--db-path requires a value")?)
}
Expand All @@ -78,6 +95,8 @@ fn parse_args() -> Result<Args, AnyError> {
concurrency: concurrency.max(1),
db_path,
report_path,
start_doc: start_doc.min(docs),
max_contention_factor,
})
}

Expand Down Expand Up @@ -172,8 +191,10 @@ async fn main() -> Result<(), AnyError> {
std::env::set_var(MEMORI_DB_PATH_ENV, &args.db_path);
std::env::set_var("MEMORI_RERANK_ENABLED", "0");
}
// 干净起点:删除旧 DB。
let _ = std::fs::remove_file(&args.db_path);
// 干净起点:删除旧 DB(--start-doc>0 续跑时保留)。
if args.start_doc == 0 {
let _ = std::fs::remove_file(&args.db_path);
}
Comment on lines +195 to +197

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题 (bug_risk): 当 --start-doc 大于零,但指定的数据库不存在时,代码会创建一个新的空数据库,并且只为 start_doc..docs 范围内的文档建立数据;此时,报告却声称包含完整的 docs 文档集,尽管前面的文档并不存在。

触发条件: 使用缺失、已删除或指定错误的 --db-path 开始续跑时。

建议修复: 除非数据库存在且包含预期的既有文档集,否则应立即失败;或者在继续之前,显式地为缺失的前缀文档建立数据。

Original comment in English

issue (bug_risk): When --start-doc is greater than zero but the specified database does not already exist, the code creates a new empty database and seeds only documents in start_doc..docs; the report then claims the full docs corpus even though the earlier documents are absent.

Triggers: When a resume is started with a missing, deleted, or incorrectly specified --db-path.

Suggested fix: Fail fast unless the database exists and contains the expected prior corpus, or explicitly seed the missing prefix before continuing.

if let Some(parent) = args.db_path.parent() {
std::fs::create_dir_all(parent)?;
}
Expand All @@ -188,7 +209,14 @@ async fn main() -> Result<(), AnyError> {
args.docs, args.sections
);
let index_started = Instant::now();
let indexed_chunks = seed_corpus(&engine, &synthetic_root, args.docs, args.sections).await?;
let indexed_chunks = seed_corpus(
&engine,
&synthetic_root,
args.docs,
args.sections,
args.start_doc,
)
.await?;
let index_ms = index_started.elapsed().as_millis() as u64;
eprintln!(
"[perf] indexed {indexed_chunks} chunks in {index_ms} ms ({:.0} chunks/s)",
Expand Down Expand Up @@ -280,6 +308,25 @@ async fn main() -> Result<(), AnyError> {
}
println!("{json}");
print_human_summary(&report);

// CI 断言:--max-contention-factor 给出时,超标以非零退出码结束(报告已写盘)。
if let Some(limit) = args.max_contention_factor {
if limit > 0.0 {
if report.contention_factor > limit {
eprintln!(
"[perf] FAIL: contention_factor {:.3} > limit {limit} —— 并发争用超标,CI 判定未达标。",
report.contention_factor
);
std::process::exit(1);
}
eprintln!(
"[perf] contention_factor {:.3} <= limit {limit} —— PASS",
report.contention_factor
);
} else {
eprintln!("[perf] WARN: --max-contention-factor {limit} 无效(须 > 0),跳过断言。");
}
}
Ok(())
}

Expand Down Expand Up @@ -324,14 +371,19 @@ async fn seed_corpus(
root: &Path,
docs: usize,
sections: usize,
start_doc: usize,
) -> Result<usize, AnyError> {
let state = engine.state();
let store = state.vector_store.clone();
store.begin_full_rebuild("perf_scale_seed").await?;
store.purge_all_index_data().await?;
if start_doc == 0 {
store.begin_full_rebuild("perf_scale_seed").await?;
store.purge_all_index_data().await?;
} else {
eprintln!("[perf] resume: keeping existing DB, continuing from doc {start_doc}/{docs}...");
}

let mut total_chunks = 0usize;
for d in 0..docs {
let mut seeded_chunks = 0usize;
for d in start_doc..docs {
let path = root.join(format!("doc_{d:06}.md"));
let text = synth_document(d, sections);
let chunks = parse_and_chunk(&path, &text)?;
Expand All @@ -353,14 +405,19 @@ async fn seed_corpus(
embeddings,
)
.await?;
total_chunks += chunks.len();
seeded_chunks += chunks.len();
if d % 200 == 0 && d > 0 {
eprintln!("[perf] seeded {d}/{docs} docs ({total_chunks} chunks)...");
eprintln!("[perf] seeded {d}/{docs} docs ({seeded_chunks} chunks this run)...");
}
}

store.finish_full_rebuild().await?;
store.load_from_db().await?;
// 以 DB 的真实 chunk 数作为最终值:resume 时 --start-doc 可能小于实际进度,
// 这些文档会被重新写入并**替换**旧 chunk;若用"库里已有数 + 本次又写入数"累加,
// 就会重复计数、虚报 indexed_chunks 与 chunks/s(压测数字偏乐观)。
let total_chunks = store.count_chunks().await? as usize;
eprintln!("[perf] chunks in db after seeding: {total_chunks}");
Ok(total_chunks)
}

Expand Down