Skip to content

Commit bdeb4e7

Browse files
Merge pull request #109 from openscm/perf/fair2-vectorise-output-extraction
perf(fair2): vectorise forcing-aggregation output extraction (~9x at 842 members)
2 parents fd4cd4d + 2f3abcb commit bdeb4e7

3 files changed

Lines changed: 168 additions & 30 deletions

File tree

changelog/109.improvement.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Sped up the FaIR2 adapter's output extraction by materialising FaIR's forcing array to numpy once and aggregating per ensemble member with integer indexing, instead of repeated per-member xarray orthogonal indexing. For an 842-member native-calibration ensemble this cut a pathway from ~370 s to ~40 s (~9x) and removed the superlinear scaling with member count. Results are numerically identical.

src/openscm_runner/adapters/fair2_adapter/_output_extractor.py

Lines changed: 41 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -158,17 +158,35 @@ def _concentration_unit(species_name: str) -> str:
158158
# properties_df, available_species) and returns a 1-D numpy array
159159
# along the time axis (or None if the recipe can't be computed against
160160
# the species set FaIR actually ran).
161+
def _forcing_to_numpy(forcing_da):
162+
"""
163+
Materialise FaIR's forcing DataArray as a plain numpy array once.
164+
165+
Returns ``(forcing_np, species_index)`` where ``forcing_np`` has axis
166+
order ``(timebounds, scenario, config, specie)`` and ``species_index``
167+
maps species name -> position on the last axis. Extracting/aggregating
168+
per (scenario, member) against this numpy array with integer indexing
169+
is orders of magnitude faster than repeated xarray ``.sel().isel()``
170+
orthogonal indexing (the dominant cost for large ensembles), and is
171+
numerically identical (same species order, same summation order).
172+
"""
173+
da = forcing_da.transpose("timebounds", "scenario", "config", "specie")
174+
species = list(da["specie"].values)
175+
return np.asarray(da.values), {name: i for i, name in enumerate(species)}
176+
177+
161178
def _sum_forcing_over(
162-
forcing_da, sc_idx: int, member_offset: int, species_to_sum: list[str]
179+
forcing_np,
180+
species_index: "dict[str, int]",
181+
sc_idx: int,
182+
member_offset: int,
183+
species_to_sum: list[str],
163184
) -> np.ndarray:
164-
"""Helper: sum FaIR's forcing array over a list of species names."""
165-
species_present = [s for s in species_to_sum if s in forcing_da["specie"].values]
166-
if not species_present:
185+
"""Sum the forcing array over a list of species names (numpy path)."""
186+
idx = [species_index[s] for s in species_to_sum if s in species_index]
187+
if not idx:
167188
return None
168-
sliced = forcing_da.sel(specie=species_present).isel(
169-
scenario=sc_idx, config=member_offset
170-
)
171-
return sliced.sum(dim="specie").values
189+
return forcing_np[:, sc_idx, member_offset, idx].sum(axis=-1)
172190

173191

174192
def _species_by_property(properties_df, predicate) -> list[str]:
@@ -179,20 +197,21 @@ def _species_by_property(properties_df, predicate) -> list[str]:
179197

180198

181199
def _build_forcing_aggregations( # noqa: PLR0912, PLR0915
182-
forcing_da, sc_idx: int, member_offset: int, properties_df
200+
forcing_np, species_index, species_in_run, sc_idx: int,
201+
member_offset: int, properties_df,
183202
) -> "dict[str, tuple[np.ndarray, str]]":
184203
"""
185204
Build the value arrays for the supported forcing aggregations.
186205
187206
Returns a dict keyed by openscm-runner variable name; values are
188207
``(values, unit)`` tuples. Aggregations whose species list does
189208
not intersect FaIR's actual species are omitted, not zero-filled.
209+
Operates on the pre-materialised numpy forcing array (see
210+
:func:`_forcing_to_numpy`) for speed.
190211
"""
191-
species_in_run = list(forcing_da["specie"].values)
192-
193212
def sum_over(species_list: list[str]) -> np.ndarray:
194213
return _sum_forcing_over(
195-
forcing_da, sc_idx, member_offset, species_list
214+
forcing_np, species_index, sc_idx, member_offset, species_list
196215
)
197216

198217
ghgs = _species_by_property(
@@ -230,11 +249,7 @@ def maybe(name: str, values):
230249
out[name] = (values, forcing_unit)
231250

232251
# Anthropogenic = total forcing minus Solar minus Volcanic
233-
total = (
234-
forcing_da.isel(scenario=sc_idx, config=member_offset)
235-
.sum(dim="specie")
236-
.values
237-
)
252+
total = forcing_np[:, sc_idx, member_offset, :].sum(axis=-1)
238253
natural_species = [s for s in ("Solar", "Volcanic") if s in species_in_run]
239254
if natural_species:
240255
natural = sum_over(natural_species)
@@ -283,13 +298,9 @@ def maybe(name: str, values):
283298
"Effective Radiative Forcing|Solar": "Solar",
284299
}
285300
for openscm_name, fair_specie in single_specie_aliases.items():
286-
if fair_specie not in species_in_run:
301+
if fair_specie not in species_index:
287302
continue
288-
values = (
289-
forcing_da.sel(specie=fair_specie)
290-
.isel(scenario=sc_idx, config=member_offset)
291-
.values
292-
)
303+
values = forcing_np[:, sc_idx, member_offset, species_index[fair_specie]]
293304
maybe(openscm_name, values)
294305

295306
# RCMIP-aligned hierarchical aggregations. Most are aliases for
@@ -367,12 +378,8 @@ def maybe(name: str, values):
367378
for spec_leaf, spec in (
368379
("CO2", "CO2"), ("CH4", "CH4"), ("N2O", "N2O"),
369380
):
370-
if spec in species_in_run:
371-
v = (
372-
forcing_da.sel(specie=spec)
373-
.isel(scenario=sc_idx, config=member_offset)
374-
.values
375-
)
381+
if spec in species_index:
382+
v = forcing_np[:, sc_idx, member_offset, species_index[spec]]
376383
maybe(f"Effective Radiative Forcing|Anthropogenic|{spec_leaf}", v)
377384

378385
return out
@@ -406,6 +413,9 @@ def extract_outputs( # noqa: PLR0913, PLR0912, PLR0915
406413
rows: list = []
407414

408415
species_in_run = list(f.forcing["specie"].values)
416+
# Materialise the forcing array once (dominant cost otherwise is
417+
# repeated xarray orthogonal indexing per member); aggregate in numpy.
418+
forcing_np, forcing_species_index = _forcing_to_numpy(f.forcing)
409419

410420
# Pre-compute aggregations once per (scenario, member) since the
411421
# recipes share intermediate sums.
@@ -415,7 +425,8 @@ def extract_outputs( # noqa: PLR0913, PLR0912, PLR0915
415425

416426
aggregations = (
417427
_build_forcing_aggregations(
418-
f.forcing, sc_idx, member_offset, properties_df
428+
forcing_np, forcing_species_index, species_in_run,
429+
sc_idx, member_offset, properties_df,
419430
)
420431
if properties_df is not None
421432
else {}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""Equivalence tests for the FaIR2 forcing-aggregation fast path.
2+
3+
The extractor materialises FaIR's forcing DataArray to numpy once and
4+
aggregates per (scenario, member) with integer indexing, instead of
5+
repeated xarray ``.sel().isel()`` orthogonal indexing (the dominant cost
6+
for large ensembles). These tests pin that the numpy path is numerically
7+
identical to a direct xarray reduction over the same species.
8+
"""
9+
from __future__ import annotations
10+
11+
import numpy as np
12+
import pandas as pd
13+
import pytest
14+
15+
xr = pytest.importorskip("xarray")
16+
17+
from openscm_runner.adapters.fair2_adapter._output_extractor import ( # noqa: E402
18+
_build_forcing_aggregations,
19+
_forcing_to_numpy,
20+
_sum_forcing_over,
21+
)
22+
23+
SPECIES = [
24+
"CO2", "CH4", "N2O", "HFC-134a", "CF4", "CFC-11",
25+
"Aerosol-radiation interactions", "Aerosol-cloud interactions",
26+
"Ozone", "Solar", "Volcanic",
27+
]
28+
29+
30+
def _synthetic_forcing(n_t=10, n_scen=2, n_cfg=5, seed=0):
31+
rng = np.random.default_rng(seed)
32+
data = rng.standard_normal((n_t, n_scen, n_cfg, len(SPECIES)))
33+
return xr.DataArray(
34+
data,
35+
dims=("timebounds", "scenario", "config", "specie"),
36+
coords={
37+
"timebounds": np.arange(1750, 1750 + n_t),
38+
"scenario": [f"s{i}" for i in range(n_scen)],
39+
"config": np.arange(n_cfg),
40+
"specie": SPECIES,
41+
},
42+
)
43+
44+
45+
def _properties_df():
46+
ghg = {"CO2", "CH4", "N2O", "HFC-134a", "CF4", "CFC-11"}
47+
types = {
48+
"HFC-134a": "f-gas", "CF4": "f-gas", "CFC-11": "cfc-11",
49+
}
50+
return pd.DataFrame(
51+
{
52+
"greenhouse_gas": [s in ghg for s in SPECIES],
53+
"type": [types.get(s, "other") for s in SPECIES],
54+
},
55+
index=SPECIES,
56+
)
57+
58+
59+
def _xarray_sum(da, sc_idx, member, species_list):
60+
present = [s for s in species_list if s in da["specie"].values]
61+
if not present:
62+
return None
63+
return (
64+
da.sel(specie=present)
65+
.isel(scenario=sc_idx, config=member)
66+
.sum(dim="specie")
67+
.values
68+
)
69+
70+
71+
def test_sum_forcing_over_matches_xarray():
72+
da = _synthetic_forcing()
73+
forcing_np, species_index = _forcing_to_numpy(da)
74+
for sc_idx in range(da.sizes["scenario"]):
75+
for member in range(da.sizes["config"]):
76+
for species_list in (
77+
["CO2", "CH4", "N2O"],
78+
["HFC-134a", "CF4"],
79+
["Solar", "Volcanic"],
80+
["not-a-species"], # empty intersection -> None
81+
):
82+
got = _sum_forcing_over(
83+
forcing_np, species_index, sc_idx, member, species_list
84+
)
85+
ref = _xarray_sum(da, sc_idx, member, species_list)
86+
if ref is None:
87+
assert got is None
88+
else:
89+
np.testing.assert_array_equal(got, ref)
90+
91+
92+
def test_build_forcing_aggregations_matches_xarray():
93+
da = _synthetic_forcing(seed=1)
94+
props = _properties_df()
95+
forcing_np, species_index = _forcing_to_numpy(da)
96+
species_in_run = list(da["specie"].values)
97+
98+
for sc_idx in range(da.sizes["scenario"]):
99+
for member in range(da.sizes["config"]):
100+
out = _build_forcing_aggregations(
101+
forcing_np, species_index, species_in_run, sc_idx, member, props
102+
)
103+
erf = "Effective Radiative Forcing"
104+
105+
# Total anthropogenic = all species minus Solar/Volcanic.
106+
total = _xarray_sum(da, sc_idx, member, species_in_run)
107+
natural = _xarray_sum(da, sc_idx, member, ["Solar", "Volcanic"])
108+
np.testing.assert_allclose(
109+
out[f"{erf}|Anthropogenic"][0], total - natural, rtol=0, atol=1e-12
110+
)
111+
# Greenhouse gases.
112+
np.testing.assert_array_equal(
113+
out[f"{erf}|Greenhouse Gases"][0],
114+
_xarray_sum(da, sc_idx, member,
115+
["CO2", "CH4", "N2O", "HFC-134a", "CF4", "CFC-11"]),
116+
)
117+
# F-gases.
118+
np.testing.assert_array_equal(
119+
out[f"{erf}|F-Gases"][0],
120+
_xarray_sum(da, sc_idx, member, ["HFC-134a", "CF4"]),
121+
)
122+
# Single-species pass-through (Ozone).
123+
np.testing.assert_array_equal(
124+
out[f"{erf}|Ozone"][0],
125+
da.sel(specie="Ozone").isel(scenario=sc_idx, config=member).values,
126+
)

0 commit comments

Comments
 (0)