Execution backends and the GPU roadmap

HyperCloudCore runs every parallel per-particle loop as a kernel body body(i, args...) launched with foreach_particle(body, backend, N, args) (see src/Backend.jl). The backend is a global setting:

set_backend!(PolyesterBackend())            # default: Polyester `@batch per=core`
set_backend!(ThreadsBackend())              # Base.Threads `@threads :dynamic`
set_backend!(KernelAbstractions.CPU())      # KernelAbstractions kernels, compiled through PoCL on the host
set_backend!(CUDABackend())                 # from CUDA.jl (likewise ROCBackend(), MetalBackend(), oneAPIBackend())

What runs as a kernel today

passkernel bodyfile
neighbour gather + scheme caches_rhs_gather!TimeStepping/TimeSteppers.jl
flux divergence + explicit sources_rhs_flux!TimeStepping/TimeSteppers.jl
stage assembly, implicit solve, implicit RHS_stage_predictor!, _stage_implicit_solve!, _stage_implicit_rhs!TimeStepping/TimeSteppers.jl
MOOD candidate check, decrement, forcing, mask expansion_mood_flag!, _mood_drop!, _mood_force!, _expand_body!TimeStepping/MOOD.jl
geometry refresh (moving grids)_refresh_slots!TimeStepping/GridMovers.jl
inverse MLS moment matrices_moment_body!Interpolation/Interpolators.jl
Shepard volumes_shepard_sums!, _shepard_sweep!Grid/Volumes.jl
CFL estimate_cfl_body!Grid/ParticleGrids.jl
cell list (bin histogram, scatter, per-bin sort)_bin_count!, _bin_scatter!, _bin_sort!Grid/NeighborLogic.jl
periodic wrap, neighbour counting, slot ranges, writing_wrap_position!, _count_neighbours!, _neighbour_ranges!, _write_neighbours!Grid/NeighborLogic.jl
boundary conditions (Dirichlet; outflow donor search, one pass per hop)_dirichlet_body!, _outflow_init!, _outflow_pass!, _outflow_fallback!Grid/BoundaryConditions.jl
adaptivity pre-check (any pair violating the spacing rules)_adapt_candidate!Grid/Adaptivity.jl

With the KernelAbstractions backend these bodies are compiled as device kernels. The 0.10 CPU() backend compiles through PoCL (OpenCL on the host), so running the test suite with it validates the GPU kernel rules (bits-type arguments, no dynamic dispatch, no allocation) without a GPU. Results agree with the Polyester path to round-off (the OpenCL compiler contracts floating-point operations differently).

Data layout

The storage containers (ParticleGridCore, NeighborData, SharedBuffers, GlobalBins, InteractionBuffer, MUSCL.gradients, MLSWeights, IMEXButcherTableau) are parametric in their array types. Adapt.adapt_structure methods in src/DeviceAdapt.jl turn the host objects into bits types for a kernel launch: arrays become device arrays, the mutable GridMetadata an immutable GridMetadataView, Refs plain values, and the host-only parts of a ParticleGrid (geometry closures and boundary map, scratch buffers, reordering state) become nothing. device_grid(CuArray, pg) builds a grid whose arrays live on a device.

The per-particle scratch of the time stepper and the interpolators (stage states, neighbour gathers, MOOD masks, cached gradients and moment matrices) grows with the particle count. Those buffers are Growables (src/Growable.jl): a mutable holder of an array of the storage type given to the constructor, reallocated with similar when the capacity is exceeded (resize! only exists for Vector). Adapt unwraps a Growable to the array it holds, and every foreach_particle launch passes its arguments through kernel_view (adapt(KernelView(), args)), so kernel bodies see the same argument types on the CPU backends as on a device. A step on a device therefore reads

dg     = device_grid(ROCArray, pg)
interp = MUSCL(Float64, D, M, 3, NoLimiter(), RusanovFlux(); storage = ROCArray)
ts     = GeneralRKTimeStepper(eq, interp, MOOD(), RK3_SSP_Tableau(Float64); storage = ROCArray)
set_backend!(ROCBackend())
ts(eq, dg, t, dt)

Memory budget

A Growable that runs out of capacity is reallocated, and for a moment the old and the new array exist at the same time: on a device that is short of memory this is where a run dies, typically in the neighbour search, whose slot arrays are by far the largest buffers. reserve! removes the surprise. Every growable buffer of a run is either per particle or per neighbour slot (the cell list of the bins is the only fixed cost), so memory_estimate prices a run from the element types of the buffers alone, and reserve! sizes them once, to the largest particle capacity that fits a byte limit, and freezes them:

dg     = device_grid(ROCArray, pg)
interp = MUSCL(Float64, D, M, 3, NoLimiter(), RusanovFlux(); storage = ROCArray)
ts     = GeneralRKTimeStepper(eq, interp, MOOD(), RK3_SSP_Tableau(Float64); storage = ROCArray)
memory_estimate(dg, ts, interp)                   # bytes for the current particles and neighbour lists
r = reserve!(dg, ts, interp; limit = 12 * 2^30)   # (; particles, slots, bytes, limit)

The neighbour slots per particle default to what the current lists occupy (the host grid's search, which every run starts with, is the CPU pretest of the run; on a device grid that is the maximum neighbour count, its slot-major layout holds max_nb slots per particle). Raise slots_per_particle for moving or adaptive grids whose neighbourhoods grow, and keep tolerance (5 % of the limit by default) for what the vendor runtime and the reductions allocate. From then on a step that needs more than the reserved capacity, more particles from adaptivity or more neighbours per particle, throws a CapacityError instead of reallocating. The builders do the same with context[:MemoryLimit] (see build_timestepper and Context keys); HyperCloud.jl sets it from its session-wide set_memory_limit!.

Known platform issue (AMDGPU.jl 2.9.1, ROCm 7.2, gfx1201): once a process has run device simulations, the contents copied by reserve! into the newly allocated device buffers can turn into zeros shortly afterwards (on the device as well as on the host; a read right after the copy still sees the data). It reproduced in every attempt and was unaffected by how the block was written (copyto! or a kernel), garbage collection, stream synchronisation, pool trimming, waiting or system-scope fences (AMD_OPT_FLUSH=0); the one condition under which the data stayed intact was keeping the replaced arrays alive. The cause is in the ROCm or AMDGPU.jl layer and is not worked around here; HyperCloud.jl's runners compare a fingerprint of the grid before and after the reservation and refuse to run on corrupted data (reproducer: a reserved device run after earlier device runs in the same session; a fresh session is unaffected).

HyperCloudCore.set_backend! — Function
set_backend!(backend)

Selects how the per-particle loops run: an ExecutionBackend, or a KernelAbstractions.Backend (wrapped in a KABackend). Returns the new backend.

source
HyperCloudCore.KABackend — Type
KABackend(backend::KernelAbstractions.Backend; workgroupsize = 256)

Runs every per-particle loop as a KernelAbstractions kernel on backend: CPU() for testing the kernels on the host, or a device backend from CUDA.jl / AMDGPU.jl / Metal.jl / oneAPI.jl. All arrays reached by the kernels must live on that device (see docs/src/gpu_roadmap.md).

source
HyperCloudCore.foreach_particle — Function
foreach_particle(body, backend, N, args::Tuple)

Runs body(i, args...) for i in 1:N on backend. body must be a kernel body in the sense of Backend.jl (no allocation, no dynamic dispatch, only args as input). The arguments are passed through kernel_view first; on a KABackend every array among them must live on that backend.

source
HyperCloudCore.device_grid — Function
device_grid(ArrayType, pg::ParticleGrid) -> ParticleGrid

A copy of pg whose component arrays are ArrayTypes (e.g. CuArray), with its own mutable metadata. The geometry (host closures and the boundary-condition map) and the reordering state are kept as they are: time steps, neighbour searches and boundary conditions then run on the device; reordering and adaptivity still need the host grid (see docs/src/gpu_roadmap.md).

source
HyperCloudCore.Growable — Type
Growable{T, V <: AbstractVector{T}} <: AbstractVector{T}
Growable(v::AbstractVector)
Growable{T}(ArrayType)

A per-particle buffer that can be reallocated to a larger capacity on any backend. parent(g) is the array currently held (V, e.g. a Vector or a ROCArray); Growable{T}(ArrayType) starts empty with storage of type ArrayType{T}. The wrapper indexes like its parent on the host and disappears under Adapt.adapt, so kernel bodies receive the raw array. reserve! fixes the capacity: a fixed buffer throws a CapacityError instead of growing.

source
HyperCloudCore.memory_estimate — Function
memory_estimate(pg::ParticleGrid, objs...; particles = pg.meta.N, slots = _slots_needed(pg)) -> NamedTuple

Bytes the growable buffers of the grid pg and of the time stepper and interpolators objs take for particles particles and slots neighbour slots, from the element types of the buffers (nothing is allocated):

bytes = particles * per_particle + slots * per_slot + fixed

fixed is the cell list of the bins, which depends on the domain and the interaction radius only. The moving-grid position buffers of a time stepper count when the grid has a mover. Returns (; bytes, per_particle, per_slot, fixed, particles, slots).

source
HyperCloudCore.reserve! — Function
reserve!(pg::ParticleGrid, objs...; limit, tolerance = 0.05, slots_per_particle = slots_per_particle(pg)) -> NamedTuple

Sizes every growable buffer of the grid pg and of the time stepper and interpolators objs once, to the largest particle capacity whose memory_estimate fits limit bytes less the fraction tolerance, with slots_per_particle neighbour slots per particle, and freezes the capacities: from then on ensure_capacity! throws a CapacityError instead of reallocating a buffer (a reallocation holds the old and the new array at the same time). Call it on the objects of the run that live on the device, right after construction and before the first step. slots_per_particle defaults to what the current neighbour lists occupy (the host grid's search is the "CPU pretest" of the run); raise it for a moving or adaptive run whose neighbourhoods grow. Throws an ArgumentError if the current particle count or neighbour lists do not fit the limit. Returns (; particles, slots, bytes, limit): the reserved capacities and the bytes the buffers occupy.

source
HyperCloudCore.slots_per_particle — Function
slots_per_particle(pg::ParticleGrid) -> Float64

Neighbour slots per particle that the current neighbour lists of pg occupy in its slot layout: the mean neighbour count on a host grid (row-major slots), the maximum neighbour count on a device grid (slot-major slots, max_nb slots per particle). The default of reserve!; raise it for a run whose neighbourhoods grow (moving or adaptive grids).

source
HyperCloudCore.CapacityError — Type
CapacityError <: Exception

Thrown by ensure_capacity! when a buffer whose capacity was fixed by reserve! would have to grow: the run has outgrown its memory budget (more particles, or more neighbours per particle, than reserved).

source

With the config-driven builders, context[:Storage] = ROCArray does the same: build_particle_grid returns the device grid, build_scheme and build_timestepper allocate on it. HyperCloud.jl's runners set it from the active backend, so set_backend!(ROCBackend()) alone moves a run_direct_simulation to the GPU.

Before a KABackend launch, check_resident walks the arguments and throws if an array does not live on that backend. Without it AMDGPU.jl would copy a host array to the device for the launch and silently drop the kernel's writes to it.

Rules for kernel bodies

  • Only args as input; no closures over the enclosing method's variables or static parameters.
  • No allocation, no Dict access, no @warn/@info, no exceptions with interpolated messages.
  • No dynamic dispatch: a runtime integer must not become a Val (use basis_count(D, degree) for the basis size of a runtime degree; the interpolator tables are selected by the generated dispatchers).
  • Arrays of arrays are not device-indexable; the stage arrays of the stepper are tuples for that reason.
  • No two particles write the same entry: neighbour-to-neighbour effects are written in the pull form (every particle gathers from its neighbours, as the MOOD forcing and expand_mask! do), which the symmetric neighbour relation allows and which keeps the result independent of the launch order.

Not yet on the device

partstatusplan
Morton reorderinghost (sort!); only runs when a grid is constructeddevice sort of the keys + permutation kernels, if ever needed between steps
particle adaptivity (events)host: detection, remap and compaction are serial per eventstays there; a kernel pre-check (_adapt_candidate!) skips the round trip when the cloud is well spaced, otherwise the grid is copied to the host, adapted, and copied back in place
data residencythe grid (device_grid), the stepper and MUSCL (storage) live on the device; static and moving grids step entirely there, neighbour search, MOOD, boundary conditions and the reductions included (the bc_map loop itself is on the host, its conditions are kernels); the builders follow context[:Storage]
growing a device gridevery per-particle and per-slot array of the grid is a Growable, so ensure_capacity! works on the device (adaptivity relies on it)
MLS volumeshost (point queries on the cell list)ShepardVolumes is on the device; MLSVolumes is a construction-time choice

Slot layout

The per-slot arrays (indices, weights, distances, and the stepper's gather buffers) have two layouts, selected by the element type of neighbor.ranges:

  • row-major (UnitRange slots, host grids): a particle's slots are contiguous, which is what the CPU's caches want;
  • slot-major (StepRange slots with stride N, device grids): slot k of all particles is contiguous, so the threads of a wavefront, which handle consecutive particles, read consecutive addresses. Every particle is padded to max_nb slots.

Kernel bodies only iterate ranges[i], so they serve both; device_grid converts between the layouts on host arrays, and the search writes whichever layout the grid has. On the RX 9070 XT the neighbour gather and flux passes, which are over 90 % of a step, run 2.7 times faster slot-major (a step 2.4 times, 250 000 particles, Float64).

Neighbour states are not buffered per slot either: the schemes read them from the stage state vector through the lazy views neighbour_states(U, pg) and neighbour_differences(U, pg, f_i) (src/NeighbourViews.jl), which index like the former gather buffers, so the MLS solves, limiters and MOOD criteria are unchanged. That removed a write and a read of every neighbour state per stage (a step 5 % faster in Float64, 8 % in Float32 at 250 000 particles).

Where the two passes stand now, 250 000 particles on the RX 9070 XT: in Float32 they stream their bytes at about the card's bandwidth (600 GB/s), so the next byte saver would be 32-bit neighbour indices (a third of the slot traffic in Float32). In Float64 they are bound by the card's Float64 arithmetic rate instead, which on this consumer part is a small fraction of its Float32 rate; the Float32 step is 2.5 times faster for that reason, and fewer bytes no longer help Float64 much. The moment matrices of a moving grid (_moment_body!, a fifth of a moving step) are recomputed at every stage because every stage has its own geometry; that is the scheme, not the layout.

The cell list is a counting sort: a kernel writes every particle's bin and bumps the bin's count with an atomic add, a prefix sum over the bins gives the segment starts, a second kernel scatters the particles into their segments through an atomic cursor, and a kernel per bin sorts its (small) segment by descending particle index. That last sort removes the only nondeterminism the atomics introduce, so the neighbour lists are identical on every backend and in every run, and in the order the former linked list produced. The counting and writing passes then walk the 3^D bins around a particle's bin as contiguous segments. The slot ranges come from a prefix sum of the counts (cumsum!) and two reductions (total and maximum), all on the device.

Testing on the device

HYPERCLOUD_BACKEND=amdgpu adds the device tests (test/gpu_tests.jl) to the suite. They need AMDGPU.jl, which is not a test dependency of the package (the registered release 2.9 supports KernelAbstractions 0.10), so they run in the test/gpu environment:

julia --project=test/gpu -e 'using Pkg; Pkg.instantiate()'     # once
HYPERCLOUD_BACKEND=amdgpu julia --project=test/gpu test/runtests.jl

Float64 throughput on consumer GPUs is a small fraction of Float32; everything is parametric in T.

Measured throughput

benchmark/step.jl, 2026-10-15: one RK3 step of linear advection with MUSCL3 on a uniform periodic cloud, RX 9070 XT (gfx1201, ROCm 7.2) against an i5-13600K limited to 8 cores, milliseconds per step. "both" is the Venkatakrishnan limiter plus MOOD (EPD1 / u1); "moving" runs a prescribed grid motion with a neighbour search every step.

caseparticlesFloat64 CPUFloat64 GPUFloat32 CPUFloat32 GPU
2-D, both, static250 000988.7713.5
2-D, both, static1 000 000387292849.8
2-D, both, moving1 000 0007445753923
2-D, both, 5 equations250 000323315.8
2-D, unlimited250 000715.32.1
2-D, limiter only250 000877.12.3
2-D, MOOD only250 000827.33.8
3-D, both, static262 1444684914
3-D, both, moving262 14499010633
3-D, both, static1 000 000145017648
3-D, both, moving1 000 000379118

Reading the table: a 5-equation system costs 3.6 times a scalar in Float64 on the GPU but only 1.7 times in Float32, the signature of the Float64 passes being arithmetic-bound and the Float32 passes bandwidth-bound. The limiter and MOOD each add about a third to a Float64 step; in Float32 MOOD costs more than the limiter because MOODu1(1e-6) sits below Float32 round-off on a smooth profile and triggers spurious iterations (scale δ with the precision). A 3-D particle costs about six times a 2-D one: three times the neighbours (about 65 against 20) and twice the basis length.