Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 238 additions & 16 deletions benchmarks/bench_connectivity.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@
import urllib.request
from pathlib import Path

import numpy as np

import uxarray as ux

from .helpers._peakmem import peak_allocated

current_path = Path(os.path.dirname(os.path.realpath(__file__)))

grid_filename_480 = "oQU480.grid.nc"
Expand Down Expand Up @@ -63,79 +67,297 @@ def teardown(self, resolution, *args, **kwargs):
"node_face_connectivity",
]

# Direct prerequisites only, read off the ``_populate_*`` functions in
# ``uxarray/grid/connectivity.py``; accessing one builds its own in turn.
CONNECTIVITY_PREREQUISITES = {
"n_nodes_per_face": (),
"face_node_connectivity": (),
"edge_node_connectivity": ("n_nodes_per_face",),
# ``_populate_edge_node_connectivity`` writes this one out too, so it costs
# nothing once that has run.
"face_edge_connectivity": ("edge_node_connectivity",),
"node_edge_connectivity": ("edge_node_connectivity",),
"face_face_connectivity": ("edge_face_connectivity",),
"edge_face_connectivity": ("face_edge_connectivity",),
"node_face_connectivity": (),
}


def _build_prerequisites(uxgrid, connectivity):
"""Builds ``connectivity``'s prerequisites, so what follows measures one
construction routine rather than the whole chain rooted at it."""
for prerequisite in CONNECTIVITY_PREREQUISITES[connectivity]:
getattr(uxgrid, prerequisite)
return uxgrid


_numba_warmed_up = False

def _warmup(uxgrid):
def _warmup():
"""Compiles the Numba kernels backing each connectivity variable.

``_build_node_edge_connectivity`` is not disk-cached, so a fresh benchmark
process would otherwise charge ~240ms of JIT compilation to whichever sample
happened to touch it first.
Every kernel in ``uxarray/grid/connectivity.py`` is ``@njit(cache=True)``, so
this carries across processes through Numba's on-disk cache -- what makes it
usable from ``setup_cache``. Loading from that cache still allocates, so it
matters for ``track_peakmem_*`` too, not just timing.
"""
global _numba_warmed_up
if _numba_warmed_up:
return
# Resolution affects how long the kernels run, not which signatures compile.
uxgrid = ux.Grid.from_topology(*_source_topology(GridBenchmark.params[0][0]))
for name in CONNECTIVITY_NAMES:
getattr(uxgrid, name)
_numba_warmed_up = True


class Connectivity(GridBenchmark):
# Each connectivity variable is cached in ``Grid._ds`` once constructed, so a
# sample may only contain a single call; otherwise every call but the first
# would time a dictionary lookup.
number = 1
_topology_cache = {}

def setup(self, resolution, *args, **kwargs):
# The benchmark grids are MPAS meshes, which carry every connectivity
# variable on disk. Reading one would time the MPAS parser rather than
# the construction routines, so reduce the grid down to the minimal
# UGRID topology and let each variable be built on demand.

def _source_topology(resolution):
"""The minimal UGRID topology for ``resolution``, read once per process.

The benchmark grids are MPAS meshes carrying every connectivity variable on
disk; reading one would measure the MPAS parser rather than the construction
routines, so each variable is left to be built on demand.

Cached because asv re-runs ``setup`` between repeats, and at dyamond
resolutions re-reading the source grid dwarfs the sample it precedes.
"""
if resolution not in _topology_cache:
source_grid = ux.open_grid(file_path_dict[resolution])
self.topology = (
_topology_cache[resolution] = (
source_grid.node_lon.data,
source_grid.node_lat.data,
source_grid.face_node_connectivity.data,
)
return _topology_cache[resolution]


_warmup(self.minimal_grid())
class MinimalGridBenchmark(GridBenchmark):
"""Template for benchmarks that construct connectivity variables on demand.

Holds a ``Grid`` carrying nothing but the minimal UGRID topology, plus the
topology needed to mint further ones, and leaves the Numba kernels compiled.
"""

# Handover slot for ``_prerequisite_setup``; see its docstring.
active_grid = None

# asv's 60s default is not enough to build a connectivity variable at 3.75km.
timeout = 1200

def setup(self, resolution, *args, **kwargs):
self.topology = _source_topology(resolution)

_warmup()
self.uxgrid = self.minimal_grid()
MinimalGridBenchmark.active_grid = self.uxgrid

def minimal_grid(self):
"""Mints a ``Grid`` holding nothing beyond the minimal UGRID topology."""
return ux.Grid.from_topology(*self.topology)

def teardown(self, resolution, *args, **kwargs):
# Cleared so a per-benchmark setup raises rather than quietly measuring
# a stale grid.
MinimalGridBenchmark.active_grid = None
del self.uxgrid
del self.topology


def _prerequisite_setup(connectivity):
"""Builds a per-benchmark ``setup`` that puts ``connectivity``'s
prerequisites in place before the clock starts.

asv collects ``setup`` from the benchmark function as well as the class and
runs the class one first, but passes neither the instance, hence the handover
through ``MinimalGridBenchmark.active_grid``.
"""

def setup(resolution, *args, **kwargs):
_build_prerequisites(MinimalGridBenchmark.active_grid, connectivity)

return setup


class Connectivity(MinimalGridBenchmark):
"""Time to construct each connectivity variable.

Prerequisites are built during ``setup``, so a sample times the one routine
that produces that variable rather than the whole chain rooted at it --
matching how :class:`ConnectivityTracemalloc` attributes memory.
"""

number = 1
warmup_time = 0

def time_n_nodes_per_face(self, resolution):
_ = self.uxgrid.n_nodes_per_face.compute()

time_n_nodes_per_face.setup = _prerequisite_setup("n_nodes_per_face")

def time_face_node(self, resolution):
_ = self.uxgrid.face_node_connectivity.compute()

time_face_node.setup = _prerequisite_setup("face_node_connectivity")

def time_edge_node(self, resolution):
_ = self.uxgrid.edge_node_connectivity.compute()

time_edge_node.setup = _prerequisite_setup("edge_node_connectivity")

# TODO: Not yet supported?
# def time_node_node(self, resolution):
# _ = self.uxgrid.node_node_connectivity

def time_face_edge(self, resolution):
_ = self.uxgrid.face_edge_connectivity.compute()

time_face_edge.setup = _prerequisite_setup("face_edge_connectivity")

# TODO: Not yet supported?
# def time_edge_edge(self, resolution):
# _ = self.uxgrid.edge_edge_connectivity

def time_node_edge(self, resolution):
_ = self.uxgrid.node_edge_connectivity.compute()

time_node_edge.setup = _prerequisite_setup("node_edge_connectivity")

def time_face_face(self, resolution):
_ = self.uxgrid.face_face_connectivity.compute()

time_face_face.setup = _prerequisite_setup("face_face_connectivity")

def time_edge_face(self, resolution):
_ = self.uxgrid.edge_face_connectivity.compute()

time_edge_face.setup = _prerequisite_setup("edge_face_connectivity")

def time_node_face(self, resolution):
_ = self.uxgrid.node_face_connectivity.compute()

time_node_face.setup = _prerequisite_setup("node_face_connectivity")


class ConnectivityTracemalloc(MinimalGridBenchmark):
"""Peak memory of each connectivity routine on its own.

The transient high-water allocation of the construction routine, with the
~245MB the process already holds excluded.
"""

unit = "bytes"

def _peak_building(self, name):
"""Peak allocation of ``name``'s own construction routine."""
uxgrid = _build_prerequisites(self.minimal_grid(), name)
return peak_allocated(lambda: getattr(uxgrid, name).compute())

def track_peakmem_n_nodes_per_face(self, resolution):
return self._peak_building("n_nodes_per_face")

def track_peakmem_face_node(self, resolution):
return self._peak_building("face_node_connectivity")

def track_peakmem_edge_node(self, resolution):
return self._peak_building("edge_node_connectivity")

def track_peakmem_face_edge(self, resolution):
return self._peak_building("face_edge_connectivity")

def track_peakmem_node_edge(self, resolution):
return self._peak_building("node_edge_connectivity")

def track_peakmem_face_face(self, resolution):
return self._peak_building("face_face_connectivity")

def track_peakmem_edge_face(self, resolution):
return self._peak_building("edge_face_connectivity")

def track_peakmem_node_face(self, resolution):
return self._peak_building("node_face_connectivity")


def _save_topology(uxgrid, npz_path):
"""Writes the minimal UGRID topology of ``uxgrid`` out to ``npz_path``.

Compressed because ``setup_cache`` writes into a ``tempfile.mkdtemp()`` and
``face_node_connectivity`` alone is multi-GB at 3.75km.
"""
np.savez_compressed(
npz_path,
node_lon=uxgrid.node_lon.data,
node_lat=uxgrid.node_lat.data,
face_node_connectivity=uxgrid.face_node_connectivity.data,
)


def _load_topology(npz_path):
"""Builds a ``Grid`` holding nothing beyond the minimal UGRID topology."""
with np.load(npz_path) as topology:
return ux.Grid.from_topology(
topology["node_lon"],
topology["node_lat"],
topology["face_node_connectivity"],
)


class ConnectivityChainRss:
"""Peak resident memory of the process while constructing each connectivity
variable.

Differs from :class:`ConnectivityTracemalloc` on two axes. Scope: no
prerequisites are built in ``setup``, so a sample covers
the whole chain rooted at that variable. Instrument: ``ru_maxrss`` for the
whole process, so the ~250MB import.
"""

# Only the parameterization is shared with ``MinimalGridBenchmark``
param_names = GridBenchmark.param_names
params = GridBenchmark.params
timeout = 1200

def setup_cache(self):
topology_paths = {}
for resolution in self.params[0]:
npz_path = os.path.abspath(f"topology_{resolution}.npz")
_save_topology(ux.open_grid(file_path_dict[resolution]), npz_path)
topology_paths[resolution] = npz_path

_warmup()

return topology_paths

setup_cache.timeout = 1800

def setup(self, topology_paths, resolution):
self.uxgrid = _load_topology(topology_paths[resolution])

def teardown(self, topology_paths, resolution):
del self.uxgrid

def peakmem_n_nodes_per_face(self, topology_paths, resolution):
_ = self.uxgrid.n_nodes_per_face.compute()

def peakmem_face_node(self, topology_paths, resolution):
_ = self.uxgrid.face_node_connectivity.compute()

def peakmem_edge_node(self, topology_paths, resolution):
_ = self.uxgrid.edge_node_connectivity.compute()

def peakmem_face_edge(self, topology_paths, resolution):
_ = self.uxgrid.face_edge_connectivity.compute()

def peakmem_node_edge(self, topology_paths, resolution):
_ = self.uxgrid.node_edge_connectivity.compute()

def peakmem_face_face(self, topology_paths, resolution):
_ = self.uxgrid.face_face_connectivity.compute()

def peakmem_edge_face(self, topology_paths, resolution):
_ = self.uxgrid.edge_face_connectivity.compute()

def peakmem_node_face(self, topology_paths, resolution):
_ = self.uxgrid.node_face_connectivity.compute()
2 changes: 1 addition & 1 deletion uxarray/grid/connectivity.py
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,7 @@ def _populate_node_edge_connectivity(grid):
)


@njit
@njit(cache=True)
def _build_node_edge_connectivity(edge_nodes, n_node):
"""Constructs the Node Edge Connectivity, which stores the indices of the edges that are shared by each node."""
n_edge, nodes_per_edge = edge_nodes.shape
Expand Down