Skip to content

Commit 7a7f229

Browse files
committed
feat(embeddings): add parallel session pooling
- Add rayon crate for parallel computation - Implement session pooling for concurrent execution - Enable parallel tokenization and inference - Optimize default batch size and thread configuration - Add environment variables for session and thread tuning
1 parent ad69aa9 commit 7a7f229

3 files changed

Lines changed: 119 additions & 95 deletions

File tree

embeddings/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

embeddings/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ serde_json = "1.0.114"
1313
serde = "1.0.197"
1414
rand = "0.8.5"
1515
reqwest = { version = "0.12.8", default-features = false, features = ["blocking", "json", "rustls-tls"] }
16+
rayon = "1.10.0"
1617

1718
candle-core = "0.9.2"
1819
candle-nn = "0.9.2"

embeddings/src/model/local.rs

Lines changed: 117 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,7 @@ use std::sync::{Arc, Mutex, OnceLock};
3030
use tokenizers::Tokenizer;
3131

3232
/// Default batch size per ONNX forward pass.
33-
const DEFAULT_BATCH_SIZE: usize = 32;
34-
35-
/// Default intra-op threads: 0 = ORT default (all cores).
36-
const DEFAULT_INTRA_THREADS: usize = 0;
33+
const DEFAULT_BATCH_SIZE: usize = 8;
3734

3835
fn batch_size() -> usize {
3936
std::env::var("EMBEDDINGS_BATCH_SIZE")
@@ -42,43 +39,28 @@ fn batch_size() -> usize {
4239
.unwrap_or(DEFAULT_BATCH_SIZE)
4340
}
4441

45-
fn intra_threads() -> usize {
46-
std::env::var("EMBEDDINGS_INTRA_THREADS")
47-
.ok()
48-
.and_then(|v| v.parse().ok())
49-
.unwrap_or(DEFAULT_INTRA_THREADS)
42+
fn available_cpus() -> usize {
43+
std::thread::available_parallelism()
44+
.map(|n| n.get())
45+
.unwrap_or(2)
5046
}
5147

52-
/// Thread-safe wrapper around `ort::session::Session`.
53-
/// ORT's C API `OrtSession::Run` is documented as thread-safe for concurrent calls.
54-
/// The Rust `ort` crate requires `&mut self` for `run()` which is overly conservative.
55-
/// This wrapper provides `run(&self)` using `UnsafeCell` to allow parallel inference
56-
/// on a single loaded model without duplicating weights in memory.
57-
struct SyncSession {
58-
inner: std::cell::UnsafeCell<ort::session::Session>,
48+
/// Number of parallel ORT sessions. Default: num_cpus / 2 (min 1).
49+
/// Moderate oversubscription (total_threads ≈ 2×cores) fills idle gaps between ORT ops.
50+
fn num_sessions() -> usize {
51+
std::env::var("EMBEDDINGS_NUM_SESSIONS")
52+
.ok()
53+
.and_then(|v| v.parse().ok())
54+
.unwrap_or_else(|| (available_cpus() / 2).max(1))
5955
}
6056

61-
// SAFETY: ORT's OrtSession::Run is thread-safe for concurrent calls.
62-
// The session state is internally synchronized by ORT.
63-
unsafe impl Sync for SyncSession {}
64-
unsafe impl Send for SyncSession {}
65-
66-
impl SyncSession {
67-
fn new(session: ort::session::Session) -> Self {
68-
Self {
69-
inner: std::cell::UnsafeCell::new(session),
70-
}
71-
}
72-
73-
/// Run inference on this session. Safe to call from multiple threads concurrently.
74-
/// SAFETY: relies on ORT's documented thread-safety of OrtSession::Run.
75-
fn run<'s, 'i, 'v: 'i, const N: usize>(
76-
&'s self,
77-
input_values: impl Into<ort::session::SessionInputs<'i, 'v, N>>,
78-
) -> ort::Result<ort::session::SessionOutputs<'s>> {
79-
// SAFETY: ORT guarantees thread-safe concurrent Run() on the same session.
80-
unsafe { &mut *self.inner.get() }.run(input_values)
81-
}
57+
/// Intra-op threads per session. Default: 4.
58+
/// With num_sessions = cpus/2, total threads = cpus/2 * 4 = 2×cpus — optimal oversubscription.
59+
fn intra_threads() -> usize {
60+
std::env::var("EMBEDDINGS_INTRA_THREADS")
61+
.ok()
62+
.and_then(|v| v.parse().ok())
63+
.unwrap_or(4)
8264
}
8365

8466
/// Model architecture type - determines pooling strategy
@@ -777,15 +759,35 @@ impl QuantizedEmbeddingModel {
777759
}
778760

779761
/// ONNX embedding model using ORT (onnxruntime) for optimized inference.
780-
/// Uses a single session with concurrent inference via SyncSession wrapper.
762+
/// Supports single session (default) or session pool for parallel inference.
781763
pub struct OnnxEmbeddingModel {
782-
session: SyncSession,
764+
sessions: Vec<Mutex<ort::session::Session>>,
783765
tokenizer: Tokenizer,
784766
max_input_len: usize,
785767
hidden_size: usize,
786768
}
787769

788770
impl OnnxEmbeddingModel {
771+
fn build_session(
772+
onnx_path: &PathBuf,
773+
it: usize,
774+
) -> Result<ort::session::Session, Box<dyn Error>> {
775+
ort::session::Session::builder()
776+
.map_err(|_| LibError::OnnxModelEvalFailed)?
777+
.with_optimization_level(ort::session::builder::GraphOptimizationLevel::Level3)
778+
.map_err(|_| LibError::OnnxModelEvalFailed)?
779+
.with_intra_threads(it)
780+
.map_err(|_| LibError::OnnxModelEvalFailed)?
781+
.with_intra_op_spinning(false)
782+
.map_err(|_| LibError::OnnxModelEvalFailed)?
783+
.with_flush_to_zero()
784+
.map_err(|_| LibError::OnnxModelEvalFailed)?
785+
.with_approximate_gelu()
786+
.map_err(|_| LibError::OnnxModelEvalFailed)?
787+
.commit_from_file(onnx_path)
788+
.map_err(|_| -> Box<dyn Error> { Box::new(LibError::ModelWeightsLoadFailed) })
789+
}
790+
789791
pub fn new(
790792
onnx_path: PathBuf,
791793
model_info: LocalModelInfo,
@@ -802,23 +804,15 @@ impl OnnxEmbeddingModel {
802804
tokenizer.with_padding(None);
803805
let _ = tokenizer.with_truncation(None);
804806

805-
let session = ort::session::Session::builder()
806-
.map_err(|_| LibError::OnnxModelEvalFailed)?
807-
.with_optimization_level(ort::session::builder::GraphOptimizationLevel::Level3)
808-
.map_err(|_| LibError::OnnxModelEvalFailed)?
809-
.with_intra_threads(intra_threads())
810-
.map_err(|_| LibError::OnnxModelEvalFailed)?
811-
.with_intra_op_spinning(false)
812-
.map_err(|_| LibError::OnnxModelEvalFailed)?
813-
.with_flush_to_zero()
814-
.map_err(|_| LibError::OnnxModelEvalFailed)?
815-
.with_approximate_gelu()
816-
.map_err(|_| LibError::OnnxModelEvalFailed)?
817-
.commit_from_file(&onnx_path)
818-
.map_err(|_| LibError::ModelWeightsLoadFailed)?;
807+
let it = intra_threads();
808+
let ns = num_sessions();
809+
let mut sessions = Vec::with_capacity(ns);
810+
for _ in 0..ns {
811+
sessions.push(Mutex::new(Self::build_session(&onnx_path, it)?));
812+
}
819813

820814
Ok(Self {
821-
session: SyncSession::new(session),
815+
sessions,
822816
tokenizer,
823817
max_input_len,
824818
hidden_size,
@@ -827,7 +821,7 @@ impl OnnxEmbeddingModel {
827821

828822
/// Run a single ONNX forward pass on one batch.
829823
fn run_batch(
830-
session: &SyncSession,
824+
session: &mut ort::session::Session,
831825
batch: &[Vec<u32>],
832826
) -> Result<Vec<Vec<f32>>, Box<dyn Error>> {
833827
let batch_size = batch.len();
@@ -909,39 +903,77 @@ impl OnnxEmbeddingModel {
909903
Ok(embeddings)
910904
}
911905

912-
/// Pipelined predict: tokenizer thread prepares batch N+1 while ORT infers batch N.
913-
fn predict_pipelined(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, Box<dyn Error>> {
906+
fn tokenize_batch(
907+
tokenizer: &Tokenizer,
908+
batch: &[&str],
909+
max_input: usize,
910+
) -> Result<Vec<Vec<u32>>, LibError> {
911+
let truncated: Vec<&str> = batch
912+
.iter()
913+
.map(|t| pre_truncate_text(t, max_input))
914+
.collect();
915+
let encoded = tokenizer
916+
.encode_batch(truncated, true)
917+
.map_err(|_| LibError::ModelTokenizerEncodeFailed)?;
918+
Ok(encoded
919+
.iter()
920+
.map(|enc| {
921+
let ids = enc.get_ids();
922+
ids[..ids.len().min(max_input)].to_vec()
923+
})
924+
.collect())
925+
}
926+
927+
/// Predict using session pool with rayon (parallel tokenize+infer per batch).
928+
fn predict_pool(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, Box<dyn Error>> {
929+
use rayon::prelude::*;
930+
914931
let bs = batch_size();
915932
let max_input = self.max_input_len;
916-
let tokenizer = &self.tokenizer;
917-
let session = &self.session;
933+
let num_sessions = self.sessions.len();
934+
935+
let text_batches: Vec<&[&str]> = texts.chunks(bs).collect();
918936

937+
let results: Vec<Result<Vec<Vec<f32>>, LibError>> = text_batches
938+
.par_iter()
939+
.enumerate()
940+
.map(|(i, batch)| {
941+
let chunks = Self::tokenize_batch(&self.tokenizer, batch, max_input)?;
942+
let mut session = self.sessions[i % num_sessions].lock().unwrap();
943+
Self::run_batch(&mut session, &chunks).map_err(|_| LibError::OnnxModelEvalFailed)
944+
})
945+
.collect();
946+
947+
let mut all_embeddings = Vec::with_capacity(texts.len());
948+
for result in results {
949+
all_embeddings.extend(result.map_err(|e| -> Box<dyn Error> { Box::new(e) })?);
950+
}
951+
Ok(all_embeddings)
952+
}
953+
954+
/// Pipelined predict: tokenizer thread prepares batch N+1 while ORT infers batch N.
955+
fn predict_pipelined(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, Box<dyn Error>> {
919956
if texts.is_empty() {
920957
return Ok(Vec::new());
921958
}
922959

923-
let text_batches: Vec<&[&str]> = texts.chunks(bs).collect();
924-
if text_batches.is_empty() {
925-
return Ok(Vec::new());
960+
// Session pool mode: use rayon parallel dispatch
961+
if self.sessions.len() > 1 {
962+
return self.predict_pool(texts);
926963
}
927964

965+
// Single session mode: pipeline tokenization with inference
966+
let bs = batch_size();
967+
let max_input = self.max_input_len;
968+
let tokenizer = &self.tokenizer;
969+
970+
let text_batches: Vec<&[&str]> = texts.chunks(bs).collect();
971+
928972
// Single batch — no pipeline overhead needed
929973
if text_batches.len() == 1 {
930-
let truncated: Vec<&str> = text_batches[0]
931-
.iter()
932-
.map(|t| pre_truncate_text(t, max_input))
933-
.collect();
934-
let encoded = tokenizer
935-
.encode_batch(truncated, true)
936-
.map_err(|_| LibError::ModelTokenizerEncodeFailed)?;
937-
let chunks: Vec<Vec<u32>> = encoded
938-
.iter()
939-
.map(|enc| {
940-
let ids = enc.get_ids();
941-
ids[..ids.len().min(max_input)].to_vec()
942-
})
943-
.collect();
944-
return Self::run_batch(session, &chunks);
974+
let chunks = Self::tokenize_batch(tokenizer, text_batches[0], max_input)
975+
.map_err(|e| -> Box<dyn Error> { Box::new(e) })?;
976+
return Self::run_batch(&mut self.sessions[0].lock().unwrap(), &chunks);
945977
}
946978

947979
// Pipeline: tokenizer thread feeds batches, main thread runs inference
@@ -952,30 +984,20 @@ impl OnnxEmbeddingModel {
952984
std::thread::scope(|s| {
953985
s.spawn(move || {
954986
for batch in &text_batches {
955-
let truncated: Vec<&str> = batch
956-
.iter()
957-
.map(|t| pre_truncate_text(t, max_input))
958-
.collect();
959-
let encoded = match tokenizer.encode_batch(truncated, true) {
960-
Ok(enc) => enc,
987+
match Self::tokenize_batch(tokenizer, batch, max_input) {
988+
Ok(chunks) => {
989+
if tx.send(chunks).is_err() {
990+
return;
991+
}
992+
}
961993
Err(_) => return,
962-
};
963-
let chunks: Vec<Vec<u32>> = encoded
964-
.iter()
965-
.map(|enc| {
966-
let ids = enc.get_ids();
967-
ids[..ids.len().min(max_input)].to_vec()
968-
})
969-
.collect();
970-
if tx.send(chunks).is_err() {
971-
return;
972994
}
973995
}
974-
// tx drops here — closes channel, rx loop ends
975996
});
976997

998+
let mut session = self.sessions[0].lock().unwrap();
977999
for chunks in rx {
978-
match Self::run_batch(session, &chunks) {
1000+
match Self::run_batch(&mut session, &chunks) {
9791001
Ok(embs) => all_embeddings.extend(embs),
9801002
Err(_) => {
9811003
infer_error = Some(LibError::OnnxModelEvalFailed);

0 commit comments

Comments
 (0)