-
Notifications
You must be signed in to change notification settings - Fork 12
rebuild cache on MapRef, fix cancellation defects #369
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Open
Changes from all commits
Commits
Show all changes
43 commits
Select commit
Hold shift + click to select a range
abe983f
Rebuild cache on MapRef, fix cancellation defects
stasimus c5bf31a
Document cache algorithm and defects it fixes
stasimus f58fed2
Address review comments, add JMH benchmark module
stasimus f96194f
Drop vendored old cache from benchmark, document results
stasimus 30fca97
Document comparing benchmark against another revision
stasimus 57f3f8b
Add before and after benchmark results
stasimus df059fa
Assert evicted load fails, stop unhandled error noise
stasimus ee87939
Fix cleanup interval, correct remove and clear docs
stasimus 7699053
align text in README to improve its readability in raw form
mr-git 96d257e
Add cancellation benchmarks, document results
stasimus 1bf6d2c
Release modify's published value when remove wins commit
stasimus c87f682
Poll for eviction instead of racing the cleanup tick
stasimus 8aa8a85
Signal eviction via release callback, format, sync docs
stasimus bb90414
Update README.md
stasimus deec8a8
Apply review comment
stasimus b47df43
Revert "Apply review comment"
stasimus 7480d58
Restore scache versionPolicyIntention lost in master merge
stasimus 0bf1616
List binary breaks explicitly instead of disabling check
stasimus 58cdc16
Update scache/src/main/scala/com/evolution/scache/ExpiringCache.scala
stasimus 2bda4ba
Update scache/src/main/scala/com/evolution/scache/ExpiringCache.scala
stasimus 5e49921
Update scache/src/main/scala/com/evolution/scache/ExpiringCache.scala
stasimus 99815b9
Update scache/src/main/scala/com/evolution/scache/ExpiringCache.scala
stasimus 5f4bb56
Update scache/src/main/scala/com/evolution/scache/ExpiringCache.scala
stasimus 6a7ad8c
Update scache/src/main/scala/com/evolution/scache/ExpiringCache.scala
stasimus 73dc466
Update scache/src/main/scala/com/evolution/scache/ExpiringCache.scala
stasimus 6c8029c
Update scache/src/main/scala/com/evolution/scache/LoadingCache.scala
stasimus e5bdd1f
Fix broken suggestion, test loadingTimeout in ExpiringCacheSpec
stasimus 4ca8ffe
Update scache/src/test/scala/com/evolution/scache/ExpiringCacheSpec.s…
stasimus ebf4920
Update scache/src/main/scala/com/evolution/scache/ExpiringCache.scala
stasimus 3fcc5f3
Update scache/src/test/scala/com/evolution/scache/ExpiringCacheSpec.s…
stasimus 5c43c6b
Use whenA in touch, keep Sync[F].delay in spec
stasimus f6458e8
run `scalafmtRepo` once on `fmt`
mr-git 2be044d
Update benchmark/src/main/scala/com/evolution/scache/bench/CacheBench…
stasimus 4abce9e
Update scache/src/main/scala/com/evolution/scache/Cache.scala
stasimus e5e6633
Update scache/src/main/scala/com/evolution/scache/Cache.scala
stasimus 7597235
Update scache/src/main/scala/com/evolution/scache/SerialMap.scala
stasimus 6f9c27a
Update scache/src/main/scala/com/evolution/scache/SerialMap.scala
stasimus 5d4bf34
Update scache/src/test/scala/com/evolution/scache/CacheSpec.scala
stasimus 3d17584
Update scache/src/test/scala/com/evolution/scache/CacheSpec.scala
stasimus 4f9095a
Update scache/src/test/scala/com/evolution/scache/CacheSpec.scala
stasimus 60b3bf2
Document that loadingTimeout does not cover clear or release
stasimus 2b65fe9
Fix build after benchmark suggestion, sync docs
stasimus 996bdca
Merge master into experimenting
stasimus File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
285 changes: 285 additions & 0 deletions
285
benchmark/src/main/scala/com/evolution/scache/bench/CacheBenchmark.scala
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,285 @@ | ||
| package com.evolution.scache.bench | ||
|
|
||
| import cats.effect.implicits.* | ||
| import cats.effect.unsafe.implicits.global | ||
| import cats.effect.{Deferred, IO} | ||
| import cats.syntax.all.* | ||
| import com.evolution.scache.{Cache, ExpiringCache} | ||
| import org.openjdk.jmh.annotations.* | ||
|
|
||
| import java.util.concurrent.TimeUnit | ||
| import scala.concurrent.duration.* | ||
|
|
||
| /** | ||
| * Cache operations under contention. | ||
| * | ||
| * One invocation is the whole workload, `Fibers` fibers running `OpsPerFiber` cache operations | ||
| * each, so the reported number is cache operations per second, contention included. | ||
| * | ||
| * A full run takes under ten minutes, hence the modest iteration counts: the numbers are good | ||
| * enough to compare implementations or spot a regression, not to split hairs over a few percent. | ||
| * | ||
| * {{{ | ||
| * sbt "benchmark/Jmh/run" | ||
| * sbt "benchmark/Jmh/run -p flavor=partitioned .*getOrUpdateHitRandom.*" | ||
| * }}} | ||
| */ | ||
| object CacheBenchmark { | ||
|
|
||
| final val Fibers = 8 | ||
| final val OpsPerFiber = 20000 | ||
| final val Ops = 160000 | ||
| final val KeySpace = 10000 | ||
|
|
||
| private val fiberIndices = (0 until Fibers).toList | ||
| private val opIndices = (0 until OpsPerFiber).toList | ||
|
|
||
| /** | ||
| * Key of the `n`-th operation of a scenario walking the key space pseudo-randomly. | ||
| * | ||
| * A hash of the operation index rather than a random number, so that every implementation and | ||
| * every iteration sees the very same key sequence, which is what makes the numbers comparable, | ||
| * and so that no shared random generator sits between the fibers and the cache. | ||
| */ | ||
| def key(fiber: Int, i: Int): Int = { | ||
| val n = fiber * OpsPerFiber + i | ||
| val h = n * 0x9e3775cd | ||
| ((h ^ (h >>> 16)) & Int.MaxValue) % KeySpace | ||
| } | ||
|
|
||
| def parRun(op: (Int, Int) => IO[Unit]): IO[Unit] = { | ||
| fiberIndices.parTraverse_ { fiber => | ||
| opIndices.traverse_ { i => op(fiber, i) } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Cache under benchmark, allocated once per trial. | ||
| * | ||
| * `flavor` selects how it is put together: `Cache.loading` with a single partition, the partitioned | ||
| * `Cache.loading`, or the partitioned `Cache.expiring` with expiration far enough away not to | ||
| * interfere. | ||
| */ | ||
| @State(Scope.Benchmark) | ||
| abstract class CacheState { | ||
|
|
||
| @Param(Array("single", "partitioned", "expiring")) | ||
| var flavor: String = "partitioned" | ||
|
|
||
| var cache: Cache[IO, Int, Int] = null | ||
|
|
||
| private var release: IO[Unit] = IO.unit | ||
|
|
||
| private def resource = { | ||
| val expireAfterRead = 1.hour | ||
| flavor match { | ||
| case "single" => Cache.loading[IO, Int, Int](partitions = 1) | ||
| case "partitioned" => Cache.loading[IO, Int, Int] | ||
| case "expiring" => Cache.expiring[IO, Int, Int](ExpiringCache.Config[IO, Int, Int](expireAfterRead)) | ||
| case flavor => sys.error(s"unknown flavor=$flavor") | ||
| } | ||
| } | ||
|
|
||
| @Setup(Level.Trial) | ||
| def allocate(): Unit = { | ||
| val (cache, release) = resource.allocated.unsafeRunSync() | ||
| this.cache = cache | ||
| this.release = release | ||
| } | ||
|
|
||
| @TearDown(Level.Trial) | ||
| def free(): Unit = release.unsafeRunSync() | ||
| } | ||
|
|
||
| /** | ||
| * Cache emptied before every invocation, so that the scenarios adding keys always take the path of | ||
| * a missing key. | ||
| */ | ||
| @State(Scope.Benchmark) | ||
| class EmptyCacheState extends CacheState { | ||
|
|
||
| @Setup(Level.Invocation) | ||
| def empty(): Unit = cache.clear.flatten.unsafeRunSync() | ||
| } | ||
|
|
||
| /** | ||
| * Cache holding the whole key space, refilled between the iterations, so that the scenarios reading | ||
| * or replacing keys always take the path of a present key. | ||
| */ | ||
| @State(Scope.Benchmark) | ||
| class PopulatedCacheState extends CacheState { | ||
|
|
||
| @Setup(Level.Iteration) | ||
| def populate(): Unit = { | ||
| (0 until CacheBenchmark.KeySpace) | ||
| .toList | ||
| .traverse_ { key => cache.put(key, key).flatten } | ||
| .unsafeRunSync() | ||
| } | ||
| } | ||
|
|
||
| @BenchmarkMode(Array(Mode.Throughput)) | ||
| @OutputTimeUnit(TimeUnit.SECONDS) | ||
| @OperationsPerInvocation(160000) | ||
| @Warmup(iterations = 1, time = 3, timeUnit = TimeUnit.SECONDS) | ||
| @Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS) | ||
| @Fork(1) | ||
| @Threads(1) | ||
| class CacheBenchmark { | ||
|
|
||
| import CacheBenchmark.* | ||
|
|
||
| @Benchmark | ||
| def getOrUpdateInsertDistinctKeys(state: EmptyCacheState): Unit = { | ||
| parRun { (fiber, i) => | ||
| val key = fiber * OpsPerFiber + i | ||
| state.cache.getOrUpdate(key)(key.pure[IO]).void | ||
| }.unsafeRunSync() | ||
| } | ||
|
|
||
| @Benchmark | ||
| def putInsertDistinctKeys(state: EmptyCacheState): Unit = { | ||
| parRun { (fiber, i) => | ||
| val key = fiber * OpsPerFiber + i | ||
| state.cache.put(key, i).flatten.void | ||
| }.unsafeRunSync() | ||
| } | ||
|
|
||
| @Benchmark | ||
| def modifyInsertDistinctKeys(state: EmptyCacheState): Unit = { | ||
| parRun { (fiber, i) => | ||
| val key = fiber * OpsPerFiber + i | ||
| state.cache.modify(key) { _ => ((), Cache.Directive.Put(i, none)) }.void | ||
| }.unsafeRunSync() | ||
| } | ||
|
|
||
| /** | ||
| * Not the same as [[getHitRandomKeys]]: `getOrUpdate` of a key that is already there still has to | ||
| * decide between a hit and a miss, which is where the old implementation touched the shared `Ref` | ||
| * even though it ended up returning a cached value. | ||
| */ | ||
| @Benchmark | ||
| def getOrUpdateHitRandomKeys(state: PopulatedCacheState): Unit = { | ||
| parRun { (fiber, i) => | ||
| val k = key(fiber, i) | ||
| state.cache.getOrUpdate(k)(k.pure[IO]).void | ||
| }.unsafeRunSync() | ||
| } | ||
|
|
||
| @Benchmark | ||
| def getOrUpdateHitSingleHotKey(state: PopulatedCacheState): Unit = { | ||
| parRun { (_, _) => state.cache.getOrUpdate(0)(0.pure[IO]).void }.unsafeRunSync() | ||
| } | ||
|
|
||
| @Benchmark | ||
| def getHitRandomKeys(state: PopulatedCacheState): Unit = { | ||
| parRun { (fiber, i) => state.cache.get(key(fiber, i)).void }.unsafeRunSync() | ||
| } | ||
|
|
||
| @Benchmark | ||
| def get1HitRandomKeys(state: PopulatedCacheState): Unit = { | ||
| parRun { (fiber, i) => state.cache.get1(key(fiber, i)).void }.unsafeRunSync() | ||
| } | ||
|
|
||
| @Benchmark | ||
| def containsRandomKeys(state: PopulatedCacheState): Unit = { | ||
| parRun { (fiber, i) => state.cache.contains(key(fiber, i)).void }.unsafeRunSync() | ||
| } | ||
|
|
||
| @Benchmark | ||
| def putReplaceRandomKeys(state: PopulatedCacheState): Unit = { | ||
| parRun { (fiber, i) => state.cache.put(key(fiber, i), i).flatten.void }.unsafeRunSync() | ||
| } | ||
|
|
||
| @Benchmark | ||
| def modifyUpdateRandomKeys(state: PopulatedCacheState): Unit = { | ||
| parRun { (fiber, i) => | ||
| state | ||
| .cache | ||
| .modify(key(fiber, i)) { | ||
| case Some(value) => ((), Cache.Directive.Put(value + 1, none)) | ||
| case None => ((), Cache.Directive.Ignore) | ||
| } | ||
| .void | ||
| }.unsafeRunSync() | ||
| } | ||
|
|
||
| @Benchmark | ||
| def removeAndPutRandomKeys(state: PopulatedCacheState): Unit = { | ||
| parRun { (fiber, i) => | ||
| val k = key(fiber, i) | ||
| state.cache.remove(k).flatten *> state.cache.put(k, i).flatten.void | ||
| }.unsafeRunSync() | ||
| } | ||
|
|
||
| @Benchmark | ||
| def mixedRandomKeys(state: PopulatedCacheState): Unit = { | ||
| parRun { (fiber, i) => | ||
| val k = key(fiber, i) | ||
| (i % 10) match { | ||
| case 0 => state.cache.put(k, i).flatten.void | ||
| case 1 => state.cache.remove(k).flatten.void | ||
| case 2 => state.cache.modify(k) { _ => ((), Cache.Directive.Put(i, none)) }.void | ||
| case 3 | 4 => state.cache.get(k).void | ||
| case _ => state.cache.getOrUpdate(k)(i.pure[IO]).void | ||
| } | ||
| }.unsafeRunSync() | ||
| } | ||
|
|
||
| /** | ||
| * Cancellation of an in-flight load: install a `Loading` entry, wait until the load has actually | ||
| * started, cancel it, which unlinks the key and completes the entry's `Deferred`. | ||
| * | ||
| * One operation is the whole start-load-cancel cycle. No number to compare against before the | ||
| * `MapRef` rewrite: loads were not cancelable there, so this scenario would hang. | ||
| */ | ||
| @Benchmark | ||
| def getOrUpdateCancelDistinctKeys(state: EmptyCacheState): Unit = { | ||
| parRun { (fiber, i) => | ||
| val key = fiber * OpsPerFiber + i | ||
| Deferred[IO, Unit].flatMap { started => | ||
| state | ||
| .cache | ||
| .getOrUpdate(key) { started.complete(()) *> IO.never } | ||
| .start | ||
| .flatMap { loading => started.get *> loading.cancel } | ||
| } | ||
| }.unsafeRunSync() | ||
| } | ||
|
|
||
| /** | ||
| * Same as [[getOrUpdateCancelDistinctKeys]], but with another fiber blocked on the loading entry | ||
| * when the load is canceled, so the cycle also covers unblocking the waiter, which either fails | ||
| * with the cancellation error or repeats the lookup and installs its own value. | ||
| */ | ||
| @Benchmark | ||
| def getOrUpdateCancelWithWaiter(state: EmptyCacheState): Unit = { | ||
| parRun { (fiber, i) => | ||
| val key = fiber * OpsPerFiber + i | ||
| Deferred[IO, Unit].flatMap { started => | ||
| for { | ||
| loading <- state.cache.getOrUpdate(key) { started.complete(()) *> IO.never }.start | ||
| _ <- started.get | ||
| waiter <- state.cache.getOrUpdate(key)(i.pure[IO]).attempt.start | ||
| _ <- loading.cancel | ||
| _ <- waiter.joinWithNever | ||
| } yield () | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| }.unsafeRunSync() | ||
| } | ||
|
|
||
| /** | ||
| * Enumeration of the whole cache, one full traversal of `KeySpace` entries per operation, hence | ||
| * measured per traversal rather than per key. | ||
| */ | ||
| @Benchmark | ||
| @OperationsPerInvocation(1) | ||
| def foldMapWholeCache(state: PopulatedCacheState): Unit = { | ||
| state | ||
| .cache | ||
| .foldMap { case (_, value) => value.fold(identity, _.pure[IO]) } | ||
| .void | ||
| .unsafeRunSync() | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.