@@ -43,16 +43,39 @@ pub(crate) fn validate_image_encoder(manifest: &ModelManifest) -> Result<()> {
4343/// Run image encoder inference on a single image.
4444pub fn embed ( handle : & ModelHandle , image : & ImageInput ) -> Result < EmbedResult > {
4545 let start = Instant :: now ( ) ;
46- let manifest = & handle. manifest ;
47- validate_image_encoder ( manifest) ?;
46+ validate_image_encoder ( & handle. manifest ) ?;
47+ let config = preprocess_config_from_manifest ( & handle. manifest ) ?;
48+ let prepared = preprocess:: preprocess ( image, & config) ?;
49+ let mut results = infer_prepared ( handle, std:: slice:: from_ref ( & prepared) , start) ?;
50+ results
51+ . pop ( )
52+ . ok_or_else ( || SparrowEngineError :: Ort ( "embed: inference returned no result" . into ( ) ) )
53+ }
4854
55+ /// Run one batched `session.run` over already-preprocessed images.
56+ ///
57+ /// Split out of [`embed`] so a caller can preprocess many images -- optionally in parallel --
58+ /// and then infer them as a single batch. The previous code ran one `session.run` per image
59+ /// even when the caller supplied a batch, which meant the session was locked and re-entered per
60+ /// image and ONNX Runtime never saw more than one row to work with.
61+ fn infer_prepared (
62+ handle : & ModelHandle ,
63+ prepared : & [ preprocess:: PreprocessResult ] ,
64+ start : Instant ,
65+ ) -> Result < Vec < EmbedResult > > {
66+ if prepared. is_empty ( ) {
67+ return Ok ( Vec :: new ( ) ) ;
68+ }
69+ let manifest = & handle. manifest ;
4970 let session = handle. pin_session ( ) ?;
50- let config = preprocess_config_from_manifest ( manifest) ?;
51- let prep = preprocess:: preprocess ( image, & config) ?;
52- let original_width = prep. meta . original_width ;
53- let original_height = prep. meta . original_height ;
5471
55- let input_value = TensorRef :: from_array_view ( & prep. tensor ) . map_err ( crate :: engine:: ort_err) ?;
72+ // Concatenate the per-image [1, C, H, W] tensors into one [N, C, H, W] batch. Geometry is
73+ // fixed by the manifest, so a mismatch here is a bug rather than bad input.
74+ let views: Vec < _ > = prepared. iter ( ) . map ( |p| p. tensor . view ( ) ) . collect ( ) ;
75+ let batch_tensor = ndarray:: concatenate ( Axis ( 0 ) , & views)
76+ . map_err ( |e| SparrowEngineError :: Ort ( format ! ( "concatenate encoder batch: {e}" ) ) ) ?;
77+
78+ let input_value = TensorRef :: from_array_view ( & batch_tensor) . map_err ( crate :: engine:: ort_err) ?;
5679 let mut guard = session
5780 . lock ( )
5881 . map_err ( |_| SparrowEngineError :: Ort ( "encoder session lock poisoned" . into ( ) ) ) ?;
@@ -67,26 +90,14 @@ pub fn embed(handle: &ModelHandle, image: &ImageInput) -> Result<EmbedResult> {
6790 } ) ;
6891 }
6992
70- let mut embedding = extract_embedding_output ( & outputs[ 0 ] , manifest) ?;
71- if let Some ( dim) = manifest. embedding_dim {
72- if embedding. len ( ) != dim {
73- return Err ( SparrowEngineError :: OutputShapeMismatch {
74- id : manifest. id . clone ( ) ,
75- shape : format ! (
76- "runtime embedding dim {} != manifest dim {dim}" ,
77- embedding. len( )
78- ) ,
79- method : manifest. postprocess_method . as_str ( ) . to_string ( ) ,
80- } ) ;
81- }
82- }
93+ let mut embeddings = extract_embedding_rows ( & outputs[ 0 ] , manifest, prepared. len ( ) ) ?;
94+ drop ( outputs) ;
95+ drop ( guard) ;
8396
8497 let normalized = match manifest. postprocess_method {
8598 PostprocessMethod :: Embedding { normalize } => normalize,
8699 _ => false ,
87100 } ;
88- finalize_embedding_for_model ( & mut embedding, normalized, & manifest. id ) ?;
89- let dim = embedding. len ( ) ;
90101 let metric = manifest. embedding_metric . ok_or_else ( || {
91102 SparrowEngineError :: InvalidManifest ( "image encoders require [embedding] metric" . to_string ( ) )
92103 } ) ?;
@@ -101,32 +112,178 @@ pub fn embed(handle: &ModelHandle, image: &ImageInput) -> Result<EmbedResult> {
101112 )
102113 } ) ?;
103114
104- drop ( outputs) ;
105- drop ( guard) ;
115+ // One elapsed span covers the whole batch, so report it per image rather than stamping the
116+ // batch total on every row (which would scale the reported cost with batch position).
117+ let processing_time_ms = start. elapsed ( ) . as_secs_f32 ( ) * 1000.0 / prepared. len ( ) as f32 ;
118+ let mut results = Vec :: with_capacity ( prepared. len ( ) ) ;
119+ for ( index, prep) in prepared. iter ( ) . enumerate ( ) {
120+ let mut embedding = std:: mem:: take ( & mut embeddings[ index] ) ;
121+ if let Some ( dim) = manifest. embedding_dim {
122+ if embedding. len ( ) != dim {
123+ return Err ( SparrowEngineError :: OutputShapeMismatch {
124+ id : manifest. id . clone ( ) ,
125+ shape : format ! (
126+ "runtime embedding dim {} != manifest dim {dim}" ,
127+ embedding. len( )
128+ ) ,
129+ method : manifest. postprocess_method . as_str ( ) . to_string ( ) ,
130+ } ) ;
131+ }
132+ }
133+ finalize_embedding_for_model ( & mut embedding, normalized, & manifest. id ) ?;
134+ let dim = embedding. len ( ) ;
135+ results. push ( EmbedResult {
136+ embedding,
137+ dim,
138+ normalized,
139+ metric,
140+ model_id : manifest. id . clone ( ) ,
141+ embedding_version : embedding_version. clone ( ) ,
142+ model_hash : model_hash. clone ( ) ,
143+ image_width : prep. meta . original_width ,
144+ image_height : prep. meta . original_height ,
145+ processing_time_ms,
146+ } ) ;
147+ }
148+ Ok ( results)
149+ }
106150
107- Ok ( EmbedResult {
108- embedding,
109- dim,
110- normalized,
111- metric,
112- model_id : manifest. id . clone ( ) ,
113- embedding_version,
114- model_hash,
115- image_width : original_width,
116- image_height : original_height,
117- processing_time_ms : start. elapsed ( ) . as_secs_f32 ( ) * 1000.0 ,
118- } )
151+ /// Images fed to the model in one `session.run`, and the granularity of parallel decode.
152+ ///
153+ /// Bounds working-set memory for a caller-supplied batch: this is a public entry point, so an
154+ /// unbounded batch would allocate `n * C * H * W * 4` bytes at the caller's discretion.
155+ const MAX_INFERENCE_BATCH : usize = 8 ;
156+
157+ /// Threads used to preprocess a chunk concurrently.
158+ ///
159+ /// Decode plus resize is pure CPU work with no shared state, so it parallelises cleanly. ONNX
160+ /// Runtime already threads *inference* internally, so this deliberately does not scale with the
161+ /// core count: oversubscribing would take cores away from the session's own thread pool. Four
162+ /// is enough to keep a batch assembled ahead of the model without competing with it.
163+ ///
164+ /// Override with `SPARROW_ENGINE_ENCODER_DECODE_WORKERS`, shared with the GPU crate. Zero or one
165+ /// restores fully serial preprocessing.
166+ fn decode_workers ( ) -> usize {
167+ const DEFAULT_WORKERS : usize = 4 ;
168+ const MAX_WORKERS : usize = 16 ;
169+ match std:: env:: var ( "SPARROW_ENGINE_ENCODER_DECODE_WORKERS" ) {
170+ Ok ( raw) => match raw. trim ( ) . parse :: < usize > ( ) {
171+ Ok ( n) => n. clamp ( 1 , MAX_WORKERS ) ,
172+ Err ( _) => DEFAULT_WORKERS ,
173+ } ,
174+ Err ( _) => DEFAULT_WORKERS ,
175+ }
119176}
120177
121178/// Run image encoder inference on multiple images, failing the whole batch on the first error.
179+ ///
180+ /// Preprocesses each chunk across [`decode_workers`] threads and then runs the chunk as **one**
181+ /// batched `session.run`. Previously this was `images.iter().map(embed).collect()`: every image
182+ /// paid a separate session lock and a batch-1 inference call, and decoding was fully serial, so
183+ /// a caller that supplied a batch got none of the benefit of having done so.
122184pub fn embed_batch ( handle : & ModelHandle , images : & [ ImageInput ] ) -> Result < Vec < EmbedResult > > {
123- images. iter ( ) . map ( |image| embed ( handle, image) ) . collect ( )
185+ if images. is_empty ( ) {
186+ return Ok ( Vec :: new ( ) ) ;
187+ }
188+ validate_image_encoder ( & handle. manifest ) ?;
189+ let config = preprocess_config_from_manifest ( & handle. manifest ) ?;
190+ let start = Instant :: now ( ) ;
191+
192+ let mut results = Vec :: with_capacity ( images. len ( ) ) ;
193+ for chunk in images. chunks ( MAX_INFERENCE_BATCH ) {
194+ let workers = decode_workers ( ) . min ( chunk. len ( ) ) ;
195+ let prepared = if workers <= 1 {
196+ chunk
197+ . iter ( )
198+ . map ( |image| preprocess:: preprocess ( image, & config) )
199+ . collect :: < Result < Vec < _ > > > ( ) ?
200+ } else {
201+ preprocess_parallel ( chunk, & config, workers) ?
202+ } ;
203+ results. extend ( infer_prepared ( handle, & prepared, start) ?) ;
204+ }
205+ Ok ( results)
206+ }
207+
208+ /// Preprocess a chunk across `workers` scoped threads, preserving input order.
209+ ///
210+ /// Work is handed out by index through an atomic counter and written back into per-index slots,
211+ /// so the output order always matches the caller's input order regardless of completion order.
212+ fn preprocess_parallel (
213+ chunk : & [ ImageInput ] ,
214+ config : & crate :: preprocess:: PreprocessConfig ,
215+ workers : usize ,
216+ ) -> Result < Vec < preprocess:: PreprocessResult > > {
217+ use std:: sync:: atomic:: { AtomicUsize , Ordering } ;
218+ use std:: sync:: Mutex ;
219+
220+ let slots: Vec < Mutex < Option < preprocess:: PreprocessResult > > > =
221+ ( 0 ..chunk. len ( ) ) . map ( |_| Mutex :: new ( None ) ) . collect ( ) ;
222+ let next = AtomicUsize :: new ( 0 ) ;
223+ let slots_ref = & slots;
224+ let next_ref = & next;
225+
226+ std:: thread:: scope ( |scope| -> Result < ( ) > {
227+ let mut handles = Vec :: with_capacity ( workers) ;
228+ for _ in 0 ..workers {
229+ handles. push ( scope. spawn ( move || -> Result < ( ) > {
230+ loop {
231+ let index = next_ref. fetch_add ( 1 , Ordering :: Relaxed ) ;
232+ if index >= chunk. len ( ) {
233+ return Ok ( ( ) ) ;
234+ }
235+ let prepared = preprocess:: preprocess ( & chunk[ index] , config) ?;
236+ * slots_ref[ index] . lock ( ) . map_err ( |_| {
237+ SparrowEngineError :: Ort ( "encoder preprocess slot poisoned" . into ( ) )
238+ } ) ? = Some ( prepared) ;
239+ }
240+ } ) ) ;
241+ }
242+ // Join every worker before returning so one failure cannot mask another and no thread
243+ // is left detached.
244+ let mut first_err = None ;
245+ for handle in handles {
246+ let outcome = match handle. join ( ) {
247+ Ok ( inner) => inner,
248+ Err ( _) => Err ( SparrowEngineError :: Ort (
249+ "encoder preprocess worker panicked" . into ( ) ,
250+ ) ) ,
251+ } ;
252+ if let Err ( err) = outcome {
253+ first_err. get_or_insert ( err) ;
254+ }
255+ }
256+ match first_err {
257+ Some ( err) => Err ( err) ,
258+ None => Ok ( ( ) ) ,
259+ }
260+ } ) ?;
261+
262+ slots
263+ . into_iter ( )
264+ . enumerate ( )
265+ . map ( |( index, slot) | {
266+ slot. into_inner ( )
267+ . map_err ( |_| SparrowEngineError :: Ort ( "encoder preprocess slot poisoned" . into ( ) ) ) ?
268+ . ok_or_else ( || {
269+ SparrowEngineError :: Ort ( format ! (
270+ "encoder preprocess produced no result for image {index}"
271+ ) )
272+ } )
273+ } )
274+ . collect ( )
124275}
125276
126- fn extract_embedding_output (
277+ /// Split a batched encoder output into one embedding per input image.
278+ ///
279+ /// Accepts rank-1 (`[dim]`, valid only for a batch of one) and rank-2 (`[batch, dim]`). The
280+ /// previous single-image helper rejected any output with more than one row; batched inference
281+ /// makes multi-row the normal case.
282+ fn extract_embedding_rows (
127283 output : & ort:: value:: DynValue ,
128284 manifest : & ModelManifest ,
129- ) -> Result < Vec < f32 > > {
285+ batch : usize ,
286+ ) -> Result < Vec < Vec < f32 > > > {
130287 match output. dtype ( ) {
131288 ValueType :: Tensor {
132289 ty : TensorElementType :: Float32 ,
@@ -135,7 +292,7 @@ fn extract_embedding_output(
135292 let output_view: ArrayViewD < ' _ , f32 > = output
136293 . try_extract_array :: < f32 > ( )
137294 . map_err ( crate :: engine:: ort_err) ?;
138- extract_embedding_vector ( output_view, manifest, |x| x)
295+ extract_embedding_vectors ( output_view, manifest, batch , |x| x)
139296 }
140297 ValueType :: Tensor {
141298 ty : TensorElementType :: Float16 ,
@@ -144,7 +301,7 @@ fn extract_embedding_output(
144301 let output_view: ArrayViewD < ' _ , half:: f16 > = output
145302 . try_extract_array :: < half:: f16 > ( )
146303 . map_err ( crate :: engine:: ort_err) ?;
147- extract_embedding_vector ( output_view, manifest, half:: f16:: to_f32)
304+ extract_embedding_vectors ( output_view, manifest, batch , half:: f16:: to_f32)
148305 }
149306 other => Err ( SparrowEngineError :: OutputShapeMismatch {
150307 id : manifest. id . clone ( ) ,
@@ -154,41 +311,50 @@ fn extract_embedding_output(
154311 }
155312}
156313
157- fn extract_embedding_vector < T : Copy > (
314+ fn extract_embedding_vectors < T : Copy > (
158315 output : ArrayViewD < ' _ , T > ,
159316 manifest : & ModelManifest ,
160- to_f32 : impl Fn ( T ) -> f32 ,
161- ) -> Result < Vec < f32 > > {
317+ batch : usize ,
318+ to_f32 : impl Fn ( T ) -> f32 + Copy ,
319+ ) -> Result < Vec < Vec < f32 > > > {
320+ let mismatch = |shape : String | SparrowEngineError :: OutputShapeMismatch {
321+ id : manifest. id . clone ( ) ,
322+ shape,
323+ method : manifest. postprocess_method . as_str ( ) . to_string ( ) ,
324+ } ;
162325 match output. ndim ( ) {
163326 1 => {
327+ if batch != 1 {
328+ return Err ( mismatch ( format ! (
329+ "rank-1 output for a batch of {batch}; expected [{batch}, dim]"
330+ ) ) ) ;
331+ }
164332 let row: ArrayView1 < ' _ , T > = output
165333 . into_dimensionality :: < ndarray:: Ix1 > ( )
166334 . map_err ( crate :: engine:: ort_err) ?;
167- Ok ( row. iter ( ) . copied ( ) . map ( to_f32) . collect ( ) )
335+ Ok ( vec ! [ row. iter( ) . copied( ) . map( to_f32) . collect( ) ] )
168336 }
169337 2 => {
170338 let rows: ArrayView2 < ' _ , T > = output
171339 . into_dimensionality :: < ndarray:: Ix2 > ( )
172340 . map_err ( crate :: engine:: ort_err) ?;
173- if rows. nrows ( ) != 1 || rows. ncols ( ) == 0 {
174- return Err ( SparrowEngineError :: OutputShapeMismatch {
175- id : manifest. id . clone ( ) ,
176- shape : format ! ( "{:?}" , rows. shape( ) ) ,
177- method : manifest. postprocess_method . as_str ( ) . to_string ( ) ,
178- } ) ;
341+ if rows. nrows ( ) != batch || rows. ncols ( ) == 0 {
342+ return Err ( mismatch ( format ! (
343+ "{:?} for a batch of {batch}" ,
344+ rows. shape( )
345+ ) ) ) ;
179346 }
180- Ok ( rows
181- . index_axis ( Axis ( 0 ) , 0 )
182- . iter ( )
183- . copied ( )
184- . map ( to_f32)
347+ Ok ( ( 0 ..batch)
348+ . map ( |index| {
349+ rows. index_axis ( Axis ( 0 ) , index)
350+ . iter ( )
351+ . copied ( )
352+ . map ( to_f32)
353+ . collect ( )
354+ } )
185355 . collect ( ) )
186356 }
187- rank => Err ( SparrowEngineError :: OutputShapeMismatch {
188- id : manifest. id . clone ( ) ,
189- shape : format ! ( "rank {rank}" ) ,
190- method : manifest. postprocess_method . as_str ( ) . to_string ( ) ,
191- } ) ,
357+ rank => Err ( mismatch ( format ! ( "rank {rank}" ) ) ) ,
192358 }
193359}
194360
0 commit comments