Skip to content

Commit 63c950c

Browse files
zhmiaoCopilot
andcommitted
Merge perf/encoder-batched-inference: batched GPU encoding, 100 -> 426 img/s
Encoder throughput on one RTX 6000 Ada with bioclip-2, measured end to end through the Python wheel in-process on 400 real camera-trap photos: as published (0.1.23), fp32 160.8 img/s this branch, fp32 275.5 img/s this branch, fp16 426.3 img/s this branch, fp16 + TensorRT 558.8 img/s Over HTTP the serving path went 100.55 -> 261 img/s with GPU utilisation 49% -> 90%. The cause was one defect repeated in four layers, each of which hid the next. embed_batch in both the GPU and CPU crates was a serial per-image loop, so the ONNX session always ran at batch 1 and no caller's batch size ever reached the model -- which is why batch 32/64/128/256 all measured identically. The Python binding's embed() then looped the SINGLE-image function, so it never reached embed_batch at all and hid both crate fixes from every wheel caller: the rebuilt wheel measured 160.71 against a published 160.8 until that was found. Finally the binding's window was set equal to the internal inference chunk, so embed_batch received exactly one chunk per call and its decode/inference overlap never ran. Alongside those: the engine-wide Mutex<JpegDecoder> was held across Session::run, serialising all concurrency on one lock -- hence 1/2/4/8 concurrent clients also measuring identically despite an inference-semaphore default of 32. Replaced with a decoder pool, default raised 3 -> 6 once decode became what binds. Defaults re-swept against fp16 rather than the CUDA-fp32 ceiling the originals were chosen for: inference chunk 8 -> 4, embed window 8 -> 256. The chunk curve is not monotonic (1->223, 3->393, 4->428, 8->388, 32->347) because that constant is a pipelining granularity, not a batch size. Both are env-overridable (SPARROW_ENGINE_INFERENCE_CHUNK, SPARROW_ENGINE_EMBED_WINDOW, SPARROW_ENGINE_INTRA_THREADS) since the knee moves with precision and provider. 426 img/s is 84% of this model's 509 img/s fp16 inference ceiling, matching the 84-88% end-to-end-versus-binding-ceiling ratio every configuration measured landed in, so the fp16 single-GPU path is now at its ceiling. Vectors are unchanged: cosine 0.9999995 against the pre-change build. fp16 keeps 394/400 identical nearest neighbours at cosine 0.99985, and all disagreements are tie-breaks with margins far below the median rank1-rank2 gap. 109 GPU-crate tests pass (2 pre-existing cuDNN-environment failures unchanged); clippy clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa7107c7-817e-41f8-b9f1-5e6d9b674025
2 parents 1801e3f + 95acdbe commit 63c950c

6 files changed

Lines changed: 885 additions & 142 deletions

File tree

sparrow-engine/sparrow-engine-cpu/src/embed.rs

Lines changed: 227 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,39 @@ pub(crate) fn validate_image_encoder(manifest: &ModelManifest) -> Result<()> {
4343
/// Run image encoder inference on a single image.
4444
pub 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.
122184
pub 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

Comments
 (0)