use anyhow::{Context, Result, bail};
use clap::{Args, ValueEnum};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use std::fmt::Write;
use std::fs::File;
use std::io::BufWriter;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::{
DevicePlatform, MobileTarget, ProjectLayoutOptions, ResolvedMatrixDevice, RunSpec,
flamegraph_viewer::{
ArtifactLink as ViewerArtifactLink, FlamegraphMode, FlamegraphViewerDoc, FrameSourceLink,
ViewerHarnessTimelineSpan, ViewerMetadataItem, ViewerTraceEvent, ViewerTraceLane,
count_folded_stack_lines, derive_benchmark_focused_folded_stacks,
render_flamegraph_viewer_html, render_standalone_flamegraph_svg, summarize_folded_stacks,
},
load_dotenv_for_layout, persist_mobile_spec, repo_root, resolve_devices_for_profile,
resolve_project_layout, run_android_build, run_ios_build, validate_benchmark_function,
};
use mobench_sdk::types::NativeLibraryArtifact;
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ProfileBackend {
Auto,
AndroidNative,
IosInstruments,
RustTracing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ProfileFormat {
Native,
Processed,
Both,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ProfileProvider {
Local,
Browserstack,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ProfileSummaryFormat {
Markdown,
Json,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CaptureWarmupMode {
Cold,
Warm,
}
impl CaptureWarmupMode {
fn as_str(self) -> &'static str {
match self {
Self::Cold => "cold",
Self::Warm => "warm",
}
}
}
#[derive(Debug, Clone, Args)]
#[command(
about = "Plan or execute a native profiling session; local android-native and ios-instruments now attempt real native capture",
after_help = concat!(
"Capability matrix:\n",
" local + android-native: attempts real simpleperf capture and symbolization\n",
" local + ios-instruments: attempts real simulator-host sample capture and flamegraph generation\n",
" local + rust-tracing: planned manifest today; structured trace output is local-only\n",
" browserstack + android-native: unsupported for native capture in this release\n",
" browserstack + ios-instruments: unsupported for native capture in this release\n",
" browserstack + rust-tracing: unsupported; use local provider for trace-events output\n",
"\n",
"Device selection:\n",
" Use --device/--os-version for one explicit BrowserStack device, or --profile with\n",
" optional --device-matrix/--config to reuse the same deterministic resolution model as\n",
" `mobench devices resolve`.\n"
)
)]
pub struct ProfileRunArgs {
#[arg(long, value_enum)]
pub target: MobileTarget,
#[arg(long, help = "Fully-qualified Rust function to profile")]
pub function: String,
#[arg(
long,
help = "Path to the benchmark crate directory containing Cargo.toml"
)]
pub crate_path: Option<PathBuf>,
#[arg(long, help = "Optional path to config file")]
pub config: Option<PathBuf>,
#[arg(long, default_value = "target/mobench/profile")]
pub output_dir: PathBuf,
#[arg(
long,
help = "Explicit BrowserStack device name to resolve for this profiling request",
requires = "os_version",
conflicts_with_all = ["profile", "device_matrix"]
)]
pub device: Option<String>,
#[arg(
long,
help = "OS version for --device (for example `16` or `14.0`)",
requires = "device",
conflicts_with_all = ["profile", "device_matrix"]
)]
pub os_version: Option<String>,
#[arg(long, help = "Device profile/tag to resolve (for example `high-spec`)")]
pub profile: Option<String>,
#[arg(
long,
help = "Path to device matrix YAML file used with --profile or config-based device tags"
)]
pub device_matrix: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = ProfileProvider::Local)]
pub provider: ProfileProvider,
#[arg(long, value_enum, default_value_t = ProfileBackend::Auto)]
pub backend: ProfileBackend,
#[arg(long, value_enum, default_value_t = ProfileFormat::Both)]
pub format: ProfileFormat,
#[arg(
long,
value_enum,
help = "Warm or cold capture mode for local native profiling (defaults to warm for local Android/iOS native backends)"
)]
pub warmup_mode: Option<CaptureWarmupMode>,
}
#[derive(Debug, Clone, Args)]
pub struct ProfileSummarizeArgs {
#[arg(long, default_value = "target/mobench/profile/profile.json")]
pub profile: PathBuf,
#[arg(long)]
pub output: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = ProfileSummaryFormat::Markdown)]
pub output_format: ProfileSummaryFormat,
}
#[derive(Debug, Clone, Args)]
pub struct ProfileDiffArgs {
#[arg(long, help = "Path to the baseline profile.json manifest")]
pub baseline: PathBuf,
#[arg(long, help = "Path to the candidate profile.json manifest")]
pub candidate: PathBuf,
#[arg(
long,
default_value = "target/mobench/profile/diff",
help = "Output directory for differential profile artifacts"
)]
pub output_dir: PathBuf,
#[arg(
long,
help = "Normalize baseline sample counts to candidate totals before diffing"
)]
pub normalize: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CaptureStatus {
Planned,
Captured,
Partial,
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SemanticCaptureStatus {
Planned,
Captured,
Partial,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactRecord {
pub label: String,
pub path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SymbolizationRecord {
pub status: CaptureStatus,
pub tool: Option<String>,
pub resolved_frames: u64,
pub unresolved_frames: u64,
pub notes: Vec<String>,
}
impl Default for SymbolizationRecord {
fn default() -> Self {
Self {
status: CaptureStatus::Planned,
tool: None,
resolved_frames: 0,
unresolved_frames: 0,
notes: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NativeCaptureRecord {
pub status: CaptureStatus,
pub raw_artifacts: Vec<ArtifactRecord>,
pub processed_artifacts: Vec<ArtifactRecord>,
pub symbolization: SymbolizationRecord,
pub viewer_hint: Option<String>,
}
impl Default for NativeCaptureRecord {
fn default() -> Self {
Self {
status: CaptureStatus::Planned,
raw_artifacts: Vec::new(),
processed_artifacts: Vec::new(),
symbolization: SymbolizationRecord::default(),
viewer_hint: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SemanticPhaseRecord {
pub name: String,
pub duration_ns: Option<u64>,
pub percent_total: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HarnessTimelineSpanRecord {
pub phase: String,
pub start_offset_ns: u64,
pub end_offset_ns: u64,
pub iteration: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SemanticProfileRecord {
pub status: SemanticCaptureStatus,
pub phases: Vec<SemanticPhaseRecord>,
pub spans_path: Option<PathBuf>,
#[serde(default)]
pub harness_timeline: Vec<HarnessTimelineSpanRecord>,
pub timeline_path: Option<PathBuf>,
}
impl Default for SemanticProfileRecord {
fn default() -> Self {
Self {
status: SemanticCaptureStatus::Planned,
phases: Vec::new(),
spans_path: None,
harness_timeline: Vec::new(),
timeline_path: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct CaptureMetadataRecord {
pub device: Option<String>,
pub os: Option<String>,
pub sample_duration_secs: Option<u64>,
pub benchmark_iterations: Option<u32>,
pub benchmark_warmup: Option<u32>,
pub warmup_mode: Option<CaptureWarmupMode>,
pub capture_method: Option<String>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ChronologicalTraceSourceRecord {
kind: String,
profiler: String,
origin: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ChronologicalTraceRecord {
source: ChronologicalTraceSourceRecord,
total_duration_ns: u64,
lanes: Vec<ViewerTraceLane>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct FrameLocationRecord {
frame: String,
source_path: PathBuf,
line: u32,
}
#[derive(Debug, Clone)]
struct TimelinePayload {
lanes: Vec<ViewerTraceLane>,
total_duration_ns: Option<u64>,
note: Option<String>,
trace_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProfileManifest {
pub run_id: String,
pub target: MobileTarget,
pub function: String,
#[serde(default = "default_profile_provider")]
pub provider: ProfileProvider,
pub backend: ProfileBackend,
pub format: ProfileFormat,
pub native_capture: NativeCaptureRecord,
pub semantic_profile: SemanticProfileRecord,
pub capture_metadata: CaptureMetadataRecord,
}
#[derive(Debug, Clone, Deserialize)]
struct ProfileManifestSerde {
run_id: String,
target: MobileTarget,
function: String,
#[serde(default = "default_profile_provider")]
provider: ProfileProvider,
backend: ProfileBackend,
format: ProfileFormat,
#[serde(default)]
native_capture: NativeCaptureRecord,
#[serde(default)]
semantic_profile: SemanticProfileRecord,
#[serde(default)]
capture_metadata: CaptureMetadataRecord,
#[serde(default)]
capture_status: Option<CaptureStatus>,
#[serde(default)]
raw_artifacts: Vec<ArtifactRecord>,
#[serde(default)]
processed_artifacts: Vec<ArtifactRecord>,
#[serde(default)]
warnings: Vec<String>,
#[serde(default)]
viewer_hint: Option<String>,
}
impl<'de> Deserialize<'de> for ProfileManifest {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let helper = ProfileManifestSerde::deserialize(deserializer)?;
Ok(Self::from(helper))
}
}
impl From<ProfileManifestSerde> for ProfileManifest {
fn from(mut helper: ProfileManifestSerde) -> Self {
let has_legacy_native_fields = helper.capture_status.is_some()
|| !helper.raw_artifacts.is_empty()
|| !helper.processed_artifacts.is_empty()
|| helper.viewer_hint.is_some();
if has_legacy_native_fields && helper.native_capture == NativeCaptureRecord::default() {
helper.native_capture = NativeCaptureRecord {
status: helper.capture_status.unwrap_or(CaptureStatus::Planned),
raw_artifacts: helper.raw_artifacts,
processed_artifacts: helper.processed_artifacts,
symbolization: SymbolizationRecord::default(),
viewer_hint: helper.viewer_hint,
};
}
if !helper.warnings.is_empty() && helper.capture_metadata.warnings.is_empty() {
helper.capture_metadata.warnings = helper.warnings;
}
Self {
run_id: helper.run_id,
target: helper.target,
function: helper.function,
provider: helper.provider,
backend: helper.backend,
format: helper.format,
native_capture: helper.native_capture,
semantic_profile: helper.semantic_profile,
capture_metadata: helper.capture_metadata,
}
}
}
fn default_profile_provider() -> ProfileProvider {
ProfileProvider::Local
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ResolvedProfileTarget {
backend: ProfileBackend,
device: Option<ResolvedProfileDevice>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ResolvedProfileDevice {
name: String,
os: String,
os_version: String,
identifier: String,
profile: Option<String>,
source: String,
}
pub fn render_profile_markdown(manifest: &ProfileManifest) -> String {
let mut markdown = String::new();
let _ = writeln!(markdown, "# Profile Summary");
let _ = writeln!(markdown);
let _ = writeln!(markdown, "- Run ID: `{}`", manifest.run_id);
let _ = writeln!(markdown, "- Target: `{}`", manifest.target.as_str());
let _ = writeln!(markdown, "- Function: `{}`", manifest.function);
let _ = writeln!(
markdown,
"- Provider: `{}`",
manifest.provider.to_possible_value().unwrap().get_name()
);
let _ = writeln!(
markdown,
"- Backend: `{}`",
manifest.backend.to_possible_value().unwrap().get_name()
);
let _ = writeln!(markdown);
let _ = writeln!(markdown, "## Native capture");
let _ = writeln!(markdown);
let _ = writeln!(
markdown,
"- Status: `{}`",
capture_status_label(manifest.native_capture.status)
);
let _ = writeln!(markdown, "- Raw artifacts:");
for artifact in &manifest.native_capture.raw_artifacts {
let _ = writeln!(
markdown,
" - `{}`: `{}`",
artifact.label,
artifact.path.display()
);
}
let _ = writeln!(markdown, "- Processed artifacts:");
for artifact in &manifest.native_capture.processed_artifacts {
let _ = writeln!(
markdown,
" - `{}`: `{}`",
artifact.label,
artifact.path.display()
);
}
let _ = writeln!(markdown, "- Symbolization:");
let _ = writeln!(
markdown,
" - Status: `{}`",
capture_status_label(manifest.native_capture.symbolization.status)
);
if let Some(tool) = &manifest.native_capture.symbolization.tool {
let _ = writeln!(markdown, " - Tool: `{tool}`");
}
let _ = writeln!(
markdown,
" - Resolved frames: `{}`",
manifest.native_capture.symbolization.resolved_frames
);
let _ = writeln!(
markdown,
" - Unresolved frames: `{}`",
manifest.native_capture.symbolization.unresolved_frames
);
for note in &manifest.native_capture.symbolization.notes {
let _ = writeln!(markdown, " - {}", note);
}
if let Some(viewer_hint) = &manifest.native_capture.viewer_hint {
let _ = writeln!(markdown);
let _ = writeln!(markdown, "## Viewer");
let _ = writeln!(markdown);
let _ = writeln!(markdown, "{}", viewer_hint);
}
let _ = writeln!(markdown);
let _ = writeln!(markdown, "## Semantic phases");
let _ = writeln!(markdown);
let _ = writeln!(
markdown,
"- Status: `{}`",
semantic_capture_status_label(manifest.semantic_profile.status)
);
match &manifest.semantic_profile.spans_path {
Some(path) => {
let _ = writeln!(markdown, "- Spans path: `{}`", path.display());
}
None => {
let _ = writeln!(markdown, "- Spans path: `not recorded`");
}
}
if manifest.semantic_profile.phases.is_empty() {
let _ = writeln!(markdown, "- No semantic phases recorded");
} else {
let _ = writeln!(markdown, "- Phases:");
for phase in &manifest.semantic_profile.phases {
let _ = writeln!(markdown, " - `{}`", phase.name);
if let Some(duration_ns) = phase.duration_ns {
let _ = writeln!(markdown, " - Duration: `{duration_ns}` ns");
}
if let Some(percent_total) = phase.percent_total {
let _ = writeln!(markdown, " - Share of total: `{percent_total}`");
}
}
}
let _ = writeln!(markdown);
let _ = writeln!(markdown, "## Capture metadata");
let _ = writeln!(markdown);
match &manifest.capture_metadata.device {
Some(device) => {
let _ = writeln!(markdown, "- Device: `{device}`");
}
None => {
let _ = writeln!(markdown, "- Device: `not recorded`");
}
}
match manifest.capture_metadata.sample_duration_secs {
Some(sample_duration_secs) => {
let _ = writeln!(markdown, "- Sample duration: `{sample_duration_secs}` s");
}
None => {
let _ = writeln!(markdown, "- Sample duration: `not recorded`");
}
}
match &manifest.capture_metadata.warmup_mode {
Some(warmup_mode) => {
let _ = writeln!(markdown, "- Warmup mode: `{}`", warmup_mode.as_str());
}
None => {
let _ = writeln!(markdown, "- Warmup mode: `not recorded`");
}
}
match &manifest.capture_metadata.capture_method {
Some(capture_method) => {
let _ = writeln!(markdown, "- Capture method: `{capture_method}`");
}
None => {
let _ = writeln!(markdown, "- Capture method: `not recorded`");
}
}
if !manifest.capture_metadata.warnings.is_empty() {
let _ = writeln!(markdown);
let _ = writeln!(markdown, "### Warnings");
let _ = writeln!(markdown);
for warning in &manifest.capture_metadata.warnings {
let _ = writeln!(markdown, "- {}", warning);
}
}
markdown
}
pub fn write_profile_manifest(path: &Path, manifest: &ProfileManifest) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = serde_json::to_string_pretty(manifest)?;
std::fs::write(path, body)?;
Ok(())
}
pub fn cmd_profile_run(args: &ProfileRunArgs, dry_run: bool) -> Result<()> {
run_profile_session_with_executor(args, dry_run, execute_capture)
}
fn run_profile_session_with_executor<E>(
args: &ProfileRunArgs,
dry_run: bool,
execute: E,
) -> Result<()>
where
E: FnOnce(&ProfileRunArgs, &ResolvedProfileTarget, &mut ProfileManifest) -> Result<()>,
{
let target = resolve_profile_target(args)?;
let run_id = build_run_id(args.target, &args.function);
let run_output_dir = args.output_dir.join(&run_id);
let mut manifest = build_capture_plan(args, &target, &run_output_dir)?;
let execution_result = if dry_run {
manifest.capture_metadata.warnings.push(
"dry-run enabled; capture planning stopped before execution and recorded the planned artifact contract only"
.into(),
);
Ok(())
} else {
execute(args, &target, &mut manifest)
};
let should_persist_outputs = dry_run
|| execution_result.is_ok()
|| manifest.native_capture.status != CaptureStatus::Planned
|| manifest.native_capture.symbolization.status != CaptureStatus::Planned
|| manifest.semantic_profile.status != SemanticCaptureStatus::Planned;
if should_persist_outputs {
write_profile_session_outputs(args, &run_output_dir, &manifest)?;
}
execution_result?;
println!(
"Profile session written to {}",
run_output_dir.join("profile.json").display()
);
println!(
"Profile summary written to {}",
run_output_dir.join("summary.md").display()
);
println!(
"Latest profile manifest refreshed at {}",
args.output_dir.join("profile.json").display()
);
println!(
"Latest profile summary refreshed at {}",
args.output_dir.join("summary.md").display()
);
Ok(())
}
fn write_profile_session_outputs(
args: &ProfileRunArgs,
run_output_dir: &Path,
manifest: &ProfileManifest,
) -> Result<()> {
std::fs::create_dir_all(&args.output_dir)?;
std::fs::create_dir_all(&run_output_dir)?;
create_selected_artifact_roots(
&manifest.native_capture.raw_artifacts,
&manifest.native_capture.processed_artifacts,
)?;
let rendered_summary = render_profile_markdown(&manifest);
let run_profile_path = run_output_dir.join("profile.json");
let run_summary_path = run_output_dir.join("summary.md");
write_semantic_phase_sidecar(manifest)?;
write_harness_timeline_sidecar(manifest)?;
refresh_flamegraph_viewer_from_manifest(run_output_dir, manifest)?;
write_profile_manifest(&run_profile_path, &manifest)?;
std::fs::write(&run_summary_path, rendered_summary.as_bytes())?;
let latest_profile_path = args.output_dir.join("profile.json");
let latest_summary_path = args.output_dir.join("summary.md");
write_profile_manifest(&latest_profile_path, &manifest)?;
std::fs::write(&latest_summary_path, rendered_summary.as_bytes())?;
Ok(())
}
fn write_semantic_phase_sidecar(manifest: &ProfileManifest) -> Result<()> {
let Some(path) = manifest.semantic_profile.spans_path.as_ref() else {
return Ok(());
};
if manifest.semantic_profile.phases.is_empty() {
return Ok(());
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(
path,
serde_json::to_vec_pretty(&manifest.semantic_profile.phases)?,
)?;
Ok(())
}
fn write_harness_timeline_sidecar(manifest: &ProfileManifest) -> Result<()> {
let Some(path) = manifest.semantic_profile.timeline_path.as_ref() else {
return Ok(());
};
if manifest.semantic_profile.harness_timeline.is_empty() {
return Ok(());
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(
path,
serde_json::to_vec_pretty(&manifest.semantic_profile.harness_timeline)?,
)?;
Ok(())
}
fn prepare_viewer_timeline_payload(
run_output_dir: &Path,
processed_root: &Path,
manifest: &ProfileManifest,
) -> Result<TimelinePayload> {
let trace_path = artifact_path_by_label(
&manifest.native_capture.processed_artifacts,
"chronological-trace",
)
.map(|path| resolve_run_relative_path(run_output_dir, path))
.unwrap_or_else(|| processed_root.join("chronological-trace.json"));
if trace_path.exists()
&& let Ok(record) = load_chronological_trace_record(&trace_path)
{
let lanes = sanitize_trace_lanes(&record);
return Ok(TimelinePayload {
total_duration_ns: Some(record.total_duration_ns),
note: build_timeline_note(&lanes),
lanes,
trace_path: Some(trace_path),
});
}
let lanes = build_harness_only_viewer_timeline_lanes(manifest);
let total_duration_ns = compute_timeline_total_duration_ns(
&build_viewer_harness_timeline(manifest),
manifest.capture_metadata.sample_duration_secs,
);
let trace_path = write_chronological_trace_sidecar(
&trace_path,
manifest,
&lanes,
total_duration_ns,
"mobench-harness-timeline",
)?;
Ok(TimelinePayload {
note: build_timeline_note(&lanes),
lanes,
total_duration_ns,
trace_path,
})
}
fn load_chronological_trace_record(path: &Path) -> Result<ChronologicalTraceRecord> {
let body = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
serde_json::from_slice(&body).with_context(|| format!("parsing {}", path.display()))
}
fn sanitize_trace_lanes(trace: &ChronologicalTraceRecord) -> Vec<ViewerTraceLane> {
if trace.source.kind != "mobench-harness-timeline" {
return trace.lanes.clone();
}
trace
.lanes
.iter()
.map(|lane| ViewerTraceLane {
id: lane.id.clone(),
label: lane.label.clone(),
events: lane
.events
.iter()
.filter(|event| event.event_kind != "sample")
.cloned()
.collect(),
})
.filter(|lane| !lane.events.is_empty())
.collect()
}
fn refresh_flamegraph_viewer_from_manifest(
run_output_dir: &Path,
manifest: &ProfileManifest,
) -> Result<()> {
let Some(viewer_path) = artifact_path_by_label(
&manifest.native_capture.processed_artifacts,
"flamegraph-viewer",
)
.map(|path| resolve_run_relative_path(run_output_dir, path)) else {
return Ok(());
};
let Some(processed_root) = viewer_path.parent() else {
return Ok(());
};
let Some(full_svg_path) = artifact_path_by_label(
&manifest.native_capture.processed_artifacts,
"flamegraph-full-svg",
)
.map(|path| resolve_run_relative_path(run_output_dir, path)) else {
return Ok(());
};
let Some(focused_svg_path) = artifact_path_by_label(
&manifest.native_capture.processed_artifacts,
"flamegraph-focused-svg",
)
.map(|path| resolve_run_relative_path(run_output_dir, path)) else {
return Ok(());
};
let Some(full_folded_path) = artifact_path_by_label(
&manifest.native_capture.processed_artifacts,
"collapsed-stacks",
)
.map(|path| resolve_run_relative_path(run_output_dir, path)) else {
return Ok(());
};
if !full_svg_path.exists() || !focused_svg_path.exists() || !full_folded_path.exists() {
return Ok(());
}
let full_svg = std::fs::read_to_string(&full_svg_path)
.with_context(|| format!("reading {}", full_svg_path.display()))?;
let focused_svg = std::fs::read_to_string(&focused_svg_path)
.with_context(|| format!("reading {}", focused_svg_path.display()))?;
let full_folded = std::fs::read_to_string(&full_folded_path)
.with_context(|| format!("reading {}", full_folded_path.display()))?;
let focused = derive_benchmark_focused_folded_stacks(
&full_folded,
benchmark_anchors_for_backend(manifest.backend),
);
let focused_warning = if focused.folded.trim().is_empty() {
Some(
"No benchmark anchor frames were detected; the benchmark-only view is falling back to the full-process flamegraph."
.to_string(),
)
} else {
None
};
let focused_folded = if focused.folded.trim().is_empty() {
full_folded.as_str()
} else {
focused.folded.as_str()
};
let harness_timeline = build_viewer_harness_timeline(manifest);
let timeline_payload =
prepare_viewer_timeline_payload(run_output_dir, processed_root, manifest)?;
let source_links = load_viewer_source_links(run_output_dir, processed_root, manifest)?;
let browser_title =
flamegraph_browser_title(project_name_from_workspace_path(run_output_dir).as_deref());
let viewer_html = render_flamegraph_viewer_html(FlamegraphViewerDoc {
title: flamegraph_title_for_manifest(manifest),
browser_title,
full_svg_document: full_svg,
focused_svg_document: focused_svg,
full_summary: summarize_folded_stacks(
&full_folded,
count_folded_stack_lines(&full_folded),
0,
None,
),
focused_summary: summarize_folded_stacks(
focused_folded,
focused.matched_stack_count,
focused.excluded_stack_count,
focused_warning,
),
sampled_duration_secs: manifest
.capture_metadata
.sample_duration_secs
.map(|value| value as f64),
run_metadata: build_viewer_run_metadata(manifest),
harness_timeline,
timeline_lanes: timeline_payload.lanes.clone(),
timeline_total_duration_ns: timeline_payload.total_duration_ns,
timeline_note: timeline_payload.note,
default_mode: FlamegraphMode::Focused,
artifact_links: build_viewer_artifact_links(
run_output_dir,
processed_root,
manifest,
timeline_payload.trace_path.as_deref(),
),
source_links: source_links.clone(),
source_link_note: viewer_source_link_note(manifest, &source_links),
});
std::fs::write(&viewer_path, viewer_html)
.with_context(|| format!("writing {}", viewer_path.display()))?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct DifferentialViewerManifest {
run_id: String,
baseline: String,
candidate: String,
#[serde(default)]
target: Option<MobileTarget>,
#[serde(default)]
function: Option<String>,
#[serde(default)]
backend: Option<ProfileBackend>,
#[serde(default)]
normalize: bool,
viewer_path: String,
#[serde(default)]
summary_path: Option<String>,
warnings: Vec<String>,
modes: Vec<DifferentialViewerModeRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct DifferentialViewerModeRecord {
mode: String,
#[serde(default)]
baseline_folded: Option<String>,
#[serde(default)]
candidate_folded: Option<String>,
diff_folded: String,
flamegraph_svg: String,
#[serde(default)]
baseline_samples: Option<u64>,
#[serde(default)]
candidate_samples: Option<u64>,
}
fn refresh_differential_flamegraph_viewer_from_manifest_path(
diff_manifest_path: &Path,
) -> Result<()> {
let diff_manifest_dir = diff_manifest_path
.parent()
.context("differential manifest path must have a parent directory")?;
let diff_manifest: DifferentialViewerManifest = serde_json::from_slice(
&std::fs::read(diff_manifest_path)
.with_context(|| format!("reading {}", diff_manifest_path.display()))?,
)
.with_context(|| format!("parsing {}", diff_manifest_path.display()))?;
let baseline_path = resolve_external_manifest_path(diff_manifest_dir, &diff_manifest.baseline);
let candidate_path =
resolve_external_manifest_path(diff_manifest_dir, &diff_manifest.candidate);
let viewer_path = resolve_external_manifest_path(diff_manifest_dir, &diff_manifest.viewer_path);
let processed_root = viewer_path
.parent()
.context("differential viewer path must have a parent directory")?;
let baseline_manifest = load_profile_manifest(&baseline_path)?;
let candidate_manifest = load_profile_manifest(&candidate_path)?;
let candidate_run_dir = candidate_path
.parent()
.context("candidate manifest path must have a parent directory")?;
let full_mode = differential_mode_record(&diff_manifest, "full")?;
let focused_mode = differential_mode_record(&diff_manifest, "focused")?;
let full_folded_path =
resolve_external_manifest_path(diff_manifest_dir, &full_mode.diff_folded);
let focused_folded_path =
resolve_external_manifest_path(diff_manifest_dir, &focused_mode.diff_folded);
let full_svg_path =
resolve_external_manifest_path(diff_manifest_dir, &full_mode.flamegraph_svg);
let focused_svg_path =
resolve_external_manifest_path(diff_manifest_dir, &focused_mode.flamegraph_svg);
let full_folded = std::fs::read_to_string(&full_folded_path)
.with_context(|| format!("reading {}", full_folded_path.display()))?;
let focused_folded = std::fs::read_to_string(&focused_folded_path)
.with_context(|| format!("reading {}", focused_folded_path.display()))?;
let full_svg = std::fs::read_to_string(&full_svg_path)
.with_context(|| format!("reading {}", full_svg_path.display()))?;
let focused_svg = std::fs::read_to_string(&focused_svg_path)
.with_context(|| format!("reading {}", focused_svg_path.display()))?;
let shared_warning = diff_manifest.warnings.first().cloned();
let full_summary = summarize_folded_stacks(
&full_folded,
count_folded_stack_lines(&full_folded),
0,
shared_warning.clone(),
);
let focused_summary = summarize_folded_stacks(
&focused_folded,
count_folded_stack_lines(&focused_folded),
0,
shared_warning,
);
let harness_timeline = build_viewer_harness_timeline(&candidate_manifest);
let timeline_payload =
prepare_viewer_timeline_payload(candidate_run_dir, processed_root, &candidate_manifest)?;
let source_links =
load_viewer_source_links(candidate_run_dir, processed_root, &candidate_manifest)?;
let browser_title =
flamegraph_browser_title(project_name_from_workspace_path(&candidate_path).as_deref());
let viewer_html = render_flamegraph_viewer_html(FlamegraphViewerDoc {
title: "Differential Flamegraph".into(),
browser_title,
full_svg_document: full_svg,
focused_svg_document: focused_svg,
full_summary,
focused_summary,
sampled_duration_secs: candidate_manifest
.capture_metadata
.sample_duration_secs
.map(|value| value as f64),
run_metadata: build_differential_viewer_run_metadata(
&diff_manifest.run_id,
&baseline_manifest,
&candidate_manifest,
),
harness_timeline,
timeline_lanes: timeline_payload.lanes.clone(),
timeline_total_duration_ns: timeline_payload.total_duration_ns,
timeline_note: timeline_payload.note,
default_mode: FlamegraphMode::Focused,
artifact_links: build_differential_viewer_artifact_links(
processed_root,
candidate_run_dir,
&candidate_manifest,
&full_folded_path,
&focused_folded_path,
&full_svg_path,
&focused_svg_path,
timeline_payload.trace_path.as_deref(),
),
source_links: source_links.clone(),
source_link_note: viewer_source_link_note(&candidate_manifest, &source_links),
});
std::fs::write(&viewer_path, viewer_html)
.with_context(|| format!("writing {}", viewer_path.display()))?;
Ok(())
}
fn differential_mode_record<'a>(
manifest: &'a DifferentialViewerManifest,
mode: &str,
) -> Result<&'a DifferentialViewerModeRecord> {
manifest
.modes
.iter()
.find(|record| record.mode == mode)
.with_context(|| format!("missing `{mode}` mode in differential viewer manifest"))
}
fn resolve_external_manifest_path(base_dir: &Path, path: &str) -> PathBuf {
let path = Path::new(path);
if path.is_absolute() {
path.to_path_buf()
} else {
let manifest_relative = base_dir.join(path);
if manifest_relative.exists() {
return manifest_relative;
}
if let Some(workspace_root) = find_workspace_root(base_dir) {
let workspace_relative = workspace_root.join(path);
if workspace_relative.exists() || !manifest_relative.exists() {
return workspace_relative;
}
}
manifest_relative
}
}
fn find_workspace_root(start: &Path) -> Option<PathBuf> {
for ancestor in start.ancestors() {
if ancestor.join("Cargo.toml").exists() {
return Some(ancestor.to_path_buf());
}
}
None
}
fn build_differential_viewer_run_metadata(
diff_run_id: &str,
baseline_manifest: &ProfileManifest,
candidate_manifest: &ProfileManifest,
) -> Vec<ViewerMetadataItem> {
let mut metadata = Vec::new();
metadata.push(ViewerMetadataItem {
label: "Baseline Run".into(),
value: baseline_manifest.run_id.clone(),
});
metadata.push(ViewerMetadataItem {
label: "Candidate Run".into(),
value: candidate_manifest.run_id.clone(),
});
metadata.push(ViewerMetadataItem {
label: "Target".into(),
value: match candidate_manifest.target {
MobileTarget::Android => "android".into(),
MobileTarget::Ios => "ios".into(),
},
});
metadata.push(ViewerMetadataItem {
label: "Backend".into(),
value: match candidate_manifest.backend {
ProfileBackend::AndroidNative => "android-native".into(),
ProfileBackend::IosInstruments => "ios-instruments".into(),
ProfileBackend::RustTracing => "rust-tracing".into(),
ProfileBackend::Auto => "auto".into(),
},
});
metadata.push(ViewerMetadataItem {
label: "Benchmark".into(),
value: candidate_manifest.function.clone(),
});
if let Some(device) = &candidate_manifest.capture_metadata.device {
metadata.push(ViewerMetadataItem {
label: "Device".into(),
value: device.clone(),
});
}
if let Some(os) = &candidate_manifest.capture_metadata.os {
metadata.push(ViewerMetadataItem {
label: "OS".into(),
value: os.clone(),
});
}
if candidate_manifest
.capture_metadata
.benchmark_iterations
.is_some()
|| candidate_manifest
.capture_metadata
.benchmark_warmup
.is_some()
{
let measured = candidate_manifest
.capture_metadata
.benchmark_iterations
.unwrap_or(0);
let warmup = candidate_manifest
.capture_metadata
.benchmark_warmup
.unwrap_or(0);
metadata.push(ViewerMetadataItem {
label: "Iterations".into(),
value: format!("{measured} measured / {warmup} warmup"),
});
}
let mut capture_parts = Vec::new();
if let Some(method) = &candidate_manifest.capture_metadata.capture_method {
capture_parts.push(method.clone());
}
if let Some(mode) = candidate_manifest.capture_metadata.warmup_mode {
capture_parts.push(mode.as_str().to_string());
}
if let Some(duration) = candidate_manifest.capture_metadata.sample_duration_secs {
capture_parts.push(format!("{duration}s sample"));
}
if !capture_parts.is_empty() {
metadata.push(ViewerMetadataItem {
label: "Capture".into(),
value: capture_parts.join(" · "),
});
}
metadata.push(ViewerMetadataItem {
label: "Run ID".into(),
value: diff_run_id.to_string(),
});
metadata
}
fn build_differential_viewer_artifact_links(
processed_root: &Path,
candidate_run_dir: &Path,
candidate_manifest: &ProfileManifest,
full_folded_path: &Path,
focused_folded_path: &Path,
full_svg_path: &Path,
focused_svg_path: &Path,
trace_path: Option<&Path>,
) -> Vec<ViewerArtifactLink> {
let mut links = Vec::new();
for label in [
"sample",
"simpleperf",
"trace-events",
"native-report",
"frame-locations",
] {
if let Some(path) =
artifact_path_by_label(&candidate_manifest.native_capture.raw_artifacts, label)
.or_else(|| {
artifact_path_by_label(
&candidate_manifest.native_capture.processed_artifacts,
label,
)
})
.map(|path| resolve_run_relative_path(candidate_run_dir, path))
.filter(|path| path.exists())
{
links.push(ViewerArtifactLink::new(
artifact_display_label(label),
relative_path_from(processed_root, &path),
));
}
}
links.push(ViewerArtifactLink::new(
"Full folded stacks",
relative_path_from(processed_root, full_folded_path),
));
links.push(ViewerArtifactLink::new(
"Benchmark-focused folded stacks",
relative_path_from(processed_root, focused_folded_path),
));
links.push(ViewerArtifactLink::new(
"Full-process SVG",
relative_path_from(processed_root, full_svg_path),
));
links.push(ViewerArtifactLink::new(
"Benchmark-only SVG",
relative_path_from(processed_root, focused_svg_path),
));
if let Some(path) = trace_path {
links.push(ViewerArtifactLink::new(
"Chronological trace",
relative_path_from(processed_root, path),
));
}
if let Some(path) = candidate_manifest.semantic_profile.spans_path.as_deref() {
links.push(ViewerArtifactLink::new(
"Semantic phases",
relative_path_from(
processed_root,
&resolve_run_relative_path(candidate_run_dir, path),
),
));
}
if let Some(path) = candidate_manifest.semantic_profile.timeline_path.as_deref() {
links.push(ViewerArtifactLink::new(
"Harness timeline",
relative_path_from(
processed_root,
&resolve_run_relative_path(candidate_run_dir, path),
),
));
}
links
}
fn artifact_path_by_label<'a>(artifacts: &'a [ArtifactRecord], label: &str) -> Option<&'a Path> {
artifacts
.iter()
.find(|artifact| artifact.label == label)
.map(|artifact| artifact.path.as_path())
}
fn benchmark_anchors_for_backend(backend: ProfileBackend) -> &'static [&'static str] {
match backend {
ProfileBackend::AndroidNative => ANDROID_BENCHMARK_ANCHORS,
ProfileBackend::IosInstruments => IOS_BENCHMARK_ANCHORS,
ProfileBackend::RustTracing | ProfileBackend::Auto => &[],
}
}
fn flamegraph_browser_title(project_name: Option<&str>) -> String {
match project_name.map(str::trim).filter(|name| !name.is_empty()) {
Some(name) => format!("Mobench Flamegraph - {name}"),
None => "Mobench Flamegraph".into(),
}
}
fn project_name_from_workspace_path(path: &Path) -> Option<String> {
find_workspace_root(path)
.and_then(|root| {
root.file_name()
.map(|name| name.to_string_lossy().into_owned())
})
.or_else(|| {
repo_root().ok().and_then(|root| {
root.file_name()
.map(|name| name.to_string_lossy().into_owned())
.filter(|name| !name.is_empty())
})
})
}
fn flamegraph_title_for_manifest(manifest: &ProfileManifest) -> String {
match manifest.backend {
ProfileBackend::AndroidNative => "Android Native Profile".into(),
ProfileBackend::IosInstruments => "iOS Native Profile".into(),
ProfileBackend::RustTracing => "Rust Tracing Profile".into(),
ProfileBackend::Auto => "Native Profile".into(),
}
}
fn build_viewer_run_metadata(manifest: &ProfileManifest) -> Vec<ViewerMetadataItem> {
let mut metadata = Vec::new();
metadata.push(ViewerMetadataItem {
label: "Run ID".into(),
value: manifest.run_id.clone(),
});
metadata.push(ViewerMetadataItem {
label: "Target".into(),
value: match manifest.target {
MobileTarget::Android => "android".into(),
MobileTarget::Ios => "ios".into(),
},
});
metadata.push(ViewerMetadataItem {
label: "Backend".into(),
value: match manifest.backend {
ProfileBackend::AndroidNative => "android-native".into(),
ProfileBackend::IosInstruments => "ios-instruments".into(),
ProfileBackend::RustTracing => "rust-tracing".into(),
ProfileBackend::Auto => "auto".into(),
},
});
metadata.push(ViewerMetadataItem {
label: "Benchmark".into(),
value: manifest.function.clone(),
});
if let Some(device) = &manifest.capture_metadata.device {
metadata.push(ViewerMetadataItem {
label: "Device".into(),
value: device.clone(),
});
}
if let Some(os) = &manifest.capture_metadata.os {
metadata.push(ViewerMetadataItem {
label: "OS".into(),
value: os.clone(),
});
}
if manifest.capture_metadata.benchmark_iterations.is_some()
|| manifest.capture_metadata.benchmark_warmup.is_some()
{
let measured = manifest.capture_metadata.benchmark_iterations.unwrap_or(0);
let warmup = manifest.capture_metadata.benchmark_warmup.unwrap_or(0);
metadata.push(ViewerMetadataItem {
label: "Iterations".into(),
value: format!("{measured} measured / {warmup} warmup"),
});
}
let mut capture_parts = Vec::new();
if let Some(method) = &manifest.capture_metadata.capture_method {
capture_parts.push(method.clone());
}
if let Some(mode) = manifest.capture_metadata.warmup_mode {
capture_parts.push(mode.as_str().to_string());
}
if let Some(duration) = manifest.capture_metadata.sample_duration_secs {
capture_parts.push(format!("{duration}s sample"));
}
if !capture_parts.is_empty() {
metadata.push(ViewerMetadataItem {
label: "Capture".into(),
value: capture_parts.join(" · "),
});
}
metadata
}
fn build_viewer_harness_timeline(manifest: &ProfileManifest) -> Vec<ViewerHarnessTimelineSpan> {
manifest
.semantic_profile
.harness_timeline
.iter()
.map(|span| ViewerHarnessTimelineSpan {
phase: span.phase.clone(),
start_offset_ns: span.start_offset_ns,
end_offset_ns: span.end_offset_ns,
iteration: span.iteration,
})
.collect()
}
fn build_harness_only_viewer_timeline_lanes(manifest: &ProfileManifest) -> Vec<ViewerTraceLane> {
let harness_events: Vec<ViewerTraceEvent> = manifest
.semantic_profile
.harness_timeline
.iter()
.map(|span| ViewerTraceEvent {
event_kind: "span".into(),
start_offset_ns: span.start_offset_ns,
end_offset_ns: Some(span.end_offset_ns),
frames: Vec::new(),
phase: Some(span.phase.clone()),
iteration: span.iteration,
})
.collect();
let mut lanes = Vec::new();
if !harness_events.is_empty() {
lanes.push(ViewerTraceLane {
id: "harness".into(),
label: "Harness".into(),
events: harness_events,
});
}
lanes
}
fn compute_timeline_total_duration_ns(
harness_timeline: &[ViewerHarnessTimelineSpan],
sampled_duration_secs: Option<u64>,
) -> Option<u64> {
harness_timeline
.iter()
.map(|span| span.end_offset_ns)
.max()
.or_else(|| sampled_duration_secs.map(|value| value.saturating_mul(1_000_000_000)))
}
fn trace_lanes_have_sample_events(lanes: &[ViewerTraceLane]) -> bool {
lanes.iter().any(|lane| {
lane.events
.iter()
.any(|event| event.event_kind == "sample" && !event.frames.is_empty())
})
}
fn build_timeline_note(lanes: &[ViewerTraceLane]) -> Option<String> {
if lanes.is_empty() {
return Some(
"Timeline mode becomes available once exact harness intervals or chronological trace events are recorded."
.into(),
);
}
if trace_lanes_have_sample_events(lanes) {
Some(
"Timeline mode shows exact harness chronology plus recorded stack samples. Aggregate flamegraph views remain full-session hotspot summaries."
.into(),
)
} else {
Some(
"Harness-only timeline. This capture recorded exact phase timing, but it does not include time-ordered stack samples for the selected interval."
.into(),
)
}
}
fn write_chronological_trace_sidecar(
trace_path: &Path,
manifest: &ProfileManifest,
lanes: &[ViewerTraceLane],
total_duration_ns: Option<u64>,
source_kind: &str,
) -> Result<Option<PathBuf>> {
let Some(total_duration_ns) = total_duration_ns else {
return Ok(None);
};
if lanes.is_empty() {
return Ok(None);
}
let trace = ChronologicalTraceRecord {
source: ChronologicalTraceSourceRecord {
kind: source_kind.into(),
profiler: manifest
.capture_metadata
.capture_method
.clone()
.unwrap_or_else(|| match manifest.backend {
ProfileBackend::AndroidNative => "simpleperf".into(),
ProfileBackend::IosInstruments => "sample".into(),
ProfileBackend::RustTracing => "trace-events".into(),
ProfileBackend::Auto => "unknown".into(),
}),
origin: match manifest.provider {
ProfileProvider::Local => "local".into(),
ProfileProvider::Browserstack => "browserstack".into(),
},
},
total_duration_ns,
lanes: lanes.to_vec(),
};
if let Some(parent) = trace_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&trace_path, serde_json::to_vec_pretty(&trace)?)
.with_context(|| format!("writing {}", trace_path.display()))?;
Ok(Some(trace_path.to_path_buf()))
}
fn build_viewer_artifact_links(
run_output_dir: &Path,
processed_root: &Path,
manifest: &ProfileManifest,
trace_path: Option<&Path>,
) -> Vec<ViewerArtifactLink> {
let mut links = Vec::new();
let artifact_order = [
"simpleperf",
"sample",
"trace-events",
"native-report",
"frame-locations",
"collapsed-stacks",
"benchmark-focused-stacks",
"flamegraph-full-svg",
"flamegraph-focused-svg",
];
for label in artifact_order {
if let Some(path) = artifact_path_by_label(&manifest.native_capture.raw_artifacts, label)
.or_else(|| artifact_path_by_label(&manifest.native_capture.processed_artifacts, label))
.map(|path| resolve_run_relative_path(run_output_dir, path))
.filter(|path| path.exists())
{
links.push(ViewerArtifactLink::new(
artifact_display_label(label),
relative_path_from(processed_root, &path),
));
}
}
if let Some(path) = trace_path {
links.push(ViewerArtifactLink::new(
"Chronological trace",
relative_path_from(processed_root, path),
));
}
if let Some(path) = manifest.semantic_profile.spans_path.as_deref() {
links.push(ViewerArtifactLink::new(
"Semantic phases",
relative_path_from(
processed_root,
&resolve_run_relative_path(run_output_dir, path),
),
));
}
if let Some(path) = manifest.semantic_profile.timeline_path.as_deref() {
links.push(ViewerArtifactLink::new(
"Harness timeline",
relative_path_from(
processed_root,
&resolve_run_relative_path(run_output_dir, path),
),
));
}
links
}
fn artifact_display_label(label: &str) -> String {
match label {
"simpleperf" => "Raw sample.perf".into(),
"sample" => "Raw sample.txt".into(),
"trace-events" => "Raw trace-events.json".into(),
"native-report" => "Native report".into(),
"frame-locations" => "Frame locations".into(),
"collapsed-stacks" => "Full folded stacks".into(),
"benchmark-focused-stacks" => "Benchmark-focused folded stacks".into(),
"flamegraph-full-svg" => "Full-process SVG".into(),
"flamegraph-focused-svg" => "Benchmark-only SVG".into(),
_ => label.to_string(),
}
}
fn resolve_run_relative_path(run_output_dir: &Path, path: &Path) -> PathBuf {
if path.is_absolute() || path.starts_with(run_output_dir) {
path.to_path_buf()
} else {
run_output_dir.join(path)
}
}
fn relative_path_from(base_dir: &Path, target: &Path) -> String {
let base_components: Vec<_> = base_dir.components().collect();
let target_components: Vec<_> = target.components().collect();
let mut shared = 0;
while shared < base_components.len()
&& shared < target_components.len()
&& base_components[shared] == target_components[shared]
{
shared += 1;
}
let mut relative = PathBuf::new();
for _ in shared..base_components.len() {
relative.push("..");
}
for component in &target_components[shared..] {
relative.push(component.as_os_str());
}
if relative.as_os_str().is_empty() {
".".into()
} else {
relative.to_string_lossy().replace('\\', "/")
}
}
fn default_source_link_note(manifest: &ProfileManifest) -> Option<String> {
match manifest.backend {
ProfileBackend::IosInstruments => Some(
"Source links are unavailable for simulator-host `sample` sessions in this release."
.into(),
),
ProfileBackend::AndroidNative => Some(
"Source links are unavailable because this capture did not record Android frame location metadata.".into(),
),
ProfileBackend::RustTracing => Some(
"Source links are unavailable for trace-events output in this release.".into(),
),
ProfileBackend::Auto => None,
}
}
fn viewer_source_link_note(
manifest: &ProfileManifest,
source_links: &[FrameSourceLink],
) -> Option<String> {
if source_links.is_empty() {
default_source_link_note(manifest)
} else {
None
}
}
fn load_viewer_source_links(
run_output_dir: &Path,
processed_root: &Path,
manifest: &ProfileManifest,
) -> Result<Vec<FrameSourceLink>> {
let Some(sidecar_path) = artifact_path_by_label(
&manifest.native_capture.processed_artifacts,
"frame-locations",
)
.map(|path| resolve_run_relative_path(run_output_dir, path)) else {
return Ok(Vec::new());
};
if !sidecar_path.exists() {
return Ok(Vec::new());
}
let records: Vec<FrameLocationRecord> = serde_json::from_slice(
&std::fs::read(&sidecar_path)
.with_context(|| format!("reading {}", sidecar_path.display()))?,
)
.with_context(|| format!("parsing {}", sidecar_path.display()))?;
let repo_root = repo_root().ok();
Ok(records
.into_iter()
.filter_map(|record| {
frame_location_record_to_source_link(processed_root, repo_root.as_deref(), record)
})
.collect())
}
fn frame_location_record_to_source_link(
processed_root: &Path,
repo_root: Option<&Path>,
record: FrameLocationRecord,
) -> Option<FrameSourceLink> {
let absolute_path = if record.source_path.is_absolute() {
record.source_path.clone()
} else if let Some(root) = repo_root {
root.join(&record.source_path)
} else {
record.source_path.clone()
};
let display_path = if let Some(root) = repo_root {
absolute_path
.strip_prefix(root)
.map(Path::to_path_buf)
.unwrap_or_else(|_| absolute_path.clone())
} else {
absolute_path.clone()
};
let href = format!(
"{}#L{}",
relative_path_from(processed_root, &absolute_path),
record.line
);
Some(FrameSourceLink {
frame: record.frame,
location: format!("{}:{}", display_path.display(), record.line),
href,
})
}
pub fn cmd_profile_summarize(args: &ProfileSummarizeArgs) -> Result<()> {
let rendered = cmd_profile_summarize_for_test(args)?;
if let Some(path) = &args.output {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, rendered.as_bytes())?;
} else {
println!("{rendered}");
}
Ok(())
}
pub fn cmd_profile_diff(args: &ProfileDiffArgs) -> Result<()> {
let baseline_manifest = load_profile_manifest(&args.baseline)?;
let candidate_manifest = load_profile_manifest(&args.candidate)?;
validate_profile_diff_inputs(
&args.baseline,
&baseline_manifest,
&args.candidate,
&candidate_manifest,
)?;
let baseline_run_dir = args
.baseline
.parent()
.context("baseline manifest path must have a parent directory")?;
let candidate_run_dir = args
.candidate
.parent()
.context("candidate manifest path must have a parent directory")?;
let diff_run_id = format!(
"{}--vs--{}",
baseline_manifest.run_id, candidate_manifest.run_id
);
let diff_run_dir = args.output_dir.join(&diff_run_id);
let processed_root = diff_run_dir.join("artifacts/processed");
std::fs::create_dir_all(&processed_root)?;
let full_mode = build_profile_diff_mode(
baseline_run_dir,
&baseline_manifest,
candidate_run_dir,
&candidate_manifest,
"full",
"collapsed-stacks",
processed_root.join("diff.full.folded"),
processed_root.join("flamegraph.full.svg"),
args.normalize,
)?;
let focused_mode = build_profile_diff_mode(
baseline_run_dir,
&baseline_manifest,
candidate_run_dir,
&candidate_manifest,
"focused",
"benchmark-focused-stacks",
processed_root.join("diff.focused.folded"),
processed_root.join("flamegraph.focused.svg"),
args.normalize,
)?;
let viewer_path = processed_root.join("flamegraph.html");
let summary_path = diff_run_dir.join("summary.md");
let diff_manifest = DifferentialViewerManifest {
run_id: diff_run_id,
baseline: path_string(&args.baseline),
candidate: path_string(&args.candidate),
target: Some(candidate_manifest.target),
function: Some(candidate_manifest.function.clone()),
backend: Some(candidate_manifest.backend),
normalize: args.normalize,
viewer_path: path_string(&viewer_path),
summary_path: Some(path_string(&summary_path)),
warnings: vec![
"Differential flamegraph colors: red = hotter in candidate, blue = hotter in baseline. Frame widths follow candidate sample counts."
.into(),
],
modes: vec![full_mode, focused_mode],
};
let diff_manifest_path = diff_run_dir.join("profile-diff.json");
write_differential_manifest(&diff_manifest_path, &diff_manifest)?;
refresh_differential_flamegraph_viewer_from_manifest_path(&diff_manifest_path)?;
let summary = render_profile_diff_markdown(&diff_manifest);
std::fs::write(&summary_path, summary.as_bytes())
.with_context(|| format!("writing {}", summary_path.display()))?;
std::fs::create_dir_all(&args.output_dir)?;
write_differential_manifest(&args.output_dir.join("profile-diff.json"), &diff_manifest)?;
std::fs::write(args.output_dir.join("summary.md"), summary.as_bytes())?;
println!(
"Differential profile written to {}",
diff_manifest_path.display()
);
println!("Differential summary written to {}", summary_path.display());
println!("Differential viewer written to {}", viewer_path.display());
Ok(())
}
fn validate_profile_diff_inputs(
baseline_path: &Path,
baseline_manifest: &ProfileManifest,
candidate_path: &Path,
candidate_manifest: &ProfileManifest,
) -> Result<()> {
if baseline_manifest.target != candidate_manifest.target {
bail!(
"profile diff requires the same target on both sides, got `{}` from {} and `{}` from {}",
baseline_manifest.target.as_str(),
baseline_path.display(),
candidate_manifest.target.as_str(),
candidate_path.display()
);
}
if baseline_manifest.backend != candidate_manifest.backend {
bail!(
"profile diff requires the same backend on both sides, got `{:?}` from {} and `{:?}` from {}",
baseline_manifest.backend,
baseline_path.display(),
candidate_manifest.backend,
candidate_path.display()
);
}
if baseline_manifest.function != candidate_manifest.function {
bail!(
"profile diff requires the same benchmark function on both sides, got `{}` from {} and `{}` from {}",
baseline_manifest.function,
baseline_path.display(),
candidate_manifest.function,
candidate_path.display()
);
}
Ok(())
}
fn build_profile_diff_mode(
baseline_run_dir: &Path,
baseline_manifest: &ProfileManifest,
candidate_run_dir: &Path,
candidate_manifest: &ProfileManifest,
mode: &str,
artifact_label: &str,
diff_folded_path: PathBuf,
flamegraph_svg_path: PathBuf,
normalize: bool,
) -> Result<DifferentialViewerModeRecord> {
let baseline_folded_path =
resolve_required_processed_artifact(baseline_run_dir, baseline_manifest, artifact_label)?;
let candidate_folded_path =
resolve_required_processed_artifact(candidate_run_dir, candidate_manifest, artifact_label)?;
if let Some(parent) = diff_folded_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut writer = BufWriter::new(
File::create(&diff_folded_path)
.with_context(|| format!("creating {}", diff_folded_path.display()))?,
);
inferno::differential::from_files(
inferno::differential::Options {
normalize,
strip_hex: false,
},
&baseline_folded_path,
&candidate_folded_path,
&mut writer,
)
.with_context(|| format!("diffing folded stacks for `{mode}` mode"))?;
let diff_folded = std::fs::read_to_string(&diff_folded_path)
.with_context(|| format!("reading {}", diff_folded_path.display()))?;
let svg = render_standalone_flamegraph_svg(&diff_folded, "Differential Flamegraph")?;
std::fs::write(&flamegraph_svg_path, svg.as_bytes())
.with_context(|| format!("writing {}", flamegraph_svg_path.display()))?;
Ok(DifferentialViewerModeRecord {
mode: mode.into(),
baseline_folded: Some(path_string(&baseline_folded_path)),
candidate_folded: Some(path_string(&candidate_folded_path)),
diff_folded: path_string(&diff_folded_path),
flamegraph_svg: path_string(&flamegraph_svg_path),
baseline_samples: Some(total_samples_in_folded_path(&baseline_folded_path)?),
candidate_samples: Some(total_samples_in_folded_path(&candidate_folded_path)?),
})
}
fn resolve_required_processed_artifact(
run_output_dir: &Path,
manifest: &ProfileManifest,
label: &str,
) -> Result<PathBuf> {
artifact_path_by_label(&manifest.native_capture.processed_artifacts, label)
.map(|path| resolve_run_relative_path(run_output_dir, path))
.filter(|path| path.exists())
.with_context(|| {
format!(
"profile manifest `{}` is missing processed artifact `{label}`",
manifest.run_id
)
})
}
fn total_samples_in_folded_path(path: &Path) -> Result<u64> {
let folded =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
Ok(folded
.lines()
.filter_map(split_folded_stack_line)
.map(|(_, count)| count)
.sum())
}
fn split_folded_stack_line(line: &str) -> Option<(&str, u64)> {
let (stack, count) = line.rsplit_once(' ')?;
if stack.is_empty() || !count.chars().all(|ch| ch.is_ascii_digit()) {
return None;
}
Some((stack, count.parse().ok()?))
}
fn write_differential_manifest(path: &Path, manifest: &DifferentialViewerManifest) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, serde_json::to_vec_pretty(manifest)?)
.with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
fn render_profile_diff_markdown(manifest: &DifferentialViewerManifest) -> String {
let mut markdown = String::new();
let _ = writeln!(markdown, "# Differential Flamegraph Summary");
let _ = writeln!(markdown);
let _ = writeln!(markdown, "- Run ID: `{}`", manifest.run_id);
if let Some(target) = manifest.target {
let _ = writeln!(markdown, "- Target: `{}`", target.as_str());
}
if let Some(function) = &manifest.function {
let _ = writeln!(markdown, "- Function: `{function}`");
}
let _ = writeln!(markdown, "- Baseline: `{}`", manifest.baseline);
let _ = writeln!(markdown, "- Candidate: `{}`", manifest.candidate);
let _ = writeln!(markdown, "- Normalize: `{}`", manifest.normalize);
let _ = writeln!(markdown, "- Viewer: `{}`", manifest.viewer_path);
let _ = writeln!(markdown);
let _ = writeln!(
markdown,
"- Differential semantics: `red = hotter in candidate, blue = hotter in baseline, widths = candidate samples`"
);
if !manifest.warnings.is_empty() {
let _ = writeln!(markdown);
let _ = writeln!(markdown, "## Notes");
let _ = writeln!(markdown);
for warning in &manifest.warnings {
let _ = writeln!(markdown, "- {}", warning);
}
}
let _ = writeln!(markdown);
let _ = writeln!(markdown, "## Modes");
let _ = writeln!(markdown);
for mode in &manifest.modes {
let _ = writeln!(markdown, "### {}", mode.mode);
if let Some(path) = &mode.baseline_folded {
let _ = writeln!(markdown, "- Baseline folded: `{}`", path);
}
if let Some(path) = &mode.candidate_folded {
let _ = writeln!(markdown, "- Candidate folded: `{}`", path);
}
let _ = writeln!(markdown, "- Diff folded: `{}`", mode.diff_folded);
let _ = writeln!(markdown, "- SVG: `{}`", mode.flamegraph_svg);
if let (Some(before), Some(after)) = (mode.baseline_samples, mode.candidate_samples) {
let _ = writeln!(
markdown,
"- Samples: baseline `{}` -> candidate `{}`",
before, after
);
}
let _ = writeln!(markdown);
}
markdown
}
fn path_string(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn capture_status_label(status: CaptureStatus) -> &'static str {
match status {
CaptureStatus::Planned => "planned",
CaptureStatus::Captured => "captured",
CaptureStatus::Partial => "partial",
CaptureStatus::Failed => "failed",
}
}
fn semantic_capture_status_label(status: SemanticCaptureStatus) -> &'static str {
match status {
SemanticCaptureStatus::Planned => "planned",
SemanticCaptureStatus::Captured => "captured",
SemanticCaptureStatus::Partial => "partial",
SemanticCaptureStatus::Failed => "failed",
}
}
#[allow(dead_code)]
pub(crate) fn symbolize_android_folded_stacks_with_resolver<F>(
folded_stacks: &str,
mut resolve: F,
) -> (String, SymbolizationRecord, String)
where
F: FnMut(&str, u64) -> Option<String>,
{
let mut lines = Vec::new();
let mut resolved_frames = 0;
let mut unresolved_frames = 0;
for line in folded_stacks.lines().filter(|line| !line.trim().is_empty()) {
let symbolized =
mobench_sdk::builders::android::symbolize_android_native_stack_line_with_resolver(
line,
|library_name, offset| resolve(library_name, offset),
);
resolved_frames += symbolized.resolved_frames;
unresolved_frames += symbolized.unresolved_frames;
lines.push(symbolized.line);
}
let symbolized_stacks = lines.join("\n");
let status = match (resolved_frames, unresolved_frames) {
(0, 0) => CaptureStatus::Planned,
(_, 0) => CaptureStatus::Captured,
(0, _) => CaptureStatus::Failed,
_ => CaptureStatus::Partial,
};
let mut notes = Vec::new();
if unresolved_frames > 0 {
notes.push("some native frames could not be symbolized".into());
}
let record = SymbolizationRecord {
status,
tool: Some("llvm-addr2line".into()),
resolved_frames,
unresolved_frames,
notes,
};
let report = if symbolized_stacks.is_empty() {
"No native frames were symbolized.".into()
} else {
symbolized_stacks.clone()
};
(symbolized_stacks, record, report)
}
#[allow(dead_code)]
pub(crate) fn symbolize_android_folded_stacks_with_native_libraries<F>(
folded_stacks: &str,
native_libraries: &[NativeLibraryArtifact],
runtime_abi: Option<&str>,
mut resolve: F,
) -> (String, SymbolizationRecord, String)
where
F: FnMut(&Path, u64) -> Option<String>,
{
let runtime_abi = runtime_abi.map(str::to_owned);
symbolize_android_folded_stacks_with_resolver(folded_stacks, |library_name, offset| {
let library_path = resolve_android_native_library_path(
native_libraries,
library_name,
runtime_abi.as_deref(),
)?;
resolve(library_path, offset)
})
}
fn resolve_android_native_library_path<'a>(
native_libraries: &'a [NativeLibraryArtifact],
library_name: &str,
runtime_abi: Option<&str>,
) -> Option<&'a Path> {
match runtime_abi {
Some(runtime_abi) => native_libraries
.iter()
.find(|artifact| artifact.library_name == library_name && artifact.abi == runtime_abi)
.map(|artifact| artifact.unstripped_path.as_path()),
None => {
let mut matching = native_libraries
.iter()
.filter(|artifact| artifact.library_name == library_name);
let artifact = matching.next()?;
if matching.next().is_some() {
return None;
}
Some(artifact.unstripped_path.as_path())
}
}
}
#[allow(dead_code)]
pub(crate) fn write_android_symbolized_outputs(
folded_stacks: &str,
native_libraries: &[NativeLibraryArtifact],
processed_root: &Path,
runtime_abi: Option<&str>,
llvm_addr2line_path: &Path,
) -> Result<SymbolizationRecord> {
let record = write_android_symbolized_outputs_with_resolver(
folded_stacks,
native_libraries,
processed_root,
runtime_abi,
|library_path, offset| {
mobench_sdk::builders::android::resolve_android_native_symbol_with_tool(
llvm_addr2line_path,
library_path,
offset,
)
},
)?;
write_android_frame_location_sidecar(
folded_stacks,
native_libraries,
processed_root,
runtime_abi,
llvm_addr2line_path,
)?;
Ok(record)
}
pub(crate) fn write_android_symbolized_outputs_with_resolver<F>(
folded_stacks: &str,
native_libraries: &[NativeLibraryArtifact],
processed_root: &Path,
runtime_abi: Option<&str>,
resolve: F,
) -> Result<SymbolizationRecord>
where
F: FnMut(&Path, u64) -> Option<String>,
{
std::fs::create_dir_all(processed_root)?;
let (symbolized_stacks, mut record, report) =
symbolize_android_folded_stacks_with_native_libraries(
folded_stacks,
native_libraries,
runtime_abi,
resolve,
);
std::fs::write(processed_root.join("stacks.folded"), &symbolized_stacks)?;
std::fs::write(processed_root.join("native-report.txt"), &report)?;
if let Some(warning) = write_dual_view_flamegraph_bundle(
&symbolized_stacks,
processed_root,
"Android Native Profile",
ANDROID_BENCHMARK_ANCHORS,
"../raw/sample.perf",
"Raw sample.perf",
)? {
record.notes.push(warning);
}
Ok(record)
}
fn write_android_frame_location_sidecar(
folded_stacks: &str,
native_libraries: &[NativeLibraryArtifact],
processed_root: &Path,
runtime_abi: Option<&str>,
llvm_addr2line_path: &Path,
) -> Result<()> {
let records = collect_android_frame_location_records(
folded_stacks,
native_libraries,
runtime_abi,
llvm_addr2line_path,
)?;
if records.is_empty() {
return Ok(());
}
let sidecar_path = processed_root.join("frame-locations.json");
std::fs::write(&sidecar_path, serde_json::to_vec_pretty(&records)?)
.with_context(|| format!("writing {}", sidecar_path.display()))?;
Ok(())
}
fn collect_android_frame_location_records(
folded_stacks: &str,
native_libraries: &[NativeLibraryArtifact],
runtime_abi: Option<&str>,
llvm_addr2line_path: &Path,
) -> Result<Vec<FrameLocationRecord>> {
let mut records = BTreeMap::<String, FrameLocationRecord>::new();
for line in folded_stacks.lines().filter(|line| !line.trim().is_empty()) {
let Some((stack, _count)) = split_folded_stack_line(line) else {
continue;
};
for frame in stack.split(';') {
let Some((library_name, offset)) = parse_android_native_offset_frame(frame) else {
continue;
};
let Some(library_path) =
resolve_android_native_library_path(native_libraries, library_name, runtime_abi)
else {
continue;
};
let Some(record) =
resolve_android_frame_location_with_tool(llvm_addr2line_path, library_path, offset)
else {
continue;
};
records.entry(record.frame.clone()).or_insert(record);
}
}
Ok(records.into_values().collect())
}
fn resolve_android_frame_location_with_tool(
tool_path: &Path,
library_path: &Path,
offset: u64,
) -> Option<FrameLocationRecord> {
let output = Command::new(tool_path)
.args(["-Cfpe"])
.arg(library_path)
.arg(format!("0x{offset:x}"))
.output()
.ok()?;
if !output.status.success() {
return None;
}
parse_android_addr2line_frame_location(&String::from_utf8_lossy(&output.stdout))
}
fn parse_android_addr2line_frame_location(stdout: &str) -> Option<FrameLocationRecord> {
let mut symbol = None::<String>;
let mut location = None::<String>;
for line in stdout.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed == "??" || trimmed.starts_with("?? ") {
continue;
}
if let Some((parsed_symbol, parsed_location)) = trimmed.split_once(" at ") {
symbol = Some(parsed_symbol.trim().to_owned());
if !parsed_location.trim().is_empty() && !parsed_location.starts_with("??") {
location = Some(parsed_location.trim().to_owned());
break;
}
continue;
}
if symbol.is_none() {
symbol = Some(trimmed.to_owned());
continue;
}
if !trimmed.starts_with("??") {
location = Some(trimmed.to_owned());
break;
}
}
let symbol = symbol?;
let location = location?;
let (source_path, line) = parse_addr2line_location(&location)?;
Some(FrameLocationRecord {
frame: symbol,
source_path,
line,
})
}
fn parse_addr2line_location(location: &str) -> Option<(PathBuf, u32)> {
let trimmed = location
.split(" (discriminator ")
.next()
.unwrap_or(location)
.trim();
if trimmed.is_empty() || trimmed.starts_with("??") {
return None;
}
let (path, line) = trimmed.rsplit_once(':')?;
Some((PathBuf::from(path), line.parse().ok()?))
}
fn parse_android_native_offset_frame(frame: &str) -> Option<(&str, u64)> {
let marker = ".so[+";
let marker_index = frame.find(marker)?;
let library_end = marker_index + 3;
let library_name = frame[..library_end].rsplit('/').next()?;
let offset_start = marker_index + marker.len();
let offset_end = frame[offset_start..].find(']')? + offset_start;
let offset_raw = &frame[offset_start..offset_end];
let offset = if let Some(hex) = offset_raw.strip_prefix("0x") {
u64::from_str_radix(hex, 16).ok()?
} else {
offset_raw.parse().ok()?
};
Some((library_name, offset))
}
fn write_dual_view_flamegraph_bundle(
full_folded_stacks: &str,
processed_root: &Path,
title: &str,
anchors: &[&str],
raw_artifact_path: &str,
raw_artifact_label: &str,
) -> Result<Option<String>> {
std::fs::create_dir_all(processed_root)?;
std::fs::write(processed_root.join("stacks.folded"), full_folded_stacks)?;
let focused = derive_benchmark_focused_folded_stacks(full_folded_stacks, anchors);
std::fs::write(
processed_root.join("benchmark.focused.folded"),
&focused.folded,
)?;
let full_svg = render_standalone_flamegraph_svg(full_folded_stacks, title)?;
std::fs::write(processed_root.join("flamegraph.full.svg"), &full_svg)?;
let full_summary = summarize_folded_stacks(
full_folded_stacks,
count_folded_stack_lines(full_folded_stacks),
0,
None,
);
let focused_warning = if focused.folded.trim().is_empty() {
Some(
"No benchmark anchor frames were detected; the benchmark-only view is falling back to the full-process flamegraph."
.to_string(),
)
} else {
None
};
let focused_svg = if focused.folded.trim().is_empty() {
full_svg.clone()
} else {
render_standalone_flamegraph_svg(&focused.folded, title)?
};
std::fs::write(processed_root.join("flamegraph.focused.svg"), &focused_svg)?;
let focused_summary = summarize_folded_stacks(
if focused.folded.trim().is_empty() {
full_folded_stacks
} else {
&focused.folded
},
focused.matched_stack_count,
focused.excluded_stack_count,
focused_warning.clone(),
);
let viewer_html = render_flamegraph_viewer_html(FlamegraphViewerDoc {
title: title.to_string(),
browser_title: flamegraph_browser_title(
project_name_from_workspace_path(processed_root).as_deref(),
),
full_svg_document: full_svg,
focused_svg_document: focused_svg,
full_summary,
focused_summary,
sampled_duration_secs: None,
run_metadata: Vec::new(),
harness_timeline: Vec::new(),
timeline_lanes: Vec::new(),
timeline_total_duration_ns: None,
timeline_note: None,
default_mode: FlamegraphMode::Focused,
artifact_links: vec![
ViewerArtifactLink::new(raw_artifact_label, raw_artifact_path),
ViewerArtifactLink::new("Native report", "native-report.txt"),
ViewerArtifactLink::new("Full folded stacks", "stacks.folded"),
ViewerArtifactLink::new(
"Benchmark-focused folded stacks",
"benchmark.focused.folded",
),
ViewerArtifactLink::new("Full-process SVG", "flamegraph.full.svg"),
ViewerArtifactLink::new("Benchmark-only SVG", "flamegraph.focused.svg"),
],
source_links: Vec::new(),
source_link_note: None,
});
std::fs::write(processed_root.join("flamegraph.html"), viewer_html)?;
Ok(focused_warning)
}
const DEFAULT_PROFILE_ITERATIONS: u32 = 20;
const DEFAULT_PROFILE_WARMUP: u32 = 3;
const DEFAULT_ANDROID_CAPTURE_DURATION_SECS: u64 = 10;
const DEFAULT_ANDROID_WARMUP_TIMEOUT_SECS: u64 = 60;
const DEFAULT_IOS_CAPTURE_DURATION_SECS: u64 = 10;
const DEFAULT_IOS_BENCH_DELAY_MS: u64 = 1_500;
const DEFAULT_IOS_PROFILE_REPEAT_UNTIL_MS: u64 = DEFAULT_IOS_CAPTURE_DURATION_SECS * 1_000;
const DEFAULT_IOS_LOG_TIMEOUT_SECS: u64 = 60;
const ANDROID_BENCH_LOG_MARKER: &str = "BENCH_JSON";
const ANDROID_BENCHMARK_ANCHORS: &[&str] = &[
"sample_fns::run_benchmark",
"mobench_sdk::timing::run_closure",
"uniffi.",
"uniffi_",
"runBenchmark",
];
const IOS_BENCHMARK_ANCHORS: &[&str] = &[
"runBenchmark(spec:)",
"sample_fns::run_benchmark",
"mobench_sdk::timing::run_closure",
"uniffi_",
"BenchRunnerFFI.run(params:)",
];
#[derive(Debug, Clone, PartialEq, Eq)]
struct LocalIosSimulator {
udid: String,
name: String,
os_version: String,
state: String,
}
impl LocalIosSimulator {
fn identifier(&self) -> String {
format!("{}-{}", self.name, self.os_version)
}
}
#[derive(Debug, Clone)]
struct AndroidProfilerToolchain {
sdk_root: PathBuf,
adb_path: PathBuf,
app_profiler_path: PathBuf,
stackcollapse_path: PathBuf,
python_path: PathBuf,
llvm_addr2line_path: PathBuf,
}
fn locate_android_profiler_toolchain() -> Result<AndroidProfilerToolchain> {
let sdk_root = std::env::var_os("ANDROID_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("ANDROID_SDK_ROOT").map(PathBuf::from))
.or_else(|| {
std::env::var_os("ANDROID_NDK_HOME")
.map(PathBuf::from)
.and_then(|ndk_home| ndk_home.parent().and_then(Path::parent).map(PathBuf::from))
})
.or_else(|| {
std::env::var_os("HOME")
.map(PathBuf::from)
.map(|home| home.join("Library").join("Android").join("sdk"))
})
.filter(|path| path.exists())
.context("Android SDK not found; set ANDROID_HOME or ANDROID_SDK_ROOT")?;
let ndk_root = std::env::var_os("ANDROID_NDK_HOME")
.map(PathBuf::from)
.filter(|path| path.exists())
.or_else(|| {
let ndk_dir = sdk_root.join("ndk");
std::fs::read_dir(&ndk_dir).ok().and_then(|entries| {
entries
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.max()
})
})
.context("Android NDK not found; set ANDROID_NDK_HOME or install an NDK under the SDK")?;
let adb_path = sdk_root.join("platform-tools").join("adb");
let app_profiler_path = ndk_root.join("simpleperf").join("app_profiler.py");
let stackcollapse_path = ndk_root.join("simpleperf").join("stackcollapse.py");
let python_path = std::env::var_os("PYTHON")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("python3"));
let llvm_addr2line_override = std::env::var_os("MOBENCH_ANDROID_LLVM_ADDR2LINE")
.or_else(|| std::env::var_os("LLVM_ADDR2LINE"))
.map(PathBuf::from);
let llvm_addr2line_path =
locate_android_llvm_addr2line(&ndk_root, llvm_addr2line_override.as_deref())?;
for path in [&adb_path, &app_profiler_path, &stackcollapse_path] {
if !path.exists() {
bail!(
"required Android profiling tool not found at {}",
path.display()
);
}
}
Ok(AndroidProfilerToolchain {
sdk_root,
adb_path,
app_profiler_path,
stackcollapse_path,
python_path,
llvm_addr2line_path,
})
}
fn locate_android_llvm_addr2line(ndk_root: &Path, override_path: Option<&Path>) -> Result<PathBuf> {
if let Some(path) = override_path {
if path.exists() {
return Ok(path.to_path_buf());
}
bail!(
"explicit llvm-addr2line override does not exist at {}",
path.display()
);
}
let prebuilt_root = ndk_root.join("toolchains").join("llvm").join("prebuilt");
let tool_name = if cfg!(windows) {
"llvm-addr2line.exe"
} else {
"llvm-addr2line"
};
let mut candidates = Vec::new();
if let Ok(entries) = std::fs::read_dir(&prebuilt_root) {
for entry in entries.flatten() {
let candidate = entry.path().join("bin").join(tool_name);
if candidate.exists() {
candidates.push(candidate);
}
}
}
candidates.sort();
candidates
.into_iter()
.next()
.context(
"llvm-addr2line not found under the Android NDK; set MOBENCH_ANDROID_LLVM_ADDR2LINE or LLVM_ADDR2LINE to override",
)
}
fn prepend_path_env(toolchain: &AndroidProfilerToolchain) -> Option<std::ffi::OsString> {
let mut entries = vec![toolchain.sdk_root.join("platform-tools").into_os_string()];
if let Some(existing) = std::env::var_os("PATH") {
entries.push(existing);
}
std::env::join_paths(entries).ok()
}
fn ensure_android_device_connected(toolchain: &AndroidProfilerToolchain) -> Result<()> {
let output = Command::new(&toolchain.adb_path)
.arg("devices")
.output()
.context("failed to run `adb devices`")?;
if !output.status.success() {
bail!("adb devices failed with status {}", output.status);
}
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout
.lines()
.skip(1)
.any(|line| line.split_whitespace().nth(1) == Some("device"))
{
return Ok(());
}
let avd_hint = sdk_root_emulator_hint(&toolchain.sdk_root)
.unwrap_or_else(|| "start an Android emulator or connect a device over adb".into());
bail!("no Android device is connected via adb; {avd_hint}");
}
fn sdk_root_emulator_hint(sdk_root: &Path) -> Option<String> {
let emulator_path = sdk_root.join("emulator").join("emulator");
if !emulator_path.exists() {
return None;
}
let output = Command::new(&emulator_path)
.arg("-list-avds")
.output()
.ok()?;
if !output.status.success() {
return None;
}
let avd = String::from_utf8_lossy(&output.stdout)
.lines()
.find(|line| !line.trim().is_empty())?
.trim()
.to_string();
Some(format!(
"start one with `{}` -avd `{}`",
emulator_path.display(),
avd
))
}
fn read_android_application_id(android_root: &Path) -> Result<String> {
let build_gradle = android_root.join("app").join("build.gradle");
let contents = std::fs::read_to_string(&build_gradle)
.with_context(|| format!("reading {}", build_gradle.display()))?;
for line in contents.lines() {
let trimmed = line.trim();
if let Some(value) = trimmed.strip_prefix("applicationId ") {
return extract_quoted_value(value)
.with_context(|| format!("parsing applicationId from {}", build_gradle.display()));
}
}
bail!("applicationId not found in {}", build_gradle.display())
}
fn extract_quoted_value(source: &str) -> Result<String> {
let start = source.find('"').context("missing opening quote")? + 1;
let end = source[start..]
.find('"')
.map(|index| start + index)
.context("missing closing quote")?;
Ok(source[start..end].to_string())
}
fn run_android_stackcollapse(
toolchain: &AndroidProfilerToolchain,
perf_data_path: &Path,
working_dir: &Path,
) -> Result<String> {
let mut command = Command::new(&toolchain.python_path);
command
.arg(&toolchain.stackcollapse_path)
.arg("-i")
.arg(perf_data_path)
.current_dir(working_dir);
if let Some(path_env) = prepend_path_env(toolchain) {
command.env("PATH", path_env);
}
let output = command
.output()
.with_context(|| format!("running {}", toolchain.stackcollapse_path.display()))?;
if !output.status.success() {
bail!(
"stackcollapse.py failed with status {}\nstdout:\n{}\nstderr:\n{}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn execute_local_android_capture(
args: &ProfileRunArgs,
manifest: &mut ProfileManifest,
) -> Result<()> {
let toolchain = locate_android_profiler_toolchain()?;
ensure_android_device_connected(&toolchain)?;
let runtime_abi = resolve_android_runtime_abi(&toolchain)?;
let layout = resolve_project_layout(ProjectLayoutOptions {
start_dir: None,
project_root: None,
crate_path: args.crate_path.as_deref(),
config_path: args.config.as_deref(),
})?;
load_dotenv_for_layout(&layout);
validate_benchmark_function(&layout, &args.function)?;
let spec = RunSpec {
target: MobileTarget::Android,
function: args.function.clone(),
iterations: DEFAULT_PROFILE_ITERATIONS,
warmup: DEFAULT_PROFILE_WARMUP,
devices: Vec::new(),
ios_completion_timeout_secs: None,
browserstack: None,
ios_xcuitest: None,
};
persist_mobile_spec(&layout, &spec, false)?;
let build = run_android_build(&layout, "", false, false)?;
let android_root = layout.output_dir.join("android");
let package_name = read_android_application_id(&android_root)?;
let warmup_mode = manifest
.capture_metadata
.warmup_mode
.unwrap_or(CaptureWarmupMode::Cold);
let raw_perf_path = manifest
.native_capture
.raw_artifacts
.iter()
.find(|artifact| artifact.label == "simpleperf")
.map(|artifact| artifact.path.clone())
.context("android profile plan missing simpleperf artifact")?;
let processed_root = manifest
.native_capture
.processed_artifacts
.iter()
.find_map(|artifact| artifact.path.parent().map(Path::to_path_buf))
.context("android profile plan missing processed artifact root")?;
if let Some(parent) = raw_perf_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::create_dir_all(&processed_root)?;
let mut install = Command::new(&toolchain.adb_path);
install.arg("install").arg("-r").arg(&build.app_path);
if let Some(path_env) = prepend_path_env(&toolchain) {
install.env("PATH", path_env.clone());
}
let install_output = install
.output()
.with_context(|| format!("installing {}", build.app_path.display()))?;
if !install_output.status.success() {
bail!(
"adb install failed with status {}\nstdout:\n{}\nstderr:\n{}",
install_output.status,
String::from_utf8_lossy(&install_output.stdout),
String::from_utf8_lossy(&install_output.stderr)
);
}
prepare_android_profile_capture(&toolchain, &package_name, warmup_mode)?;
manifest.capture_metadata.warmup_mode = Some(warmup_mode);
if let Err(error) = android_clear_logcat(&toolchain) {
manifest.capture_metadata.warnings.push(format!(
"failed to clear Android logcat before the recorded profile run: {error}"
));
}
let mut profiler = Command::new(&toolchain.python_path);
profiler
.arg(&toolchain.app_profiler_path)
.arg("-p")
.arg(&package_name)
.arg("-a")
.arg(".MainActivity")
.arg("-o")
.arg(&raw_perf_path)
.arg("-r")
.arg(format!(
"-e task-clock:u -f 1000 -g --duration {}",
DEFAULT_ANDROID_CAPTURE_DURATION_SECS
))
.current_dir(
raw_perf_path
.parent()
.context("simpleperf artifact path missing parent directory")?,
);
if let Some(path_env) = prepend_path_env(&toolchain) {
profiler.env("PATH", path_env);
}
let profiler_output = profiler.output().with_context(|| {
format!(
"running Android profiler script {}",
toolchain.app_profiler_path.display()
)
})?;
if !profiler_output.status.success() {
bail!(
"app_profiler.py failed with status {}\nstdout:\n{}\nstderr:\n{}",
profiler_output.status,
String::from_utf8_lossy(&profiler_output.stdout),
String::from_utf8_lossy(&profiler_output.stderr)
);
}
let folded_stacks = run_android_stackcollapse(
&toolchain,
&raw_perf_path,
raw_perf_path
.parent()
.context("simpleperf artifact path missing parent directory")?,
)?;
let symbolization = write_android_symbolized_outputs(
&folded_stacks,
&build.native_libraries,
&processed_root,
runtime_abi.as_deref(),
&toolchain.llvm_addr2line_path,
)?;
manifest.native_capture.symbolization = symbolization.clone();
manifest.native_capture.status = match symbolization.status {
CaptureStatus::Planned | CaptureStatus::Captured => CaptureStatus::Captured,
CaptureStatus::Partial | CaptureStatus::Failed => CaptureStatus::Partial,
};
manifest.capture_metadata.sample_duration_secs = Some(DEFAULT_ANDROID_CAPTURE_DURATION_SECS);
manifest.capture_metadata.capture_method = Some("simpleperf/app_profiler.py".into());
manifest.capture_metadata.warnings.push(format!(
"android profile run used default benchmark settings: iterations={}, warmup={}",
DEFAULT_PROFILE_ITERATIONS, DEFAULT_PROFILE_WARMUP
));
if warmup_mode == CaptureWarmupMode::Warm {
manifest.capture_metadata.warnings.push(
"performed one preparatory warm launch before recording; startup caches are warmed, but per-process bridge initialization may still appear in the captured run".into(),
);
}
match android_read_logcat(&toolchain) {
Ok(logs) => {
let reports = extract_benchmark_reports_from_logs(&logs);
if let Some(report) = select_benchmark_value_for_function(&reports, &args.function) {
merge_semantic_profile_from_bench_report(manifest, report)?;
}
}
Err(error) => {
manifest.capture_metadata.warnings.push(format!(
"semantic phase capture was unavailable because Android logcat could not be read: {error}"
));
}
}
Ok(())
}
fn prepare_android_profile_capture(
toolchain: &AndroidProfilerToolchain,
package_name: &str,
warmup_mode: CaptureWarmupMode,
) -> Result<()> {
android_force_stop(toolchain, package_name)?;
if warmup_mode == CaptureWarmupMode::Cold {
return Ok(());
}
android_clear_logcat(toolchain)?;
android_start_activity(toolchain, package_name, ".MainActivity")?;
wait_for_android_bench_log_marker(
toolchain,
ANDROID_BENCH_LOG_MARKER,
DEFAULT_ANDROID_WARMUP_TIMEOUT_SECS,
)?;
android_force_stop(toolchain, package_name)?;
Ok(())
}
fn android_force_stop(toolchain: &AndroidProfilerToolchain, package_name: &str) -> Result<()> {
let output = Command::new(&toolchain.adb_path)
.args(["shell", "am", "force-stop"])
.arg(package_name)
.output()
.with_context(|| format!("force-stopping Android package {package_name}"))?;
if !output.status.success() {
bail!(
"adb force-stop failed with status {}\nstdout:\n{}\nstderr:\n{}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
fn android_clear_logcat(toolchain: &AndroidProfilerToolchain) -> Result<()> {
let output = Command::new(&toolchain.adb_path)
.args(["logcat", "-c"])
.output()
.context("clearing Android logcat before warm profile capture")?;
if !output.status.success() {
bail!(
"adb logcat -c failed with status {}\nstdout:\n{}\nstderr:\n{}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
fn android_start_activity(
toolchain: &AndroidProfilerToolchain,
package_name: &str,
activity_name: &str,
) -> Result<()> {
let component = format!("{package_name}/{activity_name}");
let output = Command::new(&toolchain.adb_path)
.args(["shell", "am", "start", "-W", "-n"])
.arg(&component)
.output()
.with_context(|| format!("starting Android activity {component}"))?;
if !output.status.success() {
bail!(
"adb am start failed with status {}\nstdout:\n{}\nstderr:\n{}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
fn wait_for_android_bench_log_marker(
toolchain: &AndroidProfilerToolchain,
marker: &str,
timeout_secs: u64,
) -> Result<()> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
while std::time::Instant::now() < deadline {
let logcat = android_read_logcat(toolchain)?;
if android_log_contains_marker(&logcat, marker) {
return Ok(());
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
bail!("timed out waiting for Android warmup marker `{marker}` in logcat");
}
fn android_read_logcat(toolchain: &AndroidProfilerToolchain) -> Result<String> {
let output = Command::new(&toolchain.adb_path)
.args(["logcat", "-d", "-s", "BenchRunner:I", "MainActivity:D"])
.output()
.context("reading Android logcat for warm profile capture")?;
if !output.status.success() {
bail!(
"adb logcat -d failed with status {}\nstdout:\n{}\nstderr:\n{}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn android_log_contains_marker(logcat: &str, marker: &str) -> bool {
logcat.lines().any(|line| line.contains(marker))
}
fn execute_local_ios_capture(args: &ProfileRunArgs, manifest: &mut ProfileManifest) -> Result<()> {
let layout = resolve_project_layout(ProjectLayoutOptions {
start_dir: None,
project_root: None,
crate_path: args.crate_path.as_deref(),
config_path: args.config.as_deref(),
})?;
load_dotenv_for_layout(&layout);
validate_benchmark_function(&layout, &args.function)?;
let spec = RunSpec {
target: MobileTarget::Ios,
function: args.function.clone(),
iterations: DEFAULT_PROFILE_ITERATIONS,
warmup: DEFAULT_PROFILE_WARMUP,
devices: Vec::new(),
ios_completion_timeout_secs: None,
browserstack: None,
ios_xcuitest: None,
};
persist_mobile_spec(&layout, &spec, false)?;
let requested_device = resolve_profile_device(args)?;
let simulator = resolve_local_ios_simulator(requested_device.as_ref())?;
ensure_local_ios_simulator_booted(&simulator)?;
manifest.capture_metadata.device = Some(simulator.identifier());
run_ios_build(&layout, false, false, None)?;
let app_path = build_local_ios_simulator_app(&layout, &simulator)?;
install_local_ios_app(&simulator, &app_path)?;
let bundle_id = local_ios_bundle_identifier(&layout.crate_name);
let warmup_mode = manifest
.capture_metadata
.warmup_mode
.unwrap_or(CaptureWarmupMode::Cold);
manifest.capture_metadata.warmup_mode = Some(warmup_mode);
let raw_sample_path = manifest
.native_capture
.raw_artifacts
.iter()
.find(|artifact| artifact.label == "sample")
.map(|artifact| artifact.path.clone())
.context("ios profile plan missing sample artifact")?;
let processed_root = manifest
.native_capture
.processed_artifacts
.iter()
.find_map(|artifact| artifact.path.parent().map(Path::to_path_buf))
.context("ios profile plan missing processed artifact root")?;
if let Some(parent) = raw_sample_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::create_dir_all(&processed_root)?;
if warmup_mode == CaptureWarmupMode::Warm
&& let Err(error) = run_local_ios_warmup_pass(&simulator, &bundle_id, &raw_sample_path)
{
manifest.capture_metadata.warnings.push(format!(
"failed to complete the preparatory iOS warm launch cleanly; continuing with the recorded run cold-ish: {error}"
));
}
let log_dir = raw_sample_path
.parent()
.context("ios sample artifact missing parent directory")?;
let stdout_path = log_dir.join("app.stdout.log");
let stderr_path = log_dir.join("app.stderr.log");
let app_args = [
format!("--mobench-profile-bench-delay-ms={DEFAULT_IOS_BENCH_DELAY_MS}"),
format!("--mobench-profile-repeat-until-ms={DEFAULT_IOS_PROFILE_REPEAT_UNTIL_MS}"),
format!(
"--mobench-profile-result-hold-ms={}",
DEFAULT_IOS_CAPTURE_DURATION_SECS * 1_000
),
];
let app_env = [
(
"MOBENCH_BENCH_DELAY_MS",
DEFAULT_IOS_BENCH_DELAY_MS.to_string(),
),
(
"MOBENCH_PROFILE_REPEAT_UNTIL_MS",
DEFAULT_IOS_PROFILE_REPEAT_UNTIL_MS.to_string(),
),
(
"MOBENCH_PROFILE_RESULT_HOLD_MS",
(DEFAULT_IOS_CAPTURE_DURATION_SECS * 1_000).to_string(),
),
];
let pid = launch_local_ios_app(
&simulator,
&bundle_id,
&stdout_path,
&stderr_path,
&app_args,
&app_env,
)?;
let sample_result = run_ios_sample_capture(pid, &raw_sample_path);
let log_wait_result = wait_for_ios_log_marker(
&[stdout_path.clone(), stderr_path.clone()],
"BENCH_REPORT_JSON_END",
DEFAULT_IOS_LOG_TIMEOUT_SECS,
);
let terminate_result = terminate_local_ios_app(&simulator, &bundle_id);
sample_result?;
if let Err(error) = log_wait_result {
manifest.capture_metadata.warnings.push(format!(
"semantic phase capture may be incomplete because the iOS benchmark log marker was not observed before timeout: {error}"
));
}
if let Err(error) = terminate_result {
manifest.capture_metadata.warnings.push(format!(
"failed to terminate the profiled iOS simulator app after capture: {error}"
));
}
let sample_output = std::fs::read_to_string(&raw_sample_path)
.with_context(|| format!("reading iOS sample output at {}", raw_sample_path.display()))?;
let symbolization = write_ios_processed_outputs(&sample_output, &processed_root)?;
manifest.native_capture.symbolization = symbolization.clone();
manifest.native_capture.status = match symbolization.status {
CaptureStatus::Planned | CaptureStatus::Captured => CaptureStatus::Captured,
CaptureStatus::Partial | CaptureStatus::Failed => CaptureStatus::Partial,
};
manifest.capture_metadata.sample_duration_secs = Some(DEFAULT_IOS_CAPTURE_DURATION_SECS);
manifest.capture_metadata.capture_method = Some("sample/simctl".into());
manifest.capture_metadata.warnings.push(format!(
"ios profile run used default benchmark settings: iterations={}, warmup={}",
DEFAULT_PROFILE_ITERATIONS, DEFAULT_PROFILE_WARMUP
));
if warmup_mode == CaptureWarmupMode::Warm {
manifest.capture_metadata.warnings.push(
"performed one preparatory warm launch before recording so the measured sample de-emphasizes first-run bridge and UI setup costs".into(),
);
}
manifest.capture_metadata.warnings.push(format!(
"iOS profile capture repeated benchmark work for about {} ms so fast functions remain visible in sampled stacks",
DEFAULT_IOS_PROFILE_REPEAT_UNTIL_MS
));
match read_combined_text_files(&[stdout_path, stderr_path]) {
Ok(logs) => {
if let Some(report) = extract_ios_benchmark_json(&logs) {
merge_semantic_profile_from_bench_report(manifest, &report)?;
} else {
manifest.capture_metadata.warnings.push(
"semantic phase capture was unavailable because the iOS log output did not contain BENCH_REPORT_JSON markers".into(),
);
}
}
Err(error) => {
manifest.capture_metadata.warnings.push(format!(
"semantic phase capture was unavailable because iOS app logs could not be read: {error}"
));
}
}
Ok(())
}
fn run_local_ios_warmup_pass(
simulator: &LocalIosSimulator,
bundle_id: &str,
raw_sample_path: &Path,
) -> Result<()> {
let log_dir = raw_sample_path
.parent()
.context("ios sample artifact missing parent directory")?;
let stdout_path = log_dir.join("warmup.stdout.log");
let stderr_path = log_dir.join("warmup.stderr.log");
let app_args = [String::from("--mobench-profile-warmup-only=1")];
let app_env = [("MOBENCH_PROFILE_WARMUP_ONLY", String::from("1"))];
let _pid = launch_local_ios_app(
simulator,
bundle_id,
&stdout_path,
&stderr_path,
&app_args,
&app_env,
)?;
let wait_result = wait_for_ios_log_marker(
&[stdout_path, stderr_path],
"BENCH_REPORT_JSON_END",
DEFAULT_IOS_LOG_TIMEOUT_SECS,
);
let terminate_result = terminate_local_ios_app(simulator, bundle_id);
wait_result?;
terminate_result?;
Ok(())
}
fn resolve_local_ios_simulator(
requested: Option<&ResolvedProfileDevice>,
) -> Result<LocalIosSimulator> {
let output = Command::new("xcrun")
.args(["simctl", "list", "devices", "available", "--json"])
.output()
.context("listing available iOS simulators with simctl")?;
if !output.status.success() {
bail!(
"xcrun simctl list devices available --json failed with status {}\nstdout:\n{}\nstderr:\n{}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
let value: Value = serde_json::from_slice(&output.stdout)
.context("parsing iOS simulator list JSON from simctl")?;
let mut simulators = Vec::new();
let Some(devices) = value.get("devices").and_then(Value::as_object) else {
bail!("simctl JSON did not contain a `devices` object");
};
for (runtime_key, entries) in devices {
let Some(os_version) = parse_ios_runtime_version(runtime_key) else {
continue;
};
let Some(entries) = entries.as_array() else {
continue;
};
for entry in entries {
let Some(name) = entry.get("name").and_then(Value::as_str) else {
continue;
};
let Some(udid) = entry.get("udid").and_then(Value::as_str) else {
continue;
};
let Some(state) = entry.get("state").and_then(Value::as_str) else {
continue;
};
if entry
.get("isAvailable")
.and_then(Value::as_bool)
.unwrap_or(true)
{
simulators.push(LocalIosSimulator {
udid: udid.to_string(),
name: name.to_string(),
os_version: os_version.clone(),
state: state.to_string(),
});
}
}
}
if simulators.is_empty() {
bail!("no available iOS simulators were returned by `xcrun simctl list devices available`");
}
if let Some(requested) = requested {
let mut matches: Vec<_> = simulators
.into_iter()
.filter(|simulator| {
simulator.name == requested.name
&& ios_versions_match(&requested.os_version, &simulator.os_version)
})
.collect();
matches.sort_by_key(|simulator| simulator.state != "Booted");
return matches.into_iter().next().ok_or_else(|| {
anyhow::anyhow!(
"requested local iOS simulator {} {} was not found; available simulators include: {}",
requested.name,
requested.os_version,
available_ios_simulator_summary(devices)
)
});
}
simulators.sort_by_key(|simulator| simulator.state != "Booted");
simulators
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("no iOS simulators were available for local profiling"))
}
fn available_ios_simulator_summary(devices: &serde_json::Map<String, Value>) -> String {
let mut labels = Vec::new();
for (runtime_key, entries) in devices {
let Some(os_version) = parse_ios_runtime_version(runtime_key) else {
continue;
};
let Some(entries) = entries.as_array() else {
continue;
};
for entry in entries {
if entry
.get("isAvailable")
.and_then(Value::as_bool)
.unwrap_or(true)
&& let Some(name) = entry.get("name").and_then(Value::as_str)
{
labels.push(format!("{name} {os_version}"));
}
}
}
labels.sort();
labels.dedup();
labels.into_iter().take(6).collect::<Vec<_>>().join(", ")
}
fn parse_ios_runtime_version(runtime_key: &str) -> Option<String> {
runtime_key
.strip_prefix("com.apple.CoreSimulator.SimRuntime.iOS-")
.map(|value| value.replace('-', "."))
}
fn ios_versions_match(requested: &str, candidate: &str) -> bool {
let requested = requested.trim();
let candidate = candidate.trim();
requested == candidate
|| candidate.starts_with(&format!("{requested}."))
|| requested.starts_with(&format!("{candidate}."))
}
fn ensure_local_ios_simulator_booted(simulator: &LocalIosSimulator) -> Result<()> {
let output = Command::new("xcrun")
.args(["simctl", "bootstatus", &simulator.udid, "-b"])
.output()
.with_context(|| format!("booting iOS simulator {}", simulator.identifier()))?;
if !output.status.success() {
bail!(
"xcrun simctl bootstatus {} -b failed with status {}\nstdout:\n{}\nstderr:\n{}",
simulator.udid,
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
fn build_local_ios_simulator_app(
layout: &crate::ResolvedProjectLayout,
simulator: &LocalIosSimulator,
) -> Result<PathBuf> {
let project_path = layout
.output_dir
.join("ios")
.join("BenchRunner")
.join("BenchRunner.xcodeproj");
if !project_path.exists() {
bail!(
"generated BenchRunner project was not found at {}; run `cargo mobench build --target ios` or rerun the profile build step",
project_path.display()
);
}
let build_root = layout
.output_dir
.join("ios")
.join("profile-simulator-build");
let mut cmd = Command::new("xcodebuild");
cmd.arg("-project")
.arg(&project_path)
.arg("-target")
.arg("BenchRunner")
.arg("-sdk")
.arg("iphonesimulator")
.arg("-configuration")
.arg("Debug")
.arg("build")
.arg(format!("SYMROOT={}", build_root.display()))
.arg(format!("OBJROOT={}", build_root.display()))
.arg("CODE_SIGNING_ALLOWED=NO")
.arg("CODE_SIGNING_REQUIRED=NO");
let output = cmd.output().with_context(|| {
format!(
"building the local iOS BenchRunner simulator app for {}",
simulator.identifier()
)
})?;
let app_path = build_root
.join("Debug-iphonesimulator")
.join("BenchRunner.app");
if !output.status.success() || !app_path.exists() {
bail!(
"xcodebuild simulator build failed for {}\nproject: {}\napp path: {}\nexit status: {}\nstdout:\n{}\nstderr:\n{}",
simulator.identifier(),
project_path.display(),
app_path.display(),
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
Ok(app_path)
}
fn install_local_ios_app(simulator: &LocalIosSimulator, app_path: &Path) -> Result<()> {
let output = Command::new("xcrun")
.args(["simctl", "install", &simulator.udid])
.arg(app_path)
.output()
.with_context(|| {
format!(
"installing {} on iOS simulator {}",
app_path.display(),
simulator.identifier()
)
})?;
if !output.status.success() {
bail!(
"xcrun simctl install failed with status {}\nstdout:\n{}\nstderr:\n{}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
fn local_ios_bundle_identifier(crate_name: &str) -> String {
format!(
"dev.world.{}.BenchRunner",
mobench_sdk::codegen::sanitize_bundle_id_component(crate_name)
)
}
fn launch_local_ios_app(
simulator: &LocalIosSimulator,
bundle_id: &str,
stdout_path: &Path,
stderr_path: &Path,
app_args: &[String],
app_env: &[(&str, String)],
) -> Result<u32> {
let stdout_path = absolutize_profile_path(stdout_path)?;
let stderr_path = absolutize_profile_path(stderr_path)?;
if let Some(parent) = stdout_path.parent() {
std::fs::create_dir_all(parent)?;
}
if let Some(parent) = stderr_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&stdout_path, "")?;
std::fs::write(&stderr_path, "")?;
let mut cmd = Command::new("xcrun");
cmd.args(["simctl", "launch"])
.arg(format!("--stdout={}", stdout_path.display()))
.arg(format!("--stderr={}", stderr_path.display()))
.arg("--terminate-running-process")
.arg(&simulator.udid)
.arg(bundle_id);
for (key, value) in app_env {
cmd.env(format!("SIMCTL_CHILD_{key}"), value);
}
for app_arg in app_args {
cmd.arg(app_arg);
}
let output = cmd.output().with_context(|| {
format!(
"launching {} on iOS simulator {}",
bundle_id,
simulator.identifier()
)
})?;
if !output.status.success() {
bail!(
"xcrun simctl launch failed with status {}\nstdout:\n{}\nstderr:\n{}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
parse_simctl_launch_pid(&String::from_utf8_lossy(&output.stdout))
}
fn absolutize_profile_path(path: &Path) -> Result<PathBuf> {
if path.is_absolute() {
Ok(path.to_path_buf())
} else {
Ok(std::env::current_dir()
.context("resolving absolute path for iOS simulator logs")?
.join(path))
}
}
fn parse_simctl_launch_pid(stdout: &str) -> Result<u32> {
stdout
.split_whitespace()
.rev()
.find_map(|token| token.parse::<u32>().ok())
.context("simctl launch did not report an application pid")
}
fn terminate_local_ios_app(simulator: &LocalIosSimulator, bundle_id: &str) -> Result<()> {
let output = Command::new("xcrun")
.args(["simctl", "terminate", &simulator.udid, bundle_id])
.output()
.with_context(|| {
format!(
"terminating {} on iOS simulator {}",
bundle_id,
simulator.identifier()
)
})?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("found nothing to terminate") || stderr.contains("not running") {
return Ok(());
}
bail!(
"xcrun simctl terminate failed with status {}\nstdout:\n{}\nstderr:\n{}",
output.status,
String::from_utf8_lossy(&output.stdout),
stderr
);
}
fn run_ios_sample_capture(pid: u32, output_path: &Path) -> Result<()> {
let output = Command::new("sample")
.arg(pid.to_string())
.arg(DEFAULT_IOS_CAPTURE_DURATION_SECS.to_string())
.arg("1")
.arg("-mayDie")
.arg("-file")
.arg(output_path)
.output()
.with_context(|| format!("sampling iOS simulator process {pid}"))?;
if !output.status.success() {
bail!(
"sample failed with status {}\nstdout:\n{}\nstderr:\n{}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
fn wait_for_ios_log_marker(paths: &[PathBuf], marker: &str, timeout_secs: u64) -> Result<()> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
while std::time::Instant::now() < deadline {
if let Ok(logs) = read_combined_text_files(paths)
&& logs.contains(marker)
{
return Ok(());
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
bail!("timed out waiting for iOS log marker `{marker}`");
}
fn read_combined_text_files(paths: &[PathBuf]) -> Result<String> {
let mut combined = String::new();
for path in paths {
if !path.exists() {
continue;
}
combined.push_str(
&std::fs::read_to_string(path)
.with_context(|| format!("reading text file at {}", path.display()))?,
);
if !combined.ends_with('\n') {
combined.push('\n');
}
}
Ok(combined)
}
fn write_ios_processed_outputs(
sample_output: &str,
processed_root: &Path,
) -> Result<SymbolizationRecord> {
std::fs::create_dir_all(processed_root)?;
std::fs::write(processed_root.join("native-report.txt"), sample_output)?;
match collapse_ios_sample_call_graph(sample_output) {
Ok((folded_stacks, mut record)) => {
std::fs::write(processed_root.join("stacks.folded"), &folded_stacks)?;
if let Some(warning) = write_dual_view_flamegraph_bundle(
&folded_stacks,
processed_root,
"iOS Native Profile",
IOS_BENCHMARK_ANCHORS,
"../raw/sample.txt",
"Raw sample.txt",
)? {
record.notes.push(warning);
}
if record.tool.is_none() {
record.tool = Some("sample".into());
}
Ok(record)
}
Err(error) => {
std::fs::write(processed_root.join("stacks.folded"), "")?;
let _ = write_dual_view_flamegraph_bundle(
"",
processed_root,
"iOS Native Profile",
IOS_BENCHMARK_ANCHORS,
"../raw/sample.txt",
"Raw sample.txt",
)?;
Ok(SymbolizationRecord {
status: CaptureStatus::Failed,
tool: Some("sample".into()),
resolved_frames: 0,
unresolved_frames: 0,
notes: vec![format!("failed to collapse iOS sample call graph: {error}")],
})
}
}
}
#[cfg(test)]
fn collapse_ios_sample_call_graph_to_folded_stacks(sample_output: &str) -> Result<String> {
collapse_ios_sample_call_graph(sample_output).map(|(folded_stacks, _record)| folded_stacks)
}
fn collapse_ios_sample_call_graph(sample_output: &str) -> Result<(String, SymbolizationRecord)> {
#[derive(Clone)]
struct StackFrame {
indent: usize,
frame: String,
unresolved: bool,
}
#[derive(Clone)]
struct ParsedNode {
depth: usize,
count: u64,
frames: Vec<String>,
unresolved_frames: u64,
}
let mut saw_call_graph = false;
let mut in_call_graph = false;
let mut stack: Vec<StackFrame> = Vec::new();
let mut nodes = Vec::new();
for line in sample_output.lines() {
let trimmed = line.trim();
if !in_call_graph {
if trimmed == "Call graph:" {
saw_call_graph = true;
in_call_graph = true;
}
continue;
}
if trimmed.starts_with("Total number in stack")
|| trimmed.starts_with("Sort by top of stack")
|| trimmed.starts_with("Binary Images:")
{
break;
}
let Some(parsed) = parse_ios_sample_call_graph_line(line) else {
continue;
};
if parsed.is_thread_root {
stack.clear();
continue;
}
if !parsed.is_plus {
while stack
.last()
.is_some_and(|existing| existing.indent >= parsed.indent)
{
stack.pop();
}
}
stack.push(StackFrame {
indent: parsed.indent,
frame: parsed.frame.clone(),
unresolved: parsed.frame == "???",
});
nodes.push(ParsedNode {
depth: stack.len(),
count: parsed.count,
frames: stack.iter().map(|frame| frame.frame.clone()).collect(),
unresolved_frames: stack.iter().filter(|frame| frame.unresolved).count() as u64,
});
}
if !saw_call_graph {
bail!("iOS sample output did not contain a `Call graph:` section");
}
if nodes.is_empty() {
bail!("iOS sample output did not contain any callable frames");
}
let mut folded_lines = Vec::new();
let mut resolved_frames = 0_u64;
let mut unresolved_frames = 0_u64;
for (index, node) in nodes.iter().enumerate() {
let next_depth = nodes.get(index + 1).map(|next| next.depth).unwrap_or(0);
if next_depth > node.depth {
continue;
}
if node.frames.is_empty() {
continue;
}
folded_lines.push(format!("{} {}", node.frames.join(";"), node.count));
unresolved_frames += node.unresolved_frames;
resolved_frames += node.frames.len() as u64 - node.unresolved_frames;
}
let mut notes = Vec::new();
let status = if folded_lines.is_empty() {
notes.push("no leaf frames were emitted from the iOS sample call graph".into());
CaptureStatus::Failed
} else if unresolved_frames > 0 {
notes.push(format!(
"iOS sample capture retained {unresolved_frames} unresolved frame(s) as `???`"
));
CaptureStatus::Partial
} else {
CaptureStatus::Captured
};
Ok((
folded_lines.join("\n"),
SymbolizationRecord {
status,
tool: Some("sample".into()),
resolved_frames,
unresolved_frames,
notes,
},
))
}
struct ParsedIosSampleLine {
indent: usize,
count: u64,
frame: String,
is_plus: bool,
is_thread_root: bool,
}
fn parse_ios_sample_call_graph_line(line: &str) -> Option<ParsedIosSampleLine> {
// `sample` encodes stack depth by the column where the sample count appears.
// The tree prefix can include `+`, `|`, `!`, and `:` markers, so leading
// spaces alone are not enough to reconstruct the stack shape.
let digits_start = line.find(|ch: char| ch.is_ascii_digit())?;
let indent = digits_start;
let prefix = &line[..digits_start];
let is_plus = prefix.trim_end().ends_with('+');
let remainder = &line[digits_start..];
let digits_end = remainder.find(|ch: char| !ch.is_ascii_digit())?;
let count = remainder[..digits_end].parse().ok()?;
let frame_part = remainder[digits_end..].trim_start();
let frame = frame_part
.split(" (in ")
.next()
.unwrap_or(frame_part)
.split(" [")
.next()
.unwrap_or(frame_part)
.trim();
if frame.is_empty() {
return None;
}
let is_thread_root = frame.starts_with("Thread_");
Some(ParsedIosSampleLine {
indent,
count,
frame: frame.to_string(),
is_plus,
is_thread_root,
})
}
fn extract_benchmark_reports_from_logs(logs: &str) -> Vec<Value> {
let mut results = Vec::new();
if let Some(json) = extract_ios_benchmark_json(logs) {
results.push(json);
}
let marker = "BENCH_JSON ";
for line in logs.lines() {
if let Some(index) = line.find(marker) {
let json_part = &line[index + marker.len()..];
if let Ok(parsed) = serde_json::from_str::<Value>(json_part) {
results.push(parsed);
}
}
}
results
}
fn extract_ios_benchmark_json(logs: &str) -> Option<Value> {
let start_marker = "BENCH_REPORT_JSON_START";
let end_marker = "BENCH_REPORT_JSON_END";
let start_pos = logs.rfind(start_marker)?;
let after_start = &logs[start_pos + start_marker.len()..];
let end_pos = after_start.find(end_marker)?;
extract_ios_json_from_log_section(&after_start[..end_pos])
}
fn extract_ios_json_from_log_section(section: &str) -> Option<Value> {
let trimmed = section.trim();
if trimmed.starts_with('{')
&& trimmed.ends_with('}')
&& let Ok(parsed) = serde_json::from_str::<Value>(trimmed)
{
return Some(parsed);
}
for line in section.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some(json_start) = line.find('{')
&& let Some(json) = extract_balanced_json(&line[json_start..])
&& let Ok(parsed) = serde_json::from_str::<Value>(&json)
{
return Some(parsed);
}
}
let collapsed: String = section
.lines()
.map(|line| {
if let Some(prefix_end) = line.find("] ") {
&line[prefix_end + 2..]
} else {
line.trim()
}
})
.collect::<Vec<_>>()
.join("");
let json_start = collapsed.find('{')?;
let json = extract_balanced_json(&collapsed[json_start..])?;
serde_json::from_str(&json).ok()
}
fn extract_balanced_json(input: &str) -> Option<String> {
if !input.starts_with('{') {
return None;
}
let mut depth = 0;
let mut in_string = false;
let mut escape_next = false;
for (index, ch) in input.char_indices() {
if escape_next {
escape_next = false;
continue;
}
match ch {
'\\' if in_string => escape_next = true,
'"' => in_string = !in_string,
'{' if !in_string => depth += 1,
'}' if !in_string => {
depth -= 1;
if depth == 0 {
return Some(input[..=index].to_string());
}
}
_ => {}
}
}
None
}
fn benchmark_value_function(value: &Value) -> Option<&str> {
value.get("function").and_then(Value::as_str).or_else(|| {
value
.get("spec")
.and_then(|spec| spec.get("name"))
.and_then(Value::as_str)
})
}
fn select_benchmark_value_for_function<'a>(
values: &'a [Value],
function: &str,
) -> Option<&'a Value> {
let simple_name = function.split("::").last().unwrap_or(function);
values
.iter()
.rev()
.find(|value| {
benchmark_value_function(value).is_some_and(|name| {
name == function
|| name == simple_name
|| name.ends_with(&format!("::{simple_name}"))
|| function.ends_with(&format!("::{name}"))
})
})
.or_else(|| values.last())
}
fn benchmark_value_sample_duration_total_ns(benchmark_value: &Value) -> u64 {
let sample_objects_total_ns: u64 = benchmark_value
.get("samples")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|sample| sample.get("duration_ns").and_then(Value::as_u64))
.sum();
if sample_objects_total_ns > 0 {
return sample_objects_total_ns;
}
benchmark_value
.get("samples_ns")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_u64)
.sum()
}
fn populate_semantic_profile_from_benchmark_value(
manifest: &mut ProfileManifest,
benchmark_value: &Value,
) {
if let Some(spec) = benchmark_value.get("spec") {
manifest.capture_metadata.benchmark_iterations = spec
.get("iterations")
.and_then(Value::as_u64)
.map(|value| value as u32);
manifest.capture_metadata.benchmark_warmup = spec
.get("warmup")
.and_then(Value::as_u64)
.map(|value| value as u32);
}
if let Some(timeline) = benchmark_value.get("timeline").and_then(Value::as_array) {
manifest.semantic_profile.harness_timeline = timeline
.iter()
.filter_map(|span| {
Some(HarnessTimelineSpanRecord {
phase: span.get("phase")?.as_str()?.to_string(),
start_offset_ns: span.get("start_offset_ns")?.as_u64()?,
end_offset_ns: span.get("end_offset_ns")?.as_u64()?,
iteration: span
.get("iteration")
.and_then(Value::as_u64)
.map(|value| value as u32),
})
})
.collect();
}
let Some(phases) = benchmark_value.get("phases").and_then(Value::as_array) else {
return;
};
let phase_duration_total_ns: u64 = phases
.iter()
.filter_map(|phase| phase.get("duration_ns").and_then(Value::as_u64))
.sum();
let sample_duration_total_ns = benchmark_value_sample_duration_total_ns(benchmark_value);
let total_duration_ns = if sample_duration_total_ns > 0 {
sample_duration_total_ns
} else {
phase_duration_total_ns
};
let mut semantic_phases = Vec::new();
let mut partial = false;
for phase in phases {
let Some(name) = phase.get("name").and_then(Value::as_str) else {
partial = true;
continue;
};
let duration_ns = phase.get("duration_ns").and_then(Value::as_u64);
let percent_total = duration_ns.and_then(|duration_ns| {
(total_duration_ns > 0).then_some(
(duration_ns.saturating_mul(100) + (total_duration_ns / 2)) / total_duration_ns,
)
});
if duration_ns.is_none() {
partial = true;
}
semantic_phases.push(SemanticPhaseRecord {
name: name.to_string(),
duration_ns,
percent_total,
});
}
if semantic_phases.is_empty() {
return;
}
manifest.semantic_profile.status = if partial {
SemanticCaptureStatus::Partial
} else {
SemanticCaptureStatus::Captured
};
manifest.semantic_profile.phases = semantic_phases;
}
fn merge_semantic_profile_from_bench_report(
manifest: &mut ProfileManifest,
bench_report: &Value,
) -> Result<()> {
populate_semantic_profile_from_benchmark_value(manifest, bench_report);
Ok(())
}
fn resolve_android_runtime_abi(toolchain: &AndroidProfilerToolchain) -> Result<Option<String>> {
let primary_abi = read_android_device_property(&toolchain.adb_path, "ro.product.cpu.abi")?;
if let Some(abi) = primary_abi {
return Ok(Some(abi));
}
let abi_list = read_android_device_property(&toolchain.adb_path, "ro.product.cpu.abilist")?;
Ok(abi_list.and_then(|value| {
value
.split(',')
.map(str::trim)
.find(|value| !value.is_empty())
.map(str::to_owned)
}))
}
fn read_android_device_property(adb_path: &Path, property: &str) -> Result<Option<String>> {
let output = Command::new(adb_path)
.args(["shell", "getprop", property])
.output()
.with_context(|| format!("reading Android device property {property}"))?;
if !output.status.success() {
bail!(
"adb shell getprop {property} failed with status {}\nstdout:\n{}\nstderr:\n{}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
if value.is_empty() {
Ok(None)
} else {
Ok(Some(value))
}
}
fn load_profile_manifest(path: &Path) -> Result<ProfileManifest> {
let body = std::fs::read_to_string(path)?;
Ok(serde_json::from_str(&body)?)
}
pub fn cmd_profile_summarize_for_test(args: &ProfileSummarizeArgs) -> Result<String> {
let manifest = load_profile_manifest(&args.profile)?;
match args.output_format {
ProfileSummaryFormat::Markdown => Ok(render_profile_markdown(&manifest)),
ProfileSummaryFormat::Json => Ok(serde_json::to_string_pretty(&manifest)?),
}
}
fn resolve_profile_target(args: &ProfileRunArgs) -> Result<ResolvedProfileTarget> {
let backend = resolve_backend(args.target, args.backend);
validate_format_capabilities(backend, args.format)?;
let device = resolve_profile_device(args)?;
Ok(ResolvedProfileTarget { backend, device })
}
fn build_capture_plan(
args: &ProfileRunArgs,
target: &ResolvedProfileTarget,
output_root: &Path,
) -> Result<ProfileManifest> {
let backend = target.backend;
validate_format_capabilities(backend, args.format)?;
let raw_root = output_root.join("artifacts/raw");
let processed_root = output_root.join("artifacts/processed");
let (raw_artifacts, processed_artifacts) = match backend {
ProfileBackend::AndroidNative => (
vec![ArtifactRecord {
label: "simpleperf".into(),
path: raw_root.join("sample.perf"),
}],
vec![
ArtifactRecord {
label: "collapsed-stacks".into(),
path: processed_root.join("stacks.folded"),
},
ArtifactRecord {
label: "native-report".into(),
path: processed_root.join("native-report.txt"),
},
ArtifactRecord {
label: "frame-locations".into(),
path: processed_root.join("frame-locations.json"),
},
ArtifactRecord {
label: "benchmark-focused-stacks".into(),
path: processed_root.join("benchmark.focused.folded"),
},
ArtifactRecord {
label: "flamegraph-full-svg".into(),
path: processed_root.join("flamegraph.full.svg"),
},
ArtifactRecord {
label: "flamegraph-focused-svg".into(),
path: processed_root.join("flamegraph.focused.svg"),
},
ArtifactRecord {
label: "flamegraph-viewer".into(),
path: processed_root.join("flamegraph.html"),
},
ArtifactRecord {
label: "chronological-trace".into(),
path: processed_root.join("chronological-trace.json"),
},
],
),
ProfileBackend::IosInstruments => (
vec![ArtifactRecord {
label: "sample".into(),
path: raw_root.join("sample.txt"),
}],
vec![
ArtifactRecord {
label: "collapsed-stacks".into(),
path: processed_root.join("stacks.folded"),
},
ArtifactRecord {
label: "native-report".into(),
path: processed_root.join("native-report.txt"),
},
ArtifactRecord {
label: "benchmark-focused-stacks".into(),
path: processed_root.join("benchmark.focused.folded"),
},
ArtifactRecord {
label: "flamegraph-full-svg".into(),
path: processed_root.join("flamegraph.full.svg"),
},
ArtifactRecord {
label: "flamegraph-focused-svg".into(),
path: processed_root.join("flamegraph.focused.svg"),
},
ArtifactRecord {
label: "flamegraph-viewer".into(),
path: processed_root.join("flamegraph.html"),
},
ArtifactRecord {
label: "chronological-trace".into(),
path: processed_root.join("chronological-trace.json"),
},
],
),
ProfileBackend::RustTracing => (
vec![ArtifactRecord {
label: "trace-events".into(),
path: raw_root.join("trace-events.json"),
}],
Vec::new(),
),
ProfileBackend::Auto => unreachable!("auto backend should resolve before planning"),
};
let raw_artifacts = select_artifacts(raw_artifacts, args.format, ArtifactKind::Raw);
let processed_artifacts =
select_artifacts(processed_artifacts, args.format, ArtifactKind::Processed);
let viewer_hint =
select_viewer_hint(backend, args.format, &raw_artifacts, &processed_artifacts);
Ok(ProfileManifest {
run_id: build_run_id(args.target, &args.function),
target: args.target,
function: args.function.clone(),
provider: args.provider,
backend,
format: args.format,
native_capture: NativeCaptureRecord {
status: CaptureStatus::Planned,
raw_artifacts,
processed_artifacts,
symbolization: SymbolizationRecord::default(),
viewer_hint,
},
semantic_profile: SemanticProfileRecord {
spans_path: Some(output_root.join("artifacts/semantic/phases.json")),
timeline_path: Some(output_root.join("artifacts/semantic/timeline.json")),
..SemanticProfileRecord::default()
},
capture_metadata: CaptureMetadataRecord {
device: target
.device
.as_ref()
.map(|device| device.identifier.clone()),
os: target
.device
.as_ref()
.map(|device| format!("{} {}", device.os, device.os_version)),
sample_duration_secs: None,
benchmark_iterations: None,
benchmark_warmup: None,
warmup_mode: resolve_capture_warmup_mode(args.provider, backend, args.warmup_mode),
capture_method: Some(match backend {
ProfileBackend::AndroidNative => "simpleperf".into(),
ProfileBackend::IosInstruments => "sample".into(),
ProfileBackend::RustTracing => "trace-events".into(),
ProfileBackend::Auto => unreachable!("auto backend should resolve before planning"),
}),
warnings: Vec::new(),
},
})
}
fn resolve_capture_warmup_mode(
provider: ProfileProvider,
backend: ProfileBackend,
requested: Option<CaptureWarmupMode>,
) -> Option<CaptureWarmupMode> {
requested.or(match (provider, backend) {
(ProfileProvider::Local, ProfileBackend::AndroidNative)
| (ProfileProvider::Local, ProfileBackend::IosInstruments) => Some(CaptureWarmupMode::Warm),
_ => None,
})
}
fn build_run_id(target: MobileTarget, function: &str) -> String {
format!("{}-{}", target.as_str(), slugify_function_name(function))
}
fn resolve_backend(target: MobileTarget, backend: ProfileBackend) -> ProfileBackend {
match backend {
ProfileBackend::Auto => match target {
MobileTarget::Android => ProfileBackend::AndroidNative,
MobileTarget::Ios => ProfileBackend::IosInstruments,
},
_ => backend,
}
}
fn validate_format_capabilities(backend: ProfileBackend, format: ProfileFormat) -> Result<()> {
if backend == ProfileBackend::RustTracing && format == ProfileFormat::Processed {
bail!(
"processed output is unsupported for rust-tracing backend; use --format native or both"
);
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ArtifactKind {
Raw,
Processed,
}
fn select_artifacts(
artifacts: Vec<ArtifactRecord>,
format: ProfileFormat,
kind: ArtifactKind,
) -> Vec<ArtifactRecord> {
match format {
ProfileFormat::Both => artifacts,
ProfileFormat::Native if kind == ArtifactKind::Raw => artifacts,
ProfileFormat::Processed if kind == ArtifactKind::Processed => artifacts,
_ => Vec::new(),
}
}
fn create_selected_artifact_roots(
raw_artifacts: &[ArtifactRecord],
processed_artifacts: &[ArtifactRecord],
) -> Result<()> {
for artifact in raw_artifacts.iter().chain(processed_artifacts.iter()) {
if let Some(parent) = artifact.path.parent() {
std::fs::create_dir_all(parent)?;
}
}
Ok(())
}
fn resolve_profile_device(args: &ProfileRunArgs) -> Result<Option<ResolvedProfileDevice>> {
match (args.device.as_deref(), args.os_version.as_deref()) {
(Some(device), Some(os_version)) => {
let identifier = format!("{device}-{os_version}");
Ok(Some(ResolvedProfileDevice {
name: device.to_string(),
os: args.target.as_str().to_string(),
os_version: os_version.to_string(),
identifier,
profile: None,
source: "direct".into(),
}))
}
(None, None) => {
if args.profile.is_none() && args.device_matrix.is_none() {
return Ok(None);
}
let platform = match args.target {
MobileTarget::Android => DevicePlatform::Android,
MobileTarget::Ios => DevicePlatform::Ios,
};
let resolved = resolve_devices_for_profile(
platform,
args.profile.as_deref(),
args.config.as_deref(),
args.device_matrix.as_deref(),
)?;
if resolved.devices.len() != 1 {
bail!(
"profile run requires exactly one resolved device, but profile `{}` from {} produced {} devices; use --device/--os-version or a single-device profile",
resolved.profile,
resolved.source,
resolved.devices.len()
);
}
Ok(Some(resolved_profile_device_from_matrix(
resolved.devices.into_iter().next().expect("single device"),
resolved.profile,
resolved.source,
)))
}
_ => unreachable!("clap enforces paired --device/--os-version"),
}
}
fn resolved_profile_device_from_matrix(
device: ResolvedMatrixDevice,
profile: String,
source: String,
) -> ResolvedProfileDevice {
ResolvedProfileDevice {
name: device.name,
os: device.os,
os_version: device.os_version,
identifier: device.identifier,
profile: Some(profile),
source,
}
}
fn execute_capture(
args: &ProfileRunArgs,
target: &ResolvedProfileTarget,
manifest: &mut ProfileManifest,
) -> Result<()> {
if let Some(device) = &target.device {
manifest.capture_metadata.warnings.push(format!(
"resolved target device: {} ({}, source: {})",
device.identifier, device.os, device.source
));
}
let plan_only_warning = match (args.provider, target.backend) {
(ProfileProvider::Local, ProfileBackend::AndroidNative) => {
return execute_capture_with_local_android_executor(
args,
manifest,
execute_local_android_capture,
);
}
(ProfileProvider::Local, ProfileBackend::IosInstruments) => {
return execute_capture_with_local_ios_executor(
args,
manifest,
execute_local_ios_capture,
);
}
(ProfileProvider::Local, ProfileBackend::RustTracing) => Some(
"local rust-tracing capture is not implemented yet; this session records the planned trace-events artifact contract only",
),
(ProfileProvider::Browserstack, ProfileBackend::AndroidNative) => {
bail!(browserstack_native_capture_unsupported_message(
"android-native",
"local Android profiling produces simpleperf artifacts and flamegraphs",
));
}
(ProfileProvider::Browserstack, ProfileBackend::IosInstruments) => {
bail!(browserstack_native_capture_unsupported_message(
"ios-instruments",
"local iOS profiling produces raw sample output (`sample.txt`), collapsed stacks, and `flamegraph.html` from a simulator-hosted capture",
));
}
(ProfileProvider::Browserstack, ProfileBackend::RustTracing) => {
bail!(
"BrowserStack rust-tracing capture is not implemented.\nThis command currently writes a local-first profile contract only.\nUse --provider local for trace-events output, or run a normal BrowserStack benchmark if you only need timing/memory metrics."
);
}
(_, ProfileBackend::Auto) => unreachable!("auto backend should resolve before execution"),
};
if let Some(warning) = plan_only_warning {
manifest.capture_metadata.warnings.push(warning.into());
}
Ok(())
}
fn execute_capture_with_local_android_executor<E>(
args: &ProfileRunArgs,
manifest: &mut ProfileManifest,
execute: E,
) -> Result<()>
where
E: FnOnce(&ProfileRunArgs, &mut ProfileManifest) -> Result<()>,
{
if let Err(error) = execute(args, manifest) {
mark_android_capture_attempt_failed(manifest, &error);
return Err(error);
}
Ok(())
}
fn execute_capture_with_local_ios_executor<E>(
args: &ProfileRunArgs,
manifest: &mut ProfileManifest,
execute: E,
) -> Result<()>
where
E: FnOnce(&ProfileRunArgs, &mut ProfileManifest) -> Result<()>,
{
if let Err(error) = execute(args, manifest) {
mark_ios_capture_attempt_failed(manifest, &error);
return Err(error);
}
Ok(())
}
fn mark_android_capture_attempt_failed(manifest: &mut ProfileManifest, error: &anyhow::Error) {
manifest.native_capture.status = CaptureStatus::Failed;
manifest.native_capture.symbolization.status = CaptureStatus::Failed;
let failure_note = format!("local android-native capture failed: {error}");
if !manifest
.native_capture
.symbolization
.notes
.iter()
.any(|note| note == &failure_note)
{
manifest
.native_capture
.symbolization
.notes
.push(failure_note.clone());
}
if !manifest
.capture_metadata
.warnings
.iter()
.any(|warning| warning == &failure_note)
{
manifest.capture_metadata.warnings.push(failure_note);
}
}
fn mark_ios_capture_attempt_failed(manifest: &mut ProfileManifest, error: &anyhow::Error) {
manifest.native_capture.status = CaptureStatus::Failed;
manifest.native_capture.symbolization.status = CaptureStatus::Failed;
let failure_note = format!("local ios-instruments capture failed: {error}");
if !manifest
.native_capture
.symbolization
.notes
.iter()
.any(|note| note == &failure_note)
{
manifest
.native_capture
.symbolization
.notes
.push(failure_note.clone());
}
if !manifest
.capture_metadata
.warnings
.iter()
.any(|warning| warning == &failure_note)
{
manifest.capture_metadata.warnings.push(failure_note);
}
}
fn browserstack_native_capture_unsupported_message(
backend_label: &str,
artifact_guidance: &str,
) -> String {
format!(
"BrowserStack native profiling is not implemented for {backend_label}.\nThis command currently writes a local-first profile contract only.\nUse --provider local for planning/local capture, or run a normal BrowserStack benchmark if you only need timing/memory metrics.\n{artifact_guidance}."
)
}
fn select_viewer_hint(
backend: ProfileBackend,
format: ProfileFormat,
raw_artifacts: &[ArtifactRecord],
processed_artifacts: &[ArtifactRecord],
) -> Option<String> {
match backend {
ProfileBackend::AndroidNative => {
if format != ProfileFormat::Native && !processed_artifacts.is_empty() {
Some(
"Open artifacts/processed/flamegraph.html for the interactive dual-view flamegraph explorer".into(),
)
} else if !raw_artifacts.is_empty() {
Some(
"Inspect artifacts/raw/sample.perf with the Android profiling toolchain".into(),
)
} else {
None
}
}
ProfileBackend::IosInstruments => {
if format != ProfileFormat::Native && !processed_artifacts.is_empty() {
Some(
"Open artifacts/processed/flamegraph.html for the interactive dual-view flamegraph explorer".into(),
)
} else if !raw_artifacts.is_empty() {
Some("Inspect artifacts/raw/sample.txt for the raw iOS sample call graph".into())
} else if !processed_artifacts.is_empty() {
Some(
"Open artifacts/processed/flamegraph.html for the interactive dual-view flamegraph explorer".into(),
)
} else {
None
}
}
ProfileBackend::RustTracing => {
if !raw_artifacts.is_empty() {
Some("Open artifacts/raw/trace-events.json in a trace viewer".into())
} else {
None
}
}
ProfileBackend::Auto => None,
}
}
fn slugify_function_name(function: &str) -> String {
let mut slug = String::new();
for ch in function.chars() {
match ch {
':' | '/' | ' ' => slug.push('-'),
'_' | '-' => slug.push(ch),
ch if ch.is_ascii_alphanumeric() => slug.push(ch),
_ => slug.push('_'),
}
}
slug
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_run_args(
target: MobileTarget,
provider: ProfileProvider,
backend: ProfileBackend,
format: ProfileFormat,
) -> ProfileRunArgs {
ProfileRunArgs {
target,
function: "sample_fns::fibonacci".into(),
crate_path: None,
config: None,
output_dir: PathBuf::from("target/mobench/profile"),
device: None,
os_version: None,
profile: None,
device_matrix: None,
provider,
backend,
format,
warmup_mode: None,
}
}
#[test]
fn local_native_profiles_default_to_warm_capture_mode() {
let android_target = resolve_profile_target(&sample_run_args(
MobileTarget::Android,
ProfileProvider::Local,
ProfileBackend::AndroidNative,
ProfileFormat::Both,
))
.expect("resolve android target");
let android_plan = build_capture_plan(
&sample_run_args(
MobileTarget::Android,
ProfileProvider::Local,
ProfileBackend::AndroidNative,
ProfileFormat::Both,
),
&android_target,
&PathBuf::from("target/mobench/profile"),
)
.expect("build android plan");
assert_eq!(
android_plan.capture_metadata.warmup_mode,
Some(CaptureWarmupMode::Warm)
);
}
#[test]
fn explicit_capture_warmup_mode_overrides_local_default() {
let mut args = sample_run_args(
MobileTarget::Android,
ProfileProvider::Local,
ProfileBackend::AndroidNative,
ProfileFormat::Both,
);
args.warmup_mode = Some(CaptureWarmupMode::Cold);
let target = resolve_profile_target(&args).expect("resolve target");
let plan = build_capture_plan(&args, &target, &PathBuf::from("target/mobench/profile"))
.expect("build plan");
assert_eq!(
plan.capture_metadata.warmup_mode,
Some(CaptureWarmupMode::Cold)
);
}
#[test]
fn android_warmup_log_marker_detection_uses_bench_json_marker() {
assert!(android_log_contains_marker(
"03-26 19:00:00.000 BenchRunner I BENCH_JSON {\"samples_ns\":[1,2]}",
ANDROID_BENCH_LOG_MARKER
));
assert!(!android_log_contains_marker(
"03-26 19:00:00.000 BenchRunner I unrelated log line",
ANDROID_BENCH_LOG_MARKER
));
}
#[test]
fn benchmark_logs_extract_android_bench_json_reports() {
let reports = extract_benchmark_reports_from_logs(
"03-26 19:00:00.000 BenchRunner I BENCH_JSON {\"function\":\"sample_fns::fibonacci\",\"phases\":[{\"name\":\"prove\",\"duration_ns\":90},{\"name\":\"serialize\",\"duration_ns\":10}]}",
);
assert_eq!(reports.len(), 1);
assert_eq!(
benchmark_value_function(&reports[0]),
Some("sample_fns::fibonacci")
);
}
#[test]
fn semantic_profile_populates_from_benchmark_phase_payload() {
let mut manifest = build_capture_plan(
&sample_run_args(
MobileTarget::Android,
ProfileProvider::Local,
ProfileBackend::AndroidNative,
ProfileFormat::Both,
),
&resolve_profile_target(&sample_run_args(
MobileTarget::Android,
ProfileProvider::Local,
ProfileBackend::AndroidNative,
ProfileFormat::Both,
))
.expect("resolve target"),
&PathBuf::from("target/mobench/profile"),
)
.expect("build plan");
populate_semantic_profile_from_benchmark_value(
&mut manifest,
&serde_json::json!({
"function": "sample_fns::fibonacci",
"phases": [
{"name": "prove", "duration_ns": 90},
{"name": "serialize", "duration_ns": 10}
]
}),
);
assert_eq!(
manifest.semantic_profile.status,
SemanticCaptureStatus::Captured
);
assert_eq!(manifest.semantic_profile.phases.len(), 2);
assert_eq!(manifest.semantic_profile.phases[0].name, "prove");
assert_eq!(manifest.semantic_profile.phases[0].duration_ns, Some(90));
assert_eq!(manifest.semantic_profile.phases[0].percent_total, Some(90));
assert_eq!(manifest.semantic_profile.phases[1].percent_total, Some(10));
}
#[test]
fn semantic_profile_uses_samples_ns_totals_for_android_log_payloads() {
let mut manifest = build_capture_plan(
&sample_run_args(
MobileTarget::Android,
ProfileProvider::Local,
ProfileBackend::AndroidNative,
ProfileFormat::Both,
),
&resolve_profile_target(&sample_run_args(
MobileTarget::Android,
ProfileProvider::Local,
ProfileBackend::AndroidNative,
ProfileFormat::Both,
))
.expect("resolve target"),
&PathBuf::from("target/mobench/profile"),
)
.expect("build plan");
merge_semantic_profile_from_bench_report(
&mut manifest,
&serde_json::json!({
"function": "sample_fns::fibonacci",
"samples_ns": [100, 300],
"phases": [
{"name": "prove", "duration_ns": 320},
{"name": "serialize", "duration_ns": 40}
]
}),
)
.expect("merge semantic profile");
assert_eq!(
manifest.semantic_profile.status,
SemanticCaptureStatus::Captured
);
assert_eq!(manifest.semantic_profile.phases[0].percent_total, Some(80));
assert_eq!(manifest.semantic_profile.phases[1].percent_total, Some(10));
}
#[test]
fn write_profile_session_outputs_persists_semantic_phase_sidecar() {
let dir = tempfile::tempdir().expect("temp dir");
let mut manifest = sample_manifest();
manifest.semantic_profile.spans_path = Some(
dir.path()
.join("android-sample/artifacts/semantic/phases.json"),
);
let args = ProfileRunArgs {
target: MobileTarget::Android,
function: "sample_fns::fibonacci".into(),
provider: ProfileProvider::Local,
backend: ProfileBackend::AndroidNative,
format: ProfileFormat::Both,
output_dir: dir.path().to_path_buf(),
crate_path: None,
device: None,
os_version: None,
profile: None,
device_matrix: None,
config: None,
warmup_mode: Some(CaptureWarmupMode::Warm),
};
let run_output_dir = dir.path().join("android-sample");
write_profile_session_outputs(&args, &run_output_dir, &manifest)
.expect("write profile outputs");
let sidecar = std::fs::read_to_string(
manifest
.semantic_profile
.spans_path
.as_ref()
.expect("semantic spans path"),
)
.expect("read semantic sidecar");
assert!(sidecar.contains("\"prove\""));
assert!(sidecar.contains("\"serialize\""));
}
fn write_timeline_demo_session(
output_dir: &Path,
run_output_dir: &Path,
) -> Result<ProfileManifest> {
let raw_root = run_output_dir.join("artifacts/raw");
let processed_root = run_output_dir.join("artifacts/processed");
let semantic_root = run_output_dir.join("artifacts/semantic");
std::fs::create_dir_all(&raw_root)?;
std::fs::create_dir_all(&processed_root)?;
std::fs::create_dir_all(&semantic_root)?;
let mut manifest = sample_manifest();
manifest.run_id = "ios-demo".into();
manifest.target = MobileTarget::Ios;
manifest.backend = ProfileBackend::IosInstruments;
manifest.native_capture.status = CaptureStatus::Captured;
manifest.native_capture.symbolization.status = CaptureStatus::Captured;
manifest.native_capture.symbolization.tool = Some("sample".into());
manifest.capture_metadata.device = Some("iPhone 17 Pro-26.2".into());
manifest.capture_metadata.os = Some("iOS 26.2".into());
manifest.capture_metadata.capture_method = Some("sample/simctl".into());
manifest.capture_metadata.sample_duration_secs = Some(15);
manifest.capture_metadata.benchmark_iterations = Some(20);
manifest.capture_metadata.benchmark_warmup = Some(3);
manifest.capture_metadata.warmup_mode = Some(CaptureWarmupMode::Warm);
manifest.semantic_profile.spans_path = Some(semantic_root.join("phases.json"));
manifest.semantic_profile.timeline_path = Some(semantic_root.join("timeline.json"));
manifest.semantic_profile.harness_timeline = vec![
HarnessTimelineSpanRecord {
phase: "setup".into(),
start_offset_ns: 0,
end_offset_ns: 500_000_000,
iteration: None,
},
HarnessTimelineSpanRecord {
phase: "warmup-benchmark".into(),
start_offset_ns: 500_000_000,
end_offset_ns: 1_000_000_000,
iteration: Some(0),
},
HarnessTimelineSpanRecord {
phase: "measured-benchmark".into(),
start_offset_ns: 1_000_000_000,
end_offset_ns: 1_400_000_000,
iteration: Some(0),
},
HarnessTimelineSpanRecord {
phase: "measured-benchmark".into(),
start_offset_ns: 1_400_000_000,
end_offset_ns: 1_800_000_000,
iteration: Some(1),
},
HarnessTimelineSpanRecord {
phase: "teardown".into(),
start_offset_ns: 1_800_000_000,
end_offset_ns: 2_100_000_000,
iteration: None,
},
];
manifest.native_capture.raw_artifacts = vec![ArtifactRecord {
label: "sample".into(),
path: raw_root.join("sample.txt"),
}];
manifest.native_capture.processed_artifacts = vec![
ArtifactRecord {
label: "collapsed-stacks".into(),
path: processed_root.join("stacks.folded"),
},
ArtifactRecord {
label: "native-report".into(),
path: processed_root.join("native-report.txt"),
},
ArtifactRecord {
label: "benchmark-focused-stacks".into(),
path: processed_root.join("benchmark.focused.folded"),
},
ArtifactRecord {
label: "flamegraph-full-svg".into(),
path: processed_root.join("flamegraph.full.svg"),
},
ArtifactRecord {
label: "flamegraph-focused-svg".into(),
path: processed_root.join("flamegraph.focused.svg"),
},
ArtifactRecord {
label: "flamegraph-viewer".into(),
path: processed_root.join("flamegraph.html"),
},
ArtifactRecord {
label: "chronological-trace".into(),
path: processed_root.join("chronological-trace.json"),
},
];
std::fs::write(raw_root.join("sample.txt"), "synthetic sample output")?;
let folded = concat!(
"UIKitMain;runBenchmark(spec:);sample_fns::run_benchmark;sample_fns::fibonacci 5\n",
"UIKitMain;runBenchmark(spec:);sample_fns::run_benchmark;sample_fns::checksum 2\n",
);
write_dual_view_flamegraph_bundle(
folded,
&processed_root,
"iOS Native Profile",
IOS_BENCHMARK_ANCHORS,
"../raw/sample.txt",
"Raw sample.txt",
)?;
std::fs::write(
processed_root.join("chronological-trace.json"),
serde_json::to_vec_pretty(&ChronologicalTraceRecord {
source: ChronologicalTraceSourceRecord {
kind: "mobench-demo-trace".into(),
profiler: "sample/simctl".into(),
origin: "local".into(),
},
total_duration_ns: 2_100_000_000,
lanes: vec![ViewerTraceLane {
id: "main-thread".into(),
label: "Main Thread".into(),
events: vec![
ViewerTraceEvent {
event_kind: "sample".into(),
start_offset_ns: 1_050_000_000,
end_offset_ns: Some(1_180_000_000),
frames: vec![
"sample_fns::run_benchmark".into(),
"sample_fns::fibonacci".into(),
],
phase: Some("measured-benchmark".into()),
iteration: Some(0),
},
ViewerTraceEvent {
event_kind: "sample".into(),
start_offset_ns: 1_430_000_000,
end_offset_ns: Some(1_560_000_000),
frames: vec![
"sample_fns::run_benchmark".into(),
"sample_fns::checksum".into(),
],
phase: Some("measured-benchmark".into()),
iteration: Some(1),
},
],
}],
})
.expect("serialize demo trace"),
)?;
let args = ProfileRunArgs {
target: MobileTarget::Ios,
function: "sample_fns::fibonacci".into(),
provider: ProfileProvider::Local,
backend: ProfileBackend::IosInstruments,
format: ProfileFormat::Both,
output_dir: output_dir.to_path_buf(),
crate_path: None,
device: None,
os_version: None,
profile: None,
device_matrix: None,
config: None,
warmup_mode: Some(CaptureWarmupMode::Warm),
};
write_profile_session_outputs(&args, run_output_dir, &manifest)?;
Ok(manifest)
}
#[test]
fn write_profile_session_outputs_rewrites_flamegraph_with_timeline_payload() {
let dir = tempfile::tempdir().expect("temp dir");
let run_output_dir = dir.path().join("ios-demo");
let manifest =
write_timeline_demo_session(dir.path(), &run_output_dir).expect("write demo session");
let viewer_html =
std::fs::read_to_string(run_output_dir.join("artifacts/processed/flamegraph.html"))
.expect("read flamegraph viewer");
let trace_json = std::fs::read_to_string(
run_output_dir.join("artifacts/processed/chronological-trace.json"),
)
.expect("read chronological trace");
assert!(viewer_html.contains("Timeline"));
assert!(viewer_html.contains("iPhone 17 Pro-26.2"));
assert!(viewer_html.contains("20 measured / 3 warmup"));
assert!(viewer_html.contains("sample/simctl"));
assert!(viewer_html.contains("\"Main Thread\""));
assert!(viewer_html.contains("\"warmup-benchmark\""));
assert!(trace_json.contains("\"mobench-demo-trace\""));
assert!(trace_json.contains("\"Main Thread\""));
assert!(trace_json.contains(&manifest.function));
}
#[test]
#[ignore]
fn generate_flamegraph_timeline_demo_artifact() {
let output_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("target/mobench/flamegraph-timeline-demo");
let _ = std::fs::remove_dir_all(&output_dir);
let run_output_dir = output_dir.join("ios-demo");
write_timeline_demo_session(&output_dir, &run_output_dir).expect("generate demo artifact");
}
#[test]
fn refresh_differential_viewer_manifest_writes_timeline_capable_html() {
let dir = tempfile::tempdir().expect("temp dir");
let baseline_run_dir = dir.path().join("baseline-run");
let candidate_run_dir = dir.path().join("candidate-run");
let diff_run_dir = dir.path().join("diff-run");
let diff_processed_dir = diff_run_dir.join("artifacts/processed");
std::fs::create_dir_all(&baseline_run_dir).expect("create baseline run dir");
std::fs::create_dir_all(&candidate_run_dir).expect("create candidate run dir");
std::fs::create_dir_all(&diff_processed_dir).expect("create diff processed dir");
let mut baseline_manifest = sample_manifest();
baseline_manifest.run_id = "baseline-run".into();
baseline_manifest.target = MobileTarget::Ios;
baseline_manifest.backend = ProfileBackend::IosInstruments;
baseline_manifest.capture_metadata.device = Some("iPhone 17 Pro-26.2".into());
baseline_manifest.capture_metadata.os = Some("iOS 26.2".into());
baseline_manifest.capture_metadata.capture_method = Some("sample/simctl".into());
let mut candidate_manifest = baseline_manifest.clone();
candidate_manifest.run_id = "candidate-run".into();
std::fs::write(
baseline_run_dir.join("profile.json"),
serde_json::to_vec_pretty(&baseline_manifest).expect("serialize baseline manifest"),
)
.expect("write baseline manifest");
std::fs::write(
candidate_run_dir.join("profile.json"),
serde_json::to_vec_pretty(&candidate_manifest).expect("serialize candidate manifest"),
)
.expect("write candidate manifest");
std::fs::write(
diff_processed_dir.join("diff.full.folded"),
"root;main 12\n",
)
.expect("write diff full folded");
std::fs::write(
diff_processed_dir.join("diff.focused.folded"),
"bench;sample_fns::fibonacci 7\n",
)
.expect("write diff focused folded");
std::fs::write(
diff_processed_dir.join("flamegraph.full.svg"),
"<svg id=\"full\"></svg>",
)
.expect("write diff full svg");
std::fs::write(
diff_processed_dir.join("flamegraph.focused.svg"),
"<svg id=\"focused\"></svg>",
)
.expect("write diff focused svg");
let diff_manifest_path = dir.path().join("profile-diff.json");
std::fs::write(
&diff_manifest_path,
serde_json::to_vec_pretty(&serde_json::json!({
"run_id": "baseline-run--vs--candidate-run",
"baseline": "baseline-run/profile.json",
"candidate": "candidate-run/profile.json",
"viewer_path": "diff-run/artifacts/processed/flamegraph.html",
"warnings": [
"Differential flamegraph colors: red = hotter in candidate, blue = hotter in baseline. Frame widths follow candidate sample counts."
],
"modes": [
{
"mode": "full",
"diff_folded": "diff-run/artifacts/processed/diff.full.folded",
"flamegraph_svg": "diff-run/artifacts/processed/flamegraph.full.svg"
},
{
"mode": "focused",
"diff_folded": "diff-run/artifacts/processed/diff.focused.folded",
"flamegraph_svg": "diff-run/artifacts/processed/flamegraph.focused.svg"
}
]
}))
.expect("serialize diff manifest"),
)
.expect("write diff manifest");
refresh_differential_flamegraph_viewer_from_manifest_path(&diff_manifest_path)
.expect("refresh differential viewer");
let html = std::fs::read_to_string(diff_processed_dir.join("flamegraph.html"))
.expect("read differential flamegraph html");
assert!(html.contains("data-mode=\"timeline\""));
assert!(html.contains("Baseline Run"));
assert!(html.contains("Candidate Run"));
assert!(html.contains("Chronological trace"));
assert!(html.contains("Exact harness time"));
}
#[test]
fn cmd_profile_diff_writes_runtime_bundle() {
let dir = tempfile::tempdir().expect("temp dir");
let baseline_run_dir = dir.path().join("baseline-run");
let candidate_run_dir = dir.path().join("candidate-run");
std::fs::create_dir_all(baseline_run_dir.join("artifacts/processed"))
.expect("create baseline processed");
std::fs::create_dir_all(candidate_run_dir.join("artifacts/processed"))
.expect("create candidate processed");
let mut baseline_manifest = sample_manifest();
baseline_manifest.run_id = "baseline-run".into();
baseline_manifest.native_capture.status = CaptureStatus::Captured;
baseline_manifest.native_capture.symbolization.status = CaptureStatus::Captured;
let mut candidate_manifest = baseline_manifest.clone();
candidate_manifest.run_id = "candidate-run".into();
std::fs::write(
baseline_run_dir.join("artifacts/processed/stacks.folded"),
"root;sample_fns::fibonacci 4\n",
)
.expect("write baseline full");
std::fs::write(
baseline_run_dir.join("artifacts/processed/benchmark.focused.folded"),
"sample_fns::run_benchmark;sample_fns::fibonacci 4\n",
)
.expect("write baseline focused");
std::fs::write(
candidate_run_dir.join("artifacts/processed/stacks.folded"),
"root;sample_fns::fibonacci 7\nroot;sample_fns::checksum 1\n",
)
.expect("write candidate full");
std::fs::write(
candidate_run_dir.join("artifacts/processed/benchmark.focused.folded"),
"sample_fns::run_benchmark;sample_fns::fibonacci 7\n",
)
.expect("write candidate focused");
write_profile_manifest(&baseline_run_dir.join("profile.json"), &baseline_manifest)
.expect("write baseline manifest");
write_profile_manifest(&candidate_run_dir.join("profile.json"), &candidate_manifest)
.expect("write candidate manifest");
let output_dir = dir.path().join("diff");
cmd_profile_diff(&ProfileDiffArgs {
baseline: baseline_run_dir.join("profile.json"),
candidate: candidate_run_dir.join("profile.json"),
output_dir: output_dir.clone(),
normalize: true,
})
.expect("run profile diff");
let diff_dir = output_dir.join("baseline-run--vs--candidate-run");
assert!(diff_dir.join("profile-diff.json").exists());
assert!(diff_dir.join("summary.md").exists());
assert!(
diff_dir
.join("artifacts/processed/flamegraph.html")
.exists()
);
let summary = std::fs::read_to_string(diff_dir.join("summary.md")).expect("read summary");
assert!(summary.contains("Differential Flamegraph Summary"));
assert!(summary.contains("Normalize: `true`"));
}
#[test]
fn refresh_flamegraph_viewer_includes_android_source_links_when_sidecar_exists() {
let dir = tempfile::tempdir().expect("temp dir");
let run_output_dir = dir.path().join("android-source-demo");
let processed_root = run_output_dir.join("artifacts/processed");
std::fs::create_dir_all(&processed_root).expect("create processed root");
let mut manifest = sample_manifest();
manifest.run_id = "android-source-demo".into();
manifest.native_capture.status = CaptureStatus::Captured;
manifest.native_capture.symbolization.status = CaptureStatus::Captured;
manifest
.native_capture
.processed_artifacts
.push(ArtifactRecord {
label: "frame-locations".into(),
path: PathBuf::from("artifacts/processed/frame-locations.json"),
});
std::fs::write(
processed_root.join("stacks.folded"),
"root;sample_fns::fibonacci 5\n",
)
.expect("write full folded");
std::fs::write(
processed_root.join("benchmark.focused.folded"),
"sample_fns::run_benchmark;sample_fns::fibonacci 5\n",
)
.expect("write focused folded");
std::fs::write(
processed_root.join("flamegraph.full.svg"),
"<svg id=\"full\"></svg>",
)
.expect("write full svg");
std::fs::write(
processed_root.join("flamegraph.focused.svg"),
"<svg id=\"focused\"></svg>",
)
.expect("write focused svg");
std::fs::write(
processed_root.join("frame-locations.json"),
serde_json::to_vec_pretty(&vec![FrameLocationRecord {
frame: "sample_fns::fibonacci".into(),
source_path: PathBuf::from("crates/sample-fns/src/lib.rs"),
line: 42,
}])
.expect("serialize frame locations"),
)
.expect("write frame locations");
refresh_flamegraph_viewer_from_manifest(&run_output_dir, &manifest)
.expect("refresh flamegraph viewer");
let html = std::fs::read_to_string(processed_root.join("flamegraph.html"))
.expect("read viewer html");
assert!(html.contains("sample_fns::fibonacci"));
assert!(html.contains("crates/sample-fns/src/lib.rs:42"));
}
#[test]
#[ignore]
fn refresh_profile_diff_demo_viewer_artifact() {
let diff_manifest_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("target/mobench/profile-diff-demo/profile-diff.json");
refresh_differential_flamegraph_viewer_from_manifest_path(&diff_manifest_path)
.expect("refresh profile diff demo viewer");
}
#[test]
fn profile_manifest_serializes_partial_failure_state() {
let manifest = sample_manifest();
let json = serde_json::to_value(&manifest).expect("serialize manifest");
assert_eq!(json["capture_metadata"]["warnings"][0], "missing symbols");
assert_eq!(json["native_capture"]["status"], "partial");
}
#[test]
fn render_profile_summary_mentions_backend_and_artifacts() {
let manifest = sample_manifest();
let markdown = render_profile_markdown(&manifest);
assert!(markdown.contains("android-native"));
assert!(markdown.contains("## Native capture"));
assert!(markdown.contains("artifacts/raw/sample.perf"));
assert!(markdown.contains("## Semantic phases"));
assert!(markdown.contains("missing symbols"));
}
#[test]
fn profile_manifest_serializes_native_capture_sections() {
let manifest = sample_manifest();
let json = serde_json::to_value(&manifest).expect("serialize manifest");
assert!(
json.get("native_capture").is_some(),
"expected native capture metadata to be nested under native_capture, got: {json}"
);
assert!(
json["native_capture"].get("symbolization").is_some(),
"expected native capture metadata to include symbolization state, got: {json}"
);
assert!(
json["native_capture"].get("viewer_hint").is_some(),
"expected native capture metadata to include viewer hints, got: {json}"
);
}
#[test]
fn profile_manifest_serializes_semantic_profile_sections() {
let manifest = sample_manifest();
let json = serde_json::to_value(&manifest).expect("serialize manifest");
assert!(
json.get("semantic_profile").is_some(),
"expected semantic profiling metadata to be nested under semantic_profile, got: {json}"
);
assert!(
json["semantic_profile"].get("phases").is_some(),
"expected semantic profiling metadata to expose phase data, got: {json}"
);
}
#[test]
fn legacy_profile_manifest_deserializes_into_nested_sections() {
let legacy = serde_json::json!({
"run_id": "run-123",
"target": "android",
"function": "sample_fns::fibonacci",
"provider": "local",
"backend": "android-native",
"format": "both",
"capture_status": "partial",
"raw_artifacts": [
{"label": "simpleperf", "path": "artifacts/raw/sample.perf"}
],
"processed_artifacts": [
{"label": "flamegraph", "path": "artifacts/processed/flamegraph.html"}
],
"warnings": ["legacy manifest"],
"viewer_hint": "Open flamegraph.html in a browser"
});
let manifest: ProfileManifest =
serde_json::from_value(legacy).expect("deserialize legacy manifest");
assert_eq!(manifest.native_capture.status, CaptureStatus::Partial);
assert_eq!(manifest.native_capture.raw_artifacts.len(), 1);
assert_eq!(manifest.native_capture.processed_artifacts.len(), 1);
assert_eq!(
manifest.native_capture.viewer_hint.as_deref(),
Some("Open flamegraph.html in a browser")
);
assert_eq!(manifest.capture_metadata.warnings, vec!["legacy manifest"]);
assert_eq!(
manifest.semantic_profile.status,
SemanticCaptureStatus::Planned
);
}
#[test]
fn render_profile_summary_separates_native_and_semantic_outputs() {
let markdown = render_profile_markdown(&sample_manifest());
assert!(
markdown.contains("artifacts/raw/sample.perf")
|| markdown.contains("artifacts/processed/flamegraph.html"),
"expected native capture output to remain visible, got:\n{markdown}"
);
assert!(
markdown.contains("Semantic phases"),
"expected semantic phases to be rendered separately, got:\n{markdown}"
);
}
#[test]
fn profile_summary_renders_semantic_phases_separately_from_flamegraph_artifacts() {
let markdown = render_profile_markdown(&sample_manifest());
assert!(
markdown.contains("Semantic phases"),
"expected semantic phases to be rendered separately, got:\n{markdown}"
);
assert!(
markdown.contains("prove"),
"expected semantic phase names to be visible, got:\n{markdown}"
);
assert!(
markdown.contains("serialize"),
"expected semantic phase names to be visible, got:\n{markdown}"
);
}
#[test]
fn summarize_command_reads_manifest_and_renders_markdown() {
let dir = tempfile::tempdir().expect("temp dir");
let manifest_path = dir.path().join("profile.json");
write_profile_manifest(&manifest_path, &sample_manifest()).expect("write manifest");
let rendered = cmd_profile_summarize_for_test(&ProfileSummarizeArgs {
profile: manifest_path,
output: None,
output_format: ProfileSummaryFormat::Markdown,
})
.expect("summarize profile");
assert!(rendered.contains("sample_fns::fibonacci"));
assert!(rendered.contains("Profile Summary"));
}
#[test]
fn build_capture_plan_reserves_semantic_phase_sidecar_path() {
let args = sample_run_args(
MobileTarget::Android,
ProfileProvider::Local,
ProfileBackend::AndroidNative,
ProfileFormat::Both,
);
let target = resolve_profile_target(&args).expect("resolve target");
let manifest = build_capture_plan(&args, &target, &PathBuf::from("target/mobench/profile"))
.expect("build capture plan");
assert_eq!(
manifest.semantic_profile.spans_path,
Some(PathBuf::from(
"target/mobench/profile/artifacts/semantic/phases.json"
))
);
}
#[test]
fn semantic_profile_ingests_phase_timings_from_bench_report_json() {
let args = sample_run_args(
MobileTarget::Android,
ProfileProvider::Local,
ProfileBackend::AndroidNative,
ProfileFormat::Both,
);
let target = resolve_profile_target(&args).expect("resolve target");
let mut manifest =
build_capture_plan(&args, &target, &PathBuf::from("target/mobench/profile"))
.expect("build capture plan");
let bench_report = serde_json::json!({
"spec": {
"name": "sample_fns::fibonacci",
"iterations": 2,
"warmup": 1
},
"samples": [
{"duration_ns": 100},
{"duration_ns": 300}
],
"phases": [
{"name": "prove", "duration_ns": 320},
{"name": "serialize", "duration_ns": 40}
]
});
populate_semantic_profile_from_benchmark_value(&mut manifest, &bench_report);
assert_eq!(
manifest.semantic_profile.status,
SemanticCaptureStatus::Captured
);
assert_eq!(manifest.semantic_profile.phases.len(), 2);
assert_eq!(manifest.semantic_profile.phases[0].name, "prove");
assert_eq!(manifest.semantic_profile.phases[0].duration_ns, Some(320));
assert_eq!(manifest.semantic_profile.phases[0].percent_total, Some(80));
assert_eq!(manifest.semantic_profile.phases[1].name, "serialize");
assert_eq!(manifest.semantic_profile.phases[1].percent_total, Some(10));
}
#[test]
fn semantic_profile_ingests_exact_harness_timeline_and_run_counts_from_bench_report_json() {
let args = sample_run_args(
MobileTarget::Android,
ProfileProvider::Local,
ProfileBackend::AndroidNative,
ProfileFormat::Both,
);
let target = resolve_profile_target(&args).expect("resolve target");
let mut manifest =
build_capture_plan(&args, &target, &PathBuf::from("target/mobench/profile"))
.expect("build capture plan");
let bench_report = serde_json::json!({
"spec": {
"name": "sample_fns::fibonacci",
"iterations": 2,
"warmup": 1
},
"samples": [
{"duration_ns": 100},
{"duration_ns": 300}
],
"phases": [
{"name": "prove", "duration_ns": 320},
{"name": "serialize", "duration_ns": 40}
],
"timeline": [
{
"phase": "setup",
"start_offset_ns": 0,
"end_offset_ns": 10,
"iteration": null
},
{
"phase": "measured-benchmark",
"start_offset_ns": 10,
"end_offset_ns": 30,
"iteration": 0
}
]
});
populate_semantic_profile_from_benchmark_value(&mut manifest, &bench_report);
let json = serde_json::to_value(&manifest).expect("serialize manifest");
assert_eq!(
json["capture_metadata"]["benchmark_iterations"],
serde_json::json!(2)
);
assert_eq!(
json["capture_metadata"]["benchmark_warmup"],
serde_json::json!(1)
);
assert_eq!(
json["semantic_profile"]["harness_timeline"][0]["phase"],
"setup"
);
assert_eq!(
json["semantic_profile"]["harness_timeline"][1]["phase"],
"measured-benchmark"
);
}
#[test]
fn android_native_offsets_are_symbolized_into_rust_frames() {
let (symbolized, record, report) = symbolize_android_folded_stacks_with_resolver(
"dev.world.samplefns;uniffi.sample_fns.Sample_fnsKt.runBenchmark;libsample_fns.so[+94138] 1",
|library_name, offset| {
if library_name == "libsample_fns.so" && offset == 94_138 {
Some("sample_fns::fibonacci".into())
} else {
None
}
},
);
assert!(symbolized.contains("sample_fns::fibonacci"));
assert_eq!(record.status, CaptureStatus::Captured);
assert_eq!(record.resolved_frames, 1);
assert_eq!(record.unresolved_frames, 0);
assert!(report.contains("sample_fns::fibonacci"));
}
#[test]
fn android_native_offsets_use_runtime_abi_to_select_unstripped_library_paths() {
let unstripped_path =
PathBuf::from("/cargo/target/aarch64-linux-android/release/libsample_fns.so");
let packaged_path = PathBuf::from("/apk/jniLibs/arm64-v8a/libsample_fns.so");
let other_unstripped_path =
PathBuf::from("/cargo/target/x86_64-linux-android/release/libsample_fns.so");
let other_packaged_path = PathBuf::from("/apk/jniLibs/x86_64/libsample_fns.so");
let native_libraries = vec![
NativeLibraryArtifact {
abi: "arm64-v8a".into(),
library_name: "libsample_fns.so".into(),
unstripped_path: unstripped_path.clone(),
packaged_path,
},
NativeLibraryArtifact {
abi: "x86_64".into(),
library_name: "libsample_fns.so".into(),
unstripped_path: other_unstripped_path.clone(),
packaged_path: other_packaged_path,
},
];
let mut seen_paths = Vec::new();
let (symbolized, record, report) = symbolize_android_folded_stacks_with_native_libraries(
"dev.world.samplefns;libsample_fns.so[+94138] 1",
&native_libraries,
Some("x86_64"),
|path, offset| {
seen_paths.push((path.to_path_buf(), offset));
Some("sample_fns::fibonacci".into())
},
);
assert!(symbolized.contains("sample_fns::fibonacci"));
assert_eq!(seen_paths.len(), 1);
assert_eq!(seen_paths[0].0, other_unstripped_path);
assert_eq!(seen_paths[0].1, 94_138);
assert_eq!(record.status, CaptureStatus::Captured);
assert_eq!(record.resolved_frames, 1);
assert!(report.contains("sample_fns::fibonacci"));
}
#[test]
fn android_native_offsets_do_not_collapse_multiple_abis_without_a_runtime_selection() {
let native_libraries = vec![
NativeLibraryArtifact {
abi: "arm64-v8a".into(),
library_name: "libsample_fns.so".into(),
unstripped_path: PathBuf::from(
"/cargo/target/aarch64-linux-android/release/libsample_fns.so",
),
packaged_path: PathBuf::from("/apk/jniLibs/arm64-v8a/libsample_fns.so"),
},
NativeLibraryArtifact {
abi: "x86_64".into(),
library_name: "libsample_fns.so".into(),
unstripped_path: PathBuf::from(
"/cargo/target/x86_64-linux-android/release/libsample_fns.so",
),
packaged_path: PathBuf::from("/apk/jniLibs/x86_64/libsample_fns.so"),
},
];
let mut seen_paths = Vec::new();
let (symbolized, record, report) = symbolize_android_folded_stacks_with_native_libraries(
"dev.world.samplefns;libsample_fns.so[+94138] 1",
&native_libraries,
None,
|path, offset| {
seen_paths.push((path.to_path_buf(), offset));
Some("sample_fns::fibonacci".into())
},
);
assert!(symbolized.contains("libsample_fns.so[+94138]"));
assert!(report.contains("libsample_fns.so[+94138]"));
assert!(seen_paths.is_empty());
assert!(!symbolized.contains("sample_fns::fibonacci"));
assert_eq!(record.status, CaptureStatus::Failed);
assert_eq!(record.resolved_frames, 0);
assert_eq!(record.unresolved_frames, 1);
}
#[test]
fn android_post_processing_writes_symbolized_outputs_before_flamegraph_rendering() {
let temp_dir = tempfile::tempdir().expect("temp dir");
let processed_root = temp_dir.path().join("artifacts/processed");
let native_libraries = vec![NativeLibraryArtifact {
abi: "arm64-v8a".into(),
library_name: "libsample_fns.so".into(),
unstripped_path: PathBuf::from(
"/cargo/target/aarch64-linux-android/release/libsample_fns.so",
),
packaged_path: PathBuf::from("/apk/jniLibs/arm64-v8a/libsample_fns.so"),
}];
let record = write_android_symbolized_outputs_with_resolver(
"dev.world.samplefns;libsample_fns.so[+94138] 1",
&native_libraries,
&processed_root,
Some("arm64-v8a"),
|_path, offset| {
if offset == 94_138 {
Some("sample_fns::fibonacci".into())
} else {
None
}
},
)
.expect("write symbolized outputs");
let folded = std::fs::read_to_string(processed_root.join("stacks.folded"))
.expect("read stacks.folded");
let report = std::fs::read_to_string(processed_root.join("native-report.txt"))
.expect("read native report");
let flamegraph = std::fs::read_to_string(processed_root.join("flamegraph.html"))
.expect("read flamegraph");
assert!(folded.contains("sample_fns::fibonacci"));
assert!(report.contains("sample_fns::fibonacci"));
assert!(flamegraph.contains("<svg"));
assert_eq!(record.status, CaptureStatus::Captured);
assert_eq!(record.resolved_frames, 1);
assert_eq!(record.unresolved_frames, 0);
}
#[test]
fn flamegraph_html_defaults_to_viewport_width_for_standalone_svg() {
let temp_dir = tempfile::tempdir().expect("temp dir");
let output_path = temp_dir.path().join("flamegraph.svg");
let flamegraph =
render_standalone_flamegraph_svg("root;sample_fns::fibonacci 1", "Test Flamegraph")
.expect("render flamegraph");
std::fs::write(&output_path, flamegraph).expect("write flamegraph");
let flamegraph = std::fs::read_to_string(&output_path).expect("read flamegraph");
assert!(
flamegraph.contains("var fluiddrawing = false;"),
"expected standalone flamegraph HTML to disable inferno's fluiddrawing script for file:// rendering, got:\n{flamegraph}"
);
assert!(
flamegraph.contains("width:100vw")
|| flamegraph.contains("min-width:100vw")
|| flamegraph.contains("max-width:100vw"),
"expected standalone flamegraph HTML to size the SVG to the viewport width, got:\n{flamegraph}"
);
}
#[test]
fn android_ndk_addr2line_discovery_prefers_ndk_toolchain_bin() {
let temp_dir = tempfile::tempdir().expect("temp dir");
let ndk_root = temp_dir.path().join("ndk/26.3.11579264");
let tool_path = ndk_root
.join("toolchains")
.join("llvm")
.join("prebuilt")
.join("darwin-x86_64")
.join("bin")
.join(if cfg!(windows) {
"llvm-addr2line.exe"
} else {
"llvm-addr2line"
});
std::fs::create_dir_all(tool_path.parent().expect("tool parent")).expect("create tool dir");
std::fs::write(&tool_path, "#!/bin/sh\n").expect("write tool");
let discovered = locate_android_llvm_addr2line(&ndk_root, None).expect("discover tool");
assert_eq!(discovered, tool_path);
}
#[test]
fn android_ndk_addr2line_discovery_honors_explicit_override() {
let temp_dir = tempfile::tempdir().expect("temp dir");
let override_path = temp_dir.path().join("custom-llvm-addr2line");
std::fs::write(&override_path, "#!/bin/sh\n").expect("write override");
let discovered =
locate_android_llvm_addr2line(Path::new("/does/not/matter"), Some(&override_path))
.expect("discover override");
assert_eq!(discovered, override_path);
}
#[test]
fn local_android_attempted_capture_marks_failed_state() {
let args = sample_run_args(
MobileTarget::Android,
ProfileProvider::Local,
ProfileBackend::AndroidNative,
ProfileFormat::Both,
);
let target = resolve_profile_target(&args).expect("resolve target");
let mut manifest =
build_capture_plan(&args, &target, &PathBuf::from("target/mobench/profile"))
.expect("build capture plan");
let error = execute_capture_with_local_android_executor(
&args,
&mut manifest,
|_args, _manifest| anyhow::bail!("simulated android capture failure"),
)
.expect_err("simulated capture failure");
assert!(
error
.to_string()
.contains("simulated android capture failure")
);
assert_eq!(manifest.native_capture.status, CaptureStatus::Failed);
assert_eq!(
manifest.native_capture.symbolization.status,
CaptureStatus::Failed
);
assert_eq!(manifest.native_capture.symbolization.tool, None);
assert!(
manifest
.capture_metadata
.warnings
.iter()
.any(|warning| warning.contains("simulated android capture failure"))
);
}
#[test]
fn profile_session_writes_failed_android_manifest_after_attempted_execution() {
let dir = tempfile::tempdir().expect("temp dir");
let mut args = sample_run_args(
MobileTarget::Android,
ProfileProvider::Local,
ProfileBackend::AndroidNative,
ProfileFormat::Both,
);
args.output_dir = dir.path().to_path_buf();
let error = run_profile_session_with_executor(&args, false, |args, _target, manifest| {
execute_capture_with_local_android_executor(args, manifest, |_args, _manifest| {
anyhow::bail!("simulated android capture failure")
})
})
.expect_err("simulated execution failure should bubble up");
assert!(
error
.to_string()
.contains("simulated android capture failure")
);
let run_dir = dir.path().join(build_run_id(args.target, &args.function));
let manifest = load_profile_manifest(&run_dir.join("profile.json"))
.expect("load failed profile manifest");
assert_eq!(manifest.native_capture.status, CaptureStatus::Failed);
assert_eq!(
manifest.native_capture.symbolization.status,
CaptureStatus::Failed
);
assert_eq!(manifest.native_capture.symbolization.tool, None);
assert!(
manifest
.capture_metadata
.warnings
.iter()
.any(|warning| warning.contains("simulated android capture failure"))
);
assert!(run_dir.join("summary.md").exists());
assert!(dir.path().join("profile.json").exists());
assert!(dir.path().join("summary.md").exists());
}
#[test]
fn android_backend_builds_capture_plan_with_flamegraph_artifacts() {
let plan = build_capture_plan(
&sample_run_args(
MobileTarget::Android,
ProfileProvider::Local,
ProfileBackend::AndroidNative,
ProfileFormat::Both,
),
&resolve_profile_target(&sample_run_args(
MobileTarget::Android,
ProfileProvider::Local,
ProfileBackend::AndroidNative,
ProfileFormat::Both,
))
.expect("resolve target"),
&PathBuf::from("target/mobench/profile"),
)
.expect("android capture plan");
assert!(
plan.native_capture
.raw_artifacts
.iter()
.any(|p| p.path.ends_with("sample.perf"))
);
assert!(
plan.native_capture
.processed_artifacts
.iter()
.any(|p| p.path.ends_with("flamegraph.html"))
);
assert!(
plan.native_capture
.processed_artifacts
.iter()
.any(|p| p.path.ends_with("benchmark.focused.folded"))
);
assert!(
plan.native_capture
.processed_artifacts
.iter()
.any(|p| p.path.ends_with("flamegraph.full.svg"))
);
assert!(
plan.native_capture
.processed_artifacts
.iter()
.any(|p| p.path.ends_with("flamegraph.focused.svg"))
);
assert!(
plan.native_capture
.processed_artifacts
.iter()
.any(|p| p.path.ends_with("stacks.folded"))
);
assert!(
plan.native_capture
.processed_artifacts
.iter()
.any(|p| p.path.ends_with("native-report.txt"))
);
}
#[test]
fn profile_native_format_excludes_processed_artifacts_from_plan() {
let plan = build_capture_plan(
&sample_run_args(
MobileTarget::Android,
ProfileProvider::Local,
ProfileBackend::AndroidNative,
ProfileFormat::Native,
),
&resolve_profile_target(&sample_run_args(
MobileTarget::Android,
ProfileProvider::Local,
ProfileBackend::AndroidNative,
ProfileFormat::Native,
))
.expect("resolve target"),
&PathBuf::from("target/mobench/profile"),
)
.expect("native-only capture plan");
assert_eq!(plan.native_capture.raw_artifacts.len(), 1);
assert!(plan.native_capture.processed_artifacts.is_empty());
assert_eq!(
plan.native_capture.viewer_hint.as_deref(),
Some("Inspect artifacts/raw/sample.perf with the Android profiling toolchain")
);
}
#[test]
fn ios_backend_allocates_sample_and_flamegraph_artifacts() {
let plan = build_capture_plan(
&sample_run_args(
MobileTarget::Ios,
ProfileProvider::Local,
ProfileBackend::IosInstruments,
ProfileFormat::Both,
),
&resolve_profile_target(&sample_run_args(
MobileTarget::Ios,
ProfileProvider::Local,
ProfileBackend::IosInstruments,
ProfileFormat::Both,
))
.expect("resolve target"),
&PathBuf::from("target/mobench/profile"),
)
.expect("ios capture plan");
assert!(
plan.native_capture
.raw_artifacts
.iter()
.any(|p| p.path.ends_with("sample.txt"))
);
assert!(
plan.native_capture
.processed_artifacts
.iter()
.any(|p| p.path.ends_with("stacks.folded"))
);
assert!(
plan.native_capture
.processed_artifacts
.iter()
.any(|p| p.path.ends_with("native-report.txt"))
);
assert!(
plan.native_capture
.processed_artifacts
.iter()
.any(|p| p.path.ends_with("flamegraph.html"))
);
assert!(
plan.native_capture
.processed_artifacts
.iter()
.any(|p| p.path.ends_with("benchmark.focused.folded"))
);
assert!(
plan.native_capture
.processed_artifacts
.iter()
.any(|p| p.path.ends_with("flamegraph.full.svg"))
);
assert!(
plan.native_capture
.processed_artifacts
.iter()
.any(|p| p.path.ends_with("flamegraph.focused.svg"))
);
}
#[test]
fn ios_sample_call_graph_collapses_into_folded_stacks() {
let sample = r#"Call graph:
778 Thread_27177597 DispatchQueue_1: com.apple.main-thread (serial)
778 start (in dyld) + 7184 [0x18ac81d54]
776 uniffi_sample_fns_fn_func_run_benchmark (in sample_fns) + 88 [0x100000588]
+ 776 sample_fns::run_benchmark (in sample_fns) + 40 [0x100000610]
+ 776 mobench_sdk::timing::run_closure (in sample_fns) + 24 [0x100000650]
+ 776 sample_fns::fibonacci_batch (in sample_fns) + 24 [0x100000710]
+ 776 sample_fns::fibonacci (in sample_fns) + 24 [0x100000780]
2 write (in libsystem_kernel.dylib) + 40 [0x18b00c840]
"#;
let folded =
collapse_ios_sample_call_graph_to_folded_stacks(sample).expect("collapse sample");
assert!(folded.contains(
"start;uniffi_sample_fns_fn_func_run_benchmark;sample_fns::run_benchmark;mobench_sdk::timing::run_closure;sample_fns::fibonacci_batch;sample_fns::fibonacci 776"
));
assert!(folded.contains("start;write 2"));
}
#[test]
fn ios_sample_call_graph_preserves_rust_branch_with_sample_tree_markers() {
let sample = r#"Call graph:
7863 Thread_27276912: Main Thread DispatchQueue_<multiple>
+ ! : 8 runBenchmark(spec:) (in BenchRunner) + 212 [0x104c8fd04] sample_fns.swift:883
+ ! : 8 rustCallWithError<A, B>(_:_:) (in BenchRunner) + 136 [0x104c88e18] sample_fns.swift:277
+ ! : 8 makeRustCall<A, B>(_:errorHandler:) (in BenchRunner) + 272 [0x104c889d4] sample_fns.swift:286
+ ! : 8 closure #1 in runBenchmark(spec:) (in BenchRunner) + 196 [0x104c8ff74] sample_fns.swift:884
+ ! : 8 uniffi_sample_fns_fn_func_run_benchmark (in BenchRunner) + 128 [0x104ca0048]
+ ! : 8 uniffi_core::ffi::rustcalls::rust_call::hd7f37ba68899eb94 (in BenchRunner) + 60 [0x104c9e050]
+ ! : 8 uniffi_core::ffi::rustcalls::rust_call_with_out_status::hb407fdd2dbf3b59b (in BenchRunner) + 60 [0x104c9dbc8]
+ ! : 8 std::panic::catch_unwind::h37b9566b8b963094 (in BenchRunner) + 96 [0x104c9aba4]
+ ! : 8 __rust_try (in BenchRunner) + 32 [0x104c9ac48]
+ ! : 8 std::panicking::catch_unwind::do_call::h426d206e0216d0d8 (in BenchRunner) + 64 [0x104ca1400]
+ ! : 8 sample_fns::uniffi_sample_fns_fn_func_run_benchmark::_$u7b$$u7b$closure$u7d$$u7d$::h239802906291ec5b (in BenchRunner) + 180 [0x104c96a1c]
+ ! : 8 sample_fns::run_benchmark::h9909bea304da6ad4 (in BenchRunner) + 244 [0x104c9ecf8]
+ ! : | 6 sample_fns::run_benchmark::_$u7b$$u7b$closure$u7d$$u7d$::h93f4e9319d117771 (in BenchRunner) + 40 [0x104c96648]
+ ! : | 6 mobench_sdk::timing::profile_phase::hea85f2c7c3e95291 (in BenchRunner) + 116 [0x104ca0d80]
+ ! : | 6 sample_fns::run_benchmark::_$u7b$$u7b$closure$u7d$$u7d$::_$u7b$$u7b$closure$u7d$$u7d$::h4716261690d4fa31 (in BenchRunner) + 24 [0x104c96800]
+ ! : | 6 sample_fns::fibonacci_batch::hc8a1ee7297b9bb66 (in BenchRunner) + 80 [0x104c9f074]
+ ! : | 5 sample_fns::fibonacci::ha1ebbae54edac99d (in BenchRunner) + 152 [0x104c9f168]
"#;
let folded =
collapse_ios_sample_call_graph_to_folded_stacks(sample).expect("collapse sample");
assert!(
folded.contains(
"runBenchmark(spec:);rustCallWithError<A, B>(_:_:);makeRustCall<A, B>(_:errorHandler:);closure #1 in runBenchmark(spec:);uniffi_sample_fns_fn_func_run_benchmark;uniffi_core::ffi::rustcalls::rust_call::hd7f37ba68899eb94;uniffi_core::ffi::rustcalls::rust_call_with_out_status::hb407fdd2dbf3b59b;std::panic::catch_unwind::h37b9566b8b963094;__rust_try;std::panicking::catch_unwind::do_call::h426d206e0216d0d8;sample_fns::uniffi_sample_fns_fn_func_run_benchmark::_$u7b$$u7b$closure$u7d$$u7d$::h239802906291ec5b;sample_fns::run_benchmark::h9909bea304da6ad4;sample_fns::run_benchmark::_$u7b$$u7b$closure$u7d$$u7d$::h93f4e9319d117771;mobench_sdk::timing::profile_phase::hea85f2c7c3e95291;sample_fns::run_benchmark::_$u7b$$u7b$closure$u7d$$u7d$::_$u7b$$u7b$closure$u7d$$u7d$::h4716261690d4fa31;sample_fns::fibonacci_batch::hc8a1ee7297b9bb66;sample_fns::fibonacci::ha1ebbae54edac99d 5"
),
"expected folded stacks to preserve the deep Rust branch emitted by `sample`, got:\n{folded}"
);
}
#[test]
fn browserstack_profile_run_reports_unsupported_native_capture() {
let args = sample_run_args(
MobileTarget::Android,
ProfileProvider::Browserstack,
ProfileBackend::AndroidNative,
ProfileFormat::Both,
);
let target = resolve_profile_target(&args).expect("resolve target");
let mut manifest =
build_capture_plan(&args, &target, &PathBuf::from("target/mobench/profile"))
.expect("plan");
let error = execute_capture(&args, &target, &mut manifest).unwrap_err();
assert!(error.to_string().contains("BrowserStack"));
assert!(
error.to_string().contains("unsupported")
|| error.to_string().contains("not implemented")
);
}
#[test]
fn browserstack_native_profile_error_is_actionable() {
let args = sample_run_args(
MobileTarget::Ios,
ProfileProvider::Browserstack,
ProfileBackend::IosInstruments,
ProfileFormat::Both,
);
let target = resolve_profile_target(&args).expect("resolve target");
let mut manifest =
build_capture_plan(&args, &target, &PathBuf::from("target/mobench/profile"))
.expect("plan");
let error = execute_capture(&args, &target, &mut manifest).unwrap_err();
let message = error.to_string();
assert!(
message.contains("BrowserStack native profiling is not implemented"),
"expected an explicit unsupported message, got: {message}"
);
assert!(
message.contains("local-first profile contract")
|| message.contains("planned artifact contract only"),
"expected the error to explain that profile run only records planned artifacts today, got: {message}"
);
assert!(
message.contains("Use --provider local"),
"expected the error to tell the user what to do instead, got: {message}"
);
assert!(
message.contains("Instruments")
|| message.contains("time-profiler.trace")
|| message.contains("time-profiler.xml")
|| message.contains("flamegraph"),
"expected the error to clarify the iOS artifact story, got: {message}"
);
}
#[test]
fn profile_rust_tracing_processed_only_is_rejected() {
let target = resolve_profile_target(&sample_run_args(
MobileTarget::Android,
ProfileProvider::Local,
ProfileBackend::RustTracing,
ProfileFormat::Both,
))
.expect("resolve target");
let error = build_capture_plan(
&sample_run_args(
MobileTarget::Android,
ProfileProvider::Local,
ProfileBackend::RustTracing,
ProfileFormat::Processed,
),
&target,
&PathBuf::from("target/mobench/profile"),
)
.unwrap_err();
assert!(error.to_string().contains("processed"));
assert!(error.to_string().contains("rust-tracing"));
}
#[test]
fn profile_run_writes_run_scoped_and_latest_manifest_files() {
let dir = tempfile::tempdir().expect("temp dir");
let mut android_args = sample_run_args(
MobileTarget::Android,
ProfileProvider::Local,
ProfileBackend::AndroidNative,
ProfileFormat::Both,
);
android_args.output_dir = dir.path().to_path_buf();
let mut ios_args = sample_run_args(
MobileTarget::Ios,
ProfileProvider::Local,
ProfileBackend::IosInstruments,
ProfileFormat::Both,
);
ios_args.function = "sample_fns::checksum".into();
ios_args.output_dir = dir.path().to_path_buf();
cmd_profile_run(&android_args, true).expect("write first planned profile session");
run_profile_session_with_executor(&ios_args, false, |_args, _target, _manifest| Ok(()))
.expect("write second profile session");
let android_run_dir = dir.path().join("android-sample_fns--fibonacci");
let ios_run_dir = dir.path().join("ios-sample_fns--checksum");
assert!(android_run_dir.join("profile.json").exists());
assert!(android_run_dir.join("summary.md").exists());
assert!(ios_run_dir.join("profile.json").exists());
assert!(ios_run_dir.join("summary.md").exists());
assert!(dir.path().join("profile.json").exists());
assert!(dir.path().join("summary.md").exists());
let latest_manifest =
load_profile_manifest(&dir.path().join("profile.json")).expect("load latest manifest");
assert_eq!(latest_manifest.target, MobileTarget::Ios);
assert_eq!(latest_manifest.function, "sample_fns::checksum");
}
#[test]
fn profile_manifest_serializes_provider() {
let manifest = build_capture_plan(
&sample_run_args(
MobileTarget::Android,
ProfileProvider::Browserstack,
ProfileBackend::RustTracing,
ProfileFormat::Both,
),
&resolve_profile_target(&sample_run_args(
MobileTarget::Android,
ProfileProvider::Browserstack,
ProfileBackend::RustTracing,
ProfileFormat::Both,
))
.expect("resolve target"),
&PathBuf::from("target/mobench/profile"),
)
.expect("build manifest");
let json = serde_json::to_value(&manifest).expect("serialize manifest");
assert_eq!(json["provider"], "browserstack");
}
#[test]
fn resolve_profile_target_accepts_direct_ios_browserstack_device_request() {
let mut args = sample_run_args(
MobileTarget::Ios,
ProfileProvider::Browserstack,
ProfileBackend::IosInstruments,
ProfileFormat::Both,
);
args.device = Some("iPhone 14".into());
args.os_version = Some("16".into());
let target = resolve_profile_target(&args).expect("resolve direct device");
let device = target.device.expect("device");
assert_eq!(device.identifier, "iPhone 14-16");
assert_eq!(device.name, "iPhone 14");
assert_eq!(device.os_version, "16");
assert_eq!(device.source, "direct");
}
#[test]
fn profile_dry_run_always_stays_planned() {
let dir = tempfile::tempdir().expect("temp dir");
let mut args = sample_run_args(
MobileTarget::Ios,
ProfileProvider::Browserstack,
ProfileBackend::IosInstruments,
ProfileFormat::Both,
);
args.output_dir = dir.path().to_path_buf();
args.device = Some("iPhone 14".into());
args.os_version = Some("16".into());
cmd_profile_run(&args, true).expect("dry-run should stop after planning");
let manifest = load_profile_manifest(
&dir.path()
.join("ios-sample_fns--fibonacci")
.join("profile.json"),
)
.expect("load planned manifest");
assert_eq!(manifest.native_capture.status, CaptureStatus::Planned);
assert!(
manifest
.capture_metadata
.warnings
.iter()
.any(|warning| warning.contains("dry-run enabled")),
"expected dry-run warning in manifest: {:?}",
manifest.capture_metadata.warnings
);
}
#[test]
fn unsupported_browserstack_capture_fails_before_writing_fake_artifacts() {
let dir = tempfile::tempdir().expect("temp dir");
let mut args = sample_run_args(
MobileTarget::Ios,
ProfileProvider::Browserstack,
ProfileBackend::IosInstruments,
ProfileFormat::Both,
);
args.output_dir = dir.path().to_path_buf();
args.device = Some("iPhone 14".into());
args.os_version = Some("16".into());
let error = cmd_profile_run(&args, false).unwrap_err();
assert!(error.to_string().contains("BrowserStack native profiling"));
assert!(
!dir.path()
.join("ios-sample_fns--fibonacci")
.join("profile.json")
.exists(),
"unsupported execution should not write fake captured artifacts"
);
}
fn sample_manifest() -> ProfileManifest {
let target = ResolvedProfileTarget {
backend: ProfileBackend::AndroidNative,
device: Some(ResolvedProfileDevice {
name: "Pixel 7".into(),
os: "android".into(),
os_version: "13".into(),
identifier: "Pixel 7-13.0".into(),
profile: Some("high-spec".into()),
source: "matrix".into(),
}),
};
ProfileManifest {
run_id: "run-123".into(),
target: MobileTarget::Android,
function: "sample_fns::fibonacci".into(),
provider: ProfileProvider::Local,
backend: ProfileBackend::AndroidNative,
format: ProfileFormat::Both,
native_capture: NativeCaptureRecord {
status: CaptureStatus::Partial,
raw_artifacts: vec![ArtifactRecord {
label: "simpleperf".into(),
path: PathBuf::from("artifacts/raw/sample.perf"),
}],
processed_artifacts: vec![
ArtifactRecord {
label: "collapsed-stacks".into(),
path: PathBuf::from("artifacts/processed/stacks.folded"),
},
ArtifactRecord {
label: "benchmark-focused-stacks".into(),
path: PathBuf::from("artifacts/processed/benchmark.focused.folded"),
},
ArtifactRecord {
label: "native-report".into(),
path: PathBuf::from("artifacts/processed/native-report.txt"),
},
ArtifactRecord {
label: "flamegraph-full-svg".into(),
path: PathBuf::from("artifacts/processed/flamegraph.full.svg"),
},
ArtifactRecord {
label: "flamegraph-focused-svg".into(),
path: PathBuf::from("artifacts/processed/flamegraph.focused.svg"),
},
ArtifactRecord {
label: "flamegraph-viewer".into(),
path: PathBuf::from("artifacts/processed/flamegraph.html"),
},
],
symbolization: SymbolizationRecord {
status: CaptureStatus::Partial,
tool: Some("llvm-addr2line".into()),
resolved_frames: 3,
unresolved_frames: 1,
notes: vec!["missing symbols".into()],
},
viewer_hint: Some(
"Open artifacts/processed/flamegraph.html for the interactive dual-view flamegraph explorer"
.into(),
),
},
semantic_profile: SemanticProfileRecord {
status: SemanticCaptureStatus::Captured,
phases: vec![
SemanticPhaseRecord {
name: "prove".into(),
duration_ns: Some(120_000),
percent_total: None,
},
SemanticPhaseRecord {
name: "serialize".into(),
duration_ns: Some(8_000),
percent_total: None,
},
],
spans_path: Some(PathBuf::from("artifacts/semantic/spans.json")),
harness_timeline: vec![
HarnessTimelineSpanRecord {
phase: "setup".into(),
start_offset_ns: 0,
end_offset_ns: 100,
iteration: None,
},
HarnessTimelineSpanRecord {
phase: "measured-benchmark".into(),
start_offset_ns: 100,
end_offset_ns: 300,
iteration: Some(0),
},
],
timeline_path: Some(PathBuf::from("artifacts/semantic/timeline.json")),
},
capture_metadata: CaptureMetadataRecord {
device: target
.device
.as_ref()
.map(|device| device.identifier.clone()),
os: Some("android 13".into()),
sample_duration_secs: Some(15),
benchmark_iterations: Some(20),
benchmark_warmup: Some(3),
warmup_mode: Some(CaptureWarmupMode::Warm),
capture_method: Some("simpleperf".into()),
warnings: vec!["missing symbols".into()],
},
}
}
}