stack.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658
  1. #!/usr/bin/env python3
  2. #
  3. # Script to find stack usage at the function level. Will detect recursion and
  4. # report as infinite stack usage.
  5. #
  6. # Example:
  7. # ./scripts/stack.py lfs.ci lfs_util.ci -S
  8. #
  9. # Copyright (c) 2022, The littlefs authors.
  10. # SPDX-License-Identifier: BSD-3-Clause
  11. #
  12. import collections as co
  13. import csv
  14. import glob
  15. import itertools as it
  16. import math as m
  17. import os
  18. import re
  19. CI_PATHS = ['*.ci']
  20. # integer fields
  21. class IntField(co.namedtuple('IntField', 'x')):
  22. __slots__ = ()
  23. def __new__(cls, x=0):
  24. if isinstance(x, IntField):
  25. return x
  26. if isinstance(x, str):
  27. try:
  28. x = int(x, 0)
  29. except ValueError:
  30. # also accept +-∞ and +-inf
  31. if re.match('^\s*\+?\s*(?:∞|inf)\s*$', x):
  32. x = m.inf
  33. elif re.match('^\s*-\s*(?:∞|inf)\s*$', x):
  34. x = -m.inf
  35. else:
  36. raise
  37. assert isinstance(x, int) or m.isinf(x), x
  38. return super().__new__(cls, x)
  39. def __str__(self):
  40. if self.x == m.inf:
  41. return '∞'
  42. elif self.x == -m.inf:
  43. return '-∞'
  44. else:
  45. return str(self.x)
  46. def __int__(self):
  47. assert not m.isinf(self.x)
  48. return self.x
  49. def __float__(self):
  50. return float(self.x)
  51. none = '%7s' % '-'
  52. def table(self):
  53. return '%7s' % (self,)
  54. diff_none = '%7s' % '-'
  55. diff_table = table
  56. def diff_diff(self, other):
  57. new = self.x if self else 0
  58. old = other.x if other else 0
  59. diff = new - old
  60. if diff == +m.inf:
  61. return '%7s' % '+∞'
  62. elif diff == -m.inf:
  63. return '%7s' % '-∞'
  64. else:
  65. return '%+7d' % diff
  66. def ratio(self, other):
  67. new = self.x if self else 0
  68. old = other.x if other else 0
  69. if m.isinf(new) and m.isinf(old):
  70. return 0.0
  71. elif m.isinf(new):
  72. return +m.inf
  73. elif m.isinf(old):
  74. return -m.inf
  75. elif not old and not new:
  76. return 0.0
  77. elif not old:
  78. return 1.0
  79. else:
  80. return (new-old) / old
  81. def __add__(self, other):
  82. return IntField(self.x + other.x)
  83. def __sub__(self, other):
  84. return IntField(self.x - other.x)
  85. def __mul__(self, other):
  86. return IntField(self.x * other.x)
  87. def __lt__(self, other):
  88. return self.x < other.x
  89. def __gt__(self, other):
  90. return self.__class__.__lt__(other, self)
  91. def __le__(self, other):
  92. return not self.__gt__(other)
  93. def __ge__(self, other):
  94. return not self.__lt__(other)
  95. # size results
  96. class StackResult(co.namedtuple('StackResult',
  97. 'file,function,stack_frame,stack_limit')):
  98. __slots__ = ()
  99. def __new__(cls, file, function, stack_frame, stack_limit):
  100. return super().__new__(cls, file, function,
  101. IntField(stack_frame), IntField(stack_limit))
  102. def __add__(self, other):
  103. return StackResult(self.file, self.function,
  104. self.stack_frame + other.stack_frame,
  105. max(self.stack_limit, other.stack_limit))
  106. def openio(path, mode='r'):
  107. if path == '-':
  108. if mode == 'r':
  109. return os.fdopen(os.dup(sys.stdin.fileno()), 'r')
  110. else:
  111. return os.fdopen(os.dup(sys.stdout.fileno()), 'w')
  112. else:
  113. return open(path, mode)
  114. def collect(paths, *,
  115. everything=False,
  116. **args):
  117. # parse the vcg format
  118. k_pattern = re.compile('([a-z]+)\s*:', re.DOTALL)
  119. v_pattern = re.compile('(?:"(.*?)"|([a-z]+))', re.DOTALL)
  120. def parse_vcg(rest):
  121. def parse_vcg(rest):
  122. node = []
  123. while True:
  124. rest = rest.lstrip()
  125. m = k_pattern.match(rest)
  126. if not m:
  127. return (node, rest)
  128. k, rest = m.group(1), rest[m.end(0):]
  129. rest = rest.lstrip()
  130. if rest.startswith('{'):
  131. v, rest = parse_vcg(rest[1:])
  132. assert rest[0] == '}', "unexpected %r" % rest[0:1]
  133. rest = rest[1:]
  134. node.append((k, v))
  135. else:
  136. m = v_pattern.match(rest)
  137. assert m, "unexpected %r" % rest[0:1]
  138. v, rest = m.group(1) or m.group(2), rest[m.end(0):]
  139. node.append((k, v))
  140. node, rest = parse_vcg(rest)
  141. assert rest == '', "unexpected %r" % rest[0:1]
  142. return node
  143. # collect into functions
  144. callgraph = co.defaultdict(lambda: (None, None, 0, set()))
  145. f_pattern = re.compile(
  146. r'([^\\]*)\\n([^:]*)[^\\]*\\n([0-9]+) bytes \((.*)\)')
  147. for path in paths:
  148. with open(path) as f:
  149. vcg = parse_vcg(f.read())
  150. for k, graph in vcg:
  151. if k != 'graph':
  152. continue
  153. for k, info in graph:
  154. if k == 'node':
  155. info = dict(info)
  156. m = f_pattern.match(info['label'])
  157. if m:
  158. function, file, size, type = m.groups()
  159. if (not args.get('quiet')
  160. and 'static' not in type
  161. and 'bounded' not in type):
  162. print('warning: found non-static stack for %s (%s)'
  163. % (function, type, size))
  164. _, _, _, targets = callgraph[info['title']]
  165. callgraph[info['title']] = (
  166. file, function, int(size), targets)
  167. elif k == 'edge':
  168. info = dict(info)
  169. _, _, _, targets = callgraph[info['sourcename']]
  170. targets.add(info['targetname'])
  171. else:
  172. continue
  173. if not everything:
  174. for source, (s_file, s_function, _, _) in list(callgraph.items()):
  175. # discard internal functions
  176. if s_file.startswith('<') or s_file.startswith('/usr/include'):
  177. del callgraph[source]
  178. # find maximum stack size recursively, this requires also detecting cycles
  179. # (in case of recursion)
  180. def find_limit(source, seen=None):
  181. seen = seen or set()
  182. if source not in callgraph:
  183. return 0
  184. _, _, frame, targets = callgraph[source]
  185. limit = 0
  186. for target in targets:
  187. if target in seen:
  188. # found a cycle
  189. return float('inf')
  190. limit_ = find_limit(target, seen | {target})
  191. limit = max(limit, limit_)
  192. return frame + limit
  193. def find_calls(targets):
  194. calls = set()
  195. for target in targets:
  196. if target in callgraph:
  197. t_file, t_function, _, _ = callgraph[target]
  198. calls.add((t_file, t_function))
  199. return calls
  200. # build results
  201. results = []
  202. calls = {}
  203. for source, (s_file, s_function, frame, targets) in callgraph.items():
  204. limit = find_limit(source)
  205. cs = find_calls(targets)
  206. results.append(StackResult(s_file, s_function, frame, limit))
  207. calls[(s_file, s_function)] = cs
  208. return results, calls
  209. def fold(results, *,
  210. by=['file', 'function'],
  211. **_):
  212. folding = co.OrderedDict()
  213. for r in results:
  214. name = tuple(getattr(r, k) for k in by)
  215. if name not in folding:
  216. folding[name] = []
  217. folding[name].append(r)
  218. folded = []
  219. for rs in folding.values():
  220. folded.append(sum(rs[1:], start=rs[0]))
  221. return folded
  222. def fold_calls(calls, *,
  223. by=['file', 'function'],
  224. **_):
  225. def by_(name):
  226. file, function = name
  227. return (((file,) if 'file' in by else ())
  228. + ((function,) if 'function' in by else ()))
  229. folded = {}
  230. for name, cs in calls.items():
  231. name = by_(name)
  232. if name not in folded:
  233. folded[name] = set()
  234. folded[name] |= {by_(c) for c in cs}
  235. return folded
  236. def table(results, calls, diff_results=None, *,
  237. by_file=False,
  238. limit_sort=False,
  239. reverse_limit_sort=False,
  240. frame_sort=False,
  241. reverse_frame_sort=False,
  242. summary=False,
  243. all=False,
  244. percent=False,
  245. tree=False,
  246. depth=None,
  247. **_):
  248. all_, all = all, __builtins__.all
  249. # tree doesn't really make sense with depth=0, assume depth=inf
  250. if depth is None:
  251. depth = float('inf') if tree else 0
  252. # fold
  253. results = fold(results, by=['file' if by_file else 'function'])
  254. calls = fold_calls(calls, by=['file' if by_file else 'function'])
  255. if diff_results is not None:
  256. diff_results = fold(diff_results,
  257. by=['file' if by_file else 'function'])
  258. table = {
  259. r.file if by_file else r.function: r
  260. for r in results}
  261. diff_table = {
  262. r.file if by_file else r.function: r
  263. for r in diff_results or []}
  264. # sort, note that python's sort is stable
  265. names = list(table.keys() | diff_table.keys())
  266. names.sort()
  267. if diff_results is not None:
  268. names.sort(key=lambda n: -IntField.ratio(
  269. table[n].stack_frame if n in table else None,
  270. diff_table[n].stack_frame if n in diff_table else None))
  271. if limit_sort:
  272. names.sort(key=lambda n: (table[n].stack_limit,) if n in table else (),
  273. reverse=True)
  274. elif reverse_limit_sort:
  275. names.sort(key=lambda n: (table[n].stack_limit,) if n in table else (),
  276. reverse=False)
  277. elif frame_sort:
  278. names.sort(key=lambda n: (table[n].stack_frame,) if n in table else (),
  279. reverse=True)
  280. elif reverse_frame_sort:
  281. names.sort(key=lambda n: (table[n].stack_frame,) if n in table else (),
  282. reverse=False)
  283. # print header
  284. if not summary:
  285. title = '%s%s' % (
  286. 'file' if by_file else 'function',
  287. ' (%d added, %d removed)' % (
  288. sum(1 for n in table if n not in diff_table),
  289. sum(1 for n in diff_table if n not in table))
  290. if diff_results is not None and not percent else '')
  291. name_width = max(it.chain([23, len(title)], (len(n) for n in names)))
  292. else:
  293. title = ''
  294. name_width = 23
  295. name_width = 4*((name_width+1+4-1)//4)-1
  296. # adjust the name width based on the expected call depth, note that we
  297. # can't always find the depth due to recursion
  298. if not m.isinf(depth):
  299. name_width += 4*depth
  300. if not tree:
  301. print('%-*s ' % (name_width, title), end='')
  302. if diff_results is None:
  303. print(' %s %s' % (
  304. 'frame'.rjust(len(IntField.none)),
  305. 'limit'.rjust(len(IntField.none))))
  306. elif percent:
  307. print(' %s %s' % (
  308. 'frame'.rjust(len(IntField.diff_none)),
  309. 'limit'.rjust(len(IntField.diff_none))))
  310. else:
  311. print(' %s %s %s %s %s %s' % (
  312. 'oframe'.rjust(len(IntField.diff_none)),
  313. 'olimit'.rjust(len(IntField.diff_none)),
  314. 'nframe'.rjust(len(IntField.diff_none)),
  315. 'nlimit'.rjust(len(IntField.diff_none)),
  316. 'dframe'.rjust(len(IntField.diff_none)),
  317. 'dlimit'.rjust(len(IntField.diff_none))))
  318. # print entries
  319. if not summary:
  320. # print the tree recursively
  321. def table_calls(names_, depth,
  322. prefixes=('', '', '', '')):
  323. for i, name in enumerate(names_):
  324. r = table.get(name)
  325. if diff_results is not None:
  326. diff_r = diff_table.get(name)
  327. ratio = IntField.ratio(
  328. r.stack_limit if r else None,
  329. diff_r.stack_limit if diff_r else None)
  330. if not ratio and not all_:
  331. continue
  332. is_last = (i == len(names_)-1)
  333. print('%-*s ' % (name_width, prefixes[0+is_last]+name), end='')
  334. if tree:
  335. print()
  336. elif diff_results is None:
  337. print(' %s %s' % (
  338. r.stack_frame.table()
  339. if r else IntField.none,
  340. r.stack_limit.table()
  341. if r else IntField.none))
  342. elif percent:
  343. print(' %s %s%s' % (
  344. r.stack_frame.diff_table()
  345. if r else IntField.diff_none,
  346. r.stack_limit.diff_table()
  347. if r else IntField.diff_none,
  348. ' (%s)' % (
  349. '+∞%' if ratio == +m.inf
  350. else '-∞%' if ratio == -m.inf
  351. else '%+.1f%%' % (100*ratio))))
  352. else:
  353. print(' %s %s %s %s %s %s%s' % (
  354. diff_r.stack_frame.diff_table()
  355. if diff_r else IntField.diff_none,
  356. diff_r.stack_limit.diff_table()
  357. if diff_r else IntField.diff_none,
  358. r.stack_frame.diff_table()
  359. if r else IntField.diff_none,
  360. r.stack_limit.diff_table()
  361. if r else IntField.diff_none,
  362. IntField.diff_diff(
  363. r.stack_frame if r else None,
  364. diff_r.stack_frame if diff_r else None)
  365. if r or diff_r else IntField.diff_none,
  366. IntField.diff_diff(
  367. r.stack_limit if r else None,
  368. diff_r.stack_limit if diff_r else None)
  369. if r or diff_r else IntField.diff_none,
  370. ' (%s)' % (
  371. '+∞%' if ratio == +m.inf
  372. else '-∞%' if ratio == -m.inf
  373. else '%+.1f%%' % (100*ratio))
  374. if ratio else ''))
  375. # recurse?
  376. if depth > 0:
  377. cs = calls.get((name,), set())
  378. table_calls(
  379. [n for n in names if (n,) in cs],
  380. depth-1,
  381. ( prefixes[2+is_last] + "|-> ",
  382. prefixes[2+is_last] + "'-> ",
  383. prefixes[2+is_last] + "| ",
  384. prefixes[2+is_last] + " "))
  385. table_calls(names, depth)
  386. # print total
  387. if not tree:
  388. total = fold(results, by=[])
  389. r = total[0] if total else None
  390. if diff_results is not None:
  391. diff_total = fold(diff_results, by=[])
  392. diff_r = diff_total[0] if diff_total else None
  393. ratio = IntField.ratio(
  394. r.stack_limit if r else None,
  395. diff_r.stack_limit if diff_r else None)
  396. print('%-*s ' % (name_width, 'TOTAL'), end='')
  397. if diff_results is None:
  398. print(' %s %s' % (
  399. r.stack_frame.table()
  400. if r else IntField.none,
  401. r.stack_limit.table()
  402. if r else IntField.none))
  403. elif percent:
  404. print(' %s %s%s' % (
  405. r.stack_frame.diff_table()
  406. if r else IntField.diff_none,
  407. r.stack_limit.diff_table()
  408. if r else IntField.diff_none,
  409. ' (%s)' % (
  410. '+∞%' if ratio == +m.inf
  411. else '-∞%' if ratio == -m.inf
  412. else '%+.1f%%' % (100*ratio))))
  413. else:
  414. print(' %s %s %s %s %s %s%s' % (
  415. diff_r.stack_frame.diff_table()
  416. if diff_r else IntField.diff_none,
  417. diff_r.stack_limit.diff_table()
  418. if diff_r else IntField.diff_none,
  419. r.stack_frame.diff_table()
  420. if r else IntField.diff_none,
  421. r.stack_limit.diff_table()
  422. if r else IntField.diff_none,
  423. IntField.diff_diff(
  424. r.stack_frame if r else None,
  425. diff_r.stack_frame if diff_r else None)
  426. if r or diff_r else IntField.diff_none,
  427. IntField.diff_diff(
  428. r.stack_limit if r else None,
  429. diff_r.stack_limit if diff_r else None)
  430. if r or diff_r else IntField.diff_none,
  431. ' (%s)' % (
  432. '+∞%' if ratio == +m.inf
  433. else '-∞%' if ratio == -m.inf
  434. else '%+.1f%%' % (100*ratio))
  435. if ratio else ''))
  436. def main(ci_paths, **args):
  437. # find sizes
  438. if not args.get('use', None):
  439. # find .ci files
  440. paths = []
  441. for path in ci_paths:
  442. if os.path.isdir(path):
  443. path = path + '/*.ci'
  444. for path in glob.glob(path):
  445. paths.append(path)
  446. if not paths:
  447. print('no .ci files found in %r?' % ci_paths)
  448. sys.exit(-1)
  449. results, calls = collect(paths, **args)
  450. else:
  451. results = []
  452. with openio(args['use']) as f:
  453. reader = csv.DictReader(f, restval='')
  454. for r in reader:
  455. try:
  456. results.append(StackResult(**{
  457. k: v for k, v in r.items()
  458. if k in StackResult._fields}))
  459. except TypeError:
  460. pass
  461. calls = {}
  462. # fold to remove duplicates
  463. results = fold(results)
  464. # sort because why not
  465. results.sort()
  466. # write results to CSV
  467. if args.get('output'):
  468. with openio(args['output'], 'w') as f:
  469. writer = csv.DictWriter(f, StackResult._fields)
  470. writer.writeheader()
  471. for r in results:
  472. writer.writerow(r._asdict())
  473. # find previous results?
  474. if args.get('diff'):
  475. diff_results = []
  476. try:
  477. with openio(args['diff']) as f:
  478. reader = csv.DictReader(f, restval='')
  479. for r in reader:
  480. try:
  481. diff_results.append(StackResult(**{
  482. k: v for k, v in r.items()
  483. if k in StackResult._fields}))
  484. except TypeError:
  485. pass
  486. except FileNotFoundError:
  487. pass
  488. # fold to remove duplicates
  489. diff_results = fold(diff_results)
  490. # print table
  491. if not args.get('quiet'):
  492. table(
  493. results,
  494. calls,
  495. diff_results if args.get('diff') else None,
  496. **args)
  497. # error on recursion
  498. if args.get('error_on_recursion') and any(
  499. m.isinf(float(r.stack_limit)) for r in results):
  500. sys.exit(2)
  501. if __name__ == "__main__":
  502. import argparse
  503. import sys
  504. parser = argparse.ArgumentParser(
  505. description="Find stack usage at the function level.")
  506. parser.add_argument(
  507. 'ci_paths',
  508. nargs='*',
  509. default=CI_PATHS,
  510. help="Description of where to find *.ci files. May be a directory "
  511. "or a list of paths. Defaults to %r." % CI_PATHS)
  512. parser.add_argument(
  513. '-v', '--verbose',
  514. action='store_true',
  515. help="Output commands that run behind the scenes.")
  516. parser.add_argument(
  517. '-q', '--quiet',
  518. action='store_true',
  519. help="Don't show anything, useful with -o.")
  520. parser.add_argument(
  521. '-o', '--output',
  522. help="Specify CSV file to store results.")
  523. parser.add_argument(
  524. '-u', '--use',
  525. help="Don't parse anything, use this CSV file.")
  526. parser.add_argument(
  527. '-d', '--diff',
  528. help="Specify CSV file to diff against.")
  529. parser.add_argument(
  530. '-a', '--all',
  531. action='store_true',
  532. help="Show all, not just the ones that changed.")
  533. parser.add_argument(
  534. '-p', '--percent',
  535. action='store_true',
  536. help="Only show percentage change, not a full diff.")
  537. parser.add_argument(
  538. '-t', '--tree',
  539. action='store_true',
  540. help="Only show the function call tree.")
  541. parser.add_argument(
  542. '-b', '--by-file',
  543. action='store_true',
  544. help="Group by file.")
  545. parser.add_argument(
  546. '-s', '--limit-sort',
  547. action='store_true',
  548. help="Sort by stack limit.")
  549. parser.add_argument(
  550. '-S', '--reverse-limit-sort',
  551. action='store_true',
  552. help="Sort by stack limit, but backwards.")
  553. parser.add_argument(
  554. '--frame-sort',
  555. action='store_true',
  556. help="Sort by stack frame.")
  557. parser.add_argument(
  558. '--reverse-frame-sort',
  559. action='store_true',
  560. help="Sort by stack frame, but backwards.")
  561. parser.add_argument(
  562. '-Y', '--summary',
  563. action='store_true',
  564. help="Only show the total size.")
  565. parser.add_argument(
  566. '-L', '--depth',
  567. nargs='?',
  568. type=lambda x: int(x, 0),
  569. const=float('inf'),
  570. help="Depth of function calls to show.")
  571. parser.add_argument(
  572. '-e', '--error-on-recursion',
  573. action='store_true',
  574. help="Error if any functions are recursive.")
  575. parser.add_argument(
  576. '-A', '--everything',
  577. action='store_true',
  578. help="Include builtin and libc specific symbols.")
  579. parser.add_argument(
  580. '--build-dir',
  581. help="Specify the relative build directory. Used to map object files "
  582. "to the correct source files.")
  583. sys.exit(main(**{k: v
  584. for k, v in vars(parser.parse_intermixed_args()).items()
  585. if v is not None}))