1#!/usr/bin/env python 2 3import curses 4import operator 5import optparse 6import os 7import re 8import subprocess 9import sys 10import threading 11import Queue 12 13STATS_UPDATE_INTERVAL = 0.2 14PAGE_SIZE = 4096 15 16class 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 113class 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 173class 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 230def 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 246def 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 256def 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 280def 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 285def 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 349def 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 372def 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 403if __name__ == "__main__": 404 main() 405