Skip to main content

forge/profile/
mod.rs

1// #t(file: rust_deep_exit) CLI binary module, process::exit for user-facing errors.
2
3//! Profile subcommand: workspace profiling with samply.
4
5mod analyze;
6mod build;
7mod categories;
8mod monitor;
9mod report;
10mod samply;
11mod targets;
12
13use clap::Args;
14#[cfg(test)]
15use googletest::{Result as GtestResult, prelude::*};
16use wowlab_common::{output, prompt};
17use wowlab_fs::{path::Path, temporary};
18
19use crate::run::{FightDurationSeconds, IterationCount, RunParameters, RunParametersError};
20
21fn fatal(args: std::fmt::Arguments<'_>) -> ! {
22    output::error(&format!("{args}"));
23    std::process::exit(1);
24}
25
26use targets::{ProfileTarget, TargetKind};
27
28#[derive(Args, Debug)]
29pub(crate) struct ProfileArgs {
30    /// Target crate to profile (skip interactive selection).
31    #[arg(long, short)]
32    pub target: Option<String>,
33
34    /// Number of iterations (engine targets).
35    #[arg(long, default_value = "5000")]
36    pub iterations: u32,
37
38    /// Fight duration in seconds (engine targets).
39    #[arg(long, default_value = "300")]
40    pub duration: u32,
41
42    /// Spec to profile (engine targets).
43    #[arg(long)]
44    pub spec: Option<String>,
45
46    /// Output JSON instead of text.
47    #[arg(long)]
48    pub json: bool,
49
50    /// Top N functions to show.
51    #[arg(long, default_value = "50")]
52    pub top: usize,
53
54    /// Suppress non-essential output.
55    #[arg(long, short)]
56    pub quiet: bool,
57
58    /// Run criterion benchmarks instead of samply profiling.
59    #[arg(long)]
60    pub bench: bool,
61
62    /// Custom command args (for non-engine targets).
63    #[arg(last = true)]
64    pub args: Vec<String>,
65}
66
67#[derive(Debug, thiserror::Error)]
68enum CaptureProfileError {
69    #[error("failed to create profile workspace: {0}")]
70    Workspace(#[from] wowlab_fs::error::Error),
71    #[error("samply failed: {0}")]
72    Samply(#[from] samply::RecordError),
73    #[error("failed to parse profile: {0}")]
74    Parse(#[from] anyhow::Error),
75}
76
77impl TryFrom<&ProfileArgs> for RunParameters {
78    type Error = RunParametersError;
79
80    fn try_from(args: &ProfileArgs) -> Result<Self, Self::Error> {
81        let fight_duration = FightDurationSeconds::try_from(args.duration)?;
82        let iterations = IterationCount::try_from(args.iterations)?;
83
84        Ok(Self::new(iterations, fight_duration))
85    }
86}
87
88pub(crate) fn run(profile_args: &ProfileArgs, crates_dir: &Path) {
89    let all_targets = targets::discover(crates_dir);
90
91    if all_targets.is_empty() {
92        fatal(format_args!(
93            "no profiling targets found in {}",
94            crates_dir.display()
95        ));
96    }
97
98    let target = if let Some(ref name) = profile_args.target {
99        all_targets
100            .iter()
101            .find(|t| t.name == *name || t.bin_name == *name)
102            .unwrap_or_else(|| {
103                fatal(format_args!(
104                    "target '{}' not found. available: {}",
105                    name,
106                    all_targets
107                        .iter()
108                        .map(|t| t.name.as_str())
109                        .collect::<Vec<_>>()
110                        .join(", ")
111                ));
112            })
113            .clone()
114    } else {
115        interactive_select(&all_targets, crates_dir)
116    };
117
118    if !profile_args.quiet {
119        output::kv("Target", &target.name);
120        output::kv(
121            "Kind",
122            match target.kind {
123                TargetKind::Binary => "binary",
124                TargetKind::Bench => "benchmark",
125            },
126        );
127        output::blank();
128    }
129
130    if profile_args.bench || target.kind == TargetKind::Bench {
131        run_bench(&target, crates_dir, profile_args);
132    } else {
133        run_profile(&target, crates_dir, profile_args);
134    }
135}
136
137fn interactive_select(targets: &[ProfileTarget], crates_dir: &Path) -> ProfileTarget {
138    let labels: Vec<String> = targets.iter().map(ToString::to_string).collect();
139    let idx = prompt::select("Target", &labels);
140    // BOUNDS: idx returned by prompt::select which constrains to 0..targets.len()
141    let mut target = targets[idx].clone();
142
143    if target.name == "engine" {
144        let specs = targets::discover_engine_specs(crates_dir);
145        let spec_strs: Vec<&str> = specs.iter().map(String::as_str).collect();
146        let spec_idx = prompt::select("Spec", &spec_strs);
147        // BOUNDS: spec_idx returned by prompt::select which constrains to 0..specs.len()
148        let spec = &specs[spec_idx];
149
150        let iterations = prompt::text("Iterations", Some("5000"));
151        let duration = prompt::text("Duration (seconds)", Some("300"));
152
153        target.default_args = vec![
154            "sim".to_string(),
155            "--spec".to_string(),
156            spec.clone(),
157            "--iterations".to_string(),
158            iterations,
159            "--duration".to_string(),
160            duration,
161            "--threads".to_string(),
162            "1".to_string(),
163            "--format".to_string(),
164            "json".to_string(),
165        ];
166    } else if target.kind == TargetKind::Binary && target.default_args.is_empty() {
167        let custom = prompt::text("Command args (space-separated)", Some(""));
168
169        if !custom.is_empty() {
170            target.default_args = custom.split_whitespace().map(ToString::to_string).collect();
171        }
172    }
173
174    target
175}
176
177fn run_bench(target: &ProfileTarget, crates_dir: &Path, args: &ProfileArgs) {
178    if !args.quiet {
179        output::header("Building benchmark...");
180    }
181
182    let bench_binary = build::build_bench(target, crates_dir, args.quiet);
183    let Some(binary) = bench_binary else {
184        fatal(format_args!(
185            "failed to build benchmark '{}'",
186            target.bin_name
187        ));
188    };
189
190    if !args.quiet {
191        output::success(&format!("Built: {}", binary.display()));
192        output::header("Running benchmark...");
193    }
194
195    let result = monitor::run_monitored(&binary, &["--bench".to_string()], "error");
196
197    if result.exit_code != 0 {
198        fatal(format_args!("benchmark failed: {}", result.stderr));
199    }
200
201    report::print_bench_summary(&result.stdout);
202}
203
204fn run_profile(target: &ProfileTarget, crates_dir: &Path, args: &ProfileArgs) {
205    if !samply::check_samply() {
206        fatal(format_args!(
207            "samply not found. install with: cargo install samply"
208        ));
209    }
210
211    if !args.quiet {
212        output::header("Building with bench-profile...");
213    }
214
215    let binary = build::build_target(target, crates_dir, args.quiet);
216    let Some(binary) = binary else {
217        fatal(format_args!("failed to build '{}'", target.bin_name));
218    };
219
220    if !args.quiet {
221        output::success(&format!("Built: {}", binary.display()));
222    }
223
224    let (parameters, run_args) = if target.name == "engine" {
225        let parameters =
226            RunParameters::try_from(args).unwrap_or_else(|error| fatal(format_args!("{error}")));
227
228        (
229            Some(parameters),
230            build_engine_run_args(target, args, parameters),
231        )
232    } else {
233        (None, build_non_engine_run_args(target, args))
234    };
235    let host = monitor::collect_host_stats();
236
237    if !args.quiet {
238        output::header("Timing clean run (no profiler)...");
239    }
240
241    let clean = monitor::run_monitored(&binary, &run_args, "error");
242
243    if clean.exit_code != 0 {
244        fatal(format_args!("engine error: {}", clean.stderr));
245    }
246
247    let clean_secs = clean.duration.as_secs_f64();
248
249    if !args.quiet {
250        output::kv("Duration", &output::fmt_duration(clean_secs));
251        output::kv(
252            "Throughput",
253            &format!(
254                "{} sims/sec",
255                output::fmt_number(
256                    f64::from(parameters.map_or(args.iterations, RunParameters::iterations))
257                        / clean_secs,
258                )
259            ),
260        );
261        output::blank();
262    }
263
264    if !args.quiet {
265        output::header("Profiling with samply...");
266    }
267
268    let raw = capture_profile(&binary, &run_args, args.quiet)
269        .unwrap_or_else(|error| fatal(format_args!("{error}")));
270
271    let (symbols, _unresolved) = samply::resolve_symbols(&raw.strings, &binary, args.quiet);
272
273    let spec = args
274        .spec
275        .clone()
276        .or_else(|| extract_spec_from_args(&run_args))
277        .unwrap_or_default();
278
279    let analysis = analyze::analyze(
280        &raw,
281        &symbols,
282        &spec,
283        parameters.map_or(args.iterations, RunParameters::iterations),
284        clean_secs,
285        args.top,
286    );
287
288    if args.json {
289        report::print_json(&analysis, &host, &clean.stats);
290    } else {
291        report::print_text(&analysis, &host, &clean.stats);
292    }
293}
294
295fn capture_profile(
296    binary: &Path,
297    run_args: &[String],
298    quiet: bool,
299) -> Result<samply::RawProfile, CaptureProfileError> {
300    let output = temporary::Directory::with_prefix("wowlab-forge-profile-")?;
301    let path = output.path().join("profile.json.gz");
302
303    samply::record(binary, run_args, &path)?;
304
305    if !quiet {
306        output::header("Analyzing profile...");
307    }
308
309    Ok(samply::parse(&path)?)
310}
311
312fn build_engine_run_args(
313    target: &ProfileTarget,
314    args: &ProfileArgs,
315    parameters: RunParameters,
316) -> Vec<String> {
317    let spec = args.spec.clone().unwrap_or_else(|| {
318        target
319            .default_args
320            .iter()
321            .position(|argument| argument == "--spec")
322            .and_then(|index| target.default_args.get(index + 1))
323            .cloned()
324            .expect("engine target must have a default --spec")
325    });
326
327    vec![
328        "sim".to_string(),
329        "--spec".to_string(),
330        spec,
331        "--iterations".to_string(),
332        parameters.iterations().to_string(),
333        "--duration".to_string(),
334        parameters.fight_duration_secs().to_string(),
335        "--threads".to_string(),
336        "1".to_string(),
337        "--format".to_string(),
338        "json".to_string(),
339    ]
340}
341
342fn build_non_engine_run_args(target: &ProfileTarget, args: &ProfileArgs) -> Vec<String> {
343    if args.args.is_empty() {
344        target.default_args.clone()
345    } else {
346        args.args.clone()
347    }
348}
349
350const ARG_VALUE_PAIR: usize = 2;
351
352fn extract_spec_from_args(run_args: &[String]) -> Option<String> {
353    run_args
354        .windows(ARG_VALUE_PAIR)
355        // BOUNDS: windows(2) guarantees exactly 2 elements per slice
356        .find(|w| w[0] == "--spec")
357        .map(|w| w[1].clone())
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[gtest]
365    fn engine_command_uses_validated_parameters_in_stable_order() -> GtestResult<()> {
366        let target = ProfileTarget {
367            name: "engine".to_string(),
368            package: "wowlab-engine".to_string(),
369            bin_name: "engine".to_string(),
370            kind: TargetKind::Binary,
371            features: Vec::new(),
372            default_args: vec![
373                "sim".to_string(),
374                "--spec".to_string(),
375                "fire_mage".to_string(),
376            ],
377        };
378        let args = ProfileArgs {
379            target: Some("engine".to_string()),
380            iterations: 17,
381            duration: 45,
382            spec: None,
383            json: false,
384            top: 50,
385            quiet: false,
386            bench: false,
387            args: Vec::new(),
388        };
389        let parameters = RunParameters::try_from(&args).or_fail()?;
390
391        verify_that!(
392            build_engine_run_args(&target, &args, parameters),
393            elements_are![
394                "sim",
395                "--spec",
396                "fire_mage",
397                "--iterations",
398                "17",
399                "--duration",
400                "45",
401                "--threads",
402                "1",
403                "--format",
404                "json",
405            ]
406        )?;
407
408        Ok(())
409    }
410
411    #[gtest]
412    fn capture_profile_error_preserves_stage_context_and_source() -> GtestResult<()> {
413        let error = CaptureProfileError::Parse(anyhow::anyhow!("invalid profile"));
414
415        verify_that!(
416            error.to_string(),
417            eq("failed to parse profile: invalid profile")
418        )?;
419        verify_true!(std::error::Error::source(&error).is_some())?;
420
421        Ok(())
422    }
423}