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

Enhances stack safety for Eval. #1888

Merged
merged 5 commits into from
Sep 28, 2017
Merged

Conversation

non
Copy link
Contributor

@non non commented Sep 2, 2017

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

non and others added 2 commits September 2, 2017 03:00
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.
@non non added the in progress label Sep 2, 2017
@non
Copy link
Contributor Author

non commented Sep 2, 2017

Maybe my change to use "real" Arbitrary[Eval[A]] instances is killing Travis? Not sure what's going on.

def memoize: Eval[A] =
new Call[A](thunk) {
override def memoize: Eval[A] = this
override lazy val value: A = Call.loop(this).value
Copy link
Contributor

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?

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
Copy link
Contributor

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.

@johnynek
Copy link
Contributor

johnynek commented Sep 2, 2017

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.
}

@non
Copy link
Contributor Author

non commented Sep 2, 2017

@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 memoize. In that case I should just note that calling .memoize on internal nodes should be limited since it does consume stack in some cases.

@johnynek
Copy link
Contributor

johnynek commented Sep 2, 2017

So, the contract on memoize then is that it only applies to a final value? If that's true, why have it at all?

you can just do lazy val myValue = eval.value and get the result right? Or Eval.lazy(eval.value) if you want to return an Eval still. Seems like it is a bit dishonest if it can be discarded.

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 case class Memoized[A](of: Eval[A]) extends Eval[A] node and change the evaluation loop. Maybe that hits similar issues as raise did, but I can imagine if we had a side mutable.Map[Eval[_], _] in the evaluation loop where we cached the value, it might be okay, but making sure we update the map in a safe way might be a challenge

@non
Copy link
Contributor Author

non commented Sep 2, 2017

@johnynek Your Memoized suggestion is what I mean -- we can totally do it but we'll need to complicate the compute logic a fair bit.

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 memoize or pursue a more radical alternative.

@johnynek
Copy link
Contributor

johnynek commented Sep 2, 2017

Here's what I mean @non

bce5de8

@non
Copy link
Contributor Author

non commented Sep 3, 2017

@johnynek Nice!

I don't think you even need the mutable.Map right? Once you update var result in the continuation, that should be good enough, no? (Since unwinding these things is synchronous, there's no real opportunity for a race.)

@johnynek
Copy link
Contributor

johnynek commented Sep 3, 2017

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?

@non
Copy link
Contributor Author

non commented Sep 3, 2017

Yes! In fact, I"m already integrating it (without the Map for now). There was on bug with your thing that I think I've fixed. I'm cleaning it up now.

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-io
Copy link

codecov-io commented Sep 3, 2017

Codecov Report

Merging #1888 into master will decrease coverage by <.01%.
The diff coverage is 97.29%.

Impacted file tree graph

@@            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
Impacted Files Coverage Δ
...rc/main/scala/cats/laws/discipline/Arbitrary.scala 92.06% <100%> (ø) ⬆️
core/src/main/scala/cats/Eval.scala 98.75% <97.05%> (-1.25%) ⬇️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 7e9a183...c9572b2. Read the comment docs.

@johnynek
Copy link
Contributor

johnynek commented Sep 3, 2017

this looks great to me! Thanks for tackling this. Seems like a strictly better situation now (assuming perf didn't tank).

@johnynek
Copy link
Contributor

johnynek commented Sep 3, 2017

minor suggestion (also made offline): what about renaming Call => Defer and Compute => FlatMap which will make it easier to read this code and recall what each are for.

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))
Copy link
Contributor

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.

@johnynek
Copy link
Contributor

👍

@kailuowang can you also review?

@kailuowang kailuowang self-requested a review September 19, 2017 16:50
*/
@tailrec private def advance[A](fa: Eval[A]): Eval[A] =
fa match {
case call: Eval.Defer[A] =>
Copy link
Contributor

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)
Copy link
Contributor

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?

@kailuowang
Copy link
Contributor

not sure if @non has time to work on this PR. I am okay with merging it now and if necessary address the two minor issues later. WDYT @johnynek

@johnynek
Copy link
Contributor

sounds good.

I'll merge. I think we are in a good shape.

@johnynek johnynek merged commit dab28d7 into typelevel:master Sep 28, 2017
@stew stew removed the in progress label Sep 28, 2017
@mpilquist mpilquist mentioned this pull request Sep 28, 2017
@kailuowang kailuowang added this to the 1.0.0-RC1 milestone Oct 13, 2017
@kailuowang kailuowang changed the title Add a stack safety stress test for Eval. Enhances stack safety for Eval. Oct 15, 2017
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

SOE in Eval.defer
6 participants