stack.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719
  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 -Slimit
  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 itertools as it
  15. import math as m
  16. import os
  17. import re
  18. # integer fields
  19. class Int(co.namedtuple('Int', 'x')):
  20. __slots__ = ()
  21. def __new__(cls, x=0):
  22. if isinstance(x, Int):
  23. return x
  24. if isinstance(x, str):
  25. try:
  26. x = int(x, 0)
  27. except ValueError:
  28. # also accept +-∞ and +-inf
  29. if re.match('^\s*\+?\s*(?:∞|inf)\s*$', x):
  30. x = m.inf
  31. elif re.match('^\s*-\s*(?:∞|inf)\s*$', x):
  32. x = -m.inf
  33. else:
  34. raise
  35. assert isinstance(x, int) or m.isinf(x), x
  36. return super().__new__(cls, x)
  37. def __str__(self):
  38. if self.x == m.inf:
  39. return '∞'
  40. elif self.x == -m.inf:
  41. return '-∞'
  42. else:
  43. return str(self.x)
  44. def __int__(self):
  45. assert not m.isinf(self.x)
  46. return self.x
  47. def __float__(self):
  48. return float(self.x)
  49. none = '%7s' % '-'
  50. def table(self):
  51. return '%7s' % (self,)
  52. diff_none = '%7s' % '-'
  53. diff_table = table
  54. def diff_diff(self, other):
  55. new = self.x if self else 0
  56. old = other.x if other else 0
  57. diff = new - old
  58. if diff == +m.inf:
  59. return '%7s' % '+∞'
  60. elif diff == -m.inf:
  61. return '%7s' % '-∞'
  62. else:
  63. return '%+7d' % diff
  64. def ratio(self, other):
  65. new = self.x if self else 0
  66. old = other.x if other else 0
  67. if m.isinf(new) and m.isinf(old):
  68. return 0.0
  69. elif m.isinf(new):
  70. return +m.inf
  71. elif m.isinf(old):
  72. return -m.inf
  73. elif not old and not new:
  74. return 0.0
  75. elif not old:
  76. return 1.0
  77. else:
  78. return (new-old) / old
  79. def __add__(self, other):
  80. return self.__class__(self.x + other.x)
  81. def __sub__(self, other):
  82. return self.__class__(self.x - other.x)
  83. def __mul__(self, other):
  84. return self.__class__(self.x * other.x)
  85. # size results
  86. class StackResult(co.namedtuple('StackResult', [
  87. 'file', 'function', 'frame', 'limit', 'children'])):
  88. _by = ['file', 'function']
  89. _fields = ['frame', 'limit']
  90. _types = {'frame': Int, 'limit': Int}
  91. __slots__ = ()
  92. def __new__(cls, file='', function='',
  93. frame=0, limit=0, children=set()):
  94. return super().__new__(cls, file, function,
  95. Int(frame), Int(limit),
  96. children)
  97. def __add__(self, other):
  98. return StackResult(self.file, self.function,
  99. self.frame + other.frame,
  100. max(self.limit, other.limit),
  101. self.children | other.children)
  102. def openio(path, mode='r', buffering=-1):
  103. # allow '-' for stdin/stdout
  104. if path == '-':
  105. if mode == 'r':
  106. return os.fdopen(os.dup(sys.stdin.fileno()), mode, buffering)
  107. else:
  108. return os.fdopen(os.dup(sys.stdout.fileno()), mode, buffering)
  109. else:
  110. return open(path, mode, buffering)
  111. def collect(ci_paths, *,
  112. sources=None,
  113. everything=False,
  114. **args):
  115. # parse the vcg format
  116. k_pattern = re.compile('([a-z]+)\s*:', re.DOTALL)
  117. v_pattern = re.compile('(?:"(.*?)"|([a-z]+))', re.DOTALL)
  118. def parse_vcg(rest):
  119. def parse_vcg(rest):
  120. node = []
  121. while True:
  122. rest = rest.lstrip()
  123. m_ = k_pattern.match(rest)
  124. if not m_:
  125. return (node, rest)
  126. k, rest = m_.group(1), rest[m_.end(0):]
  127. rest = rest.lstrip()
  128. if rest.startswith('{'):
  129. v, rest = parse_vcg(rest[1:])
  130. assert rest[0] == '}', "unexpected %r" % rest[0:1]
  131. rest = rest[1:]
  132. node.append((k, v))
  133. else:
  134. m_ = v_pattern.match(rest)
  135. assert m_, "unexpected %r" % rest[0:1]
  136. v, rest = m_.group(1) or m_.group(2), rest[m_.end(0):]
  137. node.append((k, v))
  138. node, rest = parse_vcg(rest)
  139. assert rest == '', "unexpected %r" % rest[0:1]
  140. return node
  141. # collect into functions
  142. callgraph = co.defaultdict(lambda: (None, None, 0, set()))
  143. f_pattern = re.compile(
  144. r'([^\\]*)\\n([^:]*)[^\\]*\\n([0-9]+) bytes \((.*)\)')
  145. for path in ci_paths:
  146. with open(path) as f:
  147. vcg = parse_vcg(f.read())
  148. for k, graph in vcg:
  149. if k != 'graph':
  150. continue
  151. for k, info in graph:
  152. if k == 'node':
  153. info = dict(info)
  154. m_ = f_pattern.match(info['label'])
  155. if m_:
  156. function, file, size, type = m_.groups()
  157. if (not args.get('quiet')
  158. and 'static' not in type
  159. and 'bounded' not in type):
  160. print("warning: "
  161. "found non-static stack for %s (%s, %s)" % (
  162. function, type, size))
  163. _, _, _, targets = callgraph[info['title']]
  164. callgraph[info['title']] = (
  165. file, function, int(size), targets)
  166. elif k == 'edge':
  167. info = dict(info)
  168. _, _, _, targets = callgraph[info['sourcename']]
  169. targets.add(info['targetname'])
  170. else:
  171. continue
  172. callgraph_ = co.defaultdict(lambda: (None, None, 0, set()))
  173. for source, (s_file, s_function, frame, targets) in callgraph.items():
  174. # discard internal functions
  175. if not everything and s_function.startswith('__'):
  176. continue
  177. # ignore filtered sources
  178. if sources is not None:
  179. if not any(
  180. os.path.abspath(s_file) == os.path.abspath(s)
  181. for s in sources):
  182. continue
  183. else:
  184. # default to only cwd
  185. if not everything and not os.path.commonpath([
  186. os.getcwd(),
  187. os.path.abspath(s_file)]) == os.getcwd():
  188. continue
  189. # smiplify path
  190. if os.path.commonpath([
  191. os.getcwd(),
  192. os.path.abspath(s_file)]) == os.getcwd():
  193. s_file = os.path.relpath(s_file)
  194. else:
  195. s_file = os.path.abspath(s_file)
  196. callgraph_[source] = (s_file, s_function, frame, targets)
  197. callgraph = callgraph_
  198. if not everything:
  199. callgraph_ = co.defaultdict(lambda: (None, None, 0, set()))
  200. for source, (s_file, s_function, frame, targets) in callgraph.items():
  201. # discard filtered sources
  202. if sources is not None and not any(
  203. os.path.abspath(s_file) == os.path.abspath(s)
  204. for s in sources):
  205. continue
  206. # discard internal functions
  207. if s_function.startswith('__'):
  208. continue
  209. callgraph_[source] = (s_file, s_function, frame, targets)
  210. callgraph = callgraph_
  211. # find maximum stack size recursively, this requires also detecting cycles
  212. # (in case of recursion)
  213. def find_limit(source, seen=None):
  214. seen = seen or set()
  215. if source not in callgraph:
  216. return 0
  217. _, _, frame, targets = callgraph[source]
  218. limit = 0
  219. for target in targets:
  220. if target in seen:
  221. # found a cycle
  222. return m.inf
  223. limit_ = find_limit(target, seen | {target})
  224. limit = max(limit, limit_)
  225. return frame + limit
  226. def find_children(targets):
  227. children = set()
  228. for target in targets:
  229. if target in callgraph:
  230. t_file, t_function, _, _ = callgraph[target]
  231. children.add((t_file, t_function))
  232. return children
  233. # build results
  234. results = []
  235. for source, (s_file, s_function, frame, targets) in callgraph.items():
  236. limit = find_limit(source)
  237. children = find_children(targets)
  238. results.append(StackResult(s_file, s_function, frame, limit, children))
  239. return results
  240. def fold(Result, results, *,
  241. by=None,
  242. defines=None,
  243. **_):
  244. if by is None:
  245. by = Result._by
  246. for k in it.chain(by or [], (k for k, _ in defines or [])):
  247. if k not in Result._by and k not in Result._fields:
  248. print("error: could not find field %r?" % k)
  249. sys.exit(-1)
  250. # filter by matching defines
  251. if defines is not None:
  252. results_ = []
  253. for r in results:
  254. if all(getattr(r, k) in vs for k, vs in defines):
  255. results_.append(r)
  256. results = results_
  257. # organize results into conflicts
  258. folding = co.OrderedDict()
  259. for r in results:
  260. name = tuple(getattr(r, k) for k in by)
  261. if name not in folding:
  262. folding[name] = []
  263. folding[name].append(r)
  264. # merge conflicts
  265. folded = []
  266. for name, rs in folding.items():
  267. folded.append(sum(rs[1:], start=rs[0]))
  268. return folded
  269. def table(Result, results, diff_results=None, *,
  270. by=None,
  271. fields=None,
  272. sort=None,
  273. summary=False,
  274. all=False,
  275. percent=False,
  276. tree=False,
  277. depth=1,
  278. **_):
  279. all_, all = all, __builtins__.all
  280. if by is None:
  281. by = Result._by
  282. if fields is None:
  283. fields = Result._fields
  284. types = Result._types
  285. # fold again
  286. results = fold(Result, results, by=by)
  287. if diff_results is not None:
  288. diff_results = fold(Result, diff_results, by=by)
  289. # organize by name
  290. table = {
  291. ','.join(str(getattr(r, k) or '') for k in by): r
  292. for r in results}
  293. diff_table = {
  294. ','.join(str(getattr(r, k) or '') for k in by): r
  295. for r in diff_results or []}
  296. names = list(table.keys() | diff_table.keys())
  297. # sort again, now with diff info, note that python's sort is stable
  298. names.sort()
  299. if diff_results is not None:
  300. names.sort(key=lambda n: tuple(
  301. types[k].ratio(
  302. getattr(table.get(n), k, None),
  303. getattr(diff_table.get(n), k, None))
  304. for k in fields),
  305. reverse=True)
  306. if sort:
  307. for k, reverse in reversed(sort):
  308. names.sort(key=lambda n: (getattr(table[n], k),)
  309. if getattr(table.get(n), k, None) is not None else (),
  310. reverse=reverse ^ (not k or k in Result._fields))
  311. # build up our lines
  312. lines = []
  313. # header
  314. header = []
  315. header.append('%s%s' % (
  316. ','.join(by),
  317. ' (%d added, %d removed)' % (
  318. sum(1 for n in table if n not in diff_table),
  319. sum(1 for n in diff_table if n not in table))
  320. if diff_results is not None and not percent else '')
  321. if not summary else '')
  322. if diff_results is None:
  323. for k in fields:
  324. header.append(k)
  325. elif percent:
  326. for k in fields:
  327. header.append(k)
  328. else:
  329. for k in fields:
  330. header.append('o'+k)
  331. for k in fields:
  332. header.append('n'+k)
  333. for k in fields:
  334. header.append('d'+k)
  335. header.append('')
  336. lines.append(header)
  337. def table_entry(name, r, diff_r=None, ratios=[]):
  338. entry = []
  339. entry.append(name)
  340. if diff_results is None:
  341. for k in fields:
  342. entry.append(getattr(r, k).table()
  343. if getattr(r, k, None) is not None
  344. else types[k].none)
  345. elif percent:
  346. for k in fields:
  347. entry.append(getattr(r, k).diff_table()
  348. if getattr(r, k, None) is not None
  349. else types[k].diff_none)
  350. else:
  351. for k in fields:
  352. entry.append(getattr(diff_r, k).diff_table()
  353. if getattr(diff_r, k, None) is not None
  354. else types[k].diff_none)
  355. for k in fields:
  356. entry.append(getattr(r, k).diff_table()
  357. if getattr(r, k, None) is not None
  358. else types[k].diff_none)
  359. for k in fields:
  360. entry.append(types[k].diff_diff(
  361. getattr(r, k, None),
  362. getattr(diff_r, k, None)))
  363. if diff_results is None:
  364. entry.append('')
  365. elif percent:
  366. entry.append(' (%s)' % ', '.join(
  367. '+∞%' if t == +m.inf
  368. else '-∞%' if t == -m.inf
  369. else '%+.1f%%' % (100*t)
  370. for t in ratios))
  371. else:
  372. entry.append(' (%s)' % ', '.join(
  373. '+∞%' if t == +m.inf
  374. else '-∞%' if t == -m.inf
  375. else '%+.1f%%' % (100*t)
  376. for t in ratios
  377. if t)
  378. if any(ratios) else '')
  379. return entry
  380. # entries
  381. if not summary:
  382. for name in names:
  383. r = table.get(name)
  384. if diff_results is None:
  385. diff_r = None
  386. ratios = None
  387. else:
  388. diff_r = diff_table.get(name)
  389. ratios = [
  390. types[k].ratio(
  391. getattr(r, k, None),
  392. getattr(diff_r, k, None))
  393. for k in fields]
  394. if not all_ and not any(ratios):
  395. continue
  396. lines.append(table_entry(name, r, diff_r, ratios))
  397. # total
  398. r = next(iter(fold(Result, results, by=[])), None)
  399. if diff_results is None:
  400. diff_r = None
  401. ratios = None
  402. else:
  403. diff_r = next(iter(fold(Result, diff_results, by=[])), None)
  404. ratios = [
  405. types[k].ratio(
  406. getattr(r, k, None),
  407. getattr(diff_r, k, None))
  408. for k in fields]
  409. lines.append(table_entry('TOTAL', r, diff_r, ratios))
  410. # find the best widths, note that column 0 contains the names and column -1
  411. # the ratios, so those are handled a bit differently
  412. widths = [
  413. ((max(it.chain([w], (len(l[i]) for l in lines)))+1+4-1)//4)*4-1
  414. for w, i in zip(
  415. it.chain([23], it.repeat(7)),
  416. range(len(lines[0])-1))]
  417. # adjust the name width based on the expected call depth, though
  418. # note this doesn't really work with unbounded recursion
  419. if not summary and not m.isinf(depth):
  420. widths[0] += 4*(depth-1)
  421. # print the tree recursively
  422. if not tree:
  423. print('%-*s %s%s' % (
  424. widths[0], lines[0][0],
  425. ' '.join('%*s' % (w, x)
  426. for w, x in zip(widths[1:], lines[0][1:-1])),
  427. lines[0][-1]))
  428. if not summary:
  429. line_table = {n: l for n, l in zip(names, lines[1:-1])}
  430. def recurse(names_, depth_, prefixes=('', '', '', '')):
  431. for i, name in enumerate(names_):
  432. if name not in line_table:
  433. continue
  434. line = line_table[name]
  435. is_last = (i == len(names_)-1)
  436. print('%s%-*s ' % (
  437. prefixes[0+is_last],
  438. widths[0] - (
  439. len(prefixes[0+is_last])
  440. if not m.isinf(depth) else 0),
  441. line[0]),
  442. end='')
  443. if not tree:
  444. print(' %s%s' % (
  445. ' '.join('%*s' % (w, x)
  446. for w, x in zip(widths[1:], line[1:-1])),
  447. line[-1]),
  448. end='')
  449. print()
  450. # recurse?
  451. if name in table and depth_ > 1:
  452. children = {
  453. ','.join(str(getattr(Result(*c), k) or '') for k in by)
  454. for c in table[name].children}
  455. recurse(
  456. # note we're maintaining sort order
  457. [n for n in names if n in children],
  458. depth_-1,
  459. (prefixes[2+is_last] + "|-> ",
  460. prefixes[2+is_last] + "'-> ",
  461. prefixes[2+is_last] + "| ",
  462. prefixes[2+is_last] + " "))
  463. recurse(names, depth)
  464. if not tree:
  465. print('%-*s %s%s' % (
  466. widths[0], lines[-1][0],
  467. ' '.join('%*s' % (w, x)
  468. for w, x in zip(widths[1:], lines[-1][1:-1])),
  469. lines[-1][-1]))
  470. def main(ci_paths,
  471. by=None,
  472. fields=None,
  473. defines=None,
  474. sort=None,
  475. **args):
  476. # it doesn't really make sense to not have a depth with tree,
  477. # so assume depth=inf if tree by default
  478. if args.get('depth') is None:
  479. args['depth'] = m.inf if args['tree'] else 1
  480. elif args.get('depth') == 0:
  481. args['depth'] = m.inf
  482. # find sizes
  483. if not args.get('use', None):
  484. results = collect(ci_paths, **args)
  485. else:
  486. results = []
  487. with openio(args['use']) as f:
  488. reader = csv.DictReader(f, restval='')
  489. for r in reader:
  490. try:
  491. results.append(StackResult(
  492. **{k: r[k] for k in StackResult._by
  493. if k in r and r[k].strip()},
  494. **{k: r['stack_'+k] for k in StackResult._fields
  495. if 'stack_'+k in r and r['stack_'+k].strip()}))
  496. except TypeError:
  497. pass
  498. # fold
  499. results = fold(StackResult, results, by=by, defines=defines)
  500. # sort, note that python's sort is stable
  501. results.sort()
  502. if sort:
  503. for k, reverse in reversed(sort):
  504. results.sort(key=lambda r: (getattr(r, k),)
  505. if getattr(r, k) is not None else (),
  506. reverse=reverse ^ (not k or k in StackResult._fields))
  507. # write results to CSV
  508. if args.get('output'):
  509. with openio(args['output'], 'w') as f:
  510. writer = csv.DictWriter(f,
  511. (by if by is not None else StackResult._by)
  512. + ['stack_'+k for k in StackResult._fields])
  513. writer.writeheader()
  514. for r in results:
  515. writer.writerow(
  516. {k: getattr(r, k)
  517. for k in (by if by is not None else StackResult._by)}
  518. | {'stack_'+k: getattr(r, k)
  519. for k in StackResult._fields})
  520. # find previous results?
  521. if args.get('diff'):
  522. diff_results = []
  523. try:
  524. with openio(args['diff']) as f:
  525. reader = csv.DictReader(f, restval='')
  526. for r in reader:
  527. try:
  528. diff_results.append(StackResult(
  529. **{k: r[k] for k in StackResult._by
  530. if k in r and r[k].strip()},
  531. **{k: r['stack_'+k] for k in StackResult._fields
  532. if 'stack_'+k in r and r['stack_'+k].strip()}))
  533. except TypeError:
  534. raise
  535. except FileNotFoundError:
  536. pass
  537. # fold
  538. diff_results = fold(StackResult, diff_results, by=by, defines=defines)
  539. # print table
  540. if not args.get('quiet'):
  541. table(StackResult, results,
  542. diff_results if args.get('diff') else None,
  543. by=by if by is not None else ['function'],
  544. fields=fields,
  545. sort=sort,
  546. **args)
  547. # error on recursion
  548. if args.get('error_on_recursion') and any(
  549. m.isinf(float(r.limit)) for r in results):
  550. sys.exit(2)
  551. if __name__ == "__main__":
  552. import argparse
  553. import sys
  554. parser = argparse.ArgumentParser(
  555. description="Find stack usage at the function level.",
  556. allow_abbrev=False)
  557. parser.add_argument(
  558. 'ci_paths',
  559. nargs='*',
  560. help="Input *.ci files.")
  561. parser.add_argument(
  562. '-v', '--verbose',
  563. action='store_true',
  564. help="Output commands that run behind the scenes.")
  565. parser.add_argument(
  566. '-q', '--quiet',
  567. action='store_true',
  568. help="Don't show anything, useful with -o.")
  569. parser.add_argument(
  570. '-o', '--output',
  571. help="Specify CSV file to store results.")
  572. parser.add_argument(
  573. '-u', '--use',
  574. help="Don't parse anything, use this CSV file.")
  575. parser.add_argument(
  576. '-d', '--diff',
  577. help="Specify CSV file to diff against.")
  578. parser.add_argument(
  579. '-a', '--all',
  580. action='store_true',
  581. help="Show all, not just the ones that changed.")
  582. parser.add_argument(
  583. '-p', '--percent',
  584. action='store_true',
  585. help="Only show percentage change, not a full diff.")
  586. parser.add_argument(
  587. '-b', '--by',
  588. action='append',
  589. choices=StackResult._by,
  590. help="Group by this field.")
  591. parser.add_argument(
  592. '-f', '--field',
  593. dest='fields',
  594. action='append',
  595. choices=StackResult._fields,
  596. help="Show this field.")
  597. parser.add_argument(
  598. '-D', '--define',
  599. dest='defines',
  600. action='append',
  601. type=lambda x: (lambda k,v: (k, set(v.split(','))))(*x.split('=', 1)),
  602. help="Only include results where this field is this value.")
  603. class AppendSort(argparse.Action):
  604. def __call__(self, parser, namespace, value, option):
  605. if namespace.sort is None:
  606. namespace.sort = []
  607. namespace.sort.append((value, True if option == '-S' else False))
  608. parser.add_argument(
  609. '-s', '--sort',
  610. action=AppendSort,
  611. help="Sort by this fields.")
  612. parser.add_argument(
  613. '-S', '--reverse-sort',
  614. action=AppendSort,
  615. help="Sort by this fields, but backwards.")
  616. parser.add_argument(
  617. '-Y', '--summary',
  618. action='store_true',
  619. help="Only show the total.")
  620. parser.add_argument(
  621. '-F', '--source',
  622. dest='sources',
  623. action='append',
  624. help="Only consider definitions in this file. Defaults to anything "
  625. "in the current directory.")
  626. parser.add_argument(
  627. '--everything',
  628. action='store_true',
  629. help="Include builtin and libc specific symbols.")
  630. parser.add_argument(
  631. '--tree',
  632. action='store_true',
  633. help="Only show the function call tree.")
  634. parser.add_argument(
  635. '-Z', '--depth',
  636. nargs='?',
  637. type=lambda x: int(x, 0),
  638. const=0,
  639. help="Depth of function calls to show. 0 shows all calls but may not "
  640. "terminate!")
  641. parser.add_argument(
  642. '-e', '--error-on-recursion',
  643. action='store_true',
  644. help="Error if any functions are recursive.")
  645. sys.exit(main(**{k: v
  646. for k, v in vars(parser.parse_intermixed_args()).items()
  647. if v is not None}))