/home/noah/src/realizar/src/gguf/batch_scheduler.rs
Line | Count | Source |
1 | | //! Request Batching Infrastructure for GPU-Accelerated Inference |
2 | | //! |
3 | | //! Extracted from gguf_monolith.rs (PMAT-802) for vertical production partitioning. |
4 | | //! |
5 | | //! ## Contents |
6 | | //! |
7 | | //! - `BatchGenerationStats`: Statistics for batch generation capabilities |
8 | | //! - `PendingRequest`, `RequestBatch`, `BatchRequestCollector`: Request batching |
9 | | //! - `BatchingConfig`: Batching configuration |
10 | | //! - `SlotState`, `ContinuousBatchScheduler`: Continuous batching |
11 | | //! - `SpeculativeConfig`, `SpeculativeDecoder`: Speculative decoding |
12 | | //! - `GpuBufferPool`, `GpuBufferPoolStats`: GPU buffer management |
13 | | //! - `AsyncCommandQueue`, `CommandSlot`, `AsyncQueueStats`: Async command queue |
14 | | //! - `PrefixCache`, `PrefixCacheEntry`, `PrefixCacheStats`: Prefix caching |
15 | | //! - `MultiRequestState`, `MultiSchedulerRequest`, `SchedulingPolicy`, `MultiRequestScheduler`: Multi-request scheduling |
16 | | //! - `ChunkedPrefillConfig`, `ChunkProgress`, `ChunkedPrefill`, `ChunkedPrefillStats`: Chunked prefill |
17 | | //! |
18 | | //! ## Feature Gate |
19 | | //! |
20 | | //! This entire module is gated behind `#[cfg(feature = "gpu")]`. |
21 | | |
22 | | // Note: This module is feature-gated in mod.rs with #[cfg(feature = "gpu")] |
23 | | #![allow(clippy::many_single_char_names)] |
24 | | #![allow(clippy::similar_names)] |
25 | | |
26 | | |
27 | | use super::runtime::OwnedQuantizedKVCache; |
28 | | |
29 | | /// Statistics for batch generation configuration |
30 | | #[derive(Debug, Clone)] |
31 | | pub struct BatchGenerationStats { |
32 | | /// Whether GPU cache is ready |
33 | | pub gpu_cache_ready: bool, |
34 | | /// Memory used by GPU cache in GB |
35 | | pub cache_memory_gb: f64, |
36 | | /// Number of transformer layers |
37 | | pub num_layers: usize, |
38 | | /// Hidden dimension |
39 | | pub hidden_dim: usize, |
40 | | /// FFN intermediate dimension |
41 | | pub intermediate_dim: usize, |
42 | | /// Recommended batch size for GPU efficiency |
43 | | pub recommended_batch_size: usize, |
44 | | /// Maximum batch size before memory pressure |
45 | | pub max_batch_size: usize, |
46 | | } |
47 | | |
48 | | // ============================================================================ |
49 | | // PARITY-023: Request Batching Infrastructure |
50 | | // ============================================================================ |
51 | | |
52 | | /// A pending request waiting to be batched (PARITY-023) |
53 | | #[cfg(feature = "gpu")] |
54 | | #[derive(Debug, Clone)] |
55 | | pub struct PendingRequest { |
56 | | /// Request ID for tracking |
57 | | pub id: u64, |
58 | | /// Prompt tokens |
59 | | pub prompt: Vec<u32>, |
60 | | /// Maximum tokens to generate |
61 | | pub max_tokens: usize, |
62 | | /// Temperature for sampling |
63 | | pub temperature: f32, |
64 | | /// Top-k sampling |
65 | | pub top_k: usize, |
66 | | /// Time when request was submitted |
67 | | pub submitted_at: std::time::Instant, |
68 | | } |
69 | | |
70 | | #[cfg(feature = "gpu")] |
71 | | impl PendingRequest { |
72 | | /// Create a new pending request |
73 | 14 | pub fn new( |
74 | 14 | id: u64, |
75 | 14 | prompt: Vec<u32>, |
76 | 14 | max_tokens: usize, |
77 | 14 | temperature: f32, |
78 | 14 | top_k: usize, |
79 | 14 | ) -> Self { |
80 | 14 | Self { |
81 | 14 | id, |
82 | 14 | prompt, |
83 | 14 | max_tokens, |
84 | 14 | temperature, |
85 | 14 | top_k, |
86 | 14 | submitted_at: std::time::Instant::now(), |
87 | 14 | } |
88 | 14 | } |
89 | | |
90 | | /// Time spent waiting in queue |
91 | 8 | pub fn wait_time(&self) -> std::time::Duration { |
92 | 8 | self.submitted_at.elapsed() |
93 | 8 | } |
94 | | } |
95 | | |
96 | | /// A batch of requests ready for processing (PARITY-023) |
97 | | #[cfg(feature = "gpu")] |
98 | | #[derive(Debug)] |
99 | | pub struct RequestBatch { |
100 | | /// Requests in this batch |
101 | | pub requests: Vec<PendingRequest>, |
102 | | /// When batch was formed |
103 | | pub formed_at: std::time::Instant, |
104 | | } |
105 | | |
106 | | #[cfg(feature = "gpu")] |
107 | | impl RequestBatch { |
108 | | /// Create batch from requests |
109 | 2 | pub fn new(requests: Vec<PendingRequest>) -> Self { |
110 | 2 | Self { |
111 | 2 | requests, |
112 | 2 | formed_at: std::time::Instant::now(), |
113 | 2 | } |
114 | 2 | } |
115 | | |
116 | | /// Number of requests in batch |
117 | 4 | pub fn size(&self) -> usize { |
118 | 4 | self.requests.len() |
119 | 4 | } |
120 | | |
121 | | /// Extract prompts for batch processing |
122 | 1 | pub fn prompts(&self) -> Vec<Vec<u32>> { |
123 | 5 | self.requests.iter()1 .map1 (|r| r.prompt.clone()).collect1 () |
124 | 1 | } |
125 | | |
126 | | /// Average wait time for requests in this batch |
127 | 1 | pub fn avg_wait_time(&self) -> std::time::Duration { |
128 | 1 | if self.requests.is_empty() { |
129 | 0 | return std::time::Duration::ZERO; |
130 | 1 | } |
131 | 1 | let total: std::time::Duration = self.requests.iter().map(PendingRequest::wait_time).sum(); |
132 | 1 | total / self.requests.len() as u32 |
133 | 1 | } |
134 | | } |
135 | | |
136 | | /// Request batch collector with configurable thresholds (PARITY-023) |
137 | | /// |
138 | | /// Collects incoming requests and forms batches when: |
139 | | /// - Batch size reaches `batch_threshold`, OR |
140 | | /// - Wait time exceeds `timeout_ms` |
141 | | /// |
142 | | /// This enables efficient GPU utilization by batching multiple requests. |
143 | | #[cfg(feature = "gpu")] |
144 | | pub struct BatchRequestCollector { |
145 | | /// Pending requests |
146 | | pending: std::sync::Mutex<Vec<PendingRequest>>, |
147 | | /// Next request ID |
148 | | next_id: std::sync::atomic::AtomicU64, |
149 | | /// Batch size threshold (32 = GPU GEMM threshold from IMP-600) |
150 | | pub batch_threshold: usize, |
151 | | /// Maximum wait time before forcing batch formation (ms) |
152 | | pub timeout_ms: u64, |
153 | | /// Maximum batch size (memory limit) |
154 | | pub max_batch_size: usize, |
155 | | } |
156 | | |
157 | | #[cfg(feature = "gpu")] |
158 | | impl BatchRequestCollector { |
159 | | /// Create new collector with default thresholds |
160 | | /// |
161 | | /// Default: batch_threshold=32, timeout_ms=50, max_batch_size=64 |
162 | 0 | pub fn new() -> Self { |
163 | 0 | Self { |
164 | 0 | pending: std::sync::Mutex::new(Vec::new()), |
165 | 0 | next_id: std::sync::atomic::AtomicU64::new(0), |
166 | 0 | batch_threshold: 32, |
167 | 0 | timeout_ms: 50, |
168 | 0 | max_batch_size: 64, |
169 | 0 | } |
170 | 0 | } |
171 | | |
172 | | /// Create collector with custom thresholds |
173 | 2 | pub fn with_thresholds(batch_threshold: usize, timeout_ms: u64, max_batch_size: usize) -> Self { |
174 | 2 | Self { |
175 | 2 | pending: std::sync::Mutex::new(Vec::new()), |
176 | 2 | next_id: std::sync::atomic::AtomicU64::new(0), |
177 | 2 | batch_threshold, |
178 | 2 | timeout_ms, |
179 | 2 | max_batch_size, |
180 | 2 | } |
181 | 2 | } |
182 | | |
183 | | /// Submit a request to the collector |
184 | | /// |
185 | | /// Returns the request ID for tracking |
186 | 8 | pub fn submit( |
187 | 8 | &self, |
188 | 8 | prompt: Vec<u32>, |
189 | 8 | max_tokens: usize, |
190 | 8 | temperature: f32, |
191 | 8 | top_k: usize, |
192 | 8 | ) -> u64 { |
193 | 8 | let id = self |
194 | 8 | .next_id |
195 | 8 | .fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
196 | 8 | let request = PendingRequest::new(id, prompt, max_tokens, temperature, top_k); |
197 | | |
198 | 8 | let mut pending = self.pending.lock().expect("Mutex poisoned"); |
199 | 8 | pending.push(request); |
200 | | |
201 | 8 | id |
202 | 8 | } |
203 | | |
204 | | /// Check if batch is ready to be formed |
205 | 3 | pub fn is_batch_ready(&self) -> bool { |
206 | 3 | let pending = self.pending.lock().expect("Mutex poisoned"); |
207 | 3 | if pending.is_empty() { |
208 | 0 | return false; |
209 | 3 | } |
210 | | |
211 | | // Batch ready if threshold reached |
212 | 3 | if pending.len() >= self.batch_threshold { |
213 | 1 | return true; |
214 | 2 | } |
215 | | |
216 | | // Batch ready if oldest request has waited too long |
217 | 2 | if let Some(oldest) = pending.first() { |
218 | 2 | let wait_ms = oldest.wait_time().as_millis() as u64; |
219 | 2 | if wait_ms >= self.timeout_ms { |
220 | 0 | return true; |
221 | 2 | } |
222 | 0 | } |
223 | | |
224 | 2 | false |
225 | 3 | } |
226 | | |
227 | | /// Collect a batch of requests |
228 | | /// |
229 | | /// Returns None if no requests are pending or batch not ready |
230 | 1 | pub fn collect_batch(&self) -> Option<RequestBatch> { |
231 | 1 | let mut pending = self.pending.lock().expect("Mutex poisoned"); |
232 | 1 | if pending.is_empty() { |
233 | 0 | return None; |
234 | 1 | } |
235 | | |
236 | | // Check if batch is ready (threshold or timeout) |
237 | 1 | let ready = pending.len() >= self.batch_threshold |
238 | 0 | || pending |
239 | 0 | .first() |
240 | 0 | .is_some_and(|r| r.wait_time().as_millis() as u64 >= self.timeout_ms); |
241 | | |
242 | 1 | if !ready { |
243 | 0 | return None; |
244 | 1 | } |
245 | | |
246 | | // Take up to max_batch_size requests |
247 | 1 | let batch_size = pending.len().min(self.max_batch_size); |
248 | 1 | let requests: Vec<PendingRequest> = pending.drain(..batch_size).collect(); |
249 | | |
250 | 1 | Some(RequestBatch::new(requests)) |
251 | 1 | } |
252 | | |
253 | | /// Force collect all pending requests as a batch |
254 | 0 | pub fn flush(&self) -> Option<RequestBatch> { |
255 | 0 | let mut pending = self.pending.lock().expect("Mutex poisoned"); |
256 | 0 | if pending.is_empty() { |
257 | 0 | return None; |
258 | 0 | } |
259 | | |
260 | 0 | let requests: Vec<PendingRequest> = pending.drain(..).collect(); |
261 | 0 | Some(RequestBatch::new(requests)) |
262 | 0 | } |
263 | | |
264 | | /// Number of pending requests |
265 | 4 | pub fn pending_count(&self) -> usize { |
266 | 4 | self.pending.lock().expect("Mutex poisoned").len() |
267 | 4 | } |
268 | | |
269 | | /// Total requests submitted |
270 | 1 | pub fn total_submitted(&self) -> u64 { |
271 | 1 | self.next_id.load(std::sync::atomic::Ordering::Relaxed) |
272 | 1 | } |
273 | | } |
274 | | |
275 | | #[cfg(feature = "gpu")] |
276 | | impl Default for BatchRequestCollector { |
277 | 0 | fn default() -> Self { |
278 | 0 | Self::new() |
279 | 0 | } |
280 | | } |
281 | | |
282 | | /// Batching configuration for request collector (PARITY-023) |
283 | | #[cfg(feature = "gpu")] |
284 | | #[derive(Debug, Clone)] |
285 | | pub struct BatchingConfig { |
286 | | /// Minimum batch size to trigger GPU processing (32 from IMP-600) |
287 | | pub batch_threshold: usize, |
288 | | /// Maximum wait time before processing smaller batch (ms) |
289 | | pub timeout_ms: u64, |
290 | | /// Maximum batch size (memory limit) |
291 | | pub max_batch_size: usize, |
292 | | /// Whether to prefer latency (process immediately) or throughput (wait for batch) |
293 | | pub prefer_throughput: bool, |
294 | | } |
295 | | |
296 | | #[cfg(feature = "gpu")] |
297 | | impl Default for BatchingConfig { |
298 | 1 | fn default() -> Self { |
299 | 1 | Self { |
300 | 1 | batch_threshold: 32, |
301 | 1 | timeout_ms: 50, |
302 | 1 | max_batch_size: 64, |
303 | 1 | prefer_throughput: true, |
304 | 1 | } |
305 | 1 | } |
306 | | } |
307 | | |
308 | | #[cfg(feature = "gpu")] |
309 | | impl BatchingConfig { |
310 | | /// Config optimized for latency (smaller batches, shorter timeout) |
311 | 1 | pub fn latency_optimized() -> Self { |
312 | 1 | Self { |
313 | 1 | batch_threshold: 8, |
314 | 1 | timeout_ms: 10, |
315 | 1 | max_batch_size: 32, |
316 | 1 | prefer_throughput: false, |
317 | 1 | } |
318 | 1 | } |
319 | | |
320 | | /// Config optimized for throughput (larger batches, longer timeout) |
321 | 1 | pub fn throughput_optimized() -> Self { |
322 | 1 | Self { |
323 | 1 | batch_threshold: 32, |
324 | 1 | timeout_ms: 100, |
325 | 1 | max_batch_size: 64, |
326 | 1 | prefer_throughput: true, |
327 | 1 | } |
328 | 1 | } |
329 | | } |
330 | | |
331 | | /// Slot state for continuous batching (PARITY-028) |
332 | | #[cfg(feature = "gpu")] |
333 | | #[derive(Debug, Clone)] |
334 | | pub enum SlotState { |
335 | | /// Slot is available for new request |
336 | | Empty, |
337 | | /// Slot has active request being generated |
338 | | Active { |
339 | | /// Unique request identifier |
340 | | request_id: u64, |
341 | | /// Input prompt tokens |
342 | | prompt_tokens: Vec<u32>, |
343 | | /// Tokens generated so far |
344 | | generated_tokens: Vec<u32>, |
345 | | /// Maximum tokens to generate |
346 | | max_tokens: usize, |
347 | | /// Sampling temperature |
348 | | temperature: f32, |
349 | | /// Top-k sampling parameter |
350 | | top_k: usize, |
351 | | }, |
352 | | /// Slot has completed request waiting for retrieval |
353 | | Completed { |
354 | | /// Unique request identifier |
355 | | request_id: u64, |
356 | | /// All generated tokens |
357 | | generated_tokens: Vec<u32>, |
358 | | }, |
359 | | } |
360 | | |
361 | | #[cfg(feature = "gpu")] |
362 | | impl SlotState { |
363 | | /// Check if slot is available |
364 | 109 | pub fn is_empty(&self) -> bool { |
365 | 109 | matches!33 (self, Self::Empty) |
366 | 109 | } |
367 | | |
368 | | /// Check if slot has active generation |
369 | 123 | pub fn is_active(&self) -> bool { |
370 | 123 | matches!101 (self, Self::Active { .. }) |
371 | 123 | } |
372 | | |
373 | | /// Check if slot has completed request |
374 | 3 | pub fn is_completed(&self) -> bool { |
375 | 3 | matches!2 (self, Self::Completed { .. }) |
376 | 3 | } |
377 | | |
378 | | /// Get request ID if slot has one |
379 | 3 | pub fn request_id(&self) -> Option<u64> { |
380 | 3 | match self { |
381 | 1 | Self::Empty => None, |
382 | 1 | Self::Active { request_id, .. } | Self::Completed { request_id, .. } => { |
383 | 2 | Some(*request_id) |
384 | | }, |
385 | | } |
386 | 3 | } |
387 | | } |
388 | | |
389 | | /// Continuous batch scheduler (PARITY-028) |
390 | | /// |
391 | | /// Enables dynamic addition/removal of requests from a running batch: |
392 | | /// - Requests are assigned to slots |
393 | | /// - Each slot can be in Empty, Active, or Completed state |
394 | | /// - New requests fill empty slots immediately |
395 | | /// - Completed requests free their slots for reuse |
396 | | /// |
397 | | /// This maximizes GPU utilization by keeping the batch full. |
398 | | #[cfg(feature = "gpu")] |
399 | | pub struct ContinuousBatchScheduler { |
400 | | /// Fixed-size array of slots |
401 | | slots: std::sync::Mutex<Vec<SlotState>>, |
402 | | /// KV caches for each slot (pre-allocated) |
403 | | caches: std::sync::Mutex<Vec<OwnedQuantizedKVCache>>, |
404 | | /// Total slots (max concurrent requests) |
405 | | pub num_slots: usize, |
406 | | /// Completed request IDs for polling |
407 | | completed: std::sync::Mutex<Vec<(u64, Vec<u32>)>>, |
408 | | /// Next request ID |
409 | | next_id: std::sync::atomic::AtomicU64, |
410 | | } |
411 | | |
412 | | #[cfg(feature = "gpu")] |
413 | | impl ContinuousBatchScheduler { |
414 | | /// Create scheduler with specified number of slots |
415 | | /// |
416 | | /// # Arguments |
417 | | /// * `num_slots` - Maximum concurrent requests (typically 32-64) |
418 | | /// * `num_layers` - Number of transformer layers (for KV cache) |
419 | | /// * `hidden_dim` - Hidden dimension (for KV cache) |
420 | | /// * `max_seq_len` - Maximum sequence length (for KV cache) |
421 | 3 | pub fn new(num_slots: usize, num_layers: usize, hidden_dim: usize, max_seq_len: usize) -> Self { |
422 | 3 | let slots = vec![SlotState::Empty; num_slots]; |
423 | 3 | let caches = (0..num_slots) |
424 | 40 | .map3 (|_| OwnedQuantizedKVCache::new(num_layers, hidden_dim, max_seq_len)) |
425 | 3 | .collect(); |
426 | | |
427 | 3 | Self { |
428 | 3 | slots: std::sync::Mutex::new(slots), |
429 | 3 | caches: std::sync::Mutex::new(caches), |
430 | 3 | num_slots, |
431 | 3 | completed: std::sync::Mutex::new(Vec::new()), |
432 | 3 | next_id: std::sync::atomic::AtomicU64::new(0), |
433 | 3 | } |
434 | 3 | } |
435 | | |
436 | | /// Submit a new request to the scheduler |
437 | | /// |
438 | | /// Returns request ID if slot available, None if all slots full |
439 | 10 | pub fn submit( |
440 | 10 | &self, |
441 | 10 | prompt_tokens: Vec<u32>, |
442 | 10 | max_tokens: usize, |
443 | 10 | temperature: f32, |
444 | 10 | top_k: usize, |
445 | 10 | ) -> Option<u64> { |
446 | 10 | let mut slots = self.slots.lock().expect("Mutex poisoned"); |
447 | | |
448 | | // Find first empty slot |
449 | 10 | let empty_idx9 = slots.iter().position(SlotState::is_empty)?1 ; |
450 | | |
451 | 9 | let request_id = self |
452 | 9 | .next_id |
453 | 9 | .fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
454 | | |
455 | 9 | slots[empty_idx] = SlotState::Active { |
456 | 9 | request_id, |
457 | 9 | prompt_tokens, |
458 | 9 | generated_tokens: Vec::new(), |
459 | 9 | max_tokens, |
460 | 9 | temperature, |
461 | 9 | top_k, |
462 | 9 | }; |
463 | | |
464 | 9 | Some(request_id) |
465 | 10 | } |
466 | | |
467 | | /// Get number of active slots |
468 | 9 | pub fn active_count(&self) -> usize { |
469 | 9 | let slots = self.slots.lock().expect("Mutex poisoned"); |
470 | 120 | slots.iter()9 .filter9 (|s| s.is_active()).count9 () |
471 | 9 | } |
472 | | |
473 | | /// Get number of empty slots |
474 | 6 | pub fn empty_count(&self) -> usize { |
475 | 6 | let slots = self.slots.lock().expect("Mutex poisoned"); |
476 | 80 | slots.iter()6 .filter6 (|s| s.is_empty()).count6 () |
477 | 6 | } |
478 | | |
479 | | /// Check if any slot has completed request |
480 | 2 | pub fn has_completed(&self) -> bool { |
481 | 2 | let completed = self.completed.lock().expect("Mutex poisoned"); |
482 | 2 | !completed.is_empty() |
483 | 2 | } |
484 | | |
485 | | /// Retrieve completed request results |
486 | 1 | pub fn poll_completed(&self) -> Vec<(u64, Vec<u32>)> { |
487 | 1 | let mut completed = self.completed.lock().expect("Mutex poisoned"); |
488 | 1 | std::mem::take(&mut *completed) |
489 | 1 | } |
490 | | |
491 | | /// Mark a request as completed and move to completed queue |
492 | 1 | pub fn complete_request(&self, slot_idx: usize, tokens: Vec<u32>) { |
493 | 1 | let mut slots = self.slots.lock().expect("Mutex poisoned"); |
494 | 1 | let mut completed = self.completed.lock().expect("Mutex poisoned"); |
495 | | |
496 | 1 | if slot_idx < slots.len() { |
497 | 1 | if let SlotState::Active { request_id, .. } = &slots[slot_idx] { |
498 | 1 | let id = *request_id; |
499 | 1 | // Move to completed |
500 | 1 | completed.push((id, tokens)); |
501 | 1 | // Free the slot |
502 | 1 | slots[slot_idx] = SlotState::Empty; |
503 | 1 | |
504 | 1 | // Reset KV cache for this slot |
505 | 1 | let mut caches = self.caches.lock().expect("Mutex poisoned"); |
506 | 1 | caches[slot_idx].reset(); |
507 | 1 | }0 |
508 | 0 | } |
509 | 1 | } |
510 | | |
511 | | /// Get active slot indices and their current positions |
512 | 0 | pub fn get_active_slots(&self) -> Vec<(usize, usize)> { |
513 | 0 | let slots = self.slots.lock().expect("Mutex poisoned"); |
514 | 0 | slots |
515 | 0 | .iter() |
516 | 0 | .enumerate() |
517 | 0 | .filter_map(|(idx, slot)| match slot { |
518 | | SlotState::Active { |
519 | 0 | prompt_tokens, |
520 | 0 | generated_tokens, |
521 | | .. |
522 | | } => { |
523 | 0 | let pos = prompt_tokens.len() + generated_tokens.len(); |
524 | 0 | Some((idx, pos)) |
525 | | }, |
526 | 0 | _ => None, |
527 | 0 | }) |
528 | 0 | .collect() |
529 | 0 | } |
530 | | |
531 | | /// Get utilization (active_slots / total_slots) |
532 | 3 | pub fn utilization(&self) -> f64 { |
533 | 3 | let active = self.active_count(); |
534 | 3 | active as f64 / self.num_slots as f64 |
535 | 3 | } |
536 | | } |
537 | | |
538 | | /// Speculative decoding configuration (PARITY-029) |
539 | | #[cfg(feature = "gpu")] |
540 | | #[derive(Debug, Clone)] |
541 | | pub struct SpeculativeConfig { |
542 | | /// Number of tokens to speculatively generate per step |
543 | | pub speculation_length: usize, |
544 | | /// Temperature for draft model (lower = more deterministic) |
545 | | pub draft_temperature: f32, |
546 | | /// Whether to use same model for draft (self-speculative) |
547 | | pub self_speculative: bool, |
548 | | } |
549 | | |
550 | | #[cfg(feature = "gpu")] |
551 | | impl Default for SpeculativeConfig { |
552 | 4 | fn default() -> Self { |
553 | 4 | Self { |
554 | 4 | speculation_length: 4, |
555 | 4 | draft_temperature: 0.0, |
556 | 4 | self_speculative: true, |
557 | 4 | } |
558 | 4 | } |
559 | | } |
560 | | |
561 | | /// Result of speculative decoding verification step |
562 | | #[cfg(feature = "gpu")] |
563 | | #[derive(Debug, Clone)] |
564 | | pub struct VerificationResult { |
565 | | /// Number of draft tokens accepted |
566 | | pub accepted_count: usize, |
567 | | /// Total draft tokens generated |
568 | | pub draft_count: usize, |
569 | | /// Accepted tokens (verified by target model) |
570 | | pub accepted_tokens: Vec<u32>, |
571 | | /// Whether all draft tokens were accepted |
572 | | pub all_accepted: bool, |
573 | | } |
574 | | |
575 | | /// Speculative decoder for accelerated token generation (PARITY-029) |
576 | | /// |
577 | | /// Implements speculative decoding (Leviathan et al., 2023): |
578 | | /// 1. Draft model generates K candidate tokens quickly |
579 | | /// 2. Target model verifies all K tokens in parallel |
580 | | /// 3. Accept tokens until first rejection, then resample |
581 | | /// |
582 | | /// This enables O(K) speedup when draft acceptance rate is high. |
583 | | #[cfg(feature = "gpu")] |
584 | | pub struct SpeculativeDecoder { |
585 | | /// Speculative decoding configuration |
586 | | pub config: SpeculativeConfig, |
587 | | /// Statistics: total draft tokens generated |
588 | | pub total_draft_tokens: std::sync::atomic::AtomicU64, |
589 | | /// Statistics: total draft tokens accepted |
590 | | pub total_accepted_tokens: std::sync::atomic::AtomicU64, |
591 | | } |
592 | | |
593 | | #[cfg(feature = "gpu")] |
594 | | impl SpeculativeDecoder { |
595 | | /// Create new speculative decoder with default config |
596 | 2 | pub fn new() -> Self { |
597 | 2 | Self { |
598 | 2 | config: SpeculativeConfig::default(), |
599 | 2 | total_draft_tokens: std::sync::atomic::AtomicU64::new(0), |
600 | 2 | total_accepted_tokens: std::sync::atomic::AtomicU64::new(0), |
601 | 2 | } |
602 | 2 | } |
603 | | |
604 | | /// Create speculative decoder with custom config |
605 | 1 | pub fn with_config(config: SpeculativeConfig) -> Self { |
606 | 1 | Self { |
607 | 1 | config, |
608 | 1 | total_draft_tokens: std::sync::atomic::AtomicU64::new(0), |
609 | 1 | total_accepted_tokens: std::sync::atomic::AtomicU64::new(0), |
610 | 1 | } |
611 | 1 | } |
612 | | |
613 | | /// Get acceptance rate (accepted / total draft tokens) |
614 | 6 | pub fn acceptance_rate(&self) -> f64 { |
615 | 6 | let total = self |
616 | 6 | .total_draft_tokens |
617 | 6 | .load(std::sync::atomic::Ordering::Relaxed); |
618 | 6 | let accepted = self |
619 | 6 | .total_accepted_tokens |
620 | 6 | .load(std::sync::atomic::Ordering::Relaxed); |
621 | 6 | if total == 0 { |
622 | 4 | return 0.0; |
623 | 2 | } |
624 | 2 | accepted as f64 / total as f64 |
625 | 6 | } |
626 | | |
627 | | /// Verify draft tokens against target model logits |
628 | | /// |
629 | | /// # Arguments |
630 | | /// * `draft_tokens` - Candidate tokens from draft model |
631 | | /// * `target_logits` - Logits from target model for each position |
632 | | /// * `temperature` - Sampling temperature for rejection sampling |
633 | | /// |
634 | | /// # Returns |
635 | | /// VerificationResult with accepted tokens and statistics |
636 | 12 | pub fn verify_draft( |
637 | 12 | &self, |
638 | 12 | draft_tokens: &[u32], |
639 | 12 | target_logits: &[Vec<f32>], |
640 | 12 | temperature: f32, |
641 | 12 | ) -> VerificationResult { |
642 | 12 | let mut accepted_tokens = Vec::with_capacity(draft_tokens.len()); |
643 | 12 | let mut accepted_count = 0; |
644 | | |
645 | | // Verify each draft token against target model distribution |
646 | 44 | for (i, &draft_token) in draft_tokens12 .iter12 ().enumerate12 () { |
647 | 44 | if i >= target_logits.len() { |
648 | 0 | break; |
649 | 44 | } |
650 | | |
651 | 44 | let logits = &target_logits[i]; |
652 | | |
653 | | // Find target model's top token |
654 | 44 | let (target_token, _) = logits |
655 | 44 | .iter() |
656 | 44 | .enumerate() |
657 | 4.35k | .max_by44 (|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) |
658 | 44 | .unwrap_or((0, &0.0)); |
659 | | |
660 | | // Accept if draft matches target (greedy case) |
661 | 44 | if temperature == 0.0 { |
662 | 44 | if draft_token == target_token as u32 { |
663 | 40 | accepted_tokens.push(draft_token); |
664 | 40 | accepted_count += 1; |
665 | 40 | } else { |
666 | | // Reject and use target's token instead |
667 | 4 | accepted_tokens.push(target_token as u32); |
668 | 4 | accepted_count += 1; |
669 | 4 | break; // Stop at first mismatch |
670 | | } |
671 | | } else { |
672 | | // Rejection sampling for non-greedy decoding |
673 | | // P(accept) = min(1, p_target(x) / p_draft(x)) |
674 | | // For simplicity, accept if draft is in top-k of target |
675 | 0 | let mut sorted_indices: Vec<usize> = (0..logits.len()).collect(); |
676 | 0 | sorted_indices.sort_by(|&a, &b| { |
677 | 0 | logits[b] |
678 | 0 | .partial_cmp(&logits[a]) |
679 | 0 | .unwrap_or(std::cmp::Ordering::Equal) |
680 | 0 | }); |
681 | | |
682 | 0 | let top_k = 10; // Accept if in top-10 |
683 | 0 | let in_top_k = sorted_indices |
684 | 0 | .iter() |
685 | 0 | .take(top_k) |
686 | 0 | .any(|&idx| idx == draft_token as usize); |
687 | | |
688 | 0 | if in_top_k { |
689 | 0 | accepted_tokens.push(draft_token); |
690 | 0 | accepted_count += 1; |
691 | 0 | } else { |
692 | | // Reject, use target's sampled token |
693 | 0 | accepted_tokens.push(sorted_indices[0] as u32); |
694 | 0 | accepted_count += 1; |
695 | 0 | break; |
696 | | } |
697 | | } |
698 | | } |
699 | | |
700 | | // Update statistics |
701 | 12 | self.total_draft_tokens.fetch_add( |
702 | 12 | draft_tokens.len() as u64, |
703 | 12 | std::sync::atomic::Ordering::Relaxed, |
704 | | ); |
705 | 12 | self.total_accepted_tokens |
706 | 12 | .fetch_add(accepted_count as u64, std::sync::atomic::Ordering::Relaxed); |
707 | | |
708 | 12 | VerificationResult { |
709 | 12 | accepted_count, |
710 | 12 | draft_count: draft_tokens.len(), |
711 | 12 | accepted_tokens, |
712 | 12 | all_accepted: accepted_count == draft_tokens.len(), |
713 | 12 | } |
714 | 12 | } |
715 | | |
716 | | /// Calculate expected speedup based on acceptance rate |
717 | | /// |
718 | | /// Speedup = K * acceptance_rate + 1 (always get at least 1 token) |
719 | 3 | pub fn expected_speedup(&self) -> f64 { |
720 | 3 | let k = self.config.speculation_length as f64; |
721 | 3 | let acceptance_rate = self.acceptance_rate(); |
722 | 3 | k * acceptance_rate + 1.0 |
723 | 3 | } |
724 | | |
725 | | /// Reset statistics |
726 | 1 | pub fn reset_stats(&self) { |
727 | 1 | self.total_draft_tokens |
728 | 1 | .store(0, std::sync::atomic::Ordering::Relaxed); |
729 | 1 | self.total_accepted_tokens |
730 | 1 | .store(0, std::sync::atomic::Ordering::Relaxed); |
731 | 1 | } |
732 | | } |
733 | | |
734 | | #[cfg(feature = "gpu")] |
735 | | impl Default for SpeculativeDecoder { |
736 | 0 | fn default() -> Self { |
737 | 0 | Self::new() |
738 | 0 | } |
739 | | } |
740 | | |
741 | | /// GPU Buffer Pool for zero-allocation inference (PARITY-031, IMP-309) |
742 | | /// |
743 | | /// Pre-allocates GPU buffers during warmup to eliminate allocation overhead |
744 | | /// during generation. Uses a pool of reusable buffers for each tensor type. |
745 | | /// |
746 | | /// # Key Properties |
747 | | /// - Zero GPU malloc after warmup phase |
748 | | /// - Pre-allocated buffers for common tensor sizes |
749 | | /// - Thread-safe buffer borrowing and return |
750 | | /// |
751 | | /// # Buffer Types |
752 | | /// - Hidden state buffers: [batch_size, hidden_dim] |
753 | | /// - Intermediate buffers: [batch_size, intermediate_dim] |
754 | | /// - Attention score buffers: [batch_size, num_heads, seq_len] |
755 | | /// - KV cache buffers: [num_layers, seq_len, hidden_dim] |
756 | | #[cfg(feature = "gpu")] |
757 | | pub struct GpuBufferPool { |
758 | | /// Pre-allocated hidden state buffers |
759 | | hidden_buffers: std::sync::Mutex<Vec<Vec<f32>>>, |
760 | | /// Pre-allocated intermediate buffers (FFN) |
761 | | intermediate_buffers: std::sync::Mutex<Vec<Vec<f32>>>, |
762 | | /// Pre-allocated attention score buffers |
763 | | attention_buffers: std::sync::Mutex<Vec<Vec<f32>>>, |
764 | | /// Buffer dimensions for validation |
765 | | hidden_dim: usize, |
766 | | intermediate_dim: usize, |
767 | | max_seq_len: usize, |
768 | | num_heads: usize, |
769 | | /// Pool size per buffer type |
770 | | pool_size: usize, |
771 | | /// Statistics: buffers borrowed |
772 | | pub borrows: std::sync::atomic::AtomicU64, |
773 | | /// Statistics: buffers returned |
774 | | pub returns: std::sync::atomic::AtomicU64, |
775 | | /// Statistics: allocations after warmup (should be 0) |
776 | | pub post_warmup_allocs: std::sync::atomic::AtomicU64, |
777 | | /// Whether warmup is complete |
778 | | warmed_up: std::sync::atomic::AtomicBool, |
779 | | } |
780 | | |
781 | | #[cfg(feature = "gpu")] |
782 | | impl GpuBufferPool { |
783 | | /// Create new buffer pool with specified dimensions |
784 | 5 | pub fn new( |
785 | 5 | hidden_dim: usize, |
786 | 5 | intermediate_dim: usize, |
787 | 5 | max_seq_len: usize, |
788 | 5 | num_heads: usize, |
789 | 5 | pool_size: usize, |
790 | 5 | ) -> Self { |
791 | 5 | Self { |
792 | 5 | hidden_buffers: std::sync::Mutex::new(Vec::with_capacity(pool_size)), |
793 | 5 | intermediate_buffers: std::sync::Mutex::new(Vec::with_capacity(pool_size)), |
794 | 5 | attention_buffers: std::sync::Mutex::new(Vec::with_capacity(pool_size)), |
795 | 5 | hidden_dim, |
796 | 5 | intermediate_dim, |
797 | 5 | max_seq_len, |
798 | 5 | num_heads, |
799 | 5 | pool_size, |
800 | 5 | borrows: std::sync::atomic::AtomicU64::new(0), |
801 | 5 | returns: std::sync::atomic::AtomicU64::new(0), |
802 | 5 | post_warmup_allocs: std::sync::atomic::AtomicU64::new(0), |
803 | 5 | warmed_up: std::sync::atomic::AtomicBool::new(false), |
804 | 5 | } |
805 | 5 | } |
806 | | |
807 | | /// Warmup: pre-allocate all buffers |
808 | | /// |
809 | | /// Call this once during model initialization to eliminate |
810 | | /// allocation overhead during inference. |
811 | 3 | pub fn warmup(&self) { |
812 | | // Pre-allocate hidden state buffers |
813 | | { |
814 | 3 | let mut buffers = self.hidden_buffers.lock().expect("mutex poisoned"); |
815 | 16 | for _ in 0..self.pool_size3 { |
816 | 16 | buffers.push(vec![0.0f32; self.hidden_dim]); |
817 | 16 | } |
818 | | } |
819 | | |
820 | | // Pre-allocate intermediate buffers (FFN) |
821 | | { |
822 | 3 | let mut buffers = self.intermediate_buffers.lock().expect("mutex poisoned"); |
823 | 16 | for _ in 0..self.pool_size3 { |
824 | 16 | buffers.push(vec![0.0f32; self.intermediate_dim]); |
825 | 16 | } |
826 | | } |
827 | | |
828 | | // Pre-allocate attention score buffers |
829 | | { |
830 | 3 | let mut buffers = self.attention_buffers.lock().expect("mutex poisoned"); |
831 | 16 | for _ in 0..self.pool_size3 { |
832 | 16 | buffers.push(vec![0.0f32; self.num_heads * self.max_seq_len]); |
833 | 16 | } |
834 | | } |
835 | | |
836 | 3 | self.warmed_up |
837 | 3 | .store(true, std::sync::atomic::Ordering::Release); |
838 | 3 | } |
839 | | |
840 | | /// Borrow a hidden state buffer from the pool |
841 | | /// |
842 | | /// Returns a pre-allocated buffer if available, or allocates new if needed. |
843 | 11 | pub fn borrow_hidden(&self) -> Vec<f32> { |
844 | 11 | self.borrows |
845 | 11 | .fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
846 | | |
847 | 11 | let mut buffers = self.hidden_buffers.lock().expect("mutex poisoned"); |
848 | 11 | if let Some(buffer) = buffers.pop() { |
849 | 11 | buffer |
850 | | } else { |
851 | | // Need to allocate - track if after warmup |
852 | 0 | if self.warmed_up.load(std::sync::atomic::Ordering::Acquire) { |
853 | 0 | self.post_warmup_allocs |
854 | 0 | .fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
855 | 0 | } |
856 | 0 | vec![0.0f32; self.hidden_dim] |
857 | | } |
858 | 11 | } |
859 | | |
860 | | /// Return a hidden state buffer to the pool |
861 | 11 | pub fn return_hidden(&self, mut buffer: Vec<f32>) { |
862 | 11 | self.returns |
863 | 11 | .fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
864 | | |
865 | | // Zero out for security and determinism |
866 | 11 | buffer.fill(0.0); |
867 | | |
868 | 11 | let mut buffers = self.hidden_buffers.lock().expect("mutex poisoned"); |
869 | 11 | if buffers.len() < self.pool_size { |
870 | 11 | buffers.push(buffer); |
871 | 11 | }0 |
872 | | // If pool is full, buffer is dropped |
873 | 11 | } |
874 | | |
875 | | /// Borrow an intermediate buffer from the pool |
876 | 10 | pub fn borrow_intermediate(&self) -> Vec<f32> { |
877 | 10 | self.borrows |
878 | 10 | .fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
879 | | |
880 | 10 | let mut buffers = self.intermediate_buffers.lock().expect("mutex poisoned"); |
881 | 10 | if let Some(buffer) = buffers.pop() { |
882 | 10 | buffer |
883 | | } else { |
884 | 0 | if self.warmed_up.load(std::sync::atomic::Ordering::Acquire) { |
885 | 0 | self.post_warmup_allocs |
886 | 0 | .fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
887 | 0 | } |
888 | 0 | vec![0.0f32; self.intermediate_dim] |
889 | | } |
890 | 10 | } |
891 | | |
892 | | /// Return an intermediate buffer to the pool |
893 | 10 | pub fn return_intermediate(&self, mut buffer: Vec<f32>) { |
894 | 10 | self.returns |
895 | 10 | .fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
896 | 10 | buffer.fill(0.0); |
897 | | |
898 | 10 | let mut buffers = self.intermediate_buffers.lock().expect("mutex poisoned"); |
899 | 10 | if buffers.len() < self.pool_size { |
900 | 10 | buffers.push(buffer); |
901 | 10 | }0 |
902 | 10 | } |
903 | | |
904 | | /// Borrow an attention score buffer from the pool |
905 | 10 | pub fn borrow_attention(&self) -> Vec<f32> { |
906 | 10 | self.borrows |
907 | 10 | .fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
908 | | |
909 | 10 | let mut buffers = self.attention_buffers.lock().expect("mutex poisoned"); |
910 | 10 | if let Some(buffer) = buffers.pop() { |
911 | 10 | buffer |
912 | | } else { |
913 | 0 | if self.warmed_up.load(std::sync::atomic::Ordering::Acquire) { |
914 | 0 | self.post_warmup_allocs |
915 | 0 | .fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
916 | 0 | } |
917 | 0 | vec![0.0f32; self.num_heads * self.max_seq_len] |
918 | | } |
919 | 10 | } |
920 | | |
921 | | /// Return an attention score buffer to the pool |
922 | 10 | pub fn return_attention(&self, mut buffer: Vec<f32>) { |
923 | 10 | self.returns |
924 | 10 | .fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
925 | 10 | buffer.fill(0.0); |
926 | | |
927 | 10 | let mut buffers = self.attention_buffers.lock().expect("mutex poisoned"); |
928 | 10 | if buffers.len() < self.pool_size { |
929 | 10 | buffers.push(buffer); |
930 | 10 | }0 |
931 | 10 | } |
932 | | |
933 | | /// Check if pool has achieved zero-allocation after warmup |
934 | 1 | pub fn is_zero_alloc(&self) -> bool { |
935 | 1 | self.warmed_up.load(std::sync::atomic::Ordering::Acquire) |
936 | 1 | && self |
937 | 1 | .post_warmup_allocs |
938 | 1 | .load(std::sync::atomic::Ordering::Relaxed) |
939 | 1 | == 0 |
940 | 1 | } |
941 | | |
942 | | /// Get pool statistics |
943 | 6 | pub fn stats(&self) -> GpuBufferPoolStats { |
944 | 6 | GpuBufferPoolStats { |
945 | 6 | borrows: self.borrows.load(std::sync::atomic::Ordering::Relaxed), |
946 | 6 | returns: self.returns.load(std::sync::atomic::Ordering::Relaxed), |
947 | 6 | post_warmup_allocs: self |
948 | 6 | .post_warmup_allocs |
949 | 6 | .load(std::sync::atomic::Ordering::Relaxed), |
950 | 6 | warmed_up: self.warmed_up.load(std::sync::atomic::Ordering::Acquire), |
951 | 6 | hidden_available: self.hidden_buffers.lock().expect("mutex poisoned").len(), |
952 | 6 | intermediate_available: self |
953 | 6 | .intermediate_buffers |
954 | 6 | .lock() |
955 | 6 | .expect("mutex poisoned") |
956 | 6 | .len(), |
957 | 6 | attention_available: self.attention_buffers.lock().expect("mutex poisoned").len(), |
958 | 6 | } |
959 | 6 | } |
960 | | |
961 | | /// Calculate total memory usage of the buffer pool |
962 | 1 | pub fn memory_usage_bytes(&self) -> usize { |
963 | 1 | let hidden_bytes = self.pool_size * self.hidden_dim * 4; |
964 | 1 | let intermediate_bytes = self.pool_size * self.intermediate_dim * 4; |
965 | 1 | let attention_bytes = self.pool_size * self.num_heads * self.max_seq_len * 4; |
966 | 1 | hidden_bytes + intermediate_bytes + attention_bytes |
967 | 1 | } |
968 | | } |
969 | | |
970 | | /// Statistics for GpuBufferPool |
971 | | #[cfg(feature = "gpu")] |
972 | | #[derive(Debug, Clone)] |
973 | | pub struct GpuBufferPoolStats { |
974 | | /// Total borrows |
975 | | pub borrows: u64, |
976 | | /// Total returns |
977 | | pub returns: u64, |
978 | | /// Allocations after warmup (should be 0) |
979 | | pub post_warmup_allocs: u64, |
980 | | /// Whether warmup is complete |
981 | | pub warmed_up: bool, |
982 | | /// Available hidden buffers |
983 | | pub hidden_available: usize, |
984 | | /// Available intermediate buffers |
985 | | pub intermediate_available: usize, |
986 | | /// Available attention buffers |
987 | | pub attention_available: usize, |
988 | | } |
989 | | |
990 | | /// Async Command Queue for GPU pipelining (PARITY-032, IMP-310) |
991 | | /// |
992 | | /// Implements double-buffering to hide GPU latency by overlapping |
993 | | /// computation and data transfer. While one batch is being processed |
994 | | /// on GPU, the next batch is being prepared on CPU. |
995 | | /// |
996 | | /// # Key Properties |
997 | | /// - Double-buffering: 2 command slots for overlap |
998 | | /// - Async submission: Non-blocking command enqueue |
999 | | /// - Pipeline stages: Prepare → Submit → Execute → Complete |
1000 | | /// |
1001 | | /// # GPU Utilization Target |
1002 | | /// - Without pipelining: ~50% (waiting for results) |
1003 | | /// - With pipelining: >85% (overlapped execution) |
1004 | | #[cfg(feature = "gpu")] |
1005 | | pub struct AsyncCommandQueue { |
1006 | | /// Command slots for double-buffering (2 slots) |
1007 | | slots: [std::sync::Mutex<CommandSlot>; 2], |
1008 | | /// Current slot index for submission |
1009 | | current_slot: std::sync::atomic::AtomicUsize, |
1010 | | /// Statistics: commands submitted |
1011 | | pub commands_submitted: std::sync::atomic::AtomicU64, |
1012 | | /// Statistics: commands completed |
1013 | | pub commands_completed: std::sync::atomic::AtomicU64, |
1014 | | /// Statistics: pipeline stalls (had to wait for previous) |
1015 | | pub pipeline_stalls: std::sync::atomic::AtomicU64, |
1016 | | } |
1017 | | |
1018 | | /// State of a command slot in the async queue |
1019 | | #[cfg(feature = "gpu")] |
1020 | | #[derive(Debug, Clone)] |
1021 | | pub enum CommandSlotState { |
1022 | | /// Slot is empty and ready for new command |
1023 | | Empty, |
1024 | | /// Command is being prepared (CPU side) |
1025 | | Preparing, |
1026 | | /// Command has been submitted to GPU |
1027 | | Submitted, |
1028 | | /// Command execution is complete |
1029 | | Complete, |
1030 | | } |
1031 | | |
1032 | | /// A command slot for async execution |
1033 | | #[cfg(feature = "gpu")] |
1034 | | pub struct CommandSlot { |
1035 | | /// Current state of this slot |
1036 | | state: CommandSlotState, |
1037 | | /// Input data for the command |
1038 | | input: Option<Vec<f32>>, |
1039 | | /// Output data from the command |
1040 | | output: Option<Vec<f32>>, |
1041 | | /// Timestamp when command was submitted |
1042 | | submit_time: Option<std::time::Instant>, |
1043 | | } |
1044 | | |
1045 | | #[cfg(feature = "gpu")] |
1046 | | impl Default for CommandSlot { |
1047 | 8 | fn default() -> Self { |
1048 | 8 | Self { |
1049 | 8 | state: CommandSlotState::Empty, |
1050 | 8 | input: None, |
1051 | 8 | output: None, |
1052 | 8 | submit_time: None, |
1053 | 8 | } |
1054 | 8 | } |
1055 | | } |
1056 | | |
1057 | | #[cfg(feature = "gpu")] |
1058 | | impl AsyncCommandQueue { |
1059 | | /// Create new async command queue with double-buffering |
1060 | 4 | pub fn new() -> Self { |
1061 | 4 | Self { |
1062 | 4 | slots: [ |
1063 | 4 | std::sync::Mutex::new(CommandSlot::default()), |
1064 | 4 | std::sync::Mutex::new(CommandSlot::default()), |
1065 | 4 | ], |
1066 | 4 | current_slot: std::sync::atomic::AtomicUsize::new(0), |
1067 | 4 | commands_submitted: std::sync::atomic::AtomicU64::new(0), |
1068 | 4 | commands_completed: std::sync::atomic::AtomicU64::new(0), |
1069 | 4 | pipeline_stalls: std::sync::atomic::AtomicU64::new(0), |
1070 | 4 | } |
1071 | 4 | } |
1072 | | |
1073 | | /// Submit a command for async execution |
1074 | | /// |
1075 | | /// Returns the slot index where the command was placed. |
1076 | | /// If both slots are busy, this will block until one is available |
1077 | | /// (counted as a pipeline stall). |
1078 | 24 | pub fn submit(&self, input: Vec<f32>) -> usize { |
1079 | 24 | let slot_idx = self |
1080 | 24 | .current_slot |
1081 | 24 | .fetch_add(1, std::sync::atomic::Ordering::SeqCst) |
1082 | 24 | % 2; |
1083 | | |
1084 | 24 | let mut slot = self.slots[slot_idx].lock().expect("mutex poisoned"); |
1085 | | |
1086 | | // Check if we need to wait for previous command |
1087 | 24 | if matches!( |
1088 | 24 | slot.state, |
1089 | | CommandSlotState::Submitted | CommandSlotState::Preparing |
1090 | 0 | ) { |
1091 | 0 | self.pipeline_stalls |
1092 | 0 | .fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
1093 | 0 | // In real implementation, would wait for GPU completion |
1094 | 0 | // For now, mark as complete to allow reuse |
1095 | 0 | slot.state = CommandSlotState::Complete; |
1096 | 24 | } |
1097 | | |
1098 | | // Prepare new command |
1099 | 24 | slot.state = CommandSlotState::Preparing; |
1100 | 24 | slot.input = Some(input); |
1101 | 24 | slot.output = None; |
1102 | 24 | slot.submit_time = Some(std::time::Instant::now()); |
1103 | | |
1104 | | // Mark as submitted |
1105 | 24 | slot.state = CommandSlotState::Submitted; |
1106 | 24 | self.commands_submitted |
1107 | 24 | .fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
1108 | | |
1109 | 24 | slot_idx |
1110 | 24 | } |
1111 | | |
1112 | | /// Mark a command as complete with output |
1113 | 22 | pub fn complete(&self, slot_idx: usize, output: Vec<f32>) { |
1114 | 22 | let mut slot = self.slots[slot_idx].lock().expect("mutex poisoned"); |
1115 | 22 | slot.state = CommandSlotState::Complete; |
1116 | 22 | slot.output = Some(output); |
1117 | 22 | self.commands_completed |
1118 | 22 | .fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
1119 | 22 | } |
1120 | | |
1121 | | /// Get output from a completed command |
1122 | | /// |
1123 | | /// Returns None if command is not complete yet. |
1124 | 21 | pub fn get_output(&self, slot_idx: usize) -> Option<Vec<f32>> { |
1125 | 21 | let mut slot = self.slots[slot_idx].lock().expect("mutex poisoned"); |
1126 | 21 | if matches!0 (slot.state, CommandSlotState::Complete) { |
1127 | 21 | slot.state = CommandSlotState::Empty; |
1128 | 21 | slot.output.take() |
1129 | | } else { |
1130 | 0 | None |
1131 | | } |
1132 | 21 | } |
1133 | | |
1134 | | /// Get queue statistics |
1135 | 5 | pub fn stats(&self) -> AsyncQueueStats { |
1136 | 5 | let submitted = self |
1137 | 5 | .commands_submitted |
1138 | 5 | .load(std::sync::atomic::Ordering::Relaxed); |
1139 | 5 | let completed = self |
1140 | 5 | .commands_completed |
1141 | 5 | .load(std::sync::atomic::Ordering::Relaxed); |
1142 | 5 | let stalls = self |
1143 | 5 | .pipeline_stalls |
1144 | 5 | .load(std::sync::atomic::Ordering::Relaxed); |
1145 | | |
1146 | | // GPU utilization estimate: (1 - stalls/submitted) * 100 |
1147 | 5 | let utilization = if submitted > 0 { |
1148 | 4 | (1.0 - stalls as f64 / submitted as f64) * 100.0 |
1149 | | } else { |
1150 | 1 | 0.0 |
1151 | | }; |
1152 | | |
1153 | 5 | AsyncQueueStats { |
1154 | 5 | commands_submitted: submitted, |
1155 | 5 | commands_completed: completed, |
1156 | 5 | pipeline_stalls: stalls, |
1157 | 5 | in_flight: submitted.saturating_sub(completed), |
1158 | 5 | gpu_utilization_percent: utilization, |
1159 | 5 | } |
1160 | 5 | } |
1161 | | |
1162 | | /// Calculate pipeline efficiency |
1163 | | /// |
1164 | | /// Efficiency = commands without stall / total commands |
1165 | 1 | pub fn pipeline_efficiency(&self) -> f64 { |
1166 | 1 | let submitted = self |
1167 | 1 | .commands_submitted |
1168 | 1 | .load(std::sync::atomic::Ordering::Relaxed); |
1169 | 1 | let stalls = self |
1170 | 1 | .pipeline_stalls |
1171 | 1 | .load(std::sync::atomic::Ordering::Relaxed); |
1172 | 1 | if submitted == 0 { |
1173 | 0 | return 1.0; |
1174 | 1 | } |
1175 | 1 | (submitted - stalls) as f64 / submitted as f64 |
1176 | 1 | } |
1177 | | } |
1178 | | |
1179 | | #[cfg(feature = "gpu")] |
1180 | | impl Default for AsyncCommandQueue { |
1181 | 0 | fn default() -> Self { |
1182 | 0 | Self::new() |
1183 | 0 | } |
1184 | | } |
1185 | | |
1186 | | /// Statistics for AsyncCommandQueue |
1187 | | #[cfg(feature = "gpu")] |
1188 | | #[derive(Debug, Clone)] |
1189 | | pub struct AsyncQueueStats { |
1190 | | /// Total commands submitted |
1191 | | pub commands_submitted: u64, |
1192 | | /// Total commands completed |
1193 | | pub commands_completed: u64, |
1194 | | /// Pipeline stalls (had to wait) |
1195 | | pub pipeline_stalls: u64, |
1196 | | /// Commands currently in flight |
1197 | | pub in_flight: u64, |
1198 | | /// Estimated GPU utilization percentage |
1199 | | pub gpu_utilization_percent: f64, |
1200 | | } |
1201 | | |
1202 | | /// Prefix Cache for common prompts (PARITY-033, IMP-319) |
1203 | | /// |
1204 | | /// Caches the KV cache state for common prompt prefixes, enabling |
1205 | | /// instant response (0ms TTFT) for repeated prompts. |
1206 | | /// |
1207 | | /// # Key Properties |
1208 | | /// - Hash-based prefix lookup (FNV-1a) |
1209 | | /// - LRU eviction for memory management |
1210 | | /// - Thread-safe access |
1211 | | /// |
1212 | | /// # Use Cases |
1213 | | /// - System prompts (cached once, reused for all requests) |
1214 | | /// - Common few-shot examples |
1215 | | /// - Chat history prefixes |
1216 | | #[cfg(feature = "gpu")] |
1217 | | pub struct PrefixCache { |
1218 | | /// Cached prefix entries (hash → entry) |
1219 | | entries: std::sync::Mutex<std::collections::HashMap<u64, PrefixCacheEntry>>, |
1220 | | /// Maximum number of cached prefixes |
1221 | | max_entries: usize, |
1222 | | /// Statistics: cache hits |
1223 | | pub hits: std::sync::atomic::AtomicU64, |
1224 | | /// Statistics: cache misses |
1225 | | pub misses: std::sync::atomic::AtomicU64, |
1226 | | /// Statistics: evictions |
1227 | | pub evictions: std::sync::atomic::AtomicU64, |
1228 | | } |
1229 | | |
1230 | | /// A cached prefix entry |
1231 | | #[cfg(feature = "gpu")] |
1232 | | pub struct PrefixCacheEntry { |
1233 | | /// The original prompt tokens |
1234 | | pub tokens: Vec<u32>, |
1235 | | /// Cached K state for each layer [num_layers, seq_len, hidden_dim] |
1236 | | pub k_cache: Vec<Vec<f32>>, |
1237 | | /// Cached V state for each layer [num_layers, seq_len, hidden_dim] |
1238 | | pub v_cache: Vec<Vec<f32>>, |
1239 | | /// Timestamp for LRU eviction |
1240 | | pub last_access: std::time::Instant, |
1241 | | /// Number of times this prefix was hit |
1242 | | pub hit_count: u64, |
1243 | | } |
1244 | | |
1245 | | #[cfg(feature = "gpu")] |
1246 | | impl PrefixCache { |
1247 | | /// Create new prefix cache with specified capacity |
1248 | 4 | pub fn new(max_entries: usize) -> Self { |
1249 | 4 | Self { |
1250 | 4 | entries: std::sync::Mutex::new(std::collections::HashMap::with_capacity(max_entries)), |
1251 | 4 | max_entries, |
1252 | 4 | hits: std::sync::atomic::AtomicU64::new(0), |
1253 | 4 | misses: std::sync::atomic::AtomicU64::new(0), |
1254 | 4 | evictions: std::sync::atomic::AtomicU64::new(0), |
1255 | 4 | } |
1256 | 4 | } |
1257 | | |
1258 | | /// Hash tokens to create cache key (FNV-1a) |
1259 | 11 | fn hash_tokens(tokens: &[u32]) -> u64 { |
1260 | | const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; |
1261 | | const FNV_PRIME: u64 = 0x0100_0000_01b3; |
1262 | | |
1263 | 11 | let mut hash = FNV_OFFSET; |
1264 | 287 | for &token276 in tokens { |
1265 | 276 | hash ^= token as u64; |
1266 | 276 | hash = hash.wrapping_mul(FNV_PRIME); |
1267 | 276 | } |
1268 | 11 | hash |
1269 | 11 | } |
1270 | | |
1271 | | /// Look up a prefix in the cache |
1272 | | /// |
1273 | | /// Returns the cached KV state if found, None otherwise. |
1274 | | #[allow(clippy::type_complexity)] |
1275 | 5 | pub fn lookup(&self, tokens: &[u32]) -> Option<(Vec<Vec<f32>>, Vec<Vec<f32>>)> { |
1276 | 5 | let hash = Self::hash_tokens(tokens); |
1277 | | |
1278 | 5 | let mut entries = self.entries.lock().expect("mutex poisoned"); |
1279 | 5 | if let Some(entry3 ) = entries.get_mut(&hash) { |
1280 | | // Verify tokens match (hash collision check) |
1281 | 3 | if entry.tokens == tokens { |
1282 | 3 | self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
1283 | 3 | entry.last_access = std::time::Instant::now(); |
1284 | 3 | entry.hit_count += 1; |
1285 | 3 | return Some((entry.k_cache.clone(), entry.v_cache.clone())); |
1286 | 0 | } |
1287 | 2 | } |
1288 | | |
1289 | 2 | self.misses |
1290 | 2 | .fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
1291 | 2 | None |
1292 | 5 | } |
1293 | | |
1294 | | /// Insert a new prefix into the cache |
1295 | | /// |
1296 | | /// Evicts LRU entry if cache is full. |
1297 | 6 | pub fn insert(&self, tokens: Vec<u32>, k_cache: Vec<Vec<f32>>, v_cache: Vec<Vec<f32>>) { |
1298 | 6 | let hash = Self::hash_tokens(&tokens); |
1299 | | |
1300 | 6 | let mut entries = self.entries.lock().expect("mutex poisoned"); |
1301 | | |
1302 | | // Evict LRU if at capacity |
1303 | 6 | if entries.len() >= self.max_entries { |
1304 | | // Find oldest entry |
1305 | 1 | if let Some((&oldest_hash, _)) = entries.iter().min_by_key(|(_, e)| e.last_access) { |
1306 | 1 | entries.remove(&oldest_hash); |
1307 | 1 | self.evictions |
1308 | 1 | .fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
1309 | 1 | }0 |
1310 | 5 | } |
1311 | | |
1312 | 6 | entries.insert( |
1313 | 6 | hash, |
1314 | 6 | PrefixCacheEntry { |
1315 | 6 | tokens, |
1316 | 6 | k_cache, |
1317 | 6 | v_cache, |
1318 | 6 | last_access: std::time::Instant::now(), |
1319 | 6 | hit_count: 0, |
1320 | 6 | }, |
1321 | 6 | ); |
1322 | 6 | } |
1323 | | |
1324 | | /// Check if a prefix is cached |
1325 | 0 | pub fn contains(&self, tokens: &[u32]) -> bool { |
1326 | 0 | let hash = Self::hash_tokens(tokens); |
1327 | 0 | let entries = self.entries.lock().expect("mutex poisoned"); |
1328 | 0 | entries.contains_key(&hash) |
1329 | 0 | } |
1330 | | |
1331 | | /// Get cache statistics |
1332 | 6 | pub fn stats(&self) -> PrefixCacheStats { |
1333 | 6 | let hits = self.hits.load(std::sync::atomic::Ordering::Relaxed); |
1334 | 6 | let misses = self.misses.load(std::sync::atomic::Ordering::Relaxed); |
1335 | 6 | let total = hits + misses; |
1336 | | |
1337 | | PrefixCacheStats { |
1338 | 6 | hits, |
1339 | 6 | misses, |
1340 | 6 | evictions: self.evictions.load(std::sync::atomic::Ordering::Relaxed), |
1341 | 6 | entries: self.entries.lock().expect("mutex poisoned").len(), |
1342 | 6 | hit_rate: if total > 0 { |
1343 | 3 | hits as f64 / total as f64 |
1344 | | } else { |
1345 | 3 | 0.0 |
1346 | | }, |
1347 | | } |
1348 | 6 | } |
1349 | | |
1350 | | /// Clear all cached entries |
1351 | 0 | pub fn clear(&self) { |
1352 | 0 | let mut entries = self.entries.lock().expect("mutex poisoned"); |
1353 | 0 | entries.clear(); |
1354 | 0 | } |
1355 | | |
1356 | | /// Estimate memory usage of cached prefixes |
1357 | 1 | pub fn memory_usage_bytes(&self) -> usize { |
1358 | 1 | let entries = self.entries.lock().expect("mutex poisoned"); |
1359 | 1 | entries |
1360 | 1 | .values() |
1361 | 1 | .map(|e| { |
1362 | 32 | let k_bytes1 : usize1 = e.k_cache.iter()1 .map1 (|v| v.len() * 4).sum1 (); |
1363 | 32 | let v_bytes1 : usize1 = e.v_cache.iter()1 .map1 (|v| v.len() * 4).sum1 (); |
1364 | 1 | let token_bytes = e.tokens.len() * 4; |
1365 | 1 | k_bytes + v_bytes + token_bytes |
1366 | 1 | }) |
1367 | 1 | .sum() |
1368 | 1 | } |
1369 | | } |
1370 | | |
1371 | | #[cfg(feature = "gpu")] |
1372 | | impl Default for PrefixCache { |
1373 | 0 | fn default() -> Self { |
1374 | 0 | Self::new(16) // Default: cache 16 prefixes |
1375 | 0 | } |
1376 | | } |
1377 | | |
1378 | | /// Statistics for PrefixCache |
1379 | | #[cfg(feature = "gpu")] |
1380 | | #[derive(Debug, Clone)] |
1381 | | pub struct PrefixCacheStats { |
1382 | | /// Cache hits |
1383 | | pub hits: u64, |
1384 | | /// Cache misses |
1385 | | pub misses: u64, |
1386 | | /// Evictions due to capacity |
1387 | | pub evictions: u64, |
1388 | | /// Current number of cached entries |
1389 | | pub entries: usize, |
1390 | | /// Hit rate (0.0 - 1.0) |
1391 | | pub hit_rate: f64, |
1392 | | } |
1393 | | |
1394 | | // ============================================================================= |
1395 | | // PARITY-034: Multi-Request Scheduler with Scheduling Policies (IMP-317) |
1396 | | // ============================================================================= |
1397 | | // |
1398 | | // Extends PARITY-028's ContinuousBatchScheduler with: |
1399 | | // - Multiple scheduling policies (FCFS, SJF, Round-Robin) |
1400 | | // - Request queuing with priorities |
1401 | | // - TTFT (Time to First Token) tracking |
1402 | | // - Throughput scaling verification |
1403 | | // |
1404 | | // Architecture: |
1405 | | // - Incoming requests are queued with their KV cache states |
1406 | | // - Scheduler batches decode steps from multiple requests |
1407 | | // - GPU GEMM efficiency: batch_size > 1 enables GPU acceleration |
1408 | | // - Preemption: Long-running requests can be paused for new arrivals |
1409 | | // ============================================================================= |
1410 | | |
1411 | | /// Request state in the multi-request scheduler |
1412 | | #[cfg(feature = "gpu")] |
1413 | | #[derive(Debug, Clone, PartialEq, Eq)] |
1414 | | pub enum MultiRequestState { |
1415 | | /// Waiting for prefill |
1416 | | Pending, |
1417 | | /// Prefill in progress |
1418 | | Prefilling, |
1419 | | /// Decode in progress |
1420 | | Decoding, |
1421 | | /// Request completed |
1422 | | Completed, |
1423 | | /// Request preempted (paused) |
1424 | | Preempted, |
1425 | | } |
1426 | | |
1427 | | /// A single inference request in the multi-request scheduler |
1428 | | #[cfg(feature = "gpu")] |
1429 | | #[derive(Clone)] |
1430 | | pub struct MultiSchedulerRequest { |
1431 | | /// Unique request ID |
1432 | | pub id: u64, |
1433 | | /// Input tokens |
1434 | | pub tokens: Vec<u32>, |
1435 | | /// Generated tokens so far |
1436 | | pub generated: Vec<u32>, |
1437 | | /// Maximum tokens to generate |
1438 | | pub max_tokens: usize, |
1439 | | /// Current state |
1440 | | pub state: MultiRequestState, |
1441 | | /// KV cache position (how many tokens processed) |
1442 | | pub kv_position: usize, |
1443 | | /// Arrival time for FCFS scheduling |
1444 | | pub arrival_time: std::time::Instant, |
1445 | | /// Time first token generated (for TTFT metric) |
1446 | | pub first_token_time: Option<std::time::Instant>, |
1447 | | } |
1448 | | |
1449 | | #[cfg(feature = "gpu")] |
1450 | | impl MultiSchedulerRequest { |
1451 | | /// Create new request |
1452 | 22 | pub fn new(id: u64, tokens: Vec<u32>, max_tokens: usize) -> Self { |
1453 | 22 | Self { |
1454 | 22 | id, |
1455 | 22 | tokens, |
1456 | 22 | generated: Vec::with_capacity(max_tokens), |
1457 | 22 | max_tokens, |
1458 | 22 | state: MultiRequestState::Pending, |
1459 | 22 | kv_position: 0, |
1460 | 22 | arrival_time: std::time::Instant::now(), |
1461 | 22 | first_token_time: None, |
1462 | 22 | } |
1463 | 22 | } |
1464 | | |
1465 | | /// Check if request is complete |
1466 | 503 | pub fn is_complete(&self) -> bool { |
1467 | 503 | self.state == MultiRequestState::Completed || self.generated.len() >= self.max_tokens |
1468 | 503 | } |
1469 | | |
1470 | | /// Time to first token (None if not yet generated) |
1471 | 0 | pub fn ttft_ms(&self) -> Option<f64> { |
1472 | 0 | self.first_token_time |
1473 | 0 | .map(|t| t.duration_since(self.arrival_time).as_secs_f64() * 1000.0) |
1474 | 0 | } |
1475 | | } |
1476 | | |
1477 | | /// Scheduling policy for the batch scheduler |
1478 | | #[cfg(feature = "gpu")] |
1479 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
1480 | | pub enum SchedulingPolicy { |
1481 | | /// First-come, first-served |
1482 | | Fcfs, |
1483 | | /// Shortest job first (by remaining tokens) |
1484 | | Sjf, |
1485 | | /// Round-robin with time slices |
1486 | | RoundRobin, |
1487 | | } |
1488 | | |
1489 | | /// Multi-request scheduler with scheduling policies (PARITY-034) |
1490 | | #[cfg(feature = "gpu")] |
1491 | | pub struct MultiRequestScheduler { |
1492 | | /// Pending requests queue |
1493 | | pending: std::sync::Mutex<std::collections::VecDeque<MultiSchedulerRequest>>, |
1494 | | /// Active requests being processed |
1495 | | active: std::sync::Mutex<Vec<MultiSchedulerRequest>>, |
1496 | | /// Completed requests |
1497 | | completed: std::sync::Mutex<Vec<MultiSchedulerRequest>>, |
1498 | | /// Maximum batch size |
1499 | | max_batch_size: usize, |
1500 | | /// Maximum concurrent requests |
1501 | | max_concurrent: usize, |
1502 | | /// Scheduling policy |
1503 | | policy: SchedulingPolicy, |
1504 | | /// Request ID counter |
1505 | | next_id: std::sync::atomic::AtomicU64, |
1506 | | /// Requests submitted |
1507 | | pub requests_submitted: std::sync::atomic::AtomicU64, |
1508 | | /// Requests completed |
1509 | | pub requests_completed: std::sync::atomic::AtomicU64, |
1510 | | /// Total tokens generated |
1511 | | pub tokens_generated: std::sync::atomic::AtomicU64, |
1512 | | /// Batch iterations performed |
1513 | | pub batch_iterations: std::sync::atomic::AtomicU64, |
1514 | | } |
1515 | | |
1516 | | #[cfg(feature = "gpu")] |
1517 | | impl MultiRequestScheduler { |
1518 | | /// Create new scheduler with given parameters |
1519 | 7 | pub fn new(max_batch_size: usize, max_concurrent: usize, policy: SchedulingPolicy) -> Self { |
1520 | 7 | Self { |
1521 | 7 | pending: std::sync::Mutex::new(std::collections::VecDeque::new()), |
1522 | 7 | active: std::sync::Mutex::new(Vec::with_capacity(max_concurrent)), |
1523 | 7 | completed: std::sync::Mutex::new(Vec::new()), |
1524 | 7 | max_batch_size, |
1525 | 7 | max_concurrent, |
1526 | 7 | policy, |
1527 | 7 | next_id: std::sync::atomic::AtomicU64::new(0), |
1528 | 7 | requests_submitted: std::sync::atomic::AtomicU64::new(0), |
1529 | 7 | requests_completed: std::sync::atomic::AtomicU64::new(0), |
1530 | 7 | tokens_generated: std::sync::atomic::AtomicU64::new(0), |
1531 | 7 | batch_iterations: std::sync::atomic::AtomicU64::new(0), |
1532 | 7 | } |
1533 | 7 | } |
1534 | | |
1535 | | /// Submit a new request |
1536 | 22 | pub fn submit(&self, tokens: Vec<u32>, max_tokens: usize) -> u64 { |
1537 | 22 | let id = self |
1538 | 22 | .next_id |
1539 | 22 | .fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
1540 | 22 | let request = MultiSchedulerRequest::new(id, tokens, max_tokens); |
1541 | | |
1542 | 22 | let mut pending = self.pending.lock().expect("mutex poisoned"); |
1543 | 22 | pending.push_back(request); |
1544 | 22 | self.requests_submitted |
1545 | 22 | .fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
1546 | | |
1547 | 22 | id |
1548 | 22 | } |
1549 | | |
1550 | | /// Get batch of requests ready for decode step |
1551 | | /// |
1552 | | /// Returns request IDs and their current positions |
1553 | 107 | pub fn get_decode_batch(&self) -> Vec<(u64, usize)> { |
1554 | 107 | let mut active = self.active.lock().expect("mutex poisoned"); |
1555 | 107 | let mut pending = self.pending.lock().expect("mutex poisoned"); |
1556 | | |
1557 | | // Promote pending requests to active (up to max_concurrent) |
1558 | 129 | while active.len() < self.max_concurrent && !pending.is_empty() { |
1559 | 22 | if let Some(mut req) = pending.pop_front() { |
1560 | 22 | req.state = MultiRequestState::Decoding; |
1561 | 22 | active.push(req); |
1562 | 22 | }0 |
1563 | | } |
1564 | | |
1565 | | // Sort by policy |
1566 | 107 | match self.policy { |
1567 | 103 | SchedulingPolicy::Fcfs => { |
1568 | 103 | // Already in arrival order |
1569 | 103 | }, |
1570 | | SchedulingPolicy::Sjf => { |
1571 | 10 | active2 .sort_by_key2 (|r| r.max_tokens - r.generated.len()); |
1572 | | }, |
1573 | | SchedulingPolicy::RoundRobin => { |
1574 | | // Rotate - move first to end |
1575 | 2 | if active.len() > 1 { |
1576 | 2 | let first = active.remove(0); |
1577 | 2 | active.push(first); |
1578 | 2 | }0 |
1579 | | }, |
1580 | | } |
1581 | | |
1582 | | // Return batch of decoding requests |
1583 | 107 | active |
1584 | 107 | .iter() |
1585 | 514 | .filter107 (|r| r.state == MultiRequestState::Decoding) |
1586 | 107 | .take(self.max_batch_size) |
1587 | 514 | .map107 (|r| (r.id, r.kv_position)) |
1588 | 107 | .collect() |
1589 | 107 | } |
1590 | | |
1591 | | /// Record generated token for a request |
1592 | 503 | pub fn record_token(&self, request_id: u64, token: u32) { |
1593 | 503 | let mut active = self.active.lock().expect("mutex poisoned"); |
1594 | | |
1595 | 1.95k | if let Some(req503 ) = active.iter_mut()503 .find503 (|r| r.id == request_id) { |
1596 | | // Record TTFT for first token |
1597 | 503 | if req.first_token_time.is_none() { |
1598 | 11 | req.first_token_time = Some(std::time::Instant::now()); |
1599 | 492 | } |
1600 | | |
1601 | 503 | req.generated.push(token); |
1602 | 503 | req.kv_position += 1; |
1603 | 503 | self.tokens_generated |
1604 | 503 | .fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
1605 | | |
1606 | | // Check if complete |
1607 | 503 | if req.is_complete() { |
1608 | 11 | req.state = MultiRequestState::Completed; |
1609 | 492 | } |
1610 | 0 | } |
1611 | 503 | } |
1612 | | |
1613 | | /// Move completed requests from active to completed |
1614 | 101 | pub fn collect_completed(&self) -> Vec<MultiSchedulerRequest> { |
1615 | 101 | let mut active = self.active.lock().expect("mutex poisoned"); |
1616 | 101 | let mut completed = self.completed.lock().expect("mutex poisoned"); |
1617 | | |
1618 | 101 | let (done, still_active): (Vec<_>, Vec<_>) = active |
1619 | 101 | .drain(..) |
1620 | 601 | .partition101 (|r| r.state == MultiRequestState::Completed); |
1621 | | |
1622 | 101 | *active = still_active; |
1623 | | |
1624 | 112 | for _req11 in &done { |
1625 | 11 | self.requests_completed |
1626 | 11 | .fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
1627 | 11 | } |
1628 | | |
1629 | 101 | completed.extend(done.iter().cloned()); |
1630 | 101 | done |
1631 | 101 | } |
1632 | | |
1633 | | /// Run one batch iteration (for simulation) |
1634 | 103 | pub fn step(&self) { |
1635 | 103 | self.batch_iterations |
1636 | 103 | .fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
1637 | 103 | } |
1638 | | |
1639 | | /// Get scheduler statistics |
1640 | 107 | pub fn stats(&self) -> MultiRequestStats { |
1641 | 107 | let submitted = self |
1642 | 107 | .requests_submitted |
1643 | 107 | .load(std::sync::atomic::Ordering::Relaxed); |
1644 | 107 | let completed = self |
1645 | 107 | .requests_completed |
1646 | 107 | .load(std::sync::atomic::Ordering::Relaxed); |
1647 | 107 | let tokens = self |
1648 | 107 | .tokens_generated |
1649 | 107 | .load(std::sync::atomic::Ordering::Relaxed); |
1650 | 107 | let iterations = self |
1651 | 107 | .batch_iterations |
1652 | 107 | .load(std::sync::atomic::Ordering::Relaxed); |
1653 | | |
1654 | 107 | let pending = self.pending.lock().expect("mutex poisoned").len(); |
1655 | 107 | let active = self.active.lock().expect("mutex poisoned").len(); |
1656 | | |
1657 | | MultiRequestStats { |
1658 | 107 | requests_submitted: submitted, |
1659 | 107 | requests_completed: completed, |
1660 | 107 | tokens_generated: tokens, |
1661 | 107 | batch_iterations: iterations, |
1662 | 107 | pending_requests: pending, |
1663 | 107 | active_requests: active, |
1664 | 107 | avg_batch_size: if iterations > 0 { |
1665 | 103 | tokens as f64 / iterations as f64 |
1666 | | } else { |
1667 | 4 | 0.0 |
1668 | | }, |
1669 | | } |
1670 | 107 | } |
1671 | | } |
1672 | | |
1673 | | /// Statistics for multi-request scheduler (PARITY-034) |
1674 | | #[cfg(feature = "gpu")] |
1675 | | pub struct MultiRequestStats { |
1676 | | /// Total requests submitted |
1677 | | pub requests_submitted: u64, |
1678 | | /// Total requests completed |
1679 | | pub requests_completed: u64, |
1680 | | /// Total tokens generated |
1681 | | pub tokens_generated: u64, |
1682 | | /// Batch iterations performed |
1683 | | pub batch_iterations: u64, |
1684 | | /// Current pending requests |
1685 | | pub pending_requests: usize, |
1686 | | /// Current active requests |
1687 | | pub active_requests: usize, |
1688 | | /// Average batch size |
1689 | | pub avg_batch_size: f64, |
1690 | | } |
1691 | | |
1692 | | // ============================================================================= |
1693 | | // PARITY-035: Chunked Prefill for Long Contexts (IMP-320) |
1694 | | // ============================================================================= |
1695 | | // |
1696 | | // Enables streaming prompt processing by breaking long prefills into chunks. |
1697 | | // Key optimization for TTFT (Time to First Token) with long contexts. |
1698 | | // |
1699 | | // Architecture: |
1700 | | // - Prompt is split into chunks (default 512 tokens) |
1701 | | // - Each chunk processes incrementally, updating KV cache |
1702 | | // - First token can be generated after first chunk completes |
1703 | | // - Total prefill time is spread across chunks |
1704 | | // ============================================================================= |
1705 | | |
1706 | | /// Configuration for chunked prefill |
1707 | | #[cfg(feature = "gpu")] |
1708 | | #[derive(Debug, Clone)] |
1709 | | pub struct ChunkedPrefillConfig { |
1710 | | /// Chunk size in tokens (default: 512) |
1711 | | pub chunk_size: usize, |
1712 | | /// Maximum context length (default: 8192) |
1713 | | pub max_context: usize, |
1714 | | /// Whether to yield after each chunk for streaming |
1715 | | pub stream_chunks: bool, |
1716 | | } |
1717 | | |
1718 | | #[cfg(feature = "gpu")] |
1719 | | impl Default for ChunkedPrefillConfig { |
1720 | 5 | fn default() -> Self { |
1721 | 5 | Self { |
1722 | 5 | chunk_size: 512, |
1723 | 5 | max_context: 8192, |
1724 | 5 | stream_chunks: true, |
1725 | 5 | } |
1726 | 5 | } |
1727 | | } |
1728 | | |
1729 | | #[cfg(feature = "gpu")] |
1730 | | impl ChunkedPrefillConfig { |
1731 | | /// Create config with custom chunk size |
1732 | 5 | pub fn with_chunk_size(chunk_size: usize) -> Self { |
1733 | 5 | Self { |
1734 | 5 | chunk_size, |
1735 | 5 | ..Default::default() |
1736 | 5 | } |
1737 | 5 | } |
1738 | | } |
1739 | | |
1740 | | /// Progress report for a single chunk |
1741 | | #[cfg(feature = "gpu")] |
1742 | | #[derive(Debug, Clone)] |
1743 | | pub struct ChunkProgress { |
1744 | | /// Chunk index (0-based) |
1745 | | pub chunk_idx: usize, |
1746 | | /// Total chunks |
1747 | | pub total_chunks: usize, |
1748 | | /// Tokens processed so far |
1749 | | pub tokens_processed: usize, |
1750 | | /// Total tokens to process |
1751 | | pub total_tokens: usize, |
1752 | | /// Time for this chunk (ms) |
1753 | | pub chunk_time_ms: f64, |
1754 | | /// Cumulative time so far (ms) |
1755 | | pub cumulative_time_ms: f64, |
1756 | | } |
1757 | | |
1758 | | /// Chunked prefill processor for long context handling |
1759 | | #[cfg(feature = "gpu")] |
1760 | | pub struct ChunkedPrefill { |
1761 | | /// Configuration |
1762 | | config: ChunkedPrefillConfig, |
1763 | | /// Chunks created from prompt |
1764 | | chunks: Vec<Vec<u32>>, |
1765 | | /// Current chunk being processed |
1766 | | current_chunk: usize, |
1767 | | /// Tokens processed so far |
1768 | | tokens_processed: usize, |
1769 | | /// Start time for timing |
1770 | | start_time: Option<std::time::Instant>, |
1771 | | /// Timing for each chunk |
1772 | | chunk_times_ms: Vec<f64>, |
1773 | | } |
1774 | | |
1775 | | #[cfg(feature = "gpu")] |
1776 | | impl ChunkedPrefill { |
1777 | | /// Create new chunked prefill from prompt tokens |
1778 | 5 | pub fn new(prompt_tokens: &[u32], config: ChunkedPrefillConfig) -> Self { |
1779 | 5 | let chunks: Vec<Vec<u32>> = prompt_tokens |
1780 | 5 | .chunks(config.chunk_size) |
1781 | 5 | .map(<[u32]>::to_vec) |
1782 | 5 | .collect(); |
1783 | | |
1784 | 5 | Self { |
1785 | 5 | config, |
1786 | 5 | chunks, |
1787 | 5 | current_chunk: 0, |
1788 | 5 | tokens_processed: 0, |
1789 | 5 | start_time: None, |
1790 | 5 | chunk_times_ms: Vec::new(), |
1791 | 5 | } |
1792 | 5 | } |
1793 | | |
1794 | | /// Get total number of chunks |
1795 | 3 | pub fn total_chunks(&self) -> usize { |
1796 | 3 | self.chunks.len() |
1797 | 3 | } |
1798 | | |
1799 | | /// Get total tokens |
1800 | 4 | pub fn total_tokens(&self) -> usize { |
1801 | 4 | self.chunks.iter().map(Vec::len).sum() |
1802 | 4 | } |
1803 | | |
1804 | | /// Check if there are more chunks to process |
1805 | 2 | pub fn has_more_chunks(&self) -> bool { |
1806 | 2 | self.current_chunk < self.chunks.len() |
1807 | 2 | } |
1808 | | |
1809 | | /// Get the next chunk to process |
1810 | | /// |
1811 | | /// Returns None if all chunks are processed |
1812 | 35 | pub fn next_chunk(&mut self) -> Option<&[u32]> { |
1813 | 35 | if self.start_time.is_none() { |
1814 | 4 | self.start_time = Some(std::time::Instant::now()); |
1815 | 31 | } |
1816 | | |
1817 | 35 | if self.current_chunk < self.chunks.len() { |
1818 | 31 | let chunk = &self.chunks[self.current_chunk]; |
1819 | 31 | Some(chunk.as_slice()) |
1820 | | } else { |
1821 | 4 | None |
1822 | | } |
1823 | 35 | } |
1824 | | |
1825 | | /// Mark current chunk as complete |
1826 | 31 | pub fn complete_chunk(&mut self, chunk_time_ms: f64) { |
1827 | 31 | if self.current_chunk < self.chunks.len() { |
1828 | 31 | self.tokens_processed += self.chunks[self.current_chunk].len(); |
1829 | 31 | self.chunk_times_ms.push(chunk_time_ms); |
1830 | 31 | self.current_chunk += 1; |
1831 | 31 | }0 |
1832 | 31 | } |
1833 | | |
1834 | | /// Get progress after completing a chunk |
1835 | 2 | pub fn progress(&self) -> ChunkProgress { |
1836 | 2 | let cumulative_time_ms: f64 = self.chunk_times_ms.iter().sum(); |
1837 | | |
1838 | 2 | ChunkProgress { |
1839 | 2 | chunk_idx: self.current_chunk.saturating_sub(1), |
1840 | 2 | total_chunks: self.chunks.len(), |
1841 | 2 | tokens_processed: self.tokens_processed, |
1842 | 2 | total_tokens: self.total_tokens(), |
1843 | 2 | chunk_time_ms: self.chunk_times_ms.last().copied().unwrap_or(0.0), |
1844 | 2 | cumulative_time_ms, |
1845 | 2 | } |
1846 | 2 | } |
1847 | | |
1848 | | /// Get estimated time to first token (after first chunk) |
1849 | 1 | pub fn estimated_ttft_ms(&self) -> f64 { |
1850 | 1 | if let Some(first_chunk_time) = self.chunk_times_ms.first() { |
1851 | 1 | *first_chunk_time |
1852 | | } else { |
1853 | | // Estimate based on chunk size and typical throughput |
1854 | 0 | let tokens = self.chunks.first().map_or(0, Vec::len); |
1855 | | // Conservative estimate: 0.5ms per token for prefill |
1856 | 0 | tokens as f64 * 0.5 |
1857 | | } |
1858 | 1 | } |
1859 | | |
1860 | | /// Get statistics after completion |
1861 | 1 | pub fn stats(&self) -> ChunkedPrefillStats { |
1862 | 1 | let total_time_ms: f64 = self.chunk_times_ms.iter().sum(); |
1863 | 1 | let total_tokens = self.total_tokens(); |
1864 | 1 | let avg_chunk_time_ms = if !self.chunk_times_ms.is_empty() { |
1865 | 1 | total_time_ms / self.chunk_times_ms.len() as f64 |
1866 | | } else { |
1867 | 0 | 0.0 |
1868 | | }; |
1869 | | |
1870 | | ChunkedPrefillStats { |
1871 | 1 | total_chunks: self.chunks.len(), |
1872 | 1 | chunk_size: self.config.chunk_size, |
1873 | 1 | total_tokens, |
1874 | 1 | total_time_ms, |
1875 | 1 | avg_chunk_time_ms, |
1876 | 1 | ttft_ms: self.estimated_ttft_ms(), |
1877 | 1 | tokens_per_second: if total_time_ms > 0.0 { |
1878 | 1 | total_tokens as f64 / (total_time_ms / 1000.0) |
1879 | | } else { |
1880 | 0 | 0.0 |
1881 | | }, |
1882 | | } |
1883 | 1 | } |
1884 | | } |
1885 | | |
1886 | | /// Statistics for chunked prefill |
1887 | | #[cfg(feature = "gpu")] |
1888 | | #[derive(Debug, Clone)] |
1889 | | pub struct ChunkedPrefillStats { |
1890 | | /// Total chunks processed |
1891 | | pub total_chunks: usize, |
1892 | | /// Chunk size used |
1893 | | pub chunk_size: usize, |
1894 | | /// Total tokens processed |
1895 | | pub total_tokens: usize, |
1896 | | /// Total time (ms) |
1897 | | pub total_time_ms: f64, |
1898 | | /// Average time per chunk (ms) |
1899 | | pub avg_chunk_time_ms: f64, |
1900 | | /// Time to first token (ms) |
1901 | | pub ttft_ms: f64, |
1902 | | /// Prefill throughput (tokens/sec) |
1903 | | pub tokens_per_second: f64, |
1904 | | } |