data.py 22 KB

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