generate_scripts.py revision 99b7e65094726c8d7d4c7cc5b6e4d4165e4ce239
1# Copyright 2014 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4from __future__ import print_function
5from collections import namedtuple
6import json, os, sys
7
8AUTOTEST_NAME = 'graphics_PiglitBVT'
9INPUT_DIR = './piglit_logs/'
10OUTPUT_DIR = './test_scripts/'
11OUTPUT_FILE_PATTERN = OUTPUT_DIR + '/%s/' + AUTOTEST_NAME + '_%d.sh'
12OUTPUT_FILE_SLICES = 20
13PIGLIT_PATH = '/usr/local/autotest/deps/piglit/piglit/'
14# Do not generate scripts with "bash -e" as we want to handle errors ourself.
15FILE_HEADER = '#!/bin/bash\n\n'
16
17# Script fragment function that kicks off individual piglit tests.
18FILE_RUN_TEST = '\n\
19function run_test()\n\
20{\n\
21  local name="$1"\n\
22  local time="$2"\n\
23  local command="$3"\n\
24  echo "++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++"\n\
25  echo "Running test \"$name\" of expected runtime $time sec: $command"\n\
26  sync\n\
27  $command\n\
28  if [ $? == 0 ] ; then\n\
29    let "need_pass--"\n\
30  else\n\
31    let "failures++"\n\
32  fi\n\
33}\n\
34'
35
36# Script fragment that sumarizes the overall status.
37FILE_SUMMARY = 'popd\n\
38\n\
39if [ $need_pass == 0 ] ; then\n\
40  echo "+---------------------------------------------+"\n\
41  echo "| Overall pass, as all %d tests have passed. |"\n\
42  echo "+---------------------------------------------+"\n\
43else\n\
44  echo "+-----------------------------------------------------------+"\n\
45  echo "| Overall failure, as $need_pass tests did not pass and $failures failed. |"\n\
46  echo "+-----------------------------------------------------------+"\n\
47fi\n\
48exit $need_pass\n\
49'
50
51# Control file template for executing a slice.
52CONTROL_FILE = "\
53# Copyright 2014 The Chromium OS Authors. All rights reserved.\n\
54# Use of this source code is governed by a BSD-style license that can be\n\
55# found in the LICENSE file.\n\
56\n\
57NAME = '" + AUTOTEST_NAME + "'\n\
58AUTHOR = 'chromeos-gfx'\n\
59PURPOSE = 'Collection of automated tests for OpenGL implementations.'\n\
60CRITERIA = 'All tests in a slice have to pass, otherwise it will fail.'\n\
61SUITE = 'bvt'\n\
62EXPERIMENTAL = 'True'\n\
63TIME='SHORT'\n\
64TEST_CATEGORY = 'Functional'\n\
65TEST_CLASS = 'graphics'\n\
66TEST_TYPE = 'client'\n\
67\n\
68DOC = \"\"\"\n\
69Piglit is a collection of automated tests for OpenGL implementations.\n\
70\n\
71The goal of Piglit is to help improve the quality of open source OpenGL drivers\n\
72by providing developers with a simple means to perform regression tests.\n\
73\n\
74This control file runs slice %d out of %d slices of a passing subset of the\n\
75original collection.\n\
76\n\
77http://people.freedesktop.org/~nh/piglit/\n\
78\"\"\"\n\
79\n\
80job.run_test('" + AUTOTEST_NAME + "', test_slice=%d)\
81"
82
83def output_control_file(sl, slices):
84  """
85  Write control file for slice sl to disk.
86  """
87  filename = 'control.%d' % sl
88  with open(filename, 'w+') as f:
89    print(CONTROL_FILE % (sl, slices, sl), file=f)
90
91
92def append_script_header(f, need_pass):
93  """
94  Write the beginning of the test script to f.
95  """
96  print(FILE_HEADER, file=f)
97  # need_pass is the script variable that counts down to zero and gets returned.
98  print('need_pass=%d' % need_pass, file=f)
99  print('failures=0', file=f)
100  print('PIGLIT_PATH=%s' % PIGLIT_PATH, file=f)
101  print('export PIGLIT_SOURCE_DIR=%s' % PIGLIT_PATH, file=f)
102  print('export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$PIGLIT_PATH/lib', file=f)
103  print('', file=f)
104  print(FILE_RUN_TEST, file=f)
105  print('', file=f)
106  print('pushd $PIGLIT_PATH', file=f)
107
108
109def append_script_summary(f, need_pass):
110  """
111  Append the summary to the test script f with a required pass count.
112  """
113  print(FILE_SUMMARY % need_pass, file=f)
114
115
116def mkdir_p(path):
117  """
118  Create all directories in path.
119  """
120  try:
121    os.makedirs(path)
122  except OSError:
123    if os.path.isdir(path):
124      pass
125    else:
126      raise
127
128def get_log_filepaths(family_root):
129  """
130  Find all log files (*main.txt) that were placed into family_root.
131  """
132  main_files = []
133  for root, _, files in os.walk(family_root):
134    for filename in files:
135      if filename.endswith('main.txt'):
136        main_files.append(os.path.join(root, filename))
137  return main_files
138
139
140def load_log_files(main_files):
141  """
142  The log files are just python dictionaries, load them from disk.
143  """
144  d = {}
145  for main_file in main_files:
146    #print('Loading file %s' % main_file, file=sys.stderr)
147    d[main_file] = json.loads(open(main_file).read())
148  return d
149
150
151# Define a Test data structure containing the command line and runtime.
152Test = namedtuple('Test', 'command time passing_count not_passing_count')
153
154def get_test_statistics(d):
155  """
156  Figures out for each test how often is passed/failed, the command line and
157  how long it runs.
158  """
159  statistics = {}
160  for main_file in d:
161    for test in d[main_file]['tests']:
162      # Initialize for all known test names to zero stats.
163      statistics[test] = Test(None, 0.0, 0, 0)
164
165  for main_file in d:
166    print('Updating statistics from %s.' % main_file, file=sys.stderr)
167    tests = d[main_file]['tests']
168    for test in tests:
169      command = statistics[test].command
170      if tests[test]['result'] == 'pass':
171        # A passing test expectation is a no-op and must be ignored.
172        if 'expectation' not in main_file:
173          if 'command' in tests[test]:
174            command = tests[test]['command']
175          statistics[test] = Test(command,
176                                  max(tests[test]['time'],
177                                      statistics[test].time),
178                                  statistics[test].passing_count + 1,
179                                  statistics[test].not_passing_count)
180      else:
181        # TODO(ihf): We get a bump due to flaky tests in the expectations file.
182        # While this is intended it should be handled cleaner as it impacts
183        # the computed pass rate.
184        statistics[test] = Test(command,
185                                statistics[test].time,
186                                statistics[test].passing_count,
187                                statistics[test].not_passing_count + 1)
188
189  return statistics
190
191
192def get_max_passing(statistics):
193  """
194  Gets the maximum count of passes a test has.
195  """
196  max_passing_count = 0
197  for test in statistics:
198    max_passing_count = max(statistics[test].passing_count, max_passing_count)
199  return max_passing_count
200
201
202def get_passing_tests(statistics):
203  """
204  Gets a list of all tests that never failed and have a maximum pass count.
205  """
206  tests = []
207  max_passing_count = get_max_passing(statistics)
208  for test in statistics:
209    if (statistics[test].passing_count == max_passing_count and
210        statistics[test].not_passing_count == 0):
211      tests.append(test)
212  return sorted(tests)
213
214
215def get_intermittent_tests(statistics):
216  """
217  Gets tests that failed at least once and passed at least once.
218  """
219  tests = []
220  max_passing_count = get_max_passing(statistics)
221  for test in statistics:
222    if (statistics[test].passing_count > 0 and
223        statistics[test].passing_count < max_passing_count and
224        statistics[test].not_passing_count > 0):
225      tests.append(test)
226  return sorted(tests)
227
228
229def process_gpu_family(family, family_root):
230  """
231  This takes a directory with log files from the same gpu family and processes
232  the result log into |slices| runable scripts.
233  """
234  print('--> Processing "%s".' % family, file=sys.stderr)
235  main_files = get_log_filepaths(family_root)
236  d = load_log_files(main_files)
237  statistics = get_test_statistics(d)
238  passing_tests = get_passing_tests(statistics)
239
240  slices = OUTPUT_FILE_SLICES
241  current_slice = 1
242  slice_tests = []
243  time_slice = 0
244  num_processed = 0
245  num_pass_total = len(passing_tests)
246  time_total = 0
247  for test in passing_tests:
248    time_total += statistics[test].time
249
250  # Generate one script containing all tests. This can be used as a simpler way
251  # to run everything, but also to have an easier diff when updating piglit.
252  filename = OUTPUT_FILE_PATTERN % (family, 0)
253  # Ensure the output directory for this family exists.
254  mkdir_p(os.path.dirname(os.path.realpath(filename)))
255  if passing_tests:
256    with open(filename, 'w+') as f:
257      append_script_header(f, num_pass_total)
258      for test in passing_tests:
259        # Make script less location dependent by stripping path from commands.
260        cmd = statistics[test].command.replace(PIGLIT_PATH, '')
261        time_test = statistics[test].time
262        print('run_test "%s" %.1f "%s"' % (test, 0.0, cmd), file=f)
263      append_script_summary(f, num_pass_total)
264
265  # Slice passing tests into several pieces to get below BVT's 20 minute limit.
266  # TODO(ihf): If we ever get into the situation that one test takes more than
267  # time_total / slice we would get an empty slice afterward. Fortunately the
268  # stderr spew should warn the operator of this.
269  for test in passing_tests:
270    # We are still writing all the tests that belong in the current slice.
271    if time_slice < time_total / slices:
272      slice_tests.append(test)
273      time_test = statistics[test].time
274      time_slice += time_test
275      num_processed += 1
276
277    # We finished the slice. Now output the file with all tests in this slice.
278    if time_slice >= time_total / slices or num_processed == num_pass_total:
279      filename = OUTPUT_FILE_PATTERN % (family, current_slice)
280      with open(filename, 'w+') as f:
281        need_pass = len(slice_tests)
282        append_script_header(f, need_pass)
283        for test in slice_tests:
284          # Make script less location dependent by stripping path from commands.
285          cmd = statistics[test].command.replace(PIGLIT_PATH, '')
286          time_test = statistics[test].time
287          # TODO(ihf): Pass proper time_test instead of 0.0 once we can use it.
288          print('run_test "%s" %.1f "%s"'
289                % (test, 0.0, cmd), file=f)
290        append_script_summary(f, need_pass)
291        output_control_file(current_slice, slices)
292
293      print('Slice %d: max runtime for %d passing tests is %.1f seconds.'
294            % (current_slice, need_pass, time_slice), file=sys.stderr)
295      current_slice += 1
296      slice_tests = []
297      time_slice = 0
298
299  print('Total max runtime on "%s" for %d passing tests is %.1f seconds.' %
300          (family, num_pass_total, time_total), file=sys.stderr)
301
302  # Try to help the person updating piglit by collecting the variance
303  # across different log files into one expectations file per family.
304  output_suggested_expectations(statistics, family, family_root)
305
306
307def output_suggested_expectations(statistics, family, family_root):
308  """
309  Analyze intermittency and output suggested test expectations.
310  Test expectations are dictionaries with the same structure as logs.
311  """
312  flaky_tests = get_intermittent_tests(statistics)
313  print('Encountered %d tests that do not always pass in "%s" logs.' %
314        (len(flaky_tests), family), file=sys.stderr)
315
316  if not flaky_tests:
317    return
318
319  max_passing = get_max_passing(statistics)
320  expectations = {}
321  for test in flaky_tests:
322    pass_rate = statistics[test].passing_count / float(max_passing)
323    # Loading a json converts everything to string anyways, so save it as such
324    # and make it only 2 significiant digits.
325    expectations[test] = {'result': 'flaky', 'pass rate' : '%.2f' % pass_rate}
326
327  filename = os.path.join(family_root, 'expectations_%s_main.txt' % family)
328  with open(filename, 'w+') as f:
329    json.dump({'tests': expectations}, f, indent=2, sort_keys=True)
330
331
332def get_gpu_families(root):
333  """
334  We consider each directory under root a possible gpu family.
335  """
336  files = os.listdir(root)
337  families = []
338  for f in files:
339    if os.path.isdir(os.path.join(root, f)):
340      families.append(f)
341  return families
342
343
344def generate_scripts(root):
345  """
346  For each family under root create the corresponding set of passing test
347  scripts.
348  """
349  families = get_gpu_families(root)
350  for family in families:
351    process_gpu_family(family, os.path.join(root, family))
352
353
354# We check the log files in as highly compressed binaries.
355print('Uncompressing log files...', file=sys.stderr)
356os.system('bunzip2 ' + INPUT_DIR + '/*/*/*main.txt.bz2')
357
358# Generate the scripts.
359generate_scripts(INPUT_DIR)
360
361# Binary should remain the same, otherwise use
362#   git checkout -- piglit_output
363# or similar to reverse.
364print('Recompressing log files...', file=sys.stderr)
365os.system('bzip2 -9 ' + INPUT_DIR + '/*/*/*main.txt')
366