Implement `PackageFunctionWithSingleGlobsDep` so that `PackageFunction` relies on a single GLOBS node

This change adds a new `PackageFunctionWithSingleGlobsDep` class which creates a single GLOBS node to evaluate all package `BUILD` file's glob expression. Bazel adopts this new type of `PackageFunction` so that the number of nodes rdeping on PACKAGE is significantly reduced.

In order to facilitate readability, globbing strategies are renamed to `MULTIPLE_GLOB_HYBRID` (former `SKYFRAME_HYBRID`) and `SINGLE_GLOBS_HYBRID`. The heuristic node dropping logic is also refactored so that PACKAGE dep GLOBS node are removed after PACKAGE evaluation completes.

PiperOrigin-RevId: 629867325
Change-Id: I97c5aa71dc6adc155a1d32adb39c3812877768ba
diff --git a/src/main/java/com/google/devtools/build/lib/includescanning/IncludeParser.java b/src/main/java/com/google/devtools/build/lib/includescanning/IncludeParser.java
index 01a2a40..ecc678a 100644
--- a/src/main/java/com/google/devtools/build/lib/includescanning/IncludeParser.java
+++ b/src/main/java/com/google/devtools/build/lib/includescanning/IncludeParser.java
@@ -338,6 +338,7 @@
             containingPackageLookupValue.getContainingPackageName().getPackageFragment();
         String pattern = findFilters.get(i);
         try {
+          // TODO: b/290998109#comment60 - Convert to create GLOBS node in IncludeParser.
           globKeys.add(
               GlobValue.key(
                   containingPackageLookupValue.getContainingPackageName(),
diff --git a/src/main/java/com/google/devtools/build/lib/query2/RBuildFilesVisitor.java b/src/main/java/com/google/devtools/build/lib/query2/RBuildFilesVisitor.java
index 8f4fe44..900af32 100644
--- a/src/main/java/com/google/devtools/build/lib/query2/RBuildFilesVisitor.java
+++ b/src/main/java/com/google/devtools/build/lib/query2/RBuildFilesVisitor.java
@@ -144,7 +144,8 @@
     // globs, but these cannot be included in load statements and so we don't traverse
     // through these either.
     if (!rdep.functionName().equals(SkyFunctions.PACKAGE_LOOKUP)
-        && !rdep.functionName().equals(SkyFunctions.GLOB)) {
+        && !rdep.functionName().equals(SkyFunctions.GLOB)
+        && !rdep.functionName().equals(SkyFunctions.GLOBS)) {
       keysToVisitNext.add(rdep);
     }
   }
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/BUILD b/src/main/java/com/google/devtools/build/lib/skyframe/BUILD
index 93cd47d..a1eb9d0 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/BUILD
@@ -48,6 +48,7 @@
         "LocalRepositoryLookupFunction.java",
         "PackageFunction.java",
         "PackageFunctionWithMultipleGlobDeps.java",
+        "PackageFunctionWithSingleGlobsDep.java",
         "PackageFunctionWithoutGlobDeps.java",
         "PrepareDepsOfPatternFunction.java",
         "SequencedSkyframeExecutor.java",
@@ -138,6 +139,8 @@
         ":glob_descriptor",
         ":glob_function",
         ":glob_value",
+        ":globs_function",
+        ":globs_value",
         ":ignored_package_prefixes_function",
         ":ignored_package_prefixes_value",
         ":incremental_artifact_conflict_finder",
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/GlobsValue.java b/src/main/java/com/google/devtools/build/lib/skyframe/GlobsValue.java
index a19eded..10b9ebb 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/GlobsValue.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/GlobsValue.java
@@ -30,6 +30,8 @@
 /** {@link SkyValue} corresponding to the computation result of the {@link GlobsFunction}. */
 public class GlobsValue implements SkyValue {
 
+  // TODO: b/290998109 - Storing the matches seem unnecessary except for tests. Consider only
+  // storing `matches` when testing.
   private final ImmutableSet<PathFragment> matches;
 
   public GlobsValue(ImmutableSet<PathFragment> matches) {
@@ -195,6 +197,11 @@
     }
 
     @Override
+    public boolean skipsBatchPrefetch() {
+      return true;
+    }
+
+    @Override
     public SkyFunctionName functionName() {
       return SkyFunctions.GLOBS;
     }
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/PackageFunction.java b/src/main/java/com/google/devtools/build/lib/skyframe/PackageFunction.java
index 3d88b35..d1152c5 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/PackageFunction.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/PackageFunction.java
@@ -241,11 +241,29 @@
      * Globs are resolved using {@code PackageFunctionWithMultipleGlobDeps#SkyframeHybridGlobber},
      * which declares proper Skyframe dependencies.
      *
+     * <p>This strategy is formerly named {@code SKYFRAME_HYBRID}.
+     *
      * <p>Use when {@link PackageFunction} will be used to load packages incrementally (e.g. on both
-     * clean builds and incremental builds, perhaps with cached globs). This is Bazel's normal
-     * use-case.
+     * clean builds and incremental builds, perhaps with cached globs). This used to be Bazel's
+     * normal use-case and still is the preferred strategy if incremental evaluation performance
+     * requirement is strict.
      */
-    SKYFRAME_HYBRID,
+    MULTIPLE_GLOB_HYBRID,
+
+    /**
+     * Globs are resolved using {@code PackageFunctionWithSingleGlobsDep#GlobsGlobber}. This
+     * strategy is similar to {@link #MULTIPLE_GLOB_HYBRID} except that there is a single GLOBS
+     * Skyframe dependency including all globs defined in the package's BUILD file.
+     *
+     * <p>The {@code GLOBS} strategy is designed to replace {@code SKYFRAME_HYBRID} as Bazel's
+     * normal use case in that it coarsens the Glob-land subgraph and saving memory without
+     * meaningfully sacrificing performance.
+     *
+     * <p>However, incremental evaluation performance might regress when switching from {@link
+     * #MULTIPLE_GLOB_HYBRID} to {@link #SINGLE_GLOBS_HYBRID}. See {@link GlobFunction} for more
+     * details.
+     */
+    SINGLE_GLOBS_HYBRID,
 
     /**
      * Globs are resolved using {@link NonSkyframeGlobber}, which does not declare Skyframe
@@ -277,6 +295,7 @@
   @ForOverride
   protected abstract void handleGlobDepsAndPropagateFilesystemExceptions(
       PackageIdentifier packageIdentifier,
+      Root packageRoot,
       LoadedPackage loadedPackage,
       Environment env,
       boolean packageWasInError)
@@ -516,7 +535,11 @@
 
     try {
       handleGlobDepsAndPropagateFilesystemExceptions(
-          packageId, state.loadedPackage, env, pkgBuilder.containsErrors());
+          packageId,
+          packageLookupValue.getRoot(),
+          state.loadedPackage,
+          env,
+          pkgBuilder.containsErrors());
     } catch (InternalInconsistentFilesystemException e) {
       throw e.throwPackageFunctionException();
     } catch (FileSymlinkException e) {
@@ -1261,7 +1284,7 @@
     private ActionOnIOExceptionReadingBuildFile actionOnIOExceptionReadingBuildFile =
         PackageFunction.ActionOnIOExceptionReadingBuildFile.UseOriginalIOException.INSTANCE;
     private boolean shouldUseRepoDotBazel = true;
-    @Nullable private GlobbingStrategy globbingStrategy = GlobbingStrategy.SKYFRAME_HYBRID;
+    private GlobbingStrategy globbingStrategy = GlobbingStrategy.SINGLE_GLOBS_HYBRID;
     private Function<SkyKey, ThreadStateReceiver> threadStateReceiverFactoryForMetrics =
         k -> ThreadStateReceiver.NULL_INSTANCE;
     private AtomicReference<Semaphore> cpuBoundSemaphore = new AtomicReference<>();
@@ -1336,7 +1359,7 @@
 
     public PackageFunction build() {
       switch (globbingStrategy) {
-        case SKYFRAME_HYBRID -> {
+        case MULTIPLE_GLOB_HYBRID -> {
           return new PackageFunctionWithMultipleGlobDeps(
               packageFactory,
               pkgLocator,
@@ -1349,6 +1372,19 @@
               threadStateReceiverFactoryForMetrics,
               cpuBoundSemaphore);
         }
+        case SINGLE_GLOBS_HYBRID -> {
+          return new PackageFunctionWithSingleGlobsDep(
+              packageFactory,
+              pkgLocator,
+              showLoadingProgress,
+              numPackagesSuccessfullyLoaded,
+              bzlLoadFunctionForInlining,
+              packageProgress,
+              actionOnIOExceptionReadingBuildFile,
+              shouldUseRepoDotBazel,
+              threadStateReceiverFactoryForMetrics,
+              cpuBoundSemaphore);
+        }
         case NON_SKYFRAME -> {
           return new PackageFunctionWithoutGlobDeps(
               packageFactory,
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/PackageFunctionWithMultipleGlobDeps.java b/src/main/java/com/google/devtools/build/lib/skyframe/PackageFunctionWithMultipleGlobDeps.java
index e947f0b..b18243d 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/PackageFunctionWithMultipleGlobDeps.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/PackageFunctionWithMultipleGlobDeps.java
@@ -60,7 +60,8 @@
  * node in the dependency graph.
  *
  * <p>{@link PackageFunctionWithMultipleGlobDeps} subclass is created when the globbing strategy is
- * {@link com.google.devtools.build.lib.skyframe.PackageFunction.GlobbingStrategy#SKYFRAME_HYBRID} .
+ * {@link
+ * com.google.devtools.build.lib.skyframe.PackageFunction.GlobbingStrategy#MULTIPLE_GLOB_HYBRID}.
  * Incremental evaluation adopts {@code SKYFRAME_HYBRID} globbing strategy in order to use the
  * unchanged {@link GlobValue} stored in Skyframe when incrementally reloading the package.
  */
@@ -99,6 +100,7 @@
   @Override
   protected void handleGlobDepsAndPropagateFilesystemExceptions(
       PackageIdentifier packageIdentifier,
+      Root packageRoot,
       LoadedPackage loadedPackage,
       Environment env,
       boolean packageWasInError)
@@ -198,7 +200,7 @@
    * encountering all glob calls (meaning the real pass can still have the core problem with
    * Skyframe restarts).
    */
-  private static class SkyframeHybridGlobber implements Globber {
+  private static final class SkyframeHybridGlobber implements Globber {
     private final PackageIdentifier packageId;
     private final Root packageRoot;
     private final Environment env;
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/PackageFunctionWithSingleGlobsDep.java b/src/main/java/com/google/devtools/build/lib/skyframe/PackageFunctionWithSingleGlobsDep.java
new file mode 100644
index 0000000..f3d8023
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/PackageFunctionWithSingleGlobsDep.java
@@ -0,0 +1,212 @@
+// Copyright 2024 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.skyframe;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.devtools.build.lib.actions.ThreadStateReceiver;
+import com.google.devtools.build.lib.cmdline.PackageIdentifier;
+import com.google.devtools.build.lib.io.FileSymlinkException;
+import com.google.devtools.build.lib.io.InconsistentFilesystemException;
+import com.google.devtools.build.lib.packages.BuildFileNotFoundException;
+import com.google.devtools.build.lib.packages.CachingPackageLocator;
+import com.google.devtools.build.lib.packages.Globber;
+import com.google.devtools.build.lib.packages.GlobberUtils;
+import com.google.devtools.build.lib.packages.NonSkyframeGlobber;
+import com.google.devtools.build.lib.packages.Package;
+import com.google.devtools.build.lib.packages.PackageFactory;
+import com.google.devtools.build.lib.skyframe.GlobsValue.GlobRequest;
+import com.google.devtools.build.lib.vfs.Root;
+import com.google.devtools.build.skyframe.SkyKey;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+import javax.annotation.Nullable;
+
+/**
+ * Computes the {@link PackageValue} which depends on a single GLOBS node.
+ *
+ * <p>{@link PackageFunctionWithSingleGlobsDep} subclass is created when the globbing strategy is
+ * {@link
+ * com.google.devtools.build.lib.skyframe.PackageFunction.GlobbingStrategy#SINGLE_GLOBS_HYBRID}. All
+ * globs defined in the package's {@code BUILD} file are combined into a single GLOBS node.
+ */
+final class PackageFunctionWithSingleGlobsDep extends PackageFunction {
+
+  PackageFunctionWithSingleGlobsDep(
+      PackageFactory packageFactory,
+      CachingPackageLocator pkgLocator,
+      AtomicBoolean showLoadingProgress,
+      AtomicInteger numPackagesSuccessfullyLoaded,
+      @Nullable BzlLoadFunction bzlLoadFunctionForInlining,
+      @Nullable PackageProgressReceiver packageProgress,
+      ActionOnIOExceptionReadingBuildFile actionOnIoExceptionReadingBuildFile,
+      boolean shouldUseRepoDotBazel,
+      Function<SkyKey, ThreadStateReceiver> threadStateReceiverFactoryForMetrics,
+      AtomicReference<Semaphore> cpuBoundSemaphore) {
+    super(
+        packageFactory,
+        pkgLocator,
+        showLoadingProgress,
+        numPackagesSuccessfullyLoaded,
+        bzlLoadFunctionForInlining,
+        packageProgress,
+        actionOnIoExceptionReadingBuildFile,
+        shouldUseRepoDotBazel,
+        threadStateReceiverFactoryForMetrics,
+        cpuBoundSemaphore);
+  }
+
+  private static final class LoadedPackageWithGlobRequests extends LoadedPackage {
+    private final ImmutableSet<GlobRequest> globRequests;
+
+    private LoadedPackageWithGlobRequests(
+        Package.Builder builder, long loadTimeNanos, ImmutableSet<GlobRequest> globRequests) {
+      super(builder, loadTimeNanos);
+      this.globRequests = globRequests;
+    }
+  }
+
+  /**
+   * Performs non-Skyframe globbing operations and prepares the {@link GlobRequest}s set for
+   * subsequent Skyframe-based globbing.
+   */
+  private static final class GlobsGlobber implements Globber {
+    private final NonSkyframeGlobber nonSkyframeGlobber;
+    private final Set<GlobRequest> globRequests = Sets.newConcurrentHashSet();
+
+    private GlobsGlobber(NonSkyframeGlobber nonSkyframeGlobber) {
+      this.nonSkyframeGlobber = nonSkyframeGlobber;
+    }
+
+    @Override
+    public Token runAsync(
+        List<String> includes, List<String> excludes, Operation operation, boolean allowEmpty)
+        throws BadGlobException {
+      for (String pattern : includes) {
+        try {
+          globRequests.add(GlobRequest.create(pattern, operation));
+        } catch (InvalidGlobPatternException e) {
+          throw new BadGlobException(e.getMessage());
+        }
+      }
+
+      NonSkyframeGlobber.Token nonSkyframeGlobToken =
+          nonSkyframeGlobber.runAsync(includes, excludes, operation, allowEmpty);
+      return new GlobsToken(nonSkyframeGlobToken, operation, allowEmpty);
+    }
+
+    @Override
+    public List<String> fetchUnsorted(Token token)
+        throws BadGlobException, IOException, InterruptedException {
+      Set<String> matches = Sets.newHashSet();
+      matches.addAll(
+          nonSkyframeGlobber.fetchUnsorted(((GlobsToken) token).nonSkyframeGlobberIncludesToken));
+
+      List<String> result = new ArrayList<>(matches);
+      if (!((GlobsToken) token).allowEmpty && result.isEmpty()) {
+        GlobberUtils.throwBadGlobExceptionAllExcluded(((GlobsToken) token).globberOperation);
+      }
+      return result;
+    }
+
+    @Override
+    public void onInterrupt() {
+      nonSkyframeGlobber.onInterrupt();
+    }
+
+    @Override
+    public void onCompletion() {
+      nonSkyframeGlobber.onCompletion();
+    }
+
+    /**
+     * Returns an {@link ImmutableSet} of all package's globs, which will be used to construct
+     * {@link GlobsValue.Key} to be requested in Skyframe downstream.
+     *
+     * <p>An empty {@link ImmutableSet} is returned if there is no glob is defined in the package's
+     * BUILD file. Hence, requesting GLOBS in Skyframe is skipped downstream.
+     */
+    public ImmutableSet<GlobRequest> getGlobRequests() {
+      return ImmutableSet.copyOf(globRequests);
+    }
+
+    private static class GlobsToken extends Globber.Token {
+      private final NonSkyframeGlobber.Token nonSkyframeGlobberIncludesToken;
+      private final Globber.Operation globberOperation;
+      private final boolean allowEmpty;
+
+      private GlobsToken(
+          NonSkyframeGlobber.Token nonSkyframeGlobberIncludesToken,
+          Globber.Operation globberOperation,
+          boolean allowEmpty) {
+        this.nonSkyframeGlobberIncludesToken = nonSkyframeGlobberIncludesToken;
+        this.globberOperation = globberOperation;
+        this.allowEmpty = allowEmpty;
+      }
+    }
+  }
+
+  @Override
+  protected void handleGlobDepsAndPropagateFilesystemExceptions(
+      PackageIdentifier packageIdentifier,
+      Root packageRoot,
+      LoadedPackage loadedPackage,
+      Environment env,
+      boolean packageWasInError)
+      throws InterruptedException, InternalInconsistentFilesystemException, FileSymlinkException {
+    ImmutableSet<GlobRequest> globRequests =
+        ((LoadedPackageWithGlobRequests) loadedPackage).globRequests;
+    if (globRequests.isEmpty()) {
+      return;
+    }
+
+    GlobsValue.Key globsKey = GlobsValue.key(packageIdentifier, packageRoot, globRequests);
+    try {
+      env.getValueOrThrow(globsKey, IOException.class, BuildFileNotFoundException.class);
+    } catch (InconsistentFilesystemException e) {
+      throw new InternalInconsistentFilesystemException(packageIdentifier, e);
+    } catch (FileSymlinkException e) {
+      // Please note that GlobsFunction or its deps FileFunction throws the first
+      // `FileSymlinkException` discovered, which is consistent with how
+      // PackageFunctionWithMultipleGlobDeps#handleGlobDepsAndPropagateFilesystemExceptions handles
+      // FileSymlinkException caught.
+      throw e;
+    } catch (IOException | BuildFileNotFoundException e) {
+      maybeThrowFilesystemInconsistency(packageIdentifier, e, packageWasInError);
+    }
+  }
+
+  @Override
+  protected GlobsGlobber makeGlobber(
+      NonSkyframeGlobber nonSkyframeGlobber,
+      PackageIdentifier packageId,
+      Root packageRoot,
+      Environment env) {
+    return new GlobsGlobber(nonSkyframeGlobber);
+  }
+
+  @Override
+  protected LoadedPackage newLoadedPackage(
+      Package.Builder packageBuilder, @Nullable Globber globber, long loadTimeNanos) {
+    return new LoadedPackageWithGlobRequests(
+        packageBuilder, loadTimeNanos, ((GlobsGlobber) globber).getGlobRequests());
+  }
+}
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/PackageFunctionWithoutGlobDeps.java b/src/main/java/com/google/devtools/build/lib/skyframe/PackageFunctionWithoutGlobDeps.java
index dc4aae4..e679522 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/PackageFunctionWithoutGlobDeps.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/PackageFunctionWithoutGlobDeps.java
@@ -71,6 +71,7 @@
   @Override
   protected void handleGlobDepsAndPropagateFilesystemExceptions(
       PackageIdentifier packageIdentifier,
+      Root packageRoot,
       LoadedPackage loadedPackage,
       Environment env,
       boolean packageWasInError) {
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/SequencedSkyframeExecutor.java b/src/main/java/com/google/devtools/build/lib/skyframe/SequencedSkyframeExecutor.java
index 463dfc0..faade35 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/SequencedSkyframeExecutor.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/SequencedSkyframeExecutor.java
@@ -168,7 +168,8 @@
       ActionOnIOExceptionReadingBuildFile actionOnIOExceptionReadingBuildFile,
       boolean shouldUseRepoDotBazel,
       SkyKeyStateReceiver skyKeyStateReceiver,
-      BugReporter bugReporter) {
+      BugReporter bugReporter,
+      boolean globUnderSingleDep) {
     super(
         skyframeExecutorConsumerOnInit,
         pkgFactory,
@@ -193,7 +194,8 @@
         diffAwarenessFactories,
         workspaceInfoFromDiffReceiver,
         new SequencedRecordingDifferencer(),
-        repositoryHelpersHolder);
+        repositoryHelpersHolder,
+        globUnderSingleDep);
   }
 
   @Override
@@ -804,6 +806,7 @@
     private BugReporter bugReporter = BugReporter.defaultInstance();
     private SkyKeyStateReceiver skyKeyStateReceiver = SkyKeyStateReceiver.NULL_INSTANCE;
     private SyscallCache syscallCache = null;
+    private boolean globUnderSingleDep = true;
 
     private Builder() {}
 
@@ -839,7 +842,8 @@
               actionOnIOExceptionReadingBuildFile,
               shouldUseRepoDotBazel,
               skyKeyStateReceiver,
-              bugReporter);
+              bugReporter,
+              globUnderSingleDep);
       skyframeExecutor.init();
       return skyframeExecutor;
     }
@@ -964,5 +968,11 @@
       this.syscallCache = syscallCache;
       return this;
     }
+
+    @CanIgnoreReturnValue
+    public Builder setGlobUnderSingleDep(boolean globUnderSingleDep) {
+      this.globUnderSingleDep = globUnderSingleDep;
+      return this;
+    }
   }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/SkyframeExecutor.java b/src/main/java/com/google/devtools/build/lib/skyframe/SkyframeExecutor.java
index b007535..ff01086 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/SkyframeExecutor.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/SkyframeExecutor.java
@@ -494,6 +494,13 @@
 
   private SkyfocusState skyfocusState = SkyfocusState.DISABLED;
 
+  /**
+   * Determines the type of hybrid globbing strategy to use when {@link
+   * #tracksStateForIncrementality()} is {@code true}. See {@link #getGlobbingStrategy()} for more
+   * details.
+   */
+  private final boolean globUnderSingleDep;
+
   // Leaf keys to be kept regardless of the working set.
   private static final ImmutableSet<SkyKey> INCLUDE_KEYS_FOR_SKYFOCUS_IF_EXIST =
       ImmutableSet.of(
@@ -552,7 +559,8 @@
       @Nullable Iterable<? extends DiffAwareness.Factory> diffAwarenessFactories,
       @Nullable WorkspaceInfoFromDiffReceiver workspaceInfoFromDiffReceiver,
       @Nullable RecordingDifferencer recordingDiffer,
-      @Nullable SkyframeExecutorRepositoryHelpersHolder repositoryHelpersHolder) {
+      @Nullable SkyframeExecutorRepositoryHelpersHolder repositoryHelpersHolder,
+      boolean globUnderSingleDep) {
     // Strictly speaking, these arguments are not required for initialization, but all current
     // callsites have them at hand, so we might as well set them during construction.
     this.skyframeExecutorConsumerOnInit = skyframeExecutorConsumerOnInit;
@@ -607,6 +615,7 @@
     this.workspaceInfoFromDiffReceiver = workspaceInfoFromDiffReceiver;
     this.recordingDiffer = recordingDiffer;
     this.repositoryHelpersHolder = repositoryHelpersHolder;
+    this.globUnderSingleDep = globUnderSingleDep;
   }
 
   private ImmutableMap<SkyFunctionName, SkyFunction> skyFunctions() {
@@ -650,6 +659,7 @@
     map.put(SkyFunctions.BZL_LOAD, newBzlLoadFunction(ruleClassProvider));
     this.globFunction = newGlobFunction();
     map.put(SkyFunctions.GLOB, this.globFunction);
+    map.put(SkyFunctions.GLOBS, new GlobsFunction());
     map.put(SkyFunctions.TARGET_PATTERN, new TargetPatternFunction());
     map.put(SkyFunctions.PREPARE_DEPS_OF_PATTERNS, new PrepareDepsOfPatternsFunction());
     map.put(SkyFunctions.PREPARE_DEPS_OF_PATTERN, new PrepareDepsOfPatternFunction(pkgLocator));
@@ -1009,7 +1019,6 @@
     try {
       memoizingEvaluator.noteEvaluationsAtSameVersionMayBeFinished(eventHandler);
     } finally {
-      progressReceiver.globDeps = new ConcurrentHashMap<>();
       globFunction.complete();
       clearSyscallCache();
       // So that the supplier object can be GC-ed.
@@ -1070,9 +1079,12 @@
 
   @ForOverride
   protected GlobbingStrategy getGlobbingStrategy() {
-    return tracksStateForIncrementality()
-        ? GlobbingStrategy.SKYFRAME_HYBRID
-        : GlobbingStrategy.NON_SKYFRAME;
+    if (tracksStateForIncrementality()) {
+      return globUnderSingleDep
+          ? GlobbingStrategy.SINGLE_GLOBS_HYBRID
+          : GlobbingStrategy.MULTIPLE_GLOB_HYBRID;
+    }
+    return GlobbingStrategy.NON_SKYFRAME;
   }
 
   /**
@@ -2947,13 +2959,6 @@
     /** This receiver is only needed for execution, so it is null otherwise. */
     @Nullable private EvaluationProgressReceiver executionProgressReceiver = null;
 
-    // In non-incremental builds, we want to remove the glob subgraph after the rdep PACKAGE is
-    // done. However, edges are not stored. So, we use the `globDeps` map to temporarily store the
-    // relationship between GLOB and their dependent GLOBs.
-    // After the rdep PACKAGE has been evaluated, all direct or transitive dependent GLOBs will be
-    // recursively removed from both the in-memory graph and `globDeps` map.
-    private Map<GlobDescriptor, ImmutableList<GlobDescriptor>> globDeps = new ConcurrentHashMap<>();
-
     @Override
     public void dirtied(SkyKey skyKey, DirtyType dirtyType) {
       if (ignoreInvalidations) {
@@ -3062,30 +3067,21 @@
         }
       }
 
-      if (!heuristicallyDropNodes || directDeps == null) {
+      if (!heuristicallyDropNodes
+          || directDeps == null
+          || !getGlobbingStrategy().equals(GlobbingStrategy.SINGLE_GLOBS_HYBRID)) {
+        // `--heuristically_drop_nodes` is only meaningful when this is a non-incremental build with
+        // SINGLE_GLOBS_HYBRID strategy.
         return;
       }
 
-      if (skyKey.functionName().equals(SkyFunctions.GLOB)) {
-        checkState(!globDeps.containsKey(skyKey), skyKey);
-        ImmutableList.Builder<GlobDescriptor> directDepGlobsBuilder = ImmutableList.builder();
-        for (SkyKey dep : directDeps.getAllElementsAsIterable()) {
-          if (dep.functionName().equals(SkyFunctions.GLOB)) {
-            checkArgument(dep instanceof GlobDescriptor, dep);
-            directDepGlobsBuilder.add((GlobDescriptor) dep);
-          }
-        }
-
-        ImmutableList<GlobDescriptor> directDepGlobs = directDepGlobsBuilder.build();
-        if (!directDepGlobs.isEmpty()) {
-          globDeps.put((GlobDescriptor) skyKey, directDepGlobs);
-        }
-      }
-
+      // With non-incremental build, edges are not stored. So GLOBS node will not be useful anymore
+      // after PACKAGE evaluation completes, making it safe to be removed.
+      // See `SequencedSkyframeExecutor#decideKeepIncrementalState()` and b/261019506#comment1.
       if (skyKey.functionName().equals(SkyFunctions.PACKAGE)) {
         for (SkyKey dep : directDeps.getAllElementsAsIterable()) {
-          if (dep.functionName().equals(SkyFunctions.GLOB)) {
-            recursivelyRemoveGlobFromGraph((GlobDescriptor) dep);
+          if (dep.functionName().equals(SkyFunctions.GLOBS)) {
+            memoizingEvaluator.getInMemoryGraph().remove(dep);
           }
         }
       }
@@ -3117,16 +3113,6 @@
         }
       }
     }
-
-    private void recursivelyRemoveGlobFromGraph(GlobDescriptor root) {
-      memoizingEvaluator.getInMemoryGraph().remove(root);
-      ImmutableList<GlobDescriptor> adjacentDeps = globDeps.remove(root);
-      if (adjacentDeps != null) {
-        for (GlobDescriptor nextLevelDep : adjacentDeps) {
-          recursivelyRemoveGlobFromGraph(nextLevelDep);
-        }
-      }
-    }
   }
 
   public final ExecutionFinishedEvent createExecutionFinishedEvent() {
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/SkymeldInconsistencyReceiver.java b/src/main/java/com/google/devtools/build/lib/skyframe/SkymeldInconsistencyReceiver.java
index c25984e..a45c80b 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/SkymeldInconsistencyReceiver.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/SkymeldInconsistencyReceiver.java
@@ -28,6 +28,8 @@
  * pre-execution nodes in Skymeld mode.
  */
 public class SkymeldInconsistencyReceiver implements GraphInconsistencyReceiver {
+  // TODO: b/290998109#comment60 - After the GLOB nodes are replaced by GLOBS, the missing children
+  // below might be unexpected.
   private static final ImmutableMap<SkyFunctionName, SkyFunctionName>
       SKYMELD_EXPECTED_MISSING_CHILDREN =
           ImmutableMap.of(
diff --git a/src/test/java/com/google/devtools/build/lib/analysis/util/BuildViewTestCase.java b/src/test/java/com/google/devtools/build/lib/analysis/util/BuildViewTestCase.java
index 84bafd2..f1bef1e 100644
--- a/src/test/java/com/google/devtools/build/lib/analysis/util/BuildViewTestCase.java
+++ b/src/test/java/com/google/devtools/build/lib/analysis/util/BuildViewTestCase.java
@@ -253,19 +253,35 @@
   }
 
   @Before
-  public final void initializeSkyframeExecutor() throws Exception {
-    initializeSkyframeExecutor(/*doPackageLoadingChecks=*/ true);
+  public void initializeSkyframeExecutor() throws Exception {
+    initializeSkyframeExecutor(/* doPackageLoadingChecks= */ true);
   }
 
   public void initializeSkyframeExecutor(boolean doPackageLoadingChecks) throws Exception {
     initializeSkyframeExecutor(
-        /*doPackageLoadingChecks=*/ doPackageLoadingChecks,
-        /*diffAwarenessFactories=*/ ImmutableList.of());
+        /* doPackageLoadingChecks= */ doPackageLoadingChecks,
+        /* diffAwarenessFactories= */ ImmutableList.of(),
+        /* globUnderSingleDep= */ true);
   }
 
   public void initializeSkyframeExecutor(
       boolean doPackageLoadingChecks, ImmutableList<DiffAwareness.Factory> diffAwarenessFactories)
       throws Exception {
+    initializeSkyframeExecutor(
+        doPackageLoadingChecks, diffAwarenessFactories, /* globUnderSingleDep= */ true);
+  }
+
+  /**
+   * Only {@link com.google.devtools.build.lib.skyframe.PackageFunctionTest} still covers testing
+   * Skyframe Hybrid globbing by passing in the test parameter globUnderSingleDep.
+   *
+   * <p>All other tests adopt GLOBS strategy by setting {@code globUnderSingleDep} to {@code true}.
+   */
+  public void initializeSkyframeExecutor(
+      boolean doPackageLoadingChecks,
+      ImmutableList<DiffAwareness.Factory> diffAwarenessFactories,
+      boolean globUnderSingleDep)
+      throws Exception {
     analysisMock = getAnalysisMock();
     directories =
         new BlazeDirectories(
@@ -320,6 +336,7 @@
             .setSyscallCache(SyscallCache.NO_CACHE)
             .setDiffAwarenessFactories(diffAwarenessFactories)
             .setRepositoryHelpersHolder(getRepositoryHelpersHolder())
+            .setGlobUnderSingleDep(globUnderSingleDep)
             .build();
     if (usesInliningBzlLoadFunction()) {
       injectInliningBzlLoadFunction(skyframeExecutor, ruleClassProvider, directories);
diff --git a/src/test/java/com/google/devtools/build/lib/skyframe/BUILD b/src/test/java/com/google/devtools/build/lib/skyframe/BUILD
index cb69c03..a79889e 100644
--- a/src/test/java/com/google/devtools/build/lib/skyframe/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/skyframe/BUILD
@@ -225,6 +225,7 @@
         "//src/main/java/com/google/devtools/build/lib/skyframe:diff_awareness_manager",
         "//src/main/java/com/google/devtools/build/lib/skyframe:directory_listing_function",
         "//src/main/java/com/google/devtools/build/lib/skyframe:directory_listing_state_value",
+        "//src/main/java/com/google/devtools/build/lib/skyframe:directory_listing_value",
         "//src/main/java/com/google/devtools/build/lib/skyframe:directory_tree_digest_function",
         "//src/main/java/com/google/devtools/build/lib/skyframe:directory_tree_digest_value",
         "//src/main/java/com/google/devtools/build/lib/skyframe:file_function",
diff --git a/src/test/java/com/google/devtools/build/lib/skyframe/PackageFunctionTest.java b/src/test/java/com/google/devtools/build/lib/skyframe/PackageFunctionTest.java
index 4ba7856..6031135 100644
--- a/src/test/java/com/google/devtools/build/lib/skyframe/PackageFunctionTest.java
+++ b/src/test/java/com/google/devtools/build/lib/skyframe/PackageFunctionTest.java
@@ -29,10 +29,12 @@
 import com.google.common.collect.HashMultiset;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Multiset;
 import com.google.common.collect.Sets;
 import com.google.devtools.build.lib.actions.FileStateValue;
+import com.google.devtools.build.lib.actions.FileValue;
 import com.google.devtools.build.lib.analysis.ConfiguredRuleClassProvider;
 import com.google.devtools.build.lib.analysis.util.BuildViewTestCase;
 import com.google.devtools.build.lib.clock.BlazeClock;
@@ -42,6 +44,7 @@
 import com.google.devtools.build.lib.events.EventKind;
 import com.google.devtools.build.lib.events.ExtendedEventHandler;
 import com.google.devtools.build.lib.packages.BuildFileNotFoundException;
+import com.google.devtools.build.lib.packages.Globber.Operation;
 import com.google.devtools.build.lib.packages.NoSuchPackageException;
 import com.google.devtools.build.lib.packages.NoSuchTargetException;
 import com.google.devtools.build.lib.packages.Package;
@@ -55,6 +58,7 @@
 import com.google.devtools.build.lib.rules.repository.RepositoryDelegatorFunction;
 import com.google.devtools.build.lib.runtime.QuiescingExecutorsImpl;
 import com.google.devtools.build.lib.server.FailureDetails.PackageLoading;
+import com.google.devtools.build.lib.skyframe.GlobsValue.GlobRequest;
 import com.google.devtools.build.lib.skyframe.util.SkyframeExecutorTestUtils;
 import com.google.devtools.build.lib.testutil.ManualClock;
 import com.google.devtools.build.lib.testutil.TestRuleClassProvider;
@@ -77,10 +81,14 @@
 import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
 import com.google.devtools.build.skyframe.Differencer.DiffWithDelta.Delta;
 import com.google.devtools.build.skyframe.EvaluationResult;
+import com.google.devtools.build.skyframe.InMemoryGraph;
+import com.google.devtools.build.skyframe.InMemoryNodeEntry;
 import com.google.devtools.build.skyframe.RecordingDifferencer;
 import com.google.devtools.build.skyframe.SkyKey;
 import com.google.devtools.build.skyframe.SkyValue;
 import com.google.devtools.common.options.Options;
+import com.google.testing.junit.testparameterinjector.TestParameter;
+import com.google.testing.junit.testparameterinjector.TestParameterInjector;
 import java.io.IOException;
 import java.io.InputStream;
 import java.util.ArrayList;
@@ -95,6 +103,7 @@
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.Stream;
 import javax.annotation.Nullable;
+import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -108,7 +117,7 @@
  * Unit tests of specific functionality of PackageFunction. Note that it's already tested indirectly
  * in several other places.
  */
-@RunWith(JUnit4.class)
+@RunWith(TestParameterInjector.class)
 public class PackageFunctionTest extends BuildViewTestCase {
 
   @Rule public final MockitoRule mockito = MockitoJUnit.rule();
@@ -117,6 +126,8 @@
 
   @Mock private PackageOverheadEstimator mockPackageOverheadEstimator;
 
+  @TestParameter private boolean globUnderSingleDep;
+
   private final CustomInMemoryFs fs = new CustomInMemoryFs(new ManualClock());
 
   private void preparePackageLoading(Path... roots) {
@@ -207,6 +218,15 @@
     return result.getError(skyKey).getException();
   }
 
+  @Before
+  @Override
+  public final void initializeSkyframeExecutor() throws Exception {
+    initializeSkyframeExecutor(
+        /* doPackageLoadingChecks= */ true,
+        /* diffAwarenessFactories= */ ImmutableList.of(),
+        /* globUnderSingleDep= */ globUnderSingleDep);
+  }
+
   @Test
   public void testValidPackage() throws Exception {
     scratch.file("pkg/BUILD");
@@ -1645,19 +1665,28 @@
     // Then, when we modify the BUILD file so as to force package loading,
     scratch.overwriteFile(
         "foo/BUILD", "glob(['cycle/**/foo.txt']) # dummy comment to force package loading");
-    // But we don't make any filesystem changes that would invalidate the GlobValues, meaning that
-    // PackageFunction will observe cache hits from Skyframe globbing,
-    //
-    // And we also have our filesystem blow up if the directory symlink cycle is encountered (thus,
-    // the absence of a crash indicates the lack of non-Skyframe globbing),
-    fs.stubStatError(
-        fooCyclePath,
-        new IOException() {
-          @Override
-          public String getMessage() {
-            throw new IllegalStateException("shouldn't get here!");
-          }
-        });
+
+    if (!globUnderSingleDep) {
+      // When globbing strategy is SKYFRAME_HYBRID (globUnderSingleDep = false), and we don't make
+      // any filesystem changes that would invalidate the GlobValues, PackageFunction will observe
+      // cache hits from Skyframe globbing.
+      //
+      // And we also have our filesystem blow up if the directory symlink cycle is encountered
+      // (thus, the absence of a crash indicates the lack of non-Skyframe globbing).
+      //
+      // However, when globbing strategy is GLOBS (globUnderSingleDep = true), and we lose Skyframe
+      // Hybrid globbing, we expect package reloading still to always do non-Skyframe globbing which
+      // calls stats for symlink `foo/cycle`.
+      fs.stubStatError(
+          fooCyclePath,
+          new IOException() {
+            @Override
+            public String getMessage() {
+              throw new IllegalStateException("shouldn't get here!");
+            }
+          });
+    }
+
     // And we evaluate the PackageValue node for the Package in keepGoing mode,
     getSkyframeExecutor()
         .invalidateFilesUnderPathForTesting(
@@ -1682,6 +1711,106 @@
     // presence of a symlink cycle encountered during glob evaluation.
   }
 
+  @Test
+  public void testGlobbingSkyframeDependencyStructure() throws Exception {
+    reporter.removeHandler(failFastHandler);
+
+    Root pkgRoot = getSkyframeExecutor().getPathEntries().get(0);
+
+    Path fooBuildPath =
+        scratch.file("foo/BUILD", "glob(['dir/*.sh'])", "subpackages(include = ['subpkg/**'])");
+
+    Path fooDirPath = fooBuildPath.getParentDirectory().getChild("dir");
+    scratch.file("foo/dir/bar.sh");
+    scratch.file("foo/dir/baz.sh");
+
+    Path fooSubpkgPath = fooBuildPath.getParentDirectory().getChild("subpkg");
+    scratch.file("foo/subpkg/BUILD");
+
+    SkyKey pkgKey = PackageIdentifier.createInMainRepo("foo");
+    SkyframeExecutorTestUtils.evaluate(
+        getSkyframeExecutor(), pkgKey, /* keepGoing= */ true, reporter);
+
+    InMemoryGraph graph = getSkyframeExecutor().memoizingEvaluator.getInMemoryGraph();
+    InMemoryNodeEntry packageNode = graph.getIfPresent(pkgKey);
+    if (globUnderSingleDep) {
+      // The package subgraph with single Globs node looks like this:
+      // PKG["foo"]
+      //  |- GLOBS[["dir/*.sh", FILES], ["subpkg/**", SUBPACKAGES]]
+      //      |- FILE["foo/dir"]
+      //      |- DIRECTORY_LISTING["foo/dir"]
+      //      |- PACKAGE_LOOKUP["foo/dir"]
+      //      |- FILE["foo/subdir"]
+      //      |- PACKAGE_LOOKUP["foo/subdir"]
+      GlobsValue.Key globsKey =
+          GlobsValue.key(
+              PackageIdentifier.createInMainRepo("foo"),
+              pkgRoot,
+              ImmutableSet.of(
+                  GlobRequest.create("dir/*.sh", Operation.FILES),
+                  GlobRequest.create("subpkg/**", Operation.SUBPACKAGES)));
+      assertThat(packageNode.getDirectDeps()).contains(globsKey);
+
+      InMemoryNodeEntry globsNode = graph.getIfPresent(globsKey);
+      SkyValue globsValue = globsNode.getValue();
+      assertThat(globsValue).isInstanceOf(GlobsValue.class);
+      assertThat(((GlobsValue) globsValue).getMatches())
+          .containsExactly(
+              PathFragment.create("subpkg"),
+              PathFragment.create("dir/bar.sh"),
+              PathFragment.create("dir/baz.sh"));
+      ImmutableSet<SkyKey> globsDirectDeps = ImmutableSet.copyOf(globsNode.getDirectDeps());
+      assertThat(globsDirectDeps)
+          .containsAtLeast(
+              DirectoryListingValue.key(RootedPath.toRootedPath(pkgRoot, fooDirPath)),
+              FileValue.key(RootedPath.toRootedPath(pkgRoot, fooDirPath)),
+              FileValue.key(RootedPath.toRootedPath(pkgRoot, fooSubpkgPath)),
+              PackageLookupValue.key(PackageIdentifier.createInMainRepo("foo/dir")),
+              PackageLookupValue.key(PackageIdentifier.createInMainRepo("foo/subpkg")));
+    } else {
+      // The package subgraph with multiple Glob nodes looks like this:
+      // PKG["foo"]
+      //  |- GLOB["dir/*.sh", FILES]
+      //      |- FILE["foo/dir"]
+      //      |- DIRECTORY_LISTING["foo/dir"]
+      //      |- PACKAGE_LOOKUP["foo/dir"]
+      //  |- GLOB["subpkg/**", SUBPACKAGES]
+      //      |- FILE["foo/subdir"]
+      //      |- PACKAGE_LOOKUP["foo/subdir"]
+      GlobDescriptor dirGlobDescriptor =
+          GlobValue.key(
+              PackageIdentifier.createInMainRepo("foo"),
+              pkgRoot,
+              /* pattern= */ "dir/*.sh",
+              Operation.FILES,
+              PathFragment.EMPTY_FRAGMENT);
+      GlobDescriptor subdirGlobDescriptor =
+          GlobValue.key(
+              PackageIdentifier.createInMainRepo("foo"),
+              pkgRoot,
+              /* pattern= */ "subpkg/**",
+              Operation.SUBPACKAGES,
+              PathFragment.EMPTY_FRAGMENT);
+      assertThat(packageNode.getDirectDeps())
+          .containsAtLeast(dirGlobDescriptor, subdirGlobDescriptor);
+
+      ImmutableSet<SkyKey> dirGlobNodeDeps =
+          ImmutableSet.copyOf(graph.getIfPresent(dirGlobDescriptor).getDirectDeps());
+      assertThat(dirGlobNodeDeps)
+          .containsAtLeast(
+              DirectoryListingValue.key(RootedPath.toRootedPath(pkgRoot, fooDirPath)),
+              FileValue.key(RootedPath.toRootedPath(pkgRoot, fooDirPath)),
+              PackageLookupValue.key(PackageIdentifier.createInMainRepo("foo/dir")));
+
+      ImmutableSet<SkyKey> subdirGlobNodeDeps =
+          ImmutableSet.copyOf(graph.getIfPresent(subdirGlobDescriptor).getDirectDeps());
+      assertThat(subdirGlobNodeDeps)
+          .containsAtLeast(
+              FileValue.key(RootedPath.toRootedPath(pkgRoot, fooSubpkgPath)),
+              PackageLookupValue.key(PackageIdentifier.createInMainRepo("foo/subpkg")));
+    }
+  }
+
   private static void assertDetailedExitCode(
       Exception exception, PackageLoading.Code expectedPackageLoadingCode, ExitCode exitCode) {
     assertThat(exception).isInstanceOf(DetailedException.class);
diff --git a/src/test/shell/integration/discard_graph_edges_test.sh b/src/test/shell/integration/discard_graph_edges_test.sh
index 35868fa..ced5277 100755
--- a/src/test/shell/integration/discard_graph_edges_test.sh
+++ b/src/test/shell/integration/discard_graph_edges_test.sh
@@ -249,9 +249,9 @@
       'devtools\.build\.lib\..*\.Package$')"
   [[ "$package_count" -ge 9 ]] \
       || fail "package count $package_count too low: did you move/rename the class?"
-  local glob_count="$(extract_histogram_count "$histo_file" "GlobValueWithImmutableSet$")"
-  [[ "$glob_count" -ge 2 ]] \
-      || fail "glob count $glob_count too low: did you move/rename the class?"
+  local globs_count="$(extract_histogram_count "$histo_file" "GlobsValue$")"
+  [[ "$globs_count" -ge 2 ]] \
+      || fail "globs count $globs_count too low: did you move/rename the class?"
   local module_count="$(extract_histogram_count "$histo_file" 'eval.Module$')"
   [[ "$module_count" -gt 25 ]] \
       || fail "Module count $module_count too low: was the class renamed/moved?" # was 74
@@ -273,9 +273,9 @@
   # A few packages aren't cleared.
   [[ "$package_count" -le 26 ]] \
       || fail "package count $package_count too high"
-  glob_count="$(extract_histogram_count "$histo_file" "GlobValueWithImmutableSet$")"
-  [[ "$glob_count" -le 1 ]] \
-      || fail "glob count $glob_count too high"
+  globs_count="$(extract_histogram_count "$histo_file" "GlobsValue$")"
+  [[ "$globs_count" -le 1 ]] \
+      || fail "globs count $globs_count too high"
   module_count="$(extract_histogram_count "$histo_file" 'eval.Module$')"
   [[ "$module_count" -lt 190 ]] \
       || fail "Module count $module_count too high"