W3SNL1Step 4 of the ladder, done once, end to end, on the routine lesson 07
nominated: the DIA nonlinear interactions. Everything here is in
kokkos/; keep it open. The phase-1 rule is translate,
do not improve: same expressions, same order, same float32
arithmetic. The only thing that changes is who executes them (v,
kokkos/src/ww_kokkos/snl1_dia.cpp).
Source of truth: WW3/model/src/w3snl1md.F90 at 7.14
develop; line numbers (v, checked in
~/src/WW3, and in the header of the lab's standalone copy
before ADR-0010 removed it). The C++ keeps the Fortran's numbered
section comments:
| Fortran | Lines | Sections | Port |
|---|---|---|---|
INSNL1 |
483–786; body 602–774 | 1 quadruplet angles · 2 lambda weights · 3 directional indices · 4
frequency indices · 5 ranges · 6 allocate (W3DMNL) · 7
spectral addresses · 8 f**11 scaling · 9 interpolation
weights |
snl1_tables.cpp, make_tables(): host, once
per grid |
W3SNL1 |
115–473; locals 306–328; body 338–440 | 1 propagation constant · 2 auxiliary spectrum and arrays · 3 interactions on the extended spectrum · 4 source and diagonal | snl1_dia.cpp, snl1(): device, per
call |
Config and
Tablesww::snl1::Config (v, snl1_config.hpp) is
exactly the W3GDATMD inputs, under WW3's names:
nk, nth, xfr, dth, lam, snlc1, kdcon, kdmn, snls1, snls2, snls3, fachfe,
plus nspec(); SIG travels separately (host for
INSNL1, device for W3SNL1). PI,
TPI, TPIINV are built as
constants.F90 builds them, PI rounded to
REAL first (v). ww::snl1::Tables (v,
snl1_tables.hpp) is INSNL1's output:
nfr, nfrhgh, nfrchg, nspecx, nspecy, nspec,
dal1..3, awg[8], swg[8], and 33
device Views: ip[2][4] and im[2][4] of length
NSPECX, ic[8][2] of length NSPEC,
af11 of length NSPECX. Arrays of Views because
that is how W3SNL1 reads them, and because a View is a
copyable handle, so the whole struct is captured by value into the
kernel (v).
| Fortran | Port | Why |
|---|---|---|
ISP = ITH + (IFR-1)*NTH, all 1-based |
isp = ith + ifr*nth, all 0-based |
every table entry is the Fortran value minus one (v) |
IF3..IF6 clamped to 0 → addresses down to
1-NTH |
entries as low as -NTH are legal; the removed
L1_test_snl1_tables checked the window
[-nth, nspecy) explicitly |
INSNL1 section 7 (v) |
UE(1-NTH:NSPECY), SA1(1-NTH:NSPECX) … |
scratch of length upper + NTH; Fortran index
J at slot J-1+NTH; a table entry
j read at slot j+NTH |
the 1-NTH lower bound becomes slot 0 (v) |
A(NSPEC) one point per call |
a(nspec, npts) LayoutLeft, column
ip is one contiguous spectrum |
a batch of points per launch, memory a Fortran caller owns (v,
snl1_dia.hpp) |
One team per sea point
(TeamPolicy(npts, Kokkos::AUTO)), because the working set,
an extended spectrum plus nine helper arrays, is per point and belongs
in team scratch. The budget
(NSPECY + NTH) + 8·(NSPECX + NTH) + NSPEC floats is checked
against policy.scratch_size_max(0) before the launch; too
big throws std::runtime_error (v). Inside the team (v):
CONS from
KDMEAN: three flops, recomputed per thread; cheaper than a
barrier.TeamThreadRange over
nfr fills ue and con; a second
range zeroes slots [0, NTH) (the Fortran
DO ISP=1-NTH,0); team_barrier(). The tail
DO IFR=NFR+1,NFRHGH stays sequential in
ifr, a barrier per row: row IFR reads row
IFR-1.TeamThreadRange over
nspecx: EP1, EM1, EP2, EM2, then
SA1, SA2, DA1C..DA2M; team_barrier().TeamThreadRange over
nspec writes s(isp, ipt) and
d(isp, ipt).No reduction anywhere: every output element is written by one thread
from inputs no thread modifies, so WW_DETERMINISTIC changes
nothing and the result is bit-identical across backends and thread
counts; the launch is labelled "srce.snl1.dia" (v).
Two build facts decide whether "bit-identical" is true (v,
kokkos/src/ww_kokkos/CMakeLists.txt,
kokkos/README.md). Floating-point contraction is
off for ww_kokkos: -ffp-contract=off,
and --fmad=false for the device half under nvcc. GCC's
default fuses AWG1*UE(..) + AWG2*UE(..) into an FMA (one
rounding where the Fortran does two), and openmp-release
drifted 1.1e-5 relative from the fixture while serial-debug
was bit-identical; with contraction off all three presets reproduced the
Fortran bit for bit in the L1 tests of the day, and removing that line
is a physics change, not an optimisation. And x**n
is not std::pow: powi() and
pow11() reproduce gfortran's multiplication chains, and
they have to be the chains of the optimisation level WW3 is built at:
AF11 = (SIG*TPIINV)**11 is x^3*x^8 at
-O0 and x^5*x^6 at -O3, which is
why a fixture built at -g matched while WW3 differed by
1e-7 until pow11() followed -O3 (v)
(docs/log/2026-10-14-snl1-bitwise-af11.md).
W3KOKKOSMDkokkos/src/fortran_iface/ is the boundary:
ww_kokkos_c.hpp declares the C ABI,
snl1_shim.cpp implements it, w3kokkosmd.F90
mirrors it in ISO_C_BINDING blocks (v).
| Entry point | Does |
|---|---|
int ww_kokkos_init(int comm_f) |
starts Kokkos if nobody has; idempotent; comm_f
accepted and ignored in phase 1 (device from
WW_KOKKOS_DEVICE_ID); reads
WW_KOKKOS_SNL1 |
void ww_kokkos_finalize(void) |
releases buffers; finalises Kokkos only if it started it |
int ww_snl1_init(nk, nth, xfr, dth, lam, snlc1, kdcon, kdmn, snls1, snls2, snls3, fachfe, sig) |
make_tables once per grid; sig copied, not
borrowed |
void ww_snl1(npts, a, cg, kdmean, s, d) |
unmanaged LayoutLeft host views over the pointers,
deep_copy into persistent device buffers grown on demand,
kernel, fence, copy out; npts == 0 is a legal
no-op |
int ww_snl1_enabled(void) |
1 iff WW_KOKKOS_SNL1=1 at init |
int ww_snl1_last_error(void) |
status of the last call, 0 on success |
Three things a naive extern "C" wrapper gets wrong and
this one does not (v, snl1_shim.cpp): ownership of the
runtime (a test may own it); lifetime (a push_finalize_hook
drops the Views inside finalize, never at process exit);
errors (nothing throws across the boundary: every entry is a
try/catch that records a code).
W3KOKKOS_SETUP turns ww_snl1_enabled() into
LOGICAL :: KOKKOS_SNL1 once, so the inner loop reads a
logical; every REAL is REAL(C_FLOAT) (v).
Until 2026-10-14 L1_test_snl1_shim.cpp drove the raw C API
on the fixture with poisoned output buffers, so a forgotten copy-out
failed loudly, and shim_roundtrip made the same call from
Fortran (v, PORT_STATUS.md, "L1 / L2"); both were removed
with the port's other tests (next section). The shim is now checked only
by the WW3 run that calls it.
Since 14 October 2026 the Kokkos code is validated only by WW3's
regression tests, run inside WW3 with the Fortran routine and with the
Kokkos routine, on the CPU, and compared within
kokkos/tools/nccmp-tol/tolerances.txt (ADR-0010;
the owner: "kokkos code should NOT have its own tests, hard rule") (v).
If WW3's regtests agree for the Fortran W3SNL1 and the
Kokkos snl1, the routine is ported; nothing else is asked
of it. The reason is that one reference is enough: the Fortran WW3
ships, through the harness NOAA runs. A second copy of the reference
kept next to it can drift away unseen (ADR-0010,
Alternatives).
Earlier versions of this lesson taught the L1 tests the port had until that day. They are history now, kept here because the numbers they produced are still quoted:
| Removed on 2026-10-14 | What it was | What it showed |
|---|---|---|
snl1_ref.F90, gen_snl1_fixture.F90,
snl1_sea_state.F90 |
a verbatim standalone copy of
W3SNL1/INSNL1, and the generator that wrote a
JONSWAP (10 m/s, 100 km, γ = 3.3) × cos² sea state at 1000, 50 and 10 m
with the Fortran's answers into snl1_nk25_nth24.bin |
the fixture, 107 892 bytes (v,
kokkos/tests/fixtures/README.md) |
L1_test_snl1_tables |
all 32 address tables exactly; dal1..3,
awg, swg, af11 to 1e-6
relative |
exact |
L1_test_snl1_dia |
S and D on three points to 1e-5 relative;
zero in, zero out; A×2 → S×8,
D×4; four relaunches bit-identical |
max relative error 0.0 on all three presets, 2026-09-15 (v,
kokkos/PORT_STATUS.md) |
L1_test_snl1_shim, shim_roundtrip |
the same parity through the C ABI and from Fortran, plus the error paths | 0.0 |
gen_snl1_ww3lib.F90,
just l1-crosscheck |
a cross-check against the real W3SNL1 from a WW3
library |
never run |
The tests and their sources are in git history
(git show f721736:kokkos/tests/L1_test_snl1_dia.cpp). The
fixture stays, frozen and no longer regenerable, as the input of the
kernel benchmark ww_bench_snl1 and of
proof/snl1_cons; fixture_io.{hpp,cpp} is still
its one reader (v, kokkos/tests/fixtures/README.md). What
the change costs is said in the ADR: a defect now shows up in a model
run, minutes long, instead of in one call; and the CUDA kernel has no
check until a regtest runs on the GPU, which only the owner may
supervise.
PATCH.md: what
changes in the WW3 callerw3kokkosmd.F90 is written to be dropped into
WW3/model/src unchanged (v, its header). The caller side is
kokkos/src/fortran_iface/PATCH.md: a hunk-by-hunk recipe
for a fork branch, with real line numbers, not applied in this repo (v).
Four hunks (v):
w3srcemd.F90: inside the
W3_KOKKOS guard, USE W3KOKKOSMD,
USE W3SERVMD, ONLY: EXTCDE and
USE W3ODATMD, ONLY: NDSE (neither is otherwise in scope in
W3SRCE); a one-element KDM_K(1) local, because
KDMEAN there is the expression WNMEAN*DEPTH;
and at the single call site (lines 1258–1264)
IF (KOKKOS_SNL1) THEN CALL WW_SNL1(1, SPEC, CG1, KDM_K, VSNL, VDNL) … ELSE CALL W3SNL1(…) END IF.
The call sits in a named !$OMP CRITICAL:
W3SRCE runs inside an !$OMP PARALLEL region
and the shim has one unlocked context, so phase 1 serialises it, slower
than the Fortran when threaded; the correctness step, not the fast one.
The WW_SNL1_LAST_ERROR() /= 0 check followed by
EXTCDE is not optional: a
void C call has no other channel, and a silent failure is a
plausible-looking wrong forecast. CG1 is already
CG(1:NK,ISEA), sliced by W3WAVE, so it passes
straight through.w3initmd.F90, after
W3IOGR('READ'): WW_KOKKOS_INIT(-1),
W3KOKKOS_SETUP, EXTCDE when
IMOD > 1 (one spectral grid per process:
ww_snl1_init replaces the tables), then
WW_SNL1_INIT(…, SIG(1)). SIG(1), not
SIG: W3GDATMD allocates
SIG(0:MK+1) and the assumed-size C dummy would start at bin
0: every quadruplet one bin low, smooth, plausible and wrong; the lab's
SIG(NK) reference cannot catch it.switches.json gains a
KOKKOS category with
"build_files": ["w3kokkosmd.F90"] and
"requires": ["NL1"]. src_list.cmake needs
no change: listing the file there would compile it
unconditionally and break every non-Kokkos build.model/src/CMakeLists.txt links
ww_kokkos under -DWW_KOKKOS=ON and refuses the
option without the switch.Why the fork carries it: WW3's physics is frozen except for shims
(AGENTS_KOKKOS §5), and the lab pins WW3 as a submodule
fork (just src-init, src-pr,
src-sync (v, justfile)), so the patch lives on
a branch the pin can point at, and upstream stays untouched.
just smoke-ww3-gpu # scripts/ww3-gpu.sh --ww3-gpu smoke --cpu: regtest ww3_ts1
just full-ww3-gpu # scripts/ww3-gpu.sh --ww3-gpu full --cpu: bench_small, 9 520 sea points
just ww3-gpu-matrix # regtests/ (CMake/CTest): one test per serial NL1 entry of matrix.base (WFIP-0003)This is the port's only test (ADR-0010).
scripts/ww3-gpu.sh --ww3-gpu builds WW3 from the
WW3/ submodule, the h0ffmann/WW3 branch
hoff/kokkos-snl1 where the patch below is applied, with the
KOKKOS switch, and runs the case twice from the same
executable, WW_KOKKOS_SNL1=0 (the Fortran DIA) and
=1 (the Kokkos DIA); nccmp-tol compares the
gridded output field by field. The Kokkos run is then compared pair to
pair with --ww3, WW3 built from the same source without the
switch (v, scripts/ww3-gpu.sh header). It runs on the CPU,
Kokkos on OpenMP: a GPU run of WW3 is the owner's, supervised, and no
agent starts one. Measured on 2026-10-14: hs within
9.879e-8 relative on ww3_ts1 and within 1.832e-7 on
bench_small, both PASS at the 1e-4 tolerance, not
byte-identical. The cause was one table,
AF11 = (SIG*TPIINV)**11, computed with the -O0
chain of x**11; with the -O3 chain, on
2026-10-15, both runs became byte-identical, hs max rel 0
(v, kokkos/port_report/W3SNL1_KOKKOS.md, "How it was
regtested"). just ww3-gpu-matrix takes the same comparison
to WW3's own harness, run_cmake_test, with WW3's
test.comp beside nccmp-tol: after the fix, 0
differing files on all 100 serial NL1 entries (v,
docs/data/hoff_matrix_2026-10-15.tsv), and on all 25
shared-memory OpenMP entries with OMP_MAX_ACTIVE_LEVELS=2
(v, docs/data/hoff_matrix_omp_2026-10-15.tsv). Both tables
were written by the shell script that preceded the driver; since
2026-10-15 just ww3-gpu-matrix is a CMake/CTest project in
regtests/, one CTest test per matrix.base
entry, labelled serial or omp. CTest there is
only the runner: each test is WW3's own regtest, run with the Fortran
DIA and with the Kokkos DIA, so the port still has no tests of its own
(ADR-0010).
The older harness, just l2 ww3_ts1
(kokkos/tests/L2_replay.sh), does the same pairing on a run
prepared by just rt, in work_a and
work_b, and appends the table to
kokkos/PORT_STATUS.md (v). It needs a WW3 build with the
patch; on an upstream build both runs take the Fortran path and the
table shows zeros, which proves the harness and not the kernel.
kokkos/PORT_STATUS.md is the ledger, one row per
routine: WW3 file and lines, phase, shim, L1 parity (history), L2
replay, Serial / OpenMP / CUDA ms per call, notes (v). The
W3SNL1 row comes from ww_bench_snl1
(kokkos/tests/bench_snl1.cpp, not a test: 1 000 sea points,
20 timed calls after 3 warm-ups, median of three runs), on an Intel
i9-14900 + RTX 4090, Kokkos 5.2.0, GCC 15.3 (the owner's workstation,
not the CI runner) (v):
| backend | through the shim, ms/call | kernel only, ms/call | vs Serial |
|---|---|---|---|
| Serial (OpenMP backend, 1 thread) | 25.88 | 24.96 | 1.0x |
| OpenMP, 32 threads | 5.95 | 5.02 | 4.4x |
| CUDA, RTX 4090 | 0.75 | 0.047 | 34x |
Two things these numbers are not (v). Not the fastest the kernel can
go: every row is the -ffp-contract=off parity build, and
turning contraction on would invalidate every regtest verdict. These
timings were taken on 2026-09-15, when the parity that licensed them was
the L1 test of the same build (v,
bench/results/snl1-i9-14900-rtx4090-2026-09-15.json); a new
timing needs a passed WW3 regtest replay on the same commit. And not
what WW3 would see today: the phase-1 call site passes
NPTS = 1, and on CUDA 94 % of the 0.75 ms call is
host↔︎device copy: the entire argument for phase 2, and why "34x" is not
the model's speed-up.
docs/AGENTS_KOKKOS_202609.md §1.5, as amended by
ADR-0010: (1) the kernel with a heritage header naming the WW3 routine,
(2) the bind(C) shim and Fortran interface with the
argument table, (3) withdrawn: it was an L1 test with tolerances stated
and justified, and the port no longer has tests of its own, (4) the WW3
regtest replay and the regtest matrix on the CPU within
tolerances.txt, (5) a timing line in
PORT_STATUS.md, (6) a property checked inside a regtest run
where physics allows: for Snl, action conservation ⚠ (not
yet checked in a run; the zero-in/zero-out and cubic-scaling checks that
stood in for it went with the L1 tests). Items 1, 2 and 5 are in the
tree, and 4 passed on ww3_ts1 and bench_small
(v); the matrix has not run ⚠. Sheet:
exercises/ex13_port.md, solution
exercises/solutions/ex13_compare.sh.