Idea/Question: Default arguments vs ABI vs code size

The current ABI for default arguments generate explicit functions for each default argument in a function. As a simple example, when you call something like this:

// In the defining module.
func foo(a: Int, b: Int = 42, c: Float = 3.14) { 

// On the caller side.
foo(a: 192)

it compiles into the equivalent of:

  // In the defining module.
  func foo_impl(a: Int, b: Int, c: Float) {...}
  func foo_b() -> Int { return 42 }
  func foo_c() -> Float { return 3.14 }

  // On the caller side.
  let bval = foo_b()
  let cval = foo_c()
  foo_impl(a: 192, b: bval, c: cval)

This approach seems suboptimal for a bunch of reasons: 1) it bloats code size on the caller, and most functions have many callers but a single implementation. 2) it generates multiple symbols for the function, each of which need to be exported from a dylib and mangled, bloating object size and slowing dyld. 3) if default argument types are resilient in the caller but known in the callee this generates inefficient code. 4) The callee doesn't see these default values, which can affect optimizations within the callee. 5) These functions are tiny, and not great for the branch predictor, particularly when calling across dylibs (which involve a call through an indirect stub).

The code bloat in particular can be quite large, just look what simple calls to print turn into for example.

I know the goal here is to allow evolution of these default values, but we could achieve the same semantic result by changing the ABI to implicitly hoist these things to optionals and pass defaults as nil. For example, we could compile the above into code like this:

  // In the defining module.
  func foo_impl(a: Int, b bval: Int?, c val: Float?) {
    let b = bval ?? 42
    let c = cval ?? 3.14

  // On the caller side.
  foo_impl(a: 192, b: nil, c: nil)

Has anyone considered doing this?



What would the effect of a change like this be on certain default arguments like #file and #line, which are defined as referring to the callsite?

This would also make default parameter overrides (as in this thread: [Pitch] Allow default parameter overrides) in classes trivially fall out.


In the master branch, default argument generators should already be always-inlined into the caller. We've already realized that the "resilient default arguments" thing doesn't work for a number of reasons and plan to change it before ABI stability. cc @Slava_Pestov

1 Like

Oh ok, what's the model for that then? Using 'print' as an example that uses this, does this mean that the Swift core stdlib module ends up with SIL functions for both of them, and they are inlinable, always_inline, and public?

The model should be more or less the C++ model, where the default arguments show up as inlinable SIL functions but aren't in the binary.

1 Like

+1 makes sense! Thanks.

Actually, @Slava_Pestov have you considered the model I described at the top? It seems like it would have several advantages, including code size, compile time, runtime efficiency and simplicity of model. It seems strictly better than the model of having serialized always_inline "expression function" bodies, doesn't it?


Plus as I mentioned, we will be able to refer to the instance and non-defaulted arguments in the default value declaration.

I would love it if you guys do this.

1 Like

That's an interesting idea. Two differences between your approach and the current model:

  • Default arguments become "resilient". @Joe_Groff mentioned there are reasons this was not the chosen approach, but I don't know what they are.

  • Adding a default argument is no longer an ABI compatible change. I don't know how much of a problem this is in practice.

If we used an Optional at the ABI boundary, that would make introducing a default argument a breaking change, which is something we want to support. The 4.0-and-older scheme of default arguments being completely caller-side also breaks the ability to retroactively add default arguments, since the default argument generators wouldn't be available to old versions, which was one of our motivations for making them always callee-inlinable. I think it also potentially increases the net code size between caller and callee, since the caller now needs to reify a null constant, and the callee needs to test for null and conditionally instantiate the default value. If a default argument expression is complex, this would still be a net code size win since there are many callers for each callee, but in the common case where the default argument is a literal or a function call, it's likely to reduce down to an immediate constant or function call on the caller side anyway.

Is being able to "add default arguments retroactively without breaking ABI" important enough to be worth the code bloat and complexity costs? It seems like a very minor advantage with strict engineering disadvantages that impact all code that uses default arguments in a pretty significant way.

Is being able to “add default arguments retroactively without breaking ABI” important enough to be worth the code bloat and complexity costs?

Code bloat is potentially an issue when inlining default argument generators at the call site (but I imagine most of the time it will be a simple literal like nil, 0, "", etc). Can you explain what you mean by complexity? It seems "simple" in the sense that default arguments are basically not part of the ABI...

Also note that your model can be implemented without breaking ABI also (but compiled functions will need to know their Swift version so that the caller knows what convention to use for default arguments).


Making default arguments client-side inlined is trivial—we take our existing default argument generators and change their linkage to be inline-into-client, functionality we already implemented for other reasons. Making default arguments be passed as optional seems quite a bit more complex to me by contrast, since we now need to consider whether default arguments exist during SIL type lowering, we need a new kind of thunk to remove the optionality when using the function as a closure value, we need a new codegen path to test for null generate the default values in the function preamble, and so on. I can accept the argument it may be better for code size, but implementation complexity is not a compelling factor.

What do people think about generating thunks instead? To use @Chris_Lattner3's example:

// In the defining module.
func foo(a: Int, b: Int = 42, c: Float = 3.14) { ... }
// The compiler generates three thunks in the module
func foo(a: Int) { return foo(a: a, b:42, c:3.14) }
func foo(a: Int, b:Int) { return foo(a: a, b:b, c:3.14) }
func foo(a: Int, c:Float) { return foo(a: a, b:42, c:c) }

This avoids the callee side problems, and doing so creates an implicit and efficient ABI/resiliency boundary.

The number of thunks scales linearly for unnamed defaulted arguments. And the number of thunks for named defaulted arguments is 2^n-1, which in theory is unreasonable, but in practice would work because well-behaved code has only a handful of parameters to begin with, let alone defaulted parameters.


Interesting. This also adds the feature of functions with default arguments being able to fulfill protocol requirements and opens a door for supporting default arguments in protocols.

But I don't know about implications on overload resolution, type checker performance and stuff like that.

I think this addresses a particular concern I've had, which is the adding of parameters to a function without the need to recompile calling programs. I believe that it does not work currently, but that this would solve the issue.

public func log(msg: String) { ... }

log(msg: "message to be logged")

New definition:
public enum Destination { case logfile, console, both }
public func log(msg: String, dest: Destination = .logfile) { ... }

New usage:
log(msg: "message to be logged", dest: .both)

The original call, with a single parameter, should still work (even if part of a separate module) and would behave now as it did when there was no second parameter available. Agree?

We can already implement this with the current model, it just hasn't been a priority.

This approach or any other approach based on emission of the default argument expression inside the callee wouldn't work with "magic" default arguments, such as #file, #line, and so on.

Why should the "magic" values like #file, #line, etc drive the design of default arguments? (I don't think they should.)

For example, an assert()-like API could use @_transparent to capture the caller's #file, #line, etc and then call a helper API. To me, this balances the set of various design/efficiency tradeoffs.