blob: 64e7124af7834f4a329670bda978e6008f1e5763 [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.
"""Parses Mac vmmap output to group IOAccelerator / IOSurface allocations.
This tool helps profile GPU memory allocations (including purgeable/volatile
state) by reading vmmap output directly from a running process (via PID) or
from a saved text file.
"""
import argparse
from collections import Counter, defaultdict
import dataclasses
import logging
import re
import subprocess
import sys
@dataclasses.dataclass
class RegionEntry:
region_type: str
vsize: int
rsdnt: int
dirty: int
swap: int
purge: str # 'N' (non-volatile), 'V' (volatile), 'E' (empty), or '-'
@dataclasses.dataclass
class SummaryEntry:
region_type: str
virtual: int
resident: int
dirty: int
swapped: int
volatile: int
nonvol: int
empty: int
count: int
def parse_size(size_str):
"""Parses a size string with units (B, K, M, G) into bytes."""
match = re.match(r'^([0-9.]+)([BKMG]?)$', size_str.strip())
if not match:
return 0
val, unit = match.groups()
val = float(val)
if unit == 'K':
return int(val * 1024)
elif unit == 'M':
return int(val * 1024 * 1024)
elif unit == 'G':
return int(val * 1024 * 1024 * 1024)
return int(val)
def format_mb(num_bytes):
return f'{num_bytes / (1024 * 1024):7.2f} MB'
def parse_vmmap_lines(lines, region_types=('IOAccelerator', 'IOSurface')):
"""Parses lines of vmmap output for matching region types and summary rows.
Excludes unmarked (PURGE=-) regions that have zero resident, dirty, and
swapped memory.
"""
entries = []
summaries = []
region_re = re.compile(
r'^(?P<type>[A-Za-z0-9_ ()]+?)\s+'
r'[0-9a-fA-F]+-[0-9a-fA-F]+\s+'
r'\[\s*(?P<vsize>[0-9.BKMG]+)\s+'
r'(?P<rsdnt>[0-9.BKMG]+)\s+'
r'(?P<dirty>[0-9.BKMG]+)\s+'
r'(?P<swap>[0-9.BKMG]+)\]\s+'
r'\S+\s+\S+\s*'
r'(?:PURGE=(?P<purge>[NVE]))?\s*'
r'.*$'
)
summary_re = re.compile(
r'^(?P<type>[A-Za-z0-9_ ()]+?)\s+'
r'(?P<virtual>[0-9.BKMG]+)\s+'
r'(?P<resident>[0-9.BKMG]+)\s+'
r'(?P<dirty>[0-9.BKMG]+)\s+'
r'(?P<swapped>[0-9.BKMG]+)\s+'
r'(?P<volatile>[0-9.BKMG]+)\s+'
r'(?P<nonvol>[0-9.BKMG]+)\s+'
r'(?P<empty>[0-9.BKMG]+)\s+'
r'(?P<count>[0-9]+)(?:\s+.*)?$'
)
in_summary = False
for raw_line in lines:
line = raw_line.rstrip('\n')
if 'VIRTUAL' in line and 'VOLATILE' in line and 'NONVOL' in line:
in_summary = True
continue
if in_summary:
if (
line.startswith('REGION TYPE')
or line.startswith('===========')
or not line.strip()
):
continue
if line.startswith('TOTAL'):
in_summary = False
continue
sm = summary_re.match(line)
if sm and any(t in sm.group('type') for t in region_types):
summaries.append(
SummaryEntry(
region_type=sm.group('type').strip(),
virtual=parse_size(sm.group('virtual')),
resident=parse_size(sm.group('resident')),
dirty=parse_size(sm.group('dirty')),
swapped=parse_size(sm.group('swapped')),
volatile=parse_size(sm.group('volatile')),
nonvol=parse_size(sm.group('nonvol')),
empty=parse_size(sm.group('empty')),
count=int(sm.group('count')),
)
)
continue
if not any(t in line for t in region_types):
continue
m = region_re.match(line)
if not m:
continue
rtype = m.group('type').strip()
if not any(t in rtype for t in region_types):
continue
vsize = parse_size(m.group('vsize'))
rsdnt = parse_size(m.group('rsdnt'))
dirty = parse_size(m.group('dirty'))
swap = parse_size(m.group('swap'))
purge = m.group('purge') or '-'
if rsdnt == 0 and dirty == 0 and swap == 0 and purge == '-':
continue
entries.append(
RegionEntry(
region_type=rtype,
vsize=vsize,
rsdnt=rsdnt,
dirty=dirty,
swap=swap,
purge=purge,
)
)
return entries, summaries
def main():
logging.basicConfig(
level=logging.INFO, stream=sys.stderr, format='%(message)s'
)
parser = argparse.ArgumentParser(
description=(
'Parse Mac vmmap output to group and count IOAccelerator and '
'IOSurface allocations (including purgeable/volatile state).'
)
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument(
'--pid', type=int, help='PID of the process to call vmmap on.'
)
group.add_argument(
'--file',
type=str,
help=(
'Path to a cached vmmap output file '
'(preferably from "vmmap -noCoalesce").'
),
)
parser.add_argument(
'--types',
nargs='+',
default=['IOAccelerator', 'IOSurface'],
help='Region types to include (default: IOAccelerator IOSurface).',
)
args = parser.parse_args()
if args.file:
try:
with open(args.file, 'r', encoding='utf-8') as f:
lines = f.readlines()
except FileNotFoundError:
logging.error("Error: File '%s' not found.", args.file)
sys.exit(1)
elif args.pid:
try:
logging.info('Running vmmap -noCoalesce on PID %d...', args.pid)
output = subprocess.check_output(
['vmmap', '-noCoalesce', str(args.pid)], stderr=subprocess.STDOUT
)
lines = output.decode('utf-8', errors='replace').splitlines()
except subprocess.CalledProcessError as e:
logging.error(
'Error running vmmap: %s', e.output.decode('utf-8', errors='replace')
)
sys.exit(1)
entries, summaries = parse_vmmap_lines(lines, tuple(args.types))
if summaries:
print('=== vmmap Summary Table ===')
print(
f'{"REGION TYPE":<26} | {"COUNT":>5} | {"VIRTUAL":>10} | '
f'{"RESIDENT":>10} | {"DIRTY":>10} | {"SWAPPED":>10} | '
f'{"VOLATILE":>10} | {"NONVOL":>10} | {"EMPTY":>10}'
)
print('-' * 123)
for s in summaries:
print(
f'{s.region_type:<26} | {s.count:5d} | {format_mb(s.virtual)} | '
f'{format_mb(s.resident)} | {format_mb(s.dirty)} | '
f'{format_mb(s.swapped)} | {format_mb(s.volatile)} | '
f'{format_mb(s.nonvol)} | {format_mb(s.empty)}'
)
print()
if not entries:
logging.error(
'No active regions found for types: %s', ', '.join(args.types)
)
return
print('=== Per-Region Purgeability Breakdown ===')
# Each bucket stores: [count, vsize, rsdnt+swap, dirty].
by_type_purge = defaultdict(lambda: [0, 0, 0, 0])
for e in entries:
bucket = by_type_purge[(e.region_type, e.purge)]
bucket[0] += 1
bucket[1] += e.vsize
bucket[2] += e.rsdnt + e.swap
bucket[3] += e.dirty
purge_labels = {
'N': 'N (Non-Volatile)',
'V': 'V (Volatile/Purgeable)',
'E': 'E (Empty/Purged)',
'-': '- (Unmarked)',
}
print(
f'{"REGION TYPE":<26} | {"PURGE STATE":<22} | {"COUNT":>5} | '
f'{"VSIZE":>10} | {"RSDNT+SWAP":>10} | {"DIRTY":>10}'
)
print('-' * 98)
for (rtype, purge), stats in sorted(by_type_purge.items()):
count, vsize, rsdnt_swap, dirty = stats
print(
f'{rtype:<26} | {purge_labels.get(purge, purge):<22} | {count:5d} | '
f'{format_mb(vsize)} | {format_mb(rsdnt_swap)} | {format_mb(dirty)}'
)
total_rsdnt_swap = sum(e.rsdnt + e.swap for e in entries)
total_dirty = sum(e.dirty for e in entries)
volatile_vsize = sum(e.vsize for e in entries if e.purge == 'V')
empty_vsize = sum(e.vsize for e in entries if e.purge == 'E')
nonvol_vsize = sum(e.vsize for e in entries if e.purge == 'N')
print('-' * 98)
print(
f'Total entries: {len(entries)} | '
f'Resident+Swap: {format_mb(total_rsdnt_swap).strip()} | '
f'Dirty: {format_mb(total_dirty).strip()} | '
f'Volatile VSIZE (V): {format_mb(volatile_vsize).strip()} | '
f'Empty/Purged VSIZE (E): {format_mb(empty_vsize).strip()} | '
f'Non-Volatile VSIZE (N): {format_mb(nonvol_vsize).strip()}'
)
entries_by_type = defaultdict(list)
for e in entries:
entries_by_type[e.region_type].append(e)
for rtype, type_entries in sorted(entries_by_type.items()):
print(f'\n=== Histogram of {rtype} Regions by Virtual Size Bucket ===')
bucket_counts = Counter(e.vsize for e in type_entries)
bucket_purge = defaultdict(Counter)
for e in type_entries:
bucket_purge[e.vsize][e.purge] += 1
max_bucket_mem = max(size * count for size, count in bucket_counts.items())
for size in sorted(bucket_counts.keys()):
count = bucket_counts[size]
bucket_mem = size * count
bucket_mem_mb = bucket_mem / (1024 * 1024)
size_kb = size / 1024
p = bucket_purge[size]
purge_str = f'N:{p["N"]:4d} V:{p["V"]:4d} E:{p["E"]:4d} -:{p["-"]:4d}'
bar_len = (
int(40 * bucket_mem / max_bucket_mem) if max_bucket_mem > 0 else 0
)
bar = '*' * bar_len
print(
f'{size_kb:8.1f} KB | {count:4d} entries ({purge_str}) | '
f'{bucket_mem_mb:7.2f} MB | {bar}'
)
if __name__ == '__main__':
main()