# Copyright 2024 Google LLC # # 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 # # https://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. """Training utility functions for NeuralGCM.""" import collections from collections import abc import functools import logging import math from typing import ( Any, Callable, Iterable, Iterator, Mapping, Optional, Tuple, TypeVar, Union, ) from dinosaur import pytree_utils from dinosaur import typing import einops import gin import haiku as hk import jax from jax.experimental import mesh_utils import jax.numpy as jnp from model.legacy import optimization import numpy as np import optax # pylint: disable=logging-fstring-interpolation PRNGKeyArray = typing.PRNGKeyArray Array = Union[np.ndarray, jnp.ndarray] PyTree = Any Forcing = typing.Forcing IntOrArray = Union[int, Array] OptState = optimization.OptState ModelParams = Any ModelGradients = ModelParams EMAParams = ModelParams StepAndOptState = Tuple[IntOrArray, OptState] StepOptAndEMAState = Tuple[IntOrArray, OptState, ModelParams] LossValue = Array LossFunction = Callable[[PyTree, PyTree], LossValue] LossAndGradFunction = Callable[ [ModelParams, PRNGKeyArray, PyTree, Forcing], Tuple[LossValue, ModelGradients], ] MetricFunction = Callable[[PyTree, PyTree], Union[Array, Mapping[str, Array]]] TrainStepFunction = Callable[ [PRNGKeyArray, StepAndOptState, PyTree, Forcing], Tuple[StepAndOptState, LossValue], ] EvalStepFunction = Callable[ [ModelParams, PRNGKeyArray, PyTree, Forcing], Mapping[str, Array] ] TrajectoryFunction = Callable[ [ModelParams, PRNGKeyArray, PyTree, Forcing], Tuple[PyTree, PyTree] ] def flatten_dict( inputs: Mapping[str, Any], parent_key: str = '', sep: str = ' ', ) -> Mapping[str, Array]: """Returns a flattened version of `inputs` dictionary.""" items = [] for k, v in inputs.items(): new_key = parent_key + sep + k if parent_key else k if isinstance(v, Mapping): items.extend(flatten_dict(v, new_key, sep=sep).items()) else: items.append((new_key, v)) keys, counts = np.unique(np.array([x[0] for x in items]), return_counts=True) if (counts > 1).any(): raise ValueError(f'got duplicate keys {keys[counts > 1]}') return dict(items) # # Note that all functions below deal with *batched* inputs. # def loss_and_gradient( trajectory_fn: TrajectoryFunction, loss_fn: LossFunction, ) -> LossAndGradFunction: """Returns a function that computes loss and the gradient of the loss. Args: trajectory_fn: a function that accepts `params` and `initial_velocity` and returns a trajectory of velocities. loss_fn: a function that accepts a predicted trajectory and a ground truth trajectory, returning a scalar loss value. Returns: A function that accepts `params, initial_velocity, target_trajectory` and returns the loss and the gradient of the loss. """ def _loss( params: ModelParams, rng: PRNGKeyArray, target_trajectory: PyTree, forcing_data: typing.ForcingData, ) -> LossValue: """Returns loss value and gradient with respect to model parameters.""" _, predicted_trajectory = trajectory_fn( params, rng, target_trajectory, forcing_data ) loss = loss_fn(predicted_trajectory, target_trajectory) # type: ignore return loss return jax.value_and_grad(_loss) def train_step( loss_and_grad_fn: LossAndGradFunction, optimizer: optax.GradientTransformation, ) -> TrainStepFunction: """Returns a function that performs a single training step. Args: loss_and_grad_fn: a function that accepts `params, initial_velocity, target_trajectory` and returns the loss and the gradient of the loss. optimizer: Optax optimizer to update params and internal state. Returns: A function that performs a single training step. """ def _train_step( rng: PRNGKeyArray, step_and_state: StepAndOptState, target_trajectory: PyTree, forcing_data: typing.ForcingData, ) -> Tuple[StepAndOptState, LossValue]: """A function that performs a single training step.""" step, opt_state = step_and_state loss, grad = loss_and_grad_fn( opt_state.params, rng, target_trajectory, forcing_data ) updates, new_state = optimizer.update( grad, opt_state.state, opt_state.params ) new_params = optax.apply_updates(opt_state.params, updates) new_opt_state = OptState(state=new_state, params=new_params) return (step + 1, new_opt_state), loss return _train_step def eval_batch( trajectory_fn: TrajectoryFunction, metric_funcs: Mapping[str, MetricFunction], ) -> EvalStepFunction: """Returns a function that performs a single evaluation step. Args: trajectory_fn: a function that accepts `params` and `initial_velocity` and returns a trajectory of velocities. metric_funcs: a dictionary mapping strings to metric funcutils, each returning either a metric scalar or a dictionary of such. Returns: A function that performs a single evaluation step. """ def _eval_batch( params: ModelParams, rng: PRNGKeyArray, target_trajectory: PyTree, forcing_data: typing.ForcingData, ) -> Mapping[str, Array]: """A function that performs a single evaluation step.""" _, predicted_trajectory = trajectory_fn( params, rng, target_trajectory, forcing_data ) metric_values = { k: metric(predicted_trajectory, target_trajectory) for k, metric in metric_funcs.items() } results = flatten_dict(metric_values) return results return _eval_batch def streaming_mean( rngs: Iterable[PRNGKeyArray], batch_and_forcing: Iterable[Tuple[PyTree, Forcing]], eval_fn: Callable[[PRNGKeyArray, PyTree, Forcing], Mapping[str, Array]], data_preprocess_fn: Callable[..., PyTree] = lambda x: x, ) -> Mapping[str, Array]: """Runs evaluation on `eval_data`. Args: rngs: an iterable of random number keys to be used for evaluation. batch_and_forcing: an iterable of batched velocity trajectories and forcing. eval_fn: a function that performs a single evaluation step. data_preprocess_fn: a preprocessing function be applied to each batch. Returns: A dict mapping strings to metric values. Raises: RuntimeError: if there are no batches to iterate over. """ eval_metrics = collections.defaultdict(float) count = 0 for rng, (batch, forcing) in zip(rngs, batch_and_forcing): batch = data_preprocess_fn(batch) batch_metrics = eval_fn(rng, batch, forcing) for k, v in batch_metrics.items(): eval_metrics[k] += v count += 1 if not count: raise RuntimeError('no batches to iterate over') return {k: v / count for k, v in eval_metrics.items()} @gin.register def identity(batch: Tuple[Array, ...], rng: Array = None) -> Tuple[Array, ...]: # pytype: disable=annotation-type-mismatch # jax-ndarray """Identity preprocessing function that does not modify the `batch`.""" del rng # unused. return batch @gin.configurable def add_noise_to_input_frame( batch: Tuple[Array, ...], rng: Array, scale: float = 1e-2, **kwargs ) -> Tuple[Array, ...]: """Adds noise to the 0th time frame in the `batch`. Args: batch: original batch to which the noise will be added. rng: random number key to be used to generate noise. scale: scale of the normal noise to be added. **kwargs: other keyword arguments. Not used. Returns: batch with noise added along the 0th time slice. """ del kwargs # unused. time_zero_slice = pytree_utils.slice_along_axis(batch, 1, 0) shapes = jax.tree.map(np.shape, time_zero_slice) rngs = jax.random.split(rng, len(jax.tree.leaves(time_zero_slice))) rngs = jax.tree.unflatten(jax.tree.structure(time_zero_slice), rngs) def noise_fn(key, s): return scale * jax.random.truncated_normal(key, -2.0, 2.0, s) noise = jax.tree.map(noise_fn, rngs, shapes) add_noise_fn = lambda x, n: x.at[:, 0, ...].add(n) return jax.tree.map(add_noise_fn, batch, noise) def preprocess( data_iterator: Iterator[Tuple[Array, ...]], rng_stream: Iterator[Array], preprocess_fn: Callable[..., Tuple[Array, ...]], ): """Generator that applies `preprocess_fn` to entries of the `data_iterator`. Args: data_iterator: numpy iterator holding the data. rng_stream: stream of random numbers to be used by `preprocess_fn`. preprocess_fn: preprocessing function to be applied to each batch of data. Yields: Batch of data from `data_iterator` preprocessed with `preprocess_fn`. """ preprocess_fn = jax.jit(preprocess_fn) while True: rng = next(rng_stream) yield preprocess_fn(next(data_iterator), rng) def split_rngs(rngs: PRNGKeyArray, num: int) -> PRNGKeyArray: """Splits `rngs` into `num` along the last batch axis.""" ndim = rngs.ndim split_fn = jax.random.split for _ in range(ndim - 1): split_fn = jax.vmap(split_fn, (0, None), 1) return split_fn(rngs, num) @functools.partial(jax.jit, static_argnames=['batch_shape']) def _split_rmgs_by_batch_shape( rngs: PRNGKeyArray, batch_shape: tuple[int, ...], ) -> PRNGKeyArray: for batch_size in batch_shape[::-1]: rngs = split_rngs(rngs, batch_size) return rngs class BatchedPRNGSequence(Iterator): """Iterator of JAX different random keys split by `batch_shape`.""" def __init__( self, key_or_seed: Union[int, PRNGKeyArray], batch_shape: Optional[Tuple[int, ...]] = None, ): """Creates an instance a class. Args: key_or_seed: Key or seed to initialize the random sequence. batch_shape: Batch shape of the sequence. """ self._key = hk.PRNGSequence(key_or_seed) self.batch_shape = batch_shape def reserve(self, num: int): """Splits an additional ``num`` keys for later use.""" self._key = self._key.reserve(num) def __next__(self): rngs = next(self._key) return _split_rmgs_by_batch_shape(rngs, self.batch_shape) @jax.jit def _combine_rng_seeds(seeds: jax.Array) -> jax.Array: key = jax.random.PRNGKey(seeds[0]) for seed in seeds[1:]: key = jax.random.fold_in(key, seed) return jax.random.bits(key, shape=(), dtype=jnp.uint32) def combine_rng_seeds(*seeds: int) -> int: """Combine uint32 seeds into a single Python integer RNG seed.""" # Put the seeds on the first CPU device so that JAX runs the entire # computation on the CPU. seeds = jax.device_put( np.array(seeds), device=jax.local_devices(backend='cpu')[0] ) return int(_combine_rng_seeds(seeds)) def ensure_sharded_rng_key( rng_key: jax.Array, *, mesh: jax.sharding.Mesh ) -> jax.Array: """Ensure that a batched PRNG key is sharded across all devices.""" spec = P('batch', 'ensemble', None) sharding = jax.sharding.NamedSharding(mesh, spec) return jax.lax.with_sharding_constraint(rng_key, sharding) def get_tpu_physical_mesh_shape() -> tuple[int, int, int] | None: """Get the shape of the TPU connectivity torus for v4 or v5 chips.""" jax_devices = jax.devices() try: device_coords = [d.coords for d in jax_devices] except AttributeError: return None # no "coords" attribute (e.g., using CPU devices) dims = tuple(d + 1 for d in max(device_coords)) if len(dims) != 3 or math.prod(dims) != len(jax_devices): return None return dims # dict of dicts of indicating how to rearrange from physical TPU mesh layouts # (X, Y, Z) into logical mesh layouts (batch, ensemble, z, x, y) with # einops.rearrange for model training. # {tpu_topology: {(ensemble_shards, z_shard, x_shards, y_shards): ...}} _TPU_LAYOUT_REARRANGEMENTS = { '2x2x2': { (1, 1, 1, 1): 'b0 b1 b2 -> (b0 b1 b2) () () () ()', (1, 2, 1, 1): 'z b0 b1 -> (b0 b1) () z () ()', (2, 1, 1, 1): 'e b0 b1 -> (b0 b1) e () () ()', }, '2x2x4': { (1, 1, 1, 1): 'b0 b1 b2 -> (b0 b1 b2) () () () ()', (1, 2, 1, 1): 'z b0 b1 -> (b0 b1) () z () ()', (1, 4, 1, 1): 'b0 b1 z -> (b0 b1) () z () ()', (2, 1, 1, 1): 'e b0 b1 -> (b0 b1) e () () ()', }, '2x4x4': { (1, 1, 1, 1): 'b0 b1 b2 -> (b0 b1 b2) () () () ()', (1, 2, 1, 1): 'z b0 b1 -> (b0 b1) () z () ()', (1, 4, 1, 1): 'b0 b1 z -> (b0 b1) () z () ()', (2, 1, 1, 1): 'e b0 b1 -> (b0 b1) e () () ()', (2, 2, 1, 1): 'z (b0 e) b1 -> (b0 b1) e z () ()', }, '4x4x4': { (1, 1, 1, 1): 'b0 b1 b2 -> (b0 b1 b2) () () () ()', (1, 2, 1, 1): '(b0 z) b1 b2 -> (b0 b1 b2) () z () ()', (1, 4, 1, 1): 'b0 b1 z -> (b0 b1) () z () ()', (1, 2, 2, 1): '(b0 z) (b1 x) b2 -> (b0 b1 b2) () z x ()', (2, 1, 1, 1): '(b0 e) b1 b2 -> (b0 b1 b2) e () () ()', (2, 2, 1, 1): '(b0 e) (b1 z) b2 -> (b0 b1 b2) e z () ()', (2, 2, 2, 1): '(b0 e) (b1 z) (b2 x) -> (b0 b1 b2) e z x ()', }, '4x4x8': { (1, 1, 1, 1): 'b0 b1 b2 -> (b0 b1 b2) () () () ()', (1, 4, 2, 1): 'z (b0 x) b1 -> (b0 b1) () z x ()', (1, 4, 2, 2): 'z (b0 x) (b1 y) -> (b0 b1) () z x y', (2, 4, 2, 1): 'z (b0 x) (b1 e) -> (b0 b1) e z x ()', }, '4x8x8': { (1, 1, 1, 1): 'b0 b1 b2 -> (b0 b1 b2) () () () ()', (1, 4, 2, 1): 'z (b0 x) b1 -> (b0 b1) () z x ()', (1, 4, 2, 2): 'z (b0 x) (b1 y) -> (b0 b1) () z x y', (2, 4, 2, 1): 'z (b0 e) (b1 x) -> (b0 b1) e z x ()', (2, 4, 2, 2): 'z (b0 e x) (b1 y) -> (b0 b1) e z x y', }, '2x2x1': { (1, 1, 1, 1): 'b0 b1 () -> (b0 b1) () () () ()', (1, 2, 1, 1): 'z b0 () -> b0 () z () ()', (2, 1, 1, 1): 'e b0 () -> b0 e () () ()', (2, 2, 1, 1): 'e z () -> () e z () ()', }, '2x4x1': { (1, 1, 1, 1): 'b0 b1 () -> (b0 b1) () () () ()', (1, 2, 1, 1): 'z b0 () -> b0 () z () ()', (2, 1, 1, 1): 'e b0 () -> b0 e () () ()', (2, 2, 1, 1): 'z (b0 e) -> b0 e z () ()', }, '4x4x1': { (1, 1, 1, 1): 'b0 b1 () -> (b0 b1) () () () ()', (1, 2, 1, 1): '(b0 z) b1 () -> (b0 b1) () z () ()', (2, 1, 1, 1): '(b0 e) b1 () -> (b0 b1) e () () ()', (2, 2, 1, 1): '(b0 e) (b1 z) () -> (b0 b1) e z () ()', }, '4x8x1': { (1, 1, 1, 1): 'b0 b1 () -> (b0 b1) () () () ()', (1, 2, 1, 1): '(b0 z) b1 () -> (b0 b1) () z () ()', (1, 2, 2, 1): '(b0 z) (b1 x) () -> (b0 b1) () z x ()', (2, 1, 1, 1): '(b0 e) b1 () -> (b0 b1) e () () ()', (2, 2, 1, 1): '(b0 e) (b1 z) () -> (b0 b1) e z () ()', }, '8x8x1': { (1, 1, 1, 1): 'b0 b1 () -> (b0 b1) () () () ()', (1, 2, 1, 1): '(b0 z) b1 () -> (b0 b1) () z () ()', (1, 2, 2, 1): '(b0 z) (b1 x) () -> (b0 b1) () z x ()', (2, 1, 1, 1): '(b0 e) b1 () -> (b0 b1) e () () ()', (2, 2, 1, 1): '(b0 e) (b1 z) () -> (b0 b1) e z () ()', (2, 2, 2, 1): '(b0 e z) (b1 x) () -> (b0 b1) e z x ()', }, '8x16x1': { (1, 1, 1, 1): 'b0 b1 () -> (b0 b1) () () () ()', (1, 2, 1, 1): '(b0 z) b1 () -> (b0 b1) () z () ()', (1, 2, 2, 1): '(b0 z) (b1 x) () -> (b0 b1) () z x ()', (2, 1, 1, 1): '(b0 e) b1 () -> (b0 b1) e () () ()', (2, 2, 1, 1): '(b0 e) (b1 z) () -> (b0 b1) e z () ()', (2, 2, 2, 1): '(b0 e z) (b1 x) () -> (b0 b1) e z x ()', }, } def create_spmd_mesh(sizes: dict[str, int]) -> jax.sharding.Mesh: """Create an SPMD mesh suitable for data & model parallelism. Args: sizes: dictionary mapping from dimension names (batch, z, x, and y) to the number of devices desired along that axis in the parallel mesh. Returns: Mesh with axis names ['batch', 'ensemble', 'x', 'y', 'z'] and the desired axis sizes. """ axis_names = ['batch', 'ensemble', 'z', 'x', 'y'] for name in sizes: if name not in axis_names: raise ValueError(f'unrecognized {name!r} not in {axis_names}') logical_mesh_shape = tuple( sizes.get(axis_name, 1) for axis_name in axis_names ) if math.prod(logical_mesh_shape) != jax.device_count(): raise ValueError( f'{logical_mesh_shape=} is incompatible with {jax.device_count()=}' ) physical_mesh_shape = get_tpu_physical_mesh_shape() if physical_mesh_shape is None: try: # only succeeds if the logical mesh shape perfectly matches the physical # mesh, e.g., in the case of pure data parallelism mesh_devices = mesh_utils.create_device_mesh(logical_mesh_shape) except (AssertionError, NotImplementedError): mesh_devices = np.reshape(jax.devices(), logical_mesh_shape) else: devices = np.empty(physical_mesh_shape, dtype=object) for device in jax.devices(): devices[tuple(device.coords)] = device topology = 'x'.join(map(str, physical_mesh_shape)) logical_mesh_shape = tuple( sizes[dim] for dim in ['ensemble', 'z', 'x', 'y'] ) rearrangement = _TPU_LAYOUT_REARRANGEMENTS[topology][logical_mesh_shape] abbreviated_sizes = { 'e': sizes['ensemble'], 'z': sizes['z'], 'x': sizes['x'], 'y': sizes['y'], } abbreviated_sizes = {k: v for k, v in abbreviated_sizes.items() if v != 1} mesh_devices = einops.rearrange(devices, rearrangement, **abbreviated_sizes) return jax.sharding.Mesh(mesh_devices, axis_names) P = jax.sharding.PartitionSpec def make_distributed_array_from_local_arrays( pytree: PyTree, mesh: jax.sharding.Mesh, spatial_partitions: jax.sharding.PartitionSpec, global_batch_size: int, ) -> PyTree: """Creates a pytree of global jax arrays for data/model parallelsm. This function exists for loading spatially partitioned data, which is assumed to be replicated across the ensemble dimension. Args: pytree: PyTree of NumPy arrays to convert into distributed JAX arrays. The leading "batch" dimension is divided between different local devices. mesh: SPDM sharding mesh. spatial_partitions: JAX partition spec (of length 3) to use for partitioning spatial dimensions (z, x, y). global_batch_size: number distinct examples in a single batch across all devices. Does not include the ensemble. Returns: Pytree with the same structure as the inputs, but with arrays replaced by distributed JAX arrays. """ if len(spatial_partitions) != 3: raise ValueError(f'invalid {spatial_partitions=}') def get_shard_count(spec_part: None | str | tuple[str, ...]) -> int: # calculate the number of shards corresponding to an element in a # PartitionSpec if spec_part is None: return 1 elif isinstance(spec_part, str): return mesh.shape[spec_part] else: return math.prod(mesh.shape[x] for x in spec_part) def shard_array(x: np.ndarray) -> jax.Array: if x.ndim <= 3: # handle sim_time [batch] global_shape = (global_batch_size,) + x.shape[1:] partition_spec = P('batch', *([None] * (x.ndim - 1))) elif x.ndim == 4: # This is currently needed to handle surface data that has shape: # [batch, time, x, y]. _, x_shards, y_shards = map(get_shard_count, spatial_partitions) global_shape = ( global_batch_size, x.shape[1], x.shape[2] * x_shards, x.shape[3] * y_shards, ) partition_spec = P('batch', None, *spatial_partitions[1:]) else: # everything else has dimensions [batch, time, z, x, y] assert x.ndim == 5, x.shape z_shards, x_shards, y_shards = map(get_shard_count, spatial_partitions) if x.shape[2] == 1: z_shards = 1 global_shape = ( global_batch_size, x.shape[1], x.shape[2] * z_shards, x.shape[3] * x_shards, x.shape[4] * y_shards, ) partition_spec = P('batch', None, *spatial_partitions) sharding = jax.sharding.NamedSharding(mesh, partition_spec) single_device_arrays = put_to_devices(x, jax.local_devices(), axis=0) return jax.make_array_from_single_device_arrays( global_shape, sharding, single_device_arrays ) try: return jax.tree_util.tree_map(shard_array, pytree) except Exception as e: shape_tree = jax.tree_util.tree_map(jnp.shape, pytree) raise RuntimeError( f'failed to shard arrays with shapes {shape_tree!r}' ) from e def put_to_devices( host_array: np.ndarray, local_devices: abc.Sequence[Any], axis: int ) -> list[Any]: """Transfers a host array to local devices, split on the first dimension.""" local_device_count = len(local_devices) try: per_device_arrays = np.split(host_array, local_device_count, axis=axis) except ValueError as array_split_error: raise ValueError( f'Unable to put to devices shape {host_array.shape} with ' f'local device count {local_device_count}' ) from array_split_error device_buffers = [ jax.device_put(arr, d) for arr, d in zip(per_device_arrays, local_devices) ] return device_buffers def ensure_replicated(pytree: PyTree, *, mesh: jax.sharding.Mesh) -> PyTree: """Ensure that a pytree is replicated across all devices.""" def replicate(x): x = jnp.asarray(x) spec = jax.sharding.PartitionSpec(*([None] * x.ndim)) sharding = jax.sharding.NamedSharding(mesh, spec) return jax.lax.with_sharding_constraint(x, sharding) return jax.tree_util.tree_map(replicate, pytree) T = TypeVar('T') def jit_once(f: T, **jit_kwargs) -> T: """Like jax.jit, but raises an error instead of compiling multiple times.""" compiled = None def g(*args, **kwargs): nonlocal compiled if compiled is None: logging.info(f'lowering {f}') lowered = jax.jit(f, **jit_kwargs).lower(*args, **kwargs) logging.info(f'compiling {f}') compiled = lowered.compile() logging.info(f'finishing compiling {f}') return compiled(*args, **kwargs) return g