Skip to main content

scx_characterize/
sched_util.rs

1// Copyright (c) 2026 Meta Platforms, Inc. and affiliates.
2//
3// This software may be used and distributed according to the terms of the
4// GNU General Public License version 2.
5
6use 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}