#!/usr/bin/env python3 # # Script to find stack usage at the function level. Will detect recursion and # report as infinite stack usage. # import os import glob import itertools as it import re import csv import collections as co import math as m CI_PATHS = ['*.ci'] def collect(paths, **args): # parse the vcg format k_pattern = re.compile('([a-z]+)\s*:', re.DOTALL) v_pattern = re.compile('(?:"(.*?)"|([a-z]+))', re.DOTALL) def parse_vcg(rest): def parse_vcg(rest): node = [] while True: rest = rest.lstrip() m = k_pattern.match(rest) if not m: return (node, rest) k, rest = m.group(1), rest[m.end(0):] rest = rest.lstrip() if rest.startswith('{'): v, rest = parse_vcg(rest[1:]) assert rest[0] == '}', "unexpected %r" % rest[0:1] rest = rest[1:] node.append((k, v)) else: m = v_pattern.match(rest) assert m, "unexpected %r" % rest[0:1] v, rest = m.group(1) or m.group(2), rest[m.end(0):] node.append((k, v)) node, rest = parse_vcg(rest) assert rest == '', "unexpected %r" % rest[0:1] return node # collect into functions results = co.defaultdict(lambda: (None, None, 0, set())) f_pattern = re.compile( r'([^\\]*)\\n([^:]*)[^\\]*\\n([0-9]+) bytes \((.*)\)') for path in paths: with open(path) as f: vcg = parse_vcg(f.read()) for k, graph in vcg: if k != 'graph': continue for k, info in graph: if k == 'node': info = dict(info) m = f_pattern.match(info['label']) if m: function, file, size, type = m.groups() if not args.get('quiet') and type != 'static': print('warning: found non-static stack for %s (%s)' % (function, type)) _, _, _, targets = results[info['title']] results[info['title']] = ( file, function, int(size), targets) elif k == 'edge': info = dict(info) _, _, _, targets = results[info['sourcename']] targets.add(info['targetname']) else: continue if not args.get('everything'): for source, (s_file, s_function, _, _) in list(results.items()): # discard internal functions if s_file.startswith('<') or s_file.startswith('/usr/include'): del results[source] # find maximum stack size recursively, this requires also detecting cycles # (in case of recursion) def find_limit(source, seen=None): seen = seen or set() if source not in results: return 0 _, _, frame, targets = results[source] limit = 0 for target in targets: if target in seen: # found a cycle return float('inf') limit_ = find_limit(target, seen | {target}) limit = max(limit, limit_) return frame + limit def find_deps(targets): deps = set() for target in targets: if target in results: t_file, t_function, _, _ = results[target] deps.add((t_file, t_function)) return deps # flatten into a list flat_results = [] for source, (s_file, s_function, frame, targets) in results.items(): limit = find_limit(source) deps = find_deps(targets) flat_results.append((s_file, s_function, frame, limit, deps)) return flat_results def main(**args): def openio(path, mode='r'): if path == '-': if 'r' in mode: return os.fdopen(os.dup(sys.stdin.fileno()), 'r') else: return os.fdopen(os.dup(sys.stdout.fileno()), 'w') else: return open(path, mode) # find sizes if not args.get('use', None): # find .ci files paths = [] for path in args['ci_paths']: if os.path.isdir(path): path = path + '/*.ci' for path in glob.glob(path): paths.append(path) if not paths: print('no .ci files found in %r?' % args['ci_paths']) sys.exit(-1) results = collect(paths, **args) else: with openio(args['use']) as f: r = csv.DictReader(f) results = [ ( result['file'], result['name'], int(result['stack_frame']), float(result['stack_limit']), # note limit can be inf set()) for result in r if result.get('stack_frame') not in {None, ''} if result.get('stack_limit') not in {None, ''}] total_frame = 0 total_limit = 0 for _, _, frame, limit, _ in results: total_frame += frame total_limit = max(total_limit, limit) # find previous results? if args.get('diff'): try: with openio(args['diff']) as f: r = csv.DictReader(f) prev_results = [ ( result['file'], result['name'], int(result['stack_frame']), float(result['stack_limit']), set()) for result in r if result.get('stack_frame') not in {None, ''} if result.get('stack_limit') not in {None, ''}] except FileNotFoundError: prev_results = [] prev_total_frame = 0 prev_total_limit = 0 for _, _, frame, limit, _ in prev_results: prev_total_frame += frame prev_total_limit = max(prev_total_limit, limit) # write results to CSV if args.get('output'): merged_results = co.defaultdict(lambda: {}) other_fields = [] # merge? if args.get('merge'): try: with openio(args['merge']) as f: r = csv.DictReader(f) for result in r: file = result.pop('file', '') func = result.pop('name', '') result.pop('stack_frame', None) result.pop('stack_limit', None) merged_results[(file, func)] = result other_fields = result.keys() except FileNotFoundError: pass for file, func, frame, limit, _ in results: merged_results[(file, func)]['stack_frame'] = frame merged_results[(file, func)]['stack_limit'] = limit with openio(args['output'], 'w') as f: w = csv.DictWriter(f, ['file', 'name', *other_fields, 'stack_frame', 'stack_limit']) w.writeheader() for (file, func), result in sorted(merged_results.items()): w.writerow({'file': file, 'name': func, **result}) # print results def dedup_entries(results, by='name'): entries = co.defaultdict(lambda: (0, 0, set())) for file, func, frame, limit, deps in results: entry = (file if by == 'file' else func) entry_frame, entry_limit, entry_deps = entries[entry] entries[entry] = ( entry_frame + frame, max(entry_limit, limit), entry_deps | {file if by == 'file' else func for file, func in deps}) return entries def diff_entries(olds, news): diff = co.defaultdict(lambda: (None, None, None, None, 0, 0, 0, set())) for name, (new_frame, new_limit, deps) in news.items(): diff[name] = ( None, None, new_frame, new_limit, new_frame, new_limit, 1.0, deps) for name, (old_frame, old_limit, _) in olds.items(): _, _, new_frame, new_limit, _, _, _, deps = diff[name] diff[name] = ( old_frame, old_limit, new_frame, new_limit, (new_frame or 0) - (old_frame or 0), 0 if m.isinf(new_limit or 0) and m.isinf(old_limit or 0) else (new_limit or 0) - (old_limit or 0), 0.0 if m.isinf(new_limit or 0) and m.isinf(old_limit or 0) else +float('inf') if m.isinf(new_limit or 0) else -float('inf') if m.isinf(old_limit or 0) else +0.0 if not old_limit and not new_limit else +1.0 if not old_limit else ((new_limit or 0) - (old_limit or 0))/(old_limit or 0), deps) return diff def sorted_entries(entries): if args.get('limit_sort'): return sorted(entries, key=lambda x: (-x[1][1], x)) elif args.get('reverse_limit_sort'): return sorted(entries, key=lambda x: (+x[1][1], x)) elif args.get('frame_sort'): return sorted(entries, key=lambda x: (-x[1][0], x)) elif args.get('reverse_frame_sort'): return sorted(entries, key=lambda x: (+x[1][0], x)) else: return sorted(entries) def sorted_diff_entries(entries): if args.get('limit_sort'): return sorted(entries, key=lambda x: (-(x[1][3] or 0), x)) elif args.get('reverse_limit_sort'): return sorted(entries, key=lambda x: (+(x[1][3] or 0), x)) elif args.get('frame_sort'): return sorted(entries, key=lambda x: (-(x[1][2] or 0), x)) elif args.get('reverse_frame_sort'): return sorted(entries, key=lambda x: (+(x[1][2] or 0), x)) else: return sorted(entries, key=lambda x: (-x[1][6], x)) def print_header(by=''): if not args.get('diff'): print('%-36s %7s %7s' % (by, 'frame', 'limit')) else: print('%-36s %15s %15s %15s' % (by, 'old', 'new', 'diff')) def print_entry(name, frame, limit): print("%-36s %7d %7s" % (name, frame, '∞' if m.isinf(limit) else int(limit))) def print_diff_entry(name, old_frame, old_limit, new_frame, new_limit, diff_frame, diff_limit, ratio): print('%-36s %7s %7s %7s %7s %+7d %7s%s' % (name, old_frame if old_frame is not None else "-", ('∞' if m.isinf(old_limit) else int(old_limit)) if old_limit is not None else "-", new_frame if new_frame is not None else "-", ('∞' if m.isinf(new_limit) else int(new_limit)) if new_limit is not None else "-", diff_frame, ('+∞' if diff_limit > 0 and m.isinf(diff_limit) else '-∞' if diff_limit < 0 and m.isinf(diff_limit) else '%+d' % diff_limit), '' if not ratio else ' (+∞%)' if ratio > 0 and m.isinf(ratio) else ' (-∞%)' if ratio < 0 and m.isinf(ratio) else ' (%+.1f%%)' % (100*ratio))) def print_entries(by='name'): # build optional tree of dependencies def print_deps(entries, depth, print, filter=lambda _: True, prefixes=('', '', '', '')): entries = entries if isinstance(entries, list) else list(entries) filtered_entries = [(name, entry) for name, entry in entries if filter(name)] for i, (name, entry) in enumerate(filtered_entries): last = (i == len(filtered_entries)-1) print(prefixes[0+last] + name, entry) if depth > 0: deps = entry[-1] print_deps(entries, depth-1, print, lambda name: name in deps, ( prefixes[2+last] + "|-> ", prefixes[2+last] + "'-> ", prefixes[2+last] + "| ", prefixes[2+last] + " ")) entries = dedup_entries(results, by=by) if not args.get('diff'): print_header(by=by) print_deps( sorted_entries(entries.items()), args.get('depth') or 0, lambda name, entry: print_entry(name, *entry[:-1])) else: prev_entries = dedup_entries(prev_results, by=by) diff = diff_entries(prev_entries, entries) print_header(by='%s (%d added, %d removed)' % (by, sum(1 for _, old, _, _, _, _, _, _ in diff.values() if old is None), sum(1 for _, _, _, new, _, _, _, _ in diff.values() if new is None))) print_deps( filter( lambda x: x[1][6] or args.get('all'), sorted_diff_entries(diff.items())), args.get('depth') or 0, lambda name, entry: print_diff_entry(name, *entry[:-1])) def print_totals(): if not args.get('diff'): print_entry('TOTAL', total_frame, total_limit) else: diff_frame = total_frame - prev_total_frame diff_limit = ( 0 if m.isinf(total_limit or 0) and m.isinf(prev_total_limit or 0) else (total_limit or 0) - (prev_total_limit or 0)) ratio = ( 0.0 if m.isinf(total_limit or 0) and m.isinf(prev_total_limit or 0) else +float('inf') if m.isinf(total_limit or 0) else -float('inf') if m.isinf(prev_total_limit or 0) else 0.0 if not prev_total_limit and not total_limit else 1.0 if not prev_total_limit else ((total_limit or 0) - (prev_total_limit or 0))/(prev_total_limit or 0)) print_diff_entry('TOTAL', prev_total_frame, prev_total_limit, total_frame, total_limit, diff_frame, diff_limit, ratio) if args.get('quiet'): pass elif args.get('summary'): print_header() print_totals() elif args.get('files'): print_entries(by='file') print_totals() else: print_entries(by='name') print_totals() if __name__ == "__main__": import argparse import sys parser = argparse.ArgumentParser( description="Find stack usage at the function level.") parser.add_argument('ci_paths', nargs='*', default=CI_PATHS, help="Description of where to find *.ci files. May be a directory \ or a list of paths. Defaults to %r." % CI_PATHS) parser.add_argument('-v', '--verbose', action='store_true', help="Output commands that run behind the scenes.") parser.add_argument('-q', '--quiet', action='store_true', help="Don't show anything, useful with -o.") parser.add_argument('-o', '--output', help="Specify CSV file to store results.") parser.add_argument('-u', '--use', help="Don't parse callgraph files, instead use this CSV file.") parser.add_argument('-d', '--diff', help="Specify CSV file to diff against.") parser.add_argument('-m', '--merge', help="Merge with an existing CSV file when writing to output.") parser.add_argument('-a', '--all', action='store_true', help="Show all functions, not just the ones that changed.") parser.add_argument('-A', '--everything', action='store_true', help="Include builtin and libc specific symbols.") parser.add_argument('-s', '--limit-sort', action='store_true', help="Sort by stack limit.") parser.add_argument('-S', '--reverse-limit-sort', action='store_true', help="Sort by stack limit, but backwards.") parser.add_argument('--frame-sort', action='store_true', help="Sort by stack frame size.") parser.add_argument('--reverse-frame-sort', action='store_true', help="Sort by stack frame size, but backwards.") parser.add_argument('-L', '--depth', default=0, type=lambda x: int(x, 0), nargs='?', const=float('inf'), help="Depth of dependencies to show.") parser.add_argument('-F', '--files', action='store_true', help="Show file-level calls.") parser.add_argument('-Y', '--summary', action='store_true', help="Only show the total stack size.") parser.add_argument('--build-dir', help="Specify the relative build directory. Used to map object files \ to the correct source files.") sys.exit(main(**vars(parser.parse_args())))