# 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.
#
import torch
import torch.nn as nn
from torch_harmonics.fft import irfft, rfft
from torch_harmonics.legendre import _precompute_dlegpoly, _precompute_legpoly
from torch_harmonics.quadrature import precompute_latitudes
from torch_harmonics.truncation import truncate_sht
from torch_harmonics.utils import check
from .primitives import (
compute_split_shapes,
distributed_transpose_azimuth,
flatten_and_pad_leading_dims,
reduce_from_scatter_to_polar_region,
unpad_and_unflatten_leading_dims,
)
from .utils import azimuth_group_rank, azimuth_group_size, polar_group_rank, polar_group_size
[docs]
class DistributedRealSHT(nn.Module):
"""
Distributed version of the forward (real-valued) SHT.
Precomputes Legendre Gauss nodes, weights and associated Legendre polynomials on these nodes.
The SHT is applied to the last two dimensions of the input.
**Distribution scheme.**
The input tensor has shape ``(B, C, nlat_local, nlon_local)`` where latitudes
and longitudes are split across the polar and azimuth process groups
respectively. All leading dimensions are flattened into a single axis
``N = B * C`` which is used as the redistribution currency during the
azimuth all-to-all transposes. The forward pass proceeds as follows:
1. **Azimuth transpose** (``nlon`` ↔ ``N``) — each rank trades its local
longitude chunk for a slice of the channel axis, making ``nlon`` fully
local so the real FFT can be applied.
2. **Real FFT** along the (now local) longitude dimension.
3. **Azimuth transpose** (``N`` ↔ ``mmax``) — redistribute so that spectral
orders ``m`` are split across azimuth ranks and channels are local again.
4. **Legendre contraction** — a *distributed* matrix multiply: latitudes stay
split across the polar group and each rank contracts only the latitudes it
owns, producing a partial sum over all degrees ``l``.
5. **Reduce-scatter** over the polar group along ``l`` — completes the
quadrature sum and leaves the degrees partitioned in one collective.
The output has shape ``(B, C, lmax_local, mmax_local)`` with spectral modes
partitioned in the same way as the spatial grid.
Keeping ``nlat`` distributed is what makes the precomputed Legendre weights
scale: they are partitioned as ``(mmax_local, lmax, nlat_local)``, so the
tensor is split across the full process grid rather than replicated over the
polar group. The cost is that the quadrature sum is now accumulated across
ranks, so results are not bitwise identical to the serial transform.
If ``N < azimuth_group_size``, the leading axis is zero-padded before the
transposes and the padding is removed afterwards; since the transform is
linear this is exact.
.. seealso::
:class:`torch_harmonics.RealSHT`
Serial counterpart with full mathematical description and parameter
documentation.
Parameters
----------
nlat : int
Number of latitude points
nlon : int
Number of longitude points
lmax : int
Maximum spherical harmonic degree
mmax : int
Maximum spherical harmonic order
grid : str
Grid type (``"equiangular"``, ``"legendre-gauss"``, ``"lobatto"``, ``"equiangular-trapezoidal"``), by default ``"equiangular"``
norm : str
Normalization type (``"ortho"``, ``"schmidt"``, ``"unnorm"``), by default ``"ortho"``
csphase : bool
Whether to apply the Condon-Shortley phase factor, by default True
Returns
-------
torch.Tensor
Tensor of shape (..., lmax, mmax)
References
----------
:cite:`Schaeffer2013`, :cite:`Wang2018`
"""
def __init__(self, nlat, nlon, lmax=None, mmax=None, grid="equiangular", norm="ortho", csphase=True):
super().__init__()
self.nlat = nlat
self.nlon = nlon
self.grid = grid
self.norm = norm
self.csphase = csphase
# TODO: include assertions regarding the dimensions
# quadrature weights; the grid switch and the cosine transform live in
# precompute_latitudes, which is cached on (nlat, grid)
_, weights = precompute_latitudes(nlat, grid=self.grid)
# get the comms grid:
self.comm_size_polar = polar_group_size()
self.comm_rank_polar = polar_group_rank()
self.comm_size_azimuth = azimuth_group_size()
self.comm_rank_azimuth = azimuth_group_rank()
# determine maximum degrees based on triangular truncation
self.lmax, self.mmax = truncate_sht(self.nlat, self.nlon, lmax, mmax, self.grid)
# compute splits
self.lat_shapes = compute_split_shapes(self.nlat, self.comm_size_polar)
self.nlat_local = self.lat_shapes[self.comm_rank_polar]
self.lon_shapes = compute_split_shapes(self.nlon, self.comm_size_azimuth)
self.nlon_local = self.lon_shapes[self.comm_rank_azimuth]
self.l_shapes = compute_split_shapes(self.lmax, self.comm_size_polar)
self.lmax_local = self.l_shapes[self.comm_rank_polar]
self.m_shapes = compute_split_shapes(self.mmax, self.comm_size_azimuth)
self.mmax_local = self.m_shapes[self.comm_rank_azimuth]
self.lat_offset = sum(self.lat_shapes[: self.comm_rank_polar])
self.mmax_offset = sum(self.m_shapes[: self.comm_rank_azimuth])
# fold the 2*pi longitudinal scale factor of the forward-normalized FFT into the
# quadrature weights. It is a constant prefactor of a linear transform, so folding it
# here is exact and saves a pointwise multiply on a complex tensor in every forward.
weights = 2.0 * torch.pi * weights
# build only the block this rank keeps, rather than the whole table. The contraction
# over k is a distributed matmul completed by a reduce-scatter in the forward, so only
# the local latitudes are needed; l is contracted in full. Latitudes restrict for free
# via kmin/kmax -- they are independent of each other -- whereas the order range needs
# mmin, since reaching P^m_m means walking the seed up from m=0.
weights = weights[self.lat_offset : self.lat_offset + self.nlat_local]
# combine quadrature weights with the legendre weights
pct = _precompute_legpoly(
self.mmax_offset + self.mmax_local,
self.lmax,
self.nlat,
self.grid,
norm=self.norm,
csphase=self.csphase,
mmin=self.mmax_offset,
kmin=self.lat_offset,
kmax=self.lat_offset + self.nlat_local,
)
weights = torch.einsum("mlk,k->mlk", pct, weights).contiguous()
# remember quadrature weights
self.register_buffer("weights", weights, persistent=False)
def extra_repr(self):
return f"nlat={self.nlat}, nlon={self.nlon},\n lmax={self.lmax}, mmax={self.mmax},\n grid={self.grid}, csphase={self.csphase}"
# This transform cannot be captured in a single graph: the redistribution collectives it
# calls are themselves torch.compiler.disable()d, so at comm_size > 1 dynamo breaks at
# every one of them and inductor only ever sees the slivers in between. Those slivers hold
# complex intermediates, which triton cannot type (KeyError: 'complex64' in codegen), and
# compiling them buys nothing next to the all-to-alls surrounding them. Disabling the whole
# forward costs no fusion that the breaks had not already cost, and keeps the complex
# spectral data out of inductor entirely. The serial transforms are compiled as usual.
@torch.compiler.disable()
def forward(self, x: torch.Tensor):
check(x.dim() >= 3, lambda: f"Expected tensor with at least 3 dimensions but got {x.dim()} instead")
check(x.shape[-2] == self.nlat_local, lambda: f"Expected latitudes shape[-2]=={self.nlat_local}, got {x.shape[-2]}")
check(x.shape[-1] == self.nlon_local, lambda: f"Expected longitudes shape[-1]=={self.nlon_local}, got {x.shape[-1]}")
# the azimuth transposes below redistribute the leading (channel/batch) axis, so it
# must be at least as large as the azimuth group. Flatten all leading dims into that
# axis and zero-pad it if needed (linear transform, so padding stays zero); restore
# the original layout before returning. The polar group never touches this axis --
# the latitude contraction is a distributed matmul, not a transpose.
x, lead_shape, lead_size = flatten_and_pad_leading_dims(x, self.comm_size_azimuth)
num_chans = x.shape[-3]
# h and w is split. First we make w local by transposing into channel dim
if self.comm_size_azimuth > 1:
x = distributed_transpose_azimuth(x, (-3, -1), self.lon_shapes)
# apply real fft in the longitudinal direction: make sure to truncate to nlon. The 2*pi
# scale factor is folded into the quadrature weights, so no scaling is needed here.
x = rfft(x, nmodes=self.mmax, dim=-1, norm="forward")
# transpose: after this, m is split and c is local
if self.comm_size_azimuth > 1:
chan_shapes = compute_split_shapes(num_chans, self.comm_size_azimuth)
x = distributed_transpose_azimuth(x, (-1, -3), chan_shapes)
# transpose to put the contraction dim (nlat) on the fast axis. nlat stays split across
# the polar group: each rank contracts the latitudes it owns and the reduce-scatter
# below completes the sum.
x = x.transpose(-1, -2)
x_re = x.real.contiguous()
x_im = x.imag.contiguous()
# Legendre-Gauss quadrature: partial contraction over the local k=nlat chunk
w = self.weights.to(x_re.dtype)
out_re = torch.einsum("...mk,mlk->...lm", x_re, w)
out_im = torch.einsum("...mk,mlk->...lm", x_im, w)
# complete the quadrature sum and split l in a single collective. This runs on the real
# view: the reduce-scatter reduces in fp32, which would discard the imaginary part of a
# complex tensor, and NCCL has no complex reduction to begin with.
out = torch.stack((out_re, out_im), dim=-1)
if self.comm_size_polar > 1:
out = reduce_from_scatter_to_polar_region(out, -3)
x = torch.view_as_complex(out.contiguous())
# drop padding and restore the original leading dims
x = unpad_and_unflatten_leading_dims(x, lead_shape, lead_size)
return x
[docs]
class DistributedInverseRealSHT(nn.Module):
"""
Distributed version of the inverse (real-valued) SHT.
Precomputes Legendre Gauss nodes, weights and associated Legendre polynomials on these nodes.
**Distribution scheme.**
The input tensor has shape ``(B, C, lmax_local, mmax_local)`` where spectral
degrees and orders are split across the polar and azimuth process groups.
All leading dimensions are flattened into ``N = B * C`` for redistribution.
The forward pass proceeds as follows:
1. **Legendre synthesis** — a *distributed* matrix multiply: degrees stay
split across the polar group and each rank synthesizes from the degrees it
owns, producing a partial sum over all latitudes.
2. **Reduce-scatter** over the polar group along ``nlat`` — completes the
synthesis sum and leaves the latitudes partitioned in one collective.
3. **Azimuth transpose** (``N`` ↔ ``mmax``) — make spectral orders ``m``
fully local for the inverse FFT.
4. **Inverse real FFT** along the (now local) ``m`` / longitude dimension.
5. **Azimuth transpose** (``nlon`` ↔ ``N``) — redistribute so that
longitudes are split across azimuth ranks.
The output has shape ``(B, C, nlat_local, nlon_local)`` with the spatial
grid partitioned in the same way as the input spectral modes.
Keeping ``l`` distributed is what makes the precomputed Legendre polynomials
scale: they are partitioned as ``(mmax_local, nlat, lmax_local)``, so the
tensor is split across the full process grid rather than replicated over the
polar group. The cost is that the synthesis sum is now accumulated across
ranks, so results are not bitwise identical to the serial transform.
If ``N < azimuth_group_size``, the leading axis is zero-padded before the
transposes and the padding is removed afterwards; since the transform is
linear this is exact.
.. seealso::
:class:`torch_harmonics.InverseRealSHT`
Serial counterpart with full mathematical description and parameter
documentation.
Parameters
----------
nlat : int
Number of latitude points
nlon : int
Number of longitude points
lmax : int
Maximum spherical harmonic degree
mmax : int
Maximum spherical harmonic order
grid : str
Grid type (``"equiangular"``, ``"legendre-gauss"``, ``"lobatto"``, ``"equiangular-trapezoidal"``), by default ``"equiangular"``
norm : str
Normalization type (``"ortho"``, ``"schmidt"``, ``"unnorm"``), by default ``"ortho"``
csphase : bool
Whether to apply the Condon-Shortley phase factor, by default True
Returns
-------
torch.Tensor
Tensor of shape (..., lmax, mmax)
References
----------
:cite:`Schaeffer2013`, :cite:`Wang2018`
"""
def __init__(self, nlat, nlon, lmax=None, mmax=None, grid="equiangular", norm="ortho", csphase=True):
super().__init__()
self.nlat = nlat
self.nlon = nlon
self.grid = grid
self.norm = norm
self.csphase = csphase
# get the comms grid:
self.comm_size_polar = polar_group_size()
self.comm_rank_polar = polar_group_rank()
self.comm_size_azimuth = azimuth_group_size()
self.comm_rank_azimuth = azimuth_group_rank()
# determine maximum degrees based on triangular truncation
self.lmax, self.mmax = truncate_sht(self.nlat, self.nlon, lmax, mmax, self.grid)
# compute splits
self.lat_shapes = compute_split_shapes(self.nlat, self.comm_size_polar)
self.lon_shapes = compute_split_shapes(self.nlon, self.comm_size_azimuth)
self.l_shapes = compute_split_shapes(self.lmax, self.comm_size_polar)
self.lmax_local = self.l_shapes[self.comm_rank_polar]
self.m_shapes = compute_split_shapes(self.mmax, self.comm_size_azimuth)
self.mmax_local = self.m_shapes[self.comm_rank_azimuth]
self.lmax_offset = sum(self.l_shapes[: self.comm_rank_polar])
self.mmax_offset = sum(self.m_shapes[: self.comm_rank_azimuth])
# build only the block this rank keeps. The synthesis over l is a distributed matmul
# completed by a reduce-scatter in the forward, so only the local degrees are needed,
# while all latitudes are produced. Both ranges need an explicit offset: the sectoral
# seed couples orders and the three-term recurrence couples degrees, so each has to be
# walked from the start even though only the local window is stored.
# store as (mmax_local, nlat, lmax_local) so the contraction dim l is stride-1
pct = _precompute_legpoly(
self.mmax_offset + self.mmax_local,
self.lmax_offset + self.lmax_local,
self.nlat,
self.grid,
norm=self.norm,
inverse=True,
csphase=self.csphase,
mmin=self.mmax_offset,
lmin=self.lmax_offset,
)
pct = pct.permute(0, 2, 1).contiguous()
# register
self.register_buffer("pct", pct, persistent=False)
def extra_repr(self):
return f"nlat={self.nlat}, nlon={self.nlon},\n lmax={self.lmax}, mmax={self.mmax},\n grid={self.grid}, csphase={self.csphase}"
# This transform cannot be captured in a single graph: the redistribution collectives it
# calls are themselves torch.compiler.disable()d, so at comm_size > 1 dynamo breaks at
# every one of them and inductor only ever sees the slivers in between. Those slivers hold
# complex intermediates, which triton cannot type (KeyError: 'complex64' in codegen), and
# compiling them buys nothing next to the all-to-alls surrounding them. Disabling the whole
# forward costs no fusion that the breaks had not already cost, and keeps the complex
# spectral data out of inductor entirely. The serial transforms are compiled as usual.
@torch.compiler.disable()
def forward(self, x: torch.Tensor):
check(x.dim() >= 3, lambda: f"Expected tensor with at least 3 dimensions but got {x.dim()} instead")
check(x.shape[-2] == self.lmax_local, lambda: f"Expected spherical harmonic degrees (lmax) shape[-2]=={self.lmax_local}, got {x.shape[-2]}")
check(x.shape[-1] == self.mmax_local, lambda: f"Expected spherical harmonic orders (mmax) shape[-1]=={self.mmax_local}, got {x.shape[-1]}")
# the azimuth transposes below redistribute the leading (channel/batch) axis, so it
# must be at least as large as the azimuth group. Flatten all leading dims into that
# axis and zero-pad it if needed (linear transform, so padding stays zero); restore
# the original layout before returning. The polar group never touches this axis --
# the degree contraction is a distributed matmul, not a transpose.
x, lead_shape, lead_size = flatten_and_pad_leading_dims(x, self.comm_size_azimuth)
num_chans = x.shape[-3]
# transpose to put the contraction dim (lmax) on the fast axis. l stays split across
# the polar group: each rank synthesizes from the degrees it owns and the
# reduce-scatter below completes the sum.
x = x.transpose(-1, -2)
x_re = x.real.contiguous()
x_im = x.imag.contiguous()
# legendre transformation: partial contraction over the local l chunk
# pct layout: (mmax_local, nlat, lmax_local)
w = self.pct.to(x_re.dtype)
out_re = torch.einsum("...ml,mkl->...km", x_re, w)
out_im = torch.einsum("...ml,mkl->...km", x_im, w)
# complete the synthesis sum and split nlat in a single collective, on the real view
# (see DistributedRealSHT.forward for why the collective cannot take complex input).
out = torch.stack((out_re, out_im), dim=-1)
if self.comm_size_polar > 1:
out = reduce_from_scatter_to_polar_region(out, -3)
x = torch.view_as_complex(out.contiguous())
# transpose: after this, channels are split and m is local
if self.comm_size_azimuth > 1:
x = distributed_transpose_azimuth(x, (-3, -1), self.m_shapes)
# apply the inverse (real) FFT
x = irfft(x, n=self.nlon, dim=-1, norm="forward")
# transpose: after this, m is split and channels are local
if self.comm_size_azimuth > 1:
chan_shapes = compute_split_shapes(num_chans, self.comm_size_azimuth)
x = distributed_transpose_azimuth(x, (-1, -3), chan_shapes)
# drop padding and restore the original leading dims
x = unpad_and_unflatten_leading_dims(x, lead_shape, lead_size)
return x
[docs]
class DistributedRealVectorSHT(nn.Module):
"""
Distributed version of the forward (real) vector SHT.
Precomputes Legendre Gauss nodes, weights and associated Legendre polynomials on these nodes.
The SHT is applied to the last three dimensions of the input.
The distribution scheme is the same as for
:class:`DistributedRealSHT` (see its docstring for a step-by-step
description of the all-to-all transposes over the ``N = B * C`` axis).
The additional size-2 vector component dimension is preserved throughout.
.. seealso::
:class:`torch_harmonics.RealVectorSHT`
Serial counterpart with full mathematical description and parameter
documentation.
Parameters
----------
nlat : int
Number of latitude points
nlon : int
Number of longitude points
lmax : int
Maximum spherical harmonic degree
mmax : int
Maximum spherical harmonic order
grid : str
Grid type (``"equiangular"``, ``"legendre-gauss"``, ``"lobatto"``, ``"equiangular-trapezoidal"``), by default ``"equiangular"``
norm : str
Normalization type (``"ortho"``, ``"schmidt"``, ``"unnorm"``), by default ``"ortho"``
csphase : bool
Whether to apply the Condon-Shortley phase factor, by default True
Returns
-------
torch.Tensor
Tensor of shape (..., lmax, mmax)
References
----------
:cite:`Schaeffer2013`, :cite:`Wang2018`
"""
def __init__(self, nlat, nlon, lmax=None, mmax=None, grid="equiangular", norm="ortho", csphase=True):
super().__init__()
self.nlat = nlat
self.nlon = nlon
self.grid = grid
self.norm = norm
self.csphase = csphase
# quadrature weights; the grid switch and the cosine transform live in
# precompute_latitudes, which is cached on (nlat, grid)
_, weights = precompute_latitudes(nlat, grid=self.grid)
# get the comms grid:
self.comm_size_polar = polar_group_size()
self.comm_rank_polar = polar_group_rank()
self.comm_size_azimuth = azimuth_group_size()
self.comm_rank_azimuth = azimuth_group_rank()
# determine maximum degrees based on triangular truncation
self.lmax, self.mmax = truncate_sht(self.nlat, self.nlon, lmax, mmax, self.grid)
# compute splits
self.lat_shapes = compute_split_shapes(self.nlat, self.comm_size_polar)
self.nlat_local = self.lat_shapes[self.comm_rank_polar]
self.lon_shapes = compute_split_shapes(self.nlon, self.comm_size_azimuth)
self.nlon_local = self.lon_shapes[self.comm_rank_azimuth]
self.l_shapes = compute_split_shapes(self.lmax, self.comm_size_polar)
self.m_shapes = compute_split_shapes(self.mmax, self.comm_size_azimuth)
self.mmax_local = self.m_shapes[self.comm_rank_azimuth]
self.lat_offset = sum(self.lat_shapes[: self.comm_rank_polar])
self.mmax_offset = sum(self.m_shapes[: self.comm_rank_azimuth])
# build only the block this rank keeps: local latitudes, local orders, all degrees,
# see DistributedRealSHT.__init__
weights = weights[self.lat_offset : self.lat_offset + self.nlat_local]
# compute weights
dpct = _precompute_dlegpoly(
self.mmax_offset + self.mmax_local,
self.lmax,
self.nlat,
self.grid,
norm=self.norm,
csphase=self.csphase,
mmin=self.mmax_offset,
kmin=self.lat_offset,
kmax=self.lat_offset + self.nlat_local,
)
# fold the 2*pi longitudinal scale factor of the forward-normalized FFT into the
# quadrature weights (see DistributedRealSHT.__init__)
weights = 2.0 * torch.pi * weights
# combine integration weights, normalization factor in to one:
l = torch.arange(0, self.lmax)
norm_factor = 1.0 / l / (l + 1)
norm_factor[0] = 1.0
weights = torch.einsum("dmlk,k,l->dmlk", dpct, weights, norm_factor).contiguous()
# since the second component is imaginary, we need to take complex conjugation into account
weights[1] = -1 * weights[1]
# remember quadrature weights
self.register_buffer("weights", weights, persistent=False)
def extra_repr(self):
return f"nlat={self.nlat}, nlon={self.nlon},\n lmax={self.lmax}, mmax={self.mmax},\n grid={self.grid}, csphase={self.csphase}"
# This transform cannot be captured in a single graph: the redistribution collectives it
# calls are themselves torch.compiler.disable()d, so at comm_size > 1 dynamo breaks at
# every one of them and inductor only ever sees the slivers in between. Those slivers hold
# complex intermediates, which triton cannot type (KeyError: 'complex64' in codegen), and
# compiling them buys nothing next to the all-to-alls surrounding them. Disabling the whole
# forward costs no fusion that the breaks had not already cost, and keeps the complex
# spectral data out of inductor entirely. The serial transforms are compiled as usual.
@torch.compiler.disable()
def forward(self, x: torch.Tensor):
check(x.dim() >= 4, lambda: f"Expected tensor with at least 4 dimensions but got {x.dim()} instead")
check(x.shape[-3] == 2, lambda: f"Expected vector field shape[-3]==2, got {x.shape[-3]}")
check(x.shape[-2] == self.nlat_local, lambda: f"Expected latitudes shape[-2]=={self.nlat_local}, got {x.shape[-2]}")
check(x.shape[-1] == self.nlon_local, lambda: f"Expected longitudes shape[-1]=={self.nlon_local}, got {x.shape[-1]}")
# the transposes below redistribute the leading (channel/batch) axis across the
# process grid, so it must be at least as large as the larger comm group. Flatten
# all leading dims into that axis -- keeping the trailing (2, nlat, nlon) intact --
# and zero-pad it if needed (linear transform, so padding stays zero); restore the
# original layout before returning.
x, lead_shape, lead_size = flatten_and_pad_leading_dims(x, self.comm_size_azimuth, num_trailing_dims=3)
num_chans = x.shape[-4]
# h and w is split. First we make w local by transposing into channel dim
if self.comm_size_azimuth > 1:
x = distributed_transpose_azimuth(x, (-4, -1), self.lon_shapes)
# apply real fft in the longitudinal direction: make sure to truncate to nlon. The 2*pi
# scale factor is folded into the quadrature weights, so no scaling is needed here.
x = rfft(x, nmodes=self.mmax, dim=-1, norm="forward")
# transpose: after this, m is split and c is local
if self.comm_size_azimuth > 1:
chan_shapes = compute_split_shapes(num_chans, self.comm_size_azimuth)
x = distributed_transpose_azimuth(x, (-1, -4), chan_shapes)
# transpose to put the contraction dim (nlat) on the fast axis. nlat stays split across
# the polar group, see DistributedRealSHT.forward.
x = x.transpose(-1, -2)
x_re = x.real.contiguous()
x_im = x.imag.contiguous()
w0 = self.weights[0].to(x_re.dtype)
w1 = self.weights[1].to(x_re.dtype)
# contraction - spheroidal component
s_re = torch.einsum("...mk,mlk->...lm", x_re[..., 0, :, :], w0) - torch.einsum("...mk,mlk->...lm", x_im[..., 1, :, :], w1)
s_im = torch.einsum("...mk,mlk->...lm", x_im[..., 0, :, :], w0) + torch.einsum("...mk,mlk->...lm", x_re[..., 1, :, :], w1)
# contraction - toroidal component
t_re = -torch.einsum("...mk,mlk->...lm", x_im[..., 0, :, :], w1) - torch.einsum("...mk,mlk->...lm", x_re[..., 1, :, :], w0)
t_im = torch.einsum("...mk,mlk->...lm", x_re[..., 0, :, :], w1) - torch.einsum("...mk,mlk->...lm", x_im[..., 1, :, :], w0)
# stack the components in real space, see RealVectorSHT.forward
out_re = torch.stack((s_re, t_re), dim=-3)
out_im = torch.stack((s_im, t_im), dim=-3)
# complete the quadrature sum and split l in a single collective, on the real view
# (see DistributedRealSHT.forward for why the collective cannot take complex input).
out = torch.stack((out_re, out_im), dim=-1)
if self.comm_size_polar > 1:
out = reduce_from_scatter_to_polar_region(out, -3)
x = torch.view_as_complex(out.contiguous())
# drop padding and restore the original leading dims
x = unpad_and_unflatten_leading_dims(x, lead_shape, lead_size, num_trailing_dims=3)
return x
[docs]
class DistributedInverseRealVectorSHT(nn.Module):
"""
Distributed version of the inverse (real-valued) vector SHT.
Precomputes Legendre Gauss nodes, weights and associated Legendre polynomials on these nodes.
The distribution scheme is the same as for
:class:`DistributedInverseRealSHT` (see its docstring for a step-by-step
description of the all-to-all transposes over the ``N = B * C`` axis).
The additional size-2 vector component dimension is preserved throughout.
.. seealso::
:class:`torch_harmonics.InverseRealVectorSHT`
Serial counterpart with full mathematical description and parameter
documentation.
Parameters
----------
nlat : int
Number of latitude points
nlon : int
Number of longitude points
lmax : int
Maximum spherical harmonic degree
mmax : int
Maximum spherical harmonic order
grid : str
Grid type (``"equiangular"``, ``"legendre-gauss"``, ``"lobatto"``, ``"equiangular-trapezoidal"``), by default ``"equiangular"``
norm : str
Normalization type (``"ortho"``, ``"schmidt"``, ``"unnorm"``), by default ``"ortho"``
csphase : bool
Whether to apply the Condon-Shortley phase factor, by default True
Returns
-------
torch.Tensor
Tensor of shape (..., lmax, mmax)
References
----------
:cite:`Schaeffer2013`, :cite:`Wang2018`
"""
def __init__(self, nlat, nlon, lmax=None, mmax=None, grid="equiangular", norm="ortho", csphase=True):
super().__init__()
self.nlat = nlat
self.nlon = nlon
self.grid = grid
self.norm = norm
self.csphase = csphase
self.comm_size_polar = polar_group_size()
self.comm_rank_polar = polar_group_rank()
self.comm_size_azimuth = azimuth_group_size()
self.comm_rank_azimuth = azimuth_group_rank()
# determine maximum degrees based on triangular truncation
self.lmax, self.mmax = truncate_sht(self.nlat, self.nlon, lmax, mmax, self.grid)
# compute splits
self.lat_shapes = compute_split_shapes(self.nlat, self.comm_size_polar)
self.lon_shapes = compute_split_shapes(self.nlon, self.comm_size_azimuth)
self.l_shapes = compute_split_shapes(self.lmax, self.comm_size_polar)
self.lmax_local = self.l_shapes[self.comm_rank_polar]
self.m_shapes = compute_split_shapes(self.mmax, self.comm_size_azimuth)
self.mmax_local = self.m_shapes[self.comm_rank_azimuth]
self.lmax_offset = sum(self.l_shapes[: self.comm_rank_polar])
self.mmax_offset = sum(self.m_shapes[: self.comm_rank_azimuth])
# build only the block this rank keeps: local orders, local degrees, all latitudes,
# see DistributedInverseRealSHT.__init__
# store as (2, mmax_local, nlat, lmax_local) so the contraction dim l is stride-1
dpct = _precompute_dlegpoly(
self.mmax_offset + self.mmax_local,
self.lmax_offset + self.lmax_local,
self.nlat,
self.grid,
norm=self.norm,
inverse=True,
csphase=self.csphase,
mmin=self.mmax_offset,
lmin=self.lmax_offset,
)
dpct = dpct.permute(0, 1, 3, 2).contiguous()
# register buffer
self.register_buffer("dpct", dpct, persistent=False)
def extra_repr(self):
return f"nlat={self.nlat}, nlon={self.nlon},\n lmax={self.lmax}, mmax={self.mmax},\n grid={self.grid}, csphase={self.csphase}"
# This transform cannot be captured in a single graph: the redistribution collectives it
# calls are themselves torch.compiler.disable()d, so at comm_size > 1 dynamo breaks at
# every one of them and inductor only ever sees the slivers in between. Those slivers hold
# complex intermediates, which triton cannot type (KeyError: 'complex64' in codegen), and
# compiling them buys nothing next to the all-to-alls surrounding them. Disabling the whole
# forward costs no fusion that the breaks had not already cost, and keeps the complex
# spectral data out of inductor entirely. The serial transforms are compiled as usual.
@torch.compiler.disable()
def forward(self, x: torch.Tensor):
check(x.dim() >= 4, lambda: f"Expected tensor with at least 4 dimensions but got {x.dim()} instead")
check(x.shape[-3] == 2, lambda: f"Expected vector field shape[-3]==2, got {x.shape[-3]}")
check(x.shape[-2] == self.lmax_local, lambda: f"Expected spherical harmonic degrees (lmax) shape[-2]=={self.lmax_local}, got {x.shape[-2]}")
check(x.shape[-1] == self.mmax_local, lambda: f"Expected spherical harmonic orders (mmax) shape[-1]=={self.mmax_local}, got {x.shape[-1]}")
# the transposes below redistribute the leading (channel/batch) axis across the
# process grid, so it must be at least as large as the larger comm group. Flatten
# all leading dims into that axis -- keeping the trailing (2, lmax, mmax) intact --
# and zero-pad it if needed (linear transform, so padding stays zero); restore the
# original layout before returning.
x, lead_shape, lead_size = flatten_and_pad_leading_dims(x, self.comm_size_azimuth, num_trailing_dims=3)
num_chans = x.shape[-4]
# transpose to put the contraction dim (lmax) on the fast axis. l stays split across
# the polar group, see DistributedInverseRealSHT.forward.
x = x.transpose(-1, -2)
x_re = x.real.contiguous()
x_im = x.imag.contiguous()
# dpct layout: (2, mmax_local, nlat, lmax_local) — contract over l (stride-1 in both operands)
d0 = self.dpct[0].to(x_re.dtype)
d1 = self.dpct[1].to(x_re.dtype)
# contraction - spheroidal component
srl = torch.einsum("...ml,mkl->...km", x_re[..., 0, :, :], d0) - torch.einsum("...ml,mkl->...km", x_im[..., 1, :, :], d1)
sim = torch.einsum("...ml,mkl->...km", x_im[..., 0, :, :], d0) + torch.einsum("...ml,mkl->...km", x_re[..., 1, :, :], d1)
# contraction - toroidal component
trl = -torch.einsum("...ml,mkl->...km", x_im[..., 0, :, :], d1) - torch.einsum("...ml,mkl->...km", x_re[..., 1, :, :], d0)
tim = torch.einsum("...ml,mkl->...km", x_re[..., 0, :, :], d1) - torch.einsum("...ml,mkl->...km", x_im[..., 1, :, :], d0)
# reassemble in real space, see RealVectorSHT.forward
out_re = torch.stack((srl, trl), dim=-3)
out_im = torch.stack((sim, tim), dim=-3)
# complete the synthesis sum and split nlat in a single collective, on the real view
# (see DistributedRealSHT.forward for why the collective cannot take complex input).
out = torch.stack((out_re, out_im), dim=-1)
if self.comm_size_polar > 1:
out = reduce_from_scatter_to_polar_region(out, -3)
x = torch.view_as_complex(out.contiguous())
# transpose: after this, channels are split and m is local
if self.comm_size_azimuth > 1:
x = distributed_transpose_azimuth(x, (-4, -1), self.m_shapes)
# apply the inverse (real) FFT
x = irfft(x, n=self.nlon, dim=-1, norm="forward")
# transpose: after this, m is split and channels are local
if self.comm_size_azimuth > 1:
chan_shapes = compute_split_shapes(num_chans, self.comm_size_azimuth)
x = distributed_transpose_azimuth(x, (-1, -4), chan_shapes)
# drop padding and restore the original leading dims
x = unpad_and_unflatten_leading_dims(x, lead_shape, lead_size, num_trailing_dims=3)
return x