Skip to main content

somatize_compiler/
scheduler.rs

1//! Scheduler: distributes ExecutionPlan nodes across available workers.
2//!
3//! Rules:
4//! 1. Sequential phases → single worker (avoid data transfer)
5//! 2. Parallel branches → distribute across workers by capability
6//! 3. Differentiable connected nodes → same worker (gradient flow)
7//! 4. Study trials → round-robin across all workers
8//! 5. Auto-assign: users don't pick workers, the scheduler does
9
10use crate::ExecutionPlan;
11use serde::{Deserialize, Serialize};
12
13/// A worker's capabilities and current load.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct WorkerInfo {
16    /// Stable identifier assignments and transfers refer to.
17    pub id: String,
18    /// Human-readable name, carried into [`Assignment::worker_name`] so a
19    /// distribution plan reads without a worker lookup.
20    pub name: String,
21    /// Capability tags (e.g. `"gpu"`) matched against a plan's
22    /// `RemoteTarget::Tag` requirements.
23    pub tags: Vec<String>,
24    /// Whether the worker has a GPU.
25    pub gpu: bool,
26    /// CPU cores available on the worker.
27    pub cpu_cores: usize,
28    /// Jobs currently running; the load side of [`available_slots`](Self::available_slots).
29    pub active_jobs: usize,
30    /// Upper bound on concurrent jobs; the capacity side of
31    /// [`available_slots`](Self::available_slots).
32    pub max_concurrent: usize,
33}
34
35impl WorkerInfo {
36    /// How many more jobs this worker can take right now. Saturates at
37    /// zero: a worker reporting more active jobs than its limit is full,
38    /// not underflowed.
39    pub fn available_slots(&self) -> usize {
40        self.max_concurrent.saturating_sub(self.active_jobs)
41    }
42
43    /// Whether the worker can take at least one more job. [`schedule`]
44    /// filters on this before any placement is attempted.
45    pub fn has_capacity(&self) -> bool {
46        self.available_slots() > 0
47    }
48
49    /// Whether the worker advertises `tag` among its capability tags.
50    pub fn matches_tag(&self, tag: &str) -> bool {
51        self.tags.iter().any(|t| t == tag)
52    }
53}
54
55/// Assignment of a node/phase to a specific worker.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct Assignment {
58    /// The plan node being placed.
59    pub node_id: String,
60    /// Id of the chosen [`WorkerInfo`].
61    pub worker_id: String,
62    /// The worker's display name, copied here so the plan is readable on
63    /// its own.
64    pub worker_name: String,
65    /// The kind of phase this node runs in.
66    pub phase: Phase,
67    /// Why the scheduler chose this worker ("least loaded worker",
68    /// "grouped with differentiable neighbors", ...) — diagnostic text,
69    /// not machine-read.
70    pub reason: String,
71}
72
73/// Execution phase type.
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
75#[serde(rename_all = "snake_case")]
76pub enum Phase {
77    /// Nodes run one after another — kept on a single worker to avoid
78    /// moving intermediate data.
79    Sequential,
80    /// Independent branches run concurrently — distributed across workers.
81    Parallel,
82    /// One trial of a study, placed round-robin across all workers.
83    Trial {
84        /// Zero-based index of this trial within the study.
85        trial_index: usize,
86        /// Total number of trials in the study.
87        total: usize,
88    },
89}
90
91/// The complete distribution plan produced by the scheduler.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct DistributionPlan {
94    /// One entry per placed node: which worker, and why.
95    pub assignments: Vec<Assignment>,
96    /// The ordered phases the plan executes in.
97    pub phases: Vec<PlanPhase>,
98    /// Data movements required where consecutive nodes landed on
99    /// different workers.
100    pub data_transfers: Vec<DataTransfer>,
101    /// Non-fatal conditions ("No workers available — will execute
102    /// locally", "All workers are at capacity"). An empty `assignments`
103    /// with a warning means: run locally instead.
104    pub warnings: Vec<String>,
105}
106
107/// A phase in the execution plan.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct PlanPhase {
110    /// Position of this phase in execution order, starting at 0.
111    pub phase_index: usize,
112    /// Whether the phase runs its nodes sequentially, in parallel, or as
113    /// a study trial.
114    pub phase_type: Phase,
115    /// Every node the phase covers.
116    pub node_ids: Vec<String>,
117    /// The workers involved: one id for a sequential phase, one per
118    /// branch for a parallel one.
119    pub worker_ids: Vec<String>,
120}
121
122/// A data transfer between workers.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct DataTransfer {
125    /// The node whose output must move.
126    pub from_node: String,
127    /// The node that consumes it on the other worker.
128    pub to_node: String,
129    /// Worker id the data currently lives on.
130    pub from_worker: String,
131    /// Worker id the data must reach.
132    pub to_worker: String,
133    /// How the data moves: `"s3"`, `"direct"`, or `"cached"`.
134    pub transfer_type: String,
135}
136
137/// Mutable state accumulated during scheduling.
138struct ScheduleState<'a> {
139    workers: Vec<&'a WorkerInfo>,
140    diff_nodes: &'a [String],
141    assignments: Vec<Assignment>,
142    phases: Vec<PlanPhase>,
143    transfers: Vec<DataTransfer>,
144    warnings: Vec<String>,
145    phase_index: usize,
146}
147
148/// Schedule an execution plan across available workers.
149pub fn schedule(
150    plan: &ExecutionPlan,
151    workers: &[WorkerInfo],
152    differentiable_nodes: &[String],
153) -> DistributionPlan {
154    let mut state = ScheduleState {
155        workers: Vec::new(),
156        diff_nodes: differentiable_nodes,
157        assignments: Vec::new(),
158        phases: Vec::new(),
159        transfers: Vec::new(),
160        warnings: Vec::new(),
161        phase_index: 0,
162    };
163
164    if workers.is_empty() {
165        state
166            .warnings
167            .push("No workers available — will execute locally".into());
168        return DistributionPlan {
169            assignments: state.assignments,
170            phases: state.phases,
171            data_transfers: state.transfers,
172            warnings: state.warnings,
173        };
174    }
175
176    state.workers = workers.iter().filter(|w| w.has_capacity()).collect();
177    if state.workers.is_empty() {
178        state.warnings.push("All workers are at capacity".into());
179        return DistributionPlan {
180            assignments: state.assignments,
181            phases: state.phases,
182            data_transfers: state.transfers,
183            warnings: state.warnings,
184        };
185    }
186
187    schedule_plan(plan, &mut state, None);
188
189    DistributionPlan {
190        assignments: state.assignments,
191        phases: state.phases,
192        data_transfers: state.transfers,
193        warnings: state.warnings,
194    }
195}
196
197fn schedule_plan(plan: &ExecutionPlan, state: &mut ScheduleState<'_>, forced_worker: Option<&str>) {
198    match plan {
199        // A step schedules like any other single node. Its cost profile is
200        // different — latency-bound rather than CPU-bound — which is a
201        // reason to weight it differently once the scheduler models cost at
202        // all; today it models load, and a step contributes load like the rest.
203        ExecutionPlan::Execute { node_id } | ExecutionPlan::Step { node_id, .. } => {
204            let worker = if let Some(fw) = forced_worker {
205                state
206                    .workers
207                    .iter()
208                    .find(|w| w.id == fw)
209                    .unwrap_or(&state.workers[0])
210            } else {
211                least_loaded(&state.workers)
212            };
213
214            state.assignments.push(Assignment {
215                node_id: node_id.clone(),
216                worker_id: worker.id.clone(),
217                worker_name: worker.name.clone(),
218                phase: Phase::Sequential,
219                reason: if forced_worker.is_some() {
220                    "grouped with differentiable neighbors".into()
221                } else {
222                    "least loaded worker".into()
223                },
224            });
225        }
226
227        ExecutionPlan::Sequence(steps) => {
228            let worker = forced_worker
229                .and_then(|fw| state.workers.iter().find(|w| w.id == fw).copied())
230                .unwrap_or_else(|| least_loaded(&state.workers));
231
232            let node_ids = collect_node_ids(plan);
233            let has_diff = node_ids.iter().any(|n| state.diff_nodes.contains(n));
234            let force = if has_diff {
235                Some(worker.id.as_str())
236            } else {
237                forced_worker
238            };
239
240            state.phases.push(PlanPhase {
241                phase_index: state.phase_index,
242                phase_type: Phase::Sequential,
243                node_ids: node_ids.clone(),
244                worker_ids: vec![worker.id.clone()],
245            });
246            state.phase_index += 1;
247
248            for step in steps {
249                schedule_plan(step, state, force);
250            }
251        }
252
253        ExecutionPlan::Parallel(branches) => {
254            let branch_ids: Vec<Vec<String>> = branches.iter().map(collect_node_ids).collect();
255            let mut assigned_workers = Vec::new();
256
257            for (i, branch) in branches.iter().enumerate() {
258                let worker_idx = i % state.workers.len();
259                let worker = state.workers[worker_idx];
260                assigned_workers.push(worker.id.clone());
261
262                let worker_id = worker.id.clone();
263                schedule_plan(branch, state, Some(&worker_id));
264
265                // Check if data transfer is needed from previous phase
266                if let Some(prev) = state
267                    .assignments
268                    .iter()
269                    .rev()
270                    .find(|a| !branch_ids[i].contains(&a.node_id))
271                    .filter(|prev| prev.worker_id != state.workers[worker_idx].id)
272                {
273                    state.transfers.push(DataTransfer {
274                        from_node: prev.node_id.clone(),
275                        to_node: branch_ids[i].first().cloned().unwrap_or_default(),
276                        from_worker: prev.worker_id.clone(),
277                        to_worker: state.workers[worker_idx].id.clone(),
278                        transfer_type: "s3".into(),
279                    });
280                }
281            }
282
283            state.phases.push(PlanPhase {
284                phase_index: state.phase_index,
285                phase_type: Phase::Parallel,
286                node_ids: branch_ids.into_iter().flatten().collect(),
287                worker_ids: assigned_workers,
288            });
289            state.phase_index += 1;
290        }
291
292        ExecutionPlan::Remote { plan, .. } => {
293            schedule_plan(plan, state, None);
294        }
295
296        ExecutionPlan::Loop { body, node_id, .. } => {
297            let worker = forced_worker
298                .and_then(|fw| state.workers.iter().find(|w| w.id == fw).copied())
299                .unwrap_or_else(|| least_loaded(&state.workers));
300            state.assignments.push(Assignment {
301                node_id: node_id.clone(),
302                worker_id: worker.id.clone(),
303                worker_name: worker.name.clone(),
304                phase: Phase::Sequential,
305                reason: "loop controller".into(),
306            });
307            let worker_id = worker.id.clone();
308            schedule_plan(body, state, Some(&worker_id));
309        }
310
311        ExecutionPlan::Branch { node_id, arms, .. } => {
312            let worker = forced_worker
313                .and_then(|fw| state.workers.iter().find(|w| w.id == fw).copied())
314                .unwrap_or_else(|| least_loaded(&state.workers));
315            state.assignments.push(Assignment {
316                node_id: node_id.clone(),
317                worker_id: worker.id.clone(),
318                worker_name: worker.name.clone(),
319                phase: Phase::Sequential,
320                reason: "branch condition".into(),
321            });
322            let worker_id = worker.id.clone();
323            for (_, arm_plan) in arms {
324                schedule_plan(arm_plan, state, Some(&worker_id));
325            }
326        }
327
328        ExecutionPlan::Composite { node_ids } => {
329            let worker = forced_worker
330                .and_then(|fw| state.workers.iter().find(|w| w.id == fw).copied())
331                .unwrap_or_else(|| least_loaded(&state.workers));
332
333            state.phases.push(PlanPhase {
334                phase_index: state.phase_index,
335                phase_type: Phase::Sequential,
336                node_ids: node_ids.clone(),
337                worker_ids: vec![worker.id.clone()],
338            });
339            state.phase_index += 1;
340
341            let worker_id = worker.id.clone();
342            for nid in node_ids {
343                state.assignments.push(Assignment {
344                    node_id: nid.clone(),
345                    worker_id: worker.id.clone(),
346                    worker_name: worker.name.clone(),
347                    phase: Phase::Sequential,
348                    reason: "composite block — same worker for gradient flow".into(),
349                });
350            }
351            drop(worker_id);
352        }
353
354        ExecutionPlan::Stream { node_ids, .. } => {
355            // Stream: all filters on the same worker for stateful chunk processing.
356            let worker = forced_worker
357                .and_then(|fw| state.workers.iter().find(|w| w.id == fw).copied())
358                .unwrap_or_else(|| least_loaded(&state.workers));
359
360            state.phases.push(PlanPhase {
361                phase_index: state.phase_index,
362                phase_type: Phase::Sequential,
363                node_ids: node_ids.clone(),
364                worker_ids: vec![worker.id.clone()],
365            });
366            state.phase_index += 1;
367
368            for nid in node_ids {
369                state.assignments.push(Assignment {
370                    node_id: nid.clone(),
371                    worker_id: worker.id.clone(),
372                    worker_name: worker.name.clone(),
373                    phase: Phase::Sequential,
374                    reason: "stream block — same worker for stateful chunk processing".into(),
375                });
376            }
377        }
378
379        ExecutionPlan::Empty => {}
380    }
381}
382
383/// The worker with the most free slots.
384///
385/// Precondition: `workers` is non-empty — `schedule()` returns early (with
386/// a warning) for both "no workers" and "none with capacity" before any
387/// call can reach here.
388fn least_loaded<'a>(workers: &[&'a WorkerInfo]) -> &'a WorkerInfo {
389    workers
390        .iter()
391        .max_by_key(|w| w.available_slots())
392        .expect("schedule() filters out an empty worker set before placing")
393}
394
395fn collect_node_ids(plan: &ExecutionPlan) -> Vec<String> {
396    plan.node_ids().into_iter().map(|s| s.to_string()).collect()
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    fn test_workers() -> Vec<WorkerInfo> {
404        vec![
405            WorkerInfo {
406                id: "w1".into(),
407                name: "GPU-A100".into(),
408                tags: vec!["gpu".into()],
409                gpu: true,
410                cpu_cores: 16,
411                active_jobs: 0,
412                max_concurrent: 4,
413            },
414            WorkerInfo {
415                id: "w2".into(),
416                name: "CPU-Server".into(),
417                tags: vec!["cpu".into()],
418                gpu: false,
419                cpu_cores: 64,
420                active_jobs: 1,
421                max_concurrent: 8,
422            },
423        ]
424    }
425
426    #[test]
427    fn sequential_same_worker() {
428        let plan = ExecutionPlan::Sequence(vec![
429            ExecutionPlan::Execute {
430                node_id: "normalize".into(),
431            },
432            ExecutionPlan::Execute {
433                node_id: "select".into(),
434            },
435            ExecutionPlan::Execute {
436                node_id: "classify".into(),
437            },
438        ]);
439
440        let result = schedule(&plan, &test_workers(), &[]);
441        // All should be on the same worker
442        let worker_ids: Vec<&str> = result
443            .assignments
444            .iter()
445            .map(|a| a.worker_id.as_str())
446            .collect();
447        assert!(worker_ids.windows(2).all(|w| w[0] == w[1]));
448    }
449
450    #[test]
451    fn parallel_distributes() {
452        let plan = ExecutionPlan::Parallel(vec![
453            ExecutionPlan::Execute {
454                node_id: "train_svm".into(),
455            },
456            ExecutionPlan::Execute {
457                node_id: "train_knn".into(),
458            },
459        ]);
460
461        let result = schedule(&plan, &test_workers(), &[]);
462        assert_eq!(result.assignments.len(), 2);
463        // Should be on different workers
464        assert_ne!(
465            result.assignments[0].worker_id,
466            result.assignments[1].worker_id
467        );
468    }
469
470    #[test]
471    fn no_workers_warns() {
472        let plan = ExecutionPlan::Execute {
473            node_id: "test".into(),
474        };
475        let result = schedule(&plan, &[], &[]);
476        assert!(!result.warnings.is_empty());
477    }
478
479    #[test]
480    fn sequence_then_parallel() {
481        let plan = ExecutionPlan::Sequence(vec![
482            ExecutionPlan::Execute {
483                node_id: "load".into(),
484            },
485            ExecutionPlan::Execute {
486                node_id: "normalize".into(),
487            },
488            ExecutionPlan::Parallel(vec![
489                ExecutionPlan::Execute {
490                    node_id: "train_a".into(),
491                },
492                ExecutionPlan::Execute {
493                    node_id: "train_b".into(),
494                },
495            ]),
496        ]);
497
498        let result = schedule(&plan, &test_workers(), &[]);
499        // load + normalize on same worker, train_a and train_b distributed
500        assert!(result.assignments.len() >= 4);
501        assert_eq!(
502            result.assignments[0].worker_id,
503            result.assignments[1].worker_id
504        );
505    }
506
507    #[test]
508    fn data_transfer_on_split() {
509        let plan = ExecutionPlan::Sequence(vec![
510            ExecutionPlan::Execute {
511                node_id: "preprocess".into(),
512            },
513            ExecutionPlan::Parallel(vec![
514                ExecutionPlan::Execute {
515                    node_id: "branch_a".into(),
516                },
517                ExecutionPlan::Execute {
518                    node_id: "branch_b".into(),
519                },
520            ]),
521        ]);
522
523        let result = schedule(&plan, &test_workers(), &[]);
524        // Should have at least one data transfer (preprocess → branch on different worker)
525        assert!(
526            !result.data_transfers.is_empty()
527                || result
528                    .assignments
529                    .iter()
530                    .all(|a| a.worker_id == result.assignments[0].worker_id)
531        );
532    }
533}