class_docstr: Create an experiment with time/memory checkpoints
code: def finish(self):
      if self.cl:
          logger.debug(self.__class__.__name__ + f"finish: 0 {self}")
          self.cl.stop()
          self.cl = None  # free the CL object
  
      self.running = False
  
      if not self.enable:
          return
  
      """ Finish the experiment, reclaim memory, return final stats """
      print("\n" + self.__class__.__name__ + ": Finishing")
  
      elapsed_time = int(time.time() - self.start_time)
      print(
          f"\n*** Experiment finished in {time.strftime('%H:%M:%S', time.gmtime(elapsed_time))} (elapsed wallclock time)"
      )
  
      # first take the final snapshot of consumed resources
      cpu_ram_cons, gpu_ram_cons = self._consumed()
      self.cpu_ram_cons = cpu_ram_cons
      self.gpu_ram_cons = gpu_ram_cons
  
      # get the new var names since constructor
      var_names_cur = self.get_var_names()
      # print(var_names_cur)
  
      # XXX: this doesn't work, since some variables get modified during the
      # experiment, but indirectly and therefore shouldn't be deleted.
      # So the idea of comparing values before and after doesn't quite work.
      #
      # only newly introduced variables, or variables that have been re-used
      # changed_vars = [k for k in var_names_cur
      #            if not (k in self.var_start and self.namespace.shell.user_ns[k] is self.var_start[k])]
  
      # extract the var names added during the experiment and delete
      # them, with the exception of those we were told to preserve
      var_names_new = list(
          set(var_names_cur)
          - set(self.var_names_start)
          - set(self.var_names_keep)
      )
      var_names_deleted = []
      var_names_failed_delete = []
      for x in var_names_new:
          # seems that some vars can disappear, so we need to check they are still there
          if x in self.namespace.shell.user_ns:
              # make sure not to delete objects of the same type as self (previous
              # instances of the same)
              if type(self.namespace.shell.user_ns[x]) != type(self):
                  # and even then it sometimes fails
                  try:
                      self.namespace.xdel(x)
                      var_names_deleted.append(x)
                  except:
                      # print(f"failed to delete {x}: xdel")
                      var_names_failed_delete.append(x)
          else:
              # print(f"failed to delete {x}, not in user_ns")
              var_names_failed_delete.append(x)
      if self.var_names_keep or var_names_deleted:
          print("\n*** Newly defined local variables:")
          if var_names_deleted:
              print("Deleted:", ", ".join(sorted(var_names_deleted)))
          if var_names_failed_delete:
              print(
                  "Failed to delete:",
                  ", ".join(sorted(var_names_failed_delete)),
              )
          if self.var_names_keep:
              print("Kept:   ", ", ".join(sorted(self.var_names_keep)))
  
      # cleanup and reclamation
      collected = gc.collect()
      if collected:
          print(
              "\n*** Circular ref objects gc collected during the experiment:"
          )
          print(f"cleared {collected} objects (only temporary leakage)")
      if len(gc.garbage):
          print("\n*** Potential memory leaks during the experiment:")
          print(f"uncollected gc.garbage of {len(gc.garbage)} objects")
      # now we can attempt to reclaim GPU memory
      self.gpu_clear_cache()
      self.reclaimed = True
  
      # now we can measure how much was reclaimed
      cpu_ram_recl, gpu_ram_recl = self._reclaimed()
      cpu_ram_pct = cpu_ram_recl / cpu_ram_cons if cpu_ram_cons else 1
      gpu_ram_pct = gpu_ram_recl / gpu_ram_cons if gpu_ram_cons else 1
  
      if 1:  # align
          vals = [cpu_ram_cons, cpu_ram_recl]
      if self.backend != "cpu":
          vals += [gpu_ram_cons, gpu_ram_recl]
      w = int2width(*map(b2mb, vals)) + 1  # some air
      if w < 8:
          w = 8  # accommodate header width
  
      print("\n*** Experiment memory:")
      print(f"RAM: {'Consumed':>{w}}       {'Reclaimed':>{w}}")
      if 1:
          print(
              f"CPU: {b2mb(cpu_ram_cons):{w},.0f} {b2mb(cpu_ram_recl):{w},.0f} MB ({cpu_ram_pct*100:6.2f}%)"
          )
      if self.backend != "cpu":
          print(
              f"GPU: {b2mb(gpu_ram_cons):{w},.0f} {b2mb(gpu_ram_recl):{w},.0f} MB ({gpu_ram_pct*100:6.2f}%)"
          )
  
      self.print_state()
  
      print(
          "\n"
      )  # extra vertical white space, to not mix with user's outputs
  
      cpu_ram_avail, gpu_ram_avail = self._available()
      return self._data_format(
          cpu_ram_avail,
          cpu_ram_cons,
          cpu_ram_recl,
          gpu_ram_avail,
          gpu_ram_cons,
          gpu_ram_recl,
      )
  
imports: import gc
  import logging
  import os
  import psutil
  import sys
  import threading
  import time
  import weakref
  from IPython import get_ipython
  from IPython.core.magics.namespace import NamespaceMagics
  from collections import namedtuple
  from .cell_logger import CellLogger, b2mb, int2width, get_nvml_gpu_id
label_desc: Use image node_id 23 for calling the IPyExperiments obj's underlying member method code with example usage: obj.finish() and returns: self