diff --git a/Cargo.lock b/Cargo.lock index a6aac01077..a75ba87ae6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6246,6 +6246,8 @@ dependencies = [ "clap", "inferno", "object 0.37.3", + "regex", + "rustc-demangle", ] [[package]] diff --git a/scripts/axbuild/src/starry/mod.rs b/scripts/axbuild/src/starry/mod.rs index 92a7aa0f9e..1e688a6dce 100644 --- a/scripts/axbuild/src/starry/mod.rs +++ b/scripts/axbuild/src/starry/mod.rs @@ -1,4 +1,7 @@ -use std::path::{Path, PathBuf}; +use std::{ + fmt, + path::{Path, PathBuf}, +}; use clap::{Args, Subcommand, ValueEnum}; use ostool::{ @@ -84,22 +87,107 @@ pub struct ArgsQemu { #[derive(Args, Debug, Clone)] pub struct ArgsPerf { + /// Profile case name used in the default output path. + #[arg(long, default_value = "boot")] + pub case: String, #[arg(long)] pub arch: Option, #[arg(long, default_value_t = 99)] pub freq: u32, - #[arg(long)] + #[arg(long = "out", hide = true)] pub out: Option, + /// Output root. Final reports go under /perf//latest. + #[arg(long)] + pub output_dir: Option, #[arg(long, value_enum, default_value_t = PerfFormat::All)] pub format: PerfFormat, - #[arg(long, default_value_t = 64)] + #[arg(long, default_value_t = 128)] pub max_depth: usize, #[arg(long, value_name = "SECONDS", default_value_t = 20)] pub timeout: u64, - #[arg(long, default_value = "tb")] - pub mode: String, - #[arg(long, default_value_t = 20)] + #[arg(long, value_enum, default_value_t = PerfMode::Tb)] + pub mode: PerfMode, + #[arg(long, default_value_t = 80)] pub top: usize, + #[arg(long, default_value_t = 0.3)] + pub min_percent: f64, + #[arg(long)] + pub debug: bool, + #[arg(long)] + pub kernel_filter: bool, + /// Collect host wall/user/system CPU time metrics for the QEMU process wrapper. + #[arg(long)] + pub host_time: bool, + /// Disable the cargo starry perf default host-time metrics. + #[arg(long)] + pub no_host_time: bool, + /// Run QEMU under host perf stat. These are host/QEMU process metrics, not guest PMU values. + #[arg(long)] + pub host_perf: bool, + /// Comma-separated host perf stat events used with --host-perf. + #[arg( + long, + default_value = "task-clock,cycles,instructions,cache-references,cache-misses,\ + context-switches,cpu-migrations,page-faults" + )] + pub host_perf_events: String, + /// Send this command to the guest shell after the qperf boot prompt appears. + #[arg(long, visible_alias = "workload")] + pub shell_init_cmd: Option, + /// Prompt substring used before sending --shell-init-cmd. + #[arg(long)] + pub shell_prefix: Option, + /// Append one raw QEMU argument. Repeat for options and values. + #[arg(long = "qemu-arg", value_name = "ARG", allow_hyphen_values = true)] + pub qemu_args: Vec, + /// Guest stdout marker that starts the workload sampling window. + #[arg(long)] + pub start_marker: Option, + /// Guest stdout marker that stops the workload sampling window. + #[arg(long)] + pub stop_marker: Option, + /// Stop QEMU if the workload window stays open longer than this many seconds. + #[arg(long, value_name = "SECONDS")] + pub workload_timeout: Option, + /// Enable feature-gated in-guest qperf metric counters. + #[arg(long)] + pub qperf_metrics: bool, + /// Request SVG flamegraph generation even when --format is folded. + #[arg(long)] + pub flamegraph: bool, + /// Flamegraph view format. + #[arg(long, value_enum, default_value_t = PerfFlamegraphKind::Svg)] + pub flamegraph_kind: PerfFlamegraphKind, + /// Preserve the deepest stack qperf can collect for this build. + #[arg(long)] + pub full_stack: bool, + /// qperf callchain collection mode. `leaf` is fastest; `fp` requires frame pointers. + #[arg(long = "perf-callchain", visible_alias = "callchain", value_enum)] + pub callchain: Option, + /// Add DWARF debug info and keep symbols for qperf symbolization. + #[arg(long = "perf-debuginfo")] + pub debuginfo: bool, + /// Force frame pointers for qperf FP unwinding. + #[arg(long = "perf-force-frame-pointers")] + pub force_frame_pointers: bool, + /// Force Rust demangling in qperf-analyzer. + #[arg(long)] + pub demangle: bool, + /// Keep tiny frames in SVG output by setting flamegraph min width to zero. + #[arg(long)] + pub no_truncate: bool, + /// Include kernel symbols in symbolized stacks. This is the default for StarryOS kernels. + #[arg(long)] + pub include_kernel_symbols: bool, + /// Include user symbols when available. Current StarryOS qperf only resolves the kernel ELF. + #[arg(long)] + pub include_user_symbols: bool, + /// Folded-stack symbol style. + #[arg(long, value_enum, default_value_t = PerfSymbolStyle::Full)] + pub symbol_style: PerfSymbolStyle, + /// Generate an additional focused folded stack/flamegraph for matching frames. + #[arg(long, value_name = "REGEX")] + pub focus: Option, #[arg(long, value_name = "CPUS")] pub smp: Option, } @@ -112,6 +200,72 @@ pub enum PerfFormat { All, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum PerfMode { + Tb, + Insn, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum PerfCallchain { + Leaf, + Fp, + Logical, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum PerfFlamegraphKind { + Svg, + Html, + Folded, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum PerfSymbolStyle { + Full, + Short, + Module, +} + +impl fmt::Display for PerfMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Tb => "tb", + Self::Insn => "insn", + }) + } +} + +impl fmt::Display for PerfCallchain { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Leaf => "leaf", + Self::Fp => "fp", + Self::Logical => "logical", + }) + } +} + +impl fmt::Display for PerfFlamegraphKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Svg => "svg", + Self::Html => "html", + Self::Folded => "folded", + }) + } +} + +impl fmt::Display for PerfSymbolStyle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Full => "full", + Self::Short => "short", + Self::Module => "module", + }) + } +} + #[derive(Args)] pub struct ArgsUboot { #[command(flatten)] diff --git a/scripts/axbuild/src/starry/perf.rs b/scripts/axbuild/src/starry/perf.rs index f662a8c873..269c6d2ed1 100644 --- a/scripts/axbuild/src/starry/perf.rs +++ b/scripts/axbuild/src/starry/perf.rs @@ -1,24 +1,33 @@ use std::{ env, - fmt::Write as _, + ffi::OsString, fs, fs::File, - io::{BufRead, BufReader, Write}, + io::{BufRead, BufReader, Read, Write}, path::{Path, PathBuf}, process::{Command, ExitStatus, Stdio}, + sync::mpsc, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use anyhow::{Context, bail}; use object::{Object, ObjectSection}; +use ostool::build::config::Cargo; use serde::{Deserialize, Serialize}; -use super::{ArgsBuild, ArgsPerf, PerfFormat, Starry, build, rootfs}; +use super::{ + ArgsBuild, ArgsPerf, PerfCallchain, PerfFlamegraphKind, PerfFormat, Starry, build, rootfs, +}; use crate::{ context::{SnapshotPersistence, StarryCliArgs, starry_target_for_arch_checked}, support::process::ProcessExt, }; const QPERF_QUEUE_SIZE: usize = 4096; +const DEFAULT_STARRY_SHELL_PREFIX: &str = "root@starry:"; +const HARNESS_KIT_REPO: &str = "https://github.com/cg24-THU/tgoskit-harness_kit.git"; +const HARNESS_KIT_COMMIT: &str = "762c22725024a065e85b26e0b01121eccea651c0"; #[derive(Deserialize, Serialize)] struct PerfQemuConfig { @@ -30,6 +39,9 @@ struct PerfQemuConfig { shell_prefix: Option, shell_init_cmd: Option, timeout: Option, + start_marker: Option, + stop_marker: Option, + workload_timeout: Option, } struct QperfTools { @@ -38,12 +50,105 @@ struct QperfTools { } struct PerfOutputs { + work_dir: PathBuf, dir: PathBuf, raw: PathBuf, folded: PathBuf, flamegraph: PathBuf, + folded_boot: PathBuf, + flamegraph_boot: PathBuf, + folded_workload: PathBuf, + flamegraph_workload: PathBuf, + folded_post: PathBuf, + flamegraph_post: PathBuf, + folded_focus: PathBuf, + flamegraph_focus: PathBuf, + stack_depth_summary: PathBuf, + flamegraph_html: PathBuf, summary: PathBuf, qemu_config: PathBuf, + host_time: PathBuf, + host_perf: PathBuf, + resolve_stats: PathBuf, + window: PathBuf, + qmp_socket: PathBuf, + profile_stdout: PathBuf, + profile_stderr: PathBuf, + report_json: PathBuf, + report_md: PathBuf, + hotspots_csv: PathBuf, + hotspot_categories_csv: PathBuf, +} + +#[derive(Default, Serialize)] +struct PerfWindowReport { + enabled: bool, + start_marker: Option, + stop_marker: Option, + start_time: Option, + stop_time: Option, + duration_sec: Option, + workload_timeout: Option, + truncated_by_timeout: bool, + boot_samples_excluded: Option, + stop_requested: bool, + stop_method: Option, + warnings: Vec, + method: String, +} + +struct QemuRun { + status: ExitStatus, + window: PerfWindowReport, +} + +#[derive(Clone, Copy, Default)] +struct ChildResourceUsage { + user_micros: i128, + system_micros: i128, + major_faults: i128, + minor_faults: i128, + voluntary_context_switches: i128, + involuntary_context_switches: i128, +} + +impl ChildResourceUsage { + fn delta_since(self, before: Self) -> Self { + Self { + user_micros: nonnegative_delta(self.user_micros, before.user_micros), + system_micros: nonnegative_delta(self.system_micros, before.system_micros), + major_faults: nonnegative_delta(self.major_faults, before.major_faults), + minor_faults: nonnegative_delta(self.minor_faults, before.minor_faults), + voluntary_context_switches: nonnegative_delta( + self.voluntary_context_switches, + before.voluntary_context_switches, + ), + involuntary_context_switches: nonnegative_delta( + self.involuntary_context_switches, + before.involuntary_context_switches, + ), + } + } + + fn user_seconds(self) -> f64 { + self.user_micros as f64 / 1_000_000.0 + } + + fn system_seconds(self) -> f64 { + self.system_micros as f64 / 1_000_000.0 + } +} + +#[derive(Clone, Copy)] +struct AddressRange { + start: u64, + end: u64, +} + +#[derive(Clone, Copy)] +struct KernelTextRange { + virt: AddressRange, + phys: Option, } pub(super) async fn run(starry: &mut Starry, args: ArgsPerf) -> anyhow::Result<()> { @@ -53,16 +158,30 @@ pub(super) async fn run(starry: &mut Starry, args: ArgsPerf) -> anyhow::Result<( .clone() .unwrap_or_else(|| crate::context::DEFAULT_STARRY_ARCH.to_string()); let target = starry_target_for_arch_checked(&arch)?.to_string(); - let outputs = prepare_outputs(starry.app.workspace_root(), &arch, args.out.as_deref())?; + let outputs = prepare_outputs( + starry.app.workspace_root(), + &arch, + &args.case, + args.out.as_deref(), + args.output_dir.as_deref(), + )?; + let _axbuild_tmp_dir = set_env_if_missing( + "AXBUILD_TMP_DIR", + outputs.work_dir.join("axbuild-tmp").into_os_string(), + )?; + let _cross_cc_env = prepare_cross_c_compiler_fallback(&outputs.work_dir, &arch)?; + let generate_svg = args.flamegraph + || matches!(args.format, PerfFormat::Svg | PerfFormat::All) + && !matches!(args.flamegraph_kind, PerfFlamegraphKind::Folded); - let tools = build_qperf_tools(starry.app.workspace_root())?; + let tools = build_qperf_tools(starry.app.workspace_root(), generate_svg)?; let build_args = ArgsBuild { config: None, arch: Some(arch.clone()), target: None, smp: args.smp, - debug: true, + debug: args.debug, }; let request = starry.prepare_request( StarryCliArgs::from(&build_args), @@ -71,39 +190,64 @@ pub(super) async fn run(starry: &mut Starry, args: ArgsPerf) -> anyhow::Result<( SnapshotPersistence::Store, )?; - let cargo = build::load_cargo_config(&request)?; - starry.app.set_debug_mode(true)?; + let mut cargo = build::load_cargo_config(&request)?; + apply_perf_cargo_features(&mut cargo, &args); + starry.app.set_debug_mode(args.debug)?; starry .app .build(cargo, request.build_info_path.clone()) .await?; rootfs::ensure_qemu_rootfs_ready(&request, starry.app.workspace_root(), None).await?; - let cargo = build::load_cargo_config(&request)?; + let mut cargo = build::load_cargo_config(&request)?; + apply_perf_cargo_features(&mut cargo, &args); let qemu = rootfs::load_patched_qemu_config(starry, &request, &cargo, None, true).await?; + let elf = kernel_elf_path(starry.app.workspace_root(), &target, args.debug); + let axconfig_path = cargo.env.get("AX_CONFIG_PATH").map(PathBuf::from); + let text_range = detect_kernel_text_range(&elf, axconfig_path.as_deref())?; + write_qemu_config(&outputs, &tools, &args, &arch, qemu.args, text_range)?; - let elf = kernel_elf_path(starry.app.workspace_root(), &target); - let text_range = detect_kernel_text_range(&elf)?; - write_qemu_config(&outputs, &tools, &args, qemu.args, text_range)?; - - let kernel_bin = kernel_bin_path(starry.app.workspace_root(), &target); - let qemu_status = run_qemu_direct(&outputs, &args, &arch, &kernel_bin)?; - if !qemu_status.success() { + let kernel_bin = kernel_bin_path(starry.app.workspace_root(), &target, args.debug); + let qemu_run = run_qemu_direct(&outputs, &args, &arch, &kernel_bin)?; + if !qemu_run.status.success() { if !file_nonempty(&outputs.raw) { - bail!("qperf QEMU run failed before producing samples: {qemu_status}"); + bail!( + "qperf QEMU run failed before producing samples: {}", + qemu_run.status + ); } - eprintln!("qperf: QEMU ended with {qemu_status} after producing samples"); + eprintln!( + "qperf: QEMU ended with {} after producing samples", + qemu_run.status + ); } - let generate_svg = matches!(args.format, PerfFormat::Svg | PerfFormat::All); - run_analyzer( - &tools.analyzer, + run_analyzer(AnalyzerRun { + analyzer: &tools.analyzer, + elf: &elf, + raw: &outputs.raw, + folded: &outputs.folded, + flamegraph: &outputs.flamegraph, + resolve_stats: &outputs.resolve_stats, + depth_summary: Some(&outputs.stack_depth_summary), + generate_svg, + top_n: args.top, + start_sec: qemu_run.window.start_time, + stop_sec: qemu_run.window.stop_time, + symbol_style: args.symbol_style.to_string(), + demangle: true, + focus: None, + min_percent: flamegraph_min_percent(&args), + })?; + + generate_phase_flamegraphs( + &tools, &elf, - &outputs.raw, - &outputs.folded, - &outputs.flamegraph, + &outputs, + &args, + &qemu_run.window, generate_svg, - args.top, )?; + generate_focus_flamegraph(&tools, &elf, &outputs, &args, generate_svg)?; let flamegraph_generated = if generate_svg && !file_nonempty(&outputs.flamegraph) { try_generate_flamegraph(&outputs.folded, &outputs.flamegraph)? @@ -111,19 +255,71 @@ pub(super) async fn run(starry: &mut Starry, args: ArgsPerf) -> anyhow::Result<( generate_svg && file_nonempty(&outputs.flamegraph) }; - write_summary( + write_summary(SummaryInputs { + outputs: &outputs, + tools: &tools, + elf: &elf, + arch: &arch, + target: &target, + args: &args, + flamegraph_generated, + window: &qemu_run.window, + })?; + write_flamegraph_html(&outputs, args.flamegraph_kind, flamegraph_generated)?; + let report_harness = run_report_postprocess( + starry.app.workspace_root(), &outputs, - &tools, - &elf, - &arch, - &target, &args, - flamegraph_generated, + &arch, + exit_status_code(&qemu_run.status), )?; - print_report(&outputs, flamegraph_generated); + print_report(&outputs, &args, &report_harness); Ok(()) } +fn apply_perf_cargo_features(cargo: &mut Cargo, args: &ArgsPerf) { + cargo.features.extend([ + "ax-driver/virtio-blk".to_string(), + "ax-driver/virtio-net".to_string(), + "ax-driver/virtio-socket".to_string(), + ]); + if args.qperf_metrics { + cargo.features.push("qperf-metrics".to_string()); + } + cargo.features.sort(); + cargo.features.dedup(); + if perf_needs_debuginfo(args) { + cargo.env.insert("DWARF".to_string(), "y".to_string()); + } + if perf_needs_frame_pointers(args) { + cargo.env.insert("BACKTRACE".to_string(), "y".to_string()); + } + apply_perf_rustflags(cargo, args); +} + +fn apply_perf_rustflags(cargo: &mut Cargo, args: &ArgsPerf) { + let mut flags = Vec::new(); + if perf_needs_debuginfo(args) { + flags.push("-Cdebuginfo=2".to_string()); + flags.push("-Cstrip=none".to_string()); + } + if perf_needs_frame_pointers(args) { + flags.push("-Cforce-frame-pointers=yes".to_string()); + } + if flags.is_empty() { + return; + } + + cargo + .env + .insert("CARGO_ENCODED_RUSTFLAGS".to_string(), flags.join("\x1f")); + cargo.args.push("--config".to_string()); + let rustflags = toml::Value::Array(flags.into_iter().map(toml::Value::String).collect()); + cargo + .args + .push(format!("target.'{}'.rustflags={rustflags}", cargo.target)); +} + fn validate_args(args: &ArgsPerf) -> anyhow::Result<()> { if args.freq == 0 { bail!("--freq must be greater than 0"); @@ -131,37 +327,659 @@ fn validate_args(args: &ArgsPerf) -> anyhow::Result<()> { if args.max_depth == 0 { bail!("--max-depth must be greater than 0"); } + if args.min_percent < 0.0 { + bail!("--min-percent must be non-negative"); + } if matches!(args.format, PerfFormat::Pprof) { bail!("--format pprof is not supported yet; use --format folded, svg, or all"); } + if args + .shell_init_cmd + .as_deref() + .is_some_and(|cmd| cmd.trim().is_empty()) + { + bail!("--shell-init-cmd must not be empty"); + } + if args + .shell_prefix + .as_deref() + .is_some_and(|prefix| prefix.is_empty()) + { + bail!("--shell-prefix must not be empty"); + } + if args.host_perf && args.host_perf_events.trim().is_empty() { + bail!("--host-perf-events must not be empty when --host-perf is set"); + } + if matches!(effective_callchain(args), PerfCallchain::Logical) { + bail!( + "--perf-callchain logical is not implemented yet; use --perf-callchain fp or \ + --full-stack for frame-pointer unwinding" + ); + } + if args.include_user_symbols { + eprintln!( + "qperf: --include-user-symbols requested, but current analyzer resolves only the \ + StarryOS kernel ELF; user symbols will remain unresolved unless they are present in \ + the kernel image" + ); + } + if args + .start_marker + .as_deref() + .is_some_and(|marker| marker.trim().is_empty()) + { + bail!("--start-marker must not be empty"); + } + if args + .stop_marker + .as_deref() + .is_some_and(|marker| marker.trim().is_empty()) + { + bail!("--stop-marker must not be empty"); + } + if args.workload_timeout == Some(0) { + bail!("--workload-timeout must be greater than 0"); + } Ok(()) } -fn prepare_outputs(root: &Path, arch: &str, out: Option<&Path>) -> anyhow::Result { - let dir = out.map(PathBuf::from).unwrap_or_else(|| { - root.join("target") - .join("qperf") - .join(arch) - .join(chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string()) - }); +fn host_time_enabled(args: &ArgsPerf) -> bool { + args.host_time || !args.no_host_time +} + +fn flamegraph_min_percent(args: &ArgsPerf) -> f64 { + if args.no_truncate { + 0.0 + } else { + args.min_percent + } +} + +fn effective_max_depth(args: &ArgsPerf) -> usize { + if args.full_stack { + args.max_depth.max(256) + } else { + args.max_depth + } +} + +fn effective_callchain(args: &ArgsPerf) -> PerfCallchain { + if args.full_stack { + PerfCallchain::Fp + } else { + args.callchain.unwrap_or(PerfCallchain::Leaf) + } +} + +fn perf_needs_debuginfo(args: &ArgsPerf) -> bool { + args.full_stack || args.debuginfo +} + +fn perf_needs_frame_pointers(args: &ArgsPerf) -> bool { + args.full_stack + || args.force_frame_pointers + || matches!(effective_callchain(args), PerfCallchain::Fp) +} + +struct ScopedEnvVar { + key: &'static str, + previous: Option, + active: bool, +} + +impl Drop for ScopedEnvVar { + fn drop(&mut self) { + if !self.active { + return; + } + match &self.previous { + Some(value) => { + // SAFETY: qperf runs this CLI flow serially and restores the process + // environment before returning to the caller. + unsafe { env::set_var(self.key, value) }; + } + None => { + // SAFETY: qperf runs this CLI flow serially and restores the process + // environment before returning to the caller. + unsafe { env::remove_var(self.key) }; + } + } + } +} + +fn set_env_if_missing(key: &'static str, value: OsString) -> anyhow::Result { + let previous = env::var_os(key); + if previous.as_ref().is_some_and(|value| !value.is_empty()) { + return Ok(ScopedEnvVar { + key, + previous, + active: false, + }); + } + let path = PathBuf::from(&value); + fs::create_dir_all(&path) + .with_context(|| format!("failed to create {key} directory {}", path.display()))?; + // SAFETY: qperf runs this CLI flow serially before spawning worker threads that depend on + // axbuild paths. + unsafe { env::set_var(key, &value) }; + Ok(ScopedEnvVar { + key, + previous, + active: true, + }) +} + +fn set_env_var(key: &'static str, value: OsString) -> ScopedEnvVar { + let previous = env::var_os(key); + // SAFETY: qperf runs this CLI flow serially before spawning child processes + // that depend on the adjusted environment. + unsafe { env::set_var(key, &value) }; + ScopedEnvVar { + key, + previous, + active: true, + } +} + +fn prepare_cross_c_compiler_fallback( + work_dir: &Path, + arch: &str, +) -> anyhow::Result> { + let (compiler, zig_target, zig_include_dirs) = match arch { + "riscv64" => ( + "riscv64-linux-musl-gcc", + "riscv64-linux-musl", + [ + "libc/include/riscv64-linux-musl", + "libc/include/generic-musl", + "libc/include/riscv-linux-any", + "libc/include/any-linux-any", + "include", + ], + ), + _ => return Ok(Vec::new()), + }; + + if cross_c_compiler_works(compiler) { + return Ok(Vec::new()); + } + + let zig = find_executable("zig").ok_or_else(|| { + anyhow::anyhow!( + "StarryOS qperf {arch} build requires `{compiler}` in PATH; install a riscv64 musl C \ + compiler or install `zig` so starry perf can create a local cross-cc wrapper" + ) + })?; + let wrapper_root = work_dir.join("cross-cc"); + let bin_dir = wrapper_root.join("bin"); + let sysroot = wrapper_root.join(format!("{zig_target}-sysroot")); + let zig_lib = zig_lib_dir(&zig)?; + prepare_zig_sysroot(&sysroot, &zig_lib, &zig_include_dirs)?; + fs::create_dir_all(&bin_dir).with_context(|| { + format!( + "failed to create cross-cc bin directory {}", + bin_dir.display() + ) + })?; + let wrapper = bin_dir.join(compiler); + write_zig_cc_wrapper(&wrapper, &zig, zig_target, &sysroot)?; + let ar_wrapper = bin_dir.join("riscv64-linux-musl-ar"); + let ranlib_wrapper = bin_dir.join("riscv64-linux-musl-ranlib"); + write_zig_tool_wrapper(&ar_wrapper, &zig, "ar")?; + write_zig_tool_wrapper(&ranlib_wrapper, &zig, "ranlib")?; + + let mut paths = vec![bin_dir]; + if let Some(current) = env::var_os("PATH") { + paths.extend(env::split_paths(¤t)); + } + let path = + env::join_paths(paths).context("failed to prepend qperf cross-cc wrapper to PATH")?; + eprintln!( + "qperf: `{compiler}` missing or unusable; using Zig cross C wrapper at {}", + wrapper.display() + ); + let bindgen_args = bindgen_extra_clang_args(&zig_lib, &zig_include_dirs, zig_target); + Ok(vec![ + set_env_var("PATH", path), + set_env_var( + "BINDGEN_EXTRA_CLANG_ARGS_riscv64-unknown-linux-musl", + bindgen_args.clone().into(), + ), + set_env_var( + "BINDGEN_EXTRA_CLANG_ARGS_riscv64_unknown_linux_musl", + bindgen_args.clone().into(), + ), + set_env_var( + "BINDGEN_EXTRA_CLANG_ARGS", + append_env_words("BINDGEN_EXTRA_CLANG_ARGS", &bindgen_args), + ), + set_env_var( + "AR_riscv64gc_unknown_none_elf", + ar_wrapper.as_os_str().to_os_string(), + ), + set_env_var( + "AR_riscv64gc-unknown-none-elf", + ar_wrapper.as_os_str().to_os_string(), + ), + set_env_var( + "RANLIB_riscv64gc_unknown_none_elf", + ranlib_wrapper.as_os_str().to_os_string(), + ), + set_env_var( + "RANLIB_riscv64gc-unknown-none-elf", + ranlib_wrapper.as_os_str().to_os_string(), + ), + ]) +} + +fn cross_c_compiler_works(compiler: &str) -> bool { + if !compiler_command_succeeds(compiler, &["-print-sysroot"]) { + return false; + } + + compiler_command_succeeds(compiler, &["-E", "-x", "c", "-"]) +} + +fn compiler_command_succeeds(compiler: &str, args: &[&str]) -> bool { + Command::new(compiler) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::{cross_c_compiler_works, prepare_outputs}; + + #[cfg(unix)] + #[test] + fn cross_c_compiler_probe_does_not_require_clang_target_arg() { + use std::{fs, os::unix::fs::PermissionsExt}; + + let temp = tempfile::tempdir().unwrap(); + let compiler = temp.path().join("riscv64-linux-musl-gcc"); + fs::write( + &compiler, + r#"#!/bin/sh +for arg in "$@"; do + case "$arg" in + --target|--target=*) + exit 42 + ;; + esac +done + +if [ "$1" = "-print-sysroot" ]; then + printf '%s\n' /fake/sysroot + exit 0 +fi + +case " $* " in + *" -E -x c - "*) + cat >/dev/null + exit 0 + ;; +esac + +exit 1 +"#, + ) + .unwrap(); + let mut permissions = fs::metadata(&compiler).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&compiler, permissions).unwrap(); + + assert!(cross_c_compiler_works(compiler.to_str().unwrap())); + } + + #[test] + fn prepare_outputs_roots_relative_out_path() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let outputs = prepare_outputs( + root, + "riscv64", + "boot", + Some(Path::new("target/qperf-test")), + None, + ) + .unwrap(); + + assert!(outputs.work_dir.is_absolute()); + assert_eq!(outputs.work_dir, root.join("target")); + assert_eq!(outputs.dir, root.join("target/qperf-test")); + assert!(outputs.qmp_socket.is_absolute()); + assert!(outputs.qmp_socket.display().to_string().len() < 100); + } + + #[test] + fn prepare_outputs_roots_relative_output_dir() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let outputs = prepare_outputs( + root, + "riscv64", + "boot", + None, + Some(Path::new("target/qperf-root")), + ) + .unwrap(); + + assert!(outputs.work_dir.is_absolute()); + assert_eq!( + outputs.work_dir, + root.join("target/qperf-root/perf/riscv64/latest") + ); + assert_eq!( + outputs.dir, + root.join("target/qperf-root/perf/riscv64/latest/qperf") + ); + } +} + +fn zig_lib_dir(zig: &Path) -> anyhow::Result { + let output = Command::new(zig) + .arg("env") + .output() + .with_context(|| format!("failed to run {} env", zig.display()))?; + if !output.status.success() { + bail!("{} env failed with {}", zig.display(), output.status); + } + let text = String::from_utf8(output.stdout).context("zig env output was not UTF-8")?; + if let Ok(value) = serde_json::from_str::(&text) + && let Some(lib_dir) = value.get("lib_dir").and_then(|value| value.as_str()) + { + return Ok(PathBuf::from(lib_dir)); + } + for line in text.lines().map(str::trim) { + if let Some(value) = line.strip_prefix(".lib_dir = ") { + let lib_dir = value.trim_end_matches(',').trim().trim_matches('"'); + if !lib_dir.is_empty() { + return Ok(PathBuf::from(lib_dir)); + } + } + } + bail!("zig env did not report lib_dir") +} + +fn prepare_zig_sysroot( + sysroot: &Path, + zig_lib: &Path, + include_dirs: &[&str], +) -> anyhow::Result<()> { + let include = sysroot.join("include"); + fs::create_dir_all(&include) + .with_context(|| format!("failed to create Zig sysroot include {}", include.display()))?; + for dir in include_dirs { + let source = zig_lib.join(dir); + if source.exists() { + merge_include_dir(&source, &include)?; + } + } + Ok(()) +} + +fn bindgen_extra_clang_args(zig_lib: &Path, include_dirs: &[&str], target: &str) -> String { + let mut args = vec![format!("--target={target}")]; + for dir in include_dirs { + let include = zig_lib.join(dir); + if include.exists() { + args.push("-isystem".to_string()); + args.push(include.display().to_string()); + } + } + args.join(" ") +} + +fn append_env_words(key: &str, prefix: &str) -> OsString { + match env::var_os(key) { + Some(current) if !current.is_empty() => { + let mut value = OsString::from(prefix); + value.push(" "); + value.push(current); + value + } + _ => OsString::from(prefix), + } +} + +fn merge_include_dir(source: &Path, dest: &Path) -> anyhow::Result<()> { + for entry in fs::read_dir(source) + .with_context(|| format!("failed to read Zig include directory {}", source.display()))? + { + let entry = entry + .with_context(|| format!("failed to read Zig include entry {}", source.display()))?; + let source_path = entry.path(); + let dest_path = dest.join(entry.file_name()); + let metadata = entry.metadata().with_context(|| { + format!("failed to stat Zig include entry {}", source_path.display()) + })?; + if metadata.is_dir() { + fs::create_dir_all(&dest_path).with_context(|| { + format!( + "failed to create Zig include directory {}", + dest_path.display() + ) + })?; + merge_include_dir(&source_path, &dest_path)?; + } else if !dest_path.exists() { + symlink_file(&source_path, &dest_path)?; + } + } + Ok(()) +} + +#[cfg(unix)] +fn symlink_file(source: &Path, dest: &Path) -> anyhow::Result<()> { + std::os::unix::fs::symlink(source, dest).with_context(|| { + format!( + "failed to symlink Zig header {} -> {}", + dest.display(), + source.display() + ) + }) +} + +#[cfg(not(unix))] +fn symlink_file(source: &Path, dest: &Path) -> anyhow::Result<()> { + fs::copy(source, dest).with_context(|| { + format!( + "failed to copy Zig header {} -> {}", + source.display(), + dest.display() + ) + })?; + Ok(()) +} + +fn write_zig_cc_wrapper( + wrapper: &Path, + zig: &Path, + target: &str, + sysroot: &Path, +) -> anyhow::Result<()> { + let script = format!( + r#"#!/usr/bin/env bash +set -euo pipefail +zig_bin="${{ZIG:-{zig}}}" +target="{target}" +sysroot="{sysroot}" +if [[ "$#" -eq 1 && "$1" == "-print-sysroot" ]]; then + printf '%s\n' "$sysroot" + exit 0 +fi +args=() +skip_next=0 +for arg in "$@"; do + if [[ "$skip_next" -eq 1 ]]; then + skip_next=0 + continue + fi + case "$arg" in + --target) + skip_next=1 + ;; + --target=riscv64|--target=riscv64gc|--target=riscv64-unknown-none-elf|--target=riscv64gc-unknown-none-elf|-march=rv64gc|-mabi=lp64d) + ;; + *) + args+=("$arg") + ;; + esac +done +exec "$zig_bin" cc -target "$target" "${{args[@]}}" +"#, + zig = zig.display(), + target = target, + sysroot = sysroot.display(), + ); + fs::write(wrapper, script).with_context(|| { + format!( + "failed to write Zig C compiler wrapper {}", + wrapper.display() + ) + })?; + set_executable(wrapper)?; + Ok(()) +} + +fn write_zig_tool_wrapper(wrapper: &Path, zig: &Path, tool: &str) -> anyhow::Result<()> { + let script = format!( + r#"#!/usr/bin/env bash +set -euo pipefail +zig_bin="${{ZIG:-{zig}}}" +exec "$zig_bin" {tool} "$@" +"#, + zig = zig.display(), + tool = tool, + ); + fs::write(wrapper, script) + .with_context(|| format!("failed to write Zig tool wrapper {}", wrapper.display()))?; + set_executable(wrapper)?; + Ok(()) +} + +#[cfg(unix)] +fn set_executable(path: &Path) -> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt; + + let mut permissions = fs::metadata(path) + .with_context(|| format!("failed to stat {}", path.display()))? + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions) + .with_context(|| format!("failed to chmod +x {}", path.display())) +} + +#[cfg(not(unix))] +fn set_executable(_path: &Path) -> anyhow::Result<()> { + Ok(()) +} + +fn prepare_outputs( + root: &Path, + arch: &str, + case: &str, + out: Option<&Path>, + output_dir: Option<&Path>, +) -> anyhow::Result { + let (work_dir, dir) = if let Some(out) = out { + let dir = rooted_output_path(root, out); + let work_dir = dir + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| dir.clone()); + (work_dir, dir) + } else { + let output_root = output_dir + .map(|path| rooted_output_path(root, path)) + .unwrap_or_else(|| root.join("target").join("qperf").join(case)); + let work_dir = output_root.join("perf").join(arch).join("latest"); + let dir = work_dir.join("qperf"); + (work_dir, dir) + }; + if out.is_none() && work_dir.exists() { + fs::remove_dir_all(&work_dir).with_context(|| { + format!( + "failed to remove previous qperf output directory {}", + work_dir.display() + ) + })?; + } fs::create_dir_all(&dir) .with_context(|| format!("failed to create qperf output directory {}", dir.display()))?; + fs::create_dir_all(&work_dir).with_context(|| { + format!( + "failed to create qperf work directory {}", + work_dir.display() + ) + })?; Ok(PerfOutputs { + work_dir: work_dir.clone(), raw: dir.join("qperf.bin"), folded: dir.join("stack.folded"), flamegraph: dir.join("flamegraph.svg"), + folded_boot: dir.join("stack.boot.folded"), + flamegraph_boot: dir.join("flamegraph.boot.svg"), + folded_workload: dir.join("stack.workload.folded"), + flamegraph_workload: dir.join("flamegraph.workload.svg"), + folded_post: dir.join("stack.post.folded"), + flamegraph_post: dir.join("flamegraph.post.svg"), + folded_focus: dir.join("stack.focus.folded"), + flamegraph_focus: dir.join("flamegraph.focus.svg"), + stack_depth_summary: dir.join("stack-depth-summary.csv"), + flamegraph_html: dir.join("flamegraph.html"), summary: dir.join("summary.txt"), qemu_config: dir.join("qemu.toml"), + host_time: dir.join("qemu.time.txt"), + host_perf: dir.join("qemu.perf.csv"), + resolve_stats: dir.join("resolve.stats.json"), + window: dir.join("window.json"), + qmp_socket: short_qmp_socket_path(), + profile_stdout: work_dir.join("profile.stdout"), + profile_stderr: work_dir.join("profile.stderr"), + report_json: work_dir.join("report.json"), + report_md: work_dir.join("report.md"), + hotspots_csv: work_dir.join("hotspots.csv"), + hotspot_categories_csv: work_dir.join("hotspot_categories.csv"), dir, }) } -fn build_qperf_tools(root: &Path) -> anyhow::Result { - let manifest = root.join("tools/qperf/Cargo.toml"); - if !manifest.exists() { +fn short_qmp_socket_path() -> PathBuf { + let base = if Path::new("/tmp").is_dir() { + PathBuf::from("/tmp") + } else { + env::temp_dir() + }; + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + base.join(format!("tgos-qperf-{}-{nonce}.sock", std::process::id())) +} + +fn rooted_output_path(root: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + root.join(path) + } +} + +fn build_qperf_tools(root: &Path, analyzer_flamegraph: bool) -> anyhow::Result { + let qperf_root = qperf_source_root(root)?; + let manifest = qperf_root.join("Cargo.toml"); + let analyzer_manifest = qperf_root.join("analyzer/Cargo.toml"); + let target_dir = qperf_root.join("target"); + if !analyzer_manifest.exists() { bail!( - "qperf sources not found at {}; expected tools/qperf to be present", - manifest.display() + "qperf analyzer sources not found at {}", + analyzer_manifest.display() ); } @@ -169,23 +987,35 @@ fn build_qperf_tools(root: &Path) -> anyhow::Result { .current_dir(root) .args(["build", "--manifest-path"]) .arg(&manifest) - .args(["--release", "--target-dir"]) - .arg(root.join("tools/qperf/target")) + .arg("--release") + .arg("--target-dir") + .arg(&target_dir) .exec() .context("failed to build qperf plugin")?; - Command::new("cargo") + let mut analyzer_build = Command::new("cargo"); + analyzer_build .current_dir(root) .args(["build", "--manifest-path"]) - .arg(&manifest) - .args(["--release", "-p", "qperf-analyzer", "--target-dir"]) - .arg(root.join("tools/qperf/target")) + .arg(&analyzer_manifest) + .arg("--release") + .arg("--target-dir") + .arg(&target_dir); + if analyzer_flamegraph { + analyzer_build.args(["--features", "flamegraph"]); + } + analyzer_build .exec() .context("failed to build qperf-analyzer")?; - let release_dir = root.join("tools/qperf/target/release"); + let release_dir = target_dir.join("release"); + let plugin_name = if cfg!(target_os = "macos") { + "libqperf.dylib" + } else { + "libqperf.so" + }; let tools = QperfTools { - plugin: release_dir.join("libqperf.so"), + plugin: release_dir.join(plugin_name), analyzer: release_dir.join("qperf-analyzer"), }; ensure_file(&tools.plugin, "qperf plugin")?; @@ -193,116 +1023,1177 @@ fn build_qperf_tools(root: &Path) -> anyhow::Result { Ok(tools) } +fn qperf_source_root(root: &Path) -> anyhow::Result { + if let Some(path) = [root.join("apps/qperf"), root.join("tools/qperf")] + .into_iter() + .find(|path| path.join("Cargo.toml").exists()) + { + return Ok(path); + } + + let checkout = ensure_harness_kit_checkout(root)?; + let fixed_qperf = checkout.join("tools/qperf"); + if fixed_qperf.join("Cargo.toml").exists() { + return Ok(fixed_qperf); + } + + Err(anyhow::anyhow!( + "qperf sources not found; expected apps/qperf, tools/qperf, or fixed harness kit \ + tools/qperf to be present" + )) +} + +fn ensure_harness_kit_checkout(root: &Path) -> anyhow::Result { + if let Some(checkout) = env::var_os("TGOSKIT_HARNESS_KIT_DIR").map(PathBuf::from) { + validate_harness_kit_override(&checkout)?; + return Ok(checkout); + } + + let checkout = root + .join("target/tgoskit-harness-kit") + .join(HARNESS_KIT_COMMIT); + if checkout.join(".git").is_dir() { + let actual = git_stdout(Some(&checkout), &["rev-parse", "HEAD"])?; + if actual == HARNESS_KIT_COMMIT { + return Ok(checkout); + } + git_status( + Some(&checkout), + &["fetch", "--depth", "1", "origin", HARNESS_KIT_COMMIT], + )?; + git_status( + Some(&checkout), + &["checkout", "--detach", HARNESS_KIT_COMMIT], + )?; + git_status(Some(&checkout), &["reset", "--hard", HARNESS_KIT_COMMIT])?; + return Ok(checkout); + } + + let parent = checkout.parent().ok_or_else(|| { + anyhow::anyhow!("invalid harness kit checkout path {}", checkout.display()) + })?; + fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; + let tmp_name = format!( + "{}.tmp-{}", + checkout + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("harness-kit"), + std::process::id() + ); + let tmp_checkout = checkout.with_file_name(tmp_name); + if tmp_checkout.exists() { + fs::remove_dir_all(&tmp_checkout) + .with_context(|| format!("failed to remove {}", tmp_checkout.display()))?; + } + + let clone_result = (|| -> anyhow::Result<()> { + git_status(None, &["init", "-q", path_str(&tmp_checkout)?])?; + git_status( + Some(&tmp_checkout), + &["remote", "add", "origin", HARNESS_KIT_REPO], + )?; + git_status( + Some(&tmp_checkout), + &["fetch", "--depth", "1", "origin", HARNESS_KIT_COMMIT], + )?; + git_status(Some(&tmp_checkout), &["checkout", "--detach", "FETCH_HEAD"])?; + let actual = git_stdout(Some(&tmp_checkout), &["rev-parse", "HEAD"])?; + if actual != HARNESS_KIT_COMMIT { + bail!("fetched harness kit {actual}, expected {HARNESS_KIT_COMMIT}"); + } + Ok(()) + })(); + + if clone_result.is_err() { + let _ = fs::remove_dir_all(&tmp_checkout); + } + clone_result?; + if checkout.exists() { + fs::remove_dir_all(&checkout) + .with_context(|| format!("failed to remove {}", checkout.display()))?; + } + fs::rename(&tmp_checkout, &checkout).with_context(|| { + format!( + "failed to move {} to {}", + tmp_checkout.display(), + checkout.display() + ) + })?; + Ok(checkout) +} + +fn validate_harness_kit_override(checkout: &Path) -> anyhow::Result<()> { + ensure_file( + &checkout.join("tools/qperf/Cargo.toml"), + "TGOSKIT_HARNESS_KIT_DIR qperf manifest", + )?; + ensure_file( + &checkout.join("tools/qperf/analyzer/Cargo.toml"), + "TGOSKIT_HARNESS_KIT_DIR qperf analyzer manifest", + )?; + ensure_file( + &checkout.join("tools/starry-syscall-harness/harness.py"), + "TGOSKIT_HARNESS_KIT_DIR harness script", + )?; + + if !checkout.join(".git").is_dir() { + bail!( + "TGOSKIT_HARNESS_KIT_DIR={} is not a git checkout; cannot verify pinned harness kit \ + commit {}. Use a read-only git checkout at the pinned commit, or unset the variable \ + to let xtask manage target/tgoskit-harness-kit", + checkout.display(), + HARNESS_KIT_COMMIT + ); + } + + let actual = git_stdout(Some(checkout), &["rev-parse", "HEAD"])?; + if actual != HARNESS_KIT_COMMIT { + bail!( + "TGOSKIT_HARNESS_KIT_DIR={} is at commit {}, expected {}; the override path is \ + read-only and will not be fetched, reset, or replaced", + checkout.display(), + actual, + HARNESS_KIT_COMMIT + ); + } + Ok(()) +} + +fn git_status(cwd: Option<&Path>, args: &[&str]) -> anyhow::Result<()> { + let mut command = Command::new("git"); + if let Some(cwd) = cwd { + command.arg("-C").arg(cwd); + } + let status = command + .args(args) + .status() + .with_context(|| format!("failed to run git {}", args.join(" ")))?; + if !status.success() { + bail!("git {} failed with {status}", args.join(" ")); + } + Ok(()) +} + +fn git_stdout(cwd: Option<&Path>, args: &[&str]) -> anyhow::Result { + let mut command = Command::new("git"); + if let Some(cwd) = cwd { + command.arg("-C").arg(cwd); + } + let output = command + .args(args) + .output() + .with_context(|| format!("failed to run git {}", args.join(" ")))?; + if !output.status.success() { + bail!("git {} failed with {}", args.join(" "), output.status); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn path_str(path: &Path) -> anyhow::Result<&str> { + path.to_str() + .ok_or_else(|| anyhow::anyhow!("path is not valid UTF-8: {}", path.display())) +} + fn write_qemu_config( outputs: &PerfOutputs, tools: &QperfTools, args: &ArgsPerf, + arch: &str, qemu_args: Vec, - text_range: Option<(u64, u64)>, + text_range: Option, ) -> anyhow::Result<()> { let mut perf_qemu_args = vec!["-plugin".to_string()]; let mut plugin_params = format!( - "{},freq={},max_depth={},queue_size={},mode={},out={}", + "{},freq={},max_depth={},queue_size={},mode={},callchain={},out={}", tools.plugin.display(), args.freq, - args.max_depth, + effective_max_depth(args), QPERF_QUEUE_SIZE, args.mode, + effective_callchain(args), outputs.raw.display() ); - if let Some((start, end)) = text_range { - let _ = write!( - &mut plugin_params, - ",filter_start=0x{start:x},filter_end=0x{end:x}" - ); + plugin_params.push_str(&format!( + ",filter_kernel={}", + if args.kernel_filter { 1 } else { 0 } + )); + if let Some(range) = text_range { + let start = range.virt.start; + let end = range.virt.end; + plugin_params.push_str(&format!(",filter_start=0x{start:x},filter_end=0x{end:x}")); + if let Some(phys) = range.phys { + let offset = range.virt.start.wrapping_sub(phys.start); + plugin_params.push_str(&format!( + ",filter_alias_start=0x{:x},filter_alias_end=0x{:x},filter_alias_offset=0x{:x}", + phys.start, phys.end, offset + )); + } } perf_qemu_args.push(plugin_params); + let mut qemu_args = direct_qemu_args(arch, qemu_args)?; + qemu_args.extend(args.qemu_args.iter().cloned()); + if qemu_stdout_monitor_enabled(args) && !has_qemu_option(&qemu_args, "-qmp") { + qemu_args.extend([ + "-qmp".to_string(), + format!("unix:{},server=on,wait=off", outputs.qmp_socket.display()), + ]); + } perf_qemu_args.extend(qemu_args); + let shell_init_cmd = args + .shell_init_cmd + .as_deref() + .map(str::trim) + .filter(|cmd| !cmd.is_empty()) + .map(str::to_string); + let shell_prefix = shell_init_cmd.as_ref().map(|_| { + args.shell_prefix + .clone() + .unwrap_or_else(|| DEFAULT_STARRY_SHELL_PREFIX.to_string()) + }); + let config = PerfQemuConfig { args: perf_qemu_args, uefi: false, to_bin: true, success_regex: Vec::new(), fail_regex: vec![r"(?i)\bpanic(?:ked)?\b".to_string()], - shell_prefix: None, - shell_init_cmd: None, + shell_prefix, + shell_init_cmd, timeout: (args.timeout > 0).then_some(args.timeout), + start_marker: args.start_marker.clone(), + stop_marker: args.stop_marker.clone(), + workload_timeout: args.workload_timeout, }; fs::write(&outputs.qemu_config, toml::to_string_pretty(&config)?) .with_context(|| format!("failed to write {}", outputs.qemu_config.display()))?; Ok(()) } -fn run_qemu_direct( - outputs: &PerfOutputs, +fn direct_qemu_args(arch: &str, mut args: Vec) -> anyhow::Result> { + match arch { + "riscv64" | "loongarch64" => { + if !has_qemu_option(&args, "-machine") { + args.splice(0..0, ["-machine".to_string(), "virt".to_string()]); + } + } + _ => bail!("qperf currently supports StarryOS riscv64 and loongarch64 only"), + } + Ok(args) +} + +fn has_qemu_option(args: &[String], option: &str) -> bool { + args.iter().any(|arg| arg == option) +} + +fn run_qemu_direct( + outputs: &PerfOutputs, args: &ArgsPerf, arch: &str, kernel_bin: &Path, -) -> anyhow::Result { +) -> anyhow::Result { ensure_file(kernel_bin, "StarryOS kernel image")?; let qemu = qemu_executable(arch)?; - let qemu_args = qemu_args_from_config(&outputs.qemu_config)?; + let config = qemu_config_from_path(&outputs.qemu_config)?; + let qemu_args = config.args.clone(); + let monitor_stdout = qemu_stdout_monitor_enabled(args); - let mut command = if args.timeout > 0 { - let mut command = Command::new("timeout"); - command.arg(format!("{}s", args.timeout)); - command.arg(qemu); - command + let mut command_args = if args.timeout > 0 && !monitor_stdout { + vec![ + "timeout".to_string(), + "--signal=INT".to_string(), + "--kill-after=5s".to_string(), + format!("{}s", args.timeout), + qemu.to_string(), + ] } else { - Command::new(qemu) + vec![qemu.to_string()] }; + command_args.extend(qemu_args); + command_args.push("-kernel".to_string()); + command_args.push(kernel_bin.display().to_string()); + + if args.host_perf { + if let Some(perf) = find_executable("perf") { + let mut wrapped = vec![ + perf.display().to_string(), + "stat".to_string(), + "-x".to_string(), + ",".to_string(), + "-o".to_string(), + outputs.host_perf.display().to_string(), + "-e".to_string(), + args.host_perf_events.clone(), + "--".to_string(), + ]; + wrapped.extend(command_args); + command_args = wrapped; + } else { + write_host_perf_unavailable(&outputs.host_perf, "perf not found in PATH")?; + eprintln!("qperf: --host-perf requested but `perf` was not found in PATH"); + } + } - command.args(qemu_args).arg("-kernel").arg(kernel_bin); + let mut command = Command::new(&command_args[0]); + command.args(&command_args[1..]); eprintln!("running qperf QEMU: {command:?}"); - command.status().context("failed to spawn QEMU") + let host_wall_start = Instant::now(); + let host_usage_start = child_resource_usage(); + let qemu_run = if monitor_stdout { + run_qemu_with_stdout_monitor(command, &config, outputs, args.timeout)? + } else { + QemuRun { + status: command.status().context("failed to spawn QEMU")?, + window: window_report_from_config(&config), + } + }; + if host_time_enabled(args) { + write_host_time_metrics( + &outputs.host_time, + host_wall_start.elapsed(), + host_usage_start, + child_resource_usage(), + &qemu_run.status, + )?; + } + write_window_report(&outputs.window, &qemu_run.window)?; + if !outputs.profile_stdout.exists() { + File::create(&outputs.profile_stdout) + .with_context(|| format!("failed to create {}", outputs.profile_stdout.display()))?; + } + if !outputs.profile_stderr.exists() { + File::create(&outputs.profile_stderr) + .with_context(|| format!("failed to create {}", outputs.profile_stderr.display()))?; + } + Ok(qemu_run) } fn qemu_executable(arch: &str) -> anyhow::Result<&'static str> { - match arch { - "riscv64" => Ok("qemu-system-riscv64"), - "loongarch64" => Ok("qemu-system-loongarch64"), + let name = match arch { + "riscv64" => "qemu-system-riscv64", + "loongarch64" => "qemu-system-loongarch64", _ => bail!("qperf currently supports StarryOS riscv64 and loongarch64 only"), + }; + if find_executable(name).is_none() { + bail!( + "qperf requires `{name}` in PATH; install the matching QEMU system emulator or run \ + the Docker-based harness perf-profile entrypoint" + ); } + Ok(name) } -fn qemu_args_from_config(path: &Path) -> anyhow::Result> { +fn qemu_config_from_path(path: &Path) -> anyhow::Result { let text = fs::read_to_string(path) .with_context(|| format!("failed to read qperf QEMU config {}", path.display()))?; - let config: PerfQemuConfig = toml::from_str(&text) - .with_context(|| format!("failed to parse qperf QEMU config {}", path.display()))?; - Ok(config.args) + toml::from_str(&text) + .with_context(|| format!("failed to parse qperf QEMU config {}", path.display())) } -fn run_analyzer( - analyzer: &Path, - elf: &Path, - raw: &Path, - folded: &Path, - flamegraph: &Path, +fn qemu_stdout_monitor_enabled(args: &ArgsPerf) -> bool { + args.shell_init_cmd + .as_deref() + .is_some_and(|cmd| !cmd.trim().is_empty()) + || args.start_marker.is_some() + || args.stop_marker.is_some() + || args.workload_timeout.is_some() +} + +fn window_report_from_config(config: &PerfQemuConfig) -> PerfWindowReport { + let enabled = config.start_marker.is_some() + || config.stop_marker.is_some() + || config.workload_timeout.is_some(); + let mut report = PerfWindowReport { + enabled, + start_marker: config.start_marker.clone(), + stop_marker: config.stop_marker.clone(), + workload_timeout: config.workload_timeout, + method: if enabled { + "qperf_raw_elapsed_timestamp_filter".to_string() + } else { + "disabled".to_string() + }, + ..PerfWindowReport::default() + }; + if enabled && config.start_marker.is_none() { + report + .warnings + .push("start marker is not configured; boot samples are not excluded".to_string()); + } + if config.workload_timeout.is_some() && config.start_marker.is_none() { + report + .warnings + .push("--workload-timeout requires a start marker to open the window".to_string()); + } + report +} + +fn run_qemu_with_stdout_monitor( + mut command: Command, + config: &PerfQemuConfig, + outputs: &PerfOutputs, + overall_timeout: u64, +) -> anyhow::Result { + let mut window_report = window_report_from_config(config); + let shell_init_cmd = config + .shell_init_cmd + .as_deref() + .map(str::trim) + .filter(|cmd| !cmd.is_empty()); + if shell_init_cmd.is_some() { + command.stdin(Stdio::piped()); + } + command.stdout(Stdio::piped()); + let mut child = command.spawn().context("failed to spawn QEMU")?; + let mut stdin = child.stdin.take(); + let stdout = child.stdout.take().context("failed to open QEMU stdout")?; + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let mut stdout = BufReader::new(stdout); + let mut buf = [0_u8; 1024]; + loop { + match stdout.read(&mut buf) { + Ok(0) => break, + Ok(len) => { + if tx.send(buf[..len].to_vec()).is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + let started = Instant::now(); + let mut host_stdout = std::io::stdout().lock(); + let mut profile_stdout = File::create(&outputs.profile_stdout) + .with_context(|| format!("failed to create {}", outputs.profile_stdout.display()))?; + let mut prompt_window = Vec::new(); + let mut marker_window = Vec::new(); + let mut injected = false; + let mut echo_disable_deadline = None; + let shell_prefix = config + .shell_prefix + .as_deref() + .unwrap_or(DEFAULT_STARRY_SHELL_PREFIX); + let prefix = shell_prefix.as_bytes(); + let start_marker = config.start_marker.as_deref().map(str::as_bytes); + let stop_marker = config.stop_marker.as_deref().map(str::as_bytes); + let marker_monitoring = start_marker.is_some() || stop_marker.is_some(); + + loop { + if let Some(status) = child.try_wait().context("failed to poll QEMU")? { + if shell_init_cmd.is_some() && !injected { + window_report.warnings.push(format!( + "shell prompt `{shell_prefix}` was not observed before QEMU exited" + )); + eprintln!( + "qperf: shell prompt `{shell_prefix}` was not observed before QEMU exited" + ); + } + finalize_window_warnings(&mut window_report); + return Ok(QemuRun { + status, + window: window_report, + }); + } + + match rx.recv_timeout(Duration::from_millis(50)) { + Ok(chunk) => { + profile_stdout + .write_all(&chunk) + .context("failed to write qperf profile stdout")?; + host_stdout + .write_all(&chunk) + .context("failed to forward QEMU stdout")?; + host_stdout.flush().ok(); + let elapsed = started.elapsed().as_secs_f64(); + + if let Some(cmd) = shell_init_cmd + && !injected + && echo_disable_deadline.is_none() + { + prompt_window.extend_from_slice(&chunk); + trim_window(&mut prompt_window, prefix.len().saturating_add(1024)); + if contains_subslice(&prompt_window, prefix) { + let stdin = stdin.as_mut().context("failed to open QEMU stdin")?; + if marker_monitoring { + stdin + .write_all(b"stty -echo 2>/dev/null || true\n") + .context("failed to disable shell echo before qperf command")?; + stdin.flush().ok(); + echo_disable_deadline = + Some(Instant::now() + Duration::from_millis(150)); + } else { + write_shell_init_command(stdin, cmd)?; + injected = true; + eprintln!( + "qperf: injected shell init command after prompt `{shell_prefix}`" + ); + } + } + } + + if start_marker.is_some() || stop_marker.is_some() { + marker_window.extend_from_slice(&chunk); + let keep = start_marker + .into_iter() + .chain(stop_marker) + .map(<[u8]>::len) + .max() + .unwrap_or(0) + .saturating_add(1024); + trim_window(&mut marker_window, keep); + } + + if window_report.start_time.is_none() + && start_marker.is_some_and(|marker| contains_subslice(&marker_window, marker)) + { + window_report.start_time = Some(elapsed); + eprintln!( + "qperf: observed start marker `{}` at {elapsed:.6}s", + config.start_marker.as_deref().unwrap_or("") + ); + } + if window_report.stop_time.is_none() + && stop_marker.is_some_and(|marker| contains_subslice(&marker_window, marker)) + { + window_report.stop_time = Some(elapsed); + update_window_duration(&mut window_report); + request_qemu_stop(&mut child, outputs, &mut window_report, "stop marker")?; + break; + } + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => {} + } + + if let (Some(cmd), Some(deadline)) = (shell_init_cmd, echo_disable_deadline) + && !injected + && Instant::now() >= deadline + { + let stdin = stdin.as_mut().context("failed to open QEMU stdin")?; + write_shell_init_command(stdin, cmd)?; + injected = true; + echo_disable_deadline = None; + eprintln!("qperf: injected shell init command after prompt `{shell_prefix}`"); + } + + let elapsed = started.elapsed().as_secs_f64(); + if let (Some(start_time), Some(timeout)) = + (window_report.start_time, config.workload_timeout) + && window_report.stop_time.is_none() + && elapsed - start_time >= timeout as f64 + { + window_report.stop_time = Some(elapsed); + window_report.truncated_by_timeout = true; + update_window_duration(&mut window_report); + window_report.warnings.push(format!( + "workload window timed out after {timeout}s without stop marker" + )); + request_qemu_stop(&mut child, outputs, &mut window_report, "workload timeout")?; + break; + } + if overall_timeout > 0 && elapsed >= overall_timeout as f64 { + window_report.warnings.push(format!( + "QEMU timed out after {overall_timeout}s before workload completed" + )); + request_qemu_stop(&mut child, outputs, &mut window_report, "overall timeout")?; + break; + } + } + + let status = wait_for_child_exit(&mut child, Duration::from_secs(20))?; + if shell_init_cmd.is_some() && !injected { + window_report.warnings.push(format!( + "shell prompt `{shell_prefix}` was not observed before QEMU exited" + )); + eprintln!("qperf: shell prompt `{shell_prefix}` was not observed before QEMU exited"); + } + finalize_window_warnings(&mut window_report); + Ok(QemuRun { + status, + window: window_report, + }) +} + +fn trim_window(window: &mut Vec, keep: usize) { + if window.len() > keep { + let drain = window.len() - keep; + window.drain(..drain); + } +} + +fn write_shell_init_command(stdin: &mut impl Write, cmd: &str) -> anyhow::Result<()> { + stdin + .write_all(cmd.as_bytes()) + .context("failed to write qperf shell init command")?; + stdin + .write_all(b"\n") + .context("failed to terminate qperf shell init command")?; + stdin.flush().ok(); + Ok(()) +} + +fn update_window_duration(report: &mut PerfWindowReport) { + report.duration_sec = match (report.start_time, report.stop_time) { + (Some(start), Some(stop)) if stop >= start => Some(stop - start), + _ => None, + }; +} + +fn request_qemu_stop( + child: &mut std::process::Child, + outputs: &PerfOutputs, + report: &mut PerfWindowReport, + reason: &str, +) -> anyhow::Result<()> { + if report.stop_requested { + return Ok(()); + } + report.stop_requested = true; + match request_qmp_quit(&outputs.qmp_socket) { + Ok(()) => { + report.stop_method = Some("qmp_quit".to_string()); + eprintln!("qperf: requested QEMU quit via QMP after {reason}"); + } + Err(err) => { + report.warnings.push(format!( + "QMP quit failed after {reason}: {err}; falling back to SIGINT" + )); + interrupt_child(child)?; + report.stop_method = Some("sigint".to_string()); + eprintln!("qperf: sent SIGINT to QEMU after {reason}"); + } + } + Ok(()) +} + +#[cfg(unix)] +fn request_qmp_quit(socket: &Path) -> anyhow::Result<()> { + use std::os::unix::net::UnixStream; + + let mut stream = UnixStream::connect(socket) + .with_context(|| format!("failed to connect QMP socket {}", socket.display()))?; + stream + .set_read_timeout(Some(Duration::from_millis(200))) + .ok(); + stream + .set_write_timeout(Some(Duration::from_millis(200))) + .ok(); + let mut buf = [0_u8; 512]; + let _ = stream.read(&mut buf); + stream.write_all(b"{\"execute\":\"qmp_capabilities\"}\r\n")?; + let _ = stream.read(&mut buf); + stream.write_all(b"{\"execute\":\"quit\"}\r\n")?; + stream.flush()?; + Ok(()) +} + +#[cfg(not(unix))] +fn request_qmp_quit(_socket: &Path) -> anyhow::Result<()> { + bail!("QMP unix sockets are not supported on this host") +} + +#[cfg(unix)] +fn interrupt_child(child: &mut std::process::Child) -> anyhow::Result<()> { + let pid = child.id() as libc::pid_t; + if unsafe { libc::kill(pid, libc::SIGINT) } == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()).context("failed to send SIGINT to QEMU") + } +} + +#[cfg(not(unix))] +fn interrupt_child(child: &mut std::process::Child) -> anyhow::Result<()> { + child.kill().context("failed to kill QEMU") +} + +fn wait_for_child_exit( + child: &mut std::process::Child, + timeout: Duration, +) -> anyhow::Result { + let deadline = Instant::now() + timeout; + loop { + if let Some(status) = child.try_wait().context("failed to poll QEMU after stop")? { + return Ok(status); + } + if Instant::now() >= deadline { + child.kill().context("failed to kill unresponsive QEMU")?; + return child.wait().context("failed to wait for killed QEMU"); + } + thread::sleep(Duration::from_millis(50)); + } +} + +fn finalize_window_warnings(report: &mut PerfWindowReport) { + if !report.enabled { + return; + } + if report.start_marker.is_some() && report.start_time.is_none() { + report + .warnings + .push("start marker was not observed; folded stacks include boot samples".to_string()); + } + if report.start_time.is_some() && report.stop_marker.is_some() && report.stop_time.is_none() { + report + .warnings + .push("stop marker was not observed; workload window extends to QEMU exit".to_string()); + } + update_window_duration(report); +} + +fn write_window_report(path: &Path, report: &PerfWindowReport) -> anyhow::Result<()> { + let text = serde_json::to_string_pretty(report).context("failed to serialize qperf window")?; + fs::write(path, text).with_context(|| format!("failed to write {}", path.display())) +} + +fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool { + needle.is_empty() + || haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +fn write_host_time_metrics( + path: &Path, + elapsed: Duration, + usage_start: Option, + usage_end: Option, + status: &ExitStatus, +) -> anyhow::Result<()> { + let mut file = + File::create(path).with_context(|| format!("failed to create {}", path.display()))?; + let elapsed_seconds = elapsed.as_secs_f64(); + writeln!(file, "Elapsed time: {elapsed_seconds:.6}")?; + if let (Some(start), Some(end)) = (usage_start, usage_end) { + let usage = end.delta_since(start); + let user_seconds = usage.user_seconds(); + let system_seconds = usage.system_seconds(); + writeln!(file, "User time: {user_seconds:.6}")?; + writeln!(file, "System time: {system_seconds:.6}")?; + if elapsed_seconds > 0.0 { + let cpu_percent = (user_seconds + system_seconds) / elapsed_seconds * 100.0; + writeln!(file, "Percent of CPU this job got: {cpu_percent:.2}%")?; + } + writeln!(file, "Major page faults: {}", usage.major_faults)?; + writeln!(file, "Minor page faults: {}", usage.minor_faults)?; + writeln!( + file, + "Voluntary context switches: {}", + usage.voluntary_context_switches + )?; + writeln!( + file, + "Involuntary context switches: {}", + usage.involuntary_context_switches + )?; + } else { + writeln!(file, "User time: unavailable")?; + writeln!(file, "System time: unavailable")?; + } + writeln!(file, "Exit status: {}", exit_status_code(status))?; + Ok(()) +} + +fn write_host_perf_unavailable(path: &Path, reason: &str) -> anyhow::Result<()> { + let mut file = + File::create(path).with_context(|| format!("failed to create {}", path.display()))?; + writeln!(file, "# host perf unavailable: {reason}")?; + writeln!( + file, + "# host perf stat measures the host QEMU process; it is not a guest PMU counter" + )?; + Ok(()) +} + +fn exit_status_code(status: &ExitStatus) -> i32 { + status + .code() + .unwrap_or_else(|| if status.success() { 0 } else { 1 }) +} + +fn nonnegative_delta(after: i128, before: i128) -> i128 { + after.saturating_sub(before).max(0) +} + +#[cfg(unix)] +fn child_resource_usage() -> Option { + let mut usage = std::mem::MaybeUninit::::uninit(); + // SAFETY: getrusage initializes the provided rusage pointer when it returns 0. + if unsafe { libc::getrusage(libc::RUSAGE_CHILDREN, usage.as_mut_ptr()) } != 0 { + return None; + } + // SAFETY: getrusage returned success, so usage is initialized. + let usage = unsafe { usage.assume_init() }; + Some(ChildResourceUsage { + user_micros: timeval_micros(usage.ru_utime), + system_micros: timeval_micros(usage.ru_stime), + major_faults: usage.ru_majflt.into(), + minor_faults: usage.ru_minflt.into(), + voluntary_context_switches: usage.ru_nvcsw.into(), + involuntary_context_switches: usage.ru_nivcsw.into(), + }) +} + +#[cfg(unix)] +fn timeval_micros(value: libc::timeval) -> i128 { + i128::from(value.tv_sec) * 1_000_000 + i128::from(value.tv_usec) +} + +#[cfg(not(unix))] +fn child_resource_usage() -> Option { + None +} + +struct AnalyzerRun<'a> { + analyzer: &'a Path, + elf: &'a Path, + raw: &'a Path, + folded: &'a Path, + flamegraph: &'a Path, + resolve_stats: &'a Path, + depth_summary: Option<&'a Path>, generate_svg: bool, top_n: usize, -) -> anyhow::Result<()> { - ensure_file(elf, "StarryOS kernel ELF")?; - ensure_file(raw, "qperf raw samples")?; - let mut command = Command::new(analyzer); + start_sec: Option, + stop_sec: Option, + symbol_style: String, + demangle: bool, + focus: Option<&'a str>, + min_percent: f64, +} + +fn run_analyzer(args: AnalyzerRun<'_>) -> anyhow::Result<()> { + ensure_file(args.elf, "StarryOS kernel ELF")?; + ensure_file(args.raw, "qperf raw samples")?; + let mut command = Command::new(args.analyzer); command .arg("resolve") .arg("-e") - .arg(elf) - .arg(raw) - .arg(folded); - if top_n > 0 { - command.arg("--top").arg(top_n.to_string()); + .arg(args.elf) + .arg(args.raw) + .arg(args.folded); + if args.top_n > 0 { + command.arg("--top").arg(args.top_n.to_string()); + } + if let Some(start_sec) = args.start_sec { + command.arg("--start-sec").arg(format!("{start_sec:.9}")); + } + if let Some(stop_sec) = args.stop_sec { + command.arg("--stop-sec").arg(format!("{stop_sec:.9}")); + } + command + .arg("--symbol-style") + .arg(&args.symbol_style) + .arg("--min-percent") + .arg(args.min_percent.to_string()); + if !args.demangle { + command.arg("--no-demangle"); } - if generate_svg { - command.arg("--flamegraph").arg(flamegraph); + if let Some(focus) = args.focus { + command.arg("--focus").arg(focus); + } + command.arg("--stats").arg(args.resolve_stats); + if let Some(depth_summary) = args.depth_summary { + command.arg("--depth-summary").arg(depth_summary); + } + if args.generate_svg { + command.arg("--flamegraph").arg(args.flamegraph); } command.exec().context("failed to run qperf-analyzer")?; - ensure_file(folded, "folded stack output")?; + if !args.folded.exists() { + bail!("folded stack output not found at {}", args.folded.display()); + } Ok(()) } +fn generate_phase_flamegraphs( + tools: &QperfTools, + elf: &Path, + outputs: &PerfOutputs, + args: &ArgsPerf, + window: &PerfWindowReport, + generate_svg: bool, +) -> anyhow::Result<()> { + if window.start_time.is_some() && window.stop_time.is_some() { + fs::copy(&outputs.folded, &outputs.folded_workload).with_context(|| { + format!( + "failed to copy workload folded stack to {}", + outputs.folded_workload.display() + ) + })?; + if file_nonempty(&outputs.flamegraph) { + fs::copy(&outputs.flamegraph, &outputs.flamegraph_workload).with_context(|| { + format!( + "failed to copy workload flamegraph to {}", + outputs.flamegraph_workload.display() + ) + })?; + } + } + if let Some(start_sec) = window.start_time { + run_analyzer(AnalyzerRun { + analyzer: &tools.analyzer, + elf, + raw: &outputs.raw, + folded: &outputs.folded_boot, + flamegraph: &outputs.flamegraph_boot, + resolve_stats: &outputs + .resolve_stats + .with_file_name("resolve.boot.stats.json"), + depth_summary: Some( + &outputs + .stack_depth_summary + .with_file_name("stack-depth-summary.boot.csv"), + ), + generate_svg, + top_n: 0, + start_sec: None, + stop_sec: Some(start_sec), + symbol_style: args.symbol_style.to_string(), + demangle: true, + focus: None, + min_percent: flamegraph_min_percent(args), + })?; + } + if let Some(stop_sec) = window.stop_time { + run_analyzer(AnalyzerRun { + analyzer: &tools.analyzer, + elf, + raw: &outputs.raw, + folded: &outputs.folded_post, + flamegraph: &outputs.flamegraph_post, + resolve_stats: &outputs + .resolve_stats + .with_file_name("resolve.post.stats.json"), + depth_summary: Some( + &outputs + .stack_depth_summary + .with_file_name("stack-depth-summary.post.csv"), + ), + generate_svg, + top_n: 0, + start_sec: Some(stop_sec), + stop_sec: None, + symbol_style: args.symbol_style.to_string(), + demangle: true, + focus: None, + min_percent: flamegraph_min_percent(args), + })?; + } + Ok(()) +} + +fn generate_focus_flamegraph( + tools: &QperfTools, + elf: &Path, + outputs: &PerfOutputs, + args: &ArgsPerf, + generate_svg: bool, +) -> anyhow::Result<()> { + let Some(focus) = args.focus.as_deref() else { + return Ok(()); + }; + run_analyzer(AnalyzerRun { + analyzer: &tools.analyzer, + elf, + raw: &outputs.raw, + folded: &outputs.folded_focus, + flamegraph: &outputs.flamegraph_focus, + resolve_stats: &outputs + .resolve_stats + .with_file_name("resolve.focus.stats.json"), + depth_summary: Some( + &outputs + .stack_depth_summary + .with_file_name("stack-depth-summary.focus.csv"), + ), + generate_svg, + top_n: 0, + start_sec: None, + stop_sec: None, + symbol_style: args.symbol_style.to_string(), + demangle: true, + focus: Some(focus), + min_percent: flamegraph_min_percent(args), + }) +} + +fn write_flamegraph_html( + outputs: &PerfOutputs, + kind: PerfFlamegraphKind, + flamegraph_generated: bool, +) -> anyhow::Result<()> { + if !matches!(kind, PerfFlamegraphKind::Html) || !flamegraph_generated { + return Ok(()); + } + let svg = outputs + .flamegraph + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("flamegraph.svg"); + let html = format!( + "StarryOS qperf Flame \ + Graph