Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
607cab9
CuPy backend: host/device conversions in post-processing; math.sqrt f…
max-models Oct 16, 2026
4de785d
CUDA version of the mass-matrix assembly kernel kernel_3d_mat
max-models Oct 16, 2026
773d7b1
Parity cases for kernel_3d_mat
max-models Oct 16, 2026
1d0e0ab
Apply separable mass matrices as Kronecker products of 1D banded matr…
max-models Oct 16, 2026
e415281
Poisson/diffusion solve: fewer passes over the vectors per iteration
max-models Oct 16, 2026
c45019c
BoundaryOperator: no fused mask kernel on the fake CuPy
max-models Oct 16, 2026
82688ad
Merge branch 'devel' into gpu-stack-5-diffusion-solve (through gpu-st…
max-models Oct 16, 2026
b90ada0
Merge branch 'devel' into gpu-stack-2-cupy-fixes (through gpu-stack-1…
max-models Oct 16, 2026
aed0422
Merge branch 'devel' into gpu-stack-3-mass-assembly (through gpu-stac…
max-models Oct 16, 2026
01d66cd
Merge branch 'devel' into gpu-stack-4-kronecker-mass (through gpu-sta…
max-models Oct 16, 2026
8b64aef
Merge branch 'gpu-stack-4-kronecker-mass' into gpu-stack-5-diffusion-…
max-models Oct 16, 2026
373843a
Merge branch 'gpu-stack-1-feectools' into gpu-stack-2-cupy-fixes (fee…
max-models Oct 16, 2026
f7b86c1
Merge branch 'gpu-stack-2-cupy-fixes' into gpu-stack-3-mass-assembly …
max-models Oct 16, 2026
ea57724
Merge branch 'gpu-stack-3-mass-assembly' into gpu-stack-4-kronecker-m…
max-models Oct 16, 2026
133be44
Fused gradient with boundary operators (struphy.feec.fused_derivatives)
max-models Oct 16, 2026
5d19a35
Merge branch 'gpu-stack-1-feectools' into gpu-stack-2-cupy-fixes (fee…
max-models Oct 16, 2026
c7486ca
Merge branch 'gpu-stack-2-cupy-fixes' into gpu-stack-3-mass-assembly …
max-models Oct 16, 2026
506e188
Merge branch 'gpu-stack-3-mass-assembly' into gpu-stack-4-kronecker-m…
max-models Oct 16, 2026
c508cce
Kronecker mass: launch the band-pass kernel through CachedLaunch
max-models Oct 16, 2026
1230c59
Merge branch 'gpu-stack-4-kronecker-mass' into gpu-stack-5-diffusion-…
max-models Oct 16, 2026
b7edfba
Merge branch 'devel' into gpu-stack-2-cupy-fixes (through gpu-stack-1…
max-models Oct 16, 2026
adc6a9f
Merge branch 'devel' into gpu-stack-3-mass-assembly (through gpu-stac…
max-models Oct 16, 2026
aebde49
Merge branch 'devel' into gpu-stack-4-kronecker-mass (through gpu-sta…
max-models Oct 16, 2026
6689f72
Merge branch 'devel' into gpu-stack-5-diffusion-solve (through gpu-st…
max-models Oct 16, 2026
990ac3d
Merge branch 'gpu-stack-1-feectools' into gpu-stack-2-cupy-fixes (pcg…
max-models Oct 16, 2026
639a5af
Merge branch 'gpu-stack-2-cupy-fixes' into gpu-stack-3-mass-assembly …
max-models Oct 16, 2026
1e4c167
Merge branch 'gpu-stack-3-mass-assembly' into gpu-stack-4-kronecker-m…
max-models Oct 16, 2026
09a74c7
Merge branch 'gpu-stack-4-kronecker-mass' into gpu-stack-5-diffusion-…
max-models Oct 16, 2026
6bb532f
CuPy backend: host/device conversions in post-processing; math.sqrt f…
max-models Oct 16, 2026
5e2dced
CUDA version of the mass-matrix assembly kernel kernel_3d_mat
max-models Oct 16, 2026
b586cde
Parity cases for kernel_3d_mat
max-models Oct 16, 2026
4147d1a
Apply separable mass matrices as Kronecker products of 1D banded matr…
max-models Oct 16, 2026
f766830
Kronecker mass: launch the band-pass kernel through CachedLaunch
max-models Oct 16, 2026
9aeec98
Merge branch 'gpu-stack-4-kronecker-mass' into gpu-stack-5-diffusion-…
max-models Oct 23, 2026
d0a8f31
CuPy backend: host/device conversions in post-processing; math.sqrt f…
max-models Oct 16, 2026
5c099f3
CUDA version of the mass-matrix assembly kernel kernel_3d_mat
max-models Oct 16, 2026
bcbb46a
Parity cases for kernel_3d_mat
max-models Oct 16, 2026
bbe66ed
Apply separable mass matrices as Kronecker products of 1D banded matr…
max-models Oct 16, 2026
07fd091
Kronecker mass: launch the band-pass kernel through CachedLaunch
max-models Oct 16, 2026
986c7ce
Merge branch 'gpu-stack-4-kronecker-mass' into gpu-stack-5-diffusion-…
max-models Oct 23, 2026
f1cbb2d
CuPy backend: host/device conversions in post-processing; math.sqrt f…
max-models Oct 16, 2026
ef927d1
CUDA version of the mass-matrix assembly kernel kernel_3d_mat
max-models Oct 16, 2026
c1f8875
Parity cases for kernel_3d_mat
max-models Oct 16, 2026
890b6d9
Apply separable mass matrices as Kronecker products of 1D banded matr…
max-models Oct 16, 2026
df53602
Kronecker mass: launch the band-pass kernel through CachedLaunch
max-models Oct 16, 2026
6f686c1
Merge branch 'gpu-stack-4-kronecker-mass' into gpu-stack-5-diffusion-…
max-models Oct 23, 2026
f63a560
CuPy backend: host/device conversions in post-processing; math.sqrt f…
max-models Oct 16, 2026
1a3939e
CUDA version of the mass-matrix assembly kernel kernel_3d_mat
max-models Oct 16, 2026
361b541
Parity cases for kernel_3d_mat
max-models Oct 16, 2026
62afbad
Apply separable mass matrices as Kronecker products of 1D banded matr…
max-models Oct 16, 2026
1a0f477
Kronecker mass: launch the band-pass kernel through CachedLaunch
max-models Oct 16, 2026
674017a
Merge branch 'gpu-stack-4-kronecker-mass' into gpu-stack-5-diffusion-…
max-models Oct 23, 2026
035b8a9
CuPy backend: host/device conversions in post-processing; math.sqrt f…
max-models Oct 16, 2026
565eea9
CUDA version of the mass-matrix assembly kernel kernel_3d_mat
max-models Oct 16, 2026
08ea857
Parity cases for kernel_3d_mat
max-models Oct 16, 2026
715cc5c
Apply separable mass matrices as Kronecker products of 1D banded matr…
max-models Oct 16, 2026
c78a6f7
Kronecker mass: launch the band-pass kernel through CachedLaunch
max-models Oct 16, 2026
38f9cb4
Merge branch 'gpu-stack-4-kronecker-mass' into gpu-stack-5-diffusion-…
max-models Oct 23, 2026
766a868
Merge branch 'gpu-stack-4-kronecker-mass' into gpu-stack-5-diffusion-…
max-models Oct 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 217 additions & 0 deletions src/struphy/feec/fused_derivatives.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
"""The discrete gradient with its boundary operators as one CUDA kernel.

``Derham.grad`` is ``B1 @ G @ B0.T``: the boundary operator of the 0-forms (a 0/1 mask), the derivatives of the
three directions (feectools ``DirectionalDerivativeOperator``, a difference of neighbouring coefficients) and the
boundary operator of the 1-forms. Applied as composed operators, each product makes three passes over the vectors
(and one more over the input for the masked copy), and its transpose four. :func:`fuse_boundary_gradient` returns an
operator that applies the whole chain with one kernel on the GPU (``fused_derivatives_cuda.cu``), with the same
operations in the same order, so the result is bit for bit the same, and the composed operator otherwise.
"""

from functools import cache
from pathlib import Path

import cunumpy as xp
import numpy as np
from feectools.feec.derivatives import DirectionalDerivativeOperator
from feectools.linalg.basic import ComposedLinearOperator, LinearOperator
from feectools.linalg.block import BlockLinearOperator, BlockVector
from feectools.linalg.stencil import StencilVector

from struphy.feec.linear_operators import BoundaryOperator

# entries of the kernel metadata per component, see fused_derivatives_cuda.cu
_META = 8


@cache
def _kernels():
"""The forward and transposed CUDA kernels (compiled on first launch)."""
from cunumpy.kernels import CudaKernel

path = Path(__file__).with_name("fused_derivatives_cuda.cu")
return (
CudaKernel.from_file(path, "fused_boundary_gradient"),
CudaKernel.from_file(path, "fused_boundary_gradient_t"),
)


def _derivative_blocks(block: BlockLinearOperator):
"""The derivatives of a gradient block (one block column) or its transpose (one block row), else None."""
if not isinstance(block, BlockLinearOperator):
return None
n_rows, n_cols = block.n_block_rows, block.n_block_cols
if min(n_rows, n_cols) != 1 or not 1 <= max(n_rows, n_cols) <= 3:
return None
ops = [block[k, 0] if n_cols == 1 else block[0, k] for k in range(max(n_rows, n_cols))]
if not all(isinstance(op, DirectionalDerivativeOperator) and op.domain.ndim == 3 for op in ops):
return None
transposed = n_cols > 1 or (n_rows == 1 and ops[0].transposed)
if any(op.transposed != transposed for op in ops):
return None
return ops, transposed


def fuse_boundary_gradient(op: LinearOperator) -> LinearOperator:
"""``op`` as a :class:`FusedBoundaryGradient` if it is a gradient with boundary operators (``B_out @ G @ B_in``,
``G`` a block of directional derivatives) or the transpose of one, else ``op`` itself.

Parameters
----------
op : LinearOperator
E.g. ``derham.grad`` or ``derham.grad.T``.

Returns
-------
LinearOperator
The same operator; on the GPU applied with one kernel.
"""
if isinstance(op, FusedBoundaryGradient) or not isinstance(op, ComposedLinearOperator):
return op
factors = op.multiplicands
if len(factors) != 3:
return op
b_out, block, b_in = factors
if not (isinstance(b_out, BoundaryOperator) and isinstance(b_in, BoundaryOperator)):
return op
found = _derivative_blocks(block)
if found is None:
return op
derivatives, transposed = found
return FusedBoundaryGradient(op, b_out, derivatives, b_in, transposed)


class FusedBoundaryGradient(LinearOperator):
"""``B_out @ G @ B_in`` (a gradient with its boundary operators, or its transpose) with one kernel on the GPU.

The kernel computes the masked differences on the owned entries of the output from the input with its ghost
regions, as the composed operators do (see ``fused_derivatives_cuda.cu``). It is used for device data on a GPU; the
composed operator is applied in every other case (host data, cunumpy's fake CuPy, polar or other vectors).

Parameters
----------
op : ComposedLinearOperator
The composed operator, applied when the kernel does not apply.
b_out, b_in : BoundaryOperator
The boundary operators on the output and input side.
derivatives : list[DirectionalDerivativeOperator]
The derivatives per component (of the output, or of the input if transposed).
transposed : bool
Whether ``op`` is the transpose of a gradient (1-forms to 0-forms).
"""

def __init__(self, op, b_out, derivatives, b_in, transposed: bool):
self._op = op
self._b_out = b_out
self._b_in = b_in
self._derivatives = derivatives
self._transposed = transposed
self._meta = None
self._input_masks = None

@property
def operator(self) -> LinearOperator:
"""The composed operator."""
return self._op

@property
def domain(self):
return self._op.domain

@property
def codomain(self):
return self._op.codomain

@property
def dtype(self):
return self._op.dtype

def tosparse(self):
return self._op.tosparse()

def toarray(self):
return self._op.toarray()

def transpose(self, conjugate=False):
return fuse_boundary_gradient(self._op.transpose(conjugate=conjugate))

def dot(self, v, out=None):
if self._use_kernel(v):
return self._dot_fused(v, out)
return self._op.dot(v, out=out)

def _use_kernel(self, v) -> bool:
"""Whether :meth:`dot` uses the kernel: device data on a real GPU (not cunumpy's fake CuPy)."""
from cunumpy.kernel_testing import fake_cupy_active

blocks = v.blocks if isinstance(v, BlockVector) else [v]
if not all(isinstance(b, StencilVector) and xp.is_gpu(b._data) for b in blocks):
return False
return not fake_cupy_active()

def _metadata(self):
"""Owned region, direction and sign of each derivative, as the kernels take them (device array, cached)."""
if self._meta is None:
rows = []
for d in self._derivatives:
# the owned entries of the derivative's codomain, the same padded indices in its domain
idslice = d._idslice
rows.append(
[s.start for s in idslice] + [s.stop - s.start for s in idslice] + [d.diffdir, int(d.negative)]
)
assert all(len(r) == _META for r in rows)
self._meta = xp.asarray(np.array(rows, dtype=np.int64).ravel())
return self._meta

def _input_mask_arrays(self):
"""The 0/1 mask of the input boundary operator per block, with its ghost regions exchanged (cached).

The composed operators mask the input and then exchange the ghost regions of the masked vector (the boundary
operator leaves copies of boundary coefficients in the ghost regions unmasked). The kernels read the input with
its ghost regions exchanged instead, times this mask, whose ghost entries are then those of the owners: the
products are the exchanged values of the masked input, in serial and in parallel.
"""
if self._input_masks is None:
ones = self.domain.zeros()
for b in getattr(ones, "blocks", [ones]):
b._data[...] = 1.0
ones.ghost_regions_in_sync = True
mask = self._b_in.dot(ones)
mask.update_ghost_regions()
self._input_masks = [b._data for b in getattr(mask, "blocks", [mask])]
return self._input_masks

def _dot_fused(self, v, out=None):
"""The product with the kernels (device data)."""
if out is None:
out = self.codomain.zeros()
masks_out = self._b_out._device_masks(out)
if masks_out is None:
return self._op.dot(v, out=out)
masks_in = self._input_mask_arrays()

# the kernels read the ghost regions of the input
if not v.ghost_regions_in_sync:
v.update_ghost_regions()

forward, transposed = _kernels()
meta = self._metadata()
n_comp = len(self._derivatives)

def three(arrays):
return [arrays[min(k, len(arrays) - 1)] for k in range(3)]

if not self._transposed:
outs = three([b._data for b in out.blocks])
m_out = three([m for m, _ in masks_out])
n_threads = sum(int(np.prod([s.stop - s.start for s in d._idslice])) for d in self._derivatives)
forward(v._data, masks_in[0], *outs, *m_out, meta, n_comp, n_threads=n_threads)
else:
ys = three([b._data for b in v.blocks])
m_in = three(masks_in)
n_threads = int(np.prod([s.stop - s.start for s in self._derivatives[0]._idslice]))
transposed(*ys, *m_in, out._data, masks_out[0][0], meta, n_comp, n_threads=n_threads)

# as the composed operators: the ghost regions of the output are not written
out.ghost_regions_in_sync = False
return out
97 changes: 97 additions & 0 deletions src/struphy/feec/fused_derivatives_cuda.cu
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// The discrete gradient with its boundary operators as one kernel (struphy.feec.fused_derivatives).
#include "cunumpy/array_view.cuh"
#include "cunumpy/index.cuh"

// Per component c, 8 entries of `meta`: first owned index (3, padded coordinates), owned sizes (3), derivative
// direction, negative (0 or 1).
#define FUSED_META 8

__device__ inline Array3D<double> pick(int c, Array3D<double> a0, Array3D<double> a1, Array3D<double> a2) {
return c == 0 ? a0 : (c == 1 ? a1 : a2);
}

/**
* out_c = B_out,c D_c B_in x for the components c of a gradient, B the 0/1 masks of the boundary operators and D_c the
* derivative in direction dir_c (feectools DirectionalDerivativeOperator): on the owned entries i of out_c (padded
* coordinates, shared by x and out_c as in DirectionalDerivativeOperator),
*
* out_c[i] = ((x[i + e] * m_in[i + e]) - (x[i] * m_in[i])) * m_out_c[i] (operands swapped if negative),
*
* the operations of the composed operators in the same order (bit for bit the same). One thread per owned entry of
* all components (n_threads = sum of the owned sizes), component 0 first; the ghost regions of out are not written.
*
* @param x, m_in Input vector data (ghost regions in sync) and the mask of the input boundary operator.
* @param out0, out1, out2, m0, m1, m2 Data of the output components and the masks of the output boundary operator.
* @param meta FUSED_META entries per component, see above.
* @param n_comp Number of components (1 to 3).
*/
extern "C" __global__ void fused_boundary_gradient(Array3D<double> x, Array3D<double> m_in, Array3D<double> out0,
Array3D<double> out1, Array3D<double> out2, Array3D<double> m0,
Array3D<double> m1, Array3D<double> m2, const long long* meta,
int n_comp) {
long long n_total = 0;
for (int c = 0; c < n_comp; ++c)
n_total += meta[FUSED_META * c + 3] * meta[FUSED_META * c + 4] * meta[FUSED_META * c + 5];
CUNUMPY_THREAD_1D(t, n_total);

// CUDA-only: the component and owned entry of this thread
int c = 0;
long long local = t;
while (c < n_comp - 1) {
const long long size = meta[FUSED_META * c + 3] * meta[FUSED_META * c + 4] * meta[FUSED_META * c + 5];
if (local < size) break;
local -= size;
++c;
}
const long long* m = meta + FUSED_META * c;
const long long i3 = m[2] + local % m[5];
const long long i2 = m[1] + (local / m[5]) % m[4];
const long long i1 = m[0] + local / (m[4] * m[5]);
const int dir = (int)m[6];
const long long j1 = i1 + (dir == 0), j2 = i2 + (dir == 1), j3 = i3 + (dir == 2);

const double a = x(j1, j2, j3) * m_in(j1, j2, j3);
const double b = x(i1, i2, i3) * m_in(i1, i2, i3);
const double d = m[7] ? b - a : a - b;
Array3D<double> out = pick(c, out0, out1, out2);
Array3D<double> mask = pick(c, m0, m1, m2);
out(i1, i2, i3) = d * mask(i1, i2, i3);
}

/**
* The transpose: out = B_out (sum_c D_c^T B_in,c y_c), with D_c^T the transposed derivative; on the owned entries i of
* out,
*
* t_c = (y_c[i - e_c] * m_c[i - e_c]) - (y_c[i] * m_c[i]) (operands swapped if negative),
* out[i] = ((t_0 + t_1) + t_2) * m_out[i],
*
* summed in the order of BlockLinearOperator.dot. One thread per owned entry of out (n_threads = its owned size).
*
* @param y0, y1, y2, m0, m1, m2 Input components (ghost regions in sync) and the masks of the input boundary operator.
* @param out, m_out Output data and the mask of the output boundary operator.
* @param meta FUSED_META entries per component (the owned region of out, the same for every component).
* @param n_comp Number of components (1 to 3).
*/
extern "C" __global__ void fused_boundary_gradient_t(Array3D<double> y0, Array3D<double> y1, Array3D<double> y2,
Array3D<double> m0, Array3D<double> m1, Array3D<double> m2,
Array3D<double> out, Array3D<double> m_out,
const long long* meta, int n_comp) {
CUNUMPY_THREAD_1D(t, meta[3] * meta[4] * meta[5]);
const long long i3 = meta[2] + t % meta[5];
const long long i2 = meta[1] + (t / meta[5]) % meta[4];
const long long i1 = meta[0] + t / (meta[4] * meta[5]);

double acc = 0.0;
for (int c = 0; c < n_comp; ++c) {
const long long* m = meta + FUSED_META * c;
const int dir = (int)m[6];
const long long j1 = i1 - (dir == 0), j2 = i2 - (dir == 1), j3 = i3 - (dir == 2);
Array3D<double> y = pick(c, y0, y1, y2);
Array3D<double> mask = pick(c, m0, m1, m2);
const double a = y(j1, j2, j3) * mask(j1, j2, j3);
const double b = y(i1, i2, i3) * mask(i1, i2, i3);
const double d = m[7] ? b - a : a - b;
acc = c == 0 ? d : acc + d;
}
out(i1, i2, i3) = acc * m_out(i1, i2, i3);
}
Loading
Loading