From cae178c33d46a9314c8093bec7349d5b90bade69 Mon Sep 17 00:00:00 2001 From: Jussi Leinonen Date: Tue, 20 May 2025 08:18:08 -0700 Subject: [PATCH 1/4] Add distributed inference tools and recipe --- earth2studio/data/gfs.py | 423 ++++++--------- earth2studio/utils/distributed.py | 233 ++++++++ recipes/distributed/.gitignore | 2 + recipes/distributed/README.md | 100 ++++ recipes/distributed/cfg/diagnostic.yaml | 6 + recipes/distributed/main.py | 66 +++ recipes/distributed/pyproject.toml | 27 + recipes/distributed/requirements.txt | 510 ++++++++++++++++++ .../distributed/src/diagnostic_distributed.py | 210 ++++++++ recipes/distributed/test/.gitignore | 0 recipes/distributed/test/Makefile | 3 + recipes/distributed/test/README.md | 16 + 12 files changed, 1328 insertions(+), 268 deletions(-) create mode 100644 earth2studio/utils/distributed.py create mode 100644 recipes/distributed/.gitignore create mode 100644 recipes/distributed/README.md create mode 100644 recipes/distributed/cfg/diagnostic.yaml create mode 100644 recipes/distributed/main.py create mode 100644 recipes/distributed/pyproject.toml create mode 100644 recipes/distributed/requirements.txt create mode 100644 recipes/distributed/src/diagnostic_distributed.py create mode 100644 recipes/distributed/test/.gitignore create mode 100644 recipes/distributed/test/Makefile create mode 100644 recipes/distributed/test/README.md diff --git a/earth2studio/data/gfs.py b/earth2studio/data/gfs.py index 4c6e02a62..00eb419c1 100644 --- a/earth2studio/data/gfs.py +++ b/earth2studio/data/gfs.py @@ -14,24 +14,20 @@ # See the License for the specific language governing permissions and # limitations under the License. -import asyncio -import functools import hashlib import os import pathlib import shutil import warnings -from collections.abc import Callable -from dataclasses import dataclass -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta -import nest_asyncio import numpy as np import s3fs import xarray as xr from fsspec.implementations.ftp import FTPFileSystem from loguru import logger -from tqdm.asyncio import tqdm +from s3fs.core import S3FileSystem +from tqdm import tqdm from earth2studio.data.utils import ( datasource_cache_root, @@ -45,17 +41,6 @@ logger.add(lambda msg: tqdm.write(msg, end=""), colorize=True) -@dataclass -class GFSAsyncTask: - """Small helper struct for Async tasks""" - - data_array_indices: tuple[int, int, int] - gfs_file_uri: str - gfs_byte_offset: int - gfs_byte_length: int - gfs_modifier: Callable - - class GFS: """The global forecast service (GFS) initial state data source provided on an equirectangular grid. GFS is a weather forecast model developed by NOAA. This data @@ -70,9 +55,6 @@ class GFS: Cache data source on local memory, by default True verbose : bool, optional Print download progress, by default True - async_timeout : int, optional - Time in sec after which download will be cancelled if not finished successfully, - by default 600 Warning ------- @@ -99,13 +81,7 @@ class GFS: GFS_LAT = np.linspace(90, -90, 721) GFS_LON = np.linspace(0, 359.75, 1440) - def __init__( - self, - source: str = "aws", - cache: bool = True, - verbose: bool = True, - async_timeout: int = 600, - ): + def __init__(self, source: str = "aws", cache: bool = True, verbose: bool = True): self._cache = cache self._verbose = verbose @@ -114,7 +90,7 @@ def __init__( if source == "aws": self.uri_prefix = "noaa-gfs-bdp-pds" - self.fs = s3fs.S3FileSystem(anon=True, client_kwargs={}, asynchronous=True) + self.fs = s3fs.S3FileSystem(anon=True, client_kwargs={}) # To update search "gfs." at https://noaa-gfs-bdp-pds.s3.amazonaws.com/index.html # They are slowly adding more data @@ -141,48 +117,13 @@ def _range(time: datetime) -> None: else: raise ValueError(f"Invalid GFS source {source}") - self.async_timeout = async_timeout - def __call__( self, time: datetime | list[datetime] | TimeArray, variable: str | list[str] | VariableArray, ) -> xr.DataArray: - """Retrieve GFS initial state / analysis data - - Parameters - ---------- - time : datetime | list[datetime] | TimeArray - Timestamps to return data for (UTC). - variable : str | list[str] | VariableArray - String, list of strings or array of strings that refer to variables to - return. Must be in the GFS lexicon. - - Returns - ------- - xr.DataArray - GFS weather data array - """ - nest_asyncio.apply() # Patch asyncio to work in notebooks - try: - loop = asyncio.get_event_loop() - except RuntimeError: - # If no event loop exists, create one - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - xr_array = loop.run_until_complete( - asyncio.wait_for(self.fetch(time, variable), timeout=self.async_timeout) - ) - - return xr_array - - async def fetch( - self, - time: datetime | list[datetime] | TimeArray, - variable: str | list[str] | VariableArray, - ) -> xr.DataArray: - """Async function to get data + """Retrieve GFS initial data to be used for initial conditions for the given + time, variable information, and optional history. Parameters ---------- @@ -198,133 +139,59 @@ async def fetch( GFS weather data array """ time, variable = prep_data_inputs(time, variable) + # Create cache dir if doesnt exist pathlib.Path(self.cache).mkdir(parents=True, exist_ok=True) # Make sure input time is valid self._validate_time(time) - # Note, this could be more memory efficient and avoid pre-allocation of the array - # but this is much much cleaner to deal with, compared to something seen in the - # NCAR data source. - xr_array = xr.DataArray( - data=np.zeros( - (len(time), 1, len(variable), len(self.GFS_LAT), len(self.GFS_LON)) - ), - dims=["time", "lead_time", "variable", "lat", "lon"], - coords={ - "time": time, - "lead_time": [timedelta(hours=0)], - "variable": variable, - "lat": self.GFS_LAT, - "lon": self.GFS_LON, - }, - ) - - async_tasks = [] - async_tasks = await self._create_tasks(time, [timedelta(hours=0)], variable) - func_map = map( - functools.partial(self.fetch_wrapper, xr_array=xr_array), async_tasks - ) - - await tqdm.gather( - *func_map, desc="Fetching GFS data", disable=(not self._verbose) - ) + # Fetch index file for requested time + # Should really async this stuff + data_arrays = [] + for t0 in time: + data_array = self.fetch_dataarray(t0, variable) + data_arrays.append(data_array) # Delete cache if needed if not self._cache: shutil.rmtree(self.cache) - return xr_array.isel(lead_time=0) + return xr.concat(data_arrays, dim="time") - async def _create_tasks( - self, time: list[datetime], lead_time: list[timedelta], variable: list[str] - ) -> list[GFSAsyncTask]: - """Create download tasks, each corresponding to one grib byte range on S3 + def fetch_dataarray( + self, + time: datetime, + variables: list[str], + ) -> xr.DataArray: + """Retrives GFS initial state data array for given date time Parameters ---------- - times : list[datetime] - Timestamps to be downloaded (UTC). + time : datetime + Date time to fetch variables : list[str] - List of variables to be downloaded. + List of atmosphric variables to fetch. Must be supported in GFS lexicon Returns ------- - list[dict] - List of download tasks - """ - tasks: list[GFSAsyncTask] = [] # group pressure-level variables + xr.DataArray + GFS data array for given date time - # Start with fetching all index files for each time / lead time - args = [self._grib_index_uri(t, lt) for t in time for lt in lead_time] - func_map = map(self._fetch_index, args) - results = await tqdm.gather( - *func_map, desc="Fetching GFS index files", disable=True - ) - for i, t in enumerate(time): - for j, lt in enumerate(lead_time): - # Get index file dictionary - index_file = results.pop(0) - for k, v in enumerate(variable): - try: - gfs_name, modifier = GFSLexicon[v] - except KeyError: - logger.warning( - f"variable id {variable} not found in GFS lexicon, good luck" - ) - gfs_name = v - - def modifier(x: np.array) -> np.array: - """Modify data (if necessary).""" - return x - - byte_offset = None - byte_length = None - for key, value in index_file.items(): - if gfs_name in key: - byte_offset = value[0] - byte_length = value[1] - break - - if byte_length is None or byte_offset is None: - logger.warning( - f"Variable {v} not found in index file for time {t} at {lt}, values will be unset" - ) - continue - - tasks.append( - GFSAsyncTask( - data_array_indices=(i, j, k), - gfs_file_uri=self._grib_uri(t, lt), - gfs_byte_offset=byte_offset, - gfs_byte_length=byte_length, - gfs_modifier=modifier, - ) - ) - return tasks + Raises + ------ + KeyError + Un supported variable. + """ + da = self._fetch_gfs_dataarray(time, timedelta(hours=0), variables) + return da.isel(lead_time=0) - async def fetch_wrapper( + def _fetch_gfs_dataarray( self, - task: GFSAsyncTask, - xr_array: xr.DataArray, + time: datetime, + lead_time: timedelta, + variables: list[str], ) -> xr.DataArray: - """Small wrapper to pack arrays into the DataArray""" - out = await self.fetch_array( - task.gfs_file_uri, - task.gfs_byte_offset, - task.gfs_byte_length, - task.gfs_modifier, - ) - xr_array[*task.data_array_indices] = out - - async def fetch_array( - self, - grib_uri: str, - byte_offset: int, - byte_length: int, - modifier: Callable, - ) -> np.ndarray: """Fetch GFS data array. This will first fetch the index file to get byte range of the needed data, fetch the respective grib files and lastly combining grib files into single data array. @@ -343,18 +210,68 @@ async def fetch_array( xr.DataArray FS data array for given time and lead time """ - logger.debug(f"Fetching GRS grib file: {grib_uri} {byte_offset}-{byte_length}") - # Download the grib file to cache - grib_file = await self._fetch_remote_file( - grib_uri, - byte_offset=byte_offset, - byte_length=byte_length, - ) - # Open into xarray data-array - da = xr.open_dataarray( - grib_file, engine="cfgrib", backend_kwargs={"indexpath": ""} + logger.debug(f"Fetching GFS index file: {time} lead {lead_time}") + index_file = self._fetch_index(self._grib_index_uri(time, lead_time)) + + gfsda = xr.DataArray( + data=np.empty((1, 1, len(variables), len(self.GFS_LAT), len(self.GFS_LON))), + dims=["time", "lead_time", "variable", "lat", "lon"], + coords={ + "time": [time], + "lead_time": [lead_time], + "variable": variables, + "lat": self.GFS_LAT, + "lon": self.GFS_LON, + }, ) - return modifier(da.values) + + # TODO: Add MP here + for i, variable in enumerate( + tqdm( + variables, desc=f"Fetching GFS for {time}", disable=(not self._verbose) + ) + ): + # Convert from Earth2Studio variable ID to GFS id and modifier + # sphinx - lexicon start + try: + gfs_name, modifier = GFSLexicon[variable] + except KeyError: + logger.warning( + f"variable id {variable} not found in GFS lexicon, good luck" + ) + gfs_name = variable + + def modifier(x: np.array) -> np.array: + """Modify data (if necessary).""" + return x + + byte_offset = None + byte_length = None + for key, value in index_file.items(): + if gfs_name in key: + byte_offset = value[0] + byte_length = value[1] + break + + if byte_offset is None: + raise KeyError(f"Could not find variable {gfs_name} in index file") + # Download the grib file to cache + logger.debug( + f"Fetching GFS grib file for variable: {variable} at {time}_{lead_time}" + ) + grib_file = self._fetch_remote_file( + self._grib_uri(time, lead_time), + byte_offset=byte_offset, + byte_length=byte_length, + ) + # Open into xarray data-array + da = xr.open_dataarray( + grib_file, engine="cfgrib", backend_kwargs={"indexpath": ""} + ) + gfsda[0, 0, i] = modifier(da.values) + # sphinx - lexicon end + + return gfsda def _validate_time(self, times: list[datetime]) -> None: """Verify if date time is valid for GFS based on offline knowledge @@ -372,7 +289,7 @@ def _validate_time(self, times: list[datetime]) -> None: # Check history range for given source self._history_range(time) - async def _fetch_index(self, index_uri: str) -> dict[str, tuple[int, int]]: + def _fetch_index(self, index_uri: str) -> dict[str, tuple[int, int]]: """Fetch GFS atmospheric index file Parameters @@ -386,8 +303,7 @@ async def _fetch_index(self, index_uri: str) -> dict[str, tuple[int, int]]: Dictionary of GFS vairables (byte offset, byte length) """ # Grab index file - # TODO: Change remote file to be more proper fetch - index_file = await self._fetch_remote_file(index_uri) + index_file = self._fetch_remote_file(index_uri) with open(index_file) as file: index_lines = [line.rstrip() for line in file] @@ -412,8 +328,8 @@ async def _fetch_index(self, index_uri: str) -> dict[str, tuple[int, int]]: # Pop place holder return index_table - async def _fetch_remote_file( - self, path: str, byte_offset: int = 0, byte_length: int | None = None + def _fetch_remote_file( + self, path: str, byte_offset: int = 0, byte_length: int = None ) -> str: """Fetches remote file into cache""" sha = hashlib.sha256((path + str(byte_offset)).encode()) @@ -421,16 +337,9 @@ async def _fetch_remote_file( cache_path = os.path.join(self.cache, filename) if not pathlib.Path(cache_path).is_file(): - if self.fs.async_impl: - if byte_length: - byte_length = int(byte_offset + byte_length) - data = await self.fs._cat_file(path, start=byte_offset, end=byte_length) - else: - data = await asyncio.to_thread( - self.fs.read_block, path, offset=byte_offset, length=byte_length - ) + data = self.fs.read_block(path, offset=byte_offset, length=byte_length) with open(cache_path, "wb") as file: - await asyncio.to_thread(file.write, data) + file.write(data) return cache_path @@ -481,9 +390,16 @@ def available( if isinstance(time, np.datetime64): # np.datetime64 -> datetime _unix = np.datetime64(0, "s") _ds = np.timedelta64(1, "s") - time = datetime.fromtimestamp((time - _unix) / _ds, timezone.utc) + time = datetime.utcfromtimestamp((time - _unix) / _ds) + + # Offline checks + # try: + # cls._validate_time([time]) + # except ValueError: + # return False + + fs = S3FileSystem(anon=True) - fs = s3fs.S3FileSystem(anon=True) # Object store directory for given time # Should contain two keys: atmos and wave file_name = f"gfs.{time.year}{time.month:0>2}{time.day:0>2}/{time.hour:0>2}/" @@ -529,45 +445,6 @@ def __call__( # type: ignore[override] ) -> xr.DataArray: """Retrieve GFS forecast data - Parameters - ---------- - time : datetime | list[datetime] | TimeArray - Timestamps to return data for (UTC). - lead_time: timedelta | list[timedelta] | LeadTimeArray - Forecast lead times to fetch. - variable : str | list[str] | VariableArray - String, list of strings or array of strings that refer to variables to - return. Must be in the GFS lexicon. - - Returns - ------- - xr.DataArray - GFS weather data array - """ - nest_asyncio.apply() # Patch asyncio to work in notebooks - try: - loop = asyncio.get_event_loop() - except RuntimeError: - # If no event loop exists, create one - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - xr_array = loop.run_until_complete( - asyncio.wait_for( - self.fetch(time, lead_time, variable), timeout=self.async_timeout - ) - ) - - return xr_array - - async def fetch( # type: ignore[override] - self, - time: datetime | list[datetime] | TimeArray, - lead_time: timedelta | list[timedelta] | LeadTimeArray, - variable: str | list[str] | VariableArray, - ) -> xr.DataArray: - """Async function to get data - Parameters ---------- time : datetime | list[datetime] | TimeArray @@ -584,6 +461,7 @@ async def fetch( # type: ignore[override] GFS weather data array """ time, lead_time, variable = prep_forecast_inputs(time, lead_time, variable) + # Create cache dir if doesnt exist pathlib.Path(self.cache).mkdir(parents=True, exist_ok=True) @@ -591,44 +469,53 @@ async def fetch( # type: ignore[override] self._validate_time(time) self._validate_leadtime(lead_time) - # Note, this could be more memory efficient and avoid pre-allocation of the array - # but this is much much cleaner to deal with, compared to something seen in the - # NCAR data source. - xr_array = xr.DataArray( - data=np.zeros( - ( - len(time), - len(lead_time), - len(variable), - len(self.GFS_LAT), - len(self.GFS_LON), - ) - ), - dims=["time", "lead_time", "variable", "lat", "lon"], - coords={ - "time": time, - "lead_time": lead_time, - "variable": variable, - "lat": self.GFS_LAT, - "lon": self.GFS_LON, - }, - ) + # Fetch index file for requested time + # Should really async this stuff + data_arrays = [] + for t0 in time: + lead_arrays = [] + for l0 in lead_time: + data_array = self.fetch_dataarray(t0, l0, variable) + lead_arrays.append(data_array) - async_tasks = [] - async_tasks = await self._create_tasks(time, [timedelta(hours=0)], variable) - func_map = map( - functools.partial(self.fetch_wrapper, xr_array=xr_array), async_tasks - ) - - await tqdm.gather( - *func_map, desc="Fetching GFS data", disable=(not self._verbose) - ) + data_arrays.append(xr.concat(lead_arrays, dim="lead_time")) # Delete cache if needed if not self._cache: shutil.rmtree(self.cache) - return xr_array + return xr.concat(data_arrays, dim="time") + + def fetch_dataarray( # type: ignore[override] + self, + time: datetime, + lead_time: timedelta, + variables: list[str], + ) -> xr.DataArray: + """Retrives GFS data array for given date time by fetching the index file, + fetching variable grib files and lastly combining grib files into single data + array. + + Parameters + ---------- + time : datetime + Date time to fetch + lead_time : timedelta + Forecast lead time to fetch + variables : list[str] + List of atmosphric variables to fetch. Must be supported in GFS lexicon + + Returns + ------- + xr.DataArray + GFS data array for given date time + + Raises + ------ + KeyError + Un supported variable. + """ + return self._fetch_gfs_dataarray(time, lead_time, variables) @classmethod def _validate_leadtime(cls, lead_times: list[timedelta]) -> None: diff --git a/earth2studio/utils/distributed.py b/earth2studio/utils/distributed.py new file mode 100644 index 000000000..c958c689c --- /dev/null +++ b/earth2studio/utils/distributed.py @@ -0,0 +1,233 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024-2025 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from collections.abc import Callable, Generator +from concurrent.futures import ThreadPoolExecutor +from queue import Queue +from typing import Any, Literal + +import torch +from physicsnemo.distributed import DistributedManager +from torch.distributed import rpc + + +class DistributedInference: + """Inference a model on remote GPUs. + + DistributedInference can be used to inference a model on one or more remote GPUs + (i.e. GPUs on other ranks of the distributed environment). The user can pass data to the + remote models by calling the DistributedInference object. The input is automatically + queued and passed to the next available remote GPU. The calls are asynchronous and the + results can be obtained by iterating over the `results` method. + + Parameters + ---------- + model : Type[Callable] + The model to initialize on remote GPUs. + + This must be implemented as a callable object that has, at a minimum, a `forward` + method that takes a tensor of input data and returns a tensor of output data. + + It can also have an __init__ constructor; this is called on each remote process + when the DistributedInference is instantiated. The constructor can be used + to load the model on the remote GPU and for other initialization. + + The model can also have other methods that can be called remotely using the + `call_func` method of DistributedInference. This can be used e.g. to get information + from the remote models to the main process. + *args : + Positional arguments to pass to the model constructor. + remote_ranks : list[int] | None, optional + The ranks of the remote GPUs to initialize the model on. If not provided, the model + will be initialized on all other ranks found in the distributed environment. + **kwargs : + Keyword arguments to pass to the model constructor. + """ + + @staticmethod + def initialize() -> None: + """Initialize the DistributedInference interface. + + This function must be called before instantiating any DistributedInference objects, + typically at the beginning of an inference script. + """ + DistributedManager.initialize() + dist = DistributedManager() + + options = rpc.TensorPipeRpcBackendOptions(num_worker_threads=128) + + # build device map + local_device = str(dist.device) + (local_device_type, local_device_id) = local_device.split(":") + if local_device_type != "cuda": + raise ValueError("Only CUDA devices are supported.") + local_device_num = int(local_device_id) + device_num_list = [ + torch.empty(1, dtype=torch.int64, device=dist.device) + for _ in range(dist.world_size) + ] + # gather device numbers from each worker + local_device_num = torch.tensor( + [local_device_num], dtype=torch.int64, device=dist.device + ) + torch.distributed.all_gather(device_num_list, local_device_num) + + for rank in range(dist.world_size): + if rank == dist.rank: + continue + remote_device_num = int(device_num_list[rank][0]) + remote_device = f"cuda:{remote_device_num}" + options.set_device_map(f"worker{rank}", {local_device: remote_device}) + + rpc.init_rpc( + f"worker{dist.rank}", + rank=dist.rank, + world_size=dist.world_size, + rpc_backend_options=options, + ) + + @staticmethod + def finalize() -> None: + """Shut down the DistributedInference interface. + + This function must be called, typically at the end of an inference script, + to ensure that the ranks hosting remote models do not shut down prematurely. + """ + rpc.shutdown() + + def __init__( + self, + model: type, + *args: Any, + remote_ranks: list[int] | None = None, + **kwargs: Any, + ): + self.dist = DistributedManager() + if remote_ranks is None: # select all other ranks + remote_ranks = list(range(self.dist.world_size)) + del remote_ranks[self.dist.rank] + self.remote_ranks = remote_ranks + self.available_remotes: Queue[int] = Queue(len(remote_ranks)) + self.out_queue: Queue[Any] = Queue(len(remote_ranks)) + + # initialize remote models + self.remote_models = { + rank: rpc.remote(f"worker{rank}", model, args=args, kwargs=kwargs) + for rank in remote_ranks + } + # initialize queue of available remotes + for rank in remote_ranks: + self.available_remotes.put(rank) + + def call_func( + self, func: str, *args: Any, rank: int | Literal["all"] = "all", **kwargs: Any + ) -> Any: + """Call a member function of the remote model. + + This can be used e.g. to get information from the model or to set parameters. + + Parameters + ---------- + func : str + The name of the member function to call. + *args : + Additional positional arguments to pass to the function. + rank : int | Literal["all"], optional + The rank of the remote GPU to call the function on. If "all", the function + will be called on all remote GPUs. + **kwargs : + Additional keyword arguments to pass to the function. + + Returns + ------- + The result of the function call. If `rank` is "all", a list of results from all + remote GPUs is returned. + """ + if rank == "all": + result = [ + self.call_func(func, *args, rank=rank, **kwargs) + for rank in self.remote_ranks + ] + return result + + rm = self.remote_models[rank] + remote_func = getattr(rm.rpc_sync(), func) + return remote_func(*args, **kwargs) + + def __call__(self, *args: Any, **kwargs: Any) -> None: + """Inference the remote model asynchronously. + + This will block until a remote model is available to accept the inputs. + + Parameters + ---------- + *args : + Positional arguments to pass to the model `forward` method. + **kwargs : + Keyword arguments to pass to the model `forward` method. + """ + + # get a remote model from the queue (will block until one is available) + rank = self.available_remotes.get() + rm = self.remote_models[rank] + torch.cuda.synchronize(device=self.dist.device) + task = rm.rpc_async().forward(*args, **kwargs) + + def callback( + completed_task: torch.futures.Future, + ) -> None: # called when the inference is finished + result = completed_task.value() + torch.cuda.synchronize( + device=self.dist.device + ) # necessary to ensure result is usable + self.out_queue.put(result) + self.available_remotes.put(rank) + + task.then(callback) + + def wait(self) -> None: + """Wait for all inference tasks to finish.""" + + for _ in range(len(self.remote_ranks)): + self.available_remotes.get() + self.out_queue.put(None) # signal that the inference is finished + for rank in self.remote_ranks: + self.available_remotes.put(rank) + + def results(self) -> Generator[Any, None, None]: + """Get the results of the inference tasks. + + This method will yield results until all inference tasks have finished. The results + may arrive out of order with respect to the inference calls. + """ + while (result := self.out_queue.get()) is not None: + yield result + + +def local_concurrent_pipeline(tasks: list[Callable]) -> None: + """Run a list of tasks concurrently on the local machine. + + This can be used to set up different stages of a distributed inference pipeline. + It will block until all tasks have finished. + + Parameters + ---------- + tasks : list[Callable] + A list of tasks to run concurrently. + """ + with ThreadPoolExecutor(max_workers=len(tasks)) as executor: + for task in tasks: + executor.submit(task) diff --git a/recipes/distributed/.gitignore b/recipes/distributed/.gitignore new file mode 100644 index 000000000..869c63791 --- /dev/null +++ b/recipes/distributed/.gitignore @@ -0,0 +1,2 @@ +uv.lock +outputs* diff --git a/recipes/distributed/README.md b/recipes/distributed/README.md new file mode 100644 index 000000000..23dc99a90 --- /dev/null +++ b/recipes/distributed/README.md @@ -0,0 +1,100 @@ +# Earth2Studio Distributed Inference Recipe + +This recipe shows how to use the `DistributedInference` interface to distribute inference workloads +to multiple GPUs in a distributed computing environment (e.g. `torchrun` or MPI). + +## Prerequisites + +### Software + +Installing Earth2Studio and [Hydra](https://hydra.cc/docs/intro/) is sufficient for running the +recipe. The commands below in Quick Start will install a tested environment. + +### Hardware + +- GPUs: Any type with >= 20 GB memory, at least 2 GPUs required to run the recipe +- Storage: A few GB to store inference results and model checkpoints. + +## Quick Start + +### Installation + +Installing Earth2Studio is generally a sufficient prerequisite to use this recipe. The support +for models used by the recipe must be included in the installation. For the diagnostic model +example, this means installing Earth2Studio with + +```bash +pip install earth2studio[fcn,precip-afno] +``` + +To install a full tested environment, you can use pip: + +```bash +pip install -r requirements.txt +``` + +or set up a uv virtual environment: + +```bash +uv sync +``` + +### Test distributed inference + +Start an environment with at least 2 GPUs available. The run the distributed diagnostic model +example, substituting with the number of GPUs you have: + +```bash +# if you installed a uv environment +uv run torchrun --standalone --nnodes=1 --nproc-per-node= main.py --config-name=diagnostic.yaml + +# using default python +torchrun --standalone --nnodes=1 --nproc-per-node= main.py --config-name=diagnostic.yaml +``` + +## Documentation + +### Using the recipes + +Specify the recipe you want to run using the `--config-name` command line argument to `main.py`. +This is used to select the relevant function in `main.py`. Currently, only `diagnostic.yaml` is +provided; more recipes will be added later. + +The configuration of the recipes is managed with Hydra using YAML config files located in the +`cfg` directory. You can override default options by editing the config file, or from the command +line using the Hydra syntax: for example, to save the diagnostic model recipe output to +`output_file.zarr`: + +```bash +torchrun --standalone --nnodes=1 --nproc-per-node= main.py\ + --config-name=diagnostic.yaml ++parameters.output_path=output_file.zarr +``` + +### Supported distribution methods + +In a single-node environment, we recommend using `torchrun`. + +`DistributedInference` should also work with any distribution method supported by the +[`DistributedManager`](https://docs.nvidia.com/deeplearning/physicsnemo/physicsnemo-core/api/physicsnemo.distributed.html) +of PhysicsNeMo. The startup commands will need to be modified to the distribution. For instance, +an MPI job using 2 GPUs on a single node can be started with Slurm using a script: + +```bash +cd /recipes/distributed/ +mpirun --allow-run-as-root python main.py --config-name=diagnostic.yaml +``` + +which can then be launched with +`srun --nodes=1 --ntasks-per-node=2 --gpus-per-node=2 `, +replacing `` with the path where Earth2Studio is located and `` +with the startup script path. + +### Creating custom applications + +To create custom applications using `DistributedInference`, you can use the provided recipes as a +starting point. + +## References + +- [PyTorch TensorPipe CUDA RPC](https://docs.pytorch.org/tutorials/recipes/cuda_rpc.html), the +PyTorch feature used to implement `DistributedInference`. diff --git a/recipes/distributed/cfg/diagnostic.yaml b/recipes/distributed/cfg/diagnostic.yaml new file mode 100644 index 000000000..504e5cdd5 --- /dev/null +++ b/recipes/distributed/cfg/diagnostic.yaml @@ -0,0 +1,6 @@ +recipe: "diagnostic" + +parameters: + time: "2023-06-01T00:00:00" + nsteps: 12 + output_path: diagnostic_distributed.zarr diff --git a/recipes/distributed/main.py b/recipes/distributed/main.py new file mode 100644 index 000000000..480d4642c --- /dev/null +++ b/recipes/distributed/main.py @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024-2025 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import hydra +from omegaconf import DictConfig +from src.diagnostic_distributed import PrecipDiagnostic, diagnostic_distributed + +from earth2studio.data import GFS +from earth2studio.io import ZarrBackend +from earth2studio.models.px import FCN, PrognosticModel +from earth2studio.utils.distributed import DistributedInference, DistributedManager + + +def diagnostic( + time: str = "2023-06-01T00:00:00", + nsteps: int = 12, + output_path: str = "diagnostic_distributed.zarr", + model_cls: type[PrognosticModel] = FCN, +) -> None: + """Distributed diagnostic model recipe.""" + dist = DistributedManager() + if dist.world_size < 2: + raise ValueError("This recipe requires at least 2 processes") + + if dist.rank == 0: # rank 0 will run the prognostic model and handle IO + model = model_cls.load_model(model_cls.load_default_package()) + + # create diagnostic models on the other available ranks + dist_diagnostic = DistributedInference(PrecipDiagnostic) + + # initialize data source and IO backend + data = GFS() + io = ZarrBackend(output_path) + + # run the inference + diagnostic_distributed([time], nsteps, model, dist_diagnostic, data, io) + + +recipes = { + "diagnostic": diagnostic, +} + + +@hydra.main(version_base=None, config_path="cfg") +def main(cfg: DictConfig) -> None: + """Initialize DistributedInference, choose the recipe and run it.""" + DistributedInference.initialize() + recipes[cfg.recipe](**cfg.parameters) + DistributedInference.finalize() + + +if __name__ == "__main__": + main() diff --git a/recipes/distributed/pyproject.toml b/recipes/distributed/pyproject.toml new file mode 100644 index 000000000..268e945a3 --- /dev/null +++ b/recipes/distributed/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "earth2studio.recipe.distributed" +version = "0.1.0" +description = "Distributed Inference Recipe" +readme = "README.md" +requires-python = ">=3.10" +authors = [ + { name="NVIDIA Earth-2 Team" }, +] +dependencies = [ + "earth2studio[fcn,precip-afno]", + "hydra-core>=1.3.0", +] + +[project.urls] +Homepage = "https://github.com/NVIDIA/earth2studio/recipes/distributed" +Documentation = "https://nvidia.github.io/earth2studio" +Issues = "https://github.com/NVIDIA/earth2studio/issues" +Changelog = "https://github.com/NVIDIA/earth2studio/blob/main/CHANGELOG.md" + +[tool.uv.sources] +omegaconf = { git = "https://github.com/omry/omegaconf.git" } +earth2studio = { path = "../../", editable = true } + +[tool.hatch.build.targets.sdist] +include = ["src/**/*.py"] +exclude = [] diff --git a/recipes/distributed/requirements.txt b/recipes/distributed/requirements.txt new file mode 100644 index 000000000..a7e52c411 --- /dev/null +++ b/recipes/distributed/requirements.txt @@ -0,0 +1,510 @@ +# This file was autogenerated by uv via the following command: +# uv export --format requirements-txt --no-hashes +-e ../../ + # via earth2studio-recipe-distributed +absl-py==2.2.2 + # via dm-tree +aiobotocore==2.22.0 + # via s3fs +aiofiles==24.1.0 + # via ngcsdk +aiohappyeyeballs==2.6.1 + # via aiohttp +aiohttp==3.11.18 + # via + # aiobotocore + # gcsfs + # ngcsdk + # s3fs +aioitertools==0.12.0 + # via aiobotocore +aiosignal==1.3.2 + # via aiohttp +antlr4-python3-runtime==4.9.3 + # via hydra-core +asciitree==0.3.3 ; python_full_version < '3.11' + # via zarr +astunparse==1.6.3 + # via nvidia-dali-cuda120 +async-timeout==5.0.1 ; python_full_version < '3.11' + # via aiohttp +attrs==25.3.0 + # via + # aiohttp + # cfgrib + # dm-tree + # eccodes +bokeh==3.7.3 + # via dask +boto3==1.37.3 + # via ngcsdk +botocore==1.37.3 + # via + # aiobotocore + # boto3 + # ngcsdk + # s3transfer +cachetools==5.5.2 + # via google-auth +certifi==2025.4.26 + # via + # netcdf4 + # ngcsdk + # nvidia-physicsnemo + # requests +cffi==1.17.1 + # via + # cryptography + # eccodes +cfgrib==0.9.15.0 + # via earth2studio +cftime==1.6.4.post1 + # via + # earth2studio + # netcdf4 +charset-normalizer==3.4.2 + # via requests +click==8.2.0 + # via + # cfgrib + # dask + # distributed +cloudpickle==3.1.1 + # via + # dask + # distributed +colorama==0.4.6 ; sys_platform == 'win32' + # via + # click + # loguru + # tqdm +contourpy==1.3.2 + # via bokeh +crc32c==2.7.1 ; python_full_version >= '3.11' + # via numcodecs +cryptography==45.0.2 + # via ngcsdk +dask==2025.5.0 + # via + # distributed + # xarray +decorator==5.2.1 + # via gcsfs +distributed==2025.5.0 + # via dask +dm-tree==0.1.9 + # via nvidia-dali-cuda120 +docker==7.1.0 + # via ngcsdk +donfig==0.8.1.post1 ; python_full_version >= '3.11' + # via zarr +eccodes==2.41.0 + # via cfgrib +fasteners==0.19 ; python_full_version < '3.11' and sys_platform != 'emscripten' + # via zarr +filelock==3.18.0 + # via + # huggingface-hub + # torch +findlibs==0.1.1 + # via eccodes +frozenlist==1.6.0 + # via + # aiohttp + # aiosignal +fsspec==2025.3.2 + # via + # dask + # earth2studio + # gcsfs + # huggingface-hub + # nvidia-physicsnemo + # s3fs + # torch +gast==0.6.0 + # via nvidia-dali-cuda120 +gcsfs==2025.3.2 + # via earth2studio +google-api-core==2.24.2 + # via + # google-cloud-core + # google-cloud-storage +google-auth==2.40.1 + # via + # gcsfs + # google-api-core + # google-auth-oauthlib + # google-cloud-core + # google-cloud-storage +google-auth-oauthlib==1.2.2 + # via gcsfs +google-cloud-core==2.4.3 + # via google-cloud-storage +google-cloud-storage==3.1.0 + # via gcsfs +google-crc32c==1.7.1 + # via + # google-cloud-storage + # google-resumable-media +google-resumable-media==2.7.2 + # via google-cloud-storage +googleapis-common-protos==1.70.0 + # via google-api-core +h5netcdf==1.6.1 + # via earth2studio +h5py==3.13.0 + # via + # earth2studio + # h5netcdf +huggingface-hub==0.31.4 + # via + # earth2studio + # timm +hydra-core==1.3.0 + # via earth2studio-recipe-distributed +idna==3.10 + # via + # requests + # yarl +importlib-metadata==8.7.0 ; python_full_version < '3.12' + # via dask +isodate==0.7.2 + # via ngcsdk +jinja2==3.1.6 + # via + # bokeh + # dask + # distributed + # torch +jmespath==1.0.1 + # via + # aiobotocore + # boto3 + # botocore +locket==1.0.0 + # via + # distributed + # partd +loguru==0.7.3 + # via earth2studio +lz4==4.4.4 + # via dask +markdown-it-py==3.0.0 + # via rich +markupsafe==3.0.2 + # via jinja2 +mdurl==0.1.2 + # via markdown-it-py +mpmath==1.3.0 + # via sympy +msgpack==1.1.0 + # via distributed +multidict==6.4.4 + # via + # aiobotocore + # aiohttp + # yarl +narwhals==1.40.0 + # via bokeh +nest-asyncio==1.6.0 + # via earth2studio +netcdf4==1.7.2 + # via earth2studio +networkx==3.4.2 + # via torch +ngcsdk==3.148.1 + # via earth2studio +numcodecs==0.13.1 ; python_full_version < '3.11' + # via + # earth2studio + # zarr +numcodecs==0.14.1 ; python_full_version >= '3.11' + # via + # earth2studio + # zarr +numpy==2.2.6 + # via + # bokeh + # cfgrib + # cftime + # contourpy + # dask + # dm-tree + # eccodes + # h5py + # netcdf4 + # numcodecs + # nvidia-physicsnemo + # onnx + # onnx-weekly + # pandas + # torchvision + # xarray + # zarr +nvidia-cublas-cu12==12.8.3.14 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # via + # nvidia-cudnn-cu12 + # nvidia-cusolver-cu12 + # torch +nvidia-cuda-cupti-cu12==12.8.57 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # via torch +nvidia-cuda-nvrtc-cu12==12.8.61 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # via torch +nvidia-cuda-runtime-cu12==12.8.57 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # via torch +nvidia-cudnn-cu12==9.7.1.26 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # via torch +nvidia-cufft-cu12==11.3.3.41 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # via torch +nvidia-cufile-cu12==1.13.0.11 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # via torch +nvidia-curand-cu12==10.3.9.55 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # via torch +nvidia-cusolver-cu12==11.7.2.55 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # via torch +nvidia-cusparse-cu12==12.5.7.53 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # via + # nvidia-cusolver-cu12 + # torch +nvidia-cusparselt-cu12==0.6.3 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # via torch +nvidia-dali-cuda120==1.43.0 + # via nvidia-physicsnemo +nvidia-nccl-cu12==2.26.2 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # via torch +nvidia-nvimgcodec-cu12==0.5.0.13 + # via nvidia-dali-cuda120 +nvidia-nvjitlink-cu12==12.8.61 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # via + # nvidia-cufft-cu12 + # nvidia-cusolver-cu12 + # nvidia-cusparse-cu12 + # torch +nvidia-nvtx-cu12==12.8.55 ; platform_machine == 'x86_64' and sys_platform == 'linux' + # via torch +nvidia-physicsnemo==1.0.1 + # via earth2studio +oauthlib==3.2.2 + # via requests-oauthlib +omegaconf @ git+https://github.com/omry/omegaconf.git@117f7de07285e4d1324b9229eaf873de15279457 + # via + # earth2studio-recipe-distributed + # hydra-core +onnx==1.18.0 + # via nvidia-physicsnemo +onnx-weekly==1.19.0.dev20250519 ; python_full_version >= '3.13' + # via earth2studio +packaging==25.0 + # via + # bokeh + # dask + # distributed + # h5netcdf + # huggingface-hub + # hydra-core + # ngcsdk + # xarray + # zarr +pandas==2.2.3 + # via + # bokeh + # dask + # xarray +partd==1.4.2 + # via dask +pillow==11.2.1 + # via + # bokeh + # torchvision +polling2==0.5.0 + # via ngcsdk +prettytable==3.16.0 + # via ngcsdk +propcache==0.3.1 + # via + # aiohttp + # yarl +proto-plus==1.26.1 + # via google-api-core +protobuf==6.31.0 + # via + # google-api-core + # googleapis-common-protos + # onnx + # onnx-weekly + # proto-plus +psutil==7.0.0 + # via + # distributed + # ngcsdk +pyarrow==20.0.0 + # via dask +pyasn1==0.6.1 + # via + # pyasn1-modules + # rsa +pyasn1-modules==0.4.2 + # via google-auth +pycparser==2.22 + # via cffi +pygments==2.19.1 + # via rich +python-dateutil==2.9.0.post0 + # via + # aiobotocore + # botocore + # ngcsdk + # pandas +python-dotenv==1.1.0 + # via earth2studio +pytz==2025.2 + # via pandas +pywin32==310 ; sys_platform == 'win32' + # via docker +pyyaml==6.0.2 + # via + # bokeh + # dask + # distributed + # donfig + # huggingface-hub + # omegaconf + # timm +requests==2.32.3 + # via + # docker + # gcsfs + # google-api-core + # google-cloud-storage + # huggingface-hub + # ngcsdk + # requests-oauthlib + # requests-toolbelt +requests-oauthlib==2.0.0 + # via google-auth-oauthlib +requests-toolbelt==1.0.0 + # via ngcsdk +rich==14.0.0 + # via ngcsdk +rsa==4.9.1 + # via google-auth +s3fs==2025.3.2 + # via + # earth2studio + # nvidia-physicsnemo +s3transfer==0.11.3 + # via boto3 +safetensors==0.5.3 + # via timm +semver==3.0.4 + # via ngcsdk +setuptools==80.7.1 + # via + # ngcsdk + # nvidia-physicsnemo + # torch + # triton +shortuuid==1.0.13 + # via ngcsdk +six==1.17.0 + # via + # astunparse + # nvidia-dali-cuda120 + # python-dateutil + # treelib +sortedcontainers==2.4.0 + # via distributed +sympy==1.14.0 + # via torch +tblib==3.1.0 + # via distributed +timm==1.0.15 + # via nvidia-physicsnemo +toolz==1.0.0 + # via + # dask + # distributed + # partd +torch==2.7.0 ; sys_platform == 'darwin' + # via + # earth2studio + # nvidia-physicsnemo + # timm + # torchvision +torch==2.7.0+cpu ; sys_platform != 'darwin' and sys_platform != 'linux' + # via + # earth2studio + # nvidia-physicsnemo + # timm + # torchvision +torch==2.7.0+cu128 ; sys_platform == 'linux' + # via + # earth2studio + # nvidia-physicsnemo + # timm + # torchvision +torchvision==0.22.0 + # via timm +tornado==6.5 + # via + # bokeh + # distributed +tqdm==4.67.1 + # via + # earth2studio + # huggingface-hub + # nvidia-physicsnemo +treelib==1.7.1 + # via nvidia-physicsnemo +triton==3.3.0 ; sys_platform == 'linux' + # via torch +typing-extensions==4.13.2 + # via + # huggingface-hub + # multidict + # onnx + # onnx-weekly + # rich + # torch + # zarr +tzdata==2025.2 + # via pandas +urllib3==2.4.0 + # via + # botocore + # distributed + # docker + # ngcsdk + # requests +validators==0.35.0 + # via ngcsdk +wcwidth==0.2.13 + # via prettytable +wheel==0.45.1 + # via astunparse +win32-setctime==1.2.0 ; sys_platform == 'win32' + # via loguru +wrapt==1.17.2 + # via + # aiobotocore + # dm-tree +xarray==2025.4.0 + # via + # earth2studio + # nvidia-physicsnemo +xyzservices==2025.4.0 + # via bokeh +yarl==1.20.0 + # via aiohttp +zarr==2.18.3 ; python_full_version < '3.11' + # via + # earth2studio + # nvidia-physicsnemo +zarr==3.0.8 ; python_full_version >= '3.11' + # via + # earth2studio + # nvidia-physicsnemo +zict==3.0.0 + # via distributed +zipp==3.21.0 ; python_full_version < '3.12' + # via importlib-metadata diff --git a/recipes/distributed/src/diagnostic_distributed.py b/recipes/distributed/src/diagnostic_distributed.py new file mode 100644 index 000000000..170a5d5af --- /dev/null +++ b/recipes/distributed/src/diagnostic_distributed.py @@ -0,0 +1,210 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024-2025 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from collections import OrderedDict +from datetime import datetime + +import numpy as np +import torch +from loguru import logger +from physicsnemo.distributed import DistributedManager +from tqdm import tqdm + +from earth2studio.data import DataSource, fetch_data +from earth2studio.io import IOBackend +from earth2studio.models.dx import PrecipitationAFNO +from earth2studio.models.px import PrognosticModel +from earth2studio.utils.coords import CoordSystem, map_coords, split_coords +from earth2studio.utils.distributed import ( + DistributedInference, + local_concurrent_pipeline, +) +from earth2studio.utils.time import to_time_array + +logger.remove() +logger.add(lambda msg: tqdm.write(msg, end=""), colorize=True) + + +class PrecipDiagnostic: + """Wrapper for a precipitation diagnostic model distributed using DistributedInference. + + This provides an example of a class that can be used as the `dist_diagnostic` argument + to `diagnostic_distributed`. + """ + + def __init__(self, output_coords: CoordSystem = OrderedDict({})): + dist = DistributedManager() + self.diagnostic = PrecipitationAFNO.load_model( + PrecipitationAFNO.load_default_package() + ) + self.diagnostic.to(dist.device) + self.diagnostic_ic = self.diagnostic.input_coords() + self.diagnostic_oc = self.diagnostic.output_coords(self.diagnostic_ic) + self.output_coords = output_coords + + def get_coords(self) -> tuple[CoordSystem, CoordSystem]: + """Get the input and output coordinates of the diagnostic model.""" + return (self.diagnostic_ic, self.diagnostic_oc) + + @torch.inference_mode() + def forward( + self, x: torch.Tensor, coords: CoordSystem + ) -> tuple[torch.Tensor, CoordSystem]: + """Forward pass of the diagnostic model. + + Maps the input coordinates to the diagnostic model coordinates, runs the diagnostic, + and maps the diagnostic model result to the output coordinates. + """ + x, coords = map_coords(x, coords, self.diagnostic_ic) + x, coords = self.diagnostic(x, coords) + # Subselect domain/variables as indicated in output_coords + (x, coords) = map_coords(x, coords, self.output_coords) + return (x, coords) + + +# sphinx - diagnostic start +def diagnostic_distributed( + time: list[str] | list[datetime] | list[np.datetime64], + nsteps: int, + prognostic: PrognosticModel, + dist_diagnostic: DistributedInference, + data: DataSource, + io: IOBackend, + output_coords: CoordSystem = OrderedDict({}), +) -> IOBackend: + """Distributed diagnostic workflow. + This workflow creates a distributed inference pipeline that couples a prognostic + model on the local rank with a diagnostic model on remote rank(s). + + Parameters + ---------- + time : list[str] | list[datetime] | list[np.datetime64] + List of string, datetimes or np.datetime64 + nsteps : int + Number of forecast steps + prognostic : PrognosticModel + Prognostic model + dist_diagnostic: DistributedInference + Wrapper for a diagnostic model distributed using DistributedInference, + must be on same coordinate axis as prognostic. Must implement a `forward` + method that wraps the call to the diagnostic model, and a `get_coords` + method that returns a 2-tuple of (input_coords, output_coords). + data : DataSource + Data source + io : IOBackend + IO object + output_coords: CoordSystem, optional + IO output coordinate system override, by default OrderedDict({}) + device : torch.device, optional + Device to run inference on, by default None + + Returns + ------- + IOBackend + Output IO object + """ + # sphinx - diagnostic end + logger.info("Running diagnostic workflow!") + + dist = DistributedManager() + device = dist.device + + # Get information about the prognostic model + logger.info(f"Prognostic rank: {dist.rank}") + logger.info(f"Prognostic device: {device}") + prognostic = prognostic.to(device) + # Fetch data from data source and load onto device + prognostic_ic = prognostic.input_coords() + time = to_time_array(time) + + # Fetch initial conditions from data source and load onto device + x, coords = fetch_data( + source=data, + time=time, + variable=prognostic_ic["variable"], + lead_time=prognostic_ic["lead_time"], + device=device, + ) + logger.success(f"Fetched data from {data.__class__.__name__}") + + # Get the input and output coordinates of the remote diagnostic model + logger.info(f"Diagnostic ranks: {dist_diagnostic.remote_ranks}") + (diagnostic_ic, diagnostic_oc) = dist_diagnostic.call_func("get_coords")[0] + + # Set up IO backend and create output variables + _setup_io(io, time, nsteps, prognostic, diagnostic_oc, output_coords=output_coords) + + # Map lat and lon if needed + x, coords = map_coords(x, coords, prognostic_ic) + # Create prognostic iterator + model = prognostic.create_iterator(x, coords) + + def prognostic_loop() -> None: + """Pull outputs from prognostic model and pass to diagnostic models asynchronously.""" + for step, (x, coords) in enumerate(model): + dist_diagnostic(x.clone(), coords) + if step == nsteps: + break + dist_diagnostic.wait() + + def io_loop() -> None: + """Receive outputs from diagnostic models and write to IO backend.""" + with tqdm(total=nsteps + 1, desc="Waiting for diagnostic model data") as pbar: + for x, coords in dist_diagnostic.results(): + io.write(*split_coords(x, coords)) + pbar.update(1) + + logger.info("Inference starting!") + # launch the functions making up the inference pipeline in their own threads + # and wait for them to finish + local_concurrent_pipeline([prognostic_loop, io_loop]) + logger.success("Inference complete") + + return io + + +def _setup_io( + io: IOBackend, + time: list[str] | list[datetime] | list[np.datetime64], + nsteps: int, + prognostic: PrognosticModel, + diagnostic_oc: CoordSystem, + output_coords: CoordSystem = OrderedDict({}), +) -> None: + """Set up IO backend and create output variables.""" + + total_coords = prognostic.output_coords(prognostic.input_coords()) + for key, value in prognostic.output_coords( + prognostic.input_coords() + ).items(): # Scrub batch dims + if key in diagnostic_oc: + total_coords[key] = diagnostic_oc[key] + if value.shape == (0,): + del total_coords[key] + total_coords["time"] = time + total_coords["lead_time"] = np.asarray( + [ + prognostic.output_coords(prognostic.input_coords())["lead_time"] * i + for i in range(nsteps + 1) + ] + ).flatten() + total_coords.move_to_end("lead_time", last=False) + total_coords.move_to_end("time", last=False) + + for key, value in total_coords.items(): + total_coords[key] = output_coords.get(key, value) + var_names = total_coords.pop("variable") + io.add_array(total_coords, var_names) diff --git a/recipes/distributed/test/.gitignore b/recipes/distributed/test/.gitignore new file mode 100644 index 000000000..e69de29bb diff --git a/recipes/distributed/test/Makefile b/recipes/distributed/test/Makefile new file mode 100644 index 000000000..28e854852 --- /dev/null +++ b/recipes/distributed/test/Makefile @@ -0,0 +1,3 @@ +.PHONY: test +test: + cd ..; uv run main.py print.hello=False diff --git a/recipes/distributed/test/README.md b/recipes/distributed/test/README.md new file mode 100644 index 000000000..9ea0ba6bc --- /dev/null +++ b/recipes/distributed/test/README.md @@ -0,0 +1,16 @@ +# Tests + +## Test 1 + +Command to execute from this directory + +```bash +# commands to run the test e.g. +make test +``` + +### Expected Result + +Brief description of the expected output. E.g. + +Should only print the version of Earth2Studio From 4f2620826c3af3ea2a959ba46c7755dafb47c082 Mon Sep 17 00:00:00 2001 From: Jussi Leinonen Date: Tue, 20 May 2025 09:51:44 -0700 Subject: [PATCH 2/4] Add test for diagnostic --- recipes/distributed/test/.gitignore | 1 + recipes/distributed/test/Makefile | 3 -- recipes/distributed/test/README.md | 26 +++++++--- .../test/check_diagnostic_outputs.py | 46 ++++++++++++++++++ .../test/test_figures/diagnostic_sample.png | Bin 0 -> 117937 bytes 5 files changed, 67 insertions(+), 9 deletions(-) delete mode 100644 recipes/distributed/test/Makefile create mode 100644 recipes/distributed/test/check_diagnostic_outputs.py create mode 100644 recipes/distributed/test/test_figures/diagnostic_sample.png diff --git a/recipes/distributed/test/.gitignore b/recipes/distributed/test/.gitignore index e69de29bb..bbf2e23a1 100644 --- a/recipes/distributed/test/.gitignore +++ b/recipes/distributed/test/.gitignore @@ -0,0 +1 @@ +test_figures/tp_*.png diff --git a/recipes/distributed/test/Makefile b/recipes/distributed/test/Makefile deleted file mode 100644 index 28e854852..000000000 --- a/recipes/distributed/test/Makefile +++ /dev/null @@ -1,3 +0,0 @@ -.PHONY: test -test: - cd ..; uv run main.py print.hello=False diff --git a/recipes/distributed/test/README.md b/recipes/distributed/test/README.md index 9ea0ba6bc..1e8f98b13 100644 --- a/recipes/distributed/test/README.md +++ b/recipes/distributed/test/README.md @@ -1,16 +1,30 @@ # Tests -## Test 1 +## Test 1: Check diagnostic model outputs -Command to execute from this directory +Run the distributed diagnostic model example in the parent directory, as indicated in the main +`README`: ```bash -# commands to run the test e.g. -make test +torchrun --standalone --nnodes=1 --nproc-per-node= main.py \ + --config-name=diagnostic.yaml +``` + +Check that the run finishes without errors. Then run the `check_diagnostic_outputs.py` script: + +```bash +python check_diagnostic_outputs.py ``` ### Expected Result -Brief description of the expected output. E.g. +You should see an output similar to this: + +```bash + +``` -Should only print the version of Earth2Studio +There should also be a figure as a PNG file for each time step in the prediction in the +`test_figures` directory. Check that the outputs look reasonable for all time steps. Below +is an example: +![Sample of diagnostic model output](test_figures/diagnostic_sample.png) diff --git a/recipes/distributed/test/check_diagnostic_outputs.py b/recipes/distributed/test/check_diagnostic_outputs.py new file mode 100644 index 000000000..7f0237c2f --- /dev/null +++ b/recipes/distributed/test/check_diagnostic_outputs.py @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024-2025 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os + +import xarray as xr +from matplotlib import pyplot as plt + + +def check_diagnostic_outputs(fn: str = "../diagnostic_distributed.zarr") -> None: + """Check the diagnostic outputs.""" + + with xr.open_dataset(fn, engine="zarr") as ds: + tp = ds["tp"].values + + min_tp = tp.min() + max_tp = tp.max() + mean_tp = tp.mean() + + print(f"Minimum tp: {min_tp}") + print(f"Maximum tp: {max_tp}") + print(f"Mean tp: {mean_tp}") + + os.makedirs("test_figures", exist_ok=True) + for i in range(tp.shape[1]): + fig, ax = plt.subplots(figsize=(10, 10)) + im = ax.imshow(tp[0, i, :, :]) + fig.colorbar(im, ax=ax, orientation="vertical") + fig.savefig(f"test_figures/tp_{i:02d}.png", bbox_inches="tight") + + +if __name__ == "__main__": + check_diagnostic_outputs() diff --git a/recipes/distributed/test/test_figures/diagnostic_sample.png b/recipes/distributed/test/test_figures/diagnostic_sample.png new file mode 100644 index 0000000000000000000000000000000000000000..4ef7531bffd73b91d9dacdfcd991e7adbd2909c8 GIT binary patch literal 117937 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 U`X6GYI8u4Tr+j}R%c|b( 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%0DxcHQ}O-?>(3Jgi>1*!4BZTsNU0F8 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 literal 0 HcmV?d00001 From 4e168ebb0d60fb70beea2e7c7fcbbbfab1878994 Mon Sep 17 00:00:00 2001 From: Jussi Leinonen Date: Tue, 20 May 2025 10:03:34 -0700 Subject: [PATCH 3/4] Update READMEs --- recipes/distributed/README.md | 6 +++++- recipes/distributed/test/README.md | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/recipes/distributed/README.md b/recipes/distributed/README.md index 23dc99a90..a34a7aa9a 100644 --- a/recipes/distributed/README.md +++ b/recipes/distributed/README.md @@ -42,7 +42,7 @@ uv sync ### Test distributed inference Start an environment with at least 2 GPUs available. The run the distributed diagnostic model -example, substituting with the number of GPUs you have: +example, substituting `` with the number of GPUs you have: ```bash # if you installed a uv environment @@ -94,6 +94,10 @@ with the startup script path. To create custom applications using `DistributedInference`, you can use the provided recipes as a starting point. +## Testing + +See the [testing `README`](test/README.md). + ## References - [PyTorch TensorPipe CUDA RPC](https://docs.pytorch.org/tutorials/recipes/cuda_rpc.html), the diff --git a/recipes/distributed/test/README.md b/recipes/distributed/test/README.md index 1e8f98b13..29585d287 100644 --- a/recipes/distributed/test/README.md +++ b/recipes/distributed/test/README.md @@ -21,7 +21,9 @@ python check_diagnostic_outputs.py You should see an output similar to this: ```bash - +Minimum tp: 0.0 +Maximum tp: 0.06553447246551514 +Mean tp: 0.0005333806620910764 ``` There should also be a figure as a PNG file for each time step in the prediction in the From 2ae35801b92721213dbc00f07942756661408b5c Mon Sep 17 00:00:00 2001 From: Jussi Leinonen Date: Mon, 26 May 2025 07:51:47 -0700 Subject: [PATCH 4/4] Update to latest GFS --- earth2studio/data/gfs.py | 434 +++++++++++++++++++++++++-------------- 1 file changed, 280 insertions(+), 154 deletions(-) diff --git a/earth2studio/data/gfs.py b/earth2studio/data/gfs.py index 00eb419c1..95ea1368c 100644 --- a/earth2studio/data/gfs.py +++ b/earth2studio/data/gfs.py @@ -14,20 +14,24 @@ # See the License for the specific language governing permissions and # limitations under the License. +import asyncio +import functools import hashlib import os import pathlib import shutil import warnings -from datetime import datetime, timedelta +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +import nest_asyncio import numpy as np import s3fs import xarray as xr from fsspec.implementations.ftp import FTPFileSystem from loguru import logger -from s3fs.core import S3FileSystem -from tqdm import tqdm +from tqdm.asyncio import tqdm from earth2studio.data.utils import ( datasource_cache_root, @@ -41,6 +45,17 @@ logger.add(lambda msg: tqdm.write(msg, end=""), colorize=True) +@dataclass +class GFSAsyncTask: + """Small helper struct for Async tasks""" + + data_array_indices: tuple[int, int, int] + gfs_file_uri: str + gfs_byte_offset: int + gfs_byte_length: int + gfs_modifier: Callable + + class GFS: """The global forecast service (GFS) initial state data source provided on an equirectangular grid. GFS is a weather forecast model developed by NOAA. This data @@ -55,6 +70,9 @@ class GFS: Cache data source on local memory, by default True verbose : bool, optional Print download progress, by default True + async_timeout : int, optional + Time in sec after which download will be cancelled if not finished successfully, + by default 600 Warning ------- @@ -81,7 +99,13 @@ class GFS: GFS_LAT = np.linspace(90, -90, 721) GFS_LON = np.linspace(0, 359.75, 1440) - def __init__(self, source: str = "aws", cache: bool = True, verbose: bool = True): + def __init__( + self, + source: str = "aws", + cache: bool = True, + verbose: bool = True, + async_timeout: int = 600, + ): self._cache = cache self._verbose = verbose @@ -90,7 +114,7 @@ def __init__(self, source: str = "aws", cache: bool = True, verbose: bool = True if source == "aws": self.uri_prefix = "noaa-gfs-bdp-pds" - self.fs = s3fs.S3FileSystem(anon=True, client_kwargs={}) + self.fs = s3fs.S3FileSystem(anon=True, client_kwargs={}, asynchronous=True) # To update search "gfs." at https://noaa-gfs-bdp-pds.s3.amazonaws.com/index.html # They are slowly adding more data @@ -117,13 +141,14 @@ def _range(time: datetime) -> None: else: raise ValueError(f"Invalid GFS source {source}") + self.async_timeout = async_timeout + def __call__( self, time: datetime | list[datetime] | TimeArray, variable: str | list[str] | VariableArray, ) -> xr.DataArray: - """Retrieve GFS initial data to be used for initial conditions for the given - time, variable information, and optional history. + """Retrieve GFS initial state / analysis data Parameters ---------- @@ -138,60 +163,175 @@ def __call__( xr.DataArray GFS weather data array """ - time, variable = prep_data_inputs(time, variable) + nest_asyncio.apply() # Patch asyncio to work in notebooks + try: + loop = asyncio.get_event_loop() + except RuntimeError: + # If no event loop exists, create one + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + xr_array = loop.run_until_complete( + asyncio.wait_for(self.fetch(time, variable), timeout=self.async_timeout) + ) + + return xr_array + + async def fetch( + self, + time: datetime | list[datetime] | TimeArray, + variable: str | list[str] | VariableArray, + ) -> xr.DataArray: + """Async function to get data + + Parameters + ---------- + time : datetime | list[datetime] | TimeArray + Timestamps to return data for (UTC). + variable : str | list[str] | VariableArray + String, list of strings or array of strings that refer to variables to + return. Must be in the GFS lexicon. + Returns + ------- + xr.DataArray + GFS weather data array + """ + time, variable = prep_data_inputs(time, variable) # Create cache dir if doesnt exist pathlib.Path(self.cache).mkdir(parents=True, exist_ok=True) # Make sure input time is valid self._validate_time(time) - # Fetch index file for requested time - # Should really async this stuff - data_arrays = [] - for t0 in time: - data_array = self.fetch_dataarray(t0, variable) - data_arrays.append(data_array) + # Note, this could be more memory efficient and avoid pre-allocation of the array + # but this is much much cleaner to deal with, compared to something seen in the + # NCAR data source. + xr_array = xr.DataArray( + data=np.zeros( + (len(time), 1, len(variable), len(self.GFS_LAT), len(self.GFS_LON)) + ), + dims=["time", "lead_time", "variable", "lat", "lon"], + coords={ + "time": time, + "lead_time": [timedelta(hours=0)], + "variable": variable, + "lat": self.GFS_LAT, + "lon": self.GFS_LON, + }, + ) + + async_tasks = [] + async_tasks = await self._create_tasks(time, [timedelta(hours=0)], variable) + func_map = map( + functools.partial(self.fetch_wrapper, xr_array=xr_array), async_tasks + ) + + await tqdm.gather( + *func_map, desc="Fetching GFS data", disable=(not self._verbose) + ) # Delete cache if needed if not self._cache: shutil.rmtree(self.cache) - return xr.concat(data_arrays, dim="time") + # Close aiohttp client if s3fs + # https://github.com/fsspec/s3fs/issues/943 + # https://github.com/zarr-developers/zarr-python/issues/2901 + if isinstance(self.fs, s3fs.S3FileSystem): + await self.fs.set_session() # Make sure the session was actually initalized + s3fs.S3FileSystem.close_session(asyncio.get_event_loop(), self.fs.s3) - def fetch_dataarray( - self, - time: datetime, - variables: list[str], - ) -> xr.DataArray: - """Retrives GFS initial state data array for given date time + return xr_array.isel(lead_time=0) + + async def _create_tasks( + self, time: list[datetime], lead_time: list[timedelta], variable: list[str] + ) -> list[GFSAsyncTask]: + """Create download tasks, each corresponding to one grib byte range on S3 Parameters ---------- - time : datetime - Date time to fetch + times : list[datetime] + Timestamps to be downloaded (UTC). variables : list[str] - List of atmosphric variables to fetch. Must be supported in GFS lexicon + List of variables to be downloaded. Returns ------- - xr.DataArray - GFS data array for given date time - - Raises - ------ - KeyError - Un supported variable. + list[dict] + List of download tasks """ - da = self._fetch_gfs_dataarray(time, timedelta(hours=0), variables) - return da.isel(lead_time=0) + tasks: list[GFSAsyncTask] = [] # group pressure-level variables - def _fetch_gfs_dataarray( + # Start with fetching all index files for each time / lead time + args = [self._grib_index_uri(t, lt) for t in time for lt in lead_time] + func_map = map(self._fetch_index, args) + results = await tqdm.gather( + *func_map, desc="Fetching GFS index files", disable=True + ) + for i, t in enumerate(time): + for j, lt in enumerate(lead_time): + # Get index file dictionary + index_file = results.pop(0) + for k, v in enumerate(variable): + try: + gfs_name, modifier = GFSLexicon[v] + except KeyError: + logger.warning( + f"variable id {v} not found in GFS lexicon, good luck" + ) + gfs_name = v + + def modifier(x: np.array) -> np.array: + """Modify data (if necessary).""" + return x + + byte_offset = None + byte_length = None + for key, value in index_file.items(): + if gfs_name in key: + byte_offset = value[0] + byte_length = value[1] + break + + if byte_length is None or byte_offset is None: + logger.warning( + f"Variable {v} not found in index file for time {t} at {lt}, values will be unset" + ) + continue + + tasks.append( + GFSAsyncTask( + data_array_indices=(i, j, k), + gfs_file_uri=self._grib_uri(t, lt), + gfs_byte_offset=byte_offset, + gfs_byte_length=byte_length, + gfs_modifier=modifier, + ) + ) + return tasks + + async def fetch_wrapper( self, - time: datetime, - lead_time: timedelta, - variables: list[str], + task: GFSAsyncTask, + xr_array: xr.DataArray, ) -> xr.DataArray: + """Small wrapper to pack arrays into the DataArray""" + out = await self.fetch_array( + task.gfs_file_uri, + task.gfs_byte_offset, + task.gfs_byte_length, + task.gfs_modifier, + ) + xr_array[*task.data_array_indices] = out + + async def fetch_array( + self, + grib_uri: str, + byte_offset: int, + byte_length: int, + modifier: Callable, + ) -> np.ndarray: """Fetch GFS data array. This will first fetch the index file to get byte range of the needed data, fetch the respective grib files and lastly combining grib files into single data array. @@ -210,68 +350,18 @@ def _fetch_gfs_dataarray( xr.DataArray FS data array for given time and lead time """ - logger.debug(f"Fetching GFS index file: {time} lead {lead_time}") - index_file = self._fetch_index(self._grib_index_uri(time, lead_time)) - - gfsda = xr.DataArray( - data=np.empty((1, 1, len(variables), len(self.GFS_LAT), len(self.GFS_LON))), - dims=["time", "lead_time", "variable", "lat", "lon"], - coords={ - "time": [time], - "lead_time": [lead_time], - "variable": variables, - "lat": self.GFS_LAT, - "lon": self.GFS_LON, - }, + logger.debug(f"Fetching GFS grib file: {grib_uri} {byte_offset}-{byte_length}") + # Download the grib file to cache + grib_file = await self._fetch_remote_file( + grib_uri, + byte_offset=byte_offset, + byte_length=byte_length, ) - - # TODO: Add MP here - for i, variable in enumerate( - tqdm( - variables, desc=f"Fetching GFS for {time}", disable=(not self._verbose) - ) - ): - # Convert from Earth2Studio variable ID to GFS id and modifier - # sphinx - lexicon start - try: - gfs_name, modifier = GFSLexicon[variable] - except KeyError: - logger.warning( - f"variable id {variable} not found in GFS lexicon, good luck" - ) - gfs_name = variable - - def modifier(x: np.array) -> np.array: - """Modify data (if necessary).""" - return x - - byte_offset = None - byte_length = None - for key, value in index_file.items(): - if gfs_name in key: - byte_offset = value[0] - byte_length = value[1] - break - - if byte_offset is None: - raise KeyError(f"Could not find variable {gfs_name} in index file") - # Download the grib file to cache - logger.debug( - f"Fetching GFS grib file for variable: {variable} at {time}_{lead_time}" - ) - grib_file = self._fetch_remote_file( - self._grib_uri(time, lead_time), - byte_offset=byte_offset, - byte_length=byte_length, - ) - # Open into xarray data-array - da = xr.open_dataarray( - grib_file, engine="cfgrib", backend_kwargs={"indexpath": ""} - ) - gfsda[0, 0, i] = modifier(da.values) - # sphinx - lexicon end - - return gfsda + # Open into xarray data-array + da = xr.open_dataarray( + grib_file, engine="cfgrib", backend_kwargs={"indexpath": ""} + ) + return modifier(da.values) def _validate_time(self, times: list[datetime]) -> None: """Verify if date time is valid for GFS based on offline knowledge @@ -289,7 +379,7 @@ def _validate_time(self, times: list[datetime]) -> None: # Check history range for given source self._history_range(time) - def _fetch_index(self, index_uri: str) -> dict[str, tuple[int, int]]: + async def _fetch_index(self, index_uri: str) -> dict[str, tuple[int, int]]: """Fetch GFS atmospheric index file Parameters @@ -303,7 +393,7 @@ def _fetch_index(self, index_uri: str) -> dict[str, tuple[int, int]]: Dictionary of GFS vairables (byte offset, byte length) """ # Grab index file - index_file = self._fetch_remote_file(index_uri) + index_file = await self._fetch_remote_file(index_uri) with open(index_file) as file: index_lines = [line.rstrip() for line in file] @@ -328,8 +418,8 @@ def _fetch_index(self, index_uri: str) -> dict[str, tuple[int, int]]: # Pop place holder return index_table - def _fetch_remote_file( - self, path: str, byte_offset: int = 0, byte_length: int = None + async def _fetch_remote_file( + self, path: str, byte_offset: int = 0, byte_length: int | None = None ) -> str: """Fetches remote file into cache""" sha = hashlib.sha256((path + str(byte_offset)).encode()) @@ -337,9 +427,16 @@ def _fetch_remote_file( cache_path = os.path.join(self.cache, filename) if not pathlib.Path(cache_path).is_file(): - data = self.fs.read_block(path, offset=byte_offset, length=byte_length) + if self.fs.async_impl: + if byte_length: + byte_length = int(byte_offset + byte_length) + data = await self.fs._cat_file(path, start=byte_offset, end=byte_length) + else: + data = await asyncio.to_thread( + self.fs.read_block, path, offset=byte_offset, length=byte_length + ) with open(cache_path, "wb") as file: - file.write(data) + await asyncio.to_thread(file.write, data) return cache_path @@ -390,16 +487,9 @@ def available( if isinstance(time, np.datetime64): # np.datetime64 -> datetime _unix = np.datetime64(0, "s") _ds = np.timedelta64(1, "s") - time = datetime.utcfromtimestamp((time - _unix) / _ds) - - # Offline checks - # try: - # cls._validate_time([time]) - # except ValueError: - # return False - - fs = S3FileSystem(anon=True) + time = datetime.fromtimestamp((time - _unix) / _ds, timezone.utc) + fs = s3fs.S3FileSystem(anon=True) # Object store directory for given time # Should contain two keys: atmos and wave file_name = f"gfs.{time.year}{time.month:0>2}{time.day:0>2}/{time.hour:0>2}/" @@ -460,8 +550,46 @@ def __call__( # type: ignore[override] xr.DataArray GFS weather data array """ - time, lead_time, variable = prep_forecast_inputs(time, lead_time, variable) + nest_asyncio.apply() # Patch asyncio to work in notebooks + try: + loop = asyncio.get_event_loop() + except RuntimeError: + # If no event loop exists, create one + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + xr_array = loop.run_until_complete( + asyncio.wait_for( + self.fetch(time, lead_time, variable), timeout=self.async_timeout + ) + ) + + return xr_array + + async def fetch( # type: ignore[override] + self, + time: datetime | list[datetime] | TimeArray, + lead_time: timedelta | list[timedelta] | LeadTimeArray, + variable: str | list[str] | VariableArray, + ) -> xr.DataArray: + """Async function to get data + Parameters + ---------- + time : datetime | list[datetime] | TimeArray + Timestamps to return data for (UTC). + lead_time: timedelta | list[timedelta] | LeadTimeArray + Forecast lead times to fetch. + variable : str | list[str] | VariableArray + String, list of strings or array of strings that refer to variables to + return. Must be in the GFS lexicon. + + Returns + ------- + xr.DataArray + GFS weather data array + """ + time, lead_time, variable = prep_forecast_inputs(time, lead_time, variable) # Create cache dir if doesnt exist pathlib.Path(self.cache).mkdir(parents=True, exist_ok=True) @@ -469,53 +597,51 @@ def __call__( # type: ignore[override] self._validate_time(time) self._validate_leadtime(lead_time) - # Fetch index file for requested time - # Should really async this stuff - data_arrays = [] - for t0 in time: - lead_arrays = [] - for l0 in lead_time: - data_array = self.fetch_dataarray(t0, l0, variable) - lead_arrays.append(data_array) + # Note, this could be more memory efficient and avoid pre-allocation of the array + # but this is much much cleaner to deal with, compared to something seen in the + # NCAR data source. + xr_array = xr.DataArray( + data=np.zeros( + ( + len(time), + len(lead_time), + len(variable), + len(self.GFS_LAT), + len(self.GFS_LON), + ) + ), + dims=["time", "lead_time", "variable", "lat", "lon"], + coords={ + "time": time, + "lead_time": lead_time, + "variable": variable, + "lat": self.GFS_LAT, + "lon": self.GFS_LON, + }, + ) - data_arrays.append(xr.concat(lead_arrays, dim="lead_time")) + async_tasks = [] + async_tasks = await self._create_tasks(time, [timedelta(hours=0)], variable) + func_map = map( + functools.partial(self.fetch_wrapper, xr_array=xr_array), async_tasks + ) + + await tqdm.gather( + *func_map, desc="Fetching GFS data", disable=(not self._verbose) + ) # Delete cache if needed if not self._cache: shutil.rmtree(self.cache) - return xr.concat(data_arrays, dim="time") - - def fetch_dataarray( # type: ignore[override] - self, - time: datetime, - lead_time: timedelta, - variables: list[str], - ) -> xr.DataArray: - """Retrives GFS data array for given date time by fetching the index file, - fetching variable grib files and lastly combining grib files into single data - array. + # Close aiohttp client if s3fs + # https://github.com/fsspec/s3fs/issues/943 + # https://github.com/zarr-developers/zarr-python/issues/2901 + if isinstance(self.fs, s3fs.S3FileSystem): + await self.fs.set_session() # Make sure the session was actually initalized + s3fs.S3FileSystem.close_session(asyncio.get_event_loop(), self.fs.s3) - Parameters - ---------- - time : datetime - Date time to fetch - lead_time : timedelta - Forecast lead time to fetch - variables : list[str] - List of atmosphric variables to fetch. Must be supported in GFS lexicon - - Returns - ------- - xr.DataArray - GFS data array for given date time - - Raises - ------ - KeyError - Un supported variable. - """ - return self._fetch_gfs_dataarray(time, lead_time, variables) + return xr_array @classmethod def _validate_leadtime(cls, lead_times: list[timedelta]) -> None: