1use crate::copperlists_reader;
2use cu29::clock::{CuDuration, OptionCuTime};
3use cu29::config::{CuConfig, CuGraph, DEFAULT_MISSION_ID, Flavor};
4use cu29::curuntime::{CuExecutionUnit, CuStepPhase};
5use cu29::monitoring::CuDurationStatistics;
6use cu29::planner::{PlanEntityKind, assemble_runtime_plan, assemble_runtime_plan_from_step_keys};
7use cu29::prelude::{CopperListTuple, CuMsgMetadataTrait, CuPayloadRawBytes};
8use cu29::{CuError, CuResult};
9use serde::{Deserialize, Serialize};
10use std::collections::{BTreeMap, HashMap};
11use std::fs::File;
12use std::io::Read;
13use std::path::Path;
14
15const LOGSTATS_SCHEMA_VERSION: u32 = 2;
16const MAX_LATENCY_NS: u64 = 10_000_000_000;
17const MAX_QUANTILE_SAMPLES: usize = 2_048;
18const MAX_REPRESENTATIVE_TRACES: usize = 256;
19const MIN_CURRENT_CLUSTER_GAP_NS: u64 = 100_000;
20
21#[derive(Debug, Serialize, Deserialize)]
22pub struct LogStats {
23 pub schema_version: u32,
24 pub config_signature: String,
25 pub mission: Option<String>,
26 pub edges: Vec<EdgeLogStats>,
27 pub perf: PerfStats,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub schedule: Option<ScheduleLogStats>,
30}
31
32#[derive(Debug, Serialize, Deserialize)]
33pub struct ScheduleLogStats {
34 pub stages: Vec<StageLogStats>,
35 pub traces: Vec<ExecutionTrace>,
36 pub residual_before: DurationStats,
37 pub resource_overlaps: Vec<ResourceOverlapStats>,
38}
39
40#[derive(Debug, Serialize, Deserialize)]
41pub struct StageLogStats {
42 pub origin: String,
43 pub samples: u64,
44 pub durations: DurationStats,
45}
46
47#[derive(Clone, Debug, Serialize, Deserialize)]
48pub struct ExecutionTrace {
49 pub kind: String,
50 pub culist_id: u64,
51 pub duration_ns: u64,
53 pub wall_span_ns: u64,
55 pub excluded_intervals: u32,
57 pub residual_before_ns: Option<u64>,
58 pub intervals: Vec<ExecutionInterval>,
59}
60
61#[derive(Clone, Debug, Serialize, Deserialize)]
62pub struct ExecutionInterval {
63 pub origin: String,
64 pub start_ns: u64,
65 pub end_ns: u64,
66}
67
68#[derive(Debug, Serialize, Deserialize)]
69pub struct ResourceOverlapStats {
70 pub resource: String,
71 pub left: String,
72 pub right: String,
73 pub occurrences: u64,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct EdgeLogStats {
78 pub src: String,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub src_channel: Option<String>,
81 pub dst: String,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub dst_channel: Option<String>,
84 pub msg: String,
85 pub samples: u64,
86 pub none_samples: u64,
87 pub valid_time_samples: u64,
88 pub total_raw_bytes: u64,
89 pub avg_raw_bytes: Option<f64>,
90 pub rate_hz: Option<f64>,
91 pub throughput_bytes_per_sec: Option<f64>,
92}
93
94#[derive(Debug, Serialize, Deserialize)]
95pub struct PerfStats {
96 pub samples: u64,
97 pub valid_time_samples: u64,
98 pub end_to_end: DurationStats,
99 pub jitter: DurationStats,
100}
101
102#[derive(Debug, Default, Serialize, Deserialize)]
103pub struct DurationStats {
104 pub min_ns: Option<u64>,
105 pub max_ns: Option<u64>,
106 pub mean_ns: Option<f64>,
107 pub stddev_ns: Option<f64>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub p50_ns: Option<u64>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub p95_ns: Option<u64>,
112}
113
114#[derive(Debug, Default)]
115struct TimingAccumulator {
116 samples: u64,
117 min: Option<u64>,
118 max: Option<u64>,
119 sum: f64,
120 sum_squares: f64,
121 quantiles: WeightedQuantiles,
122}
123
124impl TimingAccumulator {
125 fn record(&mut self, value: u64) {
126 self.samples = self.samples.saturating_add(1);
127 self.min = Some(self.min.map_or(value, |current| current.min(value)));
128 self.max = Some(self.max.map_or(value, |current| current.max(value)));
129 let value_f64 = value as f64;
130 self.sum += value_f64;
131 self.sum_squares += value_f64 * value_f64;
132 self.quantiles.record(value);
133 }
134
135 fn stats(&self) -> DurationStats {
136 if self.samples == 0 {
137 return DurationStats::default();
138 }
139 let mean = self.sum / self.samples as f64;
140 let variance = (self.sum_squares / self.samples as f64 - mean * mean).max(0.0);
141 DurationStats {
142 min_ns: self.min,
143 max_ns: self.max,
144 mean_ns: Some(mean),
145 stddev_ns: Some(variance.sqrt()),
146 p50_ns: self.quantiles.quantile(0.50),
147 p95_ns: self.quantiles.quantile(0.95),
148 }
149 }
150}
151
152#[derive(Debug, Default)]
153struct WeightedQuantiles {
154 samples: Vec<u64>,
155 seen: u64,
156}
157
158impl WeightedQuantiles {
159 fn record(&mut self, value: u64) {
160 self.seen = self.seen.saturating_add(1);
161 if self.samples.len() < MAX_QUANTILE_SAMPLES {
162 self.samples.push(value);
163 return;
164 }
165 let candidate = splitmix64(self.seen) % self.seen;
166 if candidate < MAX_QUANTILE_SAMPLES as u64 {
167 self.samples[candidate as usize] = value;
168 }
169 }
170
171 fn quantile(&self, quantile: f64) -> Option<u64> {
172 if self.samples.is_empty() {
173 return None;
174 }
175 let mut values = self.samples.clone();
176 values.sort_unstable();
177 let index = ((values.len().saturating_sub(1)) as f64 * quantile).round() as usize;
178 values.get(index).copied()
179 }
180}
181
182#[derive(Clone, Debug, Eq, Hash, PartialEq)]
183struct EdgeKey {
184 src: String,
185 src_channel: Option<String>,
186 dst: String,
187 dst_channel: Option<String>,
188 msg: String,
189}
190
191#[derive(Clone, Debug)]
192struct OutputSlot {
193 edges: Vec<EdgeKey>,
194}
195
196#[derive(Debug, Default, Clone)]
197struct EdgeAccumulator {
198 samples: u64,
199 none_samples: u64,
200 valid_time_samples: u64,
201 total_raw_bytes: u64,
202 min_end_ns: Option<u64>,
203 max_end_ns: Option<u64>,
204}
205
206impl EdgeAccumulator {
207 fn record_sample(&mut self, payload_bytes: Option<u64>, end_time_ns: Option<u64>) {
208 self.samples = self.samples.saturating_add(1);
209 if let Some(bytes) = payload_bytes {
210 self.total_raw_bytes = self.total_raw_bytes.saturating_add(bytes);
211 } else {
212 self.none_samples = self.none_samples.saturating_add(1);
213 }
214
215 if let Some(end_ns) = end_time_ns {
216 self.valid_time_samples = self.valid_time_samples.saturating_add(1);
217 self.min_end_ns = Some(self.min_end_ns.map_or(end_ns, |min| min.min(end_ns)));
218 self.max_end_ns = Some(self.max_end_ns.map_or(end_ns, |max| max.max(end_ns)));
219 }
220 }
221
222 fn finalize(self, key: EdgeKey) -> EdgeLogStats {
223 let payload_samples = self.samples.saturating_sub(self.none_samples);
224 let avg_raw_bytes = if payload_samples > 0 {
225 Some(self.total_raw_bytes as f64 / payload_samples as f64)
226 } else {
227 None
228 };
229
230 let (rate_hz, throughput_bytes_per_sec) = if self.valid_time_samples >= 2 {
231 match (self.min_end_ns, self.max_end_ns) {
232 (Some(min_ns), Some(max_ns)) if max_ns > min_ns => {
233 let duration_ns = max_ns - min_ns;
234 let duration_secs = duration_ns as f64 / 1_000_000_000.0;
235 let intervals = (self.valid_time_samples - 1) as f64;
236 (
237 Some(intervals / duration_secs),
238 Some(self.total_raw_bytes as f64 / duration_secs),
239 )
240 }
241 _ => (None, None),
242 }
243 } else {
244 (None, None)
245 };
246
247 EdgeLogStats {
248 src: key.src,
249 src_channel: key.src_channel,
250 dst: key.dst,
251 dst_channel: key.dst_channel,
252 msg: key.msg,
253 samples: self.samples,
254 none_samples: self.none_samples,
255 valid_time_samples: self.valid_time_samples,
256 total_raw_bytes: self.total_raw_bytes,
257 avg_raw_bytes,
258 rate_hz,
259 throughput_bytes_per_sec,
260 }
261 }
262}
263
264#[derive(Debug)]
265struct PerfAccumulator {
266 stats: CuDurationStatistics,
267 samples: u64,
268 valid_time_samples: u64,
269}
270
271impl PerfAccumulator {
272 fn new() -> Self {
273 Self {
274 stats: CuDurationStatistics::new(CuDuration(MAX_LATENCY_NS)),
275 samples: 0,
276 valid_time_samples: 0,
277 }
278 }
279
280 fn record_sample(&mut self, latency: Option<CuDuration>) {
281 self.samples = self.samples.saturating_add(1);
282 if let Some(latency) = latency {
283 self.stats.record(latency);
284 self.valid_time_samples = self.valid_time_samples.saturating_add(1);
285 }
286 }
287
288 fn finalize(&self) -> PerfStats {
289 let end_to_end = duration_stats_from(&self.stats);
290 let jitter = jitter_stats_from(&self.stats);
291
292 PerfStats {
293 samples: self.samples,
294 valid_time_samples: self.valid_time_samples,
295 end_to_end,
296 jitter,
297 }
298 }
299}
300
301pub fn compute_logstats<P>(
302 mut reader: impl Read,
303 config: &CuConfig,
304 mission: Option<&str>,
305) -> CuResult<LogStats>
306where
307 P: CopperListTuple + CuPayloadRawBytes,
308{
309 let graph = config.get_graph(mission)?;
310 let signature = build_graph_signature(graph, mission);
311 let output_slots = build_output_slots::<P>(config, graph, mission).map_err(|e| {
312 CuError::from(format!(
313 "mission '{}': {e}",
314 mission.unwrap_or(DEFAULT_MISSION_ID)
315 ))
316 })?;
317 let mut edge_accumulators = build_edge_accumulators(graph);
318 let mut perf = PerfAccumulator::new();
319 let resource_bindings = build_resource_bindings::<P>(config, graph);
320 let mut stage_accumulators = BTreeMap::<String, TimingAccumulator>::new();
321 let mut trace_duration_accumulator = TimingAccumulator::default();
322 let mut residual_accumulator = TimingAccumulator::default();
323 let mut representative_traces = Vec::<ExecutionTrace>::new();
324 let mut worst_trace: Option<ExecutionTrace> = None;
325 let mut active_intervals = Vec::<ExecutionInterval>::new();
326 let mut overlap_counts = BTreeMap::<(String, String, String), u64>::new();
327 let mut previous_end_ns = None;
328 let mut trace_count = 0u64;
329 let mut warned_lengths = false;
330
331 for culist in copperlists_reader::<P>(&mut reader) {
332 let payload_sizes = culist.msgs.payload_raw_bytes();
333 let cumsgs = culist.msgs.cumsgs();
334
335 let payload_len = payload_sizes.len();
336 let msg_len = cumsgs.len();
337 let slot_len = output_slots.len();
338 if !warned_lengths && (payload_len != msg_len || payload_len != slot_len) {
339 eprintln!(
340 "Warning: output mapping length mismatch (sizes={}, msgs={}, slots={})",
341 payload_len, msg_len, slot_len
342 );
343 warned_lengths = true;
344 }
345
346 let count = payload_len.min(msg_len).min(slot_len);
347
348 for idx in 0..count {
349 let slot = &output_slots[idx];
350 if slot.edges.is_empty() {
351 continue;
352 }
353 let payload_bytes = payload_sizes[idx];
354 let end_time_ns = extract_end_time_ns(cumsgs[idx].metadata());
355 for edge in &slot.edges {
356 if let Some(acc) = edge_accumulators.get_mut(edge) {
357 acc.record_sample(payload_bytes, end_time_ns);
358 }
359 }
360 }
361
362 perf.record_sample(compute_end_to_end_latency(&cumsgs));
363
364 if let Some(trace) =
365 build_execution_trace(culist.id, &cumsgs, P::get_all_task_ids(), previous_end_ns)
366 {
367 for interval in &trace.intervals {
368 stage_accumulators
369 .entry(interval.origin.clone())
370 .or_default()
371 .record(interval.end_ns - interval.start_ns);
372 }
373 trace_duration_accumulator.record(trace.duration_ns);
374 if let Some(residual) = trace.residual_before_ns {
375 residual_accumulator.record(residual);
376 }
377 record_resource_overlaps(
378 &trace,
379 &resource_bindings,
380 &mut active_intervals,
381 &mut overlap_counts,
382 );
383 previous_end_ns = trace.intervals.iter().map(|interval| interval.end_ns).max();
384 trace_count = trace_count.saturating_add(1);
385 sample_trace(&mut representative_traces, trace.clone(), trace_count);
386 if worst_trace
387 .as_ref()
388 .is_none_or(|worst| trace.duration_ns > worst.duration_ns)
389 {
390 worst_trace = Some(trace);
391 }
392 }
393 }
394
395 let edges = edge_accumulators
396 .into_iter()
397 .map(|(key, acc)| acc.finalize(key))
398 .collect();
399
400 let duration_p50 = trace_duration_accumulator
401 .stats()
402 .p50_ns
403 .unwrap_or_default();
404 let typical_trace = representative_traces
405 .into_iter()
406 .min_by_key(|trace| trace.duration_ns.abs_diff(duration_p50));
407 let mut traces = Vec::new();
408 if let Some(mut typical) = typical_trace {
409 typical.kind = "typical".to_string();
410 traces.push(typical);
411 }
412 if let Some(mut worst) = worst_trace
413 && traces
414 .first()
415 .is_none_or(|typical| typical.culist_id != worst.culist_id)
416 {
417 worst.kind = "slowest".to_string();
418 traces.push(worst);
419 }
420
421 let stages = stage_accumulators
422 .into_iter()
423 .map(|(origin, accumulator)| StageLogStats {
424 origin,
425 samples: accumulator.samples,
426 durations: accumulator.stats(),
427 })
428 .collect();
429 let resource_overlaps = overlap_counts
430 .into_iter()
431 .map(
432 |((resource, left, right), occurrences)| ResourceOverlapStats {
433 resource,
434 left,
435 right,
436 occurrences,
437 },
438 )
439 .collect();
440
441 Ok(LogStats {
442 schema_version: LOGSTATS_SCHEMA_VERSION,
443 config_signature: signature,
444 mission: mission.map(|value| value.to_string()),
445 edges,
446 perf: perf.finalize(),
447 schedule: Some(ScheduleLogStats {
448 stages,
449 traces,
450 residual_before: residual_accumulator.stats(),
451 resource_overlaps,
452 }),
453 })
454}
455
456fn build_execution_trace(
457 culist_id: u64,
458 msgs: &[&dyn cu29::prelude::ErasedCuStampedData],
459 origins: &[&str],
460 previous_end_ns: Option<u64>,
461) -> Option<ExecutionTrace> {
462 let mut grouped = BTreeMap::<String, (u64, u64)>::new();
463 for (msg, origin) in msgs.iter().zip(origins.iter()) {
464 let (Some(start), Some(end)) = (
465 extract_start_time_ns(msg.metadata()),
466 extract_end_time_ns(msg.metadata()),
467 ) else {
468 continue;
469 };
470 if end < start {
471 continue;
472 }
473 grouped
474 .entry((*origin).to_string())
475 .and_modify(|range| {
476 range.0 = range.0.min(start);
477 range.1 = range.1.max(end);
478 })
479 .or_insert((start, end));
480 }
481 if grouped.is_empty() {
482 return None;
483 }
484 let mut intervals = grouped
485 .into_iter()
486 .map(|(origin, (start_ns, end_ns))| ExecutionInterval {
487 origin,
488 start_ns,
489 end_ns,
490 })
491 .collect::<Vec<_>>();
492 intervals.sort_by_key(|interval| (interval.start_ns, interval.end_ns));
493 let original_count = intervals.len();
494 let intervals = select_current_execution_cluster(intervals);
495 let start_ns = intervals.first()?.start_ns;
496 let end_ns = intervals.iter().map(|interval| interval.end_ns).max()?;
497 let duration_ns = intervals
498 .iter()
499 .map(|interval| interval.end_ns.saturating_sub(interval.start_ns))
500 .sum();
501 Some(ExecutionTrace {
502 kind: String::new(),
503 culist_id,
504 duration_ns,
505 wall_span_ns: end_ns.saturating_sub(start_ns),
506 excluded_intervals: original_count.saturating_sub(intervals.len()) as u32,
507 residual_before_ns: previous_end_ns.and_then(|end| start_ns.checked_sub(end)),
508 intervals,
509 })
510}
511
512fn select_current_execution_cluster(intervals: Vec<ExecutionInterval>) -> Vec<ExecutionInterval> {
513 if intervals.len() < 2 {
514 return intervals;
515 }
516 let total_process_ns: u64 = intervals
517 .iter()
518 .map(|interval| interval.end_ns.saturating_sub(interval.start_ns))
519 .sum();
520 let split_gap_ns = total_process_ns
521 .saturating_mul(4)
522 .max(MIN_CURRENT_CLUSTER_GAP_NS);
523 let mut ranges = Vec::new();
524 let mut cluster_start = 0;
525 for index in 1..intervals.len() {
526 let gap = intervals[index]
527 .start_ns
528 .saturating_sub(intervals[index - 1].end_ns);
529 if gap > split_gap_ns {
530 ranges.push(cluster_start..index);
531 cluster_start = index;
532 }
533 }
534 ranges.push(cluster_start..intervals.len());
535 let selected = ranges
536 .into_iter()
537 .max_by_key(|range| (range.len(), intervals[range.end - 1].end_ns))
538 .unwrap_or(0..intervals.len());
539 intervals[selected].to_vec()
540}
541
542fn sample_trace(samples: &mut Vec<ExecutionTrace>, trace: ExecutionTrace, seen: u64) {
543 if samples.len() < MAX_REPRESENTATIVE_TRACES {
544 samples.push(trace);
545 return;
546 }
547 let hash = splitmix64(trace.culist_id);
548 let candidate = hash % seen;
549 if candidate < MAX_REPRESENTATIVE_TRACES as u64 {
550 samples[candidate as usize] = trace;
551 }
552}
553
554fn splitmix64(mut value: u64) -> u64 {
555 value = value.wrapping_add(0x9e3779b97f4a7c15);
556 value = (value ^ (value >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
557 value = (value ^ (value >> 27)).wrapping_mul(0x94d049bb133111eb);
558 value ^ (value >> 31)
559}
560
561fn build_resource_bindings<P: CopperListTuple>(
562 config: &CuConfig,
563 graph: &CuGraph,
564) -> HashMap<String, Vec<String>> {
565 let mut bindings = HashMap::new();
566 for origin in P::get_all_task_ids() {
567 if bindings.contains_key(*origin) {
568 continue;
569 }
570 let mut targets: Vec<String> = if let Some(rest) = origin.strip_prefix("bridge::") {
571 let bridge_id = rest.split("::").next().unwrap_or_default();
572 config
573 .bridges
574 .iter()
575 .find(|bridge| bridge.id == bridge_id)
576 .and_then(|bridge| bridge.resources.as_ref())
577 .map(|resources| resources.values().cloned().collect())
578 .unwrap_or_default()
579 } else {
580 graph
581 .get_node_id_by_name(origin)
582 .and_then(|node_id| graph.get_node(node_id))
583 .and_then(|node| node.get_resources())
584 .map(|resources| resources.values().cloned().collect())
585 .unwrap_or_default()
586 };
587 targets.sort();
588 targets.dedup();
589 bindings.insert((*origin).to_string(), targets);
590 }
591 bindings
592}
593
594fn record_resource_overlaps(
595 trace: &ExecutionTrace,
596 bindings: &HashMap<String, Vec<String>>,
597 active: &mut Vec<ExecutionInterval>,
598 counts: &mut BTreeMap<(String, String, String), u64>,
599) {
600 let Some(trace_start) = trace
601 .intervals
602 .iter()
603 .map(|interval| interval.start_ns)
604 .min()
605 else {
606 return;
607 };
608 active.retain(|interval| interval.end_ns > trace_start);
609
610 for (index, interval) in trace.intervals.iter().enumerate() {
611 for other in active.iter().chain(trace.intervals[..index].iter()) {
612 if interval.origin == other.origin
613 || interval.start_ns >= other.end_ns
614 || other.start_ns >= interval.end_ns
615 {
616 continue;
617 }
618 let Some(left_resources) = bindings.get(&interval.origin) else {
619 continue;
620 };
621 let Some(right_resources) = bindings.get(&other.origin) else {
622 continue;
623 };
624 for resource in left_resources {
625 if right_resources.contains(resource) {
626 let (left, right) = if interval.origin <= other.origin {
627 (interval.origin.clone(), other.origin.clone())
628 } else {
629 (other.origin.clone(), interval.origin.clone())
630 };
631 *counts.entry((resource.clone(), left, right)).or_default() += 1;
632 }
633 }
634 }
635 }
636 active.extend(trace.intervals.iter().cloned());
637}
638
639pub fn write_logstats(stats: &LogStats, path: &Path) -> CuResult<()> {
640 let file = File::create(path)
641 .map_err(|e| CuError::new_with_cause("Failed to create logstats output", e))?;
642 serde_json::to_writer_pretty(file, stats)
643 .map_err(|e| CuError::new_with_cause("Failed to serialize logstats", e))?;
644 Ok(())
645}
646
647fn build_output_slots<P: CopperListTuple>(
648 config: &CuConfig,
649 graph: &CuGraph,
650 mission: Option<&str>,
651) -> CuResult<Vec<OutputSlot>> {
652 let specs = P::get_output_specs();
653 if specs.is_empty() {
654 let resolved = config.planner_resolved_order(mission.unwrap_or(DEFAULT_MISSION_ID));
655 return build_output_slots_from_plan(config, graph, resolved);
656 }
657 Ok(specs
658 .iter()
659 .map(|spec| OutputSlot {
660 edges: graph
661 .edges()
662 .filter(|edge| edge.msg == spec.msg_type && edge_matches_origin(edge, spec.task_id))
663 .map(edge_key_from_connection)
664 .collect(),
665 })
666 .collect())
667}
668
669fn edge_matches_origin(edge: &cu29::config::Cnx, origin: &str) -> bool {
670 if edge.src == origin {
671 return true;
672 }
673 let Some(rest) = origin.strip_prefix("bridge::") else {
674 return false;
675 };
676 let mut parts = rest.split("::");
677 let (Some(bridge), Some(direction), Some(channel), None) =
678 (parts.next(), parts.next(), parts.next(), parts.next())
679 else {
680 return false;
681 };
682 direction == "rx" && edge.src == bridge && edge.src_channel.as_deref() == Some(channel)
683}
684
685fn edge_key_from_connection(cnx: &cu29::config::Cnx) -> EdgeKey {
686 EdgeKey {
687 src: cnx.src.clone(),
688 src_channel: cnx.src_channel.clone(),
689 dst: cnx.dst.clone(),
690 dst_channel: cnx.dst_channel.clone(),
691 msg: cnx.msg.clone(),
692 }
693}
694
695fn build_output_slots_from_plan(
698 config: &CuConfig,
699 graph: &CuGraph,
700 resolved: Option<&[String]>,
701) -> CuResult<Vec<OutputSlot>> {
702 let plan = match resolved {
705 Some(step_keys) => assemble_runtime_plan_from_step_keys(config, graph, step_keys)?,
706 None => assemble_runtime_plan(config, graph)?,
707 };
708
709 let mut packs: Vec<(u32, String, Vec<String>)> = Vec::new();
710 for unit in &plan.execution.steps {
711 let CuExecutionUnit::Step(step) = unit else {
712 continue;
713 };
714 if step.phase == CuStepPhase::AnytimeRefine {
717 continue;
718 }
719 let Some(output_pack) = &step.output_msg_pack else {
720 continue;
721 };
722 let entity = &plan.entities[step.node_id as usize];
723 let origin = match entity.kind {
724 PlanEntityKind::Task { .. } => entity.label.clone(),
725 PlanEntityKind::BridgeRx { .. } | PlanEntityKind::BridgeTx { .. } => {
726 format!("bridge::{}", entity.label)
727 }
728 };
729 packs.push((
730 output_pack.culist_index,
731 origin,
732 output_pack.msg_types.clone(),
733 ));
734 }
735
736 packs.sort_by_key(|(culist_index, _, _)| *culist_index);
737 Ok(packs
738 .into_iter()
739 .flat_map(|(_, origin, msg_types)| {
740 msg_types.into_iter().map(move |msg| OutputSlot {
741 edges: graph
742 .edges()
743 .filter(|edge| edge.msg == msg && edge_matches_origin(edge, &origin))
744 .map(edge_key_from_connection)
745 .collect(),
746 })
747 })
748 .collect())
749}
750
751fn build_edge_accumulators(graph: &CuGraph) -> HashMap<EdgeKey, EdgeAccumulator> {
752 let mut acc = HashMap::new();
753 for cnx in graph.edges() {
754 let key = EdgeKey {
755 src: cnx.src.clone(),
756 src_channel: cnx.src_channel.clone(),
757 dst: cnx.dst.clone(),
758 dst_channel: cnx.dst_channel.clone(),
759 msg: cnx.msg.clone(),
760 };
761 acc.entry(key).or_default();
762 }
763 acc
764}
765
766fn compute_end_to_end_latency(
767 msgs: &[&dyn cu29::prelude::ErasedCuStampedData],
768) -> Option<CuDuration> {
769 let start = msgs
770 .first()
771 .and_then(|msg| extract_start_time_ns(msg.metadata()))?;
772 let end = msgs
773 .last()
774 .and_then(|msg| extract_end_time_ns(msg.metadata()))?;
775 end.checked_sub(start).map(CuDuration::from_nanos)
776}
777
778fn extract_start_time_ns(meta: &dyn CuMsgMetadataTrait) -> Option<u64> {
779 option_time_ns(meta.process_time().start)
780}
781
782fn extract_end_time_ns(meta: &dyn CuMsgMetadataTrait) -> Option<u64> {
783 option_time_ns(meta.process_time().end)
784}
785
786fn option_time_ns(value: OptionCuTime) -> Option<u64> {
787 Option::<cu29::clock::CuTime>::from(value).map(|t| t.as_nanos())
788}
789
790fn duration_stats_from(stats: &CuDurationStatistics) -> DurationStats {
791 if stats.is_empty() {
792 return DurationStats::default();
793 }
794 DurationStats {
795 min_ns: Some(stats.min().as_nanos()),
796 max_ns: Some(stats.max().as_nanos()),
797 mean_ns: Some(stats.mean().as_nanos() as f64),
798 stddev_ns: Some(stats.stddev().as_nanos() as f64),
799 p50_ns: None,
800 p95_ns: None,
801 }
802}
803
804fn jitter_stats_from(stats: &CuDurationStatistics) -> DurationStats {
805 if stats.len() < 2 {
806 return DurationStats::default();
807 }
808 DurationStats {
809 min_ns: Some(stats.jitter_min().as_nanos()),
810 max_ns: Some(stats.jitter_max().as_nanos()),
811 mean_ns: Some(stats.jitter_mean().as_nanos() as f64),
812 stddev_ns: Some(stats.jitter_stddev().as_nanos() as f64),
813 p50_ns: None,
814 p95_ns: None,
815 }
816}
817
818fn build_graph_signature(graph: &CuGraph, mission: Option<&str>) -> String {
819 let mut parts = Vec::new();
820 parts.push(format!("mission={}", mission.unwrap_or("default")));
821
822 let mut nodes: Vec<_> = graph.get_all_nodes();
823 nodes.sort_by_key(|a| a.1.get_id());
824 for (_, node) in nodes {
825 parts.push(format!(
826 "node|{}|{}|{}",
827 node.get_id(),
828 node.get_type(),
829 flavor_label(node.get_flavor())
830 ));
831 }
832
833 let mut edges: Vec<String> = graph
834 .edges()
835 .map(|cnx| {
836 format!(
837 "edge|{}|{}|{}",
838 format_endpoint(cnx.src.as_str(), cnx.src_channel.as_deref()),
839 format_endpoint(cnx.dst.as_str(), cnx.dst_channel.as_deref()),
840 cnx.msg
841 )
842 })
843 .collect();
844 edges.sort();
845 parts.extend(edges);
846
847 let joined = parts.join("\n");
848 format!("fnv1a64:{:016x}", fnv1a64(joined.as_bytes()))
849}
850
851fn flavor_label(flavor: Flavor) -> &'static str {
852 match flavor {
853 Flavor::Task => "task",
854 Flavor::Bridge => "bridge",
855 }
856}
857
858fn format_endpoint(node: &str, channel: Option<&str>) -> String {
859 match channel {
860 Some(ch) => format!("{node}/{ch}"),
861 None => node.to_string(),
862 }
863}
864
865fn fnv1a64(data: &[u8]) -> u64 {
866 const OFFSET_BASIS: u64 = 0xcbf29ce484222325;
867 const PRIME: u64 = 0x100000001b3;
868 let mut hash = OFFSET_BASIS;
869 for byte in data {
870 hash ^= u64::from(*byte);
871 hash = hash.wrapping_mul(PRIME);
872 }
873 hash
874}
875
876#[cfg(test)]
877mod tests {
878 use super::*;
879
880 #[test]
881 fn plan_output_slots_skip_anytime_refine_duplicates() {
882 let config = CuConfig::deserialize_ron(
885 r#"(
886 tasks: [
887 (id: "src", type: "demo::Src"),
888 (id: "any", type: "demo::Any", anytime: (max_refines: 2)),
889 (id: "sink", type: "demo::Sink"),
890 ],
891 cnx: [
892 (src: "src", dst: "any", msg: "u32"),
893 (src: "any", dst: "sink", msg: "u32"),
894 ],
895 )"#,
896 )
897 .expect("valid anytime config");
898 let graph = config.get_graph(None).unwrap();
899
900 let plan = assemble_runtime_plan(&config, graph).unwrap();
901 assert!(
903 plan.execution.steps.iter().any(|unit| matches!(
904 unit,
905 CuExecutionUnit::Step(step) if step.phase == CuStepPhase::AnytimeRefine
906 )),
907 "config must exercise anytime refine steps"
908 );
909 let mut expected_indices = Vec::new();
912 let mut expected_slots = 0usize;
913 for unit in &plan.execution.steps {
914 if let CuExecutionUnit::Step(step) = unit
915 && step.phase != CuStepPhase::AnytimeRefine
916 && let Some(pack) = &step.output_msg_pack
917 {
918 expected_indices.push(pack.culist_index);
919 expected_slots += pack.msg_types.len();
920 }
921 }
922 let mut deduped = expected_indices.clone();
923 deduped.sort_unstable();
924 deduped.dedup();
925 assert_eq!(deduped.len(), expected_indices.len(), "culist indices dup");
926
927 let slots = build_output_slots_from_plan(&config, graph, None).unwrap();
928 assert_eq!(slots.len(), expected_slots);
929 }
930
931 fn edge_key() -> EdgeKey {
932 EdgeKey {
933 src: "src".to_string(),
934 src_channel: None,
935 dst: "dst".to_string(),
936 dst_channel: None,
937 msg: "Msg".to_string(),
938 }
939 }
940
941 #[test]
942 fn edge_stats_average_and_rate() {
943 let mut acc = EdgeAccumulator::default();
944 acc.record_sample(Some(100), Some(1_000_000_000));
945 acc.record_sample(Some(300), Some(2_000_000_000));
946 let stats = acc.finalize(edge_key());
947
948 assert_eq!(stats.samples, 2);
949 assert_eq!(stats.none_samples, 0);
950 assert_eq!(stats.total_raw_bytes, 400);
951 assert!((stats.avg_raw_bytes.unwrap() - 200.0).abs() < 1e-6);
952 assert!((stats.rate_hz.unwrap() - 1.0).abs() < 1e-6);
953 assert!((stats.throughput_bytes_per_sec.unwrap() - 400.0).abs() < 1e-6);
954 }
955
956 #[test]
957 fn edge_stats_handles_missing_times() {
958 let mut acc = EdgeAccumulator::default();
959 acc.record_sample(Some(64), None);
960 let stats = acc.finalize(edge_key());
961 assert_eq!(stats.samples, 1);
962 assert_eq!(stats.valid_time_samples, 0);
963 assert!(stats.rate_hz.is_none());
964 assert!(stats.throughput_bytes_per_sec.is_none());
965 }
966
967 #[test]
968 fn perf_stats_skip_missing_latency() {
969 let mut perf = PerfAccumulator::new();
970 perf.record_sample(Some(CuDuration::from_nanos(1_000)));
971 perf.record_sample(None);
972 let stats = perf.finalize();
973
974 assert_eq!(stats.samples, 2);
975 assert_eq!(stats.valid_time_samples, 1);
976 assert_eq!(stats.end_to_end.min_ns, Some(1_000));
977 assert_eq!(stats.end_to_end.max_ns, Some(1_000));
978 assert_eq!(stats.jitter.min_ns, None);
979 }
980
981 #[test]
982 fn timing_accumulator_reports_bounded_quantiles() {
983 let mut timings = TimingAccumulator::default();
984 for value in 1..=10_000 {
985 timings.record(value);
986 }
987 let stats = timings.stats();
988 assert_eq!(stats.min_ns, Some(1));
989 assert_eq!(stats.max_ns, Some(10_000));
990 assert!(stats.p50_ns.unwrap().abs_diff(5_000) < 250);
991 assert!(stats.p95_ns.unwrap().abs_diff(9_500) < 250);
992 assert!(timings.quantiles.samples.len() <= MAX_QUANTILE_SAMPLES);
993 }
994
995 #[test]
996 fn resource_overlap_requires_time_and_declared_target_overlap() {
997 let trace = ExecutionTrace {
998 kind: String::new(),
999 culist_id: 1,
1000 duration_ns: 30,
1001 wall_span_ns: 30,
1002 excluded_intervals: 0,
1003 residual_before_ns: None,
1004 intervals: vec![
1005 ExecutionInterval {
1006 origin: "left".to_string(),
1007 start_ns: 10,
1008 end_ns: 30,
1009 },
1010 ExecutionInterval {
1011 origin: "right".to_string(),
1012 start_ns: 20,
1013 end_ns: 40,
1014 },
1015 ],
1016 };
1017 let bindings = HashMap::from([
1018 ("left".to_string(), vec!["gpu0".to_string()]),
1019 ("right".to_string(), vec!["gpu0".to_string()]),
1020 ]);
1021 let mut active = Vec::new();
1022 let mut counts = BTreeMap::new();
1023 record_resource_overlaps(&trace, &bindings, &mut active, &mut counts);
1024 assert_eq!(
1025 counts.get(&("gpu0".to_string(), "left".to_string(), "right".to_string())),
1026 Some(&1)
1027 );
1028 }
1029
1030 #[test]
1031 fn current_execution_cluster_excludes_carried_forward_slots() {
1032 let intervals = vec![
1033 ExecutionInterval {
1034 origin: "stale_bridge".to_string(),
1035 start_ns: 100,
1036 end_ns: 200,
1037 },
1038 ExecutionInterval {
1039 origin: "source".to_string(),
1040 start_ns: 10_000_000,
1041 end_ns: 10_000_500,
1042 },
1043 ExecutionInterval {
1044 origin: "sink".to_string(),
1045 start_ns: 10_000_600,
1046 end_ns: 10_001_000,
1047 },
1048 ];
1049 let selected = select_current_execution_cluster(intervals);
1050 assert_eq!(selected.len(), 2);
1051 assert_eq!(selected[0].origin, "source");
1052 assert_eq!(selected[1].origin, "sink");
1053 }
1054}