The memory arena in bytes per thread needs to be at least 15000000.
主线位置:写入主线第二步,
Index::writer(budget)把总预算分给索引线程;以及第四步,SegmentWriter的内存池到了预算就把段定稿。线程数怎么算见 IndexWriter,一个空段写手为什么就占 13.6 MB 见 SegmentWriter 与内存池。
flowchart TB
b["总预算"] --> t["线程数"]
t --> per["每线程 = 总 / 线程"]
per --> chk{"每线程 15 MB 以上"}
chk -->|否| err["InvalidArgument"]
chk -->|刚过| small["一段一千多篇"]
chk -->|充足| big["段大 段少"]
实验 crate:exp-tantivy/pita-lab,src/bin/p10_budget.rs。语料是 tantivy 仓库 benches/wiki.json 的 1000 篇维基摘要,重复 20 遍得到 2 万篇(uid 各不相同);schema uid(STRING | STORED)、title(TEXT | STORED)、body(TEXT)。M2 Max、macOS 27、rustc 1.99.0,12 核(available_parallelism = 12,线程数取决于它),release;uptime 负载约 4.6。
现象
available_parallelism = 12
writer(10_000_000): Some("An invalid argument was passed: 'The memory arena in bytes per thread needs to be at least 15000000.'")
writer_with_num_threads(4, 40_000_000): Some("An invalid argument was passed: 'The memory arena in bytes per thread needs to be at least 15000000.'")
writer_with_num_threads(1, 5_000_000_000): Some("An invalid argument was passed: 'The memory arena in bytes per thread cannot exceed 4293967295'")
- 传 10 MB 给
writer(),报「每线程」至少 15 MB。调用方没有提过线程。 - 指定 4 个线程、总共 40 MB,每线程 10 MB,同样的报错。
- 另一头也有上限:每线程不能达到 4,293,967,295 字节。
另一个不报错的现象:预算刚好够,段却很小。同样 2 万篇,NoMergePolicy,一次提交,各跑 3 次:
1 thread x 15000000: 16 segments, largest [1297, 1287, 1284, 1274], add+commit min of 3 = 0.457s
1 thread x 50000000: 1 segments, largest [20000], add+commit min of 3 = 0.291s
1 thread x 200000000: 1 segments, largest [20000], add+commit min of 3 = 0.286s
15 MB 的线程每 1300 篇左右就切一个段,写 2 万篇切了 16 段,耗时多了约 57%。
原因
报错是按每线程算的。 Index::writer 先取 min(核数, 8) 个线程;每线程不足 15 MB 时,线程数改成 预算 / 15 MB,至少 1:
let mut num_threads = std::cmp::min(available_parallelism()?.get(), MAX_NUM_THREAD);
let memory_budget_num_bytes_per_thread = memory_budget_in_bytes / num_threads;
if memory_budget_num_bytes_per_thread < MEMORY_BUDGET_NUM_BYTES_MIN {
num_threads = (memory_budget_in_bytes / MEMORY_BUDGET_NUM_BYTES_MIN).max(1);
}
self.writer_with_num_threads(num_threads, memory_budget_in_bytes)
10 MB 时 10 / 15 取整为 0,.max(1) 变成 1 个线程,每线程还是 10 MB。writer_with_num_threads 用总预算除以线程数,IndexWriter::new 检查每线程预算:下限 MEMORY_BUDGET_NUM_BYTES_MIN = 15 × MARGIN_IN_BYTES = 15,000,000,上限 u32::MAX - MARGIN_IN_BYTES = 4,293,967,295(内存池的地址是 32 位)。错误信息里只有「per thread」,没有告诉你线程数和总预算。
刚过下限时段很小。 一个空的段写手就有约 13.6 MB 的固定开销(词项表和几个池的首页),判满的条件是 mem_usage >= 预算 - 1 MB。15 MB 的线程留给文档的只有不到 0.4 MB,所以一千多篇就满(数字和推导见 SegmentWriter 与内存池)。段多了,后面要么由合并线程再合起来(多一轮读写),要么留下一堆小段拖慢查询。
修法
先决定线程数,再按「线程数 × 每线程预算」给总预算。每线程至少要比 15 MB 多出一截,上面的数据里 50 MB 已经能把 2 万篇写成一个段:
// 显式指定:2 个索引线程,每个 50 MB
let writer: IndexWriter = index.writer_with_num_threads(2, 100_000_000)?;
用 index.writer(总预算) 时要记得它会先占满 min(核数, 8) 个线程:本机 12 核,writer(50_000_000) 是 3 个线程、每个 16.7 MB,刚过下限;要 8 个线程各 50 MB 就得给 400 MB(线程数和预算的对照表见 IndexWriter)。同一份代码换一台核数不同的机器,线程数和每线程预算都会变。
内存紧的场景,宁可少开线程,也别让每个线程卡在 15 MB 附近。
怎么检查
报错时:算一下 总预算 / 线程数。用 writer() 时线程数是 min(available_parallelism, 8),不够 15 MB 再降。
不报错时:用 NoMergePolicy 写一批有代表性的文档、提交一次,看段数和每段的文档数:
let sizes: Vec<u32> = searcher.segment_readers().iter().map(|s| s.num_docs()).collect();
上面三行输出的段数和 largest 就是这样来的。一次提交出现很多个大小相近的段,说明每线程预算太小。
小结
- 下限 15,000,000 字节、上限 4,293,967,295 字节,都是每线程的;
writer(总预算)按min(核数, 8)分线程,不足时降线程数,最少 1 个。 writer(10_000_000)报错,因为降到 1 个线程也只有 10 MB。- 刚过下限时一个段只装一千多篇:2 万篇维基摘要,15 MB 切成 16 段、0.457 s,50 MB 一段、0.291 s。
- 用
writer_with_num_threads(n, n × 每线程预算)明确地给,每线程留足几十 MB。