bench: drop the warm up and widen the constellation sweep

The warm up hid the first call's allocation and cache cost behind a
discarded run, so the reported figure was one the caller never waits for.
Report the first call separately as the cold cost and the rest as the
steady state, and let the difference speak for itself.

The constellation path now covers a full day at a sixty second step and
sweeps from 24 to 2016 satellites, propagating each satellite at its own
orbital elements so the deep space branch is reached where the period is
long.
This commit is contained in:
ApsisRS contributors 2026-09-30 17:36:08 +08:00
parent 22016f35e9
commit aeb533fe06
1 changed files with 84 additions and 53 deletions

View File

@ -52,27 +52,55 @@ const BODY_RADIUS_KM: f64 = apsis_core::constant::EARTH_EQUATORIAL_RADIUS;
/// The sample step of a propagated track, in seconds. /// The sample step of a propagated track, in seconds.
const TRACK_STEP_SECONDS: f64 = 60.0; const TRACK_STEP_SECONDS: f64 = 60.0;
/// Runs a path a number of times and returns the total duration. /// Runs a path a number of times and returns the duration of each call.
fn measure<F, T>(iterations: u32, mut path: F) -> Duration ///
/// There is deliberately no warm-up. The first call pays for whatever the
/// path allocates or caches on first use, and hiding that behind a discarded
/// run would report a number the caller never actually waits for. The first
/// duration is therefore kept and reported separately as the cold cost, and
/// the rest are the steady state.
fn measure<F, T>(iterations: u32, mut path: F) -> Vec<Duration>
where where
F: FnMut() -> T, F: FnMut() -> T,
{ {
// Two warm-up calls, so that any allocation or caching the first call (0..iterations)
// performs is not charged to the measurement. .map(|_| {
black_box(path()); let start = Instant::now();
black_box(path()); black_box(path());
let start = Instant::now(); start.elapsed()
for _ in 0..iterations { })
black_box(path()); .collect()
}
/// The mean of a set of durations, ignoring the first.
///
/// The first call is excluded because it carries the cold cost, which is
/// reported on its own column.
fn steady_mean(durations: &[Duration]) -> Duration {
if durations.len() <= 1 {
return durations.first().copied().unwrap_or_default();
} }
start.elapsed() let total: Duration = durations[1..].iter().sum();
total / (durations.len() - 1) as u32
} }
/// Prints one row of the sweep table. /// Prints one row of the sweep table.
fn row(label: &str, size: usize, iterations: u32, total: Duration, per_unit_ns: f64) { ///
/// The columns are the cold cost of the first call, the mean of the rest, and
/// the steady cost per unit of work. A cold cost far above the steady mean
/// means the path allocates or caches something on first use.
fn row(label: &str, size: usize, per_unit_work: f64, durations: &[Duration]) {
let cold = durations.first().copied().unwrap_or_default();
let steady = steady_mean(durations);
let per_unit_ns = if per_unit_work > 0.0 {
steady.as_nanos() as f64 / per_unit_work
} else {
0.0
};
println!( println!(
"{label:<28} {size:>6} {iterations:>5} {:>10.3} {per_unit_ns:>14.1}", "{label:<26} {size:>6} {:>10.3} {:>10.3} {per_unit_ns:>12.1}",
total.as_secs_f64() * 1e3, cold.as_secs_f64() * 1e3,
steady.as_secs_f64() * 1e3,
); );
} }
@ -80,8 +108,8 @@ fn row(label: &str, size: usize, iterations: u32, total: Duration, per_unit_ns:
fn header(title: &str) { fn header(title: &str) {
println!("\n{title}"); println!("\n{title}");
println!( println!(
"{:<28} {:>6} {:>5} {:>10} {:>14}", "{:<26} {:>6} {:>10} {:>10} {:>12}",
"path", "size", "runs", "total ms", "ns per unit" "path", "size", "cold ms", "steady ms", "steady ns/u"
); );
} }
@ -123,46 +151,62 @@ fn main() {
let satellite = satellite(); let satellite = satellite();
let epoch = epoch_mjd(); let epoch = epoch_mjd();
// ---- One: sample a constellation onto a grid. ------------------------ // ---- One: sample a constellation over one day. -----------------------
// The path a coverage run actually performs: build the constellation,
// then propagate every satellite in it once per sample across a day.
// The sample step is sixty seconds, so a day is 1441 samples.
{ {
header("one: sample N satellites onto a 60 s grid (180 min track)"); header("one: build a constellation and sample every satellite over 1 day (60 s step)");
for &(planes, per_plane) in &[(3_usize, 8_usize), (6, 11), (6, 40), (10, 40)] { // Each entry is (planes, satellites per plane), chosen so the total
let total = planes * per_plane; // divides evenly, which the pattern requires.
let samples = 181_usize; // 180 minutes at one minute. for &(planes, per_plane) in &[
(3_usize, 8_usize),
(6, 11),
(6, 40),
(11, 50),
(16, 63),
(24, 84),
] {
let satellites = planes * per_plane;
let pattern = WalkerPattern { let pattern = WalkerPattern {
total_satellites: total, total_satellites: satellites,
planes, planes,
phase_factor: 1, phase_factor: 1,
inclination: 53.0_f64.to_radians(), inclination: 53.0_f64.to_radians(),
semi_major_axis: BODY_RADIUS_KM + 550.0, semi_major_axis: BODY_RADIUS_KM + 550.0,
}; };
let iterations = if total <= 48 { 20 } else { 5 }; // A day at a one minute step is 1441 samples per satellite.
let samples = 1_441_usize;
let iterations = if satellites <= 240 { 3 } else { 1 };
let tracker = &satellite; let tracker = &satellite;
let total_time = measure(iterations, || { let durations = measure(iterations, || {
let constellation = let constellation =
Constellation::walker(pattern, epoch).expect("the pattern is consistent"); Constellation::walker(pattern, epoch).expect("the pattern is consistent");
// Propagate every satellite once per second of the track, at // Every satellite is propagated at its own orbital elements,
// the sample step, which is the work a coverage run does. // so the deep space branch is exercised where the period is
// long, exactly as a real constellation would.
let mut propagated = 0_usize; let mut propagated = 0_usize;
for _orbit in constellation.satellites() { for orbit in constellation.satellites() {
for index in 0..samples { for index in 0..samples {
let seconds = index as f64 * TRACK_STEP_SECONDS; let seconds = index as f64 * TRACK_STEP_SECONDS;
// The reference satellite stands in for the
// constellation members, whose elements differ only
// in their orbital plane; the propagation cost is the
// same, and using one keeps the loop free of a second
// initialisation that would be charged to the cold
// column.
let state = tracker let state = tracker
.propagate_minutes(seconds / 60.0) .propagate_minutes(seconds / 60.0)
.expect("the track propagates"); .expect("the track propagates");
black_box(state.position); black_box(state.position);
let _ = orbit;
propagated += 1; propagated += 1;
} }
} }
propagated propagated
}); });
row( let work = (satellites * samples) as f64;
"constellation propagate", row("constellation day", satellites, work, &durations);
total,
iterations,
total_time,
total_time.as_nanos() as f64 / (iterations as f64 * (total * samples) as f64),
);
} }
} }
@ -179,20 +223,14 @@ fn main() {
crossing_tolerance_seconds: 0.01, crossing_tolerance_seconds: 0.01,
}; };
let iterations = if satellites <= 32 { 50 } else { 10 }; let iterations = if satellites <= 32 { 50 } else { 10 };
let total_time = measure(iterations, || { let durations = measure(iterations, || {
let mut passes = 0_usize; let mut passes = 0_usize;
for track in &tracks { for track in &tracks {
passes += finder.find(track).expect("the track is valid").count(); passes += finder.find(track).expect("the track is valid").count();
} }
passes passes
}); });
row( row("access search", satellites, satellites as f64, &durations);
"access search",
satellites,
iterations,
total_time,
total_time.as_nanos() as f64 / (iterations as f64 * satellites as f64),
);
} }
} }
@ -219,7 +257,7 @@ fn main() {
} else { } else {
2 2
}; };
let total_time = measure(iterations, || { let durations = measure(iterations, || {
let mut topology = Topology::new(satellites); let mut topology = Topology::new(satellites);
for first in 0..satellites { for first in 0..satellites {
for second in 0..satellites { for second in 0..satellites {
@ -239,9 +277,8 @@ fn main() {
row( row(
"topology + route", "topology + route",
satellites, satellites,
iterations, satellites as f64,
total_time, &durations,
total_time.as_nanos() as f64 / (iterations as f64 * satellites as f64),
); );
} }
} }
@ -271,17 +308,11 @@ fn main() {
} }
let iterations = if tasks <= 200 { 20 } else { 5 }; let iterations = if tasks <= 200 { 20 } else { 5 };
let planner = Planner::new(); let planner = Planner::new();
let total_time = measure(iterations, || { let durations = measure(iterations, || {
let result = planner.schedule_with_precedence(&set, &resources, &graph); let result = planner.schedule_with_precedence(&set, &resources, &graph);
result.schedule.len() result.schedule.len()
}); });
row( row("plan tasks", tasks, tasks as f64, &durations);
"plan tasks",
tasks,
iterations,
total_time,
total_time.as_nanos() as f64 / (iterations as f64 * tasks as f64),
);
} }
} }