AI・機械学習
14倍高速な埋め込み:ManticoreでONNXパスを再構築した方法
14× faster embeddings: how we rebuilt the ONNX path in Manticore (manticoresearch.com)
要約
Manticore Searchは、ONNX Runtimeを活用して埋め込み生成のパフォーマンスを大幅に向上させました。新しいONNXパスは、従来のSentenceTransformers/Candleパスと比較して平均14倍高速化され、特に大量のデータ挿入時のスループットが向上しました。この改善は、INTRA_OP_SPINNINGを無効にし、RustのORTラッパーでプラットフォーム固有のthreadingモデルを最適化することで達成されました。
全文翻訳
14倍高速な埋め込み:ManticoreでONNXパスを再構築した方法
著者: Dmitrii Kuzmenkov
公開日: 2026年6月25日 - 14分読書
Auto Embeddings(任意のテキスト列を、実行する個別のモデルサービスなしで自動的にベクトルに変換する機能)をリリースした際、最も多かったフィードバックは速度に関するものでした。以前のパスは、Hugging Faceの純粋なRust製ML推論ランタイムであるCandle上のSentenceTransformersを経由していましたが、CPUリソースを多く消費していました。ほとんどのワークロードは、どのように入力しても、1秒あたり数ドキュメントの低速な処理に留まり、同時呼び出しは単一のモデルセッションでシリアライズされていました。
そこで、ManticoreがONNXモデルを実行する方法を数週間かけて再構築しました。新しいONNX Runtimeバックエンドは、Manticore Search 27.1.5でリリースされました。ONNX(Open Neural Network Exchange)は、MiniLM、BGE、E5などのほとんどの人気オープンソース埋め込みモデルが既に公開している、ポータブルなモデルフォーマットです。その結果、同じハードウェア(平均的な安価な16コア/32スレッドサーバー)、同じモデル、同じ重みで、以前のSentenceTransformers/Candleパスと比較して平均で約14倍高速なバックエンドが実現しました。この優位性は、クライアントスレッドが1つでも32でも維持されます。古いパスは、グリッド全体で1秒あたり5〜11ドキュメントの範囲に留まっていましたが、新しいパスは1秒あたり70〜230ドキュメントの帯域幅に達します。
この記事は、エンジニアリングログです。試したこと、驚いたこと、破棄したもの、そして最終的な設計がどのようになったかについて説明します。
TL;DR
以前のSentenceTransformers/Candleパスと比較して、同じマシン(16コア/32スレッド)、同じモデル、同じ重みで、スレッド数(1/2/4/8/16/32)×バッチサイズ(1〜128)のワークロードグリッド全体で平均約14倍高速化されました。
Manticore Search 27.1.5でリリースされた新しいONNXパスは、現在、.onnxファイルを公開しているHuggingFaceモデルのデフォルトの高速パスとなっています。
all-MiniLM-L12-v2では、古いCandleパスは試したすべての構成で1秒あたり5〜11ドキュメントでした。新しいONNXパスは1秒あたり70〜230ドキュメントの範囲に収まり、クライアントスレッドが1つでも32でも、同じ約14倍のマージンが維持されます。
テストボックスでの単一挿入レイテンシ:単一クライアントで約14ミリ秒、8ウェイ同時負荷下で約56ミリ秒。どちらもCandleが到達していた200ミリ秒を下回っています。
最大のバルク取り込みスループットが必要ですか?単一クライアントスレッドで高バッチサイズ(32〜128)を使用してください。新しいバックエンドは呼び出し内で並列化するため、クライアントサイドのファンアウトは調整オーバーヘッドを積み上げるだけです。私たちのボックスでのピークは、1スレッド+バッチ=64で1秒あたり233ドキュメントでした。
最も重要だった2つの変更点は、intra_op_spinningをオフにすること、そしてワーカー内でドキュメントをバッチ処理することを諦めることでした。
ユーザーインターフェースの変更はありません。既にONNX対応のMODEL_NAMEを指しているテーブルは、自動的に新しいパスを使用します。既存のテーブルを別のモデルに切り替えるのはワンライナーではありません。ManticoreはFLOAT_VECTORフィールドのMODEL_NAMEを変更することを許可しないため、インプレースでの変更はできません。しかし、テーブル全体を再作成する必要もありません。新しいモデルを持つ新しい列を隣に追加し、その埋め込みを再構築してから古い列を削除することができます。
なぜこれが重要なのか
自動埋め込み機能により、データベース自体がINSERTごとにモデルを実行します。これは、埋め込み速度がINSERT速度であることを意味します。取り込みスループットは、埋め込みステップが維持できる速度になります。
古いSentenceTransformers/Candleパスは、パフォーマンスを犠牲にしていました。同時実行性はロック競合を引き起こし、バッチ処理された呼び出しはパディングオーバーヘッドのためにプラトーに達し、呼び出し間ではランタイムがスレッドを停止させ、次の呼び出しが前の呼び出しの続きを引き継ぐのを妨げていました。主な症状は単純でした。ボックスがフルロードを下回っていても、何を入力してもトップコマンドはそう表示されていました。単一行INSERT、128行バルクINSERT、1つのクライアントスレッド、32のクライアントスレッドのすべてが、1秒あたり5〜11ドキュメントで実行されていました。なぜなら、どのように入力してもCPUをそれ以上活用できなかったからです。
新しいONNXパスは、下限を1桁以上引き上げ、ユーザーに意味のあるパフォーマンスチューニングオプションを提供します。単一スレッド、単一行のINSERTでも、現在1秒あたり72ドキュメントに達しており、これは古いCandleの天井の約7倍です。同時実行性やバッチサイズを追加すると、1秒あたり130〜230ドキュメントの範囲に増加し、グリッドのトップは単一クライアントスレッドでバッチサイズ=64の場合、1秒あたり233ドキュメントに達します。スレッド×バッチマトリックス全体で平均すると、新しいパスは古いパスの約14倍です。
なぜCandleではなくONNXなのか
Manticoreの埋め込みライブラリは、しばらくの間いくつかのバックエンドをサポートしてきました。Candleパスは、正確性と容易なリリースにおいて優れています。しかし、MiniLMやBGEファミリーのような小さなエンコーダーモデルの本番推論においては、ONNX Runtimeは比類のないものです。
ONNX Runtime(またはORT - ONNXモデル用のMicrosoft公式、手作業でチューニングされたC++推論エンジン)は、グラフ融合、定数折りたたみ、カーネル自動チューニングを行います。
HuggingFace上のほとんどの人気埋め込みモデルは、既にonnx/ディレクトリにプリフューズされたmodel.onnxを公開しています。ディスク上のファイルは、既にORTが望む形状になっています。
同じall-MiniLM-L12-v2の重みで、CPU上で、ONNXパスはCandleパスを大幅に上回ります。品質は同じで、ドキュメントあたりの作業量ははるかに少なくなります。
ORTセッションは、少数の設定で作成されます。
let session = ort::session::Session::builder()? .with_optimization_level(GraphOptimizationLevel::Level3)? .with_intra_threads(0)? // ORTに選択させる (= 全コア)
.with_intra_op_spinning(false)? // 呼び出し間のビジーウェイトをしない
.with_flush_to_zero()? // アテンションソフトマックスでデノーマルをキルする
.with_approximate_gelu()? // 約10%高速な活性化、品質低下なし
.commit_from_file(&onnx_path)?;
これらのほとんどは、議論の余地のない「もちろんオンにする」設定です。1つはそうではありません:intra_op_spinning(false)。これについては後で詳しく説明します。これは、このブランチ全体で最大の成果であり、実際にはORTの設定というよりはロードシェイプの決定です。
並行処理モデル — ほとんどの読者が新しいと感じる部分
Rust開発者に「制約なしでONNXを高速化せよ」と与えると、彼らは2つのパターンのいずれかに到達します。私たちは両方を試しました。どちらもこのワークロードには不適切でした。
パターン1:Mutex(一度に1つのスレッドしかセッションに触れないようにするロック)の背後にある単一の共有セッション。推論が容易で、正しく実装しやすい。同時実行下では、すべての呼び出し元がロックでシリアライズされるため、スループットが低下します。CLIツールには適していますが、多くの同時INSERTを処理するデータベースには不向きです。
パターン2:セッションプール、CPUごとに1つのセッション。ロック競合はなくなりますが、コールドスタート時間が倍増し、RAM使用量が増加し、小さな入力はセッションに着地するだけでディスパッチコストを支払います。開発ブランチでこの動作するバージョンを持っていましたが、期待通りの結果は得られませんでした。
設計を解き放ったのは、ほとんどのRust ONNXラッパーが間違っていることの1つです。LinuxおよびmacOSでは、ORTのC Run() APIはスレッドセーフです。ロックなしで多くの同時呼び出し元間で単一のセッションを共有できます。C++側は既に必要なシリアライズを行っており、Rust APIは、基盤となるライブラリが実際に許可していることと一致しないルールでそれを隠しています。
そこで、セッションを小さなプラットフォーム認識型にラップします。
#[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です。基盤となるライブラリが使用しているアクセスパターンで安全であることが文書化されているため、borrow checkerをループから外しています。これは意図的なunsafeであり、1行の正当化があり、フットガンではありません。
Windowsでは、ORTのthreadingモデルには既知の問題があるため、Run()をMutexでシリアライズします。重要なのは、ロックは呼び出し全体ではなく、クロージャ全体で保持されることです。これが、Windowsで発生したレースコンディションを修正した理由です。あるスレッドのSessionOutputsがまだ読み取られている間に、別のスレッドが新しい実行を開始していました。クロージャスコープのロックであり、呼び出しスコープではありません。
適応型並列処理