Communication environment — the ONLY backend.
Roundabout no longer carries a hand-written single-process stub beside an
MPI implementation. This module always compiles and always talks to
pic_mpi_lib; whether that resolves to a real MPI library or to
pic-mpi’s serial backend is pic-mpi’s build-time choice
(PIC_ENABLE_MPI), invisible here. The single-rank contract every
collective call site must honour (never reach point-to-point or a
collective at one rank: take the local path) is spelled out in the
rdb_ocean_halo module header and at each halo_allreduce_*.
!! Communication environment — the ONLY backend. !! !! Roundabout no longer carries a hand-written single-process stub beside an !! MPI implementation. This module always compiles and always talks to !! `pic_mpi_lib`; whether that resolves to a real MPI library or to !! pic-mpi's serial backend is pic-mpi's build-time choice !! (`PIC_ENABLE_MPI`), invisible here. The single-rank contract every !! collective call site must honour (never reach point-to-point or a !! collective at one rank: take the local path) is spelled out in the !! `rdb_ocean_halo` module header and at each `halo_allreduce_*`. module rdb_comm_env !! Wraps pic_mpi_lib for MPI init/finalize, rank/size queries, !! broadcast, abort, and per-node GPU binding. !! !! Two-phase initialisation: !! 1. comm_env_init() — basic MPI, cache world rank/size !! 2. comm_env_setup_roles(use_io_server) — split comms, assign roles, bind GPU !! !! When use_io_server is enabled, the last rank on each node becomes !! a dedicated I/O server (no GPU, no solver). All solver/decomp/halo !! operations use compute_comm (excludes I/O ranks). When disabled, !! compute_comm = comm_world (current behavior, no overhead). !! !! `comm_env_push_compute_comm` / `comm_env_pop_compute_comm` are a !! TEST/TOOLING-ONLY seam: a one-level override of what !! `comm_env_compute_comm()` returns, for tests that need a collective !! to run over something other than the real job communicator (see !! their docstrings below). Production code never calls them. use rdb_constants, only: wp use pic_mpi_lib, only: comm_t, comm_world, pic_mpi_init, pic_mpi_finalize, & bcast, abort_comm #ifdef RDB_HAS_OPENACC_RUNTIME ! On NVHPC and Cray, stdpar (do concurrent) dispatches through the ! OpenACC runtime regardless of whether -acc= is on, so the openacc ! module is always available and is needed to bind the stdpar device ! under multi-GPU MPI — even on the OpenMP backend (e.g. -mp=gpu on ! NVHPC or -h omp on Cray with -acc= off). Sentinel set by ! cmake/compiler_flags.cmake. use openacc, only: acc_get_device_num, acc_get_device_type, & acc_set_device_num, acc_device_default, & acc_get_num_devices #endif #ifdef _OPENMP use omp_lib, only: omp_set_default_device #endif use, intrinsic :: iso_fortran_env, only: output_unit implicit none private public :: comm_env_init public :: comm_env_setup_roles public :: comm_env_finalize public :: comm_env_rank public :: comm_env_size public :: comm_env_bcast_real public :: comm_env_abort public :: comm_env_is_io_server public :: comm_env_io_server_rank public :: comm_env_compute_comm public :: comm_env_global_comm public :: comm_env_compute_rank public :: comm_env_compute_size public :: comm_env_node_compute_ranks public :: comm_env_node_n_compute public :: comm_env_push_compute_comm public :: comm_env_pop_compute_comm type(comm_t), save :: comm_global !! Global MPI communicator (cached after init) type(comm_t), save :: comm_compute !! Compute-only communicator (excludes I/O ranks) logical, save :: env_initialised = .false. !! True between comm_env_init and comm_env_finalize. Makes init !! idempotent and finalize a no-op when MPI was never initialised !! (e.g. a test binary that never inits the comm-env), so the shared !! per-test main can call finalize unconditionally at exit. integer, save :: cached_rank = -1 integer, save :: cached_size = -1 integer, save :: cached_compute_rank = -1 integer, save :: cached_compute_size = -1 logical, save :: cached_is_io_server = .false. integer, save :: cached_io_server_rank = -1 !! World rank of this node's I/O server (-1 if none) integer, save :: cached_node_n_compute = 0 !! Number of compute ranks on this node integer, save, allocatable :: cached_node_compute_ranks(:) !! World ranks of compute processes on this node integer, parameter :: COMPUTE_COMM_OVERRIDE_MAX_DEPTH = 1 !! TEST/TOOLING-ONLY. One-level stack depth for !! `comm_env_push_compute_comm` / `comm_env_pop_compute_comm` — see !! their docstrings. Kept at 1 deliberately: nothing in production !! code nests the override, and a deeper stack would hide a !! forgotten pop instead of failing loud on the very next push. type(comm_t), save :: compute_comm_override !! TEST/TOOLING-ONLY override payload (valid only while !! `compute_comm_override_depth == 1`). integer, save :: compute_comm_override_depth = 0 !! TEST/TOOLING-ONLY override stack depth, 0 or 1. contains subroutine comm_env_init() !! Phase 1: Initialise MPI, cache world rank/size !! Call this before reading config. Idempotent: a second call is a !! no-op, so multiple entry points / testdrive cases can call it. if (env_initialised) return call pic_mpi_init() comm_global = comm_world() cached_rank = comm_global%rank() cached_size = comm_global%size() ! Default: all ranks are compute, compute_comm = comm_world comm_compute = comm_global cached_compute_rank = cached_rank cached_compute_size = cached_size env_initialised = .true. end subroutine comm_env_init subroutine comm_env_setup_roles(use_io_server) !! Phase 2: Assign roles (I/O server vs compute), bind GPU !! Call this after reading config, before decomp/solver init. logical, intent(in) :: use_io_server !! Dedicate one rank per node as I/O server type(comm_t) :: node_comm integer :: node_rank, node_size, gpu_rank, i, n_dev integer, allocatable :: world_ranks(:) ! Compute node-local rank for GPU binding and I/O server assignment node_comm = comm_global%split() node_rank = node_comm%rank() node_size = node_comm%size() if (use_io_server .and. cached_size > 1 .and. node_size > 1) then ! Last rank on each node becomes the I/O server cached_is_io_server = (node_rank == node_size - 1) ! Broadcast I/O server's world rank to all ranks on this node cached_io_server_rank = cached_rank call bcast(node_comm, cached_io_server_rank, 1, node_size - 1) ! Gather world ranks of all node-local processes to build ! the compute rank list (exclude the I/O server) ! Use bcast from each rank as a simple allgather substitute allocate (world_ranks(0:node_size - 1)) do i = 0, node_size - 1 world_ranks(i) = cached_rank call bcast(node_comm, world_ranks(i), 1, i) end do cached_node_n_compute = node_size - 1 allocate (cached_node_compute_ranks(cached_node_n_compute)) do i = 0, node_size - 2 cached_node_compute_ranks(i + 1) = world_ranks(i) end do deallocate (world_ranks) ! Create compute communicator (excludes I/O ranks) if (cached_is_io_server) then comm_compute = comm_global%split_by(1) ! color 1 = I/O else comm_compute = comm_global%split_by(0) ! color 0 = compute end if cached_compute_rank = comm_compute%rank() cached_compute_size = comm_compute%size() ! GPU binding: compute ranks bind to their node-local rank gpu_rank = node_rank else ! No I/O server: all ranks are compute cached_is_io_server = .false. cached_io_server_rank = -1 comm_compute = comm_global cached_compute_rank = cached_rank cached_compute_size = cached_size cached_node_n_compute = node_size allocate (cached_node_compute_ranks(node_size)) do i = 0, node_size - 1 cached_node_compute_ranks(i + 1) = cached_rank - node_rank + i end do gpu_rank = node_rank end if call node_comm%finalize() ! Bind GPU (compute ranks only). Two API calls are needed when both ! `do concurrent` (stdpar) and `!$omp target` regions co-exist: ! ! * acc_set_device_num — selects the OpenACC runtime device, which ! is what stdpar (NVHPC, Cray) uses to dispatch `do concurrent` ! loops. Active whenever the compiler exposes the OpenACC ! runtime — see RDB_HAS_OPENACC_RUNTIME in compiler_flags. ! * omp_set_default_device — selects the OpenMP target device, ! used by `!$omp target` regions on the dc-openmp backend. ! Active when -mp= / -fopenmp is on. ! ! On the OpenACC backend (-acc=gpu) only the first fires; on the ! OpenMP backend both fire. Either way, every rank lands on the ! right GPU under mpirun. acc_device_default lets the runtime ! pick the appropriate device kind (NVIDIA, AMD, etc.). if (.not. cached_is_io_server) then #ifdef RDB_HAS_OPENACC_RUNTIME ! Clamp the node-local GPU index to the number of devices this ! process can actually see. With all GPUs visible this is a no-op ! (mod(node_rank, ndev) == node_rank for node_rank < ndev). When the ! launcher pins one GPU per rank via CUDA_VISIBLE_DEVICES, only one ! device is visible, so this binds device 0 (the rank's own GPU) and ! the CUDA-aware-MPI primary context also lands there -- no leftover ! context on the global device 0. Also makes ranks > GPUs (over- ! subscription) bind round-robin instead of failing. ! ! REVISIT: this clamp may be unnecessary -- NVHPC's acc_set_device_num ! might already tolerate an out-of-range device num under one-GPU ! CUDA_VISIBLE_DEVICES pinning (clamping internally). It's untested ! (the runs that confirmed the fix used this clamp). If a no-clamp ! build + pin reports a clean bind, drop this. Kept for now because ! it's a no-op when all GPUs are visible and the spec calls ! out-of-range device nums undefined, so it's the portable choice. n_dev = acc_get_num_devices(acc_device_default) if (n_dev > 0) gpu_rank = mod(gpu_rank, n_dev) call acc_set_device_num(gpu_rank, acc_device_default) #endif #ifdef _OPENMP call omp_set_default_device(gpu_rank) #endif call print_gpu_binding(gpu_rank) end if end subroutine comm_env_setup_roles subroutine print_gpu_binding(requested_gpu) !! Per-rank one-shot diagnostic: world rank -> requested device num / !! actual device num reported by the OpenACC runtime. Lets us !! confirm that mpirun is binding each rank to its own GPU rather !! than serialising N ranks on device 0. integer, intent(in) :: requested_gpu integer :: actual_dev #ifdef RDB_HAS_OPENACC_RUNTIME ! Works on either backend: stdpar+OpenACC and -mp=gpu / -h omp ! both keep the OpenACC runtime live, and acc_get_device_num ! reports the same device that stdpar/-acc dispatches against. actual_dev = acc_get_device_num(acc_get_device_type()) #else actual_dev = -1 #endif write (output_unit, "(A,I0,A,I0,A,I0,A,I0,A,I0)") & "[gpu-bind] world_rank=", cached_rank, & "/", cached_size, & " compute_rank=", cached_compute_rank, & " requested_dev=", requested_gpu, & " actual_dev=", actual_dev if (cached_rank == 0) then #ifdef RDB_CUDA_AWARE_MPI write (output_unit, "(A)") "[gpu-bind] halo path: GPU-direct (CUDA-aware MPI)" #else write (output_unit, "(A)") "[gpu-bind] halo path: HOST-STAGED (rebuild with -DRDB_CUDA_AWARE_MPI=ON for GPU-direct)" #endif end if flush (output_unit) end subroutine print_gpu_binding subroutine comm_env_finalize() !! Finalise MPI. No-op if the comm-env was never initialised, so it !! is safe to call unconditionally at program exit -- the shared !! per-test main does this for the few tests that init MPI, and it !! costs nothing for the rest (and on serial builds via the stub). if (.not. env_initialised) return if (allocated(cached_node_compute_ranks)) deallocate (cached_node_compute_ranks) call comm_global%finalize() call pic_mpi_finalize() env_initialised = .false. end subroutine comm_env_finalize function comm_env_rank() result(rank) !! Return this process's world MPI rank integer :: rank rank = cached_rank end function comm_env_rank function comm_env_size() result(nprocs) !! Return total number of MPI processes (including I/O ranks) integer :: nprocs nprocs = cached_size end function comm_env_size function comm_env_compute_rank() result(rank) !! Return this process's rank in the compute communicator integer :: rank rank = cached_compute_rank end function comm_env_compute_rank function comm_env_compute_size() result(nprocs) !! Return number of compute ranks (excludes I/O ranks) integer :: nprocs nprocs = cached_compute_size end function comm_env_compute_size function comm_env_compute_comm() result(comm) !! Return the compute-only communicator !! Falls back to comm_world() if comm_env_init has not been called !! (e.g. tests that use raw MPI_Init). !! !! TEST/TOOLING-ONLY: when `comm_env_push_compute_comm` has an !! override active, that override is returned instead — every !! caller reads this function fresh at call time (none of !! `rdb_halo`/`rdb_ocean_halo` cache the result across calls), so !! the override is visible to every collective issued while it is !! pushed, with no other code change required. type(comm_t) :: comm if (compute_comm_override_depth > 0) then comm = compute_comm_override else if (cached_rank >= 0) then comm = comm_compute else comm = comm_world() end if end function comm_env_compute_comm subroutine comm_env_push_compute_comm(comm) !! TEST/TOOLING-ONLY. Temporarily overrides what !! `comm_env_compute_comm()` returns to `comm`, until the matching !! `comm_env_pop_compute_comm()`. !! !! Exists so a test can run a genuinely serial "reference" engine !! (`compute_size=1`) on EVERY physical rank while still calling a !! COLLECTIVE report (`ocean_console_stats_report`, which always !! reduces over `comm_env_compute_comm()`, the real job !! communicator): pushing a per-rank self-communicator around that !! one call makes the collective an identity op on each rank's own !! copy instead of allreducing `nprocs` independent serial replicas !! into one bogus sum. See !! `tests/mpi/test_ocean_decomp_bitid_mpi.F90`'s !! `check_periodic_sponge_serial_out` for the call site and !! `run_one`'s HAZARD comment for the full story. !! !! NOT for production code paths — it exists purely to let a test !! stand up an alternate communicator around a fixed call without !! threading a `comm` argument through every intermediate routine. !! One-level only: a push while an override is already active is a !! bug in the caller (a missing pop, or an attempt to nest) and !! fails loud rather than silently discarding or stacking past !! `COMPUTE_COMM_OVERRIDE_MAX_DEPTH`. type(comm_t), intent(in) :: comm if (compute_comm_override_depth >= COMPUTE_COMM_OVERRIDE_MAX_DEPTH) then error stop "comm_env_push_compute_comm: override already active "// & "(one-level stack) -- call comm_env_pop_compute_comm first" end if compute_comm_override = comm compute_comm_override_depth = compute_comm_override_depth + 1 end subroutine comm_env_push_compute_comm subroutine comm_env_pop_compute_comm() !! TEST/TOOLING-ONLY. Pops the override pushed by !! `comm_env_push_compute_comm`, restoring `comm_env_compute_comm()` !! to the real compute communicator. Fails loud on pop-without-push !! (a bug in the caller, not a state this seam should absorb !! silently). Does NOT finalize the popped communicator -- the !! pusher owns that (e.g. freeing a self-comm built for the push). if (compute_comm_override_depth <= 0) then error stop "comm_env_pop_compute_comm: no override active (pop without push)" end if compute_comm_override_depth = compute_comm_override_depth - 1 end subroutine comm_env_pop_compute_comm function comm_env_global_comm() result(comm) !! Return the cached global (world) communicator. !! !! Use this instead of `pic_mpi_lib::comm_world()` from inside !! per-step code: `comm_world()` does an `MPI_Comm_dup` on every !! call, and that dup is collective on `MPI_COMM_WORLD`. Calling !! it from a routine that runs on only some ranks (e.g. the I/O !! client send path, which compute ranks hit but the I/O server !! does not) deadlocks. `comm_global` is duplicated exactly once !! by `comm_env_init` on every rank, so handing it out is free. type(comm_t) :: comm if (cached_rank >= 0) then comm = comm_global else comm = comm_world() end if end function comm_env_global_comm function comm_env_is_io_server() result(is_io) !! Return .true. if this rank is a dedicated I/O server logical :: is_io is_io = cached_is_io_server end function comm_env_is_io_server function comm_env_io_server_rank() result(rank) !! Return world rank of this node's I/O server (-1 if none) integer :: rank rank = cached_io_server_rank end function comm_env_io_server_rank function comm_env_node_n_compute() result(n) !! Return number of compute ranks on this node integer :: n n = cached_node_n_compute end function comm_env_node_n_compute function comm_env_node_compute_ranks() result(ranks) !! Return world ranks of compute processes on this node integer, allocatable :: ranks(:) ranks = cached_node_compute_ranks end function comm_env_node_compute_ranks subroutine comm_env_bcast_real(val) !! Broadcast a single real(wp) scalar from compute rank 0 !! Uses compute communicator so I/O server ranks don't participate. real(wp), intent(inout) :: val call bcast(comm_compute, val, 1, 0) end subroutine comm_env_bcast_real subroutine comm_env_abort(code) !! Abort all MPI processes with given error code integer, intent(in) :: code call abort_comm(comm_global, code) end subroutine comm_env_abort end module rdb_comm_env