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
This commit is contained in:
+1
-1
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+25
-31
@@ -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<File, ClasspathEntrySnapshot>()
|
||||
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<File, ClasspathEntrySnapshot>(maxTimePeriods = 20, maxMemoryUsageRatio = 0.8)
|
||||
|
||||
fun load(classpathEntrySnapshotFiles: List<File>, 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<File>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+188
@@ -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<KEY, VALUE>(
|
||||
|
||||
/**
|
||||
* 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<KEY, CacheEntryValue<VALUE>>()
|
||||
|
||||
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 <VALUE> 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<Int, Int, Int> {
|
||||
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<VALUE> private constructor(
|
||||
|
||||
private var strongRef: VALUE?,
|
||||
|
||||
private var softRef: SoftReference<VALUE>?, // 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
|
||||
}
|
||||
+110
@@ -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<Int, Any>(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<Int, Any>(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<Int, Any>(
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user