use std::path::Path;
use std::sync::{Arc, OnceLock};
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use hf_hub::api::tokio::Api;
use ort::environment::Environment;
use ort::session::Session;
use ort::session::builder::{AutoDevicePolicy, GraphOptimizationLevel};
use ort::value::Tensor;
use parking_lot::Mutex;
use tokenizers::{PaddingParams, PaddingStrategy, Tokenizer, TruncationParams, TruncationStrategy};
use tracing::info;
use super::Reranker;
const BATCH_SIZE: usize = 64;
struct Model {
session: Mutex<Session>,
tokenizer: Tokenizer,
token_types: bool,
}
pub struct OnnxCrossEncoder {
model: Arc<Model>,
}
fn environment() -> Result<&'static Environment> {
static ENV: OnceLock<Result<Environment, String>> = OnceLock::new();
ENV.get_or_init(|| init().map_err(|e| format!("{e:#}")))
.as_ref()
.map_err(|e| anyhow!("ONNX Runtime init failed: {e}"))
}
#[cfg(target_os = "linux")]
fn init() -> Result<Environment> {
let path = std::env::var("ORT_DYLIB_PATH")
.ok()
.filter(|p| !p.is_empty())
.unwrap_or_else(|| "libonnxruntime.so".to_string());
let builder = ort::init_from(&path).map_err(|e| anyhow!("loading {path}: {e}"))?;
builder.build().map_err(|e| anyhow!("{e}"))
}
#[cfg(not(target_os = "linux"))]
fn init() -> Result<Environment> {
ort::init().build().map_err(|e| anyhow!("{e}"))
}
fn quantized_file() -> &'static str {
#[cfg(target_arch = "aarch64")]
{
"onnx/model_qint8_arm64.onnx"
}
#[cfg(target_arch = "x86_64")]
{
if std::arch::is_x86_feature_detected!("avx512vnni") {
"onnx/model_qint8_avx512_vnni.onnx"
} else if std::arch::is_x86_feature_detected!("avx512f") {
"onnx/model_qint8_avx512.onnx"
} else {
"onnx/model_quint8_avx2.onnx"
}
}
#[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))]
{
"onnx/model.onnx"
}
}
impl OnnxCrossEncoder {
pub async fn load(model_id: &str, max_length: usize) -> Result<Self> {
let file = quantized_file();
info!("Loading ONNX cross-encoder {model_id} ({file}, max_length {max_length})");
let repo = Api::new()?.model(model_id.to_string());
let weights = repo.get(file).await?;
let tokenizer = repo.get("tokenizer.json").await?;
Self::from_files(&weights, &tokenizer, max_length)
}
pub fn from_files(weights: &Path, tokenizer: &Path, max_length: usize) -> Result<Self> {
let mut tokenizer = Tokenizer::from_file(tokenizer).map_err(|e| anyhow!(e))?;
tokenizer
.with_truncation(Some(TruncationParams {
max_length,
strategy: TruncationStrategy::OnlySecond,
..Default::default()
}))
.map_err(|e| anyhow!(e))?;
tokenizer.with_padding(Some(PaddingParams {
strategy: PaddingStrategy::BatchLongest,
..Default::default()
}));
let session = Session::builder(environment()?)
.map_err(|e| anyhow!("{e}"))?
.with_auto_device(AutoDevicePolicy::PreferCPU)
.map_err(|e| anyhow!("{e}"))?
.with_optimization_level(GraphOptimizationLevel::Level3)
.map_err(|e| anyhow!("{e}"))?
.with_intra_threads(num_cpus::get_physical())
.map_err(|e| anyhow!("{e}"))?
.commit_from_file(weights)
.map_err(|e| anyhow!("{e}"))?;
let token_types = session
.inputs()
.iter()
.any(|i| i.name() == "token_type_ids");
Ok(Self {
model: Arc::new(Model {
session: Mutex::new(session),
tokenizer,
token_types,
}),
})
}
}
impl Model {
fn score_batch(&self, query: &str, documents: &[String]) -> Result<Vec<f32>> {
let pairs: Vec<(String, String)> = documents
.iter()
.map(|d| (query.to_string(), d.clone()))
.collect();
let encodings = self
.tokenizer
.encode_batch(pairs, true)
.map_err(|e| anyhow!(e))?;
let rows = encodings.len();
let cols = encodings.first().map_or(0, |e| e.len());
let mut ids = Vec::with_capacity(rows * cols);
let mut types = Vec::with_capacity(rows * cols);
let mut mask = Vec::with_capacity(rows * cols);
for e in &encodings {
ids.extend(e.get_ids().iter().map(|&x| i64::from(x)));
types.extend(e.get_type_ids().iter().map(|&x| i64::from(x)));
mask.extend(e.get_attention_mask().iter().map(|&x| i64::from(x)));
}
let shape = [rows, cols];
let mut inputs = vec![
("input_ids", Tensor::from_array((shape, ids))?.into_dyn()),
(
"attention_mask",
Tensor::from_array((shape, mask))?.into_dyn(),
),
];
if self.token_types {
inputs.push((
"token_type_ids",
Tensor::from_array((shape, types))?.into_dyn(),
));
}
let mut session = self.session.lock();
let outputs = session.run(inputs)?;
let (_, logits) = outputs[0].try_extract_tensor::<f32>()?;
Ok(logits.iter().map(|&l| 1.0 / (1.0 + (-l).exp())).collect())
}
fn score(&self, query: &str, documents: &[String]) -> Result<Vec<f32>> {
let mut scores = Vec::with_capacity(documents.len());
for chunk in documents.chunks(BATCH_SIZE) {
scores.extend(self.score_batch(query, chunk)?);
}
Ok(scores)
}
}
#[async_trait]
impl Reranker for OnnxCrossEncoder {
async fn score(&self, query: &str, documents: &[String]) -> Result<Vec<f32>> {
if documents.is_empty() {
return Ok(Vec::new());
}
let model = Arc::clone(&self.model);
let query = query.to_string();
let documents = documents.to_vec();
tokio::task::spawn_blocking(move || model.score(&query, &documents)).await?
}
}
#[cfg(test)]
mod tests {
use std::time::Instant;
use super::*;
const MODEL: &str = "cross-encoder/ms-marco-MiniLM-L4-v2";
#[tokio::test]
#[ignore = "requires network + HF Hub download (~25MB)"]
async fn ranks_the_answer_first() {
let ce = OnnxCrossEncoder::load(MODEL, 256).await.unwrap();
let docs = vec![
"Melanie: I went camping with the kids last weekend.".to_string(),
"Caroline: I researched adoption agencies yesterday.".to_string(),
"Melanie: The weather has been great lately.".to_string(),
];
let scores = ce
.score("What did Caroline research?", &docs)
.await
.unwrap();
assert_eq!(scores.len(), 3);
assert!(scores[1] > scores[0] && scores[1] > scores[2], "{scores:?}");
}
#[tokio::test]
#[ignore = "benchmark; requires network + HF Hub download"]
async fn onnx_pool_latency() {
let ce = OnnxCrossEncoder::load(MODEL, 256).await.unwrap();
let docs: Vec<String> = (0..300)
.map(|i| {
format!(
"8 May 2023 · Melanie Melanie: turn {i}, we talked about painting a lake \
sunrise and the pottery class I signed up for with the kids last week."
)
})
.collect();
let query = "When did Melanie paint a sunrise?";
ce.score(query, &docs).await.unwrap();
let mut times = Vec::new();
for _ in 0..5 {
let t = Instant::now();
ce.score(query, &docs).await.unwrap();
times.push(t.elapsed().as_millis());
}
times.sort_unstable();
println!("onnx int8, 300 pairs: p50 {} ms, all {times:?}", times[2]);
}
}