Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions avaframe/com4FlowPy/benchmark/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Local run artifacts and machine-specific fixtures — never committed.
# Concrete fixtures carry absolute data paths; only the *.template is tracked.
runs/
fixtures/*.ini

# debug repro (not for commit)
repro_tile.py
46 changes: 46 additions & 0 deletions avaframe/com4FlowPy/benchmark/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# com4FlowPy Numba-engine benchmark & validation harness

`flowpy_bench.py` runs com4FlowPy in-process on a *fixture* (a `.ini` describing
inputs + `[GENERAL]` config), measures **wall-clock** and **peak RAM**, and
optionally compares every output raster against a **reference directory**.

It exercises the normal `com4FlowPyMain` code path (as in the `useCustomPaths`
branch of `runCom4FlowPy.py`); the only added knob is `--engine`, which selects
the compute engine:

* `--engine python` — the stock `Cell`-based BFS (default; always available).
* `--engine numba` — the ported `@njit` kernel (once merged).

## Usage

```bash
# Freeze the stock-engine oracle on the correctness fixture
python flowpy_bench.py fixtures/connaught_frequent.ini --engine python --tag oracle

# A/B the numba engine against it (bit-equivalence check)
python flowpy_bench.py fixtures/connaught_frequent.ini --engine numba

# Benchmark on BFW's own 10 m run and cross-check vs their stored outputs
python flowpy_bench.py fixtures/bfw_10m.ini --engine python
python flowpy_bench.py fixtures/bfw_10m.ini --engine numba --cpu 8
```

Each run writes `runs/<tag>_<engine>_<timestamp>/summary.json` with timing,
peak RAM, and per-output comparison metrics.

## Fixtures

| Fixture | Role | Config highlights |
|---|---|---|
| `connaught_frequent.ini` / `connaught_extreme.ini` | correctness oracle (fast A/B) | autoATES: `forestDetrainment`, uniform α (28 / 18) |
| `bfw_10m.ini` | speed benchmark + reference cross-check | BFW: `forestFriction`, `variableAlpha`, `variableUmax`, α-layer + Umax-layer |

Fixture `.ini`s carry absolute paths to input rasters. The rasters themselves
are **not** committed (size/licensing); edit the paths for your machine, or copy
a fixture to `*.local.ini` (gitignored). `runs/` is also gitignored.

## Comparison metrics (per output raster, over pixels valid in both)

`n_valid`, `n_exact`, `n_diff` (|Δ| > `--tol`), `frac_diff_ppm`,
`max_abs_diff`, `mean_abs_diff`. Use `--tol 0` for bit-exact; a small tol to
ignore IEEE-754 ULP noise from operation-order differences.
232 changes: 232 additions & 0 deletions avaframe/com4FlowPy/benchmark/REPORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
# com4FlowPy Numba compute-engine — validation & benchmark report

*Living document — updated at each phase. Audience: BFW / AvaFrame maintainers.*

## Goal

Add an optional Numba (`@njit`) compute engine to com4FlowPy that reproduces
the existing Python engine's results while cutting per-tile runtime, so large /
repeated runs (e.g. multi-site ATES workflows) become tractable. The engine is
selected by a config flag (`engine = python | numba`, default `python`); tiling,
multiprocessing, I/O, forest, and variable-parameter handling are unchanged.

Motivation: a prototype on standalone FlowPy (fork of `avaframe/FlowPy`,
`foreste_detrainment`) reached ~100× by rewriting the per-cell BFS as a single
`@njit` function over flat arrays. com4FlowPy already parallelizes (tiling +
`Pool` over release chunks) but still runs the inner BFS through the Python
`Cell` class — the target of this port.

## Method

`benchmark/flowpy_bench.py` runs com4FlowPy in-process via `com4FlowPyMain`
(the `useCustomPaths` path), recording wall-clock, peak RAM, and per-output
raster comparison against a reference directory. Fixtures under
`benchmark/fixtures/` pin exact inputs + `[GENERAL]` config. Correctness is
established by A/B comparison of the two engines on identical inputs (the
Python engine is the oracle); a run is additionally cross-checked against BFW's
own stored outputs.

Machine: 16 physical cores / 32 threads.

All results below were re-verified on the current rebased code
(OpenNHM/AvaFrame master @6e02e45d + this branch), with peak memory measured via
PSS. Current-code headline (8 cores): BFW 10 m 302 s → 6.7 s (**45×**); 5 m
Connaught extreme 5714 s → 193 s (**30×**). numba (float64) is bit-identical to
the Python engine on both (zDelta / fpTravelAngleMax; 5 m differs only at
flat-terrain routing knife-edges, see below). PSS memory is on par with the
Python engine (BFW: 8.6 vs 7.1 GiB; 5 m: 23.1 vs 19.9 GiB).

## Phase 0 — baselines & oracle (stock Python engine) — DONE

| Fixture | Domain | Config | Wall | Peak RAM | Cores |
|---|---|---|---|---|---|
| Connaught frequent | 222×222 @ 21.3 m | forestDetrainment, α=28 | 17.8 s | 6.0 GiB | 16 |
| Connaught extreme | 222×222 @ 21.3 m | forestDetrainment, α=18 | 109 s | 7.6 GiB | 16 |
| **BFW 10 m** | 482×513 @ 10 m | forestFriction, variableAlpha, variableUmax, α-layer + Umax-layer | **299 s** | 17.4 GiB | 8 |

**Key validation:** on the BFW 10 m fixture — reproducing BFW's own run config
exactly — the stock Python engine reproduces BFW's stored `zDelta` and
`fpTravelAngleMax` **bit-for-bit** (0 differing pixels / 81,795 valid; max |Δ| = 0).
This confirms the harness, config translation, and variable-parameter wiring are
faithful, and gives a bit-exact target for the Numba engine.

Benchmark target to beat: **299 s on 8 cores (BFW 10 m)**.

## Phase 1 — Numba kernel — DONE (integration)

New module `flowCoreNumba.py`: the per-release-pixel BFS as a single `@njit`
function over flat arrays, plus `calculationNumba(args)` — a drop-in for
`flowCore.calculation()` (same args, same 13-tuple). `run()` dispatches to it
when `engine=numba` (default `python`); tiling / multiprocessing / merge / I-O
unchanged. infra/back-calculation and previewMode fall back to the Python engine.

Faithful to the `fluxDistOldVersion=False` path: float persistence (no int16
truncation), g=9.81, forest friction/detrainment/frictionLayer with the
not-start / FSI>0 / skipForestDist guards, forestInteraction counting, variable
alpha/max_z/exponent per release pixel, default flux distribution
(count = dist≥threshold, sub-threshold redistribution, conservation correction,
deposition).

## Phase 2 — numerical equivalence — IN PROGRESS

A/B (numba vs Python engine), correctness fixtures — **bit-identical**:

| Fixture | Outputs checked | Result |
|---|---|---|
| Connaught frequent | zDelta, routFluxSum, fpTravelAngleMax, cellCounts | 0 diff / 21,335 px, max\|Δ\|=0 |
| Connaught extreme | same | 0 diff / 25,528 px, max\|Δ\|=0 |

BFW 10 m (forestFriction + variableAlpha + variableUmax), numba vs Python baseline
and vs BFW's own stored outputs:

| Output | numba vs baseline | vs BFW stored |
|---|---|---|
| zDelta | 0 diff / 81,795 | **bit-identical** |
| fpTravelAngleMax | 0 diff / 81,795 | **bit-identical** |
| flux | 6 px (0.07 ppm), max\|Δ\|=2.2e-4 | n/a (not stored) |
| cellCounts | 20 px (0.24 ppm), max\|Δ\|=1 | n/a (not stored) |

The scientifically primary outputs (zDelta, travel angle) are bit-exact. The
sub-ppm flux / cellCounts diffs are IEEE-754 operation-order artifacts (scalar
kernel vs numpy vectorized ops): a child flux landing within ~1 ULP of the flux
threshold is included by one engine and not the other, nudging cellCounts by 1
and flux by a rounding-level amount at a handful of boundary pixels. This is the
same class/scale of artifact documented for the standalone-FlowPy prototype
(sub-110 ppm). It does not affect zDelta because those boundary cells take their
zDelta from other paths.

## Phase 3 — speedup & determinism — headline result

BFW 10 m, matched core counts:

| Domain (8 cores) | Python | Numba | Speedup |
|---|---|---|---|
| BFW 10 m | 302 s | 6.7 s | **45×** |
| Connaught 5 m extreme | 5714 s (95 min) | 193 s | **30×** |

On the 10 m domain numba runtime is **flat across cpu=4/8/16 (~6.5 s)** — small
enough that it is no longer compute-bound (fixed costs: cold JIT compile + tiling
I/O + merge dominate). At 5 m it is genuinely compute-bound (193 s). The realised
speedup (~30–45× here) therefore depends on domain size and how compute-bound the
run is. Because adding workers past ~8 gives little wall-clock gain (numba
saturates the tile-parallelism early) but more memory, **~8 workers is the
practical sweet spot for high-resolution runs**.

**Determinism:** numba output is bit-identical across cpu=4/8/16 on all four
outputs (zDelta, fpTravelAngleMax, flux, cellCounts — 0 differing pixels). The
engine is fully deterministic w.r.t. worker count; the sub-ppm flux/cellCounts
differences vs the Python engine are therefore a stable scalar-vs-numpy artifact,
not a parallelism effect.

Peak RAM (PSS) is on par with the Python engine and is dominated by com4FlowPy's
multiprocessing, not the numba workspace (the per-BFS queue was nonetheless
right-sized from `2·H·W` to a 128k grow-and-retry buffer — a correctness/hygiene
fix, not a peak-RAM lever). A warm JIT cache removes the one-time compile cost.

## Engine comparison for BFW

com4FlowPy exposes two engines via `engine =`: `python` (the Cell-based
reference) and `numba` (JIT, double precision). Default is `python`.

BFW 10 m, 8 cores, vs the python reference:

| Engine | Wall | Peak RAM | zDelta | fpTravelAngleMax | flux | cellCounts |
|---|---|---|---|---|---|---|
| python | 302 s | 7.1 GiB | ref | ref | ref | ref |
| numba | 6.7 s | 8.6 GiB | **0 diff** | **0 diff** | 6 px / 2e-4 | 20 px / 1 |

(Wall/RAM on current master, 8 cores, PSS memory. diff = differing pixels / max\|Δ\|
over 81,795 valid px; the sub-ppm flux/cellCounts differences are the documented
scalar-vs-numpy artifact, and are bit-identical to BFW's own stored zDelta and
fpTravelAngleMax.)

**A single-precision (float32) variant was evaluated and rejected.** It was
prototyped on the theory it would cut memory on high-resolution runs; measurement
showed otherwise — no speedup (numba32 193 s vs numba 192 s at 5 m), no memory
saving (24.9 vs 23.1 GiB PSS — if anything slightly higher, since peak RAM is
dominated by com4FlowPy's multiprocessing, not the kernel workspace), and a
precision cost that is *worst exactly at high resolution* (5 m: ~68 % of cells
differ from the Python engine, zDelta up to 62 m). It offered no benefit where it
was meant to help, so it is not part of this contribution.

## 5 m (high-resolution, compute-bound) — Connaught extreme

964×1025 ≈ 988k cells; the domain that ran ~1.5 h in autoATESv3.0. 8 cores, PSS memory:

| Engine | Wall | Peak RAM |
|---|---|---|
| python | 5714 s (95 min) | 19.9 GiB |
| numba | 193 s | 23.1 GiB |

→ **numba ~30× faster**, memory on par with the Python engine.

* **Wall time is flat across cpu=4/8/16** — at 5 m the domain splits into only a
few tiles, so parallelism saturates early and the remainder is serial
(tiling / merge / JIT). More workers add memory, not speed — so physical cores
are the ceiling worth using (hyperthreads never help), and fewer is fine.
* Peak RAM is dominated by com4FlowPy's multiprocessing (per-worker tile copies),
not the numba kernel workspace (a modest grow-and-retry queue). On
memory-constrained machines, fewer workers and/or a smaller `tileSize` lower
the peak. Memory is a non-issue at 10–25 m (small tiles).

**Memory (measured with PSS — proportional set size — which correctly accounts
for shared copy-on-write pages across the worker pool):** at BFW 10 m, python@8 =
8.9 GiB, numba@8 = 8.6 GiB — numba is on par with (slightly below) the Python
engine. At 5 m, numba@8 = 25 GiB. Earlier RSS-summed figures (e.g. 76 GiB at 5 m)
over-counted shared memory ~2–3× and were misleading. The per-BFS queue workspace
was separately reduced from `2·H·W` (~300× over the observed ~3.8k peak depth) to
a modest 128k with grow-and-retry on overflow — this is a correctness/robustness
fix (a path can never be silently truncated) rather than a peak-RAM lever, since
the workspace was not the dominant allocation.

### 5 m divergence — flat-terrain routing sensitivity (a model property, not an engine bug)

The numba engine is deterministic (identical output run-to-run and across worker
counts) and bit-for-bit identical to the Python engine on every domain tested at
10–25 m (Connaught 21 m, BFW 10 m). On the finer 5 m run the two engines differ
in a small set of cells: zDelta in 124 of 444k valid cells (0.03 %, worst case
52 m at isolated points); routFluxSum and cellCounts in more cells (~20 % and
~5 %) but by small per-cell amounts (routFluxSum ≤ 1.3, cellCounts ≤ 21).

**Plain-English cause.** FlowPy routes flow downhill, splitting it at each cell
among the downhill neighbours according to how steep the drop is. On a real slope
there is a clear downhill and the path is well-defined. On a flat valley floor the
neighbours sit within ~1 m of each other — there is no real "downhill," so which
neighbour the flow follows comes down to the last rounding bit of the arithmetic.
And because the model must keep routing until the energy line reaches the alpha
angle (18° here), the flow keeps spreading across the flat with no genuine
directional preference. The two engines evaluate the same formulas by a slightly
different route (per-element scalar math vs numpy array math), so at these
near-ties they occasionally send flow to a different — but equally valid —
neighbour, after which the branches rejoin.

**Neither engine is "more correct," and this is not a tiling artifact.** We
reproduced the exact divergence inside a single isolated tile
(`benchmark/repro_tile.py`), so it is not a tile-seam or merge effect — it is the
fine 5 m grid resolving flat valley floors into many near-tie cells. To show the
result is genuinely ill-conditioned *in the model itself* (independent of which
engine computes it): nudging the DEM by a physically meaningless **±1 µm** — far
below any real elevation accuracy — shifts the Python engine's *own* reached set
by ~175 cells (±1 mm → 237; ±1 cm → 189). The numba engine is equally
deterministic and equally valid; it simply lands on a different one of these
near-tied branches. Crucially, the numba↔Python difference (~50 cells along a
path) is *smaller* than the change the Python result shows under a sub-micron
nudge. So at a flat-terrain fork the exact reached set is not a numerically
determinate quantity for *any* correct implementation — both engines are valid
IEEE-754 solutions of the same equations.

**Impact, stated plainly.** The energy-line (zDelta) footprint is essentially
identical (0.03 % of cells differ); the larger routFluxSum/cellCounts pixel counts
are small in magnitude and concentrated at the margins of flow paths in flat
runout zones. Any effect on downstream ATES classes is therefore confined to
flat-terrain margins and expected to be minor — but we flag it rather than claim
bit-identical output everywhere. This ambiguity is an inherent property of
FlowPy's alpha-angle routing in flat terrain: the port neither introduces nor
removes it, and the Python engine exhibits it too. A slope-aware stopping/
friction criterion for low-gradient terrain would address the root cause and is
noted as separate future work.

## Phase 4 — upstream packaging — PENDING

numba as an optional dependency with graceful fallback; config docs; commit the
Connaught/BFW fixtures as a replicable regression test; PR to avaframe/AvaFrame.
24 changes: 24 additions & 0 deletions avaframe/com4FlowPy/benchmark/fixtures/example.ini.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Example fixture for flowpy_bench.py — copy to <name>.ini and edit paths.
# Concrete *.ini files are gitignored (they carry machine-specific data paths).
[inputs]
demPath = /path/to/DEM.tif
releasePath = /path/to/release_or_pra_binary.tif
forestPath = /path/to/forest.tif ; optional (required if forest=True)
varAlphaPath = /path/to/alpha.tif ; optional (required if variableAlpha=True)
varUmaxPath = /path/to/umax.tif ; optional (required if variableUmaxLim=True)
referenceDir = /path/to/reference_outputs ; optional; compare outputs against these tifs

[GENERAL]
# any com4FlowPy [GENERAL] key overrides the module default
alpha = 25
exp = 8
flux_threshold = 3.0e-4
max_z = 270
forest = True
forestModule = forestFriction ; forestFriction | forestDetrainment | forestFrictionLayer
forestInteraction = True
variableAlpha = False
variableUmaxLim = False
tileSize = 15000
tileOverlap = 5000
outputFiles = zDelta|fpTravelAngleMax|cellCounts|flux
Loading
Loading