| # Copyright 2026 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| """Utilities for viewing and diffing Sum Trees of memory usage.""" |
| |
| import copy |
| import jinja2 |
| import json |
| import os |
| import pandas as pd |
| import pathlib |
| import string |
| import sys |
| from dataclasses import dataclass, field |
| |
| _HAS_IPYTHON = False |
| try: |
| if not 'unittest' in sys.modules: |
| # Do not import IPython when running unit tests to avoid module name |
| # conflicts. There is `import cProfile as profile` in IPython. |
| from IPython.display import HTML, display |
| _HAS_IPYTHON = True |
| except ImportError: |
| _HAS_IPYTHON = False |
| |
| from . import demangler |
| |
| _MEMORY_USAGE_DIR = pathlib.Path(__file__).resolve().parent |
| _SRC_PATH = _MEMORY_USAGE_DIR.parents[3] |
| sys.path.append(str(_SRC_PATH / 'third_party/perfetto/python')) |
| from perfetto.trace_processor import TraceProcessor |
| |
| |
| @dataclass |
| class TreeNode: |
| """Node in a memory usage hierarchy.""" |
| |
| name: str |
| value: int = 0 |
| delta: int = 0 |
| children: list['TreeNode'] = field(default_factory=list) |
| |
| @classmethod |
| def from_dict(cls, data: dict) -> 'TreeNode': |
| raw_children = data.get('children', []) |
| children = [TreeNode.from_dict(child) for child in raw_children] |
| params = {k: v for k, v in data.items() if k != 'children'} |
| return cls(**params, children=children) |
| |
| |
| class MemoryUsageView: |
| """Holds memory usage information possibly with multiple root nodes.""" |
| |
| def __init__(self, roots: list[TreeNode]): |
| self.roots = roots |
| |
| @classmethod |
| def from_json(cls, json_data: str) -> 'MemoryUsageView': |
| """Creates a MemoryUsageView from a JSON string. |
| |
| Provides a simple way to initialize the structure for testing. |
| """ |
| data = json.loads(json_data) |
| roots = [TreeNode.from_dict(item) for item in data] |
| return cls(roots) |
| |
| @classmethod |
| def from_heap_dump(cls, |
| trace_file_name: str, |
| demangle: bool = True, |
| aggregate: bool = True) -> 'MemoryUsageView': |
| """Creates a MemoryUsageView from a trace file with a heap dump.""" |
| abs_path = os.path.abspath(trace_file_name) |
| with TraceProcessor(trace=abs_path) as trace_processor: |
| df = _extract_heap_dump(trace_processor) |
| return cls.from_df(df, demangle=demangle, aggregate=aggregate) |
| return None |
| |
| def to_json(self, **kwargs) -> str: |
| """Converts the MemoryUsageView to a JSON string.""" |
| return json.dumps(self.roots, cls=TreeNodeEncoder, indent=0, **kwargs) |
| |
| @classmethod |
| def from_df(cls, |
| df: pd.DataFrame, |
| demangle: bool = True, |
| aggregate: bool = True) -> 'MemoryUsageView': |
| """Creates a MemoryUsageView from a DataFrame.""" |
| roots = [] |
| if demangle: |
| with demangler.Demangler() as d: |
| roots = _load_df(df, d, aggregate) |
| else: |
| roots = _load_df(df, None, aggregate) |
| return cls(roots) |
| |
| @classmethod |
| def from_comparison(cls, base: 'MemoryUsageView', |
| new: 'MemoryUsageView') -> 'MemoryUsageView': |
| """Duplicates the base view with deltas as compared to the new view.""" |
| return cls(_compare_node_lists(base.roots, new.roots)) |
| |
| def toplevel_names(self) -> list[str]: |
| return [root.name for root in self.roots] |
| |
| def toplevel_pretty_report(self) -> list[str]: |
| """Generates pretty-printed memory usage for toplevel frames.""" |
| result = [] |
| for root in self.roots: |
| pretty_size = _prettify_size(root.value) |
| result.append((root.name, root.value, pretty_size)) |
| for triplet in sorted(result, key=lambda t: t[1], reverse=True): |
| yield f'{triplet[0]}: {triplet[2]}' |
| |
| def display(self): |
| if not _HAS_IPYTHON: |
| print('Error: Could not import IPython module') |
| return |
| saved_roots = self.roots |
| unique_root_id = f'metrics-root-{id(self)}' |
| env = jinja2.Environment( |
| loader=jinja2.FileSystemLoader(_MEMORY_USAGE_DIR)) |
| env.policies['json.dumps_function'] = MemoryUsageView.to_json |
| try: |
| # Common heap profiles are a too deep for the default recursion |
| # limit. |
| previous_recursion_limit = sys.getrecursionlimit() |
| sys.setrecursionlimit(10000) |
| self.roots = copy.deepcopy(self.roots) |
| _prettify_nodes(self.roots) |
| template = env.get_template('memory_usage_table.html.j2') |
| html_str = template.render(json_data=self, root_id=unique_root_id) |
| finally: |
| sys.setrecursionlimit(previous_recursion_limit) |
| self.roots = saved_roots |
| display(HTML(html_str)) |
| |
| |
| class TreeNodeEncoder(json.JSONEncoder): |
| """A JSON encoder for TreeNode objects.""" |
| |
| def default(self, obj): |
| if isinstance(obj, TreeNode): |
| data = { |
| 'name': obj.name, |
| 'value': obj.value, |
| 'delta': obj.delta, |
| } |
| if obj.children: |
| data['children'] = [ |
| self.default(child) for child in obj.children |
| ] |
| return data |
| return super().default(obj) |
| |
| |
| def _extract_heap_dump(trace_processor: TraceProcessor) -> pd.DataFrame: |
| """Extracts a heap dump from a trace file. |
| |
| In the returned DataFrame rows with parent callsite_id must precede |
| the child rows referencing them. |
| """ |
| # The SQL schema is documented here: |
| # https://perfetto.dev/docs/analysis/sql-tables#stack_profile_callsite |
| query = r""" |
| WITH AggregatedAllocations AS ( |
| SELECT |
| callsite_id, |
| SUM(size) AS total_size_bytes |
| FROM |
| heap_profile_allocation |
| WHERE |
| size >= 0 |
| GROUP BY |
| callsite_id |
| ) |
| SELECT |
| callsite.id AS callsite_id, |
| callsite.parent_id AS parent_callsite_id, |
| callsite.depth AS depth, |
| |
| frame.name AS frame_name, |
| frame.id AS frame_id, |
| |
| -- Ensures 0 is returned instead of NULL when the callsite had no recorded |
| -- allocations. |
| COALESCE(allocations.total_size_bytes, 0) AS total_size_bytes |
| FROM |
| stack_profile_callsite AS callsite |
| JOIN stack_profile_frame AS frame |
| ON callsite.frame_id = frame.id |
| |
| -- Allocations are only available for leaf callsites. Normally allocations |
| -- are callsites with name 'malloc' and 'posix_memalign'. |
| LEFT JOIN AggregatedAllocations AS allocations |
| ON callsite.id = allocations.callsite_id |
| ORDER BY |
| depth ASC |
| """ |
| df = trace_processor.query(query).as_pandas_dataframe() |
| return df |
| |
| |
| def _load_df(df: pd.DataFrame, d: demangler.Demangler, |
| aggregate: bool) -> list[TreeNode]: |
| """Loads a DataFrame into a MemoryUsageView.roots.""" |
| # Nodes indexed by callsite_id. |
| nodes: dict[int, TreeNode] = {} |
| roots: list[TreeNode] = [] |
| for row in df.itertuples(): |
| frame_name = row.frame_name |
| if pd.isna(frame_name) or not frame_name: |
| frame_name = 'unknown' |
| elif d: |
| frame_name = d.demangle(frame_name) |
| |
| callsite_id = int(row.callsite_id) |
| assert callsite_id not in nodes, ( |
| f'Duplicate callsite id: {row.callsite_id}') |
| |
| node = TreeNode(name=frame_name, value=int(row.total_size_bytes)) |
| nodes[callsite_id] = node |
| |
| if int(row.depth) == 0: |
| assert pd.isna(row.parent_callsite_id), ( |
| f'Row with zero depth and {row.parent_callsite_id=}') |
| roots.append(node) |
| continue |
| parent_id = int(row.parent_callsite_id) |
| assert parent_id in nodes, ( |
| f'Missing parent callsite for {callsite_id}: {parent_id}') |
| parent_node = nodes[parent_id] |
| parent_node.children.append(node) |
| if aggregate: |
| roots = _aggregate_nodes(roots) |
| return roots |
| |
| |
| def _aggregate_nodes(nodes: list[TreeNode]) -> list[TreeNode]: |
| """Recursively aggregates values the list of TreeNodes for viewing. |
| |
| Replaces same-level nodes with the same name by a single node. Sorts |
| all children by value, largest first. |
| |
| Note: This function is not idempotent. Applying the aggregation twice will |
| lead to wrong results. |
| """ |
| # Group nodes by name, non-recursively. |
| grouped_by_name = {} |
| for node in nodes: |
| name = node.name |
| grouped_by_name.setdefault(name, []) |
| grouped_by_name[name].append(node) |
| |
| # Replace each group of nodes with a single occurrence. |
| name_to_grouped_node = {} |
| for name, group_list in grouped_by_name.items(): |
| all_children = [] |
| |
| # Sum up all individual node values. |
| list_total_value = 0 |
| for node in group_list: |
| list_total_value += node.value |
| all_children.extend(node.children) |
| |
| # Recursively replace children with grouped ones. |
| grouped_node = TreeNode(name=name, value=0, delta=0) |
| new_children = [] |
| if all_children: |
| new_children = _aggregate_nodes(all_children) |
| grouped_node.children = new_children |
| for child in new_children: |
| grouped_node.value += child.value |
| |
| grouped_node.value += list_total_value |
| name_to_grouped_node[name] = grouped_node |
| |
| # Sort the nodes by their cumulative value. |
| return sorted(list(name_to_grouped_node.values()), |
| key=lambda node: node.value, |
| reverse=True) |
| |
| |
| def _zip_by_name(left_nodes: list[TreeNode], |
| right_nodes: list[TreeNode]) -> list[TreeNode]: |
| """Iterates over two lists of nodes, producing pairs with an item from each. |
| |
| Nodes are paired by name. This function assumes that each input list has no |
| nodes with duplicate names. If for an element in either list there is no |
| paired node (i.e. no node with the same name) in the other list, then None |
| takes place of the non-existent node. |
| """ |
| name_to_right_node = {n.name: n for n in right_nodes} |
| processed_names = set() |
| for node1 in left_nodes: |
| name = node1.name |
| processed_names.add(name) |
| node2 = name_to_right_node.get(name) |
| yield (node1, node2) |
| for node2 in right_nodes: |
| if node2.name not in processed_names: |
| yield (None, node2) |
| |
| |
| def _compare_node_lists(nodes_base: list[TreeNode], |
| nodes_new: list[TreeNode]) -> list[TreeNode]: |
| """Recursively creates a copy of base nodes with deltas to the new list. |
| |
| Puts value difference (from base list to the new list) as node.delta in the |
| resulting tree(s). Assumes that each list (or list of children of a node |
| does not have duplicated names). This can be achieved by applying |
| _aggregate_nodes() above. Toplevel lists and all lists of children are |
| individually sorted by delta (biggest to smallest) for simplest |
| visualisation to emphasize biggest memory regressions first. |
| """ |
| result = [] |
| for base, new in _zip_by_name(nodes_base, nodes_new): |
| if not base: |
| result.append(TreeNode(name=new.name, value=0, delta=new.value)) |
| continue |
| if not new: |
| zero_new = TreeNode(name=base.name, |
| value=base.value, |
| delta=-base.value) |
| result.append(zero_new) |
| continue |
| merged_node = TreeNode(name=base.name, value=base.value) |
| merged_node.delta = new.value - base.value |
| merged_node.children = _compare_node_lists(base.children, new.children) |
| result.append(merged_node) |
| return sorted(result, key=lambda node: node.delta, reverse=True) |
| |
| |
| def _prettify_size(size_bytes: int): |
| KIB = 1024 |
| TIB = KIB**4 |
| |
| if size_bytes < KIB or size_bytes > TIB: |
| # The JS display table replaces zero integers with empty cells. To allow |
| # removing such clutter simply do not convert the result to str. |
| return size_bytes |
| |
| for unit in ['KiB', 'MiB', 'GiB']: |
| size_bytes /= KIB |
| if size_bytes < KIB or unit == 'GiB': |
| result = f'{size_bytes:.2f}'.rstrip('0').rstrip('.') |
| return f'{result} {unit}' |
| |
| |
| def _prettify_nodes(nodes: list[TreeNode]): |
| for node in nodes: |
| node.value = _prettify_size(node.value) |
| node.delta = _prettify_size(node.delta) |
| _prettify_nodes(node.children) |