GPU Acceleration via Numba CUDA#
soliton_solver achieves high performance by implementing the PDE solver entirely on NVIDIA GPUs using Numba CUDA. This document explains the GPU acceleration strategy, kernel design, and performance characteristics.
Overview#
The solver uses Numba CUDA for JIT compilation of Python functions into optimized CUDA kernels. Key benefits:
No explicit C/C++ code — Pure Python kernel definitions, compiled to CUDA at runtime
Type specialization — Kernels are optimized for float64 field arrays
Lazy compilation — Kernels compile on first invocation, then cached
Reduced compilation burden — Theory developers can write kernels in Python without CUDA expertise
CUDA Kernels#
Kernel structure#
Kernels in soliton_solver follow a 2D grid + per-thread update pattern:
from numba import cuda
from soliton_solver.core.utils import in_bounds, idx_field
@cuda.jit
def my_kernel(Field, p_i, p_f):
"""Update a field on the GPU."""
x, y = cuda.grid(2) # Get (x, y) thread index
if not in_bounds(x, y, p_i):
return # Threads outside domain do nothing
# Per-thread computation
idx = idx_field(0, x, y, p_i) # Flatten (x, y) to linear index
Field[idx] += 1.0 # Update field value
Thread mapping#
Each thread processes one lattice site \((x, y)\):
Grid dimensions — Matches 2D domain:
(nx // threads_per_block_x, ny // threads_per_block_y)Block dimensions — Typically
(8, 8)or(16, 16)threads per blockGlobal ID —
(x, y) = cuda.grid(2)gives the 2D lattice coordinateOut-of-bounds handling —
in_bounds(x, y, p_i)checks if thread is within domain and domain-interior (beyond halo)
Kernel examples#
Finite-difference derivatives#
Fourth-order central finite differences are computed per-thread:
@cuda.jit(device=True)
def compute_derivative_first(d1fd1x, Field, a, x, y, p_i, p_f):
"""Compute first derivative of field component a at (x, y)."""
xlen = p_i[0]
ylen = p_i[1]
halo = p_i[2]
lsx = p_f[2] # Lattice spacing x
lsy = p_f[3] # Lattice spacing y
# Fourth-order stencil: [-1/12, 2/3, 0, -2/3, 1/12] * f
if x > halo - 1 and x < xlen - halo:
d1fd1x[...] = (
(1.0/12.0) * Field[idx_field(a, x-2, y, p_i)]
- (2.0/3.0) * Field[idx_field(a, x-1, y, p_i)]
+ (2.0/3.0) * Field[idx_field(a, x+1, y, p_i)]
- (1.0/12.0) * Field[idx_field(a, x+2, y, p_i)]
) / lsx
Each thread independently loads neighboring field values and computes the derivative.
Gradient computation#
Theory-specific gradients use derivatives computed per-thread:
def make_do_gradient_step_kernel(do_gradient_step_point):
"""Factory creating a gradient computation kernel."""
@cuda.jit
def _kernel(Velocity, Field, d1fd1x, d2fd2x, EnergyGradient, p_i, p_f):
x, y = cuda.grid(2)
if not in_bounds(x, y, p_i):
return
# Compute derivatives
number_total_fields = p_i[4]
for a in range(number_total_fields):
compute_derivative_first(d1fd1x, Field, a, x, y, p_i, p_f)
compute_derivative_second(d2fd2x, Field, a, x, y, p_i, p_f)
# Theory-specific gradient update
do_gradient_step_point(Velocity, Field, EnergyGradient, d1fd1x, d2fd2x,
x, y, p_i, p_f)
return _kernel
The per-thread do_gradient_step_point implements the theory-specific energy gradient \(\nabla E[\phi]\).
SIMT Execution Model#
CUDA uses Single-Instruction Multiple-Thread (SIMT) execution. Understanding this improves kernel design:
Warp-level operations#
Warp — 32 consecutive threads executing the same instruction
Warp scheduler — GPU assigns warps to cores; one warp per core per cycle
Thread divergence — If threads in a warp branch differently, paths execute serially, reducing parallelism
Example of divergence (bad):
@cuda.jit
def divergent_kernel(Field):
x, y = cuda.grid(2)
if x % 2 == 0: # Divergence: half the warp takes one path
Field[...] = 1.0
else:
Field[...] = -1.0
Example without divergence (good):
@cuda.jit
def coalesced_kernel(Field):
x, y = cuda.grid(2)
Field[idx_field(0, x, y, p_i)] = float(x % 2) * 2.0 - 1.0 # No divergence
Occupancy and resource utilization#
Occupancy — Fraction of the GPU’s maximum concurrent threads.
For a kernel using 32 registers per thread and 48 KB shared memory per block:
Tesla A100 (108 SMs × 2560 threads/SM) — 1 block/SM (256 threads) → 10% occupancy
A100 can run 40-80 active warps per SM → higher occupancy beneficial
Optimization:
Minimize register usage (fewer local variables)
Minimize shared memory per block
Use
@cuda.jit(fastmath=True)for faster-but-less-precise math when appropriate
Arrested Newton Flow Algorithm#
The core minimization algorithm uses arrested Newton flow (ANF) — a second-order relaxation method combining fast descent with stability control.
Algorithm description#
Given a discrete energy \(E_h[\phi]\), the continuous ANF equation is:
Evolving this PDE toward equilibrium finds local minima. The algorithm is:
Compute gradient — \(\nabla E\) at all lattice sites
RK4 step — Advance \((φ, \dot{φ})\) by time \(\Delta t\)
Arrest check — If energy increased, set velocity to zero
Repeat until convergence
Convergence properties#
Second-order — Velocity assists motion along shallow directions
Energy stable — Velocity reset prevents oscillations
Faster than gradient descent — Particularly for multi-soliton configurations
Convergence criterion — Stop when \(\|\nabla E\|_\infty < \epsilon\)
GPU implementation#
def do_arrested_newton_flow(Velocity, Field, EnergyGradient,
gradient_step_kernel, rk4_kernel,
p_i_d, p_f_d, p_i_h, p_f_h,
prev_energy, compute_energy):
"""One arrested Newton flow minimization step."""
grid2d, block2d = launch_2d(p_i_h, threads=(8, 8))
# 1. Compute energy gradient: ∇E = ∂E/∂φ
gradient_step_kernel[grid2d, block2d](
Velocity, Field, d1fd1x, d2fd2x, EnergyGradient, p_i_d, p_f_d
)
cuda.synchronize()
# 2. RK4 step: advance (φ, φ̇) = φ(t + Δt)
do_rk4_step_kernel[grid2d, block2d](
k_out, Velocity, l_in, Temp, Field, k_prev, 0.5, p_i_d, p_f_d
)
# ... (four RK4 stages)
rk4_kernel[grid2d, block2d](
Velocity, Field, k1, k2, k3, k4, l1, l2, l3, l4, p_i_d, p_f_d
)
cuda.synchronize()
# 3. Arrest check: ∫(φ̇ · ∇E) > 0?
force = arresting_criteria(Velocity, EnergyGradient, ...)
new_energy = compute_energy(Field, ...)
if new_energy > prev_energy: # Energy increased
# Reset velocity: φ̇ ← 0
set_field_zero_kernel[grid2d, block2d](Velocity, p_i_d)
return new_energy, convergence_error
Memory layout and indexing#
Field arrays are stored in flat 1D buffers with multi-dimensional indexing via helper functions.
Flattened indexing#
For a multi-component field on an \(n_x \times n_y\) grid:
Field[a, i, j] → Field[(j) + (i) * ny + (a) * nx * ny]
This layout ensures:
Coalesced memory access — Consecutive threads access consecutive memory locations
Cache efficiency — Sequential indices fit in L1/L2 caches
Helper functions:
@cuda.jit(device=True, inline=True)
def idx_field(a, i, j, p_i):
xlen = p_i[0]
ylen = p_i[1]
return j + i * ylen + a * xlen * ylen
Performance benchmarks#
Typical performance on NVIDIA A100 GPU:
Grid size |
Fields |
Kernel time |
Throughput |
|---|---|---|---|
512×512 |
2-4 |
0.5–1 ms |
500+ M pts/s |
1024×1024 |
4-8 |
2–4 ms |
250+ M pts/s |
2048×2048 |
8-16 |
8–15 ms |
300+ M pts/s |
Bottleneck: Global memory bandwidth (typically 2 TB/s on A100).
Profiling and optimization#
Profile CUDA kernels with nvidia-smi and nsys:
# Profile GPU memory and kernel utilization
nvidia-smi dmon
# Detailed profiling with NVIDIA Systems Profiler
nsys profile --stats=true my_simulation.py
Common optimizations:
Reduce memory reads — Cache field derivatives locally
Increase arithmetic intensity — More computation per byte loaded
Minimize synchronization — Batch kernel launches between syncs
Use fast-math mode — Trade precision for speed if acceptable
Troubleshooting#
Out-of-memory errors#
If GPU memory is exhausted:
Reduce grid size (
xlen,ylen)Reduce number of fields
Use smaller data type (float32 if accuracy allows)
Kernel launch failures#
Ensure kernel signature matches launch call. Numba CUDA catches most type mismatches at compile time.
Performance degradation#
Check occupancy with profiler
Verify coalesced memory access patterns
Reduce register usage by refactoring
Profile to identify bottleneck kernels