blob: 0f5bc47eb43e8fd803a0446f1ee71a397d97aa8c [file]
# 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)