Skip to content

Commit 00333ed

Browse files
committed
[libc++] Add a utility to visualize historical benchmark data locally
This should eventually be done using `lnt` instead, but for the time being this makes it easy to visualize historical data without having an instance of `lnt` running.
1 parent acc156d commit 00333ed

File tree

3 files changed

+229
-2
lines changed

3 files changed

+229
-2
lines changed

libcxx/utils/compare-benchmarks

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,11 @@ def main(argv):
8989
help='Path to a LNT format file containing the benchmark results for the baseline.')
9090
parser.add_argument('candidate', type=argparse.FileType('r'),
9191
help='Path to a LNT format file containing the benchmark results for the candidate.')
92+
parser.add_argument('--output', '-o', type=argparse.FileType('w'), default=sys.stdout,
93+
help='Path of a file where to output the resulting comparison. Default to stdout.')
9294
parser.add_argument('--metric', type=str, default='execution_time',
9395
help='The metric to compare. LNT data may contain multiple metrics (e.g. code size, execution time, etc) -- '
9496
'this option allows selecting which metric is being analyzed. The default is "execution_time".')
95-
parser.add_argument('--output', '-o', type=argparse.FileType('w'), default=sys.stdout,
96-
help='Path of a file where to output the resulting comparison. Default to stdout.')
9797
parser.add_argument('--filter', type=str, required=False,
9898
help='An optional regular expression used to filter the benchmarks included in the comparison. '
9999
'Only benchmarks whose names match the regular expression will be included.')

libcxx/utils/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
plotly
22
tabulate
3+
tqdm

libcxx/utils/visualize-historical

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
#!/usr/bin/env python3
2+
3+
import argparse
4+
import functools
5+
import os
6+
import pathlib
7+
import re
8+
import statistics
9+
import subprocess
10+
import sys
11+
import tempfile
12+
13+
import plotly
14+
import tqdm
15+
16+
@functools.total_ordering
17+
class Commit:
18+
"""
19+
This class represents a commit inside a given Git repository.
20+
"""
21+
22+
def __init__(self, git_repo, sha):
23+
self._git_repo = git_repo
24+
self._sha = sha
25+
26+
def __eq__(self, other):
27+
"""
28+
Return whether two commits refer to the same commit.
29+
30+
This doesn't take into account the content of the Git tree at those commits, only the
31+
'identity' of the commits themselves.
32+
"""
33+
return self.fullrev == other.fullrev
34+
35+
def __lt__(self, other):
36+
"""
37+
Return whether a commit is an ancestor of another commit in the Git repository.
38+
"""
39+
# Is self._sha an ancestor of other._sha?
40+
res = subprocess.run(['git', '-C', self._git_repo, 'merge-base', '--is-ancestor', self._sha, other._sha])
41+
if res.returncode not in (0, 1):
42+
raise RuntimeError(f'Error when trying to obtain the commit order for {self._sha} and {other._sha}')
43+
return res.returncode == 0
44+
45+
def show(self, include_diff=False):
46+
"""
47+
Return the commit information equivalent to `git show` associated to this commit.
48+
"""
49+
cmd = ['git', '-C', self._git_repo, 'show', self._sha]
50+
if not include_diff:
51+
cmd.append('--no-patch')
52+
return subprocess.check_output(cmd, text=True)
53+
54+
@functools.cached_property
55+
def shortrev(self):
56+
"""
57+
Return the shortened version of the given SHA.
58+
"""
59+
return subprocess.check_output(['git', '-C', self._git_repo, 'rev-parse', '--short', self._sha], text=True).strip()
60+
61+
@functools.cached_property
62+
def fullrev(self):
63+
"""
64+
Return the full SHA associated to this commit.
65+
"""
66+
return subprocess.check_output(['git', '-C', self._git_repo, 'rev-parse', self._sha], text=True).strip()
67+
68+
def prefetch(self):
69+
"""
70+
Prefetch cached properties associated to this commit object.
71+
72+
This makes it possible to control when time is spent recovering that information from Git for
73+
e.g. better reporting to the user.
74+
"""
75+
self.shortrev
76+
self.fullrev
77+
78+
def __str__(self):
79+
return self._sha
80+
81+
def truncate_lines(string, n, marker=None):
82+
"""
83+
Truncate the given string at a certain number of lines.
84+
85+
Optionally, add a marker on the last line to identify that truncation has happened.
86+
"""
87+
lines = string.splitlines()
88+
truncated = lines[:n]
89+
if marker is not None and len(lines) > len(truncated):
90+
truncated[-1] = marker
91+
assert len(truncated) <= n, "broken post-condition"
92+
return '\n'.join(truncated)
93+
94+
def create_plot(commits, benchmarks, data):
95+
"""
96+
Create a plot object showing the evolution of each benchmark throughout the given commits.
97+
"""
98+
figure = plotly.graph_objects.Figure(layout_title_text=f'{commits[0].shortrev} to {commits[-1].shortrev}')
99+
100+
# Create the X axis and the hover information
101+
x_axis = [commit.shortrev for commit in commits]
102+
hover_info = [truncate_lines(commit.show(), 30, marker='...').replace('\n', '<br>') for commit in commits]
103+
104+
# For each benchmark, get the metric for that benchmark for each commit.
105+
#
106+
# Some commits may not have any data associated to a benchmark (e.g. runtime or compilation error).
107+
# Use None, which is handled properly by plotly.
108+
for benchmark in benchmarks:
109+
series = [commit_data.get(benchmark, None) for commit_data in data]
110+
scatter = plotly.graph_objects.Scatter(x=x_axis, y=series, text=hover_info, name=benchmark)
111+
figure.add_trace(scatter)
112+
113+
return figure
114+
115+
def directory_path(string):
116+
if os.path.isdir(string):
117+
return pathlib.Path(string)
118+
else:
119+
raise NotADirectoryError(string)
120+
121+
def parse_lnt(lines):
122+
"""
123+
Parse lines in LNT format and return a dictionnary of the form:
124+
125+
{
126+
'benchmark1': {
127+
'metric1': [float],
128+
'metric2': [float],
129+
...
130+
},
131+
'benchmark2': {
132+
'metric1': [float],
133+
'metric2': [float],
134+
...
135+
},
136+
...
137+
}
138+
139+
Each metric may have multiple values.
140+
"""
141+
results = {}
142+
for line in lines:
143+
line = line.strip()
144+
if not line:
145+
continue
146+
147+
(identifier, value) = line.split(' ')
148+
(name, metric) = identifier.split('.')
149+
if name not in results:
150+
results[name] = {}
151+
if metric not in results[name]:
152+
results[name][metric] = []
153+
results[name][metric].append(float(value))
154+
return results
155+
156+
def main(argv):
157+
parser = argparse.ArgumentParser(
158+
prog='visualize-historical',
159+
description='Visualize historical data in LNT format. This program generates a HTML file that embeds an '
160+
'interactive plot with the provided data. The HTML file can then be opened in a browser to '
161+
'visualize the data as a chart.',
162+
epilog='This script depends on the `plotly` and the `tqdm` Python modules.')
163+
parser.add_argument('directory', type=directory_path,
164+
help='Path to a valid directory containing benchmark data in LNT format, each file being named <commit>.lnt. '
165+
'This is also the format generated by the `benchmark-historical` utility.')
166+
parser.add_argument('--output', '-o', type=pathlib.Path, required=False,
167+
help='Optional path where to output the resulting HTML file. If it already exists, it is overwritten. '
168+
'Defaults to a temporary file which is opened automatically once generated, but not removed after '
169+
'creation.')
170+
parser.add_argument('--metric', type=str, default='execution_time',
171+
help='The metric to compare. LNT data may contain multiple metrics (e.g. code size, execution time, etc) -- '
172+
'this option allows selecting which metric is being visualized. The default is "execution_time".')
173+
parser.add_argument('--filter', type=str, required=False,
174+
help='An optional regular expression used to filter the benchmarks included in the chart. '
175+
'Only benchmarks whose names match the regular expression will be included. '
176+
'Since the chart is interactive, it generally makes most sense to include all the benchmarks '
177+
'and to then filter them in the browser, but in some cases producing a chart with a reduced '
178+
'number of data series is useful.')
179+
parser.add_argument('--git-repo', type=directory_path, default=pathlib.Path(os.getcwd()),
180+
help='Path to the git repository to use for ordering commits in time. '
181+
'By default, the current working directory is used.')
182+
parser.add_argument('--open', action='store_true',
183+
help='Whether to automatically open the generated HTML file when finished. If no output file is provided, '
184+
'the resulting benchmark is opened automatically by default.')
185+
args = parser.parse_args(argv)
186+
187+
# Extract benchmark data from the directory and keep only the metric we're interested in.
188+
#
189+
# Some data points may have multiple values associated to the metric (e.g. if we performed
190+
# multiple runs to reduce noise), in which case we aggregate them using a median.
191+
historical_data = []
192+
files = [f for f in args.directory.glob('*.lnt')]
193+
for file in tqdm.tqdm(files, desc='Parsing LNT files'):
194+
(commit, _) = os.path.splitext(os.path.basename(file))
195+
commit = Commit(args.git_repo, commit)
196+
with open(file, 'r') as f:
197+
lnt_data = parse_lnt(f.readlines())
198+
commit_data = {}
199+
for (bm, metrics) in lnt_data.items():
200+
commit_data[bm] = statistics.median(metrics[args.metric]) if args.metric in metrics else None
201+
historical_data.append((commit, commit_data))
202+
203+
# Obtain commit information which is then cached throughout the program. Do this
204+
# eagerly so we can provide a progress bar.
205+
for (commit, _) in tqdm.tqdm(historical_data, desc='Prefetching Git information'):
206+
commit.prefetch()
207+
208+
# Sort the data based on the ordering of commits inside the provided Git repository
209+
historical_data.sort(key=lambda x: x[0])
210+
211+
# Filter the benchmarks if needed
212+
benchmarks = {b for (_, commit_data) in historical_data for b in commit_data.keys()}
213+
if args.filter is not None:
214+
regex = re.compile(args.filter)
215+
benchmarks = {b for b in benchmarks if regex.search(b)}
216+
217+
# Plot the data for all the required benchmarks
218+
figure = create_plot([commit for (commit, _) in historical_data],
219+
sorted(list(benchmarks)),
220+
[data for (_, data) in historical_data])
221+
do_open = args.output is None or args.open
222+
output = args.output if args.output is not None else tempfile.NamedTemporaryFile(suffix='.html').name
223+
plotly.io.write_html(figure, file=output, auto_open=do_open)
224+
225+
if __name__ == '__main__':
226+
main(sys.argv[1:])

0 commit comments

Comments
 (0)