Make Scala 2 `@inline` annotation work as `inline` keyword for Scala 3 with `-source:3.0-migration`

Currently the scala.inline annotation does’t currently do anything in Scala 3 (it just ignores it). This is becase Scala 3 added the inline keyword, which in addition to fullfilling what Scala 2’s @inline does it also covers more advanced cases (i.e. metaprogramming/macros lite).

The improvement idea here is that if Scala 3 is compiling Scala 2 source code with the -source:3.0-migration compiler flag, if it sees @inline then it treats that the same way as inline.

For those not aware, in the latest versions of Scala 2.12/2.13, the @inline annotation when combined with the -opt:l:inline flag (to enable the inliner) does actually inline the val/def that supercedes it, only failing with a warning when its nonsensical/unable to do so (i.e. inlining a method that is possible to be overridden outside of the library doesn’t work since it results in undefined behavior).

For context the problem that this is solving is that one of the liraries I work on uses both Scala 2’s @inline and Scala 3 inline in order to inline cross compat code (https://github.com/apache/incubator-pekko/blob/main/actor/src/main/scala-2.12/org/apache/pekko/util/FunctionConverters.scala, https://github.com/apache/incubator-pekko/blob/main/actor/src/main/scala-2.13%2B/org/apache/pekko/util/FutureConverters.scala) are good examples. The most glaring example of why this feature is useful is this WIP PR Enable inliner for Scala 2 by mdedetrich · Pull Request #305 · apache/incubator-pekko · GitHub, and as you can see there is a lot of code that is duplicated only because of the @inline/inline Scala 2 vs Scala 3 difference.

Originally I was trying to develop a cross portable Scala macro annotation called getInline which transforms to @inline annotation for Scala 2 and inline keyword/flag for Scala 3 however it turns out that since macro annotations in Scala 3 are considered experimental it would involve having to pollute @experimental in addition to @getInline only because of Scala 3 (it appears that the intention of @experimental is its meant to propagate even to call sites of the macro annotation, and you can’t bypass it even with compiler flags).

Due to this I thought it would be wiser to entertain this idea of making it more official. In regards to specific details I don’t think it would be a lot of work, the Scala 3 compiler would just need to pick up the _root_.scala.inline annotation and treat it as inline. It may be necessary to add some sanitation to this, i.e. make this logic only apply to superseding ValDef/DefDef and anything else that may make sense.

Also note entirely sure if this technically needs a SIP, @SethTisue told me that if its under a flag (which is why I suggested -source:3.0-migration since its only relevant to cross compatibility compiling with Scala 2) it shouldn’t need a SIP. If thats the case then feel free to move the discussion elsewhere.

Pinging @lrytz if he wants to add any comments since he wrote the Scala 2 inliner that we all enjoy!

2 Likes

Recently I was removing backticks from the annotation in the standard library, which I assume were added at a time when inline was a hard keyword. It makes sense to me to take it to mean the obvious thing, at least in certain positions. For example, if inline val has the semantics of final val, it would not be useful to support the annotation.

As a footnote, 2.13 supports simplified optimizer options, where -opt:inline:** is equivalent to (let’s see if I can remember) -opt:l:inline -opt:inline-from:**. I’m sorry they weren’t backported for intrepid cross-compilers who optimize. Forgive me for calling them optimized optimizer options.

While this may be correct on a completely technical level, I don’t think that it would be constructive to put such a limitation because the entire idea behind this annotation is for cross compiling and so being more lax about is actually a benefit here.

Ultimately this is one of those features where its “buyers beware”, i.e. its going to be used by someone who knows exactly how the inliner will work for both Scala 2 and Scala 3, so even if inline val just means final val, thats not a problem.

If you actually do have enough differences between the inliner in Scala 2 vs Scala 3 then by definition you would likely need different source for the respective Scala versions and so this is not going to be useful for you anyways. For my personal usecase, the @inline is largely used on basic final def and there shoudn’t be any issues here, the intention is not that the Scala 3 compiler suddenly supports macro inline def via the @inline annotation in Scala 2. It should be the other way around, i.e. define the minimal set of @inline for Scala 2 and make the Scala 3 compiler translate the @inline to inline only for those cases.

1 Like

As my first reaction, I think 2’s @inline and 3’s inline are different and should not be conflated. But I’m very curious about other opinions.

The reason Scala 2 does inlining in its optimizer is to enable further optimizations such as closure or box elimination. It’s a backend (or even link-time) feature.

Scala 3’s inline is a language feature that is mainly useful for type-level programming and metaprogramming. Using it for performance optimization is going to fall short as there are no downstream optimizations in place - inlining itself is not an optimization, it just duplicates code that can then be specialized to the conditions at the callsite.

Just to clarify, that is how we are using both @inline and inline currently, we do want to actually inline call sites with the primary purpose being inlining code that is bespoke for the Scala version. In more detail, there is wrapper that contains def functions but the implementation of those functions is specific for the various Scala versions and the purpose of the @inline/inline here is so that it brings in that bespoke Scala code at the call site.

It is kind of a minor performance optimization (in the sense that its avoiding an unnecessary function call, JVM should inline this but there are extreme cases where it doesn’t mainly due to an upper cap on how often it can inline functions).

The idea is not to to implement (or more accurately expect) Scala 2’s full inliner optimization’s into Scala 3, its rather to have a cross compatible inline across various Scala versions whos primary purpose is to inline the definition on the call site and the entire reason why this suggestion exists is because there is a code out there that is having to deal with multiple Scala versions and more specifically has bespoke code for those said different Scala versions. If it happens to do more optimizations than that (which it does do in Scala 2’s case) then great but thats icing on the cake.

How confident are you that inlining these forwarders results in better performance?

Thats a hard question to answer because part of any performance implications is also due to how deeply nested the function calls can be which is dependent on how users call the function in their own code (alluding to -XX:MaxInlineLevel here). At least in the context of Pekko I need to investigate this more (my current efforts were delayed due to the Scala 2.13 inliner issue which has since been resolved so I will look into this in more detail). What I do know is that aside from the forwarders, Akka dev’s did add @inline into their codebase for what I assume is good reason, i.e. it did have an impact. Now it may be true that with the new Scala inliner this is not that necessary (I have to confirm this).

However I think the general question of “is @inline needed and/or what are the theoretical performance implications?” is a slight distraction, I think the more pertinent point here is that the function calls for forwarders is completely unnecessary (unlike in other cases where you have functions just because of how code/API structured) and because of that you do always wan’t @inline/inline here. Is this feature critical? No not really, but that is also why the suggestion is meant to be a practical minimum effort one. Its a nice to have that fixes some low hanging fruit and reduces a lot of boilerplate.

If this feature required a lot of effort I wouldn’t even be suggesting this, the macro I am writing to solve this issue is still on the table if this doesn’t go through albeit its going to raise some eyebrows with the @experimental thrown everywhere.

It’s a hard question indeed :slight_smile: MaxInlineLevel certainly plays a role in Scala, that’s true and we’ve noticed it before. IIRC, tiny forwarder methods (MaxTrivialSize) are always inlined without contributing to the inline level, but forwarders with enough parameters or an instance creation might already be larger than that.

On the other hand, Scala was always designed with the assumption that the JVM takes care of performance optimization, and forwarders in particular are extremely common (case class apply, field accessors, mixin forwarders, super accessors). A manually written forwarder is just more nagging because it’s visible.

Another aspect is binary compatibility / separate compilation. If Scala 3 starts to inline methods annotated @inline, the method implementation is duplicated into the client code. Putting a different (but binary compatible) version of the library on the classpath can break at runtime.

1 Like

At some point we had a @forceInline annotation that had the meaning of Scala-3 inline. One could imagine bringing that back. In Scala 2, @forceInline could be an alias of @inline. In Scala 3 it would expand to the inline modifier.

inline in Scala 3 can get some performance gains. Mostly, by avoiding megamorphic dispatch. But it has to be used with care and lots of measurements.

2 Likes

Agreed, but Scala is Scala and there are definitely use cases that is specific to Scala which Java users don’t typically deal with. My attitude to this is, there is no argument against removing unneccessary function calls by inlining unless we have code explosion (which can hurt performance) but since we are dealing with forwarders here thats not going to be the case.

In our case all of this forwarder code is entirely private so bincompat is a non concern because users should not even be calling these functions (this is also the case in other projects that have this pattern i.e. slick/slick-compat-collections/src/main at main · slick/slick · GitHub and Add scalac inliner options for Scala 2 by mdedetrich · Pull Request #2808 · slick/slick · GitHub)

I understand, but how is the Scala 3 compiler supposed to know? A new annotation like Martin suggested above would work for that. Or add a parameter to the existing @inline annotation.

Afaik for Scala 3 if you mark a basic def method using the inline keyword it will inline it as long as its final but I may be wrong here (if thats the case please let me know!)

In Scala 3 inline is guaranteed to inline. You get a compile-time error if inlining is not possible for some reason.

1 Like

If Scala 3 starts treating methods with the @inline annotation the same as methods with the inline keyword that is a breaking change, that’s what I’m trying to say. So we need a way to express in Scala 2 code that a method should be inlinied by the Scala 3 compiler.

I am under the impression that Scala 2’s @inline will always try to inline a method (assuming you are obviously using -opt:l:inline) and if it can’t then it produces a warning which is not the same as Scala 3’s inline compiler error but one could argue thats just a technical detail (i.e. you can use -Xfatal-warnings and its “essentially” the same as a compiler error).

Am I wrong here? At least personally when I used Scala 2’s @inline I never really experienced it failing to inline something unless there is an obvious reason why (i.e. inlining a non final def that is visible to external users which causes Scala 2 compiler to warn that its unable to inline method because it can potentially be overridden, this was the context behind the PR at Add final to methods to prepare for future inline by mdedetrich · Pull Request #388 · apache/incubator-pekko · GitHub).

That being said, I am really not against adding a @forceInline annotation that works both on Scala 2 and Scala 3 as @odersky suggested, I guess its more work but its also more correct?

That’s exactly the caveat: on Scala 2 you have to opt-in to inlining, and that comes with you having to specify where to inline from. When you inline from the classpath, you need to ensure that the compile-time and run-time classpaths contain the exact same version for dependencies that you inlined from.

Scala 3 always inlines methods marked inline, no matter where they are defined. So we cannot change Scala 3 to start inlining everything with the @inline annotation.

Understood, my argument here is that since this feature will be behind the -source:3.0-migration flag then this behaviour is implied, i.e. its an escape hatch. Its intended for power users who are dealing with cross compiling forwarder methods and since its behind a specific flag unless you do -source:3.0-migration then it has zero effect. Also there is another implied assumption here that if you are using @inline for Scala 2, then you have the opt-in inlining enabled otherwise it literally does nothing.

On another note irrespective of this change it sounds like its a good idea for Scala 3 to warn that @inline doesn’t do anything, and Scala 2 should produce a warning that if you have @inline without the necessary compiler flags then it also doesn’t do anything

It still wouldn’t be safe; assume you call some method from the Scala library that happens to be annotated @inline, so the Scala 3 compiler would now inline it. The classfiles you just compiled would only work correctly with exactly that Scala library on the classpath.

Yes, but you always need to define where to inline from. When building a library you can only inline from your own project, not from any of your dependencies.

I think the jit is clever enough to inline things, nowadays. the inline will cause problem sometime,that’s why kotlin has crossinline noinline inline etc things