mirror of https://github.com/sbt/sbt.git
[2.x] perf: Skip re-packaging the class directory when zinc recompiles nothing (#9609)
Reuse the sibling dirzip when zinc reports it wrote nothing. The output is still declared, so the stored ActionResult stays complete and a later hit restores the class directory exactly as before -- it is the same HashedVirtualFileRef the packaging path would have produced, read off disk rather than rebuilt. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
0ae3c152bd
commit
f7f337033d
|
|
@ -2319,13 +2319,21 @@ object Defaults extends BuildCommon with DefExtra {
|
||||||
val store = analysisStore(compileAnalysisFile.value.toPath(), c)
|
val store = analysisStore(compileAnalysisFile.value.toPath(), c)
|
||||||
// TODO - Should readAnalysis + saveAnalysis be scoped by the compile task too?
|
// TODO - Should readAnalysis + saveAnalysis be scoped by the compile task too?
|
||||||
val analysisResult = Retry.io(compileIncrementalTaskImpl(bspTask, s, ci, ping, projectId))
|
val analysisResult = Retry.io(compileIncrementalTaskImpl(bspTask, s, ci, ping, projectId))
|
||||||
val analysisOut = c.toVirtualFile(setup.cachePath())
|
|
||||||
val contents = AnalysisContents.create(analysisResult.analysis(), analysisResult.setup())
|
|
||||||
store.set(contents)
|
|
||||||
Def.declareOutput(analysisOut)
|
|
||||||
val dir = ci.options.classesDirectory
|
val dir = ci.options.classesDirectory
|
||||||
val vfDir = c.toVirtualFile(dir)
|
val vfDir = c.toVirtualFile(dir)
|
||||||
val packedDir = Def.declareOutputDirectory(vfDir)
|
val dirZip = ActionCache.dirZipPath(dir)
|
||||||
|
// Zinc leaves the class directory alone when it invalidates nothing, so the zip the previous
|
||||||
|
// run left behind still describes it and re-packing only reproduces a blob the store has.
|
||||||
|
val packedDir =
|
||||||
|
if analysisResult.hasModified() || !Files.exists(dirZip) then
|
||||||
|
Def.declareOutputDirectory(vfDir)
|
||||||
|
else Def.declareOutput(c.toVirtualFile(dirZip))
|
||||||
|
val analysisOut = c.toVirtualFile(setup.cachePath())
|
||||||
|
val contents = AnalysisContents.create(analysisResult.analysis(), analysisResult.setup())
|
||||||
|
// Packaging precedes this write so that a run interrupted in between leaves a stale analysis,
|
||||||
|
// which forces a recompile, rather than a current analysis paired with an outdated zip.
|
||||||
|
store.set(contents)
|
||||||
|
Def.declareOutput(analysisOut)
|
||||||
s.log.debug(s"wrote $vfDir")
|
s.log.debug(s"wrote $vfDir")
|
||||||
(analysisResult.hasModified(), vfDir: VirtualFileRef, packedDir: HashedVirtualFileRef)
|
(analysisResult.hasModified(), vfDir: VirtualFileRef, packedDir: HashedVirtualFileRef)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
package example
|
||||||
|
|
||||||
|
object A:
|
||||||
|
def v: Int = 1
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
package example
|
||||||
|
|
||||||
|
object B:
|
||||||
|
def w: Int = A.v + 1
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import java.nio.file.{ Files, LinkOption }
|
||||||
|
import java.nio.file.attribute.BasicFileAttributes
|
||||||
|
|
||||||
|
val recordIds = taskKey[Unit]("records the on-disk identity of the compile outputs")
|
||||||
|
val checkAnalysisChanged = taskKey[Unit]("asserts the analysis file was rewritten")
|
||||||
|
val checkAnalysisUnchanged = taskKey[Unit]("asserts the analysis file was left alone")
|
||||||
|
val checkClassesZipUnchanged = taskKey[Unit]("asserts the class directory was not re-packaged")
|
||||||
|
val checkNotModified = taskKey[Unit]("asserts zinc recompiled nothing")
|
||||||
|
val delClassesZip = taskKey[Unit]("deletes the sibling classes.sbtdir.zip")
|
||||||
|
val checkClasses = taskKey[Unit]("asserts class files are present")
|
||||||
|
|
||||||
|
// A rewritten output gets a fresh inode when the action cache relinks it into the CAS, and a fresh
|
||||||
|
// mtime where symlinks are unavailable; either one moving means the file was written again.
|
||||||
|
def idOf(f: File): String = {
|
||||||
|
val attrs =
|
||||||
|
Files.readAttributes(f.toPath, classOf[BasicFileAttributes], LinkOption.NOFOLLOW_LINKS)
|
||||||
|
s"${Option(attrs.fileKey()).getOrElse("<no-file-key>")}|${attrs.lastModifiedTime().toMillis}"
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy val classesZip = Def.task {
|
||||||
|
val dir = (Compile / classDirectory).value
|
||||||
|
new File(dir.getParentFile, dir.getName + ".sbtdir.zip")
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy val idFile = Def.task { target.value / "recorded-ids.txt" }
|
||||||
|
|
||||||
|
def recorded(kind: String) = Def.task {
|
||||||
|
IO.readLines(idFile.value)
|
||||||
|
.collectFirst { case s"$k=$v" if k == kind => v }
|
||||||
|
.getOrElse(sys.error(s"no recorded id for $kind"))
|
||||||
|
}
|
||||||
|
|
||||||
|
Global / localCacheDirectory := (ThisBuild / baseDirectory).value / "diskcache"
|
||||||
|
ThisBuild / scalaVersion := "3.8.4"
|
||||||
|
ThisBuild / exportJars := true
|
||||||
|
|
||||||
|
lazy val a = project.in(file("a"))
|
||||||
|
|
||||||
|
lazy val b = project
|
||||||
|
.in(file("b"))
|
||||||
|
.dependsOn(a)
|
||||||
|
.settings(
|
||||||
|
recordIds := Def.uncached {
|
||||||
|
IO.writeLines(
|
||||||
|
idFile.value,
|
||||||
|
Seq(
|
||||||
|
s"analysis=${idOf((Compile / compileAnalysisFile).value)}",
|
||||||
|
s"classesZip=${idOf(classesZip.value)}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
checkAnalysisChanged := Def.uncached {
|
||||||
|
val now = idOf((Compile / compileAnalysisFile).value)
|
||||||
|
val before = recorded("analysis").value
|
||||||
|
assert(now != before, s"analysis was not rewritten, so compile was a cache hit: $now")
|
||||||
|
},
|
||||||
|
checkAnalysisUnchanged := Def.uncached {
|
||||||
|
val now = idOf((Compile / compileAnalysisFile).value)
|
||||||
|
val before = recorded("analysis").value
|
||||||
|
assert(now == before, s"analysis was rewritten: $before -> $now")
|
||||||
|
},
|
||||||
|
checkClassesZipUnchanged := Def.uncached {
|
||||||
|
val now = idOf(classesZip.value)
|
||||||
|
val before = recorded("classesZip").value
|
||||||
|
assert(now == before, s"class directory was re-packaged: $before -> $now")
|
||||||
|
},
|
||||||
|
checkNotModified := Def.uncached {
|
||||||
|
val (hasModified, _, _) = (Compile / compileIncremental).value
|
||||||
|
assert(!hasModified, "zinc reported modified output, expected nothing to recompile")
|
||||||
|
},
|
||||||
|
delClassesZip := Def.uncached {
|
||||||
|
IO.delete(classesZip.value)
|
||||||
|
},
|
||||||
|
checkClasses := Def.uncached {
|
||||||
|
val classes = ((Compile / classDirectory).value ** "*.class").get()
|
||||||
|
assert(classes.nonEmpty, "no class files")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
package example
|
||||||
|
|
||||||
|
object A:
|
||||||
|
def v: Int = 2
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
# An upstream method body change invalidates a downstream module's compile cache key without
|
||||||
|
# invalidating any of its classes. Zinc then recompiles nothing, so its class directory must not
|
||||||
|
# be packaged into the action cache again.
|
||||||
|
|
||||||
|
> b/compile
|
||||||
|
> b/recordIds
|
||||||
|
|
||||||
|
# a no-op build: b's compile is a pure cache hit, nothing on disk moves
|
||||||
|
> b/compile
|
||||||
|
> b/checkAnalysisUnchanged
|
||||||
|
> b/checkClassesZipUnchanged
|
||||||
|
|
||||||
|
# the body change alters a's jar, so b's cached compile task re-runs -- rewriting b's analysis --
|
||||||
|
# but zinc recompiles nothing in b
|
||||||
|
$ copy-file changes/A2.scala a/src/main/scala/A.scala
|
||||||
|
> b/compile
|
||||||
|
> b/checkNotModified
|
||||||
|
> b/checkAnalysisChanged
|
||||||
|
> b/checkClassesZipUnchanged
|
||||||
|
|
||||||
|
# the entry stored on that miss still has to describe b's class directory: dropping the sibling
|
||||||
|
# zip forces the next hit to fetch it back out of the content-addressed store and re-extract it
|
||||||
|
> b/recordIds
|
||||||
|
> b/delClassesZip
|
||||||
|
> b/compile
|
||||||
|
> b/checkClasses
|
||||||
|
|
@ -384,6 +384,10 @@ object ActionCache:
|
||||||
outputs += vf
|
outputs += vf
|
||||||
vf
|
vf
|
||||||
|
|
||||||
|
/** The zip `packageDirectory` writes for `dirPath`, as a sibling of the directory itself. */
|
||||||
|
def dirZipPath(dirPath: Path): Path =
|
||||||
|
Paths.get(dirPath.toString + dirZipExt)
|
||||||
|
|
||||||
def packageDirectory(
|
def packageDirectory(
|
||||||
dir: VirtualFileRef,
|
dir: VirtualFileRef,
|
||||||
conv: FileConverter,
|
conv: FileConverter,
|
||||||
|
|
@ -411,7 +415,7 @@ object ActionCache:
|
||||||
IO.withTemporaryDirectory: tempDir =>
|
IO.withTemporaryDirectory: tempDir =>
|
||||||
val mPath = (tempDir / manifestFileName).toPath()
|
val mPath = (tempDir / manifestFileName).toPath()
|
||||||
makeManifest(mPath)
|
makeManifest(mPath)
|
||||||
val zipPath = Paths.get(dirPath.toString + dirZipExt)
|
val zipPath = dirZipPath(dirPath)
|
||||||
val rebase: Path => Seq[(File, String)] =
|
val rebase: Path => Seq[(File, String)] =
|
||||||
(p: Path) =>
|
(p: Path) =>
|
||||||
p match
|
p match
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue