class_docstr: Object to store runtime and memory profiling information for all of
  PyTensor's operations: compilation, rewriting, execution.
  
  Parameters
  ----------
  atexit_print : bool
      True means that this object will be printed to stderr (using .summary())
      at the end of the program.
  **kwargs : misc initializers
      These should (but need not) match the names of the class vars declared
      in this class.
class_docstr_tok: ["Object","to","store","runtime","and","memory","profiling","information","for","all","of","PyTensor","'s","operations",":","compilation",",","rewriting",",","execution",".","Parameters","--","--","--","--","--","atexit_print",":","bool","True","means","that","this","object","will","be","printed","to","stderr","(","using",".summary","(",")",")","at","the","end","of","the","program",".","*","*","kwargs",":","misc","initializers","These","should","(","but","need","not",")","match","the","names","of","the","class","vars","declared","in","this","class","."]
code: def summary_memory(self, file, N=None):
      fct_memory = {}  # fgraph->dict(node->[outputs size])
      fct_shapes = {}  # fgraph->dict(node->[outputs shapes]))
      var_mem = (
          {}
      )  # variable->size in bytes; don't include input variables
      node_mem = (
          {}
      )  # (fgraph, node)->total outputs size (only dense outputs)
  
      for fgraph, node in self.apply_callcount:
          fct_memory.setdefault(fgraph, {})
          fct_memory[fgraph].setdefault(node, [])
          fct_shapes.setdefault(fgraph, {})
          fct_shapes[fgraph].setdefault(node, [])
          sum_dense = 0
          for out in node.outputs:
              if out in self.variable_shape:
                  sh = self.variable_shape[out]
                  if hasattr(out.type, "get_size"):
                      v = out.type.get_size(sh)
                      sum_dense += v
                  else:
                      v = 0  # 'Unknown'
              else:
                  v = 0  # 'Variable isn't created'
  
              var_mem[out] = v
              fct_memory[fgraph][node].append(v)
              fct_shapes[fgraph][node].append(sh)
          node_mem[(fgraph, node)] = sum_dense
      del v
  
      # Find the function that used the most of that statistic
      max_sum_size = 0
  
      # statistics with the old and new order
      stats = [
          [[0, 0, 0], [0, 0, 0], 0, 0],  # old, with dmap
          [[0, 0, 0], [0, 0, 0], 0, 0],  # old, without dmap
          [[0, 0, 0], [0, 0, 0], 0, 0],  # new, with dmap
          [[0, 0, 0], [0, 0, 0], 0, 0],
      ]  # new, without dmap
  
      # track min peak memory usage
      min_max_peak = 0
      min_peak_time = 0
  
      def count_running_memory(
          order, fgraph, nodes_mem, ignore_dmap=False
      ):
          """
          Calculate memory with specific node order.
  
          Return a list including the following values
          1.  node_memory_size
              Sum of the size of all variables that actually allocate
              memory (excluding views, and inplace).
          2.  running_memory_size
              The memory allocated after the current apply node.
          3.  running_max_memory_size
              The maximum of running_memory_size during the function.
          4.  node_memory_saved_by_view
              The sum of memory saved by returning view instead of new
              allocation.
          5.  node_memory_saved_by_inplace
              The sum of memory saved by reusing the input instead of
              new allocation.
  
          """
          # TODO: GPU is not supported for now, needs to be refactored later
          # Initial Mem info values [CPU, GPU]
          node_memory_size = [0, 0]
          running_memory_size = [0, 0]
          running_max_memory_size = [0, 0]
          node_memory_saved_by_view = 0
          node_memory_saved_by_inplace = 0
          # This take only the inputs/outputs dependencies.
          dependencies = fgraph.profile.dependencies
  
          # Initial compute_map which is used to check if a node is valid
          compute_map = defaultdict(lambda: [0])
          for var in fgraph.inputs:
              compute_map[var][0] = 1
  
          # two data structure used to mimic Python gc
          viewed_by = {}  # {var1: [vars that view var1]}
          # The len of the list is the value of python ref
          # count. But we use a list, not just the ref count value.
          # This is more safe to help detect potential bug  in the algo
          for var in fgraph.variables:
              viewed_by[var] = []
          view_of = {}  # {var1: original var viewed by var1}
          # The original mean that we don't keep track of all the intermediate
          # relationship in the view.
  
          for node in order:
              for var in node.outputs:
                  compute_map[var][0] = 1
              idx = 0
              if ignore_dmap:
                  dmap = None
              else:
                  dmap = node.op.destroy_map
              vmap = node.op.view_map
              val = nodes_mem[node]
  
              for v in val:
                  # TODO check the op returned a view
                  if dmap and idx in dmap:
                      node_memory_saved_by_inplace += v
                  # TODO check the op returned a view
                  elif vmap and idx in vmap:
                      node_memory_saved_by_view += v
                  idx += 1
  
              # Update the Python emulating dicts and add the memory
              # allocated by the node
              idx2 = 0
              for out in node.outputs:
                  # NOTE: cg=1 was used for GPU
                  cg = 0
                  ins = None
                  if dmap and idx2 in dmap:
                      vidx = dmap[idx2]
                      assert len(vidx) == 1, (
                          "Here we only support the "
                          "possibility to destroy one "
                          "input"
                      )
                      ins = node.inputs[vidx[0]]
                  if vmap and idx2 in vmap:
                      assert ins is None
                      vidx = vmap[idx2]
                      assert len(vidx) == 1, (
                          "Here we only support the "
                          "possibility to view one "
                          "input"
                      )
                      ins = node.inputs[vidx[0]]
                  if ins is not None:
                      # This is needed for destroy_map in case it
                      # return a partial view that is destroyed.  So
                      # the output could be different then the
                      # input.
                      assert isinstance(ins, Variable)
                      # we keep track of view only again the origin
                      origin = view_of.get(ins, ins)
                      view_of[out] = origin
                      viewed_by[origin].append(out)
                  else:
                      running_memory_size[cg] += var_mem[out]
                      node_memory_size[cg] += var_mem[out]
                  idx2 += 1
  
              running_max_memory_size[0] = max(
                  running_max_memory_size[0], running_memory_size[0]
              )
              running_max_memory_size[1] = max(
                  running_max_memory_size[1], running_memory_size[1]
              )
  
              # Mimic the combination of PyTensor and Python gc
              for ins in set(node.inputs):
                  assert not (ins in view_of and viewed_by[ins])
                  # we trac the original var, so this shouldn't happen
                  # NOTE: cg=1 was used for GPU
                  cg = 0
                  if (
                      dependencies[ins]
                      and ins not in fgraph.outputs
                      and ins.owner
                      and all(
                          compute_map[v][0] for v in dependencies[ins]
                      )
                  ):
                      if ins not in view_of and not viewed_by.get(
                          ins, []
                      ):
                          running_memory_size[cg] -= var_mem[ins]
                      elif ins in view_of:
                          origin = view_of[ins]
                          viewed_by[origin].remove(ins)
                          if (
                              not viewed_by[origin]
                              and origin not in fgraph.inputs
                              and not isinstance(origin, Constant)
                          ):
                              running_memory_size[cg] -= var_mem[origin]
                  else:
                      # ins is viewed_by something else, so its
                      # memory isn't freed
                      pass
  
          return [
              node_memory_size,
              running_memory_size,
              running_max_memory_size,
              node_memory_saved_by_inplace,
              node_memory_saved_by_view,
          ]
  
      def count_minimum_peak(node_list, fgraph, nodes_mem):
          global mem_count, mem_bound, max_mem_count
          node_list = list(node_list)
          mem_count = 0
          max_mem_count = 0
          mem_bound = np.inf
          # This take only the inputs/outputs dependencies.
          dependencies = fgraph.profile.dependencies
          done_set = set()
          done_dict = {}
  
          # Initial compute_map which is used to check if a node is valid
          compute_map = defaultdict(lambda: [0])
          for var in fgraph.inputs:
              compute_map[var][0] = 1
          for var in node_list:
              for val in var.inputs:
                  if isinstance(val, Constant):
                      compute_map[val][0] = 1
  
          destroy_dependencies = get_destroy_dependencies(fgraph)
  
          # Initial executable_nodes
          executable_nodes = set()
          for var in fgraph.inputs:
              for c, _ in fgraph.clients[var]:
                  if c != "output":
                      deps = c.inputs + destroy_dependencies[c]
                      if all(compute_map[v][0] for v in deps):
                          executable_nodes.add(c)
  
          def min_memory_generator(
              executable_nodes, viewed_by, view_of
          ):
              """
              Generate all valid node order from node_list and compute its
              memory peak.
  
              Parameters
              ----------
              executable_nodes
                  Set of executable nodes.
  
              """
              global mem_count, mem_bound, max_mem_count
  
              for node in executable_nodes:
                  new_exec_nodes = executable_nodes.copy()
                  new_exec_nodes.remove(node)
  
                  # Check if cut path now
                  if max_mem_count > mem_bound:
                      continue
  
                  viewof_change = []
                  # Use to track view_of changes
  
                  viewedby_add = defaultdict(list)
                  viewedby_remove = defaultdict(list)
                  # Use to track viewed_by changes
  
                  for var in node.outputs:
                      compute_map[var][0] = 1
  
                  mem_created = 0
                  mem_freed = 0
                  max_storage = max_mem_count
  
                  dmap = node.op.destroy_map
                  vmap = node.op.view_map
  
                  idx = 0
                  # Update the Python emulating dicts and add the
                  # memory allocated by the node
                  for out in node.outputs:
                      ins = None
                      if dmap and idx in dmap:
                          vidx = dmap[idx]
                          assert len(vidx) == 1, (
                              "Here we only support "
                              "the possibility to "
                              "destroy one input"
                          )
                          ins = node.inputs[vidx[0]]
                      if vmap and idx in vmap:
                          assert ins is None
                          vidx = vmap[idx]
                          assert len(vidx) == 1, (
                              "Here we only support "
                              "the possibility to "
                              "view one input"
                          )
                          ins = node.inputs[vidx[0]]
                      if ins is not None:
                          # This is needed for destroy_map in case it
                          # return a partial view that is destroyed. So
                          # the output could be different then the
                          # input.
                          assert isinstance(ins, Variable)
                          # We keep track of view only again the original
                          origin = view_of.get(ins, ins)
                          view_of[out] = origin
                          viewof_change.append(out)
                          viewed_by[origin].append(out)
                          viewedby_add[origin].append(out)
                      else:
                          mem_created += var_mem[out]
                      idx += 1
  
                  mem_count += mem_created
                  max_mem_count = max(max_mem_count, mem_count)
  
                  # Mimic the combination of PyTensor and Python gc.
                  for ins in node.inputs:
                      assert not (ins in view_of and viewed_by[ins])
                      # We track of the original var, so this shouldn't
                      # happen
                      if (
                          dependencies[ins]
                          and ins not in fgraph.outputs
                          and ins.owner
                          and all(
                              compute_map[v][0]
                              for v in dependencies[ins]
                          )
                      ):
                          if ins not in view_of and not viewed_by.get(
                              ins, []
                          ):
                              mem_freed += var_mem[ins]
                          elif ins in view_of:
                              origin = view_of[ins]
                              viewed_by[origin].remove(ins)
                              viewedby_remove[origin].append(ins)
                              if (
                                  not viewed_by[origin]
                                  and origin not in fgraph.inputs
                                  and not isinstance(origin, Constant)
                              ):
                                  mem_freed += var_mem[origin]
                      else:
                          # ins is viewed_by something else, so its
                          # memory isn't freed
                          pass
  
                  mem_count -= mem_freed
  
                  done_set.add(node)
                  frozen_set = frozenset(done_set)
                  if (
                      done_dict.get(frozen_set, max_mem_count + 1)
                      > max_mem_count
                  ):
                      # check if frozen_set is in done_set
                      # no, add it to done_set
                      # yes, then compare the past mem and current mem
                      # bigger, update the value and continue
                      # smaller, stop this iteration, move to next node
                      done_dict[frozen_set] = max_mem_count
  
                      destroy_dependencies = get_destroy_dependencies(
                          fgraph
                      )
  
                      for var in node.outputs:
                          for c, _ in fgraph.clients[var]:
                              if c != "output":
                                  deps = (
                                      c.inputs + destroy_dependencies[c]
                                  )
                                  if all(
                                      compute_map[v][0] for v in deps
                                  ):
                                      new_exec_nodes.add(c)
  
                      if not new_exec_nodes:
                          # Check and Update mem_bound
                          if max_mem_count < mem_bound:
                              mem_bound = max_mem_count
                      else:
                          min_memory_generator(
                              new_exec_nodes, viewed_by, view_of
                          )
  
                  # Reset track variables
                  done_set.remove(node)
                  mem_count -= mem_created
                  max_mem_count = max_storage
                  mem_count += mem_freed
                  for var in node.outputs:
                      compute_map[var][0] = 0
  
                  for k_remove, v_remove in viewedby_remove.items():
                      for i in v_remove:
                          viewed_by[k_remove].append(i)
  
                  for k_add, v_add in viewedby_add.items():
                      for i in v_add:
                          viewed_by[k_add].remove(i)
  
                  for k in viewof_change:
                      del view_of[k]
  
          # two data structure used to mimic Python gc
          viewed_by = {}  # {var1: [vars that view var1]}
          # The len of the list is the value of python ref
          # count. But we use a list, not just the ref count value.
          # This is more safe to help detect potential bug  in the algo
          for var in fgraph.variables:
              viewed_by[var] = []
          view_of = {}  # {var1: original var viewed by var1}
          # The original mean that we don't keep track of all the intermediate
          # relationship in the view.
  
          min_memory_generator(executable_nodes, viewed_by, view_of)
  
          return mem_bound
  
      for fgraph, nodes_mem in fct_memory.items():
          # Sum of the size of all variables in bytes
          sum_size = sum(
              sum(v for v in val if not isinstance(v, str))
              for key, val in nodes_mem.items()
          )
  
          order = fgraph.toposort()
          # A list of intermediate variable that are not need
          # after the execution of the corresponding node.
          # It mean that after executing the node,
          # the corresponding variable can be gc.
  
          # Store the max of some stats by any function in this profile.
          max_sum_size = max(max_sum_size, sum_size)
  
          def compute_max_stats(running_memory, stats):
              (
                  max_node_memory_size,
                  max_running_max_memory_size,
                  max_node_memory_saved_by_view,
                  max_node_memory_saved_by_inplace,
              ) = stats
  
              max_node_memory_size[0] = max(
                  max_node_memory_size[0], sum(running_memory[0])
              )
              max_running_max_memory_size[0] = max(
                  max_running_max_memory_size[0], sum(running_memory[2])
              )
  
              # NOTE: we do not have GPU right now, this has to be reconsidered later
              # Separate CPU and GPU
              max_node_memory_size[1] = max(
                  max_node_memory_size[1], running_memory[0][0]
              )
              max_node_memory_size[2] = max(
                  max_node_memory_size[2], running_memory[0][1]
              )
              max_running_max_memory_size[1] = max(
                  max_running_max_memory_size[1], running_memory[2][0]
              )
              max_running_max_memory_size[2] = max(
                  max_running_max_memory_size[2], running_memory[2][1]
              )
  
              max_node_memory_saved_by_inplace = max(
                  max_node_memory_saved_by_inplace, running_memory[3]
              )
              max_node_memory_saved_by_view = max(
                  max_node_memory_saved_by_view, running_memory[4]
              )
              return (
                  max_node_memory_size,
                  max_running_max_memory_size,
                  max_node_memory_saved_by_view,
                  max_node_memory_saved_by_inplace,
              )
  
          new_order = fgraph.profile.node_executed_order
          # A list of new executed node order
          for i, (ord, ignore_dmap) in enumerate(
              [
                  (order, False),
                  (order, True),
                  (new_order, False),
                  (new_order, True),
              ]
          ):
              running_memory = count_running_memory(
                  ord, fgraph, nodes_mem, ignore_dmap=ignore_dmap
              )
  
              stats[i] = compute_max_stats(running_memory, stats[i])
  
          # Config: whether print min memory peak
          if config.profiling__min_peak_memory:
              node_list = fgraph.apply_nodes
              ttt = time.perf_counter()
              min_peak = count_minimum_peak(
                  node_list, fgraph, nodes_mem
              )
              min_peak_time += time.perf_counter() - ttt
              min_max_peak = max(min_max_peak, min_peak)
  
          del fgraph, nodes_mem
  
      if len(fct_memory) > 1:
          print(
              "Memory Profile (the max between all functions in that profile)",
              file=file,
          )
      else:
          print("Memory Profile", file=file)
  
      print("(Sparse variables are ignored)", file=file)
      print(
          "(For values in brackets, it's for linker = c|py", file=file
      )
  
      def print_stats(stats1, stats2):
          (_, max_running_max_memory_size, _, _) = stats1
          (_, new_max_running_max_memory_size, _, _) = stats2
  
          print(
              (
                  f"        CPU: {int(round(new_max_running_max_memory_size[1] / 1024.0))}KB "
                  f"({int(round(max_running_max_memory_size[1] / 1024.0))}KB)"
              ),
              file=file,
          )
          print(
              (
                  f"        GPU: {int(round(new_max_running_max_memory_size[2] / 1024.0))}KB "
                  f"({int(round(max_running_max_memory_size[2] / 1024.0))}KB)"
              ),
              file=file,
          )
          print(
              (
                  f"        CPU + GPU: {int(round(new_max_running_max_memory_size[0] / 1024.0))}KB "
                  f"({int(round(max_running_max_memory_size[0] / 1024.0))}KB)"
              ),
              file=file,
          )
  
      print("---", file=file)
      print("    Max peak memory with current setting", file=file)
      print_stats(stats[0], stats[2])
      print(
          "    Max peak memory with current setting and PyTensor flag optimizer_excluding=inplace",
          file=file,
      )
      print_stats(stats[1], stats[3])
  
      (max_node_memory_size, _, _, _) = stats[0]
      (new_max_node_memory_size, _, _, _) = stats[2]
      print(
          "    Max peak memory if allow_gc=False (linker don't make a difference)",
          file=file,
      )
      print(
          f"        CPU: {int(round(new_max_node_memory_size[1] / 1024.0))}KB",
          file=file,
      )
      print(
          f"        GPU: {int(round(new_max_node_memory_size[2] / 1024.0))}KB",
          file=file,
      )
      print(
          f"        CPU + GPU: {int(round(new_max_node_memory_size[0] / 1024.0))}KB",
          file=file,
      )
      print("---", file=file)
  
      if min_max_peak:
          print(
              "    Minimum peak from all valid apply node order is "
              f"{int(round(min_max_peak / 1024.0))}KB(took {min_peak_time:3f}s to compute)",
              file=file,
          )
  
          print("---", file=file)
  
      print("", file=file)
      if len(fct_memory) > 1:
          print(
              "    This list is based on all functions in the profile",
              file=file,
          )
      print(
          "    <Sum apply outputs (bytes)>"
          " <Apply outputs shape>"
          " <created/inplace/view>"
          " <Apply node>",
          file=file,
      )
      print("", file=file)
      items = list(node_mem.items())
      items.sort(key=lambda a: a[1], reverse=True)
      for idx, ((fgraph, node), node_outputs_size) in enumerate(
          items[:N]
      ):
          code = ["c"] * len(node.outputs)
          for out, inp in node.op.destroy_map.items():
              code[out] = "i"
          for out, inp in node.op.view_map.items():
              code[out] = "v"
          shapes = str(fct_shapes[fgraph][node])
  
          if all(hasattr(out.type, "get_size") for out in node.outputs):
              size = f"{node_outputs_size:9d}B"
              if node_outputs_size < config.profiling__min_memory_size:
                  N = idx
                  break
          else:
              size = "   Unknown"
  
          print(
              f"     {size}  {shapes} {' '.join(code)} {node}",
              file=file,
          )
  
      sum_remaining = sum(size for _, size in items[N:])
      size_sum_dense = sum(node_mem.values())
      if size_sum_dense == 0:
          p = "0%"
      else:
          p = f"({float(sum_remaining) / size_sum_dense * 100:.2f}%)"
      print(
          (
              f"   ... (remaining {max(0, len(node_mem) - N)} Apply account for "
              f"{sum_remaining:4d}B/{size_sum_dense :d}B ({p}) of the"
              " Apply with dense outputs sizes)"
          ),
          file=file,
      )
      print("", file=file)
      if N == 0:
          print(
              "    All Apply nodes have output sizes that take less "
              f"than {int(config.profiling__min_memory_size)}B.",
              file=file,
          )
      print(
          "    <created/inplace/view> is taken from the Op's declaration.",
          file=file,
      )
      print(
          "    Apply nodes marked 'inplace' or 'view' may"
          " actually allocate memory, this is not reported"
          " here. If you use DebugMode, warnings will be"
          " emitted in those cases.",
          file=file,
      )
      print("", file=file)
  
imports: import atexit
  import copy
  import logging
  import operator
  import sys
  import time
  from collections import defaultdict
  from contextlib import contextmanager
  from typing import TYPE_CHECKING, Any, Optional, Union
  import numpy
  import pytensor
  from pytensor.configdefaults import config
  from pytensor.graph.basic import Apply, Constant, Variable
  from pytensor.link.utils import get_destroy_dependencies
label_desc: Use image node_id 18 for calling the ProfileStats obj's underlying member method code with example usage: obj.summary_memory(file, N) and returns: list, mem_bound, max_node_memory_size,  max_running_max_memory_size,  max_node_memory_saved_by_view,  max_node_memory_saved_by_inplace