2 # Copyright (c) 2013 The Chromium Authors. All rights reserved.
3 # Use of this source code is governed by a BSD-style license that can be
4 # found in the LICENSE file.
6 """Performance Test Bisect Tool
8 This script bisects a series of changelists using binary search. It starts at
9 a bad revision where a performance metric has regressed, and asks for a last
10 known-good revision. It will then binary search across this revision range by
11 syncing, building, and running a performance test. If the change is
12 suspected to occur as a result of WebKit/V8 changes, the script will
13 further bisect changes to those depots and attempt to narrow down the revision
17 An example usage (using svn cl's):
19 ./tools/bisect-perf-regression.py -c\
20 "out/Release/performance_ui_tests --gtest_filter=ShutdownTest.SimpleUserQuit"\
21 -g 168222 -b 168232 -m shutdown/simple-user-quit
23 Be aware that if you're using the git workflow and specify an svn revision,
24 the script will attempt to find the git SHA1 where svn changes up to that
25 revision were merged in.
28 An example usage (using git hashes):
30 ./tools/bisect-perf-regression.py -c\
31 "out/Release/performance_ui_tests --gtest_filter=ShutdownTest.SimpleUserQuit"\
32 -g 1f6e67861535121c5c819c16a666f2436c207e7b\
33 -b b732f23b4f81c382db0b23b9035f3dadc7d925bb\
34 -m shutdown/simple-user-quit
54 # The additional repositories that might need to be bisected.
55 # If the repository has any dependant repositories (such as skia/src needs
56 # skia/include and skia/gyp to be updated), specify them in the 'depends'
57 # so that they're synced appropriately.
59 # src: path to the working directory.
60 # recurse: True if this repositry will get bisected.
61 # depends: A list of other repositories that are actually part of the same
63 # svn: Needed for git workflow to resolve hashes to svn revisions.
66 "src" : "src/third_party/WebKit",
74 "build_with": 'v8_bleeding_edge'
76 'v8_bleeding_edge' : {
77 "src" : "src/v8_bleeding_edge",
80 "svn": "https://v8.googlecode.com/svn/branches/bleeding_edge"
83 "src" : "src/third_party/skia/src",
85 "svn" : "http://skia.googlecode.com/svn/trunk/src",
86 "depends" : ['skia/include', 'skia/gyp']
89 "src" : "src/third_party/skia/include",
91 "svn" : "http://skia.googlecode.com/svn/trunk/include",
95 "src" : "src/third_party/skia/gyp",
97 "svn" : "http://skia.googlecode.com/svn/trunk/gyp",
102 DEPOT_NAMES
= DEPOT_DEPS_NAME
.keys()
106 def CalculateTruncatedMean(data_set
, truncate_percent
):
107 """Calculates the truncated mean of a set of values.
110 data_set: Set of values to use in calculation.
111 truncate_percent: The % from the upper/lower portions of the data set to
112 discard, expressed as a value in [0, 1].
115 The truncated mean as a float.
117 if len(data_set
) > 2:
118 data_set
= sorted(data_set
)
120 discard_num_float
= len(data_set
) * truncate_percent
121 discard_num_int
= int(math
.floor(discard_num_float
))
122 kept_weight
= len(data_set
) - discard_num_float
* 2
124 data_set
= data_set
[discard_num_int
:len(data_set
)-discard_num_int
]
126 weight_left
= 1.0 - (discard_num_float
- discard_num_int
)
129 # If the % to discard leaves a fractional portion, need to weight those
131 unweighted_vals
= data_set
[1:len(data_set
)-1]
132 weighted_vals
= [data_set
[0], data_set
[len(data_set
)-1]]
133 weighted_vals
= [w
* weight_left
for w
in weighted_vals
]
134 data_set
= weighted_vals
+ unweighted_vals
136 kept_weight
= len(data_set
)
138 truncated_mean
= reduce(lambda x
, y
: float(x
) + float(y
),
139 data_set
) / kept_weight
141 return truncated_mean
144 def CalculateStandardDeviation(v
):
145 mean
= CalculateTruncatedMean(v
, 0.0)
147 variances
= [float(x
) - mean
for x
in v
]
148 variances
= [x
* x
for x
in variances
]
149 variance
= reduce(lambda x
, y
: float(x
) + float(y
), variances
) / (len(v
) - 1)
150 std_dev
= math
.sqrt(variance
)
155 def IsStringFloat(string_to_check
):
156 """Checks whether or not the given string can be converted to a floating
160 string_to_check: Input string to check if it can be converted to a float.
163 True if the string can be converted to a float.
166 float(string_to_check
)
173 def IsStringInt(string_to_check
):
174 """Checks whether or not the given string can be converted to a integer.
177 string_to_check: Input string to check if it can be converted to an int.
180 True if the string can be converted to an int.
191 """Checks whether or not the script is running on Windows.
194 True if running on Windows.
196 return os
.name
== 'nt'
199 def RunProcess(command
, print_output
=False):
200 """Run an arbitrary command, returning its output and return code.
203 command: A list containing the command and args to execute.
204 print_output: Optional parameter to write output to stdout as it's
208 A tuple of the output and return code.
211 print 'Running: [%s]' % ' '.join(command
)
213 # On Windows, use shell=True to get PATH interpretation.
215 proc
= subprocess
.Popen(command
,
217 stdout
=subprocess
.PIPE
,
218 stderr
=subprocess
.PIPE
,
222 def ReadOutputWhileProcessRuns(stdout
, print_output
, out
):
224 line
= stdout
.readline()
229 sys
.stdout
.write(line
)
231 thread
= threading
.Thread(target
=ReadOutputWhileProcessRuns
,
232 args
=(proc
.stdout
, print_output
, out
))
237 return (out
[0], proc
.returncode
)
241 """Run a git subcommand, returning its output and return code.
244 command: A list containing the args to git.
247 A tuple of the output and return code.
249 command
= ['git'] + command
251 return RunProcess(command
)
254 def BuildWithMake(threads
, targets
, print_output
):
255 cmd
= ['make', 'BUILDTYPE=Release', '-j%d' % threads
] + targets
257 (output
, return_code
) = RunProcess(cmd
, print_output
)
259 return not return_code
262 def BuildWithNinja(threads
, targets
, print_output
):
263 cmd
= ['ninja', '-C', os
.path
.join('out', 'Release'),
264 '-j%d' % threads
] + targets
266 (output
, return_code
) = RunProcess(cmd
, print_output
)
268 return not return_code
271 def BuildWithVisualStudio(targets
, print_output
):
272 path_to_devenv
= os
.path
.abspath(
273 os
.path
.join(os
.environ
['VS100COMNTOOLS'], '..', 'IDE', 'devenv.com'))
274 path_to_sln
= os
.path
.join(os
.getcwd(), 'chrome', 'chrome.sln')
275 cmd
= [path_to_devenv
, '/build', 'Release', path_to_sln
]
278 cmd
.extend(['/Project', t
])
280 (output
, return_code
) = RunProcess(cmd
, print_output
)
282 return not return_code
285 class SourceControl(object):
286 """SourceControl is an abstraction over the underlying source control
287 system used for chromium. For now only git is supported, but in the
288 future, the svn workflow could be added as well."""
290 super(SourceControl
, self
).__init
__()
292 def SyncToRevisionWithGClient(self
, revision
):
293 """Uses gclient to sync to the specified revision.
295 ie. gclient sync --revision <revision>
298 revision: The git SHA1 or svn CL (depending on workflow).
301 The return code of the call.
303 return bisect_utils
.RunGClient(['sync', '--revision',
304 revision
, '--verbose'])
307 class GitSourceControl(SourceControl
):
308 """GitSourceControl is used to query the underlying source control. """
310 super(GitSourceControl
, self
).__init
__()
315 def GetRevisionList(self
, revision_range_end
, revision_range_start
):
316 """Retrieves a list of revisions between |revision_range_start| and
317 |revision_range_end|.
320 revision_range_end: The SHA1 for the end of the range.
321 revision_range_start: The SHA1 for the beginning of the range.
324 A list of the revisions between |revision_range_start| and
325 |revision_range_end| (inclusive).
327 revision_range
= '%s..%s' % (revision_range_start
, revision_range_end
)
328 cmd
= ['log', '--format=%H', '-10000', '--first-parent', revision_range
]
329 (log_output
, return_code
) = RunGit(cmd
)
331 assert not return_code
, 'An error occurred while running'\
332 ' "git %s"' % ' '.join(cmd
)
334 revision_hash_list
= log_output
.split()
335 revision_hash_list
.append(revision_range_start
)
337 return revision_hash_list
339 def SyncToRevision(self
, revision
, use_gclient
=True):
340 """Syncs to the specified revision.
343 revision: The revision to sync to.
344 use_gclient: Specifies whether or not we should sync using gclient or
345 just use source control directly.
352 results
= self
.SyncToRevisionWithGClient(revision
)
354 results
= RunGit(['checkout', revision
])[1]
358 def ResolveToRevision(self
, revision_to_check
, depot
, search
):
359 """If an SVN revision is supplied, try to resolve it to a git SHA1.
362 revision_to_check: The user supplied revision string that may need to be
363 resolved to a git SHA1.
364 depot: The depot the revision_to_check is from.
365 search: The number of changelists to try if the first fails to resolve
366 to a git hash. If the value is negative, the function will search
367 backwards chronologically, otherwise it will search forward.
370 A string containing a git SHA1 hash, otherwise None.
372 if not IsStringInt(revision_to_check
):
373 return revision_to_check
375 depot_svn
= 'svn://svn.chromium.org/chrome/trunk/src'
378 depot_svn
= DEPOT_DEPS_NAME
[depot
]['svn']
380 svn_revision
= int(revision_to_check
)
384 search_range
= xrange(svn_revision
, svn_revision
+ search
, 1)
386 search_range
= xrange(svn_revision
, svn_revision
+ search
, -1)
388 for i
in search_range
:
389 svn_pattern
= 'git-svn-id: %s@%d' % (depot_svn
, i
)
390 cmd
= ['log', '--format=%H', '-1', '--grep', svn_pattern
, 'origin/master']
392 (log_output
, return_code
) = RunGit(cmd
)
394 assert not return_code
, 'An error occurred while running'\
395 ' "git %s"' % ' '.join(cmd
)
398 log_output
= log_output
.strip()
401 git_revision
= log_output
407 def IsInProperBranch(self
):
408 """Confirms they're in the master branch for performing the bisection.
409 This is needed or gclient will fail to sync properly.
412 True if the current branch on src is 'master'
414 cmd
= ['rev-parse', '--abbrev-ref', 'HEAD']
415 (log_output
, return_code
) = RunGit(cmd
)
417 assert not return_code
, 'An error occurred while running'\
418 ' "git %s"' % ' '.join(cmd
)
420 log_output
= log_output
.strip()
422 return log_output
== "master"
424 def SVNFindRev(self
, revision
):
425 """Maps directly to the 'git svn find-rev' command.
428 revision: The git SHA1 to use.
431 An integer changelist #, otherwise None.
434 cmd
= ['svn', 'find-rev', revision
]
436 (output
, return_code
) = RunGit(cmd
)
438 assert not return_code
, 'An error occurred while running'\
439 ' "git %s"' % ' '.join(cmd
)
441 svn_revision
= output
.strip()
443 if IsStringInt(svn_revision
):
444 return int(svn_revision
)
448 def QueryRevisionInfo(self
, revision
):
449 """Gathers information on a particular revision, such as author's name,
450 email, subject, and date.
453 revision: Revision you want to gather information on.
455 A dict in the following format:
465 formats
= ['%cN', '%cE', '%s', '%cD']
466 targets
= ['author', 'email', 'subject', 'date']
468 for i
in xrange(len(formats
)):
469 cmd
= ['log', '--format=%s' % formats
[i
], '-1', revision
]
470 (output
, return_code
) = RunGit(cmd
)
471 commit_info
[targets
[i
]] = output
.rstrip()
473 assert not return_code
, 'An error occurred while running'\
474 ' "git %s"' % ' '.join(cmd
)
478 def CheckoutFileAtRevision(self
, file_name
, revision
):
479 """Performs a checkout on a file at the given revision.
484 return not RunGit(['checkout', revision
, file_name
])[1]
486 def RevertFileToHead(self
, file_name
):
487 """Unstages a file and returns it to HEAD.
492 # Reset doesn't seem to return 0 on success.
493 RunGit(['reset', 'HEAD', bisect_utils
.FILE_DEPS_GIT
])
495 return not RunGit(['checkout', bisect_utils
.FILE_DEPS_GIT
])[1]
497 class BisectPerformanceMetrics(object):
498 """BisectPerformanceMetrics performs a bisection against a list of range
499 of revisions to narrow down where performance regressions may have
502 def __init__(self
, source_control
, opts
):
503 super(BisectPerformanceMetrics
, self
).__init
__()
506 self
.source_control
= source_control
507 self
.src_cwd
= os
.getcwd()
509 self
.cleanup_commands
= []
511 # This always starts true since the script grabs latest first.
512 self
.was_blink
= True
514 for d
in DEPOT_NAMES
:
515 # The working directory of each depot is just the path to the depot, but
516 # since we're already in 'src', we can skip that part.
518 self
.depot_cwd
[d
] = self
.src_cwd
+ DEPOT_DEPS_NAME
[d
]['src'][3:]
520 def PerformCleanup(self
):
521 """Performs cleanup when script is finished."""
522 os
.chdir(self
.src_cwd
)
523 for c
in self
.cleanup_commands
:
525 shutil
.move(c
[1], c
[2])
527 assert False, 'Invalid cleanup command.'
529 def GetRevisionList(self
, bad_revision
, good_revision
):
530 """Retrieves a list of all the commits between the bad revision and
531 last known good revision."""
533 revision_work_list
= self
.source_control
.GetRevisionList(bad_revision
,
536 return revision_work_list
538 def Get3rdPartyRevisionsFromCurrentRevision(self
):
539 """Parses the DEPS file to determine WebKit/v8/etc... versions.
542 A dict in the format {depot:revision} if successful, otherwise None.
546 os
.chdir(self
.src_cwd
)
548 locals = {'Var': lambda _
: locals["vars"][_
],
549 'From': lambda *args
: None}
550 execfile(bisect_utils
.FILE_DEPS_GIT
, {}, locals)
556 rxp
= re
.compile(".git@(?P<revision>[a-fA-F0-9]+)")
558 for d
in DEPOT_NAMES
:
559 if DEPOT_DEPS_NAME
[d
]['recurse']:
560 if locals['deps'].has_key(DEPOT_DEPS_NAME
[d
]['src']):
561 re_results
= rxp
.search(locals['deps'][DEPOT_DEPS_NAME
[d
]['src']])
564 results
[d
] = re_results
.group('revision')
572 def BuildCurrentRevision(self
):
573 """Builds chrome and performance_ui_tests on the current revision.
576 True if the build was successful.
578 if self
.opts
.debug_ignore_build
:
581 targets
= ['chrome', 'performance_ui_tests']
583 if self
.opts
.use_goma
:
587 os
.chdir(self
.src_cwd
)
589 if self
.opts
.build_preference
== 'make':
590 build_success
= BuildWithMake(threads
, targets
,
591 self
.opts
.output_buildbot_annotations
)
592 elif self
.opts
.build_preference
== 'ninja':
594 targets
= [t
+ '.exe' for t
in targets
]
595 build_success
= BuildWithNinja(threads
, targets
,
596 self
.opts
.output_buildbot_annotations
)
597 elif self
.opts
.build_preference
== 'msvs':
598 assert IsWindows(), 'msvs is only supported on Windows.'
599 build_success
= BuildWithVisualStudio(targets
,
600 self
.opts
.output_buildbot_annotations
)
602 assert False, 'No build system defined.'
608 def RunGClientHooks(self
):
609 """Runs gclient with runhooks command.
612 True if gclient reports no errors.
615 if self
.opts
.debug_ignore_build
:
618 return not bisect_utils
.RunGClient(['runhooks'])
620 def ParseMetricValuesFromOutput(self
, metric
, text
):
621 """Parses output from performance_ui_tests and retrieves the results for
625 metric: The metric as a list of [<trace>, <value>] strings.
626 text: The text to parse the metric values from.
629 A list of floating point numbers found.
631 # Format is: RESULT <graph>: <trace>= <value> <units>
632 metric_formatted
= re
.escape('RESULT %s: %s=' % (metric
[0], metric
[1]))
634 text_lines
= text
.split('\n')
637 for current_line
in text_lines
:
638 # Parse the output from the performance test for the metric we're
640 metric_re
= metric_formatted
+\
641 "(\s)*(?P<values>[0-9]+(\.[0-9]*)?)"
642 metric_re
= re
.compile(metric_re
)
643 regex_results
= metric_re
.search(current_line
)
645 if not regex_results
is None:
646 values_list
+= [regex_results
.group('values')]
648 metric_re
= metric_formatted
+\
649 "(\s)*\[(\s)*(?P<values>[0-9,.]+)\]"
650 metric_re
= re
.compile(metric_re
)
651 regex_results
= metric_re
.search(current_line
)
653 if not regex_results
is None:
654 metric_values
= regex_results
.group('values')
656 values_list
+= metric_values
.split(',')
658 values_list
= [float(v
) for v
in values_list
if IsStringFloat(v
)]
660 # If the metric is times/t, we need to sum the timings in order to get
661 # similar regression results as the try-bots.
663 if metric
== ['times', 't']:
665 values_list
= [reduce(lambda x
, y
: float(x
) + float(y
), values_list
)]
669 def RunPerformanceTestAndParseResults(self
, command_to_run
, metric
):
670 """Runs a performance test on the current revision by executing the
671 'command_to_run' and parses the results.
674 command_to_run: The command to be run to execute the performance test.
675 metric: The metric to parse out from the results of the performance test.
678 On success, it will return a tuple of the average value of the metric,
679 and a success code of 0.
682 if self
.opts
.debug_ignore_perf_test
:
683 return ({'mean': 0.0, 'std_dev': 0.0}, 0)
686 command_to_run
= command_to_run
.replace('/', r
'\\')
688 args
= shlex
.split(command_to_run
)
691 os
.chdir(self
.src_cwd
)
693 start_time
= time
.time()
696 for i
in xrange(self
.opts
.repeat_test_count
):
697 # Can ignore the return code since if the tests fail, it won't return 0.
698 (output
, return_code
) = RunProcess(args
,
699 self
.opts
.output_buildbot_annotations
)
701 metric_values
+= self
.ParseMetricValuesFromOutput(metric
, output
)
703 elapsed_minutes
= (time
.time() - start_time
) / 60.0
705 if elapsed_minutes
>= self
.opts
.repeat_test_max_time
or not metric_values
:
710 # Need to get the average value if there were multiple values.
712 truncated_mean
= CalculateTruncatedMean(metric_values
,
713 self
.opts
.truncate_percent
)
714 standard_dev
= CalculateStandardDeviation(metric_values
)
717 'mean': truncated_mean
,
718 'std_dev': standard_dev
,
721 print 'Results of performance test: %12f %12f' % (
722 truncated_mean
, standard_dev
)
726 return ('Invalid metric specified, or no values returned from '
727 'performance test.', -1)
729 def FindAllRevisionsToSync(self
, revision
, depot
):
730 """Finds all dependant revisions and depots that need to be synced for a
731 given revision. This is only useful in the git workflow, as an svn depot
732 may be split into multiple mirrors.
734 ie. skia is broken up into 3 git mirrors over skia/src, skia/gyp, and
735 skia/include. To sync skia/src properly, one has to find the proper
736 revisions in skia/gyp and skia/include.
739 revision: The revision to sync to.
740 depot: The depot in use at the moment (probably skia).
743 A list of [depot, revision] pairs that need to be synced.
745 revisions_to_sync
= [[depot
, revision
]]
747 use_gclient
= (depot
== 'chromium')
749 # Some SVN depots were split into multiple git depots, so we need to
750 # figure out for each mirror which git revision to grab. There's no
751 # guarantee that the SVN revision will exist for each of the dependant
752 # depots, so we have to grep the git logs and grab the next earlier one.
753 if not use_gclient
and\
754 DEPOT_DEPS_NAME
[depot
]['depends'] and\
755 self
.source_control
.IsGit():
756 svn_rev
= self
.source_control
.SVNFindRev(revision
)
758 for d
in DEPOT_DEPS_NAME
[depot
]['depends']:
759 self
.ChangeToDepotWorkingDirectory(d
)
761 dependant_rev
= self
.source_control
.ResolveToRevision(svn_rev
, d
, -1000)
764 revisions_to_sync
.append([d
, dependant_rev
])
766 num_resolved
= len(revisions_to_sync
)
767 num_needed
= len(DEPOT_DEPS_NAME
[depot
]['depends'])
769 self
.ChangeToDepotWorkingDirectory(depot
)
771 if not ((num_resolved
- 1) == num_needed
):
774 return revisions_to_sync
776 def PerformPreBuildCleanup(self
):
777 """Performs necessary cleanup between runs."""
778 print 'Cleaning up between runs.'
781 # Having these pyc files around between runs can confuse the
782 # perf tests and cause them to crash.
783 for (path
, dir, files
) in os
.walk(os
.getcwd()):
784 for cur_file
in files
:
785 if cur_file
.endswith('.pyc'):
786 path_to_file
= os
.path
.join(path
, cur_file
)
787 os
.remove(path_to_file
)
789 def PerformWebkitDirectoryCleanup(self
, revision
):
790 """If the script is switching between Blink and WebKit during bisect,
791 its faster to just delete the directory rather than leave it up to git
797 if not self
.source_control
.CheckoutFileAtRevision(
798 bisect_utils
.FILE_DEPS_GIT
, revision
):
802 os
.chdir(self
.src_cwd
)
804 is_blink
= bisect_utils
.IsDepsFileBlink()
808 if not self
.source_control
.RevertFileToHead(
809 bisect_utils
.FILE_DEPS_GIT
):
812 if self
.was_blink
!= is_blink
:
813 self
.was_blink
= is_blink
814 return bisect_utils
.RemoveThirdPartyWebkitDirectory()
817 def PerformPreSyncCleanup(self
, revision
, depot
):
818 """Performs any necessary cleanup before syncing.
823 if depot
== 'chromium':
824 return self
.PerformWebkitDirectoryCleanup(revision
)
827 def SyncBuildAndRunRevision(self
, revision
, depot
, command_to_run
, metric
):
828 """Performs a full sync/build/run of the specified revision.
831 revision: The revision to sync to.
832 depot: The depot that's being used at the moment (src, webkit, etc.)
833 command_to_run: The command to execute the performance test.
834 metric: The performance metric being tested.
837 On success, a tuple containing the results of the performance test.
838 Otherwise, a tuple with the error message.
840 use_gclient
= (depot
== 'chromium')
842 revisions_to_sync
= self
.FindAllRevisionsToSync(revision
, depot
)
844 if not revisions_to_sync
:
845 return ('Failed to resolve dependant depots.', 1)
847 if not self
.PerformPreSyncCleanup(revision
, depot
):
848 return ('Failed to perform pre-sync cleanup.', 1)
852 if not self
.opts
.debug_ignore_sync
:
853 for r
in revisions_to_sync
:
854 self
.ChangeToDepotWorkingDirectory(r
[0])
857 self
.PerformPreBuildCleanup()
859 if not self
.source_control
.SyncToRevision(r
[1], use_gclient
):
866 success
= self
.RunGClientHooks()
869 if self
.BuildCurrentRevision():
870 results
= self
.RunPerformanceTestAndParseResults(command_to_run
,
873 if results
[1] == 0 and use_gclient
:
874 external_revisions
= self
.Get3rdPartyRevisionsFromCurrentRevision()
876 if external_revisions
:
877 return (results
[0], results
[1], external_revisions
)
879 return ('Failed to parse DEPS file for external revisions.', 1)
883 return ('Failed to build revision: [%s]' % (str(revision
, )), 1)
885 return ('Failed to run [gclient runhooks].', 1)
887 return ('Failed to sync revision: [%s]' % (str(revision
, )), 1)
889 def CheckIfRunPassed(self
, current_value
, known_good_value
, known_bad_value
):
890 """Given known good and bad values, decide if the current_value passed
894 current_value: The value of the metric being checked.
895 known_bad_value: The reference value for a "failed" run.
896 known_good_value: The reference value for a "passed" run.
899 True if the current_value is closer to the known_good_value than the
902 dist_to_good_value
= abs(current_value
['mean'] - known_good_value
['mean'])
903 dist_to_bad_value
= abs(current_value
['mean'] - known_bad_value
['mean'])
905 return dist_to_good_value
< dist_to_bad_value
907 def ChangeToDepotWorkingDirectory(self
, depot_name
):
908 """Given a depot, changes to the appropriate working directory.
911 depot_name: The name of the depot (see DEPOT_NAMES).
913 if depot_name
== 'chromium':
914 os
.chdir(self
.src_cwd
)
915 elif depot_name
in DEPOT_NAMES
:
916 os
.chdir(self
.depot_cwd
[depot_name
])
918 assert False, 'Unknown depot [ %s ] encountered. Possibly a new one'\
919 ' was added without proper support?' %\
922 def PrepareToBisectOnDepot(self
,
926 """Changes to the appropriate directory and gathers a list of revisions
927 to bisect between |start_revision| and |end_revision|.
930 current_depot: The depot we want to bisect.
931 end_revision: End of the revision range.
932 start_revision: Start of the revision range.
935 A list containing the revisions between |start_revision| and
936 |end_revision| inclusive.
938 # Change into working directory of external library to run
939 # subsequent commands.
940 old_cwd
= os
.getcwd()
941 os
.chdir(self
.depot_cwd
[current_depot
])
943 # V8 (and possibly others) is merged in periodically. Bisecting
944 # this directory directly won't give much good info.
945 if DEPOT_DEPS_NAME
[current_depot
].has_key('build_with'):
946 new_depot
= DEPOT_DEPS_NAME
[current_depot
]['build_with']
948 svn_start_revision
= self
.source_control
.SVNFindRev(start_revision
)
949 svn_end_revision
= self
.source_control
.SVNFindRev(end_revision
)
950 os
.chdir(self
.depot_cwd
[new_depot
])
952 start_revision
= self
.source_control
.ResolveToRevision(
953 svn_start_revision
, new_depot
, -1000)
954 end_revision
= self
.source_control
.ResolveToRevision(
955 svn_end_revision
, new_depot
, -1000)
957 old_name
= DEPOT_DEPS_NAME
[current_depot
]['src'][4:]
958 new_name
= DEPOT_DEPS_NAME
[new_depot
]['src'][4:]
960 os
.chdir(self
.src_cwd
)
962 shutil
.move(old_name
, old_name
+ '.bak')
963 shutil
.move(new_name
, old_name
)
964 os
.chdir(self
.depot_cwd
[current_depot
])
966 self
.cleanup_commands
.append(['mv', old_name
, new_name
])
967 self
.cleanup_commands
.append(['mv', old_name
+ '.bak', old_name
])
969 os
.chdir(self
.depot_cwd
[current_depot
])
971 depot_revision_list
= self
.GetRevisionList(end_revision
, start_revision
)
975 return depot_revision_list
977 def GatherReferenceValues(self
, good_rev
, bad_rev
, cmd
, metric
):
978 """Gathers reference values by running the performance tests on the
979 known good and bad revisions.
982 good_rev: The last known good revision where the performance regression
983 has not occurred yet.
984 bad_rev: A revision where the performance regression has already occurred.
985 cmd: The command to execute the performance test.
986 metric: The metric being tested for regression.
989 A tuple with the results of building and running each revision.
991 bad_run_results
= self
.SyncBuildAndRunRevision(bad_rev
,
996 good_run_results
= None
998 if not bad_run_results
[1]:
999 good_run_results
= self
.SyncBuildAndRunRevision(good_rev
,
1004 return (bad_run_results
, good_run_results
)
1006 def AddRevisionsIntoRevisionData(self
, revisions
, depot
, sort
, revision_data
):
1007 """Adds new revisions to the revision_data dict and initializes them.
1010 revisions: List of revisions to add.
1011 depot: Depot that's currently in use (src, webkit, etc...)
1012 sort: Sorting key for displaying revisions.
1013 revision_data: A dict to add the new revisions into. Existing revisions
1014 will have their sort keys offset.
1017 num_depot_revisions
= len(revisions
)
1019 for k
, v
in revision_data
.iteritems():
1020 if v
['sort'] > sort
:
1021 v
['sort'] += num_depot_revisions
1023 for i
in xrange(num_depot_revisions
):
1026 revision_data
[r
] = {'revision' : r
,
1030 'sort' : i
+ sort
+ 1}
1032 def PrintRevisionsToBisectMessage(self
, revision_list
, depot
):
1033 if self
.opts
.output_buildbot_annotations
:
1034 step_name
= 'Bisection Range: [%s - %s]' % (
1035 revision_list
[len(revision_list
)-1], revision_list
[0])
1036 bisect_utils
.OutputAnnotationStepStart(step_name
)
1039 print 'Revisions to bisect on [%s]:' % depot
1040 for revision_id
in revision_list
:
1041 print ' -> %s' % (revision_id
, )
1044 if self
.opts
.output_buildbot_annotations
:
1045 bisect_utils
.OutputAnnotationStepClosed()
1047 def Run(self
, command_to_run
, bad_revision_in
, good_revision_in
, metric
):
1048 """Given known good and bad revisions, run a binary search on all
1049 intermediate revisions to determine the CL where the performance regression
1053 command_to_run: Specify the command to execute the performance test.
1054 good_revision: Number/tag of the known good revision.
1055 bad_revision: Number/tag of the known bad revision.
1056 metric: The performance metric to monitor.
1059 A dict with 2 members, 'revision_data' and 'error'. On success,
1060 'revision_data' will contain a dict mapping revision ids to
1061 data about that revision. Each piece of revision data consists of a
1062 dict with the following keys:
1064 'passed': Represents whether the performance test was successful at
1065 that revision. Possible values include: 1 (passed), 0 (failed),
1066 '?' (skipped), 'F' (build failed).
1067 'depot': The depot that this revision is from (ie. WebKit)
1068 'external': If the revision is a 'src' revision, 'external' contains
1069 the revisions of each of the external libraries.
1070 'sort': A sort value for sorting the dict in order of commits.
1087 If an error occurred, the 'error' field will contain the message and
1088 'revision_data' will be empty.
1091 results
= {'revision_data' : {},
1094 # If they passed SVN CL's, etc... we can try match them to git SHA1's.
1095 bad_revision
= self
.source_control
.ResolveToRevision(bad_revision_in
,
1097 good_revision
= self
.source_control
.ResolveToRevision(good_revision_in
,
1100 if bad_revision
is None:
1101 results
['error'] = 'Could\'t resolve [%s] to SHA1.' % (bad_revision_in
,)
1104 if good_revision
is None:
1105 results
['error'] = 'Could\'t resolve [%s] to SHA1.' % (good_revision_in
,)
1108 if self
.opts
.output_buildbot_annotations
:
1109 bisect_utils
.OutputAnnotationStepStart('Gathering Revisions')
1111 print 'Gathering revision range for bisection.'
1113 # Retrieve a list of revisions to do bisection on.
1114 src_revision_list
= self
.GetRevisionList(bad_revision
, good_revision
)
1116 if self
.opts
.output_buildbot_annotations
:
1117 bisect_utils
.OutputAnnotationStepClosed()
1119 if src_revision_list
:
1120 # revision_data will store information about a revision such as the
1121 # depot it came from, the webkit/V8 revision at that time,
1122 # performance timing, build state, etc...
1123 revision_data
= results
['revision_data']
1125 # revision_list is the list we're binary searching through at the moment.
1130 for current_revision_id
in src_revision_list
:
1133 revision_data
[current_revision_id
] = {'value' : None,
1135 'depot' : 'chromium',
1137 'sort' : sort_key_ids
}
1138 revision_list
.append(current_revision_id
)
1141 max_revision
= len(revision_list
) - 1
1143 self
.PrintRevisionsToBisectMessage(revision_list
, 'src')
1145 if self
.opts
.output_buildbot_annotations
:
1146 bisect_utils
.OutputAnnotationStepStart('Gathering Reference Values')
1148 print 'Gathering reference values for bisection.'
1150 # Perform the performance tests on the good and bad revisions, to get
1152 (bad_results
, good_results
) = self
.GatherReferenceValues(good_revision
,
1157 if self
.opts
.output_buildbot_annotations
:
1158 bisect_utils
.OutputAnnotationStepClosed()
1161 results
['error'] = bad_results
[0]
1165 results
['error'] = good_results
[0]
1169 # We need these reference values to determine if later runs should be
1170 # classified as pass or fail.
1171 known_bad_value
= bad_results
[0]
1172 known_good_value
= good_results
[0]
1174 # Can just mark the good and bad revisions explicitly here since we
1175 # already know the results.
1176 bad_revision_data
= revision_data
[revision_list
[0]]
1177 bad_revision_data
['external'] = bad_results
[2]
1178 bad_revision_data
['passed'] = 0
1179 bad_revision_data
['value'] = known_bad_value
1181 good_revision_data
= revision_data
[revision_list
[max_revision
]]
1182 good_revision_data
['external'] = good_results
[2]
1183 good_revision_data
['passed'] = 1
1184 good_revision_data
['value'] = known_good_value
1187 if not revision_list
:
1190 min_revision_data
= revision_data
[revision_list
[min_revision
]]
1191 max_revision_data
= revision_data
[revision_list
[max_revision
]]
1193 if max_revision
- min_revision
<= 1:
1194 if min_revision_data
['passed'] == '?':
1195 next_revision_index
= min_revision
1196 elif max_revision_data
['passed'] == '?':
1197 next_revision_index
= max_revision
1198 elif min_revision_data
['depot'] == 'chromium':
1199 # If there were changes to any of the external libraries we track,
1200 # should bisect the changes there as well.
1201 external_depot
= None
1203 for current_depot
in DEPOT_NAMES
:
1204 if DEPOT_DEPS_NAME
[current_depot
]["recurse"]:
1205 if min_revision_data
['external'][current_depot
] !=\
1206 max_revision_data
['external'][current_depot
]:
1207 external_depot
= current_depot
1211 # If there was no change in any of the external depots, the search
1213 if not external_depot
:
1216 earliest_revision
= max_revision_data
['external'][current_depot
]
1217 latest_revision
= min_revision_data
['external'][current_depot
]
1219 new_revision_list
= self
.PrepareToBisectOnDepot(external_depot
,
1223 if not new_revision_list
:
1224 results
['error'] = 'An error occurred attempting to retrieve'\
1225 ' revision range: [%s..%s]' %\
1226 (depot_rev_range
[1], depot_rev_range
[0])
1229 self
.AddRevisionsIntoRevisionData(new_revision_list
,
1231 min_revision_data
['sort'],
1234 # Reset the bisection and perform it on the newly inserted
1236 revision_list
= new_revision_list
1238 max_revision
= len(revision_list
) - 1
1239 sort_key_ids
+= len(revision_list
)
1241 print 'Regression in metric:%s appears to be the result of changes'\
1242 ' in [%s].' % (metric
, current_depot
)
1244 self
.PrintRevisionsToBisectMessage(revision_list
, external_depot
)
1250 next_revision_index
= int((max_revision
- min_revision
) / 2) +\
1253 next_revision_id
= revision_list
[next_revision_index
]
1254 next_revision_data
= revision_data
[next_revision_id
]
1255 next_revision_depot
= next_revision_data
['depot']
1257 self
.ChangeToDepotWorkingDirectory(next_revision_depot
)
1259 if self
.opts
.output_buildbot_annotations
:
1260 step_name
= 'Working on [%s]' % next_revision_id
1261 bisect_utils
.OutputAnnotationStepStart(step_name
)
1263 print 'Working on revision: [%s]' % next_revision_id
1265 run_results
= self
.SyncBuildAndRunRevision(next_revision_id
,
1266 next_revision_depot
,
1270 if self
.opts
.output_buildbot_annotations
:
1271 bisect_utils
.OutputAnnotationStepClosed()
1273 # If the build is successful, check whether or not the metric
1275 if not run_results
[1]:
1276 if next_revision_depot
== 'chromium':
1277 next_revision_data
['external'] = run_results
[2]
1279 passed_regression
= self
.CheckIfRunPassed(run_results
[0],
1283 next_revision_data
['passed'] = passed_regression
1284 next_revision_data
['value'] = run_results
[0]
1286 if passed_regression
:
1287 max_revision
= next_revision_index
1289 min_revision
= next_revision_index
1291 next_revision_data
['passed'] = 'F'
1293 # If the build is broken, remove it and redo search.
1294 revision_list
.pop(next_revision_index
)
1298 # Weren't able to sync and retrieve the revision range.
1299 results
['error'] = 'An error occurred attempting to retrieve revision '\
1300 'range: [%s..%s]' % (good_revision
, bad_revision
)
1304 def FormatAndPrintResults(self
, bisect_results
):
1305 """Prints the results from a bisection run in a readable format.
1308 bisect_results: The results from a bisection test run.
1310 revision_data
= bisect_results
['revision_data']
1311 revision_data_sorted
= sorted(revision_data
.iteritems(),
1312 key
= lambda x
: x
[1]['sort'])
1314 if self
.opts
.output_buildbot_annotations
:
1315 bisect_utils
.OutputAnnotationStepStart('Results')
1318 print 'Full results of bisection:'
1319 for current_id
, current_data
in revision_data_sorted
:
1320 build_status
= current_data
['passed']
1322 if type(build_status
) is bool:
1323 build_status
= int(build_status
)
1325 print ' %8s %s %s' % (current_data
['depot'], current_id
, build_status
)
1329 print 'Tested commits:'
1330 for current_id
, current_data
in revision_data_sorted
:
1331 if current_data
['value']:
1332 print ' %8s %s %12f %12f' % (
1333 current_data
['depot'], current_id
,
1334 current_data
['value']['mean'], current_data
['value']['std_dev'])
1337 # Find range where it possibly broke.
1338 first_working_revision
= None
1339 last_broken_revision
= None
1341 for k
, v
in revision_data_sorted
:
1342 if v
['passed'] == 1:
1343 if not first_working_revision
:
1344 first_working_revision
= k
1347 last_broken_revision
= k
1349 if last_broken_revision
!= None and first_working_revision
!= None:
1350 print 'Results: Regression may have occurred in range:'
1351 print ' -> First Bad Revision: [%s] [%s]' %\
1352 (last_broken_revision
,
1353 revision_data
[last_broken_revision
]['depot'])
1354 print ' -> Last Good Revision: [%s] [%s]' %\
1355 (first_working_revision
,
1356 revision_data
[first_working_revision
]['depot'])
1359 self
.ChangeToDepotWorkingDirectory(
1360 revision_data
[last_broken_revision
]['depot'])
1361 info
= self
.source_control
.QueryRevisionInfo(last_broken_revision
)
1364 print 'Commit : %s' % last_broken_revision
1365 print 'Author : %s' % info
['author']
1366 print 'Email : %s' % info
['email']
1367 print 'Date : %s' % info
['date']
1368 print 'Subject : %s' % info
['subject']
1372 # Give a warning if the values were very close together
1373 good_std_dev
= revision_data
[first_working_revision
]['value']['std_dev']
1374 good_mean
= revision_data
[first_working_revision
]['value']['mean']
1375 bad_mean
= revision_data
[last_broken_revision
]['value']['mean']
1377 # A standard deviation of 0 could indicate either insufficient runs
1378 # or a test that consistently returns the same value.
1379 if good_std_dev
> 0:
1380 deviations
= math
.fabs(bad_mean
- good_mean
) / good_std_dev
1382 if deviations
< 1.5:
1383 print 'Warning: Regression was less than 1.5 standard deviations '\
1384 'from "good" value. Results may not be accurate.'
1386 elif self
.opts
.repeat_test_count
== 1:
1387 print 'Warning: Tests were only set to run once. This may be '\
1388 'insufficient to get meaningful results.'
1391 # Check for any other possible regression ranges
1392 prev_revision_data
= revision_data_sorted
[0][1]
1393 prev_revision_id
= revision_data_sorted
[0][0]
1394 possible_regressions
= []
1395 for current_id
, current_data
in revision_data_sorted
:
1396 if current_data
['value']:
1397 prev_mean
= prev_revision_data
['value']['mean']
1398 cur_mean
= current_data
['value']['mean']
1401 deviations
= math
.fabs(prev_mean
- cur_mean
) / good_std_dev
1406 percent_change
= (prev_mean
- cur_mean
) / good_mean
1408 # If the "good" valuse are supposed to be higher than the "bad"
1409 # values (ie. scores), flip the sign of the percent change so that
1410 # a positive value always represents a regression.
1411 if bad_mean
< good_mean
:
1412 percent_change
*= -1.0
1414 percent_change
= None
1416 if deviations
>= 1.5 or percent_change
> 0.01:
1417 if current_id
!= first_working_revision
:
1418 possible_regressions
.append(
1419 [current_id
, prev_revision_id
, percent_change
, deviations
])
1420 prev_revision_data
= current_data
1421 prev_revision_id
= current_id
1423 if possible_regressions
:
1425 print 'Other regressions may have occurred:'
1427 for p
in possible_regressions
:
1429 percent_change
= p
[2]
1431 current_data
= revision_data
[current_id
]
1433 previous_data
= revision_data
[previous_id
]
1435 if deviations
is None:
1438 deviations
= '%.2f' % deviations
1440 if percent_change
is None:
1443 print ' %8s %s [%.2f%%, %s x std.dev]' % (
1444 previous_data
['depot'], previous_id
, 100 * percent_change
,
1447 current_data
['depot'], current_id
)
1450 if self
.opts
.output_buildbot_annotations
:
1451 bisect_utils
.OutputAnnotationStepClosed()
1454 def DetermineAndCreateSourceControl():
1455 """Attempts to determine the underlying source control workflow and returns
1456 a SourceControl object.
1459 An instance of a SourceControl object, or None if the current workflow
1463 (output
, return_code
) = RunGit(['rev-parse', '--is-inside-work-tree'])
1465 if output
.strip() == 'true':
1466 return GitSourceControl()
1471 def SetNinjaBuildSystemDefault():
1472 """Makes ninja the default build system to be used by
1473 the bisection script."""
1474 gyp_var
= os
.getenv('GYP_GENERATORS')
1476 if not gyp_var
or not 'ninja' in gyp_var
:
1478 os
.environ
['GYP_GENERATORS'] = gyp_var
+ ',ninja'
1480 os
.environ
['GYP_GENERATORS'] = 'ninja'
1483 os
.environ
['GYP_DEFINES'] = 'component=shared_library '\
1484 'incremental_chrome_dll=1 disable_nacl=1 fastbuild=1 '\
1485 'chromium_win_pch=0'
1488 def SetMakeBuildSystemDefault():
1489 """Makes make the default build system to be used by
1490 the bisection script."""
1491 os
.environ
['GYP_GENERATORS'] = 'make'
1494 def CheckPlatformSupported(opts
):
1495 """Checks that this platform and build system are supported.
1498 opts: The options parsed from the command line.
1501 True if the platform and build system are supported.
1503 # Haven't tested the script out on any other platforms yet.
1504 supported
= ['posix', 'nt']
1505 if not os
.name
in supported
:
1506 print "Sorry, this platform isn't supported yet."
1511 if not opts
.build_preference
:
1512 opts
.build_preference
= 'msvs'
1514 if opts
.build_preference
== 'msvs':
1515 if not os
.getenv('VS100COMNTOOLS'):
1516 print 'Error: Path to visual studio could not be determined.'
1519 elif opts
.build_preference
== 'ninja':
1520 SetNinjaBuildSystemDefault()
1522 assert False, 'Error: %s build not supported' % opts
.build_preference
1524 if not opts
.build_preference
:
1525 if 'ninja' in os
.getenv('GYP_GENERATORS'):
1526 opts
.build_preference
= 'ninja'
1528 opts
.build_preference
= 'make'
1530 if opts
.build_preference
== 'ninja':
1531 SetNinjaBuildSystemDefault()
1532 elif opts
.build_preference
== 'make':
1533 SetMakeBuildSystemDefault()
1534 elif opts
.build_preference
!= 'make':
1535 assert False, 'Error: %s build not supported' % opts
.build_preference
1537 bisect_utils
.RunGClient(['runhooks'])
1542 def RmTreeAndMkDir(path_to_dir
):
1543 """Removes the directory tree specified, and then creates an empty
1544 directory in the same location.
1547 path_to_dir: Path to the directory tree.
1550 True if successful, False if an error occurred.
1553 if os
.path
.exists(path_to_dir
):
1554 shutil
.rmtree(path_to_dir
)
1556 if e
.errno
!= errno
.ENOENT
:
1560 os
.makedirs(path_to_dir
)
1562 if e
.errno
!= errno
.EEXIST
:
1568 def RemoveBuildFiles():
1569 """Removes build files from previous runs."""
1570 if RmTreeAndMkDir(os
.path
.join('out', 'Release')):
1571 if RmTreeAndMkDir(os
.path
.join('build', 'Release')):
1578 usage
= ('%prog [options] [-- chromium-options]\n'
1579 'Perform binary search on revision history to find a minimal '
1580 'range of revisions where a peformance metric regressed.\n')
1582 parser
= optparse
.OptionParser(usage
=usage
)
1584 parser
.add_option('-c', '--command',
1586 help='A command to execute your performance test at' +
1587 ' each point in the bisection.')
1588 parser
.add_option('-b', '--bad_revision',
1590 help='A bad revision to start bisection. ' +
1591 'Must be later than good revision. May be either a git' +
1592 ' or svn revision.')
1593 parser
.add_option('-g', '--good_revision',
1595 help='A revision to start bisection where performance' +
1596 ' test is known to pass. Must be earlier than the ' +
1597 'bad revision. May be either a git or svn revision.')
1598 parser
.add_option('-m', '--metric',
1600 help='The desired metric to bisect on. For example ' +
1601 '"vm_rss_final_b/vm_rss_f_b"')
1602 parser
.add_option('-w', '--working_directory',
1604 help='Path to the working directory where the script will '
1605 'do an initial checkout of the chromium depot. The '
1606 'files will be placed in a subdirectory "bisect" under '
1607 'working_directory and that will be used to perform the '
1608 'bisection. This parameter is optional, if it is not '
1609 'supplied, the script will work from the current depot.')
1610 parser
.add_option('-r', '--repeat_test_count',
1613 help='The number of times to repeat the performance test. '
1614 'Values will be clamped to range [1, 100]. '
1615 'Default value is 20.')
1616 parser
.add_option('--repeat_test_max_time',
1619 help='The maximum time (in minutes) to take running the '
1620 'performance tests. The script will run the performance '
1621 'tests according to --repeat_test_count, so long as it '
1622 'doesn\'t exceed --repeat_test_max_time. Values will be '
1623 'clamped to range [1, 60].'
1624 'Default value is 20.')
1625 parser
.add_option('-t', '--truncate_percent',
1628 help='The highest/lowest % are discarded to form a '
1629 'truncated mean. Values will be clamped to range [0, 25]. '
1630 'Default value is 25 (highest/lowest 25% will be '
1632 parser
.add_option('--build_preference',
1634 choices
=['msvs', 'ninja', 'make'],
1635 help='The preferred build system to use. On linux/mac '
1636 'the options are make/ninja. On Windows, the options '
1638 parser
.add_option('--use_goma',
1639 action
="store_true",
1640 help='Add a bunch of extra threads for goma.')
1641 parser
.add_option('--output_buildbot_annotations',
1642 action
="store_true",
1643 help='Add extra annotation output for buildbot.')
1644 parser
.add_option('--debug_ignore_build',
1645 action
="store_true",
1646 help='DEBUG: Don\'t perform builds.')
1647 parser
.add_option('--debug_ignore_sync',
1648 action
="store_true",
1649 help='DEBUG: Don\'t perform syncs.')
1650 parser
.add_option('--debug_ignore_perf_test',
1651 action
="store_true",
1652 help='DEBUG: Don\'t perform performance tests.')
1653 (opts
, args
) = parser
.parse_args()
1655 if not opts
.command
:
1656 print 'Error: missing required parameter: --command'
1661 if not opts
.good_revision
:
1662 print 'Error: missing required parameter: --good_revision'
1667 if not opts
.bad_revision
:
1668 print 'Error: missing required parameter: --bad_revision'
1674 print 'Error: missing required parameter: --metric'
1679 opts
.repeat_test_count
= min(max(opts
.repeat_test_count
, 1), 100)
1680 opts
.repeat_test_max_time
= min(max(opts
.repeat_test_max_time
, 1), 60)
1681 opts
.truncate_percent
= min(max(opts
.truncate_percent
, 0), 25)
1682 opts
.truncate_percent
= opts
.truncate_percent
/ 100.0
1684 metric_values
= opts
.metric
.split('/')
1685 if len(metric_values
) != 2:
1686 print "Invalid metric specified: [%s]" % (opts
.metric
,)
1690 if opts
.working_directory
:
1691 if bisect_utils
.CreateBisectDirectoryAndSetupDepot(opts
):
1694 os
.chdir(os
.path
.join(os
.getcwd(), 'src'))
1696 if not RemoveBuildFiles():
1697 print "Something went wrong removing the build files."
1701 if not CheckPlatformSupported(opts
):
1704 # Check what source control method they're using. Only support git workflow
1706 source_control
= DetermineAndCreateSourceControl()
1708 if not source_control
:
1709 print "Sorry, only the git workflow is supported at the moment."
1713 # gClient sync seems to fail if you're not in master branch.
1714 if not source_control
.IsInProperBranch() and not opts
.debug_ignore_sync
:
1715 print "You must switch to master branch to run bisection."
1719 bisect_test
= BisectPerformanceMetrics(source_control
, opts
)
1721 bisect_results
= bisect_test
.Run(opts
.command
,
1725 if not(bisect_results
['error']):
1726 bisect_test
.FormatAndPrintResults(bisect_results
)
1728 bisect_test
.PerformCleanup()
1730 if not(bisect_results
['error']):
1733 print 'Error: ' + bisect_results
['error']
1737 if __name__
== '__main__':