Speed up tests by running the biggest ones first

Towards the end of a test run, there's typically a period when only
one or a few cores are busy with the last of the tests---the ones that
take a very long time. By sorting the tests according to their
expected runtime and starting the longest-running ones first, we
minimize this effect.

This patch records the runtime in ~/.gtest-parallel-times once all
tests have finished; on subsequent runs, this data is used to start
the longest-running tests first. Speedup varies depending on which
tests are run, but the usual seems to be about 10%, and I haven't
observed a slowdown for any set of tests (though admittedly, my sample
is very modest).
diff --git a/gtest-parallel b/gtest-parallel
index 71c0ed6..58d2a67 100755
--- a/gtest-parallel
+++ b/gtest-parallel
@@ -12,11 +12,15 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
+import cPickle
+import gzip
 import optparse
+import os
 import subprocess
 import sys
 import threading
 import time
+import zlib
 
 stdout_lock = threading.Lock()
 class FilterFormat:
@@ -82,6 +86,52 @@
   def end(self):
     pass
 
+# Record of test runtimes. Has built-in locking.
+class TestTimes(object):
+  def __init__(self, save_file):
+    "Create new object seeded with saved test times from the given file."
+    self.__times = {}  # (test binary, test name) -> runtime in ms
+
+    # Protects calls to record_test_time(); other calls are not
+    # expected to be made concurrently.
+    self.__lock = threading.Lock()
+
+    try:
+      with gzip.GzipFile(save_file, "rb") as f:
+        times = cPickle.load(f)
+    except (EOFError, IOError, cPickle.UnpicklingError, zlib.error):
+      # File doesn't exist, isn't readable, is malformed---whatever.
+      # Just ignore it.
+      return
+
+    # Discard saved times if the format isn't right.
+    if type(times) is not dict:
+      return
+    for ((test_binary, test_name), runtime) in times.items():
+      if (type(test_binary) is not str or type(test_name) is not str
+          or type(runtime) not in {int, long}):
+        return
+
+    self.__times = times
+
+  def get_test_time(self, binary, testname):
+    "Return the last duration for the given test, or 0 if there's no record."
+    return self.__times.get((binary, testname), 0)
+
+  def record_test_time(self, binary, testname, runtime_ms):
+    "Record that the given test ran in the specified number of milliseconds."
+    with self.__lock:
+      self.__times[(binary, testname)] = runtime_ms
+
+  def write_to_file(self, save_file):
+    "Write all the times to file."
+    try:
+      with open(save_file, "wb") as f:
+        with gzip.GzipFile("", "wb", 9, f) as gzf:
+          cPickle.dump(self.__times, gzf, cPickle.HIGHEST_PROTOCOL)
+    except IOError:
+      pass  # ignore errors---saving the times isn't that important
+
 # Remove additional arguments (anything after --).
 additional_args = []
 
@@ -122,6 +172,8 @@
   sys.exit("Unknown output format: " + options.format)
 
 # Find tests.
+save_file = os.path.join(os.path.expanduser("~"), ".gtest-parallel-times")
+times = TestTimes(save_file)
 tests = []
 for test_binary in binaries:
   command = [test_binary]
@@ -155,7 +207,9 @@
       continue
 
     test = test_group + line
-    tests.append((test_binary, command, test))
+    tests.append((times.get_test_time(test_binary, test),
+                  test_binary, test, command))
+tests.sort(reverse=True)
 
 # Repeat tests (-r flag).
 tests *= options.repeat
@@ -164,6 +218,8 @@
 logger.log(str(-1) + ': TESTCNT ' + ' ' + str(len(tests)))
 
 exit_code = 0
+
+# Run the specified job. Returns the elapsed time in milliseconds.
 def run_job((command, job_id, test)):
   begin = time.time()
   sub = subprocess.Popen(command + ['--gtest_filter=' + test] +
@@ -179,10 +235,11 @@
 
   code = sub.wait()
   runtime_ms = int(1000 * (time.time() - begin))
-  logger.log(str(job_id) + ': EXIT ' + str(code) + ' ' + str(runtime_ms))
+  logger.log("%s: EXIT %s %d" % (job_id, code, runtime_ms))
   if code != 0:
     global exit_code
     exit_code = code
+  return runtime_ms
 
 def worker():
   global job_id
@@ -190,14 +247,14 @@
     job = None
     test_lock.acquire()
     if job_id < len(tests):
-      (test_binary, command, test) = tests[job_id]
+      (_, test_binary, test, command) = tests[job_id]
       logger.log(str(job_id) + ': TEST ' + test_binary + ' ' + test)
       job = (command, job_id, test)
     job_id += 1
     test_lock.release()
     if job is None:
       return
-    run_job(job)
+    times.record_test_time(test_binary, test, run_job(job))
 
 def start_daemon(func):
   t = threading.Thread(target=func)
@@ -209,4 +266,5 @@
 
 [t.join() for t in workers]
 logger.end()
+times.write_to_file(save_file)
 sys.exit(exit_code)