Repository: effekt-lang/effekt
Pull request #450 (2024-04-29 13:29:38)

Cache compiler after stdlib to speed up tests
This Draft PR experiments with caching the compiler after compiling a trivial file.

On my machine, this way I can run all tests (except ML) within 55 seconds (they run _in parallel_, though). For the ML backend the performance of mlton is still the limiting factor.

b-studios: Apparently it did not solve the Text File Busy :)

```
java.io.IOException: Cannot run program "/home/runner/work/effekt/effekt/./out/tests/effekt.chezschemelifttests/patternmatching": error=26, Text file busy
```

Patch:
diff --git a/effekt/shared/src/main/scala/effekt/context/Annotations.scala b/effekt/shared/src/main/scala/effekt/context/Annotations.scala
index 80432a65..7518a99b 100644
--- a/effekt/shared/src/main/scala/effekt/context/Annotations.scala
+++ b/effekt/shared/src/main/scala/effekt/context/Annotations.scala
@@ -70,8 +70,6 @@ object Annotations {
 
   def empty: Annotations = new Annotations(Map.empty)
 
-  private type DB = Map[Annotations.HashKey[Any], Map[Annotation[_, _], Any]]
-
   sealed trait Key[T] { def key: T }
 
   private class HashKey[T](val key: T) extends Key[T] {
@@ -306,8 +304,10 @@ object Annotations {
 trait AnnotationsDB { self: Context =>
 
   private type Annotations = Map[Annotation[_, _], Any]
-  private val annotations: Memoiser[Any, Annotations] = Memoiser.makeIdMemoiser()
-  private def annotationsAt(key: Any): Annotations = annotations.getOrDefault(key, Map.empty)
+  type DB = Memoiser[Any, Map[Annotation[_, _], Any]]
+  var db: DB = Memoiser.makeIdMemoiser()
+
+  private def annotationsAt(key: Any): Map[Annotation[_, _], Any] = db.getOrDefault(key, Map.empty)
 
   /**
    * Copies annotations, keeping existing annotations at `to`
@@ -325,12 +325,12 @@ trait AnnotationsDB { self: Context =>
    */
   def annotate[K, V](key: K, value: Map[Annotation[_, _], Any]): Unit = {
     val anns = annotationsAt(key)
-    annotations.put(key, anns ++ value)
+    db.put(key, anns ++ value)
   }
 
   def annotate[K, V](ann: Annotation[K, V], key: K, value: V): Unit = {
     val anns = annotationsAt(key)
-    annotations.put(key, anns + (ann -> value))
+    db.put(key, anns + (ann -> value))
   }
 
   def annotationOption[K, V](ann: Annotation[K, V], key: K): Option[V] =
diff --git a/effekt/shared/src/main/scala/effekt/context/Context.scala b/effekt/shared/src/main/scala/effekt/context/Context.scala
index 7a30e1c0..aca90b52 100644
--- a/effekt/shared/src/main/scala/effekt/context/Context.scala
+++ b/effekt/shared/src/main/scala/effekt/context/Context.scala
@@ -56,6 +56,9 @@ abstract class Context(val positions: Positions)
   var _config: EffektConfig = _
   def config = _config
 
+  // cache used by tasks to save their results (in addition to information in the AnnotationsDB)
+  var cache: util.Task.Cache = util.Task.emptyCache
+
   // We assume the backend never changes
   lazy val backend = config.backend()
   lazy val compiler = backend.compiler
@@ -104,6 +107,24 @@ abstract class Context(val positions: Positions)
     messaging.buffer = bufferBefore
     (msgs, res)
   }
+
+  /**
+   * The compiler state
+   */
+  case class State(annotations: DB, cache: util.Task.Cache)
+
+  /**
+   * Export the compiler state
+   */
+  def backup: State = State(this.db, this.cache)
+
+  /**
+   * Restores the compiler state from a backup
+   */
+  def restore(s: State) = {
+    db = s.annotations
+    cache = s.cache
+  }
 }
 
 /**
diff --git a/effekt/shared/src/main/scala/effekt/util/Task.scala b/effekt/shared/src/main/scala/effekt/util/Task.scala
index b3fe99f5..5c6a13a3 100644
--- a/effekt/shared/src/main/scala/effekt/util/Task.scala
+++ b/effekt/shared/src/main/scala/effekt/util/Task.scala
@@ -86,13 +86,6 @@ object Task { build =>
     def fingerprint: Long = task.fingerprint(key)
   }
 
-  /**
-   * A heterogenous store from Target to Trace
-   */
-  val db = mutable.HashMap.empty[Target[Any, Any], Trace[Any]]
-
-  def reset(): Unit = db.clear()
-
   case class Info(target: Target[_, _], hash: Long) {
     def isValid: Boolean = target.fingerprint == hash
     override def toString =
@@ -109,13 +102,15 @@ object Task { build =>
     }
   }
 
-  private def coerce[A, B](a: A): B = a.asInstanceOf[B]
+  type Cache = mutable.HashMap[Target[Any, Any], Trace[Any]]
+  def emptyCache = mutable.HashMap.empty[Target[Any, Any], Trace[Any]]
 
-  def get[K, V](target: Target[K, V]): Option[Trace[V]] =
-    coerce(db.get(coerce(target)))
+  private def coerce[A, B](a: A): B = a.asInstanceOf[B]
 
-  def update[K, V](target: Target[K, V], trace: Trace[V]): Unit =
-    db.update(coerce(target), coerce(trace))
+  def get[K, V](target: Target[K, V])(using C: Context): Option[Trace[V]] =
+    coerce(C.cache.get(coerce(target)))
+  def update[K, V](target: Target[K, V], trace: Trace[V])(using C: Context): Unit =
+    C.cache.update(coerce(target), coerce(trace))
 
   /**
    * A trace recording information about computed targets
@@ -135,8 +130,8 @@ object Task { build =>
     trace = extended.distinct
   }
 
-  def compute[K, V](target: Target[K, V])(implicit C: Context): Option[V] = {
-    var before = clearTrace()
+  def compute[K, V](target: Target[K, V])(using C: Context): Option[V] = {
+    val before = clearTrace()
     // log(s"computing for ${target}")
 
     // capture the messages, so they can be replayed later
@@ -154,7 +149,7 @@ object Task { build =>
     res
   }
 
-  def reuse[V](tr: Trace[V])(implicit C: Context): Option[V] = {
+  def reuse[V](tr: Trace[V])(using C: Context): Option[V] = {
     // log(s"reusing ${tr.current.target}")
     // replay the trace
     C.messaging.append(tr.msgs)
@@ -162,9 +157,9 @@ object Task { build =>
     tr.value
   }
 
-  def need[K, V](task: Task[K, V], key: K)(implicit C: Context): Option[V] = need(Target(task, key))
+  def need[K, V](task: Task[K, V], key: K)(using C: Context): Option[V] = need(Target(task, key))
 
-  def need[K, V](target: Target[K, V])(implicit C: Context): Option[V] = get(target) match {
+  def need[K, V](target: Target[K, V])(using C: Context): Option[V] = get(target) match {
     case Some(trace) if !trace.isValid =>
       // log(s"Something changed for ${target}")
       compute(target)
@@ -174,5 +169,6 @@ object Task { build =>
       compute(target)
   }
 
-  def dump() = db.foreach { case (k, v) => println(k.toString + " -> " + v) }
+  def dump()(using C: Context) =
+    C.cache.foreach { case (k, v) => println(k.toString + " -> " + v) }
 }


Test patch:
diff --git a/effekt/jvm/src/test/scala/effekt/EffektTests.scala b/effekt/jvm/src/test/scala/effekt/EffektTests.scala
index 91861894..c940f3f9 100644
--- a/effekt/jvm/src/test/scala/effekt/EffektTests.scala
+++ b/effekt/jvm/src/test/scala/effekt/EffektTests.scala
@@ -35,14 +35,35 @@ trait EffektTests extends munit.FunSuite {
       assertNoDiff(run(input), expected)
     }
 
+  // one shared driver for all tests in this test runner
+  object driver extends effekt.Driver
+
+  lazy val state: driver.context.State = warmup(file("empty.effekt"))
+
+  def warmup(input: File): driver.context.State =
+    val compiler = driver
+    val configs = compiler.createConfig(Seq(
+      "--Koutput", "string",
+      "--compile",
+      "--noexit-on-error",
+      "--backend", backendName,
+      "--out", output.getPath
+    ))
+    configs.verify()
+    compiler.compileFile(input.getPath, configs)
+    compiler.context.backup
+
   def run(input: File): String =
-    val compiler = new effekt.Driver {}
+    val compiler = driver
     val configs = compiler.createConfig(Seq(
       "--Koutput", "string",
       "--backend", backendName,
       "--out", output.getPath
     ))
     configs.verify()
+
+    // reuse state after compiling a trivial file
+    driver.context.restore(state)
     compiler.compileFile(input.getPath, configs)
     configs.stringEmitter.result()