blob: e0c3c6d5725a3b403ff539eff5208e89e25bbf39 [file] [log] [blame]
/*
* Copyright 2012-present Facebook, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, 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.
*/
package com.facebook.buck.android;
import static com.google.common.util.concurrent.MoreExecutors.listeningDecorator;
import com.facebook.buck.android.DxStep.Option;
import com.facebook.buck.log.CommandThreadFactory;
import com.facebook.buck.io.ProjectFilesystem;
import com.facebook.buck.rules.Sha1HashCode;
import com.facebook.buck.step.CompositeStep;
import com.facebook.buck.step.DefaultStepRunner;
import com.facebook.buck.step.ExecutionContext;
import com.facebook.buck.step.Step;
import com.facebook.buck.step.StepFailedException;
import com.facebook.buck.step.fs.RmStep;
import com.facebook.buck.step.fs.WriteFileStep;
import com.facebook.buck.step.fs.XzStep;
import com.facebook.buck.util.concurrent.MoreExecutors;
import com.facebook.buck.zip.RepackZipEntriesStep;
import com.facebook.buck.zip.ZipStep;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Charsets;
import com.google.common.base.Functions;
import com.google.common.base.Joiner;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import com.google.common.base.Supplier;
import com.google.common.base.Suppliers;
import com.google.common.collect.FluentIterable;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Multimap;
import com.google.common.hash.Hasher;
import com.google.common.hash.Hashing;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collection;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import javax.annotation.Nullable;
/**
* Optimized dx command runner which can invoke multiple dx commands in parallel and also avoid
* doing unnecessary dx invocations in the first place.
* <p>
* This is most appropriately represented as a build rule itself (which depends on individual dex
* rules) however this would require significant refactoring of AndroidBinaryRule that would be
* disruptive to other initiatives in flight (namely, ApkBuilder). It is also debatable that it is
* even the right course of action given that it would require dynamically modifying the DAG.
*/
public class SmartDexingStep implements Step {
public static interface DexInputHashesProvider {
ImmutableMap<Path, Sha1HashCode> getDexInputHashes();
}
private final Supplier<Multimap<Path, Path>> outputToInputsSupplier;
private final Optional<Path> secondaryOutputDir;
private final DexInputHashesProvider dexInputHashesProvider;
private final Path successDir;
private final Optional<Integer> numThreads;
private final EnumSet<DxStep.Option> dxOptions;
/**
* @param primaryOutputPath Path for the primary dex artifact.
* @param primaryInputsToDex Set of paths to include as inputs for the primary dex artifact.
* @param secondaryOutputDir Directory path for the secondary dex artifacts, if there are any.
* Note that this directory will be pruned such that only those secondary outputs generated
* by this command will remain in the directory!
* @param secondaryInputsToDex List of paths to input jar files, to use as dx input, keyed by the
* corresponding output dex file.
* Note that for each output file (key), a separate dx invocation will be started with the
* corresponding jar files (value) as the input.
* @param successDir Directory where success artifacts are written.
* @param numThreads Number of threads to use when invoking dx commands. If absent, a
* reasonable default will be selected based on the number of available processors.
*/
public SmartDexingStep(
final Path primaryOutputPath,
final Supplier<Set<Path>> primaryInputsToDex,
Optional<Path> secondaryOutputDir,
final Optional<Supplier<Multimap<Path, Path>>> secondaryInputsToDex,
DexInputHashesProvider dexInputHashesProvider,
Path successDir,
Optional<Integer> numThreads,
EnumSet<Option> dxOptions) {
this.outputToInputsSupplier = Suppliers.memoize(
new Supplier<Multimap<Path, Path>>() {
@Override
public Multimap<Path, Path> get() {
final ImmutableMultimap.Builder<Path, Path> map = ImmutableMultimap.builder();
map.putAll(primaryOutputPath, primaryInputsToDex.get());
if (secondaryInputsToDex.isPresent()) {
map.putAll(secondaryInputsToDex.get().get());
}
return map.build();
}
});
this.secondaryOutputDir = secondaryOutputDir;
this.dexInputHashesProvider = dexInputHashesProvider;
this.successDir = successDir;
this.numThreads = numThreads;
this.dxOptions = dxOptions;
}
static int determineOptimalThreadCount() {
return (int) (1.25 * Runtime.getRuntime().availableProcessors());
}
@Override
public int execute(ExecutionContext context) throws InterruptedException {
ProjectFilesystem projectFilesystem = context.getProjectFilesystem();
try {
Multimap<Path, Path> outputToInputs = outputToInputsSupplier.get();
runDxCommands(context, outputToInputs);
if (secondaryOutputDir.isPresent()) {
removeExtraneousSecondaryArtifacts(
secondaryOutputDir.get(),
outputToInputs.keySet(),
projectFilesystem);
}
} catch (StepFailedException | IOException e) {
context.logError(e, "There was an error in smart dexing step.");
return 1;
}
return 0;
}
private void runDxCommands(ExecutionContext context, Multimap<Path, Path> outputToInputs)
throws StepFailedException, IOException, InterruptedException {
ExecutorService service =
MoreExecutors.newMultiThreadExecutor(
new CommandThreadFactory("SmartDexing"),
numThreads.or(determineOptimalThreadCount()));
try {
DefaultStepRunner stepRunner = new DefaultStepRunner(context, listeningDecorator(service));
// Invoke dx commands in parallel for maximum thread utilization. In testing, dx revealed
// itself to be CPU (and not I/O) bound making it a good candidate for parallelization.
List<Step> dxSteps = generateDxCommands(context.getProjectFilesystem(), outputToInputs);
stepRunner.runStepsInParallelAndWait(dxSteps);
} finally {
// Wait for however long necessary for threads to finish. This should be fine, since we'll
// detect deadlocks at the top-level (since this thread won't return).
MoreExecutors.shutdown(service);
}
}
/**
* Prune the secondary output directory of any files that we didn't generate. This is
* needed because we crudely add all files in this directory to the final APK, but the number
* may have been reduced due to split-zip having less code to process.
* <p>
* This is also a defensive measure to cleanup extraneous artifacts left behind due to
* changes to buck itself.
*/
private void removeExtraneousSecondaryArtifacts(
Path secondaryOutputDir,
Set<Path> producedArtifacts,
ProjectFilesystem projectFilesystem) throws IOException {
Path normalizedRoot = projectFilesystem.getRootPath().normalize();
for (Path secondaryOutput : projectFilesystem.getDirectoryContents(secondaryOutputDir)) {
Path relativePath = normalizedRoot.relativize(secondaryOutput.normalize());
if (!producedArtifacts.contains(relativePath) &&
!secondaryOutput.getFileName().toString().endsWith(".meta")) {
projectFilesystem.rmdir(secondaryOutput);
}
}
}
@Override
public String getShortName() {
return "smart_dex";
}
@Override
public String getDescription(ExecutionContext context) {
StringBuilder b = new StringBuilder();
b.append(getShortName());
b.append(' ');
Multimap<Path, Path> outputToInputs = outputToInputsSupplier.get();
for (Path output : outputToInputs.keySet()) {
b.append("-out ");
b.append(output.toString());
b.append("-in ");
Joiner.on(':').appendTo(b,
Iterables.transform(outputToInputs.get(output), Functions.toStringFunction()));
}
return b.toString();
}
/**
* Once the {@code .class} files have been split into separate zip files, each must be converted
* to a {@code .dex} file.
*/
private List<Step> generateDxCommands(
ProjectFilesystem filesystem,
Multimap<Path, Path> outputToInputs) throws IOException {
ImmutableList.Builder<DxPseudoRule> pseudoRules = ImmutableList.builder();
ImmutableMap<Path, Sha1HashCode> dexInputHashes = dexInputHashesProvider.getDexInputHashes();
for (Path outputFile : outputToInputs.keySet()) {
pseudoRules.add(
new DxPseudoRule(
filesystem,
dexInputHashes,
FluentIterable.from(outputToInputs.get(outputFile)).toSet(),
outputFile,
successDir.resolve(outputFile.getFileName()),
dxOptions));
}
ImmutableList.Builder<Step> steps = ImmutableList.builder();
for (DxPseudoRule pseudoRule : pseudoRules.build()) {
if (!pseudoRule.checkIsCached()) {
steps.addAll(pseudoRule.buildInternal());
}
}
return steps.build();
}
/**
* Internally designed to simulate a dexing buck rule so that once refactored more broadly as
* such it should be straightforward to convert this code.
* <p>
* This pseudo rule does not use the normal .success file model but instead checksums its
* inputs. This is because the input zip files are guaranteed to have changed on the
* filesystem (ZipSplitter will always write them out even if the same), but the contents
* contained in the zip may not have changed.
*/
@VisibleForTesting
static class DxPseudoRule {
private final ProjectFilesystem filesystem;
private final Map<Path, Sha1HashCode> dexInputHashes;
private final Set<Path> srcs;
private final Path outputPath;
private final Path outputHashPath;
private final EnumSet<Option> dxOptions;
@Nullable
private String newInputsHash;
public DxPseudoRule(
ProjectFilesystem filesystem,
Map<Path, Sha1HashCode> dexInputHashes,
Set<Path> srcs,
Path outputPath,
Path outputHashPath,
EnumSet<Option> dxOptions) {
this.filesystem = filesystem;
this.dexInputHashes = ImmutableMap.copyOf(dexInputHashes);
this.srcs = ImmutableSet.copyOf(srcs);
this.outputPath = outputPath;
this.outputHashPath = outputHashPath;
this.dxOptions = dxOptions;
}
/**
* Read the previous run's hash from the filesystem.
*
* @return Previous hash if there was one; null otherwise.
*/
@Nullable
private String getPreviousInputsHash() {
// Returning null will trigger the dx command to run again.
return filesystem.readFirstLine(outputHashPath).orNull();
}
@VisibleForTesting
String hashInputs() throws IOException {
Hasher hasher = Hashing.sha1().newHasher();
for (Path src : srcs) {
Preconditions.checkState(dexInputHashes.containsKey(src));
hasher.putBytes(
Preconditions.checkNotNull(dexInputHashes.get(src))
.getHash().getBytes(Charsets.UTF_8));
}
return hasher.hash().toString();
}
public boolean checkIsCached() throws IOException {
newInputsHash = hashInputs();
if (!filesystem.exists(outputHashPath) ||
!filesystem.exists(outputPath)) {
return false;
}
// Verify input hashes.
String currentInputsHash = getPreviousInputsHash();
return newInputsHash.equals(currentInputsHash);
}
public List<Step> buildInternal() {
Preconditions.checkState(newInputsHash != null, "Must call checkIsCached first!");
List<Step> steps = Lists.newArrayList();
steps.add(createDxStepForDxPseudoRule(srcs, outputPath, dxOptions));
steps.add(new WriteFileStep(newInputsHash, outputHashPath));
// Use a composite step to ensure that runDxSteps can still make use of
// runStepsInParallelAndWait. This is necessary to keep the DxStep and
// WriteFileStep dependent in series.
return ImmutableList.<Step>of(new CompositeStep(steps));
}
}
/**
* The step to produce the .dex file will be determined by the file extension of outputPath, much
* as {@code dx} itself chooses whether to embed the dex inside a jar/zip based on the destination
* file passed to it. We also create a ".meta" file that contains information about the
* compressed and uncompressed size of the dex; this information is useful later, in applications,
* when unpacking.
*/
static Step createDxStepForDxPseudoRule(Collection<Path> filesToDex,
Path outputPath,
EnumSet<Option> dxOptions) {
String output = outputPath.toString();
List<Step> steps = Lists.newArrayList();
if (DexStore.XZ.matchesPath(outputPath)) {
Path tempDexJarOutput = Paths.get(output.replaceAll("\\.jar\\.xz$", ".tmp.jar"));
steps.add(new DxStep(tempDexJarOutput, filesToDex, dxOptions));
// We need to make sure classes.dex is STOREd in the .dex.jar file, otherwise .XZ
// compression won't be effective.
Path repackedJar = Paths.get(output.replaceAll("\\.xz$", ""));
steps.add(new RepackZipEntriesStep(
tempDexJarOutput,
repackedJar,
ImmutableSet.of("classes.dex"),
ZipStep.MIN_COMPRESSION_LEVEL));
steps.add(new RmStep(tempDexJarOutput, true));
steps.add(
new DexJarAnalysisStep(
repackedJar,
repackedJar.resolveSibling(
repackedJar.getFileName() + ".meta")));
steps.add(new XzStep(repackedJar));
} else if (DexStore.JAR.matchesPath(outputPath) || DexStore.RAW.matchesPath(outputPath) ||
output.endsWith("classes.dex")) {
steps.add(new DxStep(outputPath, filesToDex, dxOptions));
if (DexStore.JAR.matchesPath(outputPath)) {
steps.add(
new DexJarAnalysisStep(
outputPath,
outputPath.resolveSibling(
outputPath.getFileName() + ".meta")));
}
} else {
throw new IllegalArgumentException(String.format(
"Suffix of %s does not have a corresponding DexStore type.",
outputPath));
}
return new CompositeStep(steps);
}
}