1 | #!/usr/bin/env python
|
---|
2 | # -*- coding: utf-8 -*-
|
---|
3 | # $Id: analyze.py 97267 2022-10-24 00:09:44Z vboxsync $
|
---|
4 |
|
---|
5 | """
|
---|
6 | Analyzer CLI.
|
---|
7 | """
|
---|
8 |
|
---|
9 | __copyright__ = \
|
---|
10 | """
|
---|
11 | Copyright (C) 2010-2022 Oracle and/or its affiliates.
|
---|
12 |
|
---|
13 | This file is part of VirtualBox base platform packages, as
|
---|
14 | available from https://www.virtualbox.org.
|
---|
15 |
|
---|
16 | This program is free software; you can redistribute it and/or
|
---|
17 | modify it under the terms of the GNU General Public License
|
---|
18 | as published by the Free Software Foundation, in version 3 of the
|
---|
19 | License.
|
---|
20 |
|
---|
21 | This program is distributed in the hope that it will be useful, but
|
---|
22 | WITHOUT ANY WARRANTY; without even the implied warranty of
|
---|
23 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
---|
24 | General Public License for more details.
|
---|
25 |
|
---|
26 | You should have received a copy of the GNU General Public License
|
---|
27 | along with this program; if not, see <https://www.gnu.org/licenses>.
|
---|
28 |
|
---|
29 | The contents of this file may alternatively be used under the terms
|
---|
30 | of the Common Development and Distribution License Version 1.0
|
---|
31 | (CDDL), a copy of it is provided in the "COPYING.CDDL" file included
|
---|
32 | in the VirtualBox distribution, in which case the provisions of the
|
---|
33 | CDDL are applicable instead of those of the GPL.
|
---|
34 |
|
---|
35 | You may elect to license modified versions of this file under the
|
---|
36 | terms and conditions of either the GPL or the CDDL or both.
|
---|
37 |
|
---|
38 | SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0
|
---|
39 | """
|
---|
40 | __version__ = "$Revision: 97267 $"
|
---|
41 |
|
---|
42 | # Standard python imports.
|
---|
43 | import re;
|
---|
44 | import os;
|
---|
45 | import textwrap;
|
---|
46 | import sys;
|
---|
47 |
|
---|
48 | # Only the main script needs to modify the path.
|
---|
49 | try: __file__
|
---|
50 | except: __file__ = sys.argv[0];
|
---|
51 | g_ksValidationKitDir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)));
|
---|
52 | sys.path.append(g_ksValidationKitDir);
|
---|
53 |
|
---|
54 | # Validation Kit imports.
|
---|
55 | from analysis import reader
|
---|
56 | from analysis import reporting
|
---|
57 |
|
---|
58 |
|
---|
59 | def usage():
|
---|
60 | """
|
---|
61 | Display usage.
|
---|
62 | """
|
---|
63 | # Set up the output wrapper.
|
---|
64 | try: cCols = os.get_terminal_size()[0] # since 3.3
|
---|
65 | except: cCols = 79;
|
---|
66 | oWrapper = textwrap.TextWrapper(width = cCols);
|
---|
67 |
|
---|
68 | # Do the outputting.
|
---|
69 | print('Tool for comparing test results.');
|
---|
70 | print('');
|
---|
71 | oWrapper.subsequent_indent = ' ' * (len('usage: ') + 4);
|
---|
72 | print(oWrapper.fill('usage: analyze.py [options] [collection-1] -- [collection-2] [-- [collection3] [..]])'))
|
---|
73 | oWrapper.subsequent_indent = '';
|
---|
74 | print('');
|
---|
75 | print(oWrapper.fill('This tool compares two or more result collections, using one as a baseline (first by default) '
|
---|
76 | 'and showing how the results in others differs from it.'));
|
---|
77 | print('');
|
---|
78 | print(oWrapper.fill('The results (XML file) from one or more test runs makes up a collection. A collection can be '
|
---|
79 | 'named using the --name <name> option, or will get a sequential name automatically. The baseline '
|
---|
80 | 'collection will have "(baseline)" appended to its name.'));
|
---|
81 | print('');
|
---|
82 | print(oWrapper.fill('A test run produces one XML file, either via the testdriver/reporter.py machinery or via the IPRT '
|
---|
83 | 'test.cpp code. In the latter case it can be enabled and controlled via IPRT_TEST_FILE. A collection '
|
---|
84 | 'consists of one or more of test runs (i.e. XML result files). These are combined (aka distilled) '
|
---|
85 | 'into a single set of results before comparing them with the others. The --best and --avg options '
|
---|
86 | 'controls how this combining is done. The need for this is mainly to try counteract some of the '
|
---|
87 | 'instability typically found in the restuls. Just because one test run produces a better result '
|
---|
88 | 'after a change does not necessarily mean this will always be the case and that the change was to '
|
---|
89 | 'the better, it might just have been regular fluctuations in the test results.'));
|
---|
90 |
|
---|
91 | oWrapper.initial_indent = ' ';
|
---|
92 | oWrapper.subsequent_indent = ' ';
|
---|
93 | print('');
|
---|
94 | print('Options governing combining (distillation):');
|
---|
95 | print(' --avg, --average');
|
---|
96 | print(oWrapper.fill('Picks the best result by calculating the average values across all the runs.'));
|
---|
97 | print('');
|
---|
98 | print(' --best');
|
---|
99 | print(oWrapper.fill('Picks the best result from all the runs. For values, this means making guessing what result is '
|
---|
100 | 'better based on the unit. This may not always lead to the right choices.'));
|
---|
101 | print(oWrapper.initial_indent + 'Default: --best');
|
---|
102 |
|
---|
103 | print('');
|
---|
104 | print('Options relating to collections:');
|
---|
105 | print(' --name <name>');
|
---|
106 | print(oWrapper.fill('Sets the name of the current collection. By default a collection gets a sequential number.'));
|
---|
107 | print('');
|
---|
108 | print(' --baseline <num>');
|
---|
109 | print(oWrapper.fill('Sets collection given by <num> (0-based) as the baseline collection.'));
|
---|
110 | print(oWrapper.initial_indent + 'Default: --baseline 0')
|
---|
111 |
|
---|
112 | print('');
|
---|
113 | print('Filtering options:');
|
---|
114 | print(' --filter-test <substring>');
|
---|
115 | print(oWrapper.fill('Exclude tests not containing any of the substrings given via the --filter-test option. The '
|
---|
116 | 'matching is done with full test name, i.e. all parent names are prepended with ", " as separator '
|
---|
117 | '(for example "tstIOInstr, CPUID EAX=1").'));
|
---|
118 | print('');
|
---|
119 | print(' --filter-test-out <substring>');
|
---|
120 | print(oWrapper.fill('Exclude tests containing the given substring. As with --filter-test, the matching is done against '
|
---|
121 | 'the full test name.'));
|
---|
122 | print('');
|
---|
123 | print(' --filter-value <substring>');
|
---|
124 | print(oWrapper.fill('Exclude values not containing any of the substrings given via the --filter-value option. The '
|
---|
125 | 'matching is done against the value name prefixed by the full test name and ": " '
|
---|
126 | '(for example "tstIOInstr, CPUID EAX=1: real mode, CPUID").'));
|
---|
127 | print('');
|
---|
128 | print(' --filter-value-out <substring>');
|
---|
129 | print(oWrapper.fill('Exclude value containing the given substring. As with --filter-value, the matching is done against '
|
---|
130 | 'the value name prefixed by the full test name.'));
|
---|
131 |
|
---|
132 | print('');
|
---|
133 | print(' --regex-test <expr>');
|
---|
134 | print(oWrapper.fill('Same as --filter-test except the substring matching is done via a regular expression.'));
|
---|
135 | print('');
|
---|
136 | print(' --regex-test-out <expr>');
|
---|
137 | print(oWrapper.fill('Same as --filter-test-out except the substring matching is done via a regular expression.'));
|
---|
138 | print('');
|
---|
139 | print(' --regex-value <expr>');
|
---|
140 | print(oWrapper.fill('Same as --filter-value except the substring matching is done via a regular expression.'));
|
---|
141 | print('');
|
---|
142 | print(' --regex-value-out <expr>');
|
---|
143 | print(oWrapper.fill('Same as --filter-value-out except the substring matching is done via a regular expression.'));
|
---|
144 | print('');
|
---|
145 | print(' --filter-out-empty-leaf-tests');
|
---|
146 | print(oWrapper.fill('Removes any leaf tests that are without any values or sub-tests. This is useful when '
|
---|
147 | 'only considering values, especially when doing additional value filtering.'));
|
---|
148 |
|
---|
149 | print('');
|
---|
150 | print('Output options:');
|
---|
151 | print(' --brief, --verbose');
|
---|
152 | print(oWrapper.fill('Whether to omit (--brief) the value for non-baseline runs and just get along with the difference.'));
|
---|
153 | print(oWrapper.initial_indent + 'Default: --brief');
|
---|
154 | print('');
|
---|
155 | print(' --pct <num>, --pct-precision <num>');
|
---|
156 | print(oWrapper.fill('Specifies the number of decimal place to use when formatting the difference as percent.'));
|
---|
157 | print(oWrapper.initial_indent + 'Default: --pct 2');
|
---|
158 | return 1;
|
---|
159 |
|
---|
160 |
|
---|
161 | class ResultCollection(object):
|
---|
162 | """
|
---|
163 | One or more test runs that should be merged before comparison.
|
---|
164 | """
|
---|
165 |
|
---|
166 | def __init__(self, sName):
|
---|
167 | self.sName = sName;
|
---|
168 | self.aoTestTrees = [] # type: [Test]
|
---|
169 | self.asTestFiles = [] # type: [str] - runs parallel to aoTestTrees
|
---|
170 | self.oDistilled = None # type: Test
|
---|
171 |
|
---|
172 | def append(self, sFilename):
|
---|
173 | """
|
---|
174 | Loads sFilename and appends the result.
|
---|
175 | Returns True on success, False on failure.
|
---|
176 | """
|
---|
177 | oTestTree = reader.parseTestResult(sFilename);
|
---|
178 | if oTestTree:
|
---|
179 | self.aoTestTrees.append(oTestTree);
|
---|
180 | self.asTestFiles.append(sFilename);
|
---|
181 | return True;
|
---|
182 | return False;
|
---|
183 |
|
---|
184 | def isEmpty(self):
|
---|
185 | """ Checks if the result is empty. """
|
---|
186 | return len(self.aoTestTrees) == 0;
|
---|
187 |
|
---|
188 | def filterTests(self, asFilters):
|
---|
189 | """
|
---|
190 | Keeps all the tests in the test trees sub-string matching asFilters (str or re).
|
---|
191 | """
|
---|
192 | for oTestTree in self.aoTestTrees:
|
---|
193 | oTestTree.filterTests(asFilters);
|
---|
194 | return self;
|
---|
195 |
|
---|
196 | def filterOutTests(self, asFilters):
|
---|
197 | """
|
---|
198 | Removes all the tests in the test trees sub-string matching asFilters (str or re).
|
---|
199 | """
|
---|
200 | for oTestTree in self.aoTestTrees:
|
---|
201 | oTestTree.filterOutTests(asFilters);
|
---|
202 | return self;
|
---|
203 |
|
---|
204 | def filterValues(self, asFilters):
|
---|
205 | """
|
---|
206 | Keeps all the tests in the test trees sub-string matching asFilters (str or re).
|
---|
207 | """
|
---|
208 | for oTestTree in self.aoTestTrees:
|
---|
209 | oTestTree.filterValues(asFilters);
|
---|
210 | return self;
|
---|
211 |
|
---|
212 | def filterOutValues(self, asFilters):
|
---|
213 | """
|
---|
214 | Removes all the tests in the test trees sub-string matching asFilters (str or re).
|
---|
215 | """
|
---|
216 | for oTestTree in self.aoTestTrees:
|
---|
217 | oTestTree.filterOutValues(asFilters);
|
---|
218 | return self;
|
---|
219 |
|
---|
220 | def filterOutEmptyLeafTests(self):
|
---|
221 | """
|
---|
222 | Removes all the tests in the test trees that have neither child tests nor values.
|
---|
223 | """
|
---|
224 | for oTestTree in self.aoTestTrees:
|
---|
225 | oTestTree.filterOutEmptyLeafTests();
|
---|
226 | return self;
|
---|
227 |
|
---|
228 | def distill(self, sMethod, fDropLoners = False):
|
---|
229 | """
|
---|
230 | Distills the set of test results into a single one by the given method.
|
---|
231 |
|
---|
232 | Valid sMethod values:
|
---|
233 | - 'best': Pick the best result for each test and value among all the test runs.
|
---|
234 | - 'avg': Calculate the average value among all the test runs.
|
---|
235 |
|
---|
236 | When fDropLoners is True, tests and values that only appear in a single test run
|
---|
237 | will be discarded. When False (the default), the lone result will be used.
|
---|
238 | """
|
---|
239 | assert sMethod in ['best', 'avg'];
|
---|
240 | assert not self.oDistilled;
|
---|
241 |
|
---|
242 | # If empty, nothing to do.
|
---|
243 | if self.isEmpty():
|
---|
244 | return None;
|
---|
245 |
|
---|
246 | # If there is only a single tree, make a deep copy of it.
|
---|
247 | if len(self.aoTestTrees) == 1:
|
---|
248 | oDistilled = self.aoTestTrees[0].clone();
|
---|
249 | else:
|
---|
250 |
|
---|
251 | # Since we don't know if the test runs are all from the same test, we create
|
---|
252 | # dummy root tests for each run and use these are the start for the distillation.
|
---|
253 | aoDummyInputTests = [];
|
---|
254 | for oRun in self.aoTestTrees:
|
---|
255 | oDummy = reader.Test();
|
---|
256 | oDummy.aoChildren = [oRun,];
|
---|
257 | aoDummyInputTests.append(oDummy);
|
---|
258 |
|
---|
259 | # Similarly, we end up with a "dummy" root test for the result.
|
---|
260 | oDistilled = reader.Test();
|
---|
261 | oDistilled.distill(aoDummyInputTests, sMethod, fDropLoners);
|
---|
262 |
|
---|
263 | # We can drop this if there is only a single child, i.e. if all runs are for
|
---|
264 | # the same test.
|
---|
265 | if len(oDistilled.aoChildren) == 1:
|
---|
266 | oDistilled = oDistilled.aoChildren[0];
|
---|
267 |
|
---|
268 | self.oDistilled = oDistilled;
|
---|
269 | return oDistilled;
|
---|
270 |
|
---|
271 |
|
---|
272 |
|
---|
273 | # matchWithValue hacks.
|
---|
274 | g_asOptions = [];
|
---|
275 | g_iOptInd = 1;
|
---|
276 | g_sOptArg = '';
|
---|
277 |
|
---|
278 | def matchWithValue(sOption):
|
---|
279 | """ Matches an option with a value, placing the value in g_sOptArg if it matches. """
|
---|
280 | global g_asOptions, g_iOptInd, g_sOptArg;
|
---|
281 | sArg = g_asOptions[g_iOptInd];
|
---|
282 | if sArg.startswith(sOption):
|
---|
283 | if len(sArg) == len(sOption):
|
---|
284 | if g_iOptInd + 1 < len(g_asOptions):
|
---|
285 | g_iOptInd += 1;
|
---|
286 | g_sOptArg = g_asOptions[g_iOptInd];
|
---|
287 | return True;
|
---|
288 |
|
---|
289 | print('syntax error: Option %s takes a value!' % (sOption,));
|
---|
290 | raise Exception('syntax error: Option %s takes a value!' % (sOption,));
|
---|
291 |
|
---|
292 | if sArg[len(sOption)] in ('=', ':'):
|
---|
293 | g_sOptArg = sArg[len(sOption) + 1:];
|
---|
294 | return True;
|
---|
295 | return False;
|
---|
296 |
|
---|
297 |
|
---|
298 | def main(asArgs):
|
---|
299 | """ C style main(). """
|
---|
300 | #
|
---|
301 | # Parse arguments
|
---|
302 | #
|
---|
303 | oCurCollection = ResultCollection('#0');
|
---|
304 | aoCollections = [ oCurCollection, ];
|
---|
305 | iBaseline = 0;
|
---|
306 | sDistillationMethod = 'best';
|
---|
307 | fBrief = True;
|
---|
308 | cPctPrecision = 2;
|
---|
309 | asTestFilters = [];
|
---|
310 | asTestOutFilters = [];
|
---|
311 | asValueFilters = [];
|
---|
312 | asValueOutFilters = [];
|
---|
313 | fFilterOutEmptyLeafTest = True;
|
---|
314 |
|
---|
315 | global g_asOptions, g_iOptInd, g_sOptArg;
|
---|
316 | g_asOptions = asArgs;
|
---|
317 | g_iOptInd = 1;
|
---|
318 | while g_iOptInd < len(asArgs):
|
---|
319 | sArg = asArgs[g_iOptInd];
|
---|
320 | g_sOptArg = '';
|
---|
321 | #print("dbg: g_iOptInd=%s '%s'" % (g_iOptInd, sArg,));
|
---|
322 |
|
---|
323 | if sArg.startswith('--help'):
|
---|
324 | return usage();
|
---|
325 |
|
---|
326 | if matchWithValue('--filter-test'):
|
---|
327 | asTestFilters.append(g_sOptArg);
|
---|
328 | elif matchWithValue('--filter-test-out'):
|
---|
329 | asTestOutFilters.append(g_sOptArg);
|
---|
330 | elif matchWithValue('--filter-value'):
|
---|
331 | asValueFilters.append(g_sOptArg);
|
---|
332 | elif matchWithValue('--filter-value-out'):
|
---|
333 | asValueOutFilters.append(g_sOptArg);
|
---|
334 |
|
---|
335 | elif matchWithValue('--regex-test'):
|
---|
336 | asTestFilters.append(re.compile(g_sOptArg));
|
---|
337 | elif matchWithValue('--regex-test-out'):
|
---|
338 | asTestOutFilters.append(re.compile(g_sOptArg));
|
---|
339 | elif matchWithValue('--regex-value'):
|
---|
340 | asValueFilters.append(re.compile(g_sOptArg));
|
---|
341 | elif matchWithValue('--regex-value-out'):
|
---|
342 | asValueOutFilters.append(re.compile(g_sOptArg));
|
---|
343 |
|
---|
344 | elif sArg == '--filter-out-empty-leaf-tests':
|
---|
345 | fFilterOutEmptyLeafTest = True;
|
---|
346 | elif sArg == '--no-filter-out-empty-leaf-tests':
|
---|
347 | fFilterOutEmptyLeafTest = False;
|
---|
348 |
|
---|
349 | elif sArg == '--best':
|
---|
350 | sDistillationMethod = 'best';
|
---|
351 | elif sArg in ('--avg', '--average'):
|
---|
352 | sDistillationMethod = 'avg';
|
---|
353 |
|
---|
354 | elif sArg == '--brief':
|
---|
355 | fBrief = True;
|
---|
356 | elif sArg == '--verbose':
|
---|
357 | fBrief = False;
|
---|
358 |
|
---|
359 | elif matchWithValue('--pct') or matchWithValue('--pct-precision'):
|
---|
360 | cPctPrecision = int(g_sOptArg);
|
---|
361 | elif matchWithValue('--base') or matchWithValue('--baseline'):
|
---|
362 | iBaseline = int(g_sOptArg);
|
---|
363 |
|
---|
364 | # '--' starts a new collection. If current one is empty, drop it.
|
---|
365 | elif sArg == '--':
|
---|
366 | print("dbg: new collection");
|
---|
367 | #if oCurCollection.isEmpty():
|
---|
368 | # del aoCollections[-1];
|
---|
369 | oCurCollection = ResultCollection("#%s" % (len(aoCollections),));
|
---|
370 | aoCollections.append(oCurCollection);
|
---|
371 |
|
---|
372 | # Name the current result collection.
|
---|
373 | elif matchWithValue('--name'):
|
---|
374 | oCurCollection.sName = g_sOptArg;
|
---|
375 |
|
---|
376 | # Read in a file and add it to the current data set.
|
---|
377 | else:
|
---|
378 | if not oCurCollection.append(sArg):
|
---|
379 | return 1;
|
---|
380 | g_iOptInd += 1;
|
---|
381 |
|
---|
382 | #
|
---|
383 | # Post argument parsing processing.
|
---|
384 | #
|
---|
385 |
|
---|
386 | # Drop the last collection if empty.
|
---|
387 | if oCurCollection.isEmpty():
|
---|
388 | del aoCollections[-1];
|
---|
389 | if not aoCollections:
|
---|
390 | print("error: No input files given!");
|
---|
391 | return 1;
|
---|
392 |
|
---|
393 | # Check the baseline value and mark the column as such.
|
---|
394 | if iBaseline < 0 or iBaseline > len(aoCollections):
|
---|
395 | print("error: specified baseline is out of range: %s, valid range 0 <= baseline < %s"
|
---|
396 | % (iBaseline, len(aoCollections),));
|
---|
397 | return 1;
|
---|
398 | aoCollections[iBaseline].sName += ' (baseline)';
|
---|
399 |
|
---|
400 | #
|
---|
401 | # Apply filtering before distilling each collection into a single result tree.
|
---|
402 | #
|
---|
403 | if asTestFilters:
|
---|
404 | for oCollection in aoCollections:
|
---|
405 | oCollection.filterTests(asTestFilters);
|
---|
406 | if asTestOutFilters:
|
---|
407 | for oCollection in aoCollections:
|
---|
408 | oCollection.filterOutTests(asTestOutFilters);
|
---|
409 |
|
---|
410 | if asValueFilters:
|
---|
411 | for oCollection in aoCollections:
|
---|
412 | oCollection.filterValues(asValueFilters);
|
---|
413 | if asValueOutFilters:
|
---|
414 | for oCollection in aoCollections:
|
---|
415 | oCollection.filterOutValues(asValueOutFilters);
|
---|
416 |
|
---|
417 | if fFilterOutEmptyLeafTest:
|
---|
418 | for oCollection in aoCollections:
|
---|
419 | oCollection.filterOutEmptyLeafTests();
|
---|
420 |
|
---|
421 | # Distillation.
|
---|
422 | for oCollection in aoCollections:
|
---|
423 | oCollection.distill(sDistillationMethod);
|
---|
424 |
|
---|
425 | #
|
---|
426 | # Produce the report.
|
---|
427 | #
|
---|
428 | oTable = reporting.RunTable(iBaseline, fBrief, cPctPrecision);
|
---|
429 | oTable.populateFromRuns([oCollection.oDistilled for oCollection in aoCollections],
|
---|
430 | [oCollection.sName for oCollection in aoCollections]);
|
---|
431 | print('\n'.join(oTable.formatAsText()));
|
---|
432 | return 0;
|
---|
433 |
|
---|
434 | if __name__ == '__main__':
|
---|
435 | sys.exit(main(sys.argv));
|
---|
436 |
|
---|