[Pitch] @inline(always) attribute

Hi, here is a pitch to add an @inline(always) attribute.

The intent is for the attribute to act as an optimization control as mentioned in the thread: Optimization Controls and Optimization Hints .

The latest version of the proposal is at: swift-evolution/proposals/NNNN-inline-always.md at inline_always_proposal_draft · aschwaighofer/swift-evolution · GitHub

Let me know what you think!


@inline(always) attribute

* Upcoming Feature Flag: InlineAlways (edit: upcoming feature not appropriate)

Introduction

The Swift compiler performs an optimization that expands the body of a function into the caller called inlining. Inlining exposes the code in the callee to the code in the caller. After inlining, the Swift compiler has more context to optimize the code across caller and callee leading to better optimization in many cases. Inlining can increase code size. To avoid unnecessary code size increases, the Swift compiler uses heuristics (properties of the code) to determine whether to perform inlining. Sometimes these heuristics tell the compiler not to inline a function even though it would be beneficial to do so. The proposed attribute @inline(always) instructs the compiler to always inline the annotated function into the caller giving the author explicit control over the optimization.

Motivation

Inlining a function referenced by a function call enables the optimizer to see across function call boundaries. This can enable further optimization. The decision whether to inline a function is driven by compiler heuristics that depend on the shape of the code and can vary between compiler versions.

In the following example the decision to inline might depend on the number of instructions in callee and on detecting that the call to callee is frequently executed because it is surrounded by a loop. Inlining this case would be beneficial because the compiler is able to eliminate a store to a stack slot in the caller after inlining the callee because the function's inout calling convention ABI that requires an address no longer applies and further optimizations are enabled by the caller's function's context.

func callee(_ result: inout SomeValue, _ cond: Bool) {
  result = SomeValue()
  if cond {
    // many lines of code ...
  }
}

func caller() {
  var cond: Bool = false
  var x : SomeValue = SomeValue()
  for i in 0 ..< 1 {
    callee(&x, cond)
  }
}

func callerAfterInlining(_ cond: Bool {
  var x : SomeValue = SomeValue()
  var cond: Bool = false
  for i in 0 ..< 1 {
    // Inlined `callee()`:
                    // Can keep `SomeValue()` in registers because no longer
                    // passed as an `inout` argument.
    x = SomeValue() // Can  hoist `x` out of the loop and perform constant
                    // propagation.
    if cond {       // Can remove the code under the conditional because it is
                    // known not to execute.
       // many lines of code ...
    }
  }
}

The heuristic might fail to detect that code is frequently executed (surrounding loop structures might be several calls up in the call chain) or the number of instructions in the callee might be to large for the heuristic to decide that inlining is beneficial. Heuristics might change between compiler versions either directly or indirectly because some properties of the internal representation of the optimized code changes. To give code authors reliable control over the inlining process we propose to add an @inline(always) function attribute.

This optimization control should instruct the compiler to inline the referenced function or emit an error when it is not possible to do so.

@inline(always)
func callee(_ result: inout SomeValue, _ cond: Bool) {
  result = SomeValue()
  if cond {
    // many lines of code ...
  }
}

Proposed solution

We desire for the attribute to function as an optimization control. That means that the proposed @inline(always) attribute should emit an error if inlining cannot be guaranteed in all optimization modes. The value of the function at a call site can might determined dynamically at runtime. In such cases the compiler cannot determine a call site which function is applied without doing global analysis. In these cases we don't guarantee inlining even if the dynamic value of the applied function was annotated with @inline(always). We only guarantee inlining if the annotated function is directly referenced and not derived by some function value computation such as method lookup or function value (closure) formation and diagnose errors if this guarantee cannot be upheld.

A sufficiently clever optimizer might be able to derive the dynamic value at the call site, in such cases the optimizer shall respect the optimization control and perform inlining.

protocol SomeProtocol {
    func mightBeOverriden()
}

class C : SomeProtocol{
    @inline(always)
    func mightBeOverriden() {
    }
}

@inline(always)
func callee() {
}

func applyFunctionValues(_ funValue: () -> (), c: C, p: SomeProtocol) {
    funValue() // function value, not guaranteed
    c.mightBeOverriden() // dynamic method lookup, not guaranteed
    p.mightBeOverriden() // dynamic method lookup, not guaranteed
    callee() // directly referenced, guaranteed
}

func caller() {
  applyFunctionValue(callee, C())
}

caller()

Code authors shall be able to rely on that if a function is marked with @inline(always) and directly referenced from any context (within or outside of the defining module) that the function can be inlined or an error is emitted.

Detailed design

We want to diagnose an error if a directly referenced function is marked with @inline(always) and cannot be inlined. What are the cases where this might not be possible?

Interaction with @inlinable

Function bodies of functions referenceable outside of the defining module are only available to the outside module if the definition is marked @inlinable.

Therefore, a function marked with @inline(always) must be marked @inlinable if it has open, public, or package level access.

@inline(always) // error: a public function marked @inline(always) must be marked @inlinable
public func callee() {
}

Interaction with @usableFromInline

A public @inlinable function can reference a function with internal access if it is either @inlinable (see above) or @usableFromInline. @usableFromInline ensures that there is a public entry point to the internal level function but does not ensure that the body of the function is available to external modules. Therefore, it is an error to combine @inline(always) with a@usableFromInline function as we cannot guaranteed that the function can always be inlined.

@inline(always) // error: an internal function marked with `@inline(always)` and
                          `@usableFromInline` could be referenced from an
                          `@inlinable` function and must be marked inlinable
@usableFromInline
internal func callee() {}

@inlinable
public func caller() {
    callee() // could not inline callee into external module
}

Module internal access levels

It is okay to mark internal, private and fileprivate function declarations with @inline(always) in cases other than the ones mention above without the @inlinable attribute as they can only be referenced from within the module.

public func caller() {
    callee()
}

@inline(always) // okay because caller would force either `@inlinable` or
                // `@usableFromInline` if it was marked @inlinable itself
internal func callee() {
}


@inline(always) // okay can only referenced from within the module
private func callee2() {
}

Infinite recursion during inlining

We will diagnose if inlining cannot happen due to calls within a strongly connected component marked with @inline(always) as errors.

@inline(always)
func callee() {
  ...
  if cond2 {
    caller()
  }
}

@inline(always)
func caller() {
  ...
  if cond {
    callee()
  }
}

Dynamic function values

As outlined earlier the attribute does not guarantee inlining or diagnose the failure to inline when the function value is dynamic at a call site: a function value is applied, or the function value is obtained via class method lookup or protocol lookup.

@inline(always)
func callee() {}
func useFunctionValue() {
  let f = callee
  ...
  f() // function value use, not guaranteed to be inlined
}

class SomeClass : SomeProto{
  @inline(always)
  func nonFinalMethod() {}

  @inline(always)
  func method() {}
}

protocol SomeProto {
  func method()
}


func dynamicMethodLookup() {
  let c = SomeClass()
  ...
  c.nonFinalMethod() // method lookup, not guaranteed to be inlined

  let p: SomeProto = SomeClass()
  p.method() // method lookup, not guaranteed to be inlined
}

class A {
  func finalInSub() {}
  final func finalMethod() {}
}
class B : A {
  overrided final func finalInSub() {}
}

func noMethodLookup() {
    let a = A()
    a.finalMethod() // no method lookup, guaranteed to be inlined

    let b = B()
    b.finalInSubClass() // no method lookup, guaranteed to be inlined
}

Source compatibility

This proposal is additive. Existing code has not used the attribute. It has no impact on existing code. Existing references to functions in libraries that are now marked with @inline(always) will continue to compile successfully with the added effect that functions will get inlined (that could have happened with changes to inlining heuristic).

ABI compatibility

The addition of the attribute has no effect on ABI compatibility.

Implications on adoption

This feature can be freely adopted and un-adopted in source code with no deployment constraints and without affecting source or ABI compatibility.

Future directions

@inline(always) can be too restrictive in cases where inlining is only required within a module. For such cases we can introduce an @inline(module) attribute in the future.

@inlinable
public caller() {
  if coldPath {
    callee()
  }
}

public otherCaller() {
    if hotPath {
        callee()
    }
}

@inline(module)
@usableFromInline
internal func callee() {
}

Alternatives considered

We could treat @inline(always) as an optimization hint that does not need to be enforced or applied at all optimization levels similar to how the existing @inline(__always) attribute functions and not emit errors if it cannot be guaranteed to be uphold when the function is directly referenced. This would deliver less predictable optimization behavior in cases where authors overlooked requirements for inlining to happen such as not marking a public function as @inlinable.

10 Likes

Could this feature provide an automatic migration from @inline(__always)?

@inline(__always) is a hint rather than a control, so I wouldn’t expect automatic migration would be desirable.

(For example I use it on non-inlinable stuff pretty frequently, with the intention “this should always be inlined if it can be, but it won’t be in other modules”)

3 Likes

Minor procedural nit: did you mean for InlineAlways to be classified as an "experimental" feature flag? AFAICT, this feature is not source breaking so it wouldn't need an "upcoming" feature or new language mode if it were accepted. An experimental feature flag that is used to unlock access to new syntax that is being reviewed is different than an upcoming feature.

I likely did something wrong here. But I am not sure I understand you correctly.

Is my error in using an experimental flag to hide "the feature" behind "-enable-experimental-feature InlineArray" for now.

Or is my error in calling it a "Upcoming Feature Flag" in the proposal?

I think your pointing out the later?

(Or are those two somehow intrinsically linked)

Trying to answer this myself.

From swift-evolution/proposal-templates/0000-swift-template.md at main · swiftlang/swift-evolution · GitHub

Upcoming Feature Flag should be the feature name used to identify this feature under SE-0362. Not all proposals need an upcoming feature flag. You should think about whether one would be useful for your proposal as part of filling this field out.

Ah, sounds like an "upcoming feature" != "experimental feature" but has a distinct meaning in the swift evolution process.

Mine is an experimental feature. So I should remove the "Upcoming feature flag" from my proposal.

1 Like

I'd argue we should just make @inline(always) open|public|package always infer @inlinable instead of requiring people to write it out. In general, we require explicitly writing things out that are knowable to the compiler only when it has some correctness benefit for the reader but in this case I fail to see it--there is no alternative way this could work and it is plainly obvious (much more so, for example, than implicit Hashable constraints that we infer sometimes).

On the one hand, this seems reasonable given the stated motivation; but on the other, it seems like it would be an incredibly annoying diagnostic to deal with since the "obvious" workaround is that I'd have to open-code how much inlining I want and then, for the rest of it, write a separate non-@inline(always) function that copies the implementation body of one or the other? (Or, I guess, assign one of the functions to a value and then call it, but that also doesn't feel good as a diagnostic-silencing tactic.)

3 Likes

I'd argue we should just make @inline(always) open|public|package always infer @inlinable instead of requiring people to write it out. In general, we require explicitly writing things out that are knowable to the compiler only when it has some correctness benefit for the reader

That is a good point.

My thought when I made this choice was that this makes it explicit to the author that @inlinable is what is happening (the implementation is exposed). This line of argument assumes that the author is familiar with @inlinable and has not cared to read the documentation of @inline(always) (which would state that @inline(always) implies @inlinable).

There are two attributes that @inline(always) would compose with: @inlinable and @_alwaysEmitIntoClient.

@inline(always)
@inlineable
public func a() {}

@inline(always)
@_alwaysEmitIntoClient
public func b() {}
2 Likes

I would be very wary of this, because @inline(__always) public would validly mean “always inline within the module, but be resilient across modules”, and @inline(always) public would mean “be non-resilient”. That’s too dangerous a thing to be that subtle.

Hmm, interesting. Glossing over the fact that the latter isn't a public feature, I think it's quite subtle how the two would behave differently. Do we think these are two options deserving of equivalent prominence and an affirmative user choice each time, or is one an advanced feature and the other the right choice for most people? [Edit: What bad things would happen if we actually said @inline(always) open|public is implicitly @_alwaysEmitIntoClient?]

Well, we've said @inline(__always) also doesn't always inline, so there's a number of subtleties there. If we're worried about clashing with an explicitly underscored feature, perhaps best to cast about for a different word than reuse always for the new feature we want.

1 Like

Not unreasonable, but my specific concern is about making it easier to accidentally expose things in ABI. Mixups that you can fix later are much less concerning than mixups you have to live with forever.

1 Like