Skip to content

Move TailRec after Erasure. #5112

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

Merged
merged 2 commits into from
Sep 26, 2018

Conversation

sjrd
Copy link
Member

@sjrd sjrd commented Sep 15, 2018

This used not to be possible because it was too complicated for TailRec to understand trees produced by the pattern matcher. Now that patmat uses Labeled blocks instead of label-defs, it is trivial to do so: the argument of a return from a labeled block lab is in tail position if and only if the labeled block lab is
itself in tail position.

Running TailRec after erasure has two major benefits:

  • it is much simpler, as it does not have to deal with type parameters, TypeApplys, and a bunch of other stuff.
  • it supports polymorphic tail-recursive calls by construction (recursive calls whose receiver or method has different type parameters).

It also has one difficulty: it cannot see the call-site @tailrec annotations anymore. This is why we add a mini-phase to record such annotations as tree attachments.

Having TailRec after erasure will also be necessary to later make it use Labeled blocks and loops instead of label-defs itself. Indeed, in that case it will need to declare local vars for its term parameters, which would break path-dependent types within the method if it were done before erasure.

Copy link
Member

@dottybot dottybot left a comment

Choose a reason for hiding this comment

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

Hello, and thank you for opening this PR! 🎉

All contributors have signed the CLA, thank you! ❤️

Commit Messages

We want to keep history, but for that to actually be useful we have
some rules on how to format our commit messages (relevant xkcd).

Please stick to these guidelines for commit messages:

  1. Separate subject from body with a blank line
  2. When fixing an issue, start your commit message with Fix #<ISSUE-NBR>:
  3. Limit the subject line to 72 characters
  4. Capitalize the subject line
  5. Do not end the subject line with a period
  6. Use the imperative mood in the subject line ("Add" instead of "Added")
  7. Wrap the body at 80 characters
  8. Use the body to explain what and why vs. how

adapted from https://chris.beams.io/posts/git-commit

Have an awesome day! ☀️

@sjrd
Copy link
Member Author

sjrd commented Sep 15, 2018

At this point, I still have bootstrap failures locally, but I'd like to see what the CI says.

@smarter
Copy link
Member

smarter commented Sep 15, 2018

It also has one difficulty: it cannot see the call-site @tailrec annotations anymore. This is why we add a mini-phase to record such annotations as tree attachments.

We try to avoid using tree attachments unless there's no other choice in Dotty since they're a bit too magic, and using them to communicate across phases seems especially scary. Can we try to find an alternative solution? I suggest simply changing erasure to not drop annotations and check if that breaks anything.

@sjrd
Copy link
Member Author

sjrd commented Sep 15, 2018

Yeah, I'm not happy with the tree attachment either. I just wanted to make those tests pass rather than moving them to pending (because, you know, one doesn't just remove tests), but it certainly is open for suggestions.

I don't think I have the stomach to try and change erasure right now, though. I suggest we discuss that at the next meeting.

@sjrd sjrd force-pushed the tailrec-after-erasure branch from 4eaa795 to 6d8198d Compare September 17, 2018 13:20
@sjrd sjrd changed the title WiP Move TailRec after Erasure. Move TailRec after Erasure. Sep 17, 2018
@sjrd sjrd force-pushed the tailrec-after-erasure branch from 6d8198d to 5be1d73 Compare September 18, 2018 20:29
@sjrd
Copy link
Member Author

sjrd commented Sep 18, 2018

After a discussion with @odersky today, I added a commit that simply removes the call-site @tailrec checks feature altogether. Needs discussion.

@sjrd sjrd force-pushed the tailrec-after-erasure branch from bbe0a97 to b8be1e7 Compare September 22, 2018 08:38
@sjrd
Copy link
Member Author

sjrd commented Sep 22, 2018

Rebased.

@sjrd sjrd mentioned this pull request Sep 22, 2018
@allanrenucci allanrenucci self-requested a review September 24, 2018 14:01
@allanrenucci allanrenucci self-assigned this Sep 24, 2018
@smarter
Copy link
Member

smarter commented Sep 24, 2018

test performance please

@dottybot
Copy link
Member

performance test scheduled: 1 job(s) in queue, 0 running.

@dottybot
Copy link
Member

Performance test finished successfully:

Visit http://dotty-bench.epfl.ch/5112/ to see the changes.

Benchmarks is based on merging with master (a0b429b)

Copy link
Contributor

@allanrenucci allanrenucci left a comment

Choose a reason for hiding this comment

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

The doc at the top of the file needs to be updated. It mentions things about pattern matching and type parameters which are no longer true. Maybe add a paragraph about labeled blocks

val rhs = transformer.transform(dd.rhs)
rewrote = transformer.rewrote
rhs
}

if (rewrote) {
val dummyDefDef = cpy.DefDef(tree)(rhs = rhsSemiTransformed)
assert(dd.tparams.isEmpty, dd)
Copy link
Contributor

Choose a reason for hiding this comment

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

This is always true. Maybe rewrite the match case as:

     tree match {
-      case dd@DefDef(name, Nil, vparams0 :: Nil, tpt, _)
+      case dd@DefDef(name, _, vparams0 :: Nil, tpt, _)

val name = TailLabelName.fresh()
val enclosingClass = method.enclosingClass.asClass
val thisParamType =
if (enclosingClass.is(Flags.Module)) enclosingClass.thisType
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 have this distinction for ModuleClass?

Copy link
Member Author

Choose a reason for hiding this comment

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

Because the type of this in a module class is something like TheModule.this.type (not TheModule$, for example) but the type of this in a normal class is TheClass. Using anything else fails to Ycheck. I haven't found a way to produce those subtly different things using one uniform call, although I feel like there should be something in the internal API to do so.

As to why it must be TheModule.this.type and not TheModule$, I don't know, but apparently that's how it is in dotty.

val vrefs = vrefss.head
val thisRef = vrefs.head
val origMeth = tree.symbol
val origVParams = tree.vparamss.flatten map (_.symbol)
Copy link
Contributor

Choose a reason for hiding this comment

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

Isn't this vparams0.map(_.symbol)? Is the assumption after erasure that there is only one parameter list?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, you're right.

And yes, after erasure all methods have exactly one parameter list (not 0 nor 2+).

@@ -195,84 +208,45 @@ class TailRec extends MiniPhase with FullParameterization {

override def transform(tree: Tree)(implicit c: Context): Tree = {
/* A possibly polymorphic apply to be considered for tail call transformation. */
Copy link
Contributor

Choose a reason for hiding this comment

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

Update comment? polymorphic after erasure?

.substThisUnlessStatic(classSym, thisRef.tpe)
.subst(origVParams, vrefs.tail.map(_.tpe)),
treeMap = {
case tree: This if tree.symbol == classSym => thisRef
Copy link
Contributor

Choose a reason for hiding this comment

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

Why was this not needed before?

Copy link
Member Author

Choose a reason for hiding this comment

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

It was needed before, but it was handled by fullyParameterizedDef (along with many other complications for type parameters, which are not needed anymore).

} else {
val receiverIsSuper = (method.name eq sym) && enclosingClass.appliedRef.widen <:< recvWiden
val receiverIsSuper = (method.name eq sym) && enclosingClass.appliedRef.widen <:< prefix.tpe.widenDealias
Copy link
Contributor

Choose a reason for hiding this comment

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

Not your change but this is very suspicious: method.name is a Name and sym is a Symbol. I guess we don't have tests with recursive call to supertype

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 agree. It is suspicious. I'd leave out an investigation to a different PR, though, if you think that's reasonable.

Copy link
Contributor

Choose a reason for hiding this comment

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

Sure! Do you mind opening an issue, once the PR is merged?

Copy link
Member Author

Choose a reason for hiding this comment

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

case x: This => (x, x, accArgs, accT, x.symbol)
case x: Ident if x.symbol eq method => (EmptyTree, x, accArgs, accT, x.symbol)
case x => (x, x, accArgs, accT, x.symbol)
def rewriteApply(tree: Apply, sym: Symbol): Tree = {
Copy link
Contributor

Choose a reason for hiding this comment

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

rewriteApply does not need to take the symbol as parameter since the function is called only once and sym really is tree.fun.symbol

val targs = typeArguments.map(noTailTransform)
val argumentss = arguments.map(noTailTransforms)
def continue =
Apply(noTailTransform(call), arguments)
Copy link
Contributor

Choose a reason for hiding this comment

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

If this used a tree copier and noTailTransforms used mapConserve, I believe we could save some unnecessary tree copies

if (this.method.owner.isClass) receiver :: arguments
else arguments

Apply(method, argumentsWithReceiver)
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we us a tree copier to properly set the tree position?

tpd.cpy(tree)(method, argumentsWithReceiver)

Otherwise, I (personally) think using our DSL is cleaner:

method.appliedToArgs(argumentsWithReceiver)

if (prefix eq EmptyTree) This(enclosingClass.asClass)
else noTailTransform(prefix)

val method = Ident(label.termRef)
Copy link
Contributor

Choose a reason for hiding this comment

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

ref(label)?

This used not to be possible because it was too complicated for
TailRec to understand trees produced by the pattern matcher. Now
that patmat uses `Labeled` blocks instead of label-defs, it is
trivial to do so: the argument of a `return` from a labeled block
`lab` is in tail position if and only if the labeled block `lab` is
itself in tail position.

Running TailRec after erasure has two major benefits:

* it is much simpler, as it does not have to deal with type
  parameters, `TypeApply`s, and a bunch of other stuff.
* it supports polymorphic tail-recursive calls by construction
  (recursive calls whose receiver or method has different type
  parameters).

It also has one difficulty: it cannot see the *call-site*
`@tailrec` annotations anymore. This is why we add a mini-phase to
record such annotations as tree attachments.

Having TailRec after erasure will also be necessary to later make
it use `Labeled` blocks and loops instead of label-defs itself.
Indeed, in that case it will need to declare local `var`s for its
term parameters, which would break path-dependent types within the
method if it were done before erasure.
This feature does not exist in Scala 2, and was never documented in
Scala 3. Its only internal use comes from a swiping commit that
added as many `@tailrec` annotations as possible all over the
codebase: see be5720c.

Supporting that feature became problematic when TailRec was moved
after erasure, because we needed an attachment to remember
call-site `@tailrec` annotations, which would otherwise be removed
by erasure.

This commit removes the feature, on the grounds that its
cost/benefit is too high.
@sjrd sjrd force-pushed the tailrec-after-erasure branch from 7b1e8d9 to b1008d8 Compare September 25, 2018 15:40
@allanrenucci allanrenucci assigned sjrd and unassigned allanrenucci Sep 25, 2018
Copy link
Member Author

@sjrd sjrd left a comment

Choose a reason for hiding this comment

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

Updated according to your comments :)

val name = TailLabelName.fresh()
val enclosingClass = method.enclosingClass.asClass
val thisParamType =
if (enclosingClass.is(Flags.Module)) enclosingClass.thisType
Copy link
Member Author

Choose a reason for hiding this comment

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

Because the type of this in a module class is something like TheModule.this.type (not TheModule$, for example) but the type of this in a normal class is TheClass. Using anything else fails to Ycheck. I haven't found a way to produce those subtly different things using one uniform call, although I feel like there should be something in the internal API to do so.

As to why it must be TheModule.this.type and not TheModule$, I don't know, but apparently that's how it is in dotty.

val vrefs = vrefss.head
val thisRef = vrefs.head
val origMeth = tree.symbol
val origVParams = tree.vparamss.flatten map (_.symbol)
Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, you're right.

And yes, after erasure all methods have exactly one parameter list (not 0 nor 2+).

.substThisUnlessStatic(classSym, thisRef.tpe)
.subst(origVParams, vrefs.tail.map(_.tpe)),
treeMap = {
case tree: This if tree.symbol == classSym => thisRef
Copy link
Member Author

Choose a reason for hiding this comment

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

It was needed before, but it was handled by fullyParameterizedDef (along with many other complications for type parameters, which are not needed anymore).

} else {
val receiverIsSuper = (method.name eq sym) && enclosingClass.appliedRef.widen <:< recvWiden
val receiverIsSuper = (method.name eq sym) && enclosingClass.appliedRef.widen <:< prefix.tpe.widenDealias
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 agree. It is suspicious. I'd leave out an investigation to a different PR, though, if you think that's reasonable.

else tpd.cpy.Select(tree)(noTailTransform(tree.qualifier), tree.name)

case Apply(fun, args) =>
case tree@Apply(fun, args) if !fun.isInstanceOf[TypeApply] =>
Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, for special methods like isInstanceOf. Those can never be tail-recursive calls since they don't have a definition in trees. It's better not to guard, though. They'll be ignored because isRecursiveCall will be false anyway.

@sjrd sjrd assigned allanrenucci and unassigned sjrd Sep 25, 2018
Copy link
Contributor

@allanrenucci allanrenucci left a comment

Choose a reason for hiding this comment

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

LGTM 🎉

@@ -24,7 +24,7 @@ class MixinOps(cls: ClassSymbol, thisPhase: DenotTransformer)(implicit ctx: Cont
name = member.name.stripScala2LocalSuffix,
flags = member.flags &~ Deferred,
info = cls.thisType.memberInfo(member)).enteredAfter(thisPhase).asTerm
res.addAnnotations(member.annotations)
res.addAnnotations(member.annotations.filter(_.symbol != defn.TailrecAnnot))
Copy link
Contributor

Choose a reason for hiding this comment

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

Sorry, I missed this. Is this needed if you move TailRec after Mixin within the same group?

Copy link
Member Author

Choose a reason for hiding this comment

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

Even more so. But right now it's necessary above all for extension methods (for AnyVals, which also use this forwarder() method). In general, when creating a forwarder of any sort, we must remove the @tailrec annotation.

Previously, no forwarder was ever created before TailRec ran. But now that TailRec runs after the creation of extension methods, we must ensure that forwarder drops @tailrec).

@allanrenucci allanrenucci merged commit a40039e into scala:master Sep 26, 2018
@allanrenucci allanrenucci deleted the tailrec-after-erasure branch September 26, 2018 09:15
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.

4 participants