Skip to content

Commit d8a7572

Browse files
authored
Allow executable parsers for benchcomp (rust-lang#2521)
Users can now specify a command that will be run to parse the result of a single suite x variant run, as an alternative to specifying a python module that is checked into the Kani codebase. This allows for parsers to be maintained outside the Kani codebase.
1 parent cfa6968 commit d8a7572

File tree

5 files changed

+226
-8
lines changed

5 files changed

+226
-8
lines changed

docs/src/benchcomp-parse.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Custom parsers
2+
3+
Benchcomp ships with built-in *parsers* that retrieve the results of a benchmark suite after the run has completed.
4+
You can also create your own parser, either to run locally or to check into the Kani codebase.
5+
6+
## Built-in parsers
7+
8+
You specify which parser should run for each benchmark suite in `benchcomp.yaml`.
9+
For example, if you're running the kani performance suite, you would use the built-in `kani_perf` parser to parse the results:
10+
11+
```yaml
12+
suites:
13+
my_benchmark_suite:
14+
variants: [variant_1, variant_2]
15+
parser:
16+
module: kani_perf
17+
```
18+
19+
## Custom parsers
20+
21+
A parser is a program that benchcomp runs inside the root directory of a benchmark suite, after the suite run has completed.
22+
The parser should retrieve the results of the run (by parsing output files etc.) and print the results out as a YAML document.
23+
You can use your executable parser by specifying the `command` key rather than the `module` key in your `benchconf.yaml` file:
24+
25+
```yaml
26+
suites:
27+
my_benchmark_suite:
28+
variants: [variant_1, variant_2]
29+
parser:
30+
command: ./my-cool-parser.sh
31+
```
32+
33+
The `kani_perf` parser mentioned above, in `tools/benchcomp/benchcomp/parsers/kani_perf.py`, is a good starting point for writing a custom parser, as it also works as a standalone executable.
34+
Here is an example output from an executable parser:
35+
36+
```yaml
37+
metrics:
38+
runtime: {}
39+
success: {}
40+
errors: {}
41+
benchmarks:
42+
bench_1:
43+
metrics:
44+
runtime: 32
45+
success: true
46+
errors: []
47+
bench_2:
48+
metrics:
49+
runtime: 0
50+
success: false
51+
errors: ["compilation failed"]
52+
```
53+
54+
The above format is different from the final `result.yaml` file that benchcomp writes, because the above file represents the output of running a single benchmark suite using a single variant.
55+
Your parser will run once for each variant, and benchcomp combines the dictionaries into the final `result.yaml` file.
56+
57+
58+
## Contributing custom parsers to Kani
59+
60+
To turn your executable parser into one that benchcomp can invoke as a module, ensure that it has a `main(working_directory)` method that returns a dict (the same dict that it would print out as a YAML file to stdout).
61+
Save the file in `tools/benchcomp/benchcomp/parsers` using python module naming conventions (filename should be an identifier and end in `.py`).

tools/benchcomp/benchcomp/entry/run.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,18 @@
1010

1111

1212
import dataclasses
13-
import importlib
1413
import logging
1514
import os
1615
import pathlib
1716
import shutil
1817
import subprocess
18+
import typing
1919
import uuid
2020

2121
import yaml
2222

2323
import benchcomp
24+
import benchcomp.parsers
2425

2526

2627
@dataclasses.dataclass
@@ -30,7 +31,7 @@ class _SingleInvocation:
3031
suite_id: str
3132
variant_id: str
3233

33-
parser: str
34+
parse: typing.Any
3435

3536
suite_yaml_out_dir: pathlib.Path
3637
copy_benchmarks_dir: bool
@@ -73,9 +74,7 @@ def __call__(self):
7374
"Invocation of suite %s with variant %s failed", self.suite_id,
7475
self.variant_id)
7576

76-
parser_mod_name = f"benchcomp.parsers.{self.parser}"
77-
parser = importlib.import_module(parser_mod_name)
78-
suite = parser.main(self.working_copy)
77+
suite = self.parse(self.working_copy)
7978

8079
suite["suite_id"] = self.suite_id
8180
suite["variant_id"] = self.variant_id
@@ -103,13 +102,13 @@ def __call__(self):
103102
out_path.mkdir(parents=True)
104103

105104
for suite_id, suite in self.config["run"]["suites"].items():
105+
parse = benchcomp.parsers.get_parser(suite["parser"])
106106
for variant_id in suite["variants"]:
107107
variant = self.config["variants"][variant_id]
108108
config = dict(variant).pop("config")
109109
invoke = _SingleInvocation(
110110
suite_id, variant_id,
111-
suite["parser"]["module"],
112-
suite_yaml_out_dir=out_path,
111+
parse, suite_yaml_out_dir=out_path,
113112
copy_benchmarks_dir=self.copy_benchmarks_dir,
114113
**config)
115114
invoke()
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,98 @@
11
# Copyright Kani Contributors
22
# SPDX-License-Identifier: Apache-2.0 OR MIT
3+
#
4+
# Each *Parser class here specifies a different way that a parser can be
5+
# invoked: as an executable (for parsers that users write on their local
6+
# machine) or python module (that is checked into the Kani codebase).
7+
8+
# Each class has a __call__ method that takes a directory. The directory should
9+
# be a benchmark suite that has completed a run. The __call__ method parses and
10+
# returns the result of the run (by parsing output files in the directory etc).
11+
12+
13+
import dataclasses
14+
import subprocess
15+
import logging
16+
import importlib
17+
import sys
18+
19+
import yaml
20+
21+
22+
def get_parser(parser_config):
23+
if "module" in parser_config:
24+
return _ModuleParser(parser_config["module"])
25+
if "command" in parser_config:
26+
return _CommandParser(parser_config["command"])
27+
28+
logging.error(
29+
"Parser dict should contain either a"
30+
"'module' or 'command' key: '%s'", str(parser_config))
31+
sys.exit(1)
32+
33+
34+
35+
class _ModuleParser:
36+
"""A parser implemented as a module under benchcomp.parsers"""
37+
38+
def __init__(self, mod):
39+
self.parser_mod_name = f"benchcomp.parsers.{mod}"
40+
try:
41+
self.parser = importlib.import_module(self.parser_mod_name)
42+
except BaseException as exe:
43+
logging.error(
44+
"Failed to load parser module %s: %s",
45+
self.parser_mod_name, str(exe))
46+
sys.exit(1)
47+
48+
49+
def __call__(self, root_directory):
50+
try:
51+
return self.parser.main(root_directory)
52+
except BaseException as exe:
53+
logging.error(
54+
"Parser '%s' in directory %s failed: %s",
55+
self.parser_mod_name, str(root_directory), str(exe))
56+
return get_empty_parser_result()
57+
58+
59+
60+
@dataclasses.dataclass
61+
class _CommandParser:
62+
"""A parser that is a command that prints the parse result to stdout"""
63+
64+
shell_cmd: str
65+
66+
67+
def __call__(self, root_directory):
68+
try:
69+
with subprocess.Popen(
70+
self.shell_cmd, shell=True, text=True,
71+
stdout=subprocess.PIPE, cwd=root_directory) as proc:
72+
out, _ = proc.communicate(timeout=120)
73+
except subprocess.CalledProcessError as exc:
74+
logging.warning(
75+
"Invocation of parser '%s' in directory %s exited with code %d",
76+
self.shell_cmd, str(root_directory), exc.returncode)
77+
return get_empty_parser_result()
78+
except (OSError, subprocess.SubprocessError) as exe:
79+
logging.error(
80+
"Invocation of parser '%s' in directory %s failed: %s",
81+
self.shell_cmd, str(root_directory), str(exe))
82+
return get_empty_parser_result()
83+
84+
try:
85+
return yaml.safe_load(out)
86+
except yaml.YAMLError:
87+
logging.error(
88+
"Parser '%s' in directory %s printed invalid YAML:<%s>",
89+
self.shell_cmd, str(root_directory), out)
90+
return get_empty_parser_result()
91+
92+
93+
94+
def get_empty_parser_result():
95+
return {
96+
"benchmarks": {},
97+
"metrics": {},
98+
}

tools/benchcomp/benchcomp/parsers/kani_perf.py

100644100755
Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,14 @@
22
# SPDX-License-Identifier: Apache-2.0 OR MIT
33

44

5+
import os
56
import pathlib
6-
import textwrap
77
import re
8+
import textwrap
9+
10+
import yaml
11+
12+
import benchcomp.parsers
813

914

1015
def get_description():
@@ -107,3 +112,13 @@ def main(root_dir):
107112
"metrics": get_metrics(),
108113
"benchmarks": benchmarks,
109114
}
115+
116+
117+
if __name__ == "__main__":
118+
try:
119+
result = main(os.getcwd())
120+
print(yaml.dump(result, default_flow_style=False))
121+
except BaseException:
122+
print(yaml.dump(
123+
benchcomp.parsers.get_empty_parser_result(),
124+
default_flow_style=False))

tools/benchcomp/test/test_regression.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,3 +690,50 @@ def test_env(self):
690690
result["benchmarks"]["suite_1"]["variants"][
691691
"env_unset"]["metrics"]["foos"], 0,
692692
msg=yaml.dump(result, default_flow_style=False))
693+
694+
695+
def test_command_parser(self):
696+
"""Ensure that CommandParser can execute and read the output of a parser"""
697+
698+
with tempfile.TemporaryDirectory() as tmp:
699+
run_bc = Benchcomp({
700+
"variants": {
701+
"v1": {
702+
"config": {
703+
"command_line": "true",
704+
"directory": tmp,
705+
}
706+
},
707+
"v2": {
708+
"config": {
709+
"command_line": "true",
710+
"directory": tmp,
711+
}
712+
}
713+
},
714+
"run": {
715+
"suites": {
716+
"suite_1": {
717+
"parser": {
718+
"command": """
719+
echo '{
720+
"benchmarks": {},
721+
"metrics": {}
722+
}'
723+
"""
724+
},
725+
"variants": ["v2", "v1"]
726+
}
727+
}
728+
},
729+
"visualize": [],
730+
})
731+
run_bc()
732+
self.assertEqual(
733+
run_bc.proc.returncode, 0, msg=run_bc.stderr)
734+
735+
with open(run_bc.working_directory / "result.yaml") as handle:
736+
result = yaml.safe_load(handle)
737+
738+
for item in ["benchmarks", "metrics"]:
739+
self.assertIn(item, result)

0 commit comments

Comments
 (0)