| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| """Helpers to use xarray.{Variable,DataArray,Dataset} with JAX. |
| |
| Allows them to be based on JAX arrays without converting to numpy arrays under |
| the hood, so you can start with a JAX array, do some computation with it in |
| xarray-land, get a JAX array out the other end and (for example) jax.jit |
| through the whole thing. You can even jax.jit a function which accepts and |
| returns xarray.Dataset, DataArray and Variable. |
| |
| ## Creating xarray datatypes from jax arrays, and vice-versa. |
| |
| You can use the xarray_jax.{Variable,DataArray,Dataset} constructors, which have |
| the same API as the standard xarray constructors but will accept JAX arrays |
| without converting them to numpy. |
| |
| It does this by wrapping the JAX array in a wrapper before passing it to |
| xarray; you can also do this manually by calling xarray_jax.wrap on your JAX |
| arrays before passing them to the standard xarray constructors. |
| |
| To get non-wrapped JAX arrays out the other end, you can use e.g.: |
| |
| xarray_jax.jax_vars(dataset) |
| xarray_jax.jax_data(dataset.some_var) |
| |
| which will complain if the data isn't actually a JAX array. Use this if you need |
| to make sure the computation has gone via JAX, e.g. if it's the output of code |
| that you want to JIT or compute gradients through. If this is not the case and |
| you want to support passing plain numpy arrays through as well as potentially |
| JAX arrays, you can use: |
| |
| xarray_jax.unwrap_vars(dataset) |
| xarray_jax.unwrap_data(dataset.some_var) |
| |
| which will unwrap the data if it is a wrapped JAX array, but otherwise pass |
| it through to you without complaint. |
| |
| The wrapped JAX arrays aim to support all the core operations from the numpy |
| array API that xarray expects, however there may still be some gaps; if you run |
| into any problems around this, you may need to add a few more proxy methods onto |
| the wrapper class below. |
| |
| In future once JAX and xarray support the new Python array API standard |
| (https://data-apis.org/array-api/latest/index.html), we hope to avoid the need |
| for wrapping the JAX arrays like this. |
| |
| ## jax.jit and pmap of functions taking and returning xarray datatypes |
| |
| We register xarray datatypes with jax.tree_util, which allows them to be treated |
| as generic containers of jax arrays by various parts of jax including jax.jit. |
| |
| This allows for, e.g.: |
| |
| @jax.jit |
| def foo(input: xarray.Dataset) -> xarray.Dataset: |
| ... |
| |
| It will not work out-of-the-box with shape-modifying transformations like |
| jax.pmap, or e.g. a jax.tree_util.tree_map with some transform that alters array |
| shapes or dimension order. That's because we won't know what dimension names |
| and/or coordinates to use when unflattening, if the results have a different |
| shape to the data that was originally flattened. |
| |
| You can work around this using xarray_jax.dims_change_on_unflatten, however, |
| and in the case of jax.pmap we provide a wrapper xarray_jax.pmap which allows |
| it to be used with functions taking and returning xarrays. |
| |
| ## Treatment of coordinates |
| |
| We don't support passing jax arrays as coordinates when constructing a |
| DataArray/Dataset. This is because xarray's advanced indexing and slicing is |
| unlikely to work with jax arrays (at least when a Tracer is used during |
| jax.jit), and also because some important datatypes used for coordinates, like |
| timedelta64 and datetime64, are not supported by jax. |
| |
| For the purposes of tree_util and jax.jit, coordinates are not treated as leaves |
| of the tree (array data 'contained' by a Dataset/DataArray), they are just a |
| static part of the structure. That means that if a jit'ed function is called |
| twice with Dataset inputs that use different coordinates, it will compile a |
| separate version of the function for each. The coordinates are treated like |
| static_argnums by jax.jit. |
| |
| If you want to use dynamic data for coordinates, we recommend making it a |
| data_var instead of a coord. You won't be able to do indexing and slicing using |
| the coordinate, but that wasn't going to work with a jax array anyway. |
| """ |
|
|
| import collections |
| import contextlib |
| import contextvars |
| from typing import Any, Callable, Iterator, Mapping, Optional, Union, Tuple, TypeVar, cast |
| from typing import Hashable |
|
|
| import jax |
| import jax.numpy as jnp |
| import numpy as np |
| import tree |
| import xarray |
|
|
|
|
| |
| |
| |
| |
| |
| _WRAPPED_TYPES = ( |
| jax.Array, jax.ShapeDtypeStruct, jax.stages.ArgInfo) |
|
|
|
|
| def Variable(dims, data, **kwargs) -> xarray.Variable: |
| """Like xarray.Variable, but can wrap JAX arrays.""" |
| return xarray.Variable(dims, wrap(data), **kwargs) |
|
|
|
|
| _JAX_COORD_ATTR_NAME = '_jax_coord' |
|
|
|
|
| def DataArray( |
| data, |
| coords=None, |
| dims=None, |
| name=None, |
| attrs=None, |
| jax_coords=None, |
| ) -> xarray.DataArray: |
| """Like xarray.DataArray, but supports using JAX arrays. |
| |
| Args: |
| data: As for xarray.DataArray, except jax arrays are also supported. |
| coords: Coordinates for the array, see xarray.DataArray. These coordinates |
| must be based on plain numpy arrays or something convertible to plain |
| numpy arrays. Their values will form a static part of the data structure |
| from the point of view of jax.tree_util. In particular this means these |
| coordinates will be passed as plain numpy arrays even inside a JIT'd |
| function, and the JIT'd function will be recompiled under the hood if the |
| coordinates of DataArrays passed into it change. |
| If this is not convenient for you, see also jax_coords below. |
| dims: See xarray.DataArray. |
| name: See xarray.DataArray. |
| attrs: See xarray.DataArray. |
| jax_coords: Additional coordinates, which *can* use JAX arrays. These |
| coordinates will be treated as JAX data from the point of view of |
| jax.tree_util, that means when JIT'ing they will be passed as tracers and |
| computation involving them will be JIT'd. |
| Unfortunately a side-effect of this is that they can't be used as index |
| coordinates (because xarray's indexing logic is not JIT-able). If you |
| specify a coordinate with the same name as a dimension here, it will not |
| be set as an index coordinate; this behaviour is different to the default |
| for `coords`, and it means that things like `.sel` based on the jax |
| coordinate will not work. |
| Note we require `jax_coords` to be explicitly specified via a different |
| constructor argument to `coords`, rather than just looking for jax arrays |
| within the `coords` and treating them differently. This is because it |
| affects the way jax.tree_util treats them, which is somewhat orthogonal to |
| whether the value is passed in as numpy or not, and generally needs to be |
| handled consistently so is something we encourage explicit control over. |
| |
| Returns: |
| An instance of xarray.DataArray. Where JAX arrays are used as data or |
| coords, they will be wrapped with JaxArrayWrapper and can be unwrapped via |
| `unwrap` and `unwrap_data`. |
| """ |
| result = xarray.DataArray( |
| wrap(data), dims=dims, name=name, attrs=attrs or {}) |
| return assign_coords(result, coords=coords, jax_coords=jax_coords) |
|
|
|
|
| def Dataset( |
| data_vars=None, |
| coords=None, |
| attrs=None, |
| jax_coords=None, |
| ) -> xarray.Dataset: |
| """Like xarray.Dataset, but can wrap JAX arrays. |
| |
| Args: |
| data_vars: As for xarray.Dataset, except jax arrays are also supported. |
| coords: Coordinates for the dataset, see xarray.Dataset. These coordinates |
| must be based on plain numpy arrays or something convertible to plain |
| numpy arrays. Their values will form a static part of the data structure |
| from the point of view of jax.tree_util. In particular this means these |
| coordinates will be passed as plain numpy arrays even inside a JIT'd |
| function, and the JIT'd function will be recompiled under the hood if the |
| coordinates of DataArrays passed into it change. |
| If this is not convenient for you, see also jax_coords below. |
| attrs: See xarray.Dataset. |
| jax_coords: Additional coordinates, which *can* use JAX arrays. These |
| coordinates will be treated as JAX data from the point of view of |
| jax.tree_util, that means when JIT'ing they will be passed as tracers and |
| computation involving them will be JIT'd. |
| Unfortunately a side-effect of this is that they can't be used as index |
| coordinates (because xarray's indexing logic is not JIT-able). If you |
| specify a coordinate with the same name as a dimension here, it will not |
| be set as an index coordinate; this behaviour is different to the default |
| for `coords`, and it means that things like `.sel` based on the jax |
| coordinate will not work. |
| Note we require `jax_coords` to be explicitly specified via a different |
| constructor argument to `coords`, rather than just looking for jax arrays |
| within the `coords` and treating them differently. This is because it |
| affects the way jax.tree_util treats them, which is somewhat orthogonal to |
| whether the value is passed in as numpy or not, and generally needs to be |
| handled consistently so is something we encourage explicit control over. |
| |
| Returns: |
| An instance of xarray.Dataset. Where JAX arrays are used as data, they |
| will be wrapped with JaxArrayWrapper. |
| """ |
| wrapped_data_vars = {} |
| for name, var_like in (data_vars or {}).items(): |
| |
| if isinstance(var_like, _WRAPPED_TYPES): |
| wrapped_data_vars[name] = wrap(var_like) |
| elif isinstance(var_like, tuple): |
| |
| wrapped_data_vars[name] = (var_like[0], wrap(var_like[1])) + var_like[2:] |
| else: |
| |
| |
| |
| wrapped_data_vars[name] = var_like |
|
|
| result = xarray.Dataset( |
| data_vars=wrapped_data_vars, |
| attrs=attrs) |
|
|
| return assign_coords(result, coords=coords, jax_coords=jax_coords) |
|
|
|
|
| DatasetOrDataArray = TypeVar( |
| 'DatasetOrDataArray', xarray.Dataset, xarray.DataArray) |
|
|
|
|
| def assign_coords( |
| x: DatasetOrDataArray, |
| *, |
| coords: Optional[Mapping[Hashable, Any]] = None, |
| jax_coords: Optional[Mapping[Hashable, Any]] = None, |
| ) -> DatasetOrDataArray: |
| """Replacement for assign_coords which works in presence of jax_coords. |
| |
| `jax_coords` allow certain specified coordinates to have their data passed as |
| JAX arrays (including through jax.jit boundaries). The compromise in return is |
| that they are not created as index coordinates and cannot be used for .sel |
| and other coordinate-based indexing operations. See docs for `jax_coords` on |
| xarray_jax.Dataset and xarray_jax.DataArray for more information. |
| |
| This function can be used to set jax_coords on an existing DataArray or |
| Dataset, and also to set a mix of jax and non-jax coordinates. It implements |
| some workarounds to prevent xarray trying and failing to create IndexVariables |
| from jax arrays under the hood. |
| |
| If you have any jax_coords with the same name as a dimension, you'll need to |
| use this function instead of data_array.assign_coords or dataset.assign_coords |
| in general, to avoid an xarray bug where it tries (and in our case fails) to |
| create indexes for existing jax coords. See |
| https://github.com/pydata/xarray/issues/7885. |
| |
| Args: |
| x: An xarray Dataset or DataArray. |
| coords: Dict of (non-JAX) coords, or None if not assigning any. |
| jax_coords: Dict of JAX coords, or None if not assigning any. See docs for |
| xarray_jax.Dataset / DataArray for more information on jax_coords. |
| |
| Returns: |
| The Dataset or DataArray with coordinates assigned, similarly to |
| Dataset.assign_coords / DataArray.assign_coords. |
| """ |
| coords = {} if coords is None else dict(coords) |
| jax_coords = {} if jax_coords is None else dict(jax_coords) |
|
|
| |
| |
| |
| |
| existing_jax_coords = get_jax_coords(x) |
| jax_coords = existing_jax_coords | jax_coords |
| x = x.drop_vars(existing_jax_coords.keys()) |
|
|
| |
| |
| |
| |
| |
| renamed_jax_coords = {} |
| for name, coord in jax_coords.items(): |
| if isinstance(coord, xarray.DataArray): |
| coord = coord.variable |
|
|
| if isinstance(coord, list): |
| coord = np.array(coord) |
|
|
| if isinstance(coord, xarray.Variable): |
| coord = coord.copy(deep=False) |
| elif isinstance(coord, tuple): |
| |
| dims, data = coord |
| coord = Variable(dims, data) |
| elif jnp.isscalar(coord): |
| |
| coord = Variable(dims=(), data=coord) |
| elif isinstance(coord, jax.typing.ArrayLike) and jnp.ndim(coord) == 1: |
| |
| |
| coord = Variable((name,), coord) |
| else: |
| raise ValueError(f'Unsupported value for coordinate {name}') |
|
|
| |
| |
| |
| |
| |
| |
| |
| coord.attrs[_JAX_COORD_ATTR_NAME] = True |
| renamed_jax_coords[f'__NONINDEX_{name}'] = coord |
|
|
| x = x.assign_coords(coords=coords | renamed_jax_coords) |
|
|
| rename_back_mapping = {f'__NONINDEX_{name}': name for name in jax_coords} |
| if isinstance(x, xarray.Dataset): |
| |
| return x.rename_vars(rename_back_mapping) |
| else: |
| return x.rename(rename_back_mapping) |
|
|
|
|
| def get_jax_coords(x: DatasetOrDataArray) -> Mapping[Hashable, Any]: |
| return { |
| name: coord_var |
| for name, coord_var in x.coords.variables.items() |
| if coord_var.attrs.get(_JAX_COORD_ATTR_NAME, False)} |
|
|
|
|
| def assign_jax_coords( |
| x: DatasetOrDataArray, |
| jax_coords: Optional[Mapping[Hashable, Any]] = None, |
| **jax_coords_kwargs |
| ) -> DatasetOrDataArray: |
| """Assigns only jax_coords, with same API as xarray's assign_coords.""" |
| return assign_coords(x, jax_coords=jax_coords or jax_coords_kwargs) |
|
|
|
|
| def wrap(value): |
| """Wraps JAX arrays for use in xarray, passing through other values.""" |
| if isinstance(value, _WRAPPED_TYPES): |
| return JaxArrayWrapper(value) |
| else: |
| return value |
|
|
|
|
| def unwrap(value, require_jax=False): |
| """Unwraps wrapped JAX arrays used in xarray, passing through other values.""" |
| if isinstance(value, JaxArrayWrapper): |
| return value.jax_array |
| elif isinstance(value, jax.Array): |
| return value |
| elif require_jax: |
| raise TypeError(f'Expected JAX array, found {type(value)}.') |
| else: |
| return value |
|
|
|
|
| def _wrapped(func): |
| """Surrounds a function with JAX array unwrapping/wrapping.""" |
| def wrapped_func(*args, **kwargs): |
| args, kwargs = tree.map_structure(unwrap, (args, kwargs)) |
| result = func(*args, **kwargs) |
| return tree.map_structure(wrap, result) |
| return wrapped_func |
|
|
|
|
| def unwrap_data( |
| value: Union[xarray.Variable, xarray.DataArray], |
| require_jax: bool = False |
| ) -> Union[jax.Array, np.ndarray]: |
| """The unwrapped (see unwrap) data of a an xarray.Variable or DataArray.""" |
| return unwrap(value.data, require_jax=require_jax) |
|
|
|
|
| def unwrap_vars( |
| dataset: Mapping[Hashable, xarray.DataArray], |
| require_jax: bool = False |
| ) -> Mapping[str, Union[jax.Array, np.ndarray]]: |
| """The unwrapped data (see unwrap) of the variables in a dataset.""" |
| |
| |
| return {str(name): unwrap_data(var, require_jax=require_jax) |
| for name, var in dataset.items()} |
|
|
|
|
| def unwrap_coords( |
| dataset: Union[xarray.Dataset, xarray.DataArray], |
| require_jax: bool = False |
| ) -> Mapping[str, Union[jax.Array, np.ndarray]]: |
| """The unwrapped data (see unwrap) of the coords in a Dataset or DataArray.""" |
| return {str(name): unwrap_data(var, require_jax=require_jax) |
| for name, var in dataset.coords.items()} |
|
|
|
|
| def jax_data(value: Union[xarray.Variable, xarray.DataArray]) -> jax.Array: |
| """Like unwrap_data, but will complain if not a jax array.""" |
| |
| |
| return cast(jax.Array, unwrap_data(value, require_jax=True)) |
|
|
|
|
| def jax_vars( |
| dataset: Mapping[Hashable, xarray.DataArray]) -> Mapping[str, jax.Array]: |
| """Like unwrap_vars, but will complain if vars are not all jax arrays.""" |
| return cast(Mapping[str, jax.Array], unwrap_vars(dataset, require_jax=True)) |
|
|
|
|
| class JaxArrayWrapper(np.lib.mixins.NDArrayOperatorsMixin): |
| """Wraps a JAX array into a duck-typed array suitable for use with xarray. |
| |
| This uses an older duck-typed array protocol based on __array_ufunc__ and |
| __array_function__ which works with numpy and xarray. (In newer versions |
| of xarray it implements xarray.namedarray._typing._array_function.) |
| |
| This is in the process of being superseded by the Python array API standard |
| (https://data-apis.org/array-api/latest/index.html), but JAX hasn't |
| implemented it yet. Once they have, we should be able to get rid of |
| this wrapper and use JAX arrays directly with xarray. |
| |
| """ |
|
|
| def __init__(self, jax_array): |
| self.jax_array = jax_array |
|
|
| def __array_ufunc__(self, ufunc, method, *args, **kwargs): |
| for x in args: |
| if not isinstance(x, (jax.typing.ArrayLike, type(self))): |
| return NotImplemented |
| if method != '__call__': |
| return NotImplemented |
| try: |
| |
| func = getattr(jnp, ufunc.__name__) |
| except AttributeError: |
| return NotImplemented |
| |
| |
| |
| |
| kwargs.pop('out', None) |
| return _wrapped(func)(*args, **kwargs) |
|
|
| def __array_function__(self, func, types, args, kwargs): |
| try: |
| |
| func = getattr(jnp, func.__name__) |
| except AttributeError: |
| return NotImplemented |
| return _wrapped(func)(*args, **kwargs) |
|
|
| def __repr__(self): |
| return f'xarray_jax.JaxArrayWrapper({repr(self.jax_array)})' |
|
|
| |
| |
|
|
| |
|
|
| @property |
| def shape(self): |
| return self.jax_array.shape |
|
|
| @property |
| def dtype(self): |
| return self.jax_array.dtype |
|
|
| @property |
| def ndim(self): |
| return self.jax_array.ndim |
|
|
| @property |
| def size(self): |
| return self.jax_array.size |
|
|
| @property |
| def real(self): |
| return self.jax_array.real |
|
|
| @property |
| def imag(self): |
| return self.jax_array.imag |
|
|
| |
|
|
| |
| |
| def __array__(self, dtype=None, context=None): |
| return np.asarray(self.jax_array, dtype=dtype) |
|
|
| __getitem__ = _wrapped(lambda array, *args: array.__getitem__(*args)) |
| |
| |
| astype = _wrapped(lambda array, *args, **kwargs: array.astype(*args)) |
|
|
| |
| |
| |
| |
| |
| |
| transpose = _wrapped(jnp.transpose) |
| reshape = _wrapped(jnp.reshape) |
| all = _wrapped(jnp.all) |
|
|
|
|
| def apply_ufunc(func, *args, require_jax=False, **apply_ufunc_kwargs): |
| """Like xarray.apply_ufunc but for jax-specific ufuncs. |
| |
| Many numpy ufuncs will work fine out of the box with xarray_jax and |
| JaxArrayWrapper, since JaxArrayWrapper quacks (mostly) like a numpy array and |
| will convert many numpy operations to jax ops under the hood. For these |
| situations, xarray.apply_ufunc should work fine. |
| |
| But sometimes you need a jax-specific ufunc which needs to be given a |
| jax array as input or return a jax array as output. In that case you should |
| use this helper as it will remove any JaxArrayWrapper before calling the func, |
| and wrap the result afterwards before handing it back to xarray. |
| |
| Args: |
| func: A function that works with jax arrays (e.g. using functions from |
| jax.numpy) but otherwise meets the spec for the func argument to |
| xarray.apply_ufunc. |
| *args: xarray arguments to be mapped to arguments for func |
| (see xarray.apply_ufunc). |
| require_jax: Whether to require that inputs are based on jax arrays or allow |
| those based on plain numpy arrays too. |
| **apply_ufunc_kwargs: See xarray.apply_ufunc. |
| |
| Returns: |
| Corresponding xarray results (see xarray.apply_ufunc). |
| """ |
| def wrapped_func(*maybe_wrapped_args): |
| unwrapped_args = [unwrap(a, require_jax) for a in maybe_wrapped_args] |
| result = func(*unwrapped_args) |
| |
| return jax.tree_util.tree_map(wrap, result) |
| return xarray.apply_ufunc(wrapped_func, *args, **apply_ufunc_kwargs) |
|
|
|
|
| def pmap( |
| fn: Callable[..., Any], |
| dim: str, |
| axis_name: Optional[str] = None, |
| devices=None, |
| backend=None, |
| ) -> Callable[..., Any]: |
| """Wraps a subset of jax.pmap functionality to handle xarray input/output. |
| |
| Constraints: |
| * Any Dataset or DataArray passed to the function must have `dim` as the |
| first dimension. This will be checked. You can ensure this if necessary |
| by calling `.transpose(dim, ...)` beforehand. |
| * All args and return values will be mapped over the first dimension, |
| it will use in_axes=0, out_axes=0. |
| * No support for static_broadcasted_argnums, donate_argnums etc. |
| |
| Args: |
| fn: Function to be pmap'd which takes and returns trees which may contain |
| xarray Dataset/DataArray. Any Dataset/DataArrays passed as input must use |
| `dim` as the first dimension on all arrays. |
| dim: The xarray dimension name corresponding to the first dimension that is |
| pmapped over (pmap is called with in_axes=0, out_axes=0). |
| axis_name: Used by jax to identify the mapped axis so that parallel |
| collectives can be applied. Defaults to same as `dim`. |
| devices: |
| backend: |
| See jax.pmap. |
| |
| Returns: |
| A pmap'd version of `fn`, which takes and returns Dataset/DataArray with an |
| extra leading dimension `dim` relative to what the original `fn` sees. |
| """ |
| return _vmap_or_pmap( |
| fn, dim, axis_name, devices, backend, is_vmap=False, |
| ) |
|
|
|
|
| def vmap( |
| fn: Callable[..., Any], |
| dim: str, |
| axis_name: Optional[str] = None, |
| ) -> Callable[..., Any]: |
| """Similar to pmap, but for vmap.""" |
| return _vmap_or_pmap( |
| fn, dim, axis_name, None, None, is_vmap=True, |
| ) |
|
|
|
|
| def _vmap_or_pmap( |
| fn: Callable[..., Any], |
| dim: str, |
| axis_name: Optional[str] = None, |
| devices=None, |
| backend=None, |
| is_vmap: bool = False, |
| ) -> Callable[..., Any]: |
| """See pmap documentations.""" |
|
|
| input_treedef = None |
| output_treedef = None |
|
|
| def fn_passed_to_pmap(*flat_args): |
| assert input_treedef is not None |
| |
| def check_and_remove_leading_dim(dims): |
| try: |
| index = dims.index(dim) |
| except ValueError: |
| index = None |
| if index != 0: |
| raise ValueError(f'Expected dim {dim} at index 0, found at {index}.') |
| return dims[1:] |
| with dims_change_on_unflatten(check_and_remove_leading_dim): |
| args = jax.tree_util.tree_unflatten(input_treedef, flat_args) |
| result = fn(*args) |
| nonlocal output_treedef |
| flat_result, output_treedef = jax.tree_util.tree_flatten(result) |
| return flat_result |
|
|
| if is_vmap: |
| assert devices is None |
| assert backend is None |
| pmapped_fn = jax.vmap( |
| fn_passed_to_pmap, |
| axis_name=axis_name or dim, |
| in_axes=0, |
| out_axes=0) |
| else: |
| pmapped_fn = jax.pmap( |
| fn_passed_to_pmap, |
| axis_name=axis_name or dim, |
| in_axes=0, |
| out_axes=0, |
| devices=devices, |
| backend=backend) |
|
|
| def result_fn(*args): |
| nonlocal input_treedef |
| flat_args, input_treedef = jax.tree_util.tree_flatten(args) |
| flat_result = pmapped_fn(*flat_args) |
| assert output_treedef is not None |
| |
| |
| with dims_change_on_unflatten(lambda dims: (dim,) + dims): |
| return jax.tree_util.tree_unflatten(output_treedef, flat_result) |
|
|
| return result_fn |
|
|
|
|
| _PyTree = TypeVar('_PyTree') |
|
|
|
|
| def tree_map_variables( |
| func: Callable[[xarray.Variable], xarray.Variable], |
| tree_data: _PyTree) -> _PyTree: |
| """Like jax.tree.map but operates with Variables as leaves. |
| |
| This will work with any jax.tree_util-registered PyTree containing xarray |
| datatypes. All jax data in xarray datatypes is exposed via xarray.Variable |
| nodes by our registered flatten/unflatten functions and hence here too. Note |
| static coordinate data will not be mapped over however. |
| |
| This allows you to see the associated dimensions for each leaf, and to change |
| them. If you change them, it's your responsibility to ensure that when |
| unflattened back into DataArray/Dataset/DataTree the result still makes sense. |
| In particular that any updated shapes are consistent with the shapes of any |
| static (non-jax_coord) coordinates, since these will not be mapped over. |
| |
| Args: |
| func: Function from xarray.Variable to xarray.Variable. |
| tree_data: PyTree to be mapped over. |
| |
| Returns: |
| PyTree with the same structure as `tree_data` but where xarray.Variables |
| within xarray datatypes have been mapped over by `func`. Any leaves outside |
| of xarray datatypes will be unchanged. |
| """ |
| return jax.tree.map( |
| lambda leaf: func(leaf) if isinstance(leaf, xarray.Variable) else leaf, |
| tree_data, |
| is_leaf=lambda x: isinstance(x, xarray.Variable)) |
|
|
|
|
| def tree_map_with_dims( |
| func: Callable[[jax.typing.ArrayLike, tuple[str, ...] | None], |
| jax.typing.ArrayLike], |
| data: _PyTree, |
| ) -> _PyTree: |
| """Like jax.tree.map but also passes in xarray dimensions where known. |
| |
| This is convenient when applying logic to every jax array in some xarray data |
| structure, which wants to be sensitive to the xarray dimension names. |
| Typical examples of this would be jax operations relating to sharding where |
| you may want to map xarray dimension names to sharding axis names. |
| |
| This only supports changing array shapes in limited situations (see below). |
| |
| Unlike tree_map_variables above, this will also map over plain jax arrays |
| that don't occur within xarray.Variable nodes; these will be passed to func |
| with dims=None. |
| |
| Args: |
| func: A function from (jax_array, dims) -> jax_array. dims will correspond |
| to the dimension names of the xarray.Variable containing the jax_array |
| where it occurs within an xarray.Variable, note this includes arrays |
| within xarray.Dataset and xarray.DataArray too. For plain jax arrays that |
| don't occur within an xarray.Variable, dims will be None. |
| The returned jax array should generally be of the same shape as the input. |
| However you can get away with changing the shape of a particular dimension |
| in limited circumstances: when there are no explicit coordinates involving |
| that dimension, or when the only coordinates involving that dimension are |
| jax_coords and you modify their shapes too in a consistent fashion. |
| You are not allowed to change the dimension order, add or remove |
| dimensions. |
| data: Any pytree with jax ArrayLike leaves suitable for use with |
| `jax.tree.map`. Thanks to xarray_jax such pytrees may include xarray |
| datatypes. |
| |
| Returns: |
| A pytree of the same structure as data, with the result of applying func |
| to each jax array found. |
| """ |
| |
| |
| |
| |
| def is_leaf(x): |
| return isinstance(x, xarray.Variable) |
|
|
| def wrapped_func(x): |
| if isinstance(x, xarray.Variable): |
| array = unwrap(x.data) |
| array = func(array, x.dims) |
| return Variable(dims=x.dims, data=array) |
| else: |
| return func(x, None) |
|
|
| return jax.tree_util.tree_map(wrapped_func, data, is_leaf=is_leaf) |
|
|
|
|
| _Carry = TypeVar('_Carry') |
| _X = TypeVar('_X') |
| _Y = TypeVar('_Y') |
|
|
|
|
| def scan(f: Callable[[_Carry, _X], tuple[_Carry, _Y]], |
| init: _Carry, |
| dim: str, |
| xs: _X | None = None, |
| length: int | None = None, |
| reverse: bool = False, |
| unroll: int | bool = 1, |
| ) -> tuple[_Carry, _Y]: |
| """Like jax.lax.scan but supports xarray data. |
| |
| This can handle a jax.tree containing any mix of xarray and plain jax data. |
| It scans along the dimension `dim` for xarray data, and the leading axis for |
| any non-xarray data. These scanned-along dimensions must all be consistent in |
| size. |
| |
| Static coordinates along `dim` in the `xs` will not be present on the `x` |
| argument to `f`, since they would be different on each iteration and we can't |
| pass them through as static data. jax_coords will be passed through correctly |
| however. |
| |
| Static coordinates along `dim` on the `xs` will also not be present on the |
| resulting `ys`, you will need to copy these across yourself if desired. |
| (This one we may be able to fix in future.) |
| |
| Args: |
| f: Function to apply at each step of the scan. This should map |
| (carry, x) -> (carry, y), where `x` is a slice of the `xs` along the `dim` |
| axis (with the `dim` axis and any coordinates using it dropped), and `y` |
| is a slice of the desired output along the `dim` axis, with no `dim` axis |
| itself. |
| x, y and carry can in general be trees, in which the above applies to |
| each leaf of the tree. |
| init: Initial value of the carry. |
| dim: The xarray dimension name to scan along, for xarray data. |
| xs: The input to be scanned. If not provided, will scan over `length` |
| iterations with None passed as the `x` argument to `f`. |
| length: The length of the scan, if `xs` are not provided. |
| reverse: Whether to scan in reverse order. |
| unroll: How many steps to unroll the scan, see jax.lax.scan. |
| |
| Returns: |
| final_carry: The carry returned from the final step of the scan. |
| ys: Data corresponding to the `y` returned from `f` on each step of the |
| scan, concatenated along an extra leading dimension (named `dim` for |
| xarray data). |
| """ |
| if xs is not None: |
| |
| |
| |
| xs = tree_map_variables(lambda v: v.transpose(dim, ...), xs) |
| xs_leaves, xs_treedef = jax.tree.flatten(xs) |
| else: |
| xs_treedef = None |
| xs_leaves = None |
|
|
| y_treedef = None |
|
|
| def scan_fn(carry, x_leaves): |
| if x_leaves is None: |
| x = None |
| else: |
| with dims_change_on_unflatten(lambda dims: dims[1:]): |
| x = jax.tree.unflatten(xs_treedef, x_leaves) |
| carry, y = f(carry, x) |
|
|
| nonlocal y_treedef |
| y_leaves, y_treedef = jax.tree.flatten(y) |
| return carry, y_leaves |
|
|
| final_carry, ys_leaves = jax.lax.scan( |
| scan_fn, |
| init, |
| xs_leaves, |
| length=length, |
| reverse=reverse, |
| unroll=unroll) |
|
|
| assert isinstance(y_treedef, jax.tree_util.PyTreeDef) |
|
|
| with dims_change_on_unflatten(lambda dims: (dim,) + dims): |
| ys = jax.tree.unflatten(y_treedef, ys_leaves) |
|
|
| return final_carry, ys |
|
|
|
|
| |
|
|
|
|
| DimsChangeFn = Callable[[Tuple[Hashable, ...]], Tuple[Hashable, ...]] |
| _DIMS_CHANGE_ON_UNFLATTEN_FN: contextvars.ContextVar[DimsChangeFn] = ( |
| contextvars.ContextVar('dims_change_on_unflatten_fn')) |
|
|
|
|
| @contextlib.contextmanager |
| def dims_change_on_unflatten(dims_change_fn: DimsChangeFn): |
| """Can be used to change the dims used when unflattening arrays into xarrays. |
| |
| This is useful when some axes were added to / removed from the underlying jax |
| arrays after they were flattened using jax.tree_util.tree_flatten, and you |
| want to unflatten them again afterwards using the original treedef but |
| adjusted for the added/removed dimensions. |
| |
| It can also be used with jax.tree_util.tree_map, when it's called with a |
| function that adds/removes axes or otherwise changes the axis order. |
| |
| When dimensions are removed, any coordinates using those removed dimensions |
| will also be removed on unflatten. |
| |
| This is implemented as a context manager that sets some thread-local state |
| affecting the behaviour of our unflatten functions, because it's not possible |
| to directly modify the treedef to change the dims/coords in it (and with |
| tree_map, the treedef isn't exposed to you anyway). |
| |
| Args: |
| dims_change_fn: Maps a tuple of dimension names for the original |
| Variable/DataArray/Dataset that was flattened, to an updated tuple of |
| dimensions which should be used when unflattening. |
| |
| Yields: |
| To a context manager in whose scope jax.tree_util.tree_unflatten and |
| jax.tree_util.tree_map will apply the dims_change_fn before reconstructing |
| xarrays from jax arrays. |
| """ |
| token = _DIMS_CHANGE_ON_UNFLATTEN_FN.set(dims_change_fn) |
| try: |
| yield |
| finally: |
| _DIMS_CHANGE_ON_UNFLATTEN_FN.reset(token) |
|
|
|
|
| def _flatten_variable(v: xarray.Variable) -> Tuple[ |
| Tuple[jax.typing.ArrayLike], Tuple[Hashable, ...]]: |
| """Flattens a Variable for jax.tree_util.""" |
| children = (unwrap_data(v),) |
| aux = v.dims |
| return children, aux |
|
|
|
|
| def _unflatten_variable( |
| aux: Tuple[Hashable, ...], |
| children: Tuple[jax.typing.ArrayLike]) -> xarray.Variable: |
| """Unflattens a Variable for jax.tree_util.""" |
| dims = aux |
| dims_change_fn = _DIMS_CHANGE_ON_UNFLATTEN_FN.get(None) |
| if dims_change_fn: dims = dims_change_fn(dims) |
| return Variable(dims=dims, data=children[0]) |
|
|
|
|
| def _split_static_and_jax_coords( |
| coords: xarray.core.coordinates.Coordinates) -> Tuple[ |
| Mapping[Hashable, xarray.Variable], Mapping[Hashable, xarray.Variable]]: |
| static_coord_vars = {} |
| jax_coord_vars = {} |
| for name, coord in coords.items(): |
| if coord.attrs.get(_JAX_COORD_ATTR_NAME, False): |
| jax_coord_vars[name] = coord.variable |
| else: |
| assert not isinstance(coord, (jax.Array, JaxArrayWrapper)) |
| static_coord_vars[name] = coord.variable |
| return static_coord_vars, jax_coord_vars |
|
|
|
|
| def _drop_with_none_of_dims( |
| coord_vars: Mapping[Hashable, xarray.Variable], |
| dims: Tuple[Hashable, ...]) -> Mapping[Hashable, xarray.Variable]: |
| return {name: var for name, var in coord_vars.items() |
| if set(var.dims) <= set(dims)} |
|
|
|
|
| class _HashableCoords(collections.abc.Mapping): |
| """Wraps a dict of xarray Variables as hashable, used for static coordinates. |
| |
| This needs to be hashable so that when an xarray.Dataset is passed to a |
| jax.jit'ed function, jax can check whether it's seen an array with the |
| same static coordinates(*) before or whether it needs to recompile the |
| function for the new values of the static coordinates. |
| |
| (*) note jax_coords are not included in this; their value can be different |
| on different calls without triggering a recompile. |
| """ |
|
|
| def __init__(self, coord_vars: Mapping[Hashable, xarray.Variable]): |
| self._variables = coord_vars |
|
|
| def __repr__(self) -> str: |
| return f'_HashableCoords({repr(self._variables)})' |
|
|
| def __getitem__(self, key: Hashable) -> xarray.Variable: |
| return self._variables[key] |
|
|
| def __len__(self) -> int: |
| return len(self._variables) |
|
|
| def __iter__(self) -> Iterator[Hashable]: |
| return iter(self._variables) |
|
|
| def __hash__(self): |
| if not hasattr(self, '_hash'): |
| self._hash = hash(frozenset((name, var.data.tobytes()) |
| for name, var in self._variables.items())) |
| return self._hash |
|
|
| def __eq__(self, other): |
| if self is other: |
| return True |
| elif not isinstance(other, type(self)): |
| return NotImplemented |
| elif self._variables is other._variables: |
| return True |
| else: |
| return self._variables.keys() == other._variables.keys() and all( |
| variable.equals(other._variables[name]) |
| for name, variable in self._variables.items()) |
|
|
|
|
| def _flatten_data_array(v: xarray.DataArray) -> Tuple[ |
| |
| Tuple[xarray.Variable, Mapping[Hashable, xarray.Variable]], |
| |
| Tuple[Optional[Hashable], _HashableCoords]]: |
| """Flattens a DataArray for jax.tree_util.""" |
| static_coord_vars, jax_coord_vars = _split_static_and_jax_coords(v.coords) |
| children = (v.variable, jax_coord_vars) |
| aux = (v.name, _HashableCoords(static_coord_vars)) |
| return children, aux |
|
|
|
|
| def _unflatten_data_array( |
| aux: Tuple[Optional[Hashable], _HashableCoords], |
| children: Tuple[xarray.Variable, Mapping[Hashable, xarray.Variable]], |
| ) -> xarray.DataArray: |
| """Unflattens a DataArray for jax.tree_util.""" |
| variable, jax_coord_vars = children |
| name, static_coord_vars = aux |
| if _DIMS_CHANGE_ON_UNFLATTEN_FN.get(None): |
| |
| |
| |
| |
| |
| |
| static_coord_vars = _drop_with_none_of_dims( |
| static_coord_vars, variable.dims) |
| return DataArray( |
| variable, name=name, coords=static_coord_vars, jax_coords=jax_coord_vars) |
|
|
|
|
| def _flatten_dataset(dataset: xarray.Dataset) -> Tuple[ |
| |
| Tuple[Mapping[Hashable, xarray.Variable], |
| Mapping[Hashable, xarray.Variable]], |
| |
| _HashableCoords]: |
| """Flattens a Dataset for jax.tree_util.""" |
| variables = {name: data_array.variable |
| for name, data_array in dataset.data_vars.items()} |
| static_coord_vars, jax_coord_vars = _split_static_and_jax_coords( |
| dataset.coords) |
| children = (variables, jax_coord_vars) |
| aux = _HashableCoords(static_coord_vars) |
| return children, aux |
|
|
|
|
| def _unflatten_dataset( |
| aux: _HashableCoords, |
| children: Tuple[Mapping[Hashable, xarray.Variable], |
| Mapping[Hashable, xarray.Variable]], |
| ) -> xarray.Dataset: |
| """Unflattens a Dataset for jax.tree_util.""" |
| data_vars, jax_coord_vars = children |
| static_coord_vars = aux |
| dataset = xarray.Dataset(data_vars) |
| if _DIMS_CHANGE_ON_UNFLATTEN_FN.get(None): |
| |
| |
| static_coord_vars = _drop_with_none_of_dims( |
| static_coord_vars, dataset.dims) |
| return assign_coords( |
| dataset, coords=static_coord_vars, jax_coords=jax_coord_vars) |
|
|
|
|
| def _flatten_datatree(datatree: xarray.DataTree) -> Tuple[ |
| Tuple[Mapping[str, xarray.DataTree], xarray.Dataset], str | None]: |
| """Flattens a DataTree for jax.tree_util.""" |
| |
| |
| |
| node_dataset = datatree.to_dataset(inherit=False) |
| children = (dict(datatree.children), node_dataset) |
| aux = datatree.name |
| return children, aux |
|
|
|
|
| def _unflatten_datatree( |
| aux: str | None, |
| children: Tuple[Mapping[str, xarray.DataTree], xarray.Dataset], |
| ) -> xarray.DataTree: |
| """Unflattens a DataTree for jax.tree_util.""" |
| children_dict, node_dataset = children |
| name = aux |
| return xarray.DataTree( |
| dataset=node_dataset, children=children_dict, name=name) |
|
|
|
|
| jax.tree_util.register_pytree_node( |
| xarray.Variable, _flatten_variable, _unflatten_variable) |
| |
| |
| |
| jax.tree_util.register_pytree_node( |
| xarray.IndexVariable, _flatten_variable, _unflatten_variable) |
| jax.tree_util.register_pytree_node( |
| xarray.DataArray, _flatten_data_array, _unflatten_data_array) |
| jax.tree_util.register_pytree_node( |
| xarray.Dataset, _flatten_dataset, _unflatten_dataset) |
| jax.tree_util.register_pytree_node( |
| xarray.DataTree, _flatten_datatree, _unflatten_datatree) |
|
|