[2.x] perf: Read the resolution's project cache once per report (#9522)

Resolution.projectCache is not a field: it maps projectCache0 into a
version-string-keyed copy on every call. SbtUpdateReport read it once per
dependency, and again per parent POM while assembling inherited licence info, so
for N modules resolved that is N rebuilds of an N-entry immutable map -- turning
a Resolution into an UpdateReport was quadratic in the modules it names.

On a 301-project build this was 61.9% of the CPU update spends, 83% of it
entering through lookupProject.

Read it once per report and reuse it, at every call site including the eviction
loop, which read it three times per conflict. The report produced is unchanged;
only the number of times the same map is built.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Mai Huy Hoàng 2026-07-30 12:17:04 +07:00 committed by GitHub
parent 1bde3d23a9
commit 3a79f269c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 17 additions and 6 deletions

View File

@ -254,8 +254,15 @@ private[internal] object SbtUpdateReport {
.withOptional(false)
.clearOverrides
// `Resolution.projectCache` is not a field. It builds a version-string-keyed view of
// `projectCache0` from scratch on every call, so reading it per dependency -- as the lookups
// below do, once per module and again per parent while assembling inherited info -- rebuilds a
// map of every resolved project once per module. Read it once and the lookups become what they
// read like.
val projectCache = res.projectCache
def lookupProject(mv: coursier.core.Resolution.ModuleVersion): Option[Project] =
res.projectCache.get(mv) match {
projectCache.get(mv) match {
case Some((_, p)) => Some(p)
case _ =>
interProjectDependencies.find(p => mv == (p.module, p.version))
@ -377,11 +384,15 @@ private[internal] object SbtUpdateReport {
classLoaders = classLoaders,
)
// Rebuilt on every read; see the note in `moduleReports`. The eviction loop below reads it
// three times per conflict.
val subProjectCache = subRes.projectCache
val reports0 = subRes.rootDependencies match {
case Seq(dep) if subRes.projectCache.contains(dep.moduleVersion) =>
case Seq(dep) if subProjectCache.contains(dep.moduleVersion) =>
// quick hack ensuring the module for the only root dependency
// appears first in the update report, see https://github.com/coursier/coursier/issues/650
val (_, proj) = subRes.projectCache(dep.moduleVersion)
val (_, proj) = subProjectCache(dep.moduleVersion)
val mod = moduleId((dep, proj.version, infoProperties(proj).toMap))
val (main, other) = reports.partition { r =>
r.module.organization == mod.organization &&
@ -406,14 +417,14 @@ private[internal] object SbtUpdateReport {
// rather than handing them for each dependency (where each dependency could have its own forced
// versions, and apply and pass them to its transitive dependencies, just like for exclusions today).
if !forceVersions.contains(c.module)
projOpt = subRes.projectCache
projOpt = subProjectCache
.get((c.module, c.wantedVersion))
.orElse(subRes.projectCache.get((c.module, c.version)))
.orElse(subProjectCache.get((c.module, c.version)))
(_, proj) <- projOpt.toSeq
} yield {
val dep = Dependency(c.module, c.wantedVersion)
val dependee = Dependency(c.dependeeModule, c.dependeeVersion)
val dependeeProj = subRes.projectCache.get((c.dependeeModule, c.dependeeVersion)) match {
val dependeeProj = subProjectCache.get((c.dependeeModule, c.dependeeVersion)) match {
case Some((_, p)) =>
ProjectInfo(
p.version,