598 lines · python
1# SPDX-License-Identifier: GPL-2.02import re3import csv4import json5import argparse6from pathlib import Path7import subprocess8 9 10class TestError:11 def __init__(self, metric: list[str], wl: str, value: list[float], low: float, up=float('nan'), description=str()):12 self.metric: list = metric # multiple metrics in relationship type tests13 self.workloads = [wl] # multiple workloads possible14 self.collectedValue: list = value15 self.valueLowBound = low16 self.valueUpBound = up17 self.description = description18 19 def __repr__(self) -> str:20 if len(self.metric) > 1:21 return "\nMetric Relationship Error: \tThe collected value of metric {0}\n\22 \tis {1} in workload(s): {2} \n\23 \tbut expected value range is [{3}, {4}]\n\24 \tRelationship rule description: \'{5}\'".format(self.metric, self.collectedValue, self.workloads,25 self.valueLowBound, self.valueUpBound, self.description)26 elif len(self.collectedValue) == 0:27 return "\nNo Metric Value Error: \tMetric {0} returns with no value \n\28 \tworkload(s): {1}".format(self.metric, self.workloads)29 else:30 return "\nWrong Metric Value Error: \tThe collected value of metric {0}\n\31 \tis {1} in workload(s): {2}\n\32 \tbut expected value range is [{3}, {4}]"\33 .format(self.metric, self.collectedValue, self.workloads,34 self.valueLowBound, self.valueUpBound)35 36 37class Validator:38 def __init__(self, rulefname, reportfname='', t=5, debug=False, datafname='', fullrulefname='', workload='true', metrics=''):39 self.rulefname = rulefname40 self.reportfname = reportfname41 self.rules = None42 self.collectlist: str = metrics43 self.metrics = self.__set_metrics(metrics)44 self.skiplist = set()45 self.tolerance = t46 47 self.workloads = [x for x in workload.split(",") if x]48 self.wlidx = 0 # idx of current workloads49 self.allresults = dict() # metric results of all workload50 self.alltotalcnt = dict()51 self.allpassedcnt = dict()52 53 self.results = dict() # metric results of current workload54 # vars for test pass/failure statistics55 # metrics with no results or negative results, neg result counts failed tests56 self.ignoremetrics = set()57 self.totalcnt = 058 self.passedcnt = 059 # vars for errors60 self.errlist = list()61 62 # vars for Rule Generator63 self.pctgmetrics = set() # Percentage rule64 65 # vars for debug66 self.datafname = datafname67 self.debug = debug68 self.fullrulefname = fullrulefname69 70 def __set_metrics(self, metrics=''):71 if metrics != '':72 return set(metrics.split(","))73 else:74 return set()75 76 def read_json(self, filename: str) -> dict:77 try:78 with open(Path(filename).resolve(), "r") as f:79 data = json.loads(f.read())80 except OSError as e:81 print(f"Error when reading file {e}")82 sys.exit()83 84 return data85 86 def json_dump(self, data, output_file):87 parent = Path(output_file).parent88 if not parent.exists():89 parent.mkdir(parents=True)90 91 with open(output_file, "w+") as output_file:92 json.dump(data,93 output_file,94 ensure_ascii=True,95 indent=4)96 97 def get_results(self, idx: int = 0):98 return self.results.get(idx)99 100 def get_bounds(self, lb, ub, error, alias={}, ridx: int = 0) -> list:101 """102 Get bounds and tolerance from lb, ub, and error.103 If missing lb, use 0.0; missing ub, use float('inf); missing error, use self.tolerance.104 105 @param lb: str/float, lower bound106 @param ub: str/float, upper bound107 @param error: float/str, error tolerance108 @returns: lower bound, return inf if the lower bound is a metric value and is not collected109 upper bound, return -1 if the upper bound is a metric value and is not collected110 tolerance, denormalized base on upper bound value111 """112 # init ubv and lbv to invalid values113 def get_bound_value(bound, initval, ridx):114 val = initval115 if isinstance(bound, int) or isinstance(bound, float):116 val = bound117 elif isinstance(bound, str):118 if bound == '':119 val = float("inf")120 elif bound in alias:121 vall = self.get_value(alias[ub], ridx)122 if vall:123 val = vall[0]124 elif bound.replace('.', '1').isdigit():125 val = float(bound)126 else:127 print("Wrong bound: {0}".format(bound))128 else:129 print("Wrong bound: {0}".format(bound))130 return val131 132 ubv = get_bound_value(ub, -1, ridx)133 lbv = get_bound_value(lb, float('inf'), ridx)134 t = get_bound_value(error, self.tolerance, ridx)135 136 # denormalize error threshold137 denormerr = t * ubv / 100 if ubv != 100 and ubv > 0 else t138 139 return lbv, ubv, denormerr140 141 def get_value(self, name: str, ridx: int = 0) -> list:142 """143 Get value of the metric from self.results.144 If result of this metric is not provided, the metric name will be added into self.ignoremetics.145 All future test(s) on this metric will fail.146 147 @param name: name of the metric148 @returns: list with value found in self.results; list is empty when value is not found.149 """150 results = []151 data = self.results[ridx] if ridx in self.results else self.results[0]152 if name not in self.ignoremetrics:153 if name in data:154 results.append(data[name])155 elif name.replace('.', '1').isdigit():156 results.append(float(name))157 else:158 self.ignoremetrics.add(name)159 return results160 161 def check_bound(self, val, lb, ub, err):162 return True if val <= ub + err and val >= lb - err else False163 164 # Positive Value Sanity check165 def pos_val_test(self):166 """167 Check if metrics value are non-negative.168 One metric is counted as one test.169 Failure: when metric value is negative or not provided.170 Metrics with negative value will be added into self.ignoremetrics.171 """172 negmetric = dict()173 pcnt = 0174 tcnt = 0175 rerun = list()176 results = self.get_results()177 if not results:178 return179 for name, val in results.items():180 if val < 0:181 negmetric[name] = val182 rerun.append(name)183 else:184 pcnt += 1185 tcnt += 1186 # The first round collect_perf() run these metrics with simple workload187 # "true". We give metrics a second chance with a longer workload if less188 # than 20 metrics failed positive test.189 if len(rerun) > 0 and len(rerun) < 20:190 second_results = dict()191 self.second_test(rerun, second_results)192 for name, val in second_results.items():193 if name not in negmetric:194 continue195 if val >= 0:196 del negmetric[name]197 pcnt += 1198 199 if len(negmetric.keys()):200 self.ignoremetrics.update(negmetric.keys())201 self.errlist.extend(202 [TestError([m], self.workloads[self.wlidx], negmetric[m], 0) for m in negmetric.keys()])203 204 return205 206 def evaluate_formula(self, formula: str, alias: dict, ridx: int = 0):207 """208 Evaluate the value of formula.209 210 @param formula: the formula to be evaluated211 @param alias: the dict has alias to metric name mapping212 @returns: value of the formula is success; -1 if the one or more metric value not provided213 """214 stack = []215 b = 0216 errs = []217 sign = "+"218 f = str()219 220 # TODO: support parenthesis?221 for i in range(len(formula)):222 if i+1 == len(formula) or formula[i] in ('+', '-', '*', '/'):223 s = alias[formula[b:i]] if i + \224 1 < len(formula) else alias[formula[b:]]225 v = self.get_value(s, ridx)226 if not v:227 errs.append(s)228 else:229 f = f + "{0}(={1:.4f})".format(s, v[0])230 if sign == "*":231 stack[-1] = stack[-1] * v232 elif sign == "/":233 stack[-1] = stack[-1] / v234 elif sign == '-':235 stack.append(-v[0])236 else:237 stack.append(v[0])238 if i + 1 < len(formula):239 sign = formula[i]240 f += sign241 b = i + 1242 243 if len(errs) > 0:244 return -1, "Metric value missing: "+','.join(errs)245 246 val = sum(stack)247 return val, f248 249 # Relationships Tests250 def relationship_test(self, rule: dict):251 """252 Validate if the metrics follow the required relationship in the rule.253 eg. lower_bound <= eval(formula)<= upper_bound254 One rule is counted as ont test.255 Failure: when one or more metric result(s) not provided, or when formula evaluated outside of upper/lower bounds.256 257 @param rule: dict with metric name(+alias), formula, and required upper and lower bounds.258 """259 alias = dict()260 for m in rule['Metrics']:261 alias[m['Alias']] = m['Name']262 lbv, ubv, t = self.get_bounds(263 rule['RangeLower'], rule['RangeUpper'], rule['ErrorThreshold'], alias, ridx=rule['RuleIndex'])264 val, f = self.evaluate_formula(265 rule['Formula'], alias, ridx=rule['RuleIndex'])266 267 lb = rule['RangeLower']268 ub = rule['RangeUpper']269 if isinstance(lb, str):270 if lb in alias:271 lb = alias[lb]272 if isinstance(ub, str):273 if ub in alias:274 ub = alias[ub]275 276 if val == -1:277 self.errlist.append(TestError([m['Name'] for m in rule['Metrics']], self.workloads[self.wlidx], [],278 lb, ub, rule['Description']))279 elif not self.check_bound(val, lbv, ubv, t):280 self.errlist.append(TestError([m['Name'] for m in rule['Metrics']], self.workloads[self.wlidx], [val],281 lb, ub, rule['Description']))282 else:283 self.passedcnt += 1284 self.totalcnt += 1285 286 return287 288 # Single Metric Test289 def single_test(self, rule: dict):290 """291 Validate if the metrics are in the required value range.292 eg. lower_bound <= metrics_value <= upper_bound293 One metric is counted as one test in this type of test.294 One rule may include one or more metrics.295 Failure: when the metric value not provided or the value is outside the bounds.296 This test updates self.total_cnt.297 298 @param rule: dict with metrics to validate and the value range requirement299 """300 lbv, ubv, t = self.get_bounds(301 rule['RangeLower'], rule['RangeUpper'], rule['ErrorThreshold'])302 metrics = rule['Metrics']303 passcnt = 0304 totalcnt = 0305 failures = dict()306 rerun = list()307 for m in metrics:308 totalcnt += 1309 result = self.get_value(m['Name'])310 if len(result) > 0 and self.check_bound(result[0], lbv, ubv, t) or m['Name'] in self.skiplist:311 passcnt += 1312 else:313 failures[m['Name']] = result314 rerun.append(m['Name'])315 316 if len(rerun) > 0 and len(rerun) < 20:317 second_results = dict()318 self.second_test(rerun, second_results)319 for name, val in second_results.items():320 if name not in failures:321 continue322 if self.check_bound(val, lbv, ubv, t):323 passcnt += 1324 del failures[name]325 else:326 failures[name] = [val]327 self.results[0][name] = val328 329 self.totalcnt += totalcnt330 self.passedcnt += passcnt331 if len(failures.keys()) != 0:332 self.errlist.extend([TestError([name], self.workloads[self.wlidx], val,333 rule['RangeLower'], rule['RangeUpper']) for name, val in failures.items()])334 335 return336 337 def create_report(self):338 """339 Create final report and write into a JSON file.340 """341 print(self.errlist)342 343 if self.debug:344 allres = [{"Workload": self.workloads[i], "Results": self.allresults[i]}345 for i in range(0, len(self.workloads))]346 self.json_dump(allres, self.datafname)347 348 def check_rule(self, testtype, metric_list):349 """350 Check if the rule uses metric(s) that not exist in current platform.351 352 @param metric_list: list of metrics from the rule.353 @return: False when find one metric out in Metric file. (This rule should not skipped.)354 True when all metrics used in the rule are found in Metric file.355 """356 if testtype == "RelationshipTest":357 for m in metric_list:358 if m['Name'] not in self.metrics:359 return False360 return True361 362 # Start of Collector and Converter363 def convert(self, data: list, metricvalues: dict):364 """365 Convert collected metric data from the -j output to dict of {metric_name:value}.366 """367 for json_string in data:368 try:369 result = json.loads(json_string)370 if "metric-unit" in result and result["metric-unit"] != "(null)" and result["metric-unit"] != "":371 name = result["metric-unit"].split(" ")[1] if len(result["metric-unit"].split(" ")) > 1 \372 else result["metric-unit"]373 metricvalues[name.lower()] = float(result["metric-value"])374 except ValueError as error:375 continue376 return377 378 def _run_perf(self, metric, workload: str):379 tool = 'perf'380 command = [tool, 'stat', '-j', '-M', f"{metric}", "-a"]381 wl = workload.split()382 command.extend(wl)383 print(" ".join(command))384 cmd = subprocess.run(command, stderr=subprocess.PIPE, encoding='utf-8')385 data = [x+'}' for x in cmd.stderr.split('}\n') if x]386 if data[0][0] != '{':387 data[0] = data[0][data[0].find('{'):]388 return data389 390 def collect_perf(self, workload: str):391 """392 Collect metric data with "perf stat -M" on given workload with -a and -j.393 """394 self.results = dict()395 print(f"Starting perf collection")396 print(f"Long workload: {workload}")397 collectlist = dict()398 if self.collectlist != "":399 collectlist[0] = {x for x in self.collectlist.split(",")}400 else:401 collectlist[0] = set(list(self.metrics))402 # Create metric set for relationship rules403 for rule in self.rules:404 if rule["TestType"] == "RelationshipTest":405 metrics = [m["Name"] for m in rule["Metrics"]]406 if not any(m not in collectlist[0] for m in metrics):407 collectlist[rule["RuleIndex"]] = [408 ",".join(list(set(metrics)))]409 410 for idx, metrics in collectlist.items():411 if idx == 0:412 wl = "true"413 else:414 wl = workload415 for metric in metrics:416 data = self._run_perf(metric, wl)417 if idx not in self.results:418 self.results[idx] = dict()419 self.convert(data, self.results[idx])420 return421 422 def second_test(self, collectlist, second_results):423 workload = self.workloads[self.wlidx]424 for metric in collectlist:425 data = self._run_perf(metric, workload)426 self.convert(data, second_results)427 428 # End of Collector and Converter429 430 # Start of Rule Generator431 def parse_perf_metrics(self):432 """433 Read and parse perf metric file:434 1) find metrics with '1%' or '100%' as ScaleUnit for Percent check435 2) create metric name list436 """437 command = ['perf', 'list', '-j', '--details', 'metrics']438 cmd = subprocess.run(command, stdout=subprocess.PIPE,439 stderr=subprocess.PIPE, encoding='utf-8')440 try:441 data = json.loads(cmd.stdout)442 for m in data:443 if 'MetricName' not in m:444 print("Warning: no metric name")445 continue446 name = m['MetricName'].lower()447 self.metrics.add(name)448 if 'ScaleUnit' in m and (m['ScaleUnit'] == '1%' or m['ScaleUnit'] == '100%'):449 self.pctgmetrics.add(name.lower())450 except ValueError as error:451 print(f"Error when parsing metric data")452 sys.exit()453 454 return455 456 def remove_unsupported_rules(self, rules):457 new_rules = []458 for rule in rules:459 add_rule = True460 for m in rule["Metrics"]:461 if m["Name"] in self.skiplist or m["Name"] not in self.metrics:462 add_rule = False463 break464 if add_rule:465 new_rules.append(rule)466 return new_rules467 468 def create_rules(self):469 """470 Create full rules which includes:471 1) All the rules from the "relationshi_rules" file472 2) SingleMetric rule for all the 'percent' metrics473 474 Reindex all the rules to avoid repeated RuleIndex475 """476 data = self.read_json(self.rulefname)477 rules = data['RelationshipRules']478 self.skiplist = set([name.lower() for name in data['SkipList']])479 self.rules = self.remove_unsupported_rules(rules)480 pctgrule = {'RuleIndex': 0,481 'TestType': 'SingleMetricTest',482 'RangeLower': '0',483 'RangeUpper': '100',484 'ErrorThreshold': self.tolerance,485 'Description': 'Metrics in percent unit have value with in [0, 100]',486 'Metrics': [{'Name': m.lower()} for m in self.pctgmetrics]}487 self.rules.append(pctgrule)488 489 # Re-index all rules to avoid repeated RuleIndex490 idx = 1491 for r in self.rules:492 r['RuleIndex'] = idx493 idx += 1494 495 if self.debug:496 # TODO: need to test and generate file name correctly497 data = {'RelationshipRules': self.rules, 'SupportedMetrics': [498 {"MetricName": name} for name in self.metrics]}499 self.json_dump(data, self.fullrulefname)500 501 return502 # End of Rule Generator503 504 def _storewldata(self, key):505 '''506 Store all the data of one workload into the corresponding data structure for all workloads.507 @param key: key to the dictionaries (index of self.workloads).508 '''509 self.allresults[key] = self.results510 self.alltotalcnt[key] = self.totalcnt511 self.allpassedcnt[key] = self.passedcnt512 513 # Initialize data structures before data validation of each workload514 def _init_data(self):515 516 testtypes = ['PositiveValueTest',517 'RelationshipTest', 'SingleMetricTest']518 self.results = dict()519 self.ignoremetrics = set()520 self.errlist = list()521 self.totalcnt = 0522 self.passedcnt = 0523 524 def test(self):525 '''526 The real entry point of the test framework.527 This function loads the validation rule JSON file and Standard Metric file to create rules for528 testing and namemap dictionaries.529 It also reads in result JSON file for testing.530 531 In the test process, it passes through each rule and launch correct test function bases on the532 'TestType' field of the rule.533 534 The final report is written into a JSON file.535 '''536 if not self.collectlist:537 self.parse_perf_metrics()538 if not self.metrics:539 print("No metric found for testing")540 return 0541 self.create_rules()542 for i in range(0, len(self.workloads)):543 self.wlidx = i544 self._init_data()545 self.collect_perf(self.workloads[i])546 # Run positive value test547 self.pos_val_test()548 for r in self.rules:549 # skip rules that uses metrics not exist in this platform550 testtype = r['TestType']551 if not self.check_rule(testtype, r['Metrics']):552 continue553 if testtype == 'RelationshipTest':554 self.relationship_test(r)555 elif testtype == 'SingleMetricTest':556 self.single_test(r)557 else:558 print("Unsupported Test Type: ", testtype)559 print("Workload: ", self.workloads[i])560 print("Total Test Count: ", self.totalcnt)561 print("Passed Test Count: ", self.passedcnt)562 self._storewldata(i)563 self.create_report()564 return len(self.errlist) > 0565# End of Class Validator566 567 568def main() -> None:569 parser = argparse.ArgumentParser(570 description="Launch metric value validation")571 572 parser.add_argument(573 "-rule", help="Base validation rule file", required=True)574 parser.add_argument(575 "-output_dir", help="Path for validator output file, report file", required=True)576 parser.add_argument("-debug", help="Debug run, save intermediate data to files",577 action="store_true", default=False)578 parser.add_argument(579 "-wl", help="Workload to run while data collection", default="true")580 parser.add_argument("-m", help="Metric list to validate", default="")581 args = parser.parse_args()582 outpath = Path(args.output_dir)583 reportf = Path.joinpath(outpath, 'perf_report.json')584 fullrule = Path.joinpath(outpath, 'full_rule.json')585 datafile = Path.joinpath(outpath, 'perf_data.json')586 587 validator = Validator(args.rule, reportf, debug=args.debug,588 datafname=datafile, fullrulefname=fullrule, workload=args.wl,589 metrics=args.m)590 ret = validator.test()591 592 return ret593 594 595if __name__ == "__main__":596 import sys597 sys.exit(main())598