#!/usr/bin/env python import curses import operator import optparse import os import re import subprocess import sys import threading import Queue STATS_UPDATE_INTERVAL = 0.2 PAGE_SIZE = 4096 class PagecacheStats(): """Holds pagecache stats by accounting for pages added and removed. """ def __init__(self, inode_to_filename): self._inode_to_filename = inode_to_filename self._file_size = {} self._file_pages = {} self._total_pages_added = 0 self._total_pages_removed = 0 def add_page(self, device_number, inode, offset): # See if we can find the page in our lookup table if (device_number, inode) in self._inode_to_filename: filename, filesize = self._inode_to_filename[(device_number, inode)] if filename not in self._file_pages: self._file_pages[filename] = [1, 0] else: self._file_pages[filename][0] += 1 self._total_pages_added += 1 if filename not in self._file_size: self._file_size[filename] = filesize def remove_page(self, device_number, inode, offset): if (device_number, inode) in self._inode_to_filename: filename, filesize = self._inode_to_filename[(device_number, inode)] if filename not in self._file_pages: self._file_pages[filename] = [0, 1] else: self._file_pages[filename][1] += 1 self._total_pages_removed += 1 if filename not in self._file_size: self._file_size[filename] = filesize def pages_to_mb(self, num_pages): return "%.2f" % round(num_pages * PAGE_SIZE / 1024.0 / 1024.0, 2) def bytes_to_mb(self, num_bytes): return "%.2f" % round(int(num_bytes) / 1024.0 / 1024.0, 2) def print_pages_and_mb(self, num_pages): pages_string = str(num_pages) + ' (' + str(self.pages_to_mb(num_pages)) + ' MB)' return pages_string def reset_stats(self): self._file_pages.clear() self._total_pages_added = 0; self._total_pages_removed = 0; def print_stats(self): # Create new merged dict sorted_added = sorted(self._file_pages.items(), key=operator.itemgetter(1), reverse=True) row_format = "{:<70}{:<12}{:<14}{:<9}" print row_format.format('NAME', 'ADDED (MB)', 'REMOVED (MB)', 'SIZE (MB)') for filename, added in sorted_added: filesize = self._file_size[filename] added = self._file_pages[filename][0] removed = self._file_pages[filename][1] if (filename > 64): filename = filename[-64:] print row_format.format(filename, self.pages_to_mb(added), self.pages_to_mb(removed), self.bytes_to_mb(filesize)) print row_format.format('TOTAL', self.pages_to_mb(self._total_pages_added), self.pages_to_mb(self._total_pages_removed), '') def print_stats_curses(self, pad): sorted_added = sorted(self._file_pages.items(), key=operator.itemgetter(1), reverse=True) height, width = pad.getmaxyx() pad.clear() pad.addstr(0, 2, 'NAME'.ljust(68), curses.A_REVERSE) pad.addstr(0, 70, 'ADDED (MB)'.ljust(12), curses.A_REVERSE) pad.addstr(0, 82, 'REMOVED (MB)'.ljust(14), curses.A_REVERSE) pad.addstr(0, 96, 'SIZE (MB)'.ljust(9), curses.A_REVERSE) y = 1 for filename, added_removed in sorted_added: filesize = self._file_size[filename] added = self._file_pages[filename][0] removed = self._file_pages[filename][1] if (filename > 64): filename = filename[-64:] pad.addstr(y, 2, filename) pad.addstr(y, 70, self.pages_to_mb(added).rjust(10)) pad.addstr(y, 80, self.pages_to_mb(removed).rjust(14)) pad.addstr(y, 96, self.bytes_to_mb(filesize).rjust(9)) y += 1 if y == height - 2: pad.addstr(y, 4, "") break y += 1 pad.addstr(y, 2, 'TOTAL'.ljust(74), curses.A_REVERSE) pad.addstr(y, 70, str(self.pages_to_mb(self._total_pages_added)).rjust(10), curses.A_REVERSE) pad.addstr(y, 80, str(self.pages_to_mb(self._total_pages_removed)).rjust(14), curses.A_REVERSE) pad.refresh(0,0, 0,0, height,width) class FileReaderThread(threading.Thread): """Reads data from a file/pipe on a worker thread. Use the standard threading. Thread object API to start and interact with the thread (start(), join(), etc.). """ def __init__(self, file_object, output_queue, text_file, chunk_size=-1): """Initializes a FileReaderThread. Args: file_object: The file or pipe to read from. output_queue: A Queue.Queue object that will receive the data text_file: If True, the file will be read one line at a time, and chunk_size will be ignored. If False, line breaks are ignored and chunk_size must be set to a positive integer. chunk_size: When processing a non-text file (text_file = False), chunk_size is the amount of data to copy into the queue with each read operation. For text files, this parameter is ignored. """ threading.Thread.__init__(self) self._file_object = file_object self._output_queue = output_queue self._text_file = text_file self._chunk_size = chunk_size assert text_file or chunk_size > 0 def run(self): """Overrides Thread's run() function. Returns when an EOF is encountered. """ if self._text_file: # Read a text file one line at a time. for line in self._file_object: self._output_queue.put(line) else: # Read binary or text data until we get to EOF. while True: chunk = self._file_object.read(self._chunk_size) if not chunk: break self._output_queue.put(chunk) def set_chunk_size(self, chunk_size): """Change the read chunk size. This function can only be called if the FileReaderThread object was created with an initial chunk_size > 0. Args: chunk_size: the new chunk size for this file. Must be > 0. """ # The chunk size can be changed asynchronously while a file is being read # in a worker thread. However, type of file can not be changed after the # the FileReaderThread has been created. These asserts verify that we are # only changing the chunk size, and not the type of file. assert not self._text_file assert chunk_size > 0 self._chunk_size = chunk_size class AdbUtils(): @staticmethod def add_adb_serial(adb_command, device_serial): if device_serial is not None: adb_command.insert(1, device_serial) adb_command.insert(1, '-s') @staticmethod def construct_adb_shell_command(shell_args, device_serial): adb_command = ['adb', 'shell', ' '.join(shell_args)] AdbUtils.add_adb_serial(adb_command, device_serial) return adb_command @staticmethod def run_adb_shell(shell_args, device_serial): """Runs "adb shell" with the given arguments. Args: shell_args: array of arguments to pass to adb shell. device_serial: if not empty, will add the appropriate command-line parameters so that adb targets the given device. Returns: A tuple containing the adb output (stdout & stderr) and the return code from adb. Will exit if adb fails to start. """ adb_command = AdbUtils.construct_adb_shell_command(shell_args, device_serial) adb_output = [] adb_return_code = 0 try: adb_output = subprocess.check_output(adb_command, stderr=subprocess.STDOUT, shell=False, universal_newlines=True) except OSError as error: # This usually means that the adb executable was not found in the path. print >> sys.stderr, ('\nThe command "%s" failed with the following error:' % ' '.join(adb_command)) print >> sys.stderr, ' %s' % str(error) print >> sys.stderr, 'Is adb in your path?' adb_return_code = error.errno adb_output = error except subprocess.CalledProcessError as error: # The process exited with an error. adb_return_code = error.returncode adb_output = error.output return (adb_output, adb_return_code) @staticmethod def do_preprocess_adb_cmd(command, serial): args = [command] dump, ret_code = AdbUtils.run_adb_shell(args, serial) if ret_code != 0: return None dump = ''.join(dump) return dump def parse_atrace_line(line, pagecache_stats, app_name): # Find a mm_filemap_add_to_page_cache entry m = re.match('.* (mm_filemap_add_to_page_cache|mm_filemap_delete_from_page_cache): dev (\d+):(\d+) ino ([0-9a-z]+) page=([0-9a-z]+) pfn=([0-9a-z]+) ofs=(\d+).*', line) if m != None: # Get filename device_number = int(m.group(2)) << 8 | int(m.group(3)) if device_number == 0: return inode = int(m.group(4), 16) if app_name != None and not (app_name in m.group(0)): return if m.group(1) == 'mm_filemap_add_to_page_cache': pagecache_stats.add_page(device_number, inode, m.group(4)) elif m.group(1) == 'mm_filemap_delete_from_page_cache': pagecache_stats.remove_page(device_number, inode, m.group(4)) def build_inode_lookup_table(inode_dump): inode2filename = {} text = inode_dump.splitlines() for line in text: result = re.match('([0-9]+)d? ([0-9]+) ([0-9]+) (.*)', line) if result: inode2filename[(int(result.group(1)), int(result.group(2)))] = (result.group(4), result.group(3)) return inode2filename; def get_inode_data(datafile, dumpfile, adb_serial): if datafile is not None and os.path.isfile(datafile): print('Using cached inode data from ' + datafile) f = open(datafile, 'r') stat_dump = f.read(); else: # Build inode maps if we were tracing page cache print('Downloading inode data from device') stat_dump = AdbUtils.do_preprocess_adb_cmd( 'find /apex /system /system_ext /product /data /vendor ' + '-exec stat -c "%d %i %s %n" {} \;', adb_serial) if stat_dump is None: print 'Could not retrieve inode data from device.' sys.exit(1) if dumpfile is not None: print 'Storing inode data in ' + dumpfile f = open(dumpfile, 'w') f.write(stat_dump) f.close() sys.stdout.write('Done.\n') return stat_dump def read_and_parse_trace_file(trace_file, pagecache_stats, app_name): for line in trace_file: parse_atrace_line(line, pagecache_stats, app_name) pagecache_stats.print_stats(); def read_and_parse_trace_data_live(stdout, stderr, pagecache_stats, app_name): # Start reading trace data stdout_queue = Queue.Queue(maxsize=128) stderr_queue = Queue.Queue() stdout_thread = FileReaderThread(stdout, stdout_queue, text_file=True, chunk_size=64) stderr_thread = FileReaderThread(stderr, stderr_queue, text_file=True) stdout_thread.start() stderr_thread.start() stdscr = curses.initscr() try: height, width = stdscr.getmaxyx() curses.noecho() curses.cbreak() stdscr.keypad(True) stdscr.nodelay(True) stdscr.refresh() # We need at least a 30x100 window used_width = max(width, 100) used_height = max(height, 30) # Create a pad for pagecache stats pagecache_pad = curses.newpad(used_height - 2, used_width) stdscr.addstr(used_height - 1, 0, 'KEY SHORTCUTS: (r)eset stats, CTRL-c to quit') while (stdout_thread.isAlive() or stderr_thread.isAlive() or not stdout_queue.empty() or not stderr_queue.empty()): while not stderr_queue.empty(): # Pass along errors from adb. line = stderr_queue.get() sys.stderr.write(line) while True: try: line = stdout_queue.get(True, STATS_UPDATE_INTERVAL) parse_atrace_line(line, pagecache_stats, app_name) except Queue.Empty: break key = '' try: key = stdscr.getkey() except: pass if key == 'r': pagecache_stats.reset_stats() pagecache_stats.print_stats_curses(pagecache_pad) except Exception, e: curses.endwin() print e finally: curses.endwin() # The threads should already have stopped, so this is just for cleanup. stdout_thread.join() stderr_thread.join() stdout.close() stderr.close() def parse_options(argv): usage = 'Usage: %prog [options]' desc = 'Example: %prog' parser = optparse.OptionParser(usage=usage, description=desc) parser.add_option('-d', dest='inode_dump_file', metavar='FILE', help='Dump the inode data read from a device to a file.' ' This file can then be reused with the -i option to speed' ' up future invocations of this script.') parser.add_option('-i', dest='inode_data_file', metavar='FILE', help='Read cached inode data from a file saved arlier with the' ' -d option.') parser.add_option('-s', '--serial', dest='device_serial', type='string', help='adb device serial number') parser.add_option('-f', dest='trace_file', metavar='FILE', help='Show stats from a trace file, instead of running live.') parser.add_option('-a', dest='app_name', type='string', help='filter a particular app') options, categories = parser.parse_args(argv[1:]) if options.inode_dump_file and options.inode_data_file: parser.error('options -d and -i can\'t be used at the same time') return (options, categories) def main(): options, categories = parse_options(sys.argv) # Load inode data for this device inode_data = get_inode_data(options.inode_data_file, options.inode_dump_file, options.device_serial) # Build (dev, inode) -> filename hash inode_lookup_table = build_inode_lookup_table(inode_data) # Init pagecache stats pagecache_stats = PagecacheStats(inode_lookup_table) if options.trace_file is not None: if not os.path.isfile(options.trace_file): print >> sys.stderr, ('Couldn\'t load trace file.') sys.exit(1) trace_file = open(options.trace_file, 'r') read_and_parse_trace_file(trace_file, pagecache_stats, options.app_name) else: # Construct and execute trace command trace_cmd = AdbUtils.construct_adb_shell_command(['atrace', '--stream', 'pagecache'], options.device_serial) try: atrace = subprocess.Popen(trace_cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) except OSError as error: print >> sys.stderr, ('The command failed') sys.exit(1) read_and_parse_trace_data_live(atrace.stdout, atrace.stderr, pagecache_stats, options.app_name) if __name__ == "__main__": main()