#!/usr/bin/env python3
# Copyright 2022 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Maps the superpage occupancy, and the allocated object sizes on the heap.

To run this:

1. Build pa_dump_heap at a close-enough revision to the Chrome instance you're
   interested in.
2. Run pa_dump_heap --pid=PID_OF_RUNNING_CHROME_PROCESS --json=output.json
3. Run plot_superpages.py --json=output.json --output=output.png
"""

import argparse
import bisect
import math
import json

import matplotlib
from matplotlib import pylab as plt
import numpy as np

# Valid for the "before slot" variant.
_BRP_OVERHEAD = 4


def ParseJson(filename: str) -> dict:
  with open(filename, 'r') as f:
    data = json.load(f)
    return data


def PlotSuperPageData(data: dict, output_filename: str):
  num_superpages = len(data)
  num_partition_pages = len(data[0]['partition_pages'])
  data_np = np.zeros((num_superpages, num_partition_pages, 3))

  address_to_superpage = {superpage['address']: superpage for superpage in data}
  addresses = sorted(address_to_superpage.keys())

  BLACK = (0.0, 0.0, 0.0)
  GRAY = (0.5, 0.5, 0.5)
  RED = (1.0, 0.0, 0.0)
  GREEN = (0.0, 0.8, 0.0)
  WHITE = (1.0, 1.0, 1.0)
  cmap = matplotlib.cm.get_cmap('coolwarm')

  for superpage_index in range(len(addresses)):
    superpage = address_to_superpage[addresses[superpage_index]]
    for index, partition_page in enumerate(superpage['partition_pages']):
      value = None
      if partition_page['type'] == 'metadata':
        value = BLACK
      elif partition_page['type'] == 'guard':
        value = GRAY
      elif partition_page['all_zeros'] and not 'is_active' in partition_page:
        # Otherwise it may be the subsequent partition page of a decommitted
        # slot span.
        if partition_page['page_index_in_span'] == 0:
          value = WHITE

      if value is not None:
        data_np[superpage_index, index] = value
        continue

      assert partition_page['type'] == 'payload'
      if partition_page['page_index_in_span'] == 0:
        num_partition_pages_in_slot_span = math.ceil(
          partition_page['num_system_pages_per_slot_span'] / 4
        )
        if partition_page['is_decommitted'] or partition_page['is_empty']:
          value = GREEN
        else:
          fullness = (
            partition_page['num_allocated_slots']
            / partition_page['slots_per_span']
          )
          value = cmap(fullness)[:3]
        data_np[
          superpage_index, index : index + num_partition_pages_in_slot_span, :
        ] = value

  plt.figure(figsize=(20, len(address_to_superpage) / 2))
  plt.imshow(data_np)
  plt.title('Super page map')
  plt.yticks(ticks=range(len(addresses)), labels=addresses)
  plt.xlabel('PartitionPage index')

  handles = [
    matplotlib.patches.Patch(facecolor=BLACK, edgecolor='k', label='Metadata'),
    matplotlib.patches.Patch(facecolor=GRAY, edgecolor='k', label='Guard'),
    matplotlib.patches.Patch(facecolor=WHITE, edgecolor='k', label='Empty'),
    matplotlib.patches.Patch(
      facecolor=GREEN, edgecolor='k', label='Decommitted'
    ),
    matplotlib.patches.Patch(
      facecolor=cmap(0.0), edgecolor='k', label='Committed Empty'
    ),
    matplotlib.patches.Patch(
      facecolor=cmap(1.0), edgecolor='k', label='Committed Full'
    ),
  ]
  plt.legend(handles=handles, loc='lower left', fontsize=12, framealpha=0.7)

  plt.savefig(output_filename, bbox_inches='tight')


def PlotSuperPageCompressionData(data: dict, output_filename: str):
  num_superpages = len(data)
  num_pages = len(data[0]['page_sizes'])
  data_np = np.zeros((num_superpages, num_pages, 3))

  address_to_superpage = {superpage['address']: superpage for superpage in data}
  addresses = sorted(address_to_superpage.keys())

  WHITE = (1.0, 1.0, 1.0)
  cmap = matplotlib.cm.get_cmap('coolwarm')

  total_compressed_size = 0
  total_uncompressed_size = 0
  for superpage_index in range(len(addresses)):
    superpage = address_to_superpage[addresses[superpage_index]]
    for index, page in enumerate(superpage['page_sizes']):
      total_uncompressed_size += page['uncompressed']
      total_compressed_size += page['compressed']
      if page['uncompressed'] == 0:
        value = WHITE
      else:
        value = cmap(page['compressed'] / page['uncompressed'])[:3]
      data_np[superpage_index, index] = value

  overall_compression_ratio = (
    (100.0 * total_compressed_size / (total_uncompressed_size))
    if total_uncompressed_size
    else 0
  )
  plt.figure(figsize=(20, len(address_to_superpage)))
  plt.imshow(data_np, aspect=8)
  plt.title(
    'Super page compression ratio map - Ratio = %.1f%% '
    '- Compressed Size = %.1fMiB'
    % (overall_compression_ratio, total_compressed_size / (1 << 20))
  )
  plt.yticks(ticks=range(len(addresses)), labels=addresses)
  plt.xlabel('Page index in SuperPage')

  handles = [
    matplotlib.patches.Patch(
      facecolor=WHITE, edgecolor='k', label='Empty / Decommitted'
    ),
    matplotlib.patches.Patch(
      facecolor=cmap(0.0), edgecolor='k', label='Compressible'
    ),
    matplotlib.patches.Patch(
      facecolor=cmap(1.0), edgecolor='k', label='Incompressible'
    ),
  ]
  plt.legend(handles=handles, loc='lower left', fontsize=12, framealpha=0.7)
  plt.savefig(output_filename, bbox_inches='tight')


def _PlotFragmentationCommon(bucket_to_allocated: dict, output_filename: str):
  slot_sizes = sorted(bucket_to_allocated.keys())
  allocated = []
  waste = []
  for slot_size in slot_sizes:
    slots = np.array(bucket_to_allocated[slot_size])
    allocated.append(np.sum(slots))
    waste.append(np.sum(slot_size - slots))

  plt.figure(figsize=(18, 8))
  indices = range(len(slot_sizes))
  plt.bar(indices, allocated, label='Requested Memory')
  b = plt.bar(
    indices, waste, bottom=allocated, label='Waste due to padding + bucketing'
  )

  waste_percentage = [
    ('%d%%' % (int(100.0 * w / (w + a)))) if a else ''
    for (w, a) in zip(waste, allocated)
  ]

  plt.bar_label(b, labels=waste_percentage)
  plt.xticks(indices, slot_sizes, rotation='vertical')
  plt.xlim(left=-0.5, right=len(indices))
  plt.xlabel('Bucket size')
  plt.ylabel('Memory (bytes)')
  plt.legend()

  total_allocated = np.sum(allocated)
  total_waste = np.sum(waste)
  plt.title(
    'Absolute and relative waste due to bucketing and padding'
    ' per bucket - Total Allocated = %dMiB, Waste = %d%%'
    % (
      total_allocated / (1 << 20),
      int(100 * total_waste / (total_allocated + total_waste)),
    )
  )
  plt.savefig(output_filename, bbox_inches='tight')


def _AdjustSizes(data: dict, bucket_sizes: list, adjustment_size: int) -> dict:
  requested_sizes = []
  for slot_span in data:
    requested_sizes += slot_span['allocated_sizes']

  # Assumes that all buckets have *some* live allocations.
  bucket_sizes = sorted(bucket_sizes)
  bucket_to_allocated = {slot_size: list() for slot_size in bucket_sizes}

  # Map the requested sizes without paddingb to buckets.
  for requested_size in requested_sizes:
    adjusted_size = requested_size + adjustment_size
    bucket_index = bisect.bisect_left(bucket_sizes, adjusted_size)
    slot_size = bucket_sizes[bucket_index]
    assert bucket_sizes[bucket_index] >= adjusted_size
    bucket_to_allocated[slot_size].append(adjusted_size)

  return bucket_to_allocated


def PlotSimulatedFragmentationData(
  data: dict, bucket_sizes: list, output_filename: str
):
  # No adjustment, want to check waste without any padding.
  bucket_to_allocated = _AdjustSizes(data, bucket_sizes, 0)
  _PlotFragmentationCommon(bucket_to_allocated, output_filename)


def PlotFragmentationData(data: dict, bucket_sizes: list, output_filename: str):
  # "Before allocation" takes only 4 bytes, but the instrumentation added
  # expands this to 8 bytes. To reconstruct what it would have been without it,
  # take the requested size and add 4 bytes to it.
  bucket_to_allocated = _AdjustSizes(data, bucket_sizes, _BRP_OVERHEAD)
  _PlotFragmentationCommon(bucket_to_allocated, output_filename)


def PlotDelta(data: dict, bucket_sizes: list, output_filename: str):
  bucket_to_allocated_brp = _AdjustSizes(data, bucket_sizes, _BRP_OVERHEAD)
  bucket_to_allocated_no_brp = _AdjustSizes(data, bucket_sizes, 0)
  slot_sizes = sorted(bucket_to_allocated_no_brp)

  allocated_per_bucket_brp = {
    slot_size: len(bucket_to_allocated_brp[slot_size]) * slot_size
    for slot_size in bucket_to_allocated_brp
  }
  allocated_per_bucket_no_brp = {
    slot_size: len(bucket_to_allocated_no_brp[slot_size]) * slot_size
    for slot_size in bucket_to_allocated_no_brp
  }
  delta = [
    allocated_per_bucket_brp[slot_size] - allocated_per_bucket_no_brp[slot_size]
    for slot_size in slot_sizes
  ]

  plt.figure(figsize=(18, 8))
  indices = range(len(slot_sizes))
  plt.bar(indices, delta, label='Delta')
  plt.xticks(indices, slot_sizes, rotation='vertical')
  plt.xlim(left=-0.5, right=len(indices))
  plt.xlabel('Bucket size')
  plt.ylabel('Memory delta (bytes)')
  plt.title(
    'Per-bucket size difference BRP/No-BRP - Total = %.1fMiB'
    % (sum(delta) / (1 << 20))
  )
  plt.savefig(output_filename, bbox_inches='tight')


def main():
  parser = argparse.ArgumentParser()
  parser.add_argument(
    '--json', help='JSON dump from pa_tcache_inspect', required=True
  )
  parser.add_argument('--output', help='Output filename prefix', required=True)

  args = parser.parse_args()
  data = ParseJson(args.json)

  PlotSuperPageData(data['superpages'], args.output + '_superpage.png')
  PlotSuperPageCompressionData(
    data['superpages'], args.output + '_compression.png'
  )

  # Allocated object data is not always available.
  if 'allocated_sizes' in data:
    bucket_sizes = [x['slot_size'] for x in data['buckets']]
    PlotFragmentationData(
      data['allocated_sizes'], bucket_sizes, args.output + '_waste.png'
    )
    PlotSimulatedFragmentationData(
      data['allocated_sizes'],
      bucket_sizes,
      args.output + '_waste_simulated.png',
    )
    PlotDelta(data['allocated_sizes'], bucket_sizes, args.output + '_delta.png')


if __name__ == '__main__':
  main()
