Skip to content

Commit d3ec547

Browse files
authored
Merge pull request #200 from csiro-coasts/track-numpy-memory-usage
Improve peak memory allocation during polygon construction
2 parents c788fb2 + aa9a518 commit d3ec547

File tree

10 files changed

+201
-61
lines changed

10 files changed

+201
-61
lines changed

docs/releases/development.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
Next release (in development)
33
=============================
44

5+
* Reduce memory allocations when constructing polygons.
6+
This should allow opening even larger datasets
7+
(:pr:`200`).
58
* Add support for Python 3.14 and drop support for Python 3.11,
69
following `SPEC-0 <https://scientific-python.org/specs/spec-0000/>`_.
710
(:pr:`201`).

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ filterwarnings = [
102102
markers = [
103103
"matplotlib: Tests that involve matplotlib and plotting",
104104
"tutorial: Tests that involve the tutorial datasets",
105+
"memory_usage: Regression tests for memory allocations",
105106
]
106107

107108
mpl-use-full-test-name = true

src/emsarray/conventions/arakawa_c.py

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -261,18 +261,31 @@ def pack_index(self, grid_kind: ArakawaCGridKind, indexes: Sequence[int]) -> Ara
261261
return cast(ArakawaCIndex, (grid_kind, *indexes))
262262

263263
def _make_polygons(self) -> numpy.ndarray:
264-
# Make an array of shape (j, i, 2) of all the nodes
265-
grid = numpy.stack([self.node.longitude.values, self.node.latitude.values], axis=-1)
266-
267-
# Transform this in to an array of shape (topology.size, 4, 2)
268-
points = numpy.stack([
269-
grid[:-1, :-1],
270-
grid[:-1, +1:],
271-
grid[+1:, +1:],
272-
grid[+1:, :-1],
273-
], axis=2).reshape((-1, 4, 2))
274-
275-
return utils.make_polygons_with_holes(points)
264+
j_size, i_size = self.face.shape
265+
longitude = self.node.longitude.values
266+
latitude = self.node.latitude.values
267+
268+
# Preallocate the points array. We will copy data straight in to this
269+
# to save repeated memory allocations.
270+
points = numpy.empty(shape=(i_size, 4, 2), dtype=self.node.longitude.dtype)
271+
# Preallocate the output array so we can fill it in batches
272+
out = numpy.full(shape=self.face.size, fill_value=None, dtype=object)
273+
# Construct polygons row by row
274+
for j in range(self.face.shape[0]):
275+
points[:, 0, 0] = longitude[j + 0, :-1]
276+
points[:, 1, 0] = longitude[j + 0, +1:]
277+
points[:, 2, 0] = longitude[j + 1, +1:]
278+
points[:, 3, 0] = longitude[j + 1, :-1]
279+
280+
points[:, 0, 1] = latitude[j + 0, :-1]
281+
points[:, 1, 1] = latitude[j + 0, +1:]
282+
points[:, 2, 1] = latitude[j + 1, +1:]
283+
points[:, 3, 1] = latitude[j + 1, :-1]
284+
285+
j_slice = slice(j * i_size, (j + 1) * i_size)
286+
utils.make_polygons_with_holes(points, out=out[j_slice])
287+
288+
return out
276289

277290
@cached_property
278291
def face_centres(self) -> numpy.ndarray:

src/emsarray/conventions/grid.py

Lines changed: 42 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -413,39 +413,32 @@ def _make_polygons(self) -> numpy.ndarray:
413413
lon_bounds = self.topology.longitude_bounds.values
414414
lat_bounds = self.topology.latitude_bounds.values
415415

416-
# Make a bounds array as if this dataset had 2D coordinates.
417-
# 1D bounds are (lon, 2) and (lat, 2).
418-
# 2D bounds are (lat, lon, 4)
419-
# where the 4 points are (j-i, i-1), (j-1, i+1), (j+1, i+1), (j+1, i-1).
420-
# The bounds values are repeated as required, are given a new dimension,
421-
# then repeated along that new dimension.
422-
# They will come out as array with shape (y_size, x_size, 4)
423-
424-
lon_bounds_2d = numpy.stack([
425-
lon_bounds[:, 0],
426-
lon_bounds[:, 1],
427-
lon_bounds[:, 1],
428-
lon_bounds[:, 0],
429-
], axis=-1)
430-
lon_bounds_2d = numpy.broadcast_to(numpy.expand_dims(lon_bounds_2d, 0), (y_size, x_size, 4))
431-
432-
lat_bounds_2d = numpy.stack([
433-
lat_bounds[:, 0],
434-
lat_bounds[:, 0],
435-
lat_bounds[:, 1],
436-
lat_bounds[:, 1],
437-
], axis=-1)
438-
lat_bounds_2d = numpy.broadcast_to(numpy.expand_dims(lat_bounds_2d, 0), (x_size, y_size, 4))
439-
lat_bounds_2d = numpy.transpose(lat_bounds_2d, (1, 0, 2))
440-
441-
assert lon_bounds_2d.shape == lat_bounds_2d.shape == (y_size, x_size, 4)
442-
443-
# points is a (topology.size, 4, 2) array of the corners of each cell
444-
points = numpy.stack([lon_bounds_2d, lat_bounds_2d], axis=-1).reshape((-1, 4, 2))
445-
446-
polygons = utils.make_polygons_with_holes(points)
447-
448-
return polygons
416+
# Create the polygons batched by row.
417+
# The point array is copied by shapely before being used,
418+
# so this can accidentally use a whole bunch of memory for large datasets.
419+
# Creating them one by one is very slow but very memory efficient.
420+
# Creating the polygons in one batch is faster but uses up a huge amount of memory.
421+
# Batching them row by row is a decent compromise.
422+
out = numpy.full(shape=y_size * x_size, dtype=object, fill_value=None)
423+
424+
# By preallocating this array, we can copy data in to it to save on a number of allocations.
425+
chunk_points = numpy.empty(shape=(x_size, 4, 2), dtype=lon_bounds.dtype)
426+
# By chunking by row, the longitude bounds never change between loops
427+
chunk_points[:, 0, 0] = lon_bounds[:, 0]
428+
chunk_points[:, 1, 0] = lon_bounds[:, 1]
429+
chunk_points[:, 2, 0] = lon_bounds[:, 1]
430+
chunk_points[:, 3, 0] = lon_bounds[:, 0]
431+
432+
for row in range(y_size):
433+
chunk_points[:, 0, 1] = lat_bounds[row, 0]
434+
chunk_points[:, 1, 1] = lat_bounds[row, 0]
435+
chunk_points[:, 2, 1] = lat_bounds[row, 1]
436+
chunk_points[:, 3, 1] = lat_bounds[row, 1]
437+
438+
row_slice = slice(row * x_size, (row + 1) * x_size)
439+
utils.make_polygons_with_holes(chunk_points, out=out[row_slice])
440+
441+
return out
449442

450443
@cached_property
451444
def face_centres(self) -> numpy.ndarray:
@@ -597,13 +590,22 @@ def check_dataset(cls, dataset: xarray.Dataset) -> int | None:
597590

598591
def _make_polygons(self) -> numpy.ndarray:
599592
# Construct polygons from the bounds of the cells
600-
lon_bounds = self.topology.longitude_bounds.values
601-
lat_bounds = self.topology.latitude_bounds.values
602-
603-
# points is a (topology.size, 4, 2) array of the corners of each cell
604-
points = numpy.stack([lon_bounds, lat_bounds], axis=-1).reshape((-1, 4, 2))
605-
606-
return utils.make_polygons_with_holes(points)
593+
j_size, i_size = self.topology.shape
594+
lon_bounds = self.topology.longitude_bounds
595+
lat_bounds = self.topology.latitude_bounds
596+
597+
assert lon_bounds.shape == (j_size, i_size, 4)
598+
assert lat_bounds.shape == (j_size, i_size, 4)
599+
600+
chunk_points = numpy.empty(shape=(i_size, 4, 2), dtype=lon_bounds.dtype)
601+
out = numpy.full(shape=j_size * i_size, dtype=object, fill_value=None)
602+
for j in range(j_size):
603+
chunk_points[:, :, 0] = lon_bounds[j, :, :]
604+
chunk_points[:, :, 1] = lat_bounds[j, :, :]
605+
chunk_slice = slice(j * i_size, (j + 1) * i_size)
606+
utils.make_polygons_with_holes(chunk_points, out=out[chunk_slice])
607+
608+
return out
607609

608610
@cached_property
609611
def face_centres(self) -> numpy.ndarray:

src/emsarray/conventions/ugrid.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88
import enum
99
import logging
10+
import math
1011
import pathlib
1112
import warnings
1213
from collections import defaultdict
@@ -1115,10 +1116,15 @@ def _make_polygons(self) -> numpy.ndarray:
11151116
for unique_size in unique_sizes:
11161117
# Extract the face node data for every polygon of this size
11171118
indices = numpy.flatnonzero(polygon_sizes == unique_size)
1118-
nodes = numpy.ma.getdata(face_node)[indices, :unique_size]
1119-
coords = numpy.stack([node_x[nodes], node_y[nodes]], axis=-1)
1120-
# Generate the polygons directly in to their correct locations
1121-
shapely.polygons(coords, indices=indices, out=polygons)
1119+
chunk_size = 1000
1120+
chunk_count = math.ceil(len(indices) / chunk_size)
1121+
for chunk_index in range(chunk_count):
1122+
chunk_slice = slice(chunk_index * chunk_size, (chunk_index + 1) * chunk_size)
1123+
chunk_indices = indices[chunk_slice]
1124+
nodes = numpy.ma.getdata(face_node)[chunk_indices, :unique_size]
1125+
coords = numpy.stack([node_x[nodes], node_y[nodes]], axis=-1)
1126+
# Generate the polygons directly in to their correct locations
1127+
shapely.polygons(coords, indices=chunk_indices, out=polygons)
11221128

11231129
return polygons
11241130

tests/conventions/test_cfgrid1d.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import logging
23
import pathlib
34

45
import numpy
@@ -15,7 +16,11 @@
1516
CFGrid1D, CFGrid1DTopology, CFGridKind, CFGridTopology
1617
)
1718
from emsarray.operations import geometry
18-
from tests.utils import assert_property_not_cached, box, mask_from_strings
19+
from tests.utils import (
20+
assert_property_not_cached, box, mask_from_strings, track_peak_memory_usage
21+
)
22+
23+
logger = logging.getLogger(__name__)
1924

2025

2126
def make_dataset(
@@ -500,3 +505,17 @@ def test_topology():
500505
assert_allclose(
501506
latitude_bounds.values,
502507
0.1 * numpy.array([[i - 0.5, i + 0.5] for i in range(11)]))
508+
509+
510+
def test_make_polygon_memory_usage() -> None:
511+
width, height = 2000, 1000
512+
dataset = make_dataset(width=width, height=height)
513+
514+
with track_peak_memory_usage() as tracker:
515+
assert len(dataset.ems.polygons) == width * height
516+
517+
logger.info("current memory usage: %d, peak memory usage: %d", tracker.current, tracker.peak)
518+
519+
target = 135_000_000
520+
assert tracker.peak < target, "Peak memory allocation is too large"
521+
assert tracker.peak > target * 0.9, "Peak memory allocation is suspiciously small - did you improve things?"

tests/conventions/test_cfgrid2d.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88
import itertools
99
import json
10+
import logging
1011
import pathlib
1112

1213
import numpy
@@ -25,9 +26,12 @@
2526
from emsarray.operations import geometry
2627
from tests.utils import (
2728
AxisAlignedShocGrid, DiagonalShocGrid, ShocGridGenerator,
28-
ShocLayerGenerator, assert_property_not_cached, plot_geometry
29+
ShocLayerGenerator, assert_property_not_cached, plot_geometry,
30+
track_peak_memory_usage
2931
)
3032

33+
logger = logging.getLogger(__name__)
34+
3135

3236
def make_dataset(
3337
*,
@@ -497,3 +501,17 @@ def test_plot_on_figure() -> None:
497501
dataset.ems.plot_on_figure(figure, surface_temp)
498502

499503
assert len(figure.axes) == 2
504+
505+
506+
def test_make_polygon_memory_usage() -> None:
507+
j_size, i_size = 1000, 2000
508+
dataset = make_dataset(j_size=j_size, i_size=i_size)
509+
510+
with track_peak_memory_usage() as tracker:
511+
assert len(dataset.ems.polygons) == j_size * i_size
512+
513+
logger.info("current memory usage: %d, peak memory usage: %d", tracker.current, tracker.peak)
514+
515+
target = 300_000_000
516+
assert tracker.peak < target, "Peak memory allocation is too large"
517+
assert tracker.peak > target * 0.9, "Peak memory allocation is suspiciously small - did you improve things?"

tests/conventions/test_shoc_standard.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import itertools
22
import json
3+
import logging
34
import pathlib
45

56
import numpy
@@ -17,9 +18,12 @@
1718
from emsarray.conventions.shoc import ShocStandard
1819
from emsarray.operations import geometry
1920
from tests.utils import (
20-
DiagonalShocGrid, ShocGridGenerator, ShocLayerGenerator, mask_from_strings
21+
DiagonalShocGrid, ShocGridGenerator, ShocLayerGenerator, mask_from_strings,
22+
track_peak_memory_usage
2123
)
2224

25+
logger = logging.getLogger(__name__)
26+
2327

2428
def make_dataset(
2529
*,
@@ -657,3 +661,18 @@ def clip_values(values: numpy.ndarray) -> numpy.ndarray:
657661
assert clipped.ems.polygons[6] is None
658662
assert clipped.ems.polygons[7].equals_exact(original_polygons[7], 1e-6)
659663
assert clipped.ems.polygons[8] is None
664+
665+
666+
@pytest.mark.memory_usage
667+
def test_make_polygons_memory_usage():
668+
j_size, i_size = 1000, 2000
669+
dataset = make_dataset(j_size=j_size, i_size=i_size)
670+
671+
with track_peak_memory_usage() as tracker:
672+
assert len(dataset.ems.polygons) == j_size * i_size
673+
674+
logger.info("current memory usage: %d, peak memory usage: %d", tracker.current, tracker.peak)
675+
676+
target = 134_500_000
677+
assert tracker.peak < target, "Peak memory allocation is too large"
678+
assert tracker.peak > target * 0.9, "Peak memory allocation is suspiciously small - did you improve things?"

tests/conventions/test_ugrid.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import logging
23
import pathlib
34
import warnings
45

@@ -20,7 +21,11 @@
2021
ConventionViolationError, ConventionViolationWarning
2122
)
2223
from emsarray.operations import geometry
23-
from tests.utils import assert_property_not_cached, filter_warning
24+
from tests.utils import (
25+
assert_property_not_cached, filter_warning, track_peak_memory_usage
26+
)
27+
28+
logger = logging.getLogger(__name__)
2429

2530

2631
def make_faces(width: int, height, fill_value: int) -> tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray]:
@@ -981,3 +986,17 @@ def test_has_valid_face_edge_connectivity():
981986
dataset_fill_value_above_range['Mesh2_face_edges'].encoding['_FillValue'] = 89
982987

983988
assert dataset_fill_value_above_range.ems.topology.has_valid_face_edge_connectivity is True
989+
990+
991+
@pytest.mark.memory_usage
992+
def test_make_polygons_memory_usage():
993+
dataset = make_dataset(width=600, height=600)
994+
995+
with track_peak_memory_usage() as tracker:
996+
assert len(dataset.ems.polygons) == dataset.ems.topology.face_count
997+
998+
logger.info("current memory usage: %d, peak memory usage: %d", tracker.current, tracker.peak)
999+
1000+
target = 78_000_000
1001+
assert tracker.peak < target, "Peak memory allocation is too large"
1002+
assert tracker.peak > target * 0.9, "Peak memory allocation is suspiciously small - did you improve things?"

tests/utils.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
import contextlib
33
import importlib.metadata
44
import itertools
5+
import tracemalloc
56
import warnings
67
from collections.abc import Hashable
78
from functools import cached_property
8-
from typing import Any
9+
from types import TracebackType
10+
from typing import Any, Type
911

1012
import matplotlib.pyplot as plt
1113
import numpy
@@ -462,3 +464,41 @@ def plot_geometry(
462464
axes.set_extent(extent)
463465

464466
figure.savefig(out)
467+
468+
469+
class TracemallocTracker:
470+
_finished = False
471+
_usage = None
472+
473+
def __enter__(self):
474+
tracemalloc.start()
475+
return self
476+
477+
@property
478+
def current(self):
479+
if not self._finished:
480+
raise RuntimeError("Context manager has not exited yet")
481+
return self._usage[0]
482+
483+
@property
484+
def peak(self):
485+
if not self._finished:
486+
raise RuntimeError("Context manager has not exited yet")
487+
return self._usage[1]
488+
489+
def __exit__(
490+
self,
491+
exc_type: Type[Exception] | None,
492+
exc_value: Exception | None,
493+
exc_traceback: TracebackType | None,
494+
) -> bool | None:
495+
self._finished = True
496+
self._usage = tracemalloc.get_traced_memory()
497+
498+
tracemalloc.stop()
499+
500+
return None
501+
502+
503+
def track_peak_memory_usage():
504+
return TracemallocTracker()

0 commit comments

Comments
 (0)