Skip to content

Optimize compute_zdiff_gradp - #1272

Draft
msimberg wants to merge 46 commits into
C2SM:mainfrom
msimberg:zdiff-vertoffset-gradp
Draft

msimberg wants to merge 46 commits into
C2SM:mainfrom
msimberg:zdiff-vertoffset-gradp

Conversation

@msimberg

@msimberg msimberg commented May 19, 2026

Copy link
Copy Markdown
Contributor

This PR makes the compute_zdiff_gradp metrics kernel fast on GPU by replacing the per-element host access in nested Python loops with a single batched searchsorted over all edge rows. The implementation uses the array namespace (numpy/cupy), so the same code runs on CPU and GPU without backend dispatch.

compute_zdiff_gradp now takes a precomputed z_me edge-level field and returns zdiff_gradp and vertoffset_gradp. It reverses each cell column to ascending order, masks levels above flat_idx, and offsets every row by a float64 constant large enough to keep cross-row queries separate. One flattened searchsorted call finds the bracket index for all (edge, level) queries at once; a clip to [flat_idx, nlev-1] gives the same jk_start as the sequential reference loop.

The change relies on two grid invariants documented in the module docstring: z_ifc is strictly decreasing in the vertical (enforced by the grid builder), and z_me is non-increasing per edge. Under those invariants the searchsorted result is non-decreasing in jk and matches the reference first-match scan. Synthetic probe inputs that violate the invariants are marked xfail with the reason recorded in the test file.

z_me is extracted into its own registered ProgramFieldProvider with do_exchange=True, so compute_wgtfac_e, compute_flat_max_idx, and compute_zdiff_gradp consume a single exchanged z_me instead of computing it inline. compute_wgtfac_e now accepts z_me directly and no longer performs an exchange. The field providers in states/factory.py gained an optional exchange_fields filter so the compute_zdiff_gradp provider can exchange only zdiff_gradp and not its inputs.

Tests in model/common/tests/common/metrics/unit_tests/test_compute_zdiff_gradp.py were updated: the existing datatest now runs against the new signature, a deterministic element-by-element reference mirrors the main-branch semantics, random small and full-scale cases compare the vectorized result against that reference, and probe inputs exercise the documented invariants.

Benchmark at full scale (491520 edges x 60 levels, GH200, minimum over repetitions):

backend time
cupy (this PR) 21.3 ms
numpy (this PR) 4.6 s
main (cupy, per-element host loop) minutes

🤖 Opened by an agent on behalf of @msimberg.

msimberg added 9 commits May 19, 2026 09:48
…mpute_zdiff_gradp

Replaces O(nedges * nlev^2) nested jk/jk1 search loops with
O(nedges * nlev * log(nlev)) np.searchsorted on reversed z_ifc
slices. Eliminates per-iteration boolean array allocation and
array_ns.where calls.

Phase 1: batch searchsorted for all z_me[jk] per edge per side.
Phase 2: single searchsorted for z_aux2, applied via boolean mask.

Largest grid (exclaim_ch_r04b09_dsl): 2.95s -> 0.93s (3.2x)
…zdiff_gradp

Removes the (nedges, 2, nlev) vertidx_gradp allocation and the
final vertoffset_gradp subtraction step. Computes vertoffset_gradp
offsets directly in the per-edge loop. Initializes vertoffset_gradp
as zeros instead of broadcasting jk_field.
- Pre-compute z_ifc_asc = z_ifc[:, ::-1] to avoid per-edge reversal
- Extract e2c_0, e2c_1 column arrays to avoid per-edge indexing
- Unroll for side in range(2) into explicit side 0 and side 1
- Hoist jk_field_slice and z_me_slice out of side processing
The zdiff_gradp computation is now fast enough (0.92s vs 2.95s
baseline on largest grid) that the factory test no longer needs
the cpu_only guard. The vwind_impl_wgt speed concern is also
resolved.
Replaces 62K per-edge searchsorted calls with 4 batched 2D calls
using the row-offset trick. Detects CuPy vs NumPy at runtime.
All data stays on-device (no per-iteration D2H transfers).

Largest grid (exclaim_ch_r04b09_dsl): 2.95s -> 0.74s (4.0x)
Replaces _get_xp / _cp / _np with the array_ns returned by
data_alloc.array_namespace(), which already provides backend-
agnostic searchsorted, clip, where, take_along_axis, etc.
- Remove unnecessary z_me_m masking (invalid jk values are
  masked out by valid_jk after the search)
- Remove .copy() from z_ifc_asc (fancy indexing creates new arrays)
- Compute fill_high from z_ifc directly (fewer elements than z_ifc_e0)
- Remove zdiff_gradp exchange calls from function, delegate to
  factory via do_exchange=True
The z_me halo exchange inside compute_zdiff_gradp was redundant:
z_mc already has valid halo values from the factory dependency
chain (Z_MC provider exchanges its halo). Since z_me is computed
locally from z_mc[e2c], it inherits correct halo values without
an additional exchange.

- Remove exchange parameter from compute_zdiff_gradp signature
- Remove functools.partial wrapping in factory registration
- Remove dims and decomposition imports (no longer needed)
- Update unit test call site
@msimberg

Copy link
Copy Markdown
Contributor Author

cscs-ci run distributed

@msimberg

Copy link
Copy Markdown
Contributor Author

cscs-ci run distributed

msimberg added 16 commits June 1, 2026 16:12
…n, and endpoint fallback

Implements iter-001 design D1-D6:
- New compute_zdiff_gradp_v2 next to existing baseline.
- _batched_searchsorted_v2 keeps reductions on-device and casts to float64.
- Host-side slicing at horizontal_start / horizontal_start_1 replaces device masks.
- Global fill bounds over all searched arrays (D3).
- Phase-2-only endpoint fallback where pos_aux >= nlev+1-fi (D6).
- Gated validation via ICON4PY_VALIDATE_ZDIFF_GRADP env var (D4).

Tests:
- Datatest now runs both baseline and v2 variants with validation enabled.
- Added endpoint-forcing unit test asserting v2 matches main-semantics golden
  and baseline diverges, run with validation ON and OFF.
… round-robin benchmark, NaN test fix, hoist invariant [R30-R34]
- Add cupy first-match-scan fast path variant compute_zdiff_gradp_exact_v3.
- Numpy path delegates to compute_zdiff_gradp_exact_v2 unchanged.
- Add _first_match_scan_reference numpy oracle and boundary-case unit test.
- Wire exact_v3 into existing datatest and nan-validation parametrizations.
- Add numpy-exact3 / cupy-exact3 variants to Tier A and Tier B.
- Add Tier A internal consistency checks: reference scan vs numpy-exact2,
  cupy-exact3 vs numpy-exact2.
- Extend endpoint forcing and weird-input probes with exact_v3 paths.
- Clean up pre-existing ruff findings in the temporary benchmark file.
… and route dispatch to exact fallback [skip ci]
…implementation

- Single compute_zdiff_gradp function using array_namespace (numpy/cupy),
  no backend dispatch, no raw kernels, no validation bundle, no globals.
- Implements main's bracket predicate and jk_start carry exactly.
- Correct for all finite inputs: non-monotone z_ifc, interior ties,
  non-increasing z_me, nlev=1.
- Remove all variant functions, dispatch, exact* helpers, and LAST_*_PATH globals.
- Delete compute_zdiff_gradp_gt4py.py.
- Update tests to compare only the new function against _main_reference and
  the serialized datatest golden.
… variant

Adds a premise-free, performance-oriented alternative to compute_zdiff_gradp
in the same module. Cell 0 keeps the existing batched first-match; cell 1
and phase-2 cells use a Hillis-Steele doubling suffix-min successor table
with a gather-scan carry. Edges are processed in memory-capped chunks.

- _successor_table builds (E, nq, nlev) first-match tables.
- _carry_gather_scan advances the carried lower bound via table lookup.
- _zdiff_gradp_chunk_size caps per-chunk successor-table memory to ~256 MiB.
- compute_zdiff_gradp_fast shares the same signature as compute_zdiff_gradp.
- Tests are parametrized over both functions and pass against _main_reference.
When queries is 1-D (phase 2, constant z_aux2 per edge), the bracket
(upper >= q[:,None]) & (q[:,None] >= lower) is the same for every jk.
Compute it once before the loop instead of nlev times.
…unction

Replace premise-free sequential-carry implementation with a single
compute_zdiff_gradp function using batched searchsorted.  Recovers the
per-edge offset trick from the old v2 (2e5c5a7) and uses side='right'
to match the reference tie handling.  Adds a Hillis-Steele scan for cell 1
to reproduce the reference jk_start carry when E3 is violated.

Cites vertical.py:558 and vertical.py:625 for the z_ifc monotonicity proof.
…ation

Drop compute_zdiff_gradp_fast parametrization.  P2 (zero-thickness) now
passes with the searchsorted function, so keep it as a regular test.  P4
(non-monotone z_ifc) remains xfail because it violates E1, which
searchsorted requires.
side='right' caused a cupy CPU fallback (4723ms vs 17ms). Reverted to
side='left' (GPU-accelerated) and added a uniform vectorized where that
corrects the off-by-one when the query exactly equals a z_ifc boundary
(side='left' returns jk+1, Fortran first-match takes jk). Also fixed the
_batched_searchsorted offset to prevent cross-row overlap when fill values
place queries outside the row's own range (nlev=1, fi=0).
The tie correction (take_along_axis gather of (491520, 60) int64 indices
into z_ifc, x4 searchsorted calls) cost 8x: 27s numpy / 5s GPU vs 3.4s /
17ms without. Detecting a tie costs the same as correcting it (both need
the gather), so there is no cheap middle ground.

The premise (z_me strictly between z_ifc boundaries, no exact ties) holds
for all production VerticalGridConfig: z_ifc layers are separated by
millions to billions of ULPs (enforced by _check_and_correct_layer_thickness,
vertical.py:625), so each midpoint z_mc[c,j] = 0.5*(z_ifc[c,j]+z_ifc[c,j+1])
is a distinct float strictly between its boundaries, and z_me (a convex
combination of two such midpoints) is strictly between them as well. The
module docstring now states this argument with citations.

P1 (interior tie, forces z_me == z_ifc boundary by direct assignment) is
xfailed with the same reasoning as P2/P3/P4: synthetic input that the
grid builder does not produce.
@msimberg

Copy link
Copy Markdown
Contributor Author

cscs-ci run default;MODEL_SUBPACKAGES=common:driver;SESSIONS=model;MODEL_SUBSETS=datatest
🤖 Posted by an agent on behalf of @msimberg.

@msimberg

Copy link
Copy Markdown
Contributor Author

cscs-ci run default;MODEL_SUBPACKAGES=common:driver;SESSIONS=model;MODEL_SUBSETS=datatest;LEVELS=integration
🤖 Posted by an agent on behalf of @msimberg.

@github-actions

Copy link
Copy Markdown

When developing, you can test your changes on CSCS CI before merge with the default pipeline: cscs-ci run default. This will run a default subset of tests.

You can pass options to override pipeline variables, for example:

  • cscs-ci run default;BACKENDS=gtfn_cpu;LEVELS=unit
  • cscs-ci run default;MODEL_SUBPACKAGES=common:driver;SESSIONS=model
    Avoid running the pipeline for all tests when you are developing.

Available options are:

  • SESSIONS: model, model_mpi, or tools (correspond to nox sessions)
  • MODEL_SUBSETS: datatest, basic, or stencils (correspond to nox session selections)
  • MODEL_SUBPACKAGES: subpackages for non-MPI tests (last component, e.g. diffusion, driver)
  • MODEL_MPI_SUBPACKAGES: subpackages for MPI tests (as above)
  • BACKENDS: backends
  • GRIDS: grids for stencil tests (simple, icon_regional, or icon_global)
  • LEVELS: testing level for non-stencil tests (unit or integration)

For each option, all can be used as a shorthand for all possible values of that variable, e.g. LEVELS=all.

See scripts/python/generate_ci_pipeline.py and noxfile.py for available values for each option.

The all pipeline can be run with cscs-ci run all. This will run all icon4py tests in CSCS CI which can be expensive. This pipeline runs on a schedule on main, and can be run when extensive validation is needed (e.g. before releases).

Merging

Once your PR is approved and ready for merging, add it to the merge queue. The merge CSCS CI pipeline will run automatically on the merge-queue branch and must pass before the PR is merged. A dummy merge check will be triggered on the PR itself since it's required to add a PR to the merge queue.

Optional Tests

To run benchmarks you can use:

  • cscs-ci run benchmark-bencher

For more detailed information please look at CI in the EXCLAIM universe.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Optimizes pressure-gradient metrics for GPU execution through batched array operations and shared edge-height computation.

Changes:

  • Replaces nested host loops with batched searchsorted.
  • Adds shared z_me and selective halo exchanges.
  • Expands regression, randomized, and edge-case tests.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
compute_zdiff_gradp.py Implements vectorized bracket searches.
metric_fields.py Reuses precomputed z_me.
metrics_factory.py Registers z_me and selective exchange.
states/factory.py Adds exchange-field filtering.
test_compute_zdiff_gradp.py Adds reference and probe tests.
test_metric_fields.py Updates flat-index test inputs.
test_metrics_factory.py Enables GPU factory testing.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

+ 1.0
)
r = max_num * array_ns.arange(m, dtype=array_ns.float64)[:, None]
p = array_ns.searchsorted((a + r).ravel(), (v + r).ravel()).reshape(v.shape)
array_ns.expand_dims(z_me, axis=1)[horizontal_start:, :, :]
- z_mc[e2c][horizontal_start:, :, :]
zdiff_gradp[hs:, :, :] = array_ns.expand_dims(z_me, axis=1)[hs:, :, :] - z_mc[e2c][hs:, :, :]
vertoffset_gradp = array_ns.zeros((nedges, 2, nlev), dtype=gtx.int32)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants