blob: 2c45f68eaf42cc8cd70320afcf2267958e7257ba [file]
// Copyright 2026 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.lib.buildtool;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableMap;
import com.google.devtools.build.lib.actions.ActionLookupKey;
import com.google.devtools.build.lib.analysis.RuleConfiguredObjectValue;
import com.google.devtools.build.lib.concurrent.ExecutorUtil;
import com.google.devtools.build.lib.concurrent.NamedForkJoinPool;
import com.google.devtools.build.lib.profiler.Profiler;
import com.google.devtools.build.lib.profiler.SilentCloseable;
import com.google.devtools.build.lib.skyframe.ActionLookupValuesTraversal;
import com.google.devtools.build.lib.skyframe.AspectKeyCreator.AspectBaseKey;
import com.google.devtools.build.lib.skyframe.ConfiguredTargetKey;
import com.google.devtools.build.skyframe.InMemoryGraph;
import com.google.devtools.build.skyframe.InMemoryNodeEntry;
import com.google.devtools.build.skyframe.SkyKey;
import com.google.devtools.build.skyframe.SkyValue;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
import java.util.Arrays;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CountedCompleter;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.IntStream;
import javax.annotation.Nullable;
/**
* Counts the number of analysis nodes ({@link RuleConfiguredObjectValue}s) and actions in the
* transitive closure of each root target.
*
* <p>Key insight: summing counts bottom-up would double-count shared diamond deps, while a separate
* traversal per root would revisit shared subgraphs multiple times. Instead, this algorithm flips
* the question around (from "which nodes does root r reach" to "which roots reach node n"?). This
* is propagated top-down via a bitmask (64 roots per word), making the operation cheap bitwise ORs.
*
* <p>The algorithm has two passes, both of which are fork/join traversals with one small task per
* node:
*
* <ol>
* <li><b>Discovery</b> (runs once) walks the Skyframe graph from {@code roots} and builds a
* compact DAG of the relevant {@link ActionLookupKey}s indexed by dense IDs, with each node's
* action count, integer-indexed dependencies and in-degree.
* <li><b>Propagation</b> (runs in batches of roots) computes, for every node, the set of roots it
* is reachable from as a bitmask. A node is processed once all of its parents have OR-ed
* their masks into it (Kahn's algorithm), which yields a topological order on the fly with no
* separate sorting pass. Processing a node adds its counts to every root in its mask.
* </ol>
*/
final class TransitiveTargetCounter {
/** Transitive analysis node and action counts for a single root target. */
record Counts(long targetCount, long actionCount) {}
/** Atomic element access on {@link Propagation#state} ({@code long[]}). */
private static final VarHandle LONG_ARRAY = MethodHandles.arrayElementVarHandle(long[].class);
/**
* Atomic operations on {@link Node#indegree} (a plain {@code int} field), avoiding a separate
* {@link AtomicInteger} allocation per {@link Node}.
*/
private static final VarHandle INDEGREE;
static {
try {
INDEGREE = MethodHandles.lookup().findVarHandle(Node.class, "indegree", int.class);
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}
/** Upper bound on the size of the per-pass bitmask array, to bound memory on huge inputs. */
private static final long MAX_MASK_BYTES = 1L << 27;
/** Parallelism threshold for {@link ConcurrentHashMap#forEachValue}. */
private static final long FOR_EACH_PARALLELISM_THRESHOLD = 1024;
private static final int[] NO_DEPS = new int[0];
private TransitiveTargetCounter() {}
/** A node of the compact subgraph. */
private static final class Node {
final int id;
/** Number of actions, or -1 if this node is not a {@link RuleConfiguredObjectValue}. */
int actions = -1;
/** IDs of the relevant direct dependencies. */
int[] deps = NO_DEPS;
/** Number of incoming edges from other nodes; updated via {@link #INDEGREE}. */
int indegree;
Node(int id) {
this.id = id;
}
}
/**
* Returns {@code obj} as an {@link ActionLookupKey} if it can be or transitively reach a {@link
* RuleConfiguredObjectValue}, or {@code null} otherwise.
*
* <p>Non-configurable {@link ConfiguredTargetKey}s ({@code configurationKey == null}, i.e. {@code
* InputFile}, {@code PackageGroup}, {@code EnvironmentGroup}) always produce {@code
* NonRuleConfiguredTargetValue} and only depend on visibility {@code PackageGroup}s, so they can
* never be or reach a {@link RuleConfiguredObjectValue}.
*/
@Nullable
private static ActionLookupKey asRelevantKey(@Nullable Object obj) {
if (obj instanceof ConfiguredTargetKey ctk) {
return ctk.getConfigurationKey() != null ? ctk : null;
}
if (obj instanceof AspectBaseKey aspectKey) {
return aspectKey;
}
return null;
}
/** Shared state and traversal logic of the discovery pass. */
private static final class Discovery {
private final InMemoryGraph graph;
final ConcurrentHashMap<ActionLookupKey, Node> nodes;
final AtomicInteger nextId = new AtomicInteger();
Discovery(InMemoryGraph graph) {
this.graph = graph;
this.nodes = new ConcurrentHashMap<>(Math.max(1024, graph.valuesSize() / 2));
}
/**
* Returns the node of {@code key}, creating it and forking a {@link VisitTask} under {@code
* parent} if this is the first time the key is seen.
*/
Node nodeFor(ActionLookupKey key, CountedCompleter<?> parent) {
return nodes.computeIfAbsent(
key,
k -> {
Node fresh = new Node(nextId.getAndIncrement());
parent.addToPendingCount(1);
new VisitTask(parent, this, k, fresh).fork();
return fresh;
});
}
/** Visits one node: records its actions and dependencies and forks tasks for new deps. */
void visit(CountedCompleter<?> parent, ActionLookupKey key, Node node) {
InMemoryNodeEntry entry = graph.getIfPresent(key);
if (entry != null && entry.isDone()) {
SkyValue value = entry.getValue();
if (value instanceof RuleConfiguredObjectValue ruleConfiguredObject
&& !ActionLookupValuesTraversal.isDelegatedConfiguredTargetValue(key, value)) {
node.actions = ruleConfiguredObject.getActions().size();
}
// `value` is null when a done node completed with an error under --keep_going
// (entry.getValue() unwraps ErrorInfoValue to null), including cycle nodes whose retained
// cycle edges would violate the DAG invariant of Kahn's algorithm. `keepsEdges()` is false
// under --notrack_incremental_state (NonIncrementalInMemoryNodeEntry), where direct deps
// are discarded on completion and getCompressedDirectDepsForDoneEntry() throws.
if (value != null && entry.keepsEdges()) {
node.deps = visitCompressedDeps(parent, entry.getCompressedDirectDepsForDoneEntry());
}
}
}
/**
* Visits the deps in a compressed {@code GroupedDeps}: either an {@code Object[]} of {@link
* SkyKey}s and {@code Integer} group markers, a single bare {@link SkyKey}, or an opaque empty
* marker.
*/
private int[] visitCompressedDeps(CountedCompleter<?> parent, Object compressed) {
if (!(compressed instanceof Object[] depArray)) {
ActionLookupKey depKey = asRelevantKey(compressed);
if (depKey == null) {
return NO_DEPS;
}
Node dep = nodeFor(depKey, parent);
INDEGREE.getAndAdd(dep, 1);
return new int[] {dep.id};
}
// Look all dependencies up and read their IDs first, and only then bump their in-degrees:
// reading dep.id faults the Node's cache line in alongside the hash-table lookup while the
// out-of-order engine can overlap misses across iterations, whereas the atomic increments in
// the second loop are full fences that would otherwise serialize them.
int maxDeps = depArray.length;
Node[] depNodes = new Node[maxDeps];
int[] deps = new int[maxDeps];
int n = 0;
for (Object elem : depArray) {
ActionLookupKey depKey = asRelevantKey(elem);
if (depKey == null) {
continue;
}
Node dep = nodeFor(depKey, parent);
depNodes[n] = dep;
deps[n] = dep.id;
n++;
}
for (int i = 0; i < n; i++) {
INDEGREE.getAndAdd(depNodes[i], 1);
}
return n == 0 ? NO_DEPS : (n == maxDeps ? deps : Arrays.copyOf(deps, n));
}
}
/** Fork/join task that visits one node during {@link Discovery}. */
private static final class VisitTask extends CountedCompleter<Void> {
private final Discovery discovery;
private final ActionLookupKey key;
private final Node node;
VisitTask(CountedCompleter<?> parent, Discovery discovery, ActionLookupKey key, Node node) {
super(parent);
this.discovery = discovery;
this.key = key;
this.node = node;
}
@Override
public void compute() {
discovery.visit(this, key, node);
tryComplete();
}
}
/**
* Shared state and traversal logic of one propagation pass over the root range {@code
* [iFirstRoot, iLastRoot)}.
*/
private static final class Propagation {
private final Node[] byId;
private final int iFirstRoot;
private final int iLastRoot;
private final int words;
/**
* Number of {@code long} slots per node in {@link #state}: {@link #words} bitmask words plus 1
* slot for the remaining in-degree.
*/
private final int stride;
/** Per node: {@code words} mask words followed by the remaining in-degree. */
private final long[] state;
/**
* Thread-local {@code [targets..., actions...]} accumulators, one pair of slots per root of
* this pass (index {@code i - iFirstRoot}).
*/
private final ThreadLocal<long[]> accumulator;
final ConcurrentLinkedQueue<long[]> accumulators = new ConcurrentLinkedQueue<>();
Propagation(Node[] byId, int iFirstRoot, int iLastRoot) {
this.byId = byId;
this.iFirstRoot = iFirstRoot;
this.iLastRoot = iLastRoot;
this.words = (iLastRoot - iFirstRoot + 63) / 64;
// + 1 for the remaining in-degree stored after each node's bitmask words.
this.stride = words + 1;
this.state = new long[byId.length * stride];
this.accumulator =
ThreadLocal.withInitial(
() -> {
long[] acc = new long[2 * (iLastRoot - iFirstRoot)];
accumulators.add(acc);
return acc;
});
}
CountedCompleter<Void> createTask(Node[] rootNodes) {
return new CountedCompleter<Void>() {
@Override
public void compute() {
start(this, rootNodes);
tryComplete();
}
};
}
/** Initializes {@link #state} and forks {@link PropagateTask}s for all in-degree-0 roots. */
private void start(CountedCompleter<?> parent, Node[] rootNodes) {
// Runs on a pool worker, so the parallel stream is executed by this pool too.
IntStream.range(0, byId.length)
.parallel()
.forEach(id -> state[id * stride + words] = byId[id].indegree);
for (int i = iFirstRoot; i < iLastRoot; i++) {
int bit = i - iFirstRoot;
state[rootNodes[i].id * stride + bit / 64] |= 1L << (bit % 64);
}
// Only roots can have no parents; start every in-degree-0 root across all roots (not just
// [iFirstRoot, iLastRoot)) so Kahn's algorithm reaches roots in [iFirstRoot, iLastRoot)
// whose parents are roots from other passes. Because `roots` is a Set, rootNodes are
// distinct and each is started once.
for (Node root : rootNodes) {
if (root.indegree == 0) {
parent.addToPendingCount(1);
new PropagateTask(parent, this, root).fork();
}
}
}
/** Processes one node whose parents have all been processed and forks its ready children. */
void propagate(CountedCompleter<?> parent, Node node) {
int base = node.id * stride;
if (node.actions >= 0) {
long[] acc = accumulator.get();
int passRoots = acc.length / 2;
for (int w = 0; w < words; w++) {
long mask = state[base + w];
int rootBase = w * 64;
// Iterate over the set bits in `mask`; where a bit is set, update the accumulator
// accordingly.
while (mask != 0) {
int root = rootBase + Long.numberOfTrailingZeros(mask);
acc[root]++;
acc[passRoots + root] += node.actions;
// Unset the lowest set bit.
mask &= mask - 1;
}
}
}
// Iterate over every dep: OR the mask of this node into the mask of the dep and decrement the
// remaining in-degree of the dep by one. If the dep has no more parents remaining, enqueue
// it.
for (int dep : node.deps) {
int depBase = dep * stride;
for (int w = 0; w < words; w++) {
long mask = state[base + w];
// Skip the (fenced) atomic OR if the child already has all of our bits, which is common
// when parents share roots. Bits are only ever set, so a stale read is harmless.
if (mask != 0) {
long current = state[depBase + w];
if ((current | mask) != current) {
LONG_ARRAY.getAndBitwiseOr(state, depBase + w, mask);
}
}
}
if ((long) LONG_ARRAY.getAndAdd(state, depBase + words, -1L) == 1L) {
parent.addToPendingCount(1);
new PropagateTask(parent, this, byId[dep]).fork();
}
}
}
}
/** Fork/join task that processes one ready node during {@link Propagation}. */
private static final class PropagateTask extends CountedCompleter<Void> {
private final Propagation propagation;
private final Node node;
PropagateTask(CountedCompleter<?> parent, Propagation propagation, Node node) {
super(parent);
this.propagation = propagation;
this.node = node;
}
@Override
public void compute() {
propagation.propagate(this, node);
tryComplete();
}
}
/** Runs {@code task} in {@code pool}, propagating failures and interrupts. */
private static void run(ForkJoinPool pool, ForkJoinTask<?> task) throws InterruptedException {
try {
pool.submit(task).get();
} catch (ExecutionException e) {
Throwables.throwIfUnchecked(e.getCause());
throw new IllegalStateException(e.getCause());
}
}
/**
* Runs the discovery pass from {@code rootKeys}, filling {@code rootNodes} with their nodes, and
* returns all discovered nodes indexed by ID.
*/
private static Node[] discover(
InMemoryGraph graph, ConfiguredTargetKey[] rootKeys, Node[] rootNodes, ForkJoinPool pool)
throws InterruptedException {
Discovery discovery = new Discovery(graph);
CountedCompleter<Void> discoveryRoot =
new CountedCompleter<Void>() {
@Override
public void compute() {
for (int i = 0; i < rootKeys.length; i++) {
rootNodes[i] = discovery.nodeFor(rootKeys[i], this);
}
tryComplete();
}
};
run(pool, discoveryRoot);
// Populate `byId` in a separate pass once `nextId` gives the exact reachable node count:
// during discovery that count is unknown (it can be much smaller than `graph.valuesSize()`,
// or larger if roots or deps are absent from `graph`), so building `byId` on the fly would
// require concurrent resizing or chunked arrays. Wrapping `forEachValue` in
// `ForkJoinTask.adapt` runs it in `pool` so its subtasks fork onto `pool` instead of
// `ForkJoinPool.commonPool()`.
Node[] byId = new Node[discovery.nextId.get()];
run(
pool,
ForkJoinTask.adapt(
() ->
discovery.nodes.forEachValue(
FOR_EACH_PARALLELISM_THRESHOLD, node -> byId[node.id] = node)));
return byId;
}
static ImmutableMap<ConfiguredTargetKey, Counts> count(
InMemoryGraph graph, Set<ConfiguredTargetKey> roots) throws InterruptedException {
return count(graph, roots, MAX_MASK_BYTES);
}
@VisibleForTesting
static ImmutableMap<ConfiguredTargetKey, Counts> count(
InMemoryGraph graph, Set<ConfiguredTargetKey> roots, long maxMaskBytes)
throws InterruptedException {
if (roots.isEmpty()) {
return ImmutableMap.of();
}
ForkJoinPool pool =
NamedForkJoinPool.newNamedPool(
"transitive-target-counter", Runtime.getRuntime().availableProcessors());
boolean completedNormally = false;
try {
ImmutableMap<ConfiguredTargetKey, Counts> result = count(graph, roots, pool, maxMaskBytes);
completedNormally = true;
return result;
} finally {
if (completedNormally
? ExecutorUtil.interruptibleShutdown(pool)
: ExecutorUtil.uninterruptibleShutdownNow(pool)) {
Thread.currentThread().interrupt();
}
}
}
private static ImmutableMap<ConfiguredTargetKey, Counts> count(
InMemoryGraph graph, Set<ConfiguredTargetKey> roots, ForkJoinPool pool, long maxMaskBytes)
throws InterruptedException {
ConfiguredTargetKey[] rootKeys = roots.toArray(new ConfiguredTargetKey[0]);
int numRoots = rootKeys.length;
long[] totals = new long[2 * numRoots];
// 1. Discovery, builds up a compact DAG indexed by dense integer IDs. Done in a separate method
// so that the key -> Node map is unreachable (and collectable) during propagation.
Node[] rootNodes = new Node[numRoots];
Node[] byId;
try (SilentCloseable c = Profiler.instance().profile("TransitiveTargetCounter.discovery")) {
byId = discover(graph, rootKeys, rootNodes, pool);
}
// 2. Propagation, in passes over ranges of roots if the bitmasks would be too large.
//
// roots: [0 ................. iFirstRoot .... iLastRoot ................. numRoots)
// |<-- <= 64*words roots -->|
// bit b = i - iFirstRoot -> word b/64, bit b%64
//
// state: |<- node 0 (stride = words + 1) ->|<- node 1 (stride = words + 1) ->| ...
// +---------+-----+-----------+-----+---------+-----+-----------+-----+
// | mask[0] | ... | mask[w-1] |indeg| mask[0] | ... | mask[w-1] |indeg| ...
// +---------+-----+-----------+-----+---------+-----+-----------+-----+
//
// totals: |<--- targetCount[0..numRoots) --->|<--- actionCount[0..numRoots) --->|
try (SilentCloseable c = Profiler.instance().profile("TransitiveTargetCounter.propagation")) {
int nodeCount = byId.length;
int maxWordsPerPass =
(int) Math.max(1, Math.min(1 << 20, maxMaskBytes / (8L * Math.max(1, nodeCount))));
for (int iFirstRoot = 0; iFirstRoot < numRoots; iFirstRoot += 64 * maxWordsPerPass) {
int iLastRoot = Math.min(numRoots, iFirstRoot + 64 * maxWordsPerPass);
int passRoots = iLastRoot - iFirstRoot;
Propagation propagation = new Propagation(byId, iFirstRoot, iLastRoot);
run(pool, propagation.createTask(rootNodes));
for (long[] acc : propagation.accumulators) {
for (int i = 0; i < passRoots; i++) {
totals[iFirstRoot + i] += acc[i];
totals[numRoots + iFirstRoot + i] += acc[passRoots + i];
}
}
}
}
ImmutableMap.Builder<ConfiguredTargetKey, Counts> result =
ImmutableMap.builderWithExpectedSize(numRoots);
for (int i = 0; i < numRoots; i++) {
result.put(rootKeys[i], new Counts(totals[i], totals[numRoots + i]));
}
return result.buildOrThrow();
}
}