| // 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(); |
| } |
| } |
| } |