use super::*;
use std::time::Instant;
const MAX_PROFILE_SAMPLES: u32 = 10_000;
#[derive(Default)]
struct TimingAggregate {
width: u32,
height: u32,
samples: Vec<u64>,
}
pub fn profile_project(options: &ProfileOptions) -> Result<Output> {
if options.samples == 0 || options.samples > MAX_PROFILE_SAMPLES {
bail!("--samples must be in 1..={MAX_PROFILE_SAMPLES}");
}
if options.warmup > MAX_PROFILE_SAMPLES {
bail!("--warmup must be at most {MAX_PROFILE_SAMPLES}");
}
let loaded = LoadedManifest::load(&options.project)?;
ensure_source_files_exist(&loaded)?;
let media = crate::media::MediaInputs::new_headless(&loaded)?;
let (width, height) = render::resolve_dimensions(&loaded, None, options.width, options.height)?;
let fps = render::resolve_fps(&loaded, None, options.fps)?;
let target_frame = resolve_target_frame(&loaded, None, options.frame, options.time, fps)?;
let context = HeadlessContext::new(64, 64)
.context("failed to create headless OpenGL context for profiling")?;
let mut runtime = Runtime::new(&context)?;
let project = build_native_project(&loaded)?;
runtime.load_project(&project)?;
let uniform_values =
crate::uniforms::parse_assignments(&loaded.manifest.uniforms, &options.set_uniforms)?;
crate::uniforms::apply_to_runtime(&mut runtime, &uniform_values)?;
let _ = render_from_zero(&mut runtime, target_frame, fps, width, height, &[], &media)?;
for _ in 0..options.warmup {
runtime.tick_fixed(1.0 / fps, fps)?;
let media_time = runtime.time();
media.update(&mut runtime, media_time)?;
let _ = runtime.render(width, height)?;
}
runtime.set_profiling(true)?;
let first_sample_frame = runtime.frame().saturating_add(1);
let mut cpu_samples = Vec::with_capacity(options.samples as usize);
let mut gpu_total_samples = Vec::with_capacity(options.samples as usize);
let mut passes: BTreeMap<String, TimingAggregate> = BTreeMap::new();
for _ in 0..options.samples {
runtime.tick_fixed(1.0 / fps, fps)?;
let media_time = runtime.time();
media.update(&mut runtime, media_time)?;
let started = Instant::now();
let _ = runtime.render(width, height)?;
cpu_samples.push(started.elapsed().as_nanos() as u64);
let timings = runtime.pass_timings()?;
gpu_total_samples.push(
timings
.iter()
.map(|timing| timing.gpu_nanoseconds)
.sum::<u64>(),
);
for timing in timings {
let aggregate = passes.entry(timing.name).or_default();
aggregate.width = timing.width;
aggregate.height = timing.height;
aggregate.samples.push(timing.gpu_nanoseconds);
}
}
runtime.set_profiling(false)?;
let pass_rows = passes
.iter()
.map(|(name, aggregate)| {
let stats = timing_stats(&aggregate.samples);
(name.clone(), aggregate.width, aggregate.height, stats)
})
.collect::<Vec<_>>();
let cpu = timing_stats(&cpu_samples);
let gpu_total = timing_stats(&gpu_total_samples);
let total_gpu_mean_ns = gpu_total.mean_ns;
let persistent_buffer_bytes = estimated_persistent_buffer_bytes(&loaded, width, height)?;
let mut human = format!(
"Profile: {}x{} @ {} fps, {} samples (frames {}..={})\n",
width,
height,
fps,
options.samples,
first_sample_frame,
runtime.frame()
);
human.push_str(
"Timing mode: isolated passes (cross-pass GPU overlap disabled; boundary sync overhead included)\n",
);
human.push_str("Pass Resolution mean min max\n");
for (name, pass_width, pass_height, stats) in &pass_rows {
human.push_str(&format!(
"{name:<28} {pass_width:>5}x{pass_height:<5} {:>8.3}ms {:>8.3}ms {:>8.3}ms\n",
stats.mean_ns / 1_000_000.0,
stats.min_ns as f64 / 1_000_000.0,
stats.max_ns as f64 / 1_000_000.0,
));
}
human.push_str(&format!(
"GPU pass total (mean): {:.3} ms\nCPU render call (mean): {:.3} ms\nPersistent GPU state VRAM estimate: {:.2} MiB",
total_gpu_mean_ns / 1_000_000.0,
cpu.mean_ns / 1_000_000.0,
persistent_buffer_bytes as f64 / (1024.0 * 1024.0),
));
let json_passes = pass_rows
.iter()
.map(|(name, pass_width, pass_height, stats)| {
json!({
"name": name,
"width": pass_width,
"height": pass_height,
"samples": stats.count,
"mean_ms": stats.mean_ns / 1_000_000.0,
"median_ms": stats.median_ns / 1_000_000.0,
"p95_ms": stats.p95_ns / 1_000_000.0,
"min_ms": stats.min_ns as f64 / 1_000_000.0,
"max_ms": stats.max_ns as f64 / 1_000_000.0,
})
})
.collect::<Vec<_>>();
Ok(Output {
human,
json: json!({
"ok": true,
"project": loaded.manifest.project.name,
"width": width,
"height": height,
"fps": fps,
"warmup": options.warmup,
"samples": options.samples,
"first_sample_frame": first_sample_frame,
"last_sample_frame": runtime.frame(),
"passes": json_passes,
"timing_mode": "isolated_passes",
"gpu_pass_total_mean_ms": total_gpu_mean_ns / 1_000_000.0,
"gpu_pass_total": {
"mean_ms": gpu_total.mean_ns / 1_000_000.0,
"median_ms": gpu_total.median_ns / 1_000_000.0,
"p95_ms": gpu_total.p95_ns / 1_000_000.0,
"min_ms": gpu_total.min_ns as f64 / 1_000_000.0,
"max_ms": gpu_total.max_ns as f64 / 1_000_000.0,
},
"cpu_render": {
"mean_ms": cpu.mean_ns / 1_000_000.0,
"median_ms": cpu.median_ns / 1_000_000.0,
"p95_ms": cpu.p95_ns / 1_000_000.0,
"min_ms": cpu.min_ns as f64 / 1_000_000.0,
"max_ms": cpu.max_ns as f64 / 1_000_000.0,
},
"estimated_persistent_gpu_state_vram_bytes": persistent_buffer_bytes,
}),
})
}
struct TimingStats {
count: usize,
mean_ns: f64,
median_ns: f64,
p95_ns: f64,
min_ns: u64,
max_ns: u64,
}
fn timing_stats(samples: &[u64]) -> TimingStats {
let min_ns = samples.iter().copied().min().unwrap_or(0);
let max_ns = samples.iter().copied().max().unwrap_or(0);
let mean_ns = if samples.is_empty() {
0.0
} else {
samples.iter().map(|value| *value as f64).sum::<f64>() / samples.len() as f64
};
let mut sorted = samples.to_vec();
sorted.sort_unstable();
let median_ns = match sorted.len() {
0 => 0.0,
len if len % 2 == 1 => sorted[len / 2] as f64,
len => (sorted[len / 2 - 1] as f64 + sorted[len / 2] as f64) / 2.0,
};
let p95_ns = if sorted.is_empty() {
0.0
} else {
let rank = ((sorted.len() as f64 * 0.95).ceil() as usize).clamp(1, sorted.len());
sorted[rank - 1] as f64
};
TimingStats {
count: samples.len(),
mean_ns,
median_ns,
p95_ns,
min_ns,
max_ns,
}
}
fn estimated_persistent_buffer_bytes(
loaded: &LoadedManifest,
output_width: u32,
output_height: u32,
) -> Result<u64> {
let feedback_sources = loaded
.manifest
.passes
.iter()
.flat_map(|pass| &pass.inputs)
.filter(|input| input.frame == crate::manifest::FrameRef::Previous)
.map(|input| input.source.as_str())
.collect::<std::collections::HashSet<_>>();
let mut bytes = 0u64;
for pass in &loaded.manifest.passes {
if !matches!(pass.kind, PassKind::Buffer | PassKind::Compute) {
continue;
}
let (width, height) = loaded
.manifest
.pass_dimensions(pass, output_width, output_height);
let bytes_per_pixel = |format| match format {
crate::manifest::RenderFormat::R32f => 4u64,
crate::manifest::RenderFormat::Rg32f | crate::manifest::RenderFormat::Rgba16f => 8,
crate::manifest::RenderFormat::Rgba32f => 16,
};
let target_bytes = std::iter::once(pass.format)
.chain(pass.extra_outputs.iter().copied())
.try_fold(0u64, |total, format| {
total
.checked_add(bytes_per_pixel(format))
.context("persistent MRT VRAM estimate overflow")
})?;
let copies = if feedback_sources.contains(pass.name.as_str()) {
2u64
} else {
1
};
let pass_bytes = u64::from(width)
.checked_mul(u64::from(height))
.and_then(|value| value.checked_mul(target_bytes))
.and_then(|value| value.checked_mul(copies))
.context("persistent buffer VRAM estimate overflow")?;
bytes = bytes
.checked_add(pass_bytes)
.context("persistent buffer VRAM estimate overflow")?;
}
let mut storage = std::collections::HashMap::new();
for pass in &loaded.manifest.passes {
for buffer in &pass.storage {
storage.entry(buffer.name.as_str()).or_insert(buffer.size);
}
}
for size in storage.into_values() {
bytes = bytes
.checked_add(size)
.context("persistent storage VRAM estimate overflow")?;
}
Ok(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timing_stats_handles_empty_and_values() {
let empty = timing_stats(&[]);
assert_eq!(empty.count, 0);
assert_eq!(empty.mean_ns, 0.0);
assert_eq!(empty.median_ns, 0.0);
assert_eq!(empty.p95_ns, 0.0);
let values = timing_stats(&[10, 20, 30, 40]);
assert_eq!(values.min_ns, 10);
assert_eq!(values.max_ns, 40);
assert_eq!(values.mean_ns, 25.0);
assert_eq!(values.median_ns, 25.0);
assert_eq!(values.p95_ns, 40.0);
}
}