/home/noah/src/realizar/src/scheduler/mod.rs
Line | Count | Source |
1 | | //! Continuous Batching Scheduler |
2 | | //! |
3 | | //! Per spec ยง8: Implements continuous batching for LLM serving based on vLLM. |
4 | | //! Reference: [8] Yu et al. (2022) "Orca: A Distributed Serving System for Transformer-Based Generative Models" |
5 | | //! |
6 | | //! ## Key Features |
7 | | //! |
8 | | //! - **Iteration-Level Scheduling**: New requests join batch at any iteration |
9 | | //! - **Preemption**: Low-priority requests can be preempted for high-priority |
10 | | //! - **Memory-Aware**: Respects KV cache limits when scheduling |
11 | | //! - **Fair Queuing**: Prevents starvation of long requests |
12 | | //! |
13 | | //! ## Scheduling Algorithm |
14 | | //! |
15 | | //! ```text |
16 | | //! while running: |
17 | | //! 1. Check for completed sequences (EOS or max_tokens) |
18 | | //! 2. Preempt sequences if memory pressure |
19 | | //! 3. Schedule waiting sequences if space available |
20 | | //! 4. Run one iteration of generation for batch |
21 | | //! ``` |
22 | | |
23 | | // Module-level clippy allows |
24 | | #![allow(clippy::must_use_candidate)] |
25 | | #![allow(clippy::return_self_not_must_use)] |
26 | | #![allow(clippy::missing_errors_doc)] |
27 | | #![allow(clippy::unnecessary_wraps)] // Result wrapping for API consistency |
28 | | #![allow(clippy::derivable_impls)] // Manual impl for documentation clarity |
29 | | #![allow(clippy::option_if_let_else)] // map_or is more readable |
30 | | |
31 | | use crate::paged_kv::{PagedCacheError, PagedKvCache, SeqId}; |
32 | | use serde::{Deserialize, Serialize}; |
33 | | use std::collections::{BinaryHeap, HashMap, VecDeque}; |
34 | | use std::time::Instant; |
35 | | use thiserror::Error; |
36 | | |
37 | | // PMAT-802: Extracted modules |
38 | | mod chunked_prefill; |
39 | | mod types; |
40 | | pub use chunked_prefill::{ChunkedPrefillConfig, ChunkedPrefillScheduler, ChunkedPrefillState, ChunkedPrefillStats}; |
41 | | pub use types::{Priority, SequenceState, SchedulerStats}; |
42 | | |
43 | | /// Error type for scheduler operations |
44 | | #[derive(Debug, Error)] |
45 | | pub enum SchedulerError { |
46 | | /// Queue is full |
47 | | #[error("Request queue full: capacity {capacity}")] |
48 | | QueueFull { |
49 | | /// Queue capacity |
50 | | capacity: usize, |
51 | | }, |
52 | | |
53 | | /// Request not found |
54 | | #[error("Request not found: {0}")] |
55 | | RequestNotFound(u64), |
56 | | |
57 | | /// KV cache error |
58 | | #[error("KV cache error: {0}")] |
59 | | CacheError(#[from] PagedCacheError), |
60 | | |
61 | | /// Invalid state |
62 | | #[error("Invalid scheduler state: {0}")] |
63 | | InvalidState(String), |
64 | | } |
65 | | |
66 | | /// Generation request |
67 | | #[derive(Debug, Clone)] |
68 | | pub struct SchedulerRequest { |
69 | | /// Unique request ID |
70 | | pub request_id: u64, |
71 | | /// Input token IDs |
72 | | pub input_ids: Vec<u32>, |
73 | | /// Maximum tokens to generate |
74 | | pub max_tokens: usize, |
75 | | /// Priority level |
76 | | pub priority: Priority, |
77 | | /// Arrival time |
78 | | pub arrival_time: Instant, |
79 | | /// Sequence ID (assigned when scheduled) |
80 | | pub seq_id: Option<SeqId>, |
81 | | /// Current state |
82 | | pub state: SequenceState, |
83 | | /// Generated tokens so far |
84 | | pub generated_tokens: Vec<u32>, |
85 | | /// Number of decode iterations |
86 | | pub iterations: usize, |
87 | | } |
88 | | |
89 | | impl SchedulerRequest { |
90 | | /// Create a new request |
91 | 22 | pub fn new(request_id: u64, input_ids: Vec<u32>, max_tokens: usize) -> Self { |
92 | 22 | Self { |
93 | 22 | request_id, |
94 | 22 | input_ids, |
95 | 22 | max_tokens, |
96 | 22 | priority: Priority::default(), |
97 | 22 | arrival_time: Instant::now(), |
98 | 22 | seq_id: None, |
99 | 22 | state: SequenceState::Waiting, |
100 | 22 | generated_tokens: Vec::new(), |
101 | 22 | iterations: 0, |
102 | 22 | } |
103 | 22 | } |
104 | | |
105 | | /// Set priority |
106 | 16 | pub fn with_priority(mut self, priority: Priority) -> Self { |
107 | 16 | self.priority = priority; |
108 | 16 | self |
109 | 16 | } |
110 | | |
111 | | /// Total tokens (input + generated) |
112 | 3 | pub fn total_tokens(&self) -> usize { |
113 | 3 | self.input_ids.len() + self.generated_tokens.len() |
114 | 3 | } |
115 | | |
116 | | /// Remaining tokens to generate |
117 | 2 | pub fn remaining_tokens(&self) -> usize { |
118 | 2 | self.max_tokens.saturating_sub(self.generated_tokens.len()) |
119 | 2 | } |
120 | | |
121 | | /// Check if generation is complete |
122 | 6 | pub fn is_complete(&self, eos_token: u32) -> bool { |
123 | 6 | self.generated_tokens.len() >= self.max_tokens |
124 | 5 | || self.generated_tokens.last() == Some(&eos_token) |
125 | 6 | } |
126 | | |
127 | | /// Time waiting in queue |
128 | 3 | pub fn wait_time(&self) -> std::time::Duration { |
129 | 3 | self.arrival_time.elapsed() |
130 | 3 | } |
131 | | } |
132 | | |
133 | | /// Priority-aware entry for the waiting queue |
134 | | #[derive(Debug)] |
135 | | struct PriorityEntry { |
136 | | priority: Priority, |
137 | | arrival_time: Instant, |
138 | | request_id: u64, |
139 | | } |
140 | | |
141 | | impl PartialEq for PriorityEntry { |
142 | 2 | fn eq(&self, other: &Self) -> bool { |
143 | 2 | self.request_id == other.request_id |
144 | 2 | } |
145 | | } |
146 | | |
147 | | impl Eq for PriorityEntry {} |
148 | | |
149 | | impl PartialOrd for PriorityEntry { |
150 | 6 | fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { |
151 | 6 | Some(self.cmp(other)) |
152 | 6 | } |
153 | | } |
154 | | |
155 | | impl Ord for PriorityEntry { |
156 | 6 | fn cmp(&self, other: &Self) -> std::cmp::Ordering { |
157 | | // Higher priority first, then earlier arrival |
158 | 6 | match self.priority.cmp(&other.priority) { |
159 | 4 | std::cmp::Ordering::Equal => other.arrival_time.cmp(&self.arrival_time), |
160 | 2 | other => other, |
161 | | } |
162 | 6 | } |
163 | | } |
164 | | |
165 | | /// Scheduler output for one iteration |
166 | | #[derive(Debug, Clone, Default)] |
167 | | pub struct SchedulerOutput { |
168 | | /// Sequences to run this iteration |
169 | | pub scheduled_seq_ids: Vec<SeqId>, |
170 | | /// Request IDs for scheduled sequences |
171 | | pub scheduled_request_ids: Vec<u64>, |
172 | | /// Sequences that were preempted |
173 | | pub preempted_seq_ids: Vec<SeqId>, |
174 | | /// Sequences that completed |
175 | | pub completed_request_ids: Vec<u64>, |
176 | | /// Number of tokens in prefill phase |
177 | | pub num_prefill_tokens: usize, |
178 | | /// Number of tokens in decode phase |
179 | | pub num_decode_tokens: usize, |
180 | | } |
181 | | |
182 | | impl SchedulerOutput { |
183 | | /// Total tokens scheduled |
184 | 2 | pub fn total_tokens(&self) -> usize { |
185 | 2 | self.num_prefill_tokens + self.num_decode_tokens |
186 | 2 | } |
187 | | |
188 | | /// Check if batch is empty |
189 | 2 | pub fn is_empty(&self) -> bool { |
190 | 2 | self.scheduled_seq_ids.is_empty() |
191 | 2 | } |
192 | | } |
193 | | |
194 | | /// Continuous batching scheduler |
195 | | pub struct Scheduler { |
196 | | /// All requests by ID |
197 | | requests: HashMap<u64, SchedulerRequest>, |
198 | | /// Waiting queue (priority-ordered) |
199 | | waiting_queue: BinaryHeap<PriorityEntry>, |
200 | | /// Running sequences |
201 | | running: Vec<u64>, |
202 | | /// Preempted sequences (can be resumed) |
203 | | preempted: VecDeque<u64>, |
204 | | /// Maximum batch size |
205 | | max_batch_size: usize, |
206 | | /// Maximum queue size |
207 | | max_queue_size: usize, |
208 | | /// Maximum tokens per batch |
209 | | max_tokens_per_batch: usize, |
210 | | /// Next request ID |
211 | | next_request_id: u64, |
212 | | /// Statistics |
213 | | stats: SchedulerStats, |
214 | | /// Total wait time for completed requests (for averaging) |
215 | | total_wait_time_ms: f64, |
216 | | } |
217 | | |
218 | | impl Scheduler { |
219 | | /// Create a new scheduler |
220 | 16 | pub fn new(max_batch_size: usize, max_queue_size: usize) -> Self { |
221 | 16 | Self { |
222 | 16 | requests: HashMap::new(), |
223 | 16 | waiting_queue: BinaryHeap::new(), |
224 | 16 | running: Vec::new(), |
225 | 16 | preempted: VecDeque::new(), |
226 | 16 | max_batch_size, |
227 | 16 | max_queue_size, |
228 | 16 | max_tokens_per_batch: max_batch_size * 2048, // Default: assume 2k context |
229 | 16 | next_request_id: 0, |
230 | 16 | stats: SchedulerStats::default(), |
231 | 16 | total_wait_time_ms: 0.0, |
232 | 16 | } |
233 | 16 | } |
234 | | |
235 | | /// Set maximum tokens per batch |
236 | 1 | pub fn with_max_tokens(mut self, max_tokens: usize) -> Self { |
237 | 1 | self.max_tokens_per_batch = max_tokens; |
238 | 1 | self |
239 | 1 | } |
240 | | |
241 | | /// Add a new request to the queue |
242 | 12 | pub fn add_request( |
243 | 12 | &mut self, |
244 | 12 | input_ids: Vec<u32>, |
245 | 12 | max_tokens: usize, |
246 | 12 | ) -> Result<u64, SchedulerError> { |
247 | 12 | self.add_request_with_priority(input_ids, max_tokens, Priority::Normal) |
248 | 12 | } |
249 | | |
250 | | /// Add a request with priority |
251 | 16 | pub fn add_request_with_priority( |
252 | 16 | &mut self, |
253 | 16 | input_ids: Vec<u32>, |
254 | 16 | max_tokens: usize, |
255 | 16 | priority: Priority, |
256 | 16 | ) -> Result<u64, SchedulerError> { |
257 | 16 | if self.waiting_queue.len() >= self.max_queue_size { |
258 | 1 | return Err(SchedulerError::QueueFull { |
259 | 1 | capacity: self.max_queue_size, |
260 | 1 | }); |
261 | 15 | } |
262 | | |
263 | 15 | let request_id = self.next_request_id; |
264 | 15 | self.next_request_id += 1; |
265 | | |
266 | 15 | let request = |
267 | 15 | SchedulerRequest::new(request_id, input_ids, max_tokens).with_priority(priority); |
268 | 15 | let entry = PriorityEntry { |
269 | 15 | priority, |
270 | 15 | arrival_time: request.arrival_time, |
271 | 15 | request_id, |
272 | 15 | }; |
273 | | |
274 | 15 | self.requests.insert(request_id, request); |
275 | 15 | self.waiting_queue.push(entry); |
276 | 15 | self.stats.total_requests += 1; |
277 | 15 | self.stats.queue_depth = self.waiting_queue.len(); |
278 | | |
279 | 15 | Ok(request_id) |
280 | 16 | } |
281 | | |
282 | | /// Schedule one iteration of generation |
283 | 12 | pub fn schedule( |
284 | 12 | &mut self, |
285 | 12 | kv_cache: &mut PagedKvCache, |
286 | 12 | eos_token: u32, |
287 | 12 | ) -> Result<SchedulerOutput, SchedulerError> { |
288 | 12 | let mut output = SchedulerOutput::default(); |
289 | | |
290 | | // 1. Check for completed sequences |
291 | 12 | self.check_completions(&mut output, eos_token); |
292 | | |
293 | | // 2. Preempt if memory pressure (simplified: check if we can fit new sequences) |
294 | 12 | self.handle_preemption(&mut output, kv_cache); |
295 | | |
296 | | // 3. Resume preempted sequences if possible |
297 | 12 | self.resume_preempted(&mut output, kv_cache)?0 ; |
298 | | |
299 | | // 4. Schedule new sequences from waiting queue |
300 | 12 | self.schedule_waiting(&mut output, kv_cache)?0 ; |
301 | | |
302 | | // 5. Build final output |
303 | 25 | for &request_id13 in &self.running { |
304 | 13 | if let Some(request) = self.requests.get(&request_id) { |
305 | 13 | if let Some(seq_id) = request.seq_id { |
306 | 13 | output.scheduled_seq_ids.push(seq_id); |
307 | 13 | output.scheduled_request_ids.push(request_id); |
308 | | |
309 | 13 | if request.iterations == 0 { |
310 | 11 | // Prefill phase |
311 | 11 | output.num_prefill_tokens += request.input_ids.len(); |
312 | 11 | } else { |
313 | 2 | // Decode phase (1 token per sequence) |
314 | 2 | output.num_decode_tokens += 1; |
315 | 2 | } |
316 | 0 | } |
317 | 0 | } |
318 | | } |
319 | | |
320 | 12 | self.stats.running_count = self.running.len(); |
321 | 12 | self.stats.queue_depth = self.waiting_queue.len(); |
322 | | |
323 | 12 | Ok(output) |
324 | 12 | } |
325 | | |
326 | | /// Update scheduler after generation iteration |
327 | 4 | pub fn update_after_iteration(&mut self, generated_tokens: &HashMap<u64, u32>) { |
328 | 8 | for (&request_id4 , &token4 ) in generated_tokens { |
329 | 4 | if let Some(request3 ) = self.requests.get_mut(&request_id) { |
330 | 3 | request.generated_tokens.push(token); |
331 | 3 | request.iterations += 1; |
332 | 3 | }1 |
333 | | } |
334 | 4 | } |
335 | | |
336 | | /// Mark request as complete |
337 | 3 | pub fn complete_request(&mut self, request_id: u64, kv_cache: &mut PagedKvCache) { |
338 | 3 | if let Some(request2 ) = self.requests.get_mut(&request_id) { |
339 | 2 | request.state = SequenceState::Completed; |
340 | | |
341 | | // Free KV cache |
342 | 2 | if let Some(seq_id) = request.seq_id { |
343 | 2 | kv_cache.free_sequence(seq_id); |
344 | 2 | }0 |
345 | | |
346 | | // Update stats |
347 | 2 | self.stats.completed_requests += 1; |
348 | 2 | let wait_time = request.wait_time().as_secs_f64() * 1000.0; |
349 | 2 | self.total_wait_time_ms += wait_time; |
350 | 2 | self.stats.avg_wait_time_ms = |
351 | 2 | self.total_wait_time_ms / self.stats.completed_requests as f64; |
352 | 1 | } |
353 | | |
354 | | // Remove from running |
355 | 3 | self.running.retain(|&id| id2 != request_id2 ); |
356 | 3 | } |
357 | | |
358 | | /// Get request by ID |
359 | 4 | pub fn get_request(&self, request_id: u64) -> Option<&SchedulerRequest> { |
360 | 4 | self.requests.get(&request_id) |
361 | 4 | } |
362 | | |
363 | | /// Get scheduler statistics |
364 | 4 | pub fn stats(&self) -> &SchedulerStats { |
365 | 4 | &self.stats |
366 | 4 | } |
367 | | |
368 | | /// Check for completed sequences |
369 | 12 | fn check_completions(&mut self, output: &mut SchedulerOutput, eos_token: u32) { |
370 | 12 | let completed: Vec<u64> = self |
371 | 12 | .running |
372 | 12 | .iter() |
373 | 12 | .filter(|&&id| {3 |
374 | 3 | self.requests |
375 | 3 | .get(&id) |
376 | 3 | .is_some_and(|r| r.is_complete(eos_token)) |
377 | 3 | }) |
378 | 12 | .copied() |
379 | 12 | .collect(); |
380 | | |
381 | 13 | for request_id1 in completed { |
382 | 1 | if let Some(request) = self.requests.get_mut(&request_id) { |
383 | 1 | request.state = SequenceState::Completed; |
384 | 1 | }0 |
385 | 1 | output.completed_request_ids.push(request_id); |
386 | | } |
387 | 12 | } |
388 | | |
389 | | /// Handle preemption under memory pressure |
390 | 12 | fn handle_preemption(&mut self, output: &mut SchedulerOutput, kv_cache: &mut PagedKvCache) { |
391 | | // Simple preemption: if running at max and waiting queue has higher priority |
392 | 12 | if self.running.len() >= self.max_batch_size && !self.waiting_queue.is_empty()1 { |
393 | | // Check if waiting has higher priority than lowest running |
394 | 1 | if let Some(waiting_entry) = self.waiting_queue.peek() { |
395 | 1 | let min_running_priority = self |
396 | 1 | .running |
397 | 1 | .iter() |
398 | 1 | .filter_map(|&id| self.requests.get(&id)) |
399 | 1 | .map(|r| r.priority) |
400 | 1 | .min() |
401 | 1 | .unwrap_or(Priority::Critical); |
402 | | |
403 | 1 | if waiting_entry.priority > min_running_priority { |
404 | | // Find lowest priority running request to preempt |
405 | 1 | if let Some(&preempt_id) = self.running.iter().find(|&&id| { |
406 | 1 | self.requests |
407 | 1 | .get(&id) |
408 | 1 | .is_some_and(|r| r.priority == min_running_priority) |
409 | 1 | }) { |
410 | | // Preempt the request |
411 | 1 | if let Some(request) = self.requests.get_mut(&preempt_id) { |
412 | 1 | request.state = SequenceState::Preempted; |
413 | 1 | if let Some(seq_id) = request.seq_id { |
414 | 1 | output.preempted_seq_ids.push(seq_id); |
415 | 1 | kv_cache.free_sequence(seq_id); |
416 | 1 | }0 |
417 | 1 | request.seq_id = None; |
418 | 0 | } |
419 | 1 | self.running.retain(|&id| id != preempt_id); |
420 | 1 | self.preempted.push_back(preempt_id); |
421 | 1 | self.stats.preemptions += 1; |
422 | 0 | } |
423 | 0 | } |
424 | 0 | } |
425 | 11 | } |
426 | 12 | } |
427 | | |
428 | | /// Resume preempted sequences |
429 | 12 | fn resume_preempted( |
430 | 12 | &mut self, |
431 | 12 | _output: &mut SchedulerOutput, |
432 | 12 | kv_cache: &mut PagedKvCache, |
433 | 12 | ) -> Result<(), SchedulerError> { |
434 | 13 | while self.running.len() < self.max_batch_size { |
435 | 12 | if let Some(request_id1 ) = self.preempted.pop_front() { |
436 | 1 | if let Some(request) = self.requests.get_mut(&request_id) { |
437 | | // Try to allocate KV cache |
438 | 1 | let total_tokens = request.total_tokens(); |
439 | 1 | match kv_cache.allocate_sequence(total_tokens) { |
440 | 1 | Ok(seq_id) => { |
441 | 1 | request.seq_id = Some(seq_id); |
442 | 1 | request.state = SequenceState::Running; |
443 | 1 | self.running.push(request_id); |
444 | 1 | }, |
445 | | Err(_) => { |
446 | | // Can't allocate, put back in preempted queue |
447 | 0 | self.preempted.push_front(request_id); |
448 | 0 | break; |
449 | | }, |
450 | | } |
451 | 0 | } |
452 | | } else { |
453 | 11 | break; |
454 | | } |
455 | | } |
456 | 12 | Ok(()) |
457 | 12 | } |
458 | | |
459 | | /// Schedule waiting requests |
460 | 12 | fn schedule_waiting( |
461 | 12 | &mut self, |
462 | 12 | _output: &mut SchedulerOutput, |
463 | 12 | kv_cache: &mut PagedKvCache, |
464 | 12 | ) -> Result<(), SchedulerError> { |
465 | 22 | while self.running.len() < self.max_batch_size { |
466 | 18 | if let Some(entry10 ) = self.waiting_queue.pop() { |
467 | 10 | if let Some(request) = self.requests.get_mut(&entry.request_id) { |
468 | 10 | let total_tokens = request.input_ids.len(); |
469 | 10 | match kv_cache.allocate_sequence(total_tokens) { |
470 | 10 | Ok(seq_id) => { |
471 | 10 | request.seq_id = Some(seq_id); |
472 | 10 | request.state = SequenceState::Running; |
473 | 10 | self.running.push(entry.request_id); |
474 | 10 | }, |
475 | | Err(_) => { |
476 | | // Can't allocate, put back in queue (at front since already popped) |
477 | 0 | self.waiting_queue.push(entry); |
478 | 0 | break; |
479 | | }, |
480 | | } |
481 | 0 | } |
482 | | } else { |
483 | 8 | break; |
484 | | } |
485 | | } |
486 | 12 | Ok(()) |
487 | 12 | } |
488 | | |
489 | | /// Number of waiting requests |
490 | 4 | pub fn waiting_count(&self) -> usize { |
491 | 4 | self.waiting_queue.len() |
492 | 4 | } |
493 | | |
494 | | /// Number of running requests |
495 | 4 | pub fn running_count(&self) -> usize { |
496 | 4 | self.running.len() |
497 | 4 | } |
498 | | |
499 | | /// Number of preempted requests |
500 | 0 | pub fn preempted_count(&self) -> usize { |
501 | 0 | self.preempted.len() |
502 | 0 | } |
503 | | } |
504 | | |
505 | | // ============================================================================ |
506 | | // SLOT-BASED SERVER CONCURRENCY (per llama.cpp) |
507 | | // ============================================================================ |
508 | | // |
509 | | // llama.cpp uses a slot-based architecture for concurrent inference: |
510 | | // - Fixed number of slots, each with its own KV cache allocation |
511 | | // - State machine: IDLE โ PROCESSING โ GENERATING โ (complete) โ IDLE |
512 | | // - Slots can be dynamically assigned to incoming requests |
513 | | // - Enables efficient handling of multiple concurrent clients |
514 | | // ============================================================================ |
515 | | |
516 | | /// Slot state machine (per llama.cpp server architecture) |
517 | | /// |
518 | | /// Each slot transitions through these states: |
519 | | /// - IDLE: Ready to accept new requests |
520 | | /// - PROCESSING: Initial prompt processing (prefill phase) |
521 | | /// - GENERATING: Token generation (decode phase) |
522 | | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
523 | | pub enum SlotState { |
524 | | /// Slot is available for new requests |
525 | | Idle, |
526 | | /// Processing initial prompt (prefill) |
527 | | Processing, |
528 | | /// Generating tokens (decode) |
529 | | Generating, |
530 | | } |
531 | | |
532 | | impl Default for SlotState { |
533 | 1 | fn default() -> Self { |
534 | 1 | Self::Idle |
535 | 1 | } |
536 | | } |
537 | | |
538 | | /// Server slot for concurrent inference |
539 | | /// |
540 | | /// Per llama.cpp: Each slot manages one inference request with its own |
541 | | /// KV cache allocation and state machine. |
542 | | #[derive(Debug, Clone)] |
543 | | pub struct Slot { |
544 | | /// Unique slot ID |
545 | | pub id: usize, |
546 | | /// Current state |
547 | | pub state: SlotState, |
548 | | /// Assigned request ID (None if idle) |
549 | | pub request_id: Option<u64>, |
550 | | /// Sequence ID for KV cache |
551 | | pub seq_id: Option<SeqId>, |
552 | | /// Input tokens (prompt) |
553 | | pub input_tokens: Vec<u32>, |
554 | | /// Generated tokens so far |
555 | | pub generated_tokens: Vec<u32>, |
556 | | /// Maximum tokens to generate |
557 | | pub max_tokens: usize, |
558 | | /// Number of prompt tokens processed |
559 | | pub n_prompt_tokens_processed: usize, |
560 | | /// Generation start time |
561 | | pub generation_start: Option<Instant>, |
562 | | /// Total prompt processing time (ms) |
563 | | pub prompt_time_ms: f64, |
564 | | /// Total generation time (ms) |
565 | | pub generation_time_ms: f64, |
566 | | } |
567 | | |
568 | | impl Slot { |
569 | | /// Create a new idle slot |
570 | 43 | pub fn new(id: usize) -> Self { |
571 | 43 | Self { |
572 | 43 | id, |
573 | 43 | state: SlotState::Idle, |
574 | 43 | request_id: None, |
575 | 43 | seq_id: None, |
576 | 43 | input_tokens: Vec::new(), |
577 | 43 | generated_tokens: Vec::new(), |
578 | 43 | max_tokens: 0, |
579 | 43 | n_prompt_tokens_processed: 0, |
580 | 43 | generation_start: None, |
581 | 43 | prompt_time_ms: 0.0, |
582 | 43 | generation_time_ms: 0.0, |
583 | 43 | } |
584 | 43 | } |
585 | | |
586 | | /// Check if slot is available |
587 | 53 | pub fn is_idle(&self) -> bool { |
588 | 53 | self.state == SlotState::Idle |
589 | 53 | } |
590 | | |
591 | | /// Check if slot is actively generating |
592 | 10 | pub fn is_generating(&self) -> bool { |
593 | 10 | self.state == SlotState::Generating |
594 | 10 | } |
595 | | |
596 | | /// Assign a request to this slot |
597 | 18 | pub fn assign(&mut self, request_id: u64, input_tokens: Vec<u32>, max_tokens: usize) { |
598 | 18 | self.state = SlotState::Processing; |
599 | 18 | self.request_id = Some(request_id); |
600 | 18 | self.input_tokens = input_tokens; |
601 | 18 | self.max_tokens = max_tokens; |
602 | 18 | self.generated_tokens.clear(); |
603 | 18 | self.n_prompt_tokens_processed = 0; |
604 | 18 | self.prompt_time_ms = 0.0; |
605 | 18 | self.generation_time_ms = 0.0; |
606 | 18 | self.generation_start = None; |
607 | 18 | } |
608 | | |
609 | | /// Transition from processing to generating |
610 | 9 | pub fn start_generation(&mut self, prompt_time_ms: f64) { |
611 | 9 | self.state = SlotState::Generating; |
612 | 9 | self.prompt_time_ms = prompt_time_ms; |
613 | 9 | self.generation_start = Some(Instant::now()); |
614 | 9 | } |
615 | | |
616 | | /// Add a generated token |
617 | 8 | pub fn add_token(&mut self, token: u32) { |
618 | 8 | self.generated_tokens.push(token); |
619 | 8 | } |
620 | | |
621 | | /// Check if generation is complete |
622 | 5 | pub fn is_complete(&self, eos_token: u32) -> bool { |
623 | 5 | if self.generated_tokens.len() >= self.max_tokens { |
624 | 1 | return true; |
625 | 4 | } |
626 | 4 | if let Some(&last3 ) = self.generated_tokens.last() { |
627 | 3 | if last == eos_token { |
628 | 1 | return true; |
629 | 2 | } |
630 | 1 | } |
631 | 3 | false |
632 | 5 | } |
633 | | |
634 | | /// Finish generation and reset to idle |
635 | 1 | pub fn finish(&mut self) { |
636 | 1 | if let Some(start) = self.generation_start { |
637 | 1 | self.generation_time_ms = start.elapsed().as_secs_f64() * 1000.0; |
638 | 1 | }0 |
639 | 1 | self.state = SlotState::Idle; |
640 | 1 | self.request_id = None; |
641 | 1 | self.seq_id = None; |
642 | 1 | } |
643 | | |
644 | | /// Get tokens per second for this slot's generation |
645 | 5 | pub fn tokens_per_second(&self) -> f64 { |
646 | 5 | if self.generation_time_ms > 0.0 { |
647 | 0 | self.generated_tokens.len() as f64 / (self.generation_time_ms / 1000.0) |
648 | | } else { |
649 | 5 | 0.0 |
650 | | } |
651 | 5 | } |
652 | | } |
653 | | |
654 | | /// Slot manager for concurrent inference |
655 | | /// |
656 | | /// Per llama.cpp: Manages a fixed pool of slots for handling concurrent requests. |
657 | | /// Each slot has its own KV cache allocation and can process one request at a time. |
658 | | #[derive(Debug)] |
659 | | pub struct SlotManager { |
660 | | /// Available slots |
661 | | slots: Vec<Slot>, |
662 | | /// Maximum context length per slot |
663 | | pub max_context_length: usize, |
664 | | /// Next request ID |
665 | | next_request_id: u64, |
666 | | } |
667 | | |
668 | | impl SlotManager { |
669 | | /// Create a new slot manager with the specified number of slots |
670 | 10 | pub fn new(num_slots: usize, max_context_length: usize) -> Self { |
671 | 10 | let slots = (0..num_slots).map(Slot::new).collect(); |
672 | 10 | Self { |
673 | 10 | slots, |
674 | 10 | max_context_length, |
675 | 10 | next_request_id: 0, |
676 | 10 | } |
677 | 10 | } |
678 | | |
679 | | /// Get number of total slots |
680 | 1 | pub fn num_slots(&self) -> usize { |
681 | 1 | self.slots.len() |
682 | 1 | } |
683 | | |
684 | | /// Get number of idle slots |
685 | 7 | pub fn num_idle_slots(&self) -> usize { |
686 | 28 | self.slots.iter()7 .filter7 (|s| s.is_idle()).count7 () |
687 | 7 | } |
688 | | |
689 | | /// Get number of active (non-idle) slots |
690 | 5 | pub fn num_active_slots(&self) -> usize { |
691 | 5 | self.slots.len() - self.num_idle_slots() |
692 | 5 | } |
693 | | |
694 | | /// Find an idle slot |
695 | 12 | pub fn find_idle_slot(&self) -> Option<usize> { |
696 | 12 | self.slots.iter().position(Slot::is_idle) |
697 | 12 | } |
698 | | |
699 | | /// Assign a request to an available slot |
700 | | /// |
701 | | /// Returns the slot ID if successful, None if no slots available. |
702 | 12 | pub fn assign_request( |
703 | 12 | &mut self, |
704 | 12 | input_tokens: Vec<u32>, |
705 | 12 | max_tokens: usize, |
706 | 12 | ) -> Option<(usize, u64)> { |
707 | 12 | let slot_id11 = self.find_idle_slot()?1 ; |
708 | 11 | let request_id = self.next_request_id; |
709 | 11 | self.next_request_id += 1; |
710 | | |
711 | 11 | self.slots[slot_id].assign(request_id, input_tokens, max_tokens); |
712 | 11 | Some((slot_id, request_id)) |
713 | 12 | } |
714 | | |
715 | | /// Get a reference to a slot |
716 | 3 | pub fn get_slot(&self, slot_id: usize) -> Option<&Slot> { |
717 | 3 | self.slots.get(slot_id) |
718 | 3 | } |
719 | | |
720 | | /// Get a mutable reference to a slot |
721 | 3 | pub fn get_slot_mut(&mut self, slot_id: usize) -> Option<&mut Slot> { |
722 | 3 | self.slots.get_mut(slot_id) |
723 | 3 | } |
724 | | |
725 | | /// Get all active slots (non-idle) |
726 | 1 | pub fn active_slots(&self) -> impl Iterator<Item = &Slot> { |
727 | 4 | self.slots.iter()1 .filter1 (|s| !s.is_idle()) |
728 | 1 | } |
729 | | |
730 | | /// Get all generating slots |
731 | 1 | pub fn generating_slots(&self) -> impl Iterator<Item = &Slot> { |
732 | 4 | self.slots.iter()1 .filter1 (|s| s.is_generating()) |
733 | 1 | } |
734 | | |
735 | | /// Get slots ready for batch processing |
736 | 1 | pub fn batch_slots(&self) -> Vec<usize> { |
737 | 1 | self.slots |
738 | 1 | .iter() |
739 | 1 | .enumerate() |
740 | 4 | .filter1 (|(_, s)| s.is_generating()) |
741 | 1 | .map(|(i, _)| i) |
742 | 1 | .collect() |
743 | 1 | } |
744 | | |
745 | | /// Get server utilization (0.0 to 1.0) |
746 | 4 | pub fn utilization(&self) -> f64 { |
747 | 4 | if self.slots.is_empty() { |
748 | 1 | 0.0 |
749 | | } else { |
750 | 3 | self.num_active_slots() as f64 / self.slots.len() as f64 |
751 | | } |
752 | 4 | } |
753 | | |
754 | | /// Get aggregate tokens per second across all slots |
755 | 1 | pub fn aggregate_tokens_per_second(&self) -> f64 { |
756 | 1 | self.slots.iter().map(Slot::tokens_per_second).sum() |
757 | 1 | } |
758 | | } |
759 | | |
760 | | // ============================================================================ |
761 | | // CONTINUOUS BATCHING (ubatch/sbatch per llama.cpp) |
762 | | // ============================================================================ |
763 | | // |
764 | | // llama.cpp's continuous batching system: |
765 | | // - ubatch (micro-batch): Tokens processed in a single forward pass |
766 | | // - sbatch (sequence batch): Multiple sequences grouped for batched inference |
767 | | // |
768 | | // This enables: |
769 | | // - Dynamic batching: New sequences can join mid-inference |
770 | | // - Efficient GPU utilization: Batch multiple decode steps together |
771 | | // - Mixed prefill/decode: Process prefill and decode in same batch |
772 | | // ============================================================================ |
773 | | |
774 | | /// Batch type for continuous batching |
775 | | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
776 | | pub enum BatchType { |
777 | | /// Prefill batch (processing initial prompts) |
778 | | Prefill, |
779 | | /// Decode batch (generating tokens) |
780 | | Decode, |
781 | | /// Mixed prefill and decode |
782 | | Mixed, |
783 | | } |
784 | | |
785 | | impl Default for BatchType { |
786 | 1 | fn default() -> Self { |
787 | 1 | Self::Decode |
788 | 1 | } |
789 | | } |
790 | | |
791 | | /// Token position within a batch |
792 | | #[derive(Debug, Clone, Copy, Serialize, Deserialize)] |
793 | | pub struct BatchToken { |
794 | | /// Token ID |
795 | | pub token_id: u32, |
796 | | /// Sequence ID this token belongs to |
797 | | pub seq_idx: usize, |
798 | | /// Position within the sequence |
799 | | pub seq_pos: usize, |
800 | | /// Whether this is a prompt token (vs generated) |
801 | | pub is_prompt: bool, |
802 | | } |
803 | | |
804 | | impl BatchToken { |
805 | | /// Create a new batch token |
806 | 32 | pub fn new(token_id: u32, seq_idx: usize, seq_pos: usize, is_prompt: bool) -> Self { |
807 | 32 | Self { |
808 | 32 | token_id, |
809 | 32 | seq_idx, |
810 | 32 | seq_pos, |
811 | 32 | is_prompt, |
812 | 32 | } |
813 | 32 | } |
814 | | } |
815 | | |
816 | | /// Micro-batch (ubatch) - tokens for a single forward pass |
817 | | /// |
818 | | /// Per llama.cpp: A ubatch contains tokens that will be processed together |
819 | | /// in a single forward pass. Can contain tokens from multiple sequences. |
820 | | #[derive(Debug, Clone, Default, Serialize, Deserialize)] |
821 | | pub struct MicroBatch { |
822 | | /// Tokens in this micro-batch |
823 | | pub tokens: Vec<BatchToken>, |
824 | | /// Sequence indices included in this batch |
825 | | pub seq_indices: Vec<usize>, |
826 | | /// Batch type (prefill/decode/mixed) |
827 | | pub batch_type: BatchType, |
828 | | /// Maximum sequence length in batch |
829 | | pub max_seq_len: usize, |
830 | | /// Number of prompt tokens in batch |
831 | | pub n_prompt_tokens: usize, |
832 | | /// Number of decode tokens in batch |
833 | | pub n_decode_tokens: usize, |
834 | | } |
835 | | |
836 | | impl MicroBatch { |
837 | | /// Create a new empty micro-batch |
838 | 9 | pub fn new() -> Self { |
839 | 9 | Self { |
840 | 9 | tokens: Vec::new(), |
841 | 9 | seq_indices: Vec::new(), |
842 | 9 | batch_type: BatchType::Decode, |
843 | 9 | max_seq_len: 0, |
844 | 9 | n_prompt_tokens: 0, |
845 | 9 | n_decode_tokens: 0, |
846 | 9 | } |
847 | 9 | } |
848 | | |
849 | | /// Create a micro-batch with capacity |
850 | 7 | pub fn with_capacity(capacity: usize) -> Self { |
851 | 7 | Self { |
852 | 7 | tokens: Vec::with_capacity(capacity), |
853 | 7 | seq_indices: Vec::new(), |
854 | 7 | batch_type: BatchType::Decode, |
855 | 7 | max_seq_len: 0, |
856 | 7 | n_prompt_tokens: 0, |
857 | 7 | n_decode_tokens: 0, |
858 | 7 | } |
859 | 7 | } |
860 | | |
861 | | /// Add a token to the batch |
862 | 31 | pub fn add_token(&mut self, token: BatchToken) { |
863 | 31 | if token.is_prompt { |
864 | 23 | self.n_prompt_tokens += 1; |
865 | 23 | } else { |
866 | 8 | self.n_decode_tokens += 1; |
867 | 8 | } |
868 | | |
869 | | // Track sequence index |
870 | 31 | if !self.seq_indices.contains(&token.seq_idx) { |
871 | 20 | self.seq_indices.push(token.seq_idx); |
872 | 20 | }11 |
873 | | |
874 | | // Update max sequence length |
875 | 31 | self.max_seq_len = self.max_seq_len.max(token.seq_pos + 1); |
876 | | |
877 | 31 | self.tokens.push(token); |
878 | | |
879 | | // Update batch type |
880 | 31 | self.update_batch_type(); |
881 | 31 | } |
882 | | |
883 | | /// Update batch type based on token composition |
884 | 31 | fn update_batch_type(&mut self) { |
885 | 31 | self.batch_type = match (self.n_prompt_tokens > 0, self.n_decode_tokens > 0) { |
886 | 23 | (true, false) => BatchType::Prefill, |
887 | 5 | (true, true) => BatchType::Mixed, |
888 | | // Both (false, true) and (false, false) result in Decode |
889 | 3 | (false, _) => BatchType::Decode, |
890 | | }; |
891 | 31 | } |
892 | | |
893 | | /// Total number of tokens |
894 | 29 | pub fn len(&self) -> usize { |
895 | 29 | self.tokens.len() |
896 | 29 | } |
897 | | |
898 | | /// Check if batch is empty |
899 | 15 | pub fn is_empty(&self) -> bool { |
900 | 15 | self.tokens.is_empty() |
901 | 15 | } |
902 | | |
903 | | /// Number of sequences in batch |
904 | 3 | pub fn num_sequences(&self) -> usize { |
905 | 3 | self.seq_indices.len() |
906 | 3 | } |
907 | | |
908 | | /// Check if batch is pure prefill |
909 | 5 | pub fn is_prefill(&self) -> bool { |
910 | 5 | self.batch_type == BatchType::Prefill |
911 | 5 | } |
912 | | |
913 | | /// Check if batch is pure decode |
914 | 3 | pub fn is_decode(&self) -> bool { |
915 | 3 | self.batch_type == BatchType::Decode |
916 | 3 | } |
917 | | |
918 | | /// Check if batch is mixed |
919 | 3 | pub fn is_mixed(&self) -> bool { |
920 | 3 | self.batch_type == BatchType::Mixed |
921 | 3 | } |
922 | | |
923 | | /// Get token IDs as a vector (for model input) |
924 | 2 | pub fn token_ids(&self) -> Vec<u32> { |
925 | 2 | self.tokens.iter().map(|t| t.token_id).collect() |
926 | 2 | } |
927 | | |
928 | | /// Get sequence positions (for position embeddings) |
929 | 1 | pub fn positions(&self) -> Vec<usize> { |
930 | 1 | self.tokens.iter().map(|t| t.seq_pos).collect() |
931 | 1 | } |
932 | | |
933 | | /// Clear the batch |
934 | 1 | pub fn clear(&mut self) { |
935 | 1 | self.tokens.clear(); |
936 | 1 | self.seq_indices.clear(); |
937 | 1 | self.batch_type = BatchType::Decode; |
938 | 1 | self.max_seq_len = 0; |
939 | 1 | self.n_prompt_tokens = 0; |
940 | 1 | self.n_decode_tokens = 0; |
941 | 1 | } |
942 | | } |
943 | | |
944 | | /// Sequence batch entry |
945 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
946 | | pub struct SequenceBatchEntry { |
947 | | /// Sequence index |
948 | | pub seq_idx: usize, |
949 | | /// Slot ID |
950 | | pub slot_id: usize, |
951 | | /// Request ID |
952 | | pub request_id: u64, |
953 | | /// Current position in sequence |
954 | | pub position: usize, |
955 | | /// Tokens to process (for prefill) |
956 | | pub tokens: Vec<u32>, |
957 | | /// Is this sequence in prefill or decode mode |
958 | | pub is_prefill: bool, |
959 | | } |
960 | | |
961 | | impl SequenceBatchEntry { |
962 | | /// Create new sequence batch entry |
963 | 28 | pub fn new(seq_idx: usize, slot_id: usize, request_id: u64) -> Self { |
964 | 28 | Self { |
965 | 28 | seq_idx, |
966 | 28 | slot_id, |
967 | 28 | request_id, |
968 | 28 | position: 0, |
969 | 28 | tokens: Vec::new(), |
970 | 28 | is_prefill: true, |
971 | 28 | } |
972 | 28 | } |
973 | | |
974 | | /// Set tokens for prefill |
975 | 12 | pub fn with_tokens(mut self, tokens: Vec<u32>) -> Self { |
976 | 12 | self.tokens = tokens; |
977 | 12 | self |
978 | 12 | } |
979 | | |
980 | | /// Set position |
981 | 1 | pub fn at_position(mut self, position: usize) -> Self { |
982 | 1 | self.position = position; |
983 | 1 | self |
984 | 1 | } |
985 | | |
986 | | /// Mark as decode (not prefill) |
987 | 4 | pub fn decoding(mut self) -> Self { |
988 | 4 | self.is_prefill = false; |
989 | 4 | self |
990 | 4 | } |
991 | | } |
992 | | |
993 | | /// Sequence batch (sbatch) - multiple sequences for batched inference |
994 | | /// |
995 | | /// Per llama.cpp: Groups sequences that will be processed together. |
996 | | /// Manages the mapping from batch positions to individual sequences. |
997 | | #[derive(Debug, Clone, Default, Serialize, Deserialize)] |
998 | | pub struct SequenceBatch { |
999 | | /// Sequences in this batch |
1000 | | pub sequences: Vec<SequenceBatchEntry>, |
1001 | | /// Maximum batch size |
1002 | | pub max_batch_size: usize, |
1003 | | /// Current batch utilization |
1004 | | pub utilization: f64, |
1005 | | } |
1006 | | |
1007 | | impl SequenceBatch { |
1008 | | /// Create a new sequence batch with max size |
1009 | 25 | pub fn new(max_batch_size: usize) -> Self { |
1010 | 25 | Self { |
1011 | 25 | sequences: Vec::with_capacity(max_batch_size), |
1012 | 25 | max_batch_size, |
1013 | 25 | utilization: 0.0, |
1014 | 25 | } |
1015 | 25 | } |
1016 | | |
1017 | | /// Add a sequence to the batch |
1018 | 26 | pub fn add_sequence(&mut self, entry: SequenceBatchEntry) -> bool { |
1019 | 26 | if self.sequences.len() >= self.max_batch_size { |
1020 | 1 | return false; |
1021 | 25 | } |
1022 | 25 | self.sequences.push(entry); |
1023 | 25 | self.update_utilization(); |
1024 | 25 | true |
1025 | 26 | } |
1026 | | |
1027 | | /// Remove a sequence by index |
1028 | 4 | pub fn remove_sequence(&mut self, seq_idx: usize) -> Option<SequenceBatchEntry> { |
1029 | 4 | let pos2 = self.sequences.iter().position(|s| s.seq_idx3 == seq_idx3 )?2 ; |
1030 | 2 | let entry = self.sequences.remove(pos); |
1031 | 2 | self.update_utilization(); |
1032 | 2 | Some(entry) |
1033 | 4 | } |
1034 | | |
1035 | | /// Update utilization metric |
1036 | 28 | fn update_utilization(&mut self) { |
1037 | 28 | self.utilization = if self.max_batch_size > 0 { |
1038 | 27 | self.sequences.len() as f64 / self.max_batch_size as f64 |
1039 | | } else { |
1040 | 1 | 0.0 |
1041 | | }; |
1042 | 28 | } |
1043 | | |
1044 | | /// Number of sequences in batch |
1045 | 6 | pub fn len(&self) -> usize { |
1046 | 6 | self.sequences.len() |
1047 | 6 | } |
1048 | | |
1049 | | /// Check if batch is empty |
1050 | 3 | pub fn is_empty(&self) -> bool { |
1051 | 3 | self.sequences.is_empty() |
1052 | 3 | } |
1053 | | |
1054 | | /// Check if batch is full |
1055 | 17 | pub fn is_full(&self) -> bool { |
1056 | 17 | self.sequences.len() >= self.max_batch_size |
1057 | 17 | } |
1058 | | |
1059 | | /// Get prefill sequences |
1060 | 1 | pub fn prefill_sequences(&self) -> impl Iterator<Item = &SequenceBatchEntry> { |
1061 | 1 | self.sequences.iter().filter(|s| s.is_prefill) |
1062 | 1 | } |
1063 | | |
1064 | | /// Get decode sequences |
1065 | 1 | pub fn decode_sequences(&self) -> impl Iterator<Item = &SequenceBatchEntry> { |
1066 | 2 | self.sequences.iter()1 .filter1 (|s| !s.is_prefill) |
1067 | 1 | } |
1068 | | |
1069 | | /// Count prefill sequences |
1070 | 1 | pub fn num_prefill(&self) -> usize { |
1071 | 1 | self.sequences.iter().filter(|s| s.is_prefill).count() |
1072 | 1 | } |
1073 | | |
1074 | | /// Count decode sequences |
1075 | 1 | pub fn num_decode(&self) -> usize { |
1076 | 3 | self.sequences.iter()1 .filter1 (|s| !s.is_prefill).count1 () |
1077 | 1 | } |
1078 | | |
1079 | | /// Clear the batch |
1080 | 1 | pub fn clear(&mut self) { |
1081 | 1 | self.sequences.clear(); |
1082 | 1 | self.utilization = 0.0; |
1083 | 1 | } |
1084 | | |
1085 | | /// Get sequence by index |
1086 | 4 | pub fn get(&self, seq_idx: usize) -> Option<&SequenceBatchEntry> { |
1087 | 4 | self.sequences.iter().find(|s| s.seq_idx3 == seq_idx3 ) |
1088 | 4 | } |
1089 | | |
1090 | | /// Get mutable sequence by index |
1091 | 5 | pub fn get_mut(&mut self, seq_idx: usize) -> Option<&mut SequenceBatchEntry> { |
1092 | 5 | self.sequences.iter_mut().find(|s| s.seq_idx == seq_idx) |
1093 | 5 | } |
1094 | | } |
1095 | | |
1096 | | /// Batch configuration for continuous batching |
1097 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
1098 | | pub struct BatchConfig { |
1099 | | /// Maximum tokens per micro-batch |
1100 | | pub max_ubatch_tokens: usize, |
1101 | | /// Maximum sequences per sequence batch |
1102 | | pub max_sbatch_sequences: usize, |
1103 | | /// Prefer pure decode batches (vs mixed) |
1104 | | pub prefer_pure_decode: bool, |
1105 | | /// Maximum context length |
1106 | | pub max_context_length: usize, |
1107 | | /// Enable dynamic batching (add sequences mid-inference) |
1108 | | pub dynamic_batching: bool, |
1109 | | } |
1110 | | |
1111 | | impl Default for BatchConfig { |
1112 | 16 | fn default() -> Self { |
1113 | 16 | Self { |
1114 | 16 | max_ubatch_tokens: 512, |
1115 | 16 | max_sbatch_sequences: 8, |
1116 | 16 | prefer_pure_decode: true, |
1117 | 16 | max_context_length: 2048, |
1118 | 16 | dynamic_batching: true, |
1119 | 16 | } |
1120 | 16 | } |
1121 | | } |
1122 | | |
1123 | | impl BatchConfig { |
1124 | | /// Create batch config with custom max tokens |
1125 | 2 | pub fn with_max_tokens(mut self, max_tokens: usize) -> Self { |
1126 | 2 | self.max_ubatch_tokens = max_tokens; |
1127 | 2 | self |
1128 | 2 | } |
1129 | | |
1130 | | /// Create batch config with custom max sequences |
1131 | 2 | pub fn with_max_sequences(mut self, max_seqs: usize) -> Self { |
1132 | 2 | self.max_sbatch_sequences = max_seqs; |
1133 | 2 | self |
1134 | 2 | } |
1135 | | } |
1136 | | |
1137 | | /// Batch scheduler for continuous batching |
1138 | | /// |
1139 | | /// Coordinates micro-batch and sequence batch creation, |
1140 | | /// implementing llama.cpp-style continuous batching. |
1141 | | pub struct BatchScheduler { |
1142 | | /// Configuration |
1143 | | config: BatchConfig, |
1144 | | /// Current sequence batch |
1145 | | sbatch: SequenceBatch, |
1146 | | /// Next sequence index |
1147 | | next_seq_idx: usize, |
1148 | | /// Statistics |
1149 | | stats: BatchStats, |
1150 | | } |
1151 | | |
1152 | | /// Statistics for batch scheduler |
1153 | | #[derive(Debug, Clone, Default, Serialize, Deserialize)] |
1154 | | pub struct BatchStats { |
1155 | | /// Total micro-batches created |
1156 | | pub ubatches_created: u64, |
1157 | | /// Total sequence batches created |
1158 | | pub sbatches_created: u64, |
1159 | | /// Total tokens processed |
1160 | | pub tokens_processed: u64, |
1161 | | /// Total prefill tokens |
1162 | | pub prefill_tokens: u64, |
1163 | | /// Total decode tokens |
1164 | | pub decode_tokens: u64, |
1165 | | /// Average tokens per ubatch |
1166 | | pub avg_ubatch_size: f64, |
1167 | | /// Average sequences per sbatch |
1168 | | pub avg_sbatch_size: f64, |
1169 | | } |
1170 | | |
1171 | | impl BatchScheduler { |
1172 | | /// Create a new batch scheduler with default config |
1173 | 11 | pub fn new() -> Self { |
1174 | 11 | Self::with_config(BatchConfig::default()) |
1175 | 11 | } |
1176 | | |
1177 | | /// Create a new batch scheduler with custom config |
1178 | 14 | pub fn with_config(config: BatchConfig) -> Self { |
1179 | 14 | let max_seqs = config.max_sbatch_sequences; |
1180 | 14 | Self { |
1181 | 14 | config, |
1182 | 14 | sbatch: SequenceBatch::new(max_seqs), |
1183 | 14 | next_seq_idx: 0, |
1184 | 14 | stats: BatchStats::default(), |
1185 | 14 | } |
1186 | 14 | } |
1187 | | |
1188 | | /// Add a new sequence to the batch scheduler |
1189 | 12 | pub fn add_sequence( |
1190 | 12 | &mut self, |
1191 | 12 | slot_id: usize, |
1192 | 12 | request_id: u64, |
1193 | 12 | input_tokens: Vec<u32>, |
1194 | 12 | ) -> Option<usize> { |
1195 | 12 | if self.sbatch.is_full() { |
1196 | 1 | return None; |
1197 | 11 | } |
1198 | | |
1199 | 11 | let seq_idx = self.next_seq_idx; |
1200 | 11 | self.next_seq_idx += 1; |
1201 | | |
1202 | 11 | let entry = SequenceBatchEntry::new(seq_idx, slot_id, request_id).with_tokens(input_tokens); |
1203 | | |
1204 | 11 | if self.sbatch.add_sequence(entry) { |
1205 | 11 | Some(seq_idx) |
1206 | | } else { |
1207 | 0 | None |
1208 | | } |
1209 | 12 | } |
1210 | | |
1211 | | /// Mark sequence as completed and remove from batch |
1212 | 2 | pub fn complete_sequence(&mut self, seq_idx: usize) -> Option<SequenceBatchEntry> { |
1213 | 2 | self.sbatch.remove_sequence(seq_idx) |
1214 | 2 | } |
1215 | | |
1216 | | /// Transition sequence from prefill to decode |
1217 | 4 | pub fn start_decode(&mut self, seq_idx: usize, position: usize) -> bool { |
1218 | 4 | if let Some(entry3 ) = self.sbatch.get_mut(seq_idx) { |
1219 | 3 | entry.is_prefill = false; |
1220 | 3 | entry.position = position; |
1221 | 3 | entry.tokens.clear(); // No longer need prefill tokens |
1222 | 3 | true |
1223 | | } else { |
1224 | 1 | false |
1225 | | } |
1226 | 4 | } |
1227 | | |
1228 | | /// Create a micro-batch from current sequences |
1229 | | /// |
1230 | | /// Returns a micro-batch optimized for the current state: |
1231 | | /// - Prefill: Process all prompt tokens for prefill sequences |
1232 | | /// - Decode: Process one token per decode sequence |
1233 | | /// - Mixed: Combines both (if config allows) |
1234 | 6 | pub fn create_ubatch(&mut self) -> MicroBatch { |
1235 | 6 | let mut ubatch = MicroBatch::with_capacity(self.config.max_ubatch_tokens); |
1236 | | |
1237 | | // Process prefill sequences first (if any) |
1238 | 12 | for entry6 in &self.sbatch.sequences { |
1239 | 6 | if entry.is_prefill { |
1240 | | // Add all prefill tokens |
1241 | 11 | for (i, &token_id) in entry.tokens.iter()4 .enumerate4 () { |
1242 | 11 | if ubatch.len() >= self.config.max_ubatch_tokens { |
1243 | 1 | break; |
1244 | 10 | } |
1245 | 10 | ubatch.add_token(BatchToken::new(token_id, entry.seq_idx, i, true)); |
1246 | | } |
1247 | 2 | } |
1248 | | } |
1249 | | |
1250 | | // If prefer_pure_decode and we have prefill tokens, return early |
1251 | 6 | if self.config.prefer_pure_decode && !ubatch.is_empty()5 && ubatch3 .is_prefill3 () { |
1252 | 3 | self.record_ubatch(&ubatch); |
1253 | 3 | return ubatch; |
1254 | 3 | } |
1255 | | |
1256 | | // Add decode tokens |
1257 | 6 | for entry3 in &self.sbatch.sequences { |
1258 | 3 | if !entry.is_prefill { |
1259 | 2 | if ubatch.len() >= self.config.max_ubatch_tokens { |
1260 | 0 | break; |
1261 | 2 | } |
1262 | | // Decode sequences process one token at their current position |
1263 | | // (the actual token ID will be filled in during inference) |
1264 | 2 | ubatch.add_token(BatchToken::new( |
1265 | | 0, // Placeholder - will be filled by inference |
1266 | 2 | entry.seq_idx, |
1267 | 2 | entry.position, |
1268 | | false, |
1269 | | )); |
1270 | 1 | } |
1271 | | } |
1272 | | |
1273 | 3 | self.record_ubatch(&ubatch); |
1274 | 3 | ubatch |
1275 | 6 | } |
1276 | | |
1277 | | /// Record ubatch statistics |
1278 | 6 | fn record_ubatch(&mut self, ubatch: &MicroBatch) { |
1279 | 6 | if ubatch.is_empty() { |
1280 | 1 | return; |
1281 | 5 | } |
1282 | | |
1283 | 5 | self.stats.ubatches_created += 1; |
1284 | 5 | self.stats.tokens_processed += ubatch.len() as u64; |
1285 | 5 | self.stats.prefill_tokens += ubatch.n_prompt_tokens as u64; |
1286 | 5 | self.stats.decode_tokens += ubatch.n_decode_tokens as u64; |
1287 | | |
1288 | | // Update rolling average |
1289 | 5 | let n = self.stats.ubatches_created as f64; |
1290 | 5 | self.stats.avg_ubatch_size = |
1291 | 5 | self.stats.avg_ubatch_size * (n - 1.0) / n + ubatch.len() as f64 / n; |
1292 | 6 | } |
1293 | | |
1294 | | /// Get the current sequence batch |
1295 | 2 | pub fn sbatch(&self) -> &SequenceBatch { |
1296 | 2 | &self.sbatch |
1297 | 2 | } |
1298 | | |
1299 | | /// Get scheduler statistics |
1300 | 1 | pub fn stats(&self) -> &BatchStats { |
1301 | 1 | &self.stats |
1302 | 1 | } |
1303 | | |
1304 | | /// Get configuration |
1305 | 0 | pub fn config(&self) -> &BatchConfig { |
1306 | 0 | &self.config |
1307 | 0 | } |
1308 | | |
1309 | | /// Number of active sequences |
1310 | 5 | pub fn num_sequences(&self) -> usize { |
1311 | 5 | self.sbatch.len() |
1312 | 5 | } |
1313 | | |
1314 | | /// Check if scheduler has capacity |
1315 | 3 | pub fn has_capacity(&self) -> bool { |
1316 | 3 | !self.sbatch.is_full() |
1317 | 3 | } |
1318 | | |
1319 | | /// Current batch utilization |
1320 | 1 | pub fn utilization(&self) -> f64 { |
1321 | 1 | self.sbatch.utilization |
1322 | 1 | } |
1323 | | } |
1324 | | |
1325 | | impl Default for BatchScheduler { |
1326 | 1 | fn default() -> Self { |
1327 | 1 | Self::new() |
1328 | 1 | } |
1329 | | } |
1330 | | |
1331 | | // ============================================================================ |
1332 | | // DYNAMIC BATCH PRIORITY SCHEDULING |
1333 | | // ============================================================================ |
1334 | | // |
1335 | | // Advanced priority scheduling with: |
1336 | | // - Age-based priority promotion (prevent starvation) |
1337 | | // - Deadline-aware scheduling (SLA support) |
1338 | | // - Priority-weighted token budgets |
1339 | | // - Multi-level feedback queue (MLFQ) style scheduling |
1340 | | // - Fair share allocation across priority levels |
1341 | | // |
1342 | | // Reference: Orca (Yu et al., 2022), vLLM priority scheduling |
1343 | | // ============================================================================ |
1344 | | |
1345 | | /// Deadline specification for a request |
1346 | | #[derive(Debug, Clone, Copy, Serialize, Deserialize)] |
1347 | | pub struct Deadline { |
1348 | | /// Target completion time (milliseconds from arrival) |
1349 | | pub target_latency_ms: u64, |
1350 | | /// Hard deadline (must complete by this time, else drop) |
1351 | | pub hard_deadline_ms: Option<u64>, |
1352 | | /// Soft SLA target (percentage of requests meeting target) |
1353 | | pub sla_target: f64, |
1354 | | } |
1355 | | |
1356 | | impl Default for Deadline { |
1357 | 4 | fn default() -> Self { |
1358 | 4 | Self { |
1359 | 4 | target_latency_ms: 1000, // 1 second default |
1360 | 4 | hard_deadline_ms: None, |
1361 | 4 | sla_target: 0.99, // 99% SLA |
1362 | 4 | } |
1363 | 4 | } |
1364 | | } |
1365 | | |
1366 | | impl Deadline { |
1367 | | /// Create a deadline with target latency |
1368 | 3 | pub fn with_target(target_ms: u64) -> Self { |
1369 | 3 | Self { |
1370 | 3 | target_latency_ms: target_ms, |
1371 | 3 | ..Default::default() |
1372 | 3 | } |
1373 | 3 | } |
1374 | | |
1375 | | /// Create a strict deadline with hard cutoff |
1376 | 1 | pub fn strict(target_ms: u64, hard_ms: u64) -> Self { |
1377 | 1 | Self { |
1378 | 1 | target_latency_ms: target_ms, |
1379 | 1 | hard_deadline_ms: Some(hard_ms), |
1380 | 1 | sla_target: 1.0, |
1381 | 1 | } |
1382 | 1 | } |
1383 | | } |
1384 | | |
1385 | | /// Dynamic priority configuration |
1386 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
1387 | | pub struct DynamicPriorityConfig { |
1388 | | /// Enable age-based priority promotion |
1389 | | pub enable_age_promotion: bool, |
1390 | | /// Time (ms) before promoting to next priority level |
1391 | | pub promotion_interval_ms: u64, |
1392 | | /// Maximum priority level after promotion (prevent runaway) |
1393 | | pub max_promoted_priority: Priority, |
1394 | | /// Token budget per priority level (proportion of batch) |
1395 | | pub priority_budgets: [f64; 4], // Low, Normal, High, Critical |
1396 | | /// Enable deadline-aware scheduling |
1397 | | pub enable_deadline_scheduling: bool, |
1398 | | /// Urgency boost factor for approaching deadlines |
1399 | | pub urgency_factor: f64, |
1400 | | /// Minimum tokens to allocate per request |
1401 | | pub min_tokens_per_request: usize, |
1402 | | /// Enable fair share scheduling |
1403 | | pub enable_fair_share: bool, |
1404 | | } |
1405 | | |
1406 | | impl Default for DynamicPriorityConfig { |
1407 | 19 | fn default() -> Self { |
1408 | 19 | Self { |
1409 | 19 | enable_age_promotion: true, |
1410 | 19 | promotion_interval_ms: 5000, // Promote after 5 seconds |
1411 | 19 | max_promoted_priority: Priority::High, |
1412 | 19 | // Budget allocation: Low=5%, Normal=30%, High=40%, Critical=25% |
1413 | 19 | priority_budgets: [0.05, 0.30, 0.40, 0.25], |
1414 | 19 | enable_deadline_scheduling: true, |
1415 | 19 | urgency_factor: 2.0, |
1416 | 19 | min_tokens_per_request: 1, |
1417 | 19 | enable_fair_share: true, |
1418 | 19 | } |
1419 | 19 | } |
1420 | | } |
1421 | | |
1422 | | impl DynamicPriorityConfig { |
1423 | | /// Create config with custom budgets |
1424 | 1 | pub fn with_budgets(budgets: [f64; 4]) -> Self { |
1425 | 1 | Self { |
1426 | 1 | priority_budgets: budgets, |
1427 | 1 | ..Default::default() |
1428 | 1 | } |
1429 | 1 | } |
1430 | | |
1431 | | /// Disable age promotion |
1432 | 3 | pub fn no_promotion(mut self) -> Self { |
1433 | 3 | self.enable_age_promotion = false; |
1434 | 3 | self |
1435 | 3 | } |
1436 | | |
1437 | | /// Set promotion interval |
1438 | 1 | pub fn with_promotion_interval(mut self, ms: u64) -> Self { |
1439 | 1 | self.promotion_interval_ms = ms; |
1440 | 1 | self |
1441 | 1 | } |
1442 | | } |
1443 | | |
1444 | | /// Request entry with dynamic priority tracking |
1445 | | #[derive(Debug, Clone)] |
1446 | | pub struct DynamicRequest { |
1447 | | /// Base request data |
1448 | | pub request_id: u64, |
1449 | | /// Input tokens |
1450 | | pub input_ids: Vec<u32>, |
1451 | | /// Maximum tokens to generate |
1452 | | pub max_tokens: usize, |
1453 | | /// Original priority (as submitted) |
1454 | | pub original_priority: Priority, |
1455 | | /// Effective priority (may be promoted) |
1456 | | pub effective_priority: Priority, |
1457 | | /// Arrival time |
1458 | | pub arrival_time: Instant, |
1459 | | /// Deadline specification |
1460 | | pub deadline: Option<Deadline>, |
1461 | | /// Number of times priority was promoted |
1462 | | pub promotions: u32, |
1463 | | /// Current state |
1464 | | pub state: SequenceState, |
1465 | | /// Generated tokens |
1466 | | pub generated_tokens: Vec<u32>, |
1467 | | /// Sequence ID (when scheduled) |
1468 | | pub seq_id: Option<SeqId>, |
1469 | | /// Time-to-first-token (if started) |
1470 | | pub ttft_ms: Option<f64>, |
1471 | | } |
1472 | | |
1473 | | impl DynamicRequest { |
1474 | | /// Create a new dynamic request |
1475 | 30 | pub fn new(request_id: u64, input_ids: Vec<u32>, max_tokens: usize) -> Self { |
1476 | 30 | Self { |
1477 | 30 | request_id, |
1478 | 30 | input_ids, |
1479 | 30 | max_tokens, |
1480 | 30 | original_priority: Priority::Normal, |
1481 | 30 | effective_priority: Priority::Normal, |
1482 | 30 | arrival_time: Instant::now(), |
1483 | 30 | deadline: None, |
1484 | 30 | promotions: 0, |
1485 | 30 | state: SequenceState::Waiting, |
1486 | 30 | generated_tokens: Vec::new(), |
1487 | 30 | seq_id: None, |
1488 | 30 | ttft_ms: None, |
1489 | 30 | } |
1490 | 30 | } |
1491 | | |
1492 | | /// Set priority |
1493 | 23 | pub fn with_priority(mut self, priority: Priority) -> Self { |
1494 | 23 | self.original_priority = priority; |
1495 | 23 | self.effective_priority = priority; |
1496 | 23 | self |
1497 | 23 | } |
1498 | | |
1499 | | /// Set deadline |
1500 | 2 | pub fn with_deadline(mut self, deadline: Deadline) -> Self { |
1501 | 2 | self.deadline = Some(deadline); |
1502 | 2 | self |
1503 | 2 | } |
1504 | | |
1505 | | /// Wait time since arrival |
1506 | 20 | pub fn wait_time_ms(&self) -> u64 { |
1507 | 20 | self.arrival_time.elapsed().as_millis() as u64 |
1508 | 20 | } |
1509 | | |
1510 | | /// Check if deadline is approaching (within 2x target latency) |
1511 | 1 | pub fn is_urgent(&self) -> bool { |
1512 | 1 | if let Some(deadline0 ) = &self.deadline { |
1513 | 0 | let elapsed = self.wait_time_ms(); |
1514 | 0 | elapsed >= deadline.target_latency_ms / 2 |
1515 | | } else { |
1516 | 1 | false |
1517 | | } |
1518 | 1 | } |
1519 | | |
1520 | | /// Check if hard deadline has passed |
1521 | 13 | pub fn is_expired(&self) -> bool { |
1522 | 13 | if let Some(deadline1 ) = &self.deadline { |
1523 | 1 | if let Some(hard0 ) = deadline.hard_deadline_ms { |
1524 | 0 | return self.wait_time_ms() > hard; |
1525 | 1 | } |
1526 | 12 | } |
1527 | 13 | false |
1528 | 13 | } |
1529 | | |
1530 | | /// Calculate urgency score (0.0 to 1.0+) |
1531 | | /// Higher score = more urgent |
1532 | 2 | pub fn urgency_score(&self) -> f64 { |
1533 | 2 | if let Some(deadline1 ) = &self.deadline { |
1534 | 1 | let elapsed = self.wait_time_ms() as f64; |
1535 | 1 | let target = deadline.target_latency_ms as f64; |
1536 | 1 | if target > 0.0 { |
1537 | 0 | elapsed / target |
1538 | | } else { |
1539 | 1 | 0.0 |
1540 | | } |
1541 | | } else { |
1542 | 1 | 0.0 |
1543 | | } |
1544 | 2 | } |
1545 | | |
1546 | | /// Remaining tokens to generate |
1547 | 11 | pub fn remaining_tokens(&self) -> usize { |
1548 | 11 | self.max_tokens.saturating_sub(self.generated_tokens.len()) |
1549 | 11 | } |
1550 | | |
1551 | | /// Total tokens (input + generated) |
1552 | 2 | pub fn total_tokens(&self) -> usize { |
1553 | 2 | self.input_ids.len() + self.generated_tokens.len() |
1554 | 2 | } |
1555 | | } |
1556 | | |
1557 | | /// Statistics for dynamic priority scheduling |
1558 | | #[derive(Debug, Clone, Default, Serialize, Deserialize)] |
1559 | | pub struct DynamicSchedulerStats { |
1560 | | /// Total requests processed |
1561 | | pub total_requests: u64, |
1562 | | /// Requests completed |
1563 | | pub completed_requests: u64, |
1564 | | /// Requests that met SLA |
1565 | | pub sla_met: u64, |
1566 | | /// Requests that missed SLA |
1567 | | pub sla_missed: u64, |
1568 | | /// Requests dropped (hard deadline exceeded) |
1569 | | pub dropped_requests: u64, |
1570 | | /// Total priority promotions |
1571 | | pub promotions: u64, |
1572 | | /// Average time-to-first-token (ms) |
1573 | | pub avg_ttft_ms: f64, |
1574 | | /// p99 time-to-first-token (ms) |
1575 | | pub p99_ttft_ms: f64, |
1576 | | /// Tokens allocated per priority level |
1577 | | pub tokens_by_priority: [u64; 4], |
1578 | | /// Current queue depth per priority |
1579 | | pub queue_depth_by_priority: [usize; 4], |
1580 | | } |
1581 | | |
1582 | | /// Dynamic batch priority scheduler |
1583 | | /// |
1584 | | /// Implements advanced priority scheduling with: |
1585 | | /// - Age-based priority promotion (MLFQ-style) |
1586 | | /// - Deadline-aware scheduling for SLA compliance |
1587 | | /// - Fair share token budget allocation |
1588 | | /// - Urgency-based boosting for time-sensitive requests |
1589 | | pub struct DynamicPriorityScheduler { |
1590 | | /// Configuration |
1591 | | config: DynamicPriorityConfig, |
1592 | | /// All requests by ID |
1593 | | requests: HashMap<u64, DynamicRequest>, |
1594 | | /// Priority queues (one per level) |
1595 | | priority_queues: [VecDeque<u64>; 4], |
1596 | | /// Running requests |
1597 | | running: Vec<u64>, |
1598 | | /// Next request ID |
1599 | | next_request_id: u64, |
1600 | | /// Statistics |
1601 | | stats: DynamicSchedulerStats, |
1602 | | /// TTFT samples for percentile calculation |
1603 | | ttft_samples: Vec<f64>, |
1604 | | /// Total batch token budget |
1605 | | batch_token_budget: usize, |
1606 | | } |
1607 | | |
1608 | | impl DynamicPriorityScheduler { |
1609 | | /// Create a new dynamic priority scheduler |
1610 | 14 | pub fn new(batch_token_budget: usize) -> Self { |
1611 | 14 | Self::with_config(batch_token_budget, DynamicPriorityConfig::default()) |
1612 | 14 | } |
1613 | | |
1614 | | /// Create with custom configuration |
1615 | 17 | pub fn with_config(batch_token_budget: usize, config: DynamicPriorityConfig) -> Self { |
1616 | 17 | Self { |
1617 | 17 | config, |
1618 | 17 | requests: HashMap::new(), |
1619 | 17 | priority_queues: [ |
1620 | 17 | VecDeque::new(), |
1621 | 17 | VecDeque::new(), |
1622 | 17 | VecDeque::new(), |
1623 | 17 | VecDeque::new(), |
1624 | 17 | ], |
1625 | 17 | running: Vec::new(), |
1626 | 17 | next_request_id: 0, |
1627 | 17 | stats: DynamicSchedulerStats::default(), |
1628 | 17 | ttft_samples: Vec::new(), |
1629 | 17 | batch_token_budget, |
1630 | 17 | } |
1631 | 17 | } |
1632 | | |
1633 | | /// Add a request with priority and optional deadline |
1634 | 22 | pub fn add_request( |
1635 | 22 | &mut self, |
1636 | 22 | input_ids: Vec<u32>, |
1637 | 22 | max_tokens: usize, |
1638 | 22 | priority: Priority, |
1639 | 22 | deadline: Option<Deadline>, |
1640 | 22 | ) -> u64 { |
1641 | 22 | let request_id = self.next_request_id; |
1642 | 22 | self.next_request_id += 1; |
1643 | | |
1644 | 22 | let mut request = |
1645 | 22 | DynamicRequest::new(request_id, input_ids, max_tokens).with_priority(priority); |
1646 | 22 | if let Some(d1 ) = deadline { |
1647 | 1 | request = request.with_deadline(d); |
1648 | 21 | } |
1649 | | |
1650 | | // Add to appropriate priority queue |
1651 | 22 | let queue_idx = priority as usize; |
1652 | 22 | self.priority_queues[queue_idx].push_back(request_id); |
1653 | 22 | self.requests.insert(request_id, request); |
1654 | | |
1655 | 22 | self.stats.total_requests += 1; |
1656 | 22 | self.update_queue_depths(); |
1657 | | |
1658 | 22 | request_id |
1659 | 22 | } |
1660 | | |
1661 | | /// Add a simple request (Normal priority, no deadline) |
1662 | 3 | pub fn add_simple_request(&mut self, input_ids: Vec<u32>, max_tokens: usize) -> u64 { |
1663 | 3 | self.add_request(input_ids, max_tokens, Priority::Normal, None) |
1664 | 3 | } |
1665 | | |
1666 | | /// Perform age-based priority promotion |
1667 | 8 | pub fn promote_aged_requests(&mut self) { |
1668 | 8 | if !self.config.enable_age_promotion { |
1669 | 1 | return; |
1670 | 7 | } |
1671 | | |
1672 | 7 | let promotion_threshold = self.config.promotion_interval_ms; |
1673 | 7 | let max_priority = self.config.max_promoted_priority; |
1674 | | |
1675 | | // Check each queue except Critical (can't promote beyond Critical) |
1676 | 28 | for queue_idx21 in 0..3 { |
1677 | 21 | let current_priority = match queue_idx { |
1678 | 7 | 0 => Priority::Low, |
1679 | 7 | 1 => Priority::Normal, |
1680 | 7 | 2 => Priority::High, |
1681 | 0 | _ => continue, |
1682 | | }; |
1683 | | |
1684 | | // Skip if current priority is already at max promoted level |
1685 | 21 | if current_priority >= max_priority { |
1686 | 7 | continue; |
1687 | 14 | } |
1688 | | |
1689 | | // Find requests to promote |
1690 | 14 | let mut to_promote = Vec::new(); |
1691 | 14 | for &request_id9 in &self.priority_queues[queue_idx] { |
1692 | 9 | if let Some(request) = self.requests.get(&request_id) { |
1693 | 9 | let promotions_time = promotion_threshold * (request.promotions as u64 + 1); |
1694 | 9 | if request.wait_time_ms() >= promotions_time { |
1695 | 0 | to_promote.push(request_id); |
1696 | 9 | } |
1697 | 0 | } |
1698 | | } |
1699 | | |
1700 | | // Promote requests |
1701 | 14 | for request_id0 in to_promote { |
1702 | 0 | self.promote_request(request_id); |
1703 | 0 | } |
1704 | | } |
1705 | 8 | } |
1706 | | |
1707 | | /// Promote a single request to next priority level |
1708 | 0 | fn promote_request(&mut self, request_id: u64) { |
1709 | 0 | if let Some(request) = self.requests.get_mut(&request_id) { |
1710 | 0 | let current_idx = request.effective_priority as usize; |
1711 | 0 | let max_idx = self.config.max_promoted_priority as usize; |
1712 | | |
1713 | 0 | if current_idx < max_idx { |
1714 | | // Remove from current queue |
1715 | 0 | self.priority_queues[current_idx].retain(|&id| id != request_id); |
1716 | | |
1717 | | // Promote |
1718 | 0 | let new_priority = match current_idx + 1 { |
1719 | 0 | 1 => Priority::Normal, |
1720 | 0 | 2 => Priority::High, |
1721 | 0 | 3 => Priority::Critical, |
1722 | 0 | _ => return, |
1723 | | }; |
1724 | 0 | request.effective_priority = new_priority; |
1725 | 0 | request.promotions += 1; |
1726 | | |
1727 | | // Add to new queue (at front since it's been waiting) |
1728 | 0 | self.priority_queues[current_idx + 1].push_front(request_id); |
1729 | 0 | self.stats.promotions += 1; |
1730 | 0 | } |
1731 | 0 | } |
1732 | 0 | } |
1733 | | |
1734 | | /// Drop expired requests (hard deadline exceeded) |
1735 | 7 | pub fn drop_expired(&mut self) -> Vec<u64> { |
1736 | 7 | let mut dropped = Vec::new(); |
1737 | | |
1738 | 35 | for queue28 in &mut self.priority_queues { |
1739 | 28 | let mut to_remove = Vec::new(); |
1740 | 28 | for &request_id12 in queue.iter() { |
1741 | 12 | if let Some(request) = self.requests.get(&request_id) { |
1742 | 12 | if request.is_expired() { |
1743 | 0 | to_remove.push(request_id); |
1744 | 12 | } |
1745 | 0 | } |
1746 | | } |
1747 | | |
1748 | 28 | for request_id0 in to_remove { |
1749 | 0 | queue.retain(|&id| id != request_id); |
1750 | 0 | if let Some(mut request) = self.requests.remove(&request_id) { |
1751 | 0 | request.state = SequenceState::Failed; |
1752 | 0 | dropped.push(request_id); |
1753 | 0 | self.stats.dropped_requests += 1; |
1754 | 0 | } |
1755 | | } |
1756 | | } |
1757 | | |
1758 | 7 | self.update_queue_depths(); |
1759 | 7 | dropped |
1760 | 7 | } |
1761 | | |
1762 | | /// Schedule requests using dynamic priority and token budgets |
1763 | | /// |
1764 | | /// Returns (scheduled_request_ids, tokens_allocated_per_request) |
1765 | 7 | pub fn schedule(&mut self, available_slots: usize) -> Vec<(u64, usize)> { |
1766 | | // First, handle promotions and expirations |
1767 | 7 | self.promote_aged_requests(); |
1768 | 7 | self.drop_expired(); |
1769 | | |
1770 | 7 | let mut scheduled = Vec::new(); |
1771 | 7 | let mut remaining_budget = self.batch_token_budget; |
1772 | 7 | let mut remaining_slots = available_slots; |
1773 | | |
1774 | | // Calculate token budgets per priority level |
1775 | 7 | let budgets: [usize; 4] = if self.config.enable_fair_share { |
1776 | 6 | self.config |
1777 | 6 | .priority_budgets |
1778 | 24 | .map6 (|b| (b * self.batch_token_budget as f64) as usize) |
1779 | | } else { |
1780 | 1 | [ |
1781 | 1 | remaining_budget, |
1782 | 1 | remaining_budget, |
1783 | 1 | remaining_budget, |
1784 | 1 | remaining_budget, |
1785 | 1 | ] |
1786 | | }; |
1787 | | |
1788 | | // Schedule from highest priority to lowest |
1789 | 22 | for queue_idx in (0..4)7 .rev7 () { |
1790 | 22 | if remaining_slots == 0 || remaining_budget == 018 { |
1791 | 5 | break; |
1792 | 17 | } |
1793 | | |
1794 | 17 | let queue = &mut self.priority_queues[queue_idx]; |
1795 | 17 | let mut priority_budget = budgets[queue_idx].min(remaining_budget); |
1796 | | |
1797 | | // Sort queue by urgency for deadline-aware scheduling |
1798 | 17 | if self.config.enable_deadline_scheduling { |
1799 | 17 | let mut sorted: Vec<_> = queue.iter().copied().collect(); |
1800 | 17 | sorted.sort_by(|&a, &b| {0 |
1801 | 0 | let req_a = self.requests.get(&a); |
1802 | 0 | let req_b = self.requests.get(&b); |
1803 | 0 | match (req_a, req_b) { |
1804 | 0 | (Some(ra), Some(rb)) => rb |
1805 | 0 | .urgency_score() |
1806 | 0 | .partial_cmp(&ra.urgency_score()) |
1807 | 0 | .unwrap_or(std::cmp::Ordering::Equal), |
1808 | 0 | _ => std::cmp::Ordering::Equal, |
1809 | | } |
1810 | 0 | }); |
1811 | 17 | *queue = sorted.into_iter().collect(); |
1812 | 0 | } |
1813 | | |
1814 | | // Schedule requests from this priority level |
1815 | 17 | let mut scheduled_from_queue = Vec::new(); |
1816 | 17 | for &request_id9 in queue.iter() { |
1817 | 9 | if remaining_slots == 0 || priority_budget < self.config.min_tokens_per_request { |
1818 | 0 | break; |
1819 | 9 | } |
1820 | | |
1821 | 9 | if let Some(request) = self.requests.get(&request_id) { |
1822 | | // Calculate tokens to allocate |
1823 | 9 | let tokens_needed = request.remaining_tokens().max(1); |
1824 | 9 | let tokens_to_allocate = tokens_needed |
1825 | 9 | .min(priority_budget) |
1826 | 9 | .max(self.config.min_tokens_per_request); |
1827 | | |
1828 | 9 | if tokens_to_allocate > 0 { |
1829 | 9 | scheduled.push((request_id, tokens_to_allocate)); |
1830 | 9 | scheduled_from_queue.push(request_id); |
1831 | 9 | priority_budget = priority_budget.saturating_sub(tokens_to_allocate); |
1832 | 9 | remaining_budget = remaining_budget.saturating_sub(tokens_to_allocate); |
1833 | 9 | remaining_slots -= 1; |
1834 | 9 | |
1835 | 9 | // Track tokens by priority |
1836 | 9 | self.stats.tokens_by_priority[queue_idx] += tokens_to_allocate as u64; |
1837 | 9 | }0 |
1838 | 0 | } |
1839 | | } |
1840 | | |
1841 | | // Remove scheduled requests from queue and update state |
1842 | 26 | for request_id9 in scheduled_from_queue { |
1843 | 9 | queue.retain(|&id| id != request_id); |
1844 | 9 | if let Some(request) = self.requests.get_mut(&request_id) { |
1845 | 9 | request.state = SequenceState::Running; |
1846 | 9 | self.running.push(request_id); |
1847 | | |
1848 | | // Record TTFT if first time running |
1849 | 9 | if request.ttft_ms.is_none() { |
1850 | 9 | let ttft = request.wait_time_ms() as f64; |
1851 | 9 | request.ttft_ms = Some(ttft); |
1852 | 9 | self.ttft_samples.push(ttft); |
1853 | 9 | }0 |
1854 | 0 | } |
1855 | | } |
1856 | | } |
1857 | | |
1858 | 7 | self.update_queue_depths(); |
1859 | 7 | scheduled |
1860 | 7 | } |
1861 | | |
1862 | | /// Complete a request and update statistics |
1863 | 3 | pub fn complete_request(&mut self, request_id: u64) -> Option<DynamicRequest> { |
1864 | | // Remove from running |
1865 | 3 | self.running.retain(|&id| id2 != request_id2 ); |
1866 | | |
1867 | 3 | if let Some(mut request2 ) = self.requests.remove(&request_id) { |
1868 | 2 | request.state = SequenceState::Completed; |
1869 | 2 | self.stats.completed_requests += 1; |
1870 | | |
1871 | | // Check SLA compliance |
1872 | 2 | if let Some(deadline1 ) = &request.deadline { |
1873 | 1 | let elapsed = request.wait_time_ms(); |
1874 | 1 | if elapsed <= deadline.target_latency_ms { |
1875 | 1 | self.stats.sla_met += 1; |
1876 | 1 | } else { |
1877 | 0 | self.stats.sla_missed += 1; |
1878 | 0 | } |
1879 | 1 | } |
1880 | | |
1881 | | // Update average TTFT |
1882 | 2 | self.update_ttft_stats(); |
1883 | | |
1884 | 2 | Some(request) |
1885 | | } else { |
1886 | 1 | None |
1887 | | } |
1888 | 3 | } |
1889 | | |
1890 | | /// Update TTFT statistics |
1891 | 2 | fn update_ttft_stats(&mut self) { |
1892 | 2 | if self.ttft_samples.is_empty() { |
1893 | 0 | return; |
1894 | 2 | } |
1895 | | |
1896 | | // Average |
1897 | 2 | let sum: f64 = self.ttft_samples.iter().sum(); |
1898 | 2 | self.stats.avg_ttft_ms = sum / self.ttft_samples.len() as f64; |
1899 | | |
1900 | | // P99 |
1901 | 2 | let mut sorted = self.ttft_samples.clone(); |
1902 | 2 | sorted.sort_by(|a, b| a0 .partial_cmp0 (b0 ).unwrap_or0 (std::cmp::Ordering::Equal0 )); |
1903 | 2 | let p99_idx = ((sorted.len() as f64) * 0.99) as usize; |
1904 | 2 | self.stats.p99_ttft_ms = sorted |
1905 | 2 | .get(p99_idx.min(sorted.len() - 1)) |
1906 | 2 | .copied() |
1907 | 2 | .unwrap_or(0.0); |
1908 | 2 | } |
1909 | | |
1910 | | /// Update queue depth statistics |
1911 | 36 | fn update_queue_depths(&mut self) { |
1912 | 144 | for (i, queue) in self.priority_queues36 .iter36 ().enumerate36 () { |
1913 | 144 | self.stats.queue_depth_by_priority[i] = queue.len(); |
1914 | 144 | } |
1915 | 36 | } |
1916 | | |
1917 | | /// Get a request by ID |
1918 | 2 | pub fn get_request(&self, request_id: u64) -> Option<&DynamicRequest> { |
1919 | 2 | self.requests.get(&request_id) |
1920 | 2 | } |
1921 | | |
1922 | | /// Get statistics |
1923 | 5 | pub fn stats(&self) -> &DynamicSchedulerStats { |
1924 | 5 | &self.stats |
1925 | 5 | } |
1926 | | |
1927 | | /// Get configuration |
1928 | 1 | pub fn config(&self) -> &DynamicPriorityConfig { |
1929 | 1 | &self.config |
1930 | 1 | } |
1931 | | |
1932 | | /// Total waiting requests |
1933 | 4 | pub fn waiting_count(&self) -> usize { |
1934 | 4 | self.priority_queues.iter().map(VecDeque::len).sum() |
1935 | 4 | } |
1936 | | |
1937 | | /// Running requests |
1938 | 3 | pub fn running_count(&self) -> usize { |
1939 | 3 | self.running.len() |
1940 | 3 | } |
1941 | | |
1942 | | /// SLA compliance rate (0.0 to 1.0) |
1943 | 2 | pub fn sla_compliance_rate(&self) -> f64 { |
1944 | 2 | let total = self.stats.sla_met + self.stats.sla_missed; |
1945 | 2 | if total == 0 { |
1946 | 1 | 1.0 |
1947 | | } else { |
1948 | 1 | self.stats.sla_met as f64 / total as f64 |
1949 | | } |
1950 | 2 | } |
1951 | | |
1952 | | /// Get queue depth for a priority level |
1953 | 8 | pub fn queue_depth(&self, priority: Priority) -> usize { |
1954 | 8 | self.priority_queues[priority as usize].len() |
1955 | 8 | } |
1956 | | } |
1957 | | |
1958 | | // ============================================================================ |
1959 | | // Tests |
1960 | | // ============================================================================ |
1961 | | |
1962 | | // Tests extracted to tests.rs (PMAT-802) |
1963 | | #[cfg(test)] |
1964 | | #[path = "tests.rs"] |
1965 | | mod scheduler_tests; |