Skip to main content

cu29_runtime/planner/
propose.rs

1//! Candidate plans from a profile and a contract: the response-time model
2//! with the worker as its unit, a constraint-ranked score, and a local search
3//! over which worker runs each occurrence and in which order.
4//!
5//! Workers form a pipeline: no zero-lag edge leads from a later worker back
6//! to an earlier one, so a worker may run ahead of the others up to
7//! `max_in_flight` instead of every worker finishing a CopperList before any
8//! starts the next. Under a real-time policy each CPU gets a second worker
9//! one priority above the base one, for the chains with short deadlines.
10
11use super::CuContract;
12use super::CuMissionPlan;
13use super::CuPlan;
14use super::CuPlanPlacement;
15use super::CuPlanThread;
16use super::CuPlanWorker;
17use super::CuProfile;
18use crate::config::CuConfig;
19use crate::config::MAX_RT_PRIORITY;
20use crate::config::SchedulingPolicy;
21use crate::config::TaskKind;
22use alloc::collections::BTreeMap;
23use alloc::collections::BTreeSet;
24use alloc::collections::VecDeque;
25use alloc::format;
26use alloc::string::String;
27use alloc::string::ToString;
28use alloc::vec;
29use alloc::vec::Vec;
30use cu29_traits::CuError;
31use cu29_traits::CuResult;
32use serde::Deserialize;
33use serde::Serialize;
34
35/// Response times past this many periods count as unbounded.
36const RESPONSE_CAP_PERIODS: u64 = 20;
37/// Moves without improvement before a restart jumps back to its best plan.
38const PATIENCE: usize = 120;
39/// Unconditional moves applied after such a jump.
40const KICK: usize = 3;
41/// Two candidates whose sum tier differs by less than this fraction count as
42/// the same candidate.
43const MIN_SEPARATION: f64 = 0.01;
44/// A delivered rate this close to nominal counts as kept.
45const RATE_TOLERANCE: f64 = 0.005;
46/// The resolution of the rate tier.
47const RATE_STEP: f64 = 0.02;
48/// Deterministic search seed; search tuning is deliberately not user-facing.
49const SEARCH_SEED: u64 = 20260914;
50/// Moves per deterministic local-search restart.
51#[cfg(not(test))]
52const SEARCH_MOVES: usize = 2000;
53#[cfg(test)]
54const SEARCH_MOVES: usize = 300;
55/// Deterministic local-search restarts.
56#[cfg(not(test))]
57const SEARCH_RESTARTS: usize = 8;
58#[cfg(test)]
59const SEARCH_RESTARTS: usize = 3;
60
61/// What the proposer is asked to do.
62#[derive(Clone, Debug)]
63pub struct ProposeRequest<'a> {
64    pub config: &'a CuConfig,
65    pub mission: &'a str,
66    pub contract: &'a CuContract,
67    pub profile: &'a CuProfile,
68    /// How many distinct candidates to return.
69    pub candidates: usize,
70}
71
72/// One candidate: its plan and what the model predicts for it.
73#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
74pub struct CuCandidate {
75    pub plan: CuPlan,
76    pub prediction: CuPrediction,
77}
78
79/// The model's prediction for one plan of one mission.
80#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct CuPrediction {
83    /// The constraint-ranked score tiers; smallest first is better.
84    pub score: Vec<f64>,
85    pub chains: BTreeMap<String, CuChainPrediction>,
86    pub workers: BTreeMap<String, CuWorkerPrediction>,
87    /// Predicted delivered fraction per contract source.
88    pub sources: BTreeMap<String, f64>,
89}
90
91#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
92#[serde(deny_unknown_fields)]
93pub struct CuChainPrediction {
94    pub latency_ns: u64,
95    pub deadline_ns: u64,
96    /// `latency / deadline`.
97    pub ratio: f64,
98}
99
100#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
101#[serde(deny_unknown_fields)]
102pub struct CuWorkerPrediction {
103    pub cpu: usize,
104    /// Busy share of the window: the worker's own cost plus that of the
105    /// higher-priority workers on its CPU.
106    pub load: f64,
107    /// Response time of one cycle, `None` when it does not converge.
108    pub response_ns: Option<u64>,
109    /// Delivered fraction of cycles.
110    pub rate: f64,
111}
112
113/// The model's view of one mission plan's inventory: cost per unit,
114/// dependency edges, and what each chain and source maps to.
115///
116/// A unit is one occurrence, or an anytime base occurrence with its refine
117/// occurrences: the executor runs those phases together on one worker, so
118/// the search never separates them.
119struct Model {
120    inventory: CuMissionPlan,
121    /// Occurrence indices of each unit, in phase order.
122    units: Vec<Vec<usize>>,
123    /// Expected cost of each unit per cycle, in nanoseconds.
124    cost: Vec<u64>,
125    /// Cost of each unit in a cycle where it fires, in nanoseconds.
126    fired_cost: Vec<u64>,
127    /// Zero-lag predecessor units of each unit.
128    preds: Vec<Vec<usize>>,
129    /// CopperList period, the dispatch granularity `g`.
130    copperlist_ns: u64,
131    /// Cycle period `T`.
132    cycle_ns: u64,
133    /// Per chain: `(source occurrence, sink occurrence)` per CL offset.
134    chains: Vec<Vec<(usize, usize)>>,
135    chain_deadlines: Vec<u32>,
136    /// Per contract source: its occurrences.
137    sources: Vec<Vec<usize>>,
138    /// Per unit: it lies on a chain whose deadline is within its source's
139    /// period, so it may run on a higher-tier worker.
140    short: Vec<bool>,
141    /// The units on each such chain's paths; a chain stays within one tier.
142    short_paths: Vec<Vec<usize>>,
143    /// Per window cycle and unit: whether the unit fires there (always,
144    /// without a firing pattern).
145    fires: Vec<Vec<bool>>,
146    cpus: Vec<usize>,
147    /// CopperLists in flight at once, at least one cycle's worth.
148    max_in_flight: u32,
149    /// Expected cost of each unit in each cycle of the window, the longest
150    /// source period, from its firing pattern.
151    window_cost: Vec<Vec<u64>>,
152    policy: SchedulingPolicy,
153    headroom: f64,
154}
155
156/// A candidate under search: an ordered list of occurrences per worker. Lane
157/// `l` runs on `cpus[l % cpus.len()]` at tier `l / cpus.len()`; tier 1 is
158/// the higher-priority worker of that CPU.
159#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
160struct Assignment {
161    lanes: Vec<Vec<usize>>,
162}
163
164struct Evaluation {
165    score: Vec<f64>,
166    /// Per chain, the latency the score used.
167    chain_latency: Vec<u64>,
168    response: Vec<Option<u64>>,
169    rate: Vec<f64>,
170    load: Vec<f64>,
171    cycle_rate: f64,
172}
173
174impl Model {
175    fn new(
176        request: &ProposeRequest<'_>,
177        inventory: CuMissionPlan,
178        max_in_flight: u32,
179    ) -> CuResult<Self> {
180        let contract = request.contract;
181        let profile = request.profile;
182        let copperlist_ns = match request
183            .config
184            .runtime
185            .as_ref()
186            .and_then(|runtime| runtime.rate_target_hz)
187        {
188            Some(rate) => 1_000_000_000 / rate.max(1),
189            None => profile
190                .window_ns
191                .checked_div(profile.copperlists.max(1))
192                .unwrap_or(0)
193                .max(1),
194        };
195        let background: BTreeSet<&str> = inventory
196            .background
197            .iter()
198            .map(|entry| entry.task.as_str())
199            .collect();
200        // A refine occurrence joins the unit of its task's base occurrence in
201        // the same CopperList; the profile measures the whole job under the
202        // base key.
203        let mut units: Vec<Vec<usize>> = Vec::new();
204        let mut unit_of = vec![usize::MAX; inventory.steps.len()];
205        for (index, step) in inventory.steps.iter().enumerate() {
206            if is_refine(&step.key) {
207                let base = inventory.steps.iter().position(|other| {
208                    other.copperlist == step.copperlist
209                        && !is_refine(&other.key)
210                        && task_of_key(&other.key) == task_of_key(&step.key)
211                });
212                let Some(base) = base.map(|b| unit_of[b]).filter(|&u| u != usize::MAX) else {
213                    return Err(CuError::from(format!(
214                        "Refine step '{}' has no base step before it",
215                        step.key
216                    )));
217                };
218                units[base].push(index);
219                unit_of[index] = base;
220            } else {
221                unit_of[index] = units.len();
222                units.push(vec![index]);
223            }
224        }
225        let mut cost = Vec::with_capacity(units.len());
226        let mut fired_cost = Vec::with_capacity(units.len());
227        let mut firing = Vec::with_capacity(units.len());
228        for unit in &units {
229            let step = &inventory.steps[unit[0]];
230            let task = task_of_key(&step.key);
231            if task.is_some_and(|task| background.contains(task)) {
232                // The gateway only publishes and dispatches; the compute runs
233                // on its pool.
234                cost.push(0);
235                fired_cost.push(0);
236                firing.push(None);
237                continue;
238            }
239            let operation = profile.operations.get(&step.key).ok_or_else(|| {
240                CuError::from(format!("The profile has no measurement for '{}'", step.key))
241            })?;
242            // The fraction of CopperLists the operation fires in, from counts,
243            // so an overloaded profiling run (slower CopperLists) does not
244            // understate it.
245            let fired = (operation.fired.samples as f64 / profile.copperlists.max(1) as f64)
246                .clamp(0.0, 1.0);
247            let expected =
248                operation.fired.mean_ns * fired + operation.skipped.mean_ns * (1.0 - fired);
249            cost.push(expected as u64);
250            fired_cost.push(operation.fired.mean_ns.max(operation.skipped.mean_ns) as u64);
251            firing.push(
252                operation
253                    .firing
254                    .clone()
255                    .map(|pattern| (pattern, operation.fired.mean_ns, operation.skipped.mean_ns)),
256            );
257        }
258        // The window is the longest source period; a unit's cost in each of
259        // its CopperLists follows its firing pattern, or its average without.
260        let copperlists_per_cycle = inventory.copperlists_per_cycle;
261        let window_ns = contract
262            .sources
263            .iter()
264            .map(|source| u64::from(source.period_ms) * 1_000_000)
265            .max()
266            .unwrap_or(copperlist_ns)
267            .max(copperlist_ns);
268        let window_cycles = window_ns.div_ceil(copperlist_ns * u64::from(copperlists_per_cycle));
269        let window_cls = (window_cycles * u64::from(copperlists_per_cycle)) as u32;
270        let probability = |cycle: u64, unit: usize| {
271            firing[unit].as_ref().map(|(pattern, _, _)| {
272                let offset = cycle as u32 * copperlists_per_cycle
273                    + inventory.steps[units[unit][0]].copperlist;
274                pattern.probability(window_cls, offset)
275            })
276        };
277        let window_cost: Vec<Vec<u64>> = (0..window_cycles)
278            .map(|cycle| {
279                (0..units.len())
280                    .map(|unit| match (probability(cycle, unit), &firing[unit]) {
281                        (Some(p), Some((_, fired_ns, skipped_ns))) => {
282                            (fired_ns * p + skipped_ns * (1.0 - p)) as u64
283                        }
284                        _ => cost[unit],
285                    })
286                    .collect()
287            })
288            .collect();
289        let fires: Vec<Vec<bool>> = (0..window_cycles)
290            .map(|cycle| {
291                (0..units.len())
292                    .map(|unit| probability(cycle, unit).is_none_or(|p| p > 0.0))
293                    .collect()
294            })
295            .collect();
296        let mut preds: Vec<Vec<usize>> = vec![Vec::new(); units.len()];
297        for edge in &inventory.dependencies {
298            let (from, to) = (unit_of[edge.from as usize], unit_of[edge.to as usize]);
299            if edge.cycle_lag == 0 && from != to && !preds[to].contains(&from) {
300                preds[to].push(from);
301            }
302        }
303        // Running an anytime task's phases together must not close a cycle
304        // through another component (a resource or state edge into a
305        // refinement and out of the base).
306        if let Some(unit) = first_unit_on_a_cycle(&preds) {
307            return Err(CuError::from(format!(
308                "Step '{}' and its refinements cannot run together: an edge leads out of one of its phases and back into an earlier one through another component",
309                inventory.steps[units[unit][0]].key
310            )));
311        }
312        // A task's unit at each CopperList offset.
313        let units_of = |task: &str| -> Vec<usize> {
314            (0..inventory.copperlists_per_cycle)
315                .filter_map(|offset| {
316                    inventory
317                        .steps
318                        .iter()
319                        .position(|step| {
320                            step.copperlist == offset
321                                && !is_refine(&step.key)
322                                && task_of_key(&step.key) == Some(task)
323                        })
324                        .map(|index| unit_of[index])
325                })
326                .collect()
327        };
328        let mut chains = Vec::new();
329        for chain in &contract.chains {
330            let sources = units_of(&chain.source);
331            let sinks = units_of(&chain.sink);
332            if sources.len() != inventory.copperlists_per_cycle as usize
333                || sinks.len() != sources.len()
334            {
335                return Err(CuError::from(format!(
336                    "Chain '{}' names a task without a whole-phase step",
337                    chain.id
338                )));
339            }
340            chains.push(sources.into_iter().zip(sinks).collect());
341        }
342        let sources = contract
343            .sources
344            .iter()
345            .map(|source| units_of(&source.task))
346            .collect();
347        // A chain due within its source's period cannot be pipelined over
348        // periods: the units on its paths may take a higher-priority worker.
349        let mut succs: Vec<Vec<usize>> = vec![Vec::new(); units.len()];
350        for (to, from) in preds.iter().enumerate() {
351            for &from in from {
352                succs[from].push(to);
353            }
354        }
355        let mut short = vec![false; units.len()];
356        let mut short_paths = Vec::new();
357        for (chain, pairs) in chains.iter().enumerate() {
358            let spec = &contract.chains[chain];
359            let period = contract
360                .sources
361                .iter()
362                .find(|source| source.task == spec.source)
363                .map(|source| source.period_ms);
364            if period.is_some_and(|period| spec.deadline_ms <= period) {
365                for &(source, sink) in pairs {
366                    let (forward, backward) = (reachable(&succs, source), reachable(&preds, sink));
367                    let path: Vec<usize> = (0..units.len())
368                        .filter(|&u| forward[u] && backward[u])
369                        .collect();
370                    for &unit in &path {
371                        short[unit] = true;
372                    }
373                    short_paths.push(path);
374                }
375            }
376        }
377        Ok(Self {
378            cycle_ns: copperlist_ns * u64::from(copperlists_per_cycle),
379            inventory,
380            units,
381            cost,
382            fired_cost,
383            preds,
384            copperlist_ns,
385            chains,
386            chain_deadlines: contract
387                .chains
388                .iter()
389                .map(|chain| chain.deadline_ms)
390                .collect(),
391            sources,
392            short,
393            short_paths,
394            fires,
395            cpus: contract.cpus.clone(),
396            max_in_flight: max_in_flight.max(copperlists_per_cycle),
397            window_cost,
398            policy: contract.worker_policy,
399            headroom: contract.headroom,
400        })
401    }
402
403    /// Workers per CPU: a second one a priority above the base worker under
404    /// a real-time policy.
405    fn tiers(&self) -> usize {
406        match self.policy {
407            SchedulingPolicy::Fifo { priority } | SchedulingPolicy::RoundRobin { priority }
408                if priority < MAX_RT_PRIORITY =>
409            {
410                2
411            }
412            _ => 1,
413        }
414    }
415
416    fn lane_cpu(&self, lane: usize) -> usize {
417        self.cpus[lane % self.cpus.len()]
418    }
419
420    fn lane_tier(&self, lane: usize) -> usize {
421        lane / self.cpus.len()
422    }
423
424    fn worker_id(&self, lane: usize) -> String {
425        let cpu = self.lane_cpu(lane);
426        if self.lane_tier(lane) == 0 {
427            format!("cpu{cpu}")
428        } else {
429            format!("cpu{cpu}-hi")
430        }
431    }
432
433    fn worker_policy(&self, lane: usize) -> SchedulingPolicy {
434        match self.policy {
435            SchedulingPolicy::Fifo { priority } if self.lane_tier(lane) > 0 => {
436                SchedulingPolicy::Fifo {
437                    priority: priority + 1,
438                }
439            }
440            SchedulingPolicy::RoundRobin { priority } if self.lane_tier(lane) > 0 => {
441                SchedulingPolicy::RoundRobin {
442                    priority: priority + 1,
443                }
444            }
445            policy => policy,
446        }
447    }
448
449    fn dispatcher(&self) -> CuPlanThread {
450        let policy = match self.policy {
451            SchedulingPolicy::Fifo { priority } => SchedulingPolicy::Fifo {
452                priority: priority + 2,
453            },
454            SchedulingPolicy::RoundRobin { priority } => SchedulingPolicy::RoundRobin {
455                priority: priority + 2,
456            },
457            policy => policy,
458        };
459        CuPlanThread { cpu: None, policy }
460    }
461
462    /// Window load of the higher-tier lanes on `lane`'s CPU.
463    fn higher_load(&self, lane: usize, window_load: &[u64]) -> u64 {
464        let cpus = self.cpus.len();
465        (0..window_load.len())
466            .filter(|&h| h % cpus == lane % cpus && h / cpus > lane / cpus)
467            .map(|h| window_load[h])
468            .sum()
469    }
470
471    /// Whether a lane order respects every zero-lag edge among its own
472    /// occurrences, the whole cycle graph stays acyclic, the lanes form a
473    /// pipeline (an edge from a later lane back to an earlier one would hold
474    /// both to one CopperList at a time), higher-tier lanes carry only
475    /// short-deadline work, which is what they preempt for, and a
476    /// short-deadline chain stays within one tier, since the lower tier runs
477    /// whole cycles behind the higher one.
478    fn is_valid(&self, assignment: &Assignment) -> bool {
479        let n = self.units.len();
480        let lanes = assignment.lanes.len();
481        let mut lane_of = vec![usize::MAX; n];
482        for (l, lane) in assignment.lanes.iter().enumerate() {
483            for &o in lane {
484                lane_of[o] = l;
485            }
486        }
487        if lane_of.contains(&usize::MAX) {
488            return false;
489        }
490        if self.tiers() > 1 {
491            if (0..n).any(|o| self.lane_tier(lane_of[o]) > 0 && !self.short[o]) {
492                return false;
493            }
494            if self.short_paths.iter().any(|path| {
495                path.iter()
496                    .any(|&o| self.lane_tier(lane_of[o]) != self.lane_tier(lane_of[path[0]]))
497            }) {
498                return false;
499            }
500        }
501        let mut incoming = vec![0usize; n];
502        let mut succs: Vec<Vec<usize>> = vec![Vec::new(); n];
503        for (to, preds) in self.preds.iter().enumerate() {
504            for &from in preds {
505                succs[from].push(to);
506                incoming[to] += 1;
507            }
508        }
509        for lane in &assignment.lanes {
510            for pair in lane.windows(2) {
511                succs[pair[0]].push(pair[1]);
512                incoming[pair[1]] += 1;
513            }
514        }
515        if kahn_count(&succs, incoming) != n {
516            return false;
517        }
518        let mut lane_succs: Vec<Vec<usize>> = vec![Vec::new(); lanes];
519        let mut lane_incoming = vec![0usize; lanes];
520        for (to, preds) in self.preds.iter().enumerate() {
521            for &from in preds {
522                let (a, b) = (lane_of[from], lane_of[to]);
523                if a != b && !lane_succs[a].contains(&b) {
524                    lane_succs[a].push(b);
525                    lane_incoming[b] += 1;
526                }
527            }
528        }
529        kahn_count(&lane_succs, lane_incoming) == lanes
530    }
531
532    /// Response-time analysis per worker, then the cycle's timeline composed
533    /// along dependencies, then the constraint-ranked score.
534    fn evaluate(&self, assignment: &Assignment) -> Option<Evaluation> {
535        let lanes = &assignment.lanes;
536        let g = self.copperlist_ns;
537        let period = self.cycle_ns;
538        let lane_cost: Vec<u64> = lanes
539            .iter()
540            .map(|lane| lane.iter().map(|&o| self.cost[o]).sum())
541            .collect();
542        // A lane's busy share of the window is its own work plus that of the
543        // higher-priority lanes on its CPU, which preempt it.
544        let window_ns = period * self.window_cost.len() as u64;
545        let window_load: Vec<u64> = lanes
546            .iter()
547            .map(|lane| {
548                lane.iter()
549                    .map(|&o| self.window_cost.iter().map(|cycle| cycle[o]).sum::<u64>())
550                    .sum()
551            })
552            .collect();
553        let mut response = Vec::with_capacity(lanes.len());
554        let mut rate = Vec::with_capacity(lanes.len());
555        let mut load = Vec::with_capacity(lanes.len());
556        let mut inflate = Vec::with_capacity(lanes.len());
557        for (l, &cost) in lane_cost.iter().enumerate() {
558            let r = fixpoint(cost, &[], g, RESPONSE_CAP_PERIODS * period);
559            response.push(r);
560            let higher = self.higher_load(l, &window_load);
561            let busy = (window_load[l] + higher) as f64 / window_ns as f64;
562            // A lane full past the headroom's share of the window has no room
563            // for the costs' tails and falls behind; measured costs' p95 is
564            // two to three times their mean on the Autoware replica.
565            let headroom = ((1.0 - self.headroom) / busy.max(1e-9)).min(1.0);
566            rate.push(
567                match r {
568                    Some(r) if r <= period => 1.0,
569                    Some(r) => period as f64 / (r + g / 2) as f64,
570                    None => 0.0,
571                }
572                .min(headroom),
573            );
574            load.push(busy);
575            // The typical stretch of a segment under preemption, as in the
576            // paper: divided by the share the higher lanes leave.
577            inflate.push(if higher < window_ns {
578                (window_ns as f64 / (window_ns - higher) as f64).min(RESPONSE_CAP_PERIODS as f64)
579            } else {
580                RESPONSE_CAP_PERIODS as f64
581            });
582        }
583        // Chains are timed in a cycle where everything fires: a long chain's
584        // latency is paid in the cycles it runs, not on average.
585        let (starts, ends) = self.timeline(lanes, &self.fired_cost, &inflate)?;
586        let (_, expected_ends) = self.timeline(lanes, &self.cost, &inflate)?;
587        // In steady state a lane may run behind another by whole cycles; the
588        // second window of a two-window timeline gives each lane's cycle
589        // time, waits included, and what a chain across lanes pays for it.
590        let per_window = self.window_cost.len();
591        let n = self.units.len();
592        let (window_starts, window_ends) = self.window_timeline(lanes, &inflate, 2)?;
593        let last_end = |lane: &[usize], window: usize| {
594            let cycle = window * per_window + per_window - 1;
595            lane.iter()
596                .map(|&o| window_ends[cycle * n + o])
597                .max()
598                .unwrap_or(0)
599        };
600        for (l, lane) in lanes.iter().enumerate() {
601            let span = last_end(lane, 1) - last_end(lane, 0);
602            if span > window_ns {
603                rate[l] = rate[l].min(window_ns as f64 / span as f64);
604            }
605        }
606        let mut chain_ratio = Vec::with_capacity(self.chains.len());
607        let mut chain_latency = Vec::with_capacity(self.chains.len());
608        for (chain, pairs) in self.chains.iter().enumerate() {
609            let deadline = u64::from(self.contract_deadline(chain)) * 1_000_000;
610            let latency = pairs
611                .iter()
612                .map(|&(source, sink)| {
613                    let steady = (per_window..2 * per_window)
614                        .filter(|&cycle| self.fires[cycle % per_window][source])
615                        .map(|cycle| {
616                            window_ends[cycle * n + sink]
617                                .saturating_sub(window_starts[cycle * n + source])
618                        })
619                        .max()
620                        .unwrap_or(0);
621                    ends[sink].saturating_sub(starts[source]).max(steady)
622                })
623                .max()
624                .unwrap_or(0);
625            chain_ratio.push(latency as f64 / deadline as f64);
626            chain_latency.push(latency);
627        }
628        // Admission is gated by the whole cycle: the slowest lane throttles
629        // every source, wherever the source itself runs, and so does the
630        // cycle's critical path when fewer cycles than its length fit in
631        // flight (`max_in_flight` CopperLists over `k` per cycle).
632        let makespan = expected_ends.iter().copied().max().unwrap_or(0);
633        let cycles_in_flight =
634            f64::from(self.max_in_flight) / f64::from(self.inventory.copperlists_per_cycle.max(1));
635        let pipelined = makespan as f64 / cycles_in_flight;
636        let cycle_rate =
637            rate.iter()
638                .copied()
639                .fold(1.0f64, f64::min)
640                .min(if pipelined <= period as f64 {
641                    1.0
642                } else {
643                    period as f64 / (pipelined + g as f64 / 2.0)
644                });
645        let source_rate: Vec<f64> = self.sources.iter().map(|_| cycle_rate).collect();
646        let rate_deficit: f64 = source_rate.iter().map(|&r| rate_deficit(r)).sum();
647        let sum: f64 = chain_ratio.iter().sum();
648        let max_load = load.iter().copied().fold(0.0f64, f64::max);
649        let score = vec![
650            round6(rate_deficit),
651            chain_ratio.iter().filter(|&&r| r > 1.0).count() as f64,
652            chain_ratio
653                .iter()
654                .filter(|&&r| r > 1.0 - self.headroom)
655                .count() as f64,
656            round6(sum),
657            round6(max_load),
658        ];
659        Some(Evaluation {
660            score,
661            chain_latency,
662            response,
663            rate,
664            load,
665            cycle_rate,
666        })
667    }
668
669    /// Each lane runs its units in order; a unit starts when its lane is free
670    /// and every predecessor has ended, and takes its cost stretched by the
671    /// lane's `inflate` factor.
672    fn timeline(
673        &self,
674        lanes: &[Vec<usize>],
675        cost: &[u64],
676        inflate: &[f64],
677    ) -> Option<(Vec<u64>, Vec<u64>)> {
678        let n = self.units.len();
679        let mut starts = vec![0u64; n];
680        let mut ends = vec![0u64; n];
681        let mut done = vec![false; n];
682        let mut lane_free = vec![0u64; lanes.len()];
683        let mut progressed = true;
684        let mut remaining = n;
685        while remaining > 0 && progressed {
686            progressed = false;
687            for (l, lane) in lanes.iter().enumerate() {
688                let Some(&o) = lane.iter().find(|&&o| !done[o]) else {
689                    continue;
690                };
691                if self.preds[o].iter().any(|&p| !done[p]) {
692                    continue;
693                }
694                let release = self.preds[o].iter().map(|&p| ends[p]).max().unwrap_or(0);
695                let start = lane_free[l].max(release);
696                starts[o] = start;
697                ends[o] = start + (cost[o] as f64 * inflate[l]) as u64;
698                lane_free[l] = ends[o];
699                done[o] = true;
700                remaining -= 1;
701                progressed = true;
702            }
703        }
704        (remaining == 0).then_some((starts, ends))
705    }
706
707    /// The lanes over `windows` windows: cycles released on the grid and
708    /// held back by the ring, lanes in order across cycles, a unit waiting
709    /// for its zero-lag predecessors of the same cycle, costs from the
710    /// firing pattern stretched by `inflate`. Starts and ends are indexed by
711    /// `cycle * units + unit`.
712    fn window_timeline(
713        &self,
714        lanes: &[Vec<usize>],
715        inflate: &[f64],
716        windows: usize,
717    ) -> Option<(Vec<u64>, Vec<u64>)> {
718        let n = self.units.len();
719        let per_window = self.window_cost.len();
720        let cycles = per_window * windows;
721        let ring =
722            (self.max_in_flight / self.inventory.copperlists_per_cycle.max(1)).max(1) as usize;
723        let mut starts = vec![0u64; n * cycles];
724        let mut ends = vec![0u64; n * cycles];
725        let mut done = vec![false; n * cycles];
726        let mut remaining = vec![n; cycles];
727        let mut cycle_end = vec![0u64; cycles];
728        let mut admission = vec![0u64; cycles];
729        let mut lane_free = vec![0u64; lanes.len()];
730        let mut position = vec![0usize; lanes.len()];
731        let mut progressed = true;
732        while progressed {
733            progressed = false;
734            for (l, lane) in lanes.iter().enumerate() {
735                while position[l] < lane.len() * cycles {
736                    let (cycle, o) = (position[l] / lane.len(), lane[position[l] % lane.len()]);
737                    let at = |unit: usize| cycle * n + unit;
738                    if cycle >= ring && remaining[cycle - ring] > 0 {
739                        break;
740                    }
741                    if self.preds[o].iter().any(|&p| !done[at(p)]) {
742                        break;
743                    }
744                    if admission[cycle] == 0 {
745                        admission[cycle] = (cycle as u64 * self.cycle_ns)
746                            .max(if cycle >= ring {
747                                cycle_end[cycle - ring]
748                            } else {
749                                0
750                            })
751                            .max(if cycle > 0 { admission[cycle - 1] } else { 0 });
752                    }
753                    let release = self.preds[o]
754                        .iter()
755                        .map(|&p| ends[at(p)])
756                        .fold(admission[cycle], u64::max);
757                    let start = lane_free[l].max(release);
758                    starts[at(o)] = start;
759                    ends[at(o)] = start
760                        + (self.window_cost[cycle % per_window][o] as f64 * inflate[l]) as u64;
761                    lane_free[l] = ends[at(o)];
762                    cycle_end[cycle] = cycle_end[cycle].max(ends[at(o)]);
763                    done[at(o)] = true;
764                    remaining[cycle] -= 1;
765                    position[l] += 1;
766                    progressed = true;
767                }
768            }
769        }
770        remaining.iter().all(|&r| r == 0).then_some((starts, ends))
771    }
772
773    fn contract_deadline(&self, chain: usize) -> u32 {
774        self.chain_deadlines[chain]
775    }
776}
777
778fn round6(value: f64) -> f64 {
779    (value * 1e6 + 0.5) as u64 as f64 / 1e6
780}
781
782/// The units reachable from `start` over `adj`, `start` included.
783fn reachable(adj: &[Vec<usize>], start: usize) -> Vec<bool> {
784    let mut seen = vec![false; adj.len()];
785    let mut stack = vec![start];
786    seen[start] = true;
787    while let Some(u) = stack.pop() {
788        for &v in &adj[u] {
789            if !seen[v] {
790                seen[v] = true;
791                stack.push(v);
792            }
793        }
794    }
795    seen
796}
797
798/// The connected component of each unit, ignoring edge direction.
799fn components(preds: &[Vec<usize>]) -> Vec<usize> {
800    let n = preds.len();
801    let mut succs: Vec<Vec<usize>> = vec![Vec::new(); n];
802    for (to, from) in preds.iter().enumerate() {
803        for &from in from {
804            succs[from].push(to);
805        }
806    }
807    let mut component = vec![usize::MAX; n];
808    let mut count = 0;
809    for start in 0..n {
810        if component[start] != usize::MAX {
811            continue;
812        }
813        let mut stack = vec![start];
814        component[start] = count;
815        while let Some(u) = stack.pop() {
816            for &v in preds[u].iter().chain(&succs[u]) {
817                if component[v] == usize::MAX {
818                    component[v] = count;
819                    stack.push(v);
820                }
821            }
822        }
823        count += 1;
824    }
825    component
826}
827
828/// How many nodes a topological walk over `succs` reaches; fewer than all
829/// means a cycle.
830fn kahn_count(succs: &[Vec<usize>], mut incoming: Vec<usize>) -> usize {
831    let mut ready: VecDeque<usize> = (0..succs.len()).filter(|&i| incoming[i] == 0).collect();
832    let mut seen = 0;
833    while let Some(node) = ready.pop_front() {
834        seen += 1;
835        for &next in &succs[node] {
836            incoming[next] -= 1;
837            if incoming[next] == 0 {
838                ready.push_back(next);
839            }
840        }
841    }
842    seen
843}
844
845/// Smallest `x = c + sum ceil((x + g) / T) C` over `hp`; `None` past `cap`.
846fn fixpoint(c: u64, hp: &[(u64, u64)], g: u64, cap: u64) -> Option<u64> {
847    let mut x = c;
848    loop {
849        let next = c + hp
850            .iter()
851            .map(|&(cost, period)| (x + g).div_ceil(period.max(1)) * cost)
852            .sum::<u64>();
853        if next == x {
854            return Some(x);
855        }
856        if next > cap {
857            return None;
858        }
859        x = next;
860    }
861}
862
863/// The task id inside a step key, `None` for bridge steps.
864fn task_of_key(key: &str) -> Option<&str> {
865    key.split('|').find_map(|part| part.strip_prefix("task:"))
866}
867
868fn is_refine(key: &str) -> bool {
869    key.contains("|phase:refine:")
870}
871
872/// A unit that no topological order can place, if the unit graph has a cycle.
873fn first_unit_on_a_cycle(preds: &[Vec<usize>]) -> Option<usize> {
874    let mut incoming: Vec<usize> = preds.iter().map(Vec::len).collect();
875    let mut succs: Vec<Vec<usize>> = vec![Vec::new(); preds.len()];
876    for (to, from) in preds.iter().enumerate() {
877        for &from in from {
878            succs[from].push(to);
879        }
880    }
881    let mut ready: VecDeque<usize> = (0..preds.len()).filter(|&i| incoming[i] == 0).collect();
882    while let Some(unit) = ready.pop_front() {
883        for &next in &succs[unit] {
884            incoming[next] -= 1;
885            if incoming[next] == 0 {
886                ready.push_back(next);
887            }
888        }
889    }
890    // Every node left is on a cycle or downstream of one; strip the
891    // downstream ones (no remaining successor of theirs is itself left) so a
892    // cycle member is named.
893    let mut left: Vec<bool> = incoming.iter().map(|&n| n > 0).collect();
894    loop {
895        let stripped = (0..preds.len()).find(|&i| left[i] && succs[i].iter().all(|&s| !left[s]));
896        match stripped {
897            Some(i) => left[i] = false,
898            None => break,
899        }
900    }
901    (0..preds.len()).find(|&i| left[i])
902}
903
904/// A source within `RATE_TOLERANCE` of its period keeps its rate; a deficit
905/// counts in steps of `RATE_STEP`, so the tiers below decide between plans
906/// whose rates the model cannot tell apart.
907pub(super) fn rate_deficit(rate: f64) -> f64 {
908    let deficit = (1.0 - RATE_TOLERANCE - rate).max(0.0);
909    (deficit / RATE_STEP).ceil() * RATE_STEP
910}
911
912/// A deterministic pseudo-random sequence for the search (splitmix64).
913struct Rng(u64);
914
915impl Rng {
916    fn next(&mut self) -> u64 {
917        self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
918        let mut z = self.0;
919        z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
920        z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
921        z ^ (z >> 31)
922    }
923
924    fn below(&mut self, n: usize) -> usize {
925        (self.next() % n.max(1) as u64) as usize
926    }
927}
928
929impl Model {
930    /// The start: a pipeline. Connected components in deadline order, each in
931    /// earliest-start order; a component that is short-deadline work
932    /// throughout goes whole to the least loaded higher-tier lane, the rest
933    /// are cut into one stage per CPU by load.
934    fn start_plan(&self) -> Assignment {
935        let n = self.units.len();
936        let cpus = self.cpus.len();
937        let tiers = self.tiers();
938        let mut incoming = vec![0usize; n];
939        let mut succs: Vec<Vec<usize>> = vec![Vec::new(); n];
940        for (to, preds) in self.preds.iter().enumerate() {
941            for &from in preds {
942                succs[from].push(to);
943                incoming[to] += 1;
944            }
945        }
946        let mut ready: VecDeque<usize> = (0..n).filter(|&i| incoming[i] == 0).collect();
947        let mut order = Vec::with_capacity(n);
948        while let Some(o) = ready.pop_front() {
949            order.push(o);
950            for &next in &succs[o] {
951                incoming[next] -= 1;
952                if incoming[next] == 0 {
953                    ready.push_back(next);
954                }
955            }
956        }
957        let mut asap = vec![0u64; n];
958        let mut position = vec![0usize; n];
959        for (i, &o) in order.iter().enumerate() {
960            position[o] = i;
961            asap[o] = self.preds[o]
962                .iter()
963                .map(|&p| asap[p] + self.cost[p])
964                .max()
965                .unwrap_or(0);
966        }
967        let component = components(&self.preds);
968        let count = component.iter().max().map_or(0, |&c| c + 1);
969        let unit_load = |o: usize| self.window_cost.iter().map(|cycle| cycle[o]).sum::<u64>();
970        let mut members: Vec<Vec<usize>> = vec![Vec::new(); count];
971        let mut deadline = vec![u64::MAX; count];
972        for o in 0..n {
973            members[component[o]].push(o);
974        }
975        for (chain, pairs) in self.chains.iter().enumerate() {
976            for &(_, sink) in pairs {
977                let c = &mut deadline[component[sink]];
978                *c = (*c).min(u64::from(self.chain_deadlines[chain]) * 1_000_000);
979            }
980        }
981        let mut components: Vec<usize> = (0..count).collect();
982        for c in &components {
983            members[*c].sort_by_key(|&o| (asap[o], position[o]));
984        }
985        let comp_load: Vec<u64> = members
986            .iter()
987            .map(|units| units.iter().map(|&o| unit_load(o)).sum())
988            .collect();
989        components.sort_by_key(|&c| (deadline[c], core::cmp::Reverse(comp_load[c]), c));
990        let share = comp_load.iter().sum::<u64>() / cpus as u64;
991        let mut lanes = vec![Vec::new(); cpus * tiers];
992        let mut lane_load = vec![0u64; cpus * tiers];
993        let mut low = Vec::new();
994        for &c in &components {
995            if tiers > 1 && members[c].iter().all(|&o| self.short[o]) {
996                let lane = cpus
997                    + (0..cpus)
998                        .min_by_key(|&i| (lane_load[cpus + i], i))
999                        .expect("at least one CPU");
1000                lanes[lane].extend(&members[c]);
1001                lane_load[lane] += comp_load[c];
1002            } else {
1003                low.extend(&members[c]);
1004            }
1005        }
1006        let capacity = |stage: usize| {
1007            share.saturating_sub(if tiers > 1 {
1008                lane_load[cpus + stage]
1009            } else {
1010                0
1011            })
1012        };
1013        let (mut stage, mut acc, mut boundary) = (0, 0u64, capacity(0));
1014        for o in low {
1015            let w = unit_load(o);
1016            if stage + 1 < cpus && acc + w / 2 > boundary {
1017                stage += 1;
1018                boundary += capacity(stage);
1019            }
1020            lanes[stage].push(o);
1021            acc += w;
1022        }
1023        Assignment { lanes }
1024    }
1025
1026    /// One random move: an occurrence moved to another lane and position, or
1027    /// two neighbours on one lane swapped. Invalid results are rejected by
1028    /// the caller through `is_valid`.
1029    fn neighbour(&self, assignment: &Assignment, rng: &mut Rng) -> Assignment {
1030        let mut lanes = assignment.lanes.clone();
1031        let occupied: Vec<usize> = (0..lanes.len()).filter(|&l| !lanes[l].is_empty()).collect();
1032        if occupied.is_empty() {
1033            return assignment.clone();
1034        }
1035        if rng.below(2) == 0 || lanes.len() == 1 {
1036            let from = occupied[rng.below(occupied.len())];
1037            let at = rng.below(lanes[from].len());
1038            let o = lanes[from].remove(at);
1039            // A unit keeps its tier: a short chain must not straddle tiers.
1040            let tier = self.lane_tier(from);
1041            let cpus = self.cpus.len();
1042            let to = tier * cpus + rng.below(cpus);
1043            let position = rng.below(lanes[to].len() + 1);
1044            lanes[to].insert(position, o);
1045        } else {
1046            let wide: Vec<usize> = occupied
1047                .iter()
1048                .copied()
1049                .filter(|&l| lanes[l].len() > 1)
1050                .collect();
1051            if wide.is_empty() {
1052                return assignment.clone();
1053            }
1054            let lane = wide[rng.below(wide.len())];
1055            let at = rng.below(lanes[lane].len() - 1);
1056            lanes[lane].swap(at, at + 1);
1057        }
1058        Assignment { lanes }
1059    }
1060
1061    /// Hill climbing from the list schedule and from seeded random starts;
1062    /// every valid assignment evaluated is kept for the final ranking.
1063    fn search(&self, seed: u64, moves: usize, restarts: usize) -> Vec<(Vec<f64>, Assignment)> {
1064        let mut seen: BTreeMap<Assignment, Option<Vec<f64>>> = BTreeMap::new();
1065        let score = |assignment: &Assignment,
1066                     seen: &mut BTreeMap<Assignment, Option<Vec<f64>>>|
1067         -> Option<Vec<f64>> {
1068            if let Some(known) = seen.get(assignment) {
1069                return known.clone();
1070            }
1071            let value = if self.is_valid(assignment) {
1072                self.evaluate(assignment).map(|evaluation| evaluation.score)
1073            } else {
1074                None
1075            };
1076            seen.insert(assignment.clone(), value.clone());
1077            value
1078        };
1079        let start = self.start_plan();
1080        for attempt in 0..restarts.max(1) {
1081            let mut rng = Rng(seed.wrapping_add(attempt as u64));
1082            let mut current = if attempt == 0 {
1083                start.clone()
1084            } else {
1085                let mut random = start.clone();
1086                for _ in 0..self.units.len() * 2 {
1087                    let candidate = self.neighbour(&random, &mut rng);
1088                    if self.is_valid(&candidate) {
1089                        random = candidate;
1090                    }
1091                }
1092                random
1093            };
1094            let mut current_score = score(&current, &mut seen);
1095            let mut best = current.clone();
1096            let mut best_score = current_score.clone();
1097            let mut stall = 0;
1098            for _ in 0..moves {
1099                let candidate = self.neighbour(&current, &mut rng);
1100                let candidate_score = score(&candidate, &mut seen);
1101                let better = match (&candidate_score, &current_score) {
1102                    (Some(c), Some(cur)) => better_score(c, cur),
1103                    (Some(_), None) => true,
1104                    _ => false,
1105                };
1106                if better {
1107                    current = candidate;
1108                    current_score = candidate_score;
1109                    stall = 0;
1110                    if best_score
1111                        .as_ref()
1112                        .is_none_or(|b| better_score(current_score.as_ref().unwrap(), b))
1113                    {
1114                        best = current.clone();
1115                        best_score = current_score.clone();
1116                    }
1117                } else {
1118                    stall += 1;
1119                    if stall >= PATIENCE {
1120                        current = best.clone();
1121                        for _ in 0..KICK {
1122                            let kicked = self.neighbour(&current, &mut rng);
1123                            if self.is_valid(&kicked) {
1124                                current = kicked;
1125                            }
1126                        }
1127                        current_score = score(&current, &mut seen);
1128                        stall = 0;
1129                    }
1130                }
1131            }
1132        }
1133        let mut scored: Vec<(Vec<f64>, Assignment)> = seen
1134            .into_iter()
1135            .filter_map(|(assignment, score)| score.map(|score| (score, assignment)))
1136            .collect();
1137        scored.sort_by(|a, b| {
1138            if better_score(&a.0, &b.0) {
1139                core::cmp::Ordering::Less
1140            } else if better_score(&b.0, &a.0) {
1141                core::cmp::Ordering::Greater
1142            } else {
1143                a.1.cmp(&b.1)
1144            }
1145        });
1146        scored
1147    }
1148
1149    /// The plan an assignment describes, with the contract's placement.
1150    fn materialize(&self, assignment: &Assignment, max_in_flight: u32) -> CuMissionPlan {
1151        let mut mission = self.inventory.clone();
1152        mission.max_in_flight = max_in_flight.max(mission.copperlists_per_cycle);
1153        mission.workers = assignment
1154            .lanes
1155            .iter()
1156            .enumerate()
1157            .filter(|(_, lane)| !lane.is_empty())
1158            .map(|(l, lane)| CuPlanWorker {
1159                id: self.worker_id(l),
1160                placement: CuPlanPlacement::Thread {
1161                    cpu: Some(self.lane_cpu(l)),
1162                    policy: self.worker_policy(l),
1163                },
1164                steps: lane
1165                    .iter()
1166                    .flat_map(|&u| self.units[u].iter().map(|&o| o as u32))
1167                    .collect(),
1168            })
1169            .collect();
1170        mission.dispatcher = Some(self.dispatcher());
1171        mission
1172    }
1173
1174    fn prediction(&self, assignment: &Assignment, contract: &CuContract) -> CuPrediction {
1175        let evaluation = self
1176            .evaluate(assignment)
1177            .expect("a ranked assignment evaluates");
1178        let chains = contract
1179            .chains
1180            .iter()
1181            .zip(&self.chains)
1182            .enumerate()
1183            .map(|(index, (chain, _))| {
1184                let deadline_ns = u64::from(chain.deadline_ms) * 1_000_000;
1185                let latency_ns = evaluation.chain_latency[index];
1186                (
1187                    chain.id.clone(),
1188                    CuChainPrediction {
1189                        latency_ns,
1190                        deadline_ns,
1191                        ratio: latency_ns as f64 / deadline_ns as f64,
1192                    },
1193                )
1194            })
1195            .collect();
1196        let workers = assignment
1197            .lanes
1198            .iter()
1199            .enumerate()
1200            .filter(|(_, lane)| !lane.is_empty())
1201            .map(|(l, _)| {
1202                (
1203                    self.worker_id(l),
1204                    CuWorkerPrediction {
1205                        cpu: self.lane_cpu(l),
1206                        load: evaluation.load[l],
1207                        response_ns: evaluation.response[l],
1208                        rate: evaluation.rate[l],
1209                    },
1210                )
1211            })
1212            .collect();
1213        let sources = contract
1214            .sources
1215            .iter()
1216            .map(|source| (source.task.clone(), evaluation.cycle_rate))
1217            .collect();
1218        CuPrediction {
1219            score: evaluation.score,
1220            chains,
1221            workers,
1222            sources,
1223        }
1224    }
1225}
1226
1227/// Lexicographic comparison, smaller is better.
1228fn better_score(a: &[f64], b: &[f64]) -> bool {
1229    for (x, y) in a.iter().zip(b) {
1230        if x < y {
1231            return true;
1232        }
1233        if x > y {
1234            return false;
1235        }
1236    }
1237    false
1238}
1239
1240/// Whether two scores describe the same candidate for reporting: every tier
1241/// before the sum tier equal, and the sum tier within `MIN_SEPARATION`.
1242fn same_candidate(a: &[f64], b: &[f64], sum_tier: usize) -> bool {
1243    a[..sum_tier] == b[..sum_tier]
1244        && (a[sum_tier] - b[sum_tier]).abs() <= MIN_SEPARATION * b[sum_tier].abs().max(1e-9)
1245}
1246
1247/// Proposes up to `request.candidates` distinct plans for one mission.
1248///
1249/// Cycle size, in-flight depth, dispatcher placement, and search budget are
1250/// derived deterministically. Every returned plan passes [`CuPlan::validate`].
1251pub fn propose(request: &ProposeRequest<'_>) -> CuResult<Vec<CuCandidate>> {
1252    if request.candidates == 0 {
1253        return Err(CuError::from(
1254            "At least one placement candidate is required",
1255        ));
1256    }
1257    request
1258        .contract
1259        .validate(request.config, Some(request.mission))?;
1260    let graph = request.config.get_graph(Some(request.mission))?;
1261    let signature = super::graph_signature(graph, Some(request.mission));
1262    if request.profile.config_signature != signature {
1263        return Err(CuError::from(format!(
1264            "The profile was recorded on another graph ({}); this config's mission '{}' is {signature}",
1265            request.profile.config_signature, request.mission
1266        )));
1267    }
1268    let baseline = CuPlan::from_config(request.config)?;
1269    let mut cycles = vec![1];
1270    if request.contract.max_in_flight >= 2
1271        && graph
1272            .get_all_nodes()
1273            .iter()
1274            .any(|(_, node)| node.get_declared_task_kind() == Some(TaskKind::Stateless))
1275    {
1276        cycles.push(2);
1277    }
1278    let mut candidates = Vec::new();
1279    for copperlists_per_cycle in cycles {
1280        for max_in_flight in copperlists_per_cycle..=request.contract.max_in_flight {
1281            candidates.extend(propose_shape(
1282                request,
1283                copperlists_per_cycle,
1284                max_in_flight,
1285                &baseline.concurrent_resources,
1286            )?);
1287        }
1288    }
1289    candidates.sort_by(|left, right| {
1290        if better_score(&left.prediction.score, &right.prediction.score) {
1291            core::cmp::Ordering::Less
1292        } else if better_score(&right.prediction.score, &left.prediction.score) {
1293            core::cmp::Ordering::Greater
1294        } else {
1295            core::cmp::Ordering::Equal
1296        }
1297    });
1298    let mut chosen: Vec<CuCandidate> = Vec::new();
1299    for candidate in candidates {
1300        if chosen.len() >= request.candidates {
1301            break;
1302        }
1303        if chosen
1304            .iter()
1305            .any(|known| same_candidate(&candidate.prediction.score, &known.prediction.score, 3))
1306        {
1307            continue;
1308        }
1309        chosen.push(candidate);
1310    }
1311    if chosen.is_empty() {
1312        return Err(CuError::from(
1313            "No valid plan was found on this contract's CPUs",
1314        ));
1315    }
1316    Ok(chosen)
1317}
1318
1319fn propose_shape(
1320    request: &ProposeRequest<'_>,
1321    copperlists_per_cycle: u32,
1322    max_in_flight: u32,
1323    concurrent_resources: &[String],
1324) -> CuResult<Vec<CuCandidate>> {
1325    let exported = CuPlan::from_config_cyclic_with_resources(
1326        request.config,
1327        copperlists_per_cycle,
1328        concurrent_resources,
1329    )?;
1330    let inventory = exported
1331        .missions
1332        .get(request.mission)
1333        .ok_or_else(|| CuError::from(format!("Unknown mission '{}'", request.mission)))?
1334        .clone();
1335    let model = Model::new(request, inventory, max_in_flight)?;
1336    let seed = SEARCH_SEED ^ (u64::from(copperlists_per_cycle) << 32) ^ u64::from(max_in_flight);
1337    let scored = model.search(seed, SEARCH_MOVES, SEARCH_RESTARTS);
1338    let mut chosen: Vec<(Vec<f64>, Assignment)> = Vec::new();
1339    for (score, assignment) in scored {
1340        if chosen.len() >= request.candidates {
1341            break;
1342        }
1343        if chosen
1344            .iter()
1345            .any(|(known, _)| same_candidate(&score, known, 3))
1346        {
1347            continue;
1348        }
1349        chosen.push((score, assignment));
1350    }
1351    let mut candidates = Vec::with_capacity(chosen.len());
1352    for (_, assignment) in chosen {
1353        let mission = model.materialize(&assignment, max_in_flight);
1354        let mut plan = exported.clone();
1355        plan.missions.insert(request.mission.to_string(), mission);
1356        let mut prepared = request.config.clone();
1357        plan.provide_capacity(&mut prepared);
1358        if plan.validate(&prepared).is_err() {
1359            continue;
1360        }
1361        candidates.push(CuCandidate {
1362            prediction: model.prediction(&assignment, request.contract),
1363            plan,
1364        });
1365    }
1366    Ok(candidates)
1367}
1368
1369#[cfg(test)]
1370mod tests {
1371    use super::*;
1372    use crate::planner::CuChain;
1373    use crate::planner::CuCostStats;
1374    use crate::planner::CuOperationProfile;
1375    use crate::planner::CuSourceRate;
1376
1377    fn config() -> CuConfig {
1378        CuConfig::deserialize_ron(
1379            r#"(
1380            runtime: (rate_target_hz: 100),
1381            logging: (copperlist_count: 4),
1382            tasks: [(id: "src", type: "Source"), (id: "left", type: "Left"),
1383                (id: "right", type: "Right", kind: stateless_task), (id: "sink", type: "Sink")],
1384            cnx: [(src: "src", dst: "left", msg: "u32"), (src: "src", dst: "right", msg: "u32"),
1385                (src: "left", dst: "sink", msg: "u32"), (src: "right", dst: "sink", msg: "u32")],
1386        )"#,
1387        )
1388        .unwrap()
1389    }
1390
1391    fn contract(cpus: Vec<usize>) -> CuContract {
1392        CuContract {
1393            chains: vec![CuChain {
1394                id: "hot".into(),
1395                source: "src".into(),
1396                sink: "sink".into(),
1397                deadline_ms: 10,
1398            }],
1399            sources: vec![CuSourceRate {
1400                task: "src".into(),
1401                period_ms: 10,
1402            }],
1403            cpus,
1404            max_in_flight: 2,
1405            headroom: 0.2,
1406            worker_policy: SchedulingPolicy::Fair,
1407        }
1408    }
1409
1410    /// `left` and `right` each cost 3 ms every CopperList; the rest is free.
1411    fn profile() -> CuProfile {
1412        let graph = config().get_graph(Some("default")).unwrap().clone();
1413        let mut profile = CuProfile::new(
1414            crate::planner::graph_signature(&graph, Some("default")),
1415            "default".into(),
1416        );
1417        profile.copperlists = 100;
1418        profile.window_ns = 1_000_000_000;
1419        for (task, cost) in [
1420            ("src", 100_000),
1421            ("left", 3_000_000),
1422            ("right", 3_000_000),
1423            ("sink", 100_000),
1424        ] {
1425            let mut samples = vec![cost; 100];
1426            profile.operations.insert(
1427                format!("mission:default|task:{task}|phase:whole"),
1428                CuOperationProfile {
1429                    fired: CuCostStats::from_samples(&mut samples),
1430                    skipped: CuCostStats::default(),
1431                    firing_rate_hz: 100.0,
1432                    firing: None,
1433                },
1434            );
1435        }
1436        profile
1437    }
1438
1439    fn request<'a>(
1440        config: &'a CuConfig,
1441        contract: &'a CuContract,
1442        profile: &'a CuProfile,
1443    ) -> ProposeRequest<'a> {
1444        ProposeRequest {
1445            config,
1446            mission: "default",
1447            contract,
1448            profile,
1449            candidates: 3,
1450        }
1451    }
1452
1453    #[test]
1454    fn parallel_branches_land_on_two_cpus_and_shorten_the_chain() {
1455        let config = config();
1456        let profile = profile();
1457        let one = contract(vec![0]);
1458        let serial = propose(&request(&config, &one, &profile)).unwrap();
1459        let two = contract(vec![0, 1]);
1460        let parallel = propose(&request(&config, &two, &profile)).unwrap();
1461        let serial_latency = serial[0].prediction.chains["hot"].latency_ns;
1462        let parallel_latency = parallel[0].prediction.chains["hot"].latency_ns;
1463        assert_eq!(serial_latency, 6_200_000);
1464        assert_eq!(parallel_latency, 3_200_000);
1465        assert_eq!(parallel[0].plan.missions["default"].workers.len(), 2);
1466        for candidate in serial.iter().chain(&parallel) {
1467            candidate.plan.validate(&config).unwrap();
1468            assert_eq!(candidate.prediction.sources["src"], 1.0);
1469        }
1470        // Same request, same answer.
1471        let again = propose(&request(&config, &two, &profile)).unwrap();
1472        assert_eq!(again, parallel);
1473    }
1474
1475    #[test]
1476    fn a_chain_is_timed_in_a_cycle_where_its_steps_fire() {
1477        let config = config();
1478        let mut profile = profile();
1479        // `left` fires in one CopperList out of ten: cheap on average, still
1480        // 3 ms in the cycles the chain runs through it.
1481        let left = profile
1482            .operations
1483            .get_mut("mission:default|task:left|phase:whole")
1484            .unwrap();
1485        left.fired = CuCostStats::from_samples(&mut [3_000_000; 10]);
1486        left.skipped = CuCostStats::from_samples(&mut vec![1_000; 90]);
1487        let one = contract(vec![0]);
1488        let serial = propose(&request(&config, &one, &profile)).unwrap();
1489        assert_eq!(serial[0].prediction.chains["hot"].latency_ns, 6_200_000);
1490        let load = serial[0].prediction.workers["cpu0"].load;
1491        assert!((0.32..0.36).contains(&load), "{load}");
1492    }
1493
1494    #[test]
1495    fn two_copperlists_per_cycle_let_the_stateless_task_overlap() {
1496        let config = config();
1497        let profile = profile();
1498        let two = contract(vec![0, 1, 2]);
1499        let candidates = propose(&request(&config, &two, &profile)).unwrap();
1500        let best = &candidates[0];
1501        let mission = &best.plan.missions["default"];
1502        assert_eq!(
1503            (mission.copperlists_per_cycle, mission.max_in_flight),
1504            (2, 2)
1505        );
1506        best.plan.validate(&config).unwrap();
1507        // Both CopperLists of the cycle fit in one 20 ms cycle with three CPUs.
1508        assert!(best.prediction.chains["hot"].latency_ns <= 6_200_000);
1509        assert!(best.prediction.workers.values().all(|w| w.load < 1.0));
1510    }
1511
1512    #[test]
1513    fn anytime_phases_stay_together_on_one_worker() {
1514        let config = CuConfig::deserialize_ron(
1515            r#"(
1516            runtime: (rate_target_hz: 100),
1517            logging: (copperlist_count: 2),
1518            tasks: [(id: "src", type: "Source"),
1519                (id: "refiner", type: "Refiner", anytime: (max_refines: 2)),
1520                (id: "sink", type: "Sink")],
1521            cnx: [(src: "src", dst: "refiner", msg: "u32"), (src: "refiner", dst: "sink", msg: "u32")],
1522        )"#,
1523        )
1524        .unwrap();
1525        let graph = config.get_graph(Some("default")).unwrap();
1526        let mut profile = CuProfile::new(
1527            crate::planner::graph_signature(graph, Some("default")),
1528            "default".into(),
1529        );
1530        profile.copperlists = 100;
1531        profile.window_ns = 1_000_000_000;
1532        for (key, cost) in [
1533            ("mission:default|task:src|phase:whole", 100_000),
1534            ("mission:default|task:refiner|phase:base", 3_000_000),
1535            ("mission:default|task:sink|phase:whole", 100_000),
1536        ] {
1537            let mut samples = vec![cost; 100];
1538            profile.operations.insert(
1539                key.into(),
1540                CuOperationProfile {
1541                    fired: CuCostStats::from_samples(&mut samples),
1542                    skipped: CuCostStats::default(),
1543                    firing_rate_hz: 100.0,
1544                    firing: None,
1545                },
1546            );
1547        }
1548        let mut contract = contract(vec![0, 1]);
1549        contract.chains[0].sink = "sink".into();
1550        contract.max_in_flight = 1;
1551        let candidates = propose(&request(&config, &contract, &profile)).unwrap();
1552        for candidate in &candidates {
1553            let mission = &candidate.plan.missions["default"];
1554            let refine = |key: &str| {
1555                mission
1556                    .steps
1557                    .iter()
1558                    .position(|s| s.key.contains(key))
1559                    .unwrap() as u32
1560            };
1561            let (base, one, two) = (refine("phase:base"), refine("refine:1"), refine("refine:2"));
1562            let worker = mission
1563                .workers
1564                .iter()
1565                .find(|w| w.steps.contains(&base))
1566                .unwrap();
1567            let at = |step| worker.steps.iter().position(|&s| s == step).unwrap();
1568            assert_eq!((at(one), at(two)), (at(base) + 1, at(base) + 2));
1569        }
1570        assert_eq!(candidates[0].prediction.chains["hot"].latency_ns, 3_200_000);
1571    }
1572
1573    #[test]
1574    fn fifo_workers_come_in_two_tiers_and_lanes_form_a_pipeline() {
1575        let config = config();
1576        let profile = profile();
1577        let mut contract = contract(vec![0, 1]);
1578        contract.worker_policy = SchedulingPolicy::Fifo { priority: 60 };
1579        let candidates = propose(&request(&config, &contract, &profile)).unwrap();
1580        for candidate in &candidates {
1581            let mission = &candidate.plan.missions["default"];
1582            let mut lane_of = BTreeMap::new();
1583            for (l, worker) in mission.workers.iter().enumerate() {
1584                let priority = match worker.placement {
1585                    CuPlanPlacement::Thread {
1586                        policy: SchedulingPolicy::Fifo { priority },
1587                        ..
1588                    } => priority,
1589                    _ => panic!("{}: not a FIFO thread", worker.id),
1590                };
1591                assert_eq!(priority, if worker.id.ends_with("-hi") { 61 } else { 60 });
1592                // The chain is due within its 10 ms period, so its whole
1593                // graph sits on higher workers.
1594                assert!(worker.id.ends_with("-hi"), "{}", worker.id);
1595                for &step in &worker.steps {
1596                    lane_of.insert(step, l);
1597                }
1598            }
1599            // The lanes must still be a pipeline.
1600            let mut edges: BTreeSet<(usize, usize)> = mission
1601                .dependencies
1602                .iter()
1603                .filter(|edge| edge.cycle_lag == 0)
1604                .map(|edge| (lane_of[&edge.from], lane_of[&edge.to]))
1605                .filter(|(a, b)| a != b)
1606                .collect();
1607            let mut lanes: BTreeSet<usize> = lane_of.values().copied().collect();
1608            while let Some(&first) = lanes.iter().find(|&&l| !edges.iter().any(|&(_, b)| b == l)) {
1609                lanes.remove(&first);
1610                edges.retain(|&(a, _)| a != first);
1611            }
1612            assert!(lanes.is_empty(), "lanes wait on each other: {edges:?}");
1613            assert_eq!(
1614                mission.dispatcher.as_ref().unwrap().policy,
1615                SchedulingPolicy::Fifo { priority: 62 }
1616            );
1617        }
1618        assert_eq!(candidates[0].prediction.chains["hot"].latency_ns, 3_200_000);
1619    }
1620
1621    #[test]
1622    fn a_missing_measurement_is_an_error() {
1623        let config = config();
1624        let mut profile = profile();
1625        profile
1626            .operations
1627            .remove("mission:default|task:left|phase:whole");
1628        let one = contract(vec![0]);
1629        assert!(propose(&request(&config, &one, &profile)).is_err());
1630    }
1631}