Files
InfiniTower/examples/perf_spike.rs

481 lines
16 KiB
Rust

//! Throwaway performance spike for InfiniTower.
//!
//! Answers one question only: how many enemies and projectiles does Bevy 0.19 carry
//! in 2D before the frame budget of 16.6 ms (60 fps) is gone?
//!
//! Deliberately minimal: no gameplay, no UI, no assets. Enemies walk radially towards
//! the centre, a single tower fires at the nearest one, projectiles fly straight, a hit
//! removes enemy and projectile. The enemy population is held constant so the load
//! does not drift during a run.
//!
//! Run (release profile, otherwise the numbers are meaningless):
//! cargo run --release --example perf_spike -- 2000
//! PERF_N=2000 cargo run --release --example perf_spike
//!
//! Knobs (env vars, CLI arg 1 overrides PERF_N):
//! PERF_N enemy population held constant (default 1000)
//! PERF_FIRE_RATE tower shots per second (default 20)
//! PERF_RENDER 1 = window + sprites, 0 = headless logic (default 1)
//! PERF_SECONDS length of the measurement window (default 12)
//! PERF_WARMUP seconds discarded before measuring (default 4)
//!
//! Every run prints a `=== PERF SPIKE RESULT ===` block with medians (not first-frame
//! values) and then exits on its own.
use std::time::Instant;
use bevy::{
diagnostic::{
Diagnostic, DiagnosticPath, Diagnostics, DiagnosticsPlugin, DiagnosticsStore,
FrameTimeDiagnosticsPlugin, LogDiagnosticsPlugin, RegisterDiagnostic,
},
platform::collections::HashSet,
prelude::*,
};
use core::{f32::consts::TAU, time::Duration};
/// Radius of the ring the enemies spawn on. Chosen so the whole arena fits into a
/// 1920x1080 window: nothing is frustum-culled, so the render cost is honest.
const ARENA_RADIUS: f32 = 500.0;
const ENEMY_SPEED: f32 = 50.0;
const ENEMY_SIZE: f32 = 8.0;
const PROJECTILE_SPEED: f32 = 700.0;
const PROJECTILE_SIZE: f32 = 4.0;
/// A projectile within this distance of an enemy counts as a hit.
const HIT_RADIUS: f32 = 10.0;
/// An enemy this close to the centre has "arrived" and is recycled to the rim.
const CENTER_RADIUS: f32 = 24.0;
/// Enough history to hold every frame of a long run at high fps.
const HISTORY: usize = 20_000;
const D_MAINTAIN: DiagnosticPath = DiagnosticPath::const_new("spike/maintain_us");
const D_MOVE_ENEMIES: DiagnosticPath = DiagnosticPath::const_new("spike/move_enemies_us");
const D_FIRE: DiagnosticPath = DiagnosticPath::const_new("spike/tower_fire_us");
const D_MOVE_PROJ: DiagnosticPath = DiagnosticPath::const_new("spike/move_projectiles_us");
const D_COLLIDE: DiagnosticPath = DiagnosticPath::const_new("spike/collide_us");
const D_ENEMIES: DiagnosticPath = DiagnosticPath::const_new("spike/enemies");
const D_PROJECTILES: DiagnosticPath = DiagnosticPath::const_new("spike/projectiles");
const D_CHECKS: DiagnosticPath = DiagnosticPath::const_new("spike/pair_checks");
#[derive(Resource, Clone, Copy)]
struct SpikeConfig {
enemy_count: usize,
fire_rate: f32,
render: bool,
warmup_secs: f64,
measure_secs: f64,
}
impl SpikeConfig {
fn from_env() -> Self {
let cli_n = std::env::args().nth(1).and_then(|a| a.parse::<usize>().ok());
Self {
enemy_count: cli_n.unwrap_or_else(|| env_num("PERF_N", 1000.0) as usize),
fire_rate: env_num("PERF_FIRE_RATE", 20.0) as f32,
render: env_flag("PERF_RENDER", true),
warmup_secs: env_num("PERF_WARMUP", 4.0),
measure_secs: env_num("PERF_SECONDS", 12.0),
}
}
}
fn env_num(key: &str, default: f64) -> f64 {
std::env::var(key)
.ok()
.and_then(|v| v.parse::<f64>().ok())
.unwrap_or(default)
}
fn env_flag(key: &str, default: bool) -> bool {
std::env::var(key)
.ok()
.map(|v| !matches!(v.as_str(), "0" | "false" | "off"))
.unwrap_or(default)
}
#[derive(Component)]
struct Enemy;
#[derive(Component)]
struct Projectile {
velocity: Vec2,
}
#[derive(Resource)]
struct TowerGun {
timer: Timer,
}
/// Monotonic counter, used as the seed for respawn angles so runs are deterministic.
#[derive(Resource, Default)]
struct SpawnSeed(u32);
#[derive(Resource)]
struct Harness {
elapsed: f64,
measuring: bool,
done: bool,
}
fn main() {
let cfg = SpikeConfig::from_env();
let mut app = App::new();
if cfg.render {
app.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "InfiniTower perf spike".into(),
// Uncapped: with vsync the frame time would just be the monitor's.
present_mode: bevy::window::PresentMode::AutoNoVsync,
resolution: bevy::window::WindowResolution::new(1920, 1080)
.with_scale_factor_override(1.0),
..default()
}),
..default()
}))
.insert_resource(bevy::winit::WinitSettings::continuous());
} else {
// Headless: same simulation, no renderer at all. The delta against the
// rendered run is the render cost.
app.add_plugins((MinimalPlugins, bevy::log::LogPlugin::default(), DiagnosticsPlugin));
}
app.add_plugins((
FrameTimeDiagnosticsPlugin::new(HISTORY),
LogDiagnosticsPlugin {
wait_duration: Duration::from_secs(2),
..default()
},
))
.insert_resource(cfg)
.init_resource::<SpawnSeed>()
.insert_resource(TowerGun {
timer: Timer::from_seconds(1.0 / cfg.fire_rate.max(0.01), TimerMode::Repeating),
})
.insert_resource(Harness {
elapsed: 0.0,
measuring: false,
done: false,
});
for path in [
&D_MAINTAIN,
&D_MOVE_ENEMIES,
&D_FIRE,
&D_MOVE_PROJ,
&D_COLLIDE,
&D_ENEMIES,
&D_PROJECTILES,
&D_CHECKS,
] {
app.register_diagnostic(Diagnostic::new(path.clone()).with_max_history_length(HISTORY));
}
app.add_systems(Startup, setup)
.add_systems(
Update,
(
maintain_population,
move_enemies,
tower_fire,
move_projectiles,
collide,
)
.chain(),
)
.add_systems(Last, harness)
.run();
}
fn setup(mut commands: Commands, cfg: Res<SpikeConfig>) {
if cfg.render {
commands.spawn(Camera2d);
// The tower itself. Purely cosmetic, the firing logic lives in a resource.
commands.spawn((
Sprite::from_color(Color::srgb(0.9, 0.9, 0.2), Vec2::splat(24.0)),
Transform::default(),
));
}
// Initial population: angles evenly spread over the circle, radii staggered.
// Without the stagger every enemy would reach the centre in the same frame and
// the frame time would oscillate instead of settling.
let n = cfg.enemy_count.max(1);
for i in 0..cfg.enemy_count {
let angle = (i as f32 / n as f32) * TAU;
let radius = ARENA_RADIUS * (0.2 + 0.8 * hash01(i as u32));
spawn_enemy(&mut commands, angle, radius, cfg.render);
}
}
/// Keeps the enemy population at exactly `PERF_N`, so the load stays constant while
/// hits and centre-arrivals despawn entities. Also the place where spawn cost shows up.
fn maintain_population(
mut commands: Commands,
cfg: Res<SpikeConfig>,
enemies: Query<(), With<Enemy>>,
mut seed: ResMut<SpawnSeed>,
mut diagnostics: Diagnostics,
) {
let t0 = Instant::now();
let alive = enemies.iter().len();
for _ in alive..cfg.enemy_count {
seed.0 = seed.0.wrapping_add(1);
spawn_enemy(&mut commands, hash01(seed.0) * TAU, ARENA_RADIUS, cfg.render);
}
let us = t0.elapsed().as_secs_f64() * 1e6;
diagnostics.add_measurement(&D_MAINTAIN, || us);
diagnostics.add_measurement(&D_ENEMIES, || alive as f64);
}
fn move_enemies(
mut commands: Commands,
time: Res<Time>,
mut enemies: Query<(Entity, &mut Transform), With<Enemy>>,
mut diagnostics: Diagnostics,
) {
let t0 = Instant::now();
let dt = time.delta_secs();
for (entity, mut transform) in &mut enemies {
let pos = transform.translation.truncate();
let dist = pos.length();
if dist <= CENTER_RADIUS {
// Reached the tower: despawn, `maintain_population` puts it back on the rim.
commands.entity(entity).despawn();
continue;
}
let step = pos / dist * (-ENEMY_SPEED * dt);
transform.translation.x += step.x;
transform.translation.y += step.y;
}
let us = t0.elapsed().as_secs_f64() * 1e6;
diagnostics.add_measurement(&D_MOVE_ENEMIES, || us);
}
/// Single tower, fixed rate, nearest target. The target scan is a full O(n) sweep
/// and only runs on frames where the timer actually fires.
fn tower_fire(
mut commands: Commands,
time: Res<Time>,
cfg: Res<SpikeConfig>,
mut gun: ResMut<TowerGun>,
enemies: Query<&Transform, With<Enemy>>,
mut diagnostics: Diagnostics,
) {
let t0 = Instant::now();
gun.timer.tick(time.delta());
let shots = gun.timer.times_finished_this_tick();
if shots > 0 {
let mut best: Option<(f32, Vec2)> = None;
for transform in &enemies {
let pos = transform.translation.truncate();
let d2 = pos.length_squared();
if best.is_none_or(|(best_d2, _)| d2 < best_d2) {
best = Some((d2, pos));
}
}
if let Some((_, target)) = best {
let dir = target.normalize_or_zero();
for i in 0..shots {
// Fan a multi-shot tick out slightly so the projectiles do not overlap.
let offset = (i as f32 - (shots as f32 - 1.0) * 0.5) * 0.04;
let velocity = Vec2::from_angle(offset).rotate(dir) * PROJECTILE_SPEED;
spawn_projectile(&mut commands, velocity, cfg.render);
}
}
}
let us = t0.elapsed().as_secs_f64() * 1e6;
diagnostics.add_measurement(&D_FIRE, || us);
}
fn move_projectiles(
mut commands: Commands,
time: Res<Time>,
mut projectiles: Query<(Entity, &mut Transform, &Projectile)>,
mut diagnostics: Diagnostics,
) {
let t0 = Instant::now();
let dt = time.delta_secs();
let mut alive = 0usize;
for (entity, mut transform, projectile) in &mut projectiles {
transform.translation.x += projectile.velocity.x * dt;
transform.translation.y += projectile.velocity.y * dt;
if transform.translation.truncate().length() > ARENA_RADIUS * 1.2 {
commands.entity(entity).despawn();
} else {
alive += 1;
}
}
let us = t0.elapsed().as_secs_f64() * 1e6;
diagnostics.add_measurement(&D_MOVE_PROJ, || us);
diagnostics.add_measurement(&D_PROJECTILES, || alive as f64);
}
/// Deliberately naive O(n*m) broad phase: every projectile against every enemy.
/// The whole point of the spike is to find out where this stops being good enough.
fn collide(
mut commands: Commands,
projectiles: Query<(Entity, &Transform), With<Projectile>>,
enemies: Query<(Entity, &Transform), With<Enemy>>,
mut diagnostics: Diagnostics,
) {
let t0 = Instant::now();
let hit_radius_sq = HIT_RADIUS * HIT_RADIUS;
let mut killed: HashSet<Entity> = HashSet::default();
let mut checks = 0u64;
for (projectile, projectile_tf) in &projectiles {
let p = projectile_tf.translation.truncate();
for (enemy, enemy_tf) in &enemies {
checks += 1;
if killed.contains(&enemy) {
continue;
}
if p.distance_squared(enemy_tf.translation.truncate()) < hit_radius_sq {
killed.insert(enemy);
commands.entity(enemy).despawn();
commands.entity(projectile).despawn();
break;
}
}
}
let us = t0.elapsed().as_secs_f64() * 1e6;
diagnostics.add_measurement(&D_COLLIDE, || us);
diagnostics.add_measurement(&D_CHECKS, || checks as f64);
}
fn spawn_enemy(commands: &mut Commands, angle: f32, radius: f32, render: bool) {
let pos = Vec2::from_angle(angle) * radius;
let transform = Transform::from_xyz(pos.x, pos.y, 0.0);
if render {
commands.spawn((
Enemy,
Sprite::from_color(Color::srgb(0.85, 0.25, 0.25), Vec2::splat(ENEMY_SIZE)),
transform,
));
} else {
commands.spawn((Enemy, transform));
}
}
fn spawn_projectile(commands: &mut Commands, velocity: Vec2, render: bool) {
let transform = Transform::from_xyz(0.0, 0.0, 1.0);
if render {
commands.spawn((
Projectile { velocity },
Sprite::from_color(Color::srgb(0.3, 0.8, 1.0), Vec2::splat(PROJECTILE_SIZE)),
transform,
));
} else {
commands.spawn((Projectile { velocity }, transform));
}
}
/// Cheap deterministic hash into [0, 1). Avoids pulling in `rand` for a throwaway example.
fn hash01(n: u32) -> f32 {
let mut x = n.wrapping_mul(0x9E37_79B9).wrapping_add(0x1234_5678);
x ^= x >> 16;
x = x.wrapping_mul(0x85EB_CA6B);
x ^= x >> 13;
(x >> 8) as f32 / (1u32 << 24) as f32
}
/// Drives warmup -> measurement -> summary -> exit, so a run is scriptable and the
/// reported numbers are medians of the settled phase, never the first frame.
fn harness(
time: Res<Time<Real>>,
cfg: Res<SpikeConfig>,
mut state: ResMut<Harness>,
mut store: ResMut<DiagnosticsStore>,
mut exit: MessageWriter<AppExit>,
) {
if state.done {
return;
}
state.elapsed += time.delta_secs_f64();
if !state.measuring {
if state.elapsed >= cfg.warmup_secs {
// Throw away everything from startup, shader compilation and the first
// population wave.
for diagnostic in store.iter_mut() {
diagnostic.clear_history();
}
state.measuring = true;
info!("warmup done, measuring for {} s", cfg.measure_secs);
}
return;
}
if state.elapsed < cfg.warmup_secs + cfg.measure_secs {
return;
}
state.done = true;
let frame_ms = sorted(&store, &FrameTimeDiagnosticsPlugin::FRAME_TIME);
let frames = frame_ms.len();
let median_ms = percentile(&frame_ms, 0.5);
let p95_ms = percentile(&frame_ms, 0.95);
let p99_ms = percentile(&frame_ms, 0.99);
let m = |path: &DiagnosticPath| percentile(&sorted(&store, path), 0.5);
let maintain = m(&D_MAINTAIN);
let move_enemies_us = m(&D_MOVE_ENEMIES);
let fire = m(&D_FIRE);
let move_proj = m(&D_MOVE_PROJ);
let collide_us = m(&D_COLLIDE);
let logic_us = maintain + move_enemies_us + fire + move_proj + collide_us;
println!("=== PERF SPIKE RESULT ===");
println!(
"mode {}",
if cfg.render { "render" } else { "headless" }
);
println!("N (enemies) {}", cfg.enemy_count);
println!("fire rate {} shots/s", cfg.fire_rate);
println!("frames measured {frames}");
println!(
"frame time median {median_ms:.3} ms | p95 {p95_ms:.3} ms | p99 {p99_ms:.3} ms"
);
println!(
"fps median {:.1} | p95 {:.1}",
1000.0 / median_ms.max(f64::EPSILON),
1000.0 / p95_ms.max(f64::EPSILON)
);
println!("enemies alive median {:.0}", m(&D_ENEMIES));
println!("projectiles median {:.0}", m(&D_PROJECTILES));
println!("pair checks median {:.0} /frame", m(&D_CHECKS));
println!(
"system medians maintain {maintain:.1} us | move_enemies {move_enemies_us:.1} us | \
tower_fire {fire:.1} us | move_projectiles {move_proj:.1} us | collide {collide_us:.1} us"
);
println!(
"logic total {:.3} ms ({:.1} % of the median frame)",
logic_us / 1000.0,
(logic_us / 1000.0) / median_ms.max(f64::EPSILON) * 100.0
);
println!("=========================");
exit.write(AppExit::Success);
}
fn sorted(store: &DiagnosticsStore, path: &DiagnosticPath) -> Vec<f64> {
let mut values: Vec<f64> = store
.get(path)
.map(|d| d.values().copied().collect())
.unwrap_or_default();
values.sort_by(|a, b| a.total_cmp(b));
values
}
/// Returns 0.0 for an empty history, which is what a disabled system should report.
fn percentile(sorted_values: &[f64], q: f64) -> f64 {
if sorted_values.is_empty() {
return 0.0;
}
let idx = ((sorted_values.len() - 1) as f64 * q).round() as usize;
sorted_values[idx]
}