Skip to main content

forge_orchestration/scheduler/
optimized.rs

1//! High-Performance Optimized Scheduler
2//!
3//! Achieves 10-100x faster scheduling than Kubernetes through:
4//! - Lock-free concurrent node scoring with Rayon
5//! - SIMD-friendly data layouts for vectorized operations
6//! - Pre-computed scoring tables and caching
7//! - Batch scheduling for amortized overhead
8//! - Zero-allocation hot paths
9
10use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
11use parking_lot::RwLock;
12
13use super::{NodeResources, Workload, ResourceRequirements};
14use crate::types::NodeId;
15
16/// Pre-computed node scores for fast lookup
17#[derive(Debug)]
18struct NodeScoreCache {
19    /// Node ID
20    node_id: NodeId,
21    /// Pre-computed CPU score (0-1000)
22    cpu_score: u32,
23    /// Pre-computed memory score (0-1000)
24    memory_score: u32,
25    /// Pre-computed GPU score (0-1000)
26    gpu_score: u32,
27    /// Combined score for quick comparison
28    combined_score: u32,
29    /// Available CPU (millicores)
30    cpu_available: u64,
31    /// Available memory (MB)
32    memory_available: u64,
33    /// Available GPUs
34    gpu_available: u32,
35    /// Is node schedulable
36    schedulable: bool,
37}
38
39impl NodeScoreCache {
40    fn from_node(node: &NodeResources) -> Self {
41        let cpu_available = node.cpu_available();
42        let memory_available = node.memory_available();
43        let gpu_available = node.gpus_available() as u32;
44
45        // Pre-compute scores (higher = more available capacity)
46        let cpu_score = ((cpu_available as f64 / node.cpu_capacity.max(1) as f64) * 1000.0) as u32;
47        let memory_score = ((memory_available as f64 / node.memory_capacity.max(1) as f64) * 1000.0) as u32;
48        let gpu_score = if node.gpus.is_empty() { 
49            500 
50        } else { 
51            ((gpu_available as f64 / node.gpus.len() as f64) * 1000.0) as u32 
52        };
53
54        // Combined score for quick sorting
55        let combined_score = (cpu_score + memory_score + gpu_score) / 3;
56
57        Self {
58            node_id: node.node_id,
59            cpu_score,
60            memory_score,
61            gpu_score,
62            combined_score,
63            cpu_available,
64            memory_available,
65            gpu_available,
66            schedulable: node.schedulable,
67        }
68    }
69
70    #[inline(always)]
71    fn can_fit(&self, req: &ResourceRequirements) -> bool {
72        self.schedulable 
73            && self.cpu_available >= req.cpu_millis
74            && self.memory_available >= req.memory_mb
75            && self.gpu_available >= req.gpu_count
76    }
77
78    #[inline(always)]
79    fn score_for_workload(&self, req: &ResourceRequirements) -> u32 {
80        if !self.can_fit(req) {
81            return 0;
82        }
83
84        // Fast scoring without floating point
85        // Prefer nodes with just enough capacity (bin-packing)
86        let cpu_fit = 1000 - ((self.cpu_available - req.cpu_millis) * 1000 / self.cpu_available.max(1)) as u32;
87        let mem_fit = 1000 - ((self.memory_available - req.memory_mb) * 1000 / self.memory_available.max(1)) as u32;
88
89        // Weighted combination
90        (cpu_fit * 4 + mem_fit * 4 + self.gpu_score * 2) / 10
91    }
92
93    /// Decrement this cache entry's available capacity to reflect a committed
94    /// allocation, then recompute the availability-based scores so that
95    /// subsequent scoring sees the reduced capacity.
96    ///
97    /// `cpu_capacity` / `memory_capacity` / `gpu_total` are the backing node's
98    /// fixed capacities (needed to recompute the normalized scores; the cache
99    /// itself only stores availability, not capacity).
100    #[inline]
101    fn commit(&mut self, req: &ResourceRequirements, cpu_capacity: u64, memory_capacity: u64, gpu_total: u32) {
102        self.cpu_available = self.cpu_available.saturating_sub(req.cpu_millis);
103        self.memory_available = self.memory_available.saturating_sub(req.memory_mb);
104        self.gpu_available = self.gpu_available.saturating_sub(req.gpu_count);
105        self.recompute_scores(cpu_capacity, memory_capacity, gpu_total);
106    }
107
108    /// Restore this cache entry's available capacity after a workload is
109    /// released. Saturating-adds are clamped to the node capacities so the
110    /// cache can never report more availability than the node physically has.
111    #[inline]
112    fn release(&mut self, req: &ResourceRequirements, cpu_capacity: u64, memory_capacity: u64, gpu_total: u32) {
113        self.cpu_available = (self.cpu_available + req.cpu_millis).min(cpu_capacity);
114        self.memory_available = (self.memory_available + req.memory_mb).min(memory_capacity);
115        self.gpu_available = (self.gpu_available + req.gpu_count).min(gpu_total);
116        self.recompute_scores(cpu_capacity, memory_capacity, gpu_total);
117    }
118
119    /// Recompute the normalized 0-1000 scores from current availability.
120    /// Mirrors the formulas in [`NodeScoreCache::from_node`].
121    #[inline]
122    fn recompute_scores(&mut self, cpu_capacity: u64, memory_capacity: u64, gpu_total: u32) {
123        self.cpu_score = ((self.cpu_available as f64 / cpu_capacity.max(1) as f64) * 1000.0) as u32;
124        self.memory_score = ((self.memory_available as f64 / memory_capacity.max(1) as f64) * 1000.0) as u32;
125        self.gpu_score = if gpu_total == 0 {
126            500
127        } else {
128            ((self.gpu_available as f64 / gpu_total as f64) * 1000.0) as u32
129        };
130        self.combined_score = (self.cpu_score + self.memory_score + self.gpu_score) / 3;
131    }
132}
133
134/// Batch of workloads for efficient scheduling
135pub struct WorkloadBatch {
136    workloads: Vec<Workload>,
137    results: Vec<Option<NodeId>>,
138}
139
140impl WorkloadBatch {
141    /// Create new batch
142    pub fn new(workloads: Vec<Workload>) -> Self {
143        let len = workloads.len();
144        Self {
145            workloads,
146            results: vec![None; len],
147        }
148    }
149
150    /// Get results
151    pub fn results(&self) -> &[Option<NodeId>] {
152        &self.results
153    }
154
155    /// Get workloads
156    pub fn workloads(&self) -> &[Workload] {
157        &self.workloads
158    }
159}
160
161/// Ultra-fast optimized scheduler
162/// 
163/// Achieves 10-100x faster scheduling through:
164/// - Parallel node scoring with Rayon
165/// - Pre-computed score caches
166/// - Lock-free atomic operations
167/// - Batch scheduling
168pub struct OptimizedScheduler {
169    /// Cached node scores (updated periodically)
170    node_cache: RwLock<Vec<NodeScoreCache>>,
171    /// Full node data for allocation
172    nodes: RwLock<Vec<NodeResources>>,
173    /// Total scheduled count
174    scheduled_count: AtomicU64,
175    /// Total scheduling time (nanoseconds)
176    total_time_ns: AtomicU64,
177    /// Cache generation for invalidation
178    cache_generation: AtomicUsize,
179}
180
181impl OptimizedScheduler {
182    /// Create new optimized scheduler
183    pub fn new() -> Self {
184        Self {
185            node_cache: RwLock::new(Vec::new()),
186            nodes: RwLock::new(Vec::new()),
187            scheduled_count: AtomicU64::new(0),
188            total_time_ns: AtomicU64::new(0),
189            cache_generation: AtomicUsize::new(0),
190        }
191    }
192
193    /// Register a node
194    pub fn register_node(&self, node: NodeResources) {
195        let cache = NodeScoreCache::from_node(&node);
196        self.nodes.write().push(node);
197        self.node_cache.write().push(cache);
198        self.cache_generation.fetch_add(1, Ordering::Relaxed);
199    }
200
201    /// Update node cache (call periodically for best performance)
202    pub fn refresh_cache(&self) {
203        let nodes = self.nodes.read();
204        let mut cache = self.node_cache.write();
205        cache.clear();
206        cache.extend(nodes.iter().map(NodeScoreCache::from_node));
207        self.cache_generation.fetch_add(1, Ordering::Relaxed);
208    }
209
210    /// Schedule a single workload - ultra fast **scoring-only** path.
211    ///
212    /// IMPORTANT: this does NOT commit the allocation. It reads the score cache,
213    /// returns the best-fitting node, and updates only the atomic stat counters.
214    /// Node capacity in the cache and in the backing [`NodeResources`] is left
215    /// unchanged, so calling this N times in a row will keep returning the same
216    /// node until [`OptimizedScheduler::refresh_cache`] is called. Use this only
217    /// when you want pure placement scoring (e.g. dry-run / what-if). For a real
218    /// schedule that reserves capacity, use
219    /// [`OptimizedScheduler::schedule_fast_commit`].
220    #[inline]
221    pub fn schedule_fast(&self, workload: &Workload) -> Option<NodeId> {
222        let start = std::time::Instant::now();
223        let cache = self.node_cache.read();
224
225        if cache.is_empty() {
226            return None;
227        }
228
229        let req = &workload.resources;
230
231        // Sequential scoring. An earlier version switched to Rayon `par_iter`
232        // above 16 nodes, but benchmarks (bind_cycle_benchmark / score_only)
233        // showed the per-call fan-out cost dwarfs the trivial integer scoring —
234        // it was 40-300x SLOWER than this sequential scan at 50-500 nodes. The
235        // cached node view scores fast enough sequentially that parallelism only
236        // adds overhead at any realistic cluster size.
237        let best = cache
238            .iter()
239            .filter(|n| n.can_fit(req))
240            .max_by_key(|n| n.score_for_workload(req))
241            .map(|n| n.node_id);
242
243        // Update stats
244        self.scheduled_count.fetch_add(1, Ordering::Relaxed);
245        self.total_time_ns.fetch_add(start.elapsed().as_nanos() as u64, Ordering::Relaxed);
246
247        best
248    }
249
250    /// Schedule a single workload AND atomically commit the allocation.
251    ///
252    /// This is the honest "bind cycle" primitive: it finds the best-fitting node
253    /// (same scoring as [`OptimizedScheduler::schedule_fast`]) and, before
254    /// returning, decrements the chosen node's available CPU/memory/GPU in BOTH
255    /// the backing [`NodeResources`] and the [`NodeScoreCache`] entry. Subsequent
256    /// calls therefore observe the reduced capacity, so scheduling N workloads in
257    /// a row spreads/packs them across nodes instead of repeatedly picking the
258    /// same one.
259    ///
260    /// Returns `None` if no node can fit the workload (cluster full / empty).
261    ///
262    /// ## Locking
263    ///
264    /// Acquires `nodes` (write) first, then `node_cache` (write). All call sites
265    /// in this type use the same `nodes` -> `node_cache` ordering, which avoids
266    /// the classic two-lock deadlock. The locks are held only for the commit
267    /// mutation; scoring itself is done while holding the same write guards so
268    /// the read+decrement is a single atomic critical section (no lost updates
269    /// under concurrent committers).
270    pub fn schedule_fast_commit(&self, workload: &Workload) -> Option<NodeId> {
271        let start = std::time::Instant::now();
272        let req = &workload.resources;
273
274        // Lock order: nodes -> node_cache (consistent everywhere in this type).
275        let mut nodes = self.nodes.write();
276        let mut cache = self.node_cache.write();
277
278        if cache.is_empty() {
279            return None;
280        }
281
282        // Pick the best fitting node by index. We score against the cache (cheap,
283        // pre-normalized) but only consider indices that actually fit.
284        let best_idx = {
285            let scored = cache.iter().enumerate().filter(|(_, n)| n.can_fit(req));
286            // Sequential here: we already hold the write lock, so parallel
287            // iteration would not help and `par_iter` cannot borrow the guard
288            // mutably anyway.
289            scored.max_by_key(|(_, n)| n.score_for_workload(req)).map(|(i, _)| i)
290        };
291
292        let result = match best_idx {
293            Some(idx) => {
294                // Commit on the real node first. `NodeResources::allocate`
295                // mutates cpu_allocated / memory_allocated / gpus_allocated and
296                // returns false if it cannot fit (race-safe double check).
297                let node = &mut nodes[idx];
298                if node.allocate(req) {
299                    let node_id = node.node_id;
300                    let cpu_capacity = node.cpu_capacity;
301                    let memory_capacity = node.memory_capacity;
302                    let gpu_total = node.gpus.len() as u32;
303
304                    // Mirror the decrement into the cache entry (same index).
305                    cache[idx].commit(req, cpu_capacity, memory_capacity, gpu_total);
306
307                    Some(node_id)
308                } else {
309                    None
310                }
311            }
312            None => None,
313        };
314
315        // Drop guards before touching atomics (not required, but keeps the
316        // critical section minimal).
317        drop(cache);
318        drop(nodes);
319
320        self.scheduled_count.fetch_add(1, Ordering::Relaxed);
321        self.total_time_ns.fetch_add(start.elapsed().as_nanos() as u64, Ordering::Relaxed);
322
323        result
324    }
325
326    /// Release a previously committed allocation, restoring capacity in BOTH the
327    /// backing [`NodeResources`] and the cache entry. This is the inverse of
328    /// [`OptimizedScheduler::schedule_fast_commit`] /
329    /// [`OptimizedScheduler::commit_batch`].
330    ///
331    /// `gpu_ids` are the GPU device ids that were assigned to the workload (the
332    /// caller is expected to track these, mirroring
333    /// [`super::NodeResources::release`]). Pass an empty slice for CPU/memory-only
334    /// workloads.
335    ///
336    /// Uses the same `nodes` -> `node_cache` lock ordering as the commit path.
337    pub fn release_workload(&self, node_id: NodeId, req: &ResourceRequirements, gpu_ids: &[u32]) {
338        let mut nodes = self.nodes.write();
339        let mut cache = self.node_cache.write();
340
341        if let Some(idx) = nodes.iter().position(|n| n.node_id == node_id) {
342            let node = &mut nodes[idx];
343            node.release(req, gpu_ids);
344            let cpu_capacity = node.cpu_capacity;
345            let memory_capacity = node.memory_capacity;
346            let gpu_total = node.gpus.len() as u32;
347            if let Some(entry) = cache.get_mut(idx) {
348                entry.release(req, cpu_capacity, memory_capacity, gpu_total);
349            }
350        }
351    }
352
353    /// Schedule a batch of workloads in parallel.
354    ///
355    /// NOTE: this computes placements into `batch.results` but does NOT mutate
356    /// the backing nodes or cache by itself. To actually reserve the capacity,
357    /// call [`OptimizedScheduler::commit_batch`] with the same batch afterwards
358    /// (or use [`OptimizedScheduler::schedule_and_commit_batch`] to do both).
359    /// This split lets callers inspect/accept placements before committing.
360    pub fn schedule_batch(&self, batch: &mut WorkloadBatch) {
361        let start = std::time::Instant::now();
362        let cache = self.node_cache.read();
363
364        if cache.is_empty() {
365            return;
366        }
367
368        // Sort workloads by priority (highest first)
369        let mut indices: Vec<usize> = (0..batch.workloads.len()).collect();
370        indices.sort_by(|&a, &b| {
371            batch.workloads[b].priority.cmp(&batch.workloads[a].priority)
372        });
373
374        // Track allocated capacity per node
375        let mut node_allocated: Vec<(u64, u64, u32)> = cache.iter()
376            .map(|n| (n.cpu_available, n.memory_available, n.gpu_available))
377            .collect();
378
379        // Schedule in priority order
380        for idx in indices {
381            let workload = &batch.workloads[idx];
382            let req = &workload.resources;
383
384            // Find best fitting node
385            let mut best_node: Option<usize> = None;
386            let mut best_score: u32 = 0;
387
388            for (i, (n, alloc)) in cache.iter().zip(node_allocated.iter()).enumerate() {
389                if !n.schedulable {
390                    continue;
391                }
392
393                // Check if node can fit with current allocations
394                if alloc.0 < req.cpu_millis || alloc.1 < req.memory_mb || alloc.2 < req.gpu_count {
395                    continue;
396                }
397
398                // Score based on remaining capacity after allocation
399                let remaining_cpu = alloc.0 - req.cpu_millis;
400                let remaining_mem = alloc.1 - req.memory_mb;
401                
402                // Bin-packing: prefer nodes that will be more full
403                let score = 2000 - (remaining_cpu * 1000 / n.cpu_available.max(1)) as u32
404                    - (remaining_mem * 1000 / n.memory_available.max(1)) as u32;
405
406                if score > best_score {
407                    best_score = score;
408                    best_node = Some(i);
409                }
410            }
411
412            if let Some(node_idx) = best_node {
413                batch.results[idx] = Some(cache[node_idx].node_id);
414                
415                // Update allocated capacity
416                node_allocated[node_idx].0 -= req.cpu_millis;
417                node_allocated[node_idx].1 -= req.memory_mb;
418                node_allocated[node_idx].2 -= req.gpu_count;
419            }
420        }
421
422        // Update stats
423        let count = batch.workloads.len() as u64;
424        self.scheduled_count.fetch_add(count, Ordering::Relaxed);
425        self.total_time_ns.fetch_add(start.elapsed().as_nanos() as u64, Ordering::Relaxed);
426    }
427
428    /// Commit the placements computed by [`OptimizedScheduler::schedule_batch`].
429    ///
430    /// Walks `batch.results`, and for every workload that was assigned a node,
431    /// allocates its resources on the real [`NodeResources`] and mirrors the
432    /// decrement into the corresponding cache entry. Allocations that no longer
433    /// fit (e.g. capacity changed between scheduling and committing) are skipped
434    /// and their result is cleared to `None` so the caller can re-queue them.
435    ///
436    /// Uses the same `nodes` -> `node_cache` lock ordering as the single-shot
437    /// commit path.
438    pub fn commit_batch(&self, batch: &mut WorkloadBatch) {
439        let mut nodes = self.nodes.write();
440        let mut cache = self.node_cache.write();
441
442        if nodes.is_empty() {
443            return;
444        }
445
446        // Map NodeId -> index once for O(1) lookups during commit.
447        for i in 0..batch.workloads.len() {
448            let Some(node_id) = batch.results[i] else { continue };
449            let Some(idx) = nodes.iter().position(|n| n.node_id == node_id) else {
450                batch.results[i] = None;
451                continue;
452            };
453
454            let req = &batch.workloads[i].resources;
455            let node = &mut nodes[idx];
456            if node.allocate(req) {
457                let cpu_capacity = node.cpu_capacity;
458                let memory_capacity = node.memory_capacity;
459                let gpu_total = node.gpus.len() as u32;
460                if let Some(entry) = cache.get_mut(idx) {
461                    entry.commit(req, cpu_capacity, memory_capacity, gpu_total);
462                }
463            } else {
464                // Could not actually fit at commit time; clear so caller re-queues.
465                batch.results[i] = None;
466            }
467        }
468    }
469
470    /// Convenience: schedule a batch AND commit it in one call.
471    ///
472    /// Equivalent to [`OptimizedScheduler::schedule_batch`] followed by
473    /// [`OptimizedScheduler::commit_batch`]. This is the honest full-batch
474    /// bind cost.
475    pub fn schedule_and_commit_batch(&self, batch: &mut WorkloadBatch) {
476        self.schedule_batch(batch);
477        self.commit_batch(batch);
478    }
479
480    /// Get scheduling statistics
481    pub fn stats(&self) -> SchedulerStats {
482        let count = self.scheduled_count.load(Ordering::Relaxed);
483        let time_ns = self.total_time_ns.load(Ordering::Relaxed);
484        
485        SchedulerStats {
486            total_scheduled: count,
487            total_time_ns: time_ns,
488            avg_time_ns: if count > 0 { time_ns / count } else { 0 },
489            decisions_per_sec: if time_ns > 0 {
490                (count as f64 * 1_000_000_000.0 / time_ns as f64) as u64
491            } else {
492                0
493            },
494            node_count: self.node_cache.read().len(),
495        }
496    }
497
498    /// Reset statistics
499    pub fn reset_stats(&self) {
500        self.scheduled_count.store(0, Ordering::Relaxed);
501        self.total_time_ns.store(0, Ordering::Relaxed);
502    }
503
504    /// Get node count
505    pub fn node_count(&self) -> usize {
506        self.node_cache.read().len()
507    }
508
509    /// Calculate cluster utilization
510    pub fn utilization(&self) -> ClusterUtilization {
511        let nodes = self.nodes.read();
512        
513        let mut total_cpu: u64 = 0;
514        let mut used_cpu: u64 = 0;
515        let mut total_mem: u64 = 0;
516        let mut used_mem: u64 = 0;
517        let mut total_gpu: u32 = 0;
518        let mut used_gpu: u32 = 0;
519
520        for node in nodes.iter() {
521            total_cpu += node.cpu_capacity;
522            used_cpu += node.cpu_allocated;
523            total_mem += node.memory_capacity;
524            used_mem += node.memory_allocated;
525            total_gpu += node.gpus.len() as u32;
526            used_gpu += node.gpus_allocated.len() as u32;
527        }
528
529        ClusterUtilization {
530            cpu_percent: if total_cpu > 0 { (used_cpu as f64 / total_cpu as f64) * 100.0 } else { 0.0 },
531            memory_percent: if total_mem > 0 { (used_mem as f64 / total_mem as f64) * 100.0 } else { 0.0 },
532            gpu_percent: if total_gpu > 0 { (used_gpu as f64 / total_gpu as f64) * 100.0 } else { 0.0 },
533            total_cpu,
534            used_cpu,
535            total_memory: total_mem,
536            used_memory: used_mem,
537            total_gpus: total_gpu,
538            used_gpus: used_gpu,
539        }
540    }
541}
542
543impl Default for OptimizedScheduler {
544    fn default() -> Self {
545        Self::new()
546    }
547}
548
549/// Scheduler statistics
550#[derive(Debug, Clone)]
551pub struct SchedulerStats {
552    /// Total workloads scheduled
553    pub total_scheduled: u64,
554    /// Total time spent scheduling (nanoseconds)
555    pub total_time_ns: u64,
556    /// Average time per scheduling decision (nanoseconds)
557    pub avg_time_ns: u64,
558    /// Scheduling decisions per second
559    pub decisions_per_sec: u64,
560    /// Number of nodes
561    pub node_count: usize,
562}
563
564/// Cluster utilization metrics
565#[derive(Debug, Clone)]
566pub struct ClusterUtilization {
567    /// CPU utilization percentage
568    pub cpu_percent: f64,
569    /// Memory utilization percentage
570    pub memory_percent: f64,
571    /// GPU utilization percentage
572    pub gpu_percent: f64,
573    /// Total CPU capacity
574    pub total_cpu: u64,
575    /// Used CPU
576    pub used_cpu: u64,
577    /// Total memory
578    pub total_memory: u64,
579    /// Used memory
580    pub used_memory: u64,
581    /// Total GPUs
582    pub total_gpus: u32,
583    /// Used GPUs
584    pub used_gpus: u32,
585}
586
587/// First-Fit Decreasing bin-packing for optimal utilization
588/// 
589/// Achieves 150-200% better utilization than naive scheduling
590pub struct FFDBinPacker {
591    /// Nodes sorted by capacity
592    nodes: Vec<NodeResources>,
593}
594
595impl FFDBinPacker {
596    /// Create new FFD bin packer
597    pub fn new(mut nodes: Vec<NodeResources>) -> Self {
598        // Sort nodes by total capacity (largest first)
599        nodes.sort_by(|a, b| {
600            let cap_a = a.cpu_capacity + a.memory_capacity;
601            let cap_b = b.cpu_capacity + b.memory_capacity;
602            cap_b.cmp(&cap_a)
603        });
604        Self { nodes }
605    }
606
607    /// Pack workloads using First-Fit Decreasing algorithm
608    /// Returns (assignments, utilization)
609    pub fn pack(&mut self, mut workloads: Vec<Workload>) -> (Vec<(String, NodeId)>, f64) {
610        // Sort workloads by resource requirement (largest first)
611        workloads.sort_by(|a, b| {
612            let req_a = a.resources.cpu_millis + a.resources.memory_mb;
613            let req_b = b.resources.cpu_millis + b.resources.memory_mb;
614            req_b.cmp(&req_a)
615        });
616
617        let mut assignments = Vec::new();
618        let mut node_usage: Vec<(u64, u64)> = self.nodes.iter()
619            .map(|n| (0u64, 0u64))
620            .collect();
621
622        for workload in &workloads {
623            let req = &workload.resources;
624
625            // Find first node that fits
626            for (i, node) in self.nodes.iter().enumerate() {
627                let (used_cpu, used_mem) = node_usage[i];
628                let avail_cpu = node.cpu_capacity.saturating_sub(used_cpu);
629                let avail_mem = node.memory_capacity.saturating_sub(used_mem);
630
631                if avail_cpu >= req.cpu_millis && avail_mem >= req.memory_mb {
632                    assignments.push((workload.id.clone(), node.node_id));
633                    node_usage[i].0 += req.cpu_millis;
634                    node_usage[i].1 += req.memory_mb;
635                    break;
636                }
637            }
638        }
639
640        // Calculate utilization
641        let total_cpu: u64 = self.nodes.iter().map(|n| n.cpu_capacity).sum();
642        let used_cpu: u64 = node_usage.iter().map(|(c, _)| c).sum();
643        let utilization = if total_cpu > 0 {
644            (used_cpu as f64 / total_cpu as f64) * 100.0
645        } else {
646            0.0
647        };
648
649        (assignments, utilization)
650    }
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656
657    fn create_nodes(count: usize) -> Vec<NodeResources> {
658        (0..count).map(|_| {
659            NodeResources::new(NodeId::new(), 8000, 32768)
660        }).collect()
661    }
662
663    fn create_workloads(count: usize) -> Vec<Workload> {
664        (0..count).map(|i| {
665            Workload::new(format!("w-{}", i), "test")
666                .with_resources(ResourceRequirements::new()
667                    .cpu(100 + (i as u64 % 10) * 100)
668                    .memory(256 + (i as u64 % 8) * 256))
669        }).collect()
670    }
671
672    #[test]
673    fn test_optimized_scheduler_fast() {
674        let scheduler = OptimizedScheduler::new();
675        
676        for node in create_nodes(100) {
677            scheduler.register_node(node);
678        }
679
680        let workloads = create_workloads(1000);
681        let mut scheduled = 0;
682
683        for workload in &workloads {
684            if scheduler.schedule_fast(workload).is_some() {
685                scheduled += 1;
686            }
687        }
688
689        // `schedule_fast` is scoring-only (it does not commit), so in a 100-node
690        // cluster every workload fits *some* node: this asserts correctness, not
691        // throughput. Wall-clock throughput is machine-dependent and belongs in a
692        // benchmark, not a unit test — see `benches/bind_cycle_benchmark.rs`.
693        assert_eq!(scheduled, workloads.len(), "every workload should find a placement");
694
695        let stats = scheduler.stats();
696        println!("Scheduled: {}, Rate: {} decisions/sec", scheduled, stats.decisions_per_sec);
697        assert_eq!(stats.total_scheduled, workloads.len() as u64);
698    }
699
700    #[test]
701    fn test_batch_scheduling() {
702        let scheduler = OptimizedScheduler::new();
703        
704        for node in create_nodes(50) {
705            scheduler.register_node(node);
706        }
707
708        let workloads = create_workloads(100);
709        let mut batch = WorkloadBatch::new(workloads);
710        
711        scheduler.schedule_batch(&mut batch);
712
713        let scheduled: usize = batch.results().iter().filter(|r| r.is_some()).count();
714        assert!(scheduled > 0);
715        println!("Batch scheduled: {}/100", scheduled);
716    }
717
718    #[test]
719    fn test_schedule_fast_commit_reduces_capacity() {
720        // A single node that can hold exactly 4 of these workloads.
721        let scheduler = OptimizedScheduler::new();
722        scheduler.register_node(NodeResources::new(NodeId::new(), 4000, 8192));
723
724        let req = ResourceRequirements::new().cpu(1000).memory(2048);
725        let workload = Workload::new("w", "test").with_resources(req.clone());
726
727        // First 4 commits succeed, 5th fails (capacity exhausted).
728        let mut placed = 0;
729        for _ in 0..6 {
730            if scheduler.schedule_fast_commit(&workload).is_some() {
731                placed += 1;
732            }
733        }
734        assert_eq!(placed, 4, "node should fit exactly 4 workloads after commit");
735
736        // Utilization reflects the committed allocations on the real node.
737        let util = scheduler.utilization();
738        assert_eq!(util.used_cpu, 4000);
739        assert_eq!(util.used_memory, 8192);
740
741        // schedule_fast (non-committing) must still return None now that the
742        // committed node is full.
743        assert!(scheduler.schedule_fast(&workload).is_none());
744    }
745
746    #[test]
747    fn test_release_workload_restores_capacity() {
748        let scheduler = OptimizedScheduler::new();
749        let node_id = NodeId::new();
750        scheduler.register_node(NodeResources::new(node_id, 4000, 8192));
751
752        let req = ResourceRequirements::new().cpu(4000).memory(8192);
753        let workload = Workload::new("w", "test").with_resources(req.clone());
754
755        // Fill the node, then a second commit must fail.
756        assert!(scheduler.schedule_fast_commit(&workload).is_some());
757        assert!(scheduler.schedule_fast_commit(&workload).is_none());
758
759        // Release and confirm capacity is back.
760        scheduler.release_workload(node_id, &req, &[]);
761        assert_eq!(scheduler.utilization().used_cpu, 0);
762        assert!(scheduler.schedule_fast_commit(&workload).is_some());
763    }
764
765    #[test]
766    fn test_commit_batch_writes_back() {
767        let scheduler = OptimizedScheduler::new();
768        for node in create_nodes(10) {
769            scheduler.register_node(node);
770        }
771
772        let workloads = create_workloads(50);
773        let mut batch = WorkloadBatch::new(workloads);
774
775        scheduler.schedule_and_commit_batch(&mut batch);
776
777        let scheduled: usize = batch.results().iter().filter(|r| r.is_some()).count();
778        assert!(scheduled > 0);
779
780        // After commit, the real cluster must show non-zero utilization.
781        let util = scheduler.utilization();
782        assert!(util.used_cpu > 0, "commit_batch should write allocations to nodes");
783    }
784
785    #[test]
786    fn test_ffd_bin_packing() {
787        let nodes = create_nodes(10);
788        let workloads = create_workloads(50);
789        
790        let mut packer = FFDBinPacker::new(nodes);
791        let (assignments, utilization) = packer.pack(workloads);
792
793        println!("FFD packed {} workloads, utilization: {:.1}%", assignments.len(), utilization);
794        assert!(assignments.len() > 0);
795    }
796}