From 53ffd7fc02b440bdee7169b6894b7026daff372b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mai=20Huy=20Ho=C3=A0ng?= Date: Wed, 26 Aug 2026 11:06:46 +0700 Subject: [PATCH] [2.x] perf: Copy instead of symlink when restoring the disk cache on APFS (#9623) A cache-restored tree is a farm of symlinks into the shared CAS. Writing it is cheap, but walking it is not: resolving each link reads an inode out of a directory far too large to stay in the OS metadata cache. Measured against a 57 GB / 952k-blob CAS, a cold stat of a CAS blob costs 165 us against 3.6 us for a regular file, which makes a cold classpath walk of a restored tree roughly 38x slower than one of real files. On an 81-module build with exportJars, a no-op compile drops from 14.75s to 5.96s once the tree is real files. DiskActionCacheStore now seeds the existing symlinkSupported latch from the filesystem holding the CAS, and materializes entries with copyFile on APFS. Files.copy reaches clonefile(2) there, so each entry gets its own inode and walks at full speed while its data blocks stay shared with the CAS: the restored tree measured 11 MB against 137 MB for the symlinked one. Copies also cannot corrupt a blob when a task overwrites its output, which a symlink into the CAS can. On every other filesystem the behavior is unchanged. Co-authored-by: Claude Opus 5 (1M context) --- .../src/main/scala/sbt/internal/util/Util.scala | 7 ++++++- .../src/main/scala/sbt/util/ActionCacheStore.scala | 4 +++- .../src/test/scala/sbt/util/ActionCacheTest.scala | 11 +++++++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/internal/util-core/src/main/scala/sbt/internal/util/Util.scala b/internal/util-core/src/main/scala/sbt/internal/util/Util.scala index e5024f215..7e6d6d5a7 100644 --- a/internal/util-core/src/main/scala/sbt/internal/util/Util.scala +++ b/internal/util-core/src/main/scala/sbt/internal/util/Util.scala @@ -8,12 +8,13 @@ package sbt.internal.util -import java.nio.file.{ Path, Paths } +import java.nio.file.{ Files, Path, Paths } import java.util.Locale import scala.collection.concurrent.TrieMap import scala.reflect.Selectable.reflectiveSelectable import scala.util.Properties +import scala.util.control.NonFatal object Util: def makeList[T](size: Int, value: T): List[T] = List.fill(size)(value) @@ -77,6 +78,10 @@ object Util: lazy val isEmacs: Boolean = sys.env.contains("INSIDE_EMACS") + def isApfs(path: Path): Boolean = + try Files.getFileStore(path).`type`().equalsIgnoreCase("apfs") + catch case NonFatal(_) => false + def nil[A]: List[A] = List.empty[A] def nilSeq[A]: Seq[A] = Seq.empty[A] def none[A]: Option[A] = (None: Option[A]) diff --git a/util-cache/src/main/scala/sbt/util/ActionCacheStore.scala b/util-cache/src/main/scala/sbt/util/ActionCacheStore.scala index 0f7975646..ff0ab8783 100644 --- a/util-cache/src/main/scala/sbt/util/ActionCacheStore.scala +++ b/util-cache/src/main/scala/sbt/util/ActionCacheStore.scala @@ -196,7 +196,9 @@ case class DiskActionCacheStore(base: Path, converter: FileConverter) dir } - private val symlinkSupported: AtomicBoolean = AtomicBoolean(true) + // Files.copy clones on APFS, so a real file costs no extra disk while sparing every later + // directory walk the CAS inode lookup that resolving a symlink pays. + private lazy val symlinkSupported: AtomicBoolean = AtomicBoolean(!Util.isApfs(casBase)) override def storeName: String = "disk" diff --git a/util-cache/src/test/scala/sbt/util/ActionCacheTest.scala b/util-cache/src/test/scala/sbt/util/ActionCacheTest.scala index c0c6fe9e2..8e9165356 100644 --- a/util-cache/src/test/scala/sbt/util/ActionCacheTest.scala +++ b/util-cache/src/test/scala/sbt/util/ActionCacheTest.scala @@ -8,6 +8,7 @@ import java.util.concurrent.{ CyclicBarrier, ExecutorService, Executors, TimeUni import sbt.internal.util.CacheEventLog import sbt.internal.util.StringVirtualFile1 +import sbt.internal.util.Util import sbt.io.IO import sbt.io.syntax.* import verify.BasicTestSuite @@ -140,6 +141,7 @@ object ActionCacheTest extends BasicTestSuite: cache.syncBlobs(refs, outputDirectory) assert((dir / "a.txt").exists, "a.txt not re-extracted after the directory was deleted") assert((dir / "b.txt").exists, "b.txt not re-extracted after the directory was deleted") + assertMaterialized(cache, (dir / "a.txt").toPath) test("Disk cache does not re-extract a dirzip whose archive digest already matches"): withDiskCache: cache => @@ -160,7 +162,7 @@ object ActionCacheTest extends BasicTestSuite: cache.syncBlobs(refs, outputDirectory) assert(IO.read(dir / "a.txt") == "diverged") - test("Disk cache relinks a digest-matching dirzip to the CAS"): + test("Disk cache materializes a digest-matching dirzip from the CAS"): withDiskCache: cache => IO.withTemporaryDirectory: tempDir => val outputDirectory = tempDir.toPath() @@ -176,7 +178,7 @@ object ActionCacheTest extends BasicTestSuite: assert(!Files.isSymbolicLink(zipPath), "packageDirectory should leave a regular file") cache.syncBlobs(refs, outputDirectory) - assert(Files.isSymbolicLink(zipPath), "digest-matching archive was not relinked to the CAS") + assertMaterialized(cache, zipPath) test("Disk cache re-extracts a dirzip whose archive digest differs"): withDiskCache: cache => @@ -874,6 +876,11 @@ object ActionCacheTest extends BasicTestSuite: keepDirectory = false ) + def assertMaterialized(cache: DiskActionCacheStore, p: Path): Unit = + if Util.isApfs(cache.casBase) then + assert(!Files.isSymbolicLink(p), s"$p was symlinked instead of copied") + else assert(Files.isSymbolicLink(p), s"$p was not symlinked into the CAS") + def getCacheConfig( cache: ActionCacheStore, outputDir: File,