# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES.
# SPDX-FileCopyrightText: All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# 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.
"""GALE (Geometry-Aware Latent Embeddings) attention layer and transformer block.
This module provides the GALE attention mechanism and GALEBlock transformer block,
which extend the Transolver physics attention with cross-attention capabilities for
geometry and global context embeddings.
"""
from __future__ import annotations
import torch
import torch.nn as nn
import torch.nn.functional as F
from einops import rearrange
from jaxtyping import Float
from torch.distributed.tensor.placement_types import Replicate
import physicsnemo # noqa: F401 for docs
from physicsnemo.core.version_check import OptionalImport
from .concrete_dropout import ConcreteDropout
from .flare_attention import _flare_self_attention, _flare_self_attention_te
from .mlp_layers import Mlp
from .physics_attention import (
PhysicsAttentionIrregularMesh,
PhysicsAttentionStructuredMesh2D,
PhysicsAttentionStructuredMesh3D,
_project_input,
)
te = OptionalImport("transformer_engine.pytorch")
def _mix_self_and_cross(
self_attn: torch.Tensor,
cross_attn: torch.Tensor,
mode: str,
state_mixing: nn.Parameter | None = None,
concat_project: nn.Module | None = None,
) -> torch.Tensor:
r"""Blend self-attention and cross-attention outputs.
Parameters
----------
self_attn : torch.Tensor
Self-attention output.
cross_attn : torch.Tensor
Cross-attention output (same shape as ``self_attn``).
mode : str
``"weighted"`` for sigmoid-gated sum, ``"concat_project"`` for
concatenation followed by a learned projection.
state_mixing : nn.Parameter or None
Learnable scalar for ``"weighted"`` mode.
concat_project : nn.Module or None
Projection module for ``"concat_project"`` mode.
Returns
-------
torch.Tensor
Blended output, same shape as inputs.
"""
match mode:
case "weighted":
w = torch.sigmoid(state_mixing)
return w * self_attn + (1 - w) * cross_attn
case "concat_project":
return concat_project(torch.cat([self_attn, cross_attn], dim=-1))
case _:
raise ValueError(f"Invalid state_mixing_mode: {mode!r}")
def _gale_compute_slice_attention_cross(
module: nn.Module,
slice_tokens: list[Float[torch.Tensor, "batch heads slices dim"]],
context: Float[torch.Tensor, "batch heads context_slices context_dim"],
) -> list[Float[torch.Tensor, "batch heads slices dim"]]:
r"""Shared cross-attention between slice tokens and context.
Used by :class:`GALE` and :class:`_GALEStructuredForwardMixin` so the
cross-attention implementation lives in one place. Projects queries from
concatenated slice tokens, keys and values from context; runs Transformer
Engine or SDPA attention; splits the result back to one tensor per input.
Parameters
----------
module : nn.Module
Module with ``cross_q``, ``cross_k``, ``cross_v``, ``use_te``,
``heads``, ``dim_head``, and (if ``use_te``) ``attn_fn``.
slice_tokens : list[torch.Tensor]
One tensor per input, each of shape :math:`(B, H, S, D)`.
context : torch.Tensor
Context tensor of shape :math:`(B, H, S_c, D_c)`.
Returns
-------
list[torch.Tensor]
One cross-attention output per element of ``slice_tokens``, each
of shape :math:`(B, H, S, D)`.
"""
q_input = torch.cat(slice_tokens, dim=-2)
# Slice tokens and context are reductions over the (possibly sharded)
# token axis: distributed inputs arrive as unreduced Partial sums, and
# everything from here on (projection bias, softmax) is nonlinear in
# them. Resolve to Replicate before projecting. Duck-typed because nn
# cannot import domain_parallel.
if hasattr(q_input, "redistribute"):
q_input = q_input.redistribute(placements=[Replicate()])
if hasattr(context, "redistribute"):
context = context.redistribute(placements=[Replicate()])
q = module.cross_q(q_input)
k = module.cross_k(context)
v = module.cross_v(context)
if module.use_te:
q = rearrange(q, "b h s d -> b s h d")
k = rearrange(k, "b h s d -> b s h d")
v = rearrange(v, "b h s d -> b s h d")
cross_attention = module.attn_fn(q, k, v)
cross_attention = rearrange(
cross_attention,
"b s (h d) -> b h s d",
h=module.heads,
d=module.dim_head,
)
else:
cross_attention = torch.nn.functional.scaled_dot_product_attention(
q, k, v, is_causal=False
)
cross_attention = torch.split(cross_attention, slice_tokens[0].shape[-2], dim=-2)
return list(cross_attention)
def _gale_forward_impl(
module: nn.Module,
x: tuple[Float[torch.Tensor, "batch tokens channels"], ...],
context: Float[torch.Tensor, "batch heads context_slices context_dim"] | None,
) -> list[Float[torch.Tensor, "batch tokens channels"]]:
r"""Single implementation of the GALE forward pipeline.
Shared by :class:`GALE` and :class:`_GALEStructuredForwardMixin`. Steps:
validate inputs; project onto slices; compute slice weights and tokens;
apply self-attention on slices; optionally cross-attend to context and
mix with ``state_mixing``; project attention outputs back to token space.
Parameters
----------
module : nn.Module
GALE-like module with ``project_input_onto_slices``,
``in_project_slice``, ``_compute_slices_from_projections``,
``_compute_slice_attention_te``, ``_compute_slice_attention_sdpa``,
``compute_slice_attention_cross``, ``_project_attention_outputs``,
plus attributes ``use_te``, ``plus``, ``state_mixing_mode``, and
``state_mixing`` (if weighted) or ``concat_project`` (if concat).
x : tuple[torch.Tensor, ...]
Input tensors, each of shape :math:`(B, N, C)`; must be non-empty.
context : torch.Tensor or None
Optional context of shape :math:`(B, H, S_c, D_c)` for cross-attention.
If ``None``, only self-attention is applied.
Returns
-------
list[torch.Tensor]
One output tensor per input, each of shape :math:`(B, N, C)`.
Raises
------
ValueError
If ``x`` is empty or any element is not 3D.
"""
if not torch.compiler.is_compiling():
if len(x) == 0:
raise ValueError("Expected non-empty tuple of input tensors")
for i, tensor in enumerate(x):
if tensor.ndim != 3:
raise ValueError(
f"Expected 3D input tensor (B, N, C) at index {i}, "
f"got {tensor.ndim}D tensor with shape {tuple(tensor.shape)}"
)
if module.plus:
x_mid = [module.project_input_onto_slices(_x) for _x in x]
fx_mid = [_x_mid for _x_mid in x_mid]
else:
x_mid, fx_mid = zip(*[module.project_input_onto_slices(_x) for _x in x])
slice_projections = [module.in_project_slice(_x_mid) for _x_mid in x_mid]
slice_weights, slice_tokens = zip(
*[
module._compute_slices_from_projections(proj, _fx_mid)
for proj, _fx_mid in zip(slice_projections, fx_mid)
]
)
if module.use_te:
self_slice_token = [
module._compute_slice_attention_te(_slice_token)
for _slice_token in slice_tokens
]
else:
self_slice_token = [
module._compute_slice_attention_sdpa(_slice_token)
for _slice_token in slice_tokens
]
if context is not None:
cross_slice_token = [
module.compute_slice_attention_cross([_slice_token], context)[0]
for _slice_token in slice_tokens
]
out_slice_token = [
_mix_self_and_cross(
sst,
cst,
module.state_mixing_mode,
state_mixing=getattr(module, "state_mixing", None),
concat_project=getattr(module, "concat_project", None),
)
for sst, cst in zip(self_slice_token, cross_slice_token)
]
else:
# Use only self-attention when no context is provided
out_slice_token = self_slice_token
outputs = [
module._project_attention_outputs(ost, sw)
for ost, sw in zip(out_slice_token, slice_weights)
]
return outputs
class GALE(PhysicsAttentionIrregularMesh):
r"""Geometry-Aware Latent Embeddings (GALE) attention layer.
This is an extension of the Transolver PhysicsAttention mechanism to support
cross-attention with a context vector, built from geometry and global embeddings.
GALE combines self-attention on learned physical state slices with cross-attention
to geometry-aware context, using a learnable mixing weight to blend the two.
Parameters
----------
dim : int
Input dimension of the features.
heads : int, optional
Number of attention heads. Default is 8.
dim_head : int, optional
Dimension of each attention head. Default is 64.
dropout : float, optional
Dropout rate. Default is 0.0.
slice_num : int, optional
Number of learned physical state slices. Default is 64.
use_te : bool, optional
Whether to use Transformer Engine backend when available. Default is False.
plus : bool, optional
Whether to use Transolver++ features. Default is False.
context_dim : int, optional
Dimension of the context vector for cross-attention. Default is 0.
concrete_dropout : bool, optional
Whether to use ConcreteDropout instead of standard dropout. Default is False.
state_mixing_mode : str, optional
How to blend self-attention and cross-attention outputs. ``"weighted"`` uses
a learnable sigmoid-gated weighted sum. ``"concat_project"``
concatenates the two along the head dimension and projects back with a
linear layer. Default is ``"weighted"``.
Forward
-------
x : tuple[torch.Tensor, ...]
Tuple of input tensors, each of shape :math:`(B, N, C)` where :math:`B` is
batch size, :math:`N` is number of tokens, and :math:`C` is number of channels.
context : tuple[torch.Tensor, ...] | None, optional
Context tensor for cross-attention of shape :math:`(B, H, S_c, D_c)` where
:math:`H` is number of heads, :math:`S_c` is number of context slices, and
:math:`D_c` is context dimension. If ``None``, only self-attention is applied.
Default is ``None``.
Outputs
-------
list[torch.Tensor]
List of output tensors, each of shape :math:`(B, N, C)`, same shape as inputs.
Notes
-----
The mixing between self-attention and cross-attention is controlled by a learnable
parameter ``state_mixing`` which is passed through a sigmoid function to ensure
the mixing weight stays in :math:`[0, 1]`.
See Also
--------
:class:`physicsnemo.models.transolver.Physics_Attention.PhysicsAttentionIrregularMesh` : Base physics attention class.
:class:`GALEBlock` : Transformer block using GALE attention.
Examples
--------
>>> import torch
>>> gale = GALE(dim=256, heads=8, dim_head=32, context_dim=32, use_te=False)
>>> x = (torch.randn(2, 100, 256),) # Single input tensor in tuple
>>> context = torch.randn(2, 8, 64, 32) # Context for cross-attention
>>> outputs = gale(x, context)
>>> len(outputs)
1
>>> outputs[0].shape
torch.Size([2, 100, 256])
"""
def __init__(
self,
dim: int,
heads: int = 8,
dim_head: int = 64,
dropout: float = 0.0,
slice_num: int = 64,
use_te: bool = False,
plus: bool = False,
context_dim: int = 0,
concrete_dropout: bool = False,
state_mixing_mode: str = "weighted",
) -> None:
super().__init__(dim, heads, dim_head, dropout, slice_num, use_te, plus)
_gale_cross_init(self, dim_head, context_dim, use_te, state_mixing_mode)
# Replace inherited out_dropout with ConcreteDropout when enabled
if concrete_dropout:
self.out_dropout = ConcreteDropout(
in_features=dim,
init_p=max(dropout, 0.05),
)
def compute_slice_attention_cross(
self,
slice_tokens: list[Float[torch.Tensor, "batch heads slices dim"]],
context: Float[torch.Tensor, "batch heads context_slices context_dim"],
) -> list[Float[torch.Tensor, "batch heads slices dim"]]:
r"""Compute cross-attention between slice tokens and context.
Parameters
----------
slice_tokens : list[torch.Tensor]
List of slice token tensors, each of shape :math:`(B, H, S, D)` where
:math:`B` is batch size, :math:`H` is number of heads, :math:`S` is
number of slices, and :math:`D` is head dimension.
context : torch.Tensor
Context tensor of shape :math:`(B, H, S_c, D_c)` where :math:`S_c` is
number of context slices and :math:`D_c` is context dimension.
Returns
-------
list[torch.Tensor]
List of cross-attention outputs, each of shape :math:`(B, H, S, D)`.
"""
return _gale_compute_slice_attention_cross(self, slice_tokens, context)
def forward(
self,
x: tuple[Float[torch.Tensor, "batch tokens channels"], ...],
context: Float[torch.Tensor, "batch heads context_slices context_dim"]
| None = None,
) -> list[Float[torch.Tensor, "batch tokens channels"]]:
r"""Forward pass of the GALE module.
Applies physics-aware self-attention combined with optional cross-attention
to geometry and global context.
Parameters
----------
x : tuple[torch.Tensor, ...]
Tuple of input tensors, each of shape :math:`(B, N, C)` where :math:`B`
is batch size, :math:`N` is number of tokens, and :math:`C` is number
of channels.
context : torch.Tensor | None, optional
Context tensor for cross-attention of shape :math:`(B, H, S_c, D_c)`
where :math:`H` is number of heads, :math:`S_c` is number of context
slices, and :math:`D_c` is context dimension. If ``None``, only
self-attention is applied. Default is ``None``.
Returns
-------
list[torch.Tensor]
List of output tensors, each of shape :math:`(B, N, C)``, same shape
as inputs.
"""
return _gale_forward_impl(self, x, context)
def _gale_cross_init(
self: nn.Module,
dim_head: int,
context_dim: int,
use_te: bool,
state_mixing_mode: str = "weighted",
) -> None:
# Match GALE: TE linear only when TE is installed (GALEBlock already errors if use_te without TE)
linear_layer = te.Linear if (use_te and te.available) else nn.Linear
self.cross_q = linear_layer(dim_head, dim_head)
self.cross_k = linear_layer(context_dim, dim_head)
self.cross_v = linear_layer(context_dim, dim_head)
self.state_mixing_mode = state_mixing_mode
match state_mixing_mode:
case "weighted":
# Learnable mixing weight between self and cross attention
# Initialize near 0.0 since sigmoid(0) = 0.5, giving balanced initial mixing
self.state_mixing = nn.Parameter(torch.tensor(0.0))
case "concat_project":
# Concatenate self and cross attention and project back to dim_head
self.concat_project = nn.Sequential(
linear_layer(2 * dim_head, dim_head),
nn.GELU(),
)
case _:
raise ValueError(
f"Invalid state_mixing_mode: {state_mixing_mode!r}. "
f"Expected 'weighted' or 'concat_project'."
)
class _GALEStructuredForwardMixin:
"""Shared cross-attention and forward for structured GALE (2D/3D conv projection)."""
def compute_slice_attention_cross(
self,
slice_tokens: list[Float[torch.Tensor, "batch heads slices dim"]],
context: Float[torch.Tensor, "batch heads context_slices context_dim"],
) -> list[Float[torch.Tensor, "batch heads slices dim"]]:
return _gale_compute_slice_attention_cross(self, slice_tokens, context)
def forward(
self,
x: tuple[Float[torch.Tensor, "batch tokens channels"], ...],
context: Float[torch.Tensor, "batch heads context_slices context_dim"]
| None = None,
) -> list[Float[torch.Tensor, "batch tokens channels"]]:
return _gale_forward_impl(self, x, context)
class GALEStructuredMesh2D(
_GALEStructuredForwardMixin, PhysicsAttentionStructuredMesh2D
):
r"""GALE with Conv2d slice projection for 2D structured grids (see :class:`GALE`)."""
def __init__(
self,
dim: int,
spatial_shape: tuple[int, int],
heads: int = 8,
dim_head: int = 64,
dropout: float = 0.0,
slice_num: int = 64,
kernel: int = 3,
use_te: bool = False,
plus: bool = False,
context_dim: int = 0,
state_mixing_mode: str = "weighted",
) -> None:
super().__init__(
dim,
spatial_shape,
heads,
dim_head,
dropout,
slice_num,
kernel,
use_te,
plus,
)
_gale_cross_init(self, dim_head, context_dim, use_te, state_mixing_mode)
class GALEStructuredMesh3D(
_GALEStructuredForwardMixin, PhysicsAttentionStructuredMesh3D
):
r"""GALE with Conv3d slice projection for 3D structured grids (see :class:`GALE`)."""
def __init__(
self,
dim: int,
spatial_shape: tuple[int, int, int],
heads: int = 8,
dim_head: int = 64,
dropout: float = 0.0,
slice_num: int = 64,
kernel: int = 3,
use_te: bool = False,
plus: bool = False,
context_dim: int = 0,
state_mixing_mode: str = "weighted",
) -> None:
super().__init__(
dim,
spatial_shape,
heads,
dim_head,
dropout,
slice_num,
kernel,
use_te,
plus,
)
_gale_cross_init(self, dim_head, context_dim, use_te, state_mixing_mode)
[docs]
class GALE_FA(nn.Module):
r"""GALE_FA: Geometry-Aware Latent Embeddings with FLARE self-Attention attention layer.
Adopted:
- FLARE attention: Fast Low-rank Attention Routing Engine
paper: https://arxiv.org/abs/2508.12594
- GeoTransolver context:
paper: https://arxiv.org/abs/2512.20399
GALE_FA is an alternative to the GALE attention mechanism of the GeoTransolver.
It supports cross-attention with a context vector, built from geometry and global embeddings.
GALE_FA combines FLARE self-attention on learned physical state slices with cross-attention
to geometry-aware context, using a learnable mixing weight to blend the two.
Parameters
----------
dim : int
Input dimension of the features.
heads : int, optional
Number of attention heads. Default is 8.
dim_head : int, optional
Dimension of each attention head. Default is 64.
dropout : float, optional
Dropout rate. Default is 0.0.
n_global_queries : int, optional
Number of learned global queries. Default is 64.
use_te : bool, optional
Whether to use Transformer Engine backend when available. Default is False.
context_dim : int, optional
Dimension of the context vector for cross-attention. Default is 0.
concrete_dropout : bool, optional
Whether to use learned concrete dropout instead of standard dropout.
Default is ``False``.
state_mixing_mode : str, optional
How to blend self-attention and cross-attention outputs. ``"weighted"`` uses
a learnable sigmoid-gated weighted sum. ``"concat_project"``
concatenates the two along the head dimension and projects back with a
linear layer. Default is ``"weighted"``.
Forward
-------
x : tuple[torch.Tensor, ...]
Tuple of input tensors, each of shape :math:`(B, N, C)` where :math:`B` is
batch size, :math:`N` is number of tokens, and :math:`C` is number of channels.
context : tuple[torch.Tensor, ...] | None, optional
Context tensor for cross-attention of shape :math:`(B, H, S_c, D_c)` where
:math:`H` is number of heads, :math:`S_c` is number of context slices, and
:math:`D_c` is context dimension. If ``None``, only self-attention is applied.
Default is ``None``.
Outputs
-------
list[torch.Tensor]
List of output tensors, each of shape :math:`(B, N, C)`, same shape as inputs.
Notes
-----
The mixing between self-attention and cross-attention is controlled by a learnable
parameter ``state_mixing`` which is passed through a sigmoid function to ensure
the mixing weight stays in :math:`[0, 1]`.
See Also
--------
:class:`GALE` : Original GeoTransolver GALE attention class.
:class:`GALEBlock` : Transformer block that calls GALE or GALE_FA attention.
Examples
--------
>>> import torch
>>> gale_fa = GALE_FA(dim=256, heads=8, dim_head=32, context_dim=32)
>>> x = (torch.randn(2, 100, 256),) # Single input tensor in tuple
>>> context = torch.randn(2, 8, 64, 32) # Context for cross-attention
>>> outputs = gale_fa(x, context)
>>> len(outputs)
1
>>> outputs[0].shape
torch.Size([2, 100, 256])
"""
def __init__(
self,
dim,
heads: int = 8,
dim_head: int = 64,
dropout: float = 0.0,
n_global_queries: int = 64,
use_te: bool = False,
context_dim: int = 0,
concrete_dropout: bool = False,
state_mixing_mode: str = "weighted",
):
# With use_te, linear projections and attention run on Transformer
# Engine; otherwise on PyTorch. A missing TE install raises with an
# install hint on first use, so no extra guard is needed here.
super().__init__()
self.use_te = use_te
self.heads = heads
self.dim_head = dim_head
self.scale = 1.0
# It is recommended by the FLARE authors to use self.scale = 1 if self.dim_head <= 8 else (self.dim_head ** -0.5)
# but we use self.scale = 1.0 because the recommended scaling is not tested yet.
inner_dim = dim_head * heads
linear_layer = te.Linear if self.use_te else nn.Linear
# Global queries for FLARE self-attention
self.q_global = nn.Parameter(torch.randn(1, heads, n_global_queries, dim_head))
# Linear projections for self-attention
self.in_project_x = linear_layer(dim, inner_dim)
self.self_k = linear_layer(dim_head, dim_head)
self.self_v = linear_layer(dim_head, dim_head)
# FLARE's self-attention passes and the cross-attention all have
# differing q/kv lengths, so TE runs them as cross-attention (BSHD).
# Keep dropout in out_dropout so TE and PyTorch use the same dropout site.
if self.use_te:
self.attn_fn = te.DotProductAttention(
num_attention_heads=self.heads,
kv_channels=self.dim_head,
attention_dropout=0.0,
attn_mask_type="no_mask",
attention_type="cross",
qkv_format="bshd",
softmax_scale=self.scale,
)
if context_dim > 0:
_gale_cross_init(self, dim_head, context_dim, use_te, state_mixing_mode)
# Linear projection for output
self.out_linear = linear_layer(inner_dim, dim)
if concrete_dropout:
self.out_dropout = ConcreteDropout(
in_features=dim,
init_p=max(dropout, 0.05),
)
else:
self.out_dropout = nn.Dropout(dropout)
def forward(
self,
x: tuple[Float[torch.Tensor, "batch tokens channels"], ...],
context: Float[torch.Tensor, "batch heads context_slices context_dim"]
| None = None,
) -> list[Float[torch.Tensor, "batch tokens channels"]]:
r"""Forward pass of the GALE_FA module.
Applies GALE_FA attention to the input features.
Parameters
----------
x : tuple[torch.Tensor, ...]
Tuple of input tensors, each of shape :math:`(B, N, C)` where :math:`B`
is batch size, :math:`N` is number of tokens, and :math:`C` is number
of channels.
context : torch.Tensor | None, optional
Context tensor for cross-attention of shape :math:`(B, H, S_c, D_c)`
where :math:`H` is number of heads, :math:`S_c` is number of context
slices, and :math:`D_c` is context dimension. If ``None``, only
self-attention is applied. Default is ``None``.
Returns
-------
list[torch.Tensor]
List of output tensors, each of shape :math:`(B, N, C)``, same shape
as inputs.
"""
# Input projection: (B, N, C) -> (B, N, H, D) -> (B, H, N, D)
x_mid = [
_project_input(
_x,
self.in_project_x,
self.heads,
self.dim_head,
"B N (H D) -> B N H D",
).permute(0, 2, 1, 3)
for _x in x
]
# FLARE self-attention per input
if self.use_te:
self_attention = [
_flare_self_attention_te(
_x_mid,
self.q_global,
self.self_k,
self.self_v,
self.attn_fn,
self.heads,
)
for _x_mid in x_mid
]
else:
self_attention = [
_flare_self_attention(
_x_mid,
self.q_global,
self.self_k,
self.self_v,
self.scale,
)
for _x_mid in x_mid
]
# Cross-attention with context and state mixing
if context is not None:
if self.use_te:
# TE cross-attention: reshape (B, H, S, D) -> bshd, run through
# the shared DotProductAttention, then back to (B, H, N, D).
k = rearrange(self.cross_k(context), "b h s d -> b s h d")
v = rearrange(self.cross_v(context), "b h s d -> b s h d")
cross_attention = [
rearrange(
self.attn_fn(
rearrange(self.cross_q(_x_mid), "b h n d -> b n h d"), k, v
),
"b n (h d) -> b h n d",
h=self.heads,
)
for _x_mid in x_mid
]
else:
q = [self.cross_q(_x_mid) for _x_mid in x_mid]
k = self.cross_k(context)
v = self.cross_v(context)
cross_attention = [
F.scaled_dot_product_attention(_q, k, v, scale=self.scale)
for _q in q
]
outputs = [
_mix_self_and_cross(
sa,
ca,
self.state_mixing_mode,
state_mixing=getattr(self, "state_mixing", None),
concat_project=getattr(self, "concat_project", None),
)
for sa, ca in zip(self_attention, cross_attention)
]
else:
outputs = self_attention
# Back to token layout: (B, H, N, D) -> (B, N, H, D)
outputs = [_y.permute(0, 2, 1, 3) for _y in outputs]
outputs = [rearrange(_out, "b n h d -> b n (h d)") for _out in outputs]
outputs = [self.out_linear(_out) for _out in outputs]
return [self.out_dropout(_out) for _out in outputs]
class GALEBlock(nn.Module):
r"""Transformer encoder block using GALE attention.
This block replaces standard self-attention with the GALE (Geometry-Aware Latent
Embeddings) attention mechanism, which combines physics-aware self-attention with
cross-attention to geometry and global context.
Parameters
----------
num_heads : int
Number of attention heads.
hidden_dim : int
Hidden dimension of the transformer.
dropout : float
Dropout rate.
act : str, optional
Activation function name. Default is ``"gelu"``.
mlp_ratio : int, optional
Ratio of MLP hidden dimension to ``hidden_dim``. Default is 4.
last_layer : bool, optional
Whether this is the last layer in the model. Default is ``False``.
out_dim : int, optional
Output dimension (only used if ``last_layer=True``). Default is 1.
slice_num : int, optional
Number of learned physical state slices. Default is 32.
use_te : bool, optional
Whether to use Transformer Engine backend. Default is ``False``.
plus : bool, optional
Whether to use Transolver++ features. Default is ``False``.
context_dim : int, optional
Dimension of the context vector for cross-attention. Default is 0.
spatial_shape : tuple[int, ...] | None, optional
If ``None``, uses irregular-mesh GALE. Length-2 tuple enables 2D Conv2d
projection; length-3 tuple enables 3D Conv3d projection (flattened
:math:`N = H \times W` or :math:`H \times W \times D`). Default is ``None``.
attention_type : str, optional
Attention backend to use. ``"GALE"`` uses the standard physics-aware
slice attention; ``"GALE_FA"`` uses flash-attention variant.
Default is ``"GALE"``.
state_mixing_mode : str, optional
How to blend self-attention and cross-attention outputs. ``"weighted"`` uses
a learnable sigmoid-gated weighted sum. ``"concat_project"``
concatenates the two along the head dimension and projects back with a
linear layer. Default is ``"weighted"``.
Forward
-------
fx : tuple[torch.Tensor, ...]
Tuple of input tensors, each of shape :math:`(B, N, C)` where :math:`B` is
batch size, :math:`N` is number of tokens, and :math:`C` is hidden dimension.
global_context : tuple[torch.Tensor, ...]
Global context tensor for cross-attention of shape :math:`(B, H, S_c, D_c)`
where :math:`H` is number of heads, :math:`S_c` is number of context slices,
and :math:`D_c` is context dimension.
Outputs
-------
list[torch.Tensor]
List of output tensors, each of shape :math:`(B, N, C)`, same shape as inputs.
Notes
-----
The block applies layer normalization before the attention operation and uses
residual connections after both the attention and MLP layers.
See Also
--------
:class:`GALE` : The attention mechanism used in this block.
:class:`physicsnemo.models.geotransolver.GeoTransolver` : Main model using GALEBlock.
Examples
--------
>>> import torch
>>> block = GALEBlock(num_heads=8, hidden_dim=256, dropout=0.1, context_dim=32, use_te=False)
>>> fx = (torch.randn(2, 100, 256),) # Single input tensor in tuple
>>> context = torch.randn(2, 8, 64, 32) # Global context
>>> outputs = block(fx, context)
>>> len(outputs)
1
>>> outputs[0].shape
torch.Size([2, 100, 256])
"""
def __init__(
self,
num_heads: int,
hidden_dim: int,
dropout: float,
act: str = "gelu",
mlp_ratio: int = 4,
last_layer: bool = False,
out_dim: int = 1,
slice_num: int = 32,
use_te: bool = False,
plus: bool = False,
context_dim: int = 0,
spatial_shape: tuple[int, ...] | None = None,
attention_type: str = "GALE",
concrete_dropout: bool = False,
state_mixing_mode: str = "weighted",
) -> None:
super().__init__()
self.last_layer = last_layer
# Layer normalization before attention
if use_te:
self.ln_1 = te.LayerNorm(hidden_dim)
else:
self.ln_1 = nn.LayerNorm(hidden_dim)
dim_head = hidden_dim // num_heads
# First match on attention backend, then on spatial shape
match attention_type:
case "GALE":
if spatial_shape is None:
self.Attn = GALE(
hidden_dim,
heads=num_heads,
dim_head=dim_head,
dropout=dropout,
slice_num=slice_num,
use_te=use_te,
plus=plus,
context_dim=context_dim,
concrete_dropout=concrete_dropout,
state_mixing_mode=state_mixing_mode,
)
elif len(spatial_shape) == 2:
self.Attn = GALEStructuredMesh2D(
hidden_dim,
spatial_shape=(int(spatial_shape[0]), int(spatial_shape[1])),
heads=num_heads,
dim_head=dim_head,
dropout=dropout,
slice_num=slice_num,
use_te=use_te,
plus=plus,
context_dim=context_dim,
state_mixing_mode=state_mixing_mode,
)
elif len(spatial_shape) == 3:
self.Attn = GALEStructuredMesh3D(
hidden_dim,
spatial_shape=(
int(spatial_shape[0]),
int(spatial_shape[1]),
int(spatial_shape[2]),
),
heads=num_heads,
dim_head=dim_head,
dropout=dropout,
slice_num=slice_num,
use_te=use_te,
plus=plus,
context_dim=context_dim,
state_mixing_mode=state_mixing_mode,
)
else:
raise ValueError(
f"spatial_shape must be None, length-2, or length-3; got {spatial_shape!r}"
)
case "GALE_FA":
self.Attn = GALE_FA(
hidden_dim,
heads=num_heads,
dim_head=dim_head,
dropout=dropout,
n_global_queries=slice_num,
use_te=use_te,
context_dim=context_dim,
concrete_dropout=concrete_dropout,
state_mixing_mode=state_mixing_mode,
)
case _:
raise ValueError(
f"Invalid attention type: {attention_type}. "
f"Expected 'GALE' or 'GALE_FA'."
)
# Feed-forward network with layer normalization
if use_te:
self.ln_mlp1 = te.LayerNormMLP(
hidden_size=hidden_dim,
ffn_hidden_size=hidden_dim * mlp_ratio,
)
else:
self.ln_mlp1 = nn.Sequential(
nn.LayerNorm(hidden_dim),
Mlp(
in_features=hidden_dim,
hidden_features=hidden_dim * mlp_ratio,
out_features=hidden_dim,
act_layer=act,
use_te=False,
),
)
# Concrete dropout after attention and FFN residuals
if concrete_dropout:
self.attn_dropout = ConcreteDropout(
in_features=hidden_dim,
init_p=max(dropout, 0.05),
)
self.ffn_dropout = ConcreteDropout(
in_features=hidden_dim,
init_p=max(dropout, 0.05),
)
else:
self.attn_dropout = None
self.ffn_dropout = None
def forward(
self,
fx: tuple[Float[torch.Tensor, "batch tokens hidden_dim"], ...],
global_context: Float[torch.Tensor, "batch heads context_slices context_dim"],
) -> list[Float[torch.Tensor, "batch tokens hidden_dim"]]:
r"""Forward pass of the GALE block.
Parameters
----------
fx : tuple[torch.Tensor, ...]
Tuple of input tensors, each of shape :math:`(B, N, C)` where :math:`B`
is batch size, :math:`N` is number of tokens, and :math:`C` is hidden
dimension.
global_context : torch.Tensor
Global context tensor for cross-attention of shape :math:`(B, H, S_c, D_c)`
where :math:`H` is number of heads, :math:`S_c` is number of context slices,
and :math:`D_c` is context dimension.
Returns
-------
list[torch.Tensor]
List of output tensors, each of shape :math:`(B, N, C)`, same shape as inputs.
"""
### Input validation
if not torch.compiler.is_compiling():
if len(fx) == 0:
raise ValueError("Expected non-empty tuple of input tensors")
for i, tensor in enumerate(fx):
if tensor.ndim != 3:
raise ValueError(
f"Expected 3D input tensor (B, N, C) at index {i}, "
f"got {tensor.ndim}D tensor with shape {tuple(tensor.shape)}"
)
# Apply pre-normalization to all inputs
normed_inputs = [self.ln_1(_fx) for _fx in fx]
# Apply GALE attention with cross-attention to global context
attn = self.Attn(tuple(normed_inputs), global_context)
# Residual connection after attention
fx_out = [attn[i] + fx[i] for i in range(len(fx))]
# Concrete dropout after attention residual
if self.attn_dropout is not None:
fx_out = [self.attn_dropout(_fx) for _fx in fx_out]
# Feed-forward network with residual connection
fx_out = [self.ln_mlp1(_fx) + _fx for _fx in fx_out]
# Concrete dropout after FFN residual
if self.ffn_dropout is not None:
fx_out = [self.ffn_dropout(_fx) for _fx in fx_out]
return fx_out