1 # Copyright 2013 The Chromium Authors. All rights reserved. 2 # Use of this source code is governed by a BSD-style license that can be 3 # found in the LICENSE file. 4 5 import copy 6 import datetime 7 import logging 8 import os 9 import re 10 import time 11 12 from lib.bucket import BUCKET_ID 13 from lib.exceptions import EmptyDumpException, InvalidDumpException 14 from lib.exceptions import ObsoleteDumpVersionException, ParsingException 15 from lib.pageframe import PageFrame 16 from lib.range_dict import ExclusiveRangeDict 17 from lib.symbol import procfs 18 19 20 LOGGER = logging.getLogger('dmprof') 21 22 23 # Heap Profile Dump versions 24 25 # DUMP_DEEP_[1-4] are obsolete. 26 # DUMP_DEEP_2+ distinct mmap regions and malloc chunks. 27 # DUMP_DEEP_3+ don't include allocation functions in their stack dumps. 28 # DUMP_DEEP_4+ support comments with '#' and global stats "nonprofiled-*". 29 # DUMP_DEEP_[1-2] should be processed by POLICY_DEEP_1. 30 # DUMP_DEEP_[3-4] should be processed by POLICY_DEEP_2 or POLICY_DEEP_3. 31 DUMP_DEEP_1 = 'DUMP_DEEP_1' 32 DUMP_DEEP_2 = 'DUMP_DEEP_2' 33 DUMP_DEEP_3 = 'DUMP_DEEP_3' 34 DUMP_DEEP_4 = 'DUMP_DEEP_4' 35 36 DUMP_DEEP_OBSOLETE = (DUMP_DEEP_1, DUMP_DEEP_2, DUMP_DEEP_3, DUMP_DEEP_4) 37 38 # DUMP_DEEP_5 doesn't separate sections for malloc and mmap. 39 # malloc and mmap are identified in bucket files. 40 # DUMP_DEEP_5 should be processed by POLICY_DEEP_4. 41 DUMP_DEEP_5 = 'DUMP_DEEP_5' 42 43 # DUMP_DEEP_6 adds a mmap list to DUMP_DEEP_5. 44 DUMP_DEEP_6 = 'DUMP_DEEP_6' 45 46 47 class Dump(object): 48 """Represents a heap profile dump.""" 49 50 _PATH_PATTERN = re.compile(r'^(.*)\.([0-9]+)\.([0-9]+)\.heap$') 51 52 _HOOK_PATTERN = re.compile( 53 r'^ ([ \(])([a-f0-9]+)([ \)])-([ \(])([a-f0-9]+)([ \)])\s+' 54 r'(hooked|unhooked)\s+(.+)$', re.IGNORECASE) 55 56 _HOOKED_PATTERN = re.compile(r'(?P<TYPE>.+ )?(?P<COMMITTED>[0-9]+) / ' 57 '(?P<RESERVED>[0-9]+) @ (?P<BUCKETID>[0-9]+)') 58 _UNHOOKED_PATTERN = re.compile(r'(?P<TYPE>.+ )?(?P<COMMITTED>[0-9]+) / ' 59 '(?P<RESERVED>[0-9]+)') 60 61 _OLD_HOOKED_PATTERN = re.compile(r'(?P<TYPE>.+) @ (?P<BUCKETID>[0-9]+)') 62 _OLD_UNHOOKED_PATTERN = re.compile(r'(?P<TYPE>.+) (?P<COMMITTED>[0-9]+)') 63 64 _TIME_PATTERN_FORMAT = re.compile( 65 r'^Time: ([0-9]+/[0-9]+/[0-9]+ [0-9]+:[0-9]+:[0-9]+)(\.[0-9]+)?') 66 _TIME_PATTERN_SECONDS = re.compile(r'^Time: ([0-9]+)$') 67 68 def __init__(self, path, modified_time): 69 self._path = path 70 matched = self._PATH_PATTERN.match(path) 71 self._pid = int(matched.group(2)) 72 self._count = int(matched.group(3)) 73 self._time = modified_time 74 self._map = {} 75 self._procmaps = ExclusiveRangeDict(ProcMapsEntryAttribute) 76 self._stacktrace_lines = [] 77 self._global_stats = {} # used only in apply_policy 78 79 self._run_id = '' 80 self._pagesize = 4096 81 self._pageframe_length = 0 82 self._pageframe_encoding = '' 83 self._has_pagecount = False 84 85 self._version = '' 86 self._lines = [] 87 88 @property 89 def path(self): 90 return self._path 91 92 @property 93 def count(self): 94 return self._count 95 96 @property 97 def time(self): 98 return self._time 99 100 @property 101 def iter_map(self): 102 for region in sorted(self._map.iteritems()): 103 yield region[0], region[1] 104 105 def iter_procmaps(self): 106 for begin, end, attr in self._map.iter_range(): 107 yield begin, end, attr 108 109 @property 110 def iter_stacktrace(self): 111 for line in self._stacktrace_lines: 112 yield line 113 114 def global_stat(self, name): 115 return self._global_stats[name] 116 117 @property 118 def run_id(self): 119 return self._run_id 120 121 @property 122 def pagesize(self): 123 return self._pagesize 124 125 @property 126 def pageframe_length(self): 127 return self._pageframe_length 128 129 @property 130 def pageframe_encoding(self): 131 return self._pageframe_encoding 132 133 @property 134 def has_pagecount(self): 135 return self._has_pagecount 136 137 @staticmethod 138 def load(path, log_header='Loading a heap profile dump: '): 139 """Loads a heap profile dump. 140 141 Args: 142 path: A file path string to load. 143 log_header: A preceding string for log messages. 144 145 Returns: 146 A loaded Dump object. 147 148 Raises: 149 ParsingException for invalid heap profile dumps. 150 """ 151 dump = Dump(path, os.stat(path).st_mtime) 152 with open(path, 'r') as f: 153 dump.load_file(f, log_header) 154 return dump 155 156 def load_file(self, f, log_header): 157 self._lines = [line for line in f 158 if line and not line.startswith('#')] 159 160 try: 161 self._version, ln = self._parse_version() 162 self._parse_meta_information() 163 if self._version == DUMP_DEEP_6: 164 self._parse_mmap_list() 165 self._parse_global_stats() 166 self._extract_stacktrace_lines(ln) 167 except EmptyDumpException: 168 LOGGER.info('%s%s ...ignored an empty dump.' % (log_header, self._path)) 169 except ParsingException, e: 170 LOGGER.error('%s%s ...error %s' % (log_header, self._path, e)) 171 raise 172 else: 173 LOGGER.info('%s%s (version:%s)' % (log_header, self._path, self._version)) 174 175 def _parse_version(self): 176 """Parses a version string in self._lines. 177 178 Returns: 179 A pair of (a string representing a version of the stacktrace dump, 180 and an integer indicating a line number next to the version string). 181 182 Raises: 183 ParsingException for invalid dump versions. 184 """ 185 version = '' 186 187 # Skip until an identifiable line. 188 headers = ('STACKTRACES:\n', 'MMAP_STACKTRACES:\n', 'heap profile: ') 189 if not self._lines: 190 raise EmptyDumpException('Empty heap dump file.') 191 (ln, found) = skip_while( 192 0, len(self._lines), 193 lambda n: not self._lines[n].startswith(headers)) 194 if not found: 195 raise InvalidDumpException('No version header.') 196 197 # Identify a version. 198 if self._lines[ln].startswith('heap profile: '): 199 version = self._lines[ln][13:].strip() 200 if version in (DUMP_DEEP_5, DUMP_DEEP_6): 201 (ln, _) = skip_while( 202 ln, len(self._lines), 203 lambda n: self._lines[n] != 'STACKTRACES:\n') 204 elif version in DUMP_DEEP_OBSOLETE: 205 raise ObsoleteDumpVersionException(version) 206 else: 207 raise InvalidDumpException('Invalid version: %s' % version) 208 elif self._lines[ln] == 'STACKTRACES:\n': 209 raise ObsoleteDumpVersionException(DUMP_DEEP_1) 210 elif self._lines[ln] == 'MMAP_STACKTRACES:\n': 211 raise ObsoleteDumpVersionException(DUMP_DEEP_2) 212 213 return (version, ln) 214 215 def _parse_global_stats(self): 216 """Parses lines in self._lines as global stats.""" 217 (ln, _) = skip_while( 218 0, len(self._lines), 219 lambda n: self._lines[n] != 'GLOBAL_STATS:\n') 220 221 global_stat_names = [ 222 'total', 'absent', 'file-exec', 'file-nonexec', 'anonymous', 'stack', 223 'other', 'nonprofiled-absent', 'nonprofiled-anonymous', 224 'nonprofiled-file-exec', 'nonprofiled-file-nonexec', 225 'nonprofiled-stack', 'nonprofiled-other', 226 'profiled-mmap', 'profiled-malloc'] 227 228 for prefix in global_stat_names: 229 (ln, _) = skip_while( 230 ln, len(self._lines), 231 lambda n: self._lines[n].split()[0] != prefix) 232 words = self._lines[ln].split() 233 self._global_stats[prefix + '_virtual'] = int(words[-2]) 234 self._global_stats[prefix + '_committed'] = int(words[-1]) 235 236 def _parse_meta_information(self): 237 """Parses lines in self._lines for meta information.""" 238 (ln, found) = skip_while( 239 0, len(self._lines), 240 lambda n: self._lines[n] != 'META:\n') 241 if not found: 242 return 243 ln += 1 244 245 while True: 246 if self._lines[ln].startswith('Time:'): 247 matched_seconds = self._TIME_PATTERN_SECONDS.match(self._lines[ln]) 248 matched_format = self._TIME_PATTERN_FORMAT.match(self._lines[ln]) 249 if matched_format: 250 self._time = time.mktime(datetime.datetime.strptime( 251 matched_format.group(1), '%Y/%m/%d %H:%M:%S').timetuple()) 252 if matched_format.group(2): 253 self._time += float(matched_format.group(2)[1:]) / 1000.0 254 elif matched_seconds: 255 self._time = float(matched_seconds.group(1)) 256 elif self._lines[ln].startswith('Reason:'): 257 pass # Nothing to do for 'Reason:' 258 elif self._lines[ln].startswith('PageSize: '): 259 self._pagesize = int(self._lines[ln][10:]) 260 elif self._lines[ln].startswith('CommandLine:'): 261 pass 262 elif (self._lines[ln].startswith('PageFrame: ') or 263 self._lines[ln].startswith('PFN: ')): 264 if self._lines[ln].startswith('PageFrame: '): 265 words = self._lines[ln][11:].split(',') 266 else: 267 words = self._lines[ln][5:].split(',') 268 for word in words: 269 if word == '24': 270 self._pageframe_length = 24 271 elif word == 'Base64': 272 self._pageframe_encoding = 'base64' 273 elif word == 'PageCount': 274 self._has_pagecount = True 275 elif self._lines[ln].startswith('RunID: '): 276 self._run_id = self._lines[ln][7:].strip() 277 elif (self._lines[ln].startswith('MMAP_LIST:') or 278 self._lines[ln].startswith('GLOBAL_STATS:')): 279 # Skip until "MMAP_LIST:" or "GLOBAL_STATS" is found. 280 break 281 else: 282 pass 283 ln += 1 284 285 def _parse_mmap_list(self): 286 """Parses lines in self._lines as a mmap list.""" 287 (ln, found) = skip_while( 288 0, len(self._lines), 289 lambda n: self._lines[n] != 'MMAP_LIST:\n') 290 if not found: 291 return {} 292 293 ln += 1 294 self._map = {} 295 current_vma = {} 296 pageframe_list = [] 297 while True: 298 entry = procfs.ProcMaps.parse_line(self._lines[ln]) 299 if entry: 300 current_vma = {} 301 for _, _, attr in self._procmaps.iter_range(entry.begin, entry.end): 302 for key, value in entry.as_dict().iteritems(): 303 attr[key] = value 304 current_vma[key] = value 305 ln += 1 306 continue 307 308 if self._lines[ln].startswith(' PF: '): 309 for pageframe in self._lines[ln][5:].split(): 310 pageframe_list.append(PageFrame.parse(pageframe, self._pagesize)) 311 ln += 1 312 continue 313 314 matched = self._HOOK_PATTERN.match(self._lines[ln]) 315 if not matched: 316 break 317 # 2: starting address 318 # 5: end address 319 # 7: hooked or unhooked 320 # 8: additional information 321 if matched.group(7) == 'hooked': 322 submatched = self._HOOKED_PATTERN.match(matched.group(8)) 323 if not submatched: 324 submatched = self._OLD_HOOKED_PATTERN.match(matched.group(8)) 325 elif matched.group(7) == 'unhooked': 326 submatched = self._UNHOOKED_PATTERN.match(matched.group(8)) 327 if not submatched: 328 submatched = self._OLD_UNHOOKED_PATTERN.match(matched.group(8)) 329 else: 330 assert matched.group(7) in ['hooked', 'unhooked'] 331 332 submatched_dict = submatched.groupdict() 333 region_info = { 'vma': current_vma } 334 if submatched_dict.get('TYPE'): 335 region_info['type'] = submatched_dict['TYPE'].strip() 336 if submatched_dict.get('COMMITTED'): 337 region_info['committed'] = int(submatched_dict['COMMITTED']) 338 if submatched_dict.get('RESERVED'): 339 region_info['reserved'] = int(submatched_dict['RESERVED']) 340 if submatched_dict.get('BUCKETID'): 341 region_info['bucket_id'] = int(submatched_dict['BUCKETID']) 342 343 if matched.group(1) == '(': 344 start = current_vma['begin'] 345 else: 346 start = int(matched.group(2), 16) 347 if matched.group(4) == '(': 348 end = current_vma['end'] 349 else: 350 end = int(matched.group(5), 16) 351 352 if pageframe_list and pageframe_list[0].start_truncated: 353 pageframe_list[0].set_size( 354 pageframe_list[0].size - start % self._pagesize) 355 if pageframe_list and pageframe_list[-1].end_truncated: 356 pageframe_list[-1].set_size( 357 pageframe_list[-1].size - (self._pagesize - end % self._pagesize)) 358 region_info['pageframe'] = pageframe_list 359 pageframe_list = [] 360 361 self._map[(start, end)] = (matched.group(7), region_info) 362 ln += 1 363 364 def _extract_stacktrace_lines(self, line_number): 365 """Extracts the position of stacktrace lines. 366 367 Valid stacktrace lines are stored into self._stacktrace_lines. 368 369 Args: 370 line_number: A line number to start parsing in lines. 371 372 Raises: 373 ParsingException for invalid dump versions. 374 """ 375 if self._version in (DUMP_DEEP_5, DUMP_DEEP_6): 376 (line_number, _) = skip_while( 377 line_number, len(self._lines), 378 lambda n: not self._lines[n].split()[0].isdigit()) 379 stacktrace_start = line_number 380 (line_number, _) = skip_while( 381 line_number, len(self._lines), 382 lambda n: self._check_stacktrace_line(self._lines[n])) 383 self._stacktrace_lines = self._lines[stacktrace_start:line_number] 384 385 elif self._version in DUMP_DEEP_OBSOLETE: 386 raise ObsoleteDumpVersionException(self._version) 387 388 else: 389 raise InvalidDumpException('Invalid version: %s' % self._version) 390 391 @staticmethod 392 def _check_stacktrace_line(stacktrace_line): 393 """Checks if a given stacktrace_line is valid as stacktrace. 394 395 Args: 396 stacktrace_line: A string to be checked. 397 398 Returns: 399 True if the given stacktrace_line is valid. 400 """ 401 words = stacktrace_line.split() 402 if len(words) < BUCKET_ID + 1: 403 return False 404 if words[BUCKET_ID - 1] != '@': 405 return False 406 return True 407 408 409 class DumpList(object): 410 """Represents a sequence of heap profile dumps.""" 411 412 def __init__(self, dump_list): 413 self._dump_list = dump_list 414 415 @staticmethod 416 def load(path_list): 417 LOGGER.info('Loading heap dump profiles.') 418 dump_list = [] 419 for path in path_list: 420 dump_list.append(Dump.load(path, ' ')) 421 return DumpList(dump_list) 422 423 def __len__(self): 424 return len(self._dump_list) 425 426 def __iter__(self): 427 for dump in self._dump_list: 428 yield dump 429 430 def __getitem__(self, index): 431 return self._dump_list[index] 432 433 434 class ProcMapsEntryAttribute(ExclusiveRangeDict.RangeAttribute): 435 """Represents an entry of /proc/maps in range_dict.ExclusiveRangeDict.""" 436 _DUMMY_ENTRY = procfs.ProcMapsEntry( 437 0, # begin 438 0, # end 439 '-', # readable 440 '-', # writable 441 '-', # executable 442 '-', # private 443 0, # offset 444 '00', # major 445 '00', # minor 446 0, # inode 447 '' # name 448 ) 449 450 def __init__(self): 451 super(ProcMapsEntryAttribute, self).__init__() 452 self._entry = self._DUMMY_ENTRY.as_dict() 453 454 def __str__(self): 455 return str(self._entry) 456 457 def __repr__(self): 458 return 'ProcMapsEntryAttribute' + str(self._entry) 459 460 def __getitem__(self, key): 461 return self._entry[key] 462 463 def __setitem__(self, key, value): 464 if key not in self._entry: 465 raise KeyError(key) 466 self._entry[key] = value 467 468 def copy(self): 469 new_entry = ProcMapsEntryAttribute() 470 for key, value in self._entry.iteritems(): 471 new_entry[key] = copy.deepcopy(value) 472 return new_entry 473 474 475 def skip_while(index, max_index, skipping_condition): 476 """Increments |index| until |skipping_condition|(|index|) is False. 477 478 Returns: 479 A pair of an integer indicating a line number after skipped, and a 480 boolean value which is True if found a line which skipping_condition 481 is False for. 482 """ 483 while skipping_condition(index): 484 index += 1 485 if index >= max_index: 486 return index, False 487 return index, True 488