Files
littlefs/scripts/stack.py
2022-11-15 13:38:13 -06:00

659 lines
22 KiB
Python
Executable File

#!/usr/bin/env python3
#
# Script to find stack usage at the function level. Will detect recursion and
# report as infinite stack usage.
#
# Example:
# ./scripts/stack.py lfs.ci lfs_util.ci -S
#
# Copyright (c) 2022, The littlefs authors.
# SPDX-License-Identifier: BSD-3-Clause
#
import collections as co
import csv
import glob
import itertools as it
import math as m
import os
import re
CI_PATHS = ['*.ci']
# integer fields
class IntField(co.namedtuple('IntField', 'x')):
__slots__ = ()
def __new__(cls, x=0):
if isinstance(x, IntField):
return x
if isinstance(x, str):
try:
x = int(x, 0)
except ValueError:
# also accept +-∞ and +-inf
if re.match('^\s*\+?\s*(?:∞|inf)\s*$', x):
x = m.inf
elif re.match('^\s*-\s*(?:∞|inf)\s*$', x):
x = -m.inf
else:
raise
assert isinstance(x, int) or m.isinf(x), x
return super().__new__(cls, x)
def __str__(self):
if self.x == m.inf:
return ''
elif self.x == -m.inf:
return '-∞'
else:
return str(self.x)
def __int__(self):
assert not m.isinf(self.x)
return self.x
def __float__(self):
return float(self.x)
none = '%7s' % '-'
def table(self):
return '%7s' % (self,)
diff_none = '%7s' % '-'
diff_table = table
def diff_diff(self, other):
new = self.x if self else 0
old = other.x if other else 0
diff = new - old
if diff == +m.inf:
return '%7s' % '+∞'
elif diff == -m.inf:
return '%7s' % '-∞'
else:
return '%+7d' % diff
def ratio(self, other):
new = self.x if self else 0
old = other.x if other else 0
if m.isinf(new) and m.isinf(old):
return 0.0
elif m.isinf(new):
return +m.inf
elif m.isinf(old):
return -m.inf
elif not old and not new:
return 0.0
elif not old:
return 1.0
else:
return (new-old) / old
def __add__(self, other):
return IntField(self.x + other.x)
def __sub__(self, other):
return IntField(self.x - other.x)
def __mul__(self, other):
return IntField(self.x * other.x)
def __lt__(self, other):
return self.x < other.x
def __gt__(self, other):
return self.__class__.__lt__(other, self)
def __le__(self, other):
return not self.__gt__(other)
def __ge__(self, other):
return not self.__lt__(other)
# size results
class StackResult(co.namedtuple('StackResult',
'file,function,stack_frame,stack_limit')):
__slots__ = ()
def __new__(cls, file, function, stack_frame, stack_limit):
return super().__new__(cls, file, function,
IntField(stack_frame), IntField(stack_limit))
def __add__(self, other):
return StackResult(self.file, self.function,
self.stack_frame + other.stack_frame,
max(self.stack_limit, other.stack_limit))
def openio(path, mode='r'):
if path == '-':
if mode == 'r':
return os.fdopen(os.dup(sys.stdin.fileno()), 'r')
else:
return os.fdopen(os.dup(sys.stdout.fileno()), 'w')
else:
return open(path, mode)
def collect(paths, *,
everything=False,
**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
callgraph = 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 'static' not in type
and 'bounded' not in type):
print('warning: found non-static stack for %s (%s)'
% (function, type, size))
_, _, _, targets = callgraph[info['title']]
callgraph[info['title']] = (
file, function, int(size), targets)
elif k == 'edge':
info = dict(info)
_, _, _, targets = callgraph[info['sourcename']]
targets.add(info['targetname'])
else:
continue
if not everything:
for source, (s_file, s_function, _, _) in list(callgraph.items()):
# discard internal functions
if s_file.startswith('<') or s_file.startswith('/usr/include'):
del callgraph[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 callgraph:
return 0
_, _, frame, targets = callgraph[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_calls(targets):
calls = set()
for target in targets:
if target in callgraph:
t_file, t_function, _, _ = callgraph[target]
calls.add((t_file, t_function))
return calls
# build results
results = []
calls = {}
for source, (s_file, s_function, frame, targets) in callgraph.items():
limit = find_limit(source)
cs = find_calls(targets)
results.append(StackResult(s_file, s_function, frame, limit))
calls[(s_file, s_function)] = cs
return results, calls
def fold(results, *,
by=['file', 'function'],
**_):
folding = co.OrderedDict()
for r in results:
name = tuple(getattr(r, k) for k in by)
if name not in folding:
folding[name] = []
folding[name].append(r)
folded = []
for rs in folding.values():
folded.append(sum(rs[1:], start=rs[0]))
return folded
def fold_calls(calls, *,
by=['file', 'function'],
**_):
def by_(name):
file, function = name
return (((file,) if 'file' in by else ())
+ ((function,) if 'function' in by else ()))
folded = {}
for name, cs in calls.items():
name = by_(name)
if name not in folded:
folded[name] = set()
folded[name] |= {by_(c) for c in cs}
return folded
def table(results, calls, diff_results=None, *,
by_file=False,
limit_sort=False,
reverse_limit_sort=False,
frame_sort=False,
reverse_frame_sort=False,
summary=False,
all=False,
percent=False,
tree=False,
depth=None,
**_):
all_, all = all, __builtins__.all
# tree doesn't really make sense with depth=0, assume depth=inf
if depth is None:
depth = float('inf') if tree else 0
# fold
results = fold(results, by=['file' if by_file else 'function'])
calls = fold_calls(calls, by=['file' if by_file else 'function'])
if diff_results is not None:
diff_results = fold(diff_results,
by=['file' if by_file else 'function'])
table = {
r.file if by_file else r.function: r
for r in results}
diff_table = {
r.file if by_file else r.function: r
for r in diff_results or []}
# sort, note that python's sort is stable
names = list(table.keys() | diff_table.keys())
names.sort()
if diff_results is not None:
names.sort(key=lambda n: -IntField.ratio(
table[n].stack_frame if n in table else None,
diff_table[n].stack_frame if n in diff_table else None))
if limit_sort:
names.sort(key=lambda n: (table[n].stack_limit,) if n in table else (),
reverse=True)
elif reverse_limit_sort:
names.sort(key=lambda n: (table[n].stack_limit,) if n in table else (),
reverse=False)
elif frame_sort:
names.sort(key=lambda n: (table[n].stack_frame,) if n in table else (),
reverse=True)
elif reverse_frame_sort:
names.sort(key=lambda n: (table[n].stack_frame,) if n in table else (),
reverse=False)
# print header
if not summary:
title = '%s%s' % (
'file' if by_file else 'function',
' (%d added, %d removed)' % (
sum(1 for n in table if n not in diff_table),
sum(1 for n in diff_table if n not in table))
if diff_results is not None and not percent else '')
name_width = max(it.chain([23, len(title)], (len(n) for n in names)))
else:
title = ''
name_width = 23
name_width = 4*((name_width+1+4-1)//4)-1
# adjust the name width based on the expected call depth, note that we
# can't always find the depth due to recursion
if not m.isinf(depth):
name_width += 4*depth
if not tree:
print('%-*s ' % (name_width, title), end='')
if diff_results is None:
print(' %s %s' % (
'frame'.rjust(len(IntField.none)),
'limit'.rjust(len(IntField.none))))
elif percent:
print(' %s %s' % (
'frame'.rjust(len(IntField.diff_none)),
'limit'.rjust(len(IntField.diff_none))))
else:
print(' %s %s %s %s %s %s' % (
'oframe'.rjust(len(IntField.diff_none)),
'olimit'.rjust(len(IntField.diff_none)),
'nframe'.rjust(len(IntField.diff_none)),
'nlimit'.rjust(len(IntField.diff_none)),
'dframe'.rjust(len(IntField.diff_none)),
'dlimit'.rjust(len(IntField.diff_none))))
# print entries
if not summary:
# print the tree recursively
def table_calls(names_, depth,
prefixes=('', '', '', '')):
for i, name in enumerate(names_):
r = table.get(name)
if diff_results is not None:
diff_r = diff_table.get(name)
ratio = IntField.ratio(
r.stack_limit if r else None,
diff_r.stack_limit if diff_r else None)
if not ratio and not all_:
continue
is_last = (i == len(names_)-1)
print('%-*s ' % (name_width, prefixes[0+is_last]+name), end='')
if tree:
print()
elif diff_results is None:
print(' %s %s' % (
r.stack_frame.table()
if r else IntField.none,
r.stack_limit.table()
if r else IntField.none))
elif percent:
print(' %s %s%s' % (
r.stack_frame.diff_table()
if r else IntField.diff_none,
r.stack_limit.diff_table()
if r else IntField.diff_none,
' (%s)' % (
'+∞%' if ratio == +m.inf
else '-∞%' if ratio == -m.inf
else '%+.1f%%' % (100*ratio))))
else:
print(' %s %s %s %s %s %s%s' % (
diff_r.stack_frame.diff_table()
if diff_r else IntField.diff_none,
diff_r.stack_limit.diff_table()
if diff_r else IntField.diff_none,
r.stack_frame.diff_table()
if r else IntField.diff_none,
r.stack_limit.diff_table()
if r else IntField.diff_none,
IntField.diff_diff(
r.stack_frame if r else None,
diff_r.stack_frame if diff_r else None)
if r or diff_r else IntField.diff_none,
IntField.diff_diff(
r.stack_limit if r else None,
diff_r.stack_limit if diff_r else None)
if r or diff_r else IntField.diff_none,
' (%s)' % (
'+∞%' if ratio == +m.inf
else '-∞%' if ratio == -m.inf
else '%+.1f%%' % (100*ratio))
if ratio else ''))
# recurse?
if depth > 0:
cs = calls.get((name,), set())
table_calls(
[n for n in names if (n,) in cs],
depth-1,
( prefixes[2+is_last] + "|-> ",
prefixes[2+is_last] + "'-> ",
prefixes[2+is_last] + "| ",
prefixes[2+is_last] + " "))
table_calls(names, depth)
# print total
if not tree:
total = fold(results, by=[])
r = total[0] if total else None
if diff_results is not None:
diff_total = fold(diff_results, by=[])
diff_r = diff_total[0] if diff_total else None
ratio = IntField.ratio(
r.stack_limit if r else None,
diff_r.stack_limit if diff_r else None)
print('%-*s ' % (name_width, 'TOTAL'), end='')
if diff_results is None:
print(' %s %s' % (
r.stack_frame.table()
if r else IntField.none,
r.stack_limit.table()
if r else IntField.none))
elif percent:
print(' %s %s%s' % (
r.stack_frame.diff_table()
if r else IntField.diff_none,
r.stack_limit.diff_table()
if r else IntField.diff_none,
' (%s)' % (
'+∞%' if ratio == +m.inf
else '-∞%' if ratio == -m.inf
else '%+.1f%%' % (100*ratio))))
else:
print(' %s %s %s %s %s %s%s' % (
diff_r.stack_frame.diff_table()
if diff_r else IntField.diff_none,
diff_r.stack_limit.diff_table()
if diff_r else IntField.diff_none,
r.stack_frame.diff_table()
if r else IntField.diff_none,
r.stack_limit.diff_table()
if r else IntField.diff_none,
IntField.diff_diff(
r.stack_frame if r else None,
diff_r.stack_frame if diff_r else None)
if r or diff_r else IntField.diff_none,
IntField.diff_diff(
r.stack_limit if r else None,
diff_r.stack_limit if diff_r else None)
if r or diff_r else IntField.diff_none,
' (%s)' % (
'+∞%' if ratio == +m.inf
else '-∞%' if ratio == -m.inf
else '%+.1f%%' % (100*ratio))
if ratio else ''))
def main(ci_paths, **args):
# find sizes
if not args.get('use', None):
# find .ci files
paths = []
for path in 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?' % ci_paths)
sys.exit(-1)
results, calls = collect(paths, **args)
else:
results = []
with openio(args['use']) as f:
reader = csv.DictReader(f, restval='')
for r in reader:
try:
results.append(StackResult(**{
k: v for k, v in r.items()
if k in StackResult._fields}))
except TypeError:
pass
calls = {}
# fold to remove duplicates
results = fold(results)
# sort because why not
results.sort()
# write results to CSV
if args.get('output'):
with openio(args['output'], 'w') as f:
writer = csv.DictWriter(f, StackResult._fields)
writer.writeheader()
for r in results:
writer.writerow(r._asdict())
# find previous results?
if args.get('diff'):
diff_results = []
try:
with openio(args['diff']) as f:
reader = csv.DictReader(f, restval='')
for r in reader:
try:
diff_results.append(StackResult(**{
k: v for k, v in r.items()
if k in StackResult._fields}))
except TypeError:
pass
except FileNotFoundError:
pass
# fold to remove duplicates
diff_results = fold(diff_results)
# print table
if not args.get('quiet'):
table(
results,
calls,
diff_results if args.get('diff') else None,
**args)
# error on recursion
if args.get('error_on_recursion') and any(
m.isinf(float(r.stack_limit)) for r in results):
sys.exit(2)
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 anything, use this CSV file.")
parser.add_argument(
'-d', '--diff',
help="Specify CSV file to diff against.")
parser.add_argument(
'-a', '--all',
action='store_true',
help="Show all, not just the ones that changed.")
parser.add_argument(
'-p', '--percent',
action='store_true',
help="Only show percentage change, not a full diff.")
parser.add_argument(
'-t', '--tree',
action='store_true',
help="Only show the function call tree.")
parser.add_argument(
'-b', '--by-file',
action='store_true',
help="Group by file.")
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.")
parser.add_argument(
'--reverse-frame-sort',
action='store_true',
help="Sort by stack frame, but backwards.")
parser.add_argument(
'-Y', '--summary',
action='store_true',
help="Only show the total size.")
parser.add_argument(
'-L', '--depth',
nargs='?',
type=lambda x: int(x, 0),
const=float('inf'),
help="Depth of function calls to show.")
parser.add_argument(
'-e', '--error-on-recursion',
action='store_true',
help="Error if any functions are recursive.")
parser.add_argument(
'-A', '--everything',
action='store_true',
help="Include builtin and libc specific symbols.")
parser.add_argument(
'--build-dir',
help="Specify the relative build directory. Used to map object files "
"to the correct source files.")
sys.exit(main(**{k: v
for k, v in vars(parser.parse_intermixed_args()).items()
if v is not None}))