Scala 3 inline: macro-like superpowers

A guest post by François Sarradin, Data engineer and CTO @ Univalence, blogger at Kerflyn’s blog & teacher at Université Gustave Eiffel.


Scala 3 introduces a new keyword called inline. This keyword proposes a concept of metaprogramming that will let you perform some code manipulation at compile-time, while still being distinct from macro. In a sense, it has macro-like superpowers, without being macro.

Inline

Inline is a keyword that appeared long ago in C/C++ languages. It asks the compiler to (try to) replace all the calls to a function by the content of its body. This can improve the performance of your application, as there is no function call. But, on the other hand, this may result in larger executable files. So, it will be a good to understand how it works and what heuristic is used to determine when the inlining is effectively proceeded, because there are cases where even if you qualify a function with inline, the inline expansion will not happen.

@inline is an annotation in Scala 2.x. With almost the same effect as C/C++. The scaladoc of the SDK indicates:

Note that by default, the Scala optimizer is disabled and no callsites are inlined. See -opt:help for information on how to enable the optimizer and inliner.

When inlining is enabled, the inliner will always try to inline the methods or callsites annotated
@inline (under the condition that inlining from the defining class is allowed, see -opt-inline-from:help). If inlining is not possible, for example, because the method is not final, an optimizer warning will be issued. See -opt-warnings:help for details.

Inlining is also an optimization proposed by the JIT (Just-In-Time) compiler at runtime, where the invocations of some methods are replaced by their body.

Almost all compilers perform constant folding, which looks like the inlining process —ie. converting simple hard-coded expression by the result of their evaluation at compile-time.

So, inline is already understood as a way to modify the behavior of the compiler. But, Scala 3 pushes this concept beyond the limitations seen above.

Scala 3 Inline function

Let’s start with a classic function, which increases a value according to a rate.

def increase(value: Double, rate: Double): Double =
   value * (1.0 + rate)

And let’s use it to increase the value 2500 by 2% and print the result.

println(increase(2500.0, 0.02))

In the Java bytecode, we will see that the two values are loaded in the call-stack, then that the function is called.

 3: aload_0
   4: ldc2_w        #39                 // double 2500.0d
   7: ldc2_w        #41                 // double 0.02d
  10: invokevirtual #44                 // Method increase:(DD)D
  13: invokestatic  #50                 // Method scala/runtime/BoxesRunTime.boxToDouble:(D)Ljava/lang/Double;
  16: invokevirtual #54                 // Method scala/Predef$.println:(Ljava/lang/Object;)V

Now, let’s just add inline in front of the increase function definition

inline def increase(value: Double, rate: Double): Double =
    value * (1.0 + rate)

NoIn the bytecode, the values are not loaded, and the increase function is not called. We only loaded 2550.0, which is the result of increasing 2500 by 2%.

3: ldc2_w        #34  // double 2550.0d
  6: invokestatic  #41  // Method scala/runtime/BoxesRunTime.boxToDouble:(D)Ljava/lang/Double;
  9: invokevirtual #45  // Method scala/Predef$.println:(Ljava/lang/Object;)V

Thus, by using the inline keyword, the code of increase is directly evaluated by the compiler at its call-sites. And the result of this evaluation is used instead of loading values and calling the function. So, this goes beyond the definition of inline seen with C/C++.

This also happens because the parameters are constant that can be evaluated at compile-time. What if we have a more complicated expression in the parameters?

val value = "2500.0".toDouble
println(increase(value, 0.02))

Here is what we get

18: dload_2            // Get 2500.0d from value
 19: ldc2_w        #54  // double 1.02d
 22: dmul               // Multiply 2500.0d by 1.02d
 23: invokestatic  #61  // Method scala/runtime/BoxesRunTime.boxToDouble:(D)Ljava/lang/Double;
 26: invokevirtual #65  // Method scala/Predef$.println:(Ljava/lang/Object;)V

There is a specific computation for the variable value, appearing in the bytecode (not shown here). After that, the parameters are loaded, then the multiplication is performed. So, in case Scala 3 has to deal with complex expressions, it does not try to resolve the function. Instead, it replaces the call-site by the content of the function. This is named partial evaluation.

Scala 3 Inline if condition

Let’s declare this function, that indicates if an integer value is positive or negative.

inline def signOf(value: Int): String =
   if (value >= 0) "Positive"
   else "Negative"

As we have seen above, if the parameter is a simple expression, the call to signOf will be evaluated and replaced by the compiler by the string value in the bytecode. But if the parameter is a more complex expression, then the call will only be replaced by the body of the function (partial evaluation).

It may happen that you do not want partial evaluation. You will not be able to force the complete to perform an exhaustive evaluation, especially if the parameter comes from user input, a third-party service, or any random sources. But you can tell the compiler to avoid complex expression as parameter. This is done by using an inline if.

inline def signOf(value: Int): String =
   inline if (value >= 0) "Positive"
   else "Negative"

This version of signOf will work like the previous one, by replacing the call with the awaiting string. But, what happens if we use a more complex expression?

signOf("42".toInt)

Here, we get a compilation error

Cannot reduce `inline if` because its condition is not a constant value: /* inlined from outside */ value$proxy2.>=(0)
     println(signOf("42".toInt))

There is also an equivalent feature with pattern matching

inline def present[A](option: Option[A]): String =
   inline option match {
     case None => "Absent"
     case Some(_) => "Present"
   }

Scala 3 Inline recursive function

What if we use inline on a recursive function?

inline def fact(n: Int): Int =
     if (n == 0) 1
     else n * fact(n - 1)

Most of the time, it will work perfectly, especially if you use literal constants. The compiler will resolve the conditional recursive call until it gets a result. If you do fact(5), like in the code below:

println(fact(5))

So, in the bytecode, fact(5) is replaced by 120. As you can see in the disassembled bytecode below, there is no call to fact, just the appearance of the constant 120.

3: bipush        120  // Push 120 in the stack
 5: invokestatic  #39  // Method scala/runtime/BoxesRunTime.boxToInteger:(I)Ljava/lang/Integer;
 8: invokevirtual #43  // Method scala/Predef$.println:(Ljava/lang/Object;)V

Now what happened with the code below?

println(fact("5".toInt))

What you get is a compiler error like this

Maximal number of successive inlines (32) exceeded,
  Maybe this is caused by a recursive inline method?
  You can use -Xmax-inlines to change the limit.

Even if you change the parameter -Xmax-inlines, it will not solve the problem, as “5”.toInt is not a constant that can be resolved at compile time. In this case, for a faster failing, you can inline the if expression.

inline def fact(n: Int): Int =
     inline if (n == 0) 1
     else n * fact(n - 1)

So, for the expression println(fact(“5”.toInt)), you get the compiler error

Cannot reduce `inline if` because its condition is not a constant value

But, if you do println(fact(33)), you get the previous error. Nevertheless, by settings -Xmax-inlines to a higher value, the compilation will succeed here.

I think that it is really interesting to use inline if or inline pattern matching with recursive inline functions, in a view to distinguish the different cases seen above when the compilation fails.

Scala 3 Inline parameter

You can use inline with function parameters.

inline def logInfo(inline message: String): Unit =
    inline if (debug.isInfoLevel) println(message)

In this case, the parameter almost act as a by-name parameter: the parameter evaluation is done only when it is used in an evaluated expression. The difference with by-name parameter is that it is evaluated by the compiler. Also, every time you use the inline parameter, it will be reevaluated. So be careful, especially if the parameter is a code block which takes time to evaluate.

Scala 3 Inline variable

Note that inline also applies to variables. For example: inline val rate = 0.02. This guarantees that any use of rate will be replaced by 0.02 in the code. With such declaration, rate is not of type Double, but rather of literal type 0.02. That’s it. Inline variable only works with literal types. If you try other kinds of type, you will get a compilation error.

inline value must have a literal constant type

Scala 3 Transparent inline

Transparent inline is a feature that applies to functions. A transparent inline function resolves its output type into a more specific type at compile-time.
Imagine that we have an ADT describing two kinds of chef.

trait Chef

 case object FrenchChef extends Chef:
   def speakFrench: String = "Bienvenue dans mon restaurant !"

 case object EnglishChef extends Chef:
   def speakEnglish: String = "Welcome to my restaurant!" 

We need a function that retrieves the kind of Chef depending on a boolean using inline.

inline def getChef(isFrench: Boolean): Chef =
    if isFrench then FrenchChef else EnglishChef

If we try to retrieve the FrenchChef and makes him speak, we get an error.

val frenchChef = getChef(true)
  frenchChef.speakFrench // value speakFrench is not a member of Chef

It does not work, since getChef returns a value of type Chef. Actually, we need the detail of implementation of a FrenchChef. That’s exactly what transparent inline is made for.

// getChef returns Chef, but at compile-time it will be replaced
 // by one of its implementation
 transparent inline def getChef(isFrench: Boolean): Chef =
   if isFrench then FrenchChef else EnglishChef

 val frenchChef = getChef(true)
 frenchChef.speakFrench // Bienvenue dans mon restaurant !

 getChef(false).speakEnglish // "Welcome to my restaurant!"

Both call-sites are not expended at the same moment during compilation. By nature, transparent inline need to be resolved during type-checking, since it can influence it, whereas inline can be resolved when the program is fully typed.

If the parameter cannot be defined at compile-time, the result of the function is not specific.

val frenchChef = getChef("true".toBoolean)
 frenchChef.speakFrench // value speakFrench is not a member of Chef

Here, the transparent inline function will act as a normal function, returning a value of type Chef. If you do not want to allow non-constant expressions in parameters, you can use an inline if.

transparent inline def getChef(isFrench: Boolean): Chef =
    inline if isFrench then FrenchChef else EnglishChef

Conclusion

We have seen different usages of inline keyword in Scala 3: as a function declaration modifier, as a conditional expression modifier, as a variable declaration modifier. All those usages aim to force the compiler to evaluate part of your program at compile-time, and thus remove whole code blocks to be compiled and converted into bytecode.

The advantage is that you can bring early optimizations to your applications. But you have to remember that it will only work with expressions that can be evaluated at compile-time, else the compiler will try to do its best to reduce your expressions, and in the worst case, it will not reduce anything. In case the evaluation of your inline declaration is based on the evaluation of some predicates, you can use inline if or inline pattern matching, in a view to check if your declaration can be evaluated at compile-time.

Regarding performance, the article by Dean Wampler (see references below) is interesting. It tends to indicate that inline expansion removes some significant overhead that the JIT has to deal with. Thus, the inline expansion would generate faster code. But, the gains tend to disappear with loops containing a huge number of iterations.

References

You may also like:

Authors

François Sarradin
François Sarradin

Data engineer and CTO @ Univalence, a service company in big data (Spark, Kafka, Hadoop, NoSQL...). I contribute to the development of services managing high volumes and high throughput. I am also a teacher at Université Gustave Eiffel on distributed persistence, distributed computation, and Scala.

Latest Blogposts

24.11.2022 / By  Daria Karasek

ZIO Test: What, Why, and How?

Functional World meetup is a global event aimed at improving learning and knowledge sharing among developers with a passion for functional programming. This year’s edition was focused on the ZIO Scala library. In that vein, we invited developer and ZIO enthusiast Marcin Krykowski to speak about the functional power of ZIO Tests. Marcin previously worked […]

24.11.2022 / By  Daria Karasek

Find the Perfect Specialists for Your Project: Test Team-Matching Startup Teamy.ai

Teamy.ai is a startup that integrates industry-leading team-matching algorithms with various workforce management capabilities in a single platform. It’s a new technology from experienced software house Scalac and offers a range of services, from AI-powered candidate matching to extensive skills mapping.  Teamy.ai is almost ready to launch. And Scalac is looking for businesses to test […]

17.11.2022 / By  Daria Karasek

Scalac’s Latest Development: Team-Matching Software, Teamy.ai 

Teamy.ai, a startup from Scalac, is a team-matching software application that pairs best-fit tech specialists with specific business requirements.  Finding the right employees for your business is challenging. Not only do you need to identify candidates with the right skills, but they must also want to work with your stack, have enough experience, be compatible […]

Need a successful project?

Estimate project