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
- “Macros in Scala 3 — Inline”. docs — Scala 3.
- Wikipedia. “Inline expansion”.
- Baeldung. “The inline modifier in Scala 3”.
- Josh Suresh. “Avoiding macros with inline and derived”. Scala Love in the City Conference. 2021
- Maxime Kjaer. “Metaprogramming in Scala 3: Inline | Let’s talk about Scala 3”. Scalacenter & 47 Degrees Academy.
- Dean Wampler. “Scala 3: A Look at “inline” (and “Programming Scala” is Now Published!)”. 2021