data.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704
  1. #!/usr/bin/env python3
  2. #
  3. # Script to find data size at the function level. Basically just a big wrapper
  4. # around nm with some extra conveniences for comparing builds. Heavily inspired
  5. # by Linux's Bloat-O-Meter.
  6. #
  7. # Example:
  8. # ./scripts/data.py lfs.o lfs_util.o -Ssize
  9. #
  10. # Copyright (c) 2022, The littlefs authors.
  11. # Copyright (c) 2020, Arm Limited. All rights reserved.
  12. # SPDX-License-Identifier: BSD-3-Clause
  13. #
  14. import collections as co
  15. import csv
  16. import difflib
  17. import itertools as it
  18. import math as m
  19. import os
  20. import re
  21. import shlex
  22. import subprocess as sp
  23. NM_PATH = ['nm']
  24. NM_TYPES = 'dDbB'
  25. OBJDUMP_PATH = ['objdump']
  26. # integer fields
  27. class Int(co.namedtuple('Int', 'x')):
  28. __slots__ = ()
  29. def __new__(cls, x=0):
  30. if isinstance(x, Int):
  31. return x
  32. if isinstance(x, str):
  33. try:
  34. x = int(x, 0)
  35. except ValueError:
  36. # also accept +-∞ and +-inf
  37. if re.match('^\s*\+?\s*(?:∞|inf)\s*$', x):
  38. x = m.inf
  39. elif re.match('^\s*-\s*(?:∞|inf)\s*$', x):
  40. x = -m.inf
  41. else:
  42. raise
  43. assert isinstance(x, int) or m.isinf(x), x
  44. return super().__new__(cls, x)
  45. def __str__(self):
  46. if self.x == m.inf:
  47. return '∞'
  48. elif self.x == -m.inf:
  49. return '-∞'
  50. else:
  51. return str(self.x)
  52. def __int__(self):
  53. assert not m.isinf(self.x)
  54. return self.x
  55. def __float__(self):
  56. return float(self.x)
  57. none = '%7s' % '-'
  58. def table(self):
  59. return '%7s' % (self,)
  60. diff_none = '%7s' % '-'
  61. diff_table = table
  62. def diff_diff(self, other):
  63. new = self.x if self else 0
  64. old = other.x if other else 0
  65. diff = new - old
  66. if diff == +m.inf:
  67. return '%7s' % '+∞'
  68. elif diff == -m.inf:
  69. return '%7s' % '-∞'
  70. else:
  71. return '%+7d' % diff
  72. def ratio(self, other):
  73. new = self.x if self else 0
  74. old = other.x if other else 0
  75. if m.isinf(new) and m.isinf(old):
  76. return 0.0
  77. elif m.isinf(new):
  78. return +m.inf
  79. elif m.isinf(old):
  80. return -m.inf
  81. elif not old and not new:
  82. return 0.0
  83. elif not old:
  84. return 1.0
  85. else:
  86. return (new-old) / old
  87. def __add__(self, other):
  88. return self.__class__(self.x + other.x)
  89. def __sub__(self, other):
  90. return self.__class__(self.x - other.x)
  91. def __mul__(self, other):
  92. return self.__class__(self.x * other.x)
  93. # data size results
  94. class DataResult(co.namedtuple('DataResult', [
  95. 'file', 'function',
  96. 'size'])):
  97. _by = ['file', 'function']
  98. _fields = ['size']
  99. _sort = ['size']
  100. _types = {'size': Int}
  101. __slots__ = ()
  102. def __new__(cls, file='', function='', size=0):
  103. return super().__new__(cls, file, function,
  104. Int(size))
  105. def __add__(self, other):
  106. return DataResult(self.file, self.function,
  107. self.size + other.size)
  108. def openio(path, mode='r', buffering=-1):
  109. # allow '-' for stdin/stdout
  110. if path == '-':
  111. if mode == 'r':
  112. return os.fdopen(os.dup(sys.stdin.fileno()), mode, buffering)
  113. else:
  114. return os.fdopen(os.dup(sys.stdout.fileno()), mode, buffering)
  115. else:
  116. return open(path, mode, buffering)
  117. def collect(obj_paths, *,
  118. nm_path=NM_PATH,
  119. nm_types=NM_TYPES,
  120. objdump_path=OBJDUMP_PATH,
  121. sources=None,
  122. everything=False,
  123. **args):
  124. size_pattern = re.compile(
  125. '^(?P<size>[0-9a-fA-F]+)' +
  126. ' (?P<type>[%s])' % re.escape(nm_types) +
  127. ' (?P<func>.+?)$')
  128. line_pattern = re.compile(
  129. '^\s+(?P<no>[0-9]+)'
  130. '(?:\s+(?P<dir>[0-9]+))?'
  131. '\s+.*'
  132. '\s+(?P<path>[^\s]+)$')
  133. info_pattern = re.compile(
  134. '^(?:.*(?P<tag>DW_TAG_[a-z_]+).*'
  135. '|.*DW_AT_name.*:\s*(?P<name>[^:\s]+)\s*'
  136. '|.*DW_AT_decl_file.*:\s*(?P<file>[0-9]+)\s*)$')
  137. results = []
  138. for path in obj_paths:
  139. # guess the source, if we have debug-info we'll replace this later
  140. file = re.sub('(\.o)?$', '.c', path, 1)
  141. # find symbol sizes
  142. results_ = []
  143. # note nm-path may contain extra args
  144. cmd = nm_path + ['--size-sort', path]
  145. if args.get('verbose'):
  146. print(' '.join(shlex.quote(c) for c in cmd))
  147. proc = sp.Popen(cmd,
  148. stdout=sp.PIPE,
  149. stderr=sp.PIPE if not args.get('verbose') else None,
  150. universal_newlines=True,
  151. errors='replace',
  152. close_fds=False)
  153. for line in proc.stdout:
  154. m = size_pattern.match(line)
  155. if m:
  156. func = m.group('func')
  157. # discard internal functions
  158. if not everything and func.startswith('__'):
  159. continue
  160. results_.append(DataResult(
  161. file, func,
  162. int(m.group('size'), 16)))
  163. proc.wait()
  164. if proc.returncode != 0:
  165. if not args.get('verbose'):
  166. for line in proc.stderr:
  167. sys.stdout.write(line)
  168. sys.exit(-1)
  169. # try to figure out the source file if we have debug-info
  170. dirs = {}
  171. files = {}
  172. # note objdump-path may contain extra args
  173. cmd = objdump_path + ['--dwarf=rawline', path]
  174. if args.get('verbose'):
  175. print(' '.join(shlex.quote(c) for c in cmd))
  176. proc = sp.Popen(cmd,
  177. stdout=sp.PIPE,
  178. stderr=sp.PIPE if not args.get('verbose') else None,
  179. universal_newlines=True,
  180. errors='replace',
  181. close_fds=False)
  182. for line in proc.stdout:
  183. # note that files contain references to dirs, which we
  184. # dereference as soon as we see them as each file table follows a
  185. # dir table
  186. m = line_pattern.match(line)
  187. if m:
  188. if not m.group('dir'):
  189. # found a directory entry
  190. dirs[int(m.group('no'))] = m.group('path')
  191. else:
  192. # found a file entry
  193. dir = int(m.group('dir'))
  194. if dir in dirs:
  195. files[int(m.group('no'))] = os.path.join(
  196. dirs[dir],
  197. m.group('path'))
  198. else:
  199. files[int(m.group('no'))] = m.group('path')
  200. proc.wait()
  201. if proc.returncode != 0:
  202. if not args.get('verbose'):
  203. for line in proc.stderr:
  204. sys.stdout.write(line)
  205. # do nothing on error, we don't need objdump to work, source files
  206. # may just be inaccurate
  207. pass
  208. defs = {}
  209. is_func = False
  210. f_name = None
  211. f_file = None
  212. # note objdump-path may contain extra args
  213. cmd = objdump_path + ['--dwarf=info', path]
  214. if args.get('verbose'):
  215. print(' '.join(shlex.quote(c) for c in cmd))
  216. proc = sp.Popen(cmd,
  217. stdout=sp.PIPE,
  218. stderr=sp.PIPE if not args.get('verbose') else None,
  219. universal_newlines=True,
  220. errors='replace',
  221. close_fds=False)
  222. for line in proc.stdout:
  223. # state machine here to find definitions
  224. m = info_pattern.match(line)
  225. if m:
  226. if m.group('tag'):
  227. if is_func:
  228. defs[f_name] = files.get(f_file, '?')
  229. is_func = (m.group('tag') == 'DW_TAG_subprogram')
  230. elif m.group('name'):
  231. f_name = m.group('name')
  232. elif m.group('file'):
  233. f_file = int(m.group('file'))
  234. if is_func:
  235. defs[f_name] = files.get(f_file, '?')
  236. proc.wait()
  237. if proc.returncode != 0:
  238. if not args.get('verbose'):
  239. for line in proc.stderr:
  240. sys.stdout.write(line)
  241. # do nothing on error, we don't need objdump to work, source files
  242. # may just be inaccurate
  243. pass
  244. for r in results_:
  245. # find best matching debug symbol, this may be slightly different
  246. # due to optimizations
  247. if defs:
  248. # exact match? avoid difflib if we can for speed
  249. if r.function in defs:
  250. file = defs[r.function]
  251. else:
  252. _, file = max(
  253. defs.items(),
  254. key=lambda d: difflib.SequenceMatcher(None,
  255. d[0],
  256. r.function, False).ratio())
  257. else:
  258. file = r.file
  259. # ignore filtered sources
  260. if sources is not None:
  261. if not any(
  262. os.path.abspath(file) == os.path.abspath(s)
  263. for s in sources):
  264. continue
  265. else:
  266. # default to only cwd
  267. if not everything and not os.path.commonpath([
  268. os.getcwd(),
  269. os.path.abspath(file)]) == os.getcwd():
  270. continue
  271. # simplify path
  272. if os.path.commonpath([
  273. os.getcwd(),
  274. os.path.abspath(file)]) == os.getcwd():
  275. file = os.path.relpath(file)
  276. else:
  277. file = os.path.abspath(file)
  278. results.append(r._replace(file=file))
  279. return results
  280. def fold(Result, results, *,
  281. by=None,
  282. defines=None,
  283. **_):
  284. if by is None:
  285. by = Result._by
  286. for k in it.chain(by or [], (k for k, _ in defines or [])):
  287. if k not in Result._by and k not in Result._fields:
  288. print("error: could not find field %r?" % k)
  289. sys.exit(-1)
  290. # filter by matching defines
  291. if defines is not None:
  292. results_ = []
  293. for r in results:
  294. if all(getattr(r, k) in vs for k, vs in defines):
  295. results_.append(r)
  296. results = results_
  297. # organize results into conflicts
  298. folding = co.OrderedDict()
  299. for r in results:
  300. name = tuple(getattr(r, k) for k in by)
  301. if name not in folding:
  302. folding[name] = []
  303. folding[name].append(r)
  304. # merge conflicts
  305. folded = []
  306. for name, rs in folding.items():
  307. folded.append(sum(rs[1:], start=rs[0]))
  308. return folded
  309. def table(Result, results, diff_results=None, *,
  310. by=None,
  311. fields=None,
  312. sort=None,
  313. summary=False,
  314. all=False,
  315. percent=False,
  316. **_):
  317. all_, all = all, __builtins__.all
  318. if by is None:
  319. by = Result._by
  320. if fields is None:
  321. fields = Result._fields
  322. types = Result._types
  323. # fold again
  324. results = fold(Result, results, by=by)
  325. if diff_results is not None:
  326. diff_results = fold(Result, diff_results, by=by)
  327. # organize by name
  328. table = {
  329. ','.join(str(getattr(r, k) or '') for k in by): r
  330. for r in results}
  331. diff_table = {
  332. ','.join(str(getattr(r, k) or '') for k in by): r
  333. for r in diff_results or []}
  334. names = list(table.keys() | diff_table.keys())
  335. # sort again, now with diff info, note that python's sort is stable
  336. names.sort()
  337. if diff_results is not None:
  338. names.sort(key=lambda n: tuple(
  339. types[k].ratio(
  340. getattr(table.get(n), k, None),
  341. getattr(diff_table.get(n), k, None))
  342. for k in fields),
  343. reverse=True)
  344. if sort:
  345. for k, reverse in reversed(sort):
  346. names.sort(
  347. key=lambda n: tuple(
  348. (getattr(table[n], k),)
  349. if getattr(table.get(n), k, None) is not None else ()
  350. for k in ([k] if k else [
  351. k for k in Result._sort if k in fields])),
  352. reverse=reverse ^ (not k or k in Result._fields))
  353. # build up our lines
  354. lines = []
  355. # header
  356. header = []
  357. header.append('%s%s' % (
  358. ','.join(by),
  359. ' (%d added, %d removed)' % (
  360. sum(1 for n in table if n not in diff_table),
  361. sum(1 for n in diff_table if n not in table))
  362. if diff_results is not None and not percent else '')
  363. if not summary else '')
  364. if diff_results is None:
  365. for k in fields:
  366. header.append(k)
  367. elif percent:
  368. for k in fields:
  369. header.append(k)
  370. else:
  371. for k in fields:
  372. header.append('o'+k)
  373. for k in fields:
  374. header.append('n'+k)
  375. for k in fields:
  376. header.append('d'+k)
  377. header.append('')
  378. lines.append(header)
  379. def table_entry(name, r, diff_r=None, ratios=[]):
  380. entry = []
  381. entry.append(name)
  382. if diff_results is None:
  383. for k in fields:
  384. entry.append(getattr(r, k).table()
  385. if getattr(r, k, None) is not None
  386. else types[k].none)
  387. elif percent:
  388. for k in fields:
  389. entry.append(getattr(r, k).diff_table()
  390. if getattr(r, k, None) is not None
  391. else types[k].diff_none)
  392. else:
  393. for k in fields:
  394. entry.append(getattr(diff_r, k).diff_table()
  395. if getattr(diff_r, k, None) is not None
  396. else types[k].diff_none)
  397. for k in fields:
  398. entry.append(getattr(r, k).diff_table()
  399. if getattr(r, k, None) is not None
  400. else types[k].diff_none)
  401. for k in fields:
  402. entry.append(types[k].diff_diff(
  403. getattr(r, k, None),
  404. getattr(diff_r, k, None)))
  405. if diff_results is None:
  406. entry.append('')
  407. elif percent:
  408. entry.append(' (%s)' % ', '.join(
  409. '+∞%' if t == +m.inf
  410. else '-∞%' if t == -m.inf
  411. else '%+.1f%%' % (100*t)
  412. for t in ratios))
  413. else:
  414. entry.append(' (%s)' % ', '.join(
  415. '+∞%' if t == +m.inf
  416. else '-∞%' if t == -m.inf
  417. else '%+.1f%%' % (100*t)
  418. for t in ratios
  419. if t)
  420. if any(ratios) else '')
  421. return entry
  422. # entries
  423. if not summary:
  424. for name in names:
  425. r = table.get(name)
  426. if diff_results is None:
  427. diff_r = None
  428. ratios = None
  429. else:
  430. diff_r = diff_table.get(name)
  431. ratios = [
  432. types[k].ratio(
  433. getattr(r, k, None),
  434. getattr(diff_r, k, None))
  435. for k in fields]
  436. if not all_ and not any(ratios):
  437. continue
  438. lines.append(table_entry(name, r, diff_r, ratios))
  439. # total
  440. r = next(iter(fold(Result, results, by=[])), None)
  441. if diff_results is None:
  442. diff_r = None
  443. ratios = None
  444. else:
  445. diff_r = next(iter(fold(Result, diff_results, by=[])), None)
  446. ratios = [
  447. types[k].ratio(
  448. getattr(r, k, None),
  449. getattr(diff_r, k, None))
  450. for k in fields]
  451. lines.append(table_entry('TOTAL', r, diff_r, ratios))
  452. # find the best widths, note that column 0 contains the names and column -1
  453. # the ratios, so those are handled a bit differently
  454. widths = [
  455. ((max(it.chain([w], (len(l[i]) for l in lines)))+1+4-1)//4)*4-1
  456. for w, i in zip(
  457. it.chain([23], it.repeat(7)),
  458. range(len(lines[0])-1))]
  459. # print our table
  460. for line in lines:
  461. print('%-*s %s%s' % (
  462. widths[0], line[0],
  463. ' '.join('%*s' % (w, x)
  464. for w, x in zip(widths[1:], line[1:-1])),
  465. line[-1]))
  466. def main(obj_paths, *,
  467. by=None,
  468. fields=None,
  469. defines=None,
  470. sort=None,
  471. **args):
  472. # find sizes
  473. if not args.get('use', None):
  474. results = collect(obj_paths, **args)
  475. else:
  476. results = []
  477. with openio(args['use']) as f:
  478. reader = csv.DictReader(f, restval='')
  479. for r in reader:
  480. try:
  481. results.append(DataResult(
  482. **{k: r[k] for k in DataResult._by
  483. if k in r and r[k].strip()},
  484. **{k: r['data_'+k] for k in DataResult._fields
  485. if 'data_'+k in r and r['data_'+k].strip()}))
  486. except TypeError:
  487. pass
  488. # fold
  489. results = fold(DataResult, results, by=by, defines=defines)
  490. # sort, note that python's sort is stable
  491. results.sort()
  492. if sort:
  493. for k, reverse in reversed(sort):
  494. results.sort(
  495. key=lambda r: tuple(
  496. (getattr(r, k),) if getattr(r, k) is not None else ()
  497. for k in ([k] if k else DataResult._sort)),
  498. reverse=reverse ^ (not k or k in DataResult._fields))
  499. # write results to CSV
  500. if args.get('output'):
  501. with openio(args['output'], 'w') as f:
  502. writer = csv.DictWriter(f,
  503. (by if by is not None else DataResult._by)
  504. + ['data_'+k for k in (
  505. fields if fields is not None else DataResult._fields)])
  506. writer.writeheader()
  507. for r in results:
  508. writer.writerow(
  509. {k: getattr(r, k) for k in (
  510. by if by is not None else DataResult._by)}
  511. | {'data_'+k: getattr(r, k) for k in (
  512. fields if fields is not None else DataResult._fields)})
  513. # find previous results?
  514. if args.get('diff'):
  515. diff_results = []
  516. try:
  517. with openio(args['diff']) as f:
  518. reader = csv.DictReader(f, restval='')
  519. for r in reader:
  520. if not any('data_'+k in r and r['data_'+k].strip()
  521. for k in DataResult._fields):
  522. continue
  523. try:
  524. diff_results.append(DataResult(
  525. **{k: r[k] for k in DataResult._by
  526. if k in r and r[k].strip()},
  527. **{k: r['data_'+k] for k in DataResult._fields
  528. if 'data_'+k in r and r['data_'+k].strip()}))
  529. except TypeError:
  530. pass
  531. except FileNotFoundError:
  532. pass
  533. # fold
  534. diff_results = fold(DataResult, diff_results, by=by, defines=defines)
  535. # print table
  536. if not args.get('quiet'):
  537. table(DataResult, results,
  538. diff_results if args.get('diff') else None,
  539. by=by if by is not None else ['function'],
  540. fields=fields,
  541. sort=sort,
  542. **args)
  543. if __name__ == "__main__":
  544. import argparse
  545. import sys
  546. parser = argparse.ArgumentParser(
  547. description="Find data size at the function level.",
  548. allow_abbrev=False)
  549. parser.add_argument(
  550. 'obj_paths',
  551. nargs='*',
  552. help="Input *.o files.")
  553. parser.add_argument(
  554. '-v', '--verbose',
  555. action='store_true',
  556. help="Output commands that run behind the scenes.")
  557. parser.add_argument(
  558. '-q', '--quiet',
  559. action='store_true',
  560. help="Don't show anything, useful with -o.")
  561. parser.add_argument(
  562. '-o', '--output',
  563. help="Specify CSV file to store results.")
  564. parser.add_argument(
  565. '-u', '--use',
  566. help="Don't parse anything, use this CSV file.")
  567. parser.add_argument(
  568. '-d', '--diff',
  569. help="Specify CSV file to diff against.")
  570. parser.add_argument(
  571. '-a', '--all',
  572. action='store_true',
  573. help="Show all, not just the ones that changed.")
  574. parser.add_argument(
  575. '-p', '--percent',
  576. action='store_true',
  577. help="Only show percentage change, not a full diff.")
  578. parser.add_argument(
  579. '-b', '--by',
  580. action='append',
  581. choices=DataResult._by,
  582. help="Group by this field.")
  583. parser.add_argument(
  584. '-f', '--field',
  585. dest='fields',
  586. action='append',
  587. choices=DataResult._fields,
  588. help="Show this field.")
  589. parser.add_argument(
  590. '-D', '--define',
  591. dest='defines',
  592. action='append',
  593. type=lambda x: (lambda k,v: (k, set(v.split(','))))(*x.split('=', 1)),
  594. help="Only include results where this field is this value.")
  595. class AppendSort(argparse.Action):
  596. def __call__(self, parser, namespace, value, option):
  597. if namespace.sort is None:
  598. namespace.sort = []
  599. namespace.sort.append((value, True if option == '-S' else False))
  600. parser.add_argument(
  601. '-s', '--sort',
  602. nargs='?',
  603. action=AppendSort,
  604. help="Sort by this field.")
  605. parser.add_argument(
  606. '-S', '--reverse-sort',
  607. nargs='?',
  608. action=AppendSort,
  609. help="Sort by this field, but backwards.")
  610. parser.add_argument(
  611. '-Y', '--summary',
  612. action='store_true',
  613. help="Only show the total.")
  614. parser.add_argument(
  615. '-F', '--source',
  616. dest='sources',
  617. action='append',
  618. help="Only consider definitions in this file. Defaults to anything "
  619. "in the current directory.")
  620. parser.add_argument(
  621. '--everything',
  622. action='store_true',
  623. help="Include builtin and libc specific symbols.")
  624. parser.add_argument(
  625. '--nm-types',
  626. default=NM_TYPES,
  627. help="Type of symbols to report, this uses the same single-character "
  628. "type-names emitted by nm. Defaults to %r." % NM_TYPES)
  629. parser.add_argument(
  630. '--nm-path',
  631. type=lambda x: x.split(),
  632. default=NM_PATH,
  633. help="Path to the nm executable, may include flags. "
  634. "Defaults to %r." % NM_PATH)
  635. parser.add_argument(
  636. '--objdump-path',
  637. type=lambda x: x.split(),
  638. default=OBJDUMP_PATH,
  639. help="Path to the objdump executable, may include flags. "
  640. "Defaults to %r." % OBJDUMP_PATH)
  641. sys.exit(main(**{k: v
  642. for k, v in vars(parser.parse_intermixed_args()).items()
  643. if v is not None}))