Reconstruction benchmarks¶
The benchmark framework runs existing reconstruction algorithms on an immutable
ReconstructionProblem. It does not add an algorithm registry or a second
synthetic-dataset abstraction.
Three deterministic simulator presets are available:
noiseless_mixed_fpm: ideal mixed amplitude/phase acquisition;aberrated_pupil_fpm: known defocus and pupil-aberration mismatch;poisson_gaussian_fpm: shot noise, Gaussian read noise, detector offset, quantization, and a finite detector range.
CameraModel is separately validated with seeded distributional tests rather
than exact sample values: un-clipped Poisson draws match their expected mean and
variance, read-noise variance scales with the square of detector gain, and the
combined variance is gain^2 * (mean_photoelectrons + read_noise_electrons^2).
These tests use 65,536 draws at signals far above the lower clamp. Clipping and
quantization are tested deterministically as a distinct detector behavior.
The normal regression suite also runs the diagnostics recorder against the versioned noiseless preset. It checks cadence, Fourier coverage dimensions, finite per-frame residuals, and a decreasing recorded objective without treating runtime or elapsed-time values as stable regression data.
Each returns the normal SimulationResult. Preset names end in a schema version
such as _v1; changing the physical definition requires a new preset version.
run_benchmark_case is generic over ReconstructionAlgorithm and
MeasurementRead. It returns a BenchmarkRecord plus the optional successful
ReconstructionResult. The record includes dimensions, original frame/source
indices, algorithm configuration, elapsed seconds, objective ratio, object/pupil errors when
ground truth exists, and per-frame residual summaries. Reconstruction and metric
failures are recorded rather than discarded.
The final argument to run_benchmark_case is an optional reconstruction-space
valid-object mask. When present, amplitude, phase, complex-field, and Fourier
metrics ignore pixels outside the mask. Public data without ground truth passes
None; per-frame intensity residuals remain available.
```rust,no_run use fpm_rs::{ Result, algorithms::AlternatingProjection, benchmark::run_benchmark_case, reconstruction::ReconstructionProblem, simulation::presets::{NOISELESS_MIXED_PRESET, noiseless_mixed_fpm}, };
fn main() -> Result<()> { let simulation = noiseless_mixed_fpm(123)?; let truth = simulation.ground_truth_object; let true_model = simulation.true_model; let problem = ReconstructionProblem::new( simulation.measurements, simulation.reconstruction_model, )?; let (mut record, result) = run_benchmark_case( "synthetic", "iterations=10,object_step=1.0", AlternatingProjection::default().iterations(10), &problem, Some(&truth), Some(&true_model), None, ); record.preset_name = Some(NOISELESS_MIXED_PRESET.into()); record.random_seed = Some(123); assert!(record.success && result.is_some()); Ok(()) }
Use `write_benchmark_csv` and `write_benchmark_json` for summaries.
`save_benchmark_outputs` writes amplitude, phase, a result bundle, and objective
history CSV. Output stems contain a stable case hash so different algorithm
configurations do not overwrite one another.
Named profiles are metadata only; examples still choose concrete algorithms
directly instead of using an algorithm registry.
| Profile | Command | Algorithms | Expected runtime | Output |
|---|---|---|---|---|
| `smoke` | `cargo run --all-features --example benchmark_algorithms -- smoke` | AP, adaptive AP, Fpie, Mpie, Epry, ADMM, global Gauss–Newton, GradientDescent | Under 1 minute on a typical laptop CPU | `target/benchmark-results/smoke` |
| `cpu` | `cargo run --all-features --example benchmark_algorithms -- cpu` | AP, adaptive AP, Fpie, Mpie, Epry, ADMM, global Gauss–Newton, GradientDescent | 1-5 minutes on a typical laptop CPU | `target/benchmark-results/cpu` |
Run the default offline smoke profile with:
```sh
cargo run --all-features --example benchmark_algorithms
Converted-dataset benchmarks use the same API. When ground truth is unavailable,
pass ground_truth: None; normalized frame residuals remain available without
ground truth. The load_local_dataset example demonstrates this path.
Normalized benchmark bundles¶
A BenchmarkRecord has two identities: case_id identifies an immutable case
configuration, while run_id is unique for each execution. Repetitions of one
case share case_id and have distinct run_id values. Frame metrics are
normalized as one row per (run_id, frame_index) rather than stored as
parallel lists.
With the parquet feature, write_benchmark_bundle writes four stable tables:
runs, frames, artifacts, and long-form metadata. Every successful run
also has one nested ResultBundle under results/<run_id>; arrays are not
duplicated in benchmark-level storage. Shared run columns have the same names
and dtypes as result summary tables, so joins are direct.
Python can build a comparison from existing results:
suite = fpm.BenchmarkSuite("algorithm-comparison")
run_id = suite.add_result(
result,
case_id="synthetic-v1-seed-17",
dataset_name="synthetic",
algorithm_configuration="iterations=20,object_step=1.0",
)
benchmark = suite.write_bundle("output/comparison", label="AP repeats")
reopened = fpm.read_benchmark_bundle(benchmark.path)
result_bundle = reopened.results[run_id]
Install fpm-rs[polars] to query the ordinary Parquet paths:
import polars as pl
runs = pl.scan_parquet(benchmark.tables.runs.path)
frames = pl.scan_parquet(benchmark.tables.frames.path)
comparison = (
runs.filter(pl.col("case_id") == "synthetic-v1-seed-17")
.select("run_id", "algorithm", "elapsed_seconds", "final_objective")
.collect()
)
per_frame = (
frames.join(runs.select("run_id", "algorithm"), on="run_id")
.group_by("algorithm")
.agg(pl.col("normalized_l2").mean())
.collect()
)
Result summaries can be joined through the artifacts table or read from the nested bundles:
runs_eager = pl.read_parquet(benchmark.tables.runs.path)
summaries = pl.concat(
[
pl.read_parquet(
benchmark.results[run_id].tables.summary.path
)
for run_id in runs_eager["run_id"]
]
)
run_summaries = runs_eager.join(
summaries,
on="run_id",
how="left",
suffix="_result",
)
one_case = (
runs_eager.filter(
(pl.col("case_id") == "synthetic-v1-seed-17")
& pl.col("success")
)
.select(
"case_id",
"algorithm",
"elapsed_seconds",
"completed_iterations",
"final_objective",
)
.sort(["case_id", "final_objective"])
)
repeats = (
runs_eager.filter(pl.col("success"))
.group_by(["case_id", "algorithm"])
.agg(
pl.len().alias("run_count"),
pl.col("elapsed_seconds").mean().alias("mean_elapsed_seconds"),
pl.col("elapsed_seconds").std().alias("std_elapsed_seconds"),
pl.col("final_objective").mean().alias("mean_final_objective"),
)
)
For frame-level analysis, join frames to runs on run_id, then retain
case_id, algorithm, and dataset_name from the run table. The Python
extension does not import Polars and does not define a second DataFrame wrapper.
Forward-model and gradient scaling benchmarks¶
ForwardModel::forward_intensity is the allocation-owning convenience API.
Repeated simulation, metrics, or parameter searches can reuse mutable scratch
from ForwardModel::workspace through forward_intensity_into or
forward_source_field_into. A workspace must not be shared concurrently;
create one per worker. FFT plans and backend objects remain shareable.
forward_intensity_stack_into evaluates complete stacks with scoped CPU workers
and preserves [frame][row][column] order. Simulator parallelizes optical
prediction, then applies CameraModel serially so seeded detector noise is
independent of worker count.
Run the dependency-free forward benchmark with:
cargo bench --bench forward_model
Set FPM_BENCH_ITERATIONS to change its duration. It compares allocation with
workspace reuse but has no machine-specific pass/fail threshold.
The gradient scaling and memory benchmark is:
cargo bench --bench gradient_parallel
It covers ordinary and multiplexed object, pupil, and illumination updates.
For each worker count it reports milliseconds per batch step, speedup relative
to one worker, and peak incremental heap for the step. Configure it with
FPM_GRADIENT_BENCH_LOW_SIZE, FPM_GRADIENT_BENCH_HIGH_SIZE,
FPM_GRADIENT_BENCH_ITERATIONS, and FPM_GRADIENT_BENCH_MAX_WORKERS. The heap
measure includes worker-local state and reduction buffers, but excludes existing
reconstruction state, native thread stacks, and system FFT/allocator memory.
Use one worker for single-frame or tiny batches. For larger CPU batches, start with two to four workers and benchmark the actual image and multiplexing sizes: worker-local high-resolution accumulators make memory grow roughly linearly and thread overhead can dominate. A 20-sample 32×32/64×64 development run measured 1.89× ordinary-update speedup with four workers (1.49 MiB incremental heap, versus 0.06 MiB serial); eight workers reached 1.91× with 2.26 MiB. These are illustrative measurements, not portable guarantees.
Array/trace refactor validation snapshot¶
The ndarray, typed-trace, and bundle refactor was compared with its clean
pre-refactor HEAD on the same machine and toolchain. Debug forward-model
measurements over 500 evaluations changed from 2265.149 to 2244.738 µs/frame
for the allocating path, 2140.048 to 2135.985 µs/frame with a reused workspace,
and 600.479 to 607.455 µs/frame for the eight-worker stack. The largest absolute
change was 1.2%.
Ten-sample 32×32/64×64 single-worker gradient steps changed as follows:
| Case | Before (ms/step) | After (ms/step) | Change |
|---|---|---|---|
| Ordinary object | 8.847 | 8.882 | +0.4% |
| Multiplexed object | 11.491 | 11.615 | +1.1% |
| Multiplexed object and pupil | 12.386 | 12.922 | +4.3% |
| Multiplexed object and illumination | 29.072 | 29.041 | -0.1% |
Peak incremental heap values were unchanged in every worker configuration. The validation threshold was a repeatable 10% regression in representative serial paths or an unexplained increase in incremental heap; neither occurred. Multi-worker timings remain scheduler-sensitive and are retained as descriptive output rather than a release gate.