Skip to content

given initializer with side-effects is not invoked by summon #19348

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

Open
armanbilge opened this issue Dec 30, 2023 · 17 comments
Open

given initializer with side-effects is not invoked by summon #19348

armanbilge opened this issue Dec 30, 2023 · 17 comments

Comments

@armanbilge
Copy link
Contributor

armanbilge commented Dec 30, 2023

Compiler version

3.4.0-RC1-bin-20231223-938d405-NIGHTLY

Minimized code

//> using scala 3.4.0-RC1-bin-20231223-938d405-NIGHTLY

class Foo {
  inline def bar(x: String) = x + "bar"
}
object Foo {
  given Foo = {
    println("gimme")
    new Foo
  }
  // implicit def foo: Foo = {
  //   println("foops")
  //   new Foo
  // }
}

class Bar {
  def baz() = {
    val foobar = summon[Foo].bar("foo")
    println(foobar)
  }
}

Output

/*
 * Decompiled with CFR 0.151.
 */
import scala.Predef$;

public class Bar {
    public void baz() {
        String foobar = "foobar";
        Predef$.MODULE$.println(foobar);
    }
}

Expectation

If we replace given with implicit def foo:

/*
 * Decompiled with CFR 0.151.
 */
import scala.Predef$;

public class Bar {
    public void baz() {
        Foo x$proxy1 = Foo$.MODULE$.foo();
        String foobar = "foobar";
        Predef$.MODULE$.println(foobar);
    }
}
@armanbilge armanbilge added itype:bug stat:needs triage Every issue needs to have an "area" and "itype" label labels Dec 30, 2023
@erikerlandson
Copy link

erikerlandson commented Dec 30, 2023

In the particular case where a typeclass has inline methods, and no internal state or side-effects, I do like the current behavior of eliding any reference to it in the compiled output.

@hamzaremmal hamzaremmal added area:typeclass-derivation and removed stat:needs triage Every issue needs to have an "area" and "itype" label labels Jan 1, 2024
@hamzaremmal
Copy link
Member

Minimisation :

object Foo:
  println("side-effect")
  inline def foo(x : String) = x + "bar"

@main def run = 
  val foobar = Foo.foo("foo")
  println(foobar)

if we change Foo from an object to a class, then we have the side effect.

@jducoeur
Copy link
Contributor

jducoeur commented Jan 2, 2024

That still seems intuitively correct to me -- since foo() is inlined, and doesn't reference its parent object, there isn't any reason for that parent to be initialized. The containing object is essentially just a logical module, not meaningfully a runtime object.

By contrast, when you're replacing it with a class, you are presumably explicitly instantiating (and thus initializing) the class before calling foo(), right?

@erikerlandson
Copy link

The original reproducer from @armanbilge seems slightly different - the implicit function that creates the Foo object has a side effect. The side effect is not attached to the class, or object.

@som-snytt
Copy link
Contributor

It's always worked like this.

scala> object X { ???; final val x = 42 }
object X

scala> def f = X.x
def f: Int

scala> f
val res0: Int = 42

scala> X
scala.NotImplementedError: an implementation is missing
  at scala.Predef$.$qmark$qmark$qmark(Predef.scala:344)
  ... 33 elided

also compare

scala> trait T { ???; final val x = 42 }
trait T

scala> null.asInstanceOf[T].x
java.lang.NullPointerException: Cannot invoke "T.x()" because "null" is null
  ... 30 elided

scala> (null: T).x
val res3: Int = 42

@erikerlandson
Copy link

erikerlandson commented Jan 3, 2024

Are constructs like given Foo = ... always compiled as objects, when they are summoned, even if they have some kind of type parameter? I think of something like given Foo[X] = ... as behaving like a function whenever it is summoned, but is that true?

And how does that compare with the older implicit def getFoo[X]: Foo[X] = ..., in terms of how that is compiled when it is summoned? I think of implicit def and given as behaving the same at the point of summoning, but are they?

@odersky
Copy link
Contributor

odersky commented Jan 8, 2024

Parameterless givens produce lazy vals, parameterized ones produce defs.

So if you need the side effect you should add a dummy type parameter [X]. But I that's really a code smell. Generally, givens with side effects are a bad abuse of the language.

@armanbilge
Copy link
Contributor Author

So if you need the side effect you should add a dummy type parameter [X]. But I that's really a code smell. Generally, givens with side effects are a bad abuse of the language.

I definitely agree! In that case ...

Parameterless givens produce lazy vals, parameterized ones produce defs.

Could parameterless givens produce @threadUnsafe lazy vals? If they are not supposed to be side-effectual anyway, then paying a synchronization cost for every access seems unnecessary.

@odersky
Copy link
Contributor

odersky commented Jan 8, 2024

Could parameterless givens produce @ThreadUnsafe lazy vals? If they are not supposed to be side-effectual anyway, then paying a synchronization cost for every access seems unnecessary.

I proposed that at some point, but was voted down, since people thought it's not that bad, and better to be safe. But I agree, if we declare side effects to be undefined behavior in givens then we might as well make the lazy val thread-unsafe.

However, there a workaround.

If the RHS of a parameterless given is a simple immutable reference, the given is implemented as a def. So you can make your value a threadunsafe lazy val (or even strict val), and then forward to it with a given:

import annotation.threadUnsafe
class C
object Test:
  def foo(): C = C()
  @threadUnsafe lazy val _C = foo()
  given C = _C

If you print the code at erasure phase this is what you get:

[[syntax trees at end of                   erasure]] // test.scala
package <empty> {
  @SourceFile("test.scala") class C() extends Object() {}
  final lazy module val Test: Test = new Test()
  @SourceFile("test.scala") final module class Test() extends Object() {
    private def writeReplace(): Object =
      new scala.runtime.ModuleSerializationProxy(classOf[Test])
    def foo(): C = new C()
    @threadUnsafe lazy def _C(): C = Test.foo()
    final given def given_C(): C = Test._C()
  }
}

@armanbilge
Copy link
Contributor Author

Thanks!

since people thought it's not that bad

Has anyone benchmarked this? @djspiewak and I were discussing this recently.

you know, thinking about it, that synchronization penalty is actually quite insidious. it probably prevents the PIC from properly inlining dynamic dispatch on typeclasses in the common case. has anyone done any serious benchmarking on Cats-style use cases with given?

It's great that there are workarounds, and in fact we'll probably go straight for @static given thanks to #19304. But most users won't have the bandwidth to implement these elaborate workarounds.

@odersky
Copy link
Contributor

odersky commented Jan 8, 2024

As far as I know, no benchmarks for this exist far. But it would be great if they did!

@sjrd
Copy link
Member

sjrd commented Jan 8, 2024

The synchronization of a thread-safe lazy val is not meant to protect the rhs. It protects the reading/writing of the cache (and bitmap) so that we stay within the JMM. Therefore, even side-effect-free rhs'es need synchronization to be correct.

@armanbilge
Copy link
Contributor Author

If I understand you correctly, are you saying that @threadUnsafe is currently broken? Because if not, then I got your point, and I'll say it 😅

import scala.annotation.threadUnsafe

class Foo {

  @threadUnsafe lazy val bar = ???

}

decompiles to

/*
 * Decompiled with CFR 0.151.
 * 
 * Could not load the following classes:
 *  scala.Predef$
 *  scala.runtime.Nothing$
 */
import scala.Predef$;
import scala.runtime.Nothing$;

public class Foo {
    private Nothing$ bar$lzy1;
    private boolean barbitmap$1;

    public Nothing$ bar() {
        if (!this.barbitmap$1) {
            this.bar$lzy1 = Predef$.MODULE$.$qmark$qmark$qmark();
            this.barbitmap$1 = true;
        }
        return this.bar$lzy1;
    }
}

The problem is that a thread may see that barbitmap$1 is true before the value of bar$lzy1 is published.

@armanbilge
Copy link
Contributor Author

armanbilge commented Jan 8, 2024

are you saying that @threadUnsafe is currently broken? Because if not, then I got your point, and I'll say it 😅

Aha, I'm sorry, I take it back. It's not broken, it is "thread unsafe" which is exactly what is promised.

@armanbilge
Copy link
Contributor Author

armanbilge commented Jan 8, 2024

To expand on my confusion, there are at least three semantics here, from weakest to strongest:

  1. the lazy val is never safe to use in a multi-threaded setting (aka "thread unsafe")
  2. the lazy val may be used in a multi-threaded setting, but its value may be initialized multiple times
  3. the lazy val may be used in a multi-threaded setting and its value will be initialized at most once

Until now I was quite wrongly under the impression that @threadUnsafe was meant to implement (2). In fact, it's implementing (1). In practice, what we'd really like is semantic (2), so maybe this needs to be a feature request.

@sjrd
Copy link
Member

sjrd commented Jan 8, 2024

If you want (2) for a specific purpose, you can implement it in user space. I did that to make tasty-query thread-safe:
scalacenter/tasty-query@057d3ae#diff-e6bebd9578265450914d8f7472dada3c2bb505da0a8642dbd9aa402848af7750L19

@armanbilge
Copy link
Contributor Author

Thanks, yes, I've done that before on many occasions. It really comes back to this:

most users won't have the bandwidth to implement these elaborate workarounds.

Which is unfortunate, because it seems that given makes no promise about evaluation of side-effects yet our ecosystem will proliferate with otherwise unnecessary synchronization overhead and possible impacts to the JIT.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

7 participants