test: add throwaway perf spike measuring 2D entity budget
This commit is contained in:
490
examples/perf_spike.rs
Normal file
490
examples/perf_spike.rs
Normal file
@@ -0,0 +1,490 @@
|
||||
//! 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_COLLIDE 1 = run naive O(n*m) collision (default 1)
|
||||
//! PERF_MOVE 1 = run enemy movement (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 = 120_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,
|
||||
collide: bool,
|
||||
move_enemies: 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),
|
||||
collide: env_flag("PERF_COLLIDE", true),
|
||||
move_enemies: env_flag("PERF_MOVE", 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.run_if(|c: Res<SpikeConfig>| c.move_enemies),
|
||||
tower_fire,
|
||||
move_projectiles,
|
||||
collide.run_if(|c: Res<SpikeConfig>| c.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!(
|
||||
"systems move={} collide={}",
|
||||
cfg.move_enemies, cfg.collide
|
||||
);
|
||||
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]
|
||||
}
|
||||
Reference in New Issue
Block a user