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

Improve beta reduction in callsite inliner for PIR #5673

Open
wants to merge 5 commits into
base: master
Choose a base branch
from

Conversation

zliu41
Copy link
Member

@zliu41 zliu41 commented Dec 9, 2023

The applyAndBetaReduce function uses safeToBetaReduce to determine whether arguments can be beta-reduced. It is an oversimplified implementation and does not detect beta reductions that take multiple steps to perform.

E.g., given (\x y -> addInteger x y) arg_x arg_y, where arg_x and arg_y are effectful terms, safeToBetaReduce thinks it is unsafe. This is not true: it can be reduced to addInteger arg_x arg_y, but it takes two steps: first reducing arg_y, then arg_x.

This PR removes safeToBetaReduce, and simply makes applyAndBetaReduce run the beta and inlining passes for PIR that we already have. To avoid exponential worst case behavior, the inlining passes invoked by applyAndBetaReduce only run unconditional inlining. The callSiteInline function itself is unchanged.

@zliu41 zliu41 requested review from michaelpj and a team December 9, 2023 01:40
Copy link
Contributor

@michaelpj michaelpj left a comment

Choose a reason for hiding this comment

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

I'm pretty suspicious about the loop of beta reduction and inlining. I think this is probably ultimately the right thing to do rather than relying on our current awkward tricks, though.

-- can only be beta-reduced one by one. An example is
-- `(\x y -> f x y) arg_x arg_y`, where `arg_x` and `arg_y` are effectful terms.
-- It needs two beta+inlining passes to reduce to `f arg_x arg_y`: the first pass
-- inlines `y` and the second pass `x`.
Copy link
Contributor

Choose a reason for hiding this comment

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

I had to think about this for a bit but yeah, this should work. Can we get a variant of #5672 that tests this case. Obviously we have a bug at the moment, but in future hopefully not.

Copy link
Member Author

Choose a reason for hiding this comment

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

I wanted to add a test case for let addInteger = \x y -> (builtin addInteger) x y, but it needs on the problem shown in #5672 to be fixed, so let me do that first.

applyAndBetaReduce rhs args = do
info <- ask
-- We run the beta and inlining passes for `len args` times, since sometimes the args
-- can only be beta-reduced one by one. An example is
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm somewhat tempted to say that that's a limitation of the inliner that we should try to fix. I'm not sure how big a deal it is if this doesn't work as effectively for effectful args, but only running the inliner once seems attractive.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'd like to see how much a difference it makes if we just do it once.

Copy link
Contributor

Choose a reason for hiding this comment

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

Okay, I tried it and things are much worse. However, I think this is not so much to do with effectful terms. It's to do with type arguments. We have long had the problem that you can't beta-reduce all the arguments at once for a term with type and term arguments. You end up with something like this:

map = (/\ a b . \(f :: a -> b) (l :: List a) . ...) {integer} {integer} incInteger ls
--->
(let
  a = integer
  b = integer
in \(f :: a -> b) (l :: List a) . ...)) incInteger ls

Whereas if we produced this:

let
  a = integer
  b = integer
  f :: a -> b = incInteger
  l :: List a = ls
in ...

the bindings for f and l don't typecheck, because their signatures reference the type variables, but their RHSs need the type variables to have been substituted in.

So we end up awkwardly having to do one beta, inline the type variable (which we don't always do!) and then do the rest of the arguments. This plausibly blocks a bunch of optimzation, with this being one example.

It would be good to improve this. It's not as simple as just always inlining type variables, because this is exactly the situation that we have when we compile down datatypes, and we really don't want to inline all of those otherwise we get truly enormous programs and the typechecker grinds to a halt.

However, I think it would be safe to always inline type variables just when doing call-site inlining, because we have the size check, so that won't let us actually grow the program!

Copy link
Member Author

Choose a reason for hiding this comment

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

I think this is not so much to do with effectful terms

It does have to do with effectful terms in the let addInteger = \x y -> (builtin addInteger) x y case (because running it once is not enough in this case), but maybe that isn't the main reason that it got much worse.

However, I think it would be safe to always inline type variables just when doing call-site inlining, because we have the size check, so that won't let us actually grow the program!

This sounds questionable: the size check arguably should not include the types in the first place, because ultimately we care about the UPLC size, not the PIR size.

Copy link
Contributor

Choose a reason for hiding this comment

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

It does have to do with effectful terms in the let addInteger = \x y -> (builtin addInteger) x y case

Why are the arguments effectful there?

This sounds questionable: the size check arguably should not include the types in the first place, because ultimately we care about the UPLC size, not the PIR size.

This is not true: if we let the size of the types get out of control we will slow down the typechecker and compiler to the point of unusablity. I know this because it happened once when we accidentally inlined all type variables. I agree it's a wart.

--
-- Note that the inlining passes we run here only run unconditional inlining.
-- Technically it can be helpful to also run callsite inlining, but it rarely
-- makes a difference and leads to exponential worst-case behavior.
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not convinced that this isn't also exponential... why do we think that?

I notice that what we actually want to do is even simpler than running unconditional inlining: we want to do unconditional inlining, only considering bindings from the current term, and then just apply the substitution if we have one. I'm not sure if it's easy to express that, but it might make this less expensive.

Copy link
Member Author

Choose a reason for hiding this comment

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

I thought it would be similar as what we currently do but that's not the case, and it can indeed be exponential. A term can be processed for unconditional inlining twice, so its subterms can be processed four times, eight times etc.

That said it is extremely unlikely to be a problem in practice, so I'm inclined to say that it is not worth having a "simpler unconditional inlining" implementation just for this.

( replicate
(len args)
( pure . Beta.beta
>=> inline'
Copy link
Contributor

Choose a reason for hiding this comment

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

inline' does still redundantly compute the term usages.

I'm not sure why we can't just call processTerm here? We're in InlineM, so we can just do that directly and not define inline' at all.

Copy link
Member Author

Choose a reason for hiding this comment

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

The first argument of applyAndBetaReduce is the renamed RHS. Since it has been renamed, usage account needs to be re-done.

Copy link
Member Author

Choose a reason for hiding this comment

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

In fact, because the RHS is renamed, we'll also need to re-calculate the VarsInfo (which I'm not doing currently), and merge it with the original VarsInfo. I'll update it.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ouch. If we're going to do that, you could compute it locally, merge it with the ones in the context using local, and then just call processTerm. I think that would be neater IMO.

Copy link
Contributor

Choose a reason for hiding this comment

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

Although maybe you would still need to recompute this information for each iteration, because if inlining actually inlines stuff, now we have newly renamed variables in the term. Possibly we can just ignore those, though. Again, it would be much simpler if we were just running the inliner once.

A further observation: we only need to rename the term when we substitute it in. I think it is in fact fine to do the application-and-beta-reduction step on the non-renamed term. After all, the original term came from elsewhere in the program and we have globally unique names. We just want to make sure that if we insert a copy of it into the main program, that final copy has new names.

Copy link
Contributor

Choose a reason for hiding this comment

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

A further observation

Grr, this is not true because of the inliner state, which is kept in StateT, so if we ran processTerm, we'd add mappings for stuff we inline to that, and they would persist and potentially be used later where they shouldn't be. So we would need to either rename or only locally modify the state.

@michaelpj
Copy link
Contributor

What I am concluding is that this is very subtle 😅 I wonder if our call-site inlining heuristic is the right one. But I don't know what else to do 🤔

@zeme-wana zeme-wana force-pushed the master branch 2 times, most recently from a161078 to db5cabb Compare July 9, 2024 09:24
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