forge_orchestration/inference/
batch.rs1use std::collections::VecDeque;
6use std::sync::Arc;
7use std::time::{Duration, Instant};
8use parking_lot::Mutex;
9use tokio::sync::{oneshot, Notify};
10use tracing::{debug, info};
11
12#[derive(Debug, Clone)]
14pub struct BatchConfig {
15 pub max_batch_size: usize,
17 pub max_wait_ms: u64,
19 pub min_batch_size: usize,
21 pub dynamic_sizing: bool,
23}
24
25impl Default for BatchConfig {
26 fn default() -> Self {
27 Self {
28 max_batch_size: 32,
29 max_wait_ms: 50,
30 min_batch_size: 1,
31 dynamic_sizing: true,
32 }
33 }
34}
35
36impl BatchConfig {
37 pub fn new() -> Self {
39 Self::default()
40 }
41
42 pub fn max_size(mut self, size: usize) -> Self {
44 self.max_batch_size = size.max(1);
45 self
46 }
47
48 pub fn max_wait(mut self, ms: u64) -> Self {
50 self.max_wait_ms = ms;
51 self
52 }
53
54 pub fn min_size(mut self, size: usize) -> Self {
56 self.min_batch_size = size.max(1);
57 self
58 }
59}
60
61pub struct BatchRequest<T> {
63 pub payload: T,
65 response_tx: oneshot::Sender<BatchResult<T>>,
67 arrived_at: Instant,
69}
70
71#[derive(Debug)]
73pub struct BatchResult<T> {
74 pub payload: T,
76 pub batch_size: usize,
78 pub queue_time_ms: u64,
80 pub process_time_ms: u64,
82}
83
84pub struct BatchProcessor<T: Send + 'static> {
86 config: BatchConfig,
87 queue: Arc<Mutex<VecDeque<BatchRequest<T>>>>,
88 notify: Arc<Notify>,
89 stats: Arc<Mutex<BatchStats>>,
90}
91
92#[derive(Debug, Default, Clone)]
94pub struct BatchStats {
95 pub total_requests: u64,
97 pub total_batches: u64,
99 pub avg_batch_size: f64,
101 pub avg_queue_time_ms: f64,
103 pub avg_process_time_ms: f64,
105}
106
107impl<T: Send + 'static> BatchProcessor<T> {
108 pub fn new(config: BatchConfig) -> Self {
110 Self {
111 config,
112 queue: Arc::new(Mutex::new(VecDeque::new())),
113 notify: Arc::new(Notify::new()),
114 stats: Arc::new(Mutex::new(BatchStats::default())),
115 }
116 }
117
118 pub async fn submit(&self, payload: T) -> Result<BatchResult<T>, BatchError> {
120 let (tx, rx) = oneshot::channel();
121
122 let request = BatchRequest {
123 payload,
124 response_tx: tx,
125 arrived_at: Instant::now(),
126 };
127
128 {
129 let mut queue = self.queue.lock();
130 queue.push_back(request);
131
132 if queue.len() >= self.config.max_batch_size {
134 self.notify.notify_one();
135 }
136 }
137
138 self.notify.notify_one();
140
141 rx.await.map_err(|_| BatchError::Cancelled)
142 }
143
144 pub async fn run<F, Fut>(&self, mut executor: F)
164 where
165 F: FnMut(Vec<T>) -> Fut,
166 Fut: std::future::Future<Output = Vec<T>>,
167 {
168 info!(max_batch = self.config.max_batch_size, "batch serving loop started");
169 loop {
170 if !self.wait_for_batch().await {
171 continue;
172 }
173 let batch = self.collect_batch();
174 if batch.is_empty() {
175 continue;
176 }
177
178 let mut payloads = Vec::with_capacity(batch.len());
179 let mut meta = Vec::with_capacity(batch.len());
180 for (payload, tx, arrived_at) in batch {
181 payloads.push(payload);
182 meta.push((tx, arrived_at));
183 }
184
185 let responses = executor(payloads).await;
186
187 let results = meta
188 .into_iter()
189 .zip(responses)
190 .map(|((tx, arrived_at), response)| (tx, arrived_at, response))
191 .collect();
192 self.complete_batch(results);
193 }
194 }
195
196 pub fn queue_len(&self) -> usize {
198 self.queue.lock().len()
199 }
200
201 pub fn stats(&self) -> BatchStats {
203 self.stats.lock().clone()
204 }
205
206 pub fn collect_batch(&self) -> Vec<(T, oneshot::Sender<BatchResult<T>>, Instant)> {
208 let mut queue = self.queue.lock();
209 let batch_size = queue.len().min(self.config.max_batch_size);
210
211 let mut batch = Vec::with_capacity(batch_size);
212 for _ in 0..batch_size {
213 if let Some(req) = queue.pop_front() {
214 batch.push((req.payload, req.response_tx, req.arrived_at));
215 }
216 }
217
218 batch
219 }
220
221 pub fn complete_batch(&self, results: Vec<(oneshot::Sender<BatchResult<T>>, Instant, T)>) {
225 let batch_size = results.len();
226 let process_start = Instant::now();
227
228 let mut total_queue_time = 0u64;
229
230 for (tx, arrived_at, response) in results {
231 let queue_time = arrived_at.elapsed().as_millis() as u64;
232 total_queue_time += queue_time;
233
234 let result = BatchResult {
235 payload: response,
236 batch_size,
237 queue_time_ms: queue_time,
238 process_time_ms: process_start.elapsed().as_millis() as u64,
239 };
240
241 let _ = tx.send(result);
242 }
243
244 let mut stats = self.stats.lock();
246 stats.total_requests += batch_size as u64;
247 stats.total_batches += 1;
248
249 let n = stats.total_batches as f64;
250 stats.avg_batch_size = stats.avg_batch_size * (n - 1.0) / n + batch_size as f64 / n;
251 stats.avg_queue_time_ms = stats.avg_queue_time_ms * (n - 1.0) / n
252 + (total_queue_time as f64 / batch_size as f64) / n;
253
254 debug!(batch_size = batch_size, "Batch completed");
255 }
256
257 pub async fn wait_for_batch(&self) -> bool {
259 let timeout = Duration::from_millis(self.config.max_wait_ms);
260
261 tokio::select! {
262 _ = self.notify.notified() => {
263 self.queue.lock().len() >= self.config.min_batch_size
265 }
266 _ = tokio::time::sleep(timeout) => {
267 !self.queue.lock().is_empty()
269 }
270 }
271 }
272}
273
274impl<T: Send + 'static> Clone for BatchProcessor<T> {
275 fn clone(&self) -> Self {
276 Self {
277 config: self.config.clone(),
278 queue: self.queue.clone(),
279 notify: self.notify.clone(),
280 stats: self.stats.clone(),
281 }
282 }
283}
284
285#[derive(Debug, thiserror::Error)]
287pub enum BatchError {
288 #[error("Request cancelled")]
290 Cancelled,
291 #[error("Queue full")]
293 QueueFull,
294 #[error("Processing failed: {0}")]
296 ProcessingFailed(String),
297}
298
299pub struct BatchCollector<T> {
301 items: Vec<T>,
302 max_size: usize,
303 created_at: Instant,
304 max_wait: Duration,
305}
306
307impl<T> BatchCollector<T> {
308 pub fn new(max_size: usize, max_wait_ms: u64) -> Self {
310 Self {
311 items: Vec::with_capacity(max_size),
312 max_size,
313 created_at: Instant::now(),
314 max_wait: Duration::from_millis(max_wait_ms),
315 }
316 }
317
318 pub fn add(&mut self, item: T) -> bool {
320 if self.items.len() < self.max_size {
321 self.items.push(item);
322 true
323 } else {
324 false
325 }
326 }
327
328 pub fn is_ready(&self) -> bool {
330 self.items.len() >= self.max_size || self.created_at.elapsed() >= self.max_wait
331 }
332
333 pub fn is_full(&self) -> bool {
335 self.items.len() >= self.max_size
336 }
337
338 pub fn len(&self) -> usize {
340 self.items.len()
341 }
342
343 pub fn is_empty(&self) -> bool {
345 self.items.is_empty()
346 }
347
348 pub fn take(self) -> Vec<T> {
350 self.items
351 }
352
353 pub fn age(&self) -> Duration {
355 self.created_at.elapsed()
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 #[test]
364 fn test_batch_collector() {
365 let mut collector: BatchCollector<i32> = BatchCollector::new(3, 100);
366
367 assert!(collector.add(1));
368 assert!(collector.add(2));
369 assert!(!collector.is_full());
370 assert!(collector.add(3));
371 assert!(collector.is_full());
372 assert!(!collector.add(4)); let items = collector.take();
375 assert_eq!(items, vec![1, 2, 3]);
376 }
377
378 #[tokio::test]
379 async fn test_batch_processor_stats() {
380 let processor: BatchProcessor<String> = BatchProcessor::new(BatchConfig::default());
381
382 let stats = processor.stats();
383 assert_eq!(stats.total_requests, 0);
384 assert_eq!(stats.total_batches, 0);
385 }
386
387 #[tokio::test]
388 async fn test_batch_processor_end_to_end() {
389 let processor: BatchProcessor<u32> = BatchProcessor::new(BatchConfig::default().max_wait(20));
392 let server = processor.clone();
393 tokio::spawn(async move {
394 server
395 .run(|inputs: Vec<u32>| async move {
396 inputs.into_iter().map(|x| x * 2).collect::<Vec<u32>>()
397 })
398 .await;
399 });
400
401 let r1 = processor.submit(21).await.unwrap();
402 assert_eq!(r1.payload, 42);
403 assert!(r1.batch_size >= 1);
404
405 let r2 = processor.submit(50).await.unwrap();
406 assert_eq!(r2.payload, 100);
407
408 assert!(processor.stats().total_requests >= 2);
409 }
410}