The GPU path of vlasov_maxwell (LUMI-G, HIP)¶
What was ported to the device, what was deliberately left on the host, the measurement that decided each, the CPU/GPU parity tolerances and where they come from, and the limitations. Every number is from a compute-node run and carries its Slurm job id; nothing here was measured on a login node.
This page covers issue #84’s “CPU / MPI / GPU path” requirement and its
acceptance criterion “CPU/GPU parity on a small case, to a stated
tolerance”. The physics and the numerics are documented by the application’s
own headers; start at
apps/vlasov_maxwell/include/vlasov_maxwell/parameters.hpp.
The files¶
File |
What it is |
|---|---|
|
kernel declarations, the device-resident brick, and |
|
every kernel; the only translation unit hipcc compiles |
|
|
|
|
The CPU application is untouched. advect.hpp, moments.hpp, maxwell.hpp
and step.hpp remain the definition of what the application computes.
vlasov_run reaches the same kernels behind --device=hip (optional
--device-x=0 leaves the spectral (x)-shift on the host). The two extra
binaries answer different questions: vlasov_hip_parity whether the device
computes the same thing as the host, and vlasov_hip_cost where the time
goes.
Build and run¶
# HIP tree (the flag is --with-rocm; there is no --hip)
./scripts/build.sh --machine=lumi --with-rocm --no-submit --no-test --jobs=32 \
--cmake-arg=-DOpenPFC_BUILD_TESTS=ON \
--build-dir=/flash/project_462001519/$USER/build/openpfc-lumi-rocm
# one GCD
srun -n 1 --gpus-per-node=8 vlasov_hip_parity --nx=32 --nvx=32 --nvy=32 --steps=20
srun -n 1 --gpus-per-node=8 vlasov_hip_parity --science=landau
srun -n 1 --gpus-per-node=8 vlasov_hip_cost --sizes=64,128,192,256 --reps=5
vlasov_hip_parity exits non-zero if any check fails, so it can be wired to
ctest as it stands. HIP builds register two tests: vlasov-hip-parity
(the 20-step operator/integrated comparison, kept short) and
vlasov-hip-science-rate (--science=landau, (32^3), (t=12), host vs
device fit_envelope_rate on mode_ex, timeout 900 s, RUN_SERIAL).
Job 21943612 (dev-g, 1 rank, (32^3), (t=12), 240 steps,
(\Delta t=0.05)): (\gamma_\mathrm{cpu}=\gamma_\mathrm{hip}=-0.1514064581),
relative difference (6.040\times10^{-13}) against a (10^{-6}) tolerance.
That is the same 1-rank Landau (\gamma) as job 21942462 (CPU 1-vs-N).
Both binaries need hipFFT
(find_package(hipfft) in the application’s CMakeLists.txt); the CPU
build never looks for it.
On LUMI-G use whole nodes (--ntasks-per-node=8 --gpus-per-node=8) and the
CPU mask from docs/lumi_slurm/tungsten_hip_scaling.sbatch. Do not set
ROCR_VISIBLE_DEVICES: the binaries call bind_local_device(local_rank) and
need every GCD visible.
What runs where¶
The Strang step of step.hpp has five phases:
phase |
operator |
work |
where |
|---|---|---|---|
A |
|
|
device (hipFFT) |
B |
|
|
device |
C |
|
|
device |
D |
|
one pass over the brick |
device |
E |
Gauss, Ampère and the ETD2 transverse pair |
|
host |
Two further pieces are split rather than placed:
The velocity-space halo exchange stays an
MPI_Sendrecvpair on host-staged slabs. Av_yghost slab ishw N_x N_vxdoubles — kilobytes against a gigabyte brick — and staging it through host memory costs less than depending on the site’s MPI being GPU-aware. Measured below ashalo.The Lagrange coefficients. The host computes each advected line’s departure cell and fractional offset, because that needs the fields and, on
v_y, the collective halo guard that has to be able to throw; the device expands each fraction intopweights, because that isp(p-1)divisions per line and divisions were what the host was actually spending its time on. Measured below ascoeffs.
What was not ported, and why¶
Phase E, the field solve. E_x, E_y and B_z are 1-D arrays of N_x
doubles — 8 kB at the resolution this application targets, against a 2 GB
distribution. The measured cost is 0.13 ms per step at 256³ — 0.012% of
the host step and 1.9% of the fully device-resident one — and the phase is
roughly a dozen short 1-D transforms, so the kernel launches alone would be
the same order as the whole phase. This is not a deferral; there is nothing
there to win.
The v_y halo exchange, as a GPU-aware MPI transfer. On one GCD the
host-staged exchange is 0.02 ms per step at 256³, 0.3% of the device step,
and a device-pointer send would save a fraction of that while making the
application depend on a property of the site’s MPI build. On eight GCDs
the same exchange is 1.05 ms per step and half the step at 128³, so the
number that says “do not bother” on one GCD says something else on eight —
and the honest reading is that most of the eight-GCD cost is skew and slab
staging together, which a GPU-aware send would only partly remove. Not done;
measured; written down rather than assumed either way.
vlasov_run itself. The science driver takes --device=hip (and
--device-x=0|1). DeviceStepper is a drop-in for the advance /
deposit_all pair; a production Landau or Weibel run can stay on the device
for the whole step.
The scope decision, in the order it was actually made¶
The instruction was to measure before porting, and the measurement was made twice, because the first answer stopped being the relevant one as soon as it was acted on.
Round one — where does the host step go? At 256³, phases B, C and D are
82% of the host Strang step and phase A is 9%. So B, C and D are the
right things to port, which is what the issue’s minimum asked for, and the
measurement agreed with it rather than merely permitting it.
Round two — where does the ported step go? With B, C and D on the
device and A left on the host, the step at 256³ fell from 1.04 s to 0.127 s
(8.2×) and its composition inverted: phase A on the host was now 77% of
what remained and the four whole-brick bus transfers it forces were another
19%. The three kernels the port existed for were 2.6% of it.
That second number is what justified porting phase A as well — not “an FFT
would be faster on a GPU”, which was never in doubt, but “leaving one O(N³)
phase on the host pins the distribution to the bus and caps the whole
exercise at 8×”. With phase A on the device the brick never leaves it, the
transfers disappear entirely, and the speedup at 128³ goes from 6.7× to
94×. Both configurations are still buildable and both are measured, because
the comparison is the argument.
The same logic applied once more, at smaller scale, to the coefficient setup.
With the weights computed on the host and uploaded, coeffs was 5.67 ms per
step at 256³ (job 21932623) — p(p-1) divisions per advected line, and
more than every device kernel in the step put together. Moving the weight
expansion to the device while leaving the departure offsets on the host took
it to 0.64 ms (job 21933310). The offsets did not move, because they carry
the collective halo guard, and because they are cheap: what was expensive was
the divisions.
The measurement¶
Provenance¶
job id |
partition |
what |
|---|---|---|
|
|
first cost sweep, phase A on host only; and the first parity run, whose two failing columns were the tolerance bug described below |
|
|
bring-up of the device phase A |
|
|
full sweep with the final binaries, used as a cross-check |
|
|
every number in this section: six parity configurations and the full cost sweep with the final binaries |
MI250X (gfx90a), ROCm through LUMI/25.09 partition/G cpeGNU, double
precision throughout, Release, interp_order = 5 unless stated. The
binaries were copied to a private path before submission so that a
concurrent rebuild could not swap them under a running job. Every run used
the LUMI-G 8-GCD CPU mask from docs/lumi_slurm/tungsten_hip_scaling.sbatch.
The standard-g job 21933310 and the dev-g job 21933340 were the same
binaries on the same shapes: the full Strang step at 256³ came out at
1.0363 s vs 1.0297 s on the host and 6.616 ms vs 6.718 ms on the device, and
every parity number agreed to the digit. The partition is not a variable.
Where the host step goes¶
One rank, one core for the host phases, a Weibel-like state, measure_mass
off. Seconds per call; A, B and D are called twice per Strang step, C once.
phase |
|
|
|
|
|
|---|---|---|---|---|---|
A |
0.000828 |
0.005463 |
0.02112 |
0.04721 |
0.02748 |
B |
0.002986 |
0.022891 |
0.07866 |
0.18933 |
0.09243 |
C |
0.002156 |
0.018002 |
0.06644 |
0.15503 |
0.07859 |
D moments |
0.002266 |
0.018479 |
0.06577 |
0.15703 |
0.07776 |
D moments, memory order |
0.002202 |
0.017984 |
0.06194 |
0.14812 |
0.07449 |
E fields |
0.000024 |
0.000056 |
0.005494 |
0.000127 |
0.000057 |
full Strang step |
0.01585 |
0.12418 |
0.44751 |
1.03634 |
0.52452 |
At 256³ the composition of the measured step is B 37%, D 30%, C 15%,
A 9%, E 0.01% — so B, C and D are 82% and that is the first measurement.
It says to port B, C and D, which is what the issue’s minimum asked for; the
measurement agreed with it rather than merely permitting it.
Two host-side observations that are not about the GPU at all:
N_x = 192costs 5.5 ms in phase E.maxwell.hpp’sdftis a radix-2 FFT for power-of-two lengths and an honestO(N²)direct sum otherwise, and the 1-D field solve runs several transforms per step. AtN_x = 192that turns a free phase into 64% of the fully device-resident step. It is a property of the CPU application, not of the port, and it is why the192³speedup below is an outlier. Anyone running this application at a non-power-of-twoN_xshould know.The host reduction runs at 0.9 GB/s, and the traversal order is not why.
moments.hppiterates its view withv_yinnermost, which in the padded x-fastest brick is a stride ofnpx npydoubles — half a megabyte at256³— so it looked like the obvious suspect. The cost driver therefore times a second, memory-order traversal of exactly the same cells (OrderedViewinvlasov_hip_cost.cpp) and it is only 2–6% faster: 0.1481 s against 0.1570 s at256³. The reduction is bound by thelogin the entropy column, not by its memory order. Worth knowing before anyone “fixes” the loop.
Where the ported step goes¶
Same runs, one GCD, seconds per call.
phase |
|
|
|
|
|
|---|---|---|---|---|---|
A |
0.000040 |
0.000191 |
0.000555 |
0.001338 |
0.000667 |
B |
0.000076 |
0.000170 |
0.000384 |
0.000741 |
0.000424 |
C |
0.000105 |
0.000273 |
0.000678 |
0.001374 |
0.000755 |
D moments |
0.000077 |
0.000135 |
0.000265 |
0.000511 |
0.000310 |
H2D whole brick |
0.000156 |
0.000871 |
0.002708 |
0.006064 |
0.003170 |
D2H whole brick |
0.000161 |
0.000887 |
0.002753 |
0.006157 |
0.003221 |
Effective bandwidths at 256³, counting the payload each phase must move —
(p+1) brick passes for a gather, one for the reduction, ten for phase A’s
pack / transform / multiply / transform / unpack chain — are 1003, 1087,
586 and 263 GB/s for A, B, C and D, against an MI250X GCD’s ~1.3 TB/s
peak. The bus runs at 22 GB/s each way, which is the number the next section
turns on.
The two configurations, end to end¶
shape |
host step |
A on host (hybrid) |
A on device |
hybrid |
device |
|---|---|---|---|---|---|
|
0.01585 s |
0.003345 s |
0.000521 s |
4.7× |
30.4× |
|
0.12418 s |
0.018548 s |
0.001324 s |
6.7× |
93.8× |
|
0.44751 s |
0.062115 s |
0.008615 s |
7.2× |
52.0× (see |
|
1.03634 s |
0.127003 s |
0.006616 s |
8.2× |
156.7× |
|
0.52452 s |
0.070697 s |
0.003570 s |
7.4× |
146.9× |
The middle column is what the issue’s minimum scope produces: gathers and
reduction on the device, spectral shift on the host. It is worth 8× and no
more, and the split says exactly why. Seconds per Strang step at 256³:
item |
A on host |
A on device |
|---|---|---|
A |
0.09828 (77%) |
0.00265 (40%) |
B + C |
0.00217 |
0.00216 |
D |
0.00109 |
0.00102 |
coefficients (host offsets, device weights) |
0.00081 |
0.00064 |
fields (host) |
0.00013 |
0.00013 |
|
0.00002 |
0.00002 |
H2D + D2H |
0.02451 (19%) |
0 |
Leaving one O(N³) phase on the host does not cost only its own time; it
costs its own time plus four whole-brick bus crossings per step, and
together those are 96% of the hybrid step. That is the measurement that
justified porting phase A, and it is a different measurement from the one
that justified porting B, C and D.
Eight GCDs¶
Same binary, --ntasks-per-node=8 --gpus-per-node=8, global grid split on
v_y, so the host column is also eight-way parallel and the comparison is
at equal rank count.
shape |
per rank |
host step |
A on host |
A on device |
|---|---|---|---|---|
|
16 |
0.02050 s |
0.004690 s (4.4×) |
0.001493 s (13.7×) |
|
32 |
0.14177 s |
0.022354 s (6.3×) |
0.002934 s (48.3×) |
The kernels keep their single-GCD speed, but the step does not scale with
them, and the split says where it goes. At 256³, going from one GCD to
eight takes phase D from 1.02 ms to 0.50 ms per step — a factor of two, not
of eight — while the v_y halo goes the wrong way, from 0.02 ms to
1.05 ms. Those are the two places the step synchronises: an MPI_Allreduce
over the packed moment buffer and a Sendrecv pair on staged slabs. At
128³ on eight GCDs each rank owns sixteen v_y cells and the halo alone
(0.73 ms) is half the step.
This is a property of the v_y-only decomposition, not a defect of the
port. One GCD does 256³ in 6.6 ms, so eight of them have under a
millisecond of arithmetic each and no amount of kernel tuning hides a
collective. A production run should give each GCD a large slab — which is
what the N_vy / halo rank cap pushes towards anyway.
Parity¶
vlasov_hip_parity asks two separate questions.
1. Operator parity. One call of each device operator against one call of the host operator on bitwise-identical input. A statement about the kernels.
2. Integrated parity. n Strang steps from the same initial condition,
comparing f, rho, J, all three field components and every ledger
column. A statement about error growth, which can fail while (1) passes,
because a Vlasov system amplifies round-off through its instabilities.
The case is Weibel-like — a bi-Maxwellian with T_y/T_x = 9, a seeded B_z
and a density perturbation — deliberately, because with zero fields every
velocity shift is exactly zero, the Lagrange weights collapse to a Kronecker
delta, and every gather in the test becomes a memcpy that would agree
bitwise no matter how wrong the kernel was.
The tolerances and where they come from¶
Let u = 2^-53 = 1.11e-16.
quantity |
tolerance |
derivation |
|---|---|---|
gathers B, C |
|
|
spectral shift A |
|
Higham’s `c log2(N) u |
moment profiles, scalars |
|
worst-case error of a sequential sum is |
|
0 |
extrema do not depend on the reduction order, so these are required to be bitwise equal |
|
|
per step: the moments’ error reaches |
|
|
the fields are a handful of linear operations on the deposited moments |
Two of these deserve their own paragraph.
kappa is measured, not assumed. It is 1.000000 for the test case,
because f is a distribution function and is non-negative to within the
interpolation’s undershoot. A run where it is not is a run whose moments are
ill-conditioned, and the driver prints the number so a reader can see which
kind of run they are looking at.
A signed sum must be judged against the magnitudes of its terms, not
against its own value. sum v_x f over a distribution symmetric in v_x
cancels to zero to fifteen digits, so J_x is ~1e-18 while the terms
summed are O(1). Dividing the difference by 1e-18 and calling the result
a relative error asks the two paths to agree to 1e-30 and tests nothing —
it was, in fact, the first version of this driver, and it failed on exactly
those two columns while everything it could actually measure passed. The
honest scale is sum |v_x f| <= v_max sum f, i.e. v_max times the density
profile, and that is what the driver uses; the same correction applies to the
ledger’s total momentum, which is zero by construction in every benchmark
here.
The gathers are not bitwise, and the reason is on the host side¶
The design aims at bitwise agreement for B and C: identical weights,
identical accumulation order, #pragma clang fp contract(off) in the
kernels. The measured bitwise fraction is 0.83 for B and 0.75 for C, with a
worst difference of exactly one ulp of max|f|.
The remaining ulp is the host compiler contracting row[i] += w[m] * f(...)
into an FMA — the LUMI cpeGNU build targets a machine that has one, and
GCC’s default is -ffp-contract=fast. The host rounds once where the device
rounds twice. The driver therefore reports the bitwise fraction alongside the
tolerance rather than asserting bitwise and quietly relaxing the assertion
later, and the p u bound above is exactly one rounding per stencil point,
which is what that difference is.
Phase A is 0.90 bitwise against FFTW, also at one ulp, which is a stronger
agreement than the eps_fft bound requires; the bound stands because it is
the one that follows from the arithmetic rather than from this pair of
libraries on this machine.
Results¶
Six configurations, all from job 21933310 (standard-g), all PASS,
and reproduced to the digit by job 21933340 on dev-g. u = 1.11e-16
throughout; kappa measured as 1.000000 in every case.
configuration |
ranks |
|
|
A bitwise |
B bitwise |
C bitwise |
|
tolerance |
used |
|---|---|---|---|---|---|---|---|---|---|
|
1 |
2.27e-13 |
2.51e-14 |
0.899 |
0.831 |
0.753 |
2.25e-15 |
3.74e-11 |
6.0e-5 |
|
1 |
2.27e-13 |
0 |
— |
0.831 |
0.753 |
2.36e-15 |
3.64e-11 |
6.5e-5 |
|
4 |
4.55e-13 |
2.51e-14 |
0.918 |
0.827 |
0.754 |
3.19e-15 |
7.38e-11 |
4.3e-5 |
|
8 |
9.10e-13 |
2.51e-14 |
0.916 |
0.831 |
0.750 |
2.63e-15 |
1.47e-10 |
1.8e-5 |
|
1 |
9.10e-13 |
4.26e-14 |
0.715 |
0.821 |
0.752 |
4.64e-15 |
1.47e-10 |
3.2e-5 |
|
1 |
9.10e-13 |
3.44e-14 |
0.430 |
0.812 |
0.752 |
5.37e-15 |
1.47e-10 |
3.7e-5 |
“used” is the measured divergence as a fraction of its tolerance: the worst case uses 0.006% of the budget, so the bound is loose by four orders of magnitude — which is what a worst-case round-off bound is supposed to be, and which is why the table also prints the measured number rather than only the verdict.
Every operator-level check passes with the same kind of margin. At 32³,
one rank, phase A on the device:
check |
measured |
tolerance |
|---|---|---|
A |
1.78e-15 |
3.90e-13 |
B |
1.78e-15 |
8.63e-15 |
C |
1.78e-15 |
8.63e-15 |
D |
2.00e-15 |
2.28e-13 |
D |
4.0e-18, 6.9e-18 |
2.73e-13 |
D particle number |
1.53e-13 |
2.86e-12 |
D entropy |
6.61e-13 |
5.86e-12 |
D |
0 |
0 (required bitwise) |
1.78e-15 is one ulp of max|f| = 15.5 in all three transport rows, so the
gathers and the spectral shift each differ from the host by a single
rounding and nothing more.
Integrated over 20 steps, the fields track the distribution: rho agrees to
4.3e-12 relative, E_x to 1.2e-14, B_z to 2.5e-15, and the Gauss
residual — the sharpest diagnostic the application has — reads 2.8718e-05
on both paths, agreeing to 6.1e-13 absolute. The per-step trace shows the
divergence growing like sqrt(n) rather than exponentially over this window,
which is what a stable transport problem seeded at round-off should do; a
longer run through Weibel saturation would not, and this driver does not
claim otherwise.
Non-power-of-two N_x weakens the spectral shift’s agreement, as
expected. At N_x = 48 rocFFT and FFTW take different factorisations and
the bitwise fraction of phase A falls from 0.90 to 0.43 — while the worst
difference stays at one ulp and the tolerance is untouched. Worth knowing if
someone reads the bitwise column as a quality metric: it is a diagnostic of
how the two libraries differ, not of how much.
One column of an earlier version of this driver was wrong, and it is worth
recording why. J_x and J_y were compared against their own magnitude,
which for a v_x-symmetric distribution is 1e-18 of pure cancellation; the
test demanded agreement to 1e-30 and failed on job 21932623 while every
column it could actually measure passed. Judging a signed sum against the
magnitudes of its terms instead — v_max times the density profile — is the
fix, and it is a derivation, not a loosened constant.
Limitations¶
--device=hipis the science path.vlasov_runwiresDeviceStepperbehind that flag. The remaining device limitations are the ones below, not a missing switch.One species is exercised.
DeviceStepperallocates a brick per species and loops over them, but every measurement and every parity run here is the default single electron species. A mobile-ion run is untested on the device.std::login the entropy column. The device and host libm may differ by one ulp per cell. That is inside the factor of 2 ineta, but it means the entropy column can never be bitwise and is not asked to be.Memory. The device path holds two padded bricks per species (the gathers cannot be done in place), plus phase A’s packed real buffer and its complex spectrum — together about
4.1 x N_x N_vx N_vy x 8bytes per species:550 MBat256³,4.4 GBat512³, and8.8 GBfor the issue’s1024 x 512 x 512target, whose distribution alone is 2.1 GB. That fits one MI250X GCD’s 64 GB several times over, so on this machine theN_vy / halorank cap and not memory is what limits the application.N_xmust factor well. hipFFT is happy with powers of two and with small-prime sizes; a large primeN_xwill plan, but slowly. The host fallback (--device-x=0) exists and is measured.No device path for the field solve, and no reason to add one. Stated above with its number, so that “it is on the host” is a measurement rather than an omission.
Single-node only. Parity was run at 1, 4 and 8 GCDs on one node; no multi-node device run was made. The
v_ydecomposition is one-dimensional and the exchange is a single-neighbourSendrecv, so nothing about it changes off-node, but that is an argument and not a measurement.