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

Make recursive calls to flatMap stack safe #44

Merged
merged 4 commits into from Dec 31, 2019

Conversation

tomp4l
Copy link
Contributor

@tomp4l tomp4l commented Dec 9, 2019

To make recursive algorithms easier to implement this pushes any
nested callbacks in flatMap onto a 'trampoline' which means only one
can run at any time. This is useful when synchronous futures are
created and used in recursive functions.

Without this patch the following test failure would occur:


    RangeError: Maximum call stack size exceeded

      17 |           } else {
      18 |             data[0] = Caml_option.some(result);
    > 19 |             Belt_List.forEach(Belt_List.reverse(callbacks[0]), (function (cb) {
         |                       ^
      20 |                     return Curry._1(cb, result);
      21 |                   }));
      22 |             callbacks[0] = /* [] */0;

      at forEachU (node_modules/bs-platform/lib/js/belt_List.js:785:18)
      at Object.forEach (node_modules/bs-platform/lib/js/belt_List.js:799:10)
      at src/Future.bs.js:19:23
      at Object._1 (node_modules/bs-platform/lib/js/curry.js:65:12)
      at src/Future.bs.js:42:30
      at Object._1 (node_modules/bs-platform/lib/js/curry.js:65:12)
      at make (src/Future.bs.js:13:9)
      at Object.value (src/Future.bs.js:41:10)
      at loop (__tests__/TestFuture.bs.js:364:54)
      at __tests__/TestFuture.bs.js:368:48

resolves #44

@coveralls
Copy link

coveralls commented Dec 9, 2019

Pull Request Test Coverage Report for Build 77

  • 0 of 0 changed or added relevant lines in 0 files are covered.
  • 1 unchanged line in 1 file lost coverage.
  • Overall coverage increased (+0.2%) to 98.901%

Files with Coverage Reduction New Missed Lines %
src/Future.bs.js 1 98.73%
Totals Coverage Status
Change from base Build 76: 0.2%
Covered Lines: 153
Relevant Lines: 154

💛 - Coveralls

@tomp4l
Copy link
Contributor Author

tomp4l commented Dec 9, 2019

Not sure why the second build failed, I only pushed a second test which passed.

@tomp4l
Copy link
Contributor Author

tomp4l commented Dec 9, 2019

Also this would ideally have the interfaces merged as the trampoline should not really be public. See #42

@scull7
Copy link
Collaborator

scull7 commented Dec 10, 2019

I'm not sure we want to add the cost of a trampoline to every invocation of the Future library. Seems that we should think about it as an "opt-in" feature for cases where it would make sense. Elm has a library which does just that: https://github.com/elm-lang/trampoline/blob/master/src/Trampoline.elm

@tomp4l
Copy link
Contributor Author

tomp4l commented Dec 10, 2019

The overhead of the trampoline is very low, for non nested calls it just creates a thunk and immediately invokes it and tries to pop an array, otherwise there is just an additional unshift. For anyone doing anything asynchronous this overhead is likely negligible, a test that runs 100k loops takes well under 1s. For anyone doing anything with synchronous futures this fixes a breaking stack overflow for large nesting.

How would you use that trampoline library with futures? If you have a Trampoline(Future('a)) and flatMap it using jump then you would end up with a Trampoline(Future(Trampoline('a))). Vice versa If you have a Future(Trampoline('a)) and try to flatMap a Jump value you will end up with a Future(Trampoline(Future('a)), it doesn't compose. One option would be to add an optional executionContext similar to Scala to every function but seems like a lot more overhead.

If you don't think the benefits of this feature outweigh the costs then fair enough but this will bite users of your library at some point if they are writing recursive functions using futures.

@scull7
Copy link
Collaborator

scull7 commented Dec 10, 2019

If you don't think the benefits of this feature outweigh the costs then fair enough but this will bite users of your library at some point if they are writing recursive functions using futures.

It's not that I think or believe that the costs will outweigh the benefits, it's the fact that I do not know. Nor do I believe that we can understand the usage of such a low level library to make an all encompassing decision. This is why I made the suggestion that we allow the user of our library to decide for themselves when and if they would like to incur the cost of the additional overhead.

For anyone doing anything asynchronous this overhead is likely negligible, a test that runs 100k loops takes well under 1s

Running a single recursive loop tells us nothing about the user who is utilizing the Future library throughout their code base. We would be making a decision that extra allocation is ok for their use case.

How would you use that trampoline library with futures?

I did not intend to suggest that we utilize the library or it's design pattern within the Future codebase. Merely showing that there are other ways to accomplish the task whereby we allow the user to decide when to incur the cost of the additional allocation.

All of that being said, this really should be implemented as an unrolled while loop to make this debate unnecessary.

http://glat.info/fext/

@tomp4l
Copy link
Contributor Author

tomp4l commented Dec 11, 2019

I'm definitely not an expert in this so I'm not sure I fully follow but the fastest solution in http://glat.info/fext/#section_setup_details_speed_test looks almost identical to this. The only difference being that it will allow the stack to grow to 5 before trampolining.

function runLoop(_callback) {
  while(true) {
    var callback = _callback;
    Curry._1(callback, /* () */0);
    var match = callbacks.pop();
    if (match !== undefined) {
      _callback = match;
      continue ;
    } else {
      return /* () */0;
    }
  };
}

If you have a solution to make this work currently please let me know but with the current implementation I couldn't find an easy way to make things stack safe. Or do you mean an option to allow opt in during make, i.e. provide an optional flag to use or not use trampoline?

@tomp4l
Copy link
Contributor Author

tomp4l commented Dec 11, 2019

Anyway this PR isn't really going anywhere right now so I'll open an issue and link this PR. Then you can make a decision on what changes if any can be made.

@scull7
Copy link
Collaborator

scull7 commented Dec 11, 2019 via email

@scull7
Copy link
Collaborator

scull7 commented Dec 11, 2019 via email

@tomp4l
Copy link
Contributor Author

tomp4l commented Dec 12, 2019

I pushed my latest changes now, I've kept backwards compatibility here. Let me know what you think of the API. I could also add another option to allow user specified executors other than trampoline

type executor = [|`executor(('a, 'a => unit) => unit)]

Again this would ideally be merged with the rei file to hide the internals.

@tomp4l
Copy link
Contributor Author

tomp4l commented Dec 12, 2019

Will need to update docs + not sure if trampoline is intuitive enough, maybe `stackSafe?

src/Future.re Outdated
@@ -21,21 +51,23 @@ let make = resolver => {
Future(
resolve =>
switch (data^) {
| Some(result) => resolve(result)
| Some(result) => trampoline(() => resolve(result))
Copy link
Collaborator

Choose a reason for hiding this comment

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

Doesn't this mean the trampoline still runs on every invocation?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes my mistake, will fix in next commit.

->Future.get(r => r |> expect |> toEqual(numberOfLoops) |> finish);
});

testAsync("async recursion is stack safe", finish => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Make a test using the default executor to ensure it blows the stack and we're not just always using the trampoline

Copy link
Contributor Author

Choose a reason for hiding this comment

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

See "value recursion blows the stack with default executor"

@@ -311,4 +311,38 @@ describe("Future Belt.Result", () => {
|> finish
);
});
});

testAsync("value recursion is stack safe", finish => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Make a deeply nested chain and test that we don't blow the stack there. Something like the following:

next(x)
->Future.flatMap(x => Future.value(x + 1))
->Future.map(x => x + 1)
->Future.flatMap(x => Future.value(x + 1)->Future.flatMap(x => Future.value(x + 1)))

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

src/Future.re Outdated
@@ -1,17 +1,47 @@
type getFn('a) = ('a => unit) => unit;

type executorOptions = [ | `none | `trampoline];
Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe rename to executorType?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think trampoline is good. If you run into the stack problem, trampoline is something you'll definitely find right away.

@tomp4l tomp4l requested a review from scull7 December 12, 2019 21:43
Copy link
Collaborator

@scull7 scull7 left a comment

Choose a reason for hiding this comment

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

Looks good, great work :shipit:

@scull7
Copy link
Collaborator

scull7 commented Dec 12, 2019

@tomp4l @briangorman Once #42 is merged then this is good to go

@tomp4l
Copy link
Contributor Author

tomp4l commented Dec 14, 2019

Will need minor tweaks for the addition of the executor

tom added 4 commits December 17, 2019 11:33
To make recursive algorithms easier to implement this pushes any
nested callbacks in flatMap onto a 'trampoline' which means only one
can run at any time. This is useful when synchronous futures are
created and used in recursive functions.
Use polymorphic variants for ease of use and to support the option
of passing a parameterised version in future with user specified executor

Keeps backwards compatibility.
@tomp4l
Copy link
Contributor Author

tomp4l commented Dec 17, 2019

I've updated the rei file, one change I did make was to make the future type abstract as I think it's more useful than exposing the underlying implementation and will make avoiding breaking changes much easier in future. If you disagree I can add the actual implementation to the interface but it will be a breaking change. As you can see the changes otherwise to the rei are minimal with just addition of optional arguments.

Copy link
Collaborator

@briangorman briangorman left a comment

Choose a reason for hiding this comment

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

Thanks!

@briangorman briangorman merged commit 543bbe6 into RationalJS:master Dec 31, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants