Rust in Everything
基于 tantivy 0.27.0

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。

💬 关联讨论

发起讨论
加载中...