Skip to main content

scx_characterize/
extract.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::process::PerfMemRecord;
7use crate::sched_util::cmd_extract_sched_util;
8use anyhow::{Context as _, Result};
9use clap::{ArgAction, Parser, Subcommand};
10use regex::Regex;
11use serde::Serialize;
12use std::collections::{BTreeSet, HashMap};
13use std::fs::File;
14use std::io::{BufRead, BufReader};
15use std::path::PathBuf;
16
17const DEFAULT_WORKLOAD_CGROUP_REGEX: &str = "workload.slice";
18const DEFAULT_WORKLOAD_ALLOTMENT_CGROUP_REGEX: &str = r"workload-tw-[^/]+\.allotment\.slice";
19
20#[derive(Debug, Parser)]
21pub struct ExtractMemOpts {
22    /// Path to perf.mem.jsonl file
23    #[clap(short = 'f', long)]
24    pub file: PathBuf,
25
26    /// Regex pattern for workload cgroup
27    #[clap(long, default_value = DEFAULT_WORKLOAD_CGROUP_REGEX)]
28    pub workload_cgroup_regex: String,
29
30    /// Regex pattern for workload allotment cgroups
31    #[clap(long, default_value = DEFAULT_WORKLOAD_ALLOTMENT_CGROUP_REGEX)]
32    pub workload_allotment_cgroup_regex: String,
33
34    /// Split significant comm subcells further by hint values
35    #[clap(long)]
36    pub use_hints: bool,
37
38    /// Verbosity level (-v for summary, -vv for detailed output)
39    #[clap(short, long, action = ArgAction::Count)]
40    pub verbose: u8,
41}
42
43#[derive(Debug, Parser)]
44pub struct ExtractOpts {
45    #[clap(subcommand)]
46    pub command: ExtractCommand,
47}
48
49#[derive(Debug, Subcommand)]
50pub enum ExtractCommand {
51    /// Extract workload cell config from perf.mem.jsonl
52    Mem(ExtractMemOpts),
53    /// Extract derived metrics from perf.sched.jsonl
54    Sched(ExtractSchedOpts),
55}
56
57#[derive(Debug, Parser)]
58pub struct ExtractSchedOpts {
59    #[clap(subcommand)]
60    pub command: ExtractSchedCommand,
61}
62
63#[derive(Debug, Subcommand)]
64pub enum ExtractSchedCommand {
65    /// Compute time-weighted CPU busy utilization from perf.sched.jsonl
66    Util(ExtractSchedUtilOpts),
67}
68
69#[derive(Debug, Parser)]
70pub struct ExtractSchedUtilOpts {
71    /// Path to perf.sched.jsonl file
72    #[clap(short = 'f', long)]
73    pub file: PathBuf,
74
75    /// Aggregation window size in milliseconds
76    #[clap(long, default_value = "1")]
77    pub window_ms: u64,
78
79    /// Comma-separated mutually exclusive categories, e.g. "worker-a@hint=0,worker-a@hint=640,svc-*,perf"
80    #[clap(long, default_value = "")]
81    pub categories: String,
82
83    /// Print extra summary information to stderr
84    #[clap(short, long)]
85    pub verbose: bool,
86}
87
88fn classify_cgroup<'a>(cgroup: &'a str, workload_cgroup: &'a str, allotment_re: &Regex) -> &'a str {
89    if !cgroup.contains(workload_cgroup) {
90        return "rest";
91    }
92
93    if let Some(m) = allotment_re.find(cgroup) {
94        return m.as_str();
95    }
96
97    workload_cgroup
98}
99
100/// Samples belonging to a group, in time order
101struct GroupData {
102    samples: Vec<PerfMemRecord>,
103}
104
105impl GroupData {
106    fn new() -> Self {
107        Self {
108            samples: Vec::new(),
109        }
110    }
111
112    fn push(&mut self, sample: PerfMemRecord) {
113        self.samples.push(sample);
114    }
115
116    fn samples(&self) -> &[PerfMemRecord] {
117        &self.samples
118    }
119
120    fn print(&self, group_name: &str, global_total: u64, verbosity: u8, use_hints: bool) {
121        let group_pct = (self.samples.len() as f64 / global_total as f64) * 100.0;
122        eprintln!(
123            "\n{}: {} samples ({:.2}%)",
124            group_name,
125            self.samples.len(),
126            group_pct
127        );
128
129        let sample_refs: Vec<_> = self.samples.iter().collect();
130        let counts = summarize_comm_groups(&sample_refs);
131        let mut printed_aggregated_marker_note = false;
132
133        let mut skipped = 0;
134        for entry in counts {
135            let pct = (entry.count as f64 / self.samples.len() as f64) * 100.0;
136            if verbosity >= 2 || pct > 1.0 {
137                let display_name = if entry.aggregated_numeric_suffixes {
138                    printed_aggregated_marker_note = true;
139                    format!("{}*", entry.name)
140                } else {
141                    entry.name.clone()
142                };
143                eprintln!("  {}: {} ({:.2}%)", display_name, entry.count, pct);
144                if use_hints && entry.hint_counts.len() > 1 {
145                    let mut skipped_hints = 0;
146                    for (hint, count) in entry.hint_counts {
147                        let hint_pct = (count as f64 / entry.count as f64) * 100.0;
148                        if verbosity >= 2 || hint_pct > 1.0 {
149                            eprintln!("    hint={}: {} ({:.2}%)", hint, count, hint_pct);
150                        } else {
151                            skipped_hints += 1;
152                        }
153                    }
154                    if skipped_hints > 0 {
155                        eprintln!("    ... {} more hints below 1%", skipped_hints);
156                    }
157                }
158                if verbosity >= 2 && entry.concrete_counts.len() > 1 {
159                    for (comm, count) in entry.concrete_counts {
160                        let comm_pct = (count as f64 / entry.count as f64) * 100.0;
161                        eprintln!("    {}: {} ({:.2}%)", comm, count, comm_pct);
162                    }
163                }
164            } else {
165                skipped += 1;
166            }
167        }
168        if skipped > 0 {
169            eprintln!("  ... {} more below 1%", skipped);
170        }
171        if printed_aggregated_marker_note {
172            eprintln!("  * trailing numeric suffixes merged");
173        }
174    }
175}
176
177/// Result of clustering analysis for a group
178struct ClusterResult {
179    /// Comms that exceed the significance threshold, optionally with hint splits
180    significant_comms: Vec<CommCluster>,
181}
182
183/// Clustering result for a single comm
184struct CommCluster {
185    name: String,
186    match_comms: Vec<String>,
187    significant_hints: Vec<u64>,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq)]
191struct CommSummary {
192    name: String,
193    count: u64,
194    aggregated_numeric_suffixes: bool,
195    hint_counts: Vec<(u64, u64)>,
196    concrete_counts: Vec<(String, u64)>,
197}
198
199#[derive(Debug, Default)]
200struct GroupedCommSamples<'a> {
201    samples: Vec<&'a PerfMemRecord>,
202    hint_counts: HashMap<u64, u64>,
203    concrete_counts: HashMap<String, u64>,
204}
205
206/// Group type for clustering decisions
207#[derive(Debug, Clone, Copy, PartialEq)]
208enum GroupType {
209    Allotment,
210    Workload,
211    Rest,
212}
213
214/// Compute clustering for samples. Returns empty result for group types
215/// where clustering is not yet implemented.
216fn compute_clusters(
217    group_type: GroupType,
218    samples: &[&PerfMemRecord],
219    threshold_pct: f64,
220    use_hints: bool,
221) -> ClusterResult {
222    // TODO(kkd): Enable clustering for Workload and Rest
223    if group_type != GroupType::Allotment {
224        return ClusterResult {
225            significant_comms: Vec::new(),
226        };
227    }
228
229    let total = samples.len();
230    let grouped_samples = group_samples_by_normalized_comm(samples);
231
232    let mut significant_comms = Vec::new();
233    if total > 0 {
234        for (cluster_name, group) in grouped_samples {
235            let count = group.samples.len() as u64;
236            let pct = (count as f64 / total as f64) * 100.0;
237            if pct > threshold_pct {
238                let significant_hints = if use_hints {
239                    compute_significant_hints(&group.samples, threshold_pct)
240                } else {
241                    Vec::new()
242                };
243                significant_comms.push(CommCluster {
244                    name: cluster_name.clone(),
245                    match_comms: group
246                        .concrete_counts
247                        .keys()
248                        .cloned()
249                        .collect::<BTreeSet<_>>()
250                        .into_iter()
251                        .collect(),
252                    significant_hints,
253                });
254            }
255        }
256    }
257    significant_comms.sort_by(|a, b| a.name.cmp(&b.name));
258
259    ClusterResult { significant_comms }
260}
261
262fn group_samples_by_normalized_comm<'a>(
263    samples: &[&'a PerfMemRecord],
264) -> HashMap<String, GroupedCommSamples<'a>> {
265    let mut grouped = HashMap::new();
266    for sample in samples {
267        let cluster_name = normalize_comm_for_cluster(&sample.comm);
268        let entry = grouped
269            .entry(cluster_name)
270            .or_insert_with(GroupedCommSamples::default);
271        entry.samples.push(*sample);
272        *entry.hint_counts.entry(sample.hint).or_insert(0) += 1;
273        *entry
274            .concrete_counts
275            .entry(sample.comm.clone())
276            .or_insert(0) += 1;
277    }
278    grouped
279}
280
281fn normalize_comm_for_cluster(comm: &str) -> String {
282    let normalized = comm.trim_end_matches(|ch: char| ch.is_ascii_digit());
283    if normalized.is_empty() {
284        comm.to_string()
285    } else {
286        normalized.to_string()
287    }
288}
289
290fn compute_significant_hints(samples: &[&PerfMemRecord], threshold_pct: f64) -> Vec<u64> {
291    let mut hint_counts: HashMap<u64, u64> = HashMap::new();
292    for sample in samples {
293        *hint_counts.entry(sample.hint).or_insert(0) += 1;
294    }
295
296    if hint_counts.len() <= 1 {
297        return Vec::new();
298    }
299
300    let total = samples.len();
301    let mut significant_hints = Vec::new();
302    if total > 0 {
303        for (hint, count) in hint_counts {
304            let pct = (count as f64 / total as f64) * 100.0;
305            if pct > threshold_pct {
306                significant_hints.push(hint);
307            }
308        }
309    }
310    significant_hints.sort_unstable();
311    significant_hints
312}
313
314fn summarize_comm_groups(samples: &[&PerfMemRecord]) -> Vec<CommSummary> {
315    let mut summary: Vec<_> = group_samples_by_normalized_comm(samples)
316        .into_iter()
317        .map(|(name, group)| {
318            let mut hint_counts: Vec<_> = group.hint_counts.into_iter().collect();
319            hint_counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
320            let mut concrete_counts: Vec<_> = group.concrete_counts.into_iter().collect();
321            concrete_counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
322            CommSummary {
323                name,
324                count: group.samples.len() as u64,
325                aggregated_numeric_suffixes: concrete_counts.len() > 1,
326                hint_counts,
327                concrete_counts,
328            }
329        })
330        .collect();
331
332    summary.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)));
333    summary
334}
335
336/// Build subcells from clustering result. Returns empty if no significant comms.
337fn build_subcells_from_clusters(result: &ClusterResult) -> Vec<CellSpec> {
338    if result.significant_comms.is_empty() {
339        return Vec::new();
340    }
341
342    let mut subcells = Vec::new();
343
344    for comm in &result.significant_comms {
345        if comm.significant_hints.is_empty() {
346            subcells.push(CellSpec {
347                name: comm.name.clone(),
348                matches: CellMatches::complex(build_comm_match_clauses(comm, None)),
349                subcells: Vec::new(),
350            });
351        } else {
352            for hint in &comm.significant_hints {
353                subcells.push(CellSpec {
354                    name: format!("{}@hint={hint}", comm.name),
355                    matches: CellMatches::complex(build_comm_match_clauses(comm, Some(*hint))),
356                    subcells: Vec::new(),
357                });
358            }
359        }
360    }
361
362    subcells.push(CellSpec {
363        name: "rest".to_string(),
364        matches: CellMatches::complex(vec![vec![]]),
365        subcells: Vec::new(),
366    });
367
368    subcells
369}
370
371fn build_comm_match_clauses(comm: &CommCluster, hint: Option<u64>) -> Vec<Vec<CellMatch>> {
372    let comm_patterns = emitted_comm_patterns(comm);
373
374    comm_patterns
375        .iter()
376        .map(|exact_comm| {
377            let mut clause = vec![CellMatch::CommPrefix(exact_comm.clone())];
378            if let Some(hint) = hint {
379                clause.push(CellMatch::Hint(hint));
380            }
381            clause
382        })
383        .collect()
384}
385
386fn emitted_comm_patterns(comm: &CommCluster) -> Vec<String> {
387    if comm.match_comms.len() > 1
388        && !comm.name.is_empty()
389        && comm
390            .match_comms
391            .iter()
392            .all(|match_comm| match_comm.starts_with(&comm.name))
393    {
394        vec![comm.name.clone()]
395    } else {
396        comm.match_comms.clone()
397    }
398}
399
400#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
401enum CellMatch {
402    CommPrefix(String),
403    Hint(u64),
404}
405
406#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
407#[serde(untagged)]
408enum CellMatches {
409    Simple(SimpleCellMatches),
410    Complex(Vec<Vec<CellMatch>>),
411}
412
413impl CellMatches {
414    fn simple(matches: SimpleCellMatches) -> Self {
415        Self::Simple(matches)
416    }
417
418    fn complex(matches: Vec<Vec<CellMatch>>) -> Self {
419        Self::Complex(matches)
420    }
421}
422
423#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
424struct SimpleCellMatches {
425    #[serde(rename = "CgroupRegex", skip_serializing_if = "Option::is_none")]
426    cgroup_regex: Option<String>,
427    #[serde(rename = "CgroupContains", skip_serializing_if = "Option::is_none")]
428    cgroup_contains: Option<String>,
429}
430
431impl SimpleCellMatches {
432    fn cgroup_regex(value: String) -> Self {
433        Self {
434            cgroup_regex: Some(value),
435            cgroup_contains: None,
436        }
437    }
438
439    fn cgroup_contains(value: String) -> Self {
440        Self {
441            cgroup_regex: None,
442            cgroup_contains: Some(value),
443        }
444    }
445}
446
447#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
448struct CellSpec {
449    name: String,
450    matches: CellMatches,
451    #[serde(skip_serializing_if = "Vec::is_empty")]
452    subcells: Vec<CellSpec>,
453}
454
455#[derive(Debug, Clone, Serialize)]
456#[serde(transparent)]
457struct CellConfig {
458    specs: Vec<CellSpec>,
459}
460
461pub fn cmd_extract_mem(opts: ExtractMemOpts) -> Result<()> {
462    let file = File::open(&opts.file).context("failed to open perf.mem.jsonl")?;
463    let reader = BufReader::new(file);
464
465    let allotment_re =
466        Regex::new(&opts.workload_allotment_cgroup_regex).context("invalid allotment regex")?;
467    let workload_cgroup = &opts.workload_cgroup_regex;
468
469    let mut groups: HashMap<String, GroupData> = HashMap::new();
470    let mut global_total: u64 = 0;
471
472    for line in reader.lines() {
473        let line = line.context("failed to read line")?;
474        let record: PerfMemRecord =
475            serde_json::from_str(&line).context("failed to parse record")?;
476
477        let group = classify_cgroup(&record.cgroup, workload_cgroup, &allotment_re);
478        groups
479            .entry(group.to_string())
480            .or_insert_with(GroupData::new)
481            .push(record);
482        global_total += 1;
483    }
484
485    let mut group_names: Vec<_> = groups.keys().cloned().collect();
486    group_names.sort_by(|a, b| {
487        let order = |s: &str| -> u8 {
488            if s == "rest" {
489                2
490            } else if s == workload_cgroup {
491                1
492            } else {
493                0
494            }
495        };
496        order(a).cmp(&order(b)).then_with(|| a.cmp(b))
497    });
498
499    if opts.verbose > 0 {
500        eprintln!("Total samples: {}", global_total);
501        for name in &group_names {
502            if let Some(data) = groups.get(name) {
503                data.print(name, global_total, opts.verbose, opts.use_hints);
504            }
505        }
506    }
507
508    let config = generate_config(
509        &groups,
510        &group_names,
511        workload_cgroup,
512        &opts.workload_allotment_cgroup_regex,
513        opts.use_hints,
514    );
515    let json = serde_json::to_string_pretty(&config).context("failed to serialize config")?;
516    println!("{}", json);
517
518    Ok(())
519}
520
521pub fn cmd_extract(opts: ExtractOpts) -> Result<()> {
522    match opts.command {
523        ExtractCommand::Mem(opts) => cmd_extract_mem(opts),
524        ExtractCommand::Sched(opts) => cmd_extract_sched(opts),
525    }
526}
527
528pub fn cmd_extract_sched(opts: ExtractSchedOpts) -> Result<()> {
529    match opts.command {
530        ExtractSchedCommand::Util(opts) => cmd_extract_sched_util(opts),
531    }
532}
533
534fn generate_config(
535    groups: &HashMap<String, GroupData>,
536    group_names: &[String],
537    workload_cgroup: &str,
538    allotment_regex: &str,
539    use_hints: bool,
540) -> CellConfig {
541    let mut specs = Vec::new();
542
543    let group_types = [
544        (GroupType::Allotment, "allotment"),
545        (GroupType::Workload, workload_cgroup),
546        (GroupType::Rest, "rest"),
547    ];
548
549    for (group_type, name) in group_types {
550        let samples: Vec<&PerfMemRecord> = match group_type {
551            GroupType::Allotment => group_names
552                .iter()
553                .filter(|n| *n != "rest" && *n != workload_cgroup)
554                .filter_map(|n| groups.get(n))
555                .flat_map(|g| g.samples())
556                .collect(),
557            GroupType::Workload => groups
558                .get(workload_cgroup)
559                .map(|g| g.samples().iter().collect())
560                .unwrap_or_default(),
561            GroupType::Rest => groups
562                .get("rest")
563                .map(|g| g.samples().iter().collect())
564                .unwrap_or_default(),
565        };
566
567        if samples.is_empty() {
568            continue;
569        }
570
571        let clusters = compute_clusters(group_type, &samples, 5.0, use_hints);
572        let subcells = build_subcells_from_clusters(&clusters);
573
574        let matches = match group_type {
575            GroupType::Allotment => {
576                CellMatches::simple(SimpleCellMatches::cgroup_regex(allotment_regex.to_string()))
577            }
578            GroupType::Workload => CellMatches::simple(SimpleCellMatches::cgroup_contains(
579                workload_cgroup.to_string(),
580            )),
581            GroupType::Rest => CellMatches::simple(SimpleCellMatches::default()),
582        };
583
584        specs.push(CellSpec {
585            name: name.to_string(),
586            matches,
587            subcells,
588        });
589    }
590
591    CellConfig { specs }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597    use serde_json::json;
598
599    fn sample(comm: &str, cgroup: &str, hint: u64) -> PerfMemRecord {
600        serde_json::from_value(json!({
601            "comm": comm,
602            "tid": 1,
603            "pid": 1,
604            "time": "0",
605            "addr": "0",
606            "cgroup": cgroup,
607            "ip": "0",
608            "sym": "sym",
609            "dso": "dso",
610            "phys_addr": "0",
611            "data_page_size": 4096,
612            "hint": hint,
613        }))
614        .expect("failed to build PerfMemRecord test sample")
615    }
616
617    fn push_samples(
618        groups: &mut HashMap<String, GroupData>,
619        group: &str,
620        comm: &str,
621        hint: u64,
622        count: usize,
623    ) {
624        let data = groups
625            .entry(group.to_string())
626            .or_insert_with(GroupData::new);
627        for _ in 0..count {
628            data.push(sample(comm, group, hint));
629        }
630    }
631
632    #[test]
633    fn mem_extract_does_not_split_comms_by_hint_without_flag() {
634        let allotment = "workload-tw-foo.allotment.slice";
635        let workload = "workload.slice";
636
637        let mut groups = HashMap::new();
638        push_samples(&mut groups, allotment, "alpha", 0, 60);
639        push_samples(&mut groups, allotment, "alpha", 7, 20);
640        push_samples(&mut groups, allotment, "alpha", 9, 10);
641        push_samples(&mut groups, allotment, "beta", 0, 10);
642
643        let group_names = vec![allotment.to_string()];
644        let config = generate_config(&groups, &group_names, workload, "allotment-regex", false);
645
646        let allotment_spec = &config.specs[0];
647        let alpha = allotment_spec
648            .subcells
649            .iter()
650            .find(|spec| spec.name == "alpha")
651            .expect("missing alpha comm subcell");
652
653        assert!(alpha.subcells.is_empty());
654    }
655
656    #[test]
657    fn mem_extract_splits_significant_comms_by_hint_with_flag() {
658        let allotment = "workload-tw-foo.allotment.slice";
659        let workload = "workload.slice";
660
661        let mut groups = HashMap::new();
662        push_samples(&mut groups, allotment, "alpha", 0, 60);
663        push_samples(&mut groups, allotment, "alpha", 7, 20);
664        push_samples(&mut groups, allotment, "alpha", 9, 10);
665        push_samples(&mut groups, allotment, "beta", 0, 10);
666
667        let group_names = vec![allotment.to_string()];
668        let config = generate_config(&groups, &group_names, workload, "allotment-regex", true);
669
670        let allotment_spec = &config.specs[0];
671        let alpha_hint_0 = allotment_spec
672            .subcells
673            .iter()
674            .find(|spec| spec.name == "alpha@hint=0")
675            .expect("missing alpha@hint=0 subcell");
676        let alpha_hint_7 = allotment_spec
677            .subcells
678            .iter()
679            .find(|spec| spec.name == "alpha@hint=7")
680            .expect("missing alpha@hint=7 subcell");
681        let alpha_hint_9 = allotment_spec
682            .subcells
683            .iter()
684            .find(|spec| spec.name == "alpha@hint=9")
685            .expect("missing alpha@hint=9 subcell");
686
687        assert_eq!(
688            alpha_hint_0.matches,
689            CellMatches::complex(vec![vec![
690                CellMatch::CommPrefix("alpha".to_string()),
691                CellMatch::Hint(0),
692            ]])
693        );
694        assert_eq!(
695            alpha_hint_7.matches,
696            CellMatches::complex(vec![vec![
697                CellMatch::CommPrefix("alpha".to_string()),
698                CellMatch::Hint(7),
699            ]])
700        );
701        assert_eq!(
702            alpha_hint_9.matches,
703            CellMatches::complex(vec![vec![
704                CellMatch::CommPrefix("alpha".to_string()),
705                CellMatch::Hint(9),
706            ]])
707        );
708
709        let beta = allotment_spec
710            .subcells
711            .iter()
712            .find(|spec| spec.name == "beta")
713            .expect("missing beta comm subcell");
714        assert!(beta.subcells.is_empty());
715    }
716
717    #[test]
718    fn mem_extract_uses_single_match_statement_for_top_level_cells() {
719        let allotment = "workload-tw-foo.allotment.slice";
720        let workload = "workload.slice";
721
722        let mut groups = HashMap::new();
723        push_samples(&mut groups, allotment, "alpha", 0, 10);
724        push_samples(&mut groups, workload, "beta", 0, 10);
725        push_samples(&mut groups, "rest", "gamma", 0, 10);
726
727        let mut group_names = vec![
728            allotment.to_string(),
729            workload.to_string(),
730            "rest".to_string(),
731        ];
732        group_names.sort();
733
734        let config = generate_config(&groups, &group_names, workload, "allotment-regex", false);
735
736        let allotment_spec = config
737            .specs
738            .iter()
739            .find(|spec| spec.name == "allotment")
740            .expect("missing allotment spec");
741        assert_eq!(
742            allotment_spec.matches,
743            CellMatches::simple(SimpleCellMatches::cgroup_regex(
744                "allotment-regex".to_string()
745            ))
746        );
747
748        let workload_spec = config
749            .specs
750            .iter()
751            .find(|spec| spec.name == workload)
752            .expect("missing workload spec");
753        assert_eq!(
754            workload_spec.matches,
755            CellMatches::simple(SimpleCellMatches::cgroup_contains(workload.to_string()))
756        );
757
758        let rest_spec = config
759            .specs
760            .iter()
761            .find(|spec| spec.name == "rest")
762            .expect("missing rest spec");
763        assert_eq!(
764            rest_spec.matches,
765            CellMatches::simple(SimpleCellMatches::default())
766        );
767    }
768
769    #[test]
770    fn mem_extract_clusters_numeric_suffix_comms_for_dominance() {
771        let allotment = "workload-tw-foo.allotment.slice";
772        let workload = "workload.slice";
773
774        let mut groups = HashMap::new();
775        push_samples(&mut groups, allotment, "mcrpxy-webNR1", 0, 4);
776        push_samples(&mut groups, allotment, "mcrpxy-webNR2", 0, 4);
777        push_samples(&mut groups, allotment, "beta", 0, 92);
778
779        let group_names = vec![allotment.to_string()];
780        let config = generate_config(&groups, &group_names, workload, "allotment-regex", false);
781
782        let allotment_spec = &config.specs[0];
783        let merged = allotment_spec
784            .subcells
785            .iter()
786            .find(|spec| spec.name == "mcrpxy-webNR")
787            .expect("missing merged numeric-suffix comm subcell");
788
789        assert_eq!(
790            merged.matches,
791            CellMatches::complex(vec![vec![CellMatch::CommPrefix(
792                "mcrpxy-webNR".to_string()
793            )]])
794        );
795        assert!(allotment_spec
796            .subcells
797            .iter()
798            .all(|spec| spec.name != "mcrpxy-webNR1" && spec.name != "mcrpxy-webNR2"));
799    }
800
801    #[test]
802    fn mem_extract_splits_merged_numeric_suffix_comm_by_hint_with_flag() {
803        let allotment = "workload-tw-foo.allotment.slice";
804        let workload = "workload.slice";
805
806        let mut groups = HashMap::new();
807        push_samples(&mut groups, allotment, "mcrpxy-webNR1", 0, 3);
808        push_samples(&mut groups, allotment, "mcrpxy-webNR1", 7, 1);
809        push_samples(&mut groups, allotment, "mcrpxy-webNR2", 7, 3);
810        push_samples(&mut groups, allotment, "mcrpxy-webNR2", 9, 1);
811        push_samples(&mut groups, allotment, "beta", 0, 92);
812
813        let group_names = vec![allotment.to_string()];
814        let config = generate_config(&groups, &group_names, workload, "allotment-regex", true);
815
816        let allotment_spec = &config.specs[0];
817        let merged_hint_0 = allotment_spec
818            .subcells
819            .iter()
820            .find(|spec| spec.name == "mcrpxy-webNR@hint=0")
821            .expect("missing merged numeric-suffix hint=0 subcell");
822        let merged_hint_7 = allotment_spec
823            .subcells
824            .iter()
825            .find(|spec| spec.name == "mcrpxy-webNR@hint=7")
826            .expect("missing merged numeric-suffix hint=7 subcell");
827        let merged_hint_9 = allotment_spec
828            .subcells
829            .iter()
830            .find(|spec| spec.name == "mcrpxy-webNR@hint=9")
831            .expect("missing merged numeric-suffix hint=9 subcell");
832        assert_eq!(
833            merged_hint_0.matches,
834            CellMatches::complex(vec![vec![
835                CellMatch::CommPrefix("mcrpxy-webNR".to_string()),
836                CellMatch::Hint(0),
837            ]])
838        );
839        assert_eq!(
840            merged_hint_7.matches,
841            CellMatches::complex(vec![vec![
842                CellMatch::CommPrefix("mcrpxy-webNR".to_string()),
843                CellMatch::Hint(7),
844            ]])
845        );
846        assert_eq!(
847            merged_hint_9.matches,
848            CellMatches::complex(vec![vec![
849                CellMatch::CommPrefix("mcrpxy-webNR".to_string()),
850                CellMatch::Hint(9),
851            ]])
852        );
853    }
854
855    #[test]
856    fn mem_extract_summary_groups_numeric_suffix_comms_and_reports_hints() {
857        let samples = vec![
858            sample("mcrpxy-webNR1", "cg", 0),
859            sample("mcrpxy-webNR2", "cg", 7),
860            sample("mcrpxy-webNR3", "cg", 7),
861        ];
862        let sample_refs: Vec<_> = samples.iter().collect();
863        let summary = summarize_comm_groups(&sample_refs);
864
865        assert_eq!(summary.len(), 1);
866        assert_eq!(summary[0].name, "mcrpxy-webNR");
867        assert_eq!(summary[0].count, 3);
868        assert!(summary[0].aggregated_numeric_suffixes);
869        assert_eq!(summary[0].hint_counts, vec![(7, 2), (0, 1)]);
870        assert_eq!(
871            summary[0].concrete_counts,
872            vec![
873                ("mcrpxy-webNR1".to_string(), 1),
874                ("mcrpxy-webNR2".to_string(), 1),
875                ("mcrpxy-webNR3".to_string(), 1),
876            ]
877        );
878    }
879}