From 56cc1ccb8397bb35464abe370df7514a56da92be Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Tue, 13 Jan 2026 20:04:25 +0000 Subject: [PATCH 01/30] refactor MLP to LoRAMLP --- fme/core/models/conditional_sfno/layers.py | 144 ++++++++++++++++++-- fme/core/models/conditional_sfno/sfnonet.py | 6 +- 2 files changed, 134 insertions(+), 16 deletions(-) diff --git a/fme/core/models/conditional_sfno/layers.py b/fme/core/models/conditional_sfno/layers.py index 8fba9691e..e9a6da1cf 100644 --- a/fme/core/models/conditional_sfno/layers.py +++ b/fme/core/models/conditional_sfno/layers.py @@ -327,7 +327,99 @@ def forward(self, x): # pragma: no cover return x -class MLP(nn.Module): +class LoraConv2d(nn.Module): + """ + LoRA adapter for Conv2d. + + Returns ONLY the low-rank update (delta), so you can do: + y = base_conv(x) + lora_conv(x) + + Design: + - Down projection: Conv2d(in -> r, same kernel/stride/pad/dilation/groups as base) + - Up projection: 1x1 Conv2d(r -> out) + - delta scaled by (alpha / r) + """ + + def __init__( + self, + in_channels: int, + out_channels: int, + kernel_size: int | tuple[int, int], + stride: int | tuple[int, int] = 1, + padding: int | tuple[int, int] = 0, + dilation: int | tuple[int, int] = 1, + groups: int = 1, + lora_rank: int = 0, + lora_alpha: float | None = None, + dropout: float = 0.0, + bias: bool = False, # LoRA update typically no bias; keep False unless you want it + ) -> None: + super().__init__() + if lora_rank <= 0: + raise ValueError(f"lora_rank must be > 0, got {lora_rank}") + + if in_channels % groups != 0: + raise ValueError("in_channels must be divisible by groups") + if out_channels % groups != 0: + raise ValueError("out_channels must be divisible by groups") + + self.in_channels = in_channels + self.out_channels = out_channels + self.groups = groups + self.rank = int(lora_rank) + self.alpha = float(lora_alpha) if lora_alpha is not None else float(self.rank) + self.scaling = self.alpha / self.rank + + # LoRA dropout is applied on the input to the adapter (common choice) + self.lora_dropout = ( + nn.Dropout(dropout) if dropout and dropout > 0.0 else nn.Identity() + ) + + # Down: match base conv's spatial behavior; keep same groups so it composes cleanly. + self.down = nn.Conv2d( + in_channels=in_channels, + out_channels=self.rank, + kernel_size=kernel_size, + stride=stride, + padding=padding, + dilation=dilation, + groups=groups, + bias=False, + ) + + # Up: 1x1 to map rank -> out_channels; use same groups for consistency. + # Note: requires rank divisible by groups (since conv groups split channels). + if self.rank % groups != 0: + raise ValueError( + f"lora_rank ({self.rank}) must be divisible by groups ({groups})" + ) + self.up = nn.Conv2d( + in_channels=self.rank, + out_channels=out_channels, + kernel_size=1, + stride=1, + padding=0, + dilation=1, + groups=groups, + bias=bias, + ) + + self.reset_parameters() + + def reset_parameters(self) -> None: + # Common LoRA init: down ~ Kaiming, up = 0 so initial delta is exactly 0. + nn.init.kaiming_uniform_(self.down.weight, a=math.sqrt(5)) + nn.init.zeros_(self.up.weight) + if self.up.bias is not None: + nn.init.zeros_(self.up.bias) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + x = self.lora_dropout(x) + delta = self.up(self.down(x)) + return delta * self.scaling + + +class LoRAMLP(nn.Module): """ Basic CNN with support for gradient checkpointing """ @@ -340,34 +432,62 @@ def __init__( act_layer=nn.GELU, output_bias=True, drop_rate=0.0, - checkpointing=0, + lora_rank: int = 0, + lora_alpha: int | None = None, ): # pragma: no cover - super(MLP, self).__init__() - self.checkpointing = checkpointing + super(LoRAMLP, self).__init__() out_features = out_features or in_features hidden_features = hidden_features or in_features fc1 = nn.Conv2d(in_features, hidden_features, 1, bias=True) act = act_layer() fc2 = nn.Conv2d(hidden_features, out_features, 1, bias=output_bias) + # self.fwd structure is kept for backwards compatibility with old checkpoints if drop_rate > 0.0: drop = nn.Dropout(drop_rate) self.fwd = nn.Sequential(fc1, act, drop, fc2, drop) else: self.fwd = nn.Sequential(fc1, act, fc2) - # by default, all weights are shared + if lora_rank > 0: + self.lora1: None | LoraConv2d = LoraConv2d( + in_features, + hidden_features, + 1, + lora_rank=lora_rank, + lora_alpha=lora_alpha, + ) + self.lora2: None | LoraConv2d = LoraConv2d( + hidden_features, + out_features, + 1, + lora_rank=lora_rank, + lora_alpha=lora_alpha, + ) + else: + self.lora1 = None + self.lora2 = None - @torch.jit.ignore - def checkpoint_forward(self, x): # pragma: no cover - """Forward method with support for gradient checkpointing""" - return checkpoint(self.fwd, x) + # by default, all weights are shared def forward(self, x): # pragma: no cover - if self.checkpointing >= 2: - return self.checkpoint_forward(x) + if len(self.fwd) == 5: + fc1, act, drop1, fc2, drop2 = self.fwd else: - return self.fwd(x) + fc1, act, fc2 = self.fwd + drop1 = drop2 = nn.Identity() + if self.lora1 is not None: + x = fc1(x) + self.lora1(x) + else: + x = fc1(x) + x = act(x) + x = drop1(x) + if self.lora2 is not None: + x = fc2(x) + self.lora2(x) + else: + x = fc2(x) + x = drop2(x) + return x class RealFFT2(nn.Module): diff --git a/fme/core/models/conditional_sfno/sfnonet.py b/fme/core/models/conditional_sfno/sfnonet.py index d3b7b58c2..affbc13b3 100644 --- a/fme/core/models/conditional_sfno/sfnonet.py +++ b/fme/core/models/conditional_sfno/sfnonet.py @@ -32,7 +32,7 @@ # import global convolution and non-linear spectral layers # helpers from .layers import ( - MLP, + LoRAMLP, ConditionalLayerNorm, Context, ContextConfig, @@ -257,14 +257,12 @@ def __init__( ) if use_mlp == True: - MLPH = MLP mlp_hidden_dim = int(embed_dim * mlp_ratio) - self.mlp = MLPH( + self.mlp = LoRAMLP( in_features=embed_dim, hidden_features=mlp_hidden_dim, act_layer=act_layer, drop_rate=drop_rate, - checkpointing=checkpointing, ) if outer_skip == "linear": From 5204ae134a1664a79466ef32a222c6b3bf92e8f7 Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Tue, 13 Jan 2026 21:47:44 +0000 Subject: [PATCH 02/30] add lora to Conv2d in conditional sfno --- fme/core/models/conditional_sfno/layers.py | 164 ++++-------------- fme/core/models/conditional_sfno/lora.py | 134 ++++++++++++++ fme/core/models/conditional_sfno/sfnonet.py | 83 +++++++-- fme/core/models/conditional_sfno/test_lora.py | 23 +++ 4 files changed, 261 insertions(+), 143 deletions(-) create mode 100644 fme/core/models/conditional_sfno/lora.py create mode 100644 fme/core/models/conditional_sfno/test_lora.py diff --git a/fme/core/models/conditional_sfno/layers.py b/fme/core/models/conditional_sfno/layers.py index e9a6da1cf..3756b3d1c 100644 --- a/fme/core/models/conditional_sfno/layers.py +++ b/fme/core/models/conditional_sfno/layers.py @@ -24,6 +24,8 @@ import torch.nn.functional as F from torch.utils.checkpoint import checkpoint +from fme.core.models.conditional_sfno.lora import LoRAConv2d + from .activations import ComplexReLU from .contractions import compl_mul2d_fwd, compl_muladd2d_fwd @@ -327,99 +329,7 @@ def forward(self, x): # pragma: no cover return x -class LoraConv2d(nn.Module): - """ - LoRA adapter for Conv2d. - - Returns ONLY the low-rank update (delta), so you can do: - y = base_conv(x) + lora_conv(x) - - Design: - - Down projection: Conv2d(in -> r, same kernel/stride/pad/dilation/groups as base) - - Up projection: 1x1 Conv2d(r -> out) - - delta scaled by (alpha / r) - """ - - def __init__( - self, - in_channels: int, - out_channels: int, - kernel_size: int | tuple[int, int], - stride: int | tuple[int, int] = 1, - padding: int | tuple[int, int] = 0, - dilation: int | tuple[int, int] = 1, - groups: int = 1, - lora_rank: int = 0, - lora_alpha: float | None = None, - dropout: float = 0.0, - bias: bool = False, # LoRA update typically no bias; keep False unless you want it - ) -> None: - super().__init__() - if lora_rank <= 0: - raise ValueError(f"lora_rank must be > 0, got {lora_rank}") - - if in_channels % groups != 0: - raise ValueError("in_channels must be divisible by groups") - if out_channels % groups != 0: - raise ValueError("out_channels must be divisible by groups") - - self.in_channels = in_channels - self.out_channels = out_channels - self.groups = groups - self.rank = int(lora_rank) - self.alpha = float(lora_alpha) if lora_alpha is not None else float(self.rank) - self.scaling = self.alpha / self.rank - - # LoRA dropout is applied on the input to the adapter (common choice) - self.lora_dropout = ( - nn.Dropout(dropout) if dropout and dropout > 0.0 else nn.Identity() - ) - - # Down: match base conv's spatial behavior; keep same groups so it composes cleanly. - self.down = nn.Conv2d( - in_channels=in_channels, - out_channels=self.rank, - kernel_size=kernel_size, - stride=stride, - padding=padding, - dilation=dilation, - groups=groups, - bias=False, - ) - - # Up: 1x1 to map rank -> out_channels; use same groups for consistency. - # Note: requires rank divisible by groups (since conv groups split channels). - if self.rank % groups != 0: - raise ValueError( - f"lora_rank ({self.rank}) must be divisible by groups ({groups})" - ) - self.up = nn.Conv2d( - in_channels=self.rank, - out_channels=out_channels, - kernel_size=1, - stride=1, - padding=0, - dilation=1, - groups=groups, - bias=bias, - ) - - self.reset_parameters() - - def reset_parameters(self) -> None: - # Common LoRA init: down ~ Kaiming, up = 0 so initial delta is exactly 0. - nn.init.kaiming_uniform_(self.down.weight, a=math.sqrt(5)) - nn.init.zeros_(self.up.weight) - if self.up.bias is not None: - nn.init.zeros_(self.up.bias) - - def forward(self, x: torch.Tensor) -> torch.Tensor: - x = self.lora_dropout(x) - delta = self.up(self.down(x)) - return delta * self.scaling - - -class LoRAMLP(nn.Module): +class MLP(nn.Module): """ Basic CNN with support for gradient checkpointing """ @@ -432,62 +342,50 @@ def __init__( act_layer=nn.GELU, output_bias=True, drop_rate=0.0, + checkpointing=0, lora_rank: int = 0, - lora_alpha: int | None = None, + lora_alpha: float | None = None, ): # pragma: no cover - super(LoRAMLP, self).__init__() + super(MLP, self).__init__() + self.checkpointing = checkpointing out_features = out_features or in_features hidden_features = hidden_features or in_features - fc1 = nn.Conv2d(in_features, hidden_features, 1, bias=True) + fc1 = LoRAConv2d( + in_features, + hidden_features, + 1, + bias=True, + lora_rank=lora_rank, + lora_alpha=lora_alpha, + ) act = act_layer() - fc2 = nn.Conv2d(hidden_features, out_features, 1, bias=output_bias) - # self.fwd structure is kept for backwards compatibility with old checkpoints + fc2 = LoRAConv2d( + hidden_features, + out_features, + 1, + bias=output_bias, + lora_rank=lora_rank, + lora_alpha=lora_alpha, + ) if drop_rate > 0.0: drop = nn.Dropout(drop_rate) self.fwd = nn.Sequential(fc1, act, drop, fc2, drop) else: self.fwd = nn.Sequential(fc1, act, fc2) - if lora_rank > 0: - self.lora1: None | LoraConv2d = LoraConv2d( - in_features, - hidden_features, - 1, - lora_rank=lora_rank, - lora_alpha=lora_alpha, - ) - self.lora2: None | LoraConv2d = LoraConv2d( - hidden_features, - out_features, - 1, - lora_rank=lora_rank, - lora_alpha=lora_alpha, - ) - else: - self.lora1 = None - self.lora2 = None - # by default, all weights are shared + @torch.jit.ignore + def checkpoint_forward(self, x): # pragma: no cover + """Forward method with support for gradient checkpointing""" + return checkpoint(self.fwd, x) + def forward(self, x): # pragma: no cover - if len(self.fwd) == 5: - fc1, act, drop1, fc2, drop2 = self.fwd - else: - fc1, act, fc2 = self.fwd - drop1 = drop2 = nn.Identity() - if self.lora1 is not None: - x = fc1(x) + self.lora1(x) + if self.checkpointing >= 2: + return self.checkpoint_forward(x) else: - x = fc1(x) - x = act(x) - x = drop1(x) - if self.lora2 is not None: - x = fc2(x) + self.lora2(x) - else: - x = fc2(x) - x = drop2(x) - return x + return self.fwd(x) class RealFFT2(nn.Module): diff --git a/fme/core/models/conditional_sfno/lora.py b/fme/core/models/conditional_sfno/lora.py new file mode 100644 index 000000000..cdc8d4949 --- /dev/null +++ b/fme/core/models/conditional_sfno/lora.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import math + +import torch +import torch.nn as nn + + +class LoRAConv2d(nn.Conv2d): + """ + Drop-in Conv2d with optional LoRA. + + - API matches torch.nn.Conv2d, with extra args: + lora_rank: int = 0 (0 disables LoRA) + lora_alpha: float = None (defaults to lora_rank) + lora_dropout: float = 0.0 + + - Can load a checkpoint saved from nn.Conv2d even when lora_rank > 0 + (i.e., state_dict only has "weight"/"bias"). + """ + + def __init__( + self, + in_channels: int, + out_channels: int, + kernel_size: int | tuple[int, int], + stride: int | tuple[int, int] = 1, + padding: int | tuple[int, int] = 0, + dilation: int | tuple[int, int] = 1, + groups: int = 1, + bias: bool = True, + padding_mode: str = "zeros", + device=None, + dtype=None, + *, + lora_rank: int = 0, + lora_alpha: float | None = None, + lora_dropout: float = 0.0, + ) -> None: + factory_kwargs = {"device": device, "dtype": dtype} + super().__init__( + in_channels=in_channels, + out_channels=out_channels, + kernel_size=kernel_size, + stride=stride, + padding=padding, + dilation=dilation, + groups=groups, + bias=bias, + padding_mode=padding_mode, + **factory_kwargs, + ) + + if lora_rank < 0: + raise ValueError(f"lora_rank must be >= 0, got {lora_rank}") + if lora_dropout < 0.0: + raise ValueError(f"lora_dropout must be >= 0, got {lora_dropout}") + + self.lora_rank = int(lora_rank) + self.lora_alpha = ( + float(lora_alpha) if lora_alpha is not None else float(lora_rank) + ) + self.lora_dropout_p = float(lora_dropout) + + self._lora_merged = False + + if self.lora_rank > 0: + # Group-compatible LoRA via two convs: + # down: 1x1 grouped conv: in_channels -> (groups * r), groups=groups + # up: kxk grouped conv: (groups * r) -> out_channels, groups=groups + # This produces a delta with the same grouped structure as the base conv. + mid_channels = self.groups * self.lora_rank + + self.lora_down = nn.Conv2d( + in_channels=self.in_channels, + out_channels=mid_channels, + kernel_size=1, + stride=1, + padding=0, + dilation=1, + groups=self.groups, + bias=False, + **factory_kwargs, + ) + self.lora_up = nn.Conv2d( + in_channels=mid_channels, + out_channels=self.out_channels, + kernel_size=self.kernel_size, + stride=self.stride, + padding=self.padding, + dilation=self.dilation, + groups=self.groups, + bias=False, + padding_mode=self.padding_mode, + **factory_kwargs, + ) + + self.lora_dropout = ( + nn.Dropout(p=self.lora_dropout_p) + if self.lora_dropout_p > 0 + else nn.Identity() + ) + + # Init: down ~ Kaiming, up = 0 so the module starts + # identical to base Conv2d. + nn.init.kaiming_uniform_(self.lora_down.weight, a=math.sqrt(5)) + nn.init.zeros_(self.lora_up.weight) + + # Scaling as in LoRA: alpha / r + self.lora_scaling = self.lora_alpha / float(self.lora_rank) + else: + # Keep attributes for easier introspection, but no parameters. + self.lora_down = None + self.lora_up = None + self.lora_dropout = nn.Identity() + self.lora_scaling = 0.0 + + def extra_repr(self) -> str: + base = super().extra_repr() + if self.lora_rank > 0: + return ( + f"{base}, lora_rank={self.lora_rank}, lora_alpha={self.lora_alpha}, " + f"lora_dropout={self.lora_dropout_p}, lora_merged={self._lora_merged}" + ) + return f"{base}, lora_rank=0" + + def forward(self, x: torch.Tensor) -> torch.Tensor: + y = super().forward(x) + if self.lora_rank == 0 or self._lora_merged: + return y + assert self.lora_down is not None and self.lora_up is not None + return ( + y + self.lora_up(self.lora_down(self.lora_dropout(x))) * self.lora_scaling + ) diff --git a/fme/core/models/conditional_sfno/sfnonet.py b/fme/core/models/conditional_sfno/sfnonet.py index affbc13b3..aa709d29e 100644 --- a/fme/core/models/conditional_sfno/sfnonet.py +++ b/fme/core/models/conditional_sfno/sfnonet.py @@ -32,13 +32,14 @@ # import global convolution and non-linear spectral layers # helpers from .layers import ( - LoRAMLP, + MLP, ConditionalLayerNorm, Context, ContextConfig, DropPath, SpectralAttention2d, ) +from .lora import LoRAConv2d from .s2convolutions import SpectralAttentionS2, SpectralConvS2 from .makani.spectral_convolution import SpectralConv @@ -195,6 +196,8 @@ def __init__( filter_residual=False, affine_norms=False, filter_num_groups: int = 1, + lora_rank: int = 0, + lora_alpha: float | None = None, ): super(FourierNeuralOperatorBlock, self).__init__() @@ -232,14 +235,23 @@ def __init__( ) if inner_skip == "linear": - self.inner_skip = nn.Conv2d(embed_dim, embed_dim, 1, 1) + self.inner_skip = LoRAConv2d( + embed_dim, embed_dim, 1, 1, lora_rank=lora_rank, lora_alpha=lora_alpha + ) elif inner_skip == "identity": self.inner_skip = nn.Identity() self.concat_skip = concat_skip if concat_skip and inner_skip is not None: - self.inner_skip_conv = nn.Conv2d(2 * embed_dim, embed_dim, 1, bias=False) + self.inner_skip_conv = LoRAConv2d( + 2 * embed_dim, + embed_dim, + 1, + bias=False, + lora_rank=lora_rank, + lora_alpha=lora_alpha, + ) if filter_type == "linear" or filter_type == "real linear": self.act_layer = act_layer() @@ -258,20 +270,31 @@ def __init__( if use_mlp == True: mlp_hidden_dim = int(embed_dim * mlp_ratio) - self.mlp = LoRAMLP( + self.mlp = MLP( in_features=embed_dim, hidden_features=mlp_hidden_dim, act_layer=act_layer, drop_rate=drop_rate, + lora_rank=lora_rank, + lora_alpha=lora_alpha, ) if outer_skip == "linear": - self.outer_skip = nn.Conv2d(embed_dim, embed_dim, 1, 1) + self.outer_skip = LoRAConv2d( + embed_dim, embed_dim, 1, 1, lora_rank=lora_rank, lora_alpha=lora_alpha + ) elif outer_skip == "identity": self.outer_skip = nn.Identity() if concat_skip and outer_skip is not None: - self.outer_skip_conv = nn.Conv2d(2 * embed_dim, embed_dim, 1, bias=False) + self.outer_skip_conv = LoRAConv2d( + 2 * embed_dim, + embed_dim, + 1, + bias=False, + lora_rank=lora_rank, + lora_alpha=lora_alpha, + ) def forward(self, x, context_embedding): x_norm = torch.zeros_like(x) @@ -522,6 +545,8 @@ def __init__( local_blocks: Optional[List[int]] = None, normalize_big_skip: bool = False, affine_norms: bool = False, + lora_rank: int = 0, + lora_alpha: float | None = None, ): super(SphericalFourierNeuralOperatorNet, self).__init__() @@ -635,6 +660,10 @@ def __init__( if hasattr(params, "filter_num_groups") else filter_num_groups ) + self.lora_rank = params.lora_rank if hasattr(params, "lora_rank") else lora_rank + self.lora_alpha = ( + params.lora_alpha if hasattr(params, "lora_alpha") else lora_alpha + ) # no global padding because we removed the horizontal distributed code self.padding = (0, 0) @@ -674,11 +703,27 @@ def __init__( encoder_modules = [] for i in range(self.encoder_layers): encoder_modules.append( - nn.Conv2d(current_dim, encoder_hidden_dim, 1, bias=True) + LoRAConv2d( + current_dim, + encoder_hidden_dim, + 1, + bias=True, + lora_rank=self.lora_rank, + lora_alpha=self.lora_alpha, + ) ) encoder_modules.append(self.activation_function()) current_dim = encoder_hidden_dim - encoder_modules.append(nn.Conv2d(current_dim, self.embed_dim, 1, bias=False)) + encoder_modules.append( + LoRAConv2d( + current_dim, + self.embed_dim, + 1, + bias=False, + lora_rank=self.lora_rank, + lora_alpha=self.lora_alpha, + ) + ) self.encoder = nn.Sequential(*encoder_modules) # dropout @@ -730,6 +775,8 @@ def __init__( filter_residual=self.filter_residual, affine_norms=self.affine_norms, filter_num_groups=self.filter_num_groups, + lora_rank=self.lora_rank, + lora_alpha=self.lora_alpha, ) self.blocks.append(block) @@ -740,11 +787,27 @@ def __init__( decoder_modules = [] for i in range(self.encoder_layers): decoder_modules.append( - nn.Conv2d(current_dim, decoder_hidden_dim, 1, bias=True) + LoRAConv2d( + current_dim, + decoder_hidden_dim, + 1, + bias=True, + lora_rank=self.lora_rank, + lora_alpha=self.lora_alpha, + ) ) decoder_modules.append(self.activation_function()) current_dim = decoder_hidden_dim - decoder_modules.append(nn.Conv2d(current_dim, self.out_chans, 1, bias=False)) + decoder_modules.append( + LoRAConv2d( + current_dim, + self.out_chans, + 1, + bias=False, + lora_rank=self.lora_rank, + lora_alpha=self.lora_alpha, + ) + ) self.decoder = nn.Sequential(*decoder_modules) # learned position embedding diff --git a/fme/core/models/conditional_sfno/test_lora.py b/fme/core/models/conditional_sfno/test_lora.py new file mode 100644 index 000000000..d880098d5 --- /dev/null +++ b/fme/core/models/conditional_sfno/test_lora.py @@ -0,0 +1,23 @@ +import torch +from torch import nn + +from fme.core.models.conditional_sfno.lora import LoRAConv2d + + +def test_lora_conv2d_load_conv2d_checkpoint(): + conv = nn.Conv2d(8, 16, 3, padding=1) + lora = LoRAConv2d(8, 16, 3, padding=1) # default should not use/require lora + + lora.load_state_dict(conv.state_dict(), strict=True) + + x = torch.randn(2, 8, 32, 32) + with torch.no_grad(): + y0 = conv(x) + y1 = lora(x) + torch.testing.assert_close( + y0, + y1, + atol=1e-6, + rtol=0, + msg="Outputs do not match after loading Conv2d checkpoint", + ) From 0a6ab69166817221bec52d6c276b9defda46eec7 Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Wed, 14 Jan 2026 22:18:13 +0000 Subject: [PATCH 03/30] add lora for spectral convolutions, add to csfno config --- fme/ace/registry/stochastic_sfno.py | 12 ++++ fme/core/models/conditional_sfno/lora.py | 12 ++-- .../models/conditional_sfno/s2convolutions.py | 55 +++++++++++++++++++ fme/core/models/conditional_sfno/sfnonet.py | 29 +++++++++- .../conditional_sfno/test_s2convolutions.py | 49 +++++++++++++++++ 5 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 fme/core/models/conditional_sfno/test_s2convolutions.py diff --git a/fme/ace/registry/stochastic_sfno.py b/fme/ace/registry/stochastic_sfno.py index debc12fde..84abcf761 100644 --- a/fme/ace/registry/stochastic_sfno.py +++ b/fme/ace/registry/stochastic_sfno.py @@ -134,6 +134,14 @@ class NoiseConditionedSFNOBuilder(ModuleConfig): normalization layers. filter_num_groups: Number of groups to use in grouped convolutions for the spectral filter. + lora_rank: Rank of the LoRA adaptations outside of spectral convolutions. + 0 (default) disables LoRA. + lora_alpha: Strength of the LoRA adaptations outside of spectral convolutions. + Defaults to lora_rank. + spectral_lora_rank: Rank of the LoRA adaptations for spectral convolutions. + 0 (default) disables LoRA. + spectral_lora_alpha: Strength of the LoRA adaptations for spectral convolutions. + Defaults to spectral_lora_rank. """ spectral_transform: Literal["sht"] = "sht" @@ -166,6 +174,10 @@ class NoiseConditionedSFNOBuilder(ModuleConfig): normalize_big_skip: bool = False affine_norms: bool = False filter_num_groups: int = 1 + lora_rank: int = 0 + lora_alpha: float | None = None + spectral_lora_rank: int = 0 + spectral_lora_alpha: float | None = None def build( self, diff --git a/fme/core/models/conditional_sfno/lora.py b/fme/core/models/conditional_sfno/lora.py index cdc8d4949..f473f5fe4 100644 --- a/fme/core/models/conditional_sfno/lora.py +++ b/fme/core/models/conditional_sfno/lora.py @@ -101,11 +101,7 @@ def __init__( else nn.Identity() ) - # Init: down ~ Kaiming, up = 0 so the module starts - # identical to base Conv2d. - nn.init.kaiming_uniform_(self.lora_down.weight, a=math.sqrt(5)) - nn.init.zeros_(self.lora_up.weight) - + self.reset_parameters() # Scaling as in LoRA: alpha / r self.lora_scaling = self.lora_alpha / float(self.lora_rank) else: @@ -115,6 +111,12 @@ def __init__( self.lora_dropout = nn.Identity() self.lora_scaling = 0.0 + def reset_parameters(self) -> None: + # Init: down ~ Kaiming, up = 0 so the module starts + # identical to base Conv2d. + nn.init.kaiming_uniform_(self.lora_down.weight, a=math.sqrt(5)) + nn.init.zeros_(self.lora_up.weight) + def extra_repr(self) -> str: base = super().extra_repr() if self.lora_rank > 0: diff --git a/fme/core/models/conditional_sfno/s2convolutions.py b/fme/core/models/conditional_sfno/s2convolutions.py index b6a373d8c..ebdd83493 100644 --- a/fme/core/models/conditional_sfno/s2convolutions.py +++ b/fme/core/models/conditional_sfno/s2convolutions.py @@ -44,6 +44,17 @@ from .factorizations import get_contract_fun +@torch.jit.script +def _contract_lora( + lora_A: torch.Tensor, + lora_B: torch.Tensor, + x: torch.Tensor, +): + lora_A = torch.view_as_complex(lora_A) + lora_B = torch.view_as_complex(lora_B) + return torch.einsum("irx,rox,bixy->boxy", lora_A, lora_B, x) + + class SpectralConvS2(nn.Module): """ Spectral Convolution according to Driscoll & Healy. Designed for convolutions on @@ -67,9 +78,16 @@ def __init__( bias=False, use_tensorly=True, filter_residual: bool = False, + lora_rank: int = 0, + lora_alpha: float | None = None, ): # pragma: no cover super(SpectralConvS2, self).__init__() + if in_channels != out_channels: + raise NotImplementedError( + "Currently only in_channels == out_channels is supported." + ) + if scale == "auto": scale = 1 / (in_channels * out_channels) @@ -151,6 +169,33 @@ def __init__( else: self.weight.is_shared_mp = ["matmul"] + if lora_rank > 0: + if self.weight.shape != ( + in_channels, + out_channels, + self.modes_lat_local, + 2, + ): + raise NotImplementedError( + "LoRA is only implemented for dhconv with unpadded weights." + ) + if use_tensorly: + raise NotImplementedError( + "LoRA is not implemented for tensorly factorized weights." + ) + self.lora_A = nn.Parameter( + scale * torch.randn(in_channels, lora_rank, self.modes_lat_local, 2) + ) + self.lora_B = nn.Parameter( + torch.zeros(lora_rank, out_channels, self.modes_lat_local, 2) + ) + self.lora_alpha = lora_alpha if lora_alpha is not None else lora_rank + self.lora_scaling = self.lora_alpha / lora_rank + else: + self.lora_A = None + self.lora_B = None + self.lora_scaling = 0.0 + # get the contraction handle self._contract = get_contract_fun( self.weight, implementation="factorized", separable=separable @@ -172,6 +217,15 @@ def forward(self, x): # pragma: no cover residual = self.inverse_transform(x) residual = residual.to(dtype) + if self.lora_A is not None and self.lora_B is not None: + lora_update = _contract_lora( + self.lora_A, + self.lora_B, + x[..., : self.modes_lat_local, : self.modes_lon_local], + ) + else: + lora_update = 0.0 + # approach with unpadded weights xp = torch.zeros_like(x) xp[..., : self.modes_lat_local, : self.modes_lon_local] = self._contract( @@ -180,6 +234,7 @@ def forward(self, x): # pragma: no cover separable=self.separable, operator_type=self.operator_type, ) + xp = xp + self.lora_scaling * lora_update x = xp.contiguous() # # approach with padded weights diff --git a/fme/core/models/conditional_sfno/sfnonet.py b/fme/core/models/conditional_sfno/sfnonet.py index aa709d29e..248d8f845 100644 --- a/fme/core/models/conditional_sfno/sfnonet.py +++ b/fme/core/models/conditional_sfno/sfnonet.py @@ -91,9 +91,14 @@ def __init__( drop_rate=0.0, num_groups=1, filter_residual=False, + lora_rank: int = 0, + lora_alpha: float | None = None, ): super(SpectralFilterLayer, self).__init__() + if lora_rank != 0 and filter_type != "linear": + raise NotImplementedError("LoRA is only supported for linear filter type.") + if filter_type == "non-linear": self.filter = SpectralAttentionS2( forward_transform, @@ -122,6 +127,8 @@ def __init__( bias=True, use_tensorly=False if factorization is None else True, filter_residual=filter_residual, + lora_rank=lora_rank, + lora_alpha=lora_alpha, ) elif filter_type == "makani-linear": self.filter = SpectralConv( @@ -198,6 +205,8 @@ def __init__( filter_num_groups: int = 1, lora_rank: int = 0, lora_alpha: float | None = None, + spectral_lora_rank: int = 0, + spectral_lora_alpha: float | None = None, ): super(FourierNeuralOperatorBlock, self).__init__() @@ -232,6 +241,8 @@ def __init__( drop_rate=drop_rate, filter_residual=filter_residual, num_groups=filter_num_groups, + lora_rank=spectral_lora_rank, + lora_alpha=spectral_lora_alpha, ) if inner_skip == "linear": @@ -547,6 +558,8 @@ def __init__( affine_norms: bool = False, lora_rank: int = 0, lora_alpha: float | None = None, + spectral_lora_rank: int = 0, + spectral_lora_alpha: float | None = None, ): super(SphericalFourierNeuralOperatorNet, self).__init__() @@ -664,6 +677,16 @@ def __init__( self.lora_alpha = ( params.lora_alpha if hasattr(params, "lora_alpha") else lora_alpha ) + self.spectral_lora_rank = ( + params.spectral_lora_rank + if hasattr(params, "spectral_lora_rank") + else spectral_lora_rank + ) + self.spectral_lora_alpha = ( + params.spectral_lora_alpha + if hasattr(params, "spectral_lora_alpha") + else spectral_lora_alpha + ) # no global padding because we removed the horizontal distributed code self.padding = (0, 0) @@ -777,6 +800,8 @@ def __init__( filter_num_groups=self.filter_num_groups, lora_rank=self.lora_rank, lora_alpha=self.lora_alpha, + spectral_lora_rank=self.spectral_lora_rank, + spectral_lora_alpha=self.spectral_lora_alpha, ) self.blocks.append(block) @@ -828,7 +853,9 @@ def __init__( def _init_weights(self, m): """Helper routine for weight initialization""" - if isinstance(m, nn.Linear) or isinstance(m, nn.Conv2d): + if isinstance(m, LoRAConv2d): + m.reset_parameters() + elif isinstance(m, nn.Linear) or isinstance(m, nn.Conv2d): trunc_normal_(m.weight, std=0.02) if m.bias is not None: nn.init.constant_(m.bias, 0) diff --git a/fme/core/models/conditional_sfno/test_s2convolutions.py b/fme/core/models/conditional_sfno/test_s2convolutions.py new file mode 100644 index 000000000..be102d628 --- /dev/null +++ b/fme/core/models/conditional_sfno/test_s2convolutions.py @@ -0,0 +1,49 @@ +import torch + +from fme.core.gridded_ops import LatLonOperations +from fme.core.models.conditional_sfno.s2convolutions import SpectralConvS2 + + +def test_spectral_conv_s2_lora(): + in_channels = 8 + out_channels = in_channels + n_lat = 12 + n_lon = 24 + operations = LatLonOperations( + area_weights=torch.ones(n_lat, n_lon), + grid="legendre-gauss", + ) + sht = operations.get_real_sht() + isht = operations.get_real_isht() + + conv1 = SpectralConvS2( + forward_transform=sht, + inverse_transform=isht, + in_channels=in_channels, + out_channels=out_channels, + operator_type="dhconv", + use_tensorly=False, + ) + assert conv1.lora_A is None + assert conv1.lora_B is None + conv2 = SpectralConvS2( + forward_transform=sht, + inverse_transform=isht, + in_channels=in_channels, + out_channels=out_channels, + operator_type="dhconv", + use_tensorly=False, + lora_rank=4, + lora_alpha=8, + ) + assert conv2.lora_A is not None + assert conv2.lora_B is not None + + conv2.load_state_dict(conv1.state_dict(), strict=False) + x = torch.randn(2, in_channels, n_lat, n_lon) + y1, residual1 = conv1(x) + y2, residual2 = conv2(x) + + # initial outputs should be identical since LoRA starts at 0 + assert torch.allclose(y1, y2, atol=1e-6) + assert torch.allclose(residual1, residual2, atol=1e-6) From 044168a250f02cc5ff3508e6c218f49eb5a9403f Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Thu, 15 Jan 2026 15:26:16 +0000 Subject: [PATCH 04/30] avoid crash on super init --- fme/core/models/conditional_sfno/lora.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/fme/core/models/conditional_sfno/lora.py b/fme/core/models/conditional_sfno/lora.py index f473f5fe4..78f84bf09 100644 --- a/fme/core/models/conditional_sfno/lora.py +++ b/fme/core/models/conditional_sfno/lora.py @@ -38,6 +38,8 @@ def __init__( lora_dropout: float = 0.0, ) -> None: factory_kwargs = {"device": device, "dtype": dtype} + self.lora_down: nn.Conv2d | None = None + self.lora_up: nn.Conv2d | None = None super().__init__( in_channels=in_channels, out_channels=out_channels, @@ -101,21 +103,24 @@ def __init__( else nn.Identity() ) - self.reset_parameters() # Scaling as in LoRA: alpha / r self.lora_scaling = self.lora_alpha / float(self.lora_rank) else: - # Keep attributes for easier introspection, but no parameters. - self.lora_down = None - self.lora_up = None self.lora_dropout = nn.Identity() self.lora_scaling = 0.0 + self.reset_parameters() def reset_parameters(self) -> None: + super().reset_parameters() + self._reset_lora_parameters() + + def _reset_lora_parameters(self): # Init: down ~ Kaiming, up = 0 so the module starts # identical to base Conv2d. - nn.init.kaiming_uniform_(self.lora_down.weight, a=math.sqrt(5)) - nn.init.zeros_(self.lora_up.weight) + if self.lora_down is not None: + nn.init.kaiming_uniform_(self.lora_down.weight, a=math.sqrt(5)) + if self.lora_up is not None: + nn.init.zeros_(self.lora_up.weight) def extra_repr(self) -> str: base = super().extra_repr() From 3dd7aa985b75bc033d7687c6e8ea6c2ddc10b010 Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Thu, 15 Jan 2026 15:40:12 +0000 Subject: [PATCH 05/30] fix change to model output --- fme/core/models/conditional_sfno/lora.py | 6 +++--- fme/core/models/conditional_sfno/sfnonet.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/fme/core/models/conditional_sfno/lora.py b/fme/core/models/conditional_sfno/lora.py index 78f84bf09..ea548ef32 100644 --- a/fme/core/models/conditional_sfno/lora.py +++ b/fme/core/models/conditional_sfno/lora.py @@ -108,13 +108,13 @@ def __init__( else: self.lora_dropout = nn.Identity() self.lora_scaling = 0.0 - self.reset_parameters() + self.reset_lora_parameters() # base parameters already reset in super init def reset_parameters(self) -> None: super().reset_parameters() - self._reset_lora_parameters() + self.reset_lora_parameters() - def _reset_lora_parameters(self): + def reset_lora_parameters(self): # Init: down ~ Kaiming, up = 0 so the module starts # identical to base Conv2d. if self.lora_down is not None: diff --git a/fme/core/models/conditional_sfno/sfnonet.py b/fme/core/models/conditional_sfno/sfnonet.py index 248d8f845..ab760c8ba 100644 --- a/fme/core/models/conditional_sfno/sfnonet.py +++ b/fme/core/models/conditional_sfno/sfnonet.py @@ -853,12 +853,12 @@ def __init__( def _init_weights(self, m): """Helper routine for weight initialization""" - if isinstance(m, LoRAConv2d): - m.reset_parameters() - elif isinstance(m, nn.Linear) or isinstance(m, nn.Conv2d): + if isinstance(m, nn.Linear) or isinstance(m, nn.Conv2d): trunc_normal_(m.weight, std=0.02) if m.bias is not None: nn.init.constant_(m.bias, 0) + if isinstance(m, LoRAConv2d): + m.reset_lora_parameters() elif isinstance(m, ConditionalLayerNorm): m.reset_parameters() From 7f6cc527fbc82da0a38dd84fc792c5cc1a6dcb54 Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Thu, 22 Jan 2026 22:00:42 +0000 Subject: [PATCH 06/30] update regression target to use dhconv --- .../models/conditional_sfno/s2convolutions.py | 16 ++++++++++++++++ .../models/conditional_sfno/test_sfnonet.py | 5 +++-- .../test_sfnonet_output_is_unchanged.pt | Bin 9177 -> 9624 bytes 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/fme/core/models/conditional_sfno/s2convolutions.py b/fme/core/models/conditional_sfno/s2convolutions.py index ebdd83493..f9864c26a 100644 --- a/fme/core/models/conditional_sfno/s2convolutions.py +++ b/fme/core/models/conditional_sfno/s2convolutions.py @@ -82,6 +82,22 @@ def __init__( lora_alpha: float | None = None, ): # pragma: no cover super(SpectralConvS2, self).__init__() + if operator_type != "dhconv": + raise NotImplementedError( + "Only 'dhconv' operator type is currently supported." + ) + if factorization is not None: + raise NotImplementedError( + "Factorizations other than None are not currently supported." + ) + if use_tensorly: + raise NotImplementedError( + "Tensorly-based implementation is not currently supported." + ) + if separable: + raise NotImplementedError( + "Separable convolutions are not currently supported." + ) if in_channels != out_channels: raise NotImplementedError( diff --git a/fme/core/models/conditional_sfno/test_sfnonet.py b/fme/core/models/conditional_sfno/test_sfnonet.py index 43945893c..d2489d7d6 100644 --- a/fme/core/models/conditional_sfno/test_sfnonet.py +++ b/fme/core/models/conditional_sfno/test_sfnonet.py @@ -101,10 +101,10 @@ def test_sfnonet_output_is_unchanged(): img_shape = (9, 18) n_samples = 4 conditional_embed_dim_scalar = 8 - conditional_embed_dim_labels = 0 + conditional_embed_dim_labels = 4 conditional_embed_dim_noise = 16 device = get_device() - params = SimpleNamespace(embed_dim=16, num_layers=2) + params = SimpleNamespace(embed_dim=16, num_layers=2, operator_type="dhconv") model = get_lat_lon_sfnonet( params=params, img_shape=img_shape, @@ -165,6 +165,7 @@ def forward(self, x): num_layers=2, normalize_big_skip=normalize_big_skip, global_layer_norm=True, # so it uses nn.LayerNorm + operator_type="dhconv", ) model = get_lat_lon_sfnonet( params=params, diff --git a/fme/core/models/conditional_sfno/testdata/test_sfnonet_output_is_unchanged.pt b/fme/core/models/conditional_sfno/testdata/test_sfnonet_output_is_unchanged.pt index 6fb14b9be8c623160ea04fd0d327a3c93bd4a335..c3e6f7897a226acbfe6ba1116080aaf445a57610 100644 GIT binary patch literal 9624 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�gnvVc#3XIr1y; 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 Y!+(GNU9cnbOS014Mo#dn{_l1FA6%k}g8%>k literal 9177 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 From 71aac8b29679c618486655c414b91ada45e66bf5 Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Thu, 22 Jan 2026 22:04:56 +0000 Subject: [PATCH 07/30] use dhconv directly, disable other options --- .../models/conditional_sfno/s2convolutions.py | 81 ++++++++----------- 1 file changed, 32 insertions(+), 49 deletions(-) diff --git a/fme/core/models/conditional_sfno/s2convolutions.py b/fme/core/models/conditional_sfno/s2convolutions.py index f9864c26a..c039de151 100644 --- a/fme/core/models/conditional_sfno/s2convolutions.py +++ b/fme/core/models/conditional_sfno/s2convolutions.py @@ -50,11 +50,36 @@ def _contract_lora( lora_B: torch.Tensor, x: torch.Tensor, ): + """ + Performs LoRA update contraction. + + Args: + lora_A: LoRA A matrix of shape (in_channels, rank, nlat, 2) + lora_B: LoRA B matrix of shape (rank, out_channels, nlat, 2) + x: Complex input tensor of shape + (batch_size, in_channels, nlat, nlon) + """ lora_A = torch.view_as_complex(lora_A) lora_B = torch.view_as_complex(lora_B) return torch.einsum("irx,rox,bixy->boxy", lora_A, lora_B, x) +@torch.jit.script +def _contract_dhconv( + xc: torch.Tensor, weight: torch.Tensor +) -> torch.Tensor: # pragma: no cover + """ + Performs a complex Driscoll-Healy style convolution operation between two tensors + 'a' and 'b'. + + Args: + xc: Complex input tensor of shape (batch_size, in_channels, nlat, nlon) + weight: Weight tensor of shape (in_channels, out_channels, nlat, 2) + """ + wc = torch.view_as_complex(weight) + return torch.einsum("bixy,iox->boxy", xc, wc) + + class SpectralConvS2(nn.Module): """ Spectral Convolution according to Driscoll & Healy. Designed for convolutions on @@ -134,11 +159,6 @@ def __init__( assert self.inverse_transform.lmax == self.modes_lat assert self.inverse_transform.mmax == self.modes_lon - weight_shape = [in_channels] - - if not self.separable: - weight_shape += [out_channels] - if isinstance(self.inverse_transform, thd.DistributedInverseRealSHT): self.modes_lat_local = self.inverse_transform.lmax_local self.modes_lon_local = self.inverse_transform.mmax_local @@ -150,40 +170,14 @@ def __init__( self.lpad = 0 self.mpad = 0 - # padded weights - # if self.operator_type == 'diagonal': - # weight_shape += [self.modes_lat_local+self.lpad_local, self.modes_lon_local+self.mpad_local] - # elif self.operator_type == 'dhconv': - # weight_shape += [self.modes_lat_local+self.lpad_local] - # else: - # raise ValueError(f"Unsupported operator type f{self.operator_type}") - - # unpadded weights - if self.operator_type == "diagonal": - weight_shape += [self.modes_lat_local, self.modes_lon_local] - elif self.operator_type == "dhconv": - weight_shape += [self.modes_lat_local] - else: - raise ValueError(f"Unsupported operator type f{self.operator_type}") + weight_shape = [in_channels, out_channels, self.modes_lat_local] - if use_tensorly: - # form weight tensors - self.weight = FactorizedTensor.new( - weight_shape, - rank=self.rank, - factorization=factorization, - fixed_rank_modes=False, - **decomposition_kwargs, - ) - # initialization of weights - self.weight.normal_(0, scale) + assert factorization == "ComplexDense" + self.weight = nn.Parameter(scale * torch.randn(*weight_shape, 2)) + if self.operator_type == "dhconv": + self.weight.is_shared_mp = ["matmul", "w"] else: - assert factorization == "ComplexDense" - self.weight = nn.Parameter(scale * torch.randn(*weight_shape, 2)) - if self.operator_type == "dhconv": - self.weight.is_shared_mp = ["matmul", "w"] - else: - self.weight.is_shared_mp = ["matmul"] + self.weight.is_shared_mp = ["matmul"] if lora_rank > 0: if self.weight.shape != ( @@ -212,11 +206,6 @@ def __init__( self.lora_B = None self.lora_scaling = 0.0 - # get the contraction handle - self._contract = get_contract_fun( - self.weight, implementation="factorized", separable=separable - ) - if bias: self.bias = nn.Parameter(scale * torch.zeros(1, out_channels, 1, 1)) @@ -244,19 +233,13 @@ def forward(self, x): # pragma: no cover # approach with unpadded weights xp = torch.zeros_like(x) - xp[..., : self.modes_lat_local, : self.modes_lon_local] = self._contract( + xp[..., : self.modes_lat_local, : self.modes_lon_local] = _contract_dhconv( x[..., : self.modes_lat_local, : self.modes_lon_local], self.weight, - separable=self.separable, - operator_type=self.operator_type, ) xp = xp + self.lora_scaling * lora_update x = xp.contiguous() - # # approach with padded weights - # x = self._contract(x, self.weight, separable=self.separable, operator_type=self.operator_type) - # x = x.contiguous() - with torch.amp.autocast("cuda", enabled=False): x = self.inverse_transform(x) From c09fd5a98056f2bd85fa1dfc56b379cdc63e2a6b Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Thu, 22 Jan 2026 22:12:01 +0000 Subject: [PATCH 08/30] delete unused code --- .../models/conditional_sfno/factorizations.py | 260 ------------------ .../models/conditional_sfno/s2convolutions.py | 7 - fme/core/models/conditional_sfno/sfnonet.py | 3 - 3 files changed, 270 deletions(-) delete mode 100644 fme/core/models/conditional_sfno/factorizations.py diff --git a/fme/core/models/conditional_sfno/factorizations.py b/fme/core/models/conditional_sfno/factorizations.py deleted file mode 100644 index 66cf6fa17..000000000 --- a/fme/core/models/conditional_sfno/factorizations.py +++ /dev/null @@ -1,260 +0,0 @@ -# flake8: noqa -# Copied from https://github.com/ai2cm/modulus/commit/22df4a9427f5f12ff6ac891083220e7f2f54d229 -# Copyright (c) 2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import tensorly as tl -import torch - -tl.set_backend("pytorch") -# from tensorly.plugins import use_opt_einsum -# use_opt_einsum('optimal') - -from tltorch.factorized_tensors.core import FactorizedTensor - -from .contractions import ( - _contract_dhconv, - _contract_diagonal, - _contract_sep_dhconv, - _contract_sep_diagonal, -) - -einsum_symbols = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" - - -def _contract_dense( - x, weight, separable=False, operator_type="diagonal" -): # pragma: no cover - order = tl.ndim(x) - # batch-size, in_channels, x, y... - x_syms = list(einsum_symbols[:order]) - - # in_channels, out_channels, x, y... - weight_syms = list(x_syms[1:]) # no batch-size - - # batch-size, out_channels, x, y... - if separable: - out_syms = [x_syms[0]] + list(weight_syms) - else: - weight_syms.insert(1, einsum_symbols[order]) # outputs - out_syms = list(weight_syms) - out_syms[0] = x_syms[0] - - if operator_type == "diagonal": - pass - elif operator_type == "block-diagonal": - weight_syms.insert(-1, einsum_symbols[order + 1]) - out_syms[-1] = weight_syms[-2] - elif operator_type == "dhconv": - weight_syms.pop() - else: - raise ValueError(f"Unkonw operator type {operator_type}") - - eq = "".join(x_syms) + "," + "".join(weight_syms) + "->" + "".join(out_syms) - - if not torch.is_tensor(weight): - weight = weight.to_tensor() - - return tl.einsum(eq, x, weight) - - -def _contract_cp( - x, cp_weight, separable=False, operator_type="diagonal" -): # pragma: no cover - order = tl.ndim(x) - - x_syms = str(einsum_symbols[:order]) - rank_sym = einsum_symbols[order] - out_sym = einsum_symbols[order + 1] - out_syms = list(x_syms) - - if separable: - factor_syms = [einsum_symbols[1] + rank_sym] # in only - else: - out_syms[1] = out_sym - factor_syms = [einsum_symbols[1] + rank_sym, out_sym + rank_sym] # in, out - - factor_syms += [xs + rank_sym for xs in x_syms[2:]] # x, y, ... - - if operator_type == "diagonal": - pass - elif operator_type == "block-diagonal": - out_syms[-1] = einsum_symbols[order + 2] - factor_syms += [out_syms[-1] + rank_sym] - elif operator_type == "dhconv": - factor_syms.pop() - else: - raise ValueError(f"Unkonw operator type {operator_type}") - - eq = ( - x_syms + "," + rank_sym + "," + ",".join(factor_syms) + "->" + "".join(out_syms) - ) - - return tl.einsum(eq, x, cp_weight.weights, *cp_weight.factors) - - -def _contract_tucker( - x, tucker_weight, separable=False, operator_type="diagonal" -): # pragma: no cover - order = tl.ndim(x) - - x_syms = str(einsum_symbols[:order]) - out_sym = einsum_symbols[order] - out_syms = list(x_syms) - if separable: - core_syms = einsum_symbols[order + 1 : 2 * order] - # factor_syms = [einsum_symbols[1]+core_syms[0]] #in only - factor_syms = [xs + rs for (xs, rs) in zip(x_syms[1:], core_syms)] # x, y, ... - - else: - core_syms = einsum_symbols[order + 1 : 2 * order + 1] - out_syms[1] = out_sym - factor_syms = [ - einsum_symbols[1] + core_syms[0], - out_sym + core_syms[1], - ] # out, in - factor_syms += [ - xs + rs for (xs, rs) in zip(x_syms[2:], core_syms[2:]) - ] # x, y, ... - - if operator_type == "diagonal": - pass - elif operator_type == "block-diagonal": - raise NotImplementedError( - f"Operator type {operator_type} not implemented for Tucker" - ) - else: - raise ValueError(f"Unkonw operator type {operator_type}") - - eq = ( - x_syms - + "," - + core_syms - + "," - + ",".join(factor_syms) - + "->" - + "".join(out_syms) - ) - - return tl.einsum(eq, x, tucker_weight.core, *tucker_weight.factors) - - -def _contract_tt( - x, tt_weight, separable=False, operator_type="diagonal" -): # pragma: no cover - order = tl.ndim(x) - - x_syms = list(einsum_symbols[:order]) - weight_syms = list(x_syms[1:]) # no batch-size - - if not separable: - weight_syms.insert(1, einsum_symbols[order]) # outputs - out_syms = list(weight_syms) - out_syms[0] = x_syms[0] - else: - out_syms = list(x_syms) - - if operator_type == "diagonal": - pass - elif operator_type == "block-diagonal": - weight_syms.insert(-1, einsum_symbols[order + 1]) - out_syms[-1] = weight_syms[-2] - elif operator_type == "dhconv": - weight_syms.pop() - else: - raise ValueError(f"Unkonw operator type {operator_type}") - - rank_syms = list(einsum_symbols[order + 2 :]) - tt_syms = [] - for i, s in enumerate(weight_syms): - tt_syms.append([rank_syms[i], s, rank_syms[i + 1]]) - eq = ( - "".join(x_syms) - + "," - + ",".join("".join(f) for f in tt_syms) - + "->" - + "".join(out_syms) - ) - - return tl.einsum(eq, x, *tt_weight.factors) - - -# jitted PyTorch contractions: -def _contract_dense_pytorch( - x, weight, separable=False, operator_type="diagonal" -): # pragma: no cover - # to cheat the fused optimizers convert to real here - x = torch.view_as_real(x) - - if separable: - if operator_type == "diagonal": - x = _contract_sep_diagonal(x, weight) - elif operator_type == "dhconv": - x = _contract_sep_dhconv(x, weight) - else: - raise ValueError(f"Unkonw operator type {operator_type}") - else: - if operator_type == "diagonal": - x = _contract_diagonal(x, weight) - elif operator_type == "dhconv": - x = _contract_dhconv(x, weight) - else: - raise ValueError(f"Unkonw operator type {operator_type}") - - # to cheat the fused optimizers convert to real here - x = torch.view_as_complex(x) - return x - - -def get_contract_fun( - weight, implementation="reconstructed", separable=False, operator_type="diagonal" -): # pragma: no cover - """Generic ND implementation of Fourier Spectral Conv contraction - - Parameters - ---------- - weight : tensorly-torch's FactorizedTensor - implementation : {'reconstructed', 'factorized'}, default is 'reconstructed' - whether to reconstruct the weight and do a forward pass (reconstructed) - or contract directly the factors of the factorized weight with the input - (factorized) - - Returns - ------- - function : (x, weight) -> x * weight in Fourier space - """ - if implementation == "reconstructed": - return _contract_dense - elif implementation == "factorized": - if torch.is_tensor(weight): - return _contract_dense_pytorch - elif isinstance(weight, FactorizedTensor): - if weight.name.lower() == "complexdense" or weight.name.lower() == "dense": - return _contract_dense - elif weight.name.lower() == "complextucker": - return _contract_tucker - elif weight.name.lower() == "complextt": - return _contract_tt - elif weight.name.lower() == "complexcp": - return _contract_cp - else: - raise ValueError(f"Got unexpected factorized weight type {weight.name}") - else: - raise ValueError( - f"Got unexpected weight type of class {weight.__class__.__name__}" - ) - else: - raise ValueError( - f'Got {implementation=}, expected "reconstructed" or "factorized"' - ) diff --git a/fme/core/models/conditional_sfno/s2convolutions.py b/fme/core/models/conditional_sfno/s2convolutions.py index c039de151..b816fcd38 100644 --- a/fme/core/models/conditional_sfno/s2convolutions.py +++ b/fme/core/models/conditional_sfno/s2convolutions.py @@ -15,19 +15,13 @@ # limitations under the License. # import FactorizedTensor from tensorly for tensorized operations -import tensorly as tl import torch import torch.nn as nn import torch.nn.functional as F -tl.set_backend("pytorch") import torch_harmonics as th import torch_harmonics.distributed as thd -# from tensorly.plugins import use_opt_einsum -# use_opt_einsum('optimal') -from tltorch.factorized_tensors.core import FactorizedTensor - # import convenience functions for factorized tensors from .activations import ComplexReLU @@ -41,7 +35,6 @@ real_mul2d_fwd, real_muladd2d_fwd, ) -from .factorizations import get_contract_fun @torch.jit.script diff --git a/fme/core/models/conditional_sfno/sfnonet.py b/fme/core/models/conditional_sfno/sfnonet.py index ab760c8ba..afdea19b1 100644 --- a/fme/core/models/conditional_sfno/sfnonet.py +++ b/fme/core/models/conditional_sfno/sfnonet.py @@ -15,7 +15,6 @@ # limitations under the License. import math -from functools import partial from typing import Any, Callable, List, Optional, Tuple import torch @@ -24,7 +23,6 @@ # get spectral transforms from torch_harmonics import torch_harmonics as th from torch.utils.checkpoint import checkpoint -from typing_extensions import Literal from .initialization import trunc_normal_ @@ -37,7 +35,6 @@ Context, ContextConfig, DropPath, - SpectralAttention2d, ) from .lora import LoRAConv2d from .s2convolutions import SpectralAttentionS2, SpectralConvS2 From 2f7ca0f5332c4b7f466d2473f801b0de81337eae Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Thu, 22 Jan 2026 22:31:58 +0000 Subject: [PATCH 09/30] enable grouped convolutions for linear filter type --- .../models/conditional_sfno/s2convolutions.py | 65 +++++++++++-------- fme/core/models/conditional_sfno/sfnonet.py | 1 + 2 files changed, 40 insertions(+), 26 deletions(-) diff --git a/fme/core/models/conditional_sfno/s2convolutions.py b/fme/core/models/conditional_sfno/s2convolutions.py index b816fcd38..0763318d1 100644 --- a/fme/core/models/conditional_sfno/s2convolutions.py +++ b/fme/core/models/conditional_sfno/s2convolutions.py @@ -47,14 +47,14 @@ def _contract_lora( Performs LoRA update contraction. Args: - lora_A: LoRA A matrix of shape (in_channels, rank, nlat, 2) - lora_B: LoRA B matrix of shape (rank, out_channels, nlat, 2) + lora_A: LoRA A matrix of shape (group, in_channels, rank, nlat, 2) + lora_B: LoRA B matrix of shape (group, rank, out_channels, nlat, 2) x: Complex input tensor of shape - (batch_size, in_channels, nlat, nlon) + (batch_size, group, in_channels, nlat, nlon) """ lora_A = torch.view_as_complex(lora_A) lora_B = torch.view_as_complex(lora_B) - return torch.einsum("irx,rox,bixy->boxy", lora_A, lora_B, x) + return torch.einsum("girx,grox,bgixy->bgoxy", lora_A, lora_B, x) @torch.jit.script @@ -66,11 +66,11 @@ def _contract_dhconv( 'a' and 'b'. Args: - xc: Complex input tensor of shape (batch_size, in_channels, nlat, nlon) - weight: Weight tensor of shape (in_channels, out_channels, nlat, 2) + xc: Complex input tensor of shape (batch_size, group, in_channels, nlat, nlon) + weight: Weight tensor of shape (group, in_channels, out_channels, nlat, 2) """ wc = torch.view_as_complex(weight) - return torch.einsum("bixy,iox->boxy", xc, wc) + return torch.einsum("bgixy,giox->bgoxy", xc, wc) class SpectralConvS2(nn.Module): @@ -87,6 +87,7 @@ def __init__( inverse_transform, in_channels, out_channels, + num_groups: int = 1, scale="auto", operator_type="diagonal", rank=0.2, @@ -121,9 +122,12 @@ def __init__( raise NotImplementedError( "Currently only in_channels == out_channels is supported." ) + assert in_channels % num_groups == 0 + assert out_channels % num_groups == 0 + self.num_groups = num_groups if scale == "auto": - scale = 1 / (in_channels * out_channels) + scale = 1 / ((in_channels / num_groups) * (out_channels / num_groups)) self.forward_transform = forward_transform self.inverse_transform = inverse_transform @@ -163,7 +167,12 @@ def __init__( self.lpad = 0 self.mpad = 0 - weight_shape = [in_channels, out_channels, self.modes_lat_local] + weight_shape = [ + num_groups, + in_channels // num_groups, + out_channels // num_groups, + self.modes_lat_local, + ] assert factorization == "ComplexDense" self.weight = nn.Parameter(scale * torch.randn(*weight_shape, 2)) @@ -173,24 +182,24 @@ def __init__( self.weight.is_shared_mp = ["matmul"] if lora_rank > 0: - if self.weight.shape != ( - in_channels, - out_channels, - self.modes_lat_local, - 2, - ): - raise NotImplementedError( - "LoRA is only implemented for dhconv with unpadded weights." - ) - if use_tensorly: - raise NotImplementedError( - "LoRA is not implemented for tensorly factorized weights." - ) self.lora_A = nn.Parameter( - scale * torch.randn(in_channels, lora_rank, self.modes_lat_local, 2) + scale + * torch.randn( + num_groups, + in_channels // num_groups, + lora_rank, + self.modes_lat_local, + 2, + ) ) self.lora_B = nn.Parameter( - torch.zeros(lora_rank, out_channels, self.modes_lat_local, 2) + torch.zeros( + num_groups, + lora_rank, + out_channels // num_groups, + self.modes_lat_local, + 2, + ) ) self.lora_alpha = lora_alpha if lora_alpha is not None else lora_rank self.lora_scaling = self.lora_alpha / lora_rank @@ -201,12 +210,12 @@ def __init__( if bias: self.bias = nn.Parameter(scale * torch.zeros(1, out_channels, 1, 1)) + self.out_channels = out_channels def forward(self, x): # pragma: no cover dtype = x.dtype residual = x x = x.float() - B, C, H, W = x.shape with torch.amp.autocast("cuda", enabled=False): x = self.forward_transform(x) @@ -215,6 +224,10 @@ def forward(self, x): # pragma: no cover residual = self.inverse_transform(x) residual = residual.to(dtype) + B, C, H, W = x.shape + assert C % self.num_groups == 0 + x = x.reshape(B, self.num_groups, C // self.num_groups, H, W) + if self.lora_A is not None and self.lora_B is not None: lora_update = _contract_lora( self.lora_A, @@ -224,13 +237,13 @@ def forward(self, x): # pragma: no cover else: lora_update = 0.0 - # approach with unpadded weights xp = torch.zeros_like(x) xp[..., : self.modes_lat_local, : self.modes_lon_local] = _contract_dhconv( x[..., : self.modes_lat_local, : self.modes_lon_local], self.weight, ) xp = xp + self.lora_scaling * lora_update + xp = xp.reshape(B, self.out_channels, H, W) x = xp.contiguous() with torch.amp.autocast("cuda", enabled=False): diff --git a/fme/core/models/conditional_sfno/sfnonet.py b/fme/core/models/conditional_sfno/sfnonet.py index afdea19b1..b65047aeb 100644 --- a/fme/core/models/conditional_sfno/sfnonet.py +++ b/fme/core/models/conditional_sfno/sfnonet.py @@ -126,6 +126,7 @@ def __init__( filter_residual=filter_residual, lora_rank=lora_rank, lora_alpha=lora_alpha, + num_groups=num_groups, ) elif filter_type == "makani-linear": self.filter = SpectralConv( From bc6b789d59f5120428bfd25646974c35512c838f Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Fri, 23 Jan 2026 14:29:00 +0000 Subject: [PATCH 10/30] restore MLP checkpointing --- fme/core/models/conditional_sfno/sfnonet.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fme/core/models/conditional_sfno/sfnonet.py b/fme/core/models/conditional_sfno/sfnonet.py index ab760c8ba..c9e41c6ab 100644 --- a/fme/core/models/conditional_sfno/sfnonet.py +++ b/fme/core/models/conditional_sfno/sfnonet.py @@ -286,6 +286,7 @@ def __init__( hidden_features=mlp_hidden_dim, act_layer=act_layer, drop_rate=drop_rate, + checkpointing=checkpointing, lora_rank=lora_rank, lora_alpha=lora_alpha, ) From 08323ba8cf8e352f7646115ec77110856b7a1eca Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Thu, 29 Jan 2026 19:35:04 +0000 Subject: [PATCH 11/30] enforce not implemented features at config level --- fme/ace/registry/stochastic_sfno.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/fme/ace/registry/stochastic_sfno.py b/fme/ace/registry/stochastic_sfno.py index 84abcf761..ff2e19b7a 100644 --- a/fme/ace/registry/stochastic_sfno.py +++ b/fme/ace/registry/stochastic_sfno.py @@ -114,8 +114,8 @@ class NoiseConditionedSFNOBuilder(ModuleConfig): pos_embed: Whether to use a position embedding. big_skip: Whether to use a big skip connection in the model. rank: Rank of the model. - factorization: Factorization to use. - separable: Whether to use a separable filter. + factorization: Unused, kept for backwards compatibility only. + separable: Unused, kept for backwards compatibility only. complex_network: Whether to use a complex network. complex_activation: Activation function to use. spectral_layers: Number of spectral layers in the model. @@ -146,7 +146,7 @@ class NoiseConditionedSFNOBuilder(ModuleConfig): spectral_transform: Literal["sht"] = "sht" filter_type: str = "non-linear" - operator_type: str = "diagonal" + operator_type: Literal["dhconv"] = "dhconv" residual_filter_factor: int = 1 embed_dim: int = 256 noise_embed_dim: int = 256 @@ -160,7 +160,7 @@ class NoiseConditionedSFNOBuilder(ModuleConfig): pos_embed: bool = True big_skip: bool = True rank: float = 1.0 - factorization: str | None = None + factorization: None = None separable: bool = False complex_network: bool = True complex_activation: str = "real" @@ -179,6 +179,17 @@ class NoiseConditionedSFNOBuilder(ModuleConfig): spectral_lora_rank: int = 0 spectral_lora_alpha: float | None = None + def __post_init__(self): + if self.factorization is not None: + raise ValueError("The 'factorization' parameter is no longer supported.") + if self.separable: + raise ValueError("The 'separable' parameter is no longer supported.") + if self.operator_type != "dhconv": + raise ValueError( + "Only 'dhconv' operator_type is supported for " + "NoiseConditionedSFNO models." + ) + def build( self, n_in_channels: int, From c3f65feb008aeafa8428627ba30538a67e484472 Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Thu, 29 Jan 2026 19:45:34 +0000 Subject: [PATCH 12/30] update sfno init to use updated makani scheme --- fme/core/models/conditional_sfno/s2convolutions.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/fme/core/models/conditional_sfno/s2convolutions.py b/fme/core/models/conditional_sfno/s2convolutions.py index d213c230a..8dea3d9a5 100644 --- a/fme/core/models/conditional_sfno/s2convolutions.py +++ b/fme/core/models/conditional_sfno/s2convolutions.py @@ -15,6 +15,7 @@ # limitations under the License. # import FactorizedTensor from tensorly for tensorized operations +import math import torch import torch.nn as nn import torch.nn.functional as F @@ -122,15 +123,19 @@ def __init__( "Currently only in_channels == out_channels is supported." ) - if scale == "auto": - scale = 1 / (in_channels * out_channels) - self.forward_transform = forward_transform self.inverse_transform = inverse_transform self.modes_lat = self.inverse_transform.lmax self.modes_lon = self.inverse_transform.mmax + if scale == "auto": + scale = math.sqrt(1 / (in_channels)) * torch.ones( + self.modes_lat, dtype=torch.complex64 + ) + # seemingly the first weight is not really complex, so we need to account for that + scale[0] *= math.sqrt(2.0) + self._round_trip_residual = filter_residual or ( (self.forward_transform.nlat != self.inverse_transform.nlat) or (self.forward_transform.nlon != self.inverse_transform.nlon) From 29cd0ff44d55c332d2f1274647f96a5c20a20506 Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Thu, 29 Jan 2026 20:49:15 +0000 Subject: [PATCH 13/30] use correctly shaped scale --- .../models/conditional_sfno/s2convolutions.py | 8 +++----- .../test_sfnonet_output_is_unchanged.pt | Bin 9624 -> 9624 bytes 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/fme/core/models/conditional_sfno/s2convolutions.py b/fme/core/models/conditional_sfno/s2convolutions.py index 8dea3d9a5..2d847ab6e 100644 --- a/fme/core/models/conditional_sfno/s2convolutions.py +++ b/fme/core/models/conditional_sfno/s2convolutions.py @@ -130,11 +130,9 @@ def __init__( self.modes_lon = self.inverse_transform.mmax if scale == "auto": - scale = math.sqrt(1 / (in_channels)) * torch.ones( - self.modes_lat, dtype=torch.complex64 - ) + scale = math.sqrt(1 / (in_channels)) * torch.ones(self.modes_lat, 2) # seemingly the first weight is not really complex, so we need to account for that - scale[0] *= math.sqrt(2.0) + scale[0, :] *= math.sqrt(2.0) self._round_trip_residual = filter_residual or ( (self.forward_transform.nlat != self.inverse_transform.nlat) @@ -205,7 +203,7 @@ def __init__( self.lora_scaling = 0.0 if bias: - self.bias = nn.Parameter(scale * torch.zeros(1, out_channels, 1, 1)) + self.bias = nn.Parameter(torch.zeros(1, out_channels, 1, 1)) def forward(self, x): # pragma: no cover dtype = x.dtype diff --git a/fme/core/models/conditional_sfno/testdata/test_sfnonet_output_is_unchanged.pt b/fme/core/models/conditional_sfno/testdata/test_sfnonet_output_is_unchanged.pt index c3e6f7897a226acbfe6ba1116080aaf445a57610..40812ebf151aaa2a0278085f34fd436f67d9d161 100644 GIT binary patch delta 7911 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 delta 7911 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 From 16813fca1c1a588062a632e634a56abd5d7b4d2f Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Tue, 3 Feb 2026 19:39:47 +0000 Subject: [PATCH 14/30] default to linear filter type, disallow non-linear --- fme/ace/registry/stochastic_sfno.py | 2 +- fme/core/models/conditional_sfno/sfnonet.py | 13 +------------ 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/fme/ace/registry/stochastic_sfno.py b/fme/ace/registry/stochastic_sfno.py index ff2e19b7a..5c88d4f3e 100644 --- a/fme/ace/registry/stochastic_sfno.py +++ b/fme/ace/registry/stochastic_sfno.py @@ -145,7 +145,7 @@ class NoiseConditionedSFNOBuilder(ModuleConfig): """ spectral_transform: Literal["sht"] = "sht" - filter_type: str = "non-linear" + filter_type: Literal["linear"] = "linear" operator_type: Literal["dhconv"] = "dhconv" residual_filter_factor: int = 1 embed_dim: int = 256 diff --git a/fme/core/models/conditional_sfno/sfnonet.py b/fme/core/models/conditional_sfno/sfnonet.py index c82e6508c..04378d0ea 100644 --- a/fme/core/models/conditional_sfno/sfnonet.py +++ b/fme/core/models/conditional_sfno/sfnonet.py @@ -97,18 +97,7 @@ def __init__( raise NotImplementedError("LoRA is only supported for linear filter type.") if filter_type == "non-linear": - self.filter = SpectralAttentionS2( - forward_transform, - inverse_transform, - embed_dim, - operator_type=operator_type, - sparsity_threshold=sparsity_threshold, - hidden_size_factor=hidden_size_factor, - complex_activation=complex_activation, - spectral_layers=spectral_layers, - drop_rate=drop_rate, - bias=False, - ) + raise NotImplementedError("Non-linear spectral filters are not supported.") # spectral transform is passed to the module elif filter_type == "linear": From 15abf2c83514b9bf8faf6bd5d277d63a5c692e28 Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Tue, 3 Feb 2026 19:41:44 +0000 Subject: [PATCH 15/30] allow makani-linear filter --- fme/ace/registry/stochastic_sfno.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fme/ace/registry/stochastic_sfno.py b/fme/ace/registry/stochastic_sfno.py index 5c88d4f3e..81236106d 100644 --- a/fme/ace/registry/stochastic_sfno.py +++ b/fme/ace/registry/stochastic_sfno.py @@ -145,7 +145,7 @@ class NoiseConditionedSFNOBuilder(ModuleConfig): """ spectral_transform: Literal["sht"] = "sht" - filter_type: Literal["linear"] = "linear" + filter_type: Literal["linear", "makani-linear"] = "linear" operator_type: Literal["dhconv"] = "dhconv" residual_filter_factor: int = 1 embed_dim: int = 256 From 39dec0dd9c25e089fb529b1c9cadeee42721bb9a Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Tue, 3 Feb 2026 19:48:22 +0000 Subject: [PATCH 16/30] update sfnonet regression target to match primary code path --- .../models/conditional_sfno/test_sfnonet.py | 6 ++++-- .../test_sfnonet_output_is_unchanged.pt | Bin 9177 -> 9624 bytes 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/fme/core/models/conditional_sfno/test_sfnonet.py b/fme/core/models/conditional_sfno/test_sfnonet.py index 43945893c..0f566cd9a 100644 --- a/fme/core/models/conditional_sfno/test_sfnonet.py +++ b/fme/core/models/conditional_sfno/test_sfnonet.py @@ -101,10 +101,12 @@ def test_sfnonet_output_is_unchanged(): img_shape = (9, 18) n_samples = 4 conditional_embed_dim_scalar = 8 - conditional_embed_dim_labels = 0 + conditional_embed_dim_labels = 4 conditional_embed_dim_noise = 16 device = get_device() - params = SimpleNamespace(embed_dim=16, num_layers=2) + params = SimpleNamespace( + embed_dim=16, num_layers=2, filter_type="linear", operator_type="dhconv" + ) model = get_lat_lon_sfnonet( params=params, img_shape=img_shape, diff --git a/fme/core/models/conditional_sfno/testdata/test_sfnonet_output_is_unchanged.pt b/fme/core/models/conditional_sfno/testdata/test_sfnonet_output_is_unchanged.pt index 6fb14b9be8c623160ea04fd0d327a3c93bd4a335..c3e6f7897a226acbfe6ba1116080aaf445a57610 100644 GIT binary patch literal 9624 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�gnvVc#3XIr1y; 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 Y!+(GNU9cnbOS014Mo#dn{_l1FA6%k}g8%>k literal 9177 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 From f615a26e656e059664d2882a7f5fec210c1ad61f Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Tue, 3 Feb 2026 20:29:30 +0000 Subject: [PATCH 17/30] update diffusion regression test to latest settings --- fme/diffusion/registry/sfno.py | 2 +- .../testdata/stepper_predict_regression.pt | Bin 13386 -> 13821 bytes .../testdata/stepper_step_regression.pt | Bin 5496 -> 5925 bytes ...stepper_train_on_batch_regression-False.pt | Bin 36082 -> 36543 bytes .../stepper_train_on_batch_regression-True.pt | Bin 36066 -> 36525 bytes 5 files changed, 1 insertion(+), 1 deletion(-) diff --git a/fme/diffusion/registry/sfno.py b/fme/diffusion/registry/sfno.py index 83ab0c1b6..b8ed2a267 100644 --- a/fme/diffusion/registry/sfno.py +++ b/fme/diffusion/registry/sfno.py @@ -20,7 +20,7 @@ class ConditionalSFNOBuilder(ModuleConfig): spectral_transform: str = "sht" filter_type: str = "linear" - operator_type: str = "diagonal" + operator_type: Literal["dhconv"] = "dhconv" scale_factor: int = 1 embed_dim: int = 256 num_layers: int = 12 diff --git a/fme/diffusion/testdata/stepper_predict_regression.pt b/fme/diffusion/testdata/stepper_predict_regression.pt index b038a58a68059f3d87319963f2655e13b9b061de..2d465efb4802e7c0eccb3396eb6030b7b4325354 100644 GIT binary patch literal 13821 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 Ve^=!QkLXhP#D+g3=l_20{{b!Hq9_0W literal 13386 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 KewY8F?Ee8$~^6_E~FxT}09{0)f1|;6JwB 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 literal 36082 zcmeFa2T+vDwl+-8B9b$LN>GXOcDDf&B8nIYqKFs(5fMZQ1~3pLi6EjR5flLhQ9+ou zSEC|e0u=)oFkwIxbIyqRgONo7Zw(%Cb;zf4>m%-`*nU z5VXuY+^y&BJuk|INO*|${EbzJq=$IVv+wd1AyQ7F;UUs4P9kPz8-0D31qJ!}hWB(V zOm2GULcdVIh4uly;c_7|X09Q!;URLaA@Uw7|Fx|l3X6MeSS-HyHxBENK8yc`t@s=E z;$IMb!$Xu@L;892`v-)-U-10D4WCbl^6vm_LR1z@O+7Cj9-`WVVCp39BN?jOhBl+f6O=f9RU|I;O_(j|41=|7N~Pc!;TMh}pj)@C_OK zI|39k#N?^E(F)-`0*KPJn@;-8kn=-&Yh3mNnGu8#d5V(<-_`FjTIkXe6c@c1Lc>_0Gg z{>CsTJjBa2WbO(JXD4YHJfTS|wr3yw)tvw3j%Y1n@IT%WeImmBmi^j69PD)d-If>> z5FQ@n_sfR(Q5{-RJ)`xj{c9U6MD+j1HvHOFEUg^uwEv9(y5XT?ezE^Ju6wel^CG|6 zzhm$J=h&?s>~#MPyQk-$etFRk)n*)pNzQduT`G{A(} z3^AsUCoaNMH=n}_srp38a3Z@hR*CH%+K9-4fZaP};^6U5B&p>J^zr|ET&m|P;v zBG|;*Nk0a*U8w_o<5Cj1cQz?{eh@TIC&K2*p>*%v7Zj9iNuLW3;HE(`Zu=0&c3T~X zEw9~JdjUtpmnJap)B~hJ7)=xwY{DbPZ^U0ztMTw-Gf4L`%o3-I;Pc_Ns68Vc>$;lb z&F7|&>ds`mZoxFR_4`P&!?X{Xc|i)Fnvsk@*%`5-1y5=HF?ZyA!Hl(hn8O~V%47Hb zQDl{g430)Jgg#isK8(m@)q1Dl{NfmXUFa8lX@LwbTojF`jt^r8OQo4bXe7~i`ws8C zRS8Fo;&Gs6DbjR~CM_}zZ00~4wmDmkxY{YOBbq14Z09t*ZJz^X9!A`qMk_MpVa#rsWON56GVd0}RJaK3wh#uZUt5l0Ps=NSIn04XuDkCr)S_=z1 z{fsOVe4hi| z9+CKJ!x*;d{c9|mHigs~ZGlm~6WGwN6Nu}8YdFbJgUqQk!m<&sA++=&i4(Md<>U+! zDO6(%bmWI^yrWH={M>Pl+7;+NehPWbcmh^_o5-5HrKI@Eb^7T5A>*g&gJ<;)+ImQg ze0U?t6n2K;Ewjb}cjXDbVW7%FxME^Zya6jU4B^dPT5+}F6!K$11X=V`jhJrhPevU* zg8TFSc&t|t=mzMaT)|H~TlrbK*4I1_5R0k=QPrnksY z(zeEcUA9*vwR0oL>+)_$4}OQ|n;wRPlRD5h8w+;xM+2NJVK~Iq86TKi3_rhx5$`HJ zlDhIZR(oO2lBeusuPR@&r)e|U!dC~Wbk15VJaYw}zlkAvtp}hwe<4wtAjTeiXaJi8 z!tm-?ns;&)T(4bB)@;9qosYJG===@jb;u(Ky`>6Q%)UbQ&=j`hTpqD@NN2XIJg|j! z1}VywgHg#%cu8~<2);0U%YQbo;abrBd=(j>Gz~|4*|Rmeo3M0CB;=Z?5Hm|zHhg0| z@!uN;=XS`l%6G>FTk;l=?KAt5W8<C8i>pD@y%(Iq>wlssQ>>|2gx(R7bF~ZW9yx8W@`w(6H z5I@l|U|~l$;PfQ{q@Un0&UUM&c~j;QUyTs*)<2vX-Kr;br}{9mq84va-b(b^;;@zH zbaG(1|9j?)^4eKdI-lkl2O| z9Xk09Kv#1NJ>388P$1byShbC-#CL6WBTG%uGY49da z3%P`Pl9$IW(%~NlvoUukGW|!hvDCxcIP~(2~q!j#p&ak8C-Zmj98iZBk~R zrUx;tq3T3F(vw-nWP#)4lT_hD1o6qZ$rcZkW*9HPQUivNL)xKmjXOl1RShCWw)^nT zpg5SbvILb6OtaaOE5q6jM8KIC7c}*$JefB`7UO17va6pHGr?lyf#zkRSYSv$xM4PI z??KW!+>}UI>9P@(Vz6eOF)@)?Kq{IJ0hcul?2b%X)k!+ouokTQx7{wq^@CCYsG& zysBe!&WeypbxEM0{ucbNE(MK?VbmeH9pw*y2xk3OatUAc@bY8{oF=~rQg!x{pfe6+ zbygYp?=>Y?UW{Q6Bc+&_Qx#N1Cqmw{-Q=|>#d5a~L#SjsIkP<<-;S~&rW=;BpDN~L zS4#$2mcAaZaiw^b`6ZMKTFl6+3O|{jPENi*zycFW@z6*+;?k!SU-RBij4wCiY@d1o z|5ct`dL%(cl<&j?^>X3ESWQ%$Jdy-m-hlHOOkwD!BlM}dB`74-;?vsx?3l5Dq`a3T zgI}M)hwc@?53kGk*y3Gm?&Sk)fZ=WS$X6XN+tNnYd|6GtcY3j-Z&hK2b2i!WGYtgX zRx%(L_Z)sv1vhk0+1BpP@qEcsL*R4Vtf{u*`N>rtu^J2cP>6@O29N^s1R|YmjFTu5QD1 z=dRhVLuv9Kz7bn&^iAY z4}Dn6u5=y6db-o_;^+J6Rf%0NF2Iu*o^HYG$E6T<9g*?hzVo^1C-HbOYqnz6Ta-4# zo$ai=hUc4%BirYtu#XS7vAu_Uacr^{u{J-4`z}htnd?I#FKY#s(%EXlGR?i{4B#v=&)r*&ctqau{+lL`il5^!7pJ2rg=_GQ^;X0w!^T4dFXh4`N2 z9df=ghc#&p#Zy-*<53X>tSY3Ys`lVvz9D@9=q6i{WNDuHD4YSW=K*Ynj~EkQbq^WH zY^C;JN3w5A{7BKfFEILgA!@QShf@uAAs|?uV2k_kvNMo4j!eQAH3PstMT9jx7{_UR zb|k58EAaB_DExMdJh9M;f_ZV}w4!=ESt2)@#Yx=2(=N1QjV+^?!?8Mc&-4r093X*K zj7$N~yHy}a?ZV`~35(K+Ak({tJd0$J$Y2sCUsn{aYAu|z+_?-va!XNDCLQ7Kh!k@^+8NtbJF=nfAkcoEJ zLylq)+2>J-?|r;TQ2r)TapxYEX{e(P=LV3ODh2ec`vNd1jc0RsQ#R|sK{hqY7+sgI z1*2J;Vf|1IR>+^l_5O|c{ZJ$9C2P(O)wR-h&&^rI@xi1;bP91-&O%Z#mvL5@2NrbK zk|D1)K-kP}Om)3IXsod(-Q}KGWYQ_D?oxtB9h^eC&YKfUvuW({p0BuMpf<6eVMiY4 z{GfKJMyx`7646RH4143Yk_F!T*y!he?DfZIa5!QEdpJU$JR4h%b7mfa;n#d&_!Uzo zw#1mQ(ir$u6is{`#EDa#59mtxlB417sB_5$bgO$F9pofJbZa&fySdRsJxd?v_0?sy zIfRWn6Gu+R`jT)-L!7(cl(nwfgJ#Z}N8H-3!Uu^yL{PSs4oFLeRd=NzX>BF^v~I#p z$eEeXv!J4p&aA((GK(2kh{bZIvP2DiGQmxjBw6}HY{)c(+^*1uf|n39MU_dY_k3rp zlORXK60zA_RW_lr4xVqeW8HCwNJl^-qkU^JJ+~RJi#?6}?rN}tnVDqd=q|j!I*)jr zI*j&eW)amgFTxEI!HuDh@%_}hq@ZIF`ZzR*JSp7Ho{fJ*GipYXnjM8m`cX0XCQFm6 zuQEus&3nkxYsW{A?V>Ilv-qPo>(H@IJ=U>m4n*wf#zTA-v5#%*u;P;a(0}qm##Wi* zS3B0R8e8dMLsBL1`AB$1(3M(9y_eQg+?yH< zuq+;0`Ay(_P6Qv0Z%1wwi^xW;xAfuMbV$CtoTN^x0_Wg`?BzLEGI!QS^k#S#Q+&9L zxt&-GKQBDP+geYMdW%N1eav^5z4aMfk!*$K6&moy6%mJy{$$Kye z<39b(NtUudi}TvS(u~#FuDeQPgo`?G-I^p%{0H}?@HC2hRSlMN&Dp_6JU(#Sg^85} zu*dq^th{kNu$`CTq_Z{=X^O*XDsdok&6fP~6@1zdWp35C$Bo+4{zpSf(`or};ectzD_Kg3O8$8oIt zS;#%PkX6+=GOg|h_-aQAI4$3eUHx>}#bg7PJiP|^hlNaVClR01@`8#xlgYO;GQ{O< zFlw5;l_m61W{as6e(ITnofSv1vN#^+-Ad&zOW(k`D2w!UuEI5K>GWBw1+!}$i=QsM z4>P}tu|2~s<5PQ=u&;*>0uGL)n+y~2(S`epK>(P7>>MkMUy zTL^cp#@)*c*kP*;%($+W8tgTp@23o-$Gyy8V%u^mrRRu6k4Q2_gE$hBs6;XpT3Ozh z+puCu79M8hjiWxKK)m-^bmLQf&wjTQmzMaD1y~6rf6B3l-Oqn3p~89z2(LyEN23yK zUp5d9S09CGJFD1&b~~b?)Cpgnm($AO3w1)oO#$gOHAD zf4pDc4aTO-V(yc@;l1QZc2KySIXV|2{S#>@-0TN`_Ps3eZ;&B(+rvrzT+A9|CXsW2 zFHq}lYts6B6-@LUNH(%*_|D@g>}#h#`zqH!f_d3$J(Y6&?N&6dwUA?mM#|`h!WzzI z&Kh`}Tg=RrV_~rSbK;Xs;DK^7wj84X=L&gL2GxQSdIm5={1u+9r%a}te(a>oS5{Pj)~q(mK~4BY85iPN>vO| zU8u)ayJLL#tu^sz(I-JuGYDQQiA&WcvF@I|`*7GGJk?g0h5010HBlyLt6e_GdG17K zQ-`vEwHv`PJ|4?|-^OU$8Axk&gM#6o(IZh2V*c|Dt~|aQ7pr`wDyIzC_K+6XJH(aw zuNH-U*E4B*<0Y;@aR}D>yaB(cb79}q>akw9Ecv{m54%vJ%H~*^;s7f{R(+xmiOrIv zhew|zdi}?-X9daJjD(#~eJKZQRP@lIV-@hAYa2N*?Hrp{q)zV7AH_tIO{@Dqh-IEp zV&qY*KDo4I8?0)c#tQKOW_Jycv9nYGwK=B(rCozXLmS3#4{P zNM#=^WhYEcI8Id;A986RLjq)Q#$^oZ0S@rB#*rw-d1LV}w_$j24G54Z`S^V{%P4hX z6?Z$a*v#W(VW|qcCcXoyRHU)-!PA+_hLWO^m|+>PH4x~NitjBZ zbZlcT8>g%%oH4Zm}P{(UR<#4>;K*$?i$CLtt$XcmNRyg^b zb>%T-b|6WE%`aJu`CWH$M@oNk@vU@H6_c03izoNALLYRvtC*G>+`xsCRu%1VOn(Aq$vaUu=!^@sJR9r2}O zrfgzDFy8rj4)hZljA6JktWkLl`DX3dDmVzAXtiJ)R!(5}a07m%wSmnVIEFc$c@CW^ z$s{a)GqRT4&m9>g!t_)V@sTbCRyel-dy>_VaCH#NFY_R)wj+FOrVkr)IgKc|oCNo8 zDsZ#ah@Dc1;Y1>{*~xclms<+Ck9quNFW*caLwtCP zjAV9Xg9KFTW|6a%cd^Wzsbpi97bABZQT_X?aQkW>b}^H{>9guAB}RdzS~sHR&09g4 z*<DTJS&iVF@+!ZQ{HLWhbjzLl0v?zL+(S5FBh zKttGPuYN3cP7EsCF@x;=Fpmsdx{g(aGqTO$4Zaz%l!%x7gx4Aqq4hTlYVE)XnvhJw(2OS z9p$<3!^D_u$;cI2yU5}mA0Vh$DnX9(YzU2#BonPj8q4;J>~d}^eepeku%#F9?V3^S-fb^9(XxUgXuRYkV&5b zq9U}Il5r`XQ!Ygo8TjKpO1B_Gdp)VRpMW>5+f65S=i_O6qu@n$UsCg&kk>1BvP0YD zuz2KmG;C}$>MJ})k50%W$GW#eXJ*2zXNm;I7h&NA(LM zZY3YU$^w(Kmls1>i5cU~jmU|~m7w-@3`@OTf%%7n$UUEW)@UI zabW^KJ0%(qpER1pj<*Kypt-C}X&;l{lS2%f#qo?4quCS%o`sIICtB(X_|v-egqwE| z+r6E`Y&;klQftJ_74M;tMtd~*SYMo|WWoZZ;&8;sAolpd8IXFrm9;z^$n-)-voEs8 z=#s1YBu_6DEH=5b$E)wqkOp&-y}PsL{N0DtJ={oI$4w`@EDPc7l5SW#xDxwoTQiYw zIr!ifca~~cME0j!kk6sB$^J%7a`#aq z*7N_N=H(1_QSl%imYoH|W!%a4kr^~Ta01(VO_FG(2at;m61XRLfu?2;B97(<@yl~M zuzLkZ;*+M4XQNLuQ{!1AWaJc5TJFW{s`fB3kFVIo*$Xe$IKgTP6WQq|24=B#?EABR zaC+Px2oW=aws)7X(_JlAFYZrN^asQ0S%&12!eSOm<6!K&uVC?7giPqV%tqEp4|}6x zK-Q$mk`?yn>Bg|nsD4%k7ID8uK9~lOxAG~lKfelUuk7bven!OaYb@?wG=i0Hokwnb z-iVj21xgKOtz}zS zl5GsR|5yczS1(1MQ#XTp#1~wdH;!L6&!1dW+km^HUZ9!lj)R)+0;2K03Ehm^K-T7p zvAcu6!Sgd6_>B>dV;kaFj&ENKwo)v1g*&VWPKMpYh&>os+Hu?SScI5x$(6RXS+BO}Agk$ulu{ZaIO zFblax?^eh2M+VAax5!ucy{ZR3C)@>-4#%>TvV8Kn$0mj9SoE}HDYT8-0YUbzbk*E# zB)ZRF*syX6S?rF4CbFMkdSe39oxg*PSzAHS{vqsai9gQPawiY_$*}cv3US}blh_`s zrC>hJfTYhfWM35q+}0 zY9oA~aFA#mbH@_`szF(lkS2FKvh!I<&m2uAwPT(m-dl!ja4*N%c3yDmejj!sG=)3x zQxq~bbi*`vAzsKGrRwdLe9P-sPB0_<7eY zBXHYM8(tQe;^j7339=+zF7bP5AO11 zn*-_R)kmnqp?22UF`g6_XcOhaHjsMNt)!x zgPY*sk_NbYe?64Fx{cq=pUbLTB-w+&QsQitO=c{v#CmB*(3R&?3HS8}Cs!1M_u2RO zr;A?FvYs_~Z$k-a&)7?AZ8nqq3pI2=?qNK;=o0)GP|jWpi?H;WVtDmtBYs+ML1bpS zu^CwlJ#{*Hg0 z-~G#{$o%)`cMB2j&z;|G9PF(A?D5^RH~!umfB!4Q-=Erk;Y#x_aAzw*=!$0>If;T6 z;dnn0w5ju%;8T<|T6_E>*C^4(FKl=z40kU^4j+Vqjo0?^9;I_A&bQ_?`bth%B&p-Ed1y5q#YHv|wB2Aj8kB#Edp-6WFaC4^cW!;0)yxlzI7f>- z^sS%`omM_1l-&co=;(fELb(*bsADs)bG$(quJD12Y9EP?Y&E1`vlX})D&#Fqk5b6E zO3S2z>8xBAy01uxo_apzv(}~4W+gGy&$@`OHhU;sl>bz4!*c|UWI2K{nQLjum(55& zc`a{Mwuisp-iMq2IE>CqGUcy5Z{<}=wYYV=m-C)sFZeTuig~&0AnGmVK*t>Vz{kH< z<&*+*>9RRd+?I~7{J>^{GN;vWtT}=IQ0GL4sJ97^w)Uy)3h3lVOj>7i{Ny6~psI!o zI{J-!^E#VrjH{p(zKy)k+l|Pezc}3+ugEPAbmG@|5cm$^ZG9QhaX$1_$^P)I^0|! z`mKW-Fd<%eqR5T@Kr5)%o8f4Btqg6JR1@kia;6}*i8r1YhrX+Pt6Uh0IafVP+Wxtb z>vT`ymk-WGGXtmc8q;s_C*X{rt;2)&-SdFYxvWJ$eYwkRAJBx{=bhzqw@;&0NBYqA zj_qjVkr3YD*>F_ep+u_($WWDoiG1g?mx7CGH~F;Y`+TL))21aSU6dKJV zuA(gk)%2l=hVJG9({fR&=^HNn?lbPgDN&?#^%>V2fA5XI{}tlzxdBovVa9CQZ;mLw zaM%l8Ef|8*9Is%5v1uqkCl=F@o%qdlF}PgQ0RF?&!Ki}6hE)f!Nm4qx7ADD(>&{bM zwjC{st3eiT?ICI4a_9`$1rbY~sOqh&;Iv;E3WLAU2A#F|RN*Op%*jjqaQP}+Hh&D3 zZfF5DvrRB##T_*J{6tu)fpFWZNqD4b0xo&nT4mW(jeZzhfPk2nAh})=AE+wAT2IHI zU5f8$QPFC6_^b(=XC~uzRVB8#BZ+QI9|MuA<{^uUcleTo5DH$jV*B^%q)UGmAp6VU z<)(w9cZ(tKBXw|3r!UbYPSoMiDLAxq8NdC63ssBs#H;sR#+IvF@z>Yov^xt!(c?61 z+jkTUt}Dh}vSP^S?Lg{tF%PB9&cO+$dyv_HW~^H#2cm;=Q1spv)a62sO_!Vk@ay-% zI&DWdEw=@I?^I!O{B(G7K!~58-$83G?#3?f6ZoUCl#Z#uG+RO(e9sJ^^V_u06RpGW z{ap(@DvyIybse&H`W6J`d32(x6A1DK3xZFc#I>n*uFK`;G^*%8AH@AvNM` zW#JhZBl!SX2?nyr{?lpOhE5cy6@^tMmkKWgrepiS7_8-a5tgb2cbpUq1CHAHge>PNXI;AEHN!dr_9F5k!v>qtj+z z!=c5_Sm?SBWu4!L+l^bHk3u}`@4p#GWtKog{~I{jb`5+Du)$pI6Z(XAq_QuLpovKr zQUBn2n`c9cVVtKB2Dz$nH?7_Ajb@JK9~1D>ZG%y`cqN|Nqy+B^Qt0LbUQ|&(m-?U4 z0gb&IklCG$=*m8Ze>R^%ZS|i)ZcjXZ*EtK^M#ti`?0q2PoQZYa8d1M>yZE&|@d^o> zPkdMF28g)fi%&gR3|6}X=#flz(crBddT_HFWp-9mRP6-1Ts`M%WW$cmQio^vG@16zaJ_|Hh6xDK|m5gruU(L6_*nzU64T)O?t=d=70Ed5><#ZN|5_K&s~6 zfRhI#(60O^xHj7e6pr1eXiF+R;W!mL+5e<-tc&r!$SU-;q6x45ItQ;?`<$MLe@MUF zp9>CNlOT8NDZEHnALjo!{Nn=2iVKoZkii$@#rE{@xpZ|0~4b<2sXRm&_t0rJ{%eqD$!@u{2sE zk-@nS*Qd5iiYt}kcGA;FhV#aaPDnLAhL7s3NEC*Q7 zpTZs7$2BT+tA7wzwc{sewY(5L$+8y?(3VEyL`-SRvX{JFMkTGXOh&UC%u(Rsp>$Pi zCZF@d1u3Q^(#r58p{>FkZeLIVlIs|4wVKc8_TE`T>*u&~D+AoR)b*?juusyCf6Oo}S{#&Ug@*wex!Gn9A1 zllS*h=G1qn(6jXtbj)~Jq<&-&dNfFdj<(H0ncf5Vl&D7G^viMlv*t27=102V#r~o6 z(9FJ-W3{aL)T|}Qeb)i*nchiWZ*d_f9deI9qg2K@GIcunr;VWLOdc0eA}V~9J`Yt& zZ$$N8+vp6PJpQIxHUIXl6zw~GI&F`P7N|bLRQB3wZtn6zpVZ|Gz4j94MY7NkEIKzSa4fIIs|8f^ZAo|Kk=Vs zG*R6HjyrX$!lw0kvLIa4oUWcY5Uub#%2~>AT(ggg&4t?y{QJ4v(SaG6Tm}D`Z$7$- zm)DaP6zH$!8q&|u{RZ#&S+xmvJ>?}q>`p<+%AJ66D)TMYg@u&QUE5~W% z{0S)I*D)*CmtOD(_`C`A^zs-n;^8nD(w@!%6)DebeoJVtmoUx_ENStB)343^^z{>L{tz~C#gqBwmaYoq| zn{3&IP<^x?dq|qm=nLDpvZG%>ci}2n*>(pPeOv)<0g-g$Qz4#JDx{+KwPBO~V4V44 z1}tt|j(3g9hoRf!@xp`6SXJs9T=9H`OHMgpeoHZZS^Ax3$4C*;fDYlp6h%1ZuL&~; zy@A#526684esJhK79P9M2KMdokW?Oz5w{O!-Eo1z7uLY+jmm7LkuKa;H$@-vzSCI` zmm@>-W}C``Je1d*!eh=)#1Aitp`G^9?2PL}`e9@sRt0#D%w*$VjOA%`Ad+r#{n1c?8F5()_FG7`mV(h}VS)kl?2hM7SfMr2E60S=^ z8|Ll89;qeVY4#95nj}ps2Bad@jg(rf>!91+htm7&UZbgo{%A~-56nm|Fkt}P6PT9F6n-dqKczNpXT7`3Cie6OSx)9bPN0wJk97X#&$eIq95J{fXw>DMClPq{xIMHE4HKCHI|= z(XTI)x#j0Z)#G0FHfc5gRwG@veF8@lS_v3i4w$JP8sictP8fy zTWRW!U~CcfiL1At3Z=U7xT>!f{V4MYR`%SFPdt8wDl7Jk!RM1badLv$zV2SOoS)$GDKKPqaHkKXIgav6AaE{p?x}bXyG*4>oahqD=bmOZ?E}`en zcAh$p9sfwEuJ#Da9J0fGM&6WmGysfitL{dx4CoZoxn@4fN&ze4=ISO4Ct zfA7`5_v+t!_3yp<_g?*bul~JP|K6*A@72He>fir=q<_c%$@zW2sDi)c-@iSqU&;SM z{(bV_<=_8b<{$Ro@%P?8=)HgNSGa#57itR!PMyM|I34B{ro=20&1oIJ11q&1m_7M{ z1IIg|a~o$1Y!x(s`*ae1(w5ng$$yJ`QFDE&}3}f%Cq`(;MMMAQPO9dF6@3 zQ%95*9eRO}6bE7BDreGvgE)kbJA)HOZAR`P>$r*PO(@6t0q$s+4a0nF!M(r%*W}mH zsFynIUH<^MaMd2~nsETS0#nH5ivl#qCkzy~9K~C98Gy#2msIn971+Ky1Sd9+#;tez zV5>0}M6T-_3_mJQ4o<5E?U~_V-C~9(ZEJ+u_;*z7!xPY5oP~z%)8^eij{(&iUocC^ z#WD}?aV3Soud8-KzOC`-=?+4c1wVt!2Bv7V%y{Ou{ty)p?gq_NS-7fk3l@l9#qeql zWW7`B$vt`(2Dv;yZPPL!z~}>&bCYJ@MKodW$|5vv)Ct;@J_Ou$Jfz^BO$9dL^2$_9STMox=F~ zcCfCg#6HpC6z&>e4IdeNfBR>UnXU=fns-4%-+Sm;WeE8u){hM}I)R48KgJUJ7GP`G z!rho;&mUVcj}$qa;=&K5zyymL)bF$hKDHu-*R)LrzqCqtcl-q!BmWNje7Qo;FS&|a zS4AT2qeFSadj#Lz+Mj$8-3Qyv1`_v3F=G56-)2fhE94b9gX!rmh`n?fiz`Iq(Jisq zr|c@s>(S|@f1e2@N(?d`*MR(vPc%PXpQg@x0Lscvc)&*??Dp(N7gmasGhSJi4tN2a z3)~02`u|@2|6f7>|7hh!oR$;?0eg<&p=X`hfW7X5$dQPwk1>Vr`kE>es{|+*GMz=4 zUS-LaE$sgM-8T0>f8+G4M-k!VfviCz6w<|NNZIW=uvpTMXq^fH#j0scV5Gs)4WnVK z3?@5z@@M-L6%)1UQkbkQLbhWkV&FB0^$8PU4w`Cgs@h(BuTY!SOfJTm3+u4Q8cUMy zB0|O~)Ixk%JWfCEgtP9aF{MHA?E0Q$yi_fowI)^I1!}Iu+I9lh<>M6DU>K-mw8G;7XVJzw z5pYsJ1aGe@lC`xd=*#Ub_~1u?jw#)>_UNS2Sa`ONM>(ALwPs5Sg{a8#EUa{qBcJ5t==aD&Ou2sny&`tGSO4FefB!F$ zf8SjFHNQXA0b=Z(P^|hb8{w@kWP51~ExPrZ8#A_@zurC=-7(CgC*;p_wz=(;+fqUm zLLz9txFwu)#xcQ~{cn-hhj>BF9YRy<>`+?!P@3j+3r$*d-sXh!V=kq^LfFsvsUUdl z8v54HKoF3Sg7OB>r)CfO^YZFuRDiqqPsJa26JHCMWlqtqI4L?ob1S|20#NgU1a!l5 z1C6{ff^yWDDqrp9&pV6aa|iV3j-QBM^T-tZp7rF7#G3?HKCY$(!Yg1 z+aOr@bSHn{x4`sJ_$?x@?^(!5CCBB80s+%BK>4{#x)&!HIB6O4Gao(V;NHC)ymw)Lv4C!cA za$*MtqtckMD5pjb&+T&(&DP(6DCBXhoYA%8ywQyA34-s!p)^rVlm-{7Q$=TKL2Ymn zN+8Lc_s?gjDeVWp)#o-X&uvG>Hp}|*+xe-D}hiW)fJ$Bo~?a2JbUpO#| zuDvvvKdoPkGzt^Y6^Vnq{i{9=w?S=b^qzi9k)WIVDxun5`;OweM^37r}1 zLM3XvIl&Q(B5;yTgog_aHakxP6kl-Ffyy*H;tX08dx^^W-a!SsP9ZWa5uL33$)BC) zhRm``5c54peHM)7zrB)2%8Ltajz{mLo(IdQ*zD_qpt@q(>d_w$Z9gygrT^DX;`o ztYksFEEQh9%E8CuO5i@o(+>GC6s>Rv#ATAP=JsJsMY9!76k|L?RE{ilKZTvMmyv-| z>72Kr0Wr;wrH-F}!f40!XoIsAJpI|19hV%7lWecT&cjb&pz=7I$6nSf!cm>ob*~4v zBk_2-+H`VSt^f*V$ALMgL&H3uf^YRCyynJ;5HFB6+C zXb0?INnRYjitzZ`Sf-p})8?79C1NHXdFT;*ESdrfy0@VcG95=fJcTp%Ou)Bpw!xhr zfxsmmz-zA?(}%n-3dj!Z$>rKpqX>oyTUSgbS3k}{H+N*h%>1Q2H5PVPZE!z`&PHs; zWh&A=^#W>Wib^8NODM%kH9hr$j$H-EqQhXkc`II5;RVJ~vdBID8YD*EgtZl(5f%XQ#&&TE<_wjbsuXMwcyE-aamhBr8rz#t||E{r$?l5uBngJB5X zBsv{kKC=%iyn2q(jkgMyAM=9O3&mN``Yc>>YaO(|-b?3bOou~lQ|aY_8Q8&RCoYkb z#l8CfUj6@HLI3~Na2|B`y+R+U8Iz|opJ0#m{fLduR>%|wayR}WzG&1RebDaFf9rd~H6h654P>7dzel;^OOW{4@31H8Blq0E4vas&ft_o< zg2y^pHpOTcv!DI}7GBRIc8YsJVTLLezq67LZ?+@d3bF9H`VLwxx*Hs3#;~U5IxHgk zn)j^GB9Y<#;9k>7Pd9&riJs|9Z__t=x2Hy2%L!3(!;Ql^>yqKNBO#BWD=+N zcsA?RH1bpV3YVWiDgJgAq;V`R*`3WY#+s1npS!7z$8l(RmyRb#UxNePgV~$$cQE*m zfP!X8R`$h|R1P@B^*<&_`}EXjTSonf!f|bq+oKOQvm8O3svm&d=&9^(=@VLI*oR3r zn2~AH8%h0_>FoZMMfg*A5_azr1I0sQU}JR}Sv`9I42A(NaY~J!F{JN?q_k2NL zmN;+@a|<>lNjL7ngtFrhd1Wv&tc^q;=`3>FN&`E8Yl7fY<;>;64sw4?G40L2-QUm9~OLy*5F*EoA|NSKLp|{ zMR1XaHr&n8$5NG>`IvXpY3jIL+^L@qG;C8nEoq9UrMZ+pAGHVVS7zw=h6 zFWi~F!*IT>2Yoo)5M{A)lqlXN*cH@*E}XWZ@#+terce~iFD&He?A*wYa*Rip+r=oG z;7?ao=?mgo!ja>kO~~C)NL}w4;PJr=(4HHPLZgt6bmxl*y4msr*CMt67wX5LiNjxW zF_Z5Ki}Y#*HG?0bEI}SxWwa2j9WH_FLMckwrwW+SOP>H@@ z>Do3!PU?s>F5TEnoyQg;t!3Bv*U#Q?Ka>SkRZ}I=KGzC?s_S61wEH>@my)HC+50%> z4Jt_bSOYcu`IhHzZQv8V_0gLr4859RiWcT5}*l&GkNMQ*+4(`p+y6}?jQ zRA9mnuPaCMD%Az%F|#?3Yw5zXb}hn*?j}&0e1~uOkj?c6xgLM33_Z8|lR)I+L*(*q z6Tg3i4Ij=Ip@FvRXsu!sR~Wm4+ckFwf2npLZ>%Cma{@c4WAp^1bbC5~Gvx+dx2>mI zO~ZbaXQlvqhXzvT^mzKcHWww%*&~2UHFRQ(2KuqRjZ@k#ZF9q29FMbWL+eY;vCiOh zG_c_jx;FN-0B`z9=SRgOot{|W_e&$8=fVP{s@gV|CizaT&3do;A7SQtl|Hc<^P;Q^Yha|D4|A z_t^FS&rC@)8#vsD8k@)=j%s#;D~VSG7d6M8@- V0|O%v2Y9oxf!G2-2vQGG3jk1%Uts_M diff --git a/fme/diffusion/testdata/stepper_train_on_batch_regression-True.pt b/fme/diffusion/testdata/stepper_train_on_batch_regression-True.pt index 18d4e938503340ef41be6671d788a3ad7e2285a2..c3a8721e19bbcaf7b893df0233784d0bcde3ff3a 100644 GIT binary patch literal 36525 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 N24V{UAxJ$$EdcsKRY(8; From 54fecc622f2198124dbe9f1841cc414973f3b672 Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Tue, 3 Feb 2026 22:08:03 +0000 Subject: [PATCH 18/30] incorporate review comments --- fme/ace/registry/stochastic_sfno.py | 2 +- fme/core/models/conditional_sfno/s2convolutions.py | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/fme/ace/registry/stochastic_sfno.py b/fme/ace/registry/stochastic_sfno.py index 81236106d..1d48be83f 100644 --- a/fme/ace/registry/stochastic_sfno.py +++ b/fme/ace/registry/stochastic_sfno.py @@ -98,7 +98,7 @@ class NoiseConditionedSFNOBuilder(ModuleConfig): spectral_transform: Type of spherical transform to use. Kept for backwards compatibility. filter_type: Type of filter to use. - operator_type: Type of operator to use. + operator_type: Type of operator to use. Only "dhconv" is supported. residual_filter_factor: Factor by which to downsample the residual. embed_dim: Dimension of the embedding. noise_embed_dim: Dimension of the noise embedding. diff --git a/fme/core/models/conditional_sfno/s2convolutions.py b/fme/core/models/conditional_sfno/s2convolutions.py index ec28d168d..4e3f06438 100644 --- a/fme/core/models/conditional_sfno/s2convolutions.py +++ b/fme/core/models/conditional_sfno/s2convolutions.py @@ -172,10 +172,7 @@ def __init__( assert factorization == "ComplexDense" self.weight = nn.Parameter(scale * torch.randn(*weight_shape, 2)) - if self.operator_type == "dhconv": - self.weight.is_shared_mp = ["matmul", "w"] - else: - self.weight.is_shared_mp = ["matmul"] + self.weight.is_shared_mp = ["matmul", "w"] if lora_rank > 0: if self.weight.shape != ( From f43e8afd62f312202db6cba0ff42fb32c2135285 Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Wed, 4 Feb 2026 19:58:33 +0000 Subject: [PATCH 19/30] remove overwrite of conv2d weights --- fme/core/models/conditional_sfno/s2convolutions.py | 5 ----- fme/core/models/conditional_sfno/sfnonet.py | 12 ------------ 2 files changed, 17 deletions(-) diff --git a/fme/core/models/conditional_sfno/s2convolutions.py b/fme/core/models/conditional_sfno/s2convolutions.py index 3e956eac8..d16c5408d 100644 --- a/fme/core/models/conditional_sfno/s2convolutions.py +++ b/fme/core/models/conditional_sfno/s2convolutions.py @@ -128,11 +128,6 @@ def __init__( "Currently only in_channels == out_channels is supported." ) - if in_channels != out_channels: - raise NotImplementedError( - "Currently only in_channels == out_channels is supported." - ) - self.forward_transform = forward_transform self.inverse_transform = inverse_transform diff --git a/fme/core/models/conditional_sfno/sfnonet.py b/fme/core/models/conditional_sfno/sfnonet.py index 04378d0ea..85b13c548 100644 --- a/fme/core/models/conditional_sfno/sfnonet.py +++ b/fme/core/models/conditional_sfno/sfnonet.py @@ -826,7 +826,6 @@ def __init__( if self.pos_embed: self.pos_embed = get_pos_embed() - self.apply(self._init_weights) if normalize_big_skip: self.norm_big_skip = ConditionalLayerNorm( in_chans, @@ -838,17 +837,6 @@ def __init__( else: self.norm_big_skip = NoLayerNorm() - def _init_weights(self, m): - """Helper routine for weight initialization""" - if isinstance(m, nn.Linear) or isinstance(m, nn.Conv2d): - trunc_normal_(m.weight, std=0.02) - if m.bias is not None: - nn.init.constant_(m.bias, 0) - if isinstance(m, LoRAConv2d): - m.reset_lora_parameters() - elif isinstance(m, ConditionalLayerNorm): - m.reset_parameters() - @torch.jit.ignore def no_weight_decay(self): # pragma: no cover """Helper""" From cbbc0b677930e59ce7f14ade734d4266fcba87d8 Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Wed, 4 Feb 2026 20:01:00 +0000 Subject: [PATCH 20/30] use varname makani is using --- fme/core/models/conditional_sfno/s2convolutions.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/fme/core/models/conditional_sfno/s2convolutions.py b/fme/core/models/conditional_sfno/s2convolutions.py index d16c5408d..9d3960b56 100644 --- a/fme/core/models/conditional_sfno/s2convolutions.py +++ b/fme/core/models/conditional_sfno/s2convolutions.py @@ -134,11 +134,6 @@ def __init__( self.modes_lat = self.inverse_transform.lmax self.modes_lon = self.inverse_transform.mmax - if scale == "auto": - scale = math.sqrt(1 / (in_channels)) * torch.ones(self.modes_lat, 2) - # seemingly the first weight is not really complex, so we need to account for that - scale[0, :] *= math.sqrt(2.0) - self._round_trip_residual = filter_residual or ( (self.forward_transform.nlat != self.inverse_transform.nlat) or (self.forward_transform.nlon != self.inverse_transform.nlon) @@ -171,6 +166,11 @@ def __init__( self.lpad = 0 self.mpad = 0 + if scale == "auto": + scale = math.sqrt(1 / (in_channels)) * torch.ones(self.modes_lat_local, 2) + # seemingly the first weight is not really complex, so we need to account for that + scale[0, :] *= math.sqrt(2.0) + weight_shape = [in_channels, out_channels, self.modes_lat_local] assert factorization == "ComplexDense" From 965765e64891dc8c87cf789540281f6fce4082a9 Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Wed, 4 Feb 2026 20:14:27 +0000 Subject: [PATCH 21/30] update regression target --- .../test_sfnonet_output_is_unchanged.pt | Bin 9624 -> 9624 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/fme/core/models/conditional_sfno/testdata/test_sfnonet_output_is_unchanged.pt b/fme/core/models/conditional_sfno/testdata/test_sfnonet_output_is_unchanged.pt index c3e6f7897a226acbfe6ba1116080aaf445a57610..95ea10fc68914a89ffcabd8475309b91785230a5 100644 GIT binary patch delta 7911 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 From 8c1846591a1994663d64fb78b7a67ec768af01b9 Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Wed, 4 Feb 2026 20:31:22 +0000 Subject: [PATCH 22/30] update diffusion regression targets --- .../testdata/stepper_predict_regression.pt | Bin 13821 -> 13821 bytes .../testdata/stepper_step_regression.pt | Bin 5925 -> 5925 bytes ...stepper_train_on_batch_regression-False.pt | Bin 36543 -> 36543 bytes .../stepper_train_on_batch_regression-True.pt | Bin 36525 -> 36525 bytes 4 files changed, 0 insertions(+), 0 deletions(-) diff --git a/fme/diffusion/testdata/stepper_predict_regression.pt b/fme/diffusion/testdata/stepper_predict_regression.pt index 2d465efb4802e7c0eccb3396eb6030b7b4325354..bd7f669fac86d46f8041d57f7f1b418bbde48478 100644 GIT binary patch delta 9901 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 NC;7km-A-KP{{fDdc0&LF diff --git a/fme/diffusion/testdata/stepper_step_regression.pt b/fme/diffusion/testdata/stepper_step_regression.pt index 8166a37f9f8d6672425d8ddaddde09f062005aee..f880ce4a0599e98ae988feec175fcccbf64f1112 100644 GIT binary patch delta 4007 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 BgO30J diff --git a/fme/diffusion/testdata/stepper_train_on_batch_regression-False.pt b/fme/diffusion/testdata/stepper_train_on_batch_regression-False.pt index 8a36b853aee367d23918ca7d8ed19eced8e2d414..18448e32577d93eb6b3a25f82b7fe6171841317a 100644 GIT binary patch literal 36543 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 literal 36543 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 diff --git a/fme/diffusion/testdata/stepper_train_on_batch_regression-True.pt b/fme/diffusion/testdata/stepper_train_on_batch_regression-True.pt index c3a8721e19bbcaf7b893df0233784d0bcde3ff3a..bfa01273e3af52912ee304ee737aa95b9c158b67 100644 GIT binary patch literal 36525 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 Date: Wed, 4 Feb 2026 20:59:12 +0000 Subject: [PATCH 23/30] remove second copy of _contract_dhconv --- .../models/conditional_sfno/s2convolutions.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/fme/core/models/conditional_sfno/s2convolutions.py b/fme/core/models/conditional_sfno/s2convolutions.py index 0adbea5aa..08b7985db 100644 --- a/fme/core/models/conditional_sfno/s2convolutions.py +++ b/fme/core/models/conditional_sfno/s2convolutions.py @@ -74,22 +74,6 @@ def _contract_dhconv( return torch.einsum("bgixy,giox->bgoxy", xc, wc) -@torch.jit.script -def _contract_dhconv( - xc: torch.Tensor, weight: torch.Tensor -) -> torch.Tensor: # pragma: no cover - """ - Performs a complex Driscoll-Healy style convolution operation between two tensors - 'a' and 'b'. - - Args: - xc: Complex input tensor of shape (batch_size, in_channels, nlat, nlon) - weight: Weight tensor of shape (in_channels, out_channels, nlat, 2) - """ - wc = torch.view_as_complex(weight) - return torch.einsum("bixy,iox->boxy", xc, wc) - - class SpectralConvS2(nn.Module): """ Spectral Convolution according to Driscoll & Healy. Designed for convolutions on From aa03c6df5a2b3b2c6989a4c60b368c4696c3f243 Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Fri, 6 Feb 2026 19:17:01 +0000 Subject: [PATCH 24/30] add unit test that dhconv is faster when using groups --- .../models/conditional_sfno/test_sfnonet.py | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/fme/core/models/conditional_sfno/test_sfnonet.py b/fme/core/models/conditional_sfno/test_sfnonet.py index 6f8d3bb80..87b58111f 100644 --- a/fme/core/models/conditional_sfno/test_sfnonet.py +++ b/fme/core/models/conditional_sfno/test_sfnonet.py @@ -1,3 +1,4 @@ +import dataclasses import os from types import SimpleNamespace @@ -9,11 +10,91 @@ from fme.core.testing.regression import validate_tensor from .layers import Context, ContextConfig +from .s2convolutions import _contract_dhconv from .sfnonet import get_lat_lon_sfnonet DIR = os.path.abspath(os.path.dirname(__file__)) +@dataclasses.dataclass +class BenchmarkResult: + ms_total: float + ms_per: float + max_alloc: int + max_reserved: int + y_shape: tuple + y_dtype: torch.dtype + + +def benchmark(fn, iters=10, warmup=1) -> BenchmarkResult: + for _ in range(warmup): + fn() + torch.cuda.synchronize() + + torch.cuda.reset_peak_memory_stats() + starter = torch.cuda.Event(enable_timing=True) + ender = torch.cuda.Event(enable_timing=True) + + starter.record() + for _ in range(iters): + y = fn() + ender.record() + torch.cuda.synchronize() + + ms = starter.elapsed_time(ender) + return BenchmarkResult( + ms_total=ms, + ms_per=ms / iters, + max_alloc=torch.cuda.max_memory_allocated(), + max_reserved=torch.cuda.max_memory_reserved(), + y_shape=tuple(y.shape), + y_dtype=y.dtype, + ) + + +@pytest.mark.skipif( + get_device().type != "cuda", + reason=( + "This test is only relevant for CUDA since " + "it's testing speed of DHConv groups on GPU." + ), +) # noqa: E501 +def test_contract_dhconv_groups_are_faster(): + B = 2 + C = 512 + H = 180 + L = 360 + G = 8 + x = torch.randn(B, 1, C, H, L, dtype=torch.complex64, device=get_device()) + w = torch.randn(1, C, C, H, 2, dtype=torch.float32, device=get_device()) + + def contract_ungrouped(): + return _contract_dhconv(x, w) + + ungrouped_result = benchmark(contract_ungrouped) + + x_grouped = x.reshape(B, G, C // G, H, L) + w_grouped = torch.randn( + G, C // G, C // G, H, 2, dtype=torch.float32, device=get_device() + ) + + def contract_grouped(): + return _contract_dhconv(x_grouped, w_grouped) + + grouped_result = benchmark(contract_grouped) + + assert grouped_result.ms_per < 2 / G * ungrouped_result.ms_per, ( + "Expected grouped DHConv to be faster than ungrouped, but got " + f"{grouped_result.ms_per:.6f} seconds for grouped and " + f"{ungrouped_result.ms_per:.6f} seconds for ungrouped." + ) + assert grouped_result.max_alloc < ungrouped_result.max_alloc, ( + "Expected grouped DHConv to use less memory than ungrouped, but got " + f"{grouped_result.max_alloc/1024/1024:.2f} MB for grouped and " + f"{ungrouped_result.max_alloc/1024/1024:.2f} MB for ungrouped." + ) + + @pytest.mark.parametrize( "conditional_embed_dim_scalar, conditional_embed_dim_labels, conditional_embed_dim_noise, residual_filter_factor", # noqa: E501 [ From 460198fc747dd8d4db4dc7cfeefffeb06098b714 Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Fri, 6 Feb 2026 19:20:55 +0000 Subject: [PATCH 25/30] move test to correct file --- .../conditional_sfno/test_s2convolutions.py | 85 +++++++++++++++++++ .../models/conditional_sfno/test_sfnonet.py | 81 ------------------ 2 files changed, 85 insertions(+), 81 deletions(-) diff --git a/fme/core/models/conditional_sfno/test_s2convolutions.py b/fme/core/models/conditional_sfno/test_s2convolutions.py index be102d628..cc6fcd1d6 100644 --- a/fme/core/models/conditional_sfno/test_s2convolutions.py +++ b/fme/core/models/conditional_sfno/test_s2convolutions.py @@ -1,8 +1,93 @@ +import dataclasses + +import pytest import torch +from fme.core.device import get_device from fme.core.gridded_ops import LatLonOperations from fme.core.models.conditional_sfno.s2convolutions import SpectralConvS2 +from .s2convolutions import _contract_dhconv + + +@dataclasses.dataclass +class BenchmarkResult: + ms_total: float + ms_per: float + max_alloc: int + max_reserved: int + y_shape: tuple + y_dtype: torch.dtype + + +def benchmark(fn, iters=10, warmup=1) -> BenchmarkResult: + for _ in range(warmup): + fn() + torch.cuda.synchronize() + + torch.cuda.reset_peak_memory_stats() + starter = torch.cuda.Event(enable_timing=True) + ender = torch.cuda.Event(enable_timing=True) + + starter.record() + for _ in range(iters): + y = fn() + ender.record() + torch.cuda.synchronize() + + ms = starter.elapsed_time(ender) + return BenchmarkResult( + ms_total=ms, + ms_per=ms / iters, + max_alloc=torch.cuda.max_memory_allocated(), + max_reserved=torch.cuda.max_memory_reserved(), + y_shape=tuple(y.shape), + y_dtype=y.dtype, + ) + + +@pytest.mark.skipif( + get_device().type != "cuda", + reason=( + "This test is only relevant for CUDA since " + "it's testing speed of DHConv groups on GPU." + ), +) # noqa: E501 +def test_contract_dhconv_groups_are_faster(): + B = 2 + C = 512 + H = 180 + L = 360 + G = 8 + x = torch.randn(B, 1, C, H, L, dtype=torch.complex64, device=get_device()) + w = torch.randn(1, C, C, H, 2, dtype=torch.float32, device=get_device()) + + def contract_ungrouped(): + return _contract_dhconv(x, w) + + ungrouped_result = benchmark(contract_ungrouped) + + x_grouped = x.reshape(B, G, C // G, H, L) + w_grouped = torch.randn( + G, C // G, C // G, H, 2, dtype=torch.float32, device=get_device() + ) + + def contract_grouped(): + return _contract_dhconv(x_grouped, w_grouped) + + grouped_result = benchmark(contract_grouped) + + assert grouped_result.ms_per < 2 / G * ungrouped_result.ms_per, ( + "Expected grouped DHConv to be faster than ungrouped, but got " + f"{grouped_result.ms_per:.6f} seconds for grouped and " + f"{ungrouped_result.ms_per:.6f} seconds for ungrouped." + ) + assert grouped_result.max_alloc < ungrouped_result.max_alloc, ( + "Expected grouped DHConv to use less memory than ungrouped, but got " + f"{grouped_result.max_alloc/1024/1024:.2f} MB for grouped and " + f"{ungrouped_result.max_alloc/1024/1024:.2f} MB for ungrouped." + ) + def test_spectral_conv_s2_lora(): in_channels = 8 diff --git a/fme/core/models/conditional_sfno/test_sfnonet.py b/fme/core/models/conditional_sfno/test_sfnonet.py index afe586f2c..3230d7c87 100644 --- a/fme/core/models/conditional_sfno/test_sfnonet.py +++ b/fme/core/models/conditional_sfno/test_sfnonet.py @@ -1,4 +1,3 @@ -import dataclasses import os from types import SimpleNamespace @@ -10,91 +9,11 @@ from fme.core.testing.regression import validate_tensor from .layers import Context, ContextConfig -from .s2convolutions import _contract_dhconv from .sfnonet import get_lat_lon_sfnonet DIR = os.path.abspath(os.path.dirname(__file__)) -@dataclasses.dataclass -class BenchmarkResult: - ms_total: float - ms_per: float - max_alloc: int - max_reserved: int - y_shape: tuple - y_dtype: torch.dtype - - -def benchmark(fn, iters=10, warmup=1) -> BenchmarkResult: - for _ in range(warmup): - fn() - torch.cuda.synchronize() - - torch.cuda.reset_peak_memory_stats() - starter = torch.cuda.Event(enable_timing=True) - ender = torch.cuda.Event(enable_timing=True) - - starter.record() - for _ in range(iters): - y = fn() - ender.record() - torch.cuda.synchronize() - - ms = starter.elapsed_time(ender) - return BenchmarkResult( - ms_total=ms, - ms_per=ms / iters, - max_alloc=torch.cuda.max_memory_allocated(), - max_reserved=torch.cuda.max_memory_reserved(), - y_shape=tuple(y.shape), - y_dtype=y.dtype, - ) - - -@pytest.mark.skipif( - get_device().type != "cuda", - reason=( - "This test is only relevant for CUDA since " - "it's testing speed of DHConv groups on GPU." - ), -) # noqa: E501 -def test_contract_dhconv_groups_are_faster(): - B = 2 - C = 512 - H = 180 - L = 360 - G = 8 - x = torch.randn(B, 1, C, H, L, dtype=torch.complex64, device=get_device()) - w = torch.randn(1, C, C, H, 2, dtype=torch.float32, device=get_device()) - - def contract_ungrouped(): - return _contract_dhconv(x, w) - - ungrouped_result = benchmark(contract_ungrouped) - - x_grouped = x.reshape(B, G, C // G, H, L) - w_grouped = torch.randn( - G, C // G, C // G, H, 2, dtype=torch.float32, device=get_device() - ) - - def contract_grouped(): - return _contract_dhconv(x_grouped, w_grouped) - - grouped_result = benchmark(contract_grouped) - - assert grouped_result.ms_per < 2 / G * ungrouped_result.ms_per, ( - "Expected grouped DHConv to be faster than ungrouped, but got " - f"{grouped_result.ms_per:.6f} seconds for grouped and " - f"{ungrouped_result.ms_per:.6f} seconds for ungrouped." - ) - assert grouped_result.max_alloc < ungrouped_result.max_alloc, ( - "Expected grouped DHConv to use less memory than ungrouped, but got " - f"{grouped_result.max_alloc/1024/1024:.2f} MB for grouped and " - f"{ungrouped_result.max_alloc/1024/1024:.2f} MB for ungrouped." - ) - - @pytest.mark.parametrize( "conditional_embed_dim_scalar, conditional_embed_dim_labels, " "conditional_embed_dim_noise, " From 0bc701af46cd1a679dac36a09db24134ee01c9d7 Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Fri, 6 Feb 2026 20:48:36 +0000 Subject: [PATCH 26/30] add test with profiling for sfno --- fme/core/models/conditional_sfno/layers.py | 104 +++++---- .../models/conditional_sfno/s2convolutions.py | 55 +++-- fme/core/models/conditional_sfno/sfnonet.py | 76 +++--- fme/core/models/conditional_sfno/sht.py | 220 ++++++++++++++++++ .../models/conditional_sfno/test_sfnonet.py | 132 ++++++++++- fme/core/models/conditional_sfno/timer.py | 40 ++++ 6 files changed, 530 insertions(+), 97 deletions(-) create mode 100644 fme/core/models/conditional_sfno/sht.py create mode 100644 fme/core/models/conditional_sfno/timer.py diff --git a/fme/core/models/conditional_sfno/layers.py b/fme/core/models/conditional_sfno/layers.py index 47648d781..2fe7e71d7 100644 --- a/fme/core/models/conditional_sfno/layers.py +++ b/fme/core/models/conditional_sfno/layers.py @@ -25,6 +25,7 @@ from torch.utils.checkpoint import checkpoint from fme.core.models.conditional_sfno.lora import LoRAConv2d +from fme.core.models.conditional_sfno.timer import CUDATimer, NullTimer from .activations import ComplexReLU from .contractions import compl_mul2d_fwd, compl_muladd2d_fwd @@ -223,7 +224,12 @@ def reset_parameters(self): torch.nn.init.constant_(self.W_bias_pos.weight, 0.0) # no bias on 2d layers as it is already handled in the non-2d layers - def forward(self, x: torch.Tensor, context: Context) -> torch.Tensor: + def forward( + self, + x: torch.Tensor, + context: Context, + timer: CUDATimer | NullTimer | None = None, + ) -> torch.Tensor: """ Conditional Layer Normalization @@ -238,56 +244,64 @@ def forward(self, x: torch.Tensor, context: Context) -> torch.Tensor: Returns: The normalized tensor, of shape (batch_size, channels, height, width). """ + if timer is None: + timer = NullTimer() if context.labels is None and ( self.W_scale_labels is not None or self.W_bias_labels is not None ): raise ValueError("labels must be provided") - if self.W_scale is not None: - if context.embedding_scalar is None: - raise ValueError("embedding_scalar must be provided") - scale: torch.Tensor = ( - self.W_scale(context.embedding_scalar).unsqueeze(-1).unsqueeze(-1) - ) - else: - scale = torch.ones( - list(x.shape[:-2]) + [1, 1], device=x.device, dtype=x.dtype - ) + with timer.context("layer_norm_compute_scaling_and_bias"): + if self.W_scale is not None: + if context.embedding_scalar is None: + raise ValueError("embedding_scalar must be provided") + scale: torch.Tensor = ( + self.W_scale(context.embedding_scalar).unsqueeze(-1).unsqueeze(-1) + ) + else: + scale = torch.ones( + list(x.shape[:-2]) + [1, 1], device=x.device, dtype=x.dtype + ) - if self.W_scale_2d is not None: - if context.noise is None: - raise ValueError("embedding_2d must be provided") - scale = scale + self.W_scale_2d(context.noise) - if self.W_bias is not None: - if context.embedding_scalar is None: - raise ValueError("embedding_scalar must be provided") - bias: torch.Tensor = ( - self.W_bias(context.embedding_scalar).unsqueeze(-1).unsqueeze(-1) - ) - else: - bias = torch.zeros( - list(x.shape[:-2]) + [1, 1], device=x.device, dtype=x.dtype - ) + if self.W_scale_2d is not None: + if context.noise is None: + raise ValueError("embedding_2d must be provided") + scale = scale + self.W_scale_2d(context.noise) + if self.W_bias is not None: + if context.embedding_scalar is None: + raise ValueError("embedding_scalar must be provided") + bias: torch.Tensor = ( + self.W_bias(context.embedding_scalar).unsqueeze(-1).unsqueeze(-1) + ) + else: + bias = torch.zeros( + list(x.shape[:-2]) + [1, 1], device=x.device, dtype=x.dtype + ) - if self.W_scale_labels is not None: - scale = scale + self.W_scale_labels(context.labels).unsqueeze(-1).unsqueeze( - -1 - ) - if self.W_bias_labels is not None: - bias = bias + self.W_bias_labels(context.labels).unsqueeze(-1).unsqueeze(-1) - if self.W_bias_2d is not None: - if context.noise is None: - raise ValueError("embedding_2d must be provided") - bias = bias + self.W_bias_2d(context.noise) - if self.W_scale_pos is not None: - if context.embedding_pos is None: - raise ValueError("embedding_pos must be provided") - scale = scale + self.W_scale_pos(context.embedding_pos) - if self.W_bias_pos is not None: - if context.embedding_pos is None: - raise ValueError("embedding_pos must be provided") - bias = bias + self.W_bias_pos(context.embedding_pos) - x_norm: torch.Tensor = self.norm(x) - return x_norm * scale + bias + if self.W_scale_labels is not None: + scale = scale + self.W_scale_labels(context.labels).unsqueeze( + -1 + ).unsqueeze(-1) + if self.W_bias_labels is not None: + bias = bias + self.W_bias_labels(context.labels).unsqueeze( + -1 + ).unsqueeze(-1) + if self.W_bias_2d is not None: + if context.noise is None: + raise ValueError("embedding_2d must be provided") + bias = bias + self.W_bias_2d(context.noise) + if self.W_scale_pos is not None: + if context.embedding_pos is None: + raise ValueError("embedding_pos must be provided") + scale = scale + self.W_scale_pos(context.embedding_pos) + if self.W_bias_pos is not None: + if context.embedding_pos is None: + raise ValueError("embedding_pos must be provided") + bias = bias + self.W_bias_pos(context.embedding_pos) + with timer.context("layer_norm_normalize"): + x_norm: torch.Tensor = self.norm(x) + with timer.context("layer_norm_apply_scaling_and_bias"): + return_value = x_norm * scale + bias + return return_value @torch.jit.script diff --git a/fme/core/models/conditional_sfno/s2convolutions.py b/fme/core/models/conditional_sfno/s2convolutions.py index 81ea83fc3..f0e6c204b 100644 --- a/fme/core/models/conditional_sfno/s2convolutions.py +++ b/fme/core/models/conditional_sfno/s2convolutions.py @@ -22,6 +22,8 @@ import torch_harmonics as th import torch_harmonics.distributed as thd +from fme.core.models.conditional_sfno.timer import CUDATimer, NullTimer + # import convenience functions for factorized tensors from .activations import ComplexReLU @@ -218,45 +220,56 @@ def __init__( self.bias = nn.Parameter(torch.zeros(1, out_channels, 1, 1)) self.out_channels = out_channels - def forward(self, x): # pragma: no cover + def forward( + self, x, timer: CUDATimer | NullTimer | None = None + ): # pragma: no cover + if timer is None: + timer = NullTimer() dtype = x.dtype residual = x x = x.float() with torch.amp.autocast("cuda", enabled=False): - x = self.forward_transform(x.float()) + with timer.context("forward_transform"): + x = self.forward_transform(x.float()) if self._round_trip_residual: - x = x.contiguous() - residual = self.inverse_transform(x) - residual = residual.to(dtype) + with timer.context("round_trip_residual"): + x = x.contiguous() + residual = self.inverse_transform(x) + residual = residual.to(dtype) B, C, H, W = x.shape assert C % self.num_groups == 0 - x = x.reshape(B, self.num_groups, C // self.num_groups, H, W) + with timer.context("group_reshape"): + x = x.reshape(B, self.num_groups, C // self.num_groups, H, W) if self.lora_A is not None and self.lora_B is not None: - lora_update = _contract_lora( - self.lora_A, - self.lora_B, - x[..., : self.modes_lat_local, : self.modes_lon_local], - ) + with timer.context("lora_update"): + lora_update = _contract_lora( + self.lora_A, + self.lora_B, + x[..., : self.modes_lat_local, : self.modes_lon_local], + ) else: lora_update = 0.0 - xp = torch.zeros_like(x) - xp[..., : self.modes_lat_local, : self.modes_lon_local] = _contract_dhconv( - x[..., : self.modes_lat_local, : self.modes_lon_local], - self.weight, - ) - xp = xp + self.lora_scaling * lora_update - xp = xp.reshape(B, self.out_channels, H, W) - x = xp.contiguous() + with timer.context("dhconv"): + xp = torch.zeros_like(x) + xp[..., : self.modes_lat_local, : self.modes_lon_local] = _contract_dhconv( + x[..., : self.modes_lat_local, : self.modes_lon_local], + self.weight, + ) + xp = xp + self.lora_scaling * lora_update + xp = xp.reshape(B, self.out_channels, H, W) + x = xp.contiguous() with torch.amp.autocast("cuda", enabled=False): - x = self.inverse_transform(x) + with timer.context("inverse_transform"): + x = self.inverse_transform(x) if hasattr(self, "bias"): - x = x + self.bias + with timer.context("add_bias"): + x = x + self.bias x = x.type(dtype) diff --git a/fme/core/models/conditional_sfno/sfnonet.py b/fme/core/models/conditional_sfno/sfnonet.py index 61d35ca27..e3df29d18 100644 --- a/fme/core/models/conditional_sfno/sfnonet.py +++ b/fme/core/models/conditional_sfno/sfnonet.py @@ -24,6 +24,8 @@ import torch_harmonics as th from torch.utils.checkpoint import checkpoint +from fme.core.models.conditional_sfno.timer import CUDATimer, NullTimer + from .initialization import trunc_normal_ # wrap fft, to unify interface to spectral transforms @@ -153,8 +155,8 @@ def __init__( else: raise (NotImplementedError) - def forward(self, x): - return self.filter(x) + def forward(self, x, timer: CUDATimer | None = None): + return self.filter(x, timer=timer) class FourierNeuralOperatorBlock(nn.Module): @@ -295,44 +297,58 @@ def __init__( lora_alpha=lora_alpha, ) - def forward(self, x, context_embedding): - x_norm = torch.zeros_like(x) - x_norm[..., : self.input_shape_loc[0], : self.input_shape_loc[1]] = self.norm0( - x[..., : self.input_shape_loc[0], : self.input_shape_loc[1]], - context_embedding, - ) - x, residual = self.filter(x_norm) + def forward(self, x, context_embedding, timer: CUDATimer | NullTimer | None = None): + if timer is None: + timer = NullTimer() + with timer.context("norm0"): + x_norm = torch.zeros_like(x) + x_norm[..., : self.input_shape_loc[0], : self.input_shape_loc[1]] = ( + self.norm0( + x[..., : self.input_shape_loc[0], : self.input_shape_loc[1]], + context_embedding, + timer=timer, + ) + ) + with timer.context("filter"): + x, residual = self.filter(x_norm, timer=timer) if hasattr(self, "inner_skip"): - if self.concat_skip: - x = torch.cat((x, self.inner_skip(residual)), dim=1) - x = self.inner_skip_conv(x) - else: - x = x + self.inner_skip(residual) + with timer.context("inner_skip"): + if self.concat_skip: + x = torch.cat((x, self.inner_skip(residual)), dim=1) + x = self.inner_skip_conv(x) + else: + x = x + self.inner_skip(residual) if hasattr(self, "act_layer"): - x = self.act_layer(x) - - x_norm = torch.zeros_like(x) - x_norm[..., : self.output_shape_loc[0], : self.output_shape_loc[1]] = ( - self.norm1( - x[..., : self.output_shape_loc[0], : self.output_shape_loc[1]], - context_embedding, + with timer.context("activation"): + x = self.act_layer(x) + + with timer.context("norm1"): + x_norm = torch.zeros_like(x) + x_norm[..., : self.output_shape_loc[0], : self.output_shape_loc[1]] = ( + self.norm1( + x[..., : self.output_shape_loc[0], : self.output_shape_loc[1]], + context_embedding, + timer=timer, + ) ) - ) - x = x_norm + x = x_norm if hasattr(self, "mlp"): - x = self.mlp(x) + with timer.context("mlp"): + x = self.mlp(x) - x = self.drop_path(x) + with timer.context("drop_path"): + x = self.drop_path(x) if hasattr(self, "outer_skip"): - if self.concat_skip: - x = torch.cat((x, self.outer_skip(residual)), dim=1) - x = self.outer_skip_conv(x) - else: - x = x + self.outer_skip(residual) + with timer.context("outer_skip"): + if self.concat_skip: + x = torch.cat((x, self.outer_skip(residual)), dim=1) + x = self.outer_skip_conv(x) + else: + x = x + self.outer_skip(residual) return x diff --git a/fme/core/models/conditional_sfno/sht.py b/fme/core/models/conditional_sfno/sht.py new file mode 100644 index 000000000..c2d5bee2a --- /dev/null +++ b/fme/core/models/conditional_sfno/sht.py @@ -0,0 +1,220 @@ +# flake8: noqa +# fmt: off +# isort: skip_file + +""" +This file contains a fix that we needed to get the SFNO to work on multiple +unroll steps in multiprocessing (e.g. multi-GPU mode.) We forked this code from +the torch harmonics sht.py file [*]. + +[*] https://github.com/NVIDIA/torch-harmonics/blob/17eefa53468d1a885d72087918eba905fa53e10a/torch_harmonics/sht.py +""" + + +# 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 +import torch.fft + +from torch_harmonics.quadrature import legendre_gauss_weights, lobatto_weights, clenshaw_curtiss_weights +from torch_harmonics.legendre import _precompute_legpoly + +from fme.core.device import get_device + + +class RealSHT(nn.Module): + """ + Defines a module for computing 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 + + [1] Schaeffer, N. Efficient spherical harmonic transforms aimed at pseudospectral numerical simulations, G3: Geochemistry, Geophysics, Geosystems. + [2] Wang, B., Wang, L., Xie, Z.; Accurate calculation of spherical and vector spherical harmonic expansions via spectral element grids; Adv Comput Math. + """ + + def __init__(self, nlat, nlon, lmax=None, mmax=None, grid="lobatto", norm="ortho", csphase=True): + """ + Initializes the SHT Layer, precomputing the necessary quadrature weights + + Parameters: + nlat: input grid resolution in the latitudinal direction + nlon: input grid resolution in the longitudinal direction + grid: grid in the latitude direction (for now only tensor product grids are supported) + """ + + super().__init__() + + self.nlat = nlat + self.nlon = nlon + self.grid = grid + self.norm = norm + self.csphase = csphase + + # TODO: include assertions regarding the dimensions + + # compute quadrature points + if self.grid == "legendre-gauss": + cost, w = legendre_gauss_weights(nlat, -1, 1) + self.lmax = lmax or self.nlat + elif self.grid == "lobatto": + cost, w = lobatto_weights(nlat, -1, 1) + self.lmax = lmax or self.nlat-1 + elif self.grid == "equiangular": + cost, w = clenshaw_curtiss_weights(nlat, -1, 1) + # cost, w = fejer2_weights(nlat, -1, 1) + self.lmax = lmax or self.nlat + elif self.grid == "healpix": + raise(NotImplementedError("'healpix' grid not supported by InverseRealVectorSHT")) + else: + raise(ValueError("Unknown quadrature mode")) + + # apply cosine transform and flip them + tq = torch.flip(torch.arccos(cost), dims=(0,)) + + # determine the dimensions + self.mmax = mmax or self.nlon // 2 + 1 + + # combine quadrature weights with the legendre weights + pct = torch.as_tensor(_precompute_legpoly(self.mmax, self.lmax, tq, norm=self.norm, csphase=self.csphase)) + weights = torch.einsum('mlk,k->mlk', pct, w) + + # remember quadrature weights + self.weights = weights.float().to(get_device()) + + def extra_repr(self): + """ + Pretty print module + """ + return f'nlat={self.nlat}, nlon={self.nlon},\n lmax={self.lmax}, mmax={self.mmax},\n grid={self.grid}, csphase={self.csphase}' + + def forward(self, x: torch.Tensor): + + assert(x.shape[-2] == self.nlat) + assert(x.shape[-1] == self.nlon) + with torch.autocast("cuda", enabled=False): + # rfft and view_as_complex don't support BF16, see https://github.com/pytorch/pytorch/issues/117844 + x = x.float() + + # apply real fft in the longitudinal direction + x = 2.0 * torch.pi * torch.fft.rfft(x, dim=-1, norm="forward") + + # do the Legendre-Gauss quadrature + x = torch.view_as_real(x) + + # distributed contraction: fork + out_shape = list(x.size()) + out_shape[-3] = self.lmax + out_shape[-2] = self.mmax + xout = torch.zeros(out_shape, dtype=x.dtype, device=x.device) + + # contraction + weights = self.weights.to(x.device).to(x.dtype) + xout[..., 0] = torch.einsum('...km,mlk->...lm', x[..., :self.mmax, 0], weights) + xout[..., 1] = torch.einsum('...km,mlk->...lm', x[..., :self.mmax, 1], weights) + x = torch.view_as_complex(xout) + + return x + +class InverseRealSHT(nn.Module): + """ + Defines a module for computing the inverse (real-valued) SHT. + Precomputes Legendre Gauss nodes, weights and associated Legendre polynomials on these nodes. + nlat, nlon: Output dimensions + lmax, mmax: Input dimensions (spherical coefficients). For convenience, these are inferred from the output dimensions + + [1] Schaeffer, N. Efficient spherical harmonic transforms aimed at pseudospectral numerical simulations, G3: Geochemistry, Geophysics, Geosystems. + [2] Wang, B., Wang, L., Xie, Z.; Accurate calculation of spherical and vector spherical harmonic expansions via spectral element grids; Adv Comput Math. + """ + + def __init__(self, nlat, nlon, lmax=None, mmax=None, grid="lobatto", norm="ortho", csphase=True): + + super().__init__() + + self.nlat = nlat + self.nlon = nlon + self.grid = grid + self.norm = norm + self.csphase = csphase + + # compute quadrature points + if self.grid == "legendre-gauss": + cost, _ = legendre_gauss_weights(nlat, -1, 1) + self.lmax = lmax or self.nlat + elif self.grid == "lobatto": + cost, _ = lobatto_weights(nlat, -1, 1) + self.lmax = lmax or self.nlat-1 + elif self.grid == "equiangular": + cost, _ = clenshaw_curtiss_weights(nlat, -1, 1) + self.lmax = lmax or self.nlat + elif self.grid == "healpix": + raise(NotImplementedError("'healpix' grid not supported by RealVectorSHT")) + else: + raise(ValueError("Unknown quadrature mode")) + + # apply cosine transform and flip them + t = torch.flip(torch.arccos(cost), dims=(0,)) + + # determine the dimensions + self.mmax = mmax or self.nlon // 2 + 1 + + pct = torch.as_tensor(_precompute_legpoly(self.mmax, self.lmax, t, norm=self.norm, inverse=True, csphase=self.csphase)) + + # register buffer + self.pct = pct.float().to(get_device()) + + def extra_repr(self): + """ + Pretty print module + """ + return f'nlat={self.nlat}, nlon={self.nlon},\n lmax={self.lmax}, mmax={self.mmax},\n grid={self.grid}, csphase={self.csphase}' + + def forward(self, x: torch.Tensor): + + assert(x.shape[-2] == self.lmax) + assert(x.shape[-1] == self.mmax) + + with torch.autocast("cuda", enabled=False): + # irfft and view_as_complex don't support BF16, see https://github.com/pytorch/pytorch/issues/117844 + # Evaluate associated Legendre functions on the output nodes + x = torch.view_as_real(x).float() + + pct = self.pct.to(x.device).to(x.dtype) + rl = torch.einsum('...lm, mlk->...km', x[..., 0], pct ) + im = torch.einsum('...lm, mlk->...km', x[..., 1], pct ) + xs = torch.stack((rl, im), -1) + + # apply the inverse (real) FFT + x = torch.view_as_complex(xs) + x = torch.fft.irfft(x, n=self.nlon, dim=-1, norm="forward") + + return x diff --git a/fme/core/models/conditional_sfno/test_sfnonet.py b/fme/core/models/conditional_sfno/test_sfnonet.py index 3230d7c87..fb9f6dc45 100644 --- a/fme/core/models/conditional_sfno/test_sfnonet.py +++ b/fme/core/models/conditional_sfno/test_sfnonet.py @@ -1,3 +1,4 @@ +import dataclasses import os from types import SimpleNamespace @@ -9,7 +10,9 @@ from fme.core.testing.regression import validate_tensor from .layers import Context, ContextConfig -from .sfnonet import get_lat_lon_sfnonet +from .sfnonet import FourierNeuralOperatorBlock, get_lat_lon_sfnonet +from .sht import InverseRealSHT, RealSHT +from .timer import CUDATimer DIR = os.path.abspath(os.path.dirname(__file__)) @@ -221,3 +224,130 @@ def forward(self, x): assert not torch.isnan(output).any() else: assert torch.isnan(output).any() + + +@dataclasses.dataclass +class BenchmarkResult: + ms_total: float + ms_per: float + max_alloc: int + max_reserved: int + y_shape: tuple + y_dtype: torch.dtype + + +def benchmark(fn, iters=10, warmup=1) -> BenchmarkResult: + for _ in range(warmup): + fn() + torch.cuda.synchronize() + + torch.cuda.reset_peak_memory_stats() + starter = torch.cuda.Event(enable_timing=True) + ender = torch.cuda.Event(enable_timing=True) + + starter.record() + for _ in range(iters): + y = fn() + ender.record() + torch.cuda.synchronize() + + ms = starter.elapsed_time(ender) + return BenchmarkResult( + ms_total=ms, + ms_per=ms / iters, + max_alloc=torch.cuda.max_memory_allocated(), + max_reserved=torch.cuda.max_memory_reserved(), + y_shape=tuple(y.shape), + y_dtype=y.dtype, + ) + + +@pytest.mark.skipif( + get_device().type != "cuda", + reason=( + "This test is only relevant for CUDA since " + "it's testing speed of SFNO blocks on GPU." + ), +) # noqa: E501 +def test_block_speed(): + B = 2 + C = 512 + H = 180 + L = 360 + G = 8 + device = get_device() + conditional_embed_dim_scalar = 0 + conditional_embed_dim_noise = 64 + conditional_embed_dim_labels = 3 + conditional_embed_dim_pos = 32 + embedding_scalar = None + context_embedding_noise = torch.randn(B, conditional_embed_dim_noise, H, L).to( + device + ) + context_embedding_labels = torch.randn(B, conditional_embed_dim_labels).to(device) + context_embedding_pos = torch.randn(B, conditional_embed_dim_pos, H, L).to(device) + context = Context( + embedding_scalar=embedding_scalar, + embedding_pos=context_embedding_pos, + noise=context_embedding_noise, + labels=context_embedding_labels, + ) + x = torch.randn(B, C, H, L, device=get_device()) + forward = RealSHT(nlat=H, nlon=L) + inverse = InverseRealSHT(nlat=H, nlon=L) + context_config = ContextConfig( + embed_dim_scalar=conditional_embed_dim_scalar, + embed_dim_noise=conditional_embed_dim_noise, + embed_dim_labels=conditional_embed_dim_labels, + embed_dim_pos=conditional_embed_dim_pos, + ) + block = FourierNeuralOperatorBlock( + forward_transform=forward, + inverse_transform=inverse, + embed_dim=C, + img_shape=(H, L), + filter_type="linear", + operator_type="dhconv", + use_mlp=True, + context_config=context_config, + ).to(device) + timer = CUDATimer() + grouped_block = FourierNeuralOperatorBlock( + forward_transform=forward, + inverse_transform=inverse, + embed_dim=C, + img_shape=(H, L), + filter_type="linear", + operator_type="dhconv", + use_mlp=True, + context_config=context_config, + filter_num_groups=G, + ).to(device) + grouped_timer = CUDATimer() + + def call_block(): + return block(x, context, timer=timer) + + def call_grouped_block(): + return grouped_block(x, context, timer=grouped_timer) + + for _ in range(10): + block(x, context) + ungrouped = benchmark(call_block, warmup=0, iters=10) + for _ in range(10): + grouped_block(x, context) + grouped = benchmark(call_grouped_block, warmup=0, iters=10) + + print("ungrouped timers: ", timer.report()) + print("grouped timers: ", grouped_timer.report()) + + assert grouped.ms_per < 2 / G * ungrouped.ms_per, ( + "Expected grouped DHConv to be faster than ungrouped, but got " + f"{grouped.ms_per:.6f} ms for grouped and " + f"{ungrouped.ms_per:.6f} ms for ungrouped." + ) + assert grouped.max_alloc < ungrouped.max_alloc, ( + "Expected grouped DHConv to use less memory than ungrouped, but got " + f"{grouped.max_alloc/1024/1024:.2f} MB for grouped and " + f"{ungrouped.max_alloc/1024/1024:.2f} MB for ungrouped." + ) diff --git a/fme/core/models/conditional_sfno/timer.py b/fme/core/models/conditional_sfno/timer.py new file mode 100644 index 000000000..b71b0c278 --- /dev/null +++ b/fme/core/models/conditional_sfno/timer.py @@ -0,0 +1,40 @@ +import collections +import contextlib + +import torch + + +class CUDATimer: + def __init__(self): + self._starters = [] + self._enders = [] + self._names = [] + + @contextlib.contextmanager + def context(self, name: str): + starter = torch.cuda.Event(enable_timing=True) + ender = torch.cuda.Event(enable_timing=True) + self._starters.append(starter) + self._enders.append(ender) + self._names.append(name) + torch.cuda.synchronize() + stream = torch.cuda.current_stream() + starter.record(stream) + try: + yield + finally: + ender.record(stream) + torch.cuda.synchronize() + return + + def report(self): + torch.cuda.synchronize() + total_times: dict[str, float] = collections.defaultdict(float) + for starter, ender, name in zip(self._starters, self._enders, self._names): + total_times[name] += starter.elapsed_time(ender) + return total_times + + +class NullTimer: + def context(self, name: str) -> contextlib.nullcontext: + return contextlib.nullcontext() From e5c92ad846c1f16b02d136e51922cafb717f219a Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Fri, 6 Feb 2026 22:04:09 +0000 Subject: [PATCH 27/30] switch to [w, h, c] ordering for 10pct speedup --- fme/core/models/conditional_sfno/layers.py | 46 ++-- fme/core/models/conditional_sfno/lora.py | 244 ++++++++++-------- .../models/conditional_sfno/s2convolutions.py | 32 +-- fme/core/models/conditional_sfno/sfnonet.py | 22 +- fme/core/models/conditional_sfno/sht.py | 55 ++-- .../models/conditional_sfno/test_sfnonet.py | 16 +- fme/core/models/conditional_sfno/timer.py | 1 - 7 files changed, 236 insertions(+), 180 deletions(-) diff --git a/fme/core/models/conditional_sfno/layers.py b/fme/core/models/conditional_sfno/layers.py index 2fe7e71d7..6873c3e9b 100644 --- a/fme/core/models/conditional_sfno/layers.py +++ b/fme/core/models/conditional_sfno/layers.py @@ -164,35 +164,33 @@ def __init__( self.W_bias_labels = None if self.embed_dim_noise > 0: # no bias as it is already handled in the non-2d layers - self.W_scale_2d = nn.Conv2d( - self.embed_dim_noise, self.n_channels, kernel_size=1, bias=False + self.W_scale_2d = nn.Linear( + self.embed_dim_noise, self.n_channels, bias=False ) - self.W_bias_2d = nn.Conv2d( - self.embed_dim_noise, self.n_channels, kernel_size=1, bias=False + self.W_bias_2d = nn.Linear( + self.embed_dim_noise, self.n_channels, bias=False ) else: self.W_scale_2d = None self.W_bias_2d = None if self.embed_dim_pos > 0: # no bias as it is already handled in the non-2d layers - self.W_scale_pos = nn.Conv2d( - self.embed_dim_pos, self.n_channels, kernel_size=1, bias=False - ) - self.W_bias_pos = nn.Conv2d( - self.embed_dim_pos, self.n_channels, kernel_size=1, bias=False + self.W_scale_pos = nn.Linear( + self.embed_dim_pos, self.n_channels, bias=False ) + self.W_bias_pos = nn.Linear(self.embed_dim_pos, self.n_channels, bias=False) else: self.W_scale_pos = None self.W_bias_pos = None if global_layer_norm: self.norm = nn.LayerNorm( - (self.n_channels, img_shape[0], img_shape[1]), + (img_shape[1], img_shape[0], self.n_channels), eps=epsilon, elementwise_affine=elementwise_affine, ) else: - self.norm = ChannelLayerNorm( - self.n_channels, + self.norm = nn.LayerNorm( + (self.n_channels,), eps=epsilon, elementwise_affine=elementwise_affine, ) @@ -238,11 +236,11 @@ def forward( Args: x: The input tensor to normalize, of shape - (batch_size, channels, height, width). + (batch_size, width, height, channels). context: The context to condition on. Returns: - The normalized tensor, of shape (batch_size, channels, height, width). + The normalized tensor, of shape (batch_size, width, height, channels). """ if timer is None: timer = NullTimer() @@ -255,11 +253,13 @@ def forward( if context.embedding_scalar is None: raise ValueError("embedding_scalar must be provided") scale: torch.Tensor = ( - self.W_scale(context.embedding_scalar).unsqueeze(-1).unsqueeze(-1) + self.W_scale(context.embedding_scalar).unsqueeze(-2).unsqueeze(-2) ) else: scale = torch.ones( - list(x.shape[:-2]) + [1, 1], device=x.device, dtype=x.dtype + list(x.shape[:-3]) + [1, 1, x.shape[-1]], + device=x.device, + dtype=x.dtype, ) if self.W_scale_2d is not None: @@ -270,21 +270,23 @@ def forward( if context.embedding_scalar is None: raise ValueError("embedding_scalar must be provided") bias: torch.Tensor = ( - self.W_bias(context.embedding_scalar).unsqueeze(-1).unsqueeze(-1) + self.W_bias(context.embedding_scalar).unsqueeze(-2).unsqueeze(-2) ) else: bias = torch.zeros( - list(x.shape[:-2]) + [1, 1], device=x.device, dtype=x.dtype + list(x.shape[:-3]) + [1, 1, x.shape[-1]], + device=x.device, + dtype=x.dtype, ) if self.W_scale_labels is not None: scale = scale + self.W_scale_labels(context.labels).unsqueeze( - -1 - ).unsqueeze(-1) + -2 + ).unsqueeze(-2) if self.W_bias_labels is not None: bias = bias + self.W_bias_labels(context.labels).unsqueeze( - -1 - ).unsqueeze(-1) + -2 + ).unsqueeze(-2) if self.W_bias_2d is not None: if context.noise is None: raise ValueError("embedding_2d must be provided") diff --git a/fme/core/models/conditional_sfno/lora.py b/fme/core/models/conditional_sfno/lora.py index ea548ef32..952bab2a7 100644 --- a/fme/core/models/conditional_sfno/lora.py +++ b/fme/core/models/conditional_sfno/lora.py @@ -1,12 +1,10 @@ from __future__ import annotations -import math - import torch import torch.nn as nn -class LoRAConv2d(nn.Conv2d): +class LoRAConv2d(nn.Module): """ Drop-in Conv2d with optional LoRA. @@ -37,105 +35,149 @@ def __init__( lora_alpha: float | None = None, lora_dropout: float = 0.0, ) -> None: - factory_kwargs = {"device": device, "dtype": dtype} - self.lora_down: nn.Conv2d | None = None - self.lora_up: nn.Conv2d | None = None - super().__init__( - in_channels=in_channels, - out_channels=out_channels, - kernel_size=kernel_size, - stride=stride, - padding=padding, - dilation=dilation, - groups=groups, + super().__init__() + self._linear = nn.Linear( + in_channels, + out_channels, bias=bias, - padding_mode=padding_mode, - **factory_kwargs, + device=device, + dtype=dtype, ) - if lora_rank < 0: - raise ValueError(f"lora_rank must be >= 0, got {lora_rank}") - if lora_dropout < 0.0: - raise ValueError(f"lora_dropout must be >= 0, got {lora_dropout}") - - self.lora_rank = int(lora_rank) - self.lora_alpha = ( - float(lora_alpha) if lora_alpha is not None else float(lora_rank) - ) - self.lora_dropout_p = float(lora_dropout) - - self._lora_merged = False - - if self.lora_rank > 0: - # Group-compatible LoRA via two convs: - # down: 1x1 grouped conv: in_channels -> (groups * r), groups=groups - # up: kxk grouped conv: (groups * r) -> out_channels, groups=groups - # This produces a delta with the same grouped structure as the base conv. - mid_channels = self.groups * self.lora_rank - - self.lora_down = nn.Conv2d( - in_channels=self.in_channels, - out_channels=mid_channels, - kernel_size=1, - stride=1, - padding=0, - dilation=1, - groups=self.groups, - bias=False, - **factory_kwargs, - ) - self.lora_up = nn.Conv2d( - in_channels=mid_channels, - out_channels=self.out_channels, - kernel_size=self.kernel_size, - stride=self.stride, - padding=self.padding, - dilation=self.dilation, - groups=self.groups, - bias=False, - padding_mode=self.padding_mode, - **factory_kwargs, - ) - - self.lora_dropout = ( - nn.Dropout(p=self.lora_dropout_p) - if self.lora_dropout_p > 0 - else nn.Identity() - ) - - # Scaling as in LoRA: alpha / r - self.lora_scaling = self.lora_alpha / float(self.lora_rank) - else: - self.lora_dropout = nn.Identity() - self.lora_scaling = 0.0 - self.reset_lora_parameters() # base parameters already reset in super init - - def reset_parameters(self) -> None: - super().reset_parameters() - self.reset_lora_parameters() - - def reset_lora_parameters(self): - # Init: down ~ Kaiming, up = 0 so the module starts - # identical to base Conv2d. - if self.lora_down is not None: - nn.init.kaiming_uniform_(self.lora_down.weight, a=math.sqrt(5)) - if self.lora_up is not None: - nn.init.zeros_(self.lora_up.weight) - - def extra_repr(self) -> str: - base = super().extra_repr() - if self.lora_rank > 0: - return ( - f"{base}, lora_rank={self.lora_rank}, lora_alpha={self.lora_alpha}, " - f"lora_dropout={self.lora_dropout_p}, lora_merged={self._lora_merged}" - ) - return f"{base}, lora_rank=0" - def forward(self, x: torch.Tensor) -> torch.Tensor: - y = super().forward(x) - if self.lora_rank == 0 or self._lora_merged: - return y - assert self.lora_down is not None and self.lora_up is not None - return ( - y + self.lora_up(self.lora_down(self.lora_dropout(x))) * self.lora_scaling - ) + return self._linear(x) + + +# class LoRAConv2d(nn.Conv2d): +# """ +# Drop-in Conv2d with optional LoRA. + +# - API matches torch.nn.Conv2d, with extra args: +# lora_rank: int = 0 (0 disables LoRA) +# lora_alpha: float = None (defaults to lora_rank) +# lora_dropout: float = 0.0 + +# - Can load a checkpoint saved from nn.Conv2d even when lora_rank > 0 +# (i.e., state_dict only has "weight"/"bias"). +# """ + +# def __init__( +# self, +# in_channels: int, +# out_channels: int, +# kernel_size: int | tuple[int, int], +# stride: int | tuple[int, int] = 1, +# padding: int | tuple[int, int] = 0, +# dilation: int | tuple[int, int] = 1, +# groups: int = 1, +# bias: bool = True, +# padding_mode: str = "zeros", +# device=None, +# dtype=None, +# *, +# lora_rank: int = 0, +# lora_alpha: float | None = None, +# lora_dropout: float = 0.0, +# ) -> None: +# factory_kwargs = {"device": device, "dtype": dtype} +# self.lora_down: nn.Conv2d | None = None +# self.lora_up: nn.Conv2d | None = None +# super().__init__( +# in_channels=in_channels, +# out_channels=out_channels, +# kernel_size=kernel_size, +# stride=stride, +# padding=padding, +# dilation=dilation, +# groups=groups, +# bias=bias, +# padding_mode=padding_mode, +# **factory_kwargs, +# ) + +# if lora_rank < 0: +# raise ValueError(f"lora_rank must be >= 0, got {lora_rank}") +# if lora_dropout < 0.0: +# raise ValueError(f"lora_dropout must be >= 0, got {lora_dropout}") + +# self.lora_rank = int(lora_rank) +# self.lora_alpha = ( +# float(lora_alpha) if lora_alpha is not None else float(lora_rank) +# ) +# self.lora_dropout_p = float(lora_dropout) + +# self._lora_merged = False + +# if self.lora_rank > 0: +# # Group-compatible LoRA via two convs: +# # down: 1x1 grouped conv: in_channels -> (groups * r), groups=groups +# # up: kxk grouped conv: (groups * r) -> out_channels, groups=groups +# # This produces a delta with the same grouped structure as the base conv. +# mid_channels = self.groups * self.lora_rank + +# self.lora_down = nn.Conv2d( +# in_channels=self.in_channels, +# out_channels=mid_channels, +# kernel_size=1, +# stride=1, +# padding=0, +# dilation=1, +# groups=self.groups, +# bias=False, +# **factory_kwargs, +# ) +# self.lora_up = nn.Conv2d( +# in_channels=mid_channels, +# out_channels=self.out_channels, +# kernel_size=self.kernel_size, +# stride=self.stride, +# padding=self.padding, +# dilation=self.dilation, +# groups=self.groups, +# bias=False, +# padding_mode=self.padding_mode, +# **factory_kwargs, +# ) + +# self.lora_dropout = ( +# nn.Dropout(p=self.lora_dropout_p) +# if self.lora_dropout_p > 0 +# else nn.Identity() +# ) + +# # Scaling as in LoRA: alpha / r +# self.lora_scaling = self.lora_alpha / float(self.lora_rank) +# else: +# self.lora_dropout = nn.Identity() +# self.lora_scaling = 0.0 +# self.reset_lora_parameters() # base parameters already reset in super init + +# def reset_parameters(self) -> None: +# super().reset_parameters() +# self.reset_lora_parameters() + +# def reset_lora_parameters(self): +# # Init: down ~ Kaiming, up = 0 so the module starts +# # identical to base Conv2d. +# if self.lora_down is not None: +# nn.init.kaiming_uniform_(self.lora_down.weight, a=math.sqrt(5)) +# if self.lora_up is not None: +# nn.init.zeros_(self.lora_up.weight) + +# def extra_repr(self) -> str: +# base = super().extra_repr() +# if self.lora_rank > 0: +# return ( +# f"{base}, lora_rank={self.lora_rank}, lora_alpha={self.lora_alpha}, " +# f"lora_dropout={self.lora_dropout_p}, lora_merged={self._lora_merged}" +# ) +# return f"{base}, lora_rank=0" + +# def forward(self, x: torch.Tensor) -> torch.Tensor: +# y = super().forward(x) +# if self.lora_rank == 0 or self._lora_merged: +# return y +# assert self.lora_down is not None and self.lora_up is not None +# return ( +# y + self.lora_up(self.lora_down(self.lora_dropout(x))) * self.lora_scaling +# ) diff --git a/fme/core/models/conditional_sfno/s2convolutions.py b/fme/core/models/conditional_sfno/s2convolutions.py index f0e6c204b..3ff909d90 100644 --- a/fme/core/models/conditional_sfno/s2convolutions.py +++ b/fme/core/models/conditional_sfno/s2convolutions.py @@ -60,7 +60,6 @@ def _contract_lora( return torch.einsum("girx,grox,bgixy->bgoxy", lora_A, lora_B, x) -@torch.jit.script def _contract_dhconv( xc: torch.Tensor, weight: torch.Tensor ) -> torch.Tensor: # pragma: no cover @@ -73,7 +72,7 @@ def _contract_dhconv( weight: Weight tensor of shape (group, in_channels, out_channels, nlat, 2) """ wc = torch.view_as_complex(weight) - return torch.einsum("bgixy,giox->bgoxy", xc, wc) + return torch.einsum("byxgi,gxoi->byxgo", xc, wc) class SpectralConvS2(nn.Module): @@ -174,15 +173,17 @@ def __init__( self.mpad = 0 if scale == "auto": - scale = math.sqrt(1 / (in_channels)) * torch.ones(self.modes_lat_local, 2) + scale = math.sqrt(1 / (in_channels)) * torch.ones( + self.modes_lat_local, 1, 1, 2 + ) # seemingly the first weight is not really complex, so we need to account for that - scale[0, :] *= math.sqrt(2.0) + scale[0, :, :, :] *= math.sqrt(2.0) weight_shape = [ num_groups, - in_channels // num_groups, - out_channels // num_groups, self.modes_lat_local, + out_channels // num_groups, + in_channels // num_groups, ] assert factorization == "ComplexDense" @@ -217,7 +218,7 @@ def __init__( self.lora_scaling = 0.0 if bias: - self.bias = nn.Parameter(torch.zeros(1, out_channels, 1, 1)) + self.bias = nn.Parameter(torch.zeros(1, 1, 1, out_channels)) self.out_channels = out_channels def forward( @@ -231,41 +232,40 @@ def forward( with torch.amp.autocast("cuda", enabled=False): with timer.context("forward_transform"): - x = self.forward_transform(x.float()) + x = self.forward_transform(x.float(), timer=timer) if self._round_trip_residual: with timer.context("round_trip_residual"): x = x.contiguous() residual = self.inverse_transform(x) residual = residual.to(dtype) - B, C, H, W = x.shape + B, W, H, C = x.shape assert C % self.num_groups == 0 - with timer.context("group_reshape"): - x = x.reshape(B, self.num_groups, C // self.num_groups, H, W) + x = x.reshape(B, W, H, self.num_groups, C // self.num_groups) if self.lora_A is not None and self.lora_B is not None: with timer.context("lora_update"): lora_update = _contract_lora( self.lora_A, self.lora_B, - x[..., : self.modes_lat_local, : self.modes_lon_local], + x, ) else: lora_update = 0.0 with timer.context("dhconv"): xp = torch.zeros_like(x) - xp[..., : self.modes_lat_local, : self.modes_lon_local] = _contract_dhconv( - x[..., : self.modes_lat_local, : self.modes_lon_local], + xp[:] = _contract_dhconv( + x, self.weight, ) xp = xp + self.lora_scaling * lora_update - xp = xp.reshape(B, self.out_channels, H, W) + xp = xp.reshape(B, W, H, self.out_channels) x = xp.contiguous() with torch.amp.autocast("cuda", enabled=False): with timer.context("inverse_transform"): - x = self.inverse_transform(x) + x = self.inverse_transform(x, timer=timer) if hasattr(self, "bias"): with timer.context("add_bias"): diff --git a/fme/core/models/conditional_sfno/sfnonet.py b/fme/core/models/conditional_sfno/sfnonet.py index e3df29d18..16fac3b45 100644 --- a/fme/core/models/conditional_sfno/sfnonet.py +++ b/fme/core/models/conditional_sfno/sfnonet.py @@ -301,13 +301,10 @@ def forward(self, x, context_embedding, timer: CUDATimer | NullTimer | None = No if timer is None: timer = NullTimer() with timer.context("norm0"): - x_norm = torch.zeros_like(x) - x_norm[..., : self.input_shape_loc[0], : self.input_shape_loc[1]] = ( - self.norm0( - x[..., : self.input_shape_loc[0], : self.input_shape_loc[1]], - context_embedding, - timer=timer, - ) + x_norm = self.norm0( + x, + context_embedding, + timer=timer, ) with timer.context("filter"): x, residual = self.filter(x_norm, timer=timer) @@ -325,13 +322,10 @@ def forward(self, x, context_embedding, timer: CUDATimer | NullTimer | None = No x = self.act_layer(x) with timer.context("norm1"): - x_norm = torch.zeros_like(x) - x_norm[..., : self.output_shape_loc[0], : self.output_shape_loc[1]] = ( - self.norm1( - x[..., : self.output_shape_loc[0], : self.output_shape_loc[1]], - context_embedding, - timer=timer, - ) + x_norm = self.norm1( + x, + context_embedding, + timer=timer, ) x = x_norm diff --git a/fme/core/models/conditional_sfno/sht.py b/fme/core/models/conditional_sfno/sht.py index c2d5bee2a..e79ba35a5 100644 --- a/fme/core/models/conditional_sfno/sht.py +++ b/fme/core/models/conditional_sfno/sht.py @@ -50,6 +50,7 @@ from torch_harmonics.legendre import _precompute_legpoly from fme.core.device import get_device +from fme.core.models.conditional_sfno.timer import CUDATimer, NullTimer class RealSHT(nn.Module): @@ -106,7 +107,7 @@ def __init__(self, nlat, nlon, lmax=None, mmax=None, grid="lobatto", norm="ortho # combine quadrature weights with the legendre weights pct = torch.as_tensor(_precompute_legpoly(self.mmax, self.lmax, tq, norm=self.norm, csphase=self.csphase)) - weights = torch.einsum('mlk,k->mlk', pct, w) + weights = torch.einsum('mlk,k->mlk', pct, w).contiguous() # remember quadrature weights self.weights = weights.float().to(get_device()) @@ -117,30 +118,38 @@ 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}' - def forward(self, x: torch.Tensor): - + def forward(self, x: torch.Tensor, timer: CUDATimer | NullTimer = NullTimer()): + # last dims [w, h, c] assert(x.shape[-2] == self.nlat) - assert(x.shape[-1] == self.nlon) + assert(x.shape[-3] == self.nlon) with torch.autocast("cuda", enabled=False): # rfft and view_as_complex don't support BF16, see https://github.com/pytorch/pytorch/issues/117844 - x = x.float() + if x.dtype != torch.float32: + with timer.context("forward_transform_cast_input"): + x = x.float() # apply real fft in the longitudinal direction - x = 2.0 * torch.pi * torch.fft.rfft(x, dim=-1, norm="forward") - + with timer.context("forward_transform_rfft"): + x = 2.0 * torch.pi * torch.fft.rfft(x, dim=-3, norm="forward") # do the Legendre-Gauss quadrature x = torch.view_as_real(x) # distributed contraction: fork out_shape = list(x.size()) out_shape[-3] = self.lmax - out_shape[-2] = self.mmax - xout = torch.zeros(out_shape, dtype=x.dtype, device=x.device) - + out_shape[-4] = self.mmax # contraction weights = self.weights.to(x.device).to(x.dtype) - xout[..., 0] = torch.einsum('...km,mlk->...lm', x[..., :self.mmax, 0], weights) - xout[..., 1] = torch.einsum('...km,mlk->...lm', x[..., :self.mmax, 1], weights) + with timer.context("forward_transform_quadrature"): + # xout = torch.zeros(out_shape, dtype=x.dtype, device=x.device) + # xout[..., 0] = torch.einsum('...mkc,mlk->...mlc', x[..., :self.mmax, :, :, 0], weights) + # xout[..., 1] = torch.einsum('...mkc,mlk->...mlc', x[..., :self.mmax, :, :, 1], weights) + # rl = torch.einsum('...mlc, mlk->...mkc', x[..., 0], pct ) + # im = torch.einsum('...mlc, mlk->...mkc', x[..., 1], pct ) + # xs = torch.stack((rl, im), -1) + rl = torch.einsum('...mkc, mlk->...mlc', x[..., :self.mmax, :, :, 0], weights) + im = torch.einsum('...mkc, mlk->...mlc', x[..., :self.mmax, :, :, 1], weights) + xout = torch.stack((rl, im), -1) x = torch.view_as_complex(xout) return x @@ -198,10 +207,10 @@ 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}' - def forward(self, x: torch.Tensor): + def forward(self, x: torch.Tensor, timer: CUDATimer | NullTimer = NullTimer()): assert(x.shape[-2] == self.lmax) - assert(x.shape[-1] == self.mmax) + assert(x.shape[-3] == self.mmax) with torch.autocast("cuda", enabled=False): # irfft and view_as_complex don't support BF16, see https://github.com/pytorch/pytorch/issues/117844 @@ -209,12 +218,16 @@ def forward(self, x: torch.Tensor): x = torch.view_as_real(x).float() pct = self.pct.to(x.device).to(x.dtype) - rl = torch.einsum('...lm, mlk->...km', x[..., 0], pct ) - im = torch.einsum('...lm, mlk->...km', x[..., 1], pct ) - xs = torch.stack((rl, im), -1) - - # apply the inverse (real) FFT - x = torch.view_as_complex(xs) - x = torch.fft.irfft(x, n=self.nlon, dim=-1, norm="forward") + with timer.context("inverse_transform_xout_allocation"): + xs = torch.zeros(x.shape[:-4] + (self.nlon, self.nlat, 2), dtype=x.dtype, device=x.device) + with timer.context("inverse_transform_quadrature"): + rl = torch.einsum('...mlc, mlk->...mkc', x[..., 0], pct ) + im = torch.einsum('...mlc, mlk->...mkc', x[..., 1], pct ) + xs = torch.stack((rl, im), -1) + + with timer.context("inverse_transform_irfft"): + # apply the inverse (real) FFT + x = torch.view_as_complex(xs) + x = torch.fft.irfft(x, n=self.nlon, dim=-3, norm="forward") return x diff --git a/fme/core/models/conditional_sfno/test_sfnonet.py b/fme/core/models/conditional_sfno/test_sfnonet.py index fb9f6dc45..6a8881e37 100644 --- a/fme/core/models/conditional_sfno/test_sfnonet.py +++ b/fme/core/models/conditional_sfno/test_sfnonet.py @@ -281,18 +281,18 @@ def test_block_speed(): conditional_embed_dim_labels = 3 conditional_embed_dim_pos = 32 embedding_scalar = None - context_embedding_noise = torch.randn(B, conditional_embed_dim_noise, H, L).to( + context_embedding_noise = torch.randn(B, L, H, conditional_embed_dim_noise).to( device ) context_embedding_labels = torch.randn(B, conditional_embed_dim_labels).to(device) - context_embedding_pos = torch.randn(B, conditional_embed_dim_pos, H, L).to(device) + context_embedding_pos = torch.randn(B, L, H, conditional_embed_dim_pos).to(device) context = Context( embedding_scalar=embedding_scalar, embedding_pos=context_embedding_pos, noise=context_embedding_noise, labels=context_embedding_labels, ) - x = torch.randn(B, C, H, L, device=get_device()) + x = torch.randn(B, L, H, C, device=get_device()) forward = RealSHT(nlat=H, nlon=L) inverse = InverseRealSHT(nlat=H, nlon=L) context_config = ContextConfig( @@ -338,8 +338,14 @@ def call_grouped_block(): grouped_block(x, context) grouped = benchmark(call_grouped_block, warmup=0, iters=10) - print("ungrouped timers: ", timer.report()) - print("grouped timers: ", grouped_timer.report()) + print( + "ungrouped timers: " + + " | ".join(f"{k}: {v:.2f} ms" for k, v in timer.report().items()) + ) + print( + "grouped timers: " + + " | ".join(f"{k}: {v:.2f} ms" for k, v in grouped_timer.report().items()) + ) assert grouped.ms_per < 2 / G * ungrouped.ms_per, ( "Expected grouped DHConv to be faster than ungrouped, but got " diff --git a/fme/core/models/conditional_sfno/timer.py b/fme/core/models/conditional_sfno/timer.py index b71b0c278..627bf76b2 100644 --- a/fme/core/models/conditional_sfno/timer.py +++ b/fme/core/models/conditional_sfno/timer.py @@ -24,7 +24,6 @@ def context(self, name: str): yield finally: ender.record(stream) - torch.cuda.synchronize() return def report(self): From 7cd13ecafdd54f7d0add026c51870575772e0bda Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Fri, 6 Feb 2026 22:46:16 +0000 Subject: [PATCH 28/30] clean up commented code, give avg times --- fme/core/models/conditional_sfno/sht.py | 17 +++-------------- fme/core/models/conditional_sfno/timer.py | 5 ++++- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/fme/core/models/conditional_sfno/sht.py b/fme/core/models/conditional_sfno/sht.py index e79ba35a5..a6a9456d7 100644 --- a/fme/core/models/conditional_sfno/sht.py +++ b/fme/core/models/conditional_sfno/sht.py @@ -134,26 +134,17 @@ def forward(self, x: torch.Tensor, timer: CUDATimer | NullTimer = NullTimer()): # do the Legendre-Gauss quadrature x = torch.view_as_real(x) - # distributed contraction: fork - out_shape = list(x.size()) - out_shape[-3] = self.lmax - out_shape[-4] = self.mmax # contraction weights = self.weights.to(x.device).to(x.dtype) with timer.context("forward_transform_quadrature"): - # xout = torch.zeros(out_shape, dtype=x.dtype, device=x.device) - # xout[..., 0] = torch.einsum('...mkc,mlk->...mlc', x[..., :self.mmax, :, :, 0], weights) - # xout[..., 1] = torch.einsum('...mkc,mlk->...mlc', x[..., :self.mmax, :, :, 1], weights) - # rl = torch.einsum('...mlc, mlk->...mkc', x[..., 0], pct ) - # im = torch.einsum('...mlc, mlk->...mkc', x[..., 1], pct ) - # xs = torch.stack((rl, im), -1) - rl = torch.einsum('...mkc, mlk->...mlc', x[..., :self.mmax, :, :, 0], weights) - im = torch.einsum('...mkc, mlk->...mlc', x[..., :self.mmax, :, :, 1], weights) + rl = torch.einsum('...mkc, mlk->...mlc', x[..., 0], weights) + im = torch.einsum('...mkc, mlk->...mlc', x[..., 1], weights) xout = torch.stack((rl, im), -1) x = torch.view_as_complex(xout) return x + class InverseRealSHT(nn.Module): """ Defines a module for computing the inverse (real-valued) SHT. @@ -218,8 +209,6 @@ def forward(self, x: torch.Tensor, timer: CUDATimer | NullTimer = NullTimer()): x = torch.view_as_real(x).float() pct = self.pct.to(x.device).to(x.dtype) - with timer.context("inverse_transform_xout_allocation"): - xs = torch.zeros(x.shape[:-4] + (self.nlon, self.nlat, 2), dtype=x.dtype, device=x.device) with timer.context("inverse_transform_quadrature"): rl = torch.einsum('...mlc, mlk->...mkc', x[..., 0], pct ) im = torch.einsum('...mlc, mlk->...mkc', x[..., 1], pct ) diff --git a/fme/core/models/conditional_sfno/timer.py b/fme/core/models/conditional_sfno/timer.py index 627bf76b2..9393b63af 100644 --- a/fme/core/models/conditional_sfno/timer.py +++ b/fme/core/models/conditional_sfno/timer.py @@ -29,9 +29,12 @@ def context(self, name: str): def report(self): torch.cuda.synchronize() total_times: dict[str, float] = collections.defaultdict(float) + counts: dict[str, int] = collections.defaultdict(int) for starter, ender, name in zip(self._starters, self._enders, self._names): total_times[name] += starter.elapsed_time(ender) - return total_times + counts[name] += 1 + avg_times = {name: total / counts[name] for name, total in total_times.items()} + return avg_times class NullTimer: From 1d3f385b427f209853f79d4b3e59c9dd54210399 Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Fri, 6 Feb 2026 23:01:22 +0000 Subject: [PATCH 29/30] make timings make sense with contiguous casts --- fme/core/models/conditional_sfno/s2convolutions.py | 7 +++---- fme/core/models/conditional_sfno/timer.py | 1 - 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/fme/core/models/conditional_sfno/s2convolutions.py b/fme/core/models/conditional_sfno/s2convolutions.py index 3ff909d90..fc9f786a1 100644 --- a/fme/core/models/conditional_sfno/s2convolutions.py +++ b/fme/core/models/conditional_sfno/s2convolutions.py @@ -232,11 +232,10 @@ def forward( with torch.amp.autocast("cuda", enabled=False): with timer.context("forward_transform"): - x = self.forward_transform(x.float(), timer=timer) + x = self.forward_transform(x.float(), timer=timer).contiguous() if self._round_trip_residual: with timer.context("round_trip_residual"): - x = x.contiguous() - residual = self.inverse_transform(x) + residual = self.inverse_transform(x).contiguous() residual = residual.to(dtype) B, W, H, C = x.shape @@ -265,7 +264,7 @@ def forward( with torch.amp.autocast("cuda", enabled=False): with timer.context("inverse_transform"): - x = self.inverse_transform(x, timer=timer) + x = self.inverse_transform(x, timer=timer).contiguous() if hasattr(self, "bias"): with timer.context("add_bias"): diff --git a/fme/core/models/conditional_sfno/timer.py b/fme/core/models/conditional_sfno/timer.py index 9393b63af..43b01d5b2 100644 --- a/fme/core/models/conditional_sfno/timer.py +++ b/fme/core/models/conditional_sfno/timer.py @@ -17,7 +17,6 @@ def context(self, name: str): self._starters.append(starter) self._enders.append(ender) self._names.append(name) - torch.cuda.synchronize() stream = torch.cuda.current_stream() starter.record(stream) try: From 6d8f983f07b63c9fa477c83e8494feea717a22ef Mon Sep 17 00:00:00 2001 From: Jeremy McGibbon Date: Fri, 6 Feb 2026 23:06:56 +0000 Subject: [PATCH 30/30] save a bit more with contiguous, clearer times --- fme/core/models/conditional_sfno/sht.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fme/core/models/conditional_sfno/sht.py b/fme/core/models/conditional_sfno/sht.py index a6a9456d7..c121811da 100644 --- a/fme/core/models/conditional_sfno/sht.py +++ b/fme/core/models/conditional_sfno/sht.py @@ -131,6 +131,7 @@ def forward(self, x: torch.Tensor, timer: CUDATimer | NullTimer = NullTimer()): # apply real fft in the longitudinal direction with timer.context("forward_transform_rfft"): x = 2.0 * torch.pi * torch.fft.rfft(x, dim=-3, norm="forward") + x = x.contiguous() # do the Legendre-Gauss quadrature x = torch.view_as_real(x)