Skip to content
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

remove casts from Eval, fix stack overflow in Eval #3519

Merged
merged 5 commits into from
Jul 16, 2020
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 40 additions & 35 deletions core/src/main/scala/cats/Eval.scala
Original file line number Diff line number Diff line change
Expand Up @@ -263,26 +263,13 @@ object Eval extends EvalInstances {
* of FlatMap nodes) or "value" (in the case of Now, Later, or
* Always nodes).
*/
@tailrec private def advance[A](fa: Eval[A]): Eval[A] =
fa match {
@tailrec private def advance[A](fa: Defer[A]): Eval[A] =
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a private method, and we only need to call it on Defer[A] to return a non-Defer result. This simplifies the code since I don't see any reason we need to deal with FlatMap as we did.

fa.thunk() match {
case call: Eval.Defer[A] =>
advance(call.thunk())
case compute: Eval.FlatMap[A] =>
new Eval.FlatMap[A] {
type Start = compute.Start
val start: () => Eval[Start] = () => compute.start()
val run: Start => Eval[A] = s => advance1(compute.run(s))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@non can you see why we did this? Can you write a test that shows the need? Removing it is a bit more efficient since we don't add a level of stack to all FlatMaps we call, BUT note, we should only really need to call advance when we know it is a Defer (note I change the argument type).

}
advance(call)
case other => other
}

/**
* Alias for advance that can be called in a non-tail position
* from an otherwise tailrec-optimized advance.
*/
private def advance1[A](fa: Eval[A]): Eval[A] =
advance(fa)

/**
* FlatMap is a type of Eval[A] that is used to chain computations
* involving .map and .flatMap. Along with Eval#flatMap it
Expand Down Expand Up @@ -318,52 +305,70 @@ object Eval extends EvalInstances {
}
}

private def evaluate[A](e: Eval[A]): A = {
type L = Eval[Any]
type M = Memoize[Any]
type C = Any => Eval[Any]
/*
* This represents the stack of flatmap functions in a series
* of Eval operations
*/
sealed abstract private class FnStack[A, B]
final private case class Ident[A, B](ev: A <:< B) extends FnStack[A, B]
final private case class Many[A, B, C](first: A => Eval[B], rest: FnStack[B, C]) extends FnStack[A, C]

def addToMemo(m: M): C = { (a: Any) =>
private def evaluate[A](e: Eval[A]): A = {
def addToMemo[A1](m: Memoize[A1]): A1 => Eval[A1] = { (a: A1) =>
m.result = Some(a)
Now(a)
}

@tailrec def loop(curr: L, fs: List[C]): Any =
@tailrec def loop[A1](curr: Eval[A1], fs: FnStack[A1, A]): A =
curr match {
case c: FlatMap[_] =>
case c: FlatMap[A1] =>
c.start() match {
case cc: FlatMap[_] =>
loop(cc.start().asInstanceOf[L], cc.run.asInstanceOf[C] :: c.run.asInstanceOf[C] :: fs)
case cc: FlatMap[c.Start] =>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not entirely sure why we don't do:

case c: FlatMap[A1] =>
  loop(c.start(), Many(c.run, fs))

and let the rest of the code run.

That would be correct, but I assume @non inlined this because for a FlatMap we know that we have at least one item on the stack and if we do my suggestion above, we allocate a Many just to pattern match on it in many cases and discard it. So, by nesting the pattern match on the c.start() we often can often sometimes avoid the allocation and pattern match.

If @non has time to chime in, it would be appreciated, I could update a comment to make it clearer.

val nextFs = Many(c.run, fs)
loop(cc.start(), Many(cc.run, nextFs))
case call: Defer[c.Start] =>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this branch needs to be added. Note I added a test to the PR the exercises this. Without this change we blow the stack.

// though the flatMap method handles defer(x).flatMap(f)
// by removing the Defer, we can nest defers,
// so defer(defer(x)).flatMap(f) could mean c.start()
// returns a Defer. We have to handle it here
loop(advance(call), Many(c.run, fs))
case mm @ Memoize(eval) =>
mm.result match {
case Some(a) =>
loop(Now(a), c.run.asInstanceOf[C] :: fs)
loop(c.run(a), fs)
case None =>
loop(eval, addToMemo(mm.asInstanceOf[M]) :: c.run.asInstanceOf[C] :: fs)
val nextFs = Many(c.run, fs)
loop(eval, Many(addToMemo(mm), nextFs))
}
case xx =>
// xx must be Now, Later, Always, all of those
// have safe .value:
loop(c.run(xx.value), fs)
}
case call: Defer[_] =>
case call: Defer[A1] =>
loop(advance(call), fs)
case m @ Memoize(eval) =>
case m: Memoize[a] =>
// a <:< A1
m.result match {
case Some(a) =>
fs match {
case f :: fs => loop(f(a), fs)
case Nil => a
case Many(f, fs) => loop(f(a), fs)
case Ident(ev) => ev(a)
}
case None =>
loop(eval, addToMemo(m.asInstanceOf[M]) :: fs)
loop[a](m.eval, Many[a, A1, A](addToMemo[a](m), fs))
}
case x =>
// Now, Later or Always don't have recursions
// so they have safe .value:
val a1 = x.value
fs match {
case f :: fs => loop(f(x.value), fs)
case Nil => x.value
case Many(f, fs) => loop(f(a1), fs)
case Ident(ev) => ev(a1)
}
}

loop(e.asInstanceOf[L], Nil).asInstanceOf[A]
loop(e, Ident(implicitly[A <:< A]))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here we have 1 allocation more than we did before: Ident where as before we did Nil. But since this is one allocation per call to .value, I think this is well worth the cost considering how many allocations we already make (1 per depth at least to build up the user level stack).

}
}

Expand Down
8 changes: 8 additions & 0 deletions tests/src/test/scala/cats/tests/EvalSuite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ class EvalSuite extends CatsSuite {
spooky.counter should ===(2)
}

test("Defer and FlatMap compose without blowing the stack") {
def inc(a: Eval[Int], count: Int): Eval[Int] =
if (count <= 0) a
else Eval.defer(Eval.defer(inc(a, count - 1))).flatMap { i => Eval.now(i + 1) }

assert(inc(Eval.now(0), 1000000).value == 1000000)
}

{
implicit val iso: SemigroupalTests.Isomorphisms[Eval] =
SemigroupalTests.Isomorphisms.invariant[Eval]
Expand Down