Rust in Everything
基于 tantivy 0.27.0

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。

💬 关联讨论

发起讨论
加载中...