#!/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, "<more...>")
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=\d+ 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 /system /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()