Skip to content

Commit 5a0df96

Browse files
fluidnumericsJoepre-commit-ci[bot]erikvansebille
authored
Windowed Array (#2718)
* Carry over windowed_array implementation to new main branch with "model" concept * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Change caching pattern to be based on spanning time index range This supports both forward and backward time stepping. Additional tests have been added to test backwards in time integration. This includes tests verifying expected data is held in the windowed cache when dt is negative and a test that compares backwards integration with purely numpy backed fieldset. * Breaking tutorial * Return empty numpy array for empty selection dictionaries * Change fs_* to fset_* per review; consistent with other naming conventions in tests * Flesh out docstring on how the default windowedarray caching works * Assert that dask array is indeed initially a dask array in test --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Erik van Sebille <erikvansebille@gmail.com>
1 parent 11179ed commit 5a0df96

6 files changed

Lines changed: 355 additions & 4 deletions

File tree

docs/user_guide/examples/tutorial_Argofloats.ipynb

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,6 @@
107107
" \"CopernicusMarine_data_for_Argo_tutorial/data\"\n",
108108
")\n",
109109
"\n",
110-
"# TODO check how we can get good performance without loading full dataset in memory\n",
111-
"ds_fields.load() # load the dataset into memory\n",
112-
"\n",
113110
"# Select fields\n",
114111
"fields = {\n",
115112
" \"U\": ds_fields[\"uo\"],\n",
@@ -120,6 +117,7 @@
120117
"# Convert to SGRID-compliant dataset and create FieldSet\n",
121118
"ds_fset = parcels.convert.copernicusmarine_to_sgrid(fields=fields)\n",
122119
"fieldset = parcels.FieldSet.from_sgrid_conventions(ds_fset)\n",
120+
"fieldset.to_windowed_arrays()\n",
123121
"\n",
124122
"# Define a new Particle type including extra Variables\n",
125123
"ArgoParticle = parcels.Particle.add_variable(\n",
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Transparent rolling time-window cache for lazy (dask-backed) field data.
2+
3+
Assumptions / current limits:
4+
* ``time`` is the leading dimension of the field (true for both the SGRID and
5+
UGRID ingestion paths; the structured path transposes to ``(time, ...)``).
6+
* Valid while the requested time indices stay within the resident window
7+
(i.e. all particles share the clock). A sample that requests time indices
8+
spanning more than the retained levels would force reloads.
9+
* The clock is assumed monotonic but may run in either direction: forward
10+
(``dt > 0``) or backward (``dt < 0``). Eviction keeps only the levels each
11+
``isel`` actually requests, which is symmetric in time -- so direction never
12+
enters the logic and no integration-direction flag is needed.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import numpy as np
18+
import xarray as xr
19+
from dask import is_dask_collection
20+
21+
# xarray / uxarray ``isel`` keyword arguments that are NOT dimension indexers.
22+
_NON_INDEXER_KWARGS = frozenset({"drop", "missing_dims", "ignore_grid"})
23+
24+
25+
class WindowedArray:
26+
"""Wrap a lazy DataArray so ``isel`` loads/caches/evicts time levels as NumPy."""
27+
28+
def __init__(self, data: xr.DataArray, time_dim: str = "time", max_levels: int | None = None):
29+
if data.dims[0] != time_dim:
30+
raise ValueError(f"WindowedArray expects {time_dim!r} as the leading dimension, got {data.dims}")
31+
self._data = data
32+
self._tdim = time_dim
33+
self._cache: dict[int, np.ndarray] = {} # time index -> NumPy slab (remaining dims)
34+
self._max = max_levels
35+
# diagnostics
36+
self.loads = 0
37+
self.bytes_read = 0
38+
self._slab_bytes = int(np.prod(data.isel({time_dim: 0}).shape)) * data.dtype.itemsize
39+
40+
# -- transparency: forward everything we don't override -------------------
41+
def __getattr__(self, name):
42+
# __getattr__ only fires for misses; reach _data without recursing.
43+
return getattr(object.__getattribute__(self, "_data"), name)
44+
45+
def __repr__(self):
46+
return (
47+
f"WindowedArray(time_dim={self._tdim!r}, cached_levels={sorted(self._cache)}, "
48+
f"loads={self.loads})\n{self._data!r}"
49+
)
50+
51+
# -- window management ----------------------------------------------------
52+
def _read_level(self, lvl: int) -> np.ndarray:
53+
"""Bulk, sequential read of one time level into NumPy (the dask->NumPy step)."""
54+
return np.asarray(self._data.isel({self._tdim: int(lvl)}).values)
55+
56+
def _ensure(self, levels: np.ndarray) -> None:
57+
for lvl in levels:
58+
lvl = int(lvl)
59+
if lvl not in self._cache:
60+
self._cache[lvl] = self._read_level(lvl)
61+
self.loads += 1
62+
self.bytes_read += self._slab_bytes
63+
# retire cached levels outside the span this call requested. Direction never
64+
# enters here: a forward (dt > 0) or backward (dt < 0) clock both shed their
65+
# trailing edge. Consecutive brackets overlap on one endpoint (inside [lo, hi]),
66+
# so it is retained and each level is still read at most once per pass.
67+
lo, hi = int(np.min(levels)), int(np.max(levels))
68+
for old in [k for k in self._cache if k < lo or k > hi]:
69+
del self._cache[old]
70+
if self._max is not None and len(self._cache) > self._max:
71+
for old in sorted(self._cache)[: len(self._cache) - self._max]:
72+
del self._cache[old]
73+
74+
# -- intercepted indexing -------------------------------------------------
75+
def isel(self, indexers: dict | None = None, **kwargs):
76+
sel = dict(indexers) if indexers is not None else {}
77+
sel.update({k: v for k, v in kwargs.items() if k not in _NON_INDEXER_KWARGS})
78+
79+
# no time selection -> nothing to window; preserve control kwargs
80+
if self._tdim not in sel:
81+
return self._data.isel(indexers, **kwargs)
82+
83+
t_ind = sel[self._tdim]
84+
t_vals = np.asarray(t_ind.values if isinstance(t_ind, xr.DataArray) else t_ind)
85+
levels = np.unique(t_vals)
86+
if levels.size == 0:
87+
# empty selection (e.g. a kernel evaluating an empty particle subset):
88+
# nothing to load or evict; gather from an empty NumPy block below
89+
block = np.empty((0, *self._data.shape[1:]), dtype=self._data.dtype)
90+
else:
91+
self._ensure(levels)
92+
# stack the resident levels into one small NumPy block; remap to local indices
93+
block = np.stack([self._cache[int(lvl)] for lvl in levels]) # (nlevels, *rest)
94+
nda = xr.DataArray(block, dims=self._data.dims) # NumPy-backed, original dim order
95+
local = np.searchsorted(levels, t_vals)
96+
sel[self._tdim] = xr.DataArray(local, dims=getattr(t_ind, "dims", ()))
97+
return nda.isel(sel) # plain vectorised gather in NumPy (no ignore_grid needed)
98+
99+
100+
def maybe_windowed(data: xr.DataArray, max_levels: int | None = None):
101+
"""Wrap dask-backed, field data in a ``WindowedArray``; else pass through.
102+
103+
NumPy-backed fields (already resident) and fields without a leading ``time``
104+
dimension are returned unchanged, so existing eager workflows are unaffected.
105+
Already-wrapped data is returned unchanged.
106+
"""
107+
if isinstance(data, WindowedArray):
108+
return data
109+
if data.dims and data.dims[0] == "time" and is_dask_collection(data.data):
110+
return WindowedArray(data, max_levels=max_levels)
111+
return data

src/parcels/_core/field.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def __init__(
101101

102102
@property
103103
def data(self):
104-
return self.model.data[self.name]
104+
return self.model.field_data(self.name)
105105

106106
@property
107107
def grid(self): # TODO PR: Remove in favour of referencing model grid directly

src/parcels/_core/fieldset.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,39 @@ def add_field(self, field: Field, name: str | None = None):
152152

153153
self.fields[name] = field
154154

155+
def to_windowed_arrays(self, *, max_levels: int | None = None):
156+
"""Wrap dask-backed field data in rolling time-window caches.
157+
158+
Opt-in optimization for forward-marching simulations where all particles
159+
share a single clock. Delegates to each underlying model; dask-backed,
160+
time-leading fields are served through a resident NumPy window (each time
161+
level loaded once and evicted as the clock advances) instead of re-reading
162+
chunks on every kernel step. NumPy-backed (eager) and non-time-leading
163+
fields are left unchanged, and re-invoking is idempotent, so this is safe
164+
to call more than once.
165+
166+
Parameters
167+
----------
168+
max_levels : int, optional
169+
Hard cap on the number of time levels kept resident per field.
170+
With the default ``None``, each interpolation call decides what
171+
stays resident: the cache keeps exactly the span of time indices
172+
that call requests and evicts every level outside it. During time
173+
integration particles bracket the current time between two
174+
adjacent levels, so the default keeps at most two levels resident.
175+
Only when a single call requests a wider time span (e.g. particles
176+
spread across many time levels) does the window grow beyond that,
177+
and ``max_levels`` then bounds its size.
178+
179+
Returns
180+
-------
181+
FieldSet
182+
``self``, to allow chaining.
183+
"""
184+
for model in self.models:
185+
model.to_windowed_arrays(max_levels=max_levels)
186+
return self
187+
155188
def add_constant_field(self, name: str, value, mesh: ptyping.Mesh = "spherical"):
156189
"""Wrapper function to add a Field that is constant in space,
157190
useful e.g. when using constant horizontal diffusivity

src/parcels/_core/model.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import parcels._sgrid as sgrid
1212
import parcels._typing as ptyping
13+
from parcels._core._windowed_array import maybe_windowed
1314
from parcels._core.basegrid import BaseGrid
1415
from parcels._core.field import Field, VectorField
1516
from parcels._core.utils.time import TimeInterval
@@ -62,6 +63,50 @@ def assert_valid_model_data(self) -> None:
6263
raise e
6364
return
6465

66+
def field_data(self, name: str) -> Any:
67+
"""Return the array backing field ``name``.
68+
69+
Normally this is the ``xr.DataArray`` held in the dataset. After
70+
:meth:`to_windowed_arrays`, dask-backed fields are served through a
71+
cached :class:`~parcels._core._windowed_array.WindowedArray` instead.
72+
"""
73+
windowed = self.__dict__.get("_windowed")
74+
if windowed is not None and name in windowed:
75+
return windowed[name]
76+
return self.data[name]
77+
78+
def to_windowed_arrays(self, *, max_levels: int | None = None) -> Self:
79+
"""Wrap dask-backed field data in rolling time-window caches.
80+
81+
Opt-in optimization for forward-marching simulations where all particles
82+
share a single clock. For each dask-backed, time-leading field, ``isel``
83+
then samples a resident NumPy window (each time level loaded once and
84+
evicted as the clock advances) instead of re-reading chunks and paying the
85+
dask scheduling overhead on every kernel step. NumPy-backed (eager) fields
86+
and non-time-leading fields are left unchanged.
87+
88+
Idempotent: re-invoking reuses the existing wrapper (and its warm cache)
89+
rather than rebuilding it.
90+
91+
Parameters
92+
----------
93+
max_levels : int, optional
94+
Hard cap on the number of time levels kept resident per field.
95+
With the default ``None``, each interpolation call decides what
96+
stays resident: the cache keeps exactly the span of time indices
97+
that call requests and evicts every level outside it. During time
98+
integration particles bracket the current time between two
99+
adjacent levels, so the default keeps at most two levels resident.
100+
Only when a single call requests a wider time span (e.g. particles
101+
spread across many time levels) does the window grow beyond that,
102+
and ``max_levels`` then bounds its size.
103+
"""
104+
windowed = self.__dict__.setdefault("_windowed", {})
105+
for name in self.scalar_field_names:
106+
current = windowed.get(name, self.data[name])
107+
windowed[name] = maybe_windowed(current, max_levels=max_levels)
108+
return self
109+
65110
@property
66111
def time_interval(self) -> TimeInterval | None:
67112
try:

0 commit comments

Comments
 (0)