1use std::{
2 env,
3 fs::File,
4 io::{BufReader, BufWriter, Cursor, Read, Write},
5 path::PathBuf,
6 process::{exit, Command, ExitStatus, Stdio},
7 str::FromStr,
8};
9
10#[cfg(unix)]
11use std::os::unix::process::ExitStatusExt;
12
13#[cfg(target_os = "linux")]
14use inferno::collapse::perf::{Folder, Options as CollapseOptions};
15
16#[cfg(target_os = "macos")]
17use inferno::collapse::xctrace::Folder;
18
19#[cfg(not(any(target_os = "linux", target_os = "macos")))]
20use inferno::collapse::dtrace::{Folder, Options as CollapseOptions};
21
22#[cfg(unix)]
23use signal_hook::consts::{SIGINT, SIGTERM};
24
25use anyhow::{anyhow, bail, Context};
26use clap::{
27 builder::{PossibleValuesParser, TypedValueParser},
28 Args,
29};
30use inferno::{collapse::Collapse, flamegraph::color::Palette, flamegraph::from_reader};
31use rustc_demangle::demangle_stream;
32
33pub enum Workload {
34 Command(Vec<String>),
35 Pid(Vec<u32>),
36 ReadPerf(PathBuf),
37}
38
39#[cfg(target_os = "linux")]
40mod arch {
41 use std::time::Duration;
42
43 use indicatif::{ProgressBar, ProgressStyle};
44
45 use super::*;
46
47 pub const SPAWN_ERROR: &str = "could not spawn perf";
48 pub const WAIT_ERROR: &str = "unable to wait for perf child command to exit";
49
50 pub(crate) fn initial_command(
51 workload: Workload,
52 sudo: Option<Option<&str>>,
53 freq: u32,
54 custom_cmd: Option<String>,
55 verbose: bool,
56 ignore_status: bool,
57 ) -> anyhow::Result<Option<PathBuf>> {
58 let perf = if let Ok(path) = env::var("PERF") {
59 path
60 } else {
61 if Command::new("perf")
62 .arg("--help")
63 .stderr(Stdio::null())
64 .stdout(Stdio::null())
65 .status()
66 .is_err()
67 {
68 bail!("perf is not installed or not present in $PATH");
69 }
70
71 String::from("perf")
72 };
73 let mut command = sudo_command(&perf, sudo);
74
75 let args = custom_cmd.unwrap_or(format!("record -F {freq} --call-graph dwarf,64000 -g"));
76
77 let mut perf_output = None;
78 let mut args = args.split_whitespace();
79 while let Some(arg) = args.next() {
80 command.arg(arg);
81
82 if arg == "-o" {
87 let next_arg = args.next().context("missing '-o' argument")?;
88 command.arg(next_arg);
89 perf_output = Some(PathBuf::from(next_arg));
90 }
91 }
92
93 let perf_output = match perf_output {
94 Some(path) => path,
95 None => {
96 command.arg("-o");
97 command.arg("perf.data");
98 PathBuf::from("perf.data")
99 }
100 };
101
102 match workload {
103 Workload::Command(c) => {
104 command.args(&c);
105 }
106 Workload::Pid(p) => {
107 if let Some((first, pids)) = p.split_first() {
108 let mut arg = first.to_string();
109
110 for pid in pids {
111 arg.push(',');
112 arg.push_str(&pid.to_string());
113 }
114
115 command.arg("-p");
116 command.arg(arg);
117 }
118 }
119 Workload::ReadPerf(_) => (),
120 }
121
122 run(command, verbose, ignore_status);
123 Ok(Some(perf_output))
124 }
125
126 pub fn output(
127 perf_output: Option<PathBuf>,
128 script_no_inline: bool,
129 sudo: Option<Option<&str>>,
130 ) -> anyhow::Result<Vec<u8>> {
131 let perf = env::var("PERF").unwrap_or_else(|_| "perf".to_string());
134 let mut command = sudo_command(&perf, sudo);
135
136 command.arg("script");
137
138 command.arg("--force");
140
141 if script_no_inline {
142 command.arg("--no-inline");
143 }
144
145 if let Some(perf_output) = perf_output {
146 command.arg("-i");
147 command.arg(perf_output);
148 }
149
150 let spinner = ProgressBar::new_spinner().with_prefix("Running perf script");
154 spinner.set_style(
155 ProgressStyle::with_template("{prefix} [{elapsed}]: {spinner:.green}").unwrap(),
156 );
157 spinner.enable_steady_tick(Duration::from_millis(500));
158
159 let result = command.output().context("unable to call perf script");
160 spinner.finish();
161 let output = result?;
162 if !output.status.success() {
163 bail!(
164 "unable to run 'perf script': ({}) {}",
165 output.status,
166 std::str::from_utf8(&output.stderr)?
167 );
168 }
169 Ok(output.stdout)
170 }
171}
172
173#[cfg(target_os = "macos")]
174mod arch {
175 use super::*;
176
177 pub const SPAWN_ERROR: &str = "could not spawn xctrace";
178 pub const WAIT_ERROR: &str = "unable to wait for xctrace record child command to exit";
179
180 pub(crate) fn initial_command(
181 workload: Workload,
182 sudo: Option<Option<&str>>,
183 freq: u32,
184 custom_cmd: Option<String>,
185 verbose: bool,
186 ignore_status: bool,
187 ) -> anyhow::Result<Option<PathBuf>> {
188 if freq != 997 {
189 bail!("xctrace doesn't support custom frequency");
190 }
191 if custom_cmd.is_some() {
192 bail!("xctrace doesn't support custom command");
193 }
194 let xctrace = env::var("XCTRACE").unwrap_or_else(|_| "xctrace".to_string());
195 let trace_file = PathBuf::from("cargo-flamegraph.trace");
196 let mut command = sudo_command(&xctrace, sudo);
197 command
198 .arg("record")
199 .arg("--template")
200 .arg("Time Profiler")
201 .arg("--output")
202 .arg(&trace_file);
203 match workload {
204 Workload::Command(args) => {
205 command
206 .arg("--target-stdout")
207 .arg("-")
208 .arg("--launch")
209 .arg("--")
210 .args(args);
211 }
212 Workload::Pid(pid) => {
213 match &*pid {
214 [pid] => {
215 command.arg("--attach").arg(pid.to_string());
218 }
219 _ => {
220 bail!("xctrace only supports profiling a single process at a time");
221 }
222 }
223 }
224 Workload::ReadPerf(_) => {}
225 }
226 run(command, verbose, ignore_status);
227 Ok(Some(trace_file))
228 }
229
230 pub fn output(
231 trace_file: Option<PathBuf>,
232 script_no_inline: bool,
233 _sudo: Option<Option<&str>>,
234 ) -> anyhow::Result<Vec<u8>> {
235 if script_no_inline {
236 bail!("--no-inline is only supported on Linux");
237 }
238
239 let xctrace = env::var("XCTRACE").unwrap_or_else(|_| "xctrace".to_string());
240 let trace_file = trace_file.context("no trace file found.")?;
241 let output = Command::new(xctrace)
242 .arg("export")
243 .arg("--input")
244 .arg(&trace_file)
245 .arg("--xpath")
246 .arg(r#"/trace-toc/*/data/table[@schema="time-profile"]"#)
247 .output()
248 .context("run xctrace export failed.")?;
249 std::fs::remove_dir_all(&trace_file)
250 .with_context(|| anyhow!("remove trace({}) failed.", trace_file.to_string_lossy()))?;
251 if !output.status.success() {
252 bail!(
253 "unable to run 'xctrace export': ({}) {}",
254 output.status,
255 String::from_utf8_lossy(&output.stderr)
256 );
257 }
258 Ok(output.stdout)
259 }
260}
261
262#[cfg(not(any(target_os = "linux", target_os = "macos")))]
263mod arch {
264 use super::*;
265
266 pub const SPAWN_ERROR: &str = "could not spawn dtrace";
267 pub const WAIT_ERROR: &str = "unable to wait for dtrace child command to exit";
268
269 #[cfg(target_os = "macos")]
270 fn base_dtrace_command(sudo: Option<Option<&str>>) -> Command {
271 let mut command = sudo_command("arch", sudo);
283
284 #[cfg(target_pointer_width = "64")]
285 command.arg("-64".to_string());
286 #[cfg(target_pointer_width = "32")]
287 command.arg("-32".to_string());
288
289 command.arg(env::var("DTRACE").unwrap_or_else(|_| "dtrace".to_string()));
290 command
291 }
292
293 #[cfg(not(target_os = "macos"))]
294 fn base_dtrace_command(sudo: Option<Option<&str>>) -> Command {
295 let dtrace = env::var("DTRACE").unwrap_or_else(|_| "dtrace".to_string());
296 sudo_command(&dtrace, sudo)
297 }
298
299 pub(crate) fn initial_command(
300 workload: Workload,
301 sudo: Option<Option<&str>>,
302 freq: u32,
303 custom_cmd: Option<String>,
304 verbose: bool,
305 ignore_status: bool,
306 ) -> anyhow::Result<Option<PathBuf>> {
307 let mut command = base_dtrace_command(sudo);
308
309 let dtrace_script = custom_cmd.unwrap_or(format!(
310 "profile-{freq} /pid == $target/ \
311 {{ @[ustack(100)] = count(); }}",
312 ));
313
314 command.arg("-x");
315 command.arg("ustackframes=100");
316
317 command.arg("-n");
318 command.arg(&dtrace_script);
319
320 command.arg("-o");
321 command.arg("cargo-flamegraph.stacks");
322
323 match workload {
324 Workload::Command(c) => {
325 let mut escaped = String::new();
326 for (i, arg) in c.iter().enumerate() {
327 if i > 0 {
328 escaped.push(' ');
329 }
330 escaped.push_str(&arg.replace(' ', "\\ "));
331 }
332
333 command.arg("-c");
334 command.arg(&escaped);
335
336 #[cfg(target_os = "windows")]
337 {
338 let mut help_test = crate::arch::base_dtrace_command(None);
339
340 let dtrace_found = help_test
341 .arg("--help")
342 .stderr(Stdio::null())
343 .stdout(Stdio::null())
344 .status()
345 .is_ok();
346 if !dtrace_found {
347 let mut command_builder = Command::new(&c[0]);
348 command_builder.args(&c[1..]);
349 print_command(&command_builder, verbose);
350
351 let trace = blondie::trace_command(command_builder, false)
352 .map_err(|err| anyhow!("could not find dtrace and could not profile using blondie: {err:?}"))?;
353
354 let f = std::fs::File::create("./cargo-flamegraph.stacks")
355 .context("unable to create temporary file 'cargo-flamegraph.stacks'")?;
356 let mut f = std::io::BufWriter::new(f);
357 trace.write_dtrace(&mut f).map_err(|err| {
358 anyhow!("unable to write dtrace output to 'cargo-flamegraph.stacks': {err:?}")
359 })?;
360
361 return Ok(None);
362 }
363 }
364 }
365 Workload::Pid(p) => {
366 for p in p {
367 command.arg("-p");
368 command.arg(p.to_string());
369 }
370 }
371 Workload::ReadPerf(_) => (),
372 }
373
374 run(command, verbose, ignore_status);
375 Ok(None)
376 }
377
378 pub fn output(
379 _: Option<PathBuf>,
380 script_no_inline: bool,
381 sudo: Option<Option<&str>>,
382 ) -> anyhow::Result<Vec<u8>> {
383 if script_no_inline {
384 bail!("--no-inline is only supported on Linux");
385 }
386
387 if sudo.is_some() {
390 #[cfg(unix)]
391 if let Ok(user) = env::var("USER") {
392 Command::new("sudo")
393 .args(["chown", user.as_str(), "cargo-flamegraph.stacks"])
394 .spawn()
395 .expect(arch::SPAWN_ERROR)
396 .wait()
397 .expect(arch::WAIT_ERROR);
398 }
399 }
400
401 let mut buf = vec![];
402 let mut f = File::open("cargo-flamegraph.stacks")
403 .context("failed to open dtrace output file 'cargo-flamegraph.stacks'")?;
404
405 f.read_to_end(&mut buf)
406 .context("failed to read dtrace expected output file 'cargo-flamegraph.stacks'")?;
407
408 std::fs::remove_file("cargo-flamegraph.stacks")
409 .context("unable to remove temporary file 'cargo-flamegraph.stacks'")?;
410
411 let string = String::from_utf8_lossy(&buf);
420 let reencoded_buf = string.as_bytes().to_owned();
421
422 if reencoded_buf != buf {
423 println!("Lossily converted invalid utf-8 found in cargo-flamegraph.stacks");
424 }
425
426 Ok(reencoded_buf)
427 }
428}
429
430fn sudo_command(command: &str, sudo: Option<Option<&str>>) -> Command {
431 let sudo = match sudo {
432 Some(sudo) => sudo,
433 None => return Command::new(command),
434 };
435
436 let mut c = Command::new("sudo");
437 if let Some(sudo_args) = sudo {
438 c.arg(sudo_args);
439 }
440 c.arg(command);
441 c
442}
443
444fn run(mut command: Command, verbose: bool, ignore_status: bool) {
445 print_command(&command, verbose);
446 let mut recorder = command.spawn().expect(arch::SPAWN_ERROR);
447 let exit_status = recorder.wait().expect(arch::WAIT_ERROR);
448
449 if !ignore_status && terminated_by_error(exit_status) {
454 eprintln!(
455 "failed to sample program, exited with code: {:?}",
456 exit_status.code()
457 );
458 exit(1);
459 }
460}
461
462#[cfg(unix)]
463fn terminated_by_error(status: ExitStatus) -> bool {
464 status
465 .signal() .map_or(true, |code| code != SIGINT && code != SIGTERM)
467 && !status.success()
468 && !(cfg!(target_os = "macos") && status.code() == Some(54))
470}
471
472#[cfg(not(unix))]
473fn terminated_by_error(status: ExitStatus) -> bool {
474 !status.success()
475}
476
477fn print_command(cmd: &Command, verbose: bool) {
478 if verbose {
479 println!("command {:?}", cmd);
480 }
481}
482
483pub fn generate_flamegraph_for_workload(workload: Workload, opts: Options) -> anyhow::Result<()> {
484 #[cfg(unix)]
491 let handler = unsafe {
492 signal_hook::low_level::register(SIGINT, || {}).expect("cannot register signal handler")
493 };
494
495 let sudo = opts.root.as_ref().map(|inner| inner.as_deref());
496
497 let perf_output = if let Workload::ReadPerf(perf_file) = workload {
498 Some(perf_file)
499 } else {
500 arch::initial_command(
501 workload,
502 sudo,
503 opts.frequency(),
504 opts.custom_cmd,
505 opts.verbose,
506 opts.ignore_status,
507 )?
508 };
509
510 #[cfg(unix)]
511 signal_hook::low_level::unregister(handler);
512
513 let output = arch::output(perf_output, opts.script_no_inline, sudo)?;
514
515 let mut demangled_output = vec![];
516
517 demangle_stream(&mut Cursor::new(output), &mut demangled_output, false)
518 .context("unable to demangle")?;
519
520 let perf_reader = BufReader::new(&*demangled_output);
521
522 let mut collapsed = vec![];
523
524 let collapsed_writer = BufWriter::new(&mut collapsed);
525
526 #[cfg(target_os = "linux")]
527 let mut folder = {
528 let mut collapse_options = CollapseOptions::default();
529 collapse_options.skip_after = opts.flamegraph_options.skip_after.clone();
530 Folder::from(collapse_options)
531 };
532
533 #[cfg(target_os = "macos")]
534 let mut folder = Folder::default();
535
536 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
537 let mut folder = {
538 let collapse_options = CollapseOptions::default();
539 Folder::from(collapse_options)
540 };
541
542 folder
543 .collapse(perf_reader, collapsed_writer)
544 .context("unable to collapse generated profile data")?;
545
546 if let Some(command) = opts.post_process {
547 let command_vec = shlex::split(&command)
548 .ok_or_else(|| anyhow!("unable to parse post-process command"))?;
549
550 let mut child = Command::new(
551 command_vec
552 .first()
553 .ok_or_else(|| anyhow!("unable to parse post-process command"))?,
554 )
555 .args(command_vec.get(1..).unwrap_or(&[]))
556 .stdin(Stdio::piped())
557 .stdout(Stdio::piped())
558 .spawn()
559 .with_context(|| format!("unable to execute {:?}", command_vec))?;
560
561 let mut stdin = child
562 .stdin
563 .take()
564 .ok_or_else(|| anyhow::anyhow!("unable to capture post-process stdin"))?;
565
566 let mut stdout = child
567 .stdout
568 .take()
569 .ok_or_else(|| anyhow::anyhow!("unable to capture post-process stdout"))?;
570
571 let thread_handle = std::thread::spawn(move || -> anyhow::Result<_> {
572 let mut collapsed_processed = Vec::new();
573 stdout.read_to_end(&mut collapsed_processed).context(
574 "unable to read the processed stacks from the stdout of the post-process process",
575 )?;
576 Ok(collapsed_processed)
577 });
578
579 stdin
580 .write_all(&collapsed)
581 .context("unable to write the raw stacks to the stdin of the post-process process")?;
582 drop(stdin);
583
584 anyhow::ensure!(
585 child.wait()?.success(),
586 "post-process exited with a non zero exit code"
587 );
588
589 collapsed = thread_handle.join().unwrap()?;
590 }
591
592 let collapsed_reader = BufReader::new(&*collapsed);
593
594 let flamegraph_filename = opts.output;
595 println!("writing flamegraph to {:?}", flamegraph_filename);
596 let flamegraph_file = File::create(&flamegraph_filename)
597 .context("unable to create flamegraph.svg output file")?;
598
599 let flamegraph_writer = BufWriter::new(flamegraph_file);
600
601 let mut inferno_opts = opts.flamegraph_options.into_inferno();
602 from_reader(&mut inferno_opts, collapsed_reader, flamegraph_writer)
603 .context("unable to generate a flamegraph from the collapsed stack data")?;
604
605 if opts.open {
606 opener::open(&flamegraph_filename).context(format!(
607 "failed to open '{}'",
608 flamegraph_filename.display()
609 ))?;
610 }
611
612 Ok(())
613}
614
615#[derive(Debug, Args)]
616pub struct Options {
617 #[clap(short, long)]
619 pub verbose: bool,
620
621 #[clap(short, long, default_value = "flamegraph.svg")]
623 output: PathBuf,
624
625 #[clap(long)]
627 open: bool,
628
629 #[clap(long, value_name = "SUDO FLAGS")]
631 pub root: Option<Option<String>>,
632
633 #[clap(short = 'F', long = "freq")]
635 frequency: Option<u32>,
636
637 #[clap(short, long = "cmd")]
639 custom_cmd: Option<String>,
640
641 #[clap(flatten)]
642 flamegraph_options: FlamegraphOptions,
643
644 #[clap(long)]
646 ignore_status: bool,
647
648 #[clap(long = "no-inline")]
650 script_no_inline: bool,
651
652 #[clap(long)]
655 post_process: Option<String>,
656}
657
658impl Options {
659 pub fn check(&self) -> anyhow::Result<()> {
660 match self.frequency.is_some() && self.custom_cmd.is_some() {
663 true => Err(anyhow!(
664 "Cannot pass both a custom command and a frequency."
665 )),
666 false => Ok(()),
667 }
668 }
669
670 pub fn frequency(&self) -> u32 {
671 self.frequency.unwrap_or(997)
672 }
673}
674
675#[derive(Debug, Args)]
676pub struct FlamegraphOptions {
677 #[clap(long, value_name = "STRING")]
679 pub title: Option<String>,
680
681 #[clap(long, value_name = "STRING")]
683 pub subtitle: Option<String>,
684
685 #[clap(long)]
687 pub deterministic: bool,
688
689 #[clap(short, long)]
691 pub inverted: bool,
692
693 #[clap(long)]
695 pub reverse: bool,
696
697 #[clap(long, value_name = "STRING")]
699 pub notes: Option<String>,
700
701 #[clap(long, default_value = "0.01", value_name = "FLOAT")]
703 pub min_width: f64,
704
705 #[clap(long)]
707 pub image_width: Option<usize>,
708
709 #[clap(
711 long,
712 value_parser = PossibleValuesParser::new(Palette::VARIANTS).map(|s| Palette::from_str(&s).unwrap())
713 )]
714 pub palette: Option<Palette>,
715
716 #[cfg(target_os = "linux")]
718 #[clap(long, value_name = "FUNCTION")]
719 pub skip_after: Vec<String>,
720
721 #[clap(long = "flamechart", conflicts_with = "reverse")]
723 pub flame_chart: bool,
724}
725
726impl FlamegraphOptions {
727 pub fn into_inferno(self) -> inferno::flamegraph::Options<'static> {
728 let mut options = inferno::flamegraph::Options::default();
729 if let Some(title) = self.title {
730 options.title = title;
731 }
732 options.subtitle = self.subtitle;
733 options.deterministic = self.deterministic;
734 if self.inverted {
735 options.direction = inferno::flamegraph::Direction::Inverted;
736 }
737 options.reverse_stack_order = self.reverse;
738 options.notes = self.notes.unwrap_or_default();
739 options.min_width = self.min_width;
740 options.image_width = self.image_width;
741 if let Some(palette) = self.palette {
742 options.colors = palette;
743 }
744 options.flame_chart = self.flame_chart;
745
746 options
747 }
748}