[2.x] Optimize incremental test further (#9364)

This applies a range of optimisations local to ClassStamper that bring the time needed for refinedTestDigests down to acceptable levels (see #9108):
* Cache the digests of transitive dependencies (big impact)
* Avoid sorting of digest subsets that would later get sorted again (small)
* Pre-compute which Analysis instances are required for each class to avoid repeated scanning of the whole list (medium)
* Merge two loops on relations.externalDeps into one (small)
* Compute the set of extra digests outside loop (small)
* Track the digest closure of each test via a BitSet (big)
This commit is contained in:
Yannick Heiber 2026-06-28 21:20:08 +02:00 committed by GitHub
parent 0ef972706c
commit ca20f68a14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 57 additions and 26 deletions

View File

@ -59,9 +59,10 @@ object IncrementalTest:
val rds = Keys.resourceDigests.value
val extra = Keys.extraTestDigests.value
val stamper = ClassStamper(cp, converter)
val testDigestExtra = extra ++ rds ++ opts
// TODO: Potentially do something about JUnit 5 and others which might not use class name
Map((testNames.flatMap: name =>
stamper.transitiveStamp(name, extra ++ rds ++ opts, s.log) match
stamper.transitiveStamp(name, testDigestExtra, s.log) match
case Some(ts) => Seq(name -> ts)
case None => Nil
)*)
@ -174,11 +175,35 @@ class ClassStamper private[sbt] (
converter,
)
private val stamps = mutable.Map.empty[String, Set[Digest]]
// Leaf digests (class bytecode hashes + library file digests) are interned to dense
// ints so transitive digest sets can be held as bit sets: union is a word-parallel OR
// and each member costs one bit instead of a 32-byte Digest.
private val digestIds = mutable.HashMap.empty[Digest, Int]
private val digestList = mutable.ArrayBuffer.empty[Digest]
private def idOf(d: Digest): Int =
digestIds.getOrElseUpdate(d, { val i = digestList.size; digestList += d; i })
private val stamps = mutable.Map.empty[String, mutable.BitSet]
// Memoizes the full transitive digest set per class name (excluding extraHashes), so the
// re-entrant external-dep walk isn't recomputed for every reference. Mapping bits back to
// digests and sorting is deferred to the root (`transitiveStamp`); intermediate results
// are only ever OR-ed into another bit set, where order and identity are irrelevant.
private val transitiveCache = mutable.Map.empty[String, mutable.BitSet]
// Cached so by-name `analyses0` is only evaluated once
private lazy val analyses = analyses0
private val stampVf: VirtualFileRef => Digest =
CacheImplicits.virtualFileRefToDigest(_)(converter)
// Index of binary class name -> analyses that produce it, so a stamp can dispatch
// straight to its owning analyses instead of scanning every analysis on the classpath.
private lazy val analysesByProduct: Map[String, Seq[Analysis]] =
val acc = mutable.HashMap.empty[String, mutable.ListBuffer[Analysis]]
analyses.foreach: a =>
a.relations.productClassName._2s.foreach: bin =>
acc.getOrElseUpdate(bin, mutable.ListBuffer.empty) += a
acc.iterator.map((k, v) => k -> v.toSeq).toMap
// Memoized: virtualFileRefToDigest does a filesystem stat per call, and the same
// library ref is referenced by many classes.
private val vfDigests = mutable.Map.empty[VirtualFileRef, Digest]
private def stampVf(vf: VirtualFileRef): Digest =
vfDigests.getOrElseUpdate(vf, CacheImplicits.virtualFileRefToDigest(vf)(converter))
/**
* Given a classpath and a class name, this tries to create a SHA-256 digest.
@ -190,22 +215,33 @@ class ClassStamper private[sbt] (
extraHashes: Seq[Digest],
log: Logger,
): Option[Digest] =
val digests = transitiveStamps(javaClassName, extraHashes, log)
val digests = sortedDigests(transitiveStamps(javaClassName, log)) ++ extraHashes
if digests.nonEmpty then Some(Digest.sha256Hash(digests*))
else None
// Map a bit set back to its digests
private def sortedDigests(bits: mutable.BitSet): Seq[Digest] =
val buf = mutable.ArrayBuffer.empty[Digest]
bits.foreach(i => buf += digestList(i))
buf.sortInPlace()
buf.toSeq
private def transitiveStamps(
javaClassName: String,
extraHashes: Seq[Digest],
log: Logger,
): Seq[Digest] =
val builder = Set.newBuilder[Digest]
analyses.foreach(internalStamp(builder, javaClassName, _, mutable.Set.empty, log))
val digests = builder.result().toSeq.sorted
digests ++ extraHashes
): mutable.BitSet =
transitiveCache.getOrElseUpdate(
javaClassName, {
val builder = mutable.BitSet.empty
analysesByProduct
.getOrElse(javaClassName, Nil)
.foreach(internalStamp(builder, javaClassName, _, mutable.Set.empty, log))
builder
}
)
private def internalStamp(
builder: mutable.Builder[Digest, Set[Digest]],
builder: mutable.BitSet,
javaClassName: String,
analysis: Analysis,
alreadySeen: mutable.Set[String],
@ -215,8 +251,8 @@ class ClassStamper private[sbt] (
// log.debug(s"test: internalStamp($javaClassName)")
def internalStamp0(className: String): Unit =
// Use a new builder so we can cache the result in `stamps`
val newBuilder = Set.newBuilder[Digest]
// Use a new bit set so we can cache the result in `stamps`
val newBuilder = mutable.BitSet.empty
// Zinc doesn't fully track the transitive dependencies
relations
@ -227,32 +263,27 @@ class ClassStamper private[sbt] (
relations
.externalDeps(className)
.foreach: libClassName =>
newBuilder ++= transitiveStamps(libClassName, Nil, log)
relations
.externalDeps(className)
.foreach: libClassName =>
newBuilder |= transitiveStamps(libClassName, log)
relations.libraryClassName
.reverse(libClassName)
.foreach: vf =>
newBuilder += stampVf(vf)
newBuilder += idOf(stampVf(vf))
analysis.apis.internal
.get(className)
.toSet
.foreach: analyzed =>
newBuilder += Digest.dummy(
37 * (17 + analyzed.transitiveBytecodeHash) + analyzed.bytecodeHash
newBuilder += idOf(
Digest.dummy(37 * (17 + analyzed.transitiveBytecodeHash) + analyzed.bytecodeHash)
)
val xs = newBuilder.result()
if xs.nonEmpty then stamps(className) = xs
if newBuilder.nonEmpty then stamps(className) = newBuilder
else ()
builder ++= xs
builder |= newBuilder
if alreadySeen.contains(javaClassName) then ()
else
stamps.get(javaClassName) match
case Some(xs) => builder ++= xs
case Some(xs) => builder |= xs
case _ =>
alreadySeen += javaClassName
// Note: internalClassDeps uses Scala-encoded class name for companion objects