From f0e38086f37b8ee635a196421c0ea966a39d0cbb Mon Sep 17 00:00:00 2001
From: Bert Vandenbroucke <vandenbroucke@strw.leidenuniv.nl>
Date: Wed, 31 Aug 2022 13:27:10 +0000
Subject: [PATCH] Ghost iteration statistics

---
 configure.ac                           |  15 +
 doc/RTD/source/AnalysisTools/index.rst |  47 +++
 src/Makefile.am                        |   2 +
 src/cell.h                             |   3 +
 src/engine.c                           |   5 +
 src/ghost_stats.c                      | 111 ++++++
 src/ghost_stats.h                      | 500 +++++++++++++++++++++++++
 src/runner_ghost.c                     |  25 ++
 swift.c                                |   2 +
 tools/plot_ghost_stats.py              | 229 +++++++++++
 10 files changed, 939 insertions(+)
 create mode 100644 src/ghost_stats.c
 create mode 100644 src/ghost_stats.h
 create mode 100644 tools/plot_ghost_stats.py

diff --git a/configure.ac b/configure.ac
index 04c82357c0..9357d35df0 100644
--- a/configure.ac
+++ b/configure.ac
@@ -405,6 +405,20 @@ elif test "$stars_density_checks" != "no"; then
    AC_DEFINE_UNQUOTED([SWIFT_STARS_DENSITY_CHECKS], [$enableval] ,[Enable stars density brute-force checks])
 fi
 
+# Check if ghost statistics are enabled
+AC_ARG_ENABLE([ghost-statistics],
+   [AS_HELP_STRING([--enable-ghost-statistics],
+     [Gather statistics about the ghost iterations for hydro, stars and black holes in N bins @<:@N@:>@]
+   )],
+   [ghost_stats="$enableval"],
+   [ghost_stats="no"]
+)
+if test "$ghost_stats" == "yes"; then
+   AC_MSG_ERROR(Need to specify the number of bins when using --enable-ghost-statistics!)
+elif test "$ghost_stats" != "no"; then
+   AC_DEFINE_UNQUOTED([SWIFT_GHOST_STATS], [$enableval] ,[Enable ghost statistics for hydro, stars and black holes])
+fi
+
 # Check whether we want to switch on glass making
 AC_ARG_ENABLE([glass-making],
    [AS_HELP_STRING([--enable-glass-making],
@@ -2984,6 +2998,7 @@ AC_MSG_RESULT([
    Boundary particles          : $boundary_particles
    Fixed boundary particles    : $fixed_boundary_particles
    Planetary fixed entropy     : $planetary_fixed_entropy
+   Ghost statistics            : $ghost_stats
 
    Continuous Sim. Data Stream : $with_csds
 
diff --git a/doc/RTD/source/AnalysisTools/index.rst b/doc/RTD/source/AnalysisTools/index.rst
index f86a793072..15922608cf 100644
--- a/doc/RTD/source/AnalysisTools/index.rst
+++ b/doc/RTD/source/AnalysisTools/index.rst
@@ -235,3 +235,50 @@ The ``.dump<.rank>`` files once seen are deleted, so dumping can be done more
 than once. For a non-MPI run the file is simply called ``.dump``, note for MPI
 you need to create one file per rank, so ``.dump.0``, ``.dump.1`` and so on.
 
+
+Neighbour search statistics
+---------------------------
+
+One of the core algorithms in SWIFT is an iterative neighbour search 
+whereby we try to find an appropriate radius around a particle's 
+position so that the weighted sum over neighbouring particles within 
+that radius is equal to some target value. The most obvious example of 
+this iterative neighbour search is the SPH density loop, but various 
+sub-grid models employ a very similar iterative neighbour search. The 
+computational cost of this iterative search is significantly affected by 
+the number of iterations that is required, and it can therefore be 
+useful to analyse the progression of the iterative scheme in detail.
+
+When configured with ``--enable-ghost-statistics=X``, SWIFT will be 
+compiled with additional diagnostics that statistically track the number 
+of iterations required to find a converged answer. Here, ``X`` is a 
+fixed number of bins to use to collect the required statistics 
+(``ghost`` refers to the fact that the iterations take place inside the 
+ghost tasks). In practice, this means that every cell in the SWIFT tree 
+will be equipped with an additional ``struct`` containing three sets of 
+``X`` bins (one set for each iterative neighbour loop: hydro, stellar 
+feedback, AGN feedback). For each bin ``i``, we store the number of 
+particles that required updating during iteration ``i``, the number of 
+particles that could not find a single neighbouring particle, the 
+minimum and maximum smoothing length of all particles that required 
+updating, and the sum of all their search radii and all their search 
+radii squared. This allows us to calculate the upper and lower limits, 
+as well as the mean and standard deviation on the search radius for each 
+iteration and for each cell. Note that there could be more iterations 
+required than the number of bins ``X``; in this case the additional 
+iterations will be accumulated in the final bin. At the end of each time 
+step, a text file is produced (one per MPI rank) that contains the 
+information for all cells that had any relevant activity. This text file 
+is named ``ghost_stats_ssss_rrrr.txt``, where ``ssss`` is the step 
+counter for that time step and ``rrrr`` is the MPI rank.
+
+The script ``tools/plot_ghost_stats.py`` takes one or multiple 
+``ghost_stats.txt`` files and computes global statistics for all the 
+cells in those files. The script also takes the name of an output file 
+where it will save those statistics as a set of plots, and an optional 
+label that will be displayed as the title of the plots. Note that there 
+are no restrictions on the number of input files or how they relate; 
+different files could represent different MPI ranks, but also different 
+time steps or even different simulations (which would make little 
+sense). It is up to the user to make sure that the input is actually 
+relevant.
diff --git a/src/Makefile.am b/src/Makefile.am
index 3177bff00e..3dbb2cfbf4 100644
--- a/src/Makefile.am
+++ b/src/Makefile.am
@@ -73,6 +73,7 @@ include_HEADERS += lightcone/lightcone_crossing.h lightcone/lightcone_array.h li
 include_HEADERS += lightcone/lightcone_map_types.h lightcone/projected_kernel.h lightcone/lightcone_shell.h
 include_HEADERS += lightcone/healpix_util.h lightcone/pixel_index.h
 include_HEADERS += power_spectrum.h
+include_HEADERS += ghost_stats.h
 
 # source files for EAGLE extra I/O
 EAGLE_EXTRA_IO_SOURCES=
@@ -177,6 +178,7 @@ AM_SOURCES += lightcone/lightcone.c lightcone/lightcone_particle_io.c lightcone/
 AM_SOURCES += lightcone/healpix_util.c lightcone/lightcone_array.c lightcone/lightcone_map.c
 AM_SOURCES += lightcone/lightcone_map_types.c lightcone/projected_kernel.c lightcone/lightcone_shell.c
 AM_SOURCES += power_spectrum.c
+AM_SOURCES += ghost_stats.c
 AM_SOURCES += $(EAGLE_EXTRA_IO_SOURCES)
 AM_SOURCES += $(QLA_COOLING_SOURCES) $(QLA_EAGLE_COOLING_SOURCES) 
 AM_SOURCES += $(EAGLE_COOLING_SOURCES) $(EAGLE_FEEDBACK_SOURCES) 
diff --git a/src/cell.h b/src/cell.h
index 6fd8b7d95b..da6c610c73 100644
--- a/src/cell.h
+++ b/src/cell.h
@@ -38,6 +38,7 @@
 #include "cell_hydro.h"
 #include "cell_sinks.h"
 #include "cell_stars.h"
+#include "ghost_stats.h"
 #include "kernel_hydro.h"
 #include "multipole_struct.h"
 #include "part.h"
@@ -463,6 +464,8 @@ struct cell {
   char subtasks_executed[task_type_count];
 #endif
 
+  struct ghost_stats ghost_statistics;
+
 } SWIFT_STRUCT_ALIGN;
 
 /* Convert cell location to ID. */
diff --git a/src/engine.c b/src/engine.c
index 86faa07863..bbae68c777 100644
--- a/src/engine.c
+++ b/src/engine.c
@@ -2398,6 +2398,11 @@ int engine_step(struct engine *e) {
       e->step % e->sched.frequency_task_levels == 0)
     scheduler_write_task_level(&e->sched, e->step);
 
+  /* we have to reset the ghost histograms here and not in engine_launch,
+     because engine_launch is re-used for the limiter and sync (and we don't
+     want to lose the data from the tasks) */
+  space_reset_ghost_histograms(e->s);
+
   /* Start all the tasks. */
   TIMER_TIC;
   engine_launch(e, "tasks");
diff --git a/src/ghost_stats.c b/src/ghost_stats.c
new file mode 100644
index 0000000000..883579d9f5
--- /dev/null
+++ b/src/ghost_stats.c
@@ -0,0 +1,111 @@
+/*******************************************************************************
+ * This file is part of SWIFT.
+ * Copyright (c) 2021 Bert Vandenbroucke (bert.vandenbroucke@gmail.com)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ ******************************************************************************/
+
+#include "ghost_stats.h"
+
+#include "cell.h"
+#include "space.h"
+
+#ifdef SWIFT_GHOST_STATS
+
+/**
+ * @brief Resets all the individual cell ghost counters to 0.
+ *
+ * @param c The #cell to reset.
+ */
+void cell_reset_ghost_histograms(struct cell *c) {
+
+  ghost_stats_reset_entries(&c->ghost_statistics);
+
+  /* recurse */
+  for (int k = 0; k < 8; ++k)
+    if (c->progeny[k] != NULL) cell_reset_ghost_histograms(c->progeny[k]);
+}
+
+/**
+ * @brief Write the ghost statistics for the given cell to the given file,
+ * using the given cell ID.
+ *
+ * This function recurses into the progenies. They all get different cell IDs
+ * by shifting the parent cell ID by 3 bits. Note that this does not
+ * guarantee unique cell IDs (but it is good enough to distinguish parents
+ * from their progeny).
+ *
+ * @param f File to write to.
+ * @param c Cell to write.
+ * @param cellID ID used to distinguish this cell from its progeny and
+ * immediate neigbours (not guaranteed to be unique!).
+ */
+void cell_write_ghost_stats(FILE *f, const struct cell *c,
+                            const long long cellID) {
+  if (c == NULL) return;
+
+  ghost_stats_write_cell_stats(f, &c->ghost_statistics, cellID);
+
+  /* Write children */
+  const long long pID = cellID << 3;
+  for (int i = 0; i < 8; i++) {
+    cell_write_ghost_stats(f, c->progeny[i], pID + i);
+  }
+}
+
+/**
+ * @brief Reset the ghost histograms for all top level cells in the space.
+ *
+ * @param s Space.
+ */
+void space_reset_ghost_histograms(struct space *s) {
+  for (int i = 0; i < s->nr_cells; ++i) {
+    cell_reset_ghost_histograms(&s->cells_top[i]);
+  }
+}
+
+/**
+ * @brief Write the ghost statistics for all the top level cells in the space
+ * to a file.
+ *
+ * The file name is composed as follows:
+ *   ghost_stats_%04i_%04i.txt
+ * where the first counter is an argument to this function (intended to be the
+ * step counter), and the second counter is the rank that does the write.
+ *
+ * @param s Space.
+ * @param j First counter in the output file name.
+ */
+void space_write_ghost_stats(const struct space *s, int j) {
+  /* Open file */
+  char filename[200];
+  sprintf(filename, "ghost_stats_%04i_%04i.txt", j, engine_rank);
+  FILE *f = fopen(filename, "w");
+  if (f == NULL) error("Error opening ghost stats file.");
+
+  /* Write header */
+  ghost_stats_write_header(f);
+
+  /* Write all the top level cells (and their children) */
+  for (int i = 0; i < s->nr_cells; i++) {
+    struct cell *c = &s->cells_top[i];
+    if (c->nodeID == engine_rank) cell_write_ghost_stats(f, c, i);
+  }
+
+  /* Cleanup */
+  fclose(f);
+}
+
+#endif
diff --git a/src/ghost_stats.h b/src/ghost_stats.h
new file mode 100644
index 0000000000..23fbcc288c
--- /dev/null
+++ b/src/ghost_stats.h
@@ -0,0 +1,500 @@
+/*******************************************************************************
+ * This file is part of SWIFT.
+ * Copyright (c) 2021 Bert Vandenbroucke (bert.vandenbroucke@gmail.com)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ ******************************************************************************/
+
+#ifndef SWIFT_GHOST_STATS_H
+#define SWIFT_GHOST_STATS_H
+
+/* Config parameters. */
+#include "minmax.h"
+#include "part.h"
+
+#include <config.h>
+#include <float.h>
+#include <stdio.h>
+
+#ifdef SWIFT_GHOST_STATS
+
+/*****************************
+ * ghost_stats functionality *
+ *****************************/
+
+/**
+ * @brief Entry for a single iteration in the ghost statistics table.
+ */
+struct ghost_stats_entry {
+  /*! Number of particles processed during the iteration. */
+  int count;
+  /*! Number of particles in the iteration that had no neighbours. */
+  int count_no_ngb;
+  /*! Minimum initial smoothing length of the particles at the start of the
+   *  iteration. */
+  float hmin;
+  /*! Maximum initial smoothing length of the particles at the start of the
+   *  iteration. */
+  float hmax;
+  /*! Sum of the initial smoothing lengths, useful to compute averages in
+   *  post-processing. */
+  double hsum;
+  /*! Sum of the initial smoothing lengths squared, useful to compute variances
+   *  and standard deviations in post-processing. */
+  double hsum2;
+};
+
+/**
+ * @brief Ghost statistics stored in a cell.
+ */
+struct ghost_stats {
+  /* Hydro ghost statistics. */
+  struct ghost_stats_entry hydro[SWIFT_GHOST_STATS + 1];
+  /* Stars ghost statistics. */
+  struct ghost_stats_entry stars[SWIFT_GHOST_STATS + 1];
+  /* Black holes ghost statistics. */
+  struct ghost_stats_entry black_holes[SWIFT_GHOST_STATS + 1];
+};
+
+/* ghost_stats_entry struct functions */
+
+/**
+ * @brief Reset the given ghost stats bin.
+ *
+ * @param bin Ghost stats bin to reset.
+ */
+__attribute__((always_inline)) INLINE static void ghost_stats_reset_entry(
+    struct ghost_stats_entry *restrict bin) {
+
+  bin->count = 0;
+  bin->count_no_ngb = 0;
+  bin->hmin = FLT_MAX;
+  bin->hmax = 0.0f;
+  bin->hsum = 0.;
+  bin->hsum2 = 0.;
+}
+
+/**
+ * @brief Write the given ghost stats bin to the given file.
+ *
+ * @param f File to write to.
+ * @param bin Ghost stats bin to write.
+ */
+__attribute__((always_inline)) INLINE static void ghost_stats_print_entry(
+    FILE *f, const struct ghost_stats_entry *restrict bin) {
+
+  if (bin->count > 0) {
+    fprintf(f, "\t%i\t%i\t%g\t%g\t%g\t%g", bin->count, bin->count_no_ngb,
+            bin->hmin, bin->hmax, bin->hsum, bin->hsum2);
+  } else {
+    fprintf(f, "\t0\t0\t0\t0\t0\t0");
+  }
+}
+
+/* ghost_stats struct functions */
+
+/**
+ * @brief Reset all the entries in the ghost_stats struct.
+ *
+ * @param gstats Ghost stats struct.
+ */
+__attribute__((always_inline)) INLINE static void ghost_stats_reset_entries(
+    struct ghost_stats *restrict gstats) {
+
+  for (int b = 0; b < SWIFT_GHOST_STATS + 1; ++b) {
+    ghost_stats_reset_entry(&gstats->hydro[b]);
+  }
+  for (int b = 0; b < SWIFT_GHOST_STATS + 1; ++b) {
+    ghost_stats_reset_entry(&gstats->stars[b]);
+  }
+  for (int b = 0; b < SWIFT_GHOST_STATS + 1; ++b) {
+    ghost_stats_reset_entry(&gstats->black_holes[b]);
+  }
+}
+
+/**
+ * @brief Account for the star particles that are still under consideration at
+ * the start of a ghost decision loop (so after the neighbour loop but before
+ * the smoothing length is updated).
+ *
+ * @param gstats Ghost stats struct to update.
+ * @param iteration_number Iteration number in the high-level ghost iteration
+ * scheme.
+ * @param scount Number of star particles still under consideration (smoothing
+ * length has not converged yet or will do so during this iteration).
+ * @param sparts Star particle array.
+ * @param sid Indices of active star particles in the array.
+ */
+__attribute__((always_inline)) INLINE static void ghost_stats_account_for_stars(
+    struct ghost_stats *restrict gstats, int iteration_number, int scount,
+    struct spart *restrict sparts, int *sid) {
+
+  /* accumulate in highest bin, so we can spot out-of-bounds values */
+  int binidx = min(iteration_number, SWIFT_GHOST_STATS - 1);
+  struct ghost_stats_entry *restrict sbin = &gstats->stars[binidx];
+  sbin->count += scount;
+  for (int i = 0; i < scount; i++) {
+    const float hi = sparts[sid[i]].h;
+    sbin->hmin = min(sbin->hmin, hi);
+    sbin->hmax = max(sbin->hmax, hi);
+    sbin->hsum += hi;
+    sbin->hsum2 += hi * hi;
+  }
+}
+
+/**
+ * @brief Account for the properties of the a converged star particle.
+ *
+ * @param gstats Ghost stats struct to update.
+ * @param sp Star particle that has a converged smoothing length.
+ */
+__attribute__((always_inline)) INLINE static void ghost_stats_converged_star(
+    struct ghost_stats *restrict gstats, struct spart *restrict sp) {
+
+  struct ghost_stats_entry *restrict sbin = &gstats->stars[SWIFT_GHOST_STATS];
+  ++sbin->count;
+  const float hi = sp->h;
+  sbin->hmin = min(sbin->hmin, hi);
+  sbin->hmax = max(sbin->hmax, hi);
+  sbin->hsum += hi;
+  sbin->hsum2 += hi * hi;
+}
+
+/**
+ * @brief Register the occurrence of a "no neighbour" event during the current
+ * star iteration step.
+ *
+ * @param gstats Ghost stats struct to update.
+ * @param iteration_number Number of the current iteration.
+ */
+__attribute__((always_inline)) INLINE static void
+ghost_stats_no_ngb_star_iteration(struct ghost_stats *restrict gstats,
+                                  int iteration_number) {
+
+  int binidx = min(iteration_number, SWIFT_GHOST_STATS - 1);
+  struct ghost_stats_entry *restrict sbin = &gstats->stars[binidx];
+  ++sbin->count_no_ngb;
+}
+
+/**
+ * @brief Register the occurrence of a converged star particle without
+ * neighbours.
+ *
+ * @param gstats Ghost stats struct to update.
+ */
+__attribute__((always_inline)) INLINE static void
+ghost_stats_no_ngb_star_converged(struct ghost_stats *restrict gstats) {
+
+  struct ghost_stats_entry *restrict sbin = &gstats->stars[SWIFT_GHOST_STATS];
+  ++sbin->count_no_ngb;
+}
+
+/**
+ * @brief Account for the black hole particles that are still under
+ * consideration at the start of a ghost decision loop (so after the neighbour
+ * loop but before the smoothing length is updated).
+ *
+ * @param gstats Ghost stats struct to update.
+ * @param iteration_number Iteration number in the high-level ghost iteration
+ * scheme.
+ * @param bcount Number of black hole particles still under consideration
+ * (smoothing length has not converged yet or will do so during this iteration).
+ * @param bparts Black hole particle array.
+ * @param sid Indices of active black hole particles in the array.
+ */
+__attribute__((always_inline)) INLINE static void
+ghost_stats_account_for_black_holes(struct ghost_stats *restrict gstats,
+                                    int iteration_number, int bcount,
+                                    struct bpart *restrict bparts, int *sid) {
+
+  /* accumulate in highest bin, so we can spot out-of-bounds values */
+  int binidx = min(iteration_number, SWIFT_GHOST_STATS - 1);
+  struct ghost_stats_entry *restrict bbin = &gstats->black_holes[binidx];
+  bbin->count += bcount;
+  for (int i = 0; i < bcount; i++) {
+    const float hi = bparts[sid[i]].h;
+    bbin->hmin = min(bbin->hmin, hi);
+    bbin->hmax = max(bbin->hmax, hi);
+    bbin->hsum += hi;
+    bbin->hsum2 += hi * hi;
+  }
+}
+
+/**
+ * @brief Account for the properties of the a converged black hole particle.
+ *
+ * @param gstats Ghost stats struct to update.
+ * @param bp Black hole particle that has a converged smoothing length.
+ */
+__attribute__((always_inline)) INLINE static void
+ghost_stats_converged_black_hole(struct ghost_stats *restrict gstats,
+                                 struct bpart *restrict bp) {
+
+  struct ghost_stats_entry *restrict bbin =
+      &gstats->black_holes[SWIFT_GHOST_STATS];
+  ++bbin->count;
+  const float hi = bp->h;
+  bbin->hmin = min(bbin->hmin, hi);
+  bbin->hmax = max(bbin->hmax, hi);
+  bbin->hsum += hi;
+  bbin->hsum2 += hi * hi;
+}
+
+/**
+ * @brief Register the occurrence of a "no neighbour" event during the current
+ * black hole iteration step.
+ *
+ * @param gstats Ghost stats struct to update.
+ * @param iteration_number Number of the current iteration.
+ */
+__attribute__((always_inline)) INLINE static void
+ghost_stats_no_ngb_black_hole_iteration(struct ghost_stats *restrict gstats,
+                                        int iteration_number) {
+
+  int binidx = min(iteration_number, SWIFT_GHOST_STATS - 1);
+  struct ghost_stats_entry *restrict bbin = &gstats->black_holes[binidx];
+  ++bbin->count_no_ngb;
+}
+
+/**
+ * @brief Register the occurrence of a converged black hole particle without
+ * neighbours.
+ *
+ * @param gstats Ghost stats struct to update.
+ */
+__attribute__((always_inline)) INLINE static void
+ghost_stats_no_ngb_black_hole_converged(struct ghost_stats *restrict gstats) {
+
+  struct ghost_stats_entry *restrict bbin =
+      &gstats->black_holes[SWIFT_GHOST_STATS];
+  ++bbin->count_no_ngb;
+}
+
+/**
+ * @brief Account for the gas particles that are still under consideration at
+ * the start of a ghost decision loop (so after the neighbour loop but before
+ * the smoothing length is updated).
+ *
+ * @param gstats Ghost stats struct to update.
+ * @param iteration_number Iteration number in the high-level ghost iteration
+ * scheme.
+ * @param count Number of gas particles still under consideration (smoothing
+ * length has not converged yet or will do so during this iteration).
+ * @param parts Gas particle array.
+ * @param sid Indices of active gas particles in the array.
+ */
+__attribute__((always_inline)) INLINE static void ghost_stats_account_for_hydro(
+    struct ghost_stats *restrict gstats, int iteration_number, int count,
+    struct part *restrict parts, int *pid) {
+
+  /* accumulate in highest bin, so we can spot out-of-bounds values */
+  int binidx = min(iteration_number, SWIFT_GHOST_STATS - 1);
+  struct ghost_stats_entry *restrict hbin = &gstats->hydro[binidx];
+  hbin->count += count;
+  for (int i = 0; i < count; i++) {
+    const float hi = parts[pid[i]].h;
+    hbin->hmin = min(hbin->hmin, hi);
+    hbin->hmax = max(hbin->hmax, hi);
+    hbin->hsum += hi;
+    hbin->hsum2 += hi * hi;
+  }
+}
+
+/**
+ * @brief Account for the properties of the a converged gas particle.
+ *
+ * @param gstats Ghost stats struct to update.
+ * @param p Gas particle that has a converged smoothing length.
+ */
+__attribute__((always_inline)) INLINE static void ghost_stats_converged_hydro(
+    struct ghost_stats *restrict gstats, struct part *restrict p) {
+
+  struct ghost_stats_entry *restrict hbin = &gstats->hydro[SWIFT_GHOST_STATS];
+  ++hbin->count;
+  const float hi = p->h;
+  hbin->hmin = min(hbin->hmin, hi);
+  hbin->hmax = max(hbin->hmax, hi);
+  hbin->hsum += hi;
+  hbin->hsum2 += hi * hi;
+}
+
+/**
+ * @brief Register the occurrence of a "no neighbour" event during the current
+ * hydro iteration step.
+ *
+ * @param gstats Ghost stats struct to update.
+ * @param iteration_number Number of the current iteration.
+ */
+__attribute__((always_inline)) INLINE static void
+ghost_stats_no_ngb_hydro_iteration(struct ghost_stats *restrict gstats,
+                                   int iteration_number) {
+
+  int binidx = min(iteration_number, SWIFT_GHOST_STATS - 1);
+  struct ghost_stats_entry *restrict hbin = &gstats->hydro[binidx];
+  ++hbin->count_no_ngb;
+}
+
+/**
+ * @brief Register the occurrence of a converged gas particle without
+ * neighbours.
+ *
+ * @param gstats Ghost stats struct to update.
+ */
+__attribute__((always_inline)) INLINE static void
+ghost_stats_no_ngb_hydro_converged(struct ghost_stats *restrict gstats) {
+
+  struct ghost_stats_entry *restrict hbin = &gstats->hydro[SWIFT_GHOST_STATS];
+  ++hbin->count_no_ngb;
+}
+
+/**
+ * @brief Write the header of a ghost statistics file.
+ *
+ * @param f File to write to.
+ */
+__attribute__((always_inline)) INLINE static void ghost_stats_write_header(
+    FILE *f) {
+
+  fprintf(f, "# Ghost statistics\n");
+  fprintf(f, "# Values listed in blocks per particle type\n");
+  fprintf(f, "# Order of types: hydro, stars, black holes\n");
+  fprintf(f, "# Number of blocks per type: %i\n", SWIFT_GHOST_STATS + 1);
+  fprintf(f, "# Number of values per block: 6\n");
+  fprintf(f, "# Last block contains converged values\n");
+  fprintf(f, "# Fields per block:\n");
+  fprintf(f, "#  - count: i4\n");
+  fprintf(f, "#  - no neighbour count: i4\n");
+  fprintf(f, "#  - min h: f4\n");
+  fprintf(f, "#  - max h: f4\n");
+  fprintf(f, "#  - sum h: f8\n");
+  fprintf(f, "#  - sum h^2: f8\n");
+  fprintf(f, "# First column is cellID\n");
+  fprintf(f, "# Cells with no values are omitted\n");
+}
+
+/**
+ * @brief Write the ghost statistics for the given cell to the given file.
+ *
+ * @param f File to write to.
+ * @param gstats Ghost statistics to write.
+ * @param cellID Cell ID to use to identify the cell.
+ */
+__attribute__((always_inline)) INLINE static void ghost_stats_write_cell_stats(
+    FILE *f, const struct ghost_stats *restrict gstats,
+    const long long cellID) {
+
+  if (gstats->hydro[0].count + gstats->stars[0].count > 0) {
+    fprintf(f, "%lld", cellID);
+    for (int b = 0; b < SWIFT_GHOST_STATS + 1; ++b) {
+      ghost_stats_print_entry(f, &gstats->hydro[b]);
+    }
+    for (int b = 0; b < SWIFT_GHOST_STATS + 1; ++b) {
+      ghost_stats_print_entry(f, &gstats->stars[b]);
+    }
+    for (int b = 0; b < SWIFT_GHOST_STATS + 1; ++b) {
+      ghost_stats_print_entry(f, &gstats->black_holes[b]);
+    }
+    fprintf(f, "\n");
+  }
+}
+
+/******************
+ * cell interface *
+ ******************/
+
+struct cell;
+
+void cell_reset_ghost_histograms(struct cell *c);
+void cell_write_ghost_stats(FILE *f, const struct cell *c,
+                            const long long cellID);
+/*******************
+ * space interface *
+ *******************/
+
+struct space;
+
+void space_reset_ghost_histograms(struct space *s);
+void space_write_ghost_stats(const struct space *s, int j);
+
+#else
+
+struct ghost_stats {};
+
+/* stars */
+__attribute__((always_inline)) INLINE static void ghost_stats_account_for_stars(
+    struct ghost_stats *restrict gstats, int iteration_number, int scount,
+    struct spart *restrict sparts, int *sid) {}
+
+__attribute__((always_inline)) INLINE static void ghost_stats_converged_star(
+    struct ghost_stats *restrict gstats, struct spart *restrict sp) {}
+
+__attribute__((always_inline)) INLINE static void
+ghost_stats_no_ngb_star_iteration(struct ghost_stats *restrict gstats,
+                                  int iteration_number) {}
+
+__attribute__((always_inline)) INLINE static void
+ghost_stats_no_ngb_star_converged(struct ghost_stats *restrict gstats) {}
+
+/* black holes */
+__attribute__((always_inline)) INLINE static void
+ghost_stats_account_for_black_holes(struct ghost_stats *restrict gstats,
+                                    int iteration_number, int bcount,
+                                    struct bpart *restrict bparts, int *sid) {}
+
+__attribute__((always_inline)) INLINE static void
+ghost_stats_converged_black_hole(struct ghost_stats *restrict gstats,
+                                 struct bpart *restrict bp) {}
+
+__attribute__((always_inline)) INLINE static void
+ghost_stats_no_ngb_black_hole_iteration(struct ghost_stats *restrict gstats,
+                                        int iteration_number) {}
+
+__attribute__((always_inline)) INLINE static void
+ghost_stats_no_ngb_black_hole_converged(struct ghost_stats *restrict gstats) {}
+
+/* hydro */
+__attribute__((always_inline)) INLINE static void ghost_stats_account_for_hydro(
+    struct ghost_stats *restrict gstats, int iteration_number, int count,
+    struct part *restrict parts, int *pid) {}
+
+__attribute__((always_inline)) INLINE static void ghost_stats_converged_hydro(
+    struct ghost_stats *restrict gstats, struct part *restrict p) {}
+
+__attribute__((always_inline)) INLINE static void
+ghost_stats_no_ngb_hydro_iteration(struct ghost_stats *restrict gstats,
+                                   int iteration_number) {}
+
+__attribute__((always_inline)) INLINE static void
+ghost_stats_no_ngb_hydro_converged(struct ghost_stats *restrict gstats) {}
+
+/// cell interface
+
+struct cell;
+
+__attribute__((always_inline)) INLINE static void cell_reset_ghost_histograms(
+    struct cell *c) {}
+
+/// space interface
+
+struct space;
+
+__attribute__((always_inline)) INLINE static void space_reset_ghost_histograms(
+    struct space *s) {}
+
+__attribute__((always_inline)) INLINE static void space_write_ghost_stats(
+    const struct space *s, int j) {}
+#endif
+
+#endif /* SWIFT_GHOST_STATS */
diff --git a/src/runner_ghost.c b/src/runner_ghost.c
index 034c4c5af5..8750bd5089 100644
--- a/src/runner_ghost.c
+++ b/src/runner_ghost.c
@@ -145,6 +145,9 @@ void runner_do_stars_ghost(struct runner *r, struct cell *c, int timer) {
     for (int num_reruns = 0; scount > 0 && num_reruns < max_smoothing_iter;
          num_reruns++) {
 
+      ghost_stats_account_for_stars(&c->ghost_statistics, num_reruns, scount,
+                                    sparts, sid);
+
       /* Reset the redo-count. */
       redo = 0;
 
@@ -173,6 +176,8 @@ void runner_do_stars_ghost(struct runner *r, struct cell *c, int timer) {
 
         if (sp->density.wcount < 1.e-5 * kernel_root) { /* No neighbours case */
 
+          ghost_stats_no_ngb_star_iteration(&c->ghost_statistics, num_reruns);
+
           /* Flag that there were no neighbours */
           has_no_neighbours = 1;
 
@@ -364,6 +369,7 @@ void runner_do_stars_ghost(struct runner *r, struct cell *c, int timer) {
 
             /* Do some damage control if no neighbours at all were found */
             if (has_no_neighbours) {
+              ghost_stats_no_ngb_star_converged(&c->ghost_statistics);
               stars_spart_has_no_neighbours(sp, cosmo);
               rt_spart_has_no_neighbours(sp);
             }
@@ -381,6 +387,8 @@ void runner_do_stars_ghost(struct runner *r, struct cell *c, int timer) {
         h_max = max(h_max, sp->h);
         h_max_active = max(h_max_active, sp->h);
 
+        ghost_stats_converged_star(&c->ghost_statistics, sp);
+
         stars_reset_feedback(sp);
 
         /* Only do feedback if stars have a reasonable birth time */
@@ -632,6 +640,9 @@ void runner_do_black_holes_density_ghost(struct runner *r, struct cell *c,
     for (int num_reruns = 0; bcount > 0 && num_reruns < max_smoothing_iter;
          num_reruns++) {
 
+      ghost_stats_account_for_black_holes(&c->ghost_statistics, num_reruns,
+                                          bcount, bparts, sid);
+
       /* Reset the redo-count. */
       redo = 0;
 
@@ -658,6 +669,9 @@ void runner_do_black_holes_density_ghost(struct runner *r, struct cell *c,
 
         if (bp->density.wcount < 1.e-5 * kernel_root) { /* No neighbours case */
 
+          ghost_stats_no_ngb_black_hole_iteration(&c->ghost_statistics,
+                                                  num_reruns);
+
           /* Flag that there were no neighbours */
           has_no_neighbours = 1;
 
@@ -774,6 +788,7 @@ void runner_do_black_holes_density_ghost(struct runner *r, struct cell *c,
 
             /* Do some damage control if no neighbours at all were found */
             if (has_no_neighbours) {
+              ghost_stats_no_ngb_black_hole_converged(&c->ghost_statistics);
               black_holes_bpart_has_no_neighbours(bp, cosmo);
             }
 
@@ -786,6 +801,8 @@ void runner_do_black_holes_density_ghost(struct runner *r, struct cell *c,
 
         /* We now have a particle whose smoothing length has converged */
 
+        ghost_stats_converged_black_hole(&c->ghost_statistics, bp);
+
         black_holes_reset_feedback(bp);
 
         /* Check if h_max has increased */
@@ -1135,6 +1152,9 @@ void runner_do_ghost(struct runner *r, struct cell *c, int timer) {
     for (int num_reruns = 0; count > 0 && num_reruns < max_smoothing_iter;
          num_reruns++) {
 
+      ghost_stats_account_for_hydro(&c->ghost_statistics, num_reruns, count,
+                                    parts, pid);
+
       /* Reset the redo-count. */
       redo = 0;
 
@@ -1161,6 +1181,8 @@ void runner_do_ghost(struct runner *r, struct cell *c, int timer) {
 
         if (p->density.wcount < 1.e-5 * kernel_root) { /* No neighbours case */
 
+          ghost_stats_no_ngb_hydro_iteration(&c->ghost_statistics, num_reruns);
+
           /* Flag that there were no neighbours */
           has_no_neighbours = 1;
 
@@ -1366,6 +1388,7 @@ void runner_do_ghost(struct runner *r, struct cell *c, int timer) {
 
             /* Do some damage control if no neighbours at all were found */
             if (has_no_neighbours) {
+              ghost_stats_no_ngb_hydro_converged(&c->ghost_statistics);
               hydro_part_has_no_neighbours(p, xp, cosmo);
               mhd_part_has_no_neighbours(p, xp, cosmo);
               chemistry_part_has_no_neighbours(p, xp, chemistry, cosmo);
@@ -1388,6 +1411,8 @@ void runner_do_ghost(struct runner *r, struct cell *c, int timer) {
         h_max = max(h_max, p->h);
         h_max_active = max(h_max_active, p->h);
 
+        ghost_stats_converged_hydro(&c->ghost_statistics, p);
+
 #ifdef EXTRA_HYDRO_LOOP
 
         /* As of here, particle gradient variables will be set. */
diff --git a/swift.c b/swift.c
index 7be7fb95a3..94740abbe5 100644
--- a/swift.c
+++ b/swift.c
@@ -1700,6 +1700,8 @@ int main(int argc, char *argv[]) {
     }
 #endif
 
+    space_write_ghost_stats(e.s, j + 1);
+
     /* Dump memory use report if collected. */
 #ifdef SWIFT_MEMUSE_REPORTS
     {
diff --git a/tools/plot_ghost_stats.py b/tools/plot_ghost_stats.py
new file mode 100644
index 0000000000..6e88043076
--- /dev/null
+++ b/tools/plot_ghost_stats.py
@@ -0,0 +1,229 @@
+#!/usr/bin/env python
+#
+# Usage:
+#  python plot_ghost_stats.py ghost_stats_file_1 [ghost_stats_file_2...]
+#    output_image.png
+#
+# Description:
+#  Plot the ghost statistics contained in the given input files. For each
+#  particle type (hydro, stars, black holes), three plots are generated:
+#   1. A histogram of the number of active particles as a function of iteration
+#      number.
+#   2. The distribution of h values for active particles as a function of the
+#      iteration number, compared with the final (converged) values.
+#   3. The number of particles that are treated as having no neighbours as a
+#      function of the iteration number.
+
+import numpy as np
+import matplotlib
+
+matplotlib.use("Agg")
+import matplotlib.pyplot as pl
+import argparse
+
+argparser = argparse.ArgumentParser()
+argparser.add_argument("input", nargs="+", action="store")
+argparser.add_argument("output", action="store")
+argparser.add_argument("--label", "-l", action="store", default=None)
+args = argparser.parse_args()
+
+data = None
+nbin = None
+nval = None
+for input in args.input:
+    # read the header block to figure out how many blocks per particle type
+    # we have
+    with open(input, "r") as ifile:
+        header = ifile.readlines()[:15]
+        this_nbin = int(header[3].split()[-1])
+        this_nval = int(header[4].split()[-1])
+    if nbin is None:
+        nbin = this_nbin
+    else:
+        if nbin != this_nbin:
+            print("Number of bins in files does not match!")
+            exit(1)
+    if nval is None:
+        nval = this_nval
+    else:
+        if nval != this_nval:
+            print("Number of values per bin in files does not match!")
+            exit(1)
+
+    tdata = np.loadtxt(input, ndmin=2)
+    if len(tdata) > 0:
+        if data is None:
+            data = np.array(tdata)
+        else:
+            data = np.append(data, tdata, axis=0)
+
+# omit meaningless first column
+data = data[:, 1:]
+
+hdata = data[:, : nval * nbin]
+sdata = data[:, nval * nbin : 2 * nval * nbin]
+bdata = data[:, 2 * nval * nbin :]
+
+
+def get_total_stats(pdata):
+    counts = pdata[:, ::nval]
+    ncell = (counts[:, 0] > 0).sum()
+    if ncell == 0:
+        return None, None, None, None, None, None, None, 0
+    nongb_counts = np.sum(pdata[:, 1::nval], axis=0)
+    hmin = np.min(pdata[:, 2::nval], axis=0, initial=np.inf, where=counts > 0)
+    hmax = np.max(pdata[:, 3::nval], axis=0, initial=0.0, where=counts > 0)
+    hsum = np.sum(pdata[:, 4::nval], axis=0, where=counts > 0)
+    hsum2 = np.sum(pdata[:, 5::nval], axis=0, where=counts > 0)
+    counts = counts.sum(axis=0)
+    filt = counts > 0
+    hmean = hsum
+    hmean[filt] /= counts[filt]
+    hmean2 = hsum2
+    hmean2[filt] /= counts[filt]
+    hstd = np.sqrt(hmean2 - hmean ** 2)
+    bins = np.arange(hmean.shape[0])
+    return (
+        bins[filt],
+        counts[filt],
+        nongb_counts[filt],
+        hmin[filt],
+        hmax[filt],
+        hmean[filt],
+        hstd[filt],
+        ncell,
+    )
+
+
+def no_values(ax):
+    ax.text(0.5, 0.5, "No values", horizontalalignment="center", transform=ax.transAxes)
+    return
+
+
+hbins, hcounts, hno_ngb, hhmin, hhmax, hhmean, hhstd, hncell = get_total_stats(
+    hdata[:, : nval * (nbin - 1)]
+)
+htbins, htcounts, htno_ngb, hthmin, hthmax, hthmean, hthstd, htncell = get_total_stats(
+    hdata[:, nval * (nbin - 1) :]
+)
+sbins, scounts, sno_ngb, shmin, shmax, shmean, shstd, sncell = get_total_stats(
+    sdata[:, : nval * (nbin - 1)]
+)
+stbins, stcounts, stno_ngb, sthmin, sthmax, sthmean, sthstd, stncell = get_total_stats(
+    sdata[:, nval * (nbin - 1) :]
+)
+bbins, bcounts, bno_ngb, bhmin, bhmax, bhmean, bhstd, bncell = get_total_stats(
+    bdata[:, : nval * (nbin - 1)]
+)
+btbins, btcounts, btno_ngb, bthmin, bthmax, bthmean, bthstd, btncell = get_total_stats(
+    bdata[:, nval * (nbin - 1) :]
+)
+
+fig, ax = pl.subplots(3, 3, sharex=True, figsize=(10, 8))
+
+if hncell > 0:
+    ax[0][0].axhline(y=htcounts[0], linestyle="--", color="k")
+    ax[1][0].axhline(y=hthmin, linestyle=":", color="k")
+    ax[1][0].axhline(y=hthmean - hthstd, linestyle="--", color="k")
+    ax[1][0].axhline(y=hthmean, linestyle="-", color="k")
+    ax[1][0].axhline(y=hthmean + hthstd, linestyle="--", color="k")
+    ax[1][0].axhline(y=hthmax, linestyle=":", color="k")
+    ax[2][0].axhline(y=htno_ngb[0], linestyle="--", color="k")
+
+    ax[0][0].bar(hbins, hcounts, log=True)
+
+    ax[1][0].plot(hbins, hhmin, label="min")
+    ax[1][0].plot(hbins, hhmax, label="max")
+    ax[1][0].fill_between(hbins, hhmean - hhstd, hhmean + hhstd, color="C2", alpha=0.5)
+    ax[1][0].plot(hbins, hhmean, color="C2", label="mean")
+    ax[1][0].set_yscale("log")
+
+    if hno_ngb.sum() > 0:
+        ax[2][0].bar(hbins, hno_ngb, log=True)
+    else:
+        no_values(ax[2][0])
+else:
+    no_values(ax[0][0])
+    no_values(ax[1][0])
+    no_values(ax[2][0])
+
+if sncell > 0:
+    ax[0][1].axhline(y=stcounts[0], linestyle="--", color="k")
+    ax[1][1].axhline(y=sthmin, linestyle=":", color="k")
+    ax[1][1].axhline(y=sthmean - sthstd, linestyle="--", color="k")
+    ax[1][1].axhline(y=sthmean, linestyle="-", color="k")
+    ax[1][1].axhline(y=sthmean + sthstd, linestyle="--", color="k")
+    ax[1][1].axhline(y=sthmax, linestyle=":", color="k")
+    ax[2][1].axhline(y=stno_ngb[0], linestyle="--", color="k")
+
+    ax[0][1].bar(sbins, scounts, log=True)
+
+    ax[1][1].plot(sbins, shmin)
+    ax[1][1].plot(sbins, shmax)
+    ax[1][1].fill_between(sbins, shmean - shstd, shmean + shstd, color="C2", alpha=0.5)
+    ax[1][1].plot(sbins, shmean, color="C2")
+    ax[1][1].set_yscale("log")
+
+    if sno_ngb.sum() > 0:
+        ax[2][1].bar(sbins, sno_ngb, log=True)
+    else:
+        no_values(ax[2][1])
+else:
+    no_values(ax[0][1])
+    no_values(ax[1][1])
+    no_values(ax[2][1])
+
+if bncell > 0:
+    ax[0][2].axhline(y=btcounts[0], linestyle="--", color="k")
+    ax[1][2].axhline(y=bthmin, linestyle=":", color="k")
+    ax[1][2].axhline(y=bthmean - bthstd, linestyle="--", color="k")
+    ax[1][2].axhline(y=bthmean, linestyle="-", color="k")
+    ax[1][2].axhline(y=bthmean + bthstd, linestyle="--", color="k")
+    ax[1][2].axhline(y=bthmax, linestyle=":", color="k")
+    ax[2][2].axhline(y=btno_ngb[0], linestyle="--", color="k")
+
+    ax[0][2].bar(bbins, bcounts, log=True)
+
+    ax[1][2].plot(bbins, bhmin)
+    ax[1][2].plot(bbins, bhmax)
+    ax[1][2].fill_between(bbins, bhmean - bhstd, bhmean + bhstd, color="C2", alpha=0.5)
+    ax[1][2].plot(bbins, bhmean, color="C2")
+    ax[1][2].set_yscale("log")
+
+    if bno_ngb.sum() > 0:
+        ax[2][2].bar(bbins, bno_ngb, log=True)
+    else:
+        no_values(ax[2][2])
+else:
+    no_values(ax[0][2])
+    no_values(ax[1][2])
+    no_values(ax[2][2])
+
+ax[1][0].set_xlabel("iteration")
+ax[1][1].set_xlabel("iteration")
+ax[1][2].set_xlabel("iteration")
+
+ax[0][0].set_ylabel("count")
+ax[1][0].set_ylabel("h")
+ax[2][0].set_ylabel("no ngb count")
+
+ax[1][0].legend(loc="best")
+ax[1][1].plot([], [], "k-", label="converged value")
+ax[1][1].legend(loc="best")
+
+ax[0][0].set_xlim(-0.5, nbin - 1.5)
+ax[0][0].set_xticks(np.arange(nbin))
+ax[0][0].set_title("hydro")
+ax[0][1].set_title("stars")
+ax[0][2].set_title("black holes")
+ax[1][0].set_title("{0} cells".format(hncell))
+ax[1][1].set_title("{0} cells".format(sncell))
+ax[1][2].set_title("{0} cells".format(bncell))
+
+if not args.label is None:
+    pl.suptitle(args.label)
+else:
+    pl.suptitle(",".join(args.input))
+
+pl.tight_layout()
+pl.savefig(args.output, dpi=300)
-- 
GitLab