From 3a79f269c7479a4b5b440ba9b6ea246ff9af8ab4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mai=20Huy=20Ho=C3=A0ng?= Date: Thu, 30 Jul 2026 12:17:04 +0700 Subject: [PATCH] [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 --- .../lmcoursier/internal/SbtUpdateReport.scala | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/lm-coursier/src/main/scala/lmcoursier/internal/SbtUpdateReport.scala b/lm-coursier/src/main/scala/lmcoursier/internal/SbtUpdateReport.scala index 83a8936d8..7ee19e4c7 100644 --- a/lm-coursier/src/main/scala/lmcoursier/internal/SbtUpdateReport.scala +++ b/lm-coursier/src/main/scala/lmcoursier/internal/SbtUpdateReport.scala @@ -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,