Skip to main content

scx_characterize/
process.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::record::{
7    perf_binary, perf_script_output_exists, report_perf_script_stderr, PERF_MEM_DATA_FILE,
8    PERF_MEM_JSONL_FILE, PERF_MEM_SCRIPT_FIELDS, PERF_MEM_SCRIPT_FILE, PERF_SCHED_DATA_FILE,
9    PERF_SCHED_JSONL_FILE, PERF_SCHED_SCRIPT_FIELDS, PERF_SCHED_SCRIPT_FILE,
10};
11use anyhow::{bail, Context as _, Result};
12use clap::Parser;
13use regex::Regex;
14use serde::{Deserialize, Serialize};
15use serde_json::{Map, Number, Value};
16use std::collections::{HashMap, HashSet};
17use std::fs::{self, File};
18use std::io::{BufRead, BufReader, BufWriter, Write};
19use std::path::{Path, PathBuf};
20use std::process::{Command, Stdio};
21use std::sync::OnceLock;
22
23#[derive(Debug, Parser)]
24pub struct ProcessOpts {
25    /// Path to profile file (tar.gz) or directory
26    #[clap(short = 'f', long)]
27    pub file: PathBuf,
28
29    /// Print extra information during processing
30    #[clap(short, long)]
31    pub verbose: bool,
32}
33
34/// Represents a single perf mem sample record
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct PerfMemRecord {
37    pub comm: String,
38    pub tid: u32,
39    pub pid: u32,
40    pub time: String,
41    pub addr: String,
42    pub cgroup: String,
43    pub ip: String,
44    pub sym: String,
45    pub dso: String,
46    pub phys_addr: String,
47    pub data_page_size: u64,
48    #[serde(default)]
49    pub hint: u64,
50    #[serde(skip, default)]
51    sample_time_ns: Option<u64>,
52}
53
54/// Represents a single sched trace record from perf sched script
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct PerfSchedScriptRecord {
57    pub comm: String,
58    pub pid: i32,
59    pub tid: i32,
60    pub cpu: u32,
61    pub time: f64,
62    pub event: String,
63    pub trace: String,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub fields: Option<Map<String, Value>>,
66    #[serde(default)]
67    pub hint: u64,
68    #[serde(skip, default)]
69    sample_time_ns: Option<u64>,
70}
71
72impl PerfSchedScriptRecord {
73    pub fn sample_time_ns(&self) -> Option<u64> {
74        self.sample_time_ns
75    }
76}
77
78#[derive(Debug, Clone, Deserialize)]
79struct HintRecord {
80    pid: i32,
81    #[allow(dead_code)]
82    tgid: i32,
83    hints: u64,
84    timestamp: u64,
85}
86
87#[derive(Debug, Clone)]
88struct ThreadHintTimeline {
89    records: Vec<HintRecord>,
90}
91
92#[derive(Debug, Clone)]
93struct OrderingIssues {
94    label: &'static str,
95    violations: u64,
96    affected_tids: HashSet<u32>,
97    example_tids: Vec<u32>,
98}
99
100#[derive(Debug, Clone, Copy)]
101struct TraceArtifacts<'a> {
102    data_file: &'a str,
103    script_file: &'a str,
104    jsonl_file: &'a str,
105    script_fields: &'a str,
106    script_kind: &'a str,
107    jsonl_kind: &'a str,
108}
109
110#[derive(Debug, Clone, Default)]
111struct HintIndex {
112    timelines: HashMap<u32, ThreadHintTimeline>,
113    ordering_issues: Option<OrderingIssues>,
114}
115
116#[derive(Debug)]
117struct HintAnnotator<'a> {
118    hint_index: &'a HintIndex,
119    cursors: HashMap<u32, usize>,
120    last_sample_time_ns: HashMap<u32, u64>,
121    ordering_issues: OrderingIssues,
122}
123
124trait HintAssignable {
125    fn hint_tid(&self) -> Option<u32>;
126    fn hint_time_ns(&self) -> Option<u64>;
127    fn set_hint(&mut self, hint: u64);
128}
129
130impl HintIndex {
131    fn load_if_exists(profile_dir: &Path) -> Result<Option<Self>> {
132        let hints_path = profile_dir.join("hints.jsonl");
133        if !hints_path.exists() {
134            return Ok(None);
135        }
136
137        let file = File::open(&hints_path).context("failed to open hints.jsonl")?;
138        let reader = BufReader::new(file);
139        let mut timelines: HashMap<u32, Vec<HintRecord>> = HashMap::new();
140        let mut last_timestamp_by_tid: HashMap<u32, u64> = HashMap::new();
141        let mut ordering_issues = OrderingIssues::new("hint timeline");
142
143        for line in reader.lines() {
144            let line = line.context("failed to read hints.jsonl line")?;
145            if line.trim().is_empty() {
146                continue;
147            }
148            let record: HintRecord =
149                serde_json::from_str(&line).context("failed to parse hints.jsonl record")?;
150            if let Ok(pid) = u32::try_from(record.pid) {
151                if let Some(last_timestamp) = last_timestamp_by_tid.insert(pid, record.timestamp) {
152                    ordering_issues.observe(pid, last_timestamp, record.timestamp);
153                }
154                timelines.entry(pid).or_default().push(record);
155            }
156        }
157
158        let timelines = timelines
159            .into_iter()
160            .map(|(tid, mut records)| {
161                records.sort_by_key(|record| record.timestamp);
162                (tid, ThreadHintTimeline { records })
163            })
164            .collect();
165
166        Ok(Some(Self {
167            timelines,
168            ordering_issues: ordering_issues.into_option(),
169        }))
170    }
171}
172
173impl<'a> HintAnnotator<'a> {
174    fn new(hint_index: &'a HintIndex) -> Self {
175        Self {
176            hint_index,
177            cursors: HashMap::new(),
178            last_sample_time_ns: HashMap::new(),
179            ordering_issues: OrderingIssues::new("sample timeline"),
180        }
181    }
182
183    fn annotate<R: HintAssignable>(&mut self, record: &mut R) {
184        let hint = record
185            .hint_tid()
186            .zip(record.hint_time_ns())
187            .and_then(|(tid, time_ns)| self.resolve_hint(tid, time_ns))
188            .unwrap_or(0);
189        record.set_hint(hint);
190    }
191
192    fn annotate_sched_record(&mut self, record: &mut PerfSchedScriptRecord) {
193        self.annotate(record);
194
195        if record.event != "sched:sched_switch" {
196            return;
197        }
198
199        let Some(time_ns) = record.hint_time_ns() else {
200            return;
201        };
202        let Some(fields) = record.fields.as_mut() else {
203            return;
204        };
205
206        fields.insert(
207            "prev_hint".to_string(),
208            Value::Number(Number::from(record.hint)),
209        );
210
211        let next_hint = fields
212            .get("next_pid")
213            .and_then(Value::as_i64)
214            .and_then(|tid| u32::try_from(tid).ok())
215            .and_then(|tid| self.resolve_hint(tid, time_ns))
216            .unwrap_or(0);
217        fields.insert(
218            "next_hint".to_string(),
219            Value::Number(Number::from(next_hint)),
220        );
221    }
222
223    fn resolve_hint(&mut self, tid: u32, time_ns: u64) -> Option<u64> {
224        if let Some(last_time_ns) = self.last_sample_time_ns.insert(tid, time_ns) {
225            self.ordering_issues.observe(tid, last_time_ns, time_ns);
226        }
227        let timeline = self.hint_index.timelines.get(&tid)?;
228        let cursor = self.cursors.entry(tid).or_insert(0);
229        timeline.resolve_hint(time_ns, cursor)
230    }
231
232    fn finish(self) -> Option<OrderingIssues> {
233        self.ordering_issues.into_option()
234    }
235}
236
237impl ThreadHintTimeline {
238    fn resolve_hint(&self, time_ns: u64, cursor: &mut usize) -> Option<u64> {
239        if self.records.is_empty() {
240            return None;
241        }
242
243        if *cursor >= self.records.len() {
244            *cursor = self.records.len() - 1;
245        }
246
247        if self.records[*cursor].timestamp > time_ns {
248            let idx = self
249                .records
250                .partition_point(|record| record.timestamp <= time_ns);
251            if idx == 0 {
252                *cursor = 0;
253                return None;
254            }
255            *cursor = idx - 1;
256            return Some(self.records[*cursor].hints);
257        }
258
259        while *cursor + 1 < self.records.len() && self.records[*cursor + 1].timestamp <= time_ns {
260            *cursor += 1;
261        }
262
263        Some(self.records[*cursor].hints)
264    }
265}
266
267impl HintAssignable for PerfMemRecord {
268    fn hint_tid(&self) -> Option<u32> {
269        Some(self.tid)
270    }
271
272    fn hint_time_ns(&self) -> Option<u64> {
273        self.sample_time_ns
274    }
275
276    fn set_hint(&mut self, hint: u64) {
277        self.hint = hint;
278    }
279}
280
281impl HintAssignable for PerfSchedScriptRecord {
282    fn hint_tid(&self) -> Option<u32> {
283        u32::try_from(self.tid).ok()
284    }
285
286    fn hint_time_ns(&self) -> Option<u64> {
287        self.sample_time_ns
288    }
289
290    fn set_hint(&mut self, hint: u64) {
291        self.hint = hint;
292    }
293}
294
295impl OrderingIssues {
296    fn new(label: &'static str) -> Self {
297        Self {
298            label,
299            violations: 0,
300            affected_tids: HashSet::new(),
301            example_tids: Vec::new(),
302        }
303    }
304
305    fn observe(&mut self, tid: u32, last_timestamp: u64, current_timestamp: u64) {
306        if current_timestamp < last_timestamp {
307            self.violations += 1;
308            if self.affected_tids.insert(tid) && self.example_tids.len() < 8 {
309                self.example_tids.push(tid);
310            }
311        }
312    }
313
314    fn into_option(self) -> Option<Self> {
315        (self.violations > 0).then_some(self)
316    }
317
318    fn warn(&self, context: &str, conservative_action: &str) {
319        let examples = if self.example_tids.is_empty() {
320            String::new()
321        } else {
322            format!(
323                " Example tids: {}.",
324                self.example_tids
325                    .iter()
326                    .map(u32::to_string)
327                    .collect::<Vec<_>>()
328                    .join(", ")
329            )
330        };
331        eprintln!(
332            "WARNING: detected {} out-of-order {} violation(s) across {} tid(s) while processing {}. {}{} Please fix the input ordering if possible.",
333            self.violations,
334            self.label,
335            self.affected_tids.len(),
336            context,
337            conservative_action,
338            examples
339        );
340    }
341}
342
343pub fn cmd_process(opts: ProcessOpts) -> Result<()> {
344    let profile_dir = prepare_profile_dir(&opts.file)?;
345
346    let output_dir = create_output_dir(&profile_dir)?;
347    println!("Output directory: {}", output_dir.display());
348
349    match run_processing(&profile_dir, &output_dir, opts.verbose) {
350        Ok(()) => Ok(()),
351        Err(e) => {
352            let _ = fs::remove_dir_all(&output_dir);
353            Err(e)
354        }
355    }
356}
357
358fn run_processing(profile_dir: &Path, output_dir: &Path, verbose: bool) -> Result<()> {
359    let mem_trace = TraceArtifacts {
360        data_file: PERF_MEM_DATA_FILE,
361        script_file: PERF_MEM_SCRIPT_FILE,
362        jsonl_file: PERF_MEM_JSONL_FILE,
363        script_fields: PERF_MEM_SCRIPT_FIELDS,
364        script_kind: "perf.mem.script",
365        jsonl_kind: "perf.mem.jsonl",
366    };
367    let sched_trace = TraceArtifacts {
368        data_file: PERF_SCHED_DATA_FILE,
369        script_file: PERF_SCHED_SCRIPT_FILE,
370        jsonl_file: PERF_SCHED_JSONL_FILE,
371        script_fields: PERF_SCHED_SCRIPT_FIELDS,
372        script_kind: "perf.sched.script",
373        jsonl_kind: "perf.sched.jsonl",
374    };
375    let hint_index = HintIndex::load_if_exists(profile_dir)?;
376    if let Some(hint_index) = hint_index.as_ref() {
377        if let Some(ordering_issues) = hint_index.ordering_issues.as_ref() {
378            ordering_issues.warn(
379                "hints.jsonl",
380                "Hint events will be sorted conservatively before annotation.",
381            );
382        }
383    }
384    copy_hints_if_present(profile_dir, output_dir)?;
385    if let Some(mem_perf_script_dst) =
386        prepare_trace_script_if_present(profile_dir, output_dir, mem_trace)?
387    {
388        parse_perf_mem_script_to_jsonl(
389            &mem_perf_script_dst,
390            &output_dir.join(mem_trace.jsonl_file),
391            hint_index.as_ref(),
392            mem_trace,
393            verbose,
394        )?;
395    }
396
397    if let Some(sched_perf_script_dst) =
398        prepare_trace_script_if_present(profile_dir, output_dir, sched_trace)?
399    {
400        parse_sched_perf_script_to_jsonl(
401            &sched_perf_script_dst,
402            &output_dir.join(sched_trace.jsonl_file),
403            hint_index.as_ref(),
404            sched_trace,
405            verbose,
406        )?;
407    }
408
409    print_profile_contents(output_dir)?;
410    Ok(())
411}
412
413fn copy_hints_if_present(profile_dir: &Path, output_dir: &Path) -> Result<()> {
414    let hints_src = profile_dir.join("hints.jsonl");
415    if !hints_src.exists() {
416        return Ok(());
417    }
418
419    let hints_dst = output_dir.join("hints.jsonl");
420    fs::copy(&hints_src, &hints_dst).with_context(|| {
421        format!(
422            "failed to copy hints.jsonl from '{}' to '{}'",
423            hints_src.display(),
424            hints_dst.display()
425        )
426    })?;
427    Ok(())
428}
429
430fn prepare_trace_script_if_present(
431    profile_dir: &Path,
432    output_dir: &Path,
433    artifacts: TraceArtifacts<'_>,
434) -> Result<Option<PathBuf>> {
435    let perf_data_src = profile_dir.join(artifacts.data_file);
436    let perf_script_src = profile_dir.join(artifacts.script_file);
437    let perf_script_dst = output_dir.join(artifacts.script_file);
438
439    if !perf_script_src.exists() && !perf_data_src.exists() {
440        println!(
441            "Skipping {}: neither {} nor {} is present",
442            artifacts.jsonl_kind, artifacts.script_file, artifacts.data_file
443        );
444        return Ok(None);
445    }
446
447    if !perf_script_src.exists() {
448        println!(
449            "Generating {} from {}...",
450            artifacts.script_kind, artifacts.data_file
451        );
452        generate_perf_script(&perf_data_src, &perf_script_src, artifacts.script_fields)?;
453    }
454
455    println!("Copying {}...", artifacts.script_kind);
456    fs::copy(&perf_script_src, &perf_script_dst)
457        .with_context(|| format!("failed to copy {}", artifacts.script_kind))?;
458
459    Ok(Some(perf_script_dst))
460}
461
462fn prepare_profile_dir(path: &Path) -> Result<PathBuf> {
463    if path.is_dir() {
464        return Ok(path.to_path_buf());
465    }
466
467    let path_str = path.to_string_lossy();
468    if !path_str.ends_with(".tar.gz") {
469        bail!("'{}' is not a directory or tar.gz archive", path.display());
470    }
471
472    let desired_dir = PathBuf::from(path_str.trim_end_matches(".tar.gz"));
473    if desired_dir.exists() {
474        return Ok(desired_dir);
475    }
476
477    validate_archive_layout(path)?;
478    fs::create_dir_all(&desired_dir).with_context(|| {
479        format!(
480            "failed to create extraction directory '{}'",
481            desired_dir.display()
482        )
483    })?;
484
485    let status = Command::new("tar")
486        .args([
487            "-xzf",
488            &path_str,
489            "--strip-components=1",
490            "-C",
491            desired_dir
492                .to_str()
493                .context("invalid extraction directory path")?,
494        ])
495        .status()
496        .context("failed to run tar")?;
497
498    if !status.success() {
499        let _ = fs::remove_dir_all(&desired_dir);
500        bail!("tar extraction failed with status: {}", status);
501    }
502
503    if !desired_dir.is_dir() {
504        bail!(
505            "expected extracted directory '{}' not found after extraction",
506            desired_dir.display()
507        );
508    }
509
510    Ok(desired_dir)
511}
512
513fn create_output_dir(profile_dir: &Path) -> Result<PathBuf> {
514    let output_dir = PathBuf::from(format!("{}.post", profile_dir.display()));
515
516    if output_dir.exists() {
517        bail!("output directory '{}' already exists", output_dir.display());
518    }
519
520    fs::create_dir_all(&output_dir).context("failed to create output directory")?;
521
522    Ok(output_dir)
523}
524
525fn validate_archive_layout(archive_path: &Path) -> Result<()> {
526    let archive_name = archive_path
527        .file_name()
528        .context("invalid archive path")?
529        .to_string_lossy()
530        .to_string();
531
532    let output = Command::new("tar")
533        .args([
534            "-tzf",
535            archive_path.to_str().context("invalid archive path")?,
536        ])
537        .output()
538        .context("failed to inspect tar archive")?;
539
540    if !output.status.success() {
541        let stderr = String::from_utf8_lossy(&output.stderr);
542        bail!("failed to inspect tar archive: {}", stderr.trim());
543    }
544
545    let listing = String::from_utf8(output.stdout).context("tar listing was not valid UTF-8")?;
546    let mut top_levels = HashSet::new();
547
548    for line in listing.lines() {
549        let line = line.trim();
550        if line.is_empty() || line == "." {
551            continue;
552        }
553
554        let top = line.split('/').next().unwrap_or_default();
555        if top.is_empty() || top == "." {
556            continue;
557        }
558
559        top_levels.insert(top.to_string());
560    }
561
562    match top_levels.len() {
563        1 => Ok(()),
564        0 => bail!("archive '{}' appears to be empty", archive_name),
565        _ => bail!(
566            "archive '{}' contains multiple top-level entries; unable to extract into a single profile directory",
567            archive_name
568        ),
569    }
570}
571
572fn generate_perf_script(
573    perf_data_path: &Path,
574    perf_script_path: &Path,
575    fields: &str,
576) -> Result<()> {
577    if !perf_data_path.exists() {
578        bail!("perf data file '{}' not found", perf_data_path.display());
579    }
580
581    let output_file = File::create(perf_script_path)
582        .with_context(|| format!("failed to create {}", perf_script_path.display()))?;
583
584    let output = Command::new(perf_binary())
585        .args([
586            "script",
587            "-F",
588            fields,
589            "-i",
590            perf_data_path.to_str().context("invalid perf.data path")?,
591        ])
592        .stdout(Stdio::from(output_file))
593        .stderr(Stdio::piped())
594        .spawn()
595        .context("failed to run perf script")?
596        .wait_with_output()
597        .context("failed to run perf script")?;
598
599    let stderr = String::from_utf8_lossy(&output.stderr);
600    let output_len = fs::metadata(perf_script_path)
601        .map(|meta| meta.len())
602        .unwrap_or(0);
603
604    if !output.status.success() {
605        if perf_script_output_exists(output_len) {
606            eprintln!(
607                "warning: perf script exited with status {} after writing {} bytes to {}. Continuing with the generated output because the output file is non-empty.",
608                output.status,
609                output_len,
610                perf_script_path.display()
611            );
612            report_perf_script_stderr(&perf_script_path.display().to_string(), &stderr);
613            return Ok(());
614        }
615        let _ = fs::remove_file(perf_script_path);
616        let stderr = stderr.trim();
617        if stderr.is_empty() {
618            bail!("perf script failed with status: {}", output.status);
619        }
620        bail!(
621            "perf script failed with status: {}: {}",
622            output.status,
623            stderr
624        );
625    }
626
627    report_perf_script_stderr(&perf_script_path.display().to_string(), &stderr);
628
629    Ok(())
630}
631
632fn sched_line_re() -> &'static Regex {
633    static RE: OnceLock<Regex> = OnceLock::new();
634    RE.get_or_init(|| {
635        Regex::new(
636            r"^\s*(?P<comm>.*?)\s+(?P<pid>-?\d+)/(?P<tid>-?\d+)\s+\[(?P<cpu>\d+)\]\s+(?P<time>\d+\.\d+):\s+(?P<event>[^:]+:[^:]+):\s*(?P<trace>.*)$",
637        )
638        .unwrap()
639    })
640}
641
642fn sched_switch_re() -> &'static Regex {
643    static RE: OnceLock<Regex> = OnceLock::new();
644    RE.get_or_init(|| {
645        Regex::new(
646            r"^(?P<prev_comm>.+?):(?P<prev_pid>-?\d+)\s+\[(?P<prev_prio>-?\d+)\]\s+(?P<prev_state>.+?)\s+==>\s+(?P<next_comm>.+?):(?P<next_pid>-?\d+)\s+\[(?P<next_prio>-?\d+)\]$",
647        )
648        .unwrap()
649    })
650}
651
652fn kv_re() -> &'static Regex {
653    static RE: OnceLock<Regex> = OnceLock::new();
654    RE.get_or_init(|| Regex::new(r"(\w+)=([^\s\]]+)").unwrap())
655}
656
657fn action_re() -> &'static Regex {
658    static RE: OnceLock<Regex> = OnceLock::new();
659    RE.get_or_init(|| Regex::new(r"\[action=([^\]]+)\]").unwrap())
660}
661
662fn parse_page_size(s: &str) -> u64 {
663    let s = s.trim();
664    if s == "N/A" || s.is_empty() {
665        return 0;
666    }
667    let s_upper = s.to_uppercase();
668    if let Some(num_str) = s_upper.strip_suffix('K') {
669        num_str.parse::<u64>().unwrap_or(0) * 1024
670    } else if let Some(num_str) = s_upper.strip_suffix('M') {
671        num_str.parse::<u64>().unwrap_or(0) * 1024 * 1024
672    } else if let Some(num_str) = s_upper.strip_suffix('G') {
673        num_str.parse::<u64>().unwrap_or(0) * 1024 * 1024 * 1024
674    } else {
675        s.parse::<u64>().unwrap_or(0)
676    }
677}
678
679fn parse_u32_pair(part: &str) -> Option<(u32, u32)> {
680    let (first, second) = part.split_once('/')?;
681    Some((first.parse().ok()?, second.parse().ok()?))
682}
683
684fn parse_perf_time_ns(s: &str) -> Option<u64> {
685    let s = s.trim();
686    if s.is_empty() {
687        return None;
688    }
689
690    let (secs_str, frac_str) = match s.split_once('.') {
691        Some((secs, frac)) => (secs, frac),
692        None => (s, ""),
693    };
694
695    let secs = secs_str.parse::<u64>().ok()?;
696    let mut frac_ns = 0u64;
697    let mut scale = 100_000_000u64;
698
699    for ch in frac_str.chars().take(9) {
700        let digit = ch.to_digit(10)? as u64;
701        frac_ns += digit * scale;
702        scale /= 10;
703    }
704
705    secs.checked_mul(1_000_000_000)?.checked_add(frac_ns)
706}
707
708fn perf_time_f64_to_ns(time: f64) -> Option<u64> {
709    if !time.is_finite() || time < 0.0 {
710        return None;
711    }
712    let ns = time * 1_000_000_000.0;
713    if ns < 0.0 || ns > u64::MAX as f64 {
714        return None;
715    }
716    Some(ns as u64)
717}
718
719fn parse_perf_mem_script_line(line: &str) -> Option<PerfMemRecord> {
720    let line = line.trim();
721    if line.is_empty() {
722        return None;
723    }
724
725    let mut iter = line.split_whitespace();
726    let mut comm_parts = Vec::new();
727    let mut pid = None;
728    let mut tid = None;
729    for token in iter.by_ref() {
730        if let Some((parsed_pid, parsed_tid)) = parse_u32_pair(token) {
731            pid = Some(parsed_pid);
732            tid = Some(parsed_tid);
733            break;
734        }
735        comm_parts.push(token);
736    }
737
738    let pid = pid?;
739    let tid = tid?;
740    let time = iter.next()?.trim_end_matches(':').to_string();
741    let sample_time_ns = parse_perf_time_ns(&time);
742    let addr = iter.next()?.to_string();
743    let cgroup = iter.next()?.to_string();
744    let ip = iter.next()?.to_string();
745    let remainder = iter.collect::<Vec<_>>().join(" ");
746
747    let (sym, dso, phys_addr, data_page_size) = if let Some(paren_end) = remainder.rfind(')') {
748        if let Some(paren_start) = remainder[..paren_end].rfind('(') {
749            let sym = remainder[..paren_start].trim().to_string();
750            let dso = remainder[paren_start + 1..paren_end].to_string();
751            let after_dso: Vec<&str> = remainder[paren_end + 1..].split_whitespace().collect();
752            let phys_addr = after_dso.first().map(|s| s.to_string()).unwrap_or_default();
753            let data_page_size = parse_page_size(after_dso.get(1).unwrap_or(&"0"));
754            (sym, dso, phys_addr, data_page_size)
755        } else {
756            (remainder, String::new(), String::new(), 0)
757        }
758    } else {
759        (String::new(), String::new(), String::new(), 0)
760    };
761
762    Some(PerfMemRecord {
763        comm: comm_parts.join(" "),
764        tid,
765        pid,
766        time,
767        addr,
768        cgroup,
769        ip,
770        sym,
771        dso,
772        phys_addr,
773        data_page_size,
774        hint: 0,
775        sample_time_ns,
776    })
777}
778
779fn maybe_int_value(value: &str) -> Value {
780    match value.parse::<i64>() {
781        Ok(num) => Value::Number(Number::from(num)),
782        Err(_) => Value::String(value.to_string()),
783    }
784}
785
786fn parse_sched_fields(event: &str, trace: &str) -> Option<Map<String, Value>> {
787    let mut fields = Map::new();
788
789    if event == "sched:sched_switch" {
790        let captures = sched_switch_re().captures(trace)?;
791        let prev_comm = captures.name("prev_comm")?.as_str();
792        let prev_pid = captures.name("prev_pid")?.as_str().parse::<i64>().ok()?;
793        let prev_prio = captures.name("prev_prio")?.as_str().parse::<i64>().ok()?;
794        let prev_state = captures.name("prev_state")?.as_str();
795        let next_comm = captures.name("next_comm")?.as_str();
796        let next_pid = captures.name("next_pid")?.as_str().parse::<i64>().ok()?;
797        let next_prio = captures.name("next_prio")?.as_str().parse::<i64>().ok()?;
798
799        fields.insert(
800            "prev_comm".to_string(),
801            Value::String(prev_comm.to_string()),
802        );
803        fields.insert(
804            "prev_pid".to_string(),
805            Value::Number(Number::from(prev_pid)),
806        );
807        fields.insert(
808            "prev_prio".to_string(),
809            Value::Number(Number::from(prev_prio)),
810        );
811        fields.insert(
812            "prev_state".to_string(),
813            Value::String(prev_state.to_string()),
814        );
815        fields.insert(
816            "next_comm".to_string(),
817            Value::String(next_comm.to_string()),
818        );
819        fields.insert(
820            "next_pid".to_string(),
821            Value::Number(Number::from(next_pid)),
822        );
823        fields.insert(
824            "next_prio".to_string(),
825            Value::Number(Number::from(next_prio)),
826        );
827        fields.insert(
828            "cpu_idle".to_string(),
829            Value::Bool(next_pid == 0 || next_comm.starts_with("swapper")),
830        );
831        fields.insert(
832            "prev_idle".to_string(),
833            Value::Bool(prev_pid == 0 || prev_comm.starts_with("swapper")),
834        );
835        return Some(fields);
836    }
837
838    for captures in kv_re().captures_iter(trace) {
839        fields.insert(
840            captures[1].to_string(),
841            maybe_int_value(captures.get(2).unwrap().as_str()),
842        );
843    }
844
845    if let Some(captures) = action_re().captures(trace) {
846        fields.insert("action".to_string(), Value::String(captures[1].to_string()));
847    }
848
849    if fields.is_empty() {
850        None
851    } else {
852        Some(fields)
853    }
854}
855
856fn parse_sched_perf_script_line(line: &str) -> Option<PerfSchedScriptRecord> {
857    let line = line.trim();
858    if line.is_empty() {
859        return None;
860    }
861
862    let captures = sched_line_re().captures(line)?;
863    let comm = captures.name("comm")?.as_str().trim().to_string();
864    let pid = captures.name("pid")?.as_str().parse::<i32>().ok()?;
865    let tid = captures.name("tid")?.as_str().parse::<i32>().ok()?;
866    let cpu = captures.name("cpu")?.as_str().parse::<u32>().ok()?;
867    let time = captures.name("time")?.as_str().parse::<f64>().ok()?;
868    let sample_time_ns = perf_time_f64_to_ns(time);
869    let event = captures.name("event")?.as_str().trim().to_string();
870    let trace = captures.name("trace")?.as_str().trim().to_string();
871    let fields = parse_sched_fields(&event, &trace);
872
873    Some(PerfSchedScriptRecord {
874        comm,
875        pid,
876        tid,
877        cpu,
878        time,
879        event,
880        trace,
881        fields,
882        hint: 0,
883        sample_time_ns,
884    })
885}
886
887fn parse_perf_mem_script_to_jsonl(
888    perf_script_path: &Path,
889    output_path: &Path,
890    hint_index: Option<&HintIndex>,
891    artifacts: TraceArtifacts<'_>,
892    verbose: bool,
893) -> Result<()> {
894    let file = File::open(perf_script_path)
895        .with_context(|| format!("failed to open {}", artifacts.script_kind))?;
896    let mut reader = BufReader::new(file);
897
898    let output_file = File::create(output_path)
899        .with_context(|| format!("failed to create {}", artifacts.jsonl_kind))?;
900    let mut writer = BufWriter::new(output_file);
901
902    let mut count = 0;
903    let mut skipped = 0;
904    let mut errors = 0;
905    let mut hint_annotator = hint_index.map(HintAnnotator::new);
906    let mut line = String::new();
907    loop {
908        line.clear();
909        if reader.read_line(&mut line).context("failed to read line")? == 0 {
910            break;
911        }
912        let raw_line = line.trim_end_matches(['\n', '\r']);
913        match parse_perf_mem_script_line(raw_line) {
914            Some(mut record) => {
915                if record.phys_addr == "0" || record.phys_addr.is_empty() {
916                    skipped += 1;
917                    if verbose {
918                        eprintln!("skipped (no phys_addr): {}", raw_line);
919                    }
920                    continue;
921                }
922                if record.comm == "perf" || record.comm == "swapper" {
923                    skipped += 1;
924                    if verbose {
925                        eprintln!("skipped (filtered comm): {}", raw_line);
926                    }
927                    continue;
928                }
929                if let Some(hint_annotator) = hint_annotator.as_mut() {
930                    hint_annotator.annotate(&mut record);
931                }
932                let json = serde_json::to_string(&record).context("failed to serialize record")?;
933                writeln!(writer, "{}", json)?;
934                count += 1;
935            }
936            None => {
937                if !raw_line.trim().is_empty() {
938                    errors += 1;
939                    if verbose {
940                        eprintln!("unparseable: {}", raw_line);
941                    }
942                }
943            }
944        }
945    }
946
947    writer.flush()?;
948
949    if let Some(ordering_issues) = hint_annotator.and_then(HintAnnotator::finish) {
950        ordering_issues.warn(
951            &perf_script_path.display().to_string(),
952            "Hint lookup fell back conservatively for out-of-order samples.",
953        );
954    }
955
956    let total = count + skipped + errors;
957    println!(
958        "Parsed {} of {} records ({} skipped, {} unparseable)",
959        count, total, skipped, errors
960    );
961
962    Ok(())
963}
964
965fn parse_sched_perf_script_to_jsonl(
966    perf_script_path: &Path,
967    output_path: &Path,
968    hint_index: Option<&HintIndex>,
969    artifacts: TraceArtifacts<'_>,
970    verbose: bool,
971) -> Result<()> {
972    let file = File::open(perf_script_path)
973        .with_context(|| format!("failed to open {}", artifacts.script_kind))?;
974    let mut reader = BufReader::new(file);
975
976    let output_file = File::create(output_path)
977        .with_context(|| format!("failed to create {}", artifacts.jsonl_kind))?;
978    let mut writer = BufWriter::new(output_file);
979
980    let mut count = 0;
981    let mut errors = 0;
982    let mut hint_annotator = hint_index.map(HintAnnotator::new);
983    let mut line = String::new();
984    loop {
985        line.clear();
986        if reader.read_line(&mut line).context("failed to read line")? == 0 {
987            break;
988        }
989        let raw_line = line.trim_end_matches(['\n', '\r']);
990        match parse_sched_perf_script_line(raw_line) {
991            Some(mut record) => {
992                if let Some(hint_annotator) = hint_annotator.as_mut() {
993                    hint_annotator.annotate_sched_record(&mut record);
994                }
995                let json = serde_json::to_string(&record).context("failed to serialize record")?;
996                writeln!(writer, "{}", json)?;
997                count += 1;
998            }
999            None => {
1000                if !raw_line.trim().is_empty() {
1001                    errors += 1;
1002                    if verbose {
1003                        eprintln!("unparseable sched line: {}", raw_line);
1004                    }
1005                }
1006            }
1007        }
1008    }
1009
1010    writer.flush()?;
1011
1012    if let Some(ordering_issues) = hint_annotator.and_then(HintAnnotator::finish) {
1013        ordering_issues.warn(
1014            &perf_script_path.display().to_string(),
1015            "Hint lookup fell back conservatively for out-of-order samples.",
1016        );
1017    }
1018
1019    let total = count + errors;
1020    println!(
1021        "Parsed {} of {} sched trace records ({} unparseable)",
1022        count, total, errors
1023    );
1024
1025    Ok(())
1026}
1027
1028fn print_profile_contents(profile_dir: &Path) -> Result<()> {
1029    println!("Output contents:");
1030
1031    let entries: Vec<_> = std::fs::read_dir(profile_dir)
1032        .context("failed to read profile directory")?
1033        .filter_map(|e| e.ok())
1034        .collect();
1035
1036    if entries.is_empty() {
1037        println!("  (empty)");
1038        return Ok(());
1039    }
1040
1041    for entry in entries {
1042        let metadata = entry.metadata().ok();
1043        let size = metadata.map(|m| m.len()).unwrap_or(0);
1044        println!("  {} ({} bytes)", entry.file_name().to_string_lossy(), size);
1045    }
1046
1047    Ok(())
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052    use super::*;
1053    use serde::de::DeserializeOwned;
1054    use tempfile::TempDir;
1055
1056    fn test_data_dir(name: &str) -> PathBuf {
1057        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1058            .join("tests")
1059            .join("data")
1060            .join(name)
1061    }
1062
1063    fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
1064        fs::create_dir_all(dst)?;
1065        for entry in fs::read_dir(src)? {
1066            let entry = entry?;
1067            let src_path = entry.path();
1068            let dst_path = dst.join(entry.file_name());
1069            let file_type = entry.file_type()?;
1070            if file_type.is_dir() {
1071                copy_dir_recursive(&src_path, &dst_path)?;
1072            } else {
1073                fs::copy(&src_path, &dst_path)?;
1074            }
1075        }
1076        Ok(())
1077    }
1078
1079    fn materialize_test_profile(name: &str) -> Result<(TempDir, PathBuf)> {
1080        let tempdir = TempDir::new()?;
1081        let profile_dir = tempdir.path().join(name);
1082        copy_dir_recursive(&test_data_dir(name), &profile_dir)?;
1083        Ok((tempdir, profile_dir))
1084    }
1085
1086    fn read_jsonl<T: DeserializeOwned>(path: &Path) -> Result<Vec<T>> {
1087        let file = File::open(path)?;
1088        let reader = BufReader::new(file);
1089        reader
1090            .lines()
1091            .map(|line| {
1092                let line = line?;
1093                serde_json::from_str(&line).context("failed to parse jsonl line")
1094            })
1095            .collect()
1096    }
1097
1098    #[test]
1099    fn parse_perf_mem_line_interprets_first_id_as_pid_and_second_as_tid() {
1100        let record = parse_perf_mem_script_line(
1101            "alpha worker 1000/2001 0.000000050: 1 /cg 2 foo (bar.so) 3 4K",
1102        )
1103        .expect("expected mem record to parse");
1104
1105        assert_eq!(record.comm, "alpha worker");
1106        assert_eq!(record.pid, 1000);
1107        assert_eq!(record.tid, 2001);
1108        assert_eq!(record.sample_time_ns, Some(50));
1109    }
1110
1111    #[test]
1112    fn process_annotates_mem_and_sched_records_with_thread_hints() -> Result<()> {
1113        let (_tempdir, profile_dir) = materialize_test_profile("profile_basic")?;
1114        let output_dir = profile_dir.with_extension("post");
1115        fs::create_dir_all(&output_dir)?;
1116
1117        run_processing(&profile_dir, &output_dir, false)?;
1118
1119        let mem_records: Vec<PerfMemRecord> = read_jsonl(&output_dir.join(PERF_MEM_JSONL_FILE))?;
1120        let sched_records: Vec<PerfSchedScriptRecord> =
1121            read_jsonl(&output_dir.join(PERF_SCHED_JSONL_FILE))?;
1122
1123        assert!(output_dir.join("hints.jsonl").exists());
1124
1125        let mem_hints: Vec<u64> = mem_records.iter().map(|record| record.hint).collect();
1126        let sched_hints: Vec<u64> = sched_records.iter().map(|record| record.hint).collect();
1127        assert_eq!(mem_records[0].pid, 1000);
1128        assert_eq!(mem_records[0].tid, 2001);
1129        assert_eq!(mem_hints, vec![0, 7, 7, 9, 0, 5, 5, 0]);
1130        assert_eq!(sched_hints, vec![0, 7, 0, 9, 0, 5, 0]);
1131        assert_eq!(
1132            sched_records[2]
1133                .fields
1134                .as_ref()
1135                .and_then(|fields| fields.get("next_hint"))
1136                .and_then(Value::as_u64),
1137            Some(7)
1138        );
1139        assert_eq!(
1140            sched_records[2]
1141                .fields
1142                .as_ref()
1143                .and_then(|fields| fields.get("prev_hint"))
1144                .and_then(Value::as_u64),
1145            Some(0)
1146        );
1147
1148        Ok(())
1149    }
1150
1151    #[test]
1152    fn process_annotates_sched_switch_prev_and_next_hints_independently() -> Result<()> {
1153        let mut timelines = HashMap::new();
1154        timelines.insert(
1155            10,
1156            ThreadHintTimeline {
1157                records: vec![HintRecord {
1158                    pid: 10,
1159                    tgid: 1000,
1160                    hints: 256,
1161                    timestamp: 500,
1162                }],
1163            },
1164        );
1165        timelines.insert(
1166            20,
1167            ThreadHintTimeline {
1168                records: vec![HintRecord {
1169                    pid: 20,
1170                    tgid: 1000,
1171                    hints: 640,
1172                    timestamp: 500,
1173                }],
1174            },
1175        );
1176        let hint_index = HintIndex {
1177            timelines,
1178            ordering_issues: None,
1179        };
1180        let mut annotator = HintAnnotator::new(&hint_index);
1181        let mut record = parse_sched_perf_script_line(
1182            "worker-a 1000/10 [000] 0.000000500: sched:sched_switch: worker-a:10 [120] R ==> worker-b:20 [120]",
1183        )
1184        .expect("expected sched record");
1185
1186        annotator.annotate_sched_record(&mut record);
1187
1188        assert_eq!(record.hint, 256);
1189        assert_eq!(
1190            record
1191                .fields
1192                .as_ref()
1193                .and_then(|fields| fields.get("prev_hint"))
1194                .and_then(Value::as_u64),
1195            Some(256)
1196        );
1197        assert_eq!(
1198            record
1199                .fields
1200                .as_ref()
1201                .and_then(|fields| fields.get("next_hint"))
1202                .and_then(Value::as_u64),
1203            Some(640)
1204        );
1205
1206        Ok(())
1207    }
1208
1209    #[test]
1210    fn hint_annotation_handles_out_of_order_hints_and_samples_conservatively() -> Result<()> {
1211        let (_tempdir, profile_dir) = materialize_test_profile("profile_out_of_order")?;
1212        let hint_index =
1213            HintIndex::load_if_exists(&profile_dir)?.expect("expected hints index to be loaded");
1214
1215        assert!(hint_index.ordering_issues.is_some());
1216
1217        let perf_script_path = profile_dir.join(PERF_MEM_SCRIPT_FILE);
1218        let output_path = profile_dir.join(PERF_MEM_JSONL_FILE);
1219        let artifacts = TraceArtifacts {
1220            data_file: PERF_MEM_DATA_FILE,
1221            script_file: PERF_MEM_SCRIPT_FILE,
1222            jsonl_file: PERF_MEM_JSONL_FILE,
1223            script_fields: PERF_MEM_SCRIPT_FIELDS,
1224            script_kind: "perf.mem.script",
1225            jsonl_kind: "perf.mem.jsonl",
1226        };
1227
1228        parse_perf_mem_script_to_jsonl(
1229            &perf_script_path,
1230            &output_path,
1231            Some(&hint_index),
1232            artifacts,
1233            false,
1234        )?;
1235
1236        let records: Vec<PerfMemRecord> = read_jsonl(&output_path)?;
1237        let hints: Vec<u64> = records.iter().map(|record| record.hint).collect();
1238
1239        assert_eq!(hints, vec![9, 7]);
1240
1241        let mut annotator = HintAnnotator::new(&hint_index);
1242        for line in fs::read_to_string(&perf_script_path)?.lines() {
1243            let mut record = parse_perf_mem_script_line(line).expect("expected mem record");
1244            annotator.annotate(&mut record);
1245        }
1246        assert!(annotator.finish().is_some());
1247
1248        Ok(())
1249    }
1250}