blob: e27dd85728b2e5e17fd3c970f9e2038bda5a65c7 [file]
#!/usr/bin/env python3
# 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.
"""Summarizes graphics memory usage on an Android device via adb shell.
Parses data from:
- adb shell dumpsys gpu
- adb shell dmabuf_dump
- adb shell dumpsys SurfaceFlinger
"""
import argparse
import logging
import os
import re
import sys
_SRC_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
sys.path.append(os.path.join(_SRC_PATH, 'third_party', 'catapult', 'devil'))
from devil.android import device_errors
from devil.android import device_utils
sys.path.append(os.path.join(_SRC_PATH, 'build', 'android'))
import devil_chromium
def _run_adb_shell_command(device, cmd):
if not device:
return ""
try:
return device.RunShellCommand(cmd,
check_return=True,
large_output=True,
raw_output=True)
except device_errors.CommandFailedError as e:
logging.error("Failed to run adb command %s on device: %s", cmd, e)
return ""
def _parse_size_to_bytes(size_val, unit_str=''):
unit = unit_str.strip().lower()
val = float(size_val)
if unit in ('kib', 'kb'):
return val * 1024.0
elif unit in ('mib', 'mb'):
return val * 1024.0 * 1024.0
elif unit in ('gib', 'gb'):
return val * 1024.0 * 1024.0 * 1024.0
elif unit in ('b', 'bytes', ''):
return val
return val
def _format_bytes(num_bytes):
if num_bytes is None:
return "N/A"
num = float(num_bytes)
if num >= 1024.0 * 1024.0 * 1024.0:
return f"{num / (1024.0 ** 3):.2f} GiB"
elif num >= 1024.0 * 1024.0:
return f"{num / (1024.0 ** 2):.2f} MiB"
elif num >= 1024.0:
return f"{num / 1024.0:.2f} KiB"
else:
return f"{int(num)} B"
def _get_pid_to_name_map(device):
if not device:
return {}
pid_map = {}
try:
for proc in device.ListProcesses():
pid_map[str(proc.pid)] = proc.name
except Exception as e:
logging.warning("Failed to get process list via Devil: %s", e)
return pid_map
def _build_sf_maps(sf_output):
"""Builds window-to-package and task-to-package mappings from SurfaceFlinger."""
window_to_package = {}
task_to_package = {}
pkg_act_pattern = re.compile(
r'(?:VRI-)?(?P<pkg>[a-zA-Z0-9_\.]+)/(?P<act>[^#\s]+)#\d+')
act_pattern = re.compile(
r'ActivityRecord\{[a-f0-9]+\s+u\d+\s+(?P<pkg_act>[^\s\}]+)\s+t(?P<task_id>\d+)\}'
)
for line in sf_output.splitlines():
pkg_act_match = pkg_act_pattern.search(line)
if pkg_act_match:
pkg = pkg_act_match.group('pkg')
act = pkg_act_match.group('act')
short_name = act.split('.')[-1]
window_to_package[short_name] = pkg
continue
act_match = act_pattern.search(line)
if act_match:
pkg_act = act_match.group('pkg_act')
task_id = act_match.group('task_id')
short_name = pkg_act.split('.')[-1]
if short_name in window_to_package:
task_to_package[task_id] = window_to_package[short_name]
return window_to_package, task_to_package
def _parse_dumpsys_gpu(content):
"""Parses 'dumpsys gpu' output."""
result = {
'global_total_bytes': None,
'global_raw_line': None,
'loaded_packages': [],
}
for line in content.splitlines():
if 'global' in line.lower():
result['global_raw_line'] = line.strip()
m = re.search(r'(\d+)', line)
if m:
result['global_total_bytes'] = int(m.group(1))
m_pkg = re.search(r'appPackageName\s*=\s*(.+)', line)
if m_pkg:
pkg = m_pkg.group(1).strip()
if pkg and pkg not in result['loaded_packages']:
result['loaded_packages'].append(pkg)
return result
def _parse_dmabuf_dump(content, pid_map=None):
"""Parses 'dmabuf_dump' output."""
if pid_map is None:
pid_map = {}
dmabuf_total_pattern = re.compile(
r'dmabuf total:\s*(\d+)\s*kB\s+kernel_rss:\s*(\d+)\s*kB\s+userspace_rss:\s*(\d+)\s*kB\s+userspace_pss:\s*(\d+)\s*kB'
)
process_header_pattern = re.compile(
r'^([a-zA-Z0-9_\.\-\/\[\]\(\)\ \:\#]+)\:(\d+)$')
process_total_pattern = re.compile(r'PROCESS TOTAL\s+(\d+)\s*kB\s+(\d+)\s*kB')
result = {
'total_kb': None,
'kernel_rss_kb': None,
'userspace_rss_kb': None,
'userspace_pss_kb': None,
'processes': [],
}
# Overall total line
tot_match = dmabuf_total_pattern.search(content)
if tot_match:
result['total_kb'] = int(tot_match.group(1))
result['kernel_rss_kb'] = int(tot_match.group(2))
result['userspace_rss_kb'] = int(tot_match.group(3))
result['userspace_pss_kb'] = int(tot_match.group(4))
# Per-process breakdown
process_blocks = re.split(r'-{5,}', content)
for block in process_blocks:
lines = [l.strip() for l in block.strip().splitlines() if l.strip()]
if not lines:
continue
header_m = process_header_pattern.search(lines[0])
proc_total_m = process_total_pattern.search(block)
if header_m and proc_total_m:
proc_name = header_m.group(1).strip()
pid_str = header_m.group(2)
rss_kb = int(proc_total_m.group(1))
pss_kb = int(proc_total_m.group(2))
resolved_name = pid_map.get(pid_str, proc_name)
result['processes'].append({
'pid': int(pid_str),
'name': proc_name,
'resolved_name': resolved_name,
'rss_bytes': rss_kb * 1024,
'pss_bytes': pss_kb * 1024,
})
result['processes'].sort(key=lambda x: x['pss_bytes'], reverse=True)
return result
def _parse_dumpsys_surfaceflinger(content, pid_map=None):
"""Parses 'dumpsys SurfaceFlinger' output."""
if pid_map is None:
pid_map = {}
window_to_package, task_to_package = _build_sf_maps(content)
gralloc_total_pattern = re.compile(
r'Total imported by gralloc:\s*([\d\.]+)\s*(\w+)')
sf_buffer_pattern = re.compile(
r'^\+\s*name:\s*(?P<name>[^,]+),\s*id:\s*(?P<id>\d+),\s*size:\s*(?P<size>[^,]+)'
r'(?:,\s*w/h:\s*(?P<wh>[^,]+))?')
size_unit_pattern = re.compile(r'^(?P<val>[\d\.]+)\s*(?P<unit>[a-zA-Z]*)$')
result = {
'gralloc_total_bytes': None,
'imported_buffers_by_name': {},
'allocator_buffers_by_requestor': {},
'itemized_buffers': [],
}
# Total imported by gralloc line
gralloc_m = gralloc_total_pattern.search(content)
if gralloc_m:
size_val = float(gralloc_m.group(1))
unit_str = gralloc_m.group(2)
result['gralloc_total_bytes'] = _parse_size_to_bytes(size_val, unit_str)
# Imported gralloc buffers section
in_imported = False
imported_agg = {}
for line in content.splitlines():
if 'Imported gralloc buffers:' in line:
in_imported = True
continue
if in_imported and ('Total imported by gralloc:' in line
or 'GraphicBufferAllocator buffers:' in line
or 'FlagManager' in line):
in_imported = False
continue
if in_imported:
m = sf_buffer_pattern.match(line.strip())
if m:
name = m.group('name').strip()
buf_id = m.group('id').strip()
size_str = m.group('size').strip()
wh_str = m.group('wh').strip() if m.group('wh') else "N/A"
sz_m = size_unit_pattern.match(size_str)
size_bytes = 0.0
if sz_m:
size_bytes = _parse_size_to_bytes(sz_m.group('val'),
sz_m.group('unit'))
# Attempt resolving owner name
resolved_owner = name
pid_m = re.search(r'pid\s*\[(\d+)\]', name)
if pid_m and pid_m.group(1) in pid_map:
resolved_owner = f"{pid_map[pid_m.group(1)]} ({name})"
elif "VRI[" in name:
vri_match = re.search(r'VRI\[([^\]]+)\]', name)
if vri_match:
vri_name = vri_match.group(1)
task_match = re.search(r'Task=(\d+)', vri_name)
if task_match and task_match.group(1) in task_to_package:
resolved_owner = (
f"{task_to_package[task_match.group(1)]} (Window:"
f" {vri_name})")
elif vri_name in window_to_package:
resolved_owner = (
f"{window_to_package[vri_name]} (Window: {vri_name})")
else:
resolved_owner = f"Window: {vri_name}"
else:
resolved_owner = name.split('#')[0]
if resolved_owner not in imported_agg:
imported_agg[resolved_owner] = {
'count': 0,
'total_bytes': 0.0,
'raw_name': name,
}
imported_agg[resolved_owner]['count'] += 1
imported_agg[resolved_owner]['total_bytes'] += size_bytes
result['itemized_buffers'].append({
'name': name,
'id': buf_id,
'size_bytes': size_bytes,
'wh': wh_str,
'resolved_owner': resolved_owner,
})
result['imported_buffers_by_name'] = imported_agg
# GraphicBufferAllocator buffers section
in_gba = False
gba_agg = {}
for line in content.splitlines():
if 'GraphicBufferAllocator buffers:' in line:
in_gba = True
continue
if in_gba:
if ('Total imported by gralloc:' in line or 'FlagManager' in line
or not line.strip()):
if 'Total imported by gralloc:' in line or 'FlagManager' in line:
in_gba = False
continue
parts = [p.strip() for p in line.split('|')]
if len(parts) >= 7 and not parts[0].startswith('Handle'):
size_str = parts[1]
requestor = parts[6]
sz_m = size_unit_pattern.match(size_str)
size_bytes = 0.0
if sz_m:
size_bytes = _parse_size_to_bytes(sz_m.group('val'),
sz_m.group('unit'))
if requestor not in gba_agg:
gba_agg[requestor] = {'count': 0, 'total_bytes': 0.0}
gba_agg[requestor]['count'] += 1
gba_agg[requestor]['total_bytes'] += size_bytes
result['allocator_buffers_by_requestor'] = gba_agg
return result
def _print_summary(gpu_data, dmabuf_data, sf_data, filter_str=None):
"""Prints formatted summary tables to stdout."""
print("\n" + "=" * 80)
print(" ANDROID GRAPHICS MEMORY USAGE SUMMARY")
print("=" * 80)
# Executive Totals Table
print("\n[ 1. OVERALL GRAPHICS MEMORY TOTALS ]")
print("-" * 80)
print(f"{'Source / Component':<35} | {'Metric / Memory':<40}")
print("-" * 80)
gpu_global = gpu_data.get('global_total_bytes')
gpu_str = _format_bytes(gpu_global)
print(f"{'dumpsys gpu (Global total)':<35} | {gpu_str:<40}")
dmabuf_pss = (dmabuf_data.get('userspace_pss_kb') * 1024
if dmabuf_data.get('userspace_pss_kb') is not None else None)
dmabuf_total = (dmabuf_data.get('total_kb') *
1024 if dmabuf_data.get('total_kb') is not None else None)
dmabuf_rss = (dmabuf_data.get('userspace_rss_kb') * 1024
if dmabuf_data.get('userspace_rss_kb') is not None else None)
print(f"{'dmabuf_dump (Userspace PSS)':<35} |"
f" {_format_bytes(dmabuf_pss):<40}")
print(f"{'dmabuf_dump (Userspace RSS)':<35} |"
f" {_format_bytes(dmabuf_rss):<40}")
print(f"{'dmabuf_dump (Kernel DMA-BUF Total)':<35} |"
f" {_format_bytes(dmabuf_total):<40}")
sf_gralloc = sf_data.get('gralloc_total_bytes')
print(f"{'dumpsys SurfaceFlinger (Gralloc)':<35} |"
f" {_format_bytes(sf_gralloc):<40}")
print("-" * 80)
# dmabuf_dump breakdown
print("\n[ 2. DMABUF_DUMP PER-PROCESS BREAKDOWN (by PSS) ]")
print("-" * 80)
print(f"{'PID':<7} | {'Process Name / Package':<38} | {'PSS':<14} |"
f" {'RSS':<14}")
print("-" * 80)
procs = dmabuf_data.get('processes', [])
if filter_str:
procs = [
p for p in procs if filter_str.lower() in p['resolved_name'].lower()
or filter_str.lower() in p['name'].lower()
]
for p in procs:
p_name = p['resolved_name']
if len(p_name) > 36:
p_name = p_name[:34] + ".."
print(
f"{p['pid']:<7} | {p_name:<38} | {_format_bytes(p['pss_bytes']):<14} |"
f" {_format_bytes(p['rss_bytes']):<14}")
print("-" * 80)
# SurfaceFlinger breakdown
print("\n[ 3. SURFACEFLINGER IMPORTED GRALLOC BUFFERS SUMMARY ]")
print("-" * 80)
print(f"{'Buffer Name / Owner':<45} | {'Count':<7} | {'Total Memory':<18}")
print("-" * 80)
imported = sf_data.get('imported_buffers_by_name', {})
sorted_imported = sorted(imported.items(),
key=lambda x: x[1]['total_bytes'],
reverse=True)
if filter_str:
sorted_imported = [
item for item in sorted_imported
if filter_str.lower() in item[0].lower()
]
for name, data in sorted_imported:
b_name = name
if len(b_name) > 43:
b_name = b_name[:41] + ".."
print(f"{b_name:<45} | {data['count']:<7} |"
f" {_format_bytes(data['total_bytes']):<18}")
print("-" * 80)
# Itemized SurfaceFlinger buffers table
itemized = sf_data.get('itemized_buffers', [])
if itemized:
print("\n[ 4. SURFACEFLINGER ITEMIZED BUFFERS LIST ]")
print("-" * 145)
print(f"{'Owner / Process / Window':<60} | "
f"{'Buffer Name':<45} | {'ID':<6} | {'Size':<12} | {'W x H':<12}")
print("-" * 145)
if filter_str:
itemized = [
b for b in itemized
if filter_str.lower() in b['resolved_owner'].lower()
or filter_str.lower() in b['name'].lower()
]
itemized.sort(key=lambda x: x['resolved_owner'])
for buf in itemized:
owner_str = buf['resolved_owner']
if len(owner_str) > 58:
owner_str = owner_str[:56] + ".."
display_name = buf['name']
if len(display_name) > 43:
display_name = display_name[:41] + ".."
print(f"{owner_str:<60} | {display_name:<45} | "
f"{buf['id']:<6} | {_format_bytes(buf['size_bytes']):<12} | "
f"{buf['wh']:<12}")
print("-" * 145)
# dumpsys gpu details
print("\n[ 5. DUMPSYS GPU DETAILS ]")
print("-" * 80)
if gpu_data.get('global_raw_line'):
print(f"Global line: {gpu_data['global_raw_line']}")
pkgs = gpu_data.get('loaded_packages', [])
if pkgs:
print(f"Active GPU App Packages ({len(pkgs)}): {', '.join(pkgs)}")
print("-" * 80 + "\n")
def main():
parser = argparse.ArgumentParser(description=(
"Parses dumpsys gpu, dmabuf_dump, and dumpsys SurfaceFlinger to"
" summarize graphics memory usage on an Android device."))
parser.add_argument(
"-s",
"--serial",
"-d",
"--device",
dest="device",
help="Target ADB device serial (for multiple connected devices)",
)
parser.add_argument(
"--filter",
help=(
"Filter results by process or buffer name substring (case-insensitive)"
),
)
args = parser.parse_args()
logging.basicConfig(
level=logging.INFO,
format="%(levelname)s: %(message)s",
stream=sys.stderr,
)
devil_chromium.Initialize()
devices = device_utils.DeviceUtils.HealthyDevices(device_arg=args.device)
if not devices:
logging.error("No healthy devices found")
return 1
if len(devices) > 1 and not args.device:
logging.warning("Multiple devices attached. Using %s.", str(devices[0]))
device = devices[0]
try:
device.EnableRoot()
except (device_errors.CommandFailedError,
device_errors.RootUserBuildError) as e:
logging.warning("Unable to enable root on device: %s", e)
logging.info("Querying device via 'dumpsys gpu'...")
gpu_content = _run_adb_shell_command(device, ['dumpsys', 'gpu'])
logging.info("Querying device via 'dmabuf_dump'...")
dmabuf_content = _run_adb_shell_command(device, ['dmabuf_dump'])
logging.info("Querying device via 'dumpsys SurfaceFlinger'...")
sf_content = _run_adb_shell_command(device, ['dumpsys', 'SurfaceFlinger'])
logging.info("Fetching process PID mapping from device...")
pid_map = _get_pid_to_name_map(device)
# Parse data
gpu_data = _parse_dumpsys_gpu(gpu_content)
dmabuf_data = _parse_dmabuf_dump(dmabuf_content, pid_map)
sf_data = _parse_dumpsys_surfaceflinger(sf_content, pid_map)
_print_summary(
gpu_data,
dmabuf_data,
sf_data,
filter_str=args.filter,
)
return 0
if __name__ == "__main__":
sys.exit(main())