base.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. # Copyright (c) 2016, Intel Corporation.
  2. #
  3. # This program is free software; you can redistribute it and/or modify it
  4. # under the terms and conditions of the GNU General Public License,
  5. # version 2, as published by the Free Software Foundation.
  6. #
  7. # This program is distributed in the hope it will be useful, but WITHOUT
  8. # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
  9. # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
  10. # more details.
  11. #
  12. """Build performance test base classes and functionality"""
  13. import json
  14. import logging
  15. import os
  16. import re
  17. import resource
  18. import socket
  19. import shutil
  20. import time
  21. import unittest
  22. import xml.etree.ElementTree as ET
  23. from collections import OrderedDict
  24. from datetime import datetime, timedelta
  25. from functools import partial
  26. from multiprocessing import Process
  27. from multiprocessing import SimpleQueue
  28. from xml.dom import minidom
  29. import oe.path
  30. from oeqa.utils.commands import CommandError, runCmd, get_bb_vars
  31. from oeqa.utils.git import GitError, GitRepo
  32. # Get logger for this module
  33. log = logging.getLogger('build-perf')
  34. # Our own version of runCmd which does not raise AssertErrors which would cause
  35. # errors to interpreted as failures
  36. runCmd2 = partial(runCmd, assert_error=False)
  37. class KernelDropCaches(object):
  38. """Container of the functions for dropping kernel caches"""
  39. sudo_passwd = None
  40. @classmethod
  41. def check(cls):
  42. """Check permssions for dropping kernel caches"""
  43. from getpass import getpass
  44. from locale import getdefaultlocale
  45. cmd = ['sudo', '-k', '-n', 'tee', '/proc/sys/vm/drop_caches']
  46. ret = runCmd2(cmd, ignore_status=True, data=b'0')
  47. if ret.output.startswith('sudo:'):
  48. pass_str = getpass(
  49. "\nThe script requires sudo access to drop caches between "
  50. "builds (echo 3 > /proc/sys/vm/drop_caches).\n"
  51. "Please enter your sudo password: ")
  52. cls.sudo_passwd = bytes(pass_str, getdefaultlocale()[1])
  53. @classmethod
  54. def drop(cls):
  55. """Drop kernel caches"""
  56. cmd = ['sudo', '-k']
  57. if cls.sudo_passwd:
  58. cmd.append('-S')
  59. input_data = cls.sudo_passwd + b'\n'
  60. else:
  61. cmd.append('-n')
  62. input_data = b''
  63. cmd += ['tee', '/proc/sys/vm/drop_caches']
  64. input_data += b'3'
  65. runCmd2(cmd, data=input_data)
  66. def str_to_fn(string):
  67. """Convert string to a sanitized filename"""
  68. return re.sub(r'(\W+)', '-', string, flags=re.LOCALE)
  69. class ResultsJsonEncoder(json.JSONEncoder):
  70. """Extended encoder for build perf test results"""
  71. unix_epoch = datetime.utcfromtimestamp(0)
  72. def default(self, obj):
  73. """Encoder for our types"""
  74. if isinstance(obj, datetime):
  75. # NOTE: we assume that all timestamps are in UTC time
  76. return (obj - self.unix_epoch).total_seconds()
  77. if isinstance(obj, timedelta):
  78. return obj.total_seconds()
  79. return json.JSONEncoder.default(self, obj)
  80. class BuildPerfTestResult(unittest.TextTestResult):
  81. """Runner class for executing the individual tests"""
  82. # List of test cases to run
  83. test_run_queue = []
  84. def __init__(self, out_dir, *args, **kwargs):
  85. super(BuildPerfTestResult, self).__init__(*args, **kwargs)
  86. self.out_dir = out_dir
  87. self.hostname = socket.gethostname()
  88. self.product = os.getenv('OE_BUILDPERFTEST_PRODUCT', 'oe-core')
  89. self.start_time = self.elapsed_time = None
  90. self.successes = []
  91. def addSuccess(self, test):
  92. """Record results from successful tests"""
  93. super(BuildPerfTestResult, self).addSuccess(test)
  94. self.successes.append(test)
  95. def addError(self, test, err):
  96. """Record results from crashed test"""
  97. test.err = err
  98. super(BuildPerfTestResult, self).addError(test, err)
  99. def addFailure(self, test, err):
  100. """Record results from failed test"""
  101. test.err = err
  102. super(BuildPerfTestResult, self).addFailure(test, err)
  103. def addExpectedFailure(self, test, err):
  104. """Record results from expectedly failed test"""
  105. test.err = err
  106. super(BuildPerfTestResult, self).addExpectedFailure(test, err)
  107. def startTest(self, test):
  108. """Pre-test hook"""
  109. test.base_dir = self.out_dir
  110. log.info("Executing test %s: %s", test.name, test.shortDescription())
  111. self.stream.write(datetime.now().strftime("[%Y-%m-%d %H:%M:%S] "))
  112. super(BuildPerfTestResult, self).startTest(test)
  113. def startTestRun(self):
  114. """Pre-run hook"""
  115. self.start_time = datetime.utcnow()
  116. def stopTestRun(self):
  117. """Pre-run hook"""
  118. self.elapsed_time = datetime.utcnow() - self.start_time
  119. def all_results(self):
  120. compound = [('SUCCESS', t, None) for t in self.successes] + \
  121. [('FAILURE', t, m) for t, m in self.failures] + \
  122. [('ERROR', t, m) for t, m in self.errors] + \
  123. [('EXPECTED_FAILURE', t, m) for t, m in self.expectedFailures] + \
  124. [('UNEXPECTED_SUCCESS', t, None) for t in self.unexpectedSuccesses] + \
  125. [('SKIPPED', t, m) for t, m in self.skipped]
  126. return sorted(compound, key=lambda info: info[1].start_time)
  127. def write_buildstats_json(self):
  128. """Write buildstats file"""
  129. buildstats = OrderedDict()
  130. for _, test, _ in self.all_results():
  131. for key, val in test.buildstats.items():
  132. buildstats[test.name + '.' + key] = val
  133. with open(os.path.join(self.out_dir, 'buildstats.json'), 'w') as fobj:
  134. json.dump(buildstats, fobj, cls=ResultsJsonEncoder)
  135. def write_results_json(self):
  136. """Write test results into a json-formatted file"""
  137. results = OrderedDict([('tester_host', self.hostname),
  138. ('start_time', self.start_time),
  139. ('elapsed_time', self.elapsed_time),
  140. ('tests', OrderedDict())])
  141. for status, test, reason in self.all_results():
  142. test_result = OrderedDict([('name', test.name),
  143. ('description', test.shortDescription()),
  144. ('status', status),
  145. ('start_time', test.start_time),
  146. ('elapsed_time', test.elapsed_time),
  147. ('measurements', test.measurements)])
  148. if status in ('ERROR', 'FAILURE', 'EXPECTED_FAILURE'):
  149. test_result['message'] = str(test.err[1])
  150. test_result['err_type'] = test.err[0].__name__
  151. test_result['err_output'] = reason
  152. elif reason:
  153. test_result['message'] = reason
  154. results['tests'][test.name] = test_result
  155. with open(os.path.join(self.out_dir, 'results.json'), 'w') as fobj:
  156. json.dump(results, fobj, indent=4,
  157. cls=ResultsJsonEncoder)
  158. def write_results_xml(self):
  159. """Write test results into a JUnit XML file"""
  160. top = ET.Element('testsuites')
  161. suite = ET.SubElement(top, 'testsuite')
  162. suite.set('name', 'oeqa.buildperf')
  163. suite.set('timestamp', self.start_time.isoformat())
  164. suite.set('time', str(self.elapsed_time.total_seconds()))
  165. suite.set('hostname', self.hostname)
  166. suite.set('failures', str(len(self.failures) + len(self.expectedFailures)))
  167. suite.set('errors', str(len(self.errors)))
  168. suite.set('skipped', str(len(self.skipped)))
  169. test_cnt = 0
  170. for status, test, reason in self.all_results():
  171. test_cnt += 1
  172. testcase = ET.SubElement(suite, 'testcase')
  173. testcase.set('classname', test.__module__ + '.' + test.__class__.__name__)
  174. testcase.set('name', test.name)
  175. testcase.set('description', test.shortDescription())
  176. testcase.set('timestamp', test.start_time.isoformat())
  177. testcase.set('time', str(test.elapsed_time.total_seconds()))
  178. if status in ('ERROR', 'FAILURE', 'EXP_FAILURE'):
  179. if status in ('FAILURE', 'EXP_FAILURE'):
  180. result = ET.SubElement(testcase, 'failure')
  181. else:
  182. result = ET.SubElement(testcase, 'error')
  183. result.set('message', str(test.err[1]))
  184. result.set('type', test.err[0].__name__)
  185. result.text = reason
  186. elif status == 'SKIPPED':
  187. result = ET.SubElement(testcase, 'skipped')
  188. result.text = reason
  189. elif status not in ('SUCCESS', 'UNEXPECTED_SUCCESS'):
  190. raise TypeError("BUG: invalid test status '%s'" % status)
  191. for data in test.measurements.values():
  192. measurement = ET.SubElement(testcase, data['type'])
  193. measurement.set('name', data['name'])
  194. measurement.set('legend', data['legend'])
  195. vals = data['values']
  196. if data['type'] == BuildPerfTestCase.SYSRES:
  197. ET.SubElement(measurement, 'time',
  198. timestamp=vals['start_time'].isoformat()).text = \
  199. str(vals['elapsed_time'].total_seconds())
  200. attrib = dict((k, str(v)) for k, v in vals['iostat'].items())
  201. ET.SubElement(measurement, 'iostat', attrib=attrib)
  202. attrib = dict((k, str(v)) for k, v in vals['rusage'].items())
  203. ET.SubElement(measurement, 'rusage', attrib=attrib)
  204. elif data['type'] == BuildPerfTestCase.DISKUSAGE:
  205. ET.SubElement(measurement, 'size').text = str(vals['size'])
  206. else:
  207. raise TypeError('BUG: unsupported measurement type')
  208. suite.set('tests', str(test_cnt))
  209. # Use minidom for pretty-printing
  210. dom_doc = minidom.parseString(ET.tostring(top, 'utf-8'))
  211. with open(os.path.join(self.out_dir, 'results.xml'), 'w') as fobj:
  212. dom_doc.writexml(fobj, addindent=' ', newl='\n', encoding='utf-8')
  213. class BuildPerfTestCase(unittest.TestCase):
  214. """Base class for build performance tests"""
  215. SYSRES = 'sysres'
  216. DISKUSAGE = 'diskusage'
  217. build_target = None
  218. def __init__(self, *args, **kwargs):
  219. super(BuildPerfTestCase, self).__init__(*args, **kwargs)
  220. self.name = self._testMethodName
  221. self.base_dir = None
  222. self.start_time = None
  223. self.elapsed_time = None
  224. self.measurements = OrderedDict()
  225. self.buildstats = OrderedDict()
  226. # self.err is supposed to be a tuple from sys.exc_info()
  227. self.err = None
  228. self.bb_vars = get_bb_vars()
  229. # TODO: remove 'times' and 'sizes' arrays when globalres support is
  230. # removed
  231. self.times = []
  232. self.sizes = []
  233. @property
  234. def tmp_dir(self):
  235. return os.path.join(self.base_dir, self.name + '.tmp')
  236. def shortDescription(self):
  237. return super(BuildPerfTestCase, self).shortDescription() or ""
  238. def setUp(self):
  239. """Set-up fixture for each test"""
  240. if not os.path.isdir(self.tmp_dir):
  241. os.mkdir(self.tmp_dir)
  242. if self.build_target:
  243. self.run_cmd(['bitbake', self.build_target, '-c', 'fetchall'])
  244. def tearDown(self):
  245. """Tear-down fixture for each test"""
  246. if os.path.isdir(self.tmp_dir):
  247. shutil.rmtree(self.tmp_dir)
  248. def run(self, *args, **kwargs):
  249. """Run test"""
  250. self.start_time = datetime.now()
  251. super(BuildPerfTestCase, self).run(*args, **kwargs)
  252. self.elapsed_time = datetime.now() - self.start_time
  253. def run_cmd(self, cmd):
  254. """Convenience method for running a command"""
  255. cmd_str = cmd if isinstance(cmd, str) else ' '.join(cmd)
  256. log.info("Logging command: %s", cmd_str)
  257. try:
  258. runCmd2(cmd)
  259. except CommandError as err:
  260. log.error("Command failed: %s", err.retcode)
  261. raise
  262. def _append_measurement(self, measurement):
  263. """Simple helper for adding measurements results"""
  264. if measurement['name'] in self.measurements:
  265. raise ValueError('BUG: two measurements with the same name in {}'.format(
  266. self.__class__.__name__))
  267. self.measurements[measurement['name']] = measurement
  268. def measure_cmd_resources(self, cmd, name, legend, save_bs=False):
  269. """Measure system resource usage of a command"""
  270. def _worker(data_q, cmd, **kwargs):
  271. """Worker process for measuring resources"""
  272. try:
  273. start_time = datetime.now()
  274. ret = runCmd2(cmd, **kwargs)
  275. etime = datetime.now() - start_time
  276. rusage_struct = resource.getrusage(resource.RUSAGE_CHILDREN)
  277. iostat = OrderedDict()
  278. with open('/proc/{}/io'.format(os.getpid())) as fobj:
  279. for line in fobj.readlines():
  280. key, val = line.split(':')
  281. iostat[key] = int(val)
  282. rusage = OrderedDict()
  283. # Skip unused fields, (i.e. 'ru_ixrss', 'ru_idrss', 'ru_isrss',
  284. # 'ru_nswap', 'ru_msgsnd', 'ru_msgrcv' and 'ru_nsignals')
  285. for key in ['ru_utime', 'ru_stime', 'ru_maxrss', 'ru_minflt',
  286. 'ru_majflt', 'ru_inblock', 'ru_oublock',
  287. 'ru_nvcsw', 'ru_nivcsw']:
  288. rusage[key] = getattr(rusage_struct, key)
  289. data_q.put({'ret': ret,
  290. 'start_time': start_time,
  291. 'elapsed_time': etime,
  292. 'rusage': rusage,
  293. 'iostat': iostat})
  294. except Exception as err:
  295. data_q.put(err)
  296. cmd_str = cmd if isinstance(cmd, str) else ' '.join(cmd)
  297. log.info("Timing command: %s", cmd_str)
  298. data_q = SimpleQueue()
  299. try:
  300. proc = Process(target=_worker, args=(data_q, cmd,))
  301. proc.start()
  302. data = data_q.get()
  303. proc.join()
  304. if isinstance(data, Exception):
  305. raise data
  306. except CommandError:
  307. log.error("Command '%s' failed", cmd_str)
  308. raise
  309. etime = data['elapsed_time']
  310. measurement = OrderedDict([('type', self.SYSRES),
  311. ('name', name),
  312. ('legend', legend)])
  313. measurement['values'] = OrderedDict([('start_time', data['start_time']),
  314. ('elapsed_time', etime),
  315. ('rusage', data['rusage']),
  316. ('iostat', data['iostat'])])
  317. if save_bs:
  318. self.save_buildstats(name)
  319. self._append_measurement(measurement)
  320. # Append to 'times' array for globalres log
  321. e_sec = etime.total_seconds()
  322. self.times.append('{:d}:{:02d}:{:05.2f}'.format(int(e_sec / 3600),
  323. int((e_sec % 3600) / 60),
  324. e_sec % 60))
  325. def measure_disk_usage(self, path, name, legend, apparent_size=False):
  326. """Estimate disk usage of a file or directory"""
  327. cmd = ['du', '-s', '--block-size', '1024']
  328. if apparent_size:
  329. cmd.append('--apparent-size')
  330. cmd.append(path)
  331. ret = runCmd2(cmd)
  332. size = int(ret.output.split()[0])
  333. log.debug("Size of %s path is %s", path, size)
  334. measurement = OrderedDict([('type', self.DISKUSAGE),
  335. ('name', name),
  336. ('legend', legend)])
  337. measurement['values'] = OrderedDict([('size', size)])
  338. self._append_measurement(measurement)
  339. # Append to 'sizes' array for globalres log
  340. self.sizes.append(str(size))
  341. def save_buildstats(self, measurement_name):
  342. """Save buildstats"""
  343. def split_nevr(nevr):
  344. """Split name and version information from recipe "nevr" string"""
  345. n_e_v, revision = nevr.rsplit('-', 1)
  346. match = re.match(r'^(?P<name>\S+)-((?P<epoch>[0-9]{1,5})_)?(?P<version>[0-9]\S*)$',
  347. n_e_v)
  348. if not match:
  349. # If we're not able to parse a version starting with a number, just
  350. # take the part after last dash
  351. match = re.match(r'^(?P<name>\S+)-((?P<epoch>[0-9]{1,5})_)?(?P<version>[^-]+)$',
  352. n_e_v)
  353. name = match.group('name')
  354. version = match.group('version')
  355. epoch = match.group('epoch')
  356. return name, epoch, version, revision
  357. def bs_to_json(filename):
  358. """Convert (task) buildstats file into json format"""
  359. bs_json = {'iostat': {},
  360. 'rusage': {},
  361. 'child_rusage': {}}
  362. with open(filename) as fobj:
  363. for line in fobj.readlines():
  364. key, val = line.split(':', 1)
  365. val = val.strip()
  366. if key == 'Started':
  367. start_time = datetime.utcfromtimestamp(float(val))
  368. bs_json['start_time'] = start_time
  369. elif key == 'Ended':
  370. end_time = datetime.utcfromtimestamp(float(val))
  371. elif key.startswith('IO '):
  372. split = key.split()
  373. bs_json['iostat'][split[1]] = int(val)
  374. elif key.find('rusage') >= 0:
  375. split = key.split()
  376. ru_key = split[-1]
  377. if ru_key in ('ru_stime', 'ru_utime'):
  378. val = float(val)
  379. else:
  380. val = int(val)
  381. ru_type = 'rusage' if split[0] == 'rusage' else \
  382. 'child_rusage'
  383. bs_json[ru_type][ru_key] = val
  384. elif key == 'Status':
  385. bs_json['status'] = val
  386. bs_json['elapsed_time'] = end_time - start_time
  387. return bs_json
  388. log.info('Saving buildstats in JSON format')
  389. bs_dirs = sorted(os.listdir(self.bb_vars['BUILDSTATS_BASE']))
  390. if len(bs_dirs) > 1:
  391. log.warning("Multiple buildstats found for test %s, only "
  392. "archiving the last one", self.name)
  393. bs_dir = os.path.join(self.bb_vars['BUILDSTATS_BASE'], bs_dirs[-1])
  394. buildstats = []
  395. for fname in os.listdir(bs_dir):
  396. recipe_dir = os.path.join(bs_dir, fname)
  397. if not os.path.isdir(recipe_dir):
  398. continue
  399. name, epoch, version, revision = split_nevr(fname)
  400. recipe_bs = {'name': name,
  401. 'epoch': epoch,
  402. 'version': version,
  403. 'revision': revision,
  404. 'tasks': {}}
  405. for task in os.listdir(recipe_dir):
  406. recipe_bs['tasks'][task] = bs_to_json(os.path.join(recipe_dir,
  407. task))
  408. buildstats.append(recipe_bs)
  409. self.buildstats[measurement_name] = buildstats
  410. def rm_tmp(self):
  411. """Cleanup temporary/intermediate files and directories"""
  412. log.debug("Removing temporary and cache files")
  413. for name in ['bitbake.lock', 'conf/sanity_info',
  414. self.bb_vars['TMPDIR']]:
  415. oe.path.remove(name, recurse=True)
  416. def rm_sstate(self):
  417. """Remove sstate directory"""
  418. log.debug("Removing sstate-cache")
  419. oe.path.remove(self.bb_vars['SSTATE_DIR'], recurse=True)
  420. def rm_cache(self):
  421. """Drop bitbake caches"""
  422. oe.path.remove(self.bb_vars['PERSISTENT_DIR'], recurse=True)
  423. @staticmethod
  424. def sync():
  425. """Sync and drop kernel caches"""
  426. log.debug("Syncing and dropping kernel caches""")
  427. KernelDropCaches.drop()
  428. os.sync()
  429. # Wait a bit for all the dirty blocks to be written onto disk
  430. time.sleep(3)
  431. class BuildPerfTestLoader(unittest.TestLoader):
  432. """Test loader for build performance tests"""
  433. sortTestMethodsUsing = None
  434. class BuildPerfTestRunner(unittest.TextTestRunner):
  435. """Test loader for build performance tests"""
  436. sortTestMethodsUsing = None
  437. def __init__(self, out_dir, *args, **kwargs):
  438. super(BuildPerfTestRunner, self).__init__(*args, **kwargs)
  439. self.out_dir = out_dir
  440. def _makeResult(self):
  441. return BuildPerfTestResult(self.out_dir, self.stream, self.descriptions,
  442. self.verbosity)