set_multithread_executor 不生效
主线位置:查询主线第四步。
Searcher::search把「每个段跑一遍收集器」交给一个Executor:单线程时依次跑,线程池时每个段一个任务。用的是哪个执行器、并行能省多少,见 IndexReader 与 Searcher。
flowchart TB idx["Index 单线程执行器"] --> rb["reader_builder 克隆"] rb --> r1["reader 带旧执行器"] idx --> set["set_multithread_executor"] set --> idx2["Index 换成线程池"] r1 --> s1["search 仍在 main"] idx2 --> rb2["之后的 reader_builder"] rb2 --> s2["search 在线程池"]
实验在 exp-tantivy/pitb-lab,bin p23_exec(release):一个 4 段的磁盘索引(NoMergePolicy,每篇一次提交),自定义收集器在 for_segment 里记下当前线程名,线程数用 ps -M 数。M2 Max、macOS 27、rustc 1.99.0,tantivy 0.27.0。
现象
启动时先把 reader 建好,后面再想起来开并行:
let reader = index.reader_builder().reload_policy(ReloadPolicy::Manual).try_into()?;
index.set_multithread_executor(4)?;
threads at start: 1
threads after set_multithread_executor(4): 5
reader built first : ["seg0@main", "seg1@main", "seg2@main", "seg3@main"]
after reload() : ["seg0@main", "seg1@main", "seg2@main", "seg3@main"]
reader built after : ["seg0@tantivy-search-1", "seg1@tantivy-search-2", "seg2@tantivy-search-3", "seg3@tantivy-search-0"]
open_in_dir again : ["seg0@main", "seg1@main", "seg2@main", "seg3@main"]
- 线程池的 4 个线程立刻起来了,但先建的 reader 查询时四个段都在
main上串行。 reload()换了一代 searcher,执行器还是旧的。- 设完执行器以后再建的 reader 才用上线程池。
- 另一处用
Index::open_in_dir重新打开同一个目录,得到的是一个全新的Index,又回到单线程。
原因
执行器是 Index 的一个字段,set_multithread_executor 只是把这个字段换掉:
pub fn set_multithread_executor(&mut self, num_threads: usize) -> crate::Result<()> {
self.executor = Executor::multi_thread(num_threads, "tantivy-search-")?;
Ok(())
}
Index 是 Clone 的,reader_builder 把当时的 Index 克隆进 reader,之后每一代 SearcherInner 都从这份克隆里拿 Index。Searcher::search 经 search_with_statistics_provider 取的是 self.inner.index.search_executor()。改原来那个 Index,已经克隆出去的副本看不到;reload 用的也还是那份副本。Executor::multi_thread 建 rayon 池时线程就起来了,所以线程数涨了,活却没派给它们。
修法
先设执行器,再建 reader。 上面「reader built after」那一行就是。Index::open_in_dir 之类每次都返回新的 Index,打开以后第一件事设执行器。
或者每次查询自己指定执行器。 search_with_executor 接受任意 Executor,但它还要一个 EnableScoring,search 原本替你按 collector.requires_scoring() 选好了。照抄 search 的逻辑包一层:
/// Same as Searcher::search, but on an executor we own.
fn search_on<C: Collector>(s: &Searcher, q: &dyn Query, c: &C, exec: &Executor) -> tantivy::Result<C::Fruit> {
let scoring = if c.requires_scoring() {
EnableScoring::enabled_from_searcher(s)
} else {
EnableScoring::disabled_from_searcher(s)
};
s.search_with_executor(q, c, exec, scoring)
}
search_with_executor: ["seg0@my-search-1", "seg1@my-search-0", "seg2@my-search-0", "seg3@my-search-0"]
TopDocs, scoring from requires_scoring: [(1.2039728, DocAddress { segment_ord: 3, doc_id: 0 })]
TopDocs, scoring disabled by mistake : Err(InvalidArgument("for each pruning should only be called if scoring is enabled"))
EnableScoring 选错了也不是静悄悄的:按分数的 TopDocs 要剪枝,TermWeight 的 for_each_pruning 发现没开打分就报错。反过来,给不需要分数的收集器开了打分,结果对,只是白算了 BM25。
执行器值不值得开另说:并行的粒度是段,只有一个段时线程池只会添开销(IndexReader 与 Searcher 有实测)。
怎么检查
Searcher::index() 能拿到它实际用的那份 Index,看一眼它的执行器:
let multi = matches!(reader.searcher().index().search_executor(), Executor::ThreadPool(_));
check reader: searcher's executor is a thread pool = false
check reader2: searcher's executor is a thread pool = true
启动时断言一次,或者打一行日志。
小结
- 执行器是
Index的字段,reader_builder克隆当时的Index;之后再set_multithread_executor,已有的 reader(包括它以后的每次reload)仍然单线程,线程池的线程却已经起来了。 - 先设执行器再建 reader;重新
open_in_dir得到的Index要重新设。 - 想按查询选执行器就用
search_with_executor,EnableScoring要按collector.requires_scoring()选,选错了按分数的TopDocs会报「for each pruning should only be called if scoring is enabled」。 - 检查:
searcher.index().search_executor()是不是Executor::ThreadPool。