-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
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
Enhances stack safety for Eval. #1888
Conversation
The basic idea is to create very deep chains of operations to try to expose any stack problems. We know that for things like map/flatMap Eval seems to work, so let's just randomly construct deeply-nested Eval expressions to see what happens. This test exposed a weakness with .memoize which is fixed. As part of this commit, I noticed that our Arbitrary[Eval[A]] instances were somewhat weak, so I upgraded them.
Maybe my change to use "real" |
core/src/main/scala/cats/Eval.scala
Outdated
def memoize: Eval[A] = | ||
new Call[A](thunk) { | ||
override def memoize: Eval[A] = this | ||
override lazy val value: A = Call.loop(this).value |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Won't this be lost if you flatMap? Isn't it ignore in that case. Is that okay?
core/src/main/scala/cats/Eval.scala
Outdated
val start: () => Eval[Start] = self.start | ||
val run: Start => Eval[A] = self.run | ||
override def memoize: Eval[A] = this | ||
override lazy val value: A = self.value |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same, this lazy val is lost on flatMap no?
Maybe memoise only applies to an outer flatMap?
I have used it when forking into two paths so I don't evaluate something twice, but this wouldn't actually work with this change I think.
Can we have a test something like this: forAll { (e: Eval[Int], fn: Int => Eval[Int]) =>
var cnt = 0
val action = e.flatMap { i = >
cnt += 1
fn(i)
}.memoize
val res = for {
i1 <- action
i2 <- action
} yield i1 == i2
assert(res.value == true)
assert(cnt == 1) // memoize means we don't build what is up that tree more than once.
} |
@johnynek This is a great observation. Much like the (now-removed) exception-handling, I think if we want to support this kind of intermediate memoization in the current approach, we have to sacrifice constant stack per intermediate memoization. The alternative would be to use a more complicated structure instead of a queue of functions, and then to "update" the various nodes as their results are complete. I'm happy to revert to the previous memoization implementation, especially since I don't think the code @mpilquist was worried about was using |
So, the contract on you can just do That said, I don't quite see why it is impossible, just that this implementation does not have it. For instance, what if we add a |
@johnynek Your I wasn't disagreeing with you -- I actually think that we should not take the change I made, and should either stick with what we have for |
@johnynek Nice! I don't think you even need the |
indeed, that's true, but using the Map means if you do: val foo: Eval[A] = getFoo
val foo1 = foo.memoize
val foo2 = foo.memoize and they wind up in the same Eval DAG, then you can can still only evaluate once. Maybe that is such a corner case it isn't worth it, but it is pretty cheap to have the map (although maybe not worth it since you have to do the map updates for everything). Would you be open to adding something like this to your change? |
Yes! In fact, I"m already integrating it (without the |
This fix was proposed by @johnynek and fixes a bug I introduced with how memoization was handled during flatMap evaluation. Our old implementation did memoize intermediate values correctly (which meant that three different evals mapped from the same source shared a memoized value). However, it was not stack-safe. My fix introduced stack-safety but broke this intermediate memoization. The new approach uses a new node (Eval.Memoize) which can be mutably-updated (much like Later). It's confirmed to be stack-safe and to handle intermediate values correctly. While it was unlikely that anyone was doing enough intermediate memoization to cause actual stack overflows, it's nice to know that this is now impossible.
Codecov Report
@@ Coverage Diff @@
## master #1888 +/- ##
==========================================
- Coverage 95.17% 95.16% -0.01%
==========================================
Files 248 248
Lines 4352 4366 +14
Branches 126 119 -7
==========================================
+ Hits 4142 4155 +13
- Misses 210 211 +1
Continue to review full report at Codecov.
|
this looks great to me! Thanks for tackling this. Seems like a strictly better situation now (assuming perf didn't tank). |
minor suggestion (also made offline): what about renaming |
core/src/main/scala/cats/Eval.scala
Outdated
type Start = compute.Start | ||
val start: () => Eval[Start] = () => compute.start() | ||
val run: Start => Eval[A] = s => doCall1(compute.run(s)) | ||
val run: Start => Eval[A] = s => advance1(compute.run(s)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we need this here? Seems like it will be run later without beating the function call here.
👍 @kailuowang can you also review? |
*/ | ||
@tailrec private def advance[A](fa: Eval[A]): Eval[A] = | ||
fa match { | ||
case call: Eval.Defer[A] => |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
totally nitpick: call
can be renamed to defer
and the compute
below. No need to address in this PR, I can do in a separate one.
m.result match { | ||
case Some(a) => | ||
fs match { | ||
case f :: fs => loop(f(a), fs) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This line isn't tested. I am curious how come the random stack safety stress test didn't hit it, is it expected?
sounds good. I'll merge. I think we are in a good shape. |
The basic idea is to create very deep chains of operations to try to expose any
stack problems. We know that for things like map/flatMap Eval seems to work, so
let's just randomly construct deeply-nested Eval expressions to see what
happens.
This test exposed a weakness with .memoize which is fixed.
As part of this commit, I noticed that our Arbitrary[Eval[A]] instances were
somewhat weak, so I upgraded them.
I was hoping to find something involving
Eval.defer
to fix #1703 which @mpilquist raised, but I couldn't find any weakness besides the things here involving.memoize
.Update by Kai: It seems fixed #1703