HealPixFFTConv: large-kernel FFT convolution#
This notebook checks the zero-padded FFT convolution against direct conv2d, verifies the identity-kernel projection round trip, tests gradients/CUDA, measures performance, and repeats the geometry test at the North Pole.
%load_ext autoreload
%autoreload 2
import time
import healpix_geo
import numpy as np
import torch
import torch.nn.functional as F
from healpix_analyse import HealPixFFTConv
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
torch.manual_seed(0)
np.random.seed(0)
print("device:", device)
device: cuda
Local NESTED patch#
The patch stays below the default ten-degree gnomonic limit. The same ordered cell_ids array defines the last dimension of every input and output.
LEVEL = 8
CENTRE = (30.0, 20.0)
RADIUS_DEG = 5.0
cell_ids, _, _ = healpix_geo.nested.cone_coverage(
CENTRE, RADIUS_DEG, LEVEL, ellipsoid="sphere"
)
cell_ids = np.asarray(cell_ids, dtype=np.int64)
print("cells:", len(cell_ids))
cells: 1599
KERNEL_SZ = 33
layer = HealPixFFTConv(
level=LEVEL,
in_channels=2,
out_channels=3,
kernel_sz=KERNEL_SZ,
cell_ids=cell_ids,
ellipsoid="sphere",
device=device,
dtype=torch.float64, # strict correctness check; benchmark is converted to float32 later
)
print(layer)
print("projected grid:", layer.transform.grid_shape)
print("padded FFT:", layer.fft_shape)
assert layer.fft_shape[0] >= layer.grid_size + KERNEL_SZ - 1
HealPixFFTConv(
level=8, nside=256, in=2, out=3, kernel_sz=33, cells=1599, grid=64, fft_shape=(128, 128), radius=5.175 deg
(transform): LocalFFT(level=8, n_cells=1599, grid_shape=(64, 64), radius=5.175 deg, ellipsoid='sphere', dtype=torch.float64)
)
projected grid: (64, 64)
padded FFT: (128, 128)
FFT result versus direct convolution#
Both paths use the same gnomonic projection and HEALPix back-projection. They differ only in the planar convolution implementation. This strict comparison uses float64 because CUDA conv2d may otherwise use cuDNN/TF32 while the FFT path uses complex64 arithmetic.
weight = torch.randn(2, 3, KERNEL_SZ, KERNEL_SZ, device=device, dtype=torch.float64) / (2 * KERNEL_SZ**2) ** 0.5
bias = torch.randn(3, device=device, dtype=torch.float64) * 0.01
layer.set_kernel(weight, bias=bias, requires_grad=True)
x = torch.randn(2, 2, len(cell_ids), device=device, dtype=torch.float64)
y_fft = layer(x)
grid = layer.transform._project_tensor(x)
grid_direct = F.conv2d(
grid, layer.weight.permute(1, 0, 2, 3), padding=KERNEL_SZ // 2
)
y_direct = layer.transform._unproject_tensor(grid_direct) + layer.bias.view(1, -1, 1)
max_abs = (y_fft - y_direct).abs().max().item()
rel_rms = ((y_fft - y_direct).square().mean().sqrt() / y_direct.square().mean().sqrt().clamp_min(1e-12)).item()
print({"max_abs": max_abs, "relative_rms": rel_rms})
assert rel_rms < 2e-10, f"FFT/direct relative RMS mismatch: {rel_rms:.3e}"
assert max_abs < 2e-9, f"FFT/direct maximum mismatch: {max_abs:.3e}"
{'max_abs': 3.6637359812630166e-15, 'relative_rms': 1.2503260022905908e-15}
Identity kernel#
A centred delta must reproduce exactly the project -> unproject result. The remaining difference from the original HEALPix field is the interpolation error documented for LocalFFT.
identity_layer = HealPixFFTConv(
LEVEL, 1, 1, KERNEL_SZ, cell_ids=cell_ids, ellipsoid="sphere", device=device
)
delta = torch.zeros(1, 1, KERNEL_SZ, KERNEL_SZ, device=device)
delta[0, 0, KERNEL_SZ // 2, KERNEL_SZ // 2] = 1
identity_layer.set_kernel(delta)
signal = torch.randn(len(cell_ids), device=device)
identity_output = identity_layer(signal)
reference = identity_layer.transform.unproject(identity_layer.transform.project(signal))
identity_max_abs = (identity_output - reference).abs().max().item()
print("identity versus project/unproject max abs:", identity_max_abs)
assert torch.allclose(identity_output, reference, rtol=3e-5, atol=3e-6)
identity versus project/unproject max abs: 5.960464477539062e-07
Autograd#
The spatial kernel is transformed inside the graph during training, so gradients must reach both the HEALPix input and the kernel.
x_grad = torch.randn(2, 2, len(cell_ids), device=device, requires_grad=True)
loss = layer(x_grad).square().mean()
loss.backward()
print({
"loss": loss.item(),
"input_grad_norm": x_grad.grad.norm().item(),
"kernel_grad_norm": layer.weight.grad.norm().item(),
})
assert torch.isfinite(x_grad.grad).all()
assert torch.isfinite(layer.weight.grad).all()
{'loss': 0.17717390947697245, 'input_grad_norm': 0.00801227055490017, 'kernel_grad_norm': 0.38059604792559915}
Timing against direct conv2d#
This compares complete HEALPix-to-HEALPix paths using the same projection. Results depend strongly on device, batch size, grid and kernel size. Increase KERNEL_SZ and batch size to locate the crossover on the target GPU.
def synchronize():
if device.type == "cuda":
torch.cuda.synchronize(device)
def benchmark(function, repetitions=20):
for _ in range(3):
function()
synchronize()
start = time.perf_counter()
for _ in range(repetitions):
function()
synchronize()
return 1e3 * (time.perf_counter() - start) / repetitions
layer = layer.float()
x = x.float()
layer.eval()
with torch.inference_mode():
fft_ms = benchmark(lambda: layer(x))
def direct_path():
projected = layer.transform._project_tensor(x)
convolved = F.conv2d(
projected, layer.weight.permute(1, 0, 2, 3), padding=KERNEL_SZ // 2
)
return layer.transform._unproject_tensor(convolved)
direct_ms = benchmark(direct_path)
print({"fft_ms": fft_ms, "direct_ms": direct_ms, "speedup": direct_ms / fft_ms})
{'fft_ms': 0.2798809204250574, 'direct_ms': 1.5176092041656375, 'speedup': 5.422338907063876}
North Pole geometry#
A final identity test verifies that constructing the tangent frame does not depend on a longitude direction at the pole.
polar_ids, _, _ = healpix_geo.nested.cone_coverage(
(0.0, 90.0), 3.0, LEVEL, ellipsoid="sphere"
)
polar_ids = np.asarray(polar_ids, dtype=np.int64)
polar = HealPixFFTConv(
LEVEL, 1, 1, 17, cell_ids=polar_ids, ellipsoid="sphere", device=device
)
polar_delta = torch.zeros(1, 1, 17, 17, device=device)
polar_delta[0, 0, 8, 8] = 1
polar.set_kernel(polar_delta)
polar_x = torch.randn(len(polar_ids), device=device)
polar_y = polar(polar_x)
polar_reference = polar.transform.unproject(polar.transform.project(polar_x))
print({
"centre": (polar.transform.centre_lon_deg, polar.transform.centre_lat_deg),
"radius_deg": polar.transform.patch_radius_deg,
"max_abs": (polar_y - polar_reference).abs().max().item(),
})
assert torch.allclose(polar_y, polar_reference, rtol=3e-5, atol=3e-6)
{'centre': (92.96285521726368, 90.0), 'radius_deg': 3.1069851092541816, 'max_abs': 4.76837158203125e-07}