perf_metric_validation.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597
  1. # SPDX-License-Identifier: GPL-2.0
  2. import re
  3. import csv
  4. import json
  5. import argparse
  6. from pathlib import Path
  7. import subprocess
  8. class TestError:
  9. def __init__(self, metric: list[str], wl: str, value: list[float], low: float, up=float('nan'), description=str()):
  10. self.metric: list = metric # multiple metrics in relationship type tests
  11. self.workloads = [wl] # multiple workloads possible
  12. self.collectedValue: list = value
  13. self.valueLowBound = low
  14. self.valueUpBound = up
  15. self.description = description
  16. def __repr__(self) -> str:
  17. if len(self.metric) > 1:
  18. return "\nMetric Relationship Error: \tThe collected value of metric {0}\n\
  19. \tis {1} in workload(s): {2} \n\
  20. \tbut expected value range is [{3}, {4}]\n\
  21. \tRelationship rule description: \'{5}\'".format(self.metric, self.collectedValue, self.workloads,
  22. self.valueLowBound, self.valueUpBound, self.description)
  23. elif len(self.collectedValue) == 0:
  24. return "\nNo Metric Value Error: \tMetric {0} returns with no value \n\
  25. \tworkload(s): {1}".format(self.metric, self.workloads)
  26. else:
  27. return "\nWrong Metric Value Error: \tThe collected value of metric {0}\n\
  28. \tis {1} in workload(s): {2}\n\
  29. \tbut expected value range is [{3}, {4}]"\
  30. .format(self.metric, self.collectedValue, self.workloads,
  31. self.valueLowBound, self.valueUpBound)
  32. class Validator:
  33. def __init__(self, rulefname, reportfname='', t=5, debug=False, datafname='', fullrulefname='', workload='true', metrics=''):
  34. self.rulefname = rulefname
  35. self.reportfname = reportfname
  36. self.rules = None
  37. self.collectlist: str = metrics
  38. self.metrics = self.__set_metrics(metrics)
  39. self.skiplist = set()
  40. self.tolerance = t
  41. self.workloads = [x for x in workload.split(",") if x]
  42. self.wlidx = 0 # idx of current workloads
  43. self.allresults = dict() # metric results of all workload
  44. self.alltotalcnt = dict()
  45. self.allpassedcnt = dict()
  46. self.results = dict() # metric results of current workload
  47. # vars for test pass/failure statistics
  48. # metrics with no results or negative results, neg result counts failed tests
  49. self.ignoremetrics = set()
  50. self.totalcnt = 0
  51. self.passedcnt = 0
  52. # vars for errors
  53. self.errlist = list()
  54. # vars for Rule Generator
  55. self.pctgmetrics = set() # Percentage rule
  56. # vars for debug
  57. self.datafname = datafname
  58. self.debug = debug
  59. self.fullrulefname = fullrulefname
  60. def __set_metrics(self, metrics=''):
  61. if metrics != '':
  62. return set(metrics.split(","))
  63. else:
  64. return set()
  65. def read_json(self, filename: str) -> dict:
  66. try:
  67. with open(Path(filename).resolve(), "r") as f:
  68. data = json.loads(f.read())
  69. except OSError as e:
  70. print(f"Error when reading file {e}")
  71. sys.exit()
  72. return data
  73. def json_dump(self, data, output_file):
  74. parent = Path(output_file).parent
  75. if not parent.exists():
  76. parent.mkdir(parents=True)
  77. with open(output_file, "w+") as output_file:
  78. json.dump(data,
  79. output_file,
  80. ensure_ascii=True,
  81. indent=4)
  82. def get_results(self, idx: int = 0):
  83. return self.results.get(idx)
  84. def get_bounds(self, lb, ub, error, alias={}, ridx: int = 0) -> list:
  85. """
  86. Get bounds and tolerance from lb, ub, and error.
  87. If missing lb, use 0.0; missing ub, use float('inf); missing error, use self.tolerance.
  88. @param lb: str/float, lower bound
  89. @param ub: str/float, upper bound
  90. @param error: float/str, error tolerance
  91. @returns: lower bound, return inf if the lower bound is a metric value and is not collected
  92. upper bound, return -1 if the upper bound is a metric value and is not collected
  93. tolerance, denormalized base on upper bound value
  94. """
  95. # init ubv and lbv to invalid values
  96. def get_bound_value(bound, initval, ridx):
  97. val = initval
  98. if isinstance(bound, int) or isinstance(bound, float):
  99. val = bound
  100. elif isinstance(bound, str):
  101. if bound == '':
  102. val = float("inf")
  103. elif bound in alias:
  104. vall = self.get_value(alias[ub], ridx)
  105. if vall:
  106. val = vall[0]
  107. elif bound.replace('.', '1').isdigit():
  108. val = float(bound)
  109. else:
  110. print("Wrong bound: {0}".format(bound))
  111. else:
  112. print("Wrong bound: {0}".format(bound))
  113. return val
  114. ubv = get_bound_value(ub, -1, ridx)
  115. lbv = get_bound_value(lb, float('inf'), ridx)
  116. t = get_bound_value(error, self.tolerance, ridx)
  117. # denormalize error threshold
  118. denormerr = t * ubv / 100 if ubv != 100 and ubv > 0 else t
  119. return lbv, ubv, denormerr
  120. def get_value(self, name: str, ridx: int = 0) -> list:
  121. """
  122. Get value of the metric from self.results.
  123. If result of this metric is not provided, the metric name will be added into self.ignoremetics.
  124. All future test(s) on this metric will fail.
  125. @param name: name of the metric
  126. @returns: list with value found in self.results; list is empty when value is not found.
  127. """
  128. results = []
  129. data = self.results[ridx] if ridx in self.results else self.results[0]
  130. if name not in self.ignoremetrics:
  131. if name in data:
  132. results.append(data[name])
  133. elif name.replace('.', '1').isdigit():
  134. results.append(float(name))
  135. else:
  136. self.ignoremetrics.add(name)
  137. return results
  138. def check_bound(self, val, lb, ub, err):
  139. return True if val <= ub + err and val >= lb - err else False
  140. # Positive Value Sanity check
  141. def pos_val_test(self):
  142. """
  143. Check if metrics value are non-negative.
  144. One metric is counted as one test.
  145. Failure: when metric value is negative or not provided.
  146. Metrics with negative value will be added into self.ignoremetrics.
  147. """
  148. negmetric = dict()
  149. pcnt = 0
  150. tcnt = 0
  151. rerun = list()
  152. results = self.get_results()
  153. if not results:
  154. return
  155. for name, val in results.items():
  156. if val < 0:
  157. negmetric[name] = val
  158. rerun.append(name)
  159. else:
  160. pcnt += 1
  161. tcnt += 1
  162. # The first round collect_perf() run these metrics with simple workload
  163. # "true". We give metrics a second chance with a longer workload if less
  164. # than 20 metrics failed positive test.
  165. if len(rerun) > 0 and len(rerun) < 20:
  166. second_results = dict()
  167. self.second_test(rerun, second_results)
  168. for name, val in second_results.items():
  169. if name not in negmetric:
  170. continue
  171. if val >= 0:
  172. del negmetric[name]
  173. pcnt += 1
  174. if len(negmetric.keys()):
  175. self.ignoremetrics.update(negmetric.keys())
  176. self.errlist.extend(
  177. [TestError([m], self.workloads[self.wlidx], negmetric[m], 0) for m in negmetric.keys()])
  178. return
  179. def evaluate_formula(self, formula: str, alias: dict, ridx: int = 0):
  180. """
  181. Evaluate the value of formula.
  182. @param formula: the formula to be evaluated
  183. @param alias: the dict has alias to metric name mapping
  184. @returns: value of the formula is success; -1 if the one or more metric value not provided
  185. """
  186. stack = []
  187. b = 0
  188. errs = []
  189. sign = "+"
  190. f = str()
  191. # TODO: support parenthesis?
  192. for i in range(len(formula)):
  193. if i+1 == len(formula) or formula[i] in ('+', '-', '*', '/'):
  194. s = alias[formula[b:i]] if i + \
  195. 1 < len(formula) else alias[formula[b:]]
  196. v = self.get_value(s, ridx)
  197. if not v:
  198. errs.append(s)
  199. else:
  200. f = f + "{0}(={1:.4f})".format(s, v[0])
  201. if sign == "*":
  202. stack[-1] = stack[-1] * v
  203. elif sign == "/":
  204. stack[-1] = stack[-1] / v
  205. elif sign == '-':
  206. stack.append(-v[0])
  207. else:
  208. stack.append(v[0])
  209. if i + 1 < len(formula):
  210. sign = formula[i]
  211. f += sign
  212. b = i + 1
  213. if len(errs) > 0:
  214. return -1, "Metric value missing: "+','.join(errs)
  215. val = sum(stack)
  216. return val, f
  217. # Relationships Tests
  218. def relationship_test(self, rule: dict):
  219. """
  220. Validate if the metrics follow the required relationship in the rule.
  221. eg. lower_bound <= eval(formula)<= upper_bound
  222. One rule is counted as ont test.
  223. Failure: when one or more metric result(s) not provided, or when formula evaluated outside of upper/lower bounds.
  224. @param rule: dict with metric name(+alias), formula, and required upper and lower bounds.
  225. """
  226. alias = dict()
  227. for m in rule['Metrics']:
  228. alias[m['Alias']] = m['Name']
  229. lbv, ubv, t = self.get_bounds(
  230. rule['RangeLower'], rule['RangeUpper'], rule['ErrorThreshold'], alias, ridx=rule['RuleIndex'])
  231. val, f = self.evaluate_formula(
  232. rule['Formula'], alias, ridx=rule['RuleIndex'])
  233. lb = rule['RangeLower']
  234. ub = rule['RangeUpper']
  235. if isinstance(lb, str):
  236. if lb in alias:
  237. lb = alias[lb]
  238. if isinstance(ub, str):
  239. if ub in alias:
  240. ub = alias[ub]
  241. if val == -1:
  242. self.errlist.append(TestError([m['Name'] for m in rule['Metrics']], self.workloads[self.wlidx], [],
  243. lb, ub, rule['Description']))
  244. elif not self.check_bound(val, lbv, ubv, t):
  245. self.errlist.append(TestError([m['Name'] for m in rule['Metrics']], self.workloads[self.wlidx], [val],
  246. lb, ub, rule['Description']))
  247. else:
  248. self.passedcnt += 1
  249. self.totalcnt += 1
  250. return
  251. # Single Metric Test
  252. def single_test(self, rule: dict):
  253. """
  254. Validate if the metrics are in the required value range.
  255. eg. lower_bound <= metrics_value <= upper_bound
  256. One metric is counted as one test in this type of test.
  257. One rule may include one or more metrics.
  258. Failure: when the metric value not provided or the value is outside the bounds.
  259. This test updates self.total_cnt.
  260. @param rule: dict with metrics to validate and the value range requirement
  261. """
  262. lbv, ubv, t = self.get_bounds(
  263. rule['RangeLower'], rule['RangeUpper'], rule['ErrorThreshold'])
  264. metrics = rule['Metrics']
  265. passcnt = 0
  266. totalcnt = 0
  267. failures = dict()
  268. rerun = list()
  269. for m in metrics:
  270. totalcnt += 1
  271. result = self.get_value(m['Name'])
  272. if len(result) > 0 and self.check_bound(result[0], lbv, ubv, t) or m['Name'] in self.skiplist:
  273. passcnt += 1
  274. else:
  275. failures[m['Name']] = result
  276. rerun.append(m['Name'])
  277. if len(rerun) > 0 and len(rerun) < 20:
  278. second_results = dict()
  279. self.second_test(rerun, second_results)
  280. for name, val in second_results.items():
  281. if name not in failures:
  282. continue
  283. if self.check_bound(val, lbv, ubv, t):
  284. passcnt += 1
  285. del failures[name]
  286. else:
  287. failures[name] = [val]
  288. self.results[0][name] = val
  289. self.totalcnt += totalcnt
  290. self.passedcnt += passcnt
  291. if len(failures.keys()) != 0:
  292. self.errlist.extend([TestError([name], self.workloads[self.wlidx], val,
  293. rule['RangeLower'], rule['RangeUpper']) for name, val in failures.items()])
  294. return
  295. def create_report(self):
  296. """
  297. Create final report and write into a JSON file.
  298. """
  299. print(self.errlist)
  300. if self.debug:
  301. allres = [{"Workload": self.workloads[i], "Results": self.allresults[i]}
  302. for i in range(0, len(self.workloads))]
  303. self.json_dump(allres, self.datafname)
  304. def check_rule(self, testtype, metric_list):
  305. """
  306. Check if the rule uses metric(s) that not exist in current platform.
  307. @param metric_list: list of metrics from the rule.
  308. @return: False when find one metric out in Metric file. (This rule should not skipped.)
  309. True when all metrics used in the rule are found in Metric file.
  310. """
  311. if testtype == "RelationshipTest":
  312. for m in metric_list:
  313. if m['Name'] not in self.metrics:
  314. return False
  315. return True
  316. # Start of Collector and Converter
  317. def convert(self, data: list, metricvalues: dict):
  318. """
  319. Convert collected metric data from the -j output to dict of {metric_name:value}.
  320. """
  321. for json_string in data:
  322. try:
  323. result = json.loads(json_string)
  324. if "metric-unit" in result and result["metric-unit"] != "(null)" and result["metric-unit"] != "":
  325. name = result["metric-unit"].split(" ")[1] if len(result["metric-unit"].split(" ")) > 1 \
  326. else result["metric-unit"]
  327. metricvalues[name.lower()] = float(result["metric-value"])
  328. except ValueError as error:
  329. continue
  330. return
  331. def _run_perf(self, metric, workload: str):
  332. tool = 'perf'
  333. command = [tool, 'stat', '-j', '-M', f"{metric}", "-a"]
  334. wl = workload.split()
  335. command.extend(wl)
  336. print(" ".join(command))
  337. cmd = subprocess.run(command, stderr=subprocess.PIPE, encoding='utf-8')
  338. data = [x+'}' for x in cmd.stderr.split('}\n') if x]
  339. if data[0][0] != '{':
  340. data[0] = data[0][data[0].find('{'):]
  341. return data
  342. def collect_perf(self, workload: str):
  343. """
  344. Collect metric data with "perf stat -M" on given workload with -a and -j.
  345. """
  346. self.results = dict()
  347. print(f"Starting perf collection")
  348. print(f"Long workload: {workload}")
  349. collectlist = dict()
  350. if self.collectlist != "":
  351. collectlist[0] = {x for x in self.collectlist.split(",")}
  352. else:
  353. collectlist[0] = set(list(self.metrics))
  354. # Create metric set for relationship rules
  355. for rule in self.rules:
  356. if rule["TestType"] == "RelationshipTest":
  357. metrics = [m["Name"] for m in rule["Metrics"]]
  358. if not any(m not in collectlist[0] for m in metrics):
  359. collectlist[rule["RuleIndex"]] = [
  360. ",".join(list(set(metrics)))]
  361. for idx, metrics in collectlist.items():
  362. if idx == 0:
  363. wl = "true"
  364. else:
  365. wl = workload
  366. for metric in metrics:
  367. data = self._run_perf(metric, wl)
  368. if idx not in self.results:
  369. self.results[idx] = dict()
  370. self.convert(data, self.results[idx])
  371. return
  372. def second_test(self, collectlist, second_results):
  373. workload = self.workloads[self.wlidx]
  374. for metric in collectlist:
  375. data = self._run_perf(metric, workload)
  376. self.convert(data, second_results)
  377. # End of Collector and Converter
  378. # Start of Rule Generator
  379. def parse_perf_metrics(self):
  380. """
  381. Read and parse perf metric file:
  382. 1) find metrics with '1%' or '100%' as ScaleUnit for Percent check
  383. 2) create metric name list
  384. """
  385. command = ['perf', 'list', '-j', '--details', 'metrics']
  386. cmd = subprocess.run(command, stdout=subprocess.PIPE,
  387. stderr=subprocess.PIPE, encoding='utf-8')
  388. try:
  389. data = json.loads(cmd.stdout)
  390. for m in data:
  391. if 'MetricName' not in m:
  392. print("Warning: no metric name")
  393. continue
  394. name = m['MetricName'].lower()
  395. self.metrics.add(name)
  396. if 'ScaleUnit' in m and (m['ScaleUnit'] == '1%' or m['ScaleUnit'] == '100%'):
  397. self.pctgmetrics.add(name.lower())
  398. except ValueError as error:
  399. print(f"Error when parsing metric data")
  400. sys.exit()
  401. return
  402. def remove_unsupported_rules(self, rules):
  403. new_rules = []
  404. for rule in rules:
  405. add_rule = True
  406. for m in rule["Metrics"]:
  407. if m["Name"] in self.skiplist or m["Name"] not in self.metrics:
  408. add_rule = False
  409. break
  410. if add_rule:
  411. new_rules.append(rule)
  412. return new_rules
  413. def create_rules(self):
  414. """
  415. Create full rules which includes:
  416. 1) All the rules from the "relationshi_rules" file
  417. 2) SingleMetric rule for all the 'percent' metrics
  418. Reindex all the rules to avoid repeated RuleIndex
  419. """
  420. data = self.read_json(self.rulefname)
  421. rules = data['RelationshipRules']
  422. self.skiplist = set([name.lower() for name in data['SkipList']])
  423. self.rules = self.remove_unsupported_rules(rules)
  424. pctgrule = {'RuleIndex': 0,
  425. 'TestType': 'SingleMetricTest',
  426. 'RangeLower': '0',
  427. 'RangeUpper': '100',
  428. 'ErrorThreshold': self.tolerance,
  429. 'Description': 'Metrics in percent unit have value with in [0, 100]',
  430. 'Metrics': [{'Name': m.lower()} for m in self.pctgmetrics]}
  431. self.rules.append(pctgrule)
  432. # Re-index all rules to avoid repeated RuleIndex
  433. idx = 1
  434. for r in self.rules:
  435. r['RuleIndex'] = idx
  436. idx += 1
  437. if self.debug:
  438. # TODO: need to test and generate file name correctly
  439. data = {'RelationshipRules': self.rules, 'SupportedMetrics': [
  440. {"MetricName": name} for name in self.metrics]}
  441. self.json_dump(data, self.fullrulefname)
  442. return
  443. # End of Rule Generator
  444. def _storewldata(self, key):
  445. '''
  446. Store all the data of one workload into the corresponding data structure for all workloads.
  447. @param key: key to the dictionaries (index of self.workloads).
  448. '''
  449. self.allresults[key] = self.results
  450. self.alltotalcnt[key] = self.totalcnt
  451. self.allpassedcnt[key] = self.passedcnt
  452. # Initialize data structures before data validation of each workload
  453. def _init_data(self):
  454. testtypes = ['PositiveValueTest',
  455. 'RelationshipTest', 'SingleMetricTest']
  456. self.results = dict()
  457. self.ignoremetrics = set()
  458. self.errlist = list()
  459. self.totalcnt = 0
  460. self.passedcnt = 0
  461. def test(self):
  462. '''
  463. The real entry point of the test framework.
  464. This function loads the validation rule JSON file and Standard Metric file to create rules for
  465. testing and namemap dictionaries.
  466. It also reads in result JSON file for testing.
  467. In the test process, it passes through each rule and launch correct test function bases on the
  468. 'TestType' field of the rule.
  469. The final report is written into a JSON file.
  470. '''
  471. if not self.collectlist:
  472. self.parse_perf_metrics()
  473. if not self.metrics:
  474. print("No metric found for testing")
  475. return 0
  476. self.create_rules()
  477. for i in range(0, len(self.workloads)):
  478. self.wlidx = i
  479. self._init_data()
  480. self.collect_perf(self.workloads[i])
  481. # Run positive value test
  482. self.pos_val_test()
  483. for r in self.rules:
  484. # skip rules that uses metrics not exist in this platform
  485. testtype = r['TestType']
  486. if not self.check_rule(testtype, r['Metrics']):
  487. continue
  488. if testtype == 'RelationshipTest':
  489. self.relationship_test(r)
  490. elif testtype == 'SingleMetricTest':
  491. self.single_test(r)
  492. else:
  493. print("Unsupported Test Type: ", testtype)
  494. print("Workload: ", self.workloads[i])
  495. print("Total Test Count: ", self.totalcnt)
  496. print("Passed Test Count: ", self.passedcnt)
  497. self._storewldata(i)
  498. self.create_report()
  499. return len(self.errlist) > 0
  500. # End of Class Validator
  501. def main() -> None:
  502. parser = argparse.ArgumentParser(
  503. description="Launch metric value validation")
  504. parser.add_argument(
  505. "-rule", help="Base validation rule file", required=True)
  506. parser.add_argument(
  507. "-output_dir", help="Path for validator output file, report file", required=True)
  508. parser.add_argument("-debug", help="Debug run, save intermediate data to files",
  509. action="store_true", default=False)
  510. parser.add_argument(
  511. "-wl", help="Workload to run while data collection", default="true")
  512. parser.add_argument("-m", help="Metric list to validate", default="")
  513. args = parser.parse_args()
  514. outpath = Path(args.output_dir)
  515. reportf = Path.joinpath(outpath, 'perf_report.json')
  516. fullrule = Path.joinpath(outpath, 'full_rule.json')
  517. datafile = Path.joinpath(outpath, 'perf_data.json')
  518. validator = Validator(args.rule, reportf, debug=args.debug,
  519. datafname=datafile, fullrulefname=fullrule, workload=args.wl,
  520. metrics=args.m)
  521. ret = validator.test()
  522. return ret
  523. if __name__ == "__main__":
  524. import sys
  525. sys.exit(main())