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