1use crate::extract::ExtractSchedUtilOpts;
7use crate::process::PerfSchedScriptRecord;
8use anyhow::{Context as _, Result};
9use serde::{Deserialize, Serialize};
10use serde_json::{Value, json};
11use std::collections::{BTreeSet, HashMap, HashSet};
12use std::fs::File;
13use std::io::{BufRead, BufReader};
14
15const DEFAULT_SYSTEM_CATEGORY_NAMES: [&str; 5] = [
16 "hardirq",
17 "softirq-rx",
18 "softirq-tx",
19 "softirq-other",
20 "nmi",
21];
22const HARDIRQ_CATEGORY: &str = "hardirq";
23const SOFTIRQ_RX_CATEGORY: &str = "softirq-rx";
24const SOFTIRQ_TX_CATEGORY: &str = "softirq-tx";
25const SOFTIRQ_OTHER_CATEGORY: &str = "softirq-other";
26const NMI_CATEGORY: &str = "nmi";
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29struct BusyUtilRecord {
30 time_ms: u64,
31 window_ms: u64,
32 cpu_count: usize,
33 total: f64,
34 uncategorized: f64,
35 categories: HashMap<String, f64>,
36}
37
38#[derive(Debug, Clone)]
39struct BusyInterval {
40 start_ns: u64,
41 end_ns: u64,
42 comm: String,
43 hint: u64,
44}
45
46#[derive(Debug, Clone)]
47enum CategoryCommMatcher {
48 Exact(String),
49 Glob(String),
50}
51
52#[derive(Debug, Clone)]
53struct CategorySpec {
54 name: String,
55 comm_matcher: CategoryCommMatcher,
56 hint: Option<u64>,
57}
58
59#[derive(Debug, Default)]
60struct CompiledCategoryMatcher {
61 exact_any_hint: HashMap<String, Vec<usize>>,
62 exact_by_hint: HashMap<String, HashMap<u64, Vec<usize>>>,
63 glob_specs: Vec<GlobCategory>,
64}
65
66#[derive(Debug)]
67struct GlobCategory {
68 index: usize,
69 pattern: String,
70 hint: Option<u64>,
71}
72
73#[derive(Debug, Clone)]
74struct RunningTask {
75 tid: i32,
76 comm: String,
77 hint: u64,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81enum ActiveSystem {
82 HardIrq,
83 SoftIrqRx,
84 SoftIrqTx,
85 SoftIrqOther,
86}
87
88impl ActiveSystem {
89 fn category_name(self) -> &'static str {
90 match self {
91 Self::HardIrq => HARDIRQ_CATEGORY,
92 Self::SoftIrqRx => SOFTIRQ_RX_CATEGORY,
93 Self::SoftIrqTx => SOFTIRQ_TX_CATEGORY,
94 Self::SoftIrqOther => SOFTIRQ_OTHER_CATEGORY,
95 }
96 }
97
98 fn is_softirq(self) -> bool {
99 matches!(self, Self::SoftIrqRx | Self::SoftIrqTx | Self::SoftIrqOther)
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
104enum ActiveExecution {
105 Task { comm: String, hint: u64 },
106 System(ActiveSystem),
107}
108
109impl ActiveExecution {
110 fn to_interval(&self, start_ns: u64, end_ns: u64) -> Option<BusyInterval> {
111 if end_ns <= start_ns {
112 return None;
113 }
114
115 let (comm, hint) = match self {
116 Self::Task { comm, hint } => (comm.clone(), *hint),
117 Self::System(system) => (system.category_name().to_string(), 0),
118 };
119
120 Some(BusyInterval {
121 start_ns,
122 end_ns,
123 comm,
124 hint,
125 })
126 }
127}
128
129#[derive(Debug, Default)]
130struct CpuState {
131 segment_start_ns: u64,
132 last_seen_ns: u64,
133 running_task: Option<RunningTask>,
134 system_stack: Vec<ActiveSystem>,
135 active_execution: Option<ActiveExecution>,
136}
137
138#[derive(Debug)]
139enum ClassifiedEvent {
140 SchedSwitch {
141 next_comm: String,
142 next_hint: u64,
143 next_tid: i32,
144 next_is_idle: bool,
145 },
146 HardIrqEntry,
147 HardIrqExit,
148 SoftIrqEntry(ActiveSystem),
149 SoftIrqExit,
150 Nmi {
151 delta_ns: u64,
152 },
153 Other,
154}
155
156trait BusyIntervalSink {
157 fn on_interval(&mut self, interval: &BusyInterval);
158}
159
160#[derive(Debug)]
161struct BucketAggregator {
162 trace_start_ns: u64,
163 window_ns: u64,
164 total_busy_ns: Vec<u64>,
165 uncategorized_busy_ns: Vec<u64>,
166 category_busy_ns: Vec<Vec<u64>>,
167 categories: Vec<CategorySpec>,
168 matcher: CompiledCategoryMatcher,
169 interval_count: usize,
170}
171
172impl BucketAggregator {
173 fn new(trace_start_ns: u64, window_ns: u64, categories: Vec<CategorySpec>) -> Self {
174 let category_busy_ns = vec![Vec::new(); categories.len()];
175 let matcher = compile_category_matcher(&categories);
176 Self {
177 trace_start_ns,
178 window_ns,
179 total_busy_ns: Vec::new(),
180 uncategorized_busy_ns: Vec::new(),
181 category_busy_ns,
182 categories,
183 matcher,
184 interval_count: 0,
185 }
186 }
187
188 fn ensure_bucket(&mut self, idx: usize) {
189 let required_len = idx + 1;
190 if self.total_busy_ns.len() >= required_len {
191 return;
192 }
193
194 self.total_busy_ns.resize(required_len, 0);
195 self.uncategorized_busy_ns.resize(required_len, 0);
196 for values in &mut self.category_busy_ns {
197 values.resize(required_len, 0);
198 }
199 }
200
201 fn finalize(
202 self,
203 trace_end_ns: u64,
204 cpu_count: usize,
205 window_ms: u64,
206 ) -> Result<(Vec<BusyUtilRecord>, usize)> {
207 if trace_end_ns <= self.trace_start_ns {
208 anyhow::bail!("non-positive sched trace duration");
209 }
210 if cpu_count == 0 {
211 anyhow::bail!("no CPUs observed in sched trace");
212 }
213
214 let total_duration_ns = trace_end_ns - self.trace_start_ns;
215 let bucket_count = total_duration_ns.div_ceil(self.window_ns) as usize;
216
217 let mut output = Vec::with_capacity(bucket_count);
218 for idx in 0..bucket_count {
219 let bucket_start_ns = self.trace_start_ns + idx as u64 * self.window_ns;
220 let bucket_end_ns = (bucket_start_ns + self.window_ns).min(trace_end_ns);
221 let bucket_width_ns = bucket_end_ns - bucket_start_ns;
222 let capacity_ns = bucket_width_ns * cpu_count as u64;
223 let scale = if capacity_ns == 0 {
224 0.0
225 } else {
226 100.0 / capacity_ns as f64
227 };
228
229 let total_busy_ns = self.total_busy_ns.get(idx).copied().unwrap_or(0);
230 let uncategorized_busy_ns = self.uncategorized_busy_ns.get(idx).copied().unwrap_or(0);
231 let categorized_busy_ns: u64 = self
232 .category_busy_ns
233 .iter()
234 .map(|values| values.get(idx).copied().unwrap_or(0))
235 .sum();
236
237 if categorized_busy_ns + uncategorized_busy_ns != total_busy_ns {
238 let category_totals: Vec<_> = self
239 .categories
240 .iter()
241 .enumerate()
242 .filter_map(|(idx_cat, category)| {
243 let value = self
244 .category_busy_ns
245 .get(idx_cat)
246 .and_then(|values| values.get(idx))
247 .copied()
248 .unwrap_or(0);
249 (value > 0).then_some(format!("{}={}ns", category.name, value))
250 })
251 .collect();
252
253 anyhow::bail!(
254 "sched util categories are not mutually exclusive in bucket starting at {}ms: total={}ns uncategorized={}ns categorized_sum={}ns [{}]. Fix --categories so each busy interval matches at most one category",
255 (bucket_start_ns - self.trace_start_ns) / 1_000_000,
256 total_busy_ns,
257 uncategorized_busy_ns,
258 categorized_busy_ns,
259 category_totals.join(", "),
260 );
261 }
262
263 let categories_json = self
264 .categories
265 .iter()
266 .enumerate()
267 .map(|(idx_cat, category)| {
268 let value = self
269 .category_busy_ns
270 .get(idx_cat)
271 .and_then(|values| values.get(idx))
272 .copied()
273 .unwrap_or(0);
274 (category.name.clone(), value as f64 * scale)
275 })
276 .collect();
277
278 output.push(BusyUtilRecord {
279 time_ms: (bucket_start_ns - self.trace_start_ns) / 1_000_000,
280 window_ms,
281 cpu_count,
282 total: total_busy_ns as f64 * scale,
283 uncategorized: uncategorized_busy_ns as f64 * scale,
284 categories: categories_json,
285 });
286 }
287
288 Ok((output, self.interval_count))
289 }
290}
291
292impl BusyIntervalSink for BucketAggregator {
293 fn on_interval(&mut self, interval: &BusyInterval) {
294 let interval_start = interval.start_ns.max(self.trace_start_ns);
295 let interval_end = interval.end_ns;
296 if interval_end <= interval_start {
297 return;
298 }
299
300 let start_idx = ((interval_start - self.trace_start_ns) / self.window_ns) as usize;
301 let end_idx = ((interval_end - self.trace_start_ns - 1) / self.window_ns) as usize;
302 self.ensure_bucket(end_idx);
303
304 let matched_categories = self.matcher.match_indices(interval);
305 for idx in start_idx..=end_idx {
306 let bucket_start = self.trace_start_ns + idx as u64 * self.window_ns;
307 let bucket_end = bucket_start + self.window_ns;
308 let overlap_ns = interval_end.min(bucket_end) - interval_start.max(bucket_start);
309 if overlap_ns == 0 {
310 continue;
311 }
312
313 self.total_busy_ns[idx] += overlap_ns;
314 if matched_categories.is_empty() {
315 self.uncategorized_busy_ns[idx] += overlap_ns;
316 } else {
317 for cat_idx in &matched_categories {
318 self.category_busy_ns[*cat_idx][idx] += overlap_ns;
319 }
320 }
321 }
322
323 self.interval_count += 1;
324 }
325}
326
327#[derive(Debug, Default)]
328struct TraceStats {
329 trace_start_ns: Option<u64>,
330 trace_end_ns: Option<u64>,
331 observed_cpus: BTreeSet<u32>,
332 saw_switch: bool,
333}
334
335impl TraceStats {
336 fn observe(&mut self, record: &PerfSchedScriptRecord, time_ns: u64) {
337 self.observed_cpus.insert(record.cpu);
338 self.trace_start_ns.get_or_insert(time_ns);
339 self.trace_end_ns = Some(time_ns);
340 if record.event == "sched:sched_switch" {
341 self.saw_switch = true;
342 }
343 }
344
345 fn trace_start_ns(&self) -> u64 {
346 self.trace_start_ns.unwrap_or(0)
347 }
348
349 fn finish(&self) -> Result<(u64, usize)> {
350 if !self.saw_switch {
351 anyhow::bail!("no sched:sched_switch events found in perf.sched.jsonl");
352 }
353 let end_ns = self.trace_end_ns.unwrap_or(0);
354 let start_ns = self.trace_start_ns();
355 if end_ns <= start_ns {
356 anyhow::bail!("non-positive sched trace duration");
357 }
358 if self.observed_cpus.is_empty() {
359 anyhow::bail!("no CPUs observed in sched trace");
360 }
361 Ok((end_ns, self.observed_cpus.len()))
362 }
363}
364
365#[derive(Debug)]
366struct SchedBusyTracker<S> {
367 cpu_states: HashMap<u32, CpuState>,
368 sink: S,
369}
370
371impl<S: BusyIntervalSink> SchedBusyTracker<S> {
372 fn new(sink: S) -> Self {
373 Self {
374 cpu_states: HashMap::new(),
375 sink,
376 }
377 }
378
379 fn process_record(&mut self, record: PerfSchedScriptRecord) {
380 let Some(time_ns) = record
381 .sample_time_ns()
382 .or_else(|| sched_time_to_ns(record.time))
383 else {
384 return;
385 };
386
387 let mut emitted = Vec::new();
388 {
389 let state = self
390 .cpu_states
391 .entry(record.cpu)
392 .or_insert_with(|| CpuState {
393 segment_start_ns: time_ns,
394 last_seen_ns: time_ns,
395 ..CpuState::default()
396 });
397 state.last_seen_ns = time_ns;
398
399 if let Some(task) = state.running_task.as_mut()
400 && record.tid == task.tid
401 && record.hint != task.hint
402 {
403 task.hint = record.hint;
404 if let Some(interval) = sync_cpu_state(state, time_ns) {
405 emitted.push(interval);
406 }
407 }
408
409 match classify_event(&record) {
410 ClassifiedEvent::SchedSwitch {
411 next_comm,
412 next_hint,
413 next_tid,
414 next_is_idle,
415 } => {
416 if next_is_idle {
417 state.running_task = None;
418 } else {
419 state.running_task = Some(RunningTask {
420 tid: next_tid,
421 comm: next_comm,
422 hint: next_hint,
423 });
424 }
425 if let Some(interval) = sync_cpu_state(state, time_ns) {
426 emitted.push(interval);
427 }
428 }
429 ClassifiedEvent::HardIrqEntry => {
430 state.system_stack.push(ActiveSystem::HardIrq);
431 if let Some(interval) = sync_cpu_state(state, time_ns) {
432 emitted.push(interval);
433 }
434 }
435 ClassifiedEvent::HardIrqExit => {
436 pop_system_override(state, ActiveSystem::HardIrq);
437 if let Some(interval) = sync_cpu_state(state, time_ns) {
438 emitted.push(interval);
439 }
440 }
441 ClassifiedEvent::SoftIrqEntry(system) => {
442 state.system_stack.push(system);
443 if let Some(interval) = sync_cpu_state(state, time_ns) {
444 emitted.push(interval);
445 }
446 }
447 ClassifiedEvent::SoftIrqExit => {
448 pop_softirq_override(state);
449 if let Some(interval) = sync_cpu_state(state, time_ns) {
450 emitted.push(interval);
451 }
452 }
453 ClassifiedEvent::Nmi { delta_ns } => {
454 let nmi_start_ns = time_ns.saturating_sub(delta_ns);
455 if let Some(interval) = flush_current_interval(state, nmi_start_ns) {
456 emitted.push(interval);
457 }
458 emitted.push(BusyInterval {
459 start_ns: nmi_start_ns,
460 end_ns: time_ns,
461 comm: NMI_CATEGORY.to_string(),
462 hint: 0,
463 });
464 state.segment_start_ns = time_ns;
465 }
466 ClassifiedEvent::Other => {}
467 }
468 }
469
470 for interval in emitted {
471 self.sink.on_interval(&interval);
472 }
473 }
474
475 fn finish(mut self, _trace_end_ns: u64) -> S {
476 for state in self.cpu_states.values_mut() {
477 let cpu_end_ns = state.last_seen_ns.max(state.segment_start_ns);
478 if let Some(interval) = flush_current_interval(state, cpu_end_ns) {
479 self.sink.on_interval(&interval);
480 }
481 }
482 self.sink
483 }
484}
485
486#[cfg(test)]
487#[derive(Debug, Default)]
488struct VecIntervalSink {
489 intervals: Vec<BusyInterval>,
490}
491
492#[cfg(test)]
493impl BusyIntervalSink for VecIntervalSink {
494 fn on_interval(&mut self, interval: &BusyInterval) {
495 self.intervals.push(interval.clone());
496 }
497}
498
499fn current_active_execution(state: &CpuState) -> Option<ActiveExecution> {
500 if let Some(system) = state.system_stack.last().copied() {
501 return Some(ActiveExecution::System(system));
502 }
503
504 state.running_task.as_ref().map(|task| {
505 if is_ksoftirqd_comm(&task.comm) {
506 ActiveExecution::System(ActiveSystem::SoftIrqOther)
507 } else {
508 ActiveExecution::Task {
509 comm: task.comm.clone(),
510 hint: task.hint,
511 }
512 }
513 })
514}
515
516fn is_ksoftirqd_comm(comm: &str) -> bool {
517 comm.starts_with("ksoftirqd/")
518}
519
520fn flush_current_interval(state: &mut CpuState, end_ns: u64) -> Option<BusyInterval> {
521 let interval = state
522 .active_execution
523 .as_ref()
524 .and_then(|active| active.to_interval(state.segment_start_ns, end_ns));
525 state.segment_start_ns = end_ns;
526 interval
527}
528
529fn sync_cpu_state(state: &mut CpuState, time_ns: u64) -> Option<BusyInterval> {
530 let next_active = current_active_execution(state);
531 if state.active_execution == next_active {
532 return None;
533 }
534
535 let interval = flush_current_interval(state, time_ns);
536 state.active_execution = next_active;
537 interval
538}
539
540fn pop_system_override(state: &mut CpuState, system: ActiveSystem) {
541 if let Some(pos) = state
542 .system_stack
543 .iter()
544 .rposition(|value| *value == system)
545 {
546 state.system_stack.remove(pos);
547 }
548}
549
550fn pop_softirq_override(state: &mut CpuState) {
551 if let Some(pos) = state
552 .system_stack
553 .iter()
554 .rposition(|value| value.is_softirq())
555 {
556 state.system_stack.remove(pos);
557 }
558}
559
560fn parse_sched_categories(spec: &str) -> Result<Vec<CategorySpec>> {
561 let mut categories = Vec::new();
562 let mut seen_names = HashSet::new();
563 let mut hinted_comm_parts = BTreeSet::new();
564 let mut exact_zero_hint_comms = HashSet::new();
565 let mut glob_zero_hint_comms = HashSet::new();
566
567 for raw_item in spec.split(',') {
568 let item = raw_item.trim();
569 if item.is_empty() {
570 continue;
571 }
572
573 let (comm_part, hint) = match item.split_once("@hint=") {
574 Some((comm, hint_str)) => (
575 comm.trim(),
576 Some(
577 hint_str
578 .trim()
579 .parse::<u64>()
580 .with_context(|| format!("invalid hint selector in category '{item}'"))?,
581 ),
582 ),
583 None => (item, None),
584 };
585
586 let comm_matcher = if comm_part.contains('*') || comm_part.contains('?') {
587 CategoryCommMatcher::Glob(comm_part.to_string())
588 } else {
589 CategoryCommMatcher::Exact(comm_part.to_string())
590 };
591
592 if hint.is_some() {
593 hinted_comm_parts.insert(comm_part.to_string());
594 }
595 if hint == Some(0) {
596 match &comm_matcher {
597 CategoryCommMatcher::Exact(comm) => {
598 exact_zero_hint_comms.insert(comm.clone());
599 }
600 CategoryCommMatcher::Glob(pattern) => {
601 glob_zero_hint_comms.insert(pattern.clone());
602 }
603 }
604 }
605
606 if !seen_names.insert(item.to_string()) {
607 continue;
608 }
609
610 categories.push(CategorySpec {
611 name: item.to_string(),
612 comm_matcher,
613 hint,
614 });
615 }
616
617 for comm_part in hinted_comm_parts {
618 let needs_zero_hint = if comm_part.contains('*') || comm_part.contains('?') {
619 !glob_zero_hint_comms.contains(&comm_part)
620 } else {
621 !exact_zero_hint_comms.contains(&comm_part)
622 };
623
624 if !needs_zero_hint {
625 continue;
626 }
627
628 let zero_hint_name = format!("{comm_part}@hint=0");
629 if !seen_names.insert(zero_hint_name.clone()) {
630 continue;
631 }
632
633 let comm_matcher = if comm_part.contains('*') || comm_part.contains('?') {
634 CategoryCommMatcher::Glob(comm_part.clone())
635 } else {
636 CategoryCommMatcher::Exact(comm_part.clone())
637 };
638
639 categories.push(CategorySpec {
640 name: zero_hint_name,
641 comm_matcher,
642 hint: Some(0),
643 });
644 }
645
646 for name in DEFAULT_SYSTEM_CATEGORY_NAMES {
647 if seen_names.insert(name.to_string()) {
648 categories.push(CategorySpec {
649 name: name.to_string(),
650 comm_matcher: CategoryCommMatcher::Exact(name.to_string()),
651 hint: None,
652 });
653 }
654 }
655
656 Ok(categories)
657}
658
659fn compile_category_matcher(categories: &[CategorySpec]) -> CompiledCategoryMatcher {
660 let mut matcher = CompiledCategoryMatcher::default();
661
662 for (index, category) in categories.iter().enumerate() {
663 match &category.comm_matcher {
664 CategoryCommMatcher::Exact(comm) => {
665 if let Some(hint) = category.hint {
666 matcher
667 .exact_by_hint
668 .entry(comm.clone())
669 .or_default()
670 .entry(hint)
671 .or_default()
672 .push(index);
673 } else {
674 matcher
675 .exact_any_hint
676 .entry(comm.clone())
677 .or_default()
678 .push(index);
679 }
680 }
681 CategoryCommMatcher::Glob(pattern) => matcher.glob_specs.push(GlobCategory {
682 index,
683 pattern: pattern.clone(),
684 hint: category.hint,
685 }),
686 }
687 }
688
689 matcher
690}
691
692impl CompiledCategoryMatcher {
693 fn match_indices(&self, interval: &BusyInterval) -> Vec<usize> {
694 let mut matched = Vec::new();
695
696 if let Some(indices) = self.exact_any_hint.get(&interval.comm) {
697 matched.extend(indices.iter().copied());
698 }
699 if let Some(by_hint) = self.exact_by_hint.get(&interval.comm)
700 && let Some(indices) = by_hint.get(&interval.hint)
701 {
702 matched.extend(indices.iter().copied());
703 }
704
705 for glob in &self.glob_specs {
706 if glob.hint.map(|hint| hint == interval.hint).unwrap_or(true)
707 && glob_match(&glob.pattern, &interval.comm)
708 {
709 matched.push(glob.index);
710 }
711 }
712
713 matched.sort_unstable();
714 matched.dedup();
715 matched
716 }
717}
718
719fn classify_event(record: &PerfSchedScriptRecord) -> ClassifiedEvent {
720 match record.event.as_str() {
721 "sched:sched_switch" => {
722 let Some(fields) = record.fields.as_ref() else {
723 return ClassifiedEvent::Other;
724 };
725
726 let next_comm = fields
727 .get("next_comm")
728 .and_then(Value::as_str)
729 .unwrap_or_default()
730 .to_string();
731 let next_hint = fields.get("next_hint").and_then(Value::as_u64).unwrap_or(0);
732 let next_tid = fields
733 .get("next_pid")
734 .and_then(Value::as_i64)
735 .and_then(|tid| i32::try_from(tid).ok())
736 .unwrap_or(0);
737 ClassifiedEvent::SchedSwitch {
738 next_is_idle: sched_switch_next_is_idle(fields),
739 next_comm,
740 next_hint,
741 next_tid,
742 }
743 }
744 "irq:irq_handler_entry" => ClassifiedEvent::HardIrqEntry,
745 "irq:irq_handler_exit" => ClassifiedEvent::HardIrqExit,
746 "irq:softirq_entry" => ClassifiedEvent::SoftIrqEntry(classify_softirq_system(record)),
747 "irq:softirq_exit" => ClassifiedEvent::SoftIrqExit,
748 _ => parse_nmi_duration_ns(record)
749 .map(|delta_ns| ClassifiedEvent::Nmi { delta_ns })
750 .unwrap_or(ClassifiedEvent::Other),
751 }
752}
753
754fn classify_softirq_system(record: &PerfSchedScriptRecord) -> ActiveSystem {
755 let action = record
756 .fields
757 .as_ref()
758 .and_then(|fields| fields.get("action"))
759 .and_then(Value::as_str)
760 .unwrap_or_default();
761
762 match action {
763 "NET_RX" => ActiveSystem::SoftIrqRx,
764 "NET_TX" => ActiveSystem::SoftIrqTx,
765 _ => ActiveSystem::SoftIrqOther,
766 }
767}
768
769fn sched_switch_next_is_idle(fields: &serde_json::Map<String, Value>) -> bool {
770 let next_pid = fields.get("next_pid").and_then(Value::as_i64).unwrap_or(-1);
771 let next_comm = fields
772 .get("next_comm")
773 .and_then(Value::as_str)
774 .unwrap_or_default();
775
776 next_pid == 0 || next_comm.starts_with("swapper")
777}
778
779fn parse_nmi_duration_ns(record: &PerfSchedScriptRecord) -> Option<u64> {
780 if !record.event.starts_with("nmi:") {
781 return None;
782 }
783
784 let needle = "delta_ns:";
785 let start = record.trace.find(needle)? + needle.len();
786 record.trace[start..]
787 .split_whitespace()
788 .next()?
789 .trim_end_matches(',')
790 .parse::<u64>()
791 .ok()
792}
793
794fn sched_time_to_ns(time: f64) -> Option<u64> {
795 if !time.is_finite() || time < 0.0 {
796 return None;
797 }
798 let ns = time * 1_000_000_000.0;
799 if ns < 0.0 || ns > u64::MAX as f64 {
800 return None;
801 }
802 Some(ns as u64)
803}
804
805fn glob_match(pattern: &str, value: &str) -> bool {
806 let p = pattern.as_bytes();
807 let v = value.as_bytes();
808 let (mut pi, mut vi) = (0usize, 0usize);
809 let mut star_pi = None;
810 let mut star_vi = 0usize;
811
812 while vi < v.len() {
813 if pi < p.len() && (p[pi] == b'?' || p[pi] == v[vi]) {
814 pi += 1;
815 vi += 1;
816 } else if pi < p.len() && p[pi] == b'*' {
817 star_pi = Some(pi);
818 pi += 1;
819 star_vi = vi;
820 } else if let Some(saved_pi) = star_pi {
821 pi = saved_pi + 1;
822 star_vi += 1;
823 vi = star_vi;
824 } else {
825 return false;
826 }
827 }
828
829 while pi < p.len() && p[pi] == b'*' {
830 pi += 1;
831 }
832
833 pi == p.len()
834}
835
836pub fn cmd_extract_sched_util(opts: ExtractSchedUtilOpts) -> Result<()> {
837 if opts.window_ms == 0 {
838 anyhow::bail!("--window-ms must be greater than zero");
839 }
840
841 let categories = parse_sched_categories(&opts.categories)?;
842 let file = File::open(&opts.file).context("failed to open perf.sched.jsonl")?;
843 let reader = BufReader::new(file);
844
845 let mut stats = TraceStats::default();
846 let window_ns = opts.window_ms * 1_000_000;
847 let mut aggregator = BucketAggregator::new(0, window_ns, categories);
848 let mut tracker = SchedBusyTracker::new(aggregator);
849
850 for line in reader.lines() {
851 let line = line.context("failed to read line")?;
852 if line.trim().is_empty() {
853 continue;
854 }
855
856 let record: PerfSchedScriptRecord =
857 serde_json::from_str(&line).context("failed to parse perf.sched.jsonl record")?;
858 let Some(time_ns) = record
859 .sample_time_ns()
860 .or_else(|| sched_time_to_ns(record.time))
861 else {
862 continue;
863 };
864
865 if stats.trace_start_ns.is_none() {
866 tracker.sink.trace_start_ns = time_ns;
867 }
868 stats.observe(&record, time_ns);
869 tracker.process_record(record);
870 }
871
872 let (trace_end_ns, cpu_count) = stats.finish()?;
873 aggregator = tracker.finish(trace_end_ns);
874 let category_count = aggregator.categories.len();
875 let (output, interval_count) = aggregator.finalize(trace_end_ns, cpu_count, opts.window_ms)?;
876
877 if opts.verbose {
878 eprintln!(
879 "sched util: {} intervals, {} buckets, {} cpus, {} categories",
880 interval_count,
881 output.len(),
882 cpu_count,
883 category_count
884 );
885 }
886
887 for record in output {
888 println!(
889 "{}",
890 serde_json::to_string(&json!({
891 "time_ms": record.time_ms,
892 "window_ms": record.window_ms,
893 "cpu_count": record.cpu_count,
894 "total": record.total,
895 "uncategorized": record.uncategorized,
896 "categories": record.categories,
897 }))?
898 );
899 }
900
901 Ok(())
902}
903
904#[cfg(test)]
905fn build_busy_intervals(
906 records: impl IntoIterator<Item = PerfSchedScriptRecord>,
907) -> Result<(Vec<BusyInterval>, u64, u64, usize)> {
908 let mut stats = TraceStats::default();
909 let mut tracker = SchedBusyTracker::new(VecIntervalSink::default());
910
911 for record in records {
912 let Some(time_ns) = record
913 .sample_time_ns()
914 .or_else(|| sched_time_to_ns(record.time))
915 else {
916 continue;
917 };
918 stats.observe(&record, time_ns);
919 tracker.process_record(record);
920 }
921
922 let (trace_end_ns, cpu_count) = stats.finish()?;
923 let sink = tracker.finish(trace_end_ns);
924 Ok((
925 sink.intervals,
926 stats.trace_start_ns(),
927 trace_end_ns,
928 cpu_count,
929 ))
930}
931
932#[cfg(test)]
933mod tests {
934 use super::*;
935 use std::fs;
936 use std::path::PathBuf;
937 use tempfile::TempDir;
938
939 fn write_sched_jsonl(tempdir: &TempDir, lines: &[&str]) -> PathBuf {
940 let path = tempdir.path().join("perf.sched.jsonl");
941 fs::write(&path, lines.join("\n") + "\n").expect("failed to write perf.sched.jsonl");
942 path
943 }
944
945 #[test]
946 fn sched_util_includes_total_uncategorized_and_hint_categories() -> Result<()> {
947 let tempdir = TempDir::new()?;
948 let path = write_sched_jsonl(
949 &tempdir,
950 &[
951 r#"{"comm":"idle","pid":0,"tid":0,"cpu":0,"time":0.0,"event":"sched:sched_switch","trace":"swapper/0:0 [120] R ==> worker-a:10 [120]","fields":{"prev_comm":"swapper/0","prev_pid":0,"prev_prio":120,"prev_state":"R","next_comm":"worker-a","next_pid":10,"next_prio":120},"hint":0}"#,
952 r#"{"comm":"worker-a","pid":10,"tid":10,"cpu":0,"time":0.0005,"event":"sched:sched_stat_runtime","trace":"comm=worker-a runtime=500000 [ns] vruntime=0 [ns]","fields":{"comm":"worker-a","runtime":500000,"vruntime":0},"hint":640}"#,
953 r#"{"comm":"worker-a","pid":10,"tid":10,"cpu":0,"time":0.001,"event":"sched:sched_switch","trace":"worker-a:10 [120] R ==> swapper/0:0 [120]","fields":{"prev_comm":"worker-a","prev_pid":10,"prev_prio":120,"prev_state":"R","next_comm":"swapper/0","next_pid":0,"next_prio":120},"hint":640}"#,
954 r#"{"comm":"idle","pid":0,"tid":0,"cpu":0,"time":0.001,"event":"sched:sched_switch","trace":"swapper/0:0 [120] R ==> svc-4:20 [120]","fields":{"prev_comm":"swapper/0","prev_pid":0,"prev_prio":120,"prev_state":"R","next_comm":"svc-4","next_pid":20,"next_prio":120},"hint":0}"#,
955 r#"{"comm":"svc-4","pid":20,"tid":20,"cpu":0,"time":0.002,"event":"sched:sched_switch","trace":"svc-4:20 [120] R ==> swapper/0:0 [120]","fields":{"prev_comm":"svc-4","prev_pid":20,"prev_prio":120,"prev_state":"R","next_comm":"swapper/0","next_pid":0,"next_prio":120},"hint":0}"#,
956 ],
957 );
958
959 let categories = parse_sched_categories("worker-a@hint=640,svc-*")?;
960 let file = File::open(&path)?;
961 let reader = BufReader::new(file);
962 let records: Vec<PerfSchedScriptRecord> = reader
963 .lines()
964 .map(|line| -> Result<_> {
965 let line = line?;
966 Ok(serde_json::from_str(&line)?)
967 })
968 .collect::<Result<_>>()?;
969
970 let (intervals, start_ns, end_ns, cpu_count) = build_busy_intervals(records)?;
971 assert_eq!(cpu_count, 1);
972 assert_eq!(start_ns, 0);
973 assert_eq!(end_ns, 2_000_000);
974 assert_eq!(intervals.len(), 3);
975 assert_eq!(intervals[0].comm, "worker-a");
976 assert_eq!(intervals[0].hint, 0);
977 assert_eq!(intervals[1].comm, "worker-a");
978 assert_eq!(intervals[1].hint, 640);
979 assert_eq!(intervals[2].comm, "svc-4");
980 assert_eq!(intervals[2].hint, 0);
981
982 let matcher = compile_category_matcher(&categories);
983 let category_names: Vec<_> = categories.iter().map(|cat| cat.name.as_str()).collect();
984 assert_eq!(
985 category_names[matcher.match_indices(&intervals[0])[0]],
986 "worker-a@hint=0"
987 );
988 assert_eq!(
989 category_names[matcher.match_indices(&intervals[1])[0]],
990 "worker-a@hint=640"
991 );
992 assert_eq!(
993 category_names[matcher.match_indices(&intervals[2])[0]],
994 "svc-*"
995 );
996
997 Ok(())
998 }
999
1000 #[test]
1001 fn sched_util_auto_adds_zero_hint_category_for_hinted_specs() -> Result<()> {
1002 let categories =
1003 parse_sched_categories("hhvmworker@hint=256,hhvmworker@hint=640,mcrpxy-*")?;
1004 let names: BTreeSet<_> = categories.iter().map(|cat| cat.name.as_str()).collect();
1005
1006 assert!(names.contains("hhvmworker@hint=0"));
1007 assert!(names.contains("hhvmworker@hint=256"));
1008 assert!(names.contains("hhvmworker@hint=640"));
1009 assert!(!names.contains("mcrpxy-*@hint=0"));
1010
1011 Ok(())
1012 }
1013
1014 #[test]
1015 fn sched_util_does_not_duplicate_explicit_zero_hint_category() -> Result<()> {
1016 let categories = parse_sched_categories("hhvmworker@hint=0,hhvmworker@hint=384")?;
1017 let names: Vec<_> = categories
1018 .iter()
1019 .filter(|cat| cat.name.starts_with("hhvmworker@hint="))
1020 .map(|cat| cat.name.as_str())
1021 .collect();
1022
1023 assert_eq!(names, vec!["hhvmworker@hint=0", "hhvmworker@hint=384"]);
1024
1025 Ok(())
1026 }
1027
1028 #[test]
1029 fn sched_util_outputs_total_and_uncategorized_records() -> Result<()> {
1030 let tempdir = TempDir::new()?;
1031 let path = write_sched_jsonl(
1032 &tempdir,
1033 &[
1034 r#"{"comm":"idle","pid":0,"tid":0,"cpu":0,"time":0.0,"event":"sched:sched_switch","trace":"swapper/0:0 [120] R ==> worker-a:10 [120]","fields":{"prev_comm":"swapper/0","prev_pid":0,"prev_prio":120,"prev_state":"R","next_comm":"worker-a","next_pid":10,"next_prio":120},"hint":0}"#,
1035 r#"{"comm":"worker-a","pid":10,"tid":10,"cpu":0,"time":0.0005,"event":"sched:sched_stat_runtime","trace":"comm=worker-a runtime=500000 [ns] vruntime=0 [ns]","fields":{"comm":"worker-a","runtime":500000,"vruntime":0},"hint":640}"#,
1036 r#"{"comm":"worker-a","pid":10,"tid":10,"cpu":0,"time":0.001,"event":"sched:sched_switch","trace":"worker-a:10 [120] R ==> swapper/0:0 [120]","fields":{"prev_comm":"worker-a","prev_pid":10,"prev_prio":120,"prev_state":"R","next_comm":"swapper/0","next_pid":0,"next_prio":120},"hint":640}"#,
1037 r#"{"comm":"idle","pid":0,"tid":0,"cpu":0,"time":0.001,"event":"sched:sched_switch","trace":"swapper/0:0 [120] R ==> uncat:20 [120]","fields":{"prev_comm":"swapper/0","prev_pid":0,"prev_prio":120,"prev_state":"R","next_comm":"uncat","next_pid":20,"next_prio":120},"hint":0}"#,
1038 r#"{"comm":"uncat","pid":20,"tid":20,"cpu":0,"time":0.002,"event":"sched:sched_switch","trace":"uncat:20 [120] R ==> swapper/0:0 [120]","fields":{"prev_comm":"uncat","prev_pid":20,"prev_prio":120,"prev_state":"R","next_comm":"swapper/0","next_pid":0,"next_prio":120},"hint":0}"#,
1039 ],
1040 );
1041
1042 let opts = ExtractSchedUtilOpts {
1043 file: path,
1044 window_ms: 1,
1045 categories: "worker-a@hint=640".to_string(),
1046 verbose: false,
1047 };
1048
1049 let categories = parse_sched_categories(&opts.categories)?;
1050 let window_ns = opts.window_ms * 1_000_000;
1051 let mut agg = BucketAggregator::new(0, window_ns, categories);
1052 let file = File::open(&opts.file)?;
1053 let reader = BufReader::new(file);
1054 let mut stats = TraceStats::default();
1055 let mut tracker = SchedBusyTracker::new(agg);
1056
1057 for line in reader.lines() {
1058 let line = line?;
1059 let record: PerfSchedScriptRecord = serde_json::from_str(&line)?;
1060 let Some(time_ns) = record
1061 .sample_time_ns()
1062 .or_else(|| sched_time_to_ns(record.time))
1063 else {
1064 continue;
1065 };
1066 if stats.trace_start_ns.is_none() {
1067 tracker.sink.trace_start_ns = time_ns;
1068 }
1069 stats.observe(&record, time_ns);
1070 tracker.process_record(record);
1071 }
1072 let (trace_end_ns, cpu_count) = stats.finish()?;
1073 agg = tracker.finish(trace_end_ns);
1074
1075 assert_eq!(cpu_count, 1);
1076 let (output, _interval_count) = agg.finalize(trace_end_ns, cpu_count, opts.window_ms)?;
1077 assert_eq!(output.len(), 2);
1078 assert_eq!(output[0].total, 100.0);
1079 assert_eq!(output[0].uncategorized, 0.0);
1080 assert_eq!(output[0].categories["worker-a@hint=0"], 50.0);
1081 assert_eq!(output[0].categories["worker-a@hint=640"], 50.0);
1082 assert_eq!(output[1].uncategorized, 100.0);
1083
1084 Ok(())
1085 }
1086
1087 #[test]
1088 fn sched_util_rejects_overlapping_categories() -> Result<()> {
1089 let tempdir = TempDir::new()?;
1090 let path = write_sched_jsonl(
1091 &tempdir,
1092 &[
1093 r#"{"comm":"idle","pid":0,"tid":0,"cpu":0,"time":0.0,"event":"sched:sched_switch","trace":"swapper/0:0 [120] R ==> worker-a:10 [120]","fields":{"prev_comm":"swapper/0","prev_pid":0,"prev_prio":120,"prev_state":"R","next_comm":"worker-a","next_pid":10,"next_prio":120,"next_hint":640},"hint":0}"#,
1094 r#"{"comm":"worker-a","pid":10,"tid":10,"cpu":0,"time":0.001,"event":"sched:sched_switch","trace":"worker-a:10 [120] R ==> swapper/0:0 [120]","fields":{"prev_comm":"worker-a","prev_pid":10,"prev_prio":120,"prev_state":"R","next_comm":"swapper/0","next_pid":0,"next_prio":120,"next_hint":0},"hint":640}"#,
1095 ],
1096 );
1097
1098 let opts = ExtractSchedUtilOpts {
1099 file: path,
1100 window_ms: 1,
1101 categories: "worker-a,worker-a@hint=640".to_string(),
1102 verbose: false,
1103 };
1104
1105 let categories = parse_sched_categories(&opts.categories)?;
1106 let window_ns = opts.window_ms * 1_000_000;
1107 let mut agg = BucketAggregator::new(0, window_ns, categories);
1108 let file = File::open(&opts.file)?;
1109 let reader = BufReader::new(file);
1110 let mut stats = TraceStats::default();
1111 let mut tracker = SchedBusyTracker::new(agg);
1112
1113 for line in reader.lines() {
1114 let line = line?;
1115 let record: PerfSchedScriptRecord = serde_json::from_str(&line)?;
1116 let Some(time_ns) = record
1117 .sample_time_ns()
1118 .or_else(|| sched_time_to_ns(record.time))
1119 else {
1120 continue;
1121 };
1122 if stats.trace_start_ns.is_none() {
1123 tracker.sink.trace_start_ns = time_ns;
1124 }
1125 stats.observe(&record, time_ns);
1126 tracker.process_record(record);
1127 }
1128 let (trace_end_ns, cpu_count) = stats.finish()?;
1129 agg = tracker.finish(trace_end_ns);
1130
1131 let err = agg
1132 .finalize(trace_end_ns, cpu_count, opts.window_ms)
1133 .expect_err("expected overlapping categories to be rejected");
1134 assert!(
1135 err.to_string()
1136 .contains("sched util categories are not mutually exclusive")
1137 );
1138
1139 Ok(())
1140 }
1141
1142 #[test]
1143 fn sched_util_splits_busy_time_on_on_cpu_hint_changes_and_tracks_irqs() -> Result<()> {
1144 let tempdir = TempDir::new()?;
1145 let path = write_sched_jsonl(
1146 &tempdir,
1147 &[
1148 r#"{"comm":"idle","pid":0,"tid":0,"cpu":0,"time":0.0,"event":"sched:sched_switch","trace":"swapper/0:0 [120] R ==> worker-a:10 [120]","fields":{"prev_comm":"swapper/0","prev_pid":0,"prev_prio":120,"prev_state":"R","next_comm":"worker-a","next_pid":10,"next_prio":120},"hint":0}"#,
1149 r#"{"comm":"worker-a","pid":10,"tid":10,"cpu":0,"time":0.0002,"event":"sched:sched_stat_runtime","trace":"comm=worker-a runtime=200000 [ns] vruntime=0 [ns]","fields":{"comm":"worker-a","runtime":200000,"vruntime":0},"hint":384}"#,
1150 r#"{"comm":"worker-a","pid":10,"tid":10,"cpu":0,"time":0.0006,"event":"irq:softirq_entry","trace":"vec=3 [action=NET_RX]","fields":{"action":"NET_RX","vec":3},"hint":384}"#,
1151 r#"{"comm":"worker-a","pid":10,"tid":10,"cpu":0,"time":0.0007,"event":"irq:softirq_exit","trace":"vec=3 [action=NET_RX]","fields":{"action":"NET_RX","vec":3},"hint":384}"#,
1152 r#"{"comm":"worker-a","pid":10,"tid":10,"cpu":0,"time":0.0008,"event":"sched:sched_stat_runtime","trace":"comm=worker-a runtime=800000 [ns] vruntime=0 [ns]","fields":{"comm":"worker-a","runtime":800000,"vruntime":0},"hint":640}"#,
1153 r#"{"comm":"worker-a","pid":10,"tid":10,"cpu":0,"time":0.0010,"event":"sched:sched_switch","trace":"worker-a:10 [120] R ==> swapper/0:0 [120]","fields":{"prev_comm":"worker-a","prev_pid":10,"prev_prio":120,"prev_state":"R","next_comm":"swapper/0","next_pid":0,"next_prio":120},"hint":640}"#,
1154 ],
1155 );
1156
1157 let file = File::open(&path)?;
1158 let reader = BufReader::new(file);
1159 let records: Vec<PerfSchedScriptRecord> = reader
1160 .lines()
1161 .map(|line| -> Result<_> {
1162 let line = line?;
1163 Ok(serde_json::from_str(&line)?)
1164 })
1165 .collect::<Result<_>>()?;
1166
1167 let (intervals, _start_ns, _end_ns, cpu_count) = build_busy_intervals(records)?;
1168 assert_eq!(cpu_count, 1);
1169 assert_eq!(intervals.len(), 5);
1170
1171 assert_eq!(intervals[0].comm, "worker-a");
1172 assert_eq!(intervals[0].hint, 0);
1173 assert_eq!((intervals[0].start_ns, intervals[0].end_ns), (0, 200_000));
1174
1175 assert_eq!(intervals[1].comm, "worker-a");
1176 assert_eq!(intervals[1].hint, 384);
1177 assert_eq!(
1178 (intervals[1].start_ns, intervals[1].end_ns),
1179 (200_000, 600_000)
1180 );
1181
1182 assert_eq!(intervals[2].comm, "softirq-rx");
1183 assert_eq!(intervals[2].hint, 0);
1184 assert_eq!(
1185 (intervals[2].start_ns, intervals[2].end_ns),
1186 (600_000, 700_000)
1187 );
1188
1189 assert_eq!(intervals[3].comm, "worker-a");
1190 assert_eq!(intervals[3].hint, 384);
1191 assert_eq!(
1192 (intervals[3].start_ns, intervals[3].end_ns),
1193 (700_000, 800_000)
1194 );
1195
1196 assert_eq!(intervals[4].comm, "worker-a");
1197 assert_eq!(intervals[4].hint, 640);
1198 assert_eq!(
1199 (intervals[4].start_ns, intervals[4].end_ns),
1200 (800_000, 1_000_000)
1201 );
1202
1203 Ok(())
1204 }
1205
1206 #[test]
1207 fn sched_util_uses_next_hint_from_switch_for_immediate_on_cpu_time() -> Result<()> {
1208 let tempdir = TempDir::new()?;
1209 let path = write_sched_jsonl(
1210 &tempdir,
1211 &[
1212 r#"{"comm":"idle","pid":0,"tid":0,"cpu":0,"time":0.0,"event":"sched:sched_switch","trace":"swapper/0:0 [120] R ==> worker-a:10 [120]","fields":{"prev_comm":"swapper/0","prev_pid":0,"prev_prio":120,"prev_state":"R","prev_hint":0,"next_comm":"worker-a","next_pid":10,"next_prio":120,"next_hint":640},"hint":0}"#,
1213 r#"{"comm":"worker-a","pid":10,"tid":10,"cpu":0,"time":0.001,"event":"sched:sched_switch","trace":"worker-a:10 [120] R ==> swapper/0:0 [120]","fields":{"prev_comm":"worker-a","prev_pid":10,"prev_prio":120,"prev_state":"R","prev_hint":640,"next_comm":"swapper/0","next_pid":0,"next_prio":120,"next_hint":0},"hint":640}"#,
1214 ],
1215 );
1216
1217 let file = File::open(&path)?;
1218 let reader = BufReader::new(file);
1219 let records: Vec<PerfSchedScriptRecord> = reader
1220 .lines()
1221 .map(|line| -> Result<_> {
1222 let line = line?;
1223 Ok(serde_json::from_str(&line)?)
1224 })
1225 .collect::<Result<_>>()?;
1226
1227 let (intervals, _start_ns, _end_ns, cpu_count) = build_busy_intervals(records)?;
1228 assert_eq!(cpu_count, 1);
1229 assert_eq!(intervals.len(), 1);
1230 assert_eq!(intervals[0].comm, "worker-a");
1231 assert_eq!(intervals[0].hint, 640);
1232 assert_eq!((intervals[0].start_ns, intervals[0].end_ns), (0, 1_000_000));
1233
1234 Ok(())
1235 }
1236
1237 #[test]
1238 fn sched_util_does_not_extrapolate_last_running_task_to_global_trace_end() -> Result<()> {
1239 let tempdir = TempDir::new()?;
1240 let path = write_sched_jsonl(
1241 &tempdir,
1242 &[
1243 r#"{"comm":"idle","pid":0,"tid":0,"cpu":0,"time":0.0,"event":"sched:sched_switch","trace":"swapper/0:0 [120] R ==> perf:900 [120]","fields":{"prev_comm":"swapper/0","prev_pid":0,"prev_prio":120,"prev_state":"R","next_comm":"perf","next_pid":900,"next_prio":120},"hint":0}"#,
1244 r#"{"comm":"perf","pid":900,"tid":900,"cpu":0,"time":0.001,"event":"sched:sched_stat_runtime","trace":"comm=perf runtime=1000000 [ns] vruntime=0 [ns]","fields":{"comm":"perf","runtime":1000000,"vruntime":0},"hint":0}"#,
1245 r#"{"comm":"idle","pid":0,"tid":0,"cpu":1,"time":0.0,"event":"sched:sched_switch","trace":"swapper/1:0 [120] R ==> worker-a:10 [120]","fields":{"prev_comm":"swapper/1","prev_pid":0,"prev_prio":120,"prev_state":"R","next_comm":"worker-a","next_pid":10,"next_prio":120},"hint":0}"#,
1246 r#"{"comm":"worker-a","pid":10,"tid":10,"cpu":1,"time":0.002,"event":"sched:sched_switch","trace":"worker-a:10 [120] R ==> swapper/1:0 [120]","fields":{"prev_comm":"worker-a","prev_pid":10,"prev_prio":120,"prev_state":"R","next_comm":"swapper/1","next_pid":0,"next_prio":120},"hint":0}"#,
1247 ],
1248 );
1249
1250 let file = File::open(&path)?;
1251 let reader = BufReader::new(file);
1252 let records: Vec<PerfSchedScriptRecord> = reader
1253 .lines()
1254 .map(|line| -> Result<_> {
1255 let line = line?;
1256 Ok(serde_json::from_str(&line)?)
1257 })
1258 .collect::<Result<_>>()?;
1259
1260 let (intervals, _start_ns, end_ns, cpu_count) = build_busy_intervals(records)?;
1261 assert_eq!(cpu_count, 2);
1262 assert_eq!(end_ns, 2_000_000);
1263
1264 let perf_interval = intervals
1265 .iter()
1266 .find(|interval| interval.comm == "perf")
1267 .expect("expected perf interval");
1268 assert_eq!(
1269 (perf_interval.start_ns, perf_interval.end_ns),
1270 (0, 1_000_000)
1271 );
1272
1273 let worker_interval = intervals
1274 .iter()
1275 .find(|interval| interval.comm == "worker-a")
1276 .expect("expected worker interval");
1277 assert_eq!(
1278 (worker_interval.start_ns, worker_interval.end_ns),
1279 (0, 2_000_000)
1280 );
1281
1282 Ok(())
1283 }
1284}