| #!/usr/bin/env python3 |
| # |
| # Copyright 2025, The Android Open Source Project |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| """A script to generate a pprof report from Android OAT (oatdump) files.""" |
| |
| import argparse |
| import gzip |
| import os |
| import re |
| import subprocess |
| import sys |
| |
| # BEGIN profile_pb2 import |
| try: |
| import profile_pb2 |
| except ImportError: |
| # Allow running the script from a checkout, not as a python_binary_host |
| ANDROID_BUILD_TOP = os.environ.get('ANDROID_BUILD_TOP') |
| if ANDROID_BUILD_TOP: |
| import_path = ANDROID_BUILD_TOP + '/system/extras/simpleperf/scripts' |
| else: |
| import_path = os.getcwd() + '/system/extras/simpleperf/scripts' |
| sys.path.append(import_path) |
| import profile_pb2 |
| # END profile_pb2 import |
| |
| # Common file extensions for OAT/ODEX files. |
| OAT_FILE_EXTENSIONS = ( |
| '.odex', |
| '.oat', |
| '@classes.dex', |
| '@classes.odex', |
| ) |
| |
| |
| def run_command(command, decode=True): |
| """Executes a shell command and returns its output, handling errors.""" |
| try: |
| process = subprocess.run( |
| command, check=True, capture_output=True, shell=True |
| ) |
| if decode: |
| return process.stdout.decode('utf-8', errors='ignore').strip() |
| return process.stdout |
| except subprocess.CalledProcessError as e: |
| sys.stderr.write( |
| f"Error running command: '{command}'\nStderr:" |
| f" {e.stderr.decode('utf-8', errors='ignore')}\n" |
| ) |
| return None |
| |
| |
| def load_proguard_map(map_file_path): |
| """Parse a Proguard map file and return a deobfuscation mapping.""" |
| mapping = {'classes': {}, 'methods': {}} |
| current_class_map = None |
| class_regex = re.compile(r'^(.*) -> (.*):') |
| method_regex = re.compile(r'^\s+\S+\s+([^\s(]+)\(.*\)\s+->\s+(.*)') |
| |
| with open(map_file_path, 'r', encoding='utf-8', errors='ignore') as f: |
| for line in f: |
| class_match = class_regex.match(line) |
| if class_match: |
| original_name, obfuscated_name = class_match.groups() |
| mapping['classes'][obfuscated_name] = original_name |
| current_class_map = { |
| 'obfuscated': obfuscated_name, |
| 'original': original_name, |
| } |
| elif current_class_map: |
| method_match = method_regex.match(line) |
| if method_match: |
| original_method, obfuscated_method = method_match.groups() |
| full_obfuscated_key = ( |
| f"{current_class_map['obfuscated']}.{obfuscated_method}" |
| ) |
| mapping['methods'][full_obfuscated_key] = original_method |
| return mapping |
| |
| |
| def stream_and_parse_oatdump(paths, proguard_map=None, is_host_file=False): |
| """Yields (oat_file, dex_file, package, class, method, size) tuples from oatdump.""" |
| current_dex_file = None |
| current_obfuscated_class_name = None |
| current_method_name = None |
| |
| dex_file_regex = re.compile(r'^location:\s*(.*?)$') |
| class_regex = re.compile(r'^\s*[0-9a-fA-F]+:\s+L([^;]+);') |
| method_regex = re.compile( |
| r'\s(?:[^\s.]+\.)*([a-zA-Z0-9_$<>]+\(.*\))\s+\(dex_method_idx=' |
| ) |
| code_size_regex = re.compile(r'^\s+CODE:.*size=(\d+)') |
| |
| for path in paths: |
| print(f'Parsing {path}...') |
| current_oat_file = path |
| process = None |
| |
| if is_host_file: |
| try: |
| lines_iterator = open(path, 'r', encoding='utf-8', errors='ignore') |
| except FileNotFoundError: |
| sys.stderr.write(f'Error: Oatdump file not found at {path}.\n') |
| continue |
| else: |
| oatdump_command = ( |
| f'adb shell oatdump --oat-file={path} --no-disassemble --no-dump:vmap' |
| ) |
| process = subprocess.Popen( |
| oatdump_command, |
| shell=True, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.PIPE, |
| text=True, |
| errors='ignore', |
| ) |
| lines_iterator = process.stdout |
| |
| for line in lines_iterator: |
| oat_dex_file_match = dex_file_regex.search(line) |
| if oat_dex_file_match: |
| current_dex_file = oat_dex_file_match.group(1) |
| current_obfuscated_class_name = None |
| current_method_name = None |
| continue |
| |
| if current_dex_file: |
| class_match = class_regex.search(line) |
| if class_match: |
| current_obfuscated_class_name = class_match.group(1).replace('/', '.') |
| current_method_name = None |
| continue |
| |
| if current_obfuscated_class_name: |
| method_match = method_regex.search(line) |
| if method_match: |
| current_method_name = method_match.group(1) |
| continue |
| |
| if not current_method_name: |
| continue |
| |
| size_match = code_size_regex.search(line) |
| if not size_match: |
| continue |
| |
| code_size = int(size_match.group(1)) |
| if code_size > 0: |
| class_to_process = current_obfuscated_class_name |
| method_to_process = current_method_name |
| |
| if proguard_map: |
| class_to_process = proguard_map['classes'].get( |
| current_obfuscated_class_name, current_obfuscated_class_name |
| ) |
| |
| method_name_part = method_to_process.split('(')[0] |
| params = method_to_process[len(method_name_part) :] |
| full_obfuscated_key = ( |
| f'{current_obfuscated_class_name}.{method_name_part}' |
| ) |
| |
| deobfuscated_method_name = proguard_map['methods'].get( |
| full_obfuscated_key |
| ) |
| if deobfuscated_method_name: |
| method_to_process = deobfuscated_method_name + params |
| |
| package, class_name = ( |
| (class_to_process.rsplit('.', 1)) |
| if '.' in class_to_process |
| else ('default', class_to_process) |
| ) |
| if package and class_name and method_to_process: |
| yield ( |
| current_oat_file, |
| current_dex_file, |
| package, |
| class_name, |
| method_to_process, |
| code_size, |
| ) |
| |
| current_method_name = None |
| |
| if is_host_file and lines_iterator: |
| lines_iterator.close() |
| |
| if process: |
| stderr_output = process.stderr.read() |
| if process.wait() != 0: |
| sys.stderr.write( |
| f'Warning: Failed to get oatdump for {path}.\nStderr:' |
| f' {stderr_output}\n' |
| ) |
| |
| |
| def generate_pprof_report(data, output_path, dex_file_oriented, argv): |
| """Generates a pprof file from the parsed oatdump data.""" |
| profile = profile_pb2.Profile() |
| # Use a dictionary for efficient string-to-ID mapping and a list to maintain |
| # the order for the protobuf string table. |
| string_to_id = {'': 0} |
| id_to_string = [''] |
| functions = {} |
| locations = {} |
| mappings = {} |
| |
| def get_string_id(s): |
| """Adds a string to the string table and returns its ID.""" |
| string_id = string_to_id.get(s) |
| if string_id is None: |
| new_id = len(id_to_string) |
| string_to_id[s] = new_id |
| id_to_string.append(s) |
| string_id = new_id |
| return string_id |
| |
| st_size = profile.sample_type.add() |
| st_size.type = get_string_id('space') |
| st_size.unit = get_string_id('bytes') |
| |
| for oat_file, dex_file, package_name, class_name, method_name, size in data: |
| if size == 0: |
| continue |
| |
| sample = profile.sample.add() |
| sample.value.append(size) |
| |
| # Generate a stack trace for the sample with the following hierarchy: |
| # - Oat file path (to identify where the code comes from) |
| # - [Optional] Dex file path (to identify where the dex code comes from) |
| # - Package name parts, e.g. 'android', 'app' |
| # - Class name, e.g. 'Activity' |
| # - Fully qualified method name, e.g. 'android.app.Activity.onCreate(android.os.Bundle)' |
| stack_frames = [] |
| stack_frames.append((oat_file, os.path.basename(oat_file))) |
| if dex_file_oriented: |
| stack_frames.append((dex_file, dex_file)) |
| package_parts = package_name.split('.') |
| current_package = '' |
| for part in package_parts: |
| current_package = f'{current_package}.{part}' if current_package else part |
| stack_frames.append((current_package, current_package)) |
| full_class_name = f'{current_package}.{class_name}' |
| stack_frames.append((full_class_name, full_class_name)) |
| full_method_name = f'{package_name}.{class_name}.{method_name}' |
| stack_frames.append((full_method_name, full_method_name)) |
| |
| location_ids = [] |
| |
| if oat_file not in mappings: |
| mapping = profile.mapping.add() |
| mapping.id = len(profile.mapping) |
| mapping.filename = get_string_id(oat_file) |
| mappings[oat_file] = mapping.id |
| mapping_id = mappings[oat_file] |
| |
| for i, (unique_id, label) in enumerate(stack_frames): |
| if unique_id not in functions: |
| func = profile.function.add() |
| func.id = len(profile.function) |
| func.name = get_string_id(label) |
| func.filename = get_string_id(oat_file) |
| functions[unique_id] = func.id |
| |
| func_id = functions[unique_id] |
| |
| loc_key = (func_id, mapping_id) |
| if loc_key not in locations: |
| loc = profile.location.add() |
| loc.id = len(profile.location) |
| loc.mapping_id = mapping_id |
| line = loc.line.add() |
| line.function_id = func_id |
| locations[loc_key] = loc.id |
| |
| location_ids.append(locations[loc_key]) |
| |
| sample.location_id.extend(reversed(location_ids)) |
| |
| comment = f"{os.path.basename(argv[0])} {' '.join(argv[1:])}" |
| profile.comment.append(get_string_id(comment)) |
| profile.string_table.extend(id_to_string) |
| |
| with gzip.open(output_path, 'wb') as f: |
| f.write(profile.SerializeToString()) |
| |
| |
| def get_oat_files_for_package(package_name): |
| """Finds all OAT/ODEX files for a given package under the primary ABI.""" |
| command_output = run_command(f'adb shell pm art dump {package_name}') |
| if not command_output: |
| sys.stderr.write(f'Error: Could not find oat file for {package_name}.\n') |
| return [] |
| |
| paths = set() |
| expect_location_for_primary_abi = False |
| location_regex = re.compile(r'^\s*\[location is (.*?)\]$') |
| |
| for line in command_output.splitlines(): |
| if expect_location_for_primary_abi: |
| location_match = location_regex.search(line) |
| if location_match: |
| location = location_match.group(1) |
| if location.endswith(OAT_FILE_EXTENSIONS): |
| paths.add(location) |
| expect_location_for_primary_abi = False |
| elif '[primary-abi]' in line: |
| expect_location_for_primary_abi = True |
| continue |
| |
| if not paths: |
| sys.stderr.write(f'Error: Failed to parse art dump for {package_name}:\n') |
| sys.stderr.write(command_output) |
| sys.stderr.write('\n') |
| return None |
| return list(paths) |
| |
| |
| def get_oat_files_for_process(process_identifier): |
| """Finds all .oat and .odex files for a given process name or PID.""" |
| pid = run_command(f'adb shell pidof -s {process_identifier}') |
| if not pid: |
| sys.stderr.write(f'Error: Could not find PID for "{process_identifier}".\n') |
| return None |
| |
| maps_content = run_command(f'adb shell cat /proc/{pid}/maps') |
| if not maps_content: |
| sys.stderr.write(f'Error: Could not read maps for PID {pid}.\n') |
| return None |
| |
| oat_files = set() |
| for line in maps_content.splitlines(): |
| path = line.split()[-1] |
| if path.endswith(OAT_FILE_EXTENSIONS): |
| oat_files.add(path) |
| return list(oat_files) |
| |
| |
| def ensure_adb_root(): |
| """Ensures adb connection has root access. |
| |
| Raises: |
| subprocess.CalledProcessError: If an adb command fails. |
| FileNotFoundError: If 'adb' command is not found. |
| SystemExit: If root access cannot be obtained. |
| """ |
| try: |
| # Attempt to restart adbd as root. This command will block until adbd restarts. |
| run_command('adb root') |
| # Wait for the device to reconnect after adb root. |
| run_command('adb wait-for-device') |
| # Verify root access by checking the user ID. |
| result_id = run_command('adb shell id -u') |
| if result_id != '0': |
| sys.stderr.write( |
| 'Error: Failed to get root access via adb. Please ensure your device' |
| ' is rooted or adbd can be restarted as root.\n' |
| ) |
| sys.exit(1) |
| except subprocess.CalledProcessError as e: |
| command = e.cmd if isinstance(e.cmd, str) else ' '.join(e.cmd) |
| sys.stderr.write( |
| f'Error: adb command failed during root check. Is adb installed and' |
| f' device connected?\n' |
| ) |
| sys.stderr.write(f'Command: {command}\n') |
| sys.stderr.write(f'Stderr: {e.stderr}\n') |
| sys.exit(1) |
| except FileNotFoundError: |
| sys.stderr.write( |
| "Error: 'adb' command not found. Please ensure adb is in your PATH.\n" |
| ) |
| sys.exit(1) |
| |
| |
| def main(argv): |
| parser = argparse.ArgumentParser( |
| description='Generate a pprof report from Android OAT files.' |
| ) |
| group = parser.add_mutually_exclusive_group(required=True) |
| group.add_argument( |
| '--package', help='Package name of the app on the connected device.' |
| ) |
| group.add_argument( |
| '--process', help='Process name or PID on the connected device.' |
| ) |
| group.add_argument( |
| '--oatdump-file', help='Path to a single oatdump output file on the host.' |
| ) |
| |
| parser.add_argument( |
| '--output', |
| default='oatdump_report.pprof', |
| help='Path to save the generated pprof file.', |
| ) |
| parser.add_argument( |
| '--map', help='Optional path to a Proguard deobfuscation map file.' |
| ) |
| parser.add_argument( |
| '--dex-file-oriented', |
| action='store_true', |
| help='If set, groups the flame graph by dex files.', |
| ) |
| args = parser.parse_args(argv) |
| |
| oat_files = [] |
| is_host = False |
| if args.oatdump_file: |
| oat_files = [args.oatdump_file] |
| is_host = True |
| else: |
| ensure_adb_root() |
| if args.package: |
| oat_files = get_oat_files_for_package(args.package) |
| elif args.process: |
| oat_files = get_oat_files_for_process(args.process) |
| |
| if not oat_files: |
| sys.stderr.write('Error: Could not find any OAT/ODEX files to process.\n') |
| sys.exit(1) |
| |
| proguard_map = load_proguard_map(args.map) if args.map else None |
| |
| parsed_data = list( |
| stream_and_parse_oatdump(oat_files, proguard_map, is_host_file=is_host) |
| ) |
| |
| if not parsed_data: |
| print('No methods found in oatdump. The report will be empty.') |
| else: |
| print(f'Generating pprof report to {args.output}...') |
| generate_pprof_report(parsed_data, args.output, args.dex_file_oriented, sys.argv) |
| print('Done.') |
| print(f'Report saved to: {os.path.abspath(args.output)}') |
| print(f'Tip: To upload to pprof web UI, run:') |
| print(f'pprof -flame {os.path.abspath(args.output)}') |
| |
| |
| if __name__ == '__main__': |
| main(sys.argv[1:]) |