1use 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 #[clap(short = 'f', long)]
27 pub file: PathBuf,
28
29 #[clap(short, long)]
31 pub verbose: bool,
32}
33
34#[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#[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}