Skip to content

Inlining or quoting generic types causes boxing #11998

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
japgolly opened this issue Apr 6, 2021 · 19 comments
Open

Inlining or quoting generic types causes boxing #11998

japgolly opened this issue Apr 6, 2021 · 19 comments
Assignees

Comments

@japgolly
Copy link
Contributor

japgolly commented Apr 6, 2021

Compiler version

3.0.1-RC1-bin-20210402-775d881-NIGHTLY

Minimized code

import scala.compiletime.*

extension [A](a: A)

  transparent inline def ==*(b: A): Boolean =
    inline erasedValue[A] match
      case _: Int => println("COMPARING INTS"); a == b
      case _      => ???

class Test:
  var i = 1
  var res = false
  def x1 = res = 1 ==* 1
  def x2 = res = 1 ==* 2
  def x3 = res = 1 ==* i
  def z1 = res = 1 == i

Output

> javap -c target/scala-3.0.1-RC1/classes/Test.class
Compiled from "BUG.scala"
public class Test {
  public Test();
    Code:
       0: aload_0
       1: invokespecial #13                 // Method java/lang/Object."<init>":()V
       4: aload_0
       5: iconst_1
       6: putfield      #15                 // Field i:I
       9: aload_0
      10: iconst_0
      11: putfield      #17                 // Field res:Z
      14: return

  public int i();
    Code:
       0: aload_0
       1: getfield      #15                 // Field i:I
       4: ireturn

  public void i_$eq(int);
    Code:
       0: aload_0
       1: iload_1
       2: putfield      #15                 // Field i:I
       5: return

  public boolean res();
    Code:
       0: aload_0
       1: getfield      #17                 // Field res:Z
       4: ireturn

  public void res_$eq(boolean);
    Code:
       0: aload_0
       1: iload_1
       2: putfield      #17                 // Field res:Z
       5: return

  public void x1();
    Code:
       0: aload_0
       1: getstatic     #33                 // Field scala/Predef$.MODULE$:Lscala/Predef$;
       4: ldc           #35                 // String COMPARING INTS
       6: invokevirtual #39                 // Method scala/Predef$.println:(Ljava/lang/Object;)V
       9: iconst_1
      10: invokevirtual #41                 // Method res_$eq:(Z)V
      13: return

  public void x2();
    Code:
       0: aload_0
       1: getstatic     #33                 // Field scala/Predef$.MODULE$:Lscala/Predef$;
       4: ldc           #35                 // String COMPARING INTS
       6: invokevirtual #39                 // Method scala/Predef$.println:(Ljava/lang/Object;)V
       9: iconst_0
      10: invokevirtual #41                 // Method res_$eq:(Z)V
      13: return

  public void x3();
    Code:
       0: aload_0
       1: aload_0
       2: invokevirtual #45                 // Method i:()I
       5: istore_1
       6: getstatic     #33                 // Field scala/Predef$.MODULE$:Lscala/Predef$;
       9: ldc           #35                 // String COMPARING INTS
      11: invokevirtual #39                 // Method scala/Predef$.println:(Ljava/lang/Object;)V
      14: iconst_1
      15: invokestatic  #51                 // Method scala/runtime/BoxesRunTime.boxToInteger:(I)Ljava/lang/Integer;
      18: iload_1
      19: invokestatic  #51                 // Method scala/runtime/BoxesRunTime.boxToInteger:(I)Ljava/lang/Integer;
      22: astore_2
      23: dup
      24: ifnonnull     35
      27: pop
      28: aload_2
      29: ifnull        42
      32: goto          46
      35: aload_2
      36: invokevirtual #55                 // Method java/lang/Object.equals:(Ljava/lang/Object;)Z
      39: ifeq          46
      42: iconst_1
      43: goto          47
      46: iconst_0
      47: invokevirtual #41                 // Method res_$eq:(Z)V
      50: return

  public void z1();
    Code:
       0: aload_0
       1: iconst_1
       2: aload_0
       3: invokevirtual #45                 // Method i:()I
       6: if_icmpne     13
       9: iconst_1
      10: goto          14
      13: iconst_0
      14: invokevirtual #41                 // Method res_$eq:(Z)V
      17: return
}

Expectation

We can see in the disassembly of x3 that it boxes the Ints. I was expecting that inline would choose the appropriate implementation of == based on the types of its arguments. I started down the path of matching on erasedValue[Int] to provide more type info to the inliner but that didn't make a difference.

Workaround

The following generates code that doesn't box but considering that I'd have to do it for every primitive, for every inline method I write, it would very quickly become an unreasonable amount of work and boilerplate.

private object Hidden:
  inline def int_eq(a: Int, b: Int): Boolean = a == b

extension [A](a: A)

  transparent inline def ==*(b: A): Boolean =
    inline erasedValue[A] match
      case _: Int => println("COMPARING INTS"); Hidden.int_eq(a.asInstanceOf[Int], b.asInstanceOf[Int])
      case _      => ???
@odersky
Copy link
Contributor

odersky commented Apr 6, 2021

I was expecting that inline would choose the appropriate implementation of == based on the types of its arguments.

That's not the case. Inlining does not revise a choice of overloaded method made in the inlined function. I had a design that did this and it looked to have great potential for optimizations. But the downside would have been that we cannot guarantee type safety or semantics preservation in inlined code. So we backed out of that.

If you need more finegrained control what code should be generated, your best bet is staging. I.e. work with splices.

@odersky odersky closed this as completed Apr 6, 2021
@japgolly
Copy link
Contributor Author

japgolly commented Apr 6, 2021

Aww shame, that other design would've been awesome. I hope it can come back one day.

@japgolly
Copy link
Contributor Author

japgolly commented Apr 7, 2021

If you need more finegrained control what code should be generated, your best bet is staging. I.e. work with splices.

I just tried that and it's the same problem:

private object OpsMacros:
  def eqImpl[A: Type](a: Expr[A], b: Expr[A])(using Quotes): Expr[Boolean] =
    '{ $a == $b }

extension [A](inline a: A)
  inline def ==*[B >: A](inline b: B)(using ev: => UnivEq[B]): Boolean =
    ${ OpsMacros.eqImpl[B]('a, 'b) }

def test(a: Boolean) = a ==* a
  public boolean test(boolean);
    Code:
       0: iload_1
       1: invokestatic  #87                 // Method scala/runtime/BoxesRunTime.boxToBoolean:(Z)Ljava/lang/Boolean;
       4: iload_1
       5: invokestatic  #87                 // Method scala/runtime/BoxesRunTime.boxToBoolean:(Z)Ljava/lang/Boolean;
       8: astore_2
       9: dup
      10: ifnonnull     21
      13: pop
      14: aload_2
      15: ifnull        28
      18: goto          32
      21: aload_2
      22: invokevirtual #24                 // Method java/lang/Object.equals:(Ljava/lang/Object;)Z
      25: ifeq          32
      28: iconst_1
      29: goto          33
      32: iconst_0
      33: ireturn

@odersky @nicolasstucki This is really unexpected but even with quotes and splices it boxes. Surely this is a bug, isn't it?

@sjrd
Copy link
Member

sjrd commented Apr 7, 2021

This seems correct to me. As is, there is a single quote that works with a generic A. The == is typechecked as being between A's, not between Ints. Quotes and splices can however help with an explicit compile-time switch on what A is.

But all of that seems to miss the bigger picture to me. Even a generic == can be locally optimized to an icmp with a local bytecode optimizer. So we should get all of this for free when we finally port the back-end optimizer.

@odersky
Copy link
Contributor

odersky commented Apr 7, 2021

Can Scala 2 compile the example without boxing?

@sjrd
Copy link
Member

sjrd commented Apr 7, 2021

I've tried with the following, and it does not seem like it manages to go that far, unfortunately.

scalaVersion := "2.13.5"
scalacOptions ++= Seq(
  "-opt:l:inline",
  "-opt-inline-from:foo.**",
)
package foo

object App {
  def main(args: Array[String]): Unit = {
    println(System.getProperty("java.vm.version"))

    val test = new Test
    println(test.x1)
    println(test.x2)
    println(test.x3)
    println(test.z1)
  }
}

object Ops {
  implicit class EqOps[A](private val a: A) extends AnyVal {
    @inline
    def ==*(b: A): Boolean = a == b
  }
}
import Ops._

class Test {
  var i = 1
  var res = false
  def x1 = res = 1 ==* 1
  def x2 = res = 1 ==* 2
  def x3 = res = 1 ==* i
  def z1 = res = 1 == i
}
>javap -c -cp target/scala-2.13/classes/ foo.Test
Compiled from "App.scala"
public class foo.Test {
  public int i();
    Code:
       0: aload_0
       1: getfield      #20                 // Field i:I
       4: ireturn

  public void i_$eq(int);
    Code:
       0: aload_0
       1: iload_1
       2: putfield      #20                 // Field i:I
       5: return

  public boolean res();
    Code:
       0: aload_0
       1: getfield      #28                 // Field res:Z
       4: ireturn

  public void res_$eq(boolean);
    Code:
       0: aload_0
       1: iload_1
       2: putfield      #28                 // Field res:Z
       5: return

  public void x1();
    Code:
       0: aload_0
       1: getstatic     #36                 // Field foo/Ops$EqOps$.MODULE$:Lfoo/Ops$EqOps$;
       4: pop
       5: getstatic     #41                 // Field foo/Ops$.MODULE$:Lfoo/Ops$;
       8: pop
       9: iconst_1
      10: invokestatic  #47                 // Method scala/runtime/BoxesRunTime.boxToInteger:(I)Ljava/lang/Integer;
      13: iconst_1
      14: invokestatic  #47                 // Method scala/runtime/BoxesRunTime.boxToInteger:(I)Ljava/lang/Integer;
      17: invokestatic  #51                 // Method scala/runtime/BoxesRunTime.equals:(Ljava/lang/Object;Ljava/lang/Object;)Z
      20: ifeq          27
      23: iconst_1
      24: goto          28
      27: iconst_0
      28: invokevirtual #53                 // Method res_$eq:(Z)V
      31: return

  public void x2();
    Code:
       0: aload_0
       1: getstatic     #36                 // Field foo/Ops$EqOps$.MODULE$:Lfoo/Ops$EqOps$;
       4: pop
       5: getstatic     #41                 // Field foo/Ops$.MODULE$:Lfoo/Ops$;
       8: pop
       9: iconst_1
      10: invokestatic  #47                 // Method scala/runtime/BoxesRunTime.boxToInteger:(I)Ljava/lang/Integer;
      13: iconst_2
      14: invokestatic  #47                 // Method scala/runtime/BoxesRunTime.boxToInteger:(I)Ljava/lang/Integer;
      17: invokestatic  #51                 // Method scala/runtime/BoxesRunTime.equals:(Ljava/lang/Object;Ljava/lang/Object;)Z
      20: ifeq          27
      23: iconst_1
      24: goto          28
      27: iconst_0
      28: invokevirtual #53                 // Method res_$eq:(Z)V
      31: return

  public void x3();
    Code:
       0: aload_0
       1: getstatic     #36                 // Field foo/Ops$EqOps$.MODULE$:Lfoo/Ops$EqOps$;
       4: pop
       5: getstatic     #41                 // Field foo/Ops$.MODULE$:Lfoo/Ops$;
       8: pop
       9: iconst_1
      10: invokestatic  #47                 // Method scala/runtime/BoxesRunTime.boxToInteger:(I)Ljava/lang/Integer;
      13: aload_0
      14: invokevirtual #57                 // Method i:()I
      17: invokestatic  #47                 // Method scala/runtime/BoxesRunTime.boxToInteger:(I)Ljava/lang/Integer;
      20: invokestatic  #51                 // Method scala/runtime/BoxesRunTime.equals:(Ljava/lang/Object;Ljava/lang/Object;)Z
      23: ifeq          30
      26: iconst_1
      27: goto          31
      30: iconst_0
      31: invokevirtual #53                 // Method res_$eq:(Z)V
      34: return

  public void z1();
    Code:
       0: aload_0
       1: iconst_1
       2: aload_0
       3: invokevirtual #57                 // Method i:()I
       6: if_icmpne     13
       9: iconst_1
      10: goto          14
      13: iconst_0
      14: invokevirtual #53                 // Method res_$eq:(Z)V
      17: return

  public foo.Test();
    Code:
       0: aload_0
       1: invokespecial #61                 // Method java/lang/Object."<init>":()V
       4: aload_0
       5: iconst_1
       6: putfield      #20                 // Field i:I
       9: aload_0
      10: iconst_0
      11: putfield      #28                 // Field res:Z
      14: return
}

However, IMO this should be fixable. I'm pretty sure it has something to track what values are boxes of something, and it could intrinsify calls to BoxesRunTime.equals() when both arguments are known to be boxes.

In fact, even the Scala.js optimizer doesn't do that intrinsification today; we should add that when we get the chance.

But the fact remains that this is something that is doable in a back-end optimizer, in a way that is not harder than in the front-end.

@japgolly
Copy link
Contributor Author

japgolly commented Apr 8, 2021

Here's an additional test / datapoint:

import scala.quoted.*

object Target {
  def blah(a: Int): Int = 1
  def blah(a: Any): Int = 2
}

object Macros:
  inline def test[A](inline a: A): Int =
    ${ macroImpl[A]('a) }

  def macroImpl[A: Type](a: Expr[A])(using Quotes): Expr[Int] =
    '{ Target.blah($a) }

@main def main = {
  println(s"Direct:     ${Target.blah(1)} | ${Target.blah("")}")
  println(s"Via Quotes: ${Macros.test(1)} | ${Macros.test("")}")
}

It prints:

Direct:     1 | 2
Via Quotes: 2 | 2

It seems to me that a potential fix is:

  • have quoting choose the correct overloaded method according to the provided arguments (whose types and even terms are statically known at quote-evaluation-time)
  • if the above bit about quote-evaluation-time is nonsense, then maybe we need a need bit of AST wrap the quoted code more declaratively without resolving overloaded methods, followed by another compiler phase to transform the new AST-type by unpacking and applying the same overload-resolution logic that normal code enjoys.
  • all of the above but replace "overload resolution" with whatever special logic the compiler applies to AnyRef-like methods on primitives (== being one case)

@japgolly japgolly changed the title Inlining boxes Inlining or quoting causes boxing Apr 8, 2021
@LPTK
Copy link
Contributor

LPTK commented Apr 8, 2021

@japgolly You can think about why it's not possible the following way.

Consider:

def foo(x: Int): Int = x
def foo(x: Any): String = x.toString

The quote '{ val tmp = Test.foo($x); tmp.length }, where x is an Expr[A], for some abstract A, is well-typed, but the quote '{ val tmp = Test.foo(42); tmp.length } is not.

We can't just haphazardly retype things with more specific types, as that could break programs in arbitrary ways. Also see this related issue: #11924

If I were you, I would favor the use of macro methods which can select the efficient implementation by case analysis on the type of the argument. In the case of methods which you don't control, like ==, you could implement a tree transformation that tries to rebuild the calls to certain methods (using the Tree interface) while checking it conserves the expected type, and if that rebuilding fails for a given call, just back out and use the original call (possibly with a warning to the user).

@japgolly
Copy link
Contributor Author

japgolly commented Apr 8, 2021

@LPTK Yeah good point. That just means we need to narrow the surface area a little though; it doesn't mean the entire concept needs to go to the bin. I imagine the most basic way to avoid problems would be to:

  1. ensure that all overloads have the same return type; otherwise emit a warning and just use the existing (boxing) behaviour
  2. treat whichever overload the current implementation chooses as (1) a fallback, and (2) proof that it will work for all types. If further specialisation doesn't work out or whatever reason, we can just emit a warning and just use the fallback

@japgolly
Copy link
Contributor Author

japgolly commented Apr 8, 2021

Sorry @LPTK I completely missed your last paragraph somehow. Reading it I see we have an approach in mind that is mostly the same, just that my idea is to apply that to the quoting machinery so everyone gets it for free (which is highly likely to be what users expect/assume would happen anyway).

@sjrd
Copy link
Member

sjrd commented Apr 8, 2021

We can't do that. While it applies to ==, in general there is no guarantee that overloaded methods are only optimizations of each other. The problem highlighted by @LPTK is at the type-system level, but the same problem can arise at the semantics/contract level. So even if the result types are the same, there is no guarantee that the contract is the same, and that selecting another overload preserves semantics.

Re-elaborating is a big no-no. It breaks semantics, no matter how one wants to look at it. Quotes are elaborated once, like "normal" code; we can't just re-elaborate them later.

@nicolasstucki
Copy link
Contributor

In the example above, the simplest solution seem to be to do something like

  def macroImpl[A: Type](a: Expr[A])(using Quotes): Expr[Int] =
    a match
      case '{ $a: Int } => '{ Target.blah($a) } // chose `blah` overload for Int
      case _ => '{ Target.blah($a) }

This way each quote '{ Target.blah($a) } gets elaborated with the overload you expect and therefore gets specialized in some sence.

@nicolasstucki nicolasstucki changed the title Inlining or quoting causes boxing Inlining or quoting generic types causes boxing Apr 8, 2021
@japgolly
Copy link
Contributor Author

japgolly commented Apr 19, 2021

Re-elaborating is a big no-no. It breaks semantics, no matter how one wants to look at it. Quotes are elaborated once, like "normal" code; we can't just re-elaborate them later.

IMO this is behaving the opposite of "normal" code. For lack of a better term, "true inlining" doesn't care about types or semantics; it simply serves as compile-time substitution. Like in C, something like #define M(i) (i == i) doesn't care about which overload is selected after expansion; it's up to users to do the sensible thing and the compiler will catch any errors that may come about after expansion. I don't know any other language that tries to preserve contractual semantics when inlining. I don't imagine that any developer would expect any differently either. Can you really imagine anyone ever saying "oh no! When I pass in Ints it uses my overload for Ints!"? The workaround in such an odd case would be to manually add a type-ascription to AnyRef which takes 1 second. Where as the workaround the problem I'm describing is that everyone will need to pattern match on types and handle every primitive case, and they'll have to do it often. That's a lot more effort and boilerplate than adding : AnyRef very rarely (if ever).

@sjrd
Copy link
Member

sjrd commented Apr 19, 2021

What you're describing is macro expansion, not inlining, even in C. True inlining definitely cares about types and semantics. It's the whole point of inlining versus untyped macro expansion. That you can confidently write your library in way that is guaranteed to work, irrespective of in which conditions your users call it.

@nicolasstucki
Copy link
Contributor

IMO this is behaving the opposite of "normal" code. For lack of a better term, "true inlining" doesn't care about types or semantics

What you refer to as "true inlining" is syntactic inlining, this kind of inlining is found in preprocessor such as C/C++ macros.
On the other hand, we have semantic inlining which provides strong guarantees about the program semantics and less surprises on the users of the method/macro.

Scala 3 uses semantic inlining.

nicolasstucki added a commit to dotty-staging/dotty that referenced this issue Apr 20, 2021
Their specilized versions are known to have the same semantics as the generic `==` or `!=`.

Improvement related to scala#11998
@japgolly
Copy link
Contributor Author

Right, that makes sense! (And thank you both for taking the time to clarify, TIL.)

Ok so let me back up a little bit and go step by step. inline in Scala 3 is "semantic inlining", what's the expectation for the quoting API? Its semantics are in the "macro expansion"/"syntactic inlining" bucket, right? I say so because it's trivial to write a method using Quotes that can break only for certain inputs; "break" meaning either I throw an exception, or generate code that doesn't type-check once it's spliced. And if that is the case that quoting uses syntactic inlining, then do we agree that the quoting API should be choosing specialised overloads, and direct, unboxed ops on primitives?

@japgolly
Copy link
Contributor Author

For reference, to avoid boxing in the Scala 2 world I simply used c.Expr[Boolean](q"$a == $b"). I think Scala 2 macros are equivalent to the Scala 3 quoting API which is part of my surprise that using quotes still resulted in boxing.

@LPTK
Copy link
Contributor

LPTK commented Apr 21, 2021

Its semantics are in the "macro expansion"/"syntactic inlining" bucket, right?

No, one could say it's in the semantic macro expansion category :^)

It's designed to bring together the advantages of semantic inlining (reliability and predictability) with the extra expressiveness and control of macro expansion. It is patently not designed to just cobble random pieces of syntax together to see if they stick after the macro has expanded, which is what C-style "unhygienic" macros do, and result in terrible user experience. One of the main complaints in Scala 2 macro was the bad user experience of broken macros, which raise seemingly-meaningless type errors when they fail, and where you have no way of telling what's wrong short of printing the expanded code and reading through unintelligible computer-generated nightmare.

generate code that doesn't type-check once it's spliced

Interested to know of any examples of this.

Note that we're not talking about the underlying lower-level trees API here, which is more powerful, though with fewer guarantees, and which will re-type things in the way you want. I still think it's a really bad idea to try and retype everything all the time. It's kind of like people who discover implicit conversions for the first time, think they are so useful, and want to use them everywhere, only to realize that this eventually leads to unmaintainable hell.

As for what you want to do, as was already pointed out, it would be a lot better as a compiler pass (perhaps in a compiler plugin).

nicolasstucki added a commit to dotty-staging/dotty that referenced this issue Apr 21, 2021
Their specilized versions are known to have the same semantics as the generic `==` or `!=`.

Improvement related to scala#11998
@nicolasstucki
Copy link
Contributor

generate code that doesn't type-check once it's spliced

Interested to know of any examples of this.

There are some cases that might be miscategorized as not type checking after inlining such as the use of summonInline or error which might fail during type checking but not due to type checking.

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

5 participants