1use super::AssembledPlan;
4use super::PlanEntityKind;
5use super::explicit_schedule::execution_keys;
6use crate::config::CuConfig;
7use crate::config::CuGraph;
8use crate::config::DEFAULT_BACKGROUND_POOL;
9use crate::config::MAX_NICE;
10use crate::config::MAX_RT_PRIORITY;
11use crate::config::MIN_NICE;
12use crate::config::MIN_RT_PRIORITY;
13use crate::config::SchedulingPolicy;
14use crate::config::TaskKind;
15use crate::curuntime::CuExecutionUnit;
16use alloc::collections::BTreeMap;
17use alloc::collections::BTreeSet;
18use alloc::collections::VecDeque;
19use alloc::format;
20use alloc::string::String;
21use alloc::string::ToString;
22use alloc::vec;
23use alloc::vec::Vec;
24use cu29_traits::CuError;
25use cu29_traits::CuResult;
26use serde::Deserialize;
27use serde::Serialize;
28
29#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct CuMissionPlan {
36 pub copperlists_per_cycle: u32,
38 pub max_in_flight: u32,
40 pub steps: Vec<CuPlanStep>,
42 pub workers: Vec<CuPlanWorker>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub dispatcher: Option<CuPlanThread>,
47 #[serde(default, skip_serializing_if = "Vec::is_empty")]
49 pub background: Vec<CuPlanBackground>,
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
52 pub dependencies: Vec<CuPlanDependency>,
53}
54
55#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct CuPlanStep {
59 pub key: String,
61 pub copperlist: u32,
63}
64
65#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct CuPlanWorker {
69 pub id: String,
71 pub placement: CuPlanPlacement,
73 pub steps: Vec<u32>,
75}
76
77#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
80pub enum CuPlanPlacement {
81 Main,
83 Thread {
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 cpu: Option<usize>,
87 #[serde(default)]
88 policy: SchedulingPolicy,
89 },
90}
91
92#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct CuPlanThread {
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub cpu: Option<usize>,
98 #[serde(default)]
99 pub policy: SchedulingPolicy,
100}
101
102#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct CuPlanBackground {
106 pub task: String,
108 pub pool: String,
110 pub max_running: u32,
112 pub result: CuPlanBackgroundResult,
114}
115
116#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
119pub enum CuPlanBackgroundResult {
120 Sampled,
122 Lag { lag: u32 },
124}
125
126#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
128#[serde(deny_unknown_fields)]
129pub struct CuPlanDependency {
130 pub from: u32,
132 pub to: u32,
134 pub cycle_lag: u32,
136}
137
138pub(super) struct PlanShape {
139 keys: Vec<String>,
140 required: BTreeSet<(usize, usize)>,
141 components: Vec<Vec<usize>>,
142 background: Vec<CuPlanBackground>,
143}
144
145impl PlanShape {
146 pub(super) fn new(
147 plan: &AssembledPlan,
148 config: &CuConfig,
149 graph: &CuGraph,
150 mission: &str,
151 concurrent_resources: &[String],
152 ) -> CuResult<Self> {
153 let keys = execution_keys(plan, mission)?;
154 let mut producers = BTreeMap::new();
155 let mut entities: BTreeMap<_, Vec<usize>> = BTreeMap::new();
156 let mut components: BTreeMap<_, Vec<usize>> = BTreeMap::new();
157 let mut resources: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
158 let mut background = Vec::new();
159
160 for (index, unit) in plan.execution.steps.iter().enumerate() {
161 let CuExecutionUnit::Step(step) = unit else {
162 return Err(CuError::from("Nested execution loops cannot be exported"));
163 };
164 if let Some(output) = &step.output_msg_pack {
165 producers.insert(output.culist_index, index);
166 }
167 entities.entry(step.node_id).or_default().push(index);
168 let (component, bindings) = match plan.entities[step.node_id as usize].kind {
169 PlanEntityKind::Task {
170 original_node_id,
171 task_index,
172 } => {
173 let node = graph.get_node(original_node_id).ok_or_else(|| {
174 CuError::from(format!("Task node {original_node_id} not found"))
175 })?;
176 if node.is_background() {
177 if entities[&step.node_id].len() == 1 {
178 background.push(CuPlanBackground {
179 task: node.get_id(),
180 pool: node.background_pool().to_string(),
181 max_running: 1,
182 result: CuPlanBackgroundResult::Sampled,
183 });
184 }
185 continue;
186 }
187 if node.get_declared_task_kind() == Some(TaskKind::Stateless) {
188 continue;
189 }
190 ((0, task_index), node.get_resources())
191 }
192 PlanEntityKind::BridgeRx {
193 bridge_config_index,
194 ..
195 }
196 | PlanEntityKind::BridgeTx {
197 bridge_config_index,
198 ..
199 } => (
200 (1, bridge_config_index),
201 config.bridges[bridge_config_index].resources.as_ref(),
202 ),
203 };
204 components.entry(component).or_default().push(index);
205 for resource in bindings.into_iter().flat_map(|map| map.values()) {
206 if !concurrent_resources.contains(resource) {
207 resources.entry(resource).or_default().push(index);
208 }
209 }
210 }
211
212 let mut required = BTreeSet::new();
213 for indices in entities.values() {
214 for pair in indices.windows(2) {
215 required.insert((pair[0], pair[1]));
216 }
217 }
218 for (index, unit) in plan.execution.steps.iter().enumerate() {
219 let CuExecutionUnit::Step(step) = unit else {
220 continue;
221 };
222 for input in &step.input_msg_indices_types {
223 let producer = producers
224 .get(&input.culist_index)
225 .ok_or_else(|| CuError::from("Missing process-step producer"))?;
226 required.insert((*producer, index));
227 }
228 }
229
230 let mut components: Vec<Vec<usize>> = components.into_values().collect();
231 for (_, mut steps) in resources {
232 steps.sort_unstable();
233 steps.dedup();
234 let owners = components
235 .iter()
236 .filter(|component| component.iter().any(|step| steps.contains(step)))
237 .count();
238 if owners > 1 {
239 components.push(steps);
240 }
241 }
242
243 Ok(Self {
244 keys,
245 required,
246 components,
247 background,
248 })
249 }
250
251 pub(super) fn cyclic_plan(self, copperlists_per_cycle: u32) -> CuResult<CuMissionPlan> {
252 if copperlists_per_cycle <= 1 {
253 return self.serial_plan();
254 }
255 let per_cl = self.keys.len();
256 let occurrence = |index: usize, offset: u32| step_id(offset as usize * per_cl + index);
257 let mut dependencies = BTreeSet::new();
258 for offset in 0..copperlists_per_cycle {
259 for &(from, to) in &self.required {
260 dependencies.insert(CuPlanDependency {
261 from: occurrence(from, offset)?,
262 to: occurrence(to, offset)?,
263 cycle_lag: 0,
264 });
265 }
266 }
267 for component in &self.components {
268 let (Some(&first), Some(&last)) = (component.first(), component.last()) else {
269 continue;
270 };
271 for offset in 0..copperlists_per_cycle {
272 for pair in component.windows(2) {
273 dependencies.insert(CuPlanDependency {
274 from: occurrence(pair[0], offset)?,
275 to: occurrence(pair[1], offset)?,
276 cycle_lag: 0,
277 });
278 }
279 if offset + 1 < copperlists_per_cycle {
280 dependencies.insert(CuPlanDependency {
281 from: occurrence(last, offset)?,
282 to: occurrence(first, offset + 1)?,
283 cycle_lag: 0,
284 });
285 }
286 }
287 dependencies.insert(CuPlanDependency {
288 from: occurrence(last, copperlists_per_cycle - 1)?,
289 to: occurrence(first, 0)?,
290 cycle_lag: 1,
291 });
292 }
293 let mut steps = Vec::with_capacity(per_cl * copperlists_per_cycle as usize);
294 for offset in 0..copperlists_per_cycle {
295 steps.extend(self.keys.iter().map(|key| CuPlanStep {
296 key: key.clone(),
297 copperlist: offset,
298 }));
299 }
300 Ok(CuMissionPlan {
301 copperlists_per_cycle,
302 max_in_flight: copperlists_per_cycle,
303 workers: vec![CuPlanWorker {
304 id: "w0".into(),
305 placement: CuPlanPlacement::Thread {
306 cpu: None,
307 policy: SchedulingPolicy::Fair,
308 },
309 steps: (0..steps.len()).map(step_id).collect::<CuResult<_>>()?,
310 }],
311 steps,
312 dispatcher: None,
313 background: self.background,
314 dependencies: dependencies.into_iter().collect(),
315 })
316 }
317
318 pub(super) fn serial_plan(self) -> CuResult<CuMissionPlan> {
319 let mut dependencies = BTreeSet::new();
320 for (from, to) in self.required {
321 dependencies.insert(CuPlanDependency {
322 from: step_id(from)?,
323 to: step_id(to)?,
324 cycle_lag: 0,
325 });
326 }
327 for component in &self.components {
328 for pair in component.windows(2) {
329 dependencies.insert(CuPlanDependency {
330 from: step_id(pair[0])?,
331 to: step_id(pair[1])?,
332 cycle_lag: 0,
333 });
334 }
335 if let (Some(first), Some(last)) = (component.first(), component.last()) {
336 dependencies.insert(CuPlanDependency {
337 from: step_id(*last)?,
338 to: step_id(*first)?,
339 cycle_lag: 1,
340 });
341 }
342 }
343 let order = (0..self.keys.len())
344 .map(step_id)
345 .collect::<CuResult<Vec<_>>>()?;
346 Ok(CuMissionPlan {
347 copperlists_per_cycle: 1,
348 max_in_flight: 1,
349 steps: self
350 .keys
351 .into_iter()
352 .map(|key| CuPlanStep { key, copperlist: 0 })
353 .collect(),
354 workers: vec![CuPlanWorker {
355 id: "main".into(),
356 placement: CuPlanPlacement::Main,
357 steps: order,
358 }],
359 dispatcher: None,
360 background: self.background,
361 dependencies: dependencies.into_iter().collect(),
362 })
363 }
364}
365
366fn step_id(index: usize) -> CuResult<u32> {
367 u32::try_from(index).map_err(|_| CuError::from("Execution plan has too many steps"))
368}
369
370fn validate_policy(what: &str, policy: SchedulingPolicy) -> CuResult<()> {
371 match policy {
372 SchedulingPolicy::Fifo { priority } | SchedulingPolicy::RoundRobin { priority } => {
373 if !(MIN_RT_PRIORITY..=MAX_RT_PRIORITY).contains(&priority) {
374 return Err(CuError::from(format!(
375 "{what}: real-time priority {priority} is out of range ({MIN_RT_PRIORITY}..={MAX_RT_PRIORITY})"
376 )));
377 }
378 }
379 SchedulingPolicy::Nice(nice) => {
380 if !(MIN_NICE..=MAX_NICE).contains(&nice) {
381 return Err(CuError::from(format!(
382 "{what}: niceness {nice} is out of range ({MIN_NICE}..={MAX_NICE})"
383 )));
384 }
385 }
386 SchedulingPolicy::Fair => {}
387 }
388 Ok(())
389}
390
391impl CuMissionPlan {
392 pub fn is_serial(&self) -> bool {
394 self.copperlists_per_cycle == 1
395 && self.max_in_flight == 1
396 && self.workers.len() == 1
397 && self.workers[0].placement == CuPlanPlacement::Main
398 }
399
400 pub fn nondeterminism(&self) -> Vec<String> {
402 self.background
403 .iter()
404 .filter(|entry| entry.result == CuPlanBackgroundResult::Sampled)
405 .map(|entry| {
406 format!(
407 "background task '{}' publishes its newest result (sampled)",
408 entry.task
409 )
410 })
411 .collect()
412 }
413
414 pub(super) fn serial_keys(&self) -> CuResult<Vec<String>> {
415 if !self.is_serial() {
416 return Err(CuError::from(
417 "Serial keys require one main worker, one CopperList per cycle, and one in flight.",
418 ));
419 }
420 self.workers[0]
421 .steps
422 .iter()
423 .map(|&index| {
424 self.steps
425 .get(index as usize)
426 .map(|step| step.key.clone())
427 .ok_or_else(|| CuError::from("Unknown worker step"))
428 })
429 .collect()
430 }
431
432 pub(super) fn validate(&self, config: &CuConfig, shape: &PlanShape) -> CuResult<Vec<usize>> {
433 if self.copperlists_per_cycle == 0 {
434 return Err(CuError::from("copperlists_per_cycle must be positive"));
435 }
436 if self.max_in_flight == 0 {
437 return Err(CuError::from("max_in_flight must be positive"));
438 }
439 if self.max_in_flight < self.copperlists_per_cycle {
440 return Err(CuError::from(format!(
441 "max_in_flight {} is smaller than copperlists_per_cycle {}",
442 self.max_in_flight, self.copperlists_per_cycle
443 )));
444 }
445 let storage = config
446 .logging
447 .as_ref()
448 .and_then(|logging| logging.copperlist_count)
449 .unwrap_or(super::DEFAULT_COPPERLIST_COUNT);
450 if self.max_in_flight as usize > storage {
451 return Err(CuError::from(format!(
452 "max_in_flight {} exceeds the {storage} preallocated CopperLists",
453 self.max_in_flight
454 )));
455 }
456 let expected = shape
457 .keys
458 .len()
459 .checked_mul(self.copperlists_per_cycle as usize)
460 .ok_or_else(|| CuError::from("Execution plan step count overflow"))?;
461 if self.steps.len() != expected {
462 return Err(CuError::from(format!(
463 "Expected {expected} process-step occurrences, found {}",
464 self.steps.len()
465 )));
466 }
467
468 let by_key: BTreeMap<_, _> = shape
469 .keys
470 .iter()
471 .enumerate()
472 .map(|(index, key)| (key, index))
473 .collect();
474 let mut occurrences = BTreeMap::new();
475 for (index, step) in self.steps.iter().enumerate() {
476 let key = *by_key
477 .get(&step.key)
478 .ok_or_else(|| CuError::from(format!("Unknown process step '{}'", step.key)))?;
479 if step.copperlist >= self.copperlists_per_cycle {
480 return Err(CuError::from(format!(
481 "CopperList offset {} is outside the schedule cycle",
482 step.copperlist
483 )));
484 }
485 if occurrences.insert((key, step.copperlist), index).is_some() {
486 return Err(CuError::from(format!(
487 "Duplicate process step '{}' in CopperList {}",
488 step.key, step.copperlist
489 )));
490 }
491 }
492
493 if self.workers.is_empty() {
494 return Err(CuError::from("Execution plan needs at least one worker"));
495 }
496 let mut assigned = vec![false; self.steps.len()];
497 let mut ids = BTreeSet::new();
498 let mut edges = vec![Vec::new(); self.steps.len()];
499 for worker in &self.workers {
500 if worker.id.is_empty() || !ids.insert(worker.id.as_str()) {
501 return Err(CuError::from(format!(
502 "Worker id '{}' is empty or used twice",
503 worker.id
504 )));
505 }
506 match &worker.placement {
507 CuPlanPlacement::Main => {
508 if !self.is_serial() || self.dispatcher.is_some() {
509 return Err(CuError::from(
510 "A main-thread worker is only valid in a serial plan",
511 ));
512 }
513 }
514 CuPlanPlacement::Thread { policy, .. } => {
515 validate_policy(&format!("Worker '{}'", worker.id), *policy)?;
516 }
517 }
518 if worker.steps.is_empty() {
519 return Err(CuError::from(format!(
520 "Worker '{}' has no steps",
521 worker.id
522 )));
523 }
524 for &index in &worker.steps {
525 let seen = assigned
526 .get_mut(index as usize)
527 .ok_or_else(|| CuError::from(format!("Unknown worker step index {index}")))?;
528 if core::mem::replace(seen, true) {
529 return Err(CuError::from(format!(
530 "Step index {index} is assigned more than once"
531 )));
532 }
533 }
534 for pair in worker.steps.windows(2) {
535 edges[pair[0] as usize].push((pair[1] as usize, 0));
536 }
537 if let (Some(first), Some(last)) = (worker.steps.first(), worker.steps.last()) {
538 edges[*last as usize].push((*first as usize, 1));
539 }
540 }
541 if let Some(dispatcher) = &self.dispatcher {
542 validate_policy("Dispatcher", dispatcher.policy)?;
543 }
544 if assigned.iter().any(|assigned| !assigned) {
545 return Err(CuError::from(
546 "Every process-step occurrence must be assigned to a worker",
547 ));
548 }
549
550 let expected_background: BTreeSet<_> =
551 shape.background.iter().map(|entry| &entry.task).collect();
552 let declared: BTreeSet<_> = self.background.iter().map(|entry| &entry.task).collect();
553 if declared.len() != self.background.len() || declared != expected_background {
554 return Err(CuError::from(format!(
555 "background must list exactly the mission's background tasks: {expected_background:?}"
556 )));
557 }
558 for entry in &self.background {
559 let known = entry.pool == DEFAULT_BACKGROUND_POOL
560 || config.runtime.as_ref().is_some_and(|runtime| {
561 runtime
562 .thread_pools
563 .iter()
564 .any(|pool| pool.id == entry.pool)
565 });
566 if !known {
567 return Err(CuError::from(format!(
568 "Background task '{}': unknown thread pool '{}'",
569 entry.task, entry.pool
570 )));
571 }
572 if entry.max_running != 1 {
573 return Err(CuError::from(format!(
574 "Background task '{}': this runtime requires max_running 1",
575 entry.task
576 )));
577 }
578 if let CuPlanBackgroundResult::Lag { lag } = entry.result
579 && lag != 1
580 {
581 return Err(CuError::from(format!(
582 "Background task '{}': this runtime supports result lag 1",
583 entry.task
584 )));
585 }
586 }
587
588 for dependency in &self.dependencies {
589 if dependency.from as usize >= edges.len() || dependency.to as usize >= edges.len() {
590 return Err(CuError::from("Dependency references an unknown step index"));
591 }
592 edges[dependency.from as usize].push((dependency.to as usize, dependency.cycle_lag));
593 }
594 let order = topological_order(&edges)?;
595 let mut rank = vec![0; order.len()];
596 for (position, &step) in order.iter().enumerate() {
597 rank[step] = position;
598 }
599 let require = |from: usize, to: usize, lag: u32| -> CuResult<()> {
600 if !reaches(&edges, from, to, lag) {
601 return Err(CuError::from(format!(
602 "Missing precedence: '{}' (CL {}) must finish before '{}' (CL {}, cycle lag {lag})",
603 self.steps[from].key,
604 self.steps[from].copperlist,
605 self.steps[to].key,
606 self.steps[to].copperlist,
607 )));
608 }
609 Ok(())
610 };
611 for copperlist in 0..self.copperlists_per_cycle {
612 for &(from, to) in &shape.required {
613 require(
614 occurrences[&(from, copperlist)],
615 occurrences[&(to, copperlist)],
616 0,
617 )?;
618 }
619 }
620 for component in &shape.components {
621 let mut endpoints = Vec::new();
622 for copperlist in 0..self.copperlists_per_cycle {
623 let mut steps: Vec<_> = component
624 .iter()
625 .map(|&key| occurrences[&(key, copperlist)])
626 .collect();
627 steps.sort_by_key(|&step| rank[step]);
628 for pair in steps.windows(2) {
629 require(pair[0], pair[1], 0)?;
630 }
631 if let (Some(&first), Some(&last)) = (steps.first(), steps.last()) {
632 endpoints.push((first, last));
633 }
634 }
635 for pair in endpoints.windows(2) {
636 require(pair[0].1, pair[1].0, 0)?;
637 }
638 if let (Some(first), Some(last)) = (endpoints.first(), endpoints.last()) {
639 require(last.1, first.0, 1)?;
640 }
641 }
642 Ok(order)
643 }
644
645 pub(super) fn layout_keys(&self, order: &[usize]) -> Vec<String> {
646 order
647 .iter()
648 .map(|&index| &self.steps[index])
649 .filter(|step| step.copperlist == 0)
650 .map(|step| step.key.clone())
651 .collect()
652 }
653}
654
655fn topological_order(edges: &[Vec<(usize, u32)>]) -> CuResult<Vec<usize>> {
656 let mut incoming = vec![0usize; edges.len()];
657 for outgoing in edges {
658 for &(to, lag) in outgoing {
659 if lag == 0 {
660 incoming[to] += 1;
661 }
662 }
663 }
664 let mut ready: VecDeque<_> = incoming
665 .iter()
666 .enumerate()
667 .filter_map(|(index, &count)| (count == 0).then_some(index))
668 .collect();
669 let mut order = Vec::with_capacity(edges.len());
670 while let Some(from) = ready.pop_front() {
671 order.push(from);
672 for &(to, lag) in &edges[from] {
673 if lag == 0 {
674 incoming[to] -= 1;
675 if incoming[to] == 0 {
676 ready.push_back(to);
677 }
678 }
679 }
680 }
681 if order.len() != edges.len() {
682 return Err(CuError::from(
683 "Execution plan contains a dependency/worker-order cycle",
684 ));
685 }
686 Ok(order)
687}
688
689fn reaches(edges: &[Vec<(usize, u32)>], from: usize, to: usize, lag: u32) -> bool {
690 if edges[from].contains(&(to, lag)) {
691 return true;
692 }
693 let mut visited = vec![false; edges.len() * (lag as usize + 1)];
694 let mut ready = vec![(from, 0u32)];
695 visited[from] = true;
696 while let Some((node, elapsed)) = ready.pop() {
697 for &(next, distance) in &edges[node] {
698 let Some(next_lag) = elapsed.checked_add(distance).filter(|&sum| sum <= lag) else {
699 continue;
700 };
701 if next == to && next_lag == lag {
702 return true;
703 }
704 let index = next_lag as usize * edges.len() + next;
705 if !visited[index] {
706 visited[index] = true;
707 ready.push((next, next_lag));
708 }
709 }
710 }
711 false
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717 use crate::planner::CuPlan;
718 use crate::planner::DistributedSchedule;
719 use crate::planner::ExplicitSchedule;
720 use crate::planner::LaneOccurrence;
721 use crate::planner::assemble_runtime_plan_for_mission;
722
723 fn chain() -> CuConfig {
724 CuConfig::deserialize_ron(
725 r#"(
726 runtime: (thread_pools: [(id: "rt", threads: 4, affinity: [0, 1])]),
727 tasks: [(id: "src", type: "Source"), (id: "sink", type: "Sink")],
728 cnx: [(src: "src", dst: "sink", msg: "u32")],
729 )"#,
730 )
731 .unwrap()
732 }
733
734 fn worker(index: u32, steps: Vec<u32>) -> CuPlanWorker {
735 CuPlanWorker {
736 id: format!("w{index}"),
737 placement: CuPlanPlacement::Thread {
738 cpu: Some(index as usize),
739 policy: SchedulingPolicy::Fifo {
740 priority: 60 - index as u8,
741 },
742 },
743 steps,
744 }
745 }
746
747 fn two_cl_pipeline() -> (CuConfig, CuPlan) {
748 let config = chain();
749 let mut plan = CuPlan::from_config(&config).unwrap();
750 let mission = plan.missions.get_mut("default").unwrap();
751 let first = mission.steps.clone();
752 mission.steps.extend(first.into_iter().map(|mut step| {
753 step.copperlist = 1;
754 step
755 }));
756 mission.copperlists_per_cycle = 2;
757 mission.max_in_flight = 2;
758 mission.workers = vec![worker(0, vec![0, 2]), worker(1, vec![1, 3])];
759 mission.dispatcher = Some(CuPlanThread {
760 cpu: Some(0),
761 policy: SchedulingPolicy::Fifo { priority: 70 },
762 });
763 mission.dependencies = vec![
764 CuPlanDependency {
765 from: 0,
766 to: 1,
767 cycle_lag: 0,
768 },
769 CuPlanDependency {
770 from: 2,
771 to: 3,
772 cycle_lag: 0,
773 },
774 ];
775 (config, plan)
776 }
777
778 #[test]
779 fn multicore_plan_round_trips_and_resolves_exact_workers() {
780 let (mut config, plan) = two_cl_pipeline();
781 plan.validate(&config).unwrap();
782 let text = plan.serialize_ron().unwrap();
783 assert!(!text.contains("version"));
784 let loaded = CuPlan::deserialize_ron(&text).unwrap();
785 assert_eq!(loaded, plan);
786 ExplicitSchedule::new(loaded).apply(&mut config).unwrap();
787 assert_eq!(CuPlan::from_config(&config).unwrap(), plan);
788
789 let assembled =
790 assemble_runtime_plan_for_mission(&config, config.get_graph(None).unwrap(), "default")
791 .unwrap();
792 let schedule = DistributedSchedule::from_assembled(&assembled).unwrap();
793 assert_eq!(schedule.worker_lengths, [2, 2]);
794 assert_eq!(
795 schedule.completion,
796 [vec![(0, 1), (1, 1)], vec![(0, 2), (1, 2)]]
797 );
798 assert!(schedule.dependencies.iter().all(|(from, to, _)| {
799 schedule.owner[*from] != schedule.owner[*to] && schedule.publish[*from]
800 }));
801 let lanes = assembled.lanes.unwrap();
802 assert_eq!((lanes.copperlists_per_cycle, lanes.max_in_flight), (2, 2));
803 assert_eq!(
804 lanes.occurrences,
805 [(0, 0), (1, 0), (0, 1), (1, 1)]
806 .map(|(step, copperlist)| LaneOccurrence { step, copperlist })
807 );
808 assert_eq!(lanes.workers[0].occurrences, [0, 2]);
809 assert_eq!(lanes.workers[1].occurrences, [1, 3]);
810 }
811
812 #[test]
813 fn pipeline_and_equivalent_explicit_plan_resolve_identically() {
814 let mut pipeline_config = CuConfig::deserialize_ron(
815 r#"(
816 runtime: (planner: (kind: Pipeline, config: { "max_in_flight": 2 })),
817 logging: (copperlist_count: 2),
818 tasks: [(id: "src", type: "Source"), (id: "mid", type: "Mid"),
819 (id: "sink", type: "Sink")],
820 cnx: [(src: "src", dst: "mid", msg: "u32"),
821 (src: "mid", dst: "sink", msg: "u32")],
822 )"#,
823 )
824 .unwrap();
825 let plan = CuPlan::from_config(&pipeline_config).unwrap();
826 let pipeline = assemble_runtime_plan_for_mission(
827 &pipeline_config,
828 pipeline_config.get_graph(None).unwrap(),
829 "default",
830 )
831 .unwrap();
832 ExplicitSchedule::new(plan)
833 .apply(&mut pipeline_config)
834 .unwrap();
835 let explicit = assemble_runtime_plan_for_mission(
836 &pipeline_config,
837 pipeline_config.get_graph(None).unwrap(),
838 "default",
839 )
840 .unwrap();
841 assert_eq!(pipeline.lanes, explicit.lanes);
842 assert_eq!(
843 super::execution_keys(&pipeline, "default").unwrap(),
844 super::execution_keys(&explicit, "default").unwrap()
845 );
846 }
847
848 #[test]
849 fn rejects_malformed_inventory_assignment_placement_and_capacity() {
850 let (config, plan) = two_cl_pipeline();
851 for mutation in 0..15 {
852 let mut invalid = plan.clone();
853 let mission = invalid.missions.get_mut("default").unwrap();
854 match mutation {
855 0 => _ = mission.steps.pop(),
856 1 => mission.steps[0].key = "unknown".into(),
857 2 => mission.steps[0].copperlist = 2,
858 3 => mission.steps[2] = mission.steps[0].clone(),
859 4 => _ = mission.workers[0].steps.pop(),
860 5 => mission.workers[0].steps.push(0),
861 6 => mission.workers[0].steps.push(99),
862 7 => mission.workers[0].placement = CuPlanPlacement::Main,
863 8 => {
864 mission.workers[0].placement = CuPlanPlacement::Thread {
865 cpu: None,
866 policy: SchedulingPolicy::Fifo { priority: 100 },
867 }
868 }
869 9 => mission.workers[1].id = mission.workers[0].id.clone(),
870 10 => mission.dependencies[0].to = 99,
871 11 => mission.max_in_flight = 3,
872 12 => {
873 mission.dispatcher = Some(CuPlanThread {
874 cpu: None,
875 policy: SchedulingPolicy::Nice(40),
876 })
877 }
878 13 => mission.max_in_flight = 1,
879 _ => mission.copperlists_per_cycle = 0,
880 }
881 assert!(invalid.validate(&config).is_err(), "mutation {mutation}");
882 }
883 }
884
885 #[test]
886 fn rejects_missing_precedence_cycles_and_state_recurrence() {
887 let (config, plan) = two_cl_pipeline();
888 let mut missing = plan.clone();
889 missing
890 .missions
891 .get_mut("default")
892 .unwrap()
893 .dependencies
894 .clear();
895 assert!(missing.validate(&config).is_err());
896
897 let mut cyclic = plan.clone();
898 cyclic
899 .missions
900 .get_mut("default")
901 .unwrap()
902 .dependencies
903 .push(CuPlanDependency {
904 from: 1,
905 to: 0,
906 cycle_lag: 0,
907 });
908 assert!(cyclic.validate(&config).is_err());
909
910 let mut recurrence = plan;
911 recurrence.missions.get_mut("default").unwrap().workers =
912 (0..4).map(|index| worker(index, vec![index])).collect();
913 assert!(recurrence.validate(&config).is_err());
914 recurrence
915 .missions
916 .get_mut("default")
917 .unwrap()
918 .dependencies
919 .extend([
920 CuPlanDependency {
921 from: 0,
922 to: 2,
923 cycle_lag: 0,
924 },
925 CuPlanDependency {
926 from: 1,
927 to: 3,
928 cycle_lag: 0,
929 },
930 CuPlanDependency {
931 from: 2,
932 to: 0,
933 cycle_lag: 1,
934 },
935 CuPlanDependency {
936 from: 3,
937 to: 1,
938 cycle_lag: 1,
939 },
940 ]);
941 recurrence.validate(&config).unwrap();
942 }
943
944 fn fork_join(features_kind: &str) -> CuConfig {
945 CuConfig::deserialize_ron(&format!(
946 r#"(
947 logging: (copperlist_count: 2),
948 tasks: [(id: "source", type: "Source"), (id: "filter", type: "Filter"),
949 (id: "features", type: "Features", kind: {features_kind}),
950 (id: "fuse", type: "Fuse")],
951 cnx: [(src: "source", dst: "filter", msg: "u32"),
952 (src: "source", dst: "features", msg: "u32"),
953 (src: "filter", dst: "fuse", msg: "u32"),
954 (src: "features", dst: "fuse", msg: "u32")],
955 )"#
956 ))
957 .unwrap()
958 }
959
960 fn overlapping_features(config: &CuConfig) -> CuPlan {
961 let mut plan = CuPlan::from_config(config).unwrap();
962 let mission = plan.missions.get_mut("default").unwrap();
963 let first = mission.steps.clone();
964 let per_cl = first.len() as u32;
965 mission.steps.extend(first.iter().cloned().map(|mut step| {
966 step.copperlist = 1;
967 step
968 }));
969 mission.copperlists_per_cycle = 2;
970 mission.max_in_flight = 2;
971 let index = |task: &str, cl: u32| {
972 let needle = format!("task:{task}|");
973 first
974 .iter()
975 .position(|step| step.key.contains(&needle))
976 .unwrap() as u32
977 + cl * per_cl
978 };
979 let ordered = [
980 ("source", 0),
981 ("filter", 0),
982 ("source", 1),
983 ("filter", 1),
984 ("fuse", 0),
985 ("fuse", 1),
986 ]
987 .map(|(task, cl)| index(task, cl));
988 mission.workers = vec![
989 worker(0, ordered.to_vec()),
990 worker(1, vec![index("features", 0)]),
991 worker(2, vec![index("features", 1)]),
992 ];
993 mission.dependencies = (0..2)
994 .flat_map(|cl| {
995 [("source", "features"), ("features", "fuse")].map(|(from, to)| CuPlanDependency {
996 from: index(from, cl),
997 to: index(to, cl),
998 cycle_lag: 0,
999 })
1000 })
1001 .collect();
1002 plan
1003 }
1004
1005 #[test]
1006 fn only_stateless_task_invocations_may_overlap_across_copperlists() {
1007 let stateless = fork_join("stateless_task");
1008 overlapping_features(&stateless)
1009 .validate(&stateless)
1010 .unwrap();
1011
1012 let stateful = fork_join("task");
1013 let error = overlapping_features(&stateful)
1014 .validate(&stateful)
1015 .unwrap_err();
1016 assert!(
1017 error.to_string().contains("Missing precedence")
1018 && error.to_string().contains("task:features"),
1019 "{error}"
1020 );
1021 }
1022
1023 #[test]
1024 fn shared_resources_require_order_unless_declared_concurrent() {
1025 let config = CuConfig::deserialize_ron(
1026 r#"(
1027 logging: (copperlist_count: 2),
1028 resources: [(id: "board", provider: "Board")],
1029 tasks: [(id: "left", type: "Source", kind: source,
1030 resources: {"bus": "board.i2c"}),
1031 (id: "right", type: "Source", kind: source,
1032 resources: {"bus": "board.i2c"}),
1033 (id: "alone", type: "Source", kind: source,
1034 resources: {"bus": "board.spi"})],
1035 )"#,
1036 )
1037 .unwrap();
1038 let mut plan = CuPlan::from_config(&config).unwrap();
1039 let mission = plan.missions.get_mut("default").unwrap();
1040 let index = |needle: &str| {
1041 mission
1042 .steps
1043 .iter()
1044 .position(|step| step.key.contains(needle))
1045 .unwrap() as u32
1046 };
1047 let (left, right, alone) = (
1048 index("task:left|"),
1049 index("task:right|"),
1050 index("task:alone|"),
1051 );
1052 let shared = |edge: &CuPlanDependency| {
1053 edge.from != edge.to
1054 && [left, right].contains(&edge.from)
1055 && [left, right].contains(&edge.to)
1056 };
1057 mission.max_in_flight = 2;
1058 mission.workers = vec![worker(0, vec![left, alone]), worker(1, vec![right])];
1059 mission.dependencies.retain(|edge| !shared(edge));
1060 assert!(plan.validate(&config).is_err());
1061 plan.concurrent_resources = vec!["board.i2c".into()];
1062 plan.validate(&config).unwrap();
1063 }
1064
1065 #[test]
1066 fn bridge_channels_require_shared_instance_recurrence() {
1067 let config = CuConfig::deserialize_ron(
1068 r#"(
1069 logging: (copperlist_count: 2),
1070 tasks: [(id: "left", type: "Sink"), (id: "right", type: "Sink")],
1071 bridges: [(id: "radio", type: "Radio",
1072 channels: [Rx(id: "a"), Rx(id: "b")])],
1073 cnx: [(src: "radio/a", dst: "left", msg: "u32"),
1074 (src: "radio/b", dst: "right", msg: "u32")],
1075 )"#,
1076 )
1077 .unwrap();
1078 let mut plan = CuPlan::from_config(&config).unwrap();
1079 let mission = plan.missions.get_mut("default").unwrap();
1080 let index = |needle: &str| {
1081 mission
1082 .steps
1083 .iter()
1084 .position(|step| step.key.contains(needle))
1085 .unwrap() as u32
1086 };
1087 let (a, b, left, right) = (
1088 index("bridge:radio:rx:a|"),
1089 index("bridge:radio:rx:b|"),
1090 index("task:left|"),
1091 index("task:right|"),
1092 );
1093 mission.max_in_flight = 2;
1094 mission.workers = vec![worker(0, vec![a, left]), worker(1, vec![b, right])];
1095 mission.dependencies = vec![
1096 CuPlanDependency {
1097 from: a,
1098 to: left,
1099 cycle_lag: 0,
1100 },
1101 CuPlanDependency {
1102 from: b,
1103 to: right,
1104 cycle_lag: 0,
1105 },
1106 ];
1107 assert!(plan.validate(&config).is_err());
1108 plan.missions
1109 .get_mut("default")
1110 .unwrap()
1111 .dependencies
1112 .extend([
1113 CuPlanDependency {
1114 from: a,
1115 to: b,
1116 cycle_lag: 0,
1117 },
1118 CuPlanDependency {
1119 from: b,
1120 to: a,
1121 cycle_lag: 1,
1122 },
1123 ]);
1124 plan.validate(&config).unwrap();
1125 }
1126
1127 #[test]
1128 fn background_policy_is_complete_and_only_lag_one_is_deterministic() {
1129 let config = CuConfig::deserialize_ron(
1130 r#"(
1131 runtime: (thread_pools: [(id: "vision", threads: 1)]),
1132 tasks: [(id: "src", type: "Source"),
1133 (id: "detector", type: "Detector", background: (pool: "vision")),
1134 (id: "sink", type: "Sink")],
1135 cnx: [(src: "src", dst: "detector", msg: "u32"),
1136 (src: "detector", dst: "sink", msg: "u32")],
1137 )"#,
1138 )
1139 .unwrap();
1140 let plan = CuPlan::from_config(&config).unwrap();
1141 assert_eq!(plan.missions["default"].nondeterminism().len(), 1);
1142 let mut deterministic = plan.clone();
1143 deterministic
1144 .missions
1145 .get_mut("default")
1146 .unwrap()
1147 .background[0]
1148 .result = CuPlanBackgroundResult::Lag { lag: 1 };
1149 deterministic.validate(&config).unwrap();
1150 assert!(
1151 deterministic.missions["default"]
1152 .nondeterminism()
1153 .is_empty()
1154 );
1155
1156 for mutation in 0..5 {
1157 let mut invalid = plan.clone();
1158 let entries = &mut invalid.missions.get_mut("default").unwrap().background;
1159 match mutation {
1160 0 => entries.clear(),
1161 1 => entries[0].pool = "missing".into(),
1162 2 => entries[0].max_running = 0,
1163 3 => entries[0].max_running = 2,
1164 _ => entries[0].result = CuPlanBackgroundResult::Lag { lag: 2 },
1165 }
1166 assert!(invalid.validate(&config).is_err(), "mutation {mutation}");
1167 }
1168 }
1169
1170 #[test]
1171 fn missions_and_unknown_schema_fields_are_rejected() {
1172 let config = CuConfig::deserialize_ron(
1173 r#"(
1174 missions: [(id: "alpha"), (id: "beta")],
1175 tasks: [(id: "src", type: "Source", kind: source)],
1176 )"#,
1177 )
1178 .unwrap();
1179 let mut plan = CuPlan::from_config(&config).unwrap();
1180 plan.missions.remove("alpha");
1181 assert!(plan.validate(&config).is_err());
1182 assert!(CuPlan::deserialize_ron("(version: 1, missions: {})").is_err());
1183 assert!(CuPlan::deserialize_ron("(missions: {}, unknown: 0)").is_err());
1184 }
1185}