Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Better heap-safety for iterative algorithms on lazy data types #7990

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

Closed
wants to merge 1 commit into from

Conversation

szeiger
Copy link
Contributor

@szeiger szeiger commented Apr 18, 2019

This is an idea I explored after seeing #7916. Some gitter discussion at https://gitter.im/scala/contributors?at=5cb6f1596a84d76ed8a99167.

What I implemented so far is an annotation @dropthis that can be applied to an expression this in a method in order to drop the reference (after pushing it onto the stack) so it can be garbage-collected. This allows writing iterative algorithms (see the foreach3 test case) that do not keep an unnecessary reference to the original receiver of the method, which is currently impossible in Scala.

So far this is a simple proof of concept. There are no checks for incorrect use of the annotation. If you put it on any non-this expression, it is ignored. If you use it and try to dereference this afterwards, you get an NPE. The next obvious step is to add these checks. The easiest way to do the flow analysis would probably be right in the backend at the ASM level but then you would get different semantics when running with a non-JVM backend, so we should do it earlier.

Furthermore, in cases where this gets aliased to a var with the same type (like in foreach2) it should be possible to perform this optimization without the need for an explicit annotation. We could reuse slot 0 in the LVT (which holds the receiver) for the var. In other cases (e.g. using an Iterator instead of manually traversing a linked list) the type may not match, so this is not an option. We don't want to null out the receiver unnecessarily in all methods, either. In these cases you would still have to use the annotation.

There is currently no optimization for aliasing of local variables or reuse of LVT slots in the backend. Every local variable gets a separate slot and everything is scoped to its full static scope.

  • Perform flow analysis for illegal use of @dropthis
  • Guard against using @dropthis on non-this expressions
  • Detect aliasing of this into a var and reuse slot 0
  • Share other LVT slots between non-overlapping variables of compatible types
  • Reduce the scope of local variables from their static scope to the actual one
  • Null out slot 0 in all forwarders and bridge methods for @dropthis-annotated methods

Simple implementation that always nulls (instead of aliasing where possible). No checks for illegal usage.
@scala-jenkins scala-jenkins added this to the 2.13.1 milestone Apr 18, 2019
@lrytz
Copy link
Member

lrytz commented Apr 23, 2019

I like the idea. I don't feel like it's something that the compiler needs to do automatically, in general, keeping this and parameters live is just how things work by default, and Scala / Java programmers are used to it. So an annotation seems like a good way to give people the option when they need it.

Some ideas

  • it should also work for other parameters than this
  • maybe we can annotate the method instead of the expression, and have the compiler figure out where to null out
  • Detect aliasing of this into a var and reuse slot 0

Not sure we need to do that, see also my comment below. The inliner nulls out local variables (e.g. the one holding this) of inlined code, but it doesn't make any attempts to re-use slots.

  • Share other LVT slots between non-overlapping variables of compatible types

We never went to implement a register allocator in the Scala backend because (1) the JVM does it and (2) having a slot per local variable makes other local analyses / optimziations much simpler. Also, I'm not sure if it would be a good idea to rely on register allocation for "correctness" (i.e., to rely on the register allocator to null out this in time).

Side-note: "compatible types" is not a necessary requirement, a local variable slot can hold objects of different types (even reference / primitive) within the same method.

@szeiger
Copy link
Contributor Author

szeiger commented Apr 23, 2019

I don't feel like it's something that the compiler needs to do automatically, in general, keeping this and parameters live is just how things work by default, and Scala / Java programmers are used to it

It doesn't need to (especially if you have an annotation to trigger it on demand) but it would be nice to do it automatically when it's free (i.e. you don't need a null assignment). I haven't done any benchmarking specifically for this case yet but in several cases of benchmarking collection methods I noticed that tail-recursive versions were faster than iterative versions. I suspect it is precicely because of this difference (the iterative version has to keep an extra variable around). Note that you cannot rely on the VM to not drop the reference anyway (see https://shipilev.net/jvm/anatomy-quarks/8-local-var-reachability/).

it should also work for other parameters than this

Right. In fact, it should also work for all local variables. Are there any cases where the code generator creates new local variables for sub-expressions that could accidentally keep a reference alive? If this is the case, we may want to allow it for arbitrary expressions.

maybe we can annotate the method instead of the expression, and have the compiler figure out where to null out

That was my original plan but it's useless for ensuring that your methods actually drops the reference when you intend to. this always becomes unreachable at some point but you want an assurance that it becomes unreachable early enough (before you start looping). The result is similar to how @tailrec ensures that all self-recursive calls are in tail position and can be optimized.

Also, I'm not sure if it would be a good idea to rely on register allocation for "correctness" (i.e., to rely on the register allocator to null out this in time)

Right, this would just be a general optimization. If you want to be sure you still need to annotate it (similar to how @tailrec is not required for the optimization but will ensure that it can be performed).

Side-note: "compatible types" is not a necessary requirement, a local variable slot can hold objects of different types (even reference / primitive) within the same method.

Good point. I didn't consider this for this and parameters. This means we could drop this automatically (without the need for an annotation) in even more cases. Ideally, I'd like the annotation to only perform the checks and have the optimization applied automatically in all cases (like @tailrec) but there are still some cases left where explicit nulling is required.

@lrytz
Copy link
Member

lrytz commented Apr 23, 2019

Are there any cases where the code generator creates new local variables for sub-expressions that could accidentally keep a reference alive?

That's a good question, I'd have to look in detail, but I can imagine this being the case. For example to emit try-catch expressions.

@szeiger
Copy link
Contributor Author

szeiger commented Apr 23, 2019

Note to self (or anyone else who might already know the answer): After looking at the LazyList PR again, it's clear that simple forwarders (like LazyList#collect) do not pose a problem. References to this are dropped even though they are (or at least should be) in the LVT. Why does this work? Can we rely on the JVM to drop references in case of tail calls or is there some other mechanism at work here?

@szeiger
Copy link
Contributor Author

szeiger commented Aug 23, 2019

After working on scala/bug#11443, it has become less clear. Using a @tailrec final def find in Stream is fine, but putting the code into a separate findImpl method with find acting as a forwarder (to allow moving the implementation up into LinearSeqOps) does not allow the original receiver to be garbage collected.

@szeiger szeiger modified the milestones: 2.13.1, 2.13.2 Aug 30, 2019
@lrytz
Copy link
Member

lrytz commented Jan 31, 2020

@szeiger should we close this one?

@SethTisue SethTisue modified the milestones: 2.13.2, 2.13.3 Feb 6, 2020
@szeiger
Copy link
Contributor Author

szeiger commented Apr 1, 2020

I don't think I'll be able to continue with this any time soon

@lrytz
Copy link
Member

lrytz commented Dec 10, 2024

This PR would enable heap safe implementations without relying on

  • either @tailrec (which re-assigns this / slot 0)
  • or the JVM JIT (which seems to compile / optimize code such that the this reference can be collected early, possibly due to register allocation, as hinted by the shipilev post linked above).

Difficulties

  • inherited final methods, e.g., mkString (inherited from IterableOnceOps), cannot override to use @nullOut
  • final forwarders like IterableOnceOps./: - even if foldLeft is heap safe (it currently is), using /: leaves the this reference live in the stack frame of the /: method
  • Synthetic forwarders (bridges, mixin forwarders, static mixin accessors, super accessors?)
  • Sometimes there's nothing to override in the lazy collection, like Growable.addAll(elems: IterableOnce[A]). We'd have to use @nullOut in the generic implementation. In the concrete example, we'd have to write:
trait Growable {
  final def ++=(elems: IterableOnce[A]): this.type = this.addAll(elems: @nullOut)

  def addAll(elems: IterableOnce[A]): this.type = {
    val it = (elems: @nullOut).iterator
    while (it.hasNext) {
      addOne(it.next())
    }
    this
  }
}

/: and mkString could be fixed in the same way. I'm not sure that is a worthwhile tradeoff to have these annotations around the library implementation and not contained within LazyList / Stream.

On the other hand, we could fix heap safety and even get rid of current overrides in LazyList with a few null assignments which won't hurt performance.

it should also work for all local variables

I was going to argue that we can use vars and null them out manually, but for something like localLazyList.reduceLeft we cannot express "load localLazyList onto the stack and null it out, then call reduceLeft".

If you use it and try to dereference this afterwards, you get an NPE. The next obvious step is to add these checks.

IMO we can do without, especially if this is going to be an internal feature. The compiler also doesn't check how local variables are used after setting them to null.

Scala 3 explicit nulls has the necessary flow typing, but it would need to be tweaked to understand the annotation.

@lrytz lrytz mentioned this pull request Dec 13, 2024
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