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

Floats let bindings #76

Open
wants to merge 24 commits into
base: main
Choose a base branch
from
Open

Conversation

mikesol
Copy link
Contributor

@mikesol mikesol commented Jul 31, 2023

Floats let bindings when strictness allows. This makes it easier to reason about BackendSemantics because, when we case on something, there's a higher chance that its subtree won't have closures from let bindings.

@natefaubion
Copy link
Collaborator

natefaubion commented Aug 4, 2023

I'm assuming the top-level special case is to avoid turning

foo = { a: let x = ... in bar x }

into

foo = let x = ... in let $0 = bar x in { a: $0 }

Because at the top-level it is still advantageous to deref into these structures. If we float here, then it may prevent access into otherwise statically known fields. Is that correct?

If so, I have a few thoughts:

  • One is that we just float at the top-level where these floated bindings become additional top-level bindings. The downside here is that it can potentially create a lot of additional top-level bindings that need to be exported. I don't know if this is actually a problem. This would require some interface changes as an optimized binding could emit multiple bindings, which may incur an additional rewrite at the end. This rewrite could be done as part of freeze potentially, so I don't think it would be an additional pass.
  • We could still provide access to static fields in this case through an alternative ExternImpl. For example, currently we have ExternDict, which is a top-level record. We could have a variation of this that goes through the lets and only exposes the fields which have no dependencies on such let bindings. This would be straightforward to do with the usage analysis that's already there.
  • Is immediate top-level syntax the actual designation we want? I could see top-level evaluation in general as a useful context. For example, you could have an annotation stating that some binding should be evaluated as much as possible up-to a lambda (this is what prepack is). Top-level syntax to me seems only useful for this particular artifact, so I'd like to explore other options that might make sense.

Additionally, on main, I've merged a --timing flag to help judge the effects of various changes. Running npm run build in backend-es being my main benchmark. This change seems to add several seconds to the build time to me (about 15% total slowdown), which I assume is related to the Free encoding. I think that it's a cleverly terse way of expressing this, but I suspect it's quite heavyweight at runtime. In general, I am avoiding abstractions like these in favor of straightline cases and loops (as much as possible) to keep performance relatively predictable.

Edit: I think an additional reason it might be slow is that this is relying on traverse, which build balanced tree of effects, where in the case of let bindings we always want right associated continuations. So we have to essentially build the intermediate Free tree, and then another pass to rewrite the tree to be right associated (resume), and then build the actual continuations. If this was just a foldr like in the other cases, and how I had suggested, this would be one shot.

@natefaubion
Copy link
Collaborator

Can you remind why this is specifically done in eval? Is this only because of recursive eval? In the past I asked if we could unify let floating into a single pass as right now it's split between eval and build. The reason I ask, is that let floating in eval is somewhat problematic because you'll have these steps:

  • build initial syntax
  • eval to floated lets
  • build syntax for inline analysis
  • eval to float back in bindings

Whereas if let floating is done only in build you would be able to float and do inline analysis in the same pass, only requiring the one subsequent eval. This makes me question if efficient let floating should all be done in build rather how we are splitting it now.

@natefaubion
Copy link
Collaborator

FWIW, I think the main reason why we couldn't do all floating in eval is because evalAssocLet is not recursive. It only floats out the immediate let, rather than recursively floating out lets within the binding.

@mikesol
Copy link
Contributor Author

mikesol commented Aug 5, 2023

Ah, I knew I had forgotten something. In the original PR I had done this: https://github.com/mikesol/purescript-backend-optimizer/blob/63fca1f8d71c888a4250d4ba76b8c77b3a9f3007/src/PureScript/Backend/Optimizer/Semantics.purs#L583C1-L594C15. I just added it back for completenesses sake to evaluate the approach in full, I'll address your question about build in a second comment.

@mikesol
Copy link
Contributor Author

mikesol commented Aug 5, 2023

Can you remind why this is specifically done in eval? Is this only because of recursive eval?

Yeah, that's why. I had tried to put everything into eval (including the rewrites) but, for some reason, I couldn't replicate the behavior of the rewrite rule during the eval stage. We can try adding it back (on the Let branch of eval) and see what goes awry.

The issue with putting it all in build if I remember correctly is that lets don't move upward in one pass. For example, if I have:

let x = 42 in {a: {b: {c:{d:{e:{f:let y=43 in y+x}}}}}}

Obv this example would get inlined anyway but imagine that it didn't and the optimizer chose let floating. This approach, with the newly minded recursive evalAssocLet, makes the let bubble up during the first eval pass, whereas doing it in build would ratchet it up one level per pass IIRC.

So to summarize:

  • I'm definitely open to ditching the free monad approach in favor of something that doesn't have two passes. I'm sure there's a way to do this, I just couldn't think of it immediately and free monads make stuff easier to write and read at the expense of bad performance.
  • I'm ok with putting all this logic into build if we can avoid the situation where there are many more runs of optimize as the binding moves up the tree.

@@ -613,12 +613,10 @@ evalAssocLet :: Env -> BackendSemantics -> (Env -> BackendSemantics -> BackendSe
evalAssocLet env sem go = case sem of
SemLet ident val k ->
SemLet ident val \nextVal1 ->
makeLet Nothing (k nextVal1) \nextVal2 ->
go (bindLocal (bindLocal env (One nextVal1)) (One nextVal2)) nextVal2
evalAssocLet env (k nextVal1) go
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not sure this is recursive in the right way. I think to reassociate left-associated lets, would you want to recurse greedily on val rather than under the continuation?

@natefaubion
Copy link
Collaborator

natefaubion commented Aug 5, 2023

Obv this example would get inlined anyway but imagine that it didn't and the optimizer chose let floating. This approach, with the newly minded recursive evalAssocLet, makes the let bubble up during the first eval pass, whereas doing it in build would ratchet it up one level per pass IIRC.

I don't think this is necessarily true:

  • build record with f, float the let out of the record.
  • build record with e, float the floated let out of the record.
  • Continue...

If it's not floating in one pass (floating is bubbling lets bottom up), then that just seems to be that you're missing other floating rules within build, such as when you build an array.

But now that I think about it, I'm not sure if this works well with levels. If you pull out multiple lets in a record/array, they will all have the same levels (since they are essentially parallel evaluation contexts), so you can't naively just put them in the same block, as the references won't make any sense any more, you would have to do additional book keeping to propagate levels to the right places, which... is exactly what eval is doing. So that makes me think that floating should be in eval, and we should make a left recursive evalAssocLet, which I think would obviate the need for the build rewrites.

runFloatLetsWithEnv atTop ee ff = go ff ee
where
go = resume >>> case _ of
Left (FloatMe sem f) -> \e1 -> if atTop then go (f sem) e1 else guardFail sem \sem' -> evalAssocLet e1 sem' \e2 v -> go (f v) e2
Copy link
Collaborator

Choose a reason for hiding this comment

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

evalAssocLet already guards on Fail, so the guardFail check here is unnecessary.

@natefaubion
Copy link
Collaborator

natefaubion commented Aug 5, 2023

I think before moving forward more with this, I myself would like to take some time to understand and reason about eval-based let floating with regards to eliminating the build rewrite constraints in it's own PR.

@mikesol
Copy link
Contributor Author

mikesol commented Aug 20, 2023

Ready for review 👍

Comment on lines +4 to +5
const $2 = f();
const $3 = g();
Copy link
Collaborator

Choose a reason for hiding this comment

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

I still find this odd that these definitions are being swapped around. I'm not sure if that's incorrect, it just seems like a strange artifact. Do you have any idea what is happening here? It may be easier to tell if all the invocations were different functions.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just making sure things are correct & then I'll respond to the point about why this is happening:

In the old one, 0 == g, 1 == f, 2 == g, 3 == f, 4 == g, so the final result of 0,1,2,3,4 was g,f,g,f,g.
In the current one, 0 == g, 1 == f, 2 == f, 3 == g, 4 == f, so the final result of 0,2,3,1,4 is g, f, g, f, g.

So it's correct, but as to why it's happening, I don't know.

Here's the trace from the new one:

== Snapshot.FunctionCompose03 ==================================================
================================================================================

++ Snapshot.FunctionCompose03.test4 Step 1 (Original) ++++++++++++++++++++++++++
  \f@0 g@1 ->
    Snapshot.FunctionCompose03.compose
      ( Snapshot.FunctionCompose03.compose
        ( Snapshot.FunctionCompose03.compose
          (g@1 Data.Unit.unit)
          (f@0 Data.Unit.unit)
        )
        (g@1 Data.Unit.unit)
      )
      ( Snapshot.FunctionCompose03.compose
        (f@0 Data.Unit.unit)
        (g@1 Data.Unit.unit)
      )

-- Snapshot.FunctionCompose03.test4 Step 2 -------------------------------------
  \f@0 g@1 ->
    let @2 = g@1 #[prim.undefined] in
    let @3 = f@0 #[prim.undefined] in
    let @4 = f@0 #[prim.undefined] in
    {- Inline -}
    let @5 = \x@5 -> @2 (@4 x@5) in
    {- Inline -}
    let
      @6 =
        let
          @6 =
            {- Inline -}
            let
              @6 =
                let @6 = g@1 #[prim.undefined] in
                \x@7 -> @5 (@6 x@7) in
            @6 in
        let @7 = g@1 #[prim.undefined] in
        {- Inline -}
        let @8 = \x@8 -> @3 (@7 x@8) in
        \x@9 -> @6 (@8 x@9) in
    @6

-- Snapshot.FunctionCompose03.test4 Step 3 -------------------------------------
  \f@0 g@1 ->
    let @2 = g@1 #[prim.undefined] in
    let @3 = f@0 #[prim.undefined] in
    let @4 = f@0 #[prim.undefined] in
    let @5 = g@1 #[prim.undefined] in
    {- Inline -}
    let
      @6 =
        \x@6 ->
          {- Inline -}
          let @7 = @5 x@6 in
          @2 (@4 @7) in
    let @7 = g@1 #[prim.undefined] in
    \x@8 -> @6 (@3 (@7 x@8))

-- Snapshot.FunctionCompose03.test4 Step 4 -------------------------------------
  \f@0 g@1 ->
    let @2 = g@1 #[prim.undefined] in
    let @3 = f@0 #[prim.undefined] in
    let @4 = f@0 #[prim.undefined] in
    let @5 = g@1 #[prim.undefined] in
    let @6 = g@1 #[prim.undefined] in
    \x@7 ->
      {- Inline -}
      let @8 = @3 (@6 x@7) in
      @2 (@4 (@5 @8))

-- Snapshot.FunctionCompose03.test4 Step 5 (Final) -----------------------------
  \f@0 g@1 ->
    let @2 = g@1 #[prim.undefined] in
    let @3 = f@0 #[prim.undefined] in
    let @4 = f@0 #[prim.undefined] in
    let @5 = g@1 #[prim.undefined] in
    let @6 = g@1 #[prim.undefined] in
    \x@7 -> @2 (@4 (@5 (@3 (@6 x@7))))

and here's the old one:

== Snapshot.FunctionCompose03 ==================================================
================================================================================

++ Snapshot.FunctionCompose03.test4 Step 1 (Original) ++++++++++++++++++++++++++
  \f@0 g@1 ->
    Snapshot.FunctionCompose03.compose
      ( Snapshot.FunctionCompose03.compose
        ( Snapshot.FunctionCompose03.compose
          (g@1 Data.Unit.unit)
          (f@0 Data.Unit.unit)
        )
        (g@1 Data.Unit.unit)
      )
      ( Snapshot.FunctionCompose03.compose
        (f@0 Data.Unit.unit)
        (g@1 Data.Unit.unit)
      )

-- Snapshot.FunctionCompose03.test4 Step 2 -------------------------------------
  \f@0 g@1 ->
    let @2 = g@1 #[prim.undefined] in
    let @3 = f@0 #[prim.undefined] in
    {- Inline -}
    let @4 = \x@4 -> @2 (@3 x@4) in
    let
      @5 =
        {- Inline -}
        let
          @5 =
            let @5 = g@1 #[prim.undefined] in
            \x@6 -> @4 (@5 x@6) in
        @5 in
    {- Inline -}
    let
      @6 =
        let @6 = f@0 #[prim.undefined] in
        let
          @7 =
            let @7 = g@1 #[prim.undefined] in
            {- Inline -}
            let @8 = \x@8 -> @6 (@7 x@8) in
            @8 in
        \x@8 -> @5 (@7 x@8) in
    @6

-- Snapshot.FunctionCompose03.test4 Step 3 -------------------------------------
  \f@0 g@1 ->
    let @2 = g@1 #[prim.undefined] in
    let @3 = f@0 #[prim.undefined] in
    let @4 = g@1 #[prim.undefined] in
    {- Inline -}
    let
      @5 =
        \x@5 ->
          {- Inline -}
          let @6 = @4 x@5 in
          @2 (@3 @6) in
    let @6 = f@0 #[prim.undefined] in
    let @7 = g@1 #[prim.undefined] in
    {- Inline -}
    let @8 = \x@8 -> @6 (@7 x@8) in
    \x@9 -> @5 (@8 x@9)

-- Snapshot.FunctionCompose03.test4 Step 4 -------------------------------------
  \f@0 g@1 ->
    let @2 = g@1 #[prim.undefined] in
    let @3 = f@0 #[prim.undefined] in
    let @4 = g@1 #[prim.undefined] in
    let @5 = f@0 #[prim.undefined] in
    let @6 = g@1 #[prim.undefined] in
    \x@7 ->
      {- Inline -}
      let @8 = @5 (@6 x@7) in
      @2 (@3 (@4 @8))

-- Snapshot.FunctionCompose03.test4 Step 5 (Final) -----------------------------
  \f@0 g@1 ->
    let @2 = g@1 #[prim.undefined] in
    let @3 = f@0 #[prim.undefined] in
    let @4 = g@1 #[prim.undefined] in
    let @5 = f@0 #[prim.undefined] in
    let @6 = g@1 #[prim.undefined] in
    \x@7 -> @2 (@3 (@4 (@5 (@6 x@7))))

Just reading through those, I can't figure out exactly what's going on yet.

Copy link
Collaborator

@natefaubion natefaubion Aug 21, 2023

Choose a reason for hiding this comment

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

It's odd to me that in Step 2 there are a bunch of left-associative binds (level 6). I wouldn't expect that to happen since eval should be reassociating all binds. It looks like this is happening in the second case as well though.

@natefaubion
Copy link
Collaborator

I haven't forgotten about this, and this is something I want to integrate. This PR handles the top-level flattening of recursive binding groups by encoding information in the name and parsing it back out later. This is not something I want to merge, but I assume it was done to avoid refactoring this module. I've been working on this refactoring in some WIP commits, but I have not fully completed it yet due to life demands. I will try to get back to it as soon as I can.

@mikesol
Copy link
Contributor Author

mikesol commented Sep 22, 2023 via email

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.

2 participants