ICON-Land (JSBACH) → gt4py Port — Handoff
Status: investigation / design phase complete; no port code written yet.
Prepared: by Christoph Müller’s Claude Code session, for handoff to another team/instance.
Scope of this doc: a self-contained consolidation of the analysis, the core thesis, quantified
facts about the codebase, the agreed strategy, the oracle/validation design, the first-slice spec,
open risks, and concrete next steps. Everything here was derived from the jsbach source tree
(externals/jsbach) — verify file:line citations and counts against current code before quoting,
as they are point-in-time.
The bundle also contains
field_catalog.csv(1152 model variables, machine-readable requirements spec) andextract_catalog.py(regenerates it from source). Re-run the script after code changes.
0. TL;DR — the one-paragraph pitch
A land-surface model looks like a nightmare for a stencil DSL: 127k SLOC of heavily
object-oriented Fortran, a runtime tree of fractional “tiles,” dynamic dispatch everywhere. But the
dynamic behavior is a Fortran idiom, not a requirement: the dispatch is coarse-grained (never in
the cell loop), closed-world (finite process set known at compile time), and frozen at init. It can
be resolved to a static, flattened pipeline at configuration time — which is exactly the shape
that lowers to gt4py.next/icon4py, and exactly the property NVIDIA already exploited to ship CUDA
graphs for JSBACH (production proof the pipeline is static). The numerical kernels (~30–40k SLOC) are
already written as flat array procedures and are portable; the framework (~80k SLOC) mostly
evaporates rather than being reimplemented. The dominant cost is not translation — it’s
scientific revalidation, because the reordering that buys performance portability also breaks
bit-reproducibility.
1. What ICON-Land / JSBACH is
Land-surface & soil component of the ICON/ECHAM climate models (MPI-M & MPI-BGC), Fortran, ~207
.f90 files. Checked out as a git submodule of icon-nwp at externals/jsbach.
Docs: https://jsbach.gitlab-pages.dkrz.de/jsbach
Framework (src/base/): a hierarchical tile tree (mo_jsb_tile*, mo_hsm_class), pluggable
processes (mo_jsb_process_*, a factory), tasks scheduled per timestep (mo_jsb_task_class),
and pointer-based memory/var/pool management (mo_jsb_var_class, mo_lnd_bgcm_*). Model setups
are “usecases” (mo_jsb_model_usecases).
Processes (src/): physical (hydrology, soil_snow_energy, srf_energy_bal, turbulence,
radiation, atmland), classic biogeochem (carbon, phenology, assimilation, disturbance,
forest_age), land-cover change (anthropogenic_lcc, natural_lcc, pre_and_post_lcc), and the
newer QUINCY biogeochem (q_* dirs). hd/ is hydrological discharge (river routing).
dsl4jsb (scripts/dsl4jsb/dsl4jsb.py): a preprocessor — see §6.
2. Core thesis — dynamic dispatch is unnecessary
- Dispatch is coarse-grained. The polymorphic call is
CALL this%Integrate(tile, options)(src/base/mo_jsb_task_class.f90:146), taking a whole tile; vectorization over cells (nproma) happens inside the kernel. Dispatch happens per task × tile × process × timestep, never in the inner loop → the vtable indirection costs ~nothing. Removing it is not about speed. - The polymorphism is closed-world + fixed-per-run. The set of process types is finite and
known at compile time (it is literally the
src/tree — no runtime plugins). Which processes run on which tiles, and the tree shape, are config-driven but frozen at initialization. Dynamic shape (N PFT tiles) ≠ dynamic type. - Therefore it resolves to a static pipeline. Read the namelist once at init → resolve the entire
tile×process×task traversal into a flat, ordered schedule of
(kernel, field-views, tile-group)→ run that schedule every step. This is monomorphization-at-config-time. No per-timestep polymorphism. - “Add a new tile type” does not need dynamic dispatch. A PhD student adding tile type N+1 is closed-world extension resolved at model-build time (you recompile/re-stage; you never load an unknown type at runtime). A tagged-union/registry gives identical expressive power; you lose only the ability to add a type without touching a central registry (the expression problem), which is a non-cost when adding a tile type is already a deliberate compile-the-model act.
Consequence for the port: kernels → gt4py.next field operators; framework → a static host-side
Python orchestration layer (schedule resolved at init). The framework stays host-side, but it can be
static, not dynamic.
3. Quantified facts (grounding for estimates)
SLOC (~127k total; find src -name '*.f90' | xargs wc -l)
base/framework ≈ 19.8k; process modules ≈ 107k.- By file role:
interface25.0k (dispatch glue + real science),memory_class18.0k (field declarations + metadata),process14.0k (pure kernels),init11.6k,config4.8k,constants2.5k. Big kernel files:q_soil_biogeochemistry/mo_q_sb_ssm_main3.6k,hydrology/mo_hydro_process3.5k,q_vegetation/mo_q_veg_growth3.4k. - ~80k = framework/declarations/config/orchestration → collapses to ~10–20k Python. ~30–40k = genuine numerics → must be faithfully re-expressed.
- 145/207 files use
CLASS(...); 140 usePOINTER— heavily OO, not stencil-shaped.
The kernels are already portable-shaped
Leaf kernels (calc_* in *_process.f90) take flat scalar/array args, not the polymorphic tile —
e.g. calc_surface_hydrology_land at src/hydrology/mo_hydro_process.f90:56 takes
REAL(wp) :: steepness(:), t_soil_sl1(:), .... The tile→array unpacking lives in the interface
layer. So the test seam already exists: feed arrays, capture arrays, diff.
Tiles (src/base/mo_jsb_lct_class.f90:55, src/base/mo_jsb_model_usecases.f90)
- Land-cover-type kinds: LAND, VEG, BARE(unused), GLACIER, LAKE — 5 defined, ~4 used
(
max_no_of_lct=10). PFTs are not distinct types — they are many instances ofVEG_TYPE. - Tiles are a tree with nested fractions (leaf absolute fraction = product down the path).
- Leaf (fractional) tiles per cell:
jsbach_litea handful;jsbach_pftsnpft=11/12;quincy_13_pfts13;jsbach_forest_age_classes= forest PFTs ×nacs(default 11) → several tens. - Frozen at init → this leaf count is the size of the dense “tile” axis to flatten over
(
[cells × n_leaf_tiles × levels]). “Process all grass, then all sand” = grouping over this axis. - Why tiling exists: surface fluxes are nonlinear in surface state (Jensen), so avg(flux)≠flux(avg); forest age classes exist because young stands are C sinks, old stands ~C-neutral (needed for land-use-change carbon accounting). The complexity is real physics, but it lives in the bookkeeping/relocation/conservation, not the per-tile flops.
Field catalog (see field_catalog.csv; from all 1152 Add_var calls, 25 *_memory_class.f90)
- State surface is ~⅓: 363 prognostic + 68 conditional vs 721 diagnostic (63%, recomputed each step). gt4py state need only carry ~360–430 fields.
- 72% have no vertical axis: 831 2D / 321 3D. Vertical axes are a small fixed set: soil-water
layers (
vgrid_soil_w), soil-energy layers (soil_e), snow (snow_e), canopy, PFT, + singletons. - One aggregation operator: all 494
Aggregate_onChunkcalls useweighted_by_fract(area-weighted), despite an abstractt_jsb_aggregatorhierarchy with a single concrete impl. The “operator zoo” is one animal. - ⚠️ Caveat: the prognostic/diagnostic flag is inferred from
lrestart(default FALSE) and is ~90% accurate — e.g.t_soil_slis labeled diagnostic but is physically the prognostic soil temperature. A human must confirm the true state set (see §9, task 1).
Transcendental density (drives the bit-rep routing, §5)
High (need tolerance): q_vegetation 68, assimilation 50, srf_energy_bal 40, hydrology 33,
q_soil_biogeochemistry 24. Low/arithmetic (bit-rep achievable): soil_snow_energy 18,
radiation 14, turbulence 10, carbon 3, q_phenology 1.
4. The dsl4jsb “DSL” — what it is (and isn’t)
scripts/dsl4jsb/dsl4jsb.py is a single 20KB script, ~60 macros, no dependencies. It is a
context-free, line-by-line regex macro expander (C-preprocessor-like) — NOT config-driven
codegen. There are no inputs but the source text: no namelist, no conditionals, no state; same
line → same output. (A post-pass lowercases UPPER_ tokens — harmless because Fortran is
case-insensitive; plus md5 caching and #line directives.)
The ~60 macros collapse to 5 concerns, all pure layout/dispatch boilerplate:
(1) polymorphic memory/config access (SELECT TYPE downcasts); (2) field access + nproma-block
slicing %ptr(ics:ice,iblk); (3) tile aggregation; (4) bgc-material pool store accessors;
(5) lctlib parameter lookup.
Port impact: capture ~0% of the macro surface, none as a preprocessor. Concerns (1), (2)-slicing, and (4)-store-plumbing evaporate (they are Fortran-OO/memory-management artifacts the Python object model + gt4py field model handle natively). What survives becomes ordinary constructs: 2D/3D → field dimensions declared once; aggregation → the static tile-axis reduction; lctlib → a parameter table by tile type. Bonus: the DSL tags are free layout metadata — each access is tagged with its rank (2D/3D) and scope (chunk/domain), which hands you the gt4py field signature for translation.
5. Oracle / bit-reproducibility strategy
To make autonomous kernel porting safe, build a deterministic oracle and route each kernel to the right gate.
- Flags (both sides): nvhpc Fortran
-Kieee -Mnofma(IEEE-strict, no FMA, no flush-to-zero); gt4py backend compiled no-fast-math +-ffp-contract=off(CPU) / nvcc--fmad=false(GPU). Compare CPU gt4py backend vs CPU Fortran — GPU libdevice diverges more (separate, looser revalidation). - What is bit-identical:
+ - * / sqrt(correctly rounded). What is not, ever: transcendentals (exp/log/pow/tanh, and**=exp(y·log(x))) go through libm and differ ~1–few ULP regardless of flags — the math libraries differ Fortran-side vs backend-side. Unfixable. - Route by transcendental density (predictable up front): arithmetic kernels → bit-exact (or
few-ULP) gate; transcendental kernels → relative tolerance (never absolute; the original
target was rel
1e-10for that bucket — note this is ~1e6 ULP, a loose backstop; keep arithmetic kernels much tighter). Refinement: measure & display the actual rel err per transcendental kernel rather than assume a threshold — let the distribution show where the gate belongs. - Translation discipline: bit-rep holds only if source-level operation order & parenthesization are preserved exactly. Mirror the Fortran arithmetic literally even when less idiomatic; where the idiomatic gt4py form reorders a sum, that kernel drops to the tolerance gate.
- Residual risk the oracle does NOT solve = input coverage: a kernel can be bit-identical on the captured inputs yet wrong on an unexercised branch (frozen soil, glacier, zero-fraction, clamp/NaN). Backstop with property/fuzz testing via FFI (f2py / iso_c_binding): generate physically-plausible random inputs, run both sides, compare. Pure functions are exactly where this is cheap and safe.
The big-picture cost: the tile-by-type reordering that buys performance portability changes floating-point summation order in aggregation → you lose bit-reproducibility against the reference → you need scientific revalidation (multi-year runs, energy/water/carbon balance closure), not just bit-diffing. This is usually the largest hidden cost of a climate-model port. Mitigation: port kernels incrementally and bit-diff each one before introducing the reordering.
6. Phased plan (agreed) + refinements
Phases: (A) human-led design — abstract the real memory-layout & dispatch requirements away from Fortran fluff; establish static dispatch suffices. (B) Python skeleton. (C) unit-test + shortest-possible validation harness. (D) port pure functions first, validate vs Fortran oracle — agent-autonomous. (E) first vertical slice with real state change on a small subset, verified as component/integration test — human-assisted. (F) more slices — agent more independent.
Cadence: design = human; pure-functions = autonomous with per-batch checkpoints (not unattended weeks); slice-1 = assisted; later slices = independent. Whoever runs it should own the oracle.
Refinements (important):
- Aggregation/relocation operators encode conservation laws (C/water closure) — they look like layout fluff but are not. Enumerate them as first-class requirements; don’t abstract them away.
- The requirements are half-written already in
*_memory_class.f90Add_varmetadata (name, dims, restart/prognostic flag, output flag) — extract, don’t invent (field_catalog.csvis a start). - Don’t waterfall the design. Design the framework abstractions fully (tile, field, schedule, aggregation operator, parameter table, build-time registry); design only the first slice’s science in depth; sketch the rest. Expect to revise the paper design after slice 1 — that’s the point of a vertical slice.
- The harness (C) is the critical-path long pole, and a hard prerequisite to the agent marathon
(D): offline reference build + golden-I/O capture + a minimal forcing/IO path (
mo_jsb4_forcingalone is 2.7k SLOC). One offline reference run yields three things at once: golden kernel I/O, initial conditions for slices, and integration-test reference trajectories. - Define “pure”: referentially transparent given explicit array inputs, no global mutable state.
Triage
calc_*first — some haveintent(inout)accumulation or reach into module state;USEing a constants module is fine, mutating a shared buffer is not.
7. First vertical slice spec (recommended)
jsbach_lite + SSE (soil-snow energy) on land+glacier tiles, offline forcing. Chosen to hit every
hard framework primitive with minimal science:
- 3D field + tridiagonal vertical solve → your first
scan_operator(the catalog exposes the Thomas-algorithm coefficients directly:t_soil_acoef/bcoef,t_snow_acoef/bcoefonsoil_e/snow_e). - 2-tile aggregation (land+glacier) → the conservation/weighted-reduction path.
- multi-timestep prognostic state (
snow_depth_sl,t_soil_sl) → restart/state path. - K-axes needed:
soil_e,snow_e. Aggregation: one weighted reduction.
Avoid starting with biogeochem (transcendental tolerance issues + pool relocation too early).
The 25-field SSE slice of field_catalog.csv is the concrete requirements list.
8. Top risks / open questions
- gt4py.next indexed gather/scatter for the tile-by-type batching (an indexed permutation of an unstructured field) — is it expressible cleanly in the current gt4py? Check first; it gates the whole batching approach.
- Land-cover change / pool relocation (
anthropogenic_lcc,natural_lcc,mo_jsb_lcc): moving conserved matter between tiles = irregular gather/scatter + conservation bookkeeping. Worst fit for a dense-field DSL. - Revalidation cost (§5) — the dominant schedule risk.
- True state set — the catalog’s prognostic flag is ~90% right; must be confirmed (§9.1).
- Coupling scope — jsbach is an ICON component. Offline standalone vs in-ICON coupled (Fortran↔Python boundary) is a major scope fork.
Effort ballpark: de-risk PoC (one column-physics slice, offline, no LCC/QUINCY) ≈ 3–6 person-months; full classic JSBACH validated ≈ 5–8 person-years; +QUINCY roughly doubles the science surface; in-ICON coupling adds a substantial work package. (Reference: icon4py — dycore + some physics — is a ~5+ year, multi-FTE effort.)
9. Concrete next steps (all groundable without a build environment)
- Resolve the true state set. Cross-reference each “prognostic” catalog candidate against the
actual restart-variable list and the restart-identity logic (see recent commit
IQ: Fix restart identity). This is the highest-leverage, riskiest-to-get-wrong column. (Recommended first.) - Per-variable aggregation map. Parse
interfacefiles to attach each field’sAggregate_onChunkcall (and rare 2D/3D/domain rank variant) to the catalog — confirms the “one operator” finding per-field. - Pure-function triage. Classify
calc_*kernels into cleanly-pure vs needs-context; produces the worklist for the agent marathon. - lctlib parameter catalog. Extract the per-land-cover-type parameter set (
Lctlib_paramlookups) — the other half of the requirements (parameters vs fields). - Stand up the offline reference build + golden capture (the §6.4 long pole) — needs the build environment.
10. External context — the CUDA-graphs validation
NVIDIA (Dmitry Alexeev, dalexeev@nvidia.com) shipped CUDA graphs for JSBACH — commit
ad55a4bc4, merged 15 May 2025 (DKRZ MR !173; capture/replay machinery in ICON core, icon-mpim!401).
It made jsbach fully OpenACC-async + multi-stream and pushed scalars on-device so nothing host-side is
baked into a frozen graph. jsbach is launch-overhead-bound (task×tile×process×timestep = hundreds–
thousands of tiny kernel launches/step); graphs amortize that.
Why it matters here: graphs are invalidated only on workflow change —
is_newday/newmonth/newyear/restart/experiment_start (src/interfaces/mo_jsb_interface.f90:680);
between those boundaries the per-step launch sequence is frozen and replayed. That is production
proof of the §2 thesis. CUDA graphs require the pipeline stability we argue is achievable: the
Fortran had to retrofit it (pin the graph boundary to workflow changes) on top of dynamic dispatch;
a static-schedule port gets it by construction, and the graph-invalidation boundaries are exactly
the residual config-time dynamism (“which processes run” changes at day/month/year). A lowered static
pipeline (icon4py program) is the ideal graph-capture input — the port does not lose this win.
11. Bundle contents
HANDOFF.md— this document.field_catalog.csv— 1152 model variables: process, name, dim, vgrid, state, output, units, long-name. The machine-readable requirements spec / SSE slice-1 source.extract_catalog.py— regenerates the catalog from../src(python3 extract_catalog.py).
Recommended reading order for a new instance: §0 → §2 → §3 → §6 → §7, then §5 and §9 when starting to build. §4/§8/§10 are supporting depth.