Proposal to change the default ownership for passing parameters

Currently the calling convention for non-trivial parameters in swift is that the caller transfers ownership to the callee.
In terms of reference counting, it means that arguments are passed with a +1 reference count.

This is good if the callee stores the argument or returns it, because a store/return needs a +1 reference count anyway.

class X { ... }

func caller() {
  // retain x
  foo(x)
}

func foo(x: X) {
  arr[0] = x // no reference counting required
}

But in many cases, it ends up that the caller has to retain the argument and the callee just has to release the argument at the end of the function.

func foo(x: X) {
  let y = x.value
  // release x
}

Also, if an argument is used multiple times in the caller, it ends up being retained multiple times just to be released again in every callee.

func caller() {
  // retain x
  foo(x) // release x in callee
  // retain x
  bar(x) // release x in callee
  // retain x
  baz(x) // release x in callee
}

The argument convention has an effect on all arguments with non-trivial types, e.g. references, value types containing references or generic types.

The swift compiler has an optimization to change the convention for module-internal functions from "owned" to "guaranteed". "Guaranteed" means that the caller guarantees that the parameter is alive during the whole function call and the callee is not responsible for destroying/consuming the argument (also called: +0 convention). But the compiler optimization cannot change the convention for public API functions, because this would change the ABI.

We believe that the “guaranteed” convention would semantically fit better with most of library and framework APIs. In fact, this has been a longstanding request from some of the standard library contributors. Therefore we'd like to change the convention from passing parameters as "owned" to "guaranteed" by default.

Now there are certain kind of functions, which usually really "consume" their arguments: setters and initializers, because they usually store their arguments.
Those functions will stay with the owned-convention.

Note that this change is not a language change. Although it changes the lifetime of objects, the Swift language does not define when objects are being destroyed. So, except for performance and code size, this change should be transparent for users.

We are also planning to introduce new keywords (which still has to go through evolution) to manually change the convention for a specific parameter. For example this makes sense for functions which are conceptually a setter, like the argument of Array.append(). The standard library will use those keywords when appropriate. But of course, those keywords can also be used in user code.

Our preliminary measurements show that on average benchmark performance improves with the guaranteed convention (although there are also some regressions). Also code size improves on average (up to 5%). But this data is preliminary as we didn’t complete the implementation yet.

Feedback/comments are welcome.

Erik

10 Likes

Sounds good to me, I agree this is the right default convention. For the sake of completeness, the other tradeoff this convention change potentially makes is memory usage, since objects can no longer be theoretically freed immediately after final use if the final use is in the middle of a guaranteed-convention call. I suspect the impact is likely to be small, because our ARC optimizer isn't ideal to begin with, but it's something to consider. Is there a measurable impact on peak memory use? Is this something we're set up to measure?

2 Likes

This is a good point. We didn't measure the impact on peak memory usage yet, but we are going to do that.
My expectation is that in most cases it will not make a significant difference. But there can be cases where it will matter, e.g.:

let x = large_object_graph
long_running_function(x)
// no uses of x anymore

For such situations it's best to explicitly define the argument as owned (using the new keyword).

1 Like

Hi Joe and Erik,

I think the other reason why changing to guaranteed will be better for most clients is that the ratio of non-mutating to mutating heap activity in most apps is quite high, and therefore some heap object is probably going to keep the object alive for a long time anyway.

In practice, the outermost stack retain/release exists to ensure that the object is kept alive just in case one of the callees causes the stack reference to become the last reference.

As both of you have already discussed, the only cost of this model is that sometimes objects are kept alive for a little longer than necessary, but this wouldn't be the first or last example of design tradeoffs that sacrifices peak memory usage for semantics that are more algorithm friendly. For example, plenty of SMP friendly allocators have per-thread caches and therefore higher peak memory usage until those caches can be drained.

Dave

1 Like

Hello, may I ask a newbie question? Could such change introduce modifications in ARC documentation, and compatibility issues for programs that rely on, for example, RAII?

This sounds good to me but similar to what @Joe_Groff mentioned this can also have an impact to whether copy-on-write is triggered on an object. Please find a silly example below which shouldn't trigger a CoW of numbers today as it'll likely be @owned numbers (unless the 'owned to guaranteed' optimisation (which is a pessimisation in this example) kicks in):

func printFavouriteNumbers(_ numbers: [Int]) {
    var numbers = numbers
    // everybody likes 23, right?
    numbers.append(23) /* with `@owned numbers` this shouldn't trigger a CoW if the caller doesn't need the array for anything else; with `@guaranteed numbers` this probably will */
    print(numbers)
}
let myFavouriteNumbers = [1, 1, 2, 3, 5]
printFavouriteNumbers(myFavouriteNumbers)
/* myFavouriteNumbers not used anymore here */

if I didn't miss anything, the above example will not trigger a CoW today (unless owned-to-guaranteed kicks in which I think is the case if function and caller are defined in the same module) but it would after this proposal is implemented. (@Erik_Eckstein mentioned that there'll be new keywords to change the default convention though.)
My question is: How will we track the impact of the extra copies that might be created due to the default convention change?

Hi @gwendal.roue,

Ya, somebody will need to update the ARC documentation. People will need to know the workarounds in case they allocate objects and then pass them to long-running functions that immediately destroy the object. They will either need to use the forthcoming attributes that @Erik_Eckstein mentioned, or – if possible – refactor the code to defer loading class reference from the heap until absolutely necessary (as opposed to eagerly or speculatively).

As for RAII, I don't think anything changes there, especially on the "Acquisition" part of RAII. That being said and unless things have changed, Swift does NOT promise C++ style destruction semantics, therefore the following C++ RAII pattern has never worked:

{
    let lock = Lock(obj)
    // Swift will complain that 'lock' is unused and
    // probably drop the reference immediately
    obj->mutate()
    // if this were C++, then the 'lock' destructor would
    // run at end of scope and implicitly protect 'obj'
    // in the mean time
}
1 Like

Thank you Dave.

Do you think I should urgently revise my mental model of ARC because it currently releases our lock at the end of a do { } block? What would then be the safe & guaranteed way to control when the lock is deinited/deallocated? Like below?

do {
    var lock: Lock? = Lock(obj)
    ...
    lock = nil // Guaranteed deallocation?
}

(I'm sorry because my question deviates the topic of the thread - you can see it as an encouragement at describing the consequences of the proposed change for regular developers, but also what guarantees it preserves)

It's true that the COW optimization would no longer be possible in your example without explicitly marking the numbers argument as consumed. I don't think our ARC optimizer even reliably makes that happen today, though, even though it should.

Note that, as Erik said, the default convention is still only a default, and the optimizer could still be allowed to do the opposite "guaranteed-to-owned" optimization for non-public functions where it appeared profitable.

Swift and ObjC's ARC semantics have never been sufficient to rely on block scope for RAII. If you need to keep an object alive for a specific duration, you must use withExtendedLifetime to make that explicit.

But sometimes you don't only want to have a long enough lifetime :-) You want to know if deinit has been called or not.) Those are two sides of the same coin, yes. But sometimes an app needs heads, and sometimes it needs tails!

(Assuming withExtendedLifetime only extends the lifetime, and does not make any guarantee on deallocation)

There is no builtin function in the compiler to track array buffer copying. What you can do is to instrument the stdlib, e.g. insert a print in _ContiguousArrayBuffer._copyContents and build your own compiler+stdlib with that instrumentation

Also note that as long as your function is not public and you call it directly (not via a method call), the compiler will be able to automatically convert the parameter form guaranteed to owned. But this is an optimization and you cannot rely on it. So the recommendation is clearly to use the "owned" keyword in this case.

1 Like

A variable's scope puts an upper bound on the referenced object's lifetime, and withExtendedLifetime puts a lower bound. If you do { let x = foo(); withExtendedLifetime(x) { } } it should be fairly safe to assume that x is released at the end of the scope. That still doesn't necessarily guarantee that deinit will happen if x got retained by other references somewhere.

2 Likes

Array.append should be marked as consuming as it wouldn't want the default ownership convention. It's an API that wants to actually store/persist its input.

1 Like

To maximize the opportunity for COW optimization, we may also want the nonmutating forms of mutation operations to explicitly take self consuming as well, since that would give them the opportunity to update a buffer in-place if self hold the only reference to it.

2 Likes

Interesting idea. That would solve the array-problem (from @johannesweiss)

1 Like

Could we expand the use of @escaping for this?

I'm not sure I understood your question, but parameter ownership is something different than @escaping

Hmm. The ownership transfer generally occurs because the ref is escaping the lifetime of the calling stack frame, doesn’t it?

Or if the callee destroys the copy it receives. +1 is better for that as you don't extend the lifetime of the object and if you're the last use of the object it's effectively moved in. "Escaping" is not a great word for it as this use case is more like a black hole, and nothing escapes from a black hole.

1 Like