# 14× 更快的嵌入：我们如何重构 Manticore 的 ONNX 路径

随 Manticore Search 27.1.5 发布的新 ONNX Runtime 后端，在相同硬件、相同模型、相同权重下，让自动 embeddings 的速度平均比之前的 SentenceTransformers/Candle 路径快约 14 倍，而且无论你运行 1 个客户端线程还是 32 个，这个优势都成立。

当我们发布 [Auto Embeddings](/blog/auto-embeddings/) 时，这个功能可以把任意文本列自动转换成向量，而且无需单独运行模型服务。最常见的反馈就是速度。之前的路径是先通过 SentenceTransformers，再建立在 [Candle](https://github.com/huggingface/candle) 之上，Candle 是 Hugging Face 的纯 Rust ML 推理运行时，它把大量 CPU 性能白白浪费了：无论我们怎么喂数据，大多数负载都只能跑到每秒十几篇文档的低位区间，而且并发调用还会在单个模型会话上串行化。

所以我们花了几周时间重构 Manticore 运行 ONNX 模型的方式。新的 ONNX Runtime 后端已随 [Manticore Search 27.1.5](/blog/manticore-search-27-1-5/) 发布。ONNX（Open Neural Network Exchange）是一种可移植的模型格式，大多数流行的开源 embedding 模型，比如 MiniLM、BGE、E5 以及其他同类模型，早已发布为这种格式。结果就是，这个后端在相同硬件上（平均来看是廉价的 16 核 / 32 线程服务器）、相同模型、相同权重、并在完整的 `threads × batch` 工作负载网格上求平均后，**比之前的 SentenceTransformers/Candle 路径平均快约 14 倍**，而且无论你运行 1 个客户端线程还是 32 个，这个优势都成立。旧路径在整个网格中一直停留在 5–11 docs/sec 区间；新路径则处在 70–230 docs/sec 区间。

这篇文章就是这次工程实践的记录：我们尝试了什么、哪些结果出人意料、哪些方案被我们舍弃了，以及最终设计长什么样。

## TL;DR

- **平均比之前的 SentenceTransformers/Candle 路径快约 14×**，这是在同一台机器（16 核 / 32 线程）、相同模型、相同权重下，跨完整 `threads × batch` 工作负载网格（1 / 2 / 4 / 8 / 16 / 32 线程 × 批大小 1…128）得到的平均结果。
- 随 [Manticore Search 27.1.5](/blog/manticore-search-27-1-5/) 发布的新 ONNX 路径，现在已成为任何带有 `.onnx` 文件的 HuggingFace 模型的默认高速路径。
- 在 `all-MiniLM-L12-v2` 上，旧的 Candle 路径在我们尝试的每一种配置下都稳定在 **5–11 docs/sec**。新的 ONNX 路径则落在 **70–230 docs/sec** 区间里，而且 **无论你跑 1 个客户端线程还是 32 个，这个约 14× 的差距都成立**。
- 我们测试机上的单次插入延迟：单客户端时约 **14 ms**，在 8 路并发负载下约 **56 ms**，都远低于 Candle 当时的 200+ ms。
- **想要最高的批量导入吞吐量？** 用 **单个客户端线程** 配合 **较大的批大小**（32–128）。新的后端会在调用内部并行化，所以客户端侧再做多路分发只会叠加协调开销。我们这台机器上的峰值是 **1 线程 + batch=64 时 233 docs/sec**。
- 最关键的两个变化：把 **`intra_op_spinning` 关掉**，以及放弃在 worker 内部对文档做批处理。
- 没有面向用户的 API 变更。已经指向支持 ONNX 的 `MODEL_NAME` 的表会自动走新路径。把现有表切换到另一个模型并不是一行命令就能完成的事 - Manticore 不允许直接修改 `FLOAT_VECTOR` 字段上的 `MODEL_NAME` - 但你也不必重建整张表：你可以在旁边新增一个使用新模型的列，重新生成它的嵌入，然后删除旧列。

## 为什么这很重要

在自动嵌入模式下，数据库会在每次 `INSERT` 时自己运行模型。这意味着嵌入速度 *就是* INSERT 速度 - 你的导入吞吐量完全取决于嵌入步骤能跑多快。

旧的 SentenceTransformers/Candle 路径把性能留在了桌面上。并发会撞上锁竞争，批量调用会因为填充开销而进入平台期，而在两次调用之间，运行时又会以一种阻止下一次调用接上上一次工作的位置暂停线程。最直观的症状很简单：无论你怎么折腾，`top` 都会显示整台机器远未满载。整个扫描范围 - 单行 INSERT、128 行批量 INSERT、1 个客户端线程、32 个客户端线程 - 都停在 **5–11 docs/sec**，因为你怎么喂它都榨不出更多 CPU。

新的 ONNX 路径把下限抬高了一个数量级，而且还给用户提供了有意义的性能调优空间。现在单线程、单行 INSERT 已经能达到 **72 docs/sec** - 这本身就比旧 Candle 上限高了约 7×。再加上并发或批大小后，它会提升到 **130–230 docs/sec** 区间，而整个网格的最高点是在 **单客户端线程、`--batch-size=64` 时的 233 docs/sec**。在整个 `threads × batch` 矩阵上取平均后，新路径大约是旧路径的 **14×**。

## 为什么选 ONNX，而不是 Candle

Manticore 的嵌入库已经支持几种后端有一段时间了。Candle 路径在正确性和易发布性方面都很不错。但对于 MiniLM 和 BGE 这类小型 encoder 模型的生产级推理，ONNX Runtime 很难被超越：

- ONNX Runtime（或 **ORT** - 微软官方的、经过手工调优的 ONNX 模型 C++ 推理引擎）会做图融合、常量折叠和 kernel 自动调优。
- HuggingFace 上大多数流行的嵌入模型已经在它们的 `onnx/` 目录里发布了预融合的 `model.onnx`。磁盘上的文件本身就已经是 ORT 想要的形态。

在相同的 `all-MiniLM-L12-v2` 权重、CPU 上，ONNX 路径相较 Candle 路径有明显提升。质量相同，但每篇文档需要做的工作少得多。

ORT 会用一组我们有明确取舍的配置来创建 session：

```rust
let session = ort::session::Session::builder()?
    .with_optimization_level(GraphOptimizationLevel::Level3)?
    .with_intra_threads(0)?            // let ORT pick (= all cores)
    .with_intra_op_spinning(false)?    // do NOT busy-wait between calls
    .with_flush_to_zero()?             // kill denormals on attention softmax
    .with_approximate_gelu()?          // ~10% faster activation, no quality loss
    .commit_from_file(&onnx_path)?;
```

其中大多数都没什么争议，属于“当然应该打开”的开关。只有一个不是：`intra_op_spinning(false)`。后面我们会回到它 - 它是整个分支里最大的提升，而且严格来说这甚至不算 ORT 的设置，更像是负载形态的决策。

## 并发模型 - 大多数读者会觉得新鲜的部分

如果你对 Rust 开发者说“让 ONNX 跑快一点”，又不给其他约束，他们通常会走两种模式中的一种。我们都试过了。对这个负载来说，它们都不对。

**模式 1：一个共享的 `Session`，外面套一个 `Mutex`**（`Mutex` 是一种锁，同一时间只允许一个线程访问 session）。这很容易推理，也很容易写对。但在并发下吞吐量会崩掉，因为每个调用都会在锁上串行化。做 CLI 工具还行，给一个要同时服务很多 INSERT 的数据库就很糟糕。

**模式 2：session 池，每个 CPU 一个 `Session`。** 不再有锁竞争，但冷启动时间会成倍增加，内存占用也会成倍增加，而且小输入为了落到某个 session 上还要额外付出分发成本。我们在一个开发分支里做出了一个可工作的版本，但它始终差那么一点没能真正跑起来。

真正解锁这个设计的，是大多数 Rust ONNX 封装都会搞错的一点：**在 Linux 和 macOS 上，ORT 的 C `Run()` API 是线程安全的。** 你可以让很多并发调用者共享同一个 `Session`，而不需要任何加锁。C++ 那一侧已经会把需要串行化的部分串行化了；Rust API 只是用借用检查器规则把它包起来，而这些规则并不符合底层库实际上允许的行为。

所以我们把 session 包装成了一个带平台感知的小类型：

```rust
#[cfg(not(target_os = "windows"))]
struct SessionWrapper {
    inner: std::cell::UnsafeCell<ort::session::Session>,
}

#[cfg(not(target_os = "windows"))]
unsafe impl Sync for SessionWrapper {}
#[cfg(not(target_os = "windows"))]
unsafe impl Send for SessionWrapper {}

impl SessionWrapper {
    fn with_session<R>(&self, f: impl FnOnce(&mut Session) -> R) -> R {
        f(unsafe { &mut *self.inner.get() })
    }
}
```

是的，这里用了 `unsafe`。我们把借用检查器从链路里拿掉了，因为底层库已经文档化说明，在我们使用的访问模式下它是安全的。这是一个有意为之、带一句话理由的 `unsafe`，不是埋雷。

在 Windows 上，ORT 的线程模型存在已知问题，所以我们用 `Mutex` 把 `Run()` 串行化。重要的是，这把锁持有的是 *整个 closure*，而不只是 `run()` 调用本身 - 这正是我们在 Windows 上看到的竞态的修复方式：一个线程的 `SessionOutputs` 还在被读取时，另一个线程已经开始了新的 `run()`。是按 closure 加锁，不是按调用加锁。

## 自适应并行 - 我们走过的弯路

这部分工作花的时间最长，因为每一本教科书都会说“要让 ONNX 跑快，就把输入做成批”。所以我们最初的尝试也照着教科书来。

我们一次对 8、16、32 篇文档做分词，把它们填充到 `max_len`，然后每个 worker 线程跑一次前向推理。结果吞吐量反而比把同样的文本一条条送进同一个 session 还低。我们又跑了一遍。结果一样。我们花了一阵子试图推翻这个结论，最后才接受它。被回滚的提交 `980b24b "Revert: perf(model): batch inference in worker threads"`，就是我们停止硬拗、开始围绕 profiler 提示重建的那个时刻。

这个意外背后有两个原因。

**填充成本。** 一个长度混合的文本批次，会把每一行都填到最长那一行的长度。然后模型做的工作量就与 `batch_size * max_len * hidden_dim` 成正比，而不管这个批次里真正有多少内容。真实文本输入的长度变化非常大：一个包含 8 句随机句子的典型批次，可能只有 1 个 60 token 的长尾样本，其余 7 行都只有 8 token。模型的大部分周期都在拿填充 token 去乘 attention weight。对于单文档批次，模型只会按照该文档实际 token 数量来工作。按每篇文档计算，在输入长度方差真实存在时，“不做批处理”反而比“做批处理”更便宜。

**自旋。** ORT 的 intra-op 线程池默认会在调度间隙里 *自旋* - 线程在一个紧循环里烧 CPU，等待下一块工作。对于一个 session 调用里的一大批任务，这几乎看不出来：线程一直在做真实工作。但在很多并发的小调用场景里，这会变成灾难：每个 worker 的 intra-op 线程池在调用之间都会把 CPU 钉在 100%，而且没有多余 CPU 留给别的事情。我们在 `top` 里看到了完全一样的模式：每个核心都 100%，但关闭自旋后吞吐量反而更低。这听起来不合理，直到你记起系统的其他部分也需要 CPU 时间 - tokenizer、HNSW 构建、以及 `searchd` 的其他工作。打开 `with_intra_op_spinning(false)` 只是改一行代码，却立刻同时提高了吞吐量并降低了 CPU 使用率。

所以最终形态和教科书式配方正好相反：

- **一个共享 session**，不要池。
- **一次推理调用只处理一篇文档**，worker 内部不做批处理。
- **很多并发调用者**，按 CPU 数量扩展。
- 调用之间**不自旋** - 像个有礼貌的公民一样把 CPU 让出来。

```rust
fn predict_pipelined(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, _> {
    let bs = batch_size();

    // Small input — single tokenize + infer, no thread overhead.
    // This is the path a 1-doc INSERT takes.
    if texts.len() <= bs {
        return Self::tokenize_and_infer(&self.session, &self.tokenizer, texts, ...);
    }

    // Large input — split across workers, each running 1-doc-at-a-time
    // through the SHARED session. This deliberately mimics the
    // many-concurrent-callers pattern that ORT is happiest with.
    let num_workers = (texts.len() / bs).min(available_cpus()).max(1);
    let docs_per_worker = texts.len().div_ceil(num_workers);

    std::thread::scope(|s| {
        for worker_texts in texts.chunks(docs_per_worker) {
            s.spawn(move || {
                for text in worker_texts {
                    Self::tokenize_and_infer(&session, &tokenizer,
                                             std::slice::from_ref(text), ...)?;
                }
                Ok(())
            });
        }
    });
    // ...
}
```

这种双分支设计是刻意为之的。一个 1 行 INSERT 进来时，`texts.len() == 1`，也就是 `<= bs`，所以它会走快路径，**零线程创建、零通道发送、零协调开销**。而一个带成千上万行的 bulk REPLACE INTO 会走并行分支，从而拿到吞吐量收益。便宜的场景保持便宜，昂贵的场景保持并行。

我们还在启动时一次性开启了并行分词（`TOKENIZERS_PARALLELISM=true`），并在进入 BPE 之前先按字符数预截断输入，这样一大块 100KB 的文本不会在模型看到它之前就先把 tokenizer 的一个 CPU 核心卡上一秒。

## 数据

所有运行都在我们的标准基准机上完成，使用 `all-MiniLM-L12-v2-onnx`，每轮 1000 篇文档。由 [manticore-load](/blog/manticore-load/) 生成：

```bash
manticore-load --quiet --drop --batch-size=1 --threads=8 --total=1000 \
  --init="CREATE TABLE t (
    f text,
    v FLOAT_VECTOR KNN_TYPE='hnsw' HNSW_SIMILARITY='l2'
      MODEL_NAME='onnx-models/all-MiniLM-L12-v2-onnx' FROM=''
  )" \
  --load="INSERT INTO t(f) VALUES('<text/10/100>')"
```

同样的命令，使用 `--batch-size=2`、`8`、`32`、`128`，全部在 8 线程下运行：

| `--batch-size` | docs/sec | 平均调用延迟 (ms) | 单文档延迟 (ms) |
| -------------: | -------: | --------------------: | -------------------: |
| 1              | 143      | 55.9                  | 55.9                 |
| 2              | 113      | 141.6                 | 70.8                 |
| 8              | 91       | 703.3                 | 87.9                 |
| 32             | 146      | 1753.4                | 54.8                 |
| 128            | 147      | 6966.0                | 54.4                 |

和同样 8 线程下的 Candle 相比 - 旧路径在所有批大小下都稳定在 **10 docs/sec** - 这意味着根据你选择的批次大小不同，吞吐量提升了 **9× 到 15×**。其中“平均调用延迟”这一列表示一次完整 `INSERT` 语句返回所需的时间，而不是单篇文档；如果除以批大小，单文档成本会落在 55–90 ms 区间。

如果把表切换到 1 个客户端线程 - 结果证明这才是批量加载的最优配置 - 数据还会继续上升：批大小为 1 / 2 / 8 / 32 / 64 / 128 时分别是 **72 / 76 / 93 / 175 / 233 / 222 docs/sec**。整个网格的峰值是 **1 线程 × batch=64 时的 233 docs/sec**，单文档延迟约 **4.3 ms**。

### 如何喂数据才能获得最高吞吐量

如果你要批量导入大量数据，并且想要最高的 docs/sec，做法很直接：从 **单个客户端线程** 发送大的 `INSERT ... VALUES (..), (..), ...` 语句（批大小 32–128），不要从很多线程发很多小 INSERT。新的后端已经在 *调用内部* 做了并行化（见上面的 `predict_pipelined` 代码），所以客户端侧再做多路分发，只会在 ORT 已经在做的事情之上叠加协调开销 - 这就是为什么 1 线程 × batch=64（233 docs/sec）会明显胜过 8 线程 × batch=128（147 docs/sec）。

如果你的负载天然就是一次一行 - 比如 Web 请求、队列消费者、MCP 服务器 - 直接用 `INSERT INTO` 就行。单线程 / 单行的 72 docs/sec 下限已经比旧 Candle 路径快了约 7×，而且延迟低到你已经不需要再围绕这一层去优化了。

### 全网格的前后对比

为了把前后差异讲得更具体一些，我们还在同一台机器、相同权重下，把旧 Candle/`trans` 路径也按完整的 `threads × batch` 网格扫了一遍：

![ONNX vs Candle throughput and CPU utilisation across thread counts and batch sizes](./onnx-embeddings-speedup/onnx-perf.png)

*每个 X 轴刻度都是 `backend threads/batch-size`。左半部分（`trans …`）是旧的 Candle 路径 - 无论线程数多少、批大小多大，docs/sec 都始终停在 5–11 之间，而 CPU 已经被钉满。右半部分（`onnx …`）是新路径 - 在整个扫描范围内，docs/sec 都高出一个数量级。新路径内部：在小批次下，增加客户端线程有帮助（1T/batch=1 = 72 → 8T/batch=1 = 143）；在大批次下，单个客户端线程反而更强（1T/batch=64 = 233 是全局峰值）。*

![ONNX vs Candle efficiency: docs/sec per % CPU across configurations](./onnx-embeddings-speedup/onnx-effeciency.png)

*同样的扫描，但把效率（docs/sec / CPU 百分比）和 docs/sec 放在一起看。Candle（`trans`）那边，两条线都贴着底部走 - 机器在烧 CPU，却没有产出文档。ONNX（`onnx`）那边，在 1–2 线程、配合中等批大小时效率最高，每消耗 1% CPU 能换来最多的嵌入，而且即使把线程数一路加到 32，它也依然远高于旧路径。*

## 接下来做什么

还有几项工作排在这之后：

- **GPU 路径。** 目前的 ONNX 配置还是纯 CPU。`_use_gpu` 参数已经透传进来了，但还没有接到 ORT 的 CUDA execution provider 上。
- **Windows 性能对齐。** 目前因为一个 ORT 线程 bug，我们在 Windows 上还是串行化运行。等这个 bug 在上游修好后，Windows 也应该能获得 Linux/macOS 已经拥有的共享 session 行为。
- **让更多架构走 ONNX 路径。** 现在 ONNX 只用于 BERT 家族 encoder。T5、causal-LM 和量化 GGUF 模型目前仍然走 Candle。

## 试试看

如果你现有的表已经指向一个支持 ONNX 的模型，那么在升级到 Manticore Search 27.1.5 或更高版本后，新路径就会接管处理，不需要改 schema，也不需要重新导入数据。你应该只会看到 `INSERT` 变得更快。

如果你还没用 ONNX 模型 - 或者你想换成更小 / 更快的模型来充分利用新后端 - 需要注意的是，**你不能在现有字段上直接切换模型**。Manticore 不支持修改现有 `FLOAT_VECTOR` 字段上的 `MODEL_NAME`，所以原地迁移不是选项。根据你的环境里哪个方案更顺手，你有两条实用路径可以选：

**方案 A - 导出、修改、重新导入。** 即使你已经没有原始源数据了，你也可以把现有表 `mysqldump` 到一个 SQL 文件里，编辑这个 dump 中的 `CREATE TABLE`，把 `MODEL_NAME` 指向你想要的 ONNX 优化模型，然后把 dump 回放到一张新表里。Manticore 会在导入时通过新路径为每一行重新生成嵌入。

**方案 B - 在旁边新增一列，重建，然后删除旧列。** 如果你更想停留在 SQL 里、避免 dump 往返，可以在同一张表上添加一个指向 ONNX 模型的新 `FLOAT_VECTOR` 列，然后从源文本对该列触发一次性重新生成嵌入：

```sql
ALTER TABLE t ADD COLUMN v_new FLOAT_VECTOR KNN_TYPE='hnsw'
  HNSW_SIMILARITY='l2'
  MODEL_NAME='Xenova/all-MiniLM-L6-v2'
  FROM='text_field';

ALTER TABLE t REBUILD EMBEDDINGS v_new;
-- once you've cut over reads to v_new, drop the old column
ALTER TABLE t DROP COLUMN v_old;
```

具体语法和约束请参见文档中的 [Rebuilding embeddings](https://manual.manticoresearch.com/dev/Updating_table_schema_and_settings#Rebuilding-embeddings) 一节。

如果是全新表，这些都不重要 - 直接从一开始就选择一个优化过的 ONNX `MODEL_NAME` 就行。

适合挑选 ONNX 就绪嵌入模型的好地方是 Hugging Face 上的 [Xenova 集合](https://huggingface.co/Xenova/models) - 这些模型已经预先转换为 ONNX，可以直接放进 `MODEL_NAME='...'`。你可以按 **feature-extraction** 任务筛选，以缩小到嵌入类模型。下面是几个合理的起点：

- `Xenova/all-MiniLM-L6-v2` - 小而快，384 维，默认首选。
- `Xenova/all-MiniLM-L12-v2` - 这篇文章里我们基准测试的模型，384 维，质量更进一步。
- `Xenova/bge-small-en-v1.5` - 英文检索能力强，384 维。
- `Xenova/multilingual-e5-small` - 多语言覆盖，384 维。

如果你还完全没用自动嵌入，[最初的公告](/blog/auto-embeddings/) 会从头带你看 SQL。

📚 [KNN search 文档](https://manual.manticoresearch.com/Searching/KNN)
💬 [Slack 社区](https://slack.manticoresearch.com/) - 我们很想看看新路径在你的数据上表现如何。
