summaryrefslogtreecommitdiff
path: root/pagecache/pagecache.py
blob: 3f96a5d18e49eb04733219b4d274ccc30abb3f57 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
#!/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=([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()