!! Setup-time memory-budget reporting for the device-mapped state.
module rdb_mem_report
   !! Free procedures that estimate, log, and verify the device memory
   !! footprint of the simulation state **before** the OpenACC
   !! `enter_data` mapping is attempted — so an over-budget run reports
   !! a readable diagnostic instead of dying opaquely inside the CUDA
   !! runtime (the motivating failure: a 10M-cell ocean config that
   !! exhausted device memory at `enter_data` with no setup-time hint).
   !!
   !! Design decision (no hand-maintained per-slot byte counts — those
   !! rot the moment a slot is added): the ONLY trustworthy figures are
   !! the OpenACC runtime's own free-memory queries.  The device
   !! footprint of the state mapping is **measured**, not estimated, as
   !! the drop in free device memory across `enter_data`
   !! (`mem_log_state_budget` snapshots free-before;
   !! `mem_log_device_actuals` reports the delta).  Host RSS growth
   !! across state init is still logged, but ONLY as an advisory
   !! host-side figure + a conservative upper bound for the pre-flight
   !! OOM warning — it includes host-only allocations (NetCDF/HDF5
   !! library pages, I/O buffers, host mirrors of the device arrays) and
   !! measured 6-31x larger than the true device map (2026-07 audit), so
   !! it must never be read as a device estimate.
   !!
   !! Three structural offsets to keep in mind when reconciling these
   !! figures against `nvidia-smi` (audited 2026-07 on V100):
   !!   1. **CUDA context + CUBIN load**: ~300-500 MB per process,
   !!      created at the first OpenACC API call / directive.  It is
   !!      inside `used = total - free` and inside nvidia-smi's
   !!      per-process figure, but is NOT state — a tiny config still
   !!      shows ~300 MB in use.
   !!   2. **Device-wide accounting + pool granularity**:
   !!      `used = total - free` counts EVERY process on the device
   !!      (shared-device activity perturbs it and the measured mapping
   !!      delta), and the NVHPC pool allocator rounds small maps up by
   !!      a few MB.
   !!   3. **One-off CUDA runtime/kernel reservation at first launch**:
   !!      the first time-loop step grows per-process device usage by
   !!      GBs (measured: ~3.0 GB coastal, ~8.6 GB ocean on V100) —
   !!      constant thereafter, independent of step count and of
   !!      `NV_ACC_MEM_MANAGE`, and NOT proportional to grid size.
   !!      Consistent with the driver's local-memory/stack backing for
   !!      stack-heavy kernels (fixed-size `local()` arrays), which
   !!      scales with the binary's kernels x device SM count.  It is
   !!      real per-process usage (visible in nvidia-smi) but is not a
   !!      leak; the growth WARNING therefore measures against the
   !!      steady post-first-sample baseline, not the post-`enter_data`
   !!      one.
   !!
   !! Post-setup growth (`mem_log_device_growth`) makes drift
   !! self-announcing: kernels that lazy-allocate device workspaces on
   !! first call (the coastal `*_workspace_ensure` pattern — measured at
   !! ~33% of the coastal device footprint) land AFTER `enter_data`, so
   !! the periodic status re-checks and the end-of-run summary reports
   !! the growth, warning above `GROWTH_WARN_FRAC`.
   !!
   !! Device queries use the OpenACC property API
   !! (`acc_get_property` with `acc_property_memory` /
   !! `acc_property_free_memory`).  They are compiled only when the
   !! OpenACC runtime module is available (`RDB_HAS_OPENACC_RUNTIME`,
   !! set by `cmake/compiler_flags.cmake` for NVHPC + Cray — covers both
   !! the `-acc=gpu` and `-mp=gpu` NVHPC backends).  On every other
   !! toolchain (gfortran, ifx) the device functions return `-1` and the
   !! loggers degrade to the host-only line.
   use, intrinsic :: iso_fortran_env, only: int64
   use rdb_constants, only: wp
   use pic_logger, only: logger => global_logger
   use pic_strings, only: to_string
#ifdef RDB_HAS_OPENACC_RUNTIME
   use openacc, only: acc_get_device_num, acc_get_device_type, &
                      acc_get_property, acc_property_memory, &
                      acc_property_free_memory
   use, intrinsic :: iso_c_binding, only: c_size_t
#endif
   implicit none
   private

   public :: mem_host_rss_bytes
   public :: mem_device_total_bytes, mem_device_free_bytes
   public :: mem_device_used_bytes
   public :: mem_log_state_budget, mem_log_device_actuals
   public :: mem_log_device_growth
   public :: mem_format_bytes
   public :: arr_bytes
   public :: mem_set_counted_budget, mem_log_computed_budget, mem_log_computed_line

   interface arr_bytes
      !! Byte footprint of an allocatable array (0 when unallocated), so a
      !! gated-off slot naturally contributes nothing to a counted total.
      !! The COUNTED companion to the runtime's MEASURED free-memory
      !! delta: each `*_state_t` sums its own arrays through this, the
      !! driver reports the total **before** `enter_data` (works on CPU
      !! builds too, where there is no device query), and
      !! `mem_log_device_actuals` reconciles the count against the
      !! measured mapping — a state array added without a matching
      !! `bytes()` term makes the measured map exceed the count and
      !! self-announces the drift (the anti-rot guard for the otherwise
      !! rot-prone per-slot count).
      module procedure arr_bytes_r1, arr_bytes_r2, arr_bytes_r3, arr_bytes_r4
      module procedure arr_bytes_i1, arr_bytes_i2, arr_bytes_i3
      module procedure arr_bytes_l1, arr_bytes_l2, arr_bytes_l3
   end interface arr_bytes

   real(wp), parameter :: COUNT_RECONCILE_FRAC = 0.25_wp
      !! The measured `enter_data` mapping may exceed the COUNTED state
      !! footprint by this fraction (NVHPC pool granularity + any
      !! device-only scratch mapped alongside the state) before the
      !! reconciliation warns.  Overshoot past it means the device mapped
      !! significantly more than every `bytes()` term accounts for — a
      !! state array is almost certainly missing from the count.

   integer(int64), parameter :: COUNT_RECONCILE_FLOOR = 64_int64*1024_int64*1024_int64
      !! Absolute slack (64 MiB) added to the reconciliation tolerance so
      !! tiny configs — where pool rounding dwarfs the relative margin —
      !! never trip the drift warning.

   integer(int64), parameter :: BYTES_PER_KIB = 1024_int64
   integer(int64), parameter :: BYTES_PER_MIB = 1024_int64*1024_int64
   integer(int64), parameter :: BYTES_PER_GIB = 1024_int64*1024_int64*1024_int64

   real(wp), parameter :: GROWTH_WARN_FRAC = 0.15_wp
      !! Device-memory growth above this fraction of the STEADY
      !! post-setup baseline (first post-setup sample — after the
      !! one-off first-launch reservation and lazy workspaces) triggers
      !! a one-shot logger warning — progressive growth (a leak)
      !! becomes self-announcing instead of silently skewing the
      !! setup-time report.

   ! Snapshots latched by the rank-0 logging path (module state, same
   ! lifetime/ownership model as the profiler's timers).  `-1` = not yet
   ! sampled / no device.
   integer(int64) :: free_before_map = -1_int64
      !! Free device bytes just before `enter_data` (latched by
      !! `mem_log_state_budget`); baseline for the measured mapping delta.
   integer(int64) :: used_after_map = -1_int64
      !! Device bytes in use just after `enter_data` (latched by
      !! `mem_log_device_actuals`); baseline for the "grew ~X after
      !! setup" attribution line.
   integer(int64) :: used_steady = -1_int64
      !! Device bytes in use at the FIRST post-setup growth sample
      !! (first status fire or end of run) — after the one-off
      !! first-launch runtime reservation and the lazy kernel
      !! workspaces have landed.  Baseline for the leak WARNING:
      !! growth past this is progressive, not the expected one-offs.
   logical :: growth_warned = .false.
      !! One-shot latch so the growth warning doesn't spam every status
      !! fire once the threshold is crossed.
   integer(int64) :: counted_state_bytes = -1_int64
      !! Counted state-array footprint latched by `mem_log_computed_budget`
      !! (summed from every `bytes()` term BEFORE `enter_data`); reconciled
      !! against the measured mapping in `mem_log_device_actuals`.  `-1` =
      !! not counted this run.

contains

   function mem_host_rss_bytes() result(bytes)
      !! Resident set size of the current process, in bytes, parsed from
      !! `VmRSS` in `/proc/self/status` (reported in kB → scaled to
      !! bytes).  Returns `-1` if the file is unreadable or the field is
      !! absent (non-Linux platforms).  Never aborts.
      integer(int64) :: bytes
      integer :: unit, ios
      character(len=256) :: line
      character(len=256) :: iomsg
      character(len=32) :: kb_str
      integer(int64) :: kb

      bytes = -1_int64
      open (newunit=unit, file="/proc/self/status", status="old", &
            action="read", iostat=ios, iomsg=iomsg)
      if (ios /= 0) return

      do
         read (unit, "(a)", iostat=ios, iomsg=iomsg) line
         if (ios /= 0) exit
         if (line(1:6) == "VmRSS:") then
            ! Layout: "VmRSS:\t   12345 kB" — strip the label + "kB".
            kb_str = adjustl(line(7:))
            ! Drop the trailing " kB" unit by reading the leading integer.
            read (kb_str, *, iostat=ios, iomsg=iomsg) kb
            if (ios == 0) bytes = kb*BYTES_PER_KIB
            exit
         end if
      end do

      close (unit)
   end function mem_host_rss_bytes

   function mem_device_total_bytes() result(bytes)
      !! Total memory of the device that the OpenACC runtime is bound to,
      !! in bytes.  Returns `-1` when no OpenACC runtime is present
      !! (CPU / gfortran / ifx builds).
      integer(int64) :: bytes
#ifdef RDB_HAS_OPENACC_RUNTIME
      integer(c_size_t) :: prop
      prop = acc_get_property(acc_get_device_num(acc_get_device_type()), &
                              acc_get_device_type(), acc_property_memory)
      bytes = int(prop, int64)
#else
      bytes = -1_int64
#endif
   end function mem_device_total_bytes

   function mem_device_free_bytes() result(bytes)
      !! Free memory on the bound device, in bytes.  Returns `-1` when no
      !! OpenACC runtime is present.
      integer(int64) :: bytes
#ifdef RDB_HAS_OPENACC_RUNTIME
      integer(c_size_t) :: prop
      prop = acc_get_property(acc_get_device_num(acc_get_device_type()), &
                              acc_get_device_type(), acc_property_free_memory)
      bytes = int(prop, int64)
#else
      bytes = -1_int64
#endif
   end function mem_device_free_bytes

   function mem_device_used_bytes() result(bytes)
      !! Device memory currently in use, in bytes (`total - free`).
      !! DEVICE-WIDE: includes this process's CUDA context (~300-500 MB)
      !! and every other process on a shared device — see the module
      !! header's structural offsets.  Returns `-1` when no OpenACC
      !! runtime is present.
      integer(int64) :: bytes
      integer(int64) :: free_b, total_b

      bytes = -1_int64
      free_b = mem_device_free_bytes()
      total_b = mem_device_total_bytes()
      if (free_b < 0_int64 .or. total_b < 0_int64) return
      bytes = total_b - free_b
   end function mem_device_used_bytes

   pure function mem_format_bytes(bytes) result(str)
      !! Human-readable byte count: two decimals, GB at/above 1 GiB,
      !! MB below.  Used for every figure in the budget log so the
      !! units stay consistent.
      integer(int64), intent(in) :: bytes
      character(len=:), allocatable :: str
      real(wp) :: gib, mib

      if (bytes >= BYTES_PER_GIB) then
         gib = real(bytes, wp)/real(BYTES_PER_GIB, wp)
         str = two_dp(gib)//" GB"
      else
         mib = real(bytes, wp)/real(BYTES_PER_MIB, wp)
         str = two_dp(mib)//" MB"
      end if
   end function mem_format_bytes

   pure function two_dp(x) result(str)
      !! Format a real to exactly two decimal places without leading
      !! blanks (`write` with an `f` edit descriptor, then `adjustl` +
      !! trim).
      real(wp), intent(in) :: x
      character(len=:), allocatable :: str
      character(len=32) :: buf
      write (buf, "(f0.2)") x
      ! `f0.2` of a value < 1 emits ".50" on some compilers; prefix a 0.
      if (buf(1:1) == ".") then
         str = "0"//trim(adjustl(buf))
      else
         str = trim(adjustl(buf))
      end if
   end function two_dp

   ! --- Counted footprint: byte size of an allocatable (0 if unallocated) ---
   ! One specific per (type, rank).  `size(a, kind=int64)` is only
   ! referenced inside the `allocated` guard, so an unallocated slot is
   ! never touched.  The element width comes from a same-kind literal
   ! (`storage_size` of the array itself would need it allocated).

   pure function arr_bytes_r1(a) result(b)
      real(wp), allocatable, intent(in) :: a(:)
      integer(int64) :: b
      b = 0_int64
      if (allocated(a)) b = size(a, kind=int64)*int(storage_size(1.0_wp)/8, int64)
   end function arr_bytes_r1

   pure function arr_bytes_r2(a) result(b)
      real(wp), allocatable, intent(in) :: a(:, :)
      integer(int64) :: b
      b = 0_int64
      if (allocated(a)) b = size(a, kind=int64)*int(storage_size(1.0_wp)/8, int64)
   end function arr_bytes_r2

   pure function arr_bytes_r3(a) result(b)
      real(wp), allocatable, intent(in) :: a(:, :, :)
      integer(int64) :: b
      b = 0_int64
      if (allocated(a)) b = size(a, kind=int64)*int(storage_size(1.0_wp)/8, int64)
   end function arr_bytes_r3

   pure function arr_bytes_r4(a) result(b)
      real(wp), allocatable, intent(in) :: a(:, :, :, :)
      integer(int64) :: b
      b = 0_int64
      if (allocated(a)) b = size(a, kind=int64)*int(storage_size(1.0_wp)/8, int64)
   end function arr_bytes_r4

   pure function arr_bytes_i1(a) result(b)
      integer, allocatable, intent(in) :: a(:)
      integer(int64) :: b
      b = 0_int64
      if (allocated(a)) b = size(a, kind=int64)*int(storage_size(0)/8, int64)
   end function arr_bytes_i1

   pure function arr_bytes_i2(a) result(b)
      integer, allocatable, intent(in) :: a(:, :)
      integer(int64) :: b
      b = 0_int64
      if (allocated(a)) b = size(a, kind=int64)*int(storage_size(0)/8, int64)
   end function arr_bytes_i2

   pure function arr_bytes_i3(a) result(b)
      integer, allocatable, intent(in) :: a(:, :, :)
      integer(int64) :: b
      b = 0_int64
      if (allocated(a)) b = size(a, kind=int64)*int(storage_size(0)/8, int64)
   end function arr_bytes_i3

   pure function arr_bytes_l1(a) result(b)
      logical, allocatable, intent(in) :: a(:)
      integer(int64) :: b
      b = 0_int64
      if (allocated(a)) b = size(a, kind=int64)*int(storage_size(.true.)/8, int64)
   end function arr_bytes_l1

   pure function arr_bytes_l2(a) result(b)
      logical, allocatable, intent(in) :: a(:, :)
      integer(int64) :: b
      b = 0_int64
      if (allocated(a)) b = size(a, kind=int64)*int(storage_size(.true.)/8, int64)
   end function arr_bytes_l2

   pure function arr_bytes_l3(a) result(b)
      logical, allocatable, intent(in) :: a(:, :, :)
      integer(int64) :: b
      b = 0_int64
      if (allocated(a)) b = size(a, kind=int64)*int(storage_size(.true.)/8, int64)
   end function arr_bytes_l3

   subroutine mem_set_counted_budget(total_bytes)
      !! Latch the COUNTED state-array footprint for the `enter_data`
      !! reconciliation, WITHOUT logging.  Must run before
      !! `mem_log_device_actuals` (the reconciliation reads this), even
      !! when the human-readable budget is printed later (e.g. folded into
      !! the driver's post-setup banner).  `total_bytes` is the sum of
      !! every state `bytes()` term (0 for gated-off slots).
      integer(int64), intent(in) :: total_bytes
      counted_state_bytes = total_bytes
   end subroutine mem_set_counted_budget

   subroutine mem_log_computed_budget(label, total_bytes)
      !! Log the COUNTED state-array footprint.  Unlike the RSS figure
      !! (host-only I/O buffers + library pages) and the device query
      !! (needs an OpenACC runtime), this is an exact sum of the state's
      !! allocatable arrays — available on every toolchain, so a CPU build
      !! gets a real footprint line too.  Latching for the reconciliation
      !! is `mem_set_counted_budget` (separate, so the print can move to
      !! the banner while the latch stays before `enter_data`).
      character(len=*), intent(in) :: label
      integer(int64), intent(in) :: total_bytes

      ! Two-space indent + four-space breakdown so the block nests cleanly
      ! under the driver's post-setup banner (top-level items at two spaces,
      ! sub-items at four).
      call logger%info("  Memory:   state arrays counted ~ "// &
                       mem_format_bytes(total_bytes)//" ("//trim(label)// &
                       ", exact allocatable footprint)")
   end subroutine mem_log_computed_budget

   subroutine mem_log_computed_line(label, bytes)
      !! Log one indented breakdown line for a component of the counted
      !! footprint (barotropic / layers / closures / …).  Skipped when the
      !! component is empty so gated-off slots don't clutter the report.
      character(len=*), intent(in) :: label
      integer(int64), intent(in) :: bytes

      if (bytes <= 0_int64) return
      call logger%info("    "//trim(label)//": "//mem_format_bytes(bytes))
   end subroutine mem_log_computed_line

   subroutine mem_log_state_budget(label, host_growth_bytes)
      !! Log the pre-mapping memory budget.  Always logs the host-side
      !! allocation growth (ADVISORY — includes host-only I/O buffers +
      !! library pages, NOT a device estimate); when the device queries
      !! succeed it also logs the device number + free/total and latches
      !! the free-memory snapshot that `mem_log_device_actuals` turns
      !! into the measured mapping delta.  Warns when the host growth
      !! (a conservative upper bound on what `enter_data` can map)
      !! exceeds 95% of free device memory — the regime where
      !! `enter_data` is likely to abort.
      character(len=*), intent(in) :: label
      integer(int64), intent(in) :: host_growth_bytes

      integer(int64) :: free_b, total_b
      integer :: devnum

      call logger%info("Memory budget ("//trim(label)//"): host allocations across setup ~ "// &
                       mem_format_bytes(host_growth_bytes)// &
                       " (RSS growth; advisory, not a device estimate)")

      free_b = mem_device_free_bytes()
      total_b = mem_device_total_bytes()
      if (free_b < 0_int64 .or. total_b < 0_int64) return  ! CPU build.

      ! Baseline for the measured mapping delta.  The property query
      ! above already forced CUDA context creation, so the delta across
      ! enter_data excludes the ~300-500 MB context cost.
      free_before_map = free_b

      devnum = device_num()
      call logger%info("  device "//to_string(devnum)//" free before mapping: "// &
                       mem_format_bytes(free_b)//" of "//mem_format_bytes(total_b)// &
                       " (host-growth upper bound: "// &
                       mem_format_bytes(host_growth_bytes)//")")

      if (real(host_growth_bytes, wp) > 0.95_wp*real(free_b, wp)) then
         call logger%warning("device memory likely insufficient — mapping <= "// &
                             mem_format_bytes(host_growth_bytes)//" with only "// &
                             mem_format_bytes(free_b)//" free")
      end if
   end subroutine mem_log_state_budget

   subroutine mem_log_device_actuals(label)
      !! Log actual device memory after `enter_data`: the device-wide
      !! usage (`used = total - free` — includes the CUDA context and
      !! any other process on a shared device) plus the MEASURED state
      !! mapping delta (free-before-mapping minus free-now; accurate to
      !! NVHPC pool granularity, ~a few MB).  Latches the post-mapping
      !! usage as the baseline for `mem_log_device_growth`.  Silent
      !! no-op when device queries are unavailable (CPU build).
      character(len=*), intent(in) :: label
      integer(int64) :: free_b, total_b, used_b

      free_b = mem_device_free_bytes()
      total_b = mem_device_total_bytes()
      if (free_b < 0_int64 .or. total_b < 0_int64) return

      used_b = total_b - free_b
      call logger%info("device memory in use: "//mem_format_bytes(used_b)// &
                       " of "//mem_format_bytes(total_b)//" ("//trim(label)// &
                       "; includes CUDA context ~300-500 MB + other processes)")
      if (free_before_map >= 0_int64) then
         block
            integer(int64) :: mapped_b, tol_b
            mapped_b = max(free_before_map - free_b, 0_int64)
            call logger%info("  state mapped: ~"//mem_format_bytes(mapped_b)// &
                             " (device-measured; shared-device activity can perturb)")

            ! Reconcile the MEASURED mapping against the COUNTED footprint.
            ! Overshoot past the tolerance means the device mapped more than
            ! every bytes() term accounts for — a state array is almost
            ! certainly missing from the count (the drift the counted total
            ! would otherwise hide).  Under-count from lazy/host-only arrays
            ! is expected and stays silent.
            if (counted_state_bytes > 0_int64) then
               tol_b = counted_state_bytes + &
                       int(COUNT_RECONCILE_FRAC*real(counted_state_bytes, wp), int64) + &
                       COUNT_RECONCILE_FLOOR
               if (mapped_b > tol_b) then
                  call logger%warning("device mapped ~"//mem_format_bytes(mapped_b)// &
                                      " but only ~"//mem_format_bytes(counted_state_bytes)// &
                                      " counted — a state array is likely missing from a "// &
                                      "bytes() term (memory-accounting drift)")
               end if
            end if
         end block
      end if

      ! Baselines for post-setup growth reporting.
      used_after_map = used_b
      used_steady = -1_int64
      growth_warned = .false.
   end subroutine mem_log_device_actuals

   subroutine mem_log_device_growth(label, quiet)
      !! Report device-memory growth since the post-`enter_data`
      !! baseline.  Two allocations land AFTER the setup-time report and
      !! would otherwise stay invisible: the lazy first-call kernel
      !! workspaces (`*_workspace_ensure`, ~33% of the coastal device
      !! footprint) and the one-off CUDA runtime/kernel reservation at
      !! first launch (GBs — module-header offset 3).  Call this in the
      !! end-of-run summary (prints usage + the "grew ~X after setup"
      !! attribution, so even a 10-step run shows the true footprint)
      !! and at the periodic status with `quiet=.true.` (no info lines).
      !! The one-shot leak WARNING measures PROGRESSIVE growth — past
      !! `GROWTH_WARN_FRAC` of the steady baseline latched at the first
      !! post-setup sample — so the expected one-offs never trip it.
      !! Silent no-op on CPU builds or when the baseline was never
      !! latched.
      character(len=*), intent(in) :: label
      logical, intent(in), optional :: quiet

      integer(int64) :: used_b, growth_b
      logical :: quiet_mode

      quiet_mode = .false.
      if (present(quiet)) quiet_mode = quiet

      used_b = mem_device_used_bytes()
      if (used_b < 0_int64 .or. used_after_map < 0_int64) return

      growth_b = used_b - used_after_map

      if (.not. quiet_mode) then
         call logger%info("device memory in use: "//mem_format_bytes(used_b)// &
                          " ("//trim(label)//")")
         if (growth_b > 0_int64) then
            call logger%info("  grew ~"//mem_format_bytes(growth_b)// &
                             " after setup (lazy kernel workspaces + one-off "// &
                             "CUDA kernel/runtime reservation)")
         end if
      end if

      if (used_steady < 0_int64) then
         ! First post-setup sample: the one-off reservations have landed;
         ! this is the steady baseline for progressive-growth (leak)
         ! detection.
         used_steady = used_b
      else if (.not. growth_warned .and. &
               real(used_b - used_steady, wp) > &
               GROWTH_WARN_FRAC*real(used_steady, wp)) then
         call logger%warning("device memory grew ~"// &
                             mem_format_bytes(used_b - used_steady)// &
                             " past the steady post-setup baseline ("// &
                             mem_format_bytes(used_steady)// &
                             ") — possible device-memory leak ("//trim(label)//")")
         growth_warned = .true.
      end if
   end subroutine mem_log_device_growth

   function device_num() result(num)
      !! Active OpenACC device number, or `-1` on CPU builds.  Kept
      !! private — only the budget logger needs it.
      integer :: num
#ifdef RDB_HAS_OPENACC_RUNTIME
      num = acc_get_device_num(acc_get_device_type())
#else
      num = -1
#endif
   end function device_num

end module rdb_mem_report
