| // Copyright 2023 The Bazel Authors. All rights reserved. | |
| // | |
| // 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.google.devtools.build.skyframe; | |
| import static com.google.common.base.MoreObjects.toStringHelper; | |
| import static com.google.common.truth.Truth.assertThat; | |
| import static com.google.devtools.build.skyframe.DirtyBuildingState.DEPTH_SATURATION_BOUND; | |
| import static com.google.devtools.build.skyframe.DirtyBuildingState.EVALUATION_COUNT_SATURATION_BOUND; | |
| import static com.google.devtools.build.skyframe.NotifyingHelper.makeNotifyingTransformer; | |
| import com.google.auto.value.AutoValue; | |
| import com.google.common.collect.ImmutableList; | |
| import com.google.common.collect.Interner; | |
| import com.google.devtools.build.lib.concurrent.BlazeInterners; | |
| import com.google.devtools.build.lib.concurrent.TieredPriorityExecutor; | |
| import com.google.devtools.build.lib.events.StoredEventHandler; | |
| import com.google.devtools.build.skyframe.EvaluationContext.UnnecessaryTemporaryStateDropperReceiver; | |
| import com.google.devtools.build.skyframe.GraphTester.StringValue; | |
| import com.google.devtools.build.skyframe.NotifyingHelper.Listener; | |
| import com.google.testing.junit.testparameterinjector.TestParameter; | |
| import com.google.testing.junit.testparameterinjector.TestParameterInjector; | |
| import java.util.ArrayList; | |
| import java.util.concurrent.ConcurrentHashMap; | |
| import java.util.concurrent.atomic.AtomicReference; | |
| import javax.annotation.Nullable; | |
| import org.junit.Test; | |
| import org.junit.runner.RunWith; | |
| @RunWith(TestParameterInjector.class) | |
| public final class PriorityTest { | |
| private static final long POLL_MS = 100; | |
| /** Low fanout occupies the highest bit that can be set without creating a negative number. */ | |
| private static final int LOW_FANOUT_PRIORITY = 0x4000_0000; | |
| private final ProcessableGraph graph = new InMemoryGraphImpl(); | |
| private final GraphTester tester = new GraphTester(); | |
| private final StoredEventHandler reportedEvents = new StoredEventHandler(); | |
| private final DirtyTrackingProgressReceiver revalidationReceiver = | |
| new DirtyTrackingProgressReceiver(null); | |
| private static final Version VERSION = IntVersion.of(0); | |
| // TODO(shahan): consider factoring this boilerplate out to a common location. | |
| private <T extends SkyValue> EvaluationResult<T> eval(SkyKey root, Listener listener) | |
| throws InterruptedException { | |
| return new ParallelEvaluator( | |
| makeNotifyingTransformer(listener).transform(graph), | |
| VERSION, | |
| Version.minimal(), | |
| tester.getSkyFunctionMap(), | |
| reportedEvents, | |
| new EmittedEventState(), | |
| EventFilter.FULL_STORAGE, | |
| ErrorInfoManager.UseChildErrorInfoIfNecessary.INSTANCE, | |
| /* keepGoing= */ false, | |
| revalidationReceiver, | |
| GraphInconsistencyReceiver.THROWING, | |
| new TieredPriorityExecutor( | |
| "test-pool", 6, 3, ParallelEvaluatorErrorClassifier.instance()), | |
| new SimpleCycleDetector(), | |
| /* mergingSkyframeAnalysisExecutionPhases= */ false, | |
| UnnecessaryTemporaryStateDropperReceiver.NULL) | |
| .eval(ImmutableList.of(root)); | |
| } | |
| // Some predefined values for use in the tests. | |
| private static final SkyValue VALUE_A1 = new StringValue("A1"); | |
| private static final SkyValue VALUE_A2 = new StringValue("A2"); | |
| private static final SkyValue VALUE_B1 = new StringValue("B1"); | |
| private static final SkyValue DONE_VALUE = new StringValue("DONE"); | |
| @AutoValue | |
| abstract static class PriorityInfo { | |
| private static PriorityInfo fromNode(NodeEntry node) { | |
| return new AutoValue_PriorityTest_PriorityInfo(node.getPriority(), node.depth()); | |
| } | |
| private static PriorityInfo of(boolean hasLowFanout, int depth, int evaluationCount) { | |
| int priority = depth + evaluationCount * evaluationCount; | |
| if (hasLowFanout) { | |
| priority += LOW_FANOUT_PRIORITY; | |
| } | |
| return new AutoValue_PriorityTest_PriorityInfo(priority, depth); | |
| } | |
| abstract int priority(); | |
| abstract int depth(); | |
| } | |
| @Test | |
| public void evaluate_incrementsChildDepthAndParentEvaluationCount() throws InterruptedException { | |
| CPUHeavySkyKey rootKey = Key.create("root"); | |
| CPUHeavySkyKey depKey = Key.create("a"); | |
| tester.getOrCreate(depKey).setConstantValue(VALUE_A1); | |
| tester | |
| .getOrCreate(rootKey) | |
| .setBuilder( | |
| (unusedKey, env) -> { | |
| var value = env.getValue(depKey); | |
| if (value == null) { | |
| return null; | |
| } | |
| assertThat(value).isEqualTo(VALUE_A1); | |
| return DONE_VALUE; | |
| }); | |
| var listener = new EvaluationPriorityListener(); | |
| assertThat(eval(rootKey, listener).get(rootKey)).isEqualTo(DONE_VALUE); | |
| assertThat(listener.priorities()) | |
| .containsExactly( | |
| rootKey, | |
| ImmutableList.of( | |
| PriorityInfo.of( | |
| /* hasLowFanout= */ false, /* depth= */ 0, /* evaluationCount= */ 0), | |
| PriorityInfo.of( | |
| /* hasLowFanout= */ false, /* depth= */ 0, /* evaluationCount= */ 1)), | |
| depKey, | |
| ImmutableList.of( | |
| PriorityInfo.of( | |
| /* hasLowFanout= */ false, /* depth= */ 1, /* evaluationCount= */ 0))); | |
| } | |
| private static class EvaluationPriorityListener implements Listener { | |
| private final ConcurrentHashMap<SkyKey, ArrayList<PriorityInfo>> priorities = | |
| new ConcurrentHashMap<>(); | |
| @Override | |
| public void accept( | |
| SkyKey key, | |
| NotifyingHelper.EventType type, | |
| NotifyingHelper.Order order, | |
| @Nullable Object context) { | |
| if (type != NotifyingHelper.EventType.EVALUATE) { | |
| return; | |
| } | |
| priorities | |
| .computeIfAbsent(key, unusedKey -> new ArrayList<>()) | |
| .add(PriorityInfo.fromNode((NodeEntry) context)); | |
| } | |
| ConcurrentHashMap<SkyKey, ArrayList<PriorityInfo>> priorities() { | |
| return priorities; | |
| } | |
| } | |
| @Test | |
| public void enqueuingChildFromDeeperParent_incrementsDepth() throws InterruptedException { | |
| CPUHeavySkyKey rootKey = Key.create("root"); | |
| // Both a1 and a2 are children of root. a2 requests a1 again to increase its depth. | |
| CPUHeavySkyKey a1 = Key.create("a1"); | |
| CPUHeavySkyKey a2 = Key.create("a2"); | |
| // b1 is a child of a1 that causes a1 to restart. b1's SkyFunction holds until a2 has had a | |
| // chance to re-request a1, so a1's increased depth at re-evaluation can be observed. | |
| CPUHeavySkyKey b1 = Key.createWithLowFanout("b1"); | |
| var nodeA1 = new AtomicReference<NodeEntry>(); | |
| var listener = | |
| new EvaluationPriorityListener() { | |
| @Override | |
| public void accept( | |
| SkyKey key, | |
| NotifyingHelper.EventType type, | |
| NotifyingHelper.Order order, | |
| @Nullable Object context) { | |
| super.accept(key, type, order, context); | |
| // Captures a1's node in addition to priority information. | |
| if (key == a1 && type == NotifyingHelper.EventType.EVALUATE) { | |
| nodeA1.compareAndSet(/* expectedValue= */ null, (NodeEntry) context); | |
| } | |
| } | |
| }; | |
| tester | |
| .getOrCreate(b1) | |
| .setBuilder( | |
| (key, env) -> { | |
| // To ensure that a1 only re-enqueues after a2 has requested it, waits until a1's | |
| // depth becomes 2. | |
| while (nodeA1.get().depth() < 2) { | |
| try { | |
| Thread.sleep(POLL_MS); | |
| } catch (InterruptedException e) { | |
| throw new AssertionError("Unexpected interruption"); | |
| } | |
| } | |
| return VALUE_B1; | |
| }); | |
| tester | |
| .getOrCreate(a1) | |
| .setBuilder( | |
| (key, env) -> { | |
| var value = env.getValue(b1); | |
| if (value == null) { | |
| return null; | |
| } | |
| assertThat(value).isEqualTo(VALUE_B1); | |
| return VALUE_A1; | |
| }); | |
| tester | |
| .getOrCreate(a2) | |
| .setBuilder( | |
| (key, env) -> { | |
| var value = env.getValue(a1); | |
| if (value == null) { | |
| return null; | |
| } | |
| assertThat(value).isEqualTo(VALUE_A1); | |
| return VALUE_A2; | |
| }); | |
| tester | |
| .getOrCreate(rootKey) | |
| .setBuilder( | |
| (key, env) -> { | |
| var value1 = env.getValue(a1); | |
| var value2 = env.getValue(a2); | |
| if (value1 == null || value2 == null) { | |
| return null; | |
| } | |
| assertThat(value1).isEqualTo(VALUE_A1); | |
| assertThat(value2).isEqualTo(VALUE_A2); | |
| return DONE_VALUE; | |
| }); | |
| assertThat(eval(rootKey, listener).get(rootKey)).isEqualTo(DONE_VALUE); | |
| var priorities = listener.priorities(); | |
| assertThat(priorities.keySet()).containsExactly(rootKey, a1, a2, b1); | |
| assertThat(priorities.get(rootKey)) | |
| .containsExactly( | |
| PriorityInfo.of(/* hasLowFanout= */ false, /* depth= */ 0, /* evaluationCount= */ 0), | |
| PriorityInfo.of(/* hasLowFanout= */ false, /* depth= */ 0, /* evaluationCount= */ 1)) | |
| .inOrder(); | |
| assertThat(priorities.get(a1)).hasSize(2); | |
| assertThat(priorities.get(a1).get(0)) | |
| .isAnyOf( | |
| // The common case where a1 starts evaluating before a2 requests it. | |
| PriorityInfo.of(/* hasLowFanout= */ false, /* depth= */ 1, /* evaluationCount= */ 0), | |
| // Sometimes a2 requests a1 before a1 starts evaluating. | |
| PriorityInfo.of(/* hasLowFanout= */ false, /* depth= */ 2, /* evaluationCount= */ 0)); | |
| assertThat(priorities.get(a1).get(1)) | |
| .isEqualTo( | |
| PriorityInfo.of(/* hasLowFanout= */ false, /* depth= */ 2, /* evaluationCount= */ 1)); | |
| assertThat(priorities.get(a2)) | |
| .containsExactly( | |
| PriorityInfo.of(/* hasLowFanout= */ false, /* depth= */ 1, /* evaluationCount= */ 0), | |
| PriorityInfo.of(/* hasLowFanout= */ false, /* depth= */ 1, /* evaluationCount= */ 1)) | |
| .inOrder(); | |
| assertThat(priorities.get(b1)) | |
| .containsAnyOf( | |
| // If a1 requests b1 first. | |
| PriorityInfo.of(/* hasLowFanout= */ true, /* depth= */ 2, /* evaluationCount= */ 0), | |
| // If a2 requests a1 first. | |
| PriorityInfo.of(/* hasLowFanout= */ true, /* depth= */ 3, /* evaluationCount= */ 0)); | |
| } | |
| @Test | |
| public void basicPriorityCalculation(@TestParameter boolean hasLowFanout) | |
| throws InterruptedException { | |
| var state = DirtyBuildingState.createNew(hasLowFanout); | |
| state.updateDepthIfGreater(5); | |
| state.incrementEvaluationCount(); | |
| state.incrementEvaluationCount(); | |
| assertThat(state.depth()).isEqualTo(5); | |
| assertThat(state.getPriority()) | |
| .isEqualTo( | |
| PriorityInfo.of(hasLowFanout, /* depth= */ 5, /* evaluationCount= */ 2).priority()); | |
| } | |
| private static final int MAX_DEPTH = 0x7FFF; | |
| private enum DepthTestCases { | |
| NEGATIVE_NUMBER(-1, MAX_DEPTH, DEPTH_SATURATION_BOUND), | |
| OVERFLOWING_VALUE(0xFFFF, MAX_DEPTH, DEPTH_SATURATION_BOUND), | |
| NON_INTERSECTING_VALUE(0x1_0000, 0, 0); | |
| private final int proposedDepth; | |
| private final int resultingDepth; | |
| private final int priority; | |
| private DepthTestCases(int proposedDepth, int resultingDepth, int priority) { | |
| this.proposedDepth = proposedDepth; | |
| this.resultingDepth = resultingDepth; | |
| this.priority = priority; | |
| } | |
| private int proposedDepth() { | |
| return proposedDepth; | |
| } | |
| private int resultingDepth() { | |
| return resultingDepth; | |
| } | |
| private int priority() { | |
| return priority; | |
| } | |
| } | |
| @Test | |
| public void updateDepth(@TestParameter DepthTestCases testCase) { | |
| var state = DirtyBuildingState.createNew(/* hasLowFanout= */ false); | |
| state.updateDepthIfGreater(testCase.proposedDepth()); | |
| assertThat(state.depth()).isEqualTo(testCase.resultingDepth()); | |
| assertThat(state.getPriority()).isEqualTo(testCase.priority()); | |
| } | |
| private static final int MAX_EVALUATION_COUNT = 0xFFFF; | |
| private enum EvaluationCountTestCases { | |
| SIMPLE(4, 16), | |
| SATURATING(64, EVALUATION_COUNT_SATURATION_BOUND * EVALUATION_COUNT_SATURATION_BOUND), | |
| MAXIMUM( | |
| MAX_EVALUATION_COUNT, | |
| EVALUATION_COUNT_SATURATION_BOUND * EVALUATION_COUNT_SATURATION_BOUND), | |
| // If there are somehow over MAX_EVALUATION_COUNT evaluations, it overflows into depth. The | |
| // only known scenario where this could happen is with partial re-evaluation. There it would | |
| // make more sense to not mark the corresponding key CPUHeavy to avoid going through the | |
| // priority queue thousands of times. In that case, its children may receive a small increase | |
| // in depth per MAX_EVALUATION_COUNT iterations as an unintended side effect. | |
| OVERFLOW(MAX_EVALUATION_COUNT + 1, 1); | |
| private final int evaluationCount; | |
| private final int priority; | |
| private EvaluationCountTestCases(int evaluationCount, int priority) { | |
| this.evaluationCount = evaluationCount; | |
| this.priority = priority; | |
| } | |
| private int evaluationCount() { | |
| return evaluationCount; | |
| } | |
| private int priority() { | |
| return priority; | |
| } | |
| } | |
| @Test | |
| public void updateEvaluationCount(@TestParameter EvaluationCountTestCases testCase) { | |
| var state = DirtyBuildingState.createNew(/* hasLowFanout= */ false); | |
| for (int i = 0; i < testCase.evaluationCount(); ++i) { | |
| state.incrementEvaluationCount(); | |
| } | |
| assertThat(state.getPriority()).isEqualTo(testCase.priority()); | |
| } | |
| private static class Key implements CPUHeavySkyKey { | |
| private static final Interner<Key> interner = BlazeInterners.newWeakInterner(); | |
| private final String arg; | |
| private final boolean hasLowFanout; | |
| static Key create(String arg) { | |
| return interner.intern(new Key(arg, /* hasLowFanout= */ false)); | |
| } | |
| static Key createWithLowFanout(String arg) { | |
| return interner.intern(new Key(arg, /* hasLowFanout= */ true)); | |
| } | |
| private Key(String arg, boolean hasLowFanout) { | |
| this.arg = arg; | |
| this.hasLowFanout = hasLowFanout; | |
| } | |
| @Override | |
| public SkyFunctionName functionName() { | |
| return SkyFunctionName.FOR_TESTING; | |
| } | |
| @Override | |
| public String argument() { | |
| return arg; | |
| } | |
| @Override | |
| public boolean hasLowFanout() { | |
| return hasLowFanout; | |
| } | |
| @Override | |
| public int hashCode() { | |
| return 31 * functionName().hashCode() + arg.hashCode(); | |
| } | |
| @Override | |
| public boolean equals(Object obj) { | |
| if (!(obj instanceof Key)) { | |
| return false; | |
| } | |
| Key that = (Key) obj; | |
| return arg.equals(that.arg) && hasLowFanout == that.hasLowFanout; | |
| } | |
| @Override | |
| public String toString() { | |
| return toStringHelper(this).add("arg", arg).add("hasLowFanout", hasLowFanout).toString(); | |
| } | |
| } | |
| } |