Add options to control executer threads and CPUs

* D8/R8/L8 gets the --thread-count option (currently not documented) to
  control the threads created in the Java Executor
* run_on_app.py gets the option --cpu-list to pin execution to a fixed
  set of cpus

Change-Id: I288bd8024b3c6b2acd5eebb284447ee5bd3456be
diff --git a/src/main/java/com/android/tools/r8/BaseCompilerCommand.java b/src/main/java/com/android/tools/r8/BaseCompilerCommand.java
index 1431a9a..80dcf13 100644
--- a/src/main/java/com/android/tools/r8/BaseCompilerCommand.java
+++ b/src/main/java/com/android/tools/r8/BaseCompilerCommand.java
@@ -15,6 +15,7 @@
 import com.android.tools.r8.utils.FileUtils;
 import com.android.tools.r8.utils.InternalOptions.DesugarState;
 import com.android.tools.r8.utils.Reporter;
+import com.android.tools.r8.utils.ThreadUtils;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -46,6 +47,7 @@
   private final BiPredicate<String, Long> dexClassChecksumFilter;
   private final List<AssertionsConfiguration> assertionsConfiguration;
   private final List<Consumer<Inspector>> outputInspections;
+  private int threadCount;
 
   BaseCompilerCommand(boolean printHelp, boolean printVersion) {
     super(printHelp, printVersion);
@@ -60,6 +62,7 @@
     dexClassChecksumFilter = (name, checksum) -> true;
     assertionsConfiguration = new ArrayList<>();
     outputInspections = null;
+    threadCount = ThreadUtils.NOT_SPECIFIED;
   }
 
   BaseCompilerCommand(
@@ -74,7 +77,8 @@
       boolean includeClassesChecksum,
       BiPredicate<String, Long> dexClassChecksumFilter,
       List<AssertionsConfiguration> assertionsConfiguration,
-      List<Consumer<Inspector>> outputInspections) {
+      List<Consumer<Inspector>> outputInspections,
+      int threadCount) {
     super(app);
     assert minApiLevel > 0;
     assert mode != null;
@@ -89,6 +93,7 @@
     this.dexClassChecksumFilter = dexClassChecksumFilter;
     this.assertionsConfiguration = assertionsConfiguration;
     this.outputInspections = outputInspections;
+    this.threadCount = threadCount;
   }
 
   /**
@@ -155,6 +160,11 @@
     return Collections.unmodifiableList(outputInspections);
   }
 
+  /** Get the number of threads to use for the compilation. */
+  public int getThreadCount() {
+    return threadCount;
+  }
+
   Reporter getReporter() {
     return reporter;
   }
@@ -178,6 +188,7 @@
 
     private CompilationMode mode;
     private int minApiLevel = 0;
+    private int threadCount = ThreadUtils.NOT_SPECIFIED;
     protected DesugarState desugarState = DesugarState.ON;
     private List<StringResource> desugaredLibraryConfigurationResources = new ArrayList<>();
     private boolean includeClassesChecksum = false;
@@ -503,6 +514,20 @@
       return self();
     }
 
+    /** Set the number of threads to use for the compilation */
+    B setThreadCount(int threadCount) {
+      if (threadCount <= 0) {
+        getReporter().error("Invalid threadCount: " + threadCount);
+      } else {
+        this.threadCount = threadCount;
+      }
+      return self();
+    }
+
+    int getThreadCount() {
+      return threadCount;
+    }
+
     /** Encodes the checksums into the dex output. */
     public boolean getIncludeClassesChecksum() {
       return includeClassesChecksum;
diff --git a/src/main/java/com/android/tools/r8/BaseCompilerCommandParser.java b/src/main/java/com/android/tools/r8/BaseCompilerCommandParser.java
index dbfe6b7..410441b 100644
--- a/src/main/java/com/android/tools/r8/BaseCompilerCommandParser.java
+++ b/src/main/java/com/android/tools/r8/BaseCompilerCommandParser.java
@@ -12,10 +12,14 @@
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.Arrays;
+import java.util.function.Consumer;
 
 public class BaseCompilerCommandParser<
     C extends BaseCompilerCommand, B extends BaseCompilerCommand.Builder<C, B>> {
 
+  protected static final String MIN_API_FLAG = "--min-api";
+  protected static final String THREAD_COUNT_FLAG = "--thread-count";
+
   static final Iterable<String> ASSERTIONS_USAGE_MESSAGE =
       Arrays.asList(
           "  --force-enable-assertions[:[<class name>|<package name>...]]",
@@ -32,19 +36,20 @@
           "                          # is the default handling of javac assertion code when",
           "                          # generating class file format.");
 
-  void parseMinApi(B builder, String minApiString, Origin origin) {
-    int minApi;
+  void parsePositiveIntArgument(
+      B builder, String flag, String argument, Origin origin, Consumer<Integer> setter) {
+    int value;
     try {
-      minApi = Integer.parseInt(minApiString);
+      value = Integer.parseInt(argument);
     } catch (NumberFormatException e) {
-      builder.error(new StringDiagnostic("Invalid argument to --min-api: " + minApiString, origin));
+      builder.error(new StringDiagnostic("Invalid argument to " + flag + ": " + argument, origin));
       return;
     }
-    if (minApi < 1) {
-      builder.error(new StringDiagnostic("Invalid argument to --min-api: " + minApiString, origin));
+    if (value < 1) {
+      builder.error(new StringDiagnostic("Invalid argument to " + flag + ": " + argument, origin));
       return;
     }
-    builder.setMinApiLevel(minApi);
+    setter.accept(value);
   }
 
   private static String PACKAGE_ASSERTION_POSTFIX = "...";
diff --git a/src/main/java/com/android/tools/r8/D8Command.java b/src/main/java/com/android/tools/r8/D8Command.java
index 32e653c..92f194a 100644
--- a/src/main/java/com/android/tools/r8/D8Command.java
+++ b/src/main/java/com/android/tools/r8/D8Command.java
@@ -17,6 +17,7 @@
 import com.android.tools.r8.utils.InternalOptions.DesugarState;
 import com.android.tools.r8.utils.Reporter;
 import com.android.tools.r8.utils.StringDiagnostic;
+import com.android.tools.r8.utils.ThreadUtils;
 import java.nio.file.Path;
 import java.util.Collection;
 import java.util.List;
@@ -226,6 +227,7 @@
           libraryConfiguration,
           getAssertionsConfiguration(),
           getOutputInspections(),
+          getThreadCount(),
           factory);
     }
   }
@@ -295,6 +297,7 @@
       DesugaredLibraryConfiguration libraryConfiguration,
       List<AssertionsConfiguration> assertionsConfiguration,
       List<Consumer<Inspector>> outputInspections,
+      int threadCount,
       DexItemFactory factory) {
     super(
         inputApp,
@@ -308,7 +311,8 @@
         encodeChecksum,
         dexClassChecksumFilter,
         assertionsConfiguration,
-        outputInspections);
+        outputInspections,
+        threadCount);
     this.intermediate = intermediate;
     this.desugarGraphConsumer = desugarGraphConsumer;
     this.desugaredLibraryKeepRuleConsumer = desugaredLibraryKeepRuleConsumer;
@@ -380,6 +384,9 @@
 
     internal.outputInspections = InspectorImpl.wrapInspections(getOutputInspections());
 
+    assert internal.threadCount == ThreadUtils.NOT_SPECIFIED;
+    internal.threadCount = getThreadCount();
+
     return internal;
   }
 }
diff --git a/src/main/java/com/android/tools/r8/D8CommandParser.java b/src/main/java/com/android/tools/r8/D8CommandParser.java
index a6827d3..e9df325 100644
--- a/src/main/java/com/android/tools/r8/D8CommandParser.java
+++ b/src/main/java/com/android/tools/r8/D8CommandParser.java
@@ -29,10 +29,11 @@
           "--output",
           "--lib",
           "--classpath",
-          "--min-api",
+          MIN_API_FLAG,
           "--main-dex-list",
           "--main-dex-list-output",
-          "--desugared-lib");
+          "--desugared-lib",
+          THREAD_COUNT_FLAG);
 
   private static final String APK_EXTENSION = ".apk";
   private static final String JAR_EXTENSION = ".jar";
@@ -121,7 +122,10 @@
                   "                          # <file> must be an existing directory or a zip file.",
                   "  --lib <file|jdk-home>   # Add <file|jdk-home> as a library resource.",
                   "  --classpath <file>      # Add <file> as a classpath resource.",
-                  "  --min-api <number>      # Minimum Android API level compatibility, default: "
+                  "  "
+                      + MIN_API_FLAG
+                      + " <number>      "
+                      + "# Minimum Android API level compatibility, default: "
                       + AndroidApiLevel.getDefault().getLevel()
                       + ".",
                   "  --intermediate          # Compile an intermediate result intended for later",
@@ -243,13 +247,17 @@
         builder.setMainDexListOutputPath(Paths.get(nextArg));
       } else if (arg.equals("--optimize-multidex-for-linearalloc")) {
         builder.setOptimizeMultidexForLinearAlloc(true);
-      } else if (arg.equals("--min-api")) {
+      } else if (arg.equals(MIN_API_FLAG)) {
         if (hasDefinedApiLevel) {
-          builder.error(new StringDiagnostic("Cannot set multiple --min-api options", origin));
+          builder.error(
+              new StringDiagnostic("Cannot set multiple " + MIN_API_FLAG + " options", origin));
         } else {
-          parseMinApi(builder, nextArg, origin);
+          parsePositiveIntArgument(builder, MIN_API_FLAG, nextArg, origin, builder::setMinApiLevel);
           hasDefinedApiLevel = true;
         }
+      } else if (arg.equals(THREAD_COUNT_FLAG)) {
+        parsePositiveIntArgument(
+            builder, THREAD_COUNT_FLAG, nextArg, origin, builder::setThreadCount);
       } else if (arg.equals("--intermediate")) {
         builder.setIntermediate(true);
       } else if (arg.equals("--no-desugaring")) {
diff --git a/src/main/java/com/android/tools/r8/L8Command.java b/src/main/java/com/android/tools/r8/L8Command.java
index c863fe6..c32fbb7 100644
--- a/src/main/java/com/android/tools/r8/L8Command.java
+++ b/src/main/java/com/android/tools/r8/L8Command.java
@@ -17,6 +17,7 @@
 import com.android.tools.r8.utils.Pair;
 import com.android.tools.r8.utils.Reporter;
 import com.android.tools.r8.utils.StringDiagnostic;
+import com.android.tools.r8.utils.ThreadUtils;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -86,6 +87,7 @@
       DesugaredLibraryConfiguration libraryConfiguration,
       List<AssertionsConfiguration> assertionsConfiguration,
       List<Consumer<Inspector>> outputInspections,
+      int threadCount,
       DexItemFactory factory) {
     super(
         inputApp,
@@ -99,7 +101,8 @@
         false,
         (name, checksum) -> true,
         assertionsConfiguration,
-        outputInspections);
+        outputInspections,
+        threadCount);
     this.d8Command = d8Command;
     this.r8Command = r8Command;
     this.libraryConfiguration = libraryConfiguration;
@@ -184,6 +187,9 @@
         new AssertionConfigurationWithDefault(
             AssertionTransformation.DISABLE, getAssertionsConfiguration());
 
+    assert internal.threadCount == ThreadUtils.NOT_SPECIFIED;
+    internal.threadCount = getThreadCount();
+
     return internal;
   }
 
@@ -322,6 +328,7 @@
           libraryConfiguration,
           getAssertionsConfiguration(),
           getOutputInspections(),
+          getThreadCount(),
           factory);
     }
   }
diff --git a/src/main/java/com/android/tools/r8/L8CommandParser.java b/src/main/java/com/android/tools/r8/L8CommandParser.java
index bf35b67..976fb4a 100644
--- a/src/main/java/com/android/tools/r8/L8CommandParser.java
+++ b/src/main/java/com/android/tools/r8/L8CommandParser.java
@@ -19,7 +19,7 @@
 public class L8CommandParser extends BaseCompilerCommandParser<L8Command, L8Command.Builder> {
 
   private static final Set<String> OPTIONS_WITH_PARAMETER =
-      ImmutableSet.of("--output", "--lib", "--min-api", "--desugared-lib");
+      ImmutableSet.of("--output", "--lib", MIN_API_FLAG, "--desugared-lib", THREAD_COUNT_FLAG);
 
   public static void main(String[] args) throws CompilationFailedException {
     L8Command command = parse(args, Origin.root()).build();
@@ -43,7 +43,10 @@
                   "  --output <file>         # Output result in <outfile>.",
                   "                          # <file> must be an existing directory or a zip file.",
                   "  --lib <file|jdk-home>   # Add <file|jdk-home> as a library resource.",
-                  "  --min-api <number>      # Minimum Android API level compatibility, default: "
+                  "  "
+                      + MIN_API_FLAG
+                      + " <number>      "
+                      + "# Minimum Android API level compatibility, default: "
                       + AndroidApiLevel.getDefault().getLevel()
                       + ".",
                   "  --pg-conf <file>        # Proguard configuration <file>.",
@@ -131,11 +134,12 @@
           continue;
         }
         outputPath = Paths.get(nextArg);
-      } else if (arg.equals("--min-api")) {
+      } else if (arg.equals(MIN_API_FLAG)) {
         if (hasDefinedApiLevel) {
-          builder.error(new StringDiagnostic("Cannot set multiple --min-api options", origin));
+          builder.error(
+              new StringDiagnostic("Cannot set multiple " + MIN_API_FLAG + " options", origin));
         } else {
-          parseMinApi(builder, nextArg, origin);
+          parsePositiveIntArgument(builder, MIN_API_FLAG, nextArg, origin, builder::setMinApiLevel);
           hasDefinedApiLevel = true;
         }
       } else if (arg.equals("--lib")) {
@@ -144,6 +148,9 @@
         builder.addProguardConfigurationFiles(Paths.get(nextArg));
       } else if (arg.equals("--desugared-lib")) {
         builder.addDesugaredLibraryConfiguration(StringResource.fromFile(Paths.get(nextArg)));
+      } else if (arg.equals(THREAD_COUNT_FLAG)) {
+        parsePositiveIntArgument(
+            builder, THREAD_COUNT_FLAG, nextArg, origin, builder::setThreadCount);
       } else if (arg.startsWith("--")) {
         if (!tryParseAssertionArgument(builder, arg, origin)) {
           builder.error(new StringDiagnostic("Unknown option: " + arg, origin));
diff --git a/src/main/java/com/android/tools/r8/R8Command.java b/src/main/java/com/android/tools/r8/R8Command.java
index a9c92d2..611bc7a 100644
--- a/src/main/java/com/android/tools/r8/R8Command.java
+++ b/src/main/java/com/android/tools/r8/R8Command.java
@@ -31,6 +31,7 @@
 import com.android.tools.r8.utils.InternalOptions.LineNumberOptimization;
 import com.android.tools.r8.utils.Reporter;
 import com.android.tools.r8.utils.StringDiagnostic;
+import com.android.tools.r8.utils.ThreadUtils;
 import com.google.common.collect.ImmutableList;
 import java.io.InputStream;
 import java.nio.file.Path;
@@ -570,7 +571,8 @@
               libraryConfiguration,
               featureSplitConfiguration,
               getAssertionsConfiguration(),
-              getOutputInspections());
+              getOutputInspections(),
+              getThreadCount());
 
       return command;
     }
@@ -728,7 +730,8 @@
       DesugaredLibraryConfiguration libraryConfiguration,
       FeatureSplitConfiguration featureSplitConfiguration,
       List<AssertionsConfiguration> assertionsConfiguration,
-      List<Consumer<Inspector>> outputInspections) {
+      List<Consumer<Inspector>> outputInspections,
+      int threadCount) {
     super(
         inputApp,
         mode,
@@ -741,7 +744,8 @@
         encodeChecksum,
         dexClassChecksumFilter,
         assertionsConfiguration,
-        outputInspections);
+        outputInspections,
+        threadCount);
     assert proguardConfiguration != null;
     assert mainDexKeepRules != null;
     this.mainDexKeepRules = mainDexKeepRules;
@@ -910,6 +914,9 @@
     internal.desugaredLibraryConfiguration = libraryConfiguration;
     internal.desugaredLibraryKeepRuleConsumer = desugaredLibraryKeepRuleConsumer;
 
+    assert internal.threadCount == ThreadUtils.NOT_SPECIFIED;
+    internal.threadCount = getThreadCount();
+
     return internal;
   }
 
diff --git a/src/main/java/com/android/tools/r8/R8CommandParser.java b/src/main/java/com/android/tools/r8/R8CommandParser.java
index 22ebb5e..84aa83e 100644
--- a/src/main/java/com/android/tools/r8/R8CommandParser.java
+++ b/src/main/java/com/android/tools/r8/R8CommandParser.java
@@ -21,13 +21,14 @@
           "--output",
           "--lib",
           "--classpath",
-          "--min-api",
+          MIN_API_FLAG,
           "--main-dex-rules",
           "--main-dex-list",
           "--main-dex-list-output",
           "--pg-conf",
           "--pg-map-output",
-          "--desugared-lib");
+          "--desugared-lib",
+          THREAD_COUNT_FLAG);
 
   public static void main(String[] args) throws CompilationFailedException {
     R8Command command = parse(args, Origin.root()).build();
@@ -64,7 +65,10 @@
                   "                          # <file> must be an existing directory or a zip file.",
                   "  --lib <file|jdk-home>   # Add <file|jdk-home> as a library resource.",
                   "  --classpath <file>      # Add <file> as a classpath resource.",
-                  "  --min-api <number>      # Minimum Android API level compatibility, default: "
+                  "  "
+                      + MIN_API_FLAG
+                      + " <number>      "
+                      + "# Minimum Android API level compatibility, default: "
                       + AndroidApiLevel.getDefault().getLevel()
                       + ".",
                   "  --pg-conf <file>        # Proguard configuration <file>.",
@@ -190,13 +194,18 @@
         addLibraryArgument(builder, argsOrigin, nextArg);
       } else if (arg.equals("--classpath")) {
         builder.addClasspathFiles(Paths.get(nextArg));
-      } else if (arg.equals("--min-api")) {
+      } else if (arg.equals(MIN_API_FLAG)) {
         if (state.hasDefinedApiLevel) {
-          builder.error(new StringDiagnostic("Cannot set multiple --min-api options", argsOrigin));
+          builder.error(
+              new StringDiagnostic("Cannot set multiple " + MIN_API_FLAG + " options", argsOrigin));
         } else {
-          parseMinApi(builder, nextArg, argsOrigin);
+          parsePositiveIntArgument(
+              builder, MIN_API_FLAG, nextArg, argsOrigin, builder::setMinApiLevel);
           state.hasDefinedApiLevel = true;
         }
+      } else if (arg.equals(THREAD_COUNT_FLAG)) {
+        parsePositiveIntArgument(
+            builder, THREAD_COUNT_FLAG, nextArg, argsOrigin, builder::setThreadCount);
       } else if (arg.equals("--no-tree-shaking")) {
         builder.setDisableTreeShaking(true);
       } else if (arg.equals("--no-minification")) {
diff --git a/src/main/java/com/android/tools/r8/utils/InternalOptions.java b/src/main/java/com/android/tools/r8/utils/InternalOptions.java
index 7ff5ff4..9a6a746 100644
--- a/src/main/java/com/android/tools/r8/utils/InternalOptions.java
+++ b/src/main/java/com/android/tools/r8/utils/InternalOptions.java
@@ -287,7 +287,7 @@
   public boolean enablePcDebugInfoOutput = false;
 
   // Number of threads to use while processing the dex files.
-  public int numberOfThreads = DETERMINISTIC_DEBUGGING ? 1 : ThreadUtils.NOT_SPECIFIED;
+  public int threadCount = DETERMINISTIC_DEBUGGING ? 1 : ThreadUtils.NOT_SPECIFIED;
   // Print smali disassembly.
   public boolean useSmaliSyntax = false;
   // Verbose output.
diff --git a/src/main/java/com/android/tools/r8/utils/ThreadUtils.java b/src/main/java/com/android/tools/r8/utils/ThreadUtils.java
index 40232ca..9bb8877 100644
--- a/src/main/java/com/android/tools/r8/utils/ThreadUtils.java
+++ b/src/main/java/com/android/tools/r8/utils/ThreadUtils.java
@@ -90,18 +90,23 @@
   static ExecutorService getExecutorServiceForProcessors(int processors) {
     // This heuristic is based on measurements on a 32 core (hyper-threaded) machine.
     int threads = processors <= 2 ? processors : (int) Math.ceil(Integer.min(processors, 16) / 2.0);
+    return getExecutorServiceForThreads(threads);
+  }
+
+  static ExecutorService getExecutorServiceForThreads(int threads) {
+    // Note Executors.newSingleThreadExecutor() is not used when just one thread is used. See
+    // b/67338394.
     return Executors.newWorkStealingPool(threads);
   }
 
   public static ExecutorService getExecutorService(int threads) {
-    // Don't use Executors.newSingleThreadExecutor() when threads == 1, see b/67338394.
     return threads == NOT_SPECIFIED
         ? getExecutorServiceForProcessors(Runtime.getRuntime().availableProcessors())
-        : Executors.newWorkStealingPool(threads);
+        : getExecutorServiceForThreads(threads);
   }
 
   public static ExecutorService getExecutorService(InternalOptions options) {
-    return getExecutorService(options.numberOfThreads);
+    return getExecutorService(options.threadCount);
   }
 
   public static int getNumberOfThreads(ExecutorService service) {
diff --git a/src/test/java/com/android/tools/r8/D8CommandTest.java b/src/test/java/com/android/tools/r8/D8CommandTest.java
index 57fc2ec..8d43d07 100644
--- a/src/test/java/com/android/tools/r8/D8CommandTest.java
+++ b/src/test/java/com/android/tools/r8/D8CommandTest.java
@@ -11,6 +11,7 @@
 import static org.junit.Assert.assertNotEquals;
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
 import com.android.sdklib.AndroidVersion;
 import com.android.tools.r8.AssertionsConfiguration.AssertionTransformation;
@@ -24,6 +25,7 @@
 import com.android.tools.r8.utils.AndroidApiLevel;
 import com.android.tools.r8.utils.AndroidApp;
 import com.android.tools.r8.utils.FileUtils;
+import com.android.tools.r8.utils.ThreadUtils;
 import com.android.tools.r8.utils.ZipUtils;
 import com.google.common.collect.ImmutableList;
 import java.io.IOException;
@@ -592,6 +594,32 @@
         d8Command.getInternalOptions().desugaredLibraryConfiguration.getRewritePrefix().isEmpty());
   }
 
+  @Test
+  public void numThreadsOption() throws Exception {
+    assertEquals(ThreadUtils.NOT_SPECIFIED, parse().getThreadCount());
+    assertEquals(1, parse("--thread-count", "1").getThreadCount());
+    assertEquals(2, parse("--thread-count", "2").getThreadCount());
+    assertEquals(10, parse("--thread-count", "10").getThreadCount());
+  }
+
+  private void numThreadsOptionInvalid(String value) throws Exception {
+    final String expectedErrorContains = "Invalid argument to --thread-count";
+    try {
+      DiagnosticsChecker.checkErrorsContains(
+          expectedErrorContains, handler -> parse(handler, "--thread-count", value));
+      fail("Expected failure");
+    } catch (CompilationFailedException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void numThreadsOptionInvalid() throws Exception {
+    numThreadsOptionInvalid("0");
+    numThreadsOptionInvalid("-1");
+    numThreadsOptionInvalid("two");
+  }
+
   private D8Command parse(String... args) throws CompilationFailedException {
     return D8Command.parse(args, EmbeddedOrigin.INSTANCE).build();
   }
diff --git a/src/test/java/com/android/tools/r8/DiagnosticsChecker.java b/src/test/java/com/android/tools/r8/DiagnosticsChecker.java
index 049cdb7..b62416f 100644
--- a/src/test/java/com/android/tools/r8/DiagnosticsChecker.java
+++ b/src/test/java/com/android/tools/r8/DiagnosticsChecker.java
@@ -6,6 +6,7 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
 import com.android.tools.r8.origin.Origin;
 import com.android.tools.r8.origin.PathOrigin;
@@ -60,6 +61,7 @@
     DiagnosticsChecker handler = new DiagnosticsChecker();
     try {
       runner.run(handler);
+      fail("Failure expected");
     } catch (CompilationFailedException e) {
       checkContains(snippet, handler.errors);
       throw e;
diff --git a/src/test/java/com/android/tools/r8/L8CommandTest.java b/src/test/java/com/android/tools/r8/L8CommandTest.java
index 060bdbb..705ecff 100644
--- a/src/test/java/com/android/tools/r8/L8CommandTest.java
+++ b/src/test/java/com/android/tools/r8/L8CommandTest.java
@@ -7,6 +7,7 @@
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
 import com.android.tools.r8.AssertionsConfiguration.AssertionTransformation;
 import com.android.tools.r8.AssertionsConfiguration.AssertionTransformationScope;
@@ -14,6 +15,7 @@
 import com.android.tools.r8.origin.EmbeddedOrigin;
 import com.android.tools.r8.origin.Origin;
 import com.android.tools.r8.utils.AndroidApiLevel;
+import com.android.tools.r8.utils.ThreadUtils;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -257,7 +259,69 @@
         AssertionTransformation.PASSTHROUGH);
   }
 
+  @Test
+  public void numThreadsOption() throws Exception {
+    assertEquals(
+        ThreadUtils.NOT_SPECIFIED,
+        parse("--desugared-lib", ToolHelper.DESUGAR_LIB_JSON_FOR_TESTING.toString())
+            .getThreadCount());
+    assertEquals(
+        1,
+        parse(
+                "--thread-count",
+                "1",
+                "--desugared-lib",
+                ToolHelper.DESUGAR_LIB_JSON_FOR_TESTING.toString())
+            .getThreadCount());
+    assertEquals(
+        2,
+        parse(
+                "--thread-count",
+                "2",
+                "--desugared-lib",
+                ToolHelper.DESUGAR_LIB_JSON_FOR_TESTING.toString())
+            .getThreadCount());
+    assertEquals(
+        10,
+        parse(
+                "--thread-count",
+                "10",
+                "--desugared-lib",
+                ToolHelper.DESUGAR_LIB_JSON_FOR_TESTING.toString())
+            .getThreadCount());
+  }
+
+  private void numThreadsOptionInvalid(String value) throws Exception {
+    final String expectedErrorContains = "Invalid argument to --thread-count";
+    try {
+      DiagnosticsChecker.checkErrorsContains(
+          expectedErrorContains,
+          handler ->
+              parse(
+                  handler,
+                  "--thread-count",
+                  value,
+                  "--desugared-lib",
+                  ToolHelper.DESUGAR_LIB_JSON_FOR_TESTING.toString()));
+      fail("Expected failure");
+    } catch (CompilationFailedException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void numThreadsOptionInvalid() throws Exception {
+    numThreadsOptionInvalid("0");
+    numThreadsOptionInvalid("-1");
+    numThreadsOptionInvalid("two");
+  }
+
   private L8Command parse(String... args) throws CompilationFailedException {
     return L8Command.parse(args, EmbeddedOrigin.INSTANCE).build();
   }
+
+  private L8Command parse(DiagnosticsHandler handler, String... args)
+      throws CompilationFailedException {
+    return L8Command.parse(args, EmbeddedOrigin.INSTANCE, handler).build();
+  }
 }
diff --git a/src/test/java/com/android/tools/r8/R8CommandTest.java b/src/test/java/com/android/tools/r8/R8CommandTest.java
index 4a703d7..29a9e22 100644
--- a/src/test/java/com/android/tools/r8/R8CommandTest.java
+++ b/src/test/java/com/android/tools/r8/R8CommandTest.java
@@ -9,6 +9,7 @@
 import static org.junit.Assert.assertNotEquals;
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
 import com.android.tools.r8.AssertionsConfiguration.AssertionTransformation;
 import com.android.tools.r8.AssertionsConfiguration.AssertionTransformationScope;
@@ -20,6 +21,7 @@
 import com.android.tools.r8.origin.Origin;
 import com.android.tools.r8.utils.AndroidApiLevel;
 import com.android.tools.r8.utils.FileUtils;
+import com.android.tools.r8.utils.ThreadUtils;
 import com.android.tools.r8.utils.ZipUtils;
 import com.google.common.collect.ImmutableList;
 import java.io.IOException;
@@ -709,6 +711,32 @@
         r8Command.getInternalOptions().desugaredLibraryConfiguration.getRewritePrefix().isEmpty());
   }
 
+  @Test
+  public void numThreadsOption() throws Exception {
+    assertEquals(ThreadUtils.NOT_SPECIFIED, parse().getThreadCount());
+    assertEquals(1, parse("--thread-count", "1").getThreadCount());
+    assertEquals(2, parse("--thread-count", "2").getThreadCount());
+    assertEquals(10, parse("--thread-count", "10").getThreadCount());
+  }
+
+  private void numThreadsOptionInvalid(String value) throws Exception {
+    final String expectedErrorContains = "Invalid argument to --thread-count";
+    try {
+      DiagnosticsChecker.checkErrorsContains(
+          expectedErrorContains, handler -> parse(handler, "--thread-count", value));
+      fail("Expected failure");
+    } catch (CompilationFailedException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void numThreadsOptionInvalid() throws Exception {
+    numThreadsOptionInvalid("0");
+    numThreadsOptionInvalid("-1");
+    numThreadsOptionInvalid("two");
+  }
+
   private R8Command parse(String... args) throws CompilationFailedException {
     return R8Command.parse(args, EmbeddedOrigin.INSTANCE).build();
   }
diff --git a/src/test/java/com/android/tools/r8/internal/R8GMSCoreDeterministicTest.java b/src/test/java/com/android/tools/r8/internal/R8GMSCoreDeterministicTest.java
index a16962d..cba792d 100644
--- a/src/test/java/com/android/tools/r8/internal/R8GMSCoreDeterministicTest.java
+++ b/src/test/java/com/android/tools/r8/internal/R8GMSCoreDeterministicTest.java
@@ -41,7 +41,7 @@
               // For this test just do random shuffle.
               options.testing.irOrdering = NondeterministicIROrdering.getInstance();
               // Only use one thread to process to process in the order decided by the callback.
-              options.numberOfThreads = 1;
+              options.threadCount = 1;
               // Ignore the missing classes.
               options.ignoreMissingClasses = true;
               // Store the generated Proguard map.
diff --git a/tools/run_on_app.py b/tools/run_on_app.py
index 16db28d..78c8172 100755
--- a/tools/run_on_app.py
+++ b/tools/run_on_app.py
@@ -167,6 +167,9 @@
                     help='Include timing',
                     default=False,
                     action='store_true')
+  result.add_option('--cpu-list',
+                    help='Run under \'taskset\' with these CPUs. See '
+                         'the \'taskset\' -c option for the format')
 
   return result.parse_args(argv)
 
@@ -575,7 +578,8 @@
             stdout=stdout,
             stderr=stderr,
             timeout=options.timeout,
-            quiet=quiet)
+            quiet=quiet,
+            cmd_prefix=['taskset', '-c', options.cpu_list] if options.cpu_list else None)
       if exit_code != 0:
         with open(stderr_path) as stderr:
           stderr_text = stderr.read()
diff --git a/tools/toolhelper.py b/tools/toolhelper.py
index 2784ea5..806205f 100644
--- a/tools/toolhelper.py
+++ b/tools/toolhelper.py
@@ -11,12 +11,13 @@
 
 def run(tool, args, build=None, debug=True,
         profile=False, track_memory_file=None, extra_args=None,
-        stderr=None, stdout=None, return_stdout=False, timeout=0, quiet=False):
+        stderr=None, stdout=None, return_stdout=False, timeout=0, quiet=False,
+        cmd_prefix=[]):
   if build is None:
     build, args = extract_build_from_args(args)
   if build:
     gradle.RunGradle(['r8lib' if tool.startswith('r8lib') else 'r8'])
-  cmd = []
+  cmd = cmd_prefix
   if track_memory_file:
     cmd.extend(['tools/track_memory.sh', track_memory_file])
   cmd.append(jdk.GetJavaExecutable())