14 KiB
FluidSim engine and solver performance profile
Run date: 2026-10-24
Raw measurements: profile-runs.csv
Harness: tools/scenario_profile.cpp
Analyzer: tools/analyze_scenario_profile.py
These runs predate the geometry-defined header layouts. Current scenarios have revised header lengths; rerun the harness for current performance figures.
Summary
The solver is the main cost in every tested configuration. At 20 cells/m it takes about 76–92% of measured process() time; controls take 3–9%, audio rendering 3–12%, and component updates 2–3% of solver time. Across the run matrix, the median boundary share of solver time falls from 56.5% at 20 cells/m to 30.5% at 80 cells/m; pipe updates rise from 32.8% to 55.0%. The bottleneck shifts from boundary resolution toward pipe-cell advancement as density grows.
Mesh density has a nonlinear runtime effect because smaller cells also require more CFL substeps. Raising density from 20 to 80 cells/m increased median frame cost by 3.0×–6.4× across these engines. The V8 was most expensive: at 80 cells/m it used about 1,054 cells and took about 134 µs per frame in the profiled run.
At a fixed density of 20 cells/m, Rusanov was the fastest solver. Relative to Rusanov at that same density, MUSCL cost 16–50% more depending on engine, while HLLE and HLLC were generally 3–25% more. At your solver-specific listening targets, MUSCL at 25 cells/m is the fastest overall because it reaches your preferred sound at a coarser mesh.
Across all tested scenarios and densities, the median sampled frame cost was 11.6 µs for Rusanov, 13.0 µs for HLLE, 13.5 µs for HLLC, and 15.9 µs for MUSCL. Those pooled values summarize this exact sweep and are affected by its chosen scenario and density mix.
No pipe positivity repairs or HLLC fallbacks occurred in the measured runs.
Method
- 6 TOML scenarios, four solvers, 4 mesh densities, and two repetitions: 192 measured runs.
- Scenarios: five, saab-chambered, single-450, twin, v8-h, yz-fat.
yz-fatis the two-stroke case; the others are four-stroke. - Densities: 10, 20, 40, 80 cells/m, applied to the scenario's configured geometry.
- Solvers: Rusanov, HLLE, HLLC, and MUSCL. Solver choice was applied to every pipe in each scenario.
- Each run warmed up for 5,000 frames, then measured 22,050 frames (0.5 simulated seconds) at 44.1 kHz. Engine speed was reset to the scenario's configured initial RPM (or 4,500 RPM when unset) before every frame; the two-stroke was held at 7,000 RPM. Throttle was 55%, load 10%, clutch 0%.
- Internal profiling sampled every other measured
process()call. Each run therefore contains 11,025 profiled frames. End-to-end wall time includes all measured frames; stage timings come from sampled frames. - Build: Release, MinGW GCC 16.2.0, Windows, Intel Core i5-12600K. The machine was otherwise used interactively, so small differences should not be over-interpreted.
Cost at 20 cells/m
Each cell shows median end-to-end wall microseconds per frame and wall/simulated-time ratio across the two repetitions. A ratio below 1.0 means this isolated scenario ran faster than real time. Boundary, pipe, and component columns are their share of inclusive solver time; controls and audio are their share of total sampled process time.
| Scenario | Solver | Cells | µs/frame | Wall / sim | Controls | Audio | Boundary / solver | Pipe / solver | Components / solver |
|---|---|---|---|---|---|---|---|---|---|
| five | rusanov | 138 | 9.5 | 0.42 | 8% | 5% | 67% | 22% | 2% |
| five | hlle | 138 | 10.5 | 0.46 | 7% | 5% | 59% | 30% | 2% |
| five | hllc | 138 | 11.0 | 0.49 | 7% | 5% | 56% | 33% | 2% |
| five | muscl | 138 | 13.6 | 0.60 | 6% | 4% | 60% | 32% | 2% |
| saab-chambered | rusanov | 123 | 12.7 | 0.56 | 5% | 4% | 59% | 29% | 2% |
| saab-chambered | hlle | 123 | 14.9 | 0.66 | 4% | 3% | 50% | 37% | 2% |
| saab-chambered | hllc | 123 | 15.9 | 0.70 | 4% | 3% | 47% | 40% | 2% |
| saab-chambered | muscl | 123 | 19.2 | 0.84 | 3% | 3% | 52% | 38% | 2% |
| single-450 | rusanov | 52 | 3.6 | 0.16 | 8% | 13% | 54% | 33% | 3% |
| single-450 | hlle | 52 | 4.1 | 0.18 | 7% | 12% | 46% | 39% | 2% |
| single-450 | hllc | 52 | 4.1 | 0.18 | 7% | 12% | 44% | 42% | 2% |
| single-450 | muscl | 52 | 5.3 | 0.23 | 7% | 10% | 50% | 39% | 2% |
| twin | rusanov | 24 | 3.6 | 0.16 | 9% | 13% | 75% | 15% | 4% |
| twin | hlle | 24 | 3.7 | 0.16 | 9% | 12% | 71% | 18% | 3% |
| twin | hllc | 24 | 3.9 | 0.17 | 9% | 12% | 69% | 21% | 3% |
| twin | muscl | 24 | 4.2 | 0.19 | 8% | 12% | 69% | 21% | 3% |
| v8-h | rusanov | 268 | 17.7 | 0.78 | 6% | 4% | 63% | 28% | 2% |
| v8-h | hlle | 268 | 20.1 | 0.88 | 6% | 3% | 56% | 34% | 2% |
| v8-h | hllc | 268 | 21.4 | 0.94 | 6% | 3% | 53% | 37% | 2% |
| v8-h | muscl | 268 | 26.2 | 1.15 | 5% | 2% | 58% | 34% | 2% |
| yz-fat | rusanov | 30 | 9.0 | 0.40 | 3% | 4% | 63% | 24% | 3% |
| yz-fat | hlle | 30 | 10.2 | 0.45 | 3% | 4% | 57% | 30% | 3% |
| yz-fat | hllc | 30 | 10.4 | 0.46 | 3% | 4% | 54% | 33% | 3% |
| yz-fat | muscl | 30 | 11.4 | 0.50 | 3% | 3% | 54% | 34% | 2% |
The three solver sub-stages are nested inside the solver measurement, so their percentages should not be added to the solver percentage. Their shares also do not sum to 100%: solver setup, CFL preparation, and loop overhead make up the remainder.
Mesh-density scaling
Median profiled total cost per frame, aggregated over all four solvers and both repetitions. Cell counts are the actual total across all pipes in the scenario.
| Scenario | Density: cells / µs | 80 vs 20 cost |
|---|---|---|
| five | 10: 74 / 9.2; 20: 138 / 10.9; 40: 273 / 22.1; 80: 544 / 53.7 | 4.92× |
| saab-chambered | 10: 68 / 12.5; 20: 123 / 15.6; 40: 243 / 22.8; 80: 476 / 51.2 | 3.29× |
| single-450 | 10: 27 / 3.3; 20: 52 / 4.3; 40: 104 / 6.4; 80: 208 / 17.7 | 4.14× |
| twin | 10: 13 / 3.8; 20: 24 / 4.0; 40: 45 / 5.3; 80: 89 / 12.0 | 3.01× |
| v8-h | 10: 144 / 16.7; 20: 268 / 20.9; 40: 528 / 54.0; 80: 1054 / 133.5 | 6.37× |
| yz-fat | 10: 17 / 8.9; 20: 30 / 10.8; 40: 57 / 14.0; 80: 108 / 20.1 | 1.87× |
Solver cost at 20 cells/m
Change in median total_ms_per_frame versus Rusanov for the same scenario and mesh density.
| Scenario | Rusanov | HLLE | HLLC | MUSCL |
|---|---|---|---|---|
| five | +0% | +11% | +16% | +44% |
| saab-chambered | +0% | +17% | +24% | +50% |
| single-450 | +0% | +14% | +15% | +47% |
| twin | +0% | +3% | +8% | +16% |
| v8-h | +0% | +13% | +21% | +45% |
| yz-fat | +0% | +14% | +15% | +26% |
All solvers at 50 cells/m
This fixed-density sweep isolates solver cost at your preferred mesh scale. Values are medians across six scenarios and two repetitions; boundary and pipe shares are fractions of inclusive solver time.
| Solver | Median µs/frame | Boundary / solver | Pipe / solver |
|---|---|---|---|
| rusanov | 14.8 | 47.2% | 38.2% |
| hlle | 20.6 | 36.2% | 50.1% |
| hllc | 22.6 | 29.5% | 56.8% |
| muscl | 29.1 | 42.3% | 48.0% |
User-rated quality targets
You reported that these densities sound great: Rusanov 70 cells/m, HLLE 60, HLLC 45, and MUSCL 25. The following timings compare those listening targets; the benchmark measures compute and output statistics, not subjective sound quality.
| Solver | Cells/m | Median µs/frame (six scenarios) | Median wall/sim | V8 cells | V8 µs/frame | V8 wall/sim |
|---|---|---|---|---|---|---|
| rusanov | 70 | 17.4 | 0.77 | 930 | 68.0 | 3.00 |
| hlle | 60 | 22.2 | 0.98 | 794 | 76.3 | 3.36 |
| hllc | 45 | 19.6 | 0.87 | 601 | 63.3 | 2.79 |
| muscl | 25 | 14.0 | 0.62 | 333 | 30.1 | 1.33 |
MUSCL at its 25 cells/m target had the lowest median compute cost across these cases. On the V8, it was about 30 µs/frame versus about 63 µs for HLLC at 45 cells/m, 68 µs for Rusanov at 70, and 76 µs for HLLE at 60. A wall/sim ratio above 1 means the isolated scenario takes longer than real time on this CPU.
Compiler SIMD comparison
Compared the Release build (GCC auto-vectorization enabled) with FLUIDSIM_ENABLE_SIMD=OFF, using the same quality-target matrix and run lengths.
| Solver | Median SIMD speedup | Matched audio output |
|---|---|---|
| rusanov | -1.1% | audio checksums identical (max delta 0) |
| hlle | +1.4% | audio checksums identical (max delta 0) |
| hllc | +3.2% | audio checksums identical (max delta 0) |
| muscl | +3.1% | audio checksums identical (max delta 0) |
Positive speedup means vectorization was faster. The changes are small and mixed (roughly 0–3% median, within the noise of these short runs), while measured audio RMS, peaks, and checksums matched exactly. The current AoS cell/flux layout is not yielding a large automatic-SIMD gain; an explicit SoA or batched kernel would need a focused prototype and a broader numerical comparison.
Paired raw runs by solver: rusanov SIMD / scalar, hlle SIMD / scalar, hllc SIMD / scalar, muscl SIMD / scalar.
Lazy solver scratch buffers
The pipe now allocates MUSCL reconstruction buffers only for MUSCL, and Roe caches only for HLLE/HLLC. Rusanov no longer allocates either unused cache. On the V8 at each solver's quality target:
| Solver | Cells | Scratch before | Scratch after | Saved |
|---|---|---|---|---|
| rusanov | 930 | 112,120 B | 60,040 B | 52,080 B (46%) |
| hlle | 794 | 95,800 B | 64,040 B | 31,760 B (33%) |
| hllc | 601 | 72,640 B | 48,600 B | 24,040 B (33%) |
| muscl | 333 | 40,480 B | 35,152 B | 5,328 B (13%) |
The buffer change reduced solver scratch allocation without changing audio checksums (maximum delta 0). It did not produce a throughput gain in this short paired comparison (median frame time changed +0.5% after the change), so keep it as a memory-footprint reduction rather than counting it as a CPU optimization. Scratch stays allocated after a solver is first selected, allowing fast mode switches.
Paired raw runs: rusanov before / after, hlle before / after, hllc before / after, muscl before / after.
A separate experiment fused primitive conversion, state validation, and minimum-timestep reduction into one pipe pass. Paired speed changes were rusanov +0.1%, hlle -0.1%, hllc -1.1%, muscl +0.7%; audio checksums stayed identical (maximum delta 0). There was no consistent gain, so that loop change was reverted.
Fused-pass raw runs: rusanov, hlle, hllc, muscl.
CPU versus memory and threading
A Windows process sample during the 50 cells/m sweep showed about 103% process CPU (approximately one full logical CPU), 7.1 MiB working set, and 3.8 MiB private bytes. The i5-12600K has 10 cores / 16 logical processors. At 50 cells/m, the V8 has 662 pipe cells; its pipe data plus solver scratch is about 96 KiB before the scratch-buffer reduction and about 60 KiB for Rusanov afterward. This is an inference of CPU-bound execution rather than RAM-capacity pressure: the working data is small and fits in the CPU's private L2 cache, but hardware cache-miss/bandwidth counters were not collected, so L1/L2 traffic is not ruled out.
The existing per-step thread benchmark measured the V8's 26 pipes at 50 cells/m: 1 worker took 0.049 s, 2 took 0.188 s, and 4 took 0.267 s for 4,000 steps. Only its artificial 64-pipe × 256-cell case benefited (0.908 s with 1 worker, 0.546 s with 4). Real engine pipes are too small to amortize per-step synchronization; don’t parallelize each pipe step without a persistent task system and a much larger workload.
An experimental GCC -march=native build crashed in the five-cylinder 50 cells/m profile with Windows access-violation exit 0xC0000005; it was rejected. The default portable Release build remains the tested build.
The focused post-change test run passed 8/8: mesh resolution, junctions, scenario catalog, Rusanov/HLLE/HLLC/MUSCL fluid validation, and outdoor audio.
Interpretation and limits
Optimization priorities
- For the 50 cells/m listening range, start with pipe advancement for HLLE/HLLC: pipe updates use about 51–57% of solver time. At your target densities, they remain the largest stage for the V8 with Rusanov 70, HLLE 60, and HLLC 45. Reducing unnecessary per-cell passes or improving the cell/flux layout is the most promising CPU work.
- Boundary resolution is still a large second target (about 30–47% of solver time at 50 cells/m), and is the largest stage for the twin and for MUSCL in several cases. Optimize the characteristic-junction root solve only with the acoustic junction benchmarks guarding its behavior.
- Leave component updates and audio rendering for later; they are smaller absolute costs in the larger engines. Keep optimizing the pipe and boundary stages first.
- Boundary resolution and pipe advancement trade places as density and solver change. At 50 cells/m, boundaries are largest for Rusanov, while pipe advancement leads for HLLE and HLLC; the V8's junction network still makes boundaries substantial.
- Increasing density raises both the number of cells and the number of solver substeps needed to meet the CFL limit. This explains why runtime growth can exceed the cell-count growth alone.
- At low densities, controls and audio are a larger fraction of total work because there are few cells to update. Their absolute time stays comparatively small as the mesh grows.
- Results are for this Release build and machine, with profiling enabled on alternating frames. Instrumentation adds some timing overhead; the tables are best used for relative comparisons within this run. Very short runs, other compilers, build flags, CPUs, and background load can change absolute timings.
- This benchmark isolates one scenario at a time. It excludes desktop rendering, UI work, output-device scheduling, and audio callback buffering. It does not compare sound quality or engine-state accuracy across mesh densities or solver methods.
- Two repetitions provide a directional profile, not a high-confidence microbenchmark. Repeat on a quiet machine before using small differences to drive optimization decisions.
Reproduce
cmake --build out/build/windows-gcc --target fluidsim_scenario_profile -j 8
out/build/windows-gcc/fluidsim_scenario_profile verification/performance/2026-10-24/profile-runs.csv 5000 22050 2
py -3 tools/analyze_scenario_profile.py verification/performance/2026-10-24/profile-runs.csv verification/performance/2026-10-24/profile-report.md