Inline optimizations

The following code seemingly does the right thing, but I'm curious as to why:

@inline(__always) func sideEffects() -> Int {
    print("called")
    return 0
}

@inline(__always) func check(_ val: Int) {
    assert(val == 0)
}


check(sideEffects()) // always
assert(sideEffects() == 0) // debug only

assert takes an autoclosure that is only evaluated in debug builds so the behavior of the second test is expected.

For the first test, the behavior is also expected but I wonder:

  1. Is this because the compiler picks up the side effects produced by the print statement and emits it anyway or
  2. do functions, even when inlined, add additional constraints to the optimizer?

Edit:

I guess this is a bad example, since both sideEffects() and check() are inlined in main, but the question still stands in the presence of conditionals

The two lines are semantically different, and the compiler is doing the right thing in both lines.

Adding @inline(__always) does not change the semantics of your code, you would get the same result with or without the inline optimization.

In your second line:

assert(sideEffects() == 0) 

the call to sideEffects() is inside the assert() and as you state, this is an auto closure that is only evaluated in debug builds.

The first line:

check(sideEffects())

is semantically equivalent to:

let val = sideEffects()
check(val)

and after inlining both functions, it becomes:

print("called")
let val = 0
assert(val == 0)

and since the print statement is outside the assert() it will execute in release mode.

1 Like

Yes, that is what I meant by my edit (both sideEffects() and check() are inlined in main).

The question is about the fact that in the first example, val is only ever used by check() which is a noop in release. Thus, the call to sideEffects() should be subject to dead-code removal

Edit and conclusion:

I guess that the compiler greedily emits all inlined code to topmost level, then applies dead-code removal which in this case only removes the return 0 but should do what I expect otherwise (e.g. option 1)

Thanks!

It would be subject to dead-code removal if the compiler can prove that the call to sideEffects() has no "side effects" :slight_smile:, but you appropriately named function does have one side effect (the print statement).

1 Like

Worded differently, imagine this code

logResult(saveTheWorld())

function calls are evaluated inside to outside, so saveTheWorld() is evaluated before logResult(...).

If logResult(...) is a no-op in release builds, the compiler will optimize the code by simply discarding the return value of saveTheWorld(), but it won't remove the call to saveTheWorld() as that would change the semantics of the program, and the compiler will only perform optimizations that does not change the semantics of the code.

4 Likes

This example will be more clear on how compiler behaves if after print/in return statement was some intensive work -- summing to 1 billion, for example. In that case you will have significant difference in running time of optimised and debug versions.

@inline(__always)
func sideEffects() -> Int {
    print("called")
    return (0..<Int(1e9)).reduce(0, +) * 0
}

If you doubt that * 0 could do the trick, removing it will still make release version run fast.

Edit: disregard this, it was just really fast


What kind of black magic is happening here though:

@inline(never) func sideEffects() -> Int {
    print("called")
    return (0..<50_000_000).reduce(0, +)
}

@inline(never) func check(_ val: Int) {
    assert(val > 0)
}


check(sideEffects())

Is the compiler just ignoring @inline(never) and optimizing anyway?

Assertions are preserved through inlining, so if a function gets inlined it will have assertion behavior according to the build settings of where it's ultimately compiled into. If you call the non-inline entry point, it will behave as built with the original module according to its build settings.

1 Like

I was only really using assertions to keep the example simple, but since you brought up modules:

How good are the @inlinable heuristics? As observed above, dead-code elimination can produce faster code even ignoring the cost of the function call itself which may be quite beneficial even when crossing package boundaries.

Is @inlinable @inline(__always) respected across modules?

1 Like

@inline(__always) should lead to a function always being inlined in -O mode. -Onone still won't inline it though.

1 Like

I guess I was a bit unclear:

SomeLibrary.swift

@inlinable @inline(__always) public func getSomething() -> (cheap: Int, expensive: Int) {
    ///...
}

SomeProgram.swift

import SomeLibrary

print(getSomething().cheap)

My question is if I compile SomeProgram with -O, will it always optimize the expensive bit away as it does within the same module?

It will always get inlined. Whether it can determine expensive is dead or not and eliminate the computation is a lot more nuanced. If anything on the path of computing expensive looks like it might have other side effects, we're not going to eliminate that side effecting code.

1 Like

[nitpicky caveat] A function call should always be inlined. Other uses of a function may not be (if you’re passing the function as a value to another function that is not inlined, there’s nothing the compiler can do).

3 Likes