Source code for torch_harmonics.distributed.primitives

# coding=utf-8

# SPDX-FileCopyrightText: Copyright (c) 2022 The torch-harmonics Authors. All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
from typing import List

import torch
import torch.distributed as dist

from torch_harmonics.quadrature import latitude_support_band
from torch_harmonics.utils import check

from ._amp_utils import _custom_fwd, _custom_setup_context
from .utils import azimuth_group, azimuth_group_size, is_distributed_azimuth, is_distributed_polar, polar_group, polar_group_rank, polar_group_size
from .utils import config as thd_config


[docs] @torch.compiler.disable() def get_group_neighbors(group): """Return the ``(prev_rank, next_rank)`` global ranks of the immediate neighbours in ``group``. Ranks wrap around cyclically, so rank 0's predecessor is the last rank in the group. Parameters ---------- group : torch.distributed.ProcessGroup The process group to query. Returns ------- tuple[int, int] ``(prev_rank, next_rank)`` as global ranks. """ group_size = dist.get_world_size(group) global_rank = dist.get_rank() group_ranks = dist.get_process_group_ranks(group) my_rank_id = group_ranks.index(global_rank) prev_rank = group_ranks[(my_rank_id - 1) % group_size] next_rank = group_ranks[(my_rank_id + 1) % group_size] return prev_rank, next_rank
def _check_shapes(msg, shapes_gather, shapes_expected): for idx, (size_gather, size_expected) in enumerate(zip(shapes_gather, shapes_expected)): if size_gather != size_expected: raise ValueError(f"{msg} shapes are not correct. Expected {size_expected}, got {size_gather} for index {idx}. Please check that the number of chunks is correct.") # helper routine to compute uneven splitting in balanced way:
[docs] def compute_split_shapes(size: int, num_chunks: int) -> List[int]: r""" Compute balanced chunk sizes for distributing a dimension across ranks. Divides ``size`` elements into ``num_chunks`` pieces that differ by at most one element. The first ``size % num_chunks`` chunks receive one extra element; the remaining chunks get the base size ``size // num_chunks``. This is used internally by every distributed module to determine how latitudes, longitudes, and spectral modes are partitioned across process groups. Parameters ---------- size : int Total number of elements to split (e.g.\ ``nlat`` or ``nlon``). num_chunks : int Number of chunks (typically the process-group size). Returns ------- List[int] Per-rank chunk sizes, ordered by rank. Raises ------ RuntimeError If ``size < num_chunks`` (every chunk must be non-empty). Examples -------- >>> from torch_harmonics.distributed import compute_split_shapes >>> compute_split_shapes(256, 4) [64, 64, 64, 64] >>> compute_split_shapes(128, 3) [43, 43, 42] >>> compute_split_shapes(10, 4) [3, 3, 2, 2] """ check(size >= num_chunks, lambda: f"Cannot split {size} elements into {num_chunks} chunks; every chunk must be non-empty.") base, remainder = divmod(size, num_chunks) return [base + 1] * remainder + [base] * (num_chunks - remainder)
[docs] def split_tensor_along_dim(tensor, dim, num_chunks): r""" Split a tensor along a given dimension into balanced chunks. Uses :func:`compute_split_shapes` to determine chunk sizes, so the split is consistent with the partitioning used by all distributed modules in torch-harmonics. Chunk sizes differ by at most one element. Parameters ---------- tensor : torch.Tensor The tensor to split. dim : int The dimension along which to split. num_chunks : int Number of chunks (typically the process-group size). Returns ------- tuple[torch.Tensor, ...] A tuple of ``num_chunks`` tensor views. Raises ------ RuntimeError If ``dim`` is out of range or ``tensor.shape[dim] < num_chunks``. Examples -------- >>> import torch >>> from torch_harmonics.distributed import split_tensor_along_dim >>> x = torch.arange(10).unsqueeze(0) # shape (1, 10) >>> parts = split_tensor_along_dim(x, dim=1, num_chunks=3) >>> [p.shape[1] for p in parts] [4, 3, 3] """ check(dim < tensor.dim(), lambda: f"Error, tensor dimension is {tensor.dim()} which cannot be split along {dim}") check(tensor.shape[dim] >= num_chunks, lambda: f"Error, cannot split dim {dim} of size {tensor.shape[dim]} into {num_chunks} chunks. Empty slices are currently not supported.") # get split sections = compute_split_shapes(tensor.shape[dim], num_chunks) tensor_list = torch.split(tensor, sections, dim=dim) return tensor_list
[docs] def flatten_and_pad_leading_dims(tensor: torch.Tensor, min_leading_size: int, num_trailing_dims: int = 2): """Collapse all but the trailing ``num_trailing_dims`` dims into a single leading dim, padding it to at least ``min_leading_size``. The distributed (S)HT redistributes this leading ("channel/batch") axis across the process grid via all-to-all transposes, which require every rank to receive a non-empty chunk -- i.e. the leading dim must be at least the (largest) group size. Uneven splits are fine (e.g. 5 elements over 4 ranks -> [2, 1, 1, 1]), so we only pad when the leading dim is *smaller* than the group size, never up to a multiple of it. Since the transforms are linear, zero-padding leaves the real entries untouched; :func:`unpad_and_unflatten_leading_dims` restores the original layout afterwards. Parameters ---------- tensor : torch.Tensor Tensor whose last ``num_trailing_dims`` dims are the transform dims (everything before them is flattened into the leading axis). min_leading_size : int Minimum size the flattened leading dim must reach. Pass ``max(comm_size_polar, comm_size_azimuth)`` -- both transpose directions split this same axis, so it must be at least as large as the larger group. num_trailing_dims : int Number of trailing dims to keep intact. ``2`` for the scalar SHT (``nlat, nlon``); ``3`` for the vector SHT (``2, nlat, nlon``), so the component axis is preserved. Returns ------- tensor : torch.Tensor Flattened (and possibly zero-padded) contiguous tensor with shape ``(M_pad, *trailing)``. lead_shape : torch.Size The original leading dims, used to restore the shape later. lead_size : int The true (pre-pad) flattened leading size, used to slice off the padding. """ # complex inputs (the inverse SHTs call this on their spectral input) are processed as # their real view, see unpad_and_unflatten_leading_dims for the rationale is_complex = tensor.is_complex() if is_complex: tensor = torch.view_as_real(tensor) num_trailing_dims += 1 lead_shape = tensor.shape[:-num_trailing_dims] tensor = tensor.reshape(-1, *tensor.shape[-num_trailing_dims:]) lead_size = tensor.shape[0] if lead_size < min_leading_size: # new_zeros preserves dtype and device zeros = tensor.new_zeros((min_leading_size - lead_size, *tensor.shape[1:])) tensor = torch.cat([tensor, zeros], dim=0) tensor = tensor.contiguous() return (torch.view_as_complex(tensor) if is_complex else tensor), lead_shape, lead_size
[docs] def unpad_and_unflatten_leading_dims(tensor: torch.Tensor, lead_shape, lead_size: int, num_trailing_dims: int = 2) -> torch.Tensor: """Inverse of :func:`flatten_and_pad_leading_dims`: drop the padding rows and restore the leading dims. The trailing ``num_trailing_dims`` dims are taken from ``tensor`` as-is, so this is valid even when the transform changed them (e.g. ``nlat, nlon`` -> ``lmax, mmax``). ``num_trailing_dims`` must match the value passed to the flatten call. Complex inputs (the forward SHTs call this on their spectral output) are processed as their real view: this is pure layout code touching only the leading dims, and a copy over a complex buffer cannot be codegen'd by inductor -- triton has no complex type, so it fails with ``KeyError: 'complex64'``. ``view_as_real`` appends the re/im pair as a trailing dim, which is simply carried along; the ``contiguous()`` right before the ``view_as_complex`` supplies the unit last-dim stride the latter requires. """ is_complex = tensor.is_complex() if is_complex: tensor = torch.view_as_real(tensor) num_trailing_dims += 1 tensor = tensor.narrow(0, 0, lead_size) out = tensor.reshape(*lead_shape, *tensor.shape[-num_trailing_dims:]).contiguous() return torch.view_as_complex(out) if is_complex else out
def _needs_equal_shapes(group) -> bool: """ Whether the group's backend requires every rank's tensor to have the same shape. gloo's all_gather and all_to_all reject tensor lists whose entries differ in shape, and the decompositions here split unevenly whenever an extent does not divide by the group size. NCCL handles such lists natively, so its calls must stay untouched: the padded path below is for gloo alone, and only when the shapes actually differ. """ return dist.get_backend(group) == dist.Backend.GLOO def _pad_to(tensor: torch.Tensor, sizes: dict) -> torch.Tensor: """Zero-pad ``tensor`` at the end of each dimension ``d`` in ``sizes`` up to ``sizes[d]``.""" for dim, size in sizes.items(): missing = size - tensor.shape[dim] if missing > 0: pad_shape = list(tensor.shape) pad_shape[dim] = missing tensor = torch.cat([tensor, tensor.new_zeros(pad_shape)], dim=dim) return tensor.contiguous() def _empty_padded(like: torch.Tensor, sizes: dict) -> torch.Tensor: """An uninitialised receive buffer shaped like ``like``, but ``sizes[d]`` long along each dimension ``d`` in ``sizes``.""" shape = list(like.shape) for dim, size in sizes.items(): shape[dim % like.dim()] = size return like.new_empty(shape) def _trim_to(tensor: torch.Tensor, sizes: dict) -> torch.Tensor: """The leading ``sizes[d]`` entries of ``tensor`` along each dimension ``d`` in ``sizes``; the inverse of :func:`_pad_to`.""" slicer = [slice(None)] * tensor.dim() for dim, size in sizes.items(): slicer[dim] = slice(0, size) return tensor[tuple(slicer)].contiguous() def _transpose(tensor, dim0, dim1, dim1_split_sizes, group=None, async_op=False, verify_shapes=None): if verify_shapes is None: verify_shapes = thd_config.debug # get comm params comm_size = dist.get_world_size(group=group) comm_rank = dist.get_rank(group=group) if comm_size == 1: return [tensor], [tensor.size(dim0)], None # verify_shapes: check that dim1_split_sizes are correct: if verify_shapes: dim0_size = tensor.size(dim0) stens = torch.as_tensor([tensor.size(dim1)], dtype=torch.int64, device=tensor.device) stens_gather = [torch.empty_like(stens) for _ in range(comm_size)] stens_gather[comm_rank] = stens dist.all_gather(stens_gather, stens, group=group) sizes_gather = [stens.item() for stens in stens_gather] _check_shapes("_transpose: error, dim1_split_sizes", sizes_gather, dim1_split_sizes) # split and local transposition tsplit = split_tensor_along_dim(tensor, num_chunks=comm_size, dim=dim0) x_send = [y.contiguous() for y in tsplit] x_send_shapes = [x.shape for x in x_send] x_recv = [] x_shape = list(x_send_shapes[comm_rank]) for dim1_len in dim1_split_sizes: x_shape[dim1] = dim1_len x_recv.append(torch.empty(x_shape, dtype=tensor.dtype, device=tensor.device)) # get dim0 split sizes dim0_split_sizes = [x[dim0] for x in x_send_shapes] # global transposition uneven = len(set(dim0_split_sizes)) > 1 or len(set(dim1_split_sizes)) > 1 if uneven and _needs_equal_shapes(group): # gloo: pad every chunk to the largest extent along both split dimensions, exchange, # and trim each received chunk back. The maxima come from the global split shapes, # so every rank pads identically. Synchronous, since the trim needs the data. max_sizes = {dim0: max(dim0_split_sizes), dim1: max(dim1_split_sizes)} x_send_padded = [_pad_to(x, max_sizes) for x in x_send] x_recv_padded = [_empty_padded(x, max_sizes) for x in x_recv] dist.all_to_all(x_recv_padded, x_send_padded, group=group) x_recv = [_trim_to(xp, {dim0: x.shape[dim0], dim1: x.shape[dim1]}) for xp, x in zip(x_recv_padded, x_recv)] req = None else: req = dist.all_to_all(x_recv, x_send, group=group, async_op=async_op) if verify_shapes: stens = torch.as_tensor([x_send[comm_rank].size(dim0)], dtype=torch.int64, device=tensor.device) stens_gather = [torch.empty_like(stens) for _ in range(comm_size)] stens_gather[comm_rank] = stens dist.all_gather(stens_gather, stens, group=group) sizes_gather = [stens.item() for stens in stens_gather] _check_shapes("_transpose: error, dim0_split_sizes", sizes_gather, dim0_split_sizes) if sum(sizes_gather) != dim0_size: raise ValueError(f"_transpose: error, dim0_split_sizes do not sum to the correct size. Expected {dim0_size}, got {torch.sum(sizes_gather)}") return x_recv, dim0_split_sizes, req class _DistributeTransposeAzimuth(torch.autograd.Function): @staticmethod @_custom_fwd(device_type="cuda") def forward(x, dims, dim1_split_sizes): x = x.contiguous() if not is_distributed_azimuth(): return x xlist, _, _ = _transpose(x, dims[0], dims[1], dim1_split_sizes, group=azimuth_group()) return torch.cat(xlist, dim=dims[1]).contiguous() @staticmethod @_custom_setup_context(device_type="cuda") def setup_context(ctx, inputs, output): x, dims, _ = inputs ctx.dims = dims if is_distributed_azimuth(): ctx.dim0_split_sizes = compute_split_shapes(x.shape[dims[0]], azimuth_group_size()) @staticmethod @torch.amp.custom_bwd(device_type="cuda") def backward(ctx, go): go = go.contiguous() if not is_distributed_azimuth(): return go, None, None dims = ctx.dims dim0_split_sizes = ctx.dim0_split_sizes gilist, _, _ = _transpose(go, dims[1], dims[0], dim0_split_sizes, group=azimuth_group()) gi = torch.cat(gilist, dim=dims[0]).contiguous() return gi, None, None class _DistributeTransposePolar(torch.autograd.Function): @staticmethod @_custom_fwd(device_type="cuda") def forward(x, dims, dim1_split_sizes): x = x.contiguous() if not is_distributed_polar(): return x xlist, _, _ = _transpose(x, dims[0], dims[1], dim1_split_sizes, group=polar_group()) return torch.cat(xlist, dim=dims[1]).contiguous() @staticmethod @_custom_setup_context(device_type="cuda") def setup_context(ctx, inputs, output): x, dims, _ = inputs ctx.dims = dims if is_distributed_polar(): ctx.dim0_split_sizes = compute_split_shapes(x.shape[dims[0]], polar_group_size()) @staticmethod @torch.amp.custom_bwd(device_type="cuda") def backward(ctx, go): go = go.contiguous() if not is_distributed_polar(): return go, None, None dims = ctx.dims dim0_split_sizes = ctx.dim0_split_sizes gilist, _, _ = _transpose(go, dims[1], dims[0], dim0_split_sizes, group=polar_group()) gi = torch.cat(gilist, dim=dims[0]).contiguous() return gi, None, None # we need those additional primitives for distributed matrix multiplications # dtypes whose accumulation error a cross-rank reduction would make worse than the local # one, so the collective is run in fp32 instead _LOW_PRECISION_DTYPES = (torch.float16, torch.bfloat16) def _reduction_dtype(dtype, use_fp32): """Working dtype for a collective reduction. ``use_fp32`` promotes low-precision inputs so that accumulating across ranks does not lose precision the local reduction had kept. It must only ever *promote*: reducing an fp64 tensor in fp32 would silently cap the result at fp32 accuracy, which is the opposite of what the flag is for. Anything already at fp32 or wider is left alone. """ if use_fp32 and dtype in _LOW_PRECISION_DTYPES: return torch.float32 return dtype def _reduce(input_, use_fp32=True, group=None): # Bypass the function if we are using only 1 GPU. if dist.get_world_size(group=group) == 1: return input_ dtype = input_.dtype work_dtype = _reduction_dtype(dtype, use_fp32) # dist.all_reduce is in-place on its tensor argument; the .clone() forces a fresh # buffer so we never mutate the caller's tensor (which would alias an autograd- # tracked value when the input is fp32 + contiguous). if work_dtype != dtype: inputf_ = input_.to(work_dtype).contiguous().clone() dist.all_reduce(inputf_, group=group) input_ = inputf_.to(dtype) else: input_ = input_.contiguous().clone() dist.all_reduce(input_, group=group) return input_ def _split(input_, dim_, group=None): # Bypass the function if we are using only 1 GPU. comm_size = dist.get_world_size(group=group) if comm_size == 1: return input_ # Split along last dimension. input_list = split_tensor_along_dim(input_, dim_, comm_size) # Note: torch.split does not create contiguous tensors by default. rank = dist.get_rank(group=group) output = input_list[rank] return output def _gather(input_, dim_, shapes_, group=None, verify_shapes=None): if verify_shapes is None: verify_shapes = thd_config.debug comm_size = dist.get_world_size(group=group) if comm_size == 1: return input_ if (shapes_ is not None) and (len(shapes_) != comm_size): raise ValueError(f"Error, shapes_ is not correct. Expected {comm_size}, got {len(shapes_)}. Please check that the number of chunks is correct.") if dim_ >= input_.dim(): raise ValueError(f"Error, dim_ is not correct. Expected {input_.dim()}, got {dim_}. Please check that the dimension is correct.") # verify shapes: if verify_shapes and shapes_ is not None: comm_rank = dist.get_rank(group=group) stens = torch.as_tensor([input_.size(dim_)], dtype=torch.int64, device=input_.device) stens_gather = [torch.empty_like(stens) for _ in range(comm_size)] stens_gather[comm_rank] = stens dist.all_gather(stens_gather, stens, group=group) sizes_gather = [stens.item() for stens in stens_gather] _check_shapes("_gather: error, shapes_", sizes_gather, shapes_) # make contiguous: input_ = input_.contiguous() input_shape = list(input_.shape) if shapes_ is None: # gather shapes across ranks comm_rank = dist.get_rank(group=group) stens = torch.as_tensor([input_.size(dim_)], dtype=torch.int64, device=input_.device) stens_gather = [torch.empty_like(stens) for _ in range(comm_size)] stens_gather[comm_rank] = stens dist.all_gather(stens_gather, stens, group=group) shapes_ = [stens.item() for stens in stens_gather] # now create the recv list input_list = [] for src in range(comm_size): input_shape[dim_] = shapes_[src] input_list.append(torch.empty(input_shape, dtype=input_.dtype, device=input_.device)) # gather data across ranks if len(set(shapes_)) > 1 and _needs_equal_shapes(group): # gloo: pad every rank's piece to the largest along dim_, gather, trim each back max_size = {dim_: max(shapes_)} gathered = [_empty_padded(x, max_size) for x in input_list] dist.all_gather(gathered, _pad_to(input_, max_size), group=group) input_list = [_trim_to(g, {dim_: size}) for g, size in zip(gathered, shapes_)] else: dist.all_gather(input_list, input_, group=group) # concatenate along dim output = torch.cat(input_list, dim=dim_) return output def _reduce_scatter(input_, dim_, use_fp32=True, group=None): """Reduce-scatter along ``dim_`` across the given group. Handles uneven splits (compute_split_shapes can produce per-rank chunk sizes that differ by one) by padding each chunk to max_chunk before the collective and trimming the per-rank output after. NCCL's reduce_scatter requires equal-sized chunks across ranks; without padding the call would silently read past short chunks' allocations, which manifests as corruption that surfaces many steps later as ``invalid memory address`` errors in unrelated kernels. ``use_fp32`` promotes fp16/bf16 inputs for the reduction, see :func:`_reduction_dtype`; wider dtypes are reduced as-is. """ # Bypass the function if we are using only 1 GPU. if dist.get_world_size(group=group) == 1: return input_ comm_size = dist.get_world_size(group=group) comm_rank = dist.get_rank(group=group) # Per-rank chunk sizes from the natural (possibly uneven) split. orig_shapes = compute_split_shapes(input_.shape[dim_], comm_size) max_chunk = max(orig_shapes) my_chunk = orig_shapes[comm_rank] is_even = min(orig_shapes) == max_chunk dtype = input_.dtype work_dtype = _reduction_dtype(dtype, use_fp32) if is_even: # Fast path: all chunks the same size; call reduce_scatter directly. input_list = split_tensor_along_dim(input_, dim_, comm_size) if work_dtype != dtype: input_list = [x.to(work_dtype) for x in input_list] input_list = [x.contiguous() for x in input_list] output = torch.empty_like(input_list[comm_rank]) dist.reduce_scatter(output, input_list, group=group) return output.to(dtype) if work_dtype != dtype else output # Uneven split: zero-pad every short chunk to max_chunk along ``dim_``, # run reduce_scatter on the now-equal-sized chunks, then trim back to # the local rank's true chunk size. Padding zeros sum to zero across # ranks and are discarded by the slice after the collective. chunks = list(split_tensor_along_dim(input_, dim_, comm_size)) padded_chunks = [] for c in chunks: if c.shape[dim_] < max_chunk: pad_shape = list(c.shape) pad_shape[dim_] = max_chunk - c.shape[dim_] pad = torch.zeros(pad_shape, dtype=c.dtype, device=c.device) c = torch.cat([c, pad], dim=dim_) if work_dtype != dtype: c = c.to(work_dtype) padded_chunks.append(c.contiguous()) output = torch.empty_like(padded_chunks[comm_rank]) dist.reduce_scatter(output, padded_chunks, group=group) if my_chunk < max_chunk: slicer = [slice(None)] * output.dim() slicer[dim_] = slice(0, my_chunk) output = output[tuple(slicer)].contiguous() return output.to(dtype) if work_dtype != dtype else output class _CopyToPolarRegion(torch.autograd.Function): @staticmethod def symbolic(graph, input_): return input_ @staticmethod @_custom_fwd(device_type="cuda") def forward(input_): return input_ @staticmethod @_custom_setup_context(device_type="cuda") def setup_context(ctx, inputs, output): pass @staticmethod @torch.amp.custom_bwd(device_type="cuda") def backward(ctx, grad_output): if is_distributed_polar(): return _reduce(grad_output, group=polar_group()) else: return grad_output class _CopyToAzimuthRegion(torch.autograd.Function): @staticmethod def symbolic(graph, input_): return input_ @staticmethod @_custom_fwd(device_type="cuda") def forward(input_): return input_ @staticmethod @_custom_setup_context(device_type="cuda") def setup_context(ctx, inputs, output): pass @staticmethod @torch.amp.custom_bwd(device_type="cuda") def backward(ctx, grad_output): if is_distributed_azimuth(): return _reduce(grad_output, group=azimuth_group()) else: return grad_output class _ScatterToPolarRegion(torch.autograd.Function): @staticmethod def symbolic(graph, input_, dim_): return _split(input_, dim_, group=polar_group()) @staticmethod @_custom_fwd(device_type="cuda") def forward(input_, dim_): if is_distributed_polar(): return _split(input_, dim_, group=polar_group()) else: return input_ @staticmethod @_custom_setup_context(device_type="cuda") def setup_context(ctx, inputs, output): input_, dim_ = inputs ctx.dim = dim_ if is_distributed_polar(): ctx.split_shapes = compute_split_shapes(input_.shape[dim_], polar_group_size()) @staticmethod @torch.amp.custom_bwd(device_type="cuda") def backward(ctx, grad_output): if is_distributed_polar(): return _gather(grad_output, ctx.dim, ctx.split_shapes, polar_group()), None else: return grad_output, None class _GatherFromPolarRegion(torch.autograd.Function): @staticmethod def symbolic(graph, input_, dim_, shapes_): return _gather(input_, dim_, shapes_, polar_group()) @staticmethod @_custom_fwd(device_type="cuda") def forward(input_, dim_, shapes_): if is_distributed_polar(): return _gather(input_, dim_, shapes_, group=polar_group()) else: return input_ @staticmethod @_custom_setup_context(device_type="cuda") def setup_context(ctx, inputs, output): _, dim_, _ = inputs ctx.dim = dim_ @staticmethod @torch.amp.custom_bwd(device_type="cuda") def backward(ctx, grad_output): if is_distributed_polar(): return _split(grad_output, ctx.dim, group=polar_group()), None, None else: return grad_output, None, None class _ReduceFromPolarRegion(torch.autograd.Function): @staticmethod def symbolic(graph, input_): if is_distributed_polar(): return _reduce(input_, group=polar_group()) else: return input_ @staticmethod @_custom_fwd(device_type="cuda") def forward(input_): if is_distributed_polar(): return _reduce(input_, group=polar_group()) else: return input_ @staticmethod @_custom_setup_context(device_type="cuda") def setup_context(ctx, inputs, output): pass @staticmethod @torch.amp.custom_bwd(device_type="cuda") def backward(ctx, grad_output): return grad_output class _ReduceFromAzimuthRegion(torch.autograd.Function): @staticmethod def symbolic(graph, input_): if is_distributed_azimuth(): return _reduce(input_, group=azimuth_group()) else: return input_ @staticmethod @_custom_fwd(device_type="cuda") def forward(input_): if is_distributed_azimuth(): return _reduce(input_, group=azimuth_group()) else: return input_ @staticmethod @_custom_setup_context(device_type="cuda") def setup_context(ctx, inputs, output): pass @staticmethod @torch.amp.custom_bwd(device_type="cuda") def backward(ctx, grad_output): return grad_output class _ReduceFromScatterToPolarRegion(torch.autograd.Function): @staticmethod def symbolic(graph, input_, dim_): if is_distributed_polar(): return _reduce_scatter(input_, dim_, group=polar_group()) else: return input_ @staticmethod @_custom_fwd(device_type="cuda") def forward(input_, dim_): if is_distributed_polar(): return _reduce_scatter(input_, dim_, group=polar_group()) else: return input_ @staticmethod @_custom_setup_context(device_type="cuda") def setup_context(ctx, inputs, output): input_, dim_ = inputs ctx.dim = dim_ if is_distributed_polar(): ctx.split_shapes = compute_split_shapes(input_.shape[dim_], polar_group_size()) @staticmethod @torch.amp.custom_bwd(device_type="cuda") def backward(ctx, grad_output): if is_distributed_polar(): return _gather(grad_output, ctx.dim, ctx.split_shapes, polar_group()), None else: return grad_output, None class _ReduceFromScatterToAzimuthRegion(torch.autograd.Function): """Fused reduce_scatter on the azimuth group: forward sums partial values across azimuth ranks and scatters along ``dim_``; backward is all_gather.""" @staticmethod def symbolic(graph, input_, dim_): if is_distributed_azimuth(): return _reduce_scatter(input_, dim_, group=azimuth_group()) else: return input_ @staticmethod @_custom_fwd(device_type="cuda") def forward(input_, dim_): if is_distributed_azimuth(): return _reduce_scatter(input_, dim_, group=azimuth_group()) else: return input_ @staticmethod @_custom_setup_context(device_type="cuda") def setup_context(ctx, inputs, output): input_, dim_ = inputs ctx.dim = dim_ if is_distributed_azimuth(): ctx.split_shapes = compute_split_shapes(input_.shape[dim_], azimuth_group_size()) @staticmethod @torch.amp.custom_bwd(device_type="cuda") def backward(ctx, grad_output): if is_distributed_azimuth(): return _gather(grad_output, ctx.dim, ctx.split_shapes, azimuth_group()), None else: return grad_output, None class _GatherFromCopyToPolarRegion(torch.autograd.Function): @staticmethod def symbolic(graph, input_, dim_, shapes_): if is_distributed_polar(): return _gather(input_, dim_, shapes_, polar_group()) else: return input_ @staticmethod @_custom_fwd(device_type="cuda") def forward(input_, dim_, shapes_): if is_distributed_polar(): return _gather(input_, dim_, shapes_, group=polar_group()) else: return input_ @staticmethod @_custom_setup_context(device_type="cuda") def setup_context(ctx, inputs, output): _, dim_, _ = inputs ctx.dim = dim_ @staticmethod @torch.amp.custom_bwd(device_type="cuda") def backward(ctx, grad_output): if is_distributed_polar(): return _reduce_scatter(grad_output, ctx.dim, use_fp32=True, group=polar_group()), None, None else: return grad_output, None, None
[docs] @torch.compiler.disable() def distributed_transpose_azimuth(input_, dims_, shapes_): """All-to-all transpose across the azimuth process group. Redistributes ``input_`` so that data sharded along ``dims_[0]`` becomes sharded along ``dims_[1]``. This is the core communication pattern used when switching between spatial and spectral partitioning of the longitude axis. Parameters ---------- input_ : torch.Tensor Input tensor, partitioned along ``dims_[0]``. dims_ : tuple[int, int] ``(source_dim, target_dim)`` — the dimension to scatter from and the dimension to gather into. shapes_ : list[int] Per-rank sizes along ``dims_[1]`` (i.e. the expected receive sizes). Returns ------- torch.Tensor Transposed tensor, now partitioned along ``dims_[1]``. """ return _DistributeTransposeAzimuth.apply(input_, dims_, shapes_)
[docs] @torch.compiler.disable() def distributed_transpose_polar(input_, dims_, shapes_): """All-to-all transpose across the polar process group. Same semantics as :func:`distributed_transpose_azimuth` but operates on the polar (latitudinal) process group. Parameters ---------- input_ : torch.Tensor Input tensor, partitioned along ``dims_[0]``. dims_ : tuple[int, int] ``(source_dim, target_dim)``. shapes_ : list[int] Per-rank sizes along ``dims_[1]``. Returns ------- torch.Tensor Transposed tensor, now partitioned along ``dims_[1]``. """ return _DistributeTransposePolar.apply(input_, dims_, shapes_)
[docs] @torch.compiler.disable() def copy_to_polar_region(input_): """Identity in the forward pass; all-reduce across polar ranks in the backward pass. Use this to broadcast a replicated tensor into a region where each polar rank will compute a partial result. The backward pass sums the partial gradients so that the replicated parameter receives the correct total gradient. Parameters ---------- input_ : torch.Tensor Replicated tensor (same value on every polar rank). Returns ------- torch.Tensor Same tensor (forward is a no-op). """ return _CopyToPolarRegion.apply(input_)
[docs] @torch.compiler.disable() def copy_to_azimuth_region(input_): """Identity in the forward pass; all-reduce across azimuth ranks in the backward pass. Azimuth counterpart of :func:`copy_to_polar_region`. Parameters ---------- input_ : torch.Tensor Replicated tensor (same value on every azimuth rank). Returns ------- torch.Tensor Same tensor (forward is a no-op). """ return _CopyToAzimuthRegion.apply(input_)
[docs] @torch.compiler.disable() def reduce_from_polar_region(input_): """All-reduce across polar ranks in the forward pass; identity in the backward pass. Use this to aggregate partial results computed independently on each polar rank. Parameters ---------- input_ : torch.Tensor Partial result on the local polar rank. Returns ------- torch.Tensor Sum of ``input_`` across all polar ranks. """ return _ReduceFromPolarRegion.apply(input_)
[docs] @torch.compiler.disable() def reduce_from_azimuth_region(input_): """All-reduce across azimuth ranks in the forward pass; identity in the backward pass. Azimuth counterpart of :func:`reduce_from_polar_region`. Parameters ---------- input_ : torch.Tensor Partial result on the local azimuth rank. Returns ------- torch.Tensor Sum of ``input_`` across all azimuth ranks. """ return _ReduceFromAzimuthRegion.apply(input_)
[docs] @torch.compiler.disable() def scatter_to_polar_region(input_, dim_): """Split ``input_`` along ``dim_`` and keep only the local polar rank's chunk. The backward pass is an all-gather that reconstructs the full tensor. Parameters ---------- input_ : torch.Tensor Full (non-partitioned) tensor. dim_ : int Dimension along which to scatter. Returns ------- torch.Tensor The local rank's slice of the input. """ return _ScatterToPolarRegion.apply(input_, dim_)
[docs] @torch.compiler.disable() def gather_from_polar_region(input_, dim_, shapes_): """All-gather along ``dim_`` across polar ranks to reconstruct the full tensor. The backward pass is a split (scatter) that distributes gradients back to owning ranks. Parameters ---------- input_ : torch.Tensor Local partition of the tensor. dim_ : int Dimension along which to gather. shapes_ : list[int] Per-rank sizes along ``dim_``. Returns ------- torch.Tensor Fully gathered tensor. """ return _GatherFromPolarRegion.apply(input_, dim_, shapes_)
[docs] @torch.compiler.disable() def reduce_from_scatter_to_polar_region(input_, dim_): """Fused reduce-scatter across polar ranks along ``dim_``. Equivalent to an all-reduce followed by keeping only the local rank's chunk, but performed in a single collective for efficiency. The backward pass is an all-gather. Parameters ---------- input_ : torch.Tensor Tensor with partial contributions from the local rank. dim_ : int Dimension along which to scatter after reducing. Returns ------- torch.Tensor Reduced and scattered tensor (local chunk only). """ return _ReduceFromScatterToPolarRegion.apply(input_, dim_)
[docs] @torch.compiler.disable() def reduce_from_scatter_to_azimuth_region(input_, dim_): """Fused reduce-scatter across azimuth ranks along ``dim_``. Azimuth counterpart of :func:`reduce_from_scatter_to_polar_region`. Parameters ---------- input_ : torch.Tensor Tensor with partial contributions from the local rank. dim_ : int Dimension along which to scatter after reducing. Returns ------- torch.Tensor Reduced and scattered tensor (local chunk only). """ return _ReduceFromScatterToAzimuthRegion.apply(input_, dim_)
[docs] @torch.compiler.disable() def gather_from_copy_to_polar_region(input_, dim_, shapes_): """All-gather along ``dim_`` across polar ranks; reduce-scatter in the backward pass. Similar to :func:`gather_from_polar_region`, but the backward pass uses reduce-scatter instead of split, making it the adjoint of a copy-then-gather pattern. Parameters ---------- input_ : torch.Tensor Local partition of the tensor. dim_ : int Dimension along which to gather. shapes_ : list[int] Per-rank sizes along ``dim_``. Returns ------- torch.Tensor Fully gathered tensor. """ return _GatherFromCopyToPolarRegion.apply(input_, dim_, shapes_)
# --------------------------------------------------------------------------- # nearest neighbor exchange algorithms # --------------------------------------------------------------------------- def _halo_gather(x, r_lat): r""" Pad ``x`` with ``r_lat`` latitude rows borrowed from each polar neighbour. ``[B, C, H, W]`` -> ``[B, C, H + 2 * r_lat, W]``. Ranks at the polar boundary have no neighbour on the missing side and are zero-padded there. This is the linear map whose adjoint is :func:`_halo_reduce`. The two are used as each other's forward and backward by :class:`_PolarHaloExchangeFn` (gather forward) and :class:`_PolarHaloReduceFn` (reduce forward), so they must stay exact adjoints -- see the inner-product identity asserted in the distributed primitives tests. """ # Nothing to borrow, so post nothing. This is not just a shortcut: every op below is a P2P # that the neighbour has to match, and with r_lat == 0 the slices degenerate (x[..., -0:, :] # is the whole tensor), so the send would be unmatched by the neighbour's 0-row receive and # NCCL could deliver its surplus to the next halo receive on this communicator -- corrupting # a later layer rather than this one. if r_lat == 0: return x group_size = polar_group_size() group_rank = polar_group_rank() prev_rank, next_rank = get_group_neighbors(polar_group()) B, C, H, W = x.shape device, dtype = x.device, x.dtype # setup send buffers send_top = x[:, :, :r_lat, :].contiguous() # top r_lat rows → rank-1 send_bot = x[:, :, H - r_lat :, :].contiguous() # bottom r_lat rows → rank+1 # setup recv buffers recv_top = torch.zeros(B, C, r_lat, W, device=device, dtype=dtype) recv_bot = torch.zeros(B, C, r_lat, W, device=device, dtype=dtype) ops = [] if group_rank > 0: ops.append(dist.P2POp(dist.isend, send_top, prev_rank, polar_group())) ops.append(dist.P2POp(dist.irecv, recv_top, prev_rank, polar_group())) if group_rank < group_size - 1: ops.append(dist.P2POp(dist.isend, send_bot, next_rank, polar_group())) ops.append(dist.P2POp(dist.irecv, recv_bot, next_rank, polar_group())) if ops: reqs = dist.batch_isend_irecv(ops) for req in reqs: req.wait() return torch.cat([recv_top, x, recv_bot], dim=2).contiguous() def _halo_reduce(y, r_lat, H): r""" Adjoint of :func:`_halo_gather`: send the halo rows of ``y`` home and accumulate them. ``[B, C, H + 2 * r_lat, W]`` -> ``[B, C, H, W]``. The halo slices of ``y`` hold contributions to rows owned by the neighbours, so they are sent back and added onto the owner's rows; symmetrically, each rank receives the contributions its neighbours hold for the rows it lent them. At the polar boundary the outward slice has no owner and is dropped, which is exactly the adjoint of :func:`_halo_gather` zero-padding that side. """ # no halo rows to send home; skip the P2P exchange entirely, as _halo_gather does if r_lat == 0: return y group_size = polar_group_size() group_rank = polar_group_rank() prev_rank, next_rank = get_group_neighbors(polar_group()) B, C, _, W = y.shape device, dtype = y.device, y.dtype # the local (non-halo) rows pass straight through out = y[:, :, r_lat : r_lat + H, :].contiguous().clone() # y[:, :, :r_lat, :] → belongs to rank-1 # y[:, :, r_lat + H:, :] → belongs to rank+1 send_to_prev = y[:, :, :r_lat, :].contiguous() send_to_next = y[:, :, r_lat + H :, :].contiguous() recv_from_prev = torch.zeros(B, C, r_lat, W, device=device, dtype=dtype) recv_from_next = torch.zeros(B, C, r_lat, W, device=device, dtype=dtype) ops = [] if group_rank > 0: ops.append(dist.P2POp(dist.isend, send_to_prev, prev_rank, polar_group())) ops.append(dist.P2POp(dist.irecv, recv_from_prev, prev_rank, polar_group())) if group_rank < group_size - 1: ops.append(dist.P2POp(dist.isend, send_to_next, next_rank, polar_group())) ops.append(dist.P2POp(dist.irecv, recv_from_next, next_rank, polar_group())) if ops: reqs = dist.batch_isend_irecv(ops) for req in reqs: req.wait() # recv_from_prev = contribution to our top r_lat rows (prev rank's recv_bot region) # recv_from_next = contribution to our bottom r_lat rows (next rank's recv_top region) if group_rank > 0: out[:, :, :r_lat, :] = out[:, :, :r_lat, :] + recv_from_prev if group_rank < group_size - 1: out[:, :, H - r_lat :, :] = out[:, :, H - r_lat :, :] + recv_from_next return out class _PolarHaloExchangeFn(torch.autograd.Function): """Differentiable lat halo exchange for polar-distributed tensors. Forward: gathers r_lat rows from neighbouring polar ranks and returns a halo-padded tensor of shape [B, C, H_local + 2*r_lat, W]. Backward: communicates halo gradient contributions back to their owning ranks and accumulates them onto the local input gradient. Ranks at the polar boundary (rank 0 / rank group_size-1) receive zero-padding on the missing side in the forward pass; the corresponding halo-gradient portion is discarded in the backward (no neighbour to send it to), which is the correct adjoint of padding with zeros. """ @staticmethod @_custom_fwd(device_type="cuda") def forward(x, r_lat): if not is_distributed_polar(): return x return _halo_gather(x, r_lat) @staticmethod @_custom_setup_context(device_type="cuda") def setup_context(ctx, inputs, output): x, r_lat = inputs ctx.r_lat = r_lat ctx.H = x.shape[2] if is_distributed_polar(): ctx.group_size = polar_group_size() ctx.group_rank = polar_group_rank() prev_rank, next_rank = get_group_neighbors(polar_group()) ctx.prev_rank = prev_rank ctx.next_rank = next_rank @staticmethod @torch.amp.custom_bwd(device_type="cuda") def backward(ctx, dout): if not is_distributed_polar(): return dout, None # The halo slices of the incoming gradient belong to the neighbouring ranks, and each # neighbour holds the gradient it owes us for the rows we lent it: exactly the adjoint # of the gather performed in the forward. # Gradient for r_lat is None (not a tensor / non-differentiable). return _halo_reduce(dout, ctx.r_lat, ctx.H), None class _PolarHaloReduceFn(torch.autograd.Function): """Adjoint of :class:`_PolarHaloExchangeFn`: halo reduce forward, halo gather backward. Forward: takes a halo-padded tensor whose halo rows hold contributions to latitudes owned by the neighbours, sends them home and accumulates, returning the local rows only -- ``[B, C, H_local + 2*r_lat, W]`` -> ``[B, C, H_local, W]``. Backward: gathers the halo rows back, the same operation the exchange performs forward. This is the direction a *scatter*-shaped operator needs: the transpose DISCO convolution writes into output latitudes that may belong to a neighbour, so its forward accumulates across the boundary rather than borrowing across it. """ @staticmethod @_custom_fwd(device_type="cuda") def forward(y, r_lat, H): if not is_distributed_polar(): return y return _halo_reduce(y, r_lat, H) @staticmethod @_custom_setup_context(device_type="cuda") def setup_context(ctx, inputs, output): _, r_lat, _ = inputs ctx.r_lat = r_lat @staticmethod @torch.amp.custom_bwd(device_type="cuda") def backward(ctx, dout): if not is_distributed_polar(): return dout, None, None # Gradients for r_lat and H are None (not tensors / non-differentiable). return _halo_gather(dout, ctx.r_lat), None, None
[docs] @torch.compiler.disable() def compute_polar_halo_radius( lats_in: torch.Tensor, lats_out: torch.Tensor, theta_cutoff: float, lat_in_shapes: List[int], lat_out_shapes: List[int], ) -> int: r""" Latitude halo radius a polar shard needs from its neighbours, derived analytically. A localized operator couples an output latitude only to input latitudes within ``theta_cutoff``, a contiguous band given by :func:`~torch_harmonics.quadrature.latitude_support_band`. A polar rank owns a contiguous range of both axes, so the rows it must borrow are exactly those where the band of its output range runs past its own input range. This returns the largest such overhang over all ranks, which is the halo width :func:`polar_halo_exchange` has to move. The band bounds are monotone in the output latitude, so only the two ends of each rank's output range are examined, and the whole thing costs a pair of binary searches rather than a pass over a sparsity pattern. Computing it from the geometry rather than by measuring a precomputed pattern is also what lets the pattern itself be built on the band -- otherwise the radius would depend on the very tensor it is supposed to bound. It is evaluated identically on every rank, so no communication is involved. Parameters ---------- lats_in : torch.Tensor Input colatitudes in radians, ascending, shape ``(nlat_in,)``. lats_out : torch.Tensor Output colatitudes in radians, ascending, shape ``(nlat_out,)``. theta_cutoff : float Effective angular support radius, i.e. including the widening the sparsity pattern is built with, see :func:`~torch_harmonics.quadrature.effective_theta_cutoff`. lat_in_shapes : List[int] Input latitudes held by each polar rank, ordered by rank. lat_out_shapes : List[int] Output latitudes held by each polar rank, ordered by rank. Returns ------- int Halo radius in latitude rows; ``0`` when the polar group holds a single rank. Raises ------ ValueError If the radius exceeds the smallest local input chunk. :func:`polar_halo_exchange` only communicates with immediate neighbours, so a halo wider than a neighbour's entire share would have to reach past it to the next rank out. """ comm_size = len(lat_out_shapes) if comm_size <= 1: return 0 lo, hi = latitude_support_band(lats_in, lats_out, theta_cutoff) in_starts = [0] out_starts = [0] for n in lat_in_shapes[:-1]: in_starts.append(in_starts[-1] + n) for n in lat_out_shapes[:-1]: out_starts.append(out_starts[-1] + n) r_lat = 0 for rank in range(comm_size): out_lo, out_n = out_starts[rank], lat_out_shapes[rank] if out_n <= 0: continue in_lo, in_n = in_starts[rank], lat_in_shapes[rank] # the band is monotone in the output latitude, so the extremes of this rank's output # range bracket every input latitude it can reach need_lo = int(lo[out_lo]) need_hi = int(hi[out_lo + out_n - 1]) if need_hi < need_lo: continue r_lat = max(r_lat, in_lo - need_lo, need_hi - (in_lo + in_n - 1)) r_lat = max(r_lat, 0) min_chunk = min(lat_in_shapes) if r_lat > min_chunk: raise ValueError( f"Latitude halo of {r_lat} rows exceeds the smallest local input chunk of {min_chunk} rows " f"(input latitudes split as {list(lat_in_shapes)} over {comm_size} polar ranks). The halo is " "exchanged with immediate neighbours only, so a halo this wide would have to reach past a " "neighbour to the rank beyond it. This means the polar decomposition is too fine for the " "angular cutoff: use fewer polar ranks, more latitudes, or a smaller theta_cutoff." ) return r_lat
[docs] @torch.compiler.disable() def polar_halo_exchange(x, r_lat): """Exchange ``r_lat`` halo rows with neighbouring polar ranks. Gathers ``r_lat`` latitude rows from each polar neighbour and returns a halo-padded tensor. Boundary ranks receive zero-padding on the missing side. The operation is fully differentiable: the backward pass sends halo gradients back to their owning ranks and accumulates them. Parameters ---------- x : torch.Tensor Input tensor of shape ``(B, C, H_local, W)``. r_lat : int Number of halo rows to exchange on each side. Returns ------- torch.Tensor Halo-padded tensor of shape ``(B, C, H_local + 2 * r_lat, W)``. """ return _PolarHaloExchangeFn.apply(x, r_lat)
[docs] @torch.compiler.disable() def polar_halo_reduce(y, r_lat, nlat_local): """Accumulate ``r_lat`` halo rows onto their owning polar ranks. The adjoint of :func:`polar_halo_exchange`, and its mirror image: where the exchange *borrows* rows from the neighbours before a gather-shaped operator reads them, this *returns* rows to the neighbours after a scatter-shaped operator has written them. Boundary ranks drop the outward halo, matching the zero-padding the exchange applies there. Fully differentiable; the backward is the exchange. Parameters ---------- y : torch.Tensor Halo-padded tensor of shape ``(B, C, nlat_local + 2 * r_lat, W)``. r_lat : int Number of halo rows on each side. nlat_local : int Number of latitudes this rank owns, i.e. the extent of the returned tensor. Returns ------- torch.Tensor Tensor of shape ``(B, C, nlat_local, W)`` holding this rank's completed rows. """ return _PolarHaloReduceFn.apply(y, r_lat, nlat_local)