benchstats 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
benchstats/__init__.py ADDED
File without changes
benchstats/__main__.py ADDED
@@ -0,0 +1,114 @@
1
+ # using abs path only here to alleviate debugging
2
+ from benchstats.compare import compareStats
3
+ from benchstats.common import LoggingConsole, detectExportFormat, bmNamesTransform
4
+ from benchstats.cli_parser import makeParser
5
+ from benchstats.render import renderComparisonResults
6
+ from benchstats.parsers import getParserFor
7
+
8
+ import os
9
+ from rich.terminal_theme import DIMMED_MONOKAI as DarkTheme, DEFAULT_TERMINAL_THEME as LightTheme
10
+
11
+
12
+ def main():
13
+ parser = makeParser()
14
+ args = parser.parse_args()
15
+
16
+ export_fmt = detectExportFormat(args.export_to, args.export_fmt)
17
+ if export_fmt is not None:
18
+ if os.path.isfile(args.export_to):
19
+ os.remove(args.export_to)
20
+ else:
21
+ assert not os.path.exists(args.export_to), "Invalid --export_to value"
22
+
23
+ console = LoggingConsole(
24
+ no_color=not args.colors,
25
+ record=export_fmt is not None,
26
+ log_level=(
27
+ LoggingConsole.LogLevel.Debug if args.show_debug else LoggingConsole.LogLevel.Warning
28
+ ),
29
+ )
30
+
31
+ if args.alpha > 0.5 or args.alpha <= 0:
32
+ console.critical(
33
+ "--alpha must be a positive number less than 0.5 (%.2f is given)" % args.alpha
34
+ )
35
+ exit(2)
36
+
37
+ Parser1 = getParserFor(args.files_parser if args.file1_parser is None else args.file1_parser)
38
+ Parser2 = getParserFor(args.files_parser if args.file2_parser is None else args.file2_parser)
39
+
40
+ s1 = Parser1(args.file1, args.filter1, args.metrics, debug_log=console).getStats()
41
+ s2 = Parser2(args.file2, args.filter2, args.metrics, debug_log=console).getStats()
42
+
43
+ s1 = bmNamesTransform(
44
+ s1,
45
+ getattr(args, "from") if args.from1 is None else args.from1,
46
+ args.to if args.to1 is None else args.to1,
47
+ 1,
48
+ console,
49
+ )
50
+ s2 = bmNamesTransform(
51
+ s2,
52
+ getattr(args, "from") if args.from2 is None else args.from2,
53
+ args.to if args.to2 is None else args.to2,
54
+ 2,
55
+ console,
56
+ )
57
+
58
+ alpha = args.alpha
59
+ if args.bonferroni:
60
+ alpha = alpha / len(s1)
61
+ console.info(
62
+ f"Bonferroni correction for {len(s1)} comparisons turns alpha={args.alpha:.3e} into {alpha:.3e}"
63
+ )
64
+
65
+ if args.main_metrics is None or len(args.main_metrics) < 1:
66
+ main_metrics = [args.metrics[0]]
67
+ else:
68
+ main_metrics = [args.metrics[mi] for mi in args.main_metrics]
69
+
70
+ cr = compareStats(
71
+ s1,
72
+ s2,
73
+ method=args.method,
74
+ main_metrics=main_metrics,
75
+ alpha=alpha,
76
+ debug_log=console,
77
+ store_sets=args.sample_stats is not None,
78
+ )
79
+
80
+ renderComparisonResults(
81
+ cr,
82
+ console,
83
+ expect_same=args.expect_same,
84
+ main_metrics=main_metrics,
85
+ sample_stats=args.sample_stats,
86
+ dark_theme=not args.export_light,
87
+ show_sample_sizes=args.sample_sizes,
88
+ always_show_pvalues=args.always_show_pvalues,
89
+ multiline=args.multiline,
90
+ )
91
+
92
+ if cr.at_least_one_differs:
93
+ console.warning(
94
+ "At least one significant difference in main metrics was detected. Returning with exit(1)."
95
+ )
96
+
97
+ if export_fmt is not None:
98
+ if "txt" == export_fmt:
99
+ console.save_text(args.export_to)
100
+ elif "svg" == export_fmt:
101
+ console.save_svg(
102
+ args.export_to, title="", theme=LightTheme if args.export_light else DarkTheme
103
+ )
104
+ elif "html" == export_fmt:
105
+ console.save_html(args.export_to, theme=LightTheme if args.export_light else DarkTheme)
106
+ else:
107
+ assert False, "NOT IMPLEMENTED?!"
108
+
109
+ if cr.at_least_one_differs:
110
+ exit(1)
111
+
112
+
113
+ if __name__ == "__main__":
114
+ main()
@@ -0,0 +1,297 @@
1
+ import argparse
2
+ from .common import kAvailableFormats
3
+ from .compare import kMethods, kDefaultAlpha
4
+ from .parsers import getBuiltinParsers
5
+ from .render import kPossibleStatNames
6
+
7
+
8
+ def makeParser():
9
+ parser = argparse.ArgumentParser(
10
+ description="A tool to compare two sets of the same benchmarks with repetitions.\n"
11
+ "Homepage: https://github.com/Arech/benchstats\n",
12
+ epilog="On custom parsers:\n"
13
+ "You could supply virtually any source data to the tool by utilizing --files_parser (or "
14
+ "--file1_parser or --file2_parser) command line argument. Each of these arguments in "
15
+ "addition to built-in parser identifiers also accepts a path to a Python file that "
16
+ "defines a custom parser. The simplest possible parser to read a single one column CSV "
17
+ "file is this:\n\n"
18
+ """# save to ./myCSV.py
19
+ import numpy as np
20
+ from benchstats.common import ParserBase
21
+
22
+ class myCSV(ParserBase):
23
+ def __init__(self, fpath, filter, metrics, debug_log=None) -> None:
24
+ self.stats = np.loadtxt(fpath, dtype=np.float64)
25
+
26
+ def getStats(self) -> dict[str, dict[str, np.ndarray]]:
27
+ return {"bm": {"real_time": self.stats}}
28
+
29
+ """
30
+ "It doesn't support filtering, different benchmarks and the metric is hardcoded - but you "
31
+ "get the idea. It's that simple.\n"
32
+ "Now to compare two datasets in csvs, just run:\n"
33
+ "python -m benchstats ./src1.csv ./src2.csv --files_parser ./myCSV.py\n\n"
34
+ "If you'll make a parser that could be useful to other people, please consider adding it "
35
+ "to the project's built-in parsers set by opening a thread with a suggestion in "
36
+ "https://github.com/Arech/benchstats/issues or by making a PR into the repo.",
37
+ formatter_class=argparse.RawDescriptionHelpFormatter,
38
+ )
39
+
40
+ parser.add_argument(
41
+ "--show_debug",
42
+ help="Shows some additional debugging info. Default: %(default)s",
43
+ action=argparse.BooleanOptionalAction,
44
+ default=True,
45
+ )
46
+
47
+ g_inputs = parser.add_argument_group(
48
+ "Input data",
49
+ "Arguments describing how a source data sets are obtained and are optionally transformed.",
50
+ )
51
+
52
+ g_inputs.add_argument(
53
+ "file1",
54
+ help="Path to the first data file with benchmark results. See also --file1_parser and "
55
+ "--filter1 arguments",
56
+ metavar="<path/to/file1>",
57
+ )
58
+
59
+ g_inputs.add_argument(
60
+ "file2",
61
+ help="Path to the second data file with benchmark results. See also --file2_parser and "
62
+ "--filter2 arguments",
63
+ metavar="<path/to/file2>",
64
+ )
65
+
66
+ g_inputs.add_argument(
67
+ "--files_parser",
68
+ help="Sets files parser class identifier, if a built-in parser is used (options are: "
69
+ f"{', '.join(getBuiltinParsers())}). Or sets a path to .py file defining a custom parser, "
70
+ "inherited from 'benchstats.common.ParserBase' class. The parser class name must be the "
71
+ "same as the file name. See below for an example.",
72
+ default="GbenchJson",
73
+ metavar="<files parser class or path>",
74
+ )
75
+
76
+ g_inputs.add_argument(
77
+ "--file1_parser",
78
+ help="Same as --files_parser, but only applies to file1.",
79
+ default=None,
80
+ metavar="<file1 parser class or path>",
81
+ )
82
+
83
+ g_inputs.add_argument(
84
+ "--filter1",
85
+ help="If specified, sets a Python regular expression (see "
86
+ "https://docs.python.org/3/howto/regex.html#regex-howto) to select benchmarks by name "
87
+ "from <file1>",
88
+ metavar="<reg expr>",
89
+ default=None,
90
+ )
91
+
92
+ g_inputs.add_argument(
93
+ "--file2_parser",
94
+ help="Same as --files_parser, but only applies to file2.",
95
+ default=None,
96
+ metavar="<file2 parser class or path>",
97
+ )
98
+
99
+ g_inputs.add_argument(
100
+ "--filter2",
101
+ help="Same as --filter1, but for <file2>",
102
+ metavar="<reg expr>",
103
+ default=None,
104
+ )
105
+
106
+ g_inputs.add_argument(
107
+ "--from",
108
+ help="--from and --to works in a pair and are used to describe benchmark names transformation. "
109
+ "--from sets a regular expression (Python re module flavor) to define a pattern to replace to --to replacement "
110
+ "string. "
111
+ "Typical use-cases include removing unnecessary parts of benchmark names (for example, Google Benchmark adds "
112
+ "some suffixes that might not convey useful information) or \"glueing\" results of different benchmarks "
113
+ "so they become comparable by the tool (for example, you have two benchmarks, the first is 'old_foo' with old algorithm "
114
+ "implementation and the other is 'foo' with a new competing algorithm implementation, - to compare their performance "
115
+ "against each other, you need to remove 'old_' prefix from the first benchmark with `--from old_`"
116
+ " and apply --filter1 and --filter2 to restrict loading only corresponding data for old or new variations). "
117
+ "Remember to escape characters that have special meaning for the shell.",
118
+ metavar="<reg expr>",
119
+ default=None,
120
+ )
121
+
122
+ g_inputs.add_argument(
123
+ "--to",
124
+ help="See help for --from.",
125
+ metavar="<replacement string>",
126
+ default=None,
127
+ )
128
+
129
+ g_inputs.add_argument(
130
+ "--from1",
131
+ help="Like --from, but for <file1> only",
132
+ metavar="<reg expr>",
133
+ default=None,
134
+ )
135
+
136
+ g_inputs.add_argument(
137
+ "--to1",
138
+ help="Like --to, but for <file1> only",
139
+ metavar="<replacement string>",
140
+ default=None,
141
+ )
142
+
143
+ g_inputs.add_argument(
144
+ "--from2",
145
+ help="Like --from, but for <file2> only",
146
+ metavar="<reg expr>",
147
+ default=None,
148
+ )
149
+
150
+ g_inputs.add_argument(
151
+ "--to2",
152
+ help="Like --to, but for <file2> only",
153
+ metavar="<replacement string>",
154
+ default=None,
155
+ )
156
+
157
+ g_inputs.add_argument(
158
+ "metrics",
159
+ help="List of metric identifiers to use in tests for each benchmark. Default: %(default)s. "
160
+ "For deterministic algorithms highly recommend to measure and to use a minimum latency per "
161
+ "repetition.",
162
+ nargs="*",
163
+ default=["real_time"],
164
+ )
165
+
166
+ g_inputs.add_argument(
167
+ "--main_metrics",
168
+ help="Indexes in 'metrics' list specifying the main metrics. These are displayed differently and differences "
169
+ "detected cause script to exit(1). Default: %(default)s.",
170
+ nargs="+",
171
+ metavar="<metric idx>",
172
+ type=int,
173
+ default=[0],
174
+ )
175
+
176
+ g_stats = parser.add_argument_group("Statistical settings")
177
+
178
+ g_stats.add_argument(
179
+ "--method",
180
+ help=(
181
+ "Selects a method of statistical testing. Possible values are are: "
182
+ + ", ".join(
183
+ [
184
+ "'" + id + f"' for {descr['name']} ({descr['url'].replace('%','%%')})"
185
+ for (id, descr) in kMethods.items()
186
+ ]
187
+ )
188
+ + ". Default is %(default)s"
189
+ ),
190
+ metavar="<method id>",
191
+ choices=kMethods.keys(),
192
+ default=list(kMethods.keys())[0],
193
+ )
194
+
195
+ g_stats.add_argument(
196
+ "--alpha",
197
+ help="Set statistical significance level (a desired mistake probability level)\n"
198
+ "Note! This is an actual probability of a mistake only when all preconditions of the "
199
+ "chosen statistical test are met. One of the most important preconditions like independence of "
200
+ "individual measurements, or constancy of underlying distribution parameters are almost "
201
+ "never met in a real-life benchmarking, even on a properly quiesced hardware. Real "
202
+ "mistake rates are higher. Default %(default)s",
203
+ metavar="<positive float less than 0.5>",
204
+ type=float,
205
+ default=kDefaultAlpha,
206
+ )
207
+
208
+ g_stats.add_argument(
209
+ "--bonferroni",
210
+ help="If set, applies a Bonferroni multiple comparisons correction "
211
+ "(https://en.wikipedia.org/wiki/Bonferroni_correction).",
212
+ action="store_true",
213
+ default=False,
214
+ )
215
+
216
+ g_rendering=parser.add_argument_group("Rendering", "Controls how results are rendered")
217
+
218
+ g_rendering.add_argument(
219
+ "--always_show_pvalues",
220
+ help="If set, always show pvalues. By default it shows pvalues only for significant differences. Note that "
221
+ "when two sets are compared stochastically same (~), or more precisely, not stochastically less and not "
222
+ "stochastically greater (see https://en.wikipedia.org/wiki/Stochastic_ordering), pvalue shown is a minimum of "
223
+ "two pvalues for less and greater comparison.",
224
+ action="store_true",
225
+ default=False,
226
+ )
227
+
228
+ g_rendering.add_argument(
229
+ "--sample_sizes",
230
+ help="Controls whether to show sizes of datasets used in a test. Default %(default)s",
231
+ action=argparse.BooleanOptionalAction,
232
+ default=True,
233
+ )
234
+
235
+ # py3.11 doesn't accept backslashes in f-strings, so preparing that in advance
236
+ stat_options = "', '".join(kPossibleStatNames.keys())
237
+ g_rendering.add_argument(
238
+ "--sample_stats",
239
+ help="Sets which additional statistics about compared samples sets to show. Could be any sequence of floats "
240
+ "(must be in range [0,100], designating a percentile value to calculate), or aliases '"
241
+ f"{stat_options}' (shortenings are accepted). Use of 'std', though isn't "
242
+ "recommended as standard deviation doesn't really mean anything for most distributions "
243
+ "beyond Normal.",
244
+ nargs="+", # default value doesn't work with *, while it must
245
+ metavar="<stat ids>",
246
+ default=None,
247
+ )
248
+
249
+ g_rendering.add_argument(
250
+ "--expect_same",
251
+ help="If set, assumes that distributions are the same (i.e. H0 hypothesis is true) and shows some additional "
252
+ "statistics useful for ensuring that a benchmark code is stable enough, or the machine is quiesced enough. "
253
+ "One good example is when <file1> and <file2> were made with exactly the same binary running on exactly the "
254
+ "same machine in the same state - on a machine with a proper setup tests shouldn't find a difference.",
255
+ action="store_true",
256
+ default=False,
257
+ )
258
+
259
+ g_rendering.add_argument(
260
+ "--colors",
261
+ help="Controls if the output should be colored. Default: %(default)s",
262
+ action=argparse.BooleanOptionalAction,
263
+ default=True,
264
+ )
265
+
266
+ g_rendering.add_argument(
267
+ "--multiline",
268
+ help="Controls if benchmark results could span several lines. Default: %(default)s",
269
+ action=argparse.BooleanOptionalAction,
270
+ default=False,
271
+ )
272
+
273
+ g_export = parser.add_argument_group("Export","Controls how to export results.")
274
+
275
+ g_export.add_argument(
276
+ "--export_to",
277
+ help="Path to file to store comparison results to.",
278
+ metavar="<path/to/export_file>",
279
+ )
280
+
281
+ g_export.add_argument(
282
+ "--export_fmt",
283
+ help=f"Format of the export file. Options are: {', '.join(kAvailableFormats)}. If not set, "
284
+ "inferred from --export_to file extension",
285
+ choices=kAvailableFormats,
286
+ default=None,
287
+ metavar="<format id>",
288
+ )
289
+
290
+ g_export.add_argument(
291
+ "--export_light",
292
+ help="If set, uses light theme instead of dark",
293
+ action="store_true",
294
+ default=False,
295
+ )
296
+
297
+ return parser
benchstats/common.py ADDED
@@ -0,0 +1,153 @@
1
+ """Describes a base class for a source of statistics capable of producing (from a file or whatnot)
2
+ data in format suitable for calling compare::compareStats()
3
+ """
4
+
5
+ import os
6
+ import re
7
+ import rich.console
8
+ from collections.abc import Iterable
9
+ import enum
10
+
11
+ kAvailableFormats = ("txt", "svg", "html")
12
+
13
+
14
+ class ParserBase:
15
+ def __init__(self, source_id, filter, metrics, debug_log=None) -> None:
16
+ """Expected source constructor.
17
+ - source_id is an identifier of a data source that the class knows how to process and turn
18
+ into inputs of compare::compareStats(). Data type is derived class implementation
19
+ dependent.
20
+ Typically it's a string referencing a file to read and parse.
21
+ - filter is an object describing how to filter data source when not everything from it
22
+ is needed.
23
+ Typically, it's a string with a regular expression to match against a benchmark name.
24
+ - metrics is a list of string representing metrics to extract for each benchmark repetition.
25
+ - debug_log is a flag to enable/disable logging, or a logger object like LoggingConsole to
26
+ send logs to. When the parser is called by benchstats CLI handler, it's always an
27
+ instance of LoggingConsole, so support for other variants (None or bool) are needed only
28
+ for built-in parsers to alleviate use of the package components in a source code.
29
+ """
30
+ pass # derived class knows what to do
31
+
32
+ def getStats(self) -> dict[str, dict[str, Iterable[float]]]:
33
+ """Return a dict that describes benchmarks and its measured statistics so it and can be
34
+ directly passed as any of the first two arguments to compare::compareStats(). See that
35
+ method for the details.
36
+ """
37
+ raise RuntimeError("DERIVED CLASS MUST IMPLEMENT METHOD")
38
+
39
+
40
+ def detectExportFormat(export_to, export_fmt):
41
+ assert (export_to is None and export_fmt is None) or (
42
+ isinstance(export_to, str) and len(export_to) > 0
43
+ )
44
+ assert export_fmt is None or export_fmt in kAvailableFormats
45
+
46
+ if export_to is not None and export_fmt is None:
47
+ root, ext = os.path.splitext(export_to)
48
+ assert ext in [
49
+ "." + e for e in kAvailableFormats
50
+ ], f"Unrecognized export file extension '{ext}' of a file in --export_to parameter"
51
+ export_fmt = ext[1:]
52
+
53
+ return export_fmt
54
+
55
+
56
+ class LoggingConsole(rich.console.Console):
57
+
58
+ @enum.verify(enum.CONTINUOUS)
59
+ class LogLevel(enum.IntEnum):
60
+ Debug = (0,)
61
+ Info = (1,)
62
+ Warning = (2,)
63
+ Error = (3,)
64
+ Failure = (4,)
65
+ Critical = 5
66
+
67
+ def __init__(self, log_level: LogLevel = LogLevel.Debug, **kwargs):
68
+ assert isinstance(log_level, LoggingConsole.LogLevel)
69
+ self.log_level = log_level
70
+ if "emoji" not in kwargs:
71
+ kwargs["emoji"] = False
72
+ if "highlight" not in kwargs:
73
+ kwargs["highlight"] = False
74
+ super().__init__(**kwargs)
75
+
76
+ def _do_log(self, color: str, lvl: str, *args, **kwargs):
77
+ if "sep" in kwargs:
78
+ sep = kwargs["sep"] if len(kwargs["sep"]) > 0 else " "
79
+ else:
80
+ sep = " "
81
+ kwargs["sep"] = sep
82
+ return super().print(f"\[[{color}]{lvl:4s}[/{color}]]{sep}", *args, **kwargs)
83
+
84
+ def debug(self, *args, **kwargs):
85
+ if self.log_level > LoggingConsole.LogLevel.Debug:
86
+ return None
87
+ return self._do_log("gray", "dbg", *args, **kwargs)
88
+
89
+ def info(self, *args, **kwargs):
90
+ if self.log_level > LoggingConsole.LogLevel.Info:
91
+ return None
92
+ return self._do_log("white", "info", *args, **kwargs)
93
+
94
+ def warning(self, *args, **kwargs):
95
+ if self.log_level > LoggingConsole.LogLevel.Warning:
96
+ return None
97
+ return self._do_log("yellow", "warn", *args, **kwargs)
98
+
99
+ def error(self, *args, **kwargs):
100
+ if self.log_level > LoggingConsole.LogLevel.Error:
101
+ return None
102
+ return self._do_log("orange", "Err", *args, **kwargs)
103
+
104
+ def failure(self, *args, **kwargs):
105
+ if self.log_level > LoggingConsole.LogLevel.Failure:
106
+ return None
107
+ return self._do_log("red", "FAIL", *args, **kwargs)
108
+
109
+ def critical(self, *args, **kwargs):
110
+ if self.log_level > LoggingConsole.LogLevel.Critical:
111
+ return None
112
+ return self._do_log("magenta", "CRIT", *args, **kwargs)
113
+
114
+
115
+ def bmNamesTransform(
116
+ stats: dict[str, dict[str, Iterable[float]]],
117
+ re_from: str | None,
118
+ re_to: str | None,
119
+ set_idx: int,
120
+ console: LoggingConsole,
121
+ ) -> dict[str, dict[str, Iterable[float]]]:
122
+ assert isinstance(stats, dict) and isinstance(set_idx, int)
123
+ assert isinstance(console, LoggingConsole)
124
+ if re_from is None:
125
+ # not nice that it glues func args with CLI args, but ok to simplify err handling
126
+ assert (
127
+ re_to is None
128
+ ), f"--to{set_idx} can only be used when there's a corresponding --from{set_idx}"
129
+ return stats
130
+ assert isinstance(re_from, str) and len(re_from) > 0
131
+ if re_to is None:
132
+ re_to = ""
133
+ else:
134
+ assert isinstance(re_to, str)
135
+
136
+ r = re.compile(re_from)
137
+
138
+ old_bms = stats.keys()
139
+ new_bms = [r.subn(re_to, bm_name)[0] for bm_name in old_bms]
140
+ unique_len = len(frozenset(new_bms))
141
+ if unique_len != len(new_bms):
142
+ console.warning(
143
+ f"--from{set_idx} -> --to{set_idx} regexp '{re_from}'->'{re_to}' is a narrowing conversion "
144
+ f"generates only {unique_len} unique name from {len(new_bms)}. This most likely"
145
+ " isn't what you want, since the order of narrowing is not specified and you might end"
146
+ " up having a wrong data for some new benchmark name."
147
+ )
148
+ console.debug("Old names are:", ", ".join(old_bms))
149
+ console.debug("New names are:", ", ".join(new_bms))
150
+ else:
151
+ assert unique_len == len(stats)
152
+
153
+ return {r.subn(re_to, bm_name)[0]: bm_metrics for bm_name, bm_metrics in stats.items()}