Home | History | Annotate | Download | only in pagecache
      1 #!/usr/bin/env python
      2 
      3 import curses
      4 import operator
      5 import optparse
      6 import os
      7 import re
      8 import subprocess
      9 import sys
     10 import threading
     11 import Queue
     12 
     13 STATS_UPDATE_INTERVAL = 0.2
     14 PAGE_SIZE = 4096
     15 
     16 class PagecacheStats():
     17   """Holds pagecache stats by accounting for pages added and removed.
     18 
     19   """
     20   def __init__(self, inode_to_filename):
     21     self._inode_to_filename = inode_to_filename
     22     self._file_size = {}
     23     self._file_pages = {}
     24     self._total_pages_added = 0
     25     self._total_pages_removed = 0
     26 
     27   def add_page(self, device_number, inode, offset):
     28     # See if we can find the page in our lookup table
     29     if (device_number, inode) in self._inode_to_filename:
     30       filename, filesize = self._inode_to_filename[(device_number, inode)]
     31       if filename not in self._file_pages:
     32         self._file_pages[filename] = [1, 0]
     33       else:
     34         self._file_pages[filename][0] += 1
     35 
     36       self._total_pages_added += 1
     37 
     38       if filename not in self._file_size:
     39         self._file_size[filename] = filesize
     40 
     41   def remove_page(self, device_number, inode, offset):
     42     if (device_number, inode) in self._inode_to_filename:
     43       filename, filesize = self._inode_to_filename[(device_number, inode)]
     44       if filename not in self._file_pages:
     45         self._file_pages[filename] = [0, 1]
     46       else:
     47         self._file_pages[filename][1] += 1
     48 
     49       self._total_pages_removed += 1
     50 
     51       if filename not in self._file_size:
     52         self._file_size[filename] = filesize
     53 
     54   def pages_to_mb(self, num_pages):
     55     return "%.2f" % round(num_pages * PAGE_SIZE / 1024.0 / 1024.0, 2)
     56 
     57   def bytes_to_mb(self, num_bytes):
     58     return "%.2f" % round(int(num_bytes) / 1024.0 / 1024.0, 2)
     59 
     60   def print_pages_and_mb(self, num_pages):
     61     pages_string = str(num_pages) + ' (' + str(self.pages_to_mb(num_pages)) + ' MB)'
     62     return pages_string
     63 
     64   def reset_stats(self):
     65     self._file_pages.clear()
     66     self._total_pages_added = 0;
     67     self._total_pages_removed = 0;
     68 
     69   def print_stats(self):
     70     # Create new merged dict
     71     sorted_added = sorted(self._file_pages.items(), key=operator.itemgetter(1), reverse=True)
     72     row_format = "{:<70}{:<12}{:<14}{:<9}"
     73     print row_format.format('NAME', 'ADDED (MB)', 'REMOVED (MB)', 'SIZE (MB)')
     74     for filename, added in sorted_added:
     75       filesize = self._file_size[filename]
     76       added = self._file_pages[filename][0]
     77       removed = self._file_pages[filename][1]
     78       if (filename > 64):
     79         filename = filename[-64:]
     80       print row_format.format(filename, self.pages_to_mb(added), self.pages_to_mb(removed), self.bytes_to_mb(filesize))
     81 
     82     print row_format.format('TOTAL', self.pages_to_mb(self._total_pages_added), self.pages_to_mb(self._total_pages_removed), '')
     83 
     84   def print_stats_curses(self, pad):
     85     sorted_added = sorted(self._file_pages.items(), key=operator.itemgetter(1), reverse=True)
     86     height, width = pad.getmaxyx()
     87     pad.clear()
     88     pad.addstr(0, 2, 'NAME'.ljust(68), curses.A_REVERSE)
     89     pad.addstr(0, 70, 'ADDED (MB)'.ljust(12), curses.A_REVERSE)
     90     pad.addstr(0, 82, 'REMOVED (MB)'.ljust(14), curses.A_REVERSE)
     91     pad.addstr(0, 96, 'SIZE (MB)'.ljust(9), curses.A_REVERSE)
     92     y = 1
     93     for filename, added_removed in sorted_added:
     94       filesize = self._file_size[filename]
     95       added  = self._file_pages[filename][0]
     96       removed = self._file_pages[filename][1]
     97       if (filename > 64):
     98         filename = filename[-64:]
     99       pad.addstr(y, 2, filename)
    100       pad.addstr(y, 70, self.pages_to_mb(added).rjust(10))
    101       pad.addstr(y, 80, self.pages_to_mb(removed).rjust(14))
    102       pad.addstr(y, 96, self.bytes_to_mb(filesize).rjust(9))
    103       y += 1
    104       if y == height - 2:
    105         pad.addstr(y, 4, "<more...>")
    106         break
    107     y += 1
    108     pad.addstr(y, 2, 'TOTAL'.ljust(74), curses.A_REVERSE)
    109     pad.addstr(y, 70, str(self.pages_to_mb(self._total_pages_added)).rjust(10), curses.A_REVERSE)
    110     pad.addstr(y, 80, str(self.pages_to_mb(self._total_pages_removed)).rjust(14), curses.A_REVERSE)
    111     pad.refresh(0,0, 0,0, height,width)
    112 
    113 class FileReaderThread(threading.Thread):
    114   """Reads data from a file/pipe on a worker thread.
    115 
    116   Use the standard threading. Thread object API to start and interact with the
    117   thread (start(), join(), etc.).
    118   """
    119 
    120   def __init__(self, file_object, output_queue, text_file, chunk_size=-1):
    121     """Initializes a FileReaderThread.
    122 
    123     Args:
    124       file_object: The file or pipe to read from.
    125       output_queue: A Queue.Queue object that will receive the data
    126       text_file: If True, the file will be read one line at a time, and
    127           chunk_size will be ignored.  If False, line breaks are ignored and
    128           chunk_size must be set to a positive integer.
    129       chunk_size: When processing a non-text file (text_file = False),
    130           chunk_size is the amount of data to copy into the queue with each
    131           read operation.  For text files, this parameter is ignored.
    132     """
    133     threading.Thread.__init__(self)
    134     self._file_object = file_object
    135     self._output_queue = output_queue
    136     self._text_file = text_file
    137     self._chunk_size = chunk_size
    138     assert text_file or chunk_size > 0
    139 
    140   def run(self):
    141     """Overrides Thread's run() function.
    142 
    143     Returns when an EOF is encountered.
    144     """
    145     if self._text_file:
    146       # Read a text file one line at a time.
    147       for line in self._file_object:
    148         self._output_queue.put(line)
    149     else:
    150       # Read binary or text data until we get to EOF.
    151       while True:
    152         chunk = self._file_object.read(self._chunk_size)
    153         if not chunk:
    154           break
    155         self._output_queue.put(chunk)
    156 
    157   def set_chunk_size(self, chunk_size):
    158     """Change the read chunk size.
    159 
    160     This function can only be called if the FileReaderThread object was
    161     created with an initial chunk_size > 0.
    162     Args:
    163       chunk_size: the new chunk size for this file.  Must be > 0.
    164     """
    165     # The chunk size can be changed asynchronously while a file is being read
    166     # in a worker thread.  However, type of file can not be changed after the
    167     # the FileReaderThread has been created.  These asserts verify that we are
    168     # only changing the chunk size, and not the type of file.
    169     assert not self._text_file
    170     assert chunk_size > 0
    171     self._chunk_size = chunk_size
    172 
    173 class AdbUtils():
    174   @staticmethod
    175   def add_adb_serial(adb_command, device_serial):
    176     if device_serial is not None:
    177       adb_command.insert(1, device_serial)
    178       adb_command.insert(1, '-s')
    179 
    180   @staticmethod
    181   def construct_adb_shell_command(shell_args, device_serial):
    182     adb_command = ['adb', 'shell', ' '.join(shell_args)]
    183     AdbUtils.add_adb_serial(adb_command, device_serial)
    184     return adb_command
    185 
    186   @staticmethod
    187   def run_adb_shell(shell_args, device_serial):
    188     """Runs "adb shell" with the given arguments.
    189 
    190     Args:
    191       shell_args: array of arguments to pass to adb shell.
    192       device_serial: if not empty, will add the appropriate command-line
    193           parameters so that adb targets the given device.
    194     Returns:
    195       A tuple containing the adb output (stdout & stderr) and the return code
    196       from adb.  Will exit if adb fails to start.
    197     """
    198     adb_command = AdbUtils.construct_adb_shell_command(shell_args, device_serial)
    199 
    200     adb_output = []
    201     adb_return_code = 0
    202     try:
    203       adb_output = subprocess.check_output(adb_command, stderr=subprocess.STDOUT,
    204                                            shell=False, universal_newlines=True)
    205     except OSError as error:
    206       # This usually means that the adb executable was not found in the path.
    207       print >> sys.stderr, ('\nThe command "%s" failed with the following error:'
    208                             % ' '.join(adb_command))
    209       print >> sys.stderr, '    %s' % str(error)
    210       print >> sys.stderr, 'Is adb in your path?'
    211       adb_return_code = error.errno
    212       adb_output = error
    213     except subprocess.CalledProcessError as error:
    214       # The process exited with an error.
    215       adb_return_code = error.returncode
    216       adb_output = error.output
    217 
    218     return (adb_output, adb_return_code)
    219 
    220   @staticmethod
    221   def do_preprocess_adb_cmd(command, serial):
    222     args = [command]
    223     dump, ret_code = AdbUtils.run_adb_shell(args, serial)
    224     if ret_code != 0:
    225       return None
    226 
    227     dump = ''.join(dump)
    228     return dump
    229 
    230 def parse_atrace_line(line, pagecache_stats, app_name):
    231   # Find a mm_filemap_add_to_page_cache entry
    232   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=\d+ ofs=(\d+).*', line)
    233   if m != None:
    234     # Get filename
    235     device_number = int(m.group(2)) << 8 | int(m.group(3))
    236     if device_number == 0:
    237       return
    238     inode = int(m.group(4), 16)
    239     if app_name != None and not (app_name in m.group(0)):
    240       return
    241     if m.group(1) == 'mm_filemap_add_to_page_cache':
    242       pagecache_stats.add_page(device_number, inode, m.group(4))
    243     elif m.group(1) == 'mm_filemap_delete_from_page_cache':
    244       pagecache_stats.remove_page(device_number, inode, m.group(4))
    245 
    246 def build_inode_lookup_table(inode_dump):
    247   inode2filename = {}
    248   text = inode_dump.splitlines()
    249   for line in text:
    250     result = re.match('([0-9]+)d? ([0-9]+) ([0-9]+) (.*)', line)
    251     if result:
    252       inode2filename[(int(result.group(1)), int(result.group(2)))] = (result.group(4), result.group(3))
    253 
    254   return inode2filename;
    255 
    256 def get_inode_data(datafile, dumpfile, adb_serial):
    257   if datafile is not None and os.path.isfile(datafile):
    258     print('Using cached inode data from ' + datafile)
    259     f = open(datafile, 'r')
    260     stat_dump = f.read();
    261   else:
    262     # Build inode maps if we were tracing page cache
    263     print('Downloading inode data from device')
    264     stat_dump = AdbUtils.do_preprocess_adb_cmd('find /system /data /vendor ' +
    265                                     '-exec stat -c "%d %i %s %n" {} \;', adb_serial)
    266     if stat_dump is None:
    267       print 'Could not retrieve inode data from device.'
    268       sys.exit(1)
    269 
    270     if dumpfile is not None:
    271       print 'Storing inode data in ' + dumpfile
    272       f = open(dumpfile, 'w')
    273       f.write(stat_dump)
    274       f.close()
    275 
    276     sys.stdout.write('Done.\n')
    277 
    278   return stat_dump
    279 
    280 def read_and_parse_trace_file(trace_file, pagecache_stats, app_name):
    281   for line in trace_file:
    282     parse_atrace_line(line, pagecache_stats, app_name)
    283   pagecache_stats.print_stats();
    284 
    285 def read_and_parse_trace_data_live(stdout, stderr, pagecache_stats, app_name):
    286   # Start reading trace data
    287   stdout_queue = Queue.Queue(maxsize=128)
    288   stderr_queue = Queue.Queue()
    289 
    290   stdout_thread = FileReaderThread(stdout, stdout_queue,
    291                                    text_file=True, chunk_size=64)
    292   stderr_thread = FileReaderThread(stderr, stderr_queue,
    293                                    text_file=True)
    294   stdout_thread.start()
    295   stderr_thread.start()
    296 
    297   stdscr = curses.initscr()
    298 
    299   try:
    300     height, width = stdscr.getmaxyx()
    301     curses.noecho()
    302     curses.cbreak()
    303     stdscr.keypad(True)
    304     stdscr.nodelay(True)
    305     stdscr.refresh()
    306     # We need at least a 30x100 window
    307     used_width = max(width, 100)
    308     used_height = max(height, 30)
    309 
    310     # Create a pad for pagecache stats
    311     pagecache_pad = curses.newpad(used_height - 2, used_width)
    312 
    313     stdscr.addstr(used_height - 1, 0, 'KEY SHORTCUTS: (r)eset stats, CTRL-c to quit')
    314     while (stdout_thread.isAlive() or stderr_thread.isAlive() or
    315            not stdout_queue.empty() or not stderr_queue.empty()):
    316       while not stderr_queue.empty():
    317         # Pass along errors from adb.
    318         line = stderr_queue.get()
    319         sys.stderr.write(line)
    320       while True:
    321         try:
    322           line = stdout_queue.get(True, STATS_UPDATE_INTERVAL)
    323           parse_atrace_line(line, pagecache_stats, app_name)
    324         except Queue.Empty:
    325           break
    326 
    327       key = ''
    328       try:
    329         key = stdscr.getkey()
    330       except:
    331         pass
    332 
    333       if key == 'r':
    334         pagecache_stats.reset_stats()
    335 
    336       pagecache_stats.print_stats_curses(pagecache_pad)
    337   except Exception, e:
    338     curses.endwin()
    339     print e
    340   finally:
    341     curses.endwin()
    342     # The threads should already have stopped, so this is just for cleanup.
    343     stdout_thread.join()
    344     stderr_thread.join()
    345 
    346     stdout.close()
    347     stderr.close()
    348 
    349 def parse_options(argv):
    350   usage = 'Usage: %prog [options]'
    351   desc = 'Example: %prog'
    352   parser = optparse.OptionParser(usage=usage, description=desc)
    353   parser.add_option('-d', dest='inode_dump_file', metavar='FILE',
    354                     help='Dump the inode data read from a device to a file.'
    355                     ' This file can then be reused with the -i option to speed'
    356                     ' up future invocations of this script.')
    357   parser.add_option('-i', dest='inode_data_file', metavar='FILE',
    358                     help='Read cached inode data from a file saved arlier with the'
    359                     ' -d option.')
    360   parser.add_option('-s', '--serial', dest='device_serial', type='string',
    361                     help='adb device serial number')
    362   parser.add_option('-f', dest='trace_file', metavar='FILE',
    363                     help='Show stats from a trace file, instead of running live.')
    364   parser.add_option('-a', dest='app_name', type='string',
    365                     help='filter a particular app')
    366 
    367   options, categories = parser.parse_args(argv[1:])
    368   if options.inode_dump_file and options.inode_data_file:
    369     parser.error('options -d and -i can\'t be used at the same time')
    370   return (options, categories)
    371 
    372 def main():
    373   options, categories = parse_options(sys.argv)
    374 
    375   # Load inode data for this device
    376   inode_data = get_inode_data(options.inode_data_file, options.inode_dump_file,
    377       options.device_serial)
    378   # Build (dev, inode) -> filename hash
    379   inode_lookup_table = build_inode_lookup_table(inode_data)
    380   # Init pagecache stats
    381   pagecache_stats = PagecacheStats(inode_lookup_table)
    382 
    383   if options.trace_file is not None:
    384     if not os.path.isfile(options.trace_file):
    385       print >> sys.stderr, ('Couldn\'t load trace file.')
    386       sys.exit(1)
    387     trace_file = open(options.trace_file, 'r')
    388     read_and_parse_trace_file(trace_file, pagecache_stats, options.app_name)
    389   else:
    390     # Construct and execute trace command
    391     trace_cmd = AdbUtils.construct_adb_shell_command(['atrace', '--stream', 'pagecache'],
    392         options.device_serial)
    393 
    394     try:
    395       atrace = subprocess.Popen(trace_cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
    396           stderr=subprocess.PIPE)
    397     except OSError as error:
    398       print >> sys.stderr, ('The command failed')
    399       sys.exit(1)
    400 
    401     read_and_parse_trace_data_live(atrace.stdout, atrace.stderr, pagecache_stats, options.app_name)
    402 
    403 if __name__ == "__main__":
    404   main()
    405