diff --git a/main/src/main/scala/sbt/internal/IncrementalTest.scala b/main/src/main/scala/sbt/internal/IncrementalTest.scala index 2a20d9981..6105e1349 100644 --- a/main/src/main/scala/sbt/internal/IncrementalTest.scala +++ b/main/src/main/scala/sbt/internal/IncrementalTest.scala @@ -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