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:
Hung Nguyen
2022-04-02 17:37:44 +01:00
committed by teamcity
parent 889db4cc5d
commit 6d3e679a59
5 changed files with 325 additions and 33 deletions
@@ -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 {
@@ -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
}
}
@@ -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
}
@@ -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))
}
}