Skip to content

Commit 506bfc4

Browse files
authored
Add benchcomp filter command (#3105)
This allows benchcomp to pass the list of results to an external program for modification before the results are visualized. This can be used, for example, to visualize only a relevant subset of results. By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 and MIT licenses.
1 parent e4a90e9 commit 506bfc4

File tree

6 files changed

+227
-6
lines changed

6 files changed

+227
-6
lines changed

docs/src/benchcomp-conf.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,25 @@ variants:
2424
```
2525
2626
27+
## Filters
28+
29+
After benchcomp has finished parsing the results, it writes the results to `results.yaml` by default.
30+
Before visualizing the results (see below), benchcomp can *filter* the results by piping them into an external program.
31+
32+
To filter results before visualizing them, add `filters` to the configuration file.
33+
34+
```yaml
35+
filters:
36+
- command_line: ./scripts/remove-redundant-results.py
37+
- command_line: cat
38+
```
39+
40+
The value of `filters` is a list of dicts.
41+
Currently the only legal key for each of the dicts is `command_line`.
42+
Benchcomp invokes each `command_line` in order, passing the results as a JSON file on stdin, and interprets the stdout as a YAML-formatted modified set of results.
43+
Filter scripts can emit either YAML (which might be more readable while developing the script), or JSON (which benchcomp will parse as a subset of YAML).
44+
45+
2746
## Built-in visualizations
2847

2948
The following visualizations are available; these can be added to the `visualize` list of `benchcomp.yaml`.

tools/benchcomp/benchcomp/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,16 @@ class ConfigFile(collections.UserDict):
6262
anyof:
6363
- schema:
6464
type: {}
65-
filter: {}
65+
filters:
66+
type: list
67+
default: []
68+
schema:
69+
type: dict
70+
keysrules:
71+
type: string
72+
allowed: ["command_line"]
73+
valuesrules:
74+
type: string
6675
visualize: {}
6776
""")
6877

tools/benchcomp/benchcomp/cmd_args.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,13 @@ def _get_args_dict():
170170
},
171171
"filter": {
172172
"help": "transform a result by piping it through a program",
173-
"args": [],
173+
"args": [{
174+
"flags": ["--result-file"],
175+
"metavar": "F",
176+
"default": pathlib.Path("result.yaml"),
177+
"type": pathlib.Path,
178+
"help": "read result from F instead of %(default)s."
179+
}],
174180
},
175181
"visualize": {
176182
"help": "render a result in various formats",
@@ -180,7 +186,7 @@ def _get_args_dict():
180186
"default": pathlib.Path("result.yaml"),
181187
"type": pathlib.Path,
182188
"help":
183-
"read result from F instead of %(default)s. "
189+
"read result from F instead of %(default)s."
184190
}, {
185191
"flags": ["--only"],
186192
"nargs": "+",
@@ -234,6 +240,11 @@ def get():
234240

235241
subparsers = ad["subparsers"].pop("parsers")
236242
subs = parser.add_subparsers(**ad["subparsers"])
243+
244+
# Add all subcommand-specific flags to the top-level argument parser,
245+
# but only add them once.
246+
flag_set = set()
247+
237248
for subcommand, info in subparsers.items():
238249
args = info.pop("args")
239250
subparser = subs.add_parser(name=subcommand, **info)
@@ -246,7 +257,9 @@ def get():
246257
for arg in args:
247258
flags = arg.pop("flags")
248259
subparser.add_argument(*flags, **arg)
249-
if arg not in global_args:
260+
long_flag = flags[-1]
261+
if arg not in global_args and long_flag not in flag_set:
262+
flag_set.add(long_flag)
250263
parser.add_argument(*flags, **arg)
251264

252265
return parser.parse_args()

tools/benchcomp/benchcomp/entry/benchcomp.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,6 @@ def main(args):
1616
args.suites_dir = run_result.out_prefix / run_result.out_symlink
1717
results = benchcomp.entry.collate.main(args)
1818

19+
results = benchcomp.entry.filter.main(args)
20+
1921
benchcomp.entry.visualize.main(args)

tools/benchcomp/benchcomp/entry/filter.py

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,91 @@
44
# Entrypoint for `benchcomp filter`
55

66

7-
def main(_):
8-
raise NotImplementedError # TODO
7+
import json
8+
import logging
9+
import pathlib
10+
import subprocess
11+
import sys
12+
import tempfile
13+
14+
import yaml
15+
16+
17+
def main(args):
18+
"""Filter the results file by piping it into a list of scripts"""
19+
20+
with open(args.result_file) as handle:
21+
old_results = yaml.safe_load(handle)
22+
23+
if "filters" not in args.config:
24+
return old_results
25+
26+
tmp_root = pathlib.Path(tempfile.gettempdir()) / "benchcomp" / "filter"
27+
tmp_root.mkdir(parents=True, exist_ok=True)
28+
tmpdir = pathlib.Path(tempfile.mkdtemp(dir=str(tmp_root)))
29+
30+
for idx, filt in enumerate(args.config["filters"]):
31+
with open(args.result_file) as handle:
32+
old_results = yaml.safe_load(handle)
33+
34+
json_results = json.dumps(old_results, indent=2)
35+
in_file = tmpdir / f"{idx}.in.json"
36+
out_file = tmpdir / f"{idx}.out.json"
37+
cmd_out = _pipe(
38+
filt["command_line"], json_results, in_file, out_file)
39+
40+
try:
41+
new_results = yaml.safe_load(cmd_out)
42+
except yaml.YAMLError as exc:
43+
logging.exception(
44+
"Filter command '%s' produced invalid YAML. Stdin of"
45+
" the command is saved in %s, stdout is saved in %s.",
46+
filt["command_line"], in_file, out_file)
47+
if hasattr(exc, "problem_mark"):
48+
logging.error(
49+
"Parse error location: line %d, column %d",
50+
exc.problem_mark.line+1, exc.problem_mark.column+1)
51+
sys.exit(1)
52+
53+
with open(args.result_file, "w") as handle:
54+
yaml.dump(new_results, handle, default_flow_style=False, indent=2)
55+
56+
return new_results
57+
58+
59+
def _pipe(shell_command, in_text, in_file, out_file):
60+
"""Pipe `in_text` into `shell_command` and return the output text
61+
62+
Save the in and out text into files for later inspection if necessary.
63+
"""
64+
65+
with open(in_file, "w") as handle:
66+
print(in_text, file=handle)
67+
68+
logging.debug(
69+
"Piping the contents of '%s' into '%s', saving into '%s'",
70+
in_file, shell_command, out_file)
71+
72+
timeout = 60
73+
with subprocess.Popen(
74+
shell_command, shell=True, text=True, stdin=subprocess.PIPE,
75+
stdout=subprocess.PIPE) as proc:
76+
try:
77+
out, _ = proc.communicate(input=in_text, timeout=timeout)
78+
except subprocess.TimeoutExpired:
79+
logging.error(
80+
"Filter command failed to terminate after %ds: '%s'",
81+
timeout, shell_command)
82+
sys.exit(1)
83+
84+
with open(out_file, "w") as handle:
85+
print(out, file=handle)
86+
87+
if proc.returncode:
88+
logging.error(
89+
"Filter command '%s' exited with code %d. Stdin of"
90+
" the command is saved in %s, stdout is saved in %s.",
91+
shell_command, proc.returncode, in_file, out_file)
92+
sys.exit(1)
93+
94+
return out

tools/benchcomp/test/test_regression.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,98 @@ def test_return_0_on_fail(self):
662662
result = yaml.safe_load(handle)
663663

664664

665+
def test_bad_filters(self):
666+
"""Ensure that bad filters terminate benchcomp"""
667+
668+
with tempfile.TemporaryDirectory() as tmp:
669+
run_bc = Benchcomp({
670+
"variants": {
671+
"variant-1": {
672+
"config": {
673+
"command_line": "true",
674+
"directory": tmp,
675+
"env": {},
676+
}
677+
},
678+
},
679+
"run": {
680+
"suites": {
681+
"suite_1": {
682+
"parser": {
683+
"command": textwrap.dedent("""\
684+
echo '{
685+
"benchmarks": { },
686+
"metrics": { }
687+
}'
688+
""")
689+
},
690+
"variants": ["variant-1"]
691+
}
692+
}
693+
},
694+
"filters": [{
695+
"command_line": "false"
696+
}],
697+
"visualize": [],
698+
})
699+
run_bc()
700+
self.assertEqual(run_bc.proc.returncode, 1, msg=run_bc.stderr)
701+
702+
703+
def test_two_filters(self):
704+
"""Ensure that the output can be filtered"""
705+
706+
with tempfile.TemporaryDirectory() as tmp:
707+
run_bc = Benchcomp({
708+
"variants": {
709+
"variant-1": {
710+
"config": {
711+
"command_line": "true",
712+
"directory": tmp,
713+
"env": {},
714+
}
715+
},
716+
},
717+
"run": {
718+
"suites": {
719+
"suite_1": {
720+
"parser": {
721+
"command": textwrap.dedent("""\
722+
echo '{
723+
"benchmarks": {
724+
"bench-1": {
725+
"variants": {
726+
"variant-1": {
727+
"metrics": {
728+
"runtime": 10,
729+
"memory": 5
730+
}
731+
}
732+
}
733+
}
734+
},
735+
"metrics": {
736+
"runtime": {},
737+
"memory": {},
738+
}
739+
}'
740+
""")
741+
},
742+
"variants": ["variant-1"]
743+
}
744+
}
745+
},
746+
"filters": [{
747+
"command_line": "sed -e 's/10/20/;s/5/10/'"
748+
}, {
749+
"command_line": """grep '"runtime": 20'"""
750+
}],
751+
"visualize": [],
752+
})
753+
run_bc()
754+
self.assertEqual(run_bc.proc.returncode, 0, msg=run_bc.stderr)
755+
756+
665757
def test_env_expansion(self):
666758
"""Ensure that config parser expands '${}' in env key"""
667759

0 commit comments

Comments
 (0)