data.py 22 KB

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