@@ -30,10 +30,7 @@ use std::sync::{Arc, Mutex, OnceLock};
3030use 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
3835fn 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 .
781763pub 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
788770impl 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