From 6d3e679a5982db914785f4e056db04d0dff1856f Mon Sep 17 00:00:00 2001 From: Hung Nguyen Date: Sat, 2 Apr 2022 17:37:44 +0100 Subject: [PATCH] Implement cache eviction for classpath snapshot cache Implement an in-memory cache that uses a combination of strong references and `SoftReference`s so that it adapts to memory availability. Cache eviction is currently performed after loading a classpath snapshot (this can be changed later if necessary). Evicted cache entries' values will be converted from strong references into `SoftReference`s so that they can still be used for as long as the JVM allows them. There are 2 types of cache eviction: - Least recently used: Oldest entries will be evicted - Memory usage limit: If memory is limited, all entries will be evicted Test: Added InMemoryCacheWithEvictionTest unit test ^KT-51978 In Progress --- .../report/metrics/BuildPerformanceMetric.kt | 2 +- .../kotlin/build/report/metrics/BuildTime.kt | 2 +- .../ClasspathSnapshotSerializer.kt | 56 +++--- .../InMemoryCacheWithEviction.kt | 188 ++++++++++++++++++ .../InMemoryCacheWithEvictionTest.kt | 110 ++++++++++ 5 files changed, 325 insertions(+), 33 deletions(-) create mode 100644 compiler/incremental-compilation-impl/src/org/jetbrains/kotlin/incremental/classpathDiff/InMemoryCacheWithEviction.kt create mode 100644 compiler/incremental-compilation-impl/test/org/jetbrains/kotlin/incremental/classpathDiff/InMemoryCacheWithEvictionTest.kt diff --git a/build-common/src/org/jetbrains/kotlin/build/report/metrics/BuildPerformanceMetric.kt b/build-common/src/org/jetbrains/kotlin/build/report/metrics/BuildPerformanceMetric.kt index 3ca71285a3c..e4f3ce276a4 100644 --- a/build-common/src/org/jetbrains/kotlin/build/report/metrics/BuildPerformanceMetric.kt +++ b/build-common/src/org/jetbrains/kotlin/build/report/metrics/BuildPerformanceMetric.kt @@ -25,7 +25,7 @@ enum class BuildPerformanceMetric(val parent: BuildPerformanceMetric? = null, va CLASSPATH_ENTRY_COUNT(parent = SHRINK_AND_SAVE_CLASSPATH_SNAPSHOT_EXECUTION_COUNT, "Number of classpath entries", type = SizeMetricType.NUMBER), CLASSPATH_SNAPSHOT_SIZE(parent = SHRINK_AND_SAVE_CLASSPATH_SNAPSHOT_EXECUTION_COUNT, "Size of classpath snapshot", type = SizeMetricType.BYTES), SHRUNK_CLASSPATH_SNAPSHOT_SIZE(parent = SHRINK_AND_SAVE_CLASSPATH_SNAPSHOT_EXECUTION_COUNT, "Size of shrunk classpath snapshot", type = SizeMetricType.BYTES), - LOAD_CLASSPATH_SNAPSHOT_CACHE_MISSES(parent = null, "Number of cache misses when loading classpath snapshot", type = SizeMetricType.NUMBER), + LOAD_CLASSPATH_ENTRY_SNAPSHOT_CACHE_MISSES(parent = null, "Number of cache misses when loading classpath entry snapshots", type = SizeMetricType.NUMBER), ; companion object { diff --git a/build-common/src/org/jetbrains/kotlin/build/report/metrics/BuildTime.kt b/build-common/src/org/jetbrains/kotlin/build/report/metrics/BuildTime.kt index 0619819c7ea..8e712c85aec 100644 --- a/build-common/src/org/jetbrains/kotlin/build/report/metrics/BuildTime.kt +++ b/build-common/src/org/jetbrains/kotlin/build/report/metrics/BuildTime.kt @@ -68,7 +68,7 @@ enum class BuildTime(val parent: BuildTime? = null, val readableString: String) FIND_INACCESSIBLE_CLASSES(parent = SNAPSHOT_CLASSES, "Find inaccessible classes"), SNAPSHOT_KOTLIN_CLASSES(parent = SNAPSHOT_CLASSES, "Snapshot Kotlin classes"), SNAPSHOT_JAVA_CLASSES(parent = SNAPSHOT_CLASSES, "Snapshot Java classes"), - SAVE_CLASSPATH_ENTRY_SNAPSHOT(parent = CLASSPATH_ENTRY_SNAPSHOT_TRANSFORM, "Save classpath entry snapshot") + SAVE_CLASSPATH_ENTRY_SNAPSHOT(parent = CLASSPATH_ENTRY_SNAPSHOT_TRANSFORM, "Save classpath entry snapshot"), ; companion object { diff --git a/compiler/incremental-compilation-impl/src/org/jetbrains/kotlin/incremental/classpathDiff/ClasspathSnapshotSerializer.kt b/compiler/incremental-compilation-impl/src/org/jetbrains/kotlin/incremental/classpathDiff/ClasspathSnapshotSerializer.kt index abc721312ef..88b98c26ef6 100644 --- a/compiler/incremental-compilation-impl/src/org/jetbrains/kotlin/incremental/classpathDiff/ClasspathSnapshotSerializer.kt +++ b/compiler/incremental-compilation-impl/src/org/jetbrains/kotlin/incremental/classpathDiff/ClasspathSnapshotSerializer.kt @@ -13,46 +13,40 @@ import org.jetbrains.kotlin.load.kotlin.header.KotlinClassHeader import java.io.DataInput import java.io.DataOutput import java.io.File -import java.util.concurrent.ConcurrentHashMap /** Utility to serialize a [ClasspathSnapshot]. */ object CachedClasspathSnapshotSerializer { - private val cache = ConcurrentHashMap() - private const val RECOMMENDED_MAX_CACHE_SIZE = 100 + + // Note: This cache is shared across builds, so we need to be careful if the snapshot file's path hasn't changed but its contents have + // changed. Luckily, each snapshot file is currently the output of a Gradle (non-incremental) transform, so that case will not happen. + // TODO: Make this code safer (not relying on how the snapshot files are produced and whether Gradle maintains the above guarantee). For + // example, if the transform is incremental, the above case may happen (the output directory of am incremental transform is unchanged + // even though its inputs/outputs have changed). Potential solutions: Write the file's content hash in the file's + // name or to another file next to it. + private val cache = InMemoryCacheWithEviction(maxTimePeriods = 20, maxMemoryUsageRatio = 0.8) fun load(classpathEntrySnapshotFiles: List, reporter: ClasspathSnapshotBuildReporter): ClasspathSnapshot { - return ClasspathSnapshot(classpathEntrySnapshotFiles.map { snapshotFile -> - // Note: This cache is shared across builds, so we need to be careful if the snapshot file's path hasn't changed but its - // contents have changed. Luckily, each snapshot file is the output of a Gradle transform and Gradle has the hash of the file's - // contents encoded in the file's path. Therefore, if the file's path hasn't changed, then its contents must also not have - // changed, and we can reuse the cache entry. - // TODO: Make this code safer (not relying on how the snapshot files are produced and whether Gradle maintains the above - // guarantee). For example, maybe we can write the file's content hash in the file's name or to another file next to it. + cache.newTimePeriod() + reporter.reportVerbose { + val counts = cache.countCacheEntriesForDebug() + @Suppress("SpellCheckingInspection") + "Load classpath snapshot, cache size = ${counts.first + counts.second + counts.third}" + + " (${counts.first} strong refs, ${counts.second + counts.third} soft refs, ${counts.third} are gc'd)" + } + + var cacheMisses: Long = 0 + val classpathSnapshot = ClasspathSnapshot(classpathEntrySnapshotFiles.map { snapshotFile -> cache.computeIfAbsent(snapshotFile) { - reporter.addMetric(BuildPerformanceMetric.LOAD_CLASSPATH_SNAPSHOT_CACHE_MISSES, 1) + cacheMisses++ ClasspathEntrySnapshotExternalizer.loadFromFile(it) } - }).also { - handleCacheEviction(recentlyReferencedKeys = classpathEntrySnapshotFiles) - } - } + }) - private fun handleCacheEviction(recentlyReferencedKeys: List) { - if (cache.size > RECOMMENDED_MAX_CACHE_SIZE) { - // Remove old entries. - // Note: - // - The cache entries after eviction = recently-referenced entries + some other entries (so that - // size = RECOMMENDED_MAX_CACHE_SIZE) - // + Removed entries don't have to be the oldest (for simplicity). - // + If recentlyReferencedKeys.size > RECOMMENDED_MAX_CACHE_SIZE, all of them will be kept. The reason is that - // recently-referenced entries will likely be used again, so we keep them even if the cache is larger than recommended. - // - It's okay to have race condition in this method. - val oldKeys = cache.keys - recentlyReferencedKeys.toSet() - for (oldKey in oldKeys) { - cache.remove(oldKey) - if (cache.size <= RECOMMENDED_MAX_CACHE_SIZE) break - } - } + cache.evictEntries() + reporter.addMetric(BuildPerformanceMetric.LOAD_CLASSPATH_ENTRY_SNAPSHOT_CACHE_MISSES, cacheMisses) + reporter.reportVerbose { "Loaded classpath snapshot, cache misses = $cacheMisses / ${classpathEntrySnapshotFiles.size}" } + + return classpathSnapshot } } diff --git a/compiler/incremental-compilation-impl/src/org/jetbrains/kotlin/incremental/classpathDiff/InMemoryCacheWithEviction.kt b/compiler/incremental-compilation-impl/src/org/jetbrains/kotlin/incremental/classpathDiff/InMemoryCacheWithEviction.kt new file mode 100644 index 00000000000..f6ed81f12ea --- /dev/null +++ b/compiler/incremental-compilation-impl/src/org/jetbrains/kotlin/incremental/classpathDiff/InMemoryCacheWithEviction.kt @@ -0,0 +1,188 @@ +/* + * Copyright 2010-2022 JetBrains s.r.o. and Kotlin Programming Language contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file. + */ + +package org.jetbrains.kotlin.incremental.classpathDiff + +import com.google.common.annotations.VisibleForTesting +import org.jetbrains.kotlin.utils.ThreadSafe +import java.lang.ref.SoftReference +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.locks.ReentrantReadWriteLock +import javax.annotation.concurrent.NotThreadSafe + +/** + * In-memory cache that uses a combination of strong references and [SoftReference]s so that it adapts to memory availability. + * + * Cache eviction is performed when a user of this cache calls [evictEntries]. Evicted cache entries' values will be converted from strong + * references into [SoftReference]s so that they can still be used for as long as the JVM allows them. + * + * There are 2 types of cache eviction: + * - Least recently used: Oldest entries will be evicted (see [maxTimePeriods]) + * - Memory usage limit: If memory is limited, all entries will be evicted (see [maxMemoryUsageRatio]) + */ +@ThreadSafe +class InMemoryCacheWithEviction( + + /** + * Cache entries' values that were not used within [maxTimePeriods] will be converted into [SoftReference]s (they can still be used for + * some more time until being garbage collected). + * + * The time period starts from 0 and will increment by 1 whenever [newTimePeriod] is called. + */ + private val maxTimePeriods: Int, + + /** + * If [memoryUsageRatio] > [maxMemoryUsageRatio], all cache entries' values will be converted into [SoftReference]s (they can still be + * used for some more time until being garbage collected). + */ + private val maxMemoryUsageRatio: Double, + + /** + * [TEST-ONLY] Function that returns the current memory usage ratio. It's here only for unit tests to provide custom values. + * Production code should not provide a value (the default function below will be used). + */ + private val memoryUsageRatio: () -> Double = { + Runtime.getRuntime().let { (it.totalMemory() - it.freeMemory()).toDouble() / it.maxMemory() } + } +) { + + /** The current time period, which starts from 0 and will increment by 1 whenever [newTimePeriod] is called. */ + private val currentTimePeriod = AtomicInteger(0) + + private val cache = ConcurrentHashMap>() + + fun newTimePeriod() { + currentTimePeriod.incrementAndGet() + } + + fun computeIfAbsent(key: KEY, valueProvider: (KEY) -> VALUE): VALUE { + return readLock { // Read lock so that this method can be called concurrently + val cacheEntryValue = cache.computeIfAbsent(key) { // `cache` is thread-safe + CacheEntryValue(value = valueProvider(key), currentTimePeriod = currentTimePeriod.get()) + } + synchronized(cacheEntryValue) { // Needs synchronization as CacheEntryValue is not thread-safe + val value = cacheEntryValue.get() ?: valueProvider(key) + cacheEntryValue.setStrongReference(value, currentTimePeriod.get()) + value + } + } + } + + fun evictEntries() { + writeLock { // Write lock so that other threads don't read/write the cache while this thread is updating it + // If memory is limited, evict all entries (turn their values into `SoftReference`s) + val entriesToEvict = if (memoryUsageRatio() > maxMemoryUsageRatio) { + cache.values + } else { + // Otherwise, evict least-recently-used entries + val lowestTimePeriodToRetain = currentTimePeriod.get() - maxTimePeriods + 1 + if (lowestTimePeriodToRetain > 0) { + cache.filterValues { it.lastUsed() < lowestTimePeriodToRetain }.values + } else emptyList() + } + entriesToEvict.forEach { it.updateToSoftReference() } + + // Also remove entries whose values are already garbage collected + cache.filterValues { it.valueWasGarbageCollected() }.keys.forEach { + cache.remove(it) + } + } + } + + private val lock = ReentrantReadWriteLock() + + private inline fun writeLock(action: () -> Unit) { + lock.writeLock().lock() + try { + action() + } finally { + lock.writeLock().unlock() + } + } + + private inline fun readLock(action: () -> VALUE): VALUE { + lock.readLock().lock() + try { + return action() + } finally { + lock.readLock().unlock() + } + } + + /** + * Returns the following numbers for debugging (in order): + * - Number of cache entries whose values are strong references + * - Number of cache entries whose values are referred to by a [SoftReference] and are not yet garbage collected + * - Number of cache entries whose values are referred to by a [SoftReference] and have already been garbage collected + */ + fun countCacheEntriesForDebug(): Triple { + return readLock { + var strongRefs = 0 + var aliveSoftRefs = 0 + var deadSoftRefs = 0 + // Note: Each iteration of the loop is atomic, but this loop is not atomic (new cache entries may be added or existing cache + // entries may be updated by `computeIfAbsent` while this loop is running). If we need this loop to be atomic, we can use + // `writeLock` instead of `readLock`, but we don't need it to be atomic. + cache.values.forEach { + synchronized(it) { + when { + !it.wasEvicted() -> strongRefs++ + it.get() != null -> aliveSoftRefs++ + else -> deadSoftRefs++ + } + } + } + Triple(strongRefs, aliveSoftRefs, deadSoftRefs) + } + } + + @VisibleForTesting + internal fun keyWasEvicted(key: KEY): Boolean { + return readLock { + cache[key]?.let { + synchronized(it) { + it.wasEvicted() + } + } ?: true + } + } + +} + +@NotThreadSafe // Not thread-safe to improve performance. The caller must take care of synchronization when using this class. +private class CacheEntryValue private constructor( + + private var strongRef: VALUE?, + + private var softRef: SoftReference?, // Not null iff strongRef == null + + /** The most recent time period when this [CacheEntryValue] was used. */ + private var lastUsed: Int +) { + + constructor(value: VALUE, currentTimePeriod: Int) : this(strongRef = value, softRef = null, lastUsed = currentTimePeriod) + + fun get(): VALUE? = strongRef ?: softRef!!.get() + + fun setStrongReference(value: VALUE, currentTimePeriod: Int) { + strongRef = value + softRef = null + lastUsed = currentTimePeriod + } + + fun updateToSoftReference() { + if (strongRef != null) { + softRef = SoftReference(strongRef) + strongRef = null + } + } + + fun wasEvicted(): Boolean = (strongRef == null) + + fun valueWasGarbageCollected(): Boolean = (get() == null) + + fun lastUsed() = lastUsed +} diff --git a/compiler/incremental-compilation-impl/test/org/jetbrains/kotlin/incremental/classpathDiff/InMemoryCacheWithEvictionTest.kt b/compiler/incremental-compilation-impl/test/org/jetbrains/kotlin/incremental/classpathDiff/InMemoryCacheWithEvictionTest.kt new file mode 100644 index 00000000000..1a98eb15916 --- /dev/null +++ b/compiler/incremental-compilation-impl/test/org/jetbrains/kotlin/incremental/classpathDiff/InMemoryCacheWithEvictionTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2010-2022 JetBrains s.r.o. and Kotlin Programming Language contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file. + */ + +package org.jetbrains.kotlin.incremental.classpathDiff + +import com.google.common.util.concurrent.AtomicDouble +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.test.fail + +class InMemoryCacheWithEvictionTest { + + @Test + fun testComputeIfAbsent() { + val cache = InMemoryCacheWithEviction(maxTimePeriods = 10, maxMemoryUsageRatio = 1.0, memoryUsageRatio = { 0.5 }) + + // Check when the entries are not yet present + assertEquals("One", cache.computeIfAbsent(1) { "One" }) + assertEquals("Two", cache.computeIfAbsent(2) { "Two" }) + + // Check when the entries are already present + assertEquals("One", cache.computeIfAbsent(1) { fail("Must not run") }) + assertEquals("Two", cache.computeIfAbsent(2) { fail("Must not run") }) + } + + @Test + fun testLeastRecentlyUsedCacheEviction() { + val cache = InMemoryCacheWithEviction(maxTimePeriods = 2, maxMemoryUsageRatio = 1.0, memoryUsageRatio = { 0.5 }) + + // Time period 0 - Cache entry 0 is added, no cache entries are evicted + cache.computeIfAbsent(0) { "Zero" } + cache.evictEntries() + assertFalse(cache.keyWasEvicted(0)) + + // Time period 1 - Cache entry 1 is added, no cache entries are evicted + cache.newTimePeriod() + cache.computeIfAbsent(1) { "One" } + cache.evictEntries() + assertFalse(cache.keyWasEvicted(0)) + assertFalse(cache.keyWasEvicted(1)) + + // Time period 2 - Cache entry 2 is added, cache entry 0 is evicted (because maxTimePeriods = 2) + cache.newTimePeriod() + cache.computeIfAbsent(2) { "Two" } + cache.evictEntries() + assertTrue(cache.keyWasEvicted(0)) + assertFalse(cache.keyWasEvicted(1)) + assertFalse(cache.keyWasEvicted(2)) + + // Time period 3 - Cache entry 1 is evicted + cache.newTimePeriod() + cache.evictEntries() + assertTrue(cache.keyWasEvicted(0)) + assertTrue(cache.keyWasEvicted(1)) + assertFalse(cache.keyWasEvicted(2)) + + // Time period 4 - Cache entry 2 is evicted + cache.newTimePeriod() + cache.evictEntries() + assertTrue(cache.keyWasEvicted(0)) + assertTrue(cache.keyWasEvicted(1)) + assertTrue(cache.keyWasEvicted(2)) + } + + @Test + fun testMemoryUsageLimitCacheEviction() { + val memoryUsageRatio = AtomicDouble(0.5) + val cache = InMemoryCacheWithEviction( + maxTimePeriods = 10, + maxMemoryUsageRatio = 0.8, + memoryUsageRatio = { memoryUsageRatio.get() } + ) + + // Time period 0 - Cache entry 0 is added, no cache entries are evicted + cache.computeIfAbsent(0) { "Zero" } + cache.evictEntries() + assertFalse(cache.keyWasEvicted(0)) + + // Time period 1 - Cache entry 1 is added, no cache entries are evicted + cache.newTimePeriod() + cache.computeIfAbsent(1) { "One" } + cache.evictEntries() + assertFalse(cache.keyWasEvicted(0)) + assertFalse(cache.keyWasEvicted(1)) + + // Memory usage increases to above the limit (maxMemoryUsageRatio = 0.8) + memoryUsageRatio.set(0.9) + + // Time period 2 - Cache entry 2 is added, all cache entries are evicted + cache.newTimePeriod() + cache.computeIfAbsent(2) { "Two" } + cache.evictEntries() + assertTrue(cache.keyWasEvicted(0)) + assertTrue(cache.keyWasEvicted(1)) + assertTrue(cache.keyWasEvicted(2)) + + // Memory usage decreases back to below the limit (maxMemoryUsageRatio = 0.8) + memoryUsageRatio.set(0.5) + + // Time period 3 - Cache entry 3 is added, again no cache entries are evicted + cache.newTimePeriod() + cache.computeIfAbsent(3) { "Three" } + cache.evictEntries() + assertFalse(cache.keyWasEvicted(3)) + } +}