• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python3
2# Copyright 2017 The PDFium Authors
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5"""Compares the performance of two versions of the pdfium code."""
6
7import argparse
8import functools
9import glob
10import json
11import multiprocessing
12import os
13import re
14import shutil
15import subprocess
16import sys
17import tempfile
18
19from common import GetBooleanGnArg
20from common import PrintErr
21from common import RunCommandPropagateErr
22from githelper import GitHelper
23from safetynet_conclusions import ComparisonConclusions
24from safetynet_conclusions import PrintConclusionsDictHumanReadable
25from safetynet_conclusions import RATING_IMPROVEMENT
26from safetynet_conclusions import RATING_REGRESSION
27from safetynet_image import ImageComparison
28
29
30def RunSingleTestCaseParallel(this, run_label, build_dir, test_case):
31  result = this.RunSingleTestCase(run_label, build_dir, test_case)
32  return (test_case, result)
33
34
35class CompareRun:
36  """A comparison between two branches of pdfium."""
37
38  def __init__(self, args):
39    self.git = GitHelper()
40    self.args = args
41    self._InitPaths()
42
43  def _InitPaths(self):
44    if self.args.this_repo:
45      self.safe_script_dir = self.args.build_dir
46    else:
47      self.safe_script_dir = os.path.join('testing', 'tools')
48
49    self.safe_measure_script_path = os.path.abspath(
50        os.path.join(self.safe_script_dir, 'safetynet_measure.py'))
51
52    input_file_re = re.compile('^.+[.]pdf$')
53    self.test_cases = []
54    for input_path in self.args.input_paths:
55      if os.path.isfile(input_path):
56        self.test_cases.append(input_path)
57      elif os.path.isdir(input_path):
58        for file_dir, _, filename_list in os.walk(input_path):
59          for input_filename in filename_list:
60            if input_file_re.match(input_filename):
61              file_path = os.path.join(file_dir, input_filename)
62              if os.path.isfile(file_path):
63                self.test_cases.append(file_path)
64
65    self.after_build_dir = self.args.build_dir
66    if self.args.build_dir_before:
67      self.before_build_dir = self.args.build_dir_before
68    else:
69      self.before_build_dir = self.after_build_dir
70
71  def Run(self):
72    """Runs comparison by checking out branches, building and measuring them.
73
74    Returns:
75      Exit code for the script.
76    """
77    if self.args.this_repo:
78      self._FreezeMeasureScript()
79
80    if self.args.branch_after:
81      if self.args.this_repo:
82        before, after = self._ProfileTwoOtherBranchesInThisRepo(
83            self.args.branch_before, self.args.branch_after)
84      else:
85        before, after = self._ProfileTwoOtherBranches(self.args.branch_before,
86                                                      self.args.branch_after)
87    elif self.args.branch_before:
88      if self.args.this_repo:
89        before, after = self._ProfileCurrentAndOtherBranchInThisRepo(
90            self.args.branch_before)
91      else:
92        before, after = self._ProfileCurrentAndOtherBranch(
93            self.args.branch_before)
94    else:
95      if self.args.this_repo:
96        before, after = self._ProfileLocalChangesAndCurrentBranchInThisRepo()
97      else:
98        before, after = self._ProfileLocalChangesAndCurrentBranch()
99
100    conclusions = self._DrawConclusions(before, after)
101    conclusions_dict = conclusions.GetOutputDict()
102    conclusions_dict.setdefault('metadata', {})['profiler'] = self.args.profiler
103
104    self._PrintConclusions(conclusions_dict)
105
106    self._CleanUp(conclusions)
107
108    if self.args.png_dir:
109      image_comparison = ImageComparison(
110          self.after_build_dir, self.args.png_dir, ('before', 'after'),
111          self.args.num_workers, self.args.png_threshold)
112      image_comparison.Run(open_in_browser=not self.args.machine_readable)
113
114    return 0
115
116  def _FreezeMeasureScript(self):
117    """Freezes a version of the measuring script.
118
119    This is needed to make sure we are comparing the pdfium library changes and
120    not script changes that may happen between the two branches.
121    """
122    self.__FreezeFile(os.path.join('testing', 'tools', 'safetynet_measure.py'))
123    self.__FreezeFile(os.path.join('testing', 'tools', 'common.py'))
124
125  def __FreezeFile(self, filename):
126    RunCommandPropagateErr(['cp', filename, self.safe_script_dir],
127                           exit_status_on_error=1)
128
129  def _ProfileTwoOtherBranchesInThisRepo(self, before_branch, after_branch):
130    """Profiles two branches that are not the current branch.
131
132    This is done in the local repository and changes may not be restored if the
133    script fails or is interrupted.
134
135    after_branch does not need to descend from before_branch, they will be
136    measured the same way
137
138    Args:
139      before_branch: One branch to profile.
140      after_branch: Other branch to profile.
141
142    Returns:
143      A tuple (before, after), where each of before and after is a dict
144      mapping a test case name to the profiling values for that test case
145      in the given branch.
146    """
147    branch_to_restore = self.git.GetCurrentBranchName()
148
149    self._StashLocalChanges()
150
151    self._CheckoutBranch(after_branch)
152    self._BuildCurrentBranch(self.after_build_dir)
153    after = self._MeasureCurrentBranch('after', self.after_build_dir)
154
155    self._CheckoutBranch(before_branch)
156    self._BuildCurrentBranch(self.before_build_dir)
157    before = self._MeasureCurrentBranch('before', self.before_build_dir)
158
159    self._CheckoutBranch(branch_to_restore)
160    self._RestoreLocalChanges()
161
162    return before, after
163
164  def _ProfileTwoOtherBranches(self, before_branch, after_branch):
165    """Profiles two branches that are not the current branch.
166
167    This is done in new, cloned repositories, therefore it is safer but slower
168    and requires downloads.
169
170    after_branch does not need to descend from before_branch, they will be
171    measured the same way
172
173    Args:
174      before_branch: One branch to profile.
175      after_branch: Other branch to profile.
176
177    Returns:
178      A tuple (before, after), where each of before and after is a dict
179      mapping a test case name to the profiling values for that test case
180      in the given branch.
181    """
182    after = self._ProfileSeparateRepo('after', self.after_build_dir,
183                                      after_branch)
184    before = self._ProfileSeparateRepo('before', self.before_build_dir,
185                                       before_branch)
186    return before, after
187
188  def _ProfileCurrentAndOtherBranchInThisRepo(self, other_branch):
189    """Profiles the current branch (with uncommitted changes) and another one.
190
191    This is done in the local repository and changes may not be restored if the
192    script fails or is interrupted.
193
194    The current branch does not need to descend from other_branch.
195
196    Args:
197      other_branch: Other branch to profile that is not the current.
198
199    Returns:
200      A tuple (before, after), where each of before and after is a dict
201      mapping a test case name to the profiling values for that test case
202      in the given branch. The current branch is considered to be "after" and
203      the other branch is considered to be "before".
204    """
205    branch_to_restore = self.git.GetCurrentBranchName()
206
207    self._BuildCurrentBranch(self.after_build_dir)
208    after = self._MeasureCurrentBranch('after', self.after_build_dir)
209
210    self._StashLocalChanges()
211
212    self._CheckoutBranch(other_branch)
213    self._BuildCurrentBranch(self.before_build_dir)
214    before = self._MeasureCurrentBranch('before', self.before_build_dir)
215
216    self._CheckoutBranch(branch_to_restore)
217    self._RestoreLocalChanges()
218
219    return before, after
220
221  def _ProfileCurrentAndOtherBranch(self, other_branch):
222    """Profiles the current branch (with uncommitted changes) and another one.
223
224    This is done in new, cloned repositories, therefore it is safer but slower
225    and requires downloads.
226
227    The current branch does not need to descend from other_branch.
228
229    Args:
230      other_branch: Other branch to profile that is not the current. None will
231          compare to the same branch.
232
233    Returns:
234      A tuple (before, after), where each of before and after is a dict
235      mapping a test case name to the profiling values for that test case
236      in the given branch. The current branch is considered to be "after" and
237      the other branch is considered to be "before".
238    """
239    self._BuildCurrentBranch(self.after_build_dir)
240    after = self._MeasureCurrentBranch('after', self.after_build_dir)
241
242    before = self._ProfileSeparateRepo('before', self.before_build_dir,
243                                       other_branch)
244
245    return before, after
246
247  def _ProfileLocalChangesAndCurrentBranchInThisRepo(self):
248    """Profiles the current branch with and without uncommitted changes.
249
250    This is done in the local repository and changes may not be restored if the
251    script fails or is interrupted.
252
253    Returns:
254      A tuple (before, after), where each of before and after is a dict
255      mapping a test case name to the profiling values for that test case
256      using the given version. The current branch without uncommitted changes is
257      considered to be "before" and with uncommitted changes is considered to be
258      "after".
259    """
260    self._BuildCurrentBranch(self.after_build_dir)
261    after = self._MeasureCurrentBranch('after', self.after_build_dir)
262
263    pushed = self._StashLocalChanges()
264    if not pushed and not self.args.build_dir_before:
265      PrintErr('Warning: No local changes to compare')
266
267    before_build_dir = self.before_build_dir
268
269    self._BuildCurrentBranch(before_build_dir)
270    before = self._MeasureCurrentBranch('before', before_build_dir)
271
272    self._RestoreLocalChanges()
273
274    return before, after
275
276  def _ProfileLocalChangesAndCurrentBranch(self):
277    """Profiles the current branch with and without uncommitted changes.
278
279    This is done in new, cloned repositories, therefore it is safer but slower
280    and requires downloads.
281
282    Returns:
283      A tuple (before, after), where each of before and after is a dict
284      mapping a test case name to the profiling values for that test case
285      using the given version. The current branch without uncommitted changes is
286      considered to be "before" and with uncommitted changes is considered to be
287      "after".
288    """
289    return self._ProfileCurrentAndOtherBranch(other_branch=None)
290
291  def _ProfileSeparateRepo(self, run_label, relative_build_dir, branch):
292    """Profiles a branch in a a temporary git repository.
293
294    Args:
295      run_label: String to differentiate this version of the code in output
296          files from other versions.
297      relative_build_dir: Path to the build dir in the current working dir to
298          clone build args from.
299      branch: Branch to checkout in the new repository. None will
300          profile the same branch checked out in the original repo.
301    Returns:
302      A dict mapping each test case name to the profiling values for that
303      test case.
304    """
305    build_dir = self._CreateTempRepo('repo_%s' % run_label, relative_build_dir,
306                                     branch)
307
308    self._BuildCurrentBranch(build_dir)
309    return self._MeasureCurrentBranch(run_label, build_dir)
310
311  def _CreateTempRepo(self, dir_name, relative_build_dir, branch):
312    """Clones a temporary git repository out of the current working dir.
313
314    Args:
315      dir_name: Name for the temporary repository directory
316      relative_build_dir: Path to the build dir in the current working dir to
317          clone build args from.
318      branch: Branch to checkout in the new repository. None will keep checked
319          out the same branch as the local repo.
320    Returns:
321      Path to the build directory of the new repository.
322    """
323    cwd = os.getcwd()
324
325    repo_dir = tempfile.mkdtemp(suffix='-%s' % dir_name)
326    src_dir = os.path.join(repo_dir, 'pdfium')
327
328    self.git.CloneLocal(os.getcwd(), src_dir)
329
330    if branch is not None:
331      os.chdir(src_dir)
332      self.git.Checkout(branch)
333
334    os.chdir(repo_dir)
335    PrintErr('Syncing...')
336
337    cmd = [
338        'gclient', 'config', '--unmanaged',
339        'https://pdfium.googlesource.com/pdfium.git'
340    ]
341    if self.args.cache_dir:
342      cmd.append('--cache-dir=%s' % self.args.cache_dir)
343    RunCommandPropagateErr(cmd, exit_status_on_error=1)
344
345    RunCommandPropagateErr(['gclient', 'sync', '--force'],
346                           exit_status_on_error=1)
347
348    PrintErr('Done.')
349
350    build_dir = os.path.join(src_dir, relative_build_dir)
351    os.makedirs(build_dir)
352    os.chdir(src_dir)
353
354    source_gn_args = os.path.join(cwd, relative_build_dir, 'args.gn')
355    dest_gn_args = os.path.join(build_dir, 'args.gn')
356    shutil.copy(source_gn_args, dest_gn_args)
357
358    RunCommandPropagateErr(['gn', 'gen', relative_build_dir],
359                           exit_status_on_error=1)
360
361    os.chdir(cwd)
362
363    return build_dir
364
365  def _CheckoutBranch(self, branch):
366    PrintErr("Checking out branch '%s'" % branch)
367    self.git.Checkout(branch)
368
369  def _StashLocalChanges(self):
370    PrintErr('Stashing local changes')
371    return self.git.StashPush()
372
373  def _RestoreLocalChanges(self):
374    PrintErr('Restoring local changes')
375    self.git.StashPopAll()
376
377  def _BuildCurrentBranch(self, build_dir):
378    """Synchronizes and builds the current version of pdfium.
379
380    Args:
381      build_dir: String with path to build directory
382    """
383    PrintErr('Syncing...')
384    RunCommandPropagateErr(['gclient', 'sync', '--force'],
385                           exit_status_on_error=1)
386    PrintErr('Done.')
387
388    PrintErr('Building...')
389    cmd = ['ninja', '-C', build_dir, 'pdfium_test']
390    if GetBooleanGnArg('use_goma', build_dir):
391      cmd.extend(['-j', '250'])
392    RunCommandPropagateErr(cmd, stdout_has_errors=True, exit_status_on_error=1)
393    PrintErr('Done.')
394
395  def _MeasureCurrentBranch(self, run_label, build_dir):
396    PrintErr('Measuring...')
397    if self.args.num_workers > 1 and len(self.test_cases) > 1:
398      results = self._RunAsync(run_label, build_dir)
399    else:
400      results = self._RunSync(run_label, build_dir)
401    PrintErr('Done.')
402
403    return results
404
405  def _RunSync(self, run_label, build_dir):
406    """Profiles the test cases synchronously.
407
408    Args:
409      run_label: String to differentiate this version of the code in output
410          files from other versions.
411      build_dir: String with path to build directory
412
413    Returns:
414      A dict mapping each test case name to the profiling values for that
415      test case.
416    """
417    results = {}
418
419    for test_case in self.test_cases:
420      result = self.RunSingleTestCase(run_label, build_dir, test_case)
421      if result is not None:
422        results[test_case] = result
423
424    return results
425
426  def _RunAsync(self, run_label, build_dir):
427    """Profiles the test cases asynchronously.
428
429    Uses as many workers as configured by --num-workers.
430
431    Args:
432      run_label: String to differentiate this version of the code in output
433          files from other versions.
434      build_dir: String with path to build directory
435
436    Returns:
437      A dict mapping each test case name to the profiling values for that
438      test case.
439    """
440    results = {}
441    pool = multiprocessing.Pool(self.args.num_workers)
442    worker_func = functools.partial(RunSingleTestCaseParallel, self, run_label,
443                                    build_dir)
444
445    try:
446      # The timeout is a workaround for http://bugs.python.org/issue8296
447      # which prevents KeyboardInterrupt from working.
448      one_year_in_seconds = 3600 * 24 * 365
449      worker_results = (
450          pool.map_async(worker_func, self.test_cases).get(one_year_in_seconds))
451      for worker_result in worker_results:
452        test_case, result = worker_result
453        if result is not None:
454          results[test_case] = result
455    except KeyboardInterrupt:
456      pool.terminate()
457      sys.exit(1)
458    else:
459      pool.close()
460
461    pool.join()
462
463    return results
464
465  def RunSingleTestCase(self, run_label, build_dir, test_case):
466    """Profiles a single test case.
467
468    Args:
469      run_label: String to differentiate this version of the code in output
470          files from other versions.
471      build_dir: String with path to build directory
472      test_case: Path to the test case.
473
474    Returns:
475      The measured profiling value for that test case.
476    """
477    command = [
478        self.safe_measure_script_path, test_case,
479        '--build-dir=%s' % build_dir
480    ]
481
482    if self.args.interesting_section:
483      command.append('--interesting-section')
484
485    if self.args.profiler:
486      command.append('--profiler=%s' % self.args.profiler)
487
488    profile_file_path = self._GetProfileFilePath(run_label, test_case)
489    if profile_file_path:
490      command.append('--output-path=%s' % profile_file_path)
491
492    if self.args.png_dir:
493      command.append('--png')
494
495    if self.args.pages:
496      command.extend(['--pages', self.args.pages])
497
498    output = RunCommandPropagateErr(command)
499
500    if output is None:
501      return None
502
503    if self.args.png_dir:
504      self._MoveImages(test_case, run_label)
505
506    # Get the time number as output, making sure it's just a number
507    output = output.strip()
508    if re.match('^[0-9]+$', output):
509      return int(output)
510
511    return None
512
513  def _MoveImages(self, test_case, run_label):
514    png_dir = os.path.join(self.args.png_dir, run_label)
515    if not os.path.exists(png_dir):
516      os.makedirs(png_dir)
517
518    test_case_dir, test_case_filename = os.path.split(test_case)
519    test_case_png_matcher = '%s.*.png' % test_case_filename
520    for output_png in glob.glob(
521        os.path.join(test_case_dir, test_case_png_matcher)):
522      shutil.move(output_png, png_dir)
523
524  def _GetProfileFilePath(self, run_label, test_case):
525    if self.args.output_dir:
526      output_filename = (
527          'callgrind.out.%s.%s' % (test_case.replace('/', '_'), run_label))
528      return os.path.join(self.args.output_dir, output_filename)
529    return None
530
531  def _DrawConclusions(self, times_before_branch, times_after_branch):
532    """Draws conclusions comparing results of test runs in two branches.
533
534    Args:
535      times_before_branch: A dict mapping each test case name to the
536          profiling values for that test case in the branch to be considered
537          as the baseline.
538      times_after_branch: A dict mapping each test case name to the
539          profiling values for that test case in the branch to be considered
540          as the new version.
541
542    Returns:
543      ComparisonConclusions with all test cases processed.
544    """
545    conclusions = ComparisonConclusions(self.args.threshold_significant)
546
547    for test_case in sorted(self.test_cases):
548      before = times_before_branch.get(test_case)
549      after = times_after_branch.get(test_case)
550      conclusions.ProcessCase(test_case, before, after)
551
552    return conclusions
553
554  def _PrintConclusions(self, conclusions_dict):
555    """Prints the conclusions as the script output.
556
557    Depending on the script args, this can output a human or a machine-readable
558    version of the conclusions.
559
560    Args:
561      conclusions_dict: Dict to print returned from
562          ComparisonConclusions.GetOutputDict().
563    """
564    if self.args.machine_readable:
565      print(json.dumps(conclusions_dict))
566    else:
567      PrintConclusionsDictHumanReadable(
568          conclusions_dict, colored=True, key=self.args.case_order)
569
570  def _CleanUp(self, conclusions):
571    """Removes profile output files for uninteresting cases.
572
573    Cases without significant regressions or improvements and considered
574    uninteresting.
575
576    Args:
577      conclusions: A ComparisonConclusions.
578    """
579    if not self.args.output_dir:
580      return
581
582    if self.args.profiler != 'callgrind':
583      return
584
585    for case_result in conclusions.GetCaseResults().values():
586      if case_result.rating not in [RATING_REGRESSION, RATING_IMPROVEMENT]:
587        self._CleanUpOutputFile('before', case_result.case_name)
588        self._CleanUpOutputFile('after', case_result.case_name)
589
590  def _CleanUpOutputFile(self, run_label, case_name):
591    """Removes one profile output file.
592
593    If the output file does not exist, fails silently.
594
595    Args:
596      run_label: String to differentiate a version of the code in output
597          files from other versions.
598      case_name: String identifying test case for which to remove the output
599          file.
600    """
601    try:
602      os.remove(self._GetProfileFilePath(run_label, case_name))
603    except OSError:
604      pass
605
606
607def main():
608  parser = argparse.ArgumentParser()
609  parser.add_argument(
610      'input_paths',
611      nargs='+',
612      help='pdf files or directories to search for pdf files '
613      'to run as test cases')
614  parser.add_argument(
615      '--branch-before',
616      help='git branch to use as "before" for comparison. '
617      'Omitting this will use the current branch '
618      'without uncommitted changes as the baseline.')
619  parser.add_argument(
620      '--branch-after',
621      help='git branch to use as "after" for comparison. '
622      'Omitting this will use the current branch '
623      'with uncommitted changes.')
624  parser.add_argument(
625      '--build-dir',
626      default=os.path.join('out', 'Release'),
627      help='relative path from the base source directory '
628      'to the build directory')
629  parser.add_argument(
630      '--build-dir-before',
631      help='relative path from the base source directory '
632      'to the build directory for the "before" branch, if '
633      'different from the build directory for the '
634      '"after" branch')
635  parser.add_argument(
636      '--cache-dir',
637      default=None,
638      help='directory with a new or preexisting cache for '
639      'downloads. Default is to not use a cache.')
640  parser.add_argument(
641      '--this-repo',
642      action='store_true',
643      help='use the repository where the script is instead of '
644      'checking out a temporary one. This is faster and '
645      'does not require downloads, but although it '
646      'restores the state of the local repo, if the '
647      'script is killed or crashes the changes can remain '
648      'stashed and you may be on another branch.')
649  parser.add_argument(
650      '--profiler',
651      default='callgrind',
652      help='which profiler to use. Supports callgrind, '
653      'perfstat, and none. Default is callgrind.')
654  parser.add_argument(
655      '--interesting-section',
656      action='store_true',
657      help='whether to measure just the interesting section or '
658      'the whole test harness. Limiting to only the '
659      'interesting section does not work on Release since '
660      'the delimiters are optimized out')
661  parser.add_argument(
662      '--pages',
663      help='selects some pages to be rendered. Page numbers '
664      'are 0-based. "--pages A" will render only page A. '
665      '"--pages A-B" will render pages A to B '
666      '(inclusive).')
667  parser.add_argument(
668      '--num-workers',
669      default=multiprocessing.cpu_count(),
670      type=int,
671      help='run NUM_WORKERS jobs in parallel')
672  parser.add_argument(
673      '--output-dir', help='directory to write the profile data output files')
674  parser.add_argument(
675      '--png-dir',
676      default=None,
677      help='outputs pngs to the specified directory that can '
678      'be compared with a static html generated. Will '
679      'affect performance measurements.')
680  parser.add_argument(
681      '--png-threshold',
682      default=0.0,
683      type=float,
684      help='Requires --png-dir. Threshold above which a png '
685      'is considered to have changed.')
686  parser.add_argument(
687      '--threshold-significant',
688      default=0.02,
689      type=float,
690      help='variations in performance above this factor are '
691      'considered significant')
692  parser.add_argument(
693      '--machine-readable',
694      action='store_true',
695      help='whether to get output for machines. If enabled the '
696      'output will be a json with the format specified in '
697      'ComparisonConclusions.GetOutputDict(). Default is '
698      'human-readable.')
699  parser.add_argument(
700      '--case-order',
701      default=None,
702      help='what key to use when sorting test cases in the '
703      'output. Accepted values are "after", "before", '
704      '"ratio" and "rating". Default is sorting by test '
705      'case path.')
706
707  args = parser.parse_args()
708
709  # Always start at the pdfium src dir, which is assumed to be two level above
710  # this script.
711  pdfium_src_dir = os.path.join(
712      os.path.dirname(__file__), os.path.pardir, os.path.pardir)
713  os.chdir(pdfium_src_dir)
714
715  git = GitHelper()
716
717  if args.branch_after and not args.branch_before:
718    PrintErr('--branch-after requires --branch-before to be specified.')
719    return 1
720
721  if args.branch_after and not git.BranchExists(args.branch_after):
722    PrintErr('Branch "%s" does not exist' % args.branch_after)
723    return 1
724
725  if args.branch_before and not git.BranchExists(args.branch_before):
726    PrintErr('Branch "%s" does not exist' % args.branch_before)
727    return 1
728
729  if args.output_dir:
730    args.output_dir = os.path.expanduser(args.output_dir)
731    if not os.path.isdir(args.output_dir):
732      PrintErr('"%s" is not a directory' % args.output_dir)
733      return 1
734
735  if args.png_dir:
736    args.png_dir = os.path.expanduser(args.png_dir)
737    if not os.path.isdir(args.png_dir):
738      PrintErr('"%s" is not a directory' % args.png_dir)
739      return 1
740
741  if args.threshold_significant <= 0.0:
742    PrintErr('--threshold-significant should receive a positive float')
743    return 1
744
745  if args.png_threshold:
746    if not args.png_dir:
747      PrintErr('--png-threshold requires --png-dir to be specified.')
748      return 1
749
750    if args.png_threshold <= 0.0:
751      PrintErr('--png-threshold should receive a positive float')
752      return 1
753
754  if args.pages:
755    if not re.match(r'^\d+(-\d+)?$', args.pages):
756      PrintErr('Supported formats for --pages are "--pages 7" and '
757               '"--pages 3-6"')
758      return 1
759
760  run = CompareRun(args)
761  return run.Run()
762
763
764if __name__ == '__main__':
765  sys.exit(main())
766