Integrate TieredPriorityExecutor under flag.

* Change is guarded behind the new flag
  --experimental_use_priority_in_analysis.
* Adds a priority to DirtyBuildingState. The priority based on a
  combination of a node's depth and how many times it has been
  restarted. Previously, information about a node's priority would be lost
  on restart and guessed from children.
* Adds `hasLowFanout` attribute to `SkyKey`, defaulting to true, and set false
  for `ActionLookupKey`, which has been observed to have more than 10k
  dependencies.

PiperOrigin-RevId: 509575949
Change-Id: I3061508e297be2b824b7ac2c4ab1d30350488b89
diff --git a/src/main/java/com/google/devtools/build/lib/actions/ActionLookupKey.java b/src/main/java/com/google/devtools/build/lib/actions/ActionLookupKey.java
index 6c609d4..23f07b1 100644
--- a/src/main/java/com/google/devtools/build/lib/actions/ActionLookupKey.java
+++ b/src/main/java/com/google/devtools/build/lib/actions/ActionLookupKey.java
@@ -30,14 +30,14 @@
  * are subclasses of {@link ActionLookupKey}. This allows callers to easily find the value key,
  * while remaining agnostic to what action lookup values actually exist.
  */
-public interface ActionLookupKey extends ArtifactOwner, CPUHeavySkyKey {
+public abstract class ActionLookupKey implements ArtifactOwner, CPUHeavySkyKey {
 
   /**
    * Returns the {@link BuildConfigurationKey} for the configuration associated with this key, or
    * {@code null} if this key has no associated configuration.
    */
   @Nullable
-  BuildConfigurationKey getConfigurationKey();
+  public abstract BuildConfigurationKey getConfigurationKey();
 
   /**
    * Returns {@code true} if this key <em>may</em> own shareable actions, as determined by {@link
@@ -50,7 +50,12 @@
    * to determine whether the individual action can be shared - notably, for a test target,
    * compilation actions are shareable, but test actions are not.
    */
-  default boolean mayOwnShareableActions() {
+  public boolean mayOwnShareableActions() {
     return getLabel() != null;
   }
+
+  @Override
+  public boolean hasLowFanout() {
+    return false; // May have >10k deps.
+  }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/analysis/AnalysisOptions.java b/src/main/java/com/google/devtools/build/lib/analysis/AnalysisOptions.java
index f7cc396..77aa61d 100644
--- a/src/main/java/com/google/devtools/build/lib/analysis/AnalysisOptions.java
+++ b/src/main/java/com/google/devtools/build/lib/analysis/AnalysisOptions.java
@@ -133,4 +133,18 @@
               + " be used. Example value: \"HOST_CPUS*0.5\".",
       converter = CpuResourceConverter.class)
   public int oomSensitiveSkyFunctionsSemaphoreSize;
+
+  @Option(
+      name = "experimental_use_priority_in_analysis",
+      defaultValue = "false",
+      documentationCategory = OptionDocumentationCategory.UNDOCUMENTED,
+      effectTags = {
+        OptionEffectTag.LOADING_AND_ANALYSIS,
+        OptionEffectTag.BAZEL_INTERNAL_CONFIGURATION
+      },
+      help =
+          "If true, runs the analysis phase with priority queuing for SkyFunctions, improving large"
+              + " build performance. This option is ignored unless"
+              + " experimental_skyframe_cpu_heavy_skykeys_thread_pool_size has a positive value.")
+  public boolean usePrioritization;
 }
diff --git a/src/main/java/com/google/devtools/build/lib/concurrent/QuiescingExecutors.java b/src/main/java/com/google/devtools/build/lib/concurrent/QuiescingExecutors.java
index 240ba97..e3555ea 100644
--- a/src/main/java/com/google/devtools/build/lib/concurrent/QuiescingExecutors.java
+++ b/src/main/java/com/google/devtools/build/lib/concurrent/QuiescingExecutors.java
@@ -21,6 +21,8 @@
 
   int globbingParallelism();
 
+  boolean usePrioritizationForAnalysis();
+
   QuiescingExecutor getAnalysisExecutor();
 
   QuiescingExecutor getExecutionExecutor();
diff --git a/src/main/java/com/google/devtools/build/lib/runtime/QuiescingExecutorsImpl.java b/src/main/java/com/google/devtools/build/lib/runtime/QuiescingExecutorsImpl.java
index 4004db3..524e7af 100644
--- a/src/main/java/com/google/devtools/build/lib/runtime/QuiescingExecutorsImpl.java
+++ b/src/main/java/com/google/devtools/build/lib/runtime/QuiescingExecutorsImpl.java
@@ -23,6 +23,7 @@
 import com.google.devtools.build.lib.concurrent.MultiExecutorQueueVisitor;
 import com.google.devtools.build.lib.concurrent.QuiescingExecutor;
 import com.google.devtools.build.lib.concurrent.QuiescingExecutors;
+import com.google.devtools.build.lib.concurrent.TieredPriorityExecutor;
 import com.google.devtools.build.lib.pkgcache.PackageOptions;
 import com.google.devtools.build.skyframe.ParallelEvaluatorErrorClassifier;
 import com.google.devtools.common.options.OptionsProvider;
@@ -51,13 +52,17 @@
    */
   private int cpuHeavySkyKeysThreadPoolSize;
 
+  private boolean usePrioritizationForAnalysis;
+
   @VisibleForTesting
   public static QuiescingExecutors forTesting() {
     return new QuiescingExecutorsImpl(
         /* analysisParallelism= */ 6,
         /* executionParallelism= */ 6,
         /* globbingParallelism= */ 6,
-        /* cpuHeavySkyKeysThreadPoolSize= */ 4);
+        /* cpuHeavySkyKeysThreadPoolSize= */ 4,
+        // Prioritization needs more test coverage.
+        /* usePrioritizationForAnalysis= */ true);
   }
 
   static QuiescingExecutorsImpl createDefault() {
@@ -65,18 +70,21 @@
         /* analysisParallelism= */ 0,
         /* executionParallelism= */ 0,
         /* globbingParallelism= */ 0,
-        /* cpuHeavySkyKeysThreadPoolSize= */ 0);
+        /* cpuHeavySkyKeysThreadPoolSize= */ 0,
+        /* usePrioritizationForAnalysis= */ false);
   }
 
   private QuiescingExecutorsImpl(
       int analysisParallelism,
       int executionParallelism,
       int globbingParallelism,
-      int cpuHeavySkyKeysThreadPoolSize) {
+      int cpuHeavySkyKeysThreadPoolSize,
+      boolean usePrioritizationForAnalysis) {
     this.analysisParallelism = analysisParallelism;
     this.executionParallelism = executionParallelism;
     this.globbingParallelism = globbingParallelism;
     this.cpuHeavySkyKeysThreadPoolSize = cpuHeavySkyKeysThreadPoolSize;
+    this.usePrioritizationForAnalysis = usePrioritizationForAnalysis;
   }
 
   void resetParameters(OptionsProvider options) {
@@ -97,6 +105,19 @@
     var analysisOptions = options.getOptions(AnalysisOptions.class);
     this.cpuHeavySkyKeysThreadPoolSize =
         analysisOptions != null ? analysisOptions.cpuHeavySkyKeysThreadPoolSize : 0;
+    if (analysisOptions != null) {
+      this.cpuHeavySkyKeysThreadPoolSize = analysisOptions.cpuHeavySkyKeysThreadPoolSize;
+      if ((this.usePrioritizationForAnalysis = analysisOptions.usePrioritization)) {
+        if (cpuHeavySkyKeysThreadPoolSize > analysisParallelism) {
+          // The prioritizing executor requires the CPU heavy pool size to be no more than
+          // analysis parallelism.
+          this.cpuHeavySkyKeysThreadPoolSize = analysisParallelism;
+        }
+      }
+    } else {
+      this.cpuHeavySkyKeysThreadPoolSize = 0;
+      this.usePrioritizationForAnalysis = false;
+    }
   }
 
   @Override
@@ -115,9 +136,21 @@
   }
 
   @Override
+  public boolean usePrioritizationForAnalysis() {
+    return usePrioritizationForAnalysis;
+  }
+
+  @Override
   public QuiescingExecutor getAnalysisExecutor() {
     checkState(analysisParallelism > 0, "expected analysisParallelism > 0 : %s", this);
     if (cpuHeavySkyKeysThreadPoolSize > 0) {
+      if (usePrioritizationForAnalysis) {
+        return new TieredPriorityExecutor(
+            "skyframe-evaluator",
+            analysisParallelism,
+            cpuHeavySkyKeysThreadPoolSize,
+            ParallelEvaluatorErrorClassifier.instance());
+      }
       return MultiExecutorQueueVisitor.createWithExecutorServices(
           newNamedPool(SKYFRAME_EVALUATOR, analysisParallelism),
           AbstractQueueVisitor.createExecutorService(
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/ActionTemplateExpansionValue.java b/src/main/java/com/google/devtools/build/lib/skyframe/ActionTemplateExpansionValue.java
index 6f83aab..2298a07 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/ActionTemplateExpansionValue.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/ActionTemplateExpansionValue.java
@@ -36,7 +36,7 @@
 
   /** Key for {@link ActionTemplateExpansionValue} nodes. */
   @AutoCodec
-  public static final class ActionTemplateExpansionKey implements ActionLookupKey {
+  public static final class ActionTemplateExpansionKey extends ActionLookupKey {
     private static final Interner<ActionTemplateExpansionKey> interner =
         BlazeInterners.newWeakInterner();
 
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/AspectKeyCreator.java b/src/main/java/com/google/devtools/build/lib/skyframe/AspectKeyCreator.java
index 2526999..fc34f04 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/AspectKeyCreator.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/AspectKeyCreator.java
@@ -60,7 +60,7 @@
   }
 
   /** Common superclass for {@link AspectKey} and {@link TopLevelAspectsKey}. */
-  public abstract static class AspectBaseKey implements ActionLookupKey {
+  public abstract static class AspectBaseKey extends ActionLookupKey {
     private final ConfiguredTargetKey baseConfiguredTargetKey;
     private final int hashCode;
 
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/BuildInfoCollectionValue.java b/src/main/java/com/google/devtools/build/lib/skyframe/BuildInfoCollectionValue.java
index 9a98352..4c82613 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/BuildInfoCollectionValue.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/BuildInfoCollectionValue.java
@@ -57,7 +57,7 @@
 
   /** Key for BuildInfoCollectionValues. */
   @AutoCodec
-  public static final class BuildInfoKeyAndConfig implements ActionLookupKey {
+  public static final class BuildInfoKeyAndConfig extends ActionLookupKey {
     private static final Interner<BuildInfoKeyAndConfig> keyInterner =
         BlazeInterners.newWeakInterner();
 
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/ConfiguredTargetKey.java b/src/main/java/com/google/devtools/build/lib/skyframe/ConfiguredTargetKey.java
index d52af1d..a4c504c 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/ConfiguredTargetKey.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/ConfiguredTargetKey.java
@@ -58,7 +58,7 @@
  * BuildConfigurationKey.
  */
 @AutoCodec
-public class ConfiguredTargetKey implements ActionLookupKey {
+public class ConfiguredTargetKey extends ActionLookupKey {
   /**
    * Cache so that the number of ConfiguredTargetKey instances is {@code O(configured targets)} and
    * not {@code O(edges between configured targets)}.
@@ -101,6 +101,7 @@
   }
 
   @Nullable
+  @Override
   public final BuildConfigurationKey getConfigurationKey() {
     return configurationKey;
   }
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/CoverageReportValue.java b/src/main/java/com/google/devtools/build/lib/skyframe/CoverageReportValue.java
index e7001df..fd474db 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/CoverageReportValue.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/CoverageReportValue.java
@@ -32,7 +32,7 @@
     super(generatingActions);
   }
 
-  private static final class CoverageReportKey implements ActionLookupKey {
+  private static final class CoverageReportKey extends ActionLookupKey {
     private CoverageReportKey() {}
 
     @Override
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/WorkspaceStatusValue.java b/src/main/java/com/google/devtools/build/lib/skyframe/WorkspaceStatusValue.java
index 653cc48..ceadba4 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/WorkspaceStatusValue.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/WorkspaceStatusValue.java
@@ -54,7 +54,7 @@
   }
 
   /** {@link com.google.devtools.build.skyframe.SkyKey} for {@link WorkspaceStatusValue}. */
-  public static final class BuildInfoKey implements ActionLookupKey {
+  public static final class BuildInfoKey extends ActionLookupKey {
     private BuildInfoKey() {}
 
     @Override
diff --git a/src/main/java/com/google/devtools/build/skyframe/AbstractParallelEvaluator.java b/src/main/java/com/google/devtools/build/skyframe/AbstractParallelEvaluator.java
index ca7f1ee..7b6b997 100644
--- a/src/main/java/com/google/devtools/build/skyframe/AbstractParallelEvaluator.java
+++ b/src/main/java/com/google/devtools/build/skyframe/AbstractParallelEvaluator.java
@@ -29,7 +29,6 @@
 import com.google.common.graph.ImmutableGraph;
 import com.google.common.graph.Traverser;
 import com.google.common.util.concurrent.ListenableFuture;
-import com.google.devtools.build.lib.bugreport.BugReport;
 import com.google.devtools.build.lib.clock.BlazeClock;
 import com.google.devtools.build.lib.collect.nestedset.NestedSetVisitor;
 import com.google.devtools.build.lib.concurrent.ComparableRunnable;
@@ -56,7 +55,6 @@
 import java.util.Collection;
 import java.util.List;
 import java.util.Set;
-import java.util.concurrent.ForkJoinPool;
 import java.util.concurrent.atomic.AtomicInteger;
 import javax.annotation.Nullable;
 
@@ -98,24 +96,16 @@
 abstract class AbstractParallelEvaluator {
   private static final GoogleLogger logger = GoogleLogger.forEnclosingClass();
 
-  /**
-   * The priority to use the first time a node is restarted.
-   *
-   * <p>This is designed to be higher than any value coming from {@link #globalEnqueuedIndex} so
-   * that we get nodes that have previously started evaluation off our plate.
-   */
-  private static final int FIRST_RESTART_PRIORITY = Integer.MAX_VALUE / 2;
-
   final ProcessableGraph graph;
   final ParallelEvaluatorContext evaluatorContext;
   protected final CycleDetector cycleDetector;
 
   /**
-   * Monotonically increasing counter designed to encourage depth-first graph exploration.
+   * A decreasing counter that results in FIFO priority tie-breaking for prioritization.
    *
-   * <p>It is expected that this never exceeds {@link #FIRST_RESTART_PRIORITY}.
+   * <p>FIFO is friendlier to priority queues.
    */
-  private final AtomicInteger globalEnqueuedIndex = new AtomicInteger(Integer.MIN_VALUE);
+  private final AtomicInteger nextEvaluateId = new AtomicInteger(Integer.MAX_VALUE);
 
   protected final Cache<SkyKey, SkyKeyComputeState> stateCache = Caffeine.newBuilder().build();
 
@@ -173,65 +163,40 @@
   /**
    * An action that evaluates a value.
    *
-   * <p>{@link Comparable} for use in priority queues. Experimentally, grouping enqueued evaluations
-   * together by parent leads to fewer in-flight evaluations and thus lower peak memory usage. Thus
-   * we store the {@link #evaluationPriority} (coming from the {@link #globalEnqueuedIndex} and use
-   * it for comparisons: later enqueuings should be evaluated earlier, to do a depth-first search,
-   * except for re-enqueued nodes, which always get top priority.
-   *
-   * <p>This is not applicable when using a {@link ForkJoinPool}, since it does not allow for easy
-   * work prioritization.
+   * <p>{@link Comparable} for use in priority queues.
    */
   private final class Evaluate implements ComparableRunnable {
     private final SkyKey skyKey;
-    private final int evaluationPriority;
+    private final long priority;
 
-    private Evaluate(SkyKey skyKey, int evaluationPriority) {
+    private Evaluate(SkyKey skyKey, int partialPriority) {
       this.skyKey = skyKey;
-      this.evaluationPriority = evaluationPriority;
+
+      // LIFO could be more robust here. In the absence of other prioritization, LIFO exploits the
+      // fact that later arriving requests tend to be deeper in the graph. However, LIFO is not a
+      // good access pattern for priority queues as it increases reader / writer contention.
+      // Measurements show FIFO is faster with existing prioritization.
+      this.priority = (((long) partialPriority) << 32) | nextEvaluateId.getAndDecrement();
+    }
+
+    @Override
+    public boolean isCpuHeavy() {
+      return skyKey instanceof CPUHeavySkyKey;
     }
 
     @Override
     public int compareTo(ComparableRunnable other) {
-      // Put other one first, so larger values come first in priority queue.
-      return Integer.compare(((Evaluate) other).evaluationPriority, this.evaluationPriority);
-    }
-
-    private int determineChildPriority() {
-      // If this evaluation is already running at a high priority, its children should be evaluated
-      // at an even higher priority - they are blocking a high priority node.
-      if (evaluationPriority >= FIRST_RESTART_PRIORITY) {
-        return evenHigherPriority();
-      }
-
-      int nextPriority = globalEnqueuedIndex.incrementAndGet();
-      if (nextPriority == FIRST_RESTART_PRIORITY) {
-        BugReport.sendBugReport(
-            new ArithmeticException("Child priority has reached restart priority"));
-      }
-      return nextPriority;
-    }
-
-    private int determineRestartPriority() {
-      // Each time a node is restarted, its priority increases so that it doesn't get lost behind
-      // other restarted nodes.
-      return evaluationPriority >= FIRST_RESTART_PRIORITY
-          ? evenHigherPriority()
-          : FIRST_RESTART_PRIORITY;
-    }
-
-    private int evenHigherPriority() {
-      if (evaluationPriority == Integer.MAX_VALUE) {
-        BugReport.sendBugReport(new ArithmeticException("Priority has reached Integer.MAX_VALUE"));
-        return Integer.MAX_VALUE;
-      }
-      return evaluationPriority + 1;
+      // Compares in reverse order so that keys with high priority are evaluated first.
+      return Long.compare(((Evaluate) other).priority, priority);
     }
 
     /**
      * Notes the rdep from the parent to the child, and then does the appropriate thing with the
      * child or the parent, returning whether the parent has both been signalled and also is ready
      * for evaluation.
+     *
+     * @param childDepth this should match {@code entry.getChildDepth()} but that performs some
+     *     computation and this is often called in a loop with the same {@code entry}.
      */
     @CanIgnoreReturnValue
     private boolean enqueueChild(
@@ -240,7 +205,7 @@
         SkyKey child,
         NodeEntry childEntry,
         boolean depAlreadyExists,
-        int childEvaluationPriority,
+        int childDepth,
         boolean enqueueParentIfReady,
         @Nullable SkyFunctionEnvironment environmentIfEnqueuing)
         throws InterruptedException {
@@ -255,13 +220,12 @@
         // Add some more context regarding crashes.
         throw new IllegalStateException("child key: " + child + " error: " + e.getMessage(), e);
       }
+      childEntry.updateDepthIfGreater(childDepth);
       switch (dependencyState) {
         case DONE:
           if (entry.signalDep(childEntry.getVersion(), child)) {
             if (enqueueParentIfReady) {
-              evaluatorContext
-                  .getVisitor()
-                  .enqueueEvaluation(skyKey, determineRestartPriority(), child);
+              evaluatorContext.getVisitor().enqueueEvaluation(skyKey, entry.getPriority(), child);
             }
             return true;
           } else {
@@ -271,16 +235,14 @@
               // If a dep was observed not-done by its parent when the parent tried to read its
               // value, but that dep is now done, then this is the only chance the parent has to be
               // signalled by that dep.
-              evaluatorContext
-                  .getVisitor()
-                  .enqueueEvaluation(skyKey, determineRestartPriority(), child);
+              evaluatorContext.getVisitor().enqueueEvaluation(skyKey, entry.getPriority(), child);
             }
           }
           break;
         case ALREADY_EVALUATING:
           break;
         case NEEDS_SCHEDULING:
-          evaluatorContext.getVisitor().enqueueEvaluation(child, childEvaluationPriority, null);
+          evaluatorContext.getVisitor().enqueueEvaluation(child, childEntry.getPriority(), null);
           break;
       }
       return false;
@@ -401,7 +363,6 @@
                 unknownStatusDeps,
                 entriesToCheck,
                 nodeEntry,
-                determineChildPriority(),
                 /* enqueueParentIfReady= */ false,
                 /* environmentIfEnqueuing= */ null);
         if (!parentIsSignalledAndReady
@@ -438,7 +399,7 @@
             throw SchedulerException.ofError(nodeEntry.getErrorInfo(), skyKey, rDepsToSignal);
           }
           evaluatorContext.signalParentsAndEnqueueIfReady(
-              skyKey, rDepsToSignal, nodeEntry.getVersion(), determineRestartPriority());
+              skyKey, rDepsToSignal, nodeEntry.getVersion());
           return DirtyOutcome.ALREADY_PROCESSED;
         case NEEDS_REBUILDING:
           nodeEntry.markRebuilding();
@@ -460,11 +421,11 @@
         Collection<SkyKey> knownChildren,
         NodeBatch oldChildren,
         NodeEntry nodeEntry,
-        int childEvaluationPriority,
         boolean enqueueParentIfReady,
         @Nullable SkyFunctionEnvironment environmentIfEnqueuing)
         throws InterruptedException {
       boolean parentIsSignalledAndReady = false;
+      int childDepth = nodeEntry.getChildDepth();
       for (SkyKey directDep : knownChildren) {
         NodeEntry directDepEntry =
             checkNotNull(
@@ -480,7 +441,7 @@
                 directDep,
                 directDepEntry,
                 /* depAlreadyExists= */ true,
-                childEvaluationPriority,
+                childDepth,
                 enqueueParentIfReady,
                 environmentIfEnqueuing);
       }
@@ -549,6 +510,7 @@
                       ProfilerTask.SKYFUNCTION,
                       skyKey.functionName().getName());
             }
+            nodeEntry.incrementEvaluationCount();
           }
         } catch (final SkyFunctionException builderException) {
           // TODO(b/261604460): invalidating the state cache here appears to be load-bearing for
@@ -619,7 +581,7 @@
               throw SchedulerException.ofError(errorInfo, skyKey, rdepsToBubbleUpTo);
             }
             evaluatorContext.signalParentsAndEnqueueIfReady(
-                skyKey, rdepsToBubbleUpTo, nodeEntry.getVersion(), determineRestartPriority());
+                skyKey, rdepsToBubbleUpTo, nodeEntry.getVersion());
             return;
           }
         } catch (RuntimeException re) {
@@ -642,7 +604,7 @@
           dirtyRewindGraphAndResetEntry(skyKey, nodeEntry, (Restart) value);
           stateCache.invalidate(skyKey);
           cancelExternalDeps(env);
-          evaluatorContext.getVisitor().enqueueEvaluation(skyKey, determineRestartPriority(), null);
+          evaluatorContext.getVisitor().enqueueEvaluation(skyKey, nodeEntry.getPriority(), null);
           return;
         }
 
@@ -681,7 +643,7 @@
             env.setValue(value);
             Set<SkyKey> reverseDeps = env.commitAndGetParents(nodeEntry);
             evaluatorContext.signalParentsAndEnqueueIfReady(
-                skyKey, reverseDeps, nodeEntry.getVersion(), determineRestartPriority());
+                skyKey, reverseDeps, nodeEntry.getVersion());
           } finally {
             evaluatorContext.getProgressReceiver().stateEnding(skyKey, NodeState.COMMIT);
           }
@@ -782,7 +744,7 @@
           // invariants either.
           Set<SkyKey> reverseDeps = env.commitAndGetParents(nodeEntry);
           evaluatorContext.signalParentsAndEnqueueIfReady(
-              skyKey, reverseDeps, nodeEntry.getVersion(), determineRestartPriority());
+              skyKey, reverseDeps, nodeEntry.getVersion());
           return;
         }
 
@@ -809,7 +771,6 @@
           newDepsThatWereInTheLastEvaluation = Sets.intersection(newDeps, oldDeps);
         }
 
-        int childEvaluationPriority = determineChildPriority();
         InterruptibleSupplier<NodeBatch> newDepsThatWerentInTheLastEvaluationNodes =
             graph.createIfAbsentBatchAsync(
                 skyKey, Reason.RDEP_ADDITION, newDepsThatWerentInTheLastEvaluation);
@@ -817,7 +778,6 @@
             newDepsThatWereInTheLastEvaluation,
             graph.getBatch(skyKey, Reason.ENQUEUING_CHILD, newDepsThatWereInTheLastEvaluation),
             nodeEntry,
-            childEvaluationPriority,
             /* enqueueParentIfReady= */ true,
             env);
 
@@ -825,6 +785,7 @@
         // all 'new' children of this node are already done. Therefore, there should not be any code
         // after this loop, as it would potentially race with the re-evaluation in another thread.
         NodeBatch newNodes = newDepsThatWerentInTheLastEvaluationNodes.get();
+        int childDepth = nodeEntry.getChildDepth();
         for (SkyKey newDirectDep : newDepsThatWerentInTheLastEvaluation) {
           enqueueChild(
               skyKey,
@@ -832,7 +793,7 @@
               newDirectDep,
               newNodes.get(newDirectDep),
               /* depAlreadyExists= */ false,
-              childEvaluationPriority,
+              childDepth,
               /* enqueueParentIfReady= */ true,
               env);
         }
@@ -843,7 +804,7 @@
           // re-enqueueing of the current node in the above loop if externalDeps != null.
           evaluatorContext
               .getVisitor()
-              .registerExternalDeps(skyKey, nodeEntry, externalDeps, determineRestartPriority());
+              .registerExternalDeps(skyKey, nodeEntry, externalDeps, nodeEntry.getPriority());
         }
         // Do not put any code here! Any code here can race with a re-evaluation of this same node
         // in another thread.
@@ -865,7 +826,7 @@
       // If a previously requested dep is no longer done, restart this node from scratch.
       stateCache.invalidate(skyKey);
       resetEntry(skyKey, nodeEntry);
-      evaluatorContext.getVisitor().enqueueEvaluation(skyKey, determineRestartPriority(), null);
+      evaluatorContext.getVisitor().enqueueEvaluation(skyKey, nodeEntry.getPriority(), null);
     }
 
     private void cancelExternalDeps(SkyFunctionEnvironment env) {
@@ -1198,7 +1159,8 @@
         .noteInconsistencyAndMaybeThrow(
             skyKey, ImmutableList.of(depKey), Inconsistency.BUILDING_PARENT_FOUND_UNDONE_CHILD);
     if (triState == DependencyState.NEEDS_SCHEDULING) {
-      evaluatorContext.getVisitor().enqueueEvaluation(depKey, FIRST_RESTART_PRIORITY, null);
+      depEntry.updateDepthIfGreater(entry.getChildDepth());
+      evaluatorContext.getVisitor().enqueueEvaluation(depKey, depEntry.getPriority(), null);
     }
     return MaybeHandleUndoneDepResult.DEP_NOT_DONE;
   }
diff --git a/src/main/java/com/google/devtools/build/skyframe/DelegatingNodeEntry.java b/src/main/java/com/google/devtools/build/skyframe/DelegatingNodeEntry.java
index dd977a4..b2741e6 100644
--- a/src/main/java/com/google/devtools/build/skyframe/DelegatingNodeEntry.java
+++ b/src/main/java/com/google/devtools/build/skyframe/DelegatingNodeEntry.java
@@ -224,4 +224,24 @@
   public void addExternalDep() {
     getDelegate().addExternalDep();
   }
+
+  @Override
+  public int getPriority() {
+    return getDelegate().getPriority();
+  }
+
+  @Override
+  public int depth() {
+    return getDelegate().depth();
+  }
+
+  @Override
+  public void updateDepthIfGreater(int proposedDepth) {
+    getDelegate().updateDepthIfGreater(proposedDepth);
+  }
+
+  @Override
+  public void incrementEvaluationCount() {
+    getDelegate().incrementEvaluationCount();
+  }
 }
diff --git a/src/main/java/com/google/devtools/build/skyframe/DirtyBuildingState.java b/src/main/java/com/google/devtools/build/skyframe/DirtyBuildingState.java
index cb0ddcf..ee53e2f 100644
--- a/src/main/java/com/google/devtools/build/skyframe/DirtyBuildingState.java
+++ b/src/main/java/com/google/devtools/build/skyframe/DirtyBuildingState.java
@@ -14,14 +14,18 @@
 package com.google.devtools.build.skyframe;
 
 import static com.google.common.base.Preconditions.checkState;
+import static java.lang.Math.min;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.devtools.build.lib.unsafe.UnsafeProvider;
 import com.google.devtools.build.lib.util.GroupedList;
 import com.google.devtools.build.skyframe.NodeEntry.DirtyState;
 import com.google.devtools.build.skyframe.NodeEntry.DirtyType;
 import javax.annotation.Nullable;
+import sun.misc.Unsafe;
 
 /**
  * State for a node that has been dirtied, and will be checked to see if it needs re-evaluation, and
@@ -30,16 +34,19 @@
  * <p>This class is public only for the benefit of alternative graph implementations outside of the
  * package.
  */
-public abstract class DirtyBuildingState {
+public abstract class DirtyBuildingState implements PriorityTracker {
   private static final int NOT_EVALUATING_SENTINEL = -1;
 
   static DirtyBuildingState create(
-      DirtyType dirtyType, GroupedList<SkyKey> lastBuildDirectDeps, SkyValue lastBuildValue) {
-    return new FullDirtyBuildingState(dirtyType, lastBuildDirectDeps, lastBuildValue);
+      DirtyType dirtyType,
+      GroupedList<SkyKey> lastBuildDirectDeps,
+      SkyValue lastBuildValue,
+      boolean hasLowFanout) {
+    return new FullDirtyBuildingState(dirtyType, lastBuildDirectDeps, lastBuildValue, hasLowFanout);
   }
 
-  static DirtyBuildingState createNew() {
-    return new FullDirtyBuildingState(DirtyType.CHANGE, null, null);
+  static DirtyBuildingState createNew(boolean hasLowFanout) {
+    return new FullDirtyBuildingState(DirtyType.CHANGE, null, null, hasLowFanout);
   }
 
   /**
@@ -88,6 +95,22 @@
   private int externalDeps;
 
   /**
+   * Priority information packed into 32-bits.
+   *
+   * <p>Packing is used because Java lacks support for native short operations and masking
+   * operations are needed to fit 1-bit of information about whether the {@link SkyKey} has low
+   * fanout. This field has the following layout.
+   *
+   * <ol>
+   *   <li><i>Has Low Fanout</i> (1-bit) - 1 if {@link SkyKey#hasLowFanout} is true for the
+   *       underlying key.
+   *   <li><i>Depth</i> (15-bits) - the current estimated depth.
+   *   <li><i>Restart Count</i> (16-bits, unsigned) - incremented when this node restarts.
+   * </ol>
+   */
+  private volatile int priority = HAS_LOW_FANOUT_MASK;
+
+  /**
    * The dependencies requested (with group markers) last time the node was built (and below, the
    * value last time the node was built). They will be compared to dependencies requested on this
    * build to check whether this node has changed in {@link NodeEntry#setValue}. If they are null,
@@ -123,11 +146,20 @@
    */
   protected int dirtyDirectDepIndex;
 
-  protected DirtyBuildingState(DirtyType dirtyType) {
+  /**
+   * Constructor.
+   *
+   * @param hasLowFanout indicates that this node is not expected to have high dependency fanout.
+   *     Satisfied by by {@link SkyKey#hasLowFanout}.
+   */
+  protected DirtyBuildingState(DirtyType dirtyType, boolean hasLowFanout) {
     dirtyState = dirtyType.getInitialDirtyState();
     // We need to iterate through the deps to see if they have changed, or to remove them if one
     // has. Initialize the iterating index.
     dirtyDirectDepIndex = 0;
+    if (!hasLowFanout) {
+      this.priority = 0;
+    }
   }
 
   /** Returns true if this state does have information about a previously built version. */
@@ -296,6 +328,13 @@
     signaledDeps = 0;
     externalDeps = 0;
     dirtyDirectDepIndex = 0;
+
+    // Resets the evaluation count.
+    int snapshot;
+    do {
+      snapshot = priority;
+    } while (!UNSAFE.compareAndSwapInt(
+        this, PRIORITY_OFFSET, snapshot, snapshot & ~EVALUATION_COUNT_MASK));
   }
 
   protected void markRebuilding() {
@@ -326,12 +365,71 @@
     return signaledDeps == numDirectDeps + externalDeps;
   }
 
+  /**
+   * A bound on depth to avoid overflow.
+   *
+   * <p>The maximum observed depth in our benchmark is ~150.
+   */
+  @VisibleForTesting static final int DEPTH_SATURATION_BOUND = 512;
+
+  /**
+   * A bound on evaluation count to avoid overflow.
+   *
+   * <p>The maximum observed evaluation count in our benchmark is 4.
+   */
+  @VisibleForTesting static final int EVALUATION_COUNT_SATURATION_BOUND = 32;
+
+  @Override
+  public int getPriority() {
+    int snapshot = priority;
+
+    // Fanout occupies the top-most bit. Shifts it over one bit so later computations don't need to
+    // consider negative priority values. Since this is the highest set bit, low-fanout nodes
+    // always have higher priority than high-fanout nodes.
+    int fanoutAdjustment = (snapshot & HAS_LOW_FANOUT_MASK) >>> 1;
+    int depth = min((snapshot & DEPTH_MASK) >> DEPTH_BIT_OFFSET, DEPTH_SATURATION_BOUND);
+    int evaluationCount = min(snapshot & EVALUATION_COUNT_MASK, EVALUATION_COUNT_SATURATION_BOUND);
+
+    // This formula was found to produce good results in our benchmark. There's no deep rationale
+    // behind it. It's likely possible to improve it, but iterating is slow.
+    return fanoutAdjustment + depth + evaluationCount * evaluationCount;
+  }
+
+  @Override
+  public int depth() {
+    return (priority & DEPTH_MASK) >> DEPTH_BIT_OFFSET;
+  }
+
+  @Override
+  public void updateDepthIfGreater(int proposedDepth) {
+    // Shifts the input for comparison instead of the snapshot, which might otherwise need to be
+    // shifted repeatedly.
+    proposedDepth = (proposedDepth << DEPTH_BIT_OFFSET) & DEPTH_MASK;
+    int snapshot;
+    do {
+      snapshot = priority;
+      if (proposedDepth <= (snapshot & DEPTH_MASK)) {
+        return;
+      }
+    } while (!UNSAFE.compareAndSwapInt(
+        this, PRIORITY_OFFSET, snapshot, (snapshot & ~DEPTH_MASK) | proposedDepth));
+  }
+
+  @Override
+  public void incrementEvaluationCount() {
+    UNSAFE.getAndAddInt(this, PRIORITY_OFFSET, ONE_EVALUATION);
+  }
+
   protected MoreObjects.ToStringHelper getStringHelper() {
+    int snapshot = priority;
     return MoreObjects.toStringHelper(this)
         .add("dirtyState", dirtyState)
         .add("signaledDeps", signaledDeps)
         .add("externalDeps", externalDeps)
-        .add("dirtyDirectDepIndex", dirtyDirectDepIndex);
+        .add("dirtyDirectDepIndex", dirtyDirectDepIndex)
+        .add("has low fanout", (snapshot & HAS_LOW_FANOUT_MASK) != 0)
+        .add("depth", (snapshot & DEPTH_MASK) >> DEPTH_BIT_OFFSET)
+        .add("evaluation count", (snapshot & EVALUATION_COUNT_MASK));
   }
 
   @Override
@@ -344,8 +442,11 @@
     private final SkyValue lastBuildValue;
 
     private FullDirtyBuildingState(
-        DirtyType dirtyType, GroupedList<SkyKey> lastBuildDirectDeps, SkyValue lastBuildValue) {
-      super(dirtyType);
+        DirtyType dirtyType,
+        GroupedList<SkyKey> lastBuildDirectDeps,
+        SkyValue lastBuildValue,
+        boolean hasLowFanout) {
+      super(dirtyType, hasLowFanout);
       this.lastBuildDirectDeps = lastBuildDirectDeps;
       checkState(
           !dirtyType.equals(DirtyType.DIRTY) || getNumOfGroupsInLastBuildDirectDeps() > 0,
@@ -386,4 +487,33 @@
           .add("lastBuildValue", lastBuildValue);
     }
   }
+
+  // Masks for `priority`.
+  private static final int HAS_LOW_FANOUT_MASK = 0x8000_0000;
+
+  private static final int DEPTH_MASK = 0x7FFF_0000;
+  private static final int DEPTH_BIT_OFFSET = 16;
+
+  private static final int EVALUATION_COUNT_MASK = 0xFFFF;
+  private static final int ONE_EVALUATION = 1;
+
+  static {
+    checkState(Integer.numberOfTrailingZeros(DEPTH_MASK) == DEPTH_BIT_OFFSET);
+    checkState(
+        Integer.numberOfTrailingZeros(EVALUATION_COUNT_MASK)
+            == Integer.numberOfTrailingZeros(ONE_EVALUATION));
+  }
+
+  private static final Unsafe UNSAFE = UnsafeProvider.unsafe();
+
+  private static final long PRIORITY_OFFSET;
+
+  static {
+    try {
+      PRIORITY_OFFSET =
+          UNSAFE.objectFieldOffset(DirtyBuildingState.class.getDeclaredField("priority"));
+    } catch (ReflectiveOperationException e) {
+      throw new ExceptionInInitializerError(e);
+    }
+  }
 }
diff --git a/src/main/java/com/google/devtools/build/skyframe/InMemoryNodeEntry.java b/src/main/java/com/google/devtools/build/skyframe/InMemoryNodeEntry.java
index 22f1486..b06cb64 100644
--- a/src/main/java/com/google/devtools/build/skyframe/InMemoryNodeEntry.java
+++ b/src/main/java/com/google/devtools/build/skyframe/InMemoryNodeEntry.java
@@ -307,7 +307,7 @@
     synchronized (this) {
       boolean done = isDone();
       if (!done && dirtyBuildingState == null) {
-        dirtyBuildingState = DirtyBuildingState.createNew();
+        dirtyBuildingState = DirtyBuildingState.createNew(key.hasLowFanout());
       }
       if (reverseDep != null) {
         if (done) {
@@ -439,7 +439,7 @@
   @ForOverride
   protected DirtyBuildingState createDirtyBuildingStateForDoneNode(
       DirtyType dirtyType, GroupedList<SkyKey> directDeps, SkyValue value) {
-    return DirtyBuildingState.create(dirtyType, directDeps, value);
+    return DirtyBuildingState.create(dirtyType, directDeps, value, key.hasLowFanout());
   }
 
   private static final GroupedList<SkyKey> EMPTY_LIST = new GroupedList<>();
@@ -659,6 +659,42 @@
     checkGroupSizes(!it.hasNext(), deps, groupSizes);
   }
 
+  @Override
+  public int getPriority() {
+    var snapshot = dirtyBuildingState;
+    if (snapshot == null) {
+      return Integer.MAX_VALUE;
+    }
+    return snapshot.getPriority();
+  }
+
+  @Override
+  public int depth() {
+    var snapshot = dirtyBuildingState;
+    if (snapshot == null) {
+      return 0;
+    }
+    return snapshot.depth();
+  }
+
+  @Override
+  public void updateDepthIfGreater(int proposedDepth) {
+    var snapshot = dirtyBuildingState;
+    if (snapshot == null) {
+      return;
+    }
+    snapshot.updateDepthIfGreater(proposedDepth);
+  }
+
+  @Override
+  public void incrementEvaluationCount() {
+    var snapshot = dirtyBuildingState;
+    if (snapshot == null) {
+      return;
+    }
+    snapshot.incrementEvaluationCount();
+  }
+
   private static void checkGroupSizes(
       boolean condition, Set<SkyKey> deps, List<Integer> groupSizes) {
     checkArgument(
diff --git a/src/main/java/com/google/devtools/build/skyframe/NodeEntry.java b/src/main/java/com/google/devtools/build/skyframe/NodeEntry.java
index b785e53..b9bfd66 100644
--- a/src/main/java/com/google/devtools/build/skyframe/NodeEntry.java
+++ b/src/main/java/com/google/devtools/build/skyframe/NodeEntry.java
@@ -31,7 +31,7 @@
  * <p>Certain graph implementations' node entries can throw {@link InterruptedException} on various
  * accesses. Such exceptions should not be caught locally -- they should be allowed to propagate up.
  */
-public interface NodeEntry {
+public interface NodeEntry extends PriorityTracker {
 
   /**
    * Return code for {@link #addReverseDepAndCheckIfDone} and {@link
diff --git a/src/main/java/com/google/devtools/build/skyframe/ParallelEvaluator.java b/src/main/java/com/google/devtools/build/skyframe/ParallelEvaluator.java
index 5818143..a83ccc2 100644
--- a/src/main/java/com/google/devtools/build/skyframe/ParallelEvaluator.java
+++ b/src/main/java/com/google/devtools/build/skyframe/ParallelEvaluator.java
@@ -143,9 +143,7 @@
         // in order to be thread-safe.
         switch (entry.addReverseDepAndCheckIfDone(null)) {
           case NEEDS_SCHEDULING:
-            // Low priority because this node is not needed by any other currently evaluating node.
-            // So keep it at the back of the queue as long as there's other useful work to be done.
-            evaluatorContext.getVisitor().enqueueEvaluation(skyKey, Integer.MIN_VALUE, null);
+            evaluatorContext.getVisitor().enqueueEvaluation(skyKey, entry.getPriority(), null);
             break;
           case DONE:
             informProgressReceiverThatValueIsDone(skyKey, entry);
diff --git a/src/main/java/com/google/devtools/build/skyframe/ParallelEvaluatorContext.java b/src/main/java/com/google/devtools/build/skyframe/ParallelEvaluatorContext.java
index 8893897..5957586 100644
--- a/src/main/java/com/google/devtools/build/skyframe/ParallelEvaluatorContext.java
+++ b/src/main/java/com/google/devtools/build/skyframe/ParallelEvaluatorContext.java
@@ -59,13 +59,13 @@
   private final Supplier<NodeEntryVisitor> visitorSupplier;
 
   /**
-   * Returns a {@link Runnable} given a {@code key} to evaluate and an {@code evaluationPriority}
-   * indicating whether it should be scheduled for evaluation soon (higher is better). The returned
-   * {@link Runnable} is a {@link ComparableRunnable} so that it can be ordered by {@code
-   * evaluationPriority} in a priority queue if needed.
+   * Returns a {@link Runnable} given a {@code key} to evaluate.
+   *
+   * <p>The returned {@link Runnable} is a {@link ComparableRunnable} so that it can be ordered by a
+   * priority queue.
    */
   interface RunnableMaker {
-    ComparableRunnable make(SkyKey key, int evaluationPriority);
+    ComparableRunnable make(SkyKey key, int priority);
   }
 
   public ParallelEvaluatorContext(
@@ -117,19 +117,15 @@
     }
   }
 
-  /**
-   * Signals all parents that this node is finished and enqueues any parents that are ready at the
-   * given evaluation priority.
-   */
-  void signalParentsAndEnqueueIfReady(
-      SkyKey skyKey, Set<SkyKey> parents, Version version, int evaluationPriority)
+  /** Signals all parents that this node is finished and enqueues any parents that are ready. */
+  void signalParentsAndEnqueueIfReady(SkyKey skyKey, Set<SkyKey> parents, Version version)
       throws InterruptedException {
     NodeBatch batch = graph.getBatch(skyKey, Reason.SIGNAL_DEP, parents);
     for (SkyKey parent : parents) {
       NodeEntry entry = checkNotNull(batch.get(parent), parent);
       boolean evaluationRequired = entry.signalDep(version, skyKey);
       if (evaluationRequired || parent.supportsPartialReevaluation()) {
-        getVisitor().enqueueEvaluation(parent, evaluationPriority, skyKey);
+        getVisitor().enqueueEvaluation(parent, entry.getPriority(), skyKey);
       }
     }
   }
diff --git a/src/main/java/com/google/devtools/build/skyframe/PriorityTracker.java b/src/main/java/com/google/devtools/build/skyframe/PriorityTracker.java
new file mode 100644
index 0000000..1d93283
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/skyframe/PriorityTracker.java
@@ -0,0 +1,73 @@
+// 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;
+
+/**
+ * Tracks the priority of node evaluations.
+ *
+ * <p>Prioritization has two goals: decrease the total walltime and minimize the number of inflight
+ * nodes. The build graph has uneven depth. When the build is less CPU-bound due to available
+ * parallelism, long sequential dependencies should be prioritized first so that they do not become
+ * long poles in walltime.
+ *
+ * <p>Minimizing inflight nodes is desirable because they contribute to memory pressure. A high
+ * inflight node count increases work done by the garbage collector and triggers cache clearing.
+ * Assigning higher priority to nodes with a higher likelihood of completing, without further
+ * fanout, helps to avoid these causes of poor performance.
+ *
+ * <p>Prioritization uses two runtime signals.
+ *
+ * <ul>
+ *   <li><i>depth</i>: the deeper a node, the more likely it is to be one that could be on a long
+ *       critical path of computations and the closer to leaf-level and the less likely it is to
+ *       cause further fanout.
+ *   <li><i>evaluationCount</i>: {@link SkyFunction}s are written to minimize restarts and they are
+ *       usually bounded for any node. The higher the evaluation count, the more likely the next
+ *       restart completes the node, reducing memory pressure.
+ * </ul>
+ *
+ * <p>Prioritization also uses statically determined information. Certain types of nodes have high
+ * fanout by design. These may be annotated using the {@link SkyKey#hasLowFanout} method.
+ */
+interface PriorityTracker {
+  /** The priority with higher meaning more urgent. */
+  int getPriority();
+
+  /**
+   * Current estimated depth.
+   *
+   * <p>Depth is initialized by adding one to parent depth. For heavy computations to prioritize
+   * correctly, their reverse dependencies under transitive closure (excluding the root) should also
+   * track depth.
+   *
+   * <p>8-bits is likely enough for this. It's an {@code int} because Java doesn't support
+   * arithmetic operations on narrower types.
+   */
+  int depth();
+
+  /**
+   * Attempts to update the depth.
+   *
+   * <p>During evaluation, parent nodes initialize priority as one more than their own priority.
+   * Since a node may have multiple parents, depth may increase during evaluation.
+   */
+  void updateDepthIfGreater(int proposedDepth);
+
+  /** Adds one to the evaluation count component of priority. */
+  void incrementEvaluationCount();
+
+  default int getChildDepth() {
+    return depth() + 1;
+  }
+}
diff --git a/src/main/java/com/google/devtools/build/skyframe/SkyKey.java b/src/main/java/com/google/devtools/build/skyframe/SkyKey.java
index 6d4eaf6..f22db75 100644
--- a/src/main/java/com/google/devtools/build/skyframe/SkyKey.java
+++ b/src/main/java/com/google/devtools/build/skyframe/SkyKey.java
@@ -166,4 +166,13 @@
       internerAsMap.remove(sample);
     }
   }
+
+  /**
+   * A hint to schedulers that evaluating this key shouldn't cause high fanout.
+   *
+   * <p>Keys with high fan-out create memory pressure and are assigned low priority.
+   */
+  default boolean hasLowFanout() {
+    return false;
+  }
 }
diff --git a/src/test/java/com/google/devtools/build/lib/actions/util/InjectedActionLookupKey.java b/src/test/java/com/google/devtools/build/lib/actions/util/InjectedActionLookupKey.java
index c0ffa28..68b2e9f 100644
--- a/src/test/java/com/google/devtools/build/lib/actions/util/InjectedActionLookupKey.java
+++ b/src/test/java/com/google/devtools/build/lib/actions/util/InjectedActionLookupKey.java
@@ -24,7 +24,7 @@
  * An {@link ActionLookupKey} with a non-hermetic {@link SkyFunctionName} so that its value can be
  * directly injected during tests.
  */
-public final class InjectedActionLookupKey implements ActionLookupKey {
+public final class InjectedActionLookupKey extends ActionLookupKey {
   public static final SkyFunctionName INJECTED_ACTION_LOOKUP =
       SkyFunctionName.createNonHermetic("INJECTED_ACTION_LOOKUP");
 
diff --git a/src/test/java/com/google/devtools/build/lib/analysis/RunfilesTest.java b/src/test/java/com/google/devtools/build/lib/analysis/RunfilesTest.java
index 9bbb4bf..9486060 100644
--- a/src/test/java/com/google/devtools/build/lib/analysis/RunfilesTest.java
+++ b/src/test/java/com/google/devtools/build/lib/analysis/RunfilesTest.java
@@ -155,7 +155,7 @@
     assertThat(Iterables.getOnlyElement(eventCollector).getKind()).isEqualTo(EventKind.ERROR);
   }
 
-  private static final class SimpleActionLookupKey implements ActionLookupKey {
+  private static final class SimpleActionLookupKey extends ActionLookupKey {
     private final String name;
 
     SimpleActionLookupKey(String name) {
diff --git a/src/test/java/com/google/devtools/build/skyframe/NotifyingHelper.java b/src/test/java/com/google/devtools/build/skyframe/NotifyingHelper.java
index 36c95ae..d31e3f8 100644
--- a/src/test/java/com/google/devtools/build/skyframe/NotifyingHelper.java
+++ b/src/test/java/com/google/devtools/build/skyframe/NotifyingHelper.java
@@ -80,13 +80,16 @@
     @Override
     public NodeEntry get(@Nullable SkyKey requestor, Reason reason, SkyKey key)
         throws InterruptedException {
+      var node = delegate.get(requestor, reason, key);
       // Maintains behavior for tests written when all DEP_REQUESTED calls were made as batch
       // requests. Now there are optimizations in SkyFunctionEnvironment for looking up deps
       // individually, but older tests may be written to listen for a GET_BATCH event.
       if (reason == Reason.DEP_REQUESTED) {
         notifyingHelper.graphListener.accept(key, EventType.GET_BATCH, Order.BEFORE, reason);
+      } else if (reason == Reason.EVALUATION) {
+        notifyingHelper.graphListener.accept(key, EventType.EVALUATE, Order.BEFORE, node);
       }
-      return notifyingHelper.wrapEntry(key, delegate.get(requestor, reason, key));
+      return notifyingHelper.wrapEntry(key, node);
     }
 
     @Override
@@ -134,6 +137,7 @@
    */
   public enum EventType {
     CREATE_IF_ABSENT,
+    EVALUATE,
     ADD_REVERSE_DEP,
     ADD_EXTERNAL_DEP,
     REMOVE_REVERSE_DEP,
diff --git a/src/test/java/com/google/devtools/build/skyframe/PriorityTest.java b/src/test/java/com/google/devtools/build/skyframe/PriorityTest.java
new file mode 100644
index 0000000..1b5f19f
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/skyframe/PriorityTest.java
@@ -0,0 +1,428 @@
+// 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.collect.nestedset.NestedSetVisitor;
+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 NestedSetVisitor.VisitedState(),
+            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();
+    }
+  }
+}