Skip to main content

forge_orchestration/inference/
batch.rs

1//! Request batching for AI/ML inference
2//!
3//! Provides dynamic batching to improve throughput for inference workloads.
4
5use 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/// Configuration for batch processing
13#[derive(Debug, Clone)]
14pub struct BatchConfig {
15    /// Maximum batch size
16    pub max_batch_size: usize,
17    /// Maximum wait time before processing a partial batch
18    pub max_wait_ms: u64,
19    /// Minimum batch size to trigger immediate processing
20    pub min_batch_size: usize,
21    /// Enable dynamic batch sizing based on load
22    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    /// Create a new batch config
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    /// Set maximum batch size
43    pub fn max_size(mut self, size: usize) -> Self {
44        self.max_batch_size = size.max(1);
45        self
46    }
47
48    /// Set maximum wait time in milliseconds
49    pub fn max_wait(mut self, ms: u64) -> Self {
50        self.max_wait_ms = ms;
51        self
52    }
53
54    /// Set minimum batch size for immediate processing
55    pub fn min_size(mut self, size: usize) -> Self {
56        self.min_batch_size = size.max(1);
57        self
58    }
59}
60
61/// A request in the batch queue
62pub struct BatchRequest<T> {
63    /// Request payload
64    pub payload: T,
65    /// Response channel
66    response_tx: oneshot::Sender<BatchResult<T>>,
67    /// Request arrival time
68    arrived_at: Instant,
69}
70
71/// Result of a batched request
72#[derive(Debug)]
73pub struct BatchResult<T> {
74    /// Response payload
75    pub payload: T,
76    /// Batch size this request was processed with
77    pub batch_size: usize,
78    /// Time spent waiting in queue
79    pub queue_time_ms: u64,
80    /// Processing time
81    pub process_time_ms: u64,
82}
83
84/// Batch processor for inference requests
85pub 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/// Statistics for batch processing
93#[derive(Debug, Default, Clone)]
94pub struct BatchStats {
95    /// Total requests processed
96    pub total_requests: u64,
97    /// Total batches processed
98    pub total_batches: u64,
99    /// Average batch size
100    pub avg_batch_size: f64,
101    /// Average queue time in ms
102    pub avg_queue_time_ms: f64,
103    /// Average processing time in ms
104    pub avg_process_time_ms: f64,
105}
106
107impl<T: Send + 'static> BatchProcessor<T> {
108    /// Create a new batch processor
109    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    /// Submit a request and wait for the result
119    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            // Notify if we've reached max batch size
133            if queue.len() >= self.config.max_batch_size {
134                self.notify.notify_one();
135            }
136        }
137
138        // Also notify to start the timer
139        self.notify.notify_one();
140
141        rx.await.map_err(|_| BatchError::Cancelled)
142    }
143
144    /// Run the dynamic-batching serving loop: wait for a batch, collect it, run
145    /// `executor` on the payloads, and deliver each request's response.
146    ///
147    /// This is the piece that makes [`BatchProcessor::submit`] resolve — without
148    /// a running `run` (or manual `collect_batch`/`complete_batch`), submitted
149    /// futures never complete. It is the serving primitive an inference/expert
150    /// worker spawns as a task; it loops until the task is cancelled.
151    ///
152    /// `executor` receives the batched payloads in arrival order and must return
153    /// exactly one response per input, in the same order. (If it returns fewer,
154    /// the surplus requests are cancelled rather than answered.)
155    ///
156    /// ```ignore
157    /// let p = processor.clone();
158    /// tokio::spawn(async move {
159    ///     p.run(|batch: Vec<Req>| async move { model.forward(batch).await }).await;
160    /// });
161    /// let result = processor.submit(req).await?; // resolved by the loop
162    /// ```
163    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    /// Get current queue length
197    pub fn queue_len(&self) -> usize {
198        self.queue.lock().len()
199    }
200
201    /// Get batch statistics
202    pub fn stats(&self) -> BatchStats {
203        self.stats.lock().clone()
204    }
205
206    /// Collect a batch of requests (up to max_batch_size)
207    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    /// Complete a batch with results.
222    ///
223    /// Each entry is `(response_sender, arrival_instant, response_payload)`.
224    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        // Update stats
245        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    /// Wait for a batch to be ready
258    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                // Check if we have enough for a batch
264                self.queue.lock().len() >= self.config.min_batch_size
265            }
266            _ = tokio::time::sleep(timeout) => {
267                // Timeout - process whatever we have
268                !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/// Batch processing error
286#[derive(Debug, thiserror::Error)]
287pub enum BatchError {
288    /// Request was cancelled
289    #[error("Request cancelled")]
290    Cancelled,
291    /// Queue is full
292    #[error("Queue full")]
293    QueueFull,
294    /// Processing failed
295    #[error("Processing failed: {0}")]
296    ProcessingFailed(String),
297}
298
299/// Simple batch collector for manual batch processing
300pub 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    /// Create a new batch collector
309    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    /// Add an item to the batch
319    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    /// Check if batch is ready (full or timeout)
329    pub fn is_ready(&self) -> bool {
330        self.items.len() >= self.max_size || self.created_at.elapsed() >= self.max_wait
331    }
332
333    /// Check if batch is full
334    pub fn is_full(&self) -> bool {
335        self.items.len() >= self.max_size
336    }
337
338    /// Get current batch size
339    pub fn len(&self) -> usize {
340        self.items.len()
341    }
342
343    /// Check if batch is empty
344    pub fn is_empty(&self) -> bool {
345        self.items.is_empty()
346    }
347
348    /// Take the collected items
349    pub fn take(self) -> Vec<T> {
350        self.items
351    }
352
353    /// Time since batch was created
354    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)); // Should fail, batch is full
373        
374        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        // Spawn the serving loop with a trivial "model" that doubles each input,
390        // then submit requests and confirm they are batched and answered.
391        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}