First of all, just to reproduce (sorry, I'm on armt64) what you're seeing. Let's check out the assembly (-emit-assembly
):
$ swiftc -O -emit-assembly -module-name output test.swift | swift demangle | grep -A23 ^output.forward
output.forward(output.Storage, (output.Wrapper) -> ()) -> ():
.cfi_startproc
stp x22, x21, [sp, #-48]!
stp x20, x19, [sp, #16]
stp x29, x30, [sp, #32]
add x29, sp, #32
.cfi_def_cfa w29, 16
.cfi_offset w30, -8
.cfi_offset w29, -16
.cfi_offset w19, -24
.cfi_offset w20, -32
.cfi_offset w21, -40
.cfi_offset w22, -48
mov x20, x2
mov x19, x1
mov x21, x0
bl _swift_retain
blr x19
mov x0, x21
ldp x29, x30, [sp, #32]
ldp x20, x19, [sp, #16]
ldp x22, x21, [sp], #48
b _swift_release
.cfi_endproc
Indeed, a retain/release pair around the call to the closure (blr x19
).
For ownership information, I think looking into the SIL (-emit-sil
) is the way to go
$ swiftc -O -emit-sil -module-name output test.swift | swift demangle | grep -A13 '^sil.*forward'
sil hidden @output.forward(output.Storage, (output.Wrapper) -> ()) -> () : $@convention(thin) (@guaranteed Storage, @noescape @callee_guaranteed (@guaranteed Wrapper) -> ()) -> () {
// %0 "storage" // users: %7, %5, %4, %2
// %1 "callback" // users: %6, %3
bb0(%0 : $Storage, %1 : $@noescape @callee_guaranteed (@guaranteed Wrapper) -> ()):
debug_value %0 : $Storage, let, name "storage", argno 1 // id: %2
debug_value %1 : $@noescape @callee_guaranteed (@guaranteed Wrapper) -> (), let, name "callback", argno 2 // id: %3
%4 = struct $Wrapper (%0 : $Storage) // user: %6
strong_retain %0 : $Storage // id: %5
%6 = apply %1(%4) : $@noescape @callee_guaranteed (@guaranteed Wrapper) -> ()
strong_release %0 : $Storage // id: %7
%8 = tuple () // user: %9
return %8 : $() // id: %9
} // end sil function 'output.forward(output.Storage, (output.Wrapper) -> ()) -> ()'
We can learn a few things here:
- The
forward
function gets theStorage
as@guaranteed
:@guaranteed Storage
.@guaranteed
means that the caller guarantees that the reference is alive but it doesn't "donate" a +1 ref count to us. - The closure wants
Storage
as@guaranteed
too ((@guaranteed Wrapper) -> (), let, name "callback"
)
But that still leaves the question why we can't just "forward" the @guaranteed
the forward
receives, through Wrapper
into the callback
. This looks like it could work because we know that Wrapper
dies at the end of the function.
But let's take a step back and get the SIL without optimisations (remove the -O
)
$ swiftc -emit-sil -module-name output test.swift | swift demangle | grep -A13 '^sil.*forward'
sil hidden @output.forward(output.Storage, (output.Wrapper) -> ()) -> () : $@convention(thin) (@guaranteed Storage, @noescape @callee_guaranteed (@guaranteed Wrapper) -> ()) -> () {
// %0 "storage" // users: %7, %5, %2
// %1 "callback" // users: %8, %3
bb0(%0 : $Storage, %1 : $@noescape @callee_guaranteed (@guaranteed Wrapper) -> ()):
debug_value %0 : $Storage, let, name "storage", argno 1 // id: %2
debug_value %1 : $@noescape @callee_guaranteed (@guaranteed Wrapper) -> (), let, name "callback", argno 2 // id: %3
%4 = metatype $@thin Wrapper.Type // user: %7
strong_retain %0 : $Storage // id: %5
// function_ref Wrapper.init(storage:)
%6 = function_ref @output.Wrapper.init(storage: output.Storage) -> output.Wrapper : $@convention(method) (@owned Storage, @thin Wrapper.Type) -> @owned Wrapper // user: %7
%7 = apply %6(%0, %4) : $@convention(method) (@owned Storage, @thin Wrapper.Type) -> @owned Wrapper // users: %9, %8
%8 = apply %1(%7) : $@noescape @callee_guaranteed (@guaranteed Wrapper) -> ()
release_value %7 : $Wrapper // id: %9
%10 = tuple () // user: %11
Here we see something interesting, Wrapper.init
needs the Storage
as @owned
: %6 = function_ref @output.Wrapper.init(storage: output.Storage) -> output.Wrapper : $@convention(method) (@owned Storage, [...]
.
But we received this Storage
as @guaranteed
so kinda with a +0
reference count. Can we donate/forward one ref count into the Wrapper.init
? I think the answer is no because we don't own a ref count at all. So to satisfy the @owned
(+1) we need to retain it first (which is exactly what we do).
Now you could argue that with enough analysis, the compiler can figure out that we don't actually need to do that because Wrapper
dies again within our function. But this is only correct if we can guarantee that the closure cannot tell that we performed this optimisation.
But I think it can tell and therefore removing this retain/release pair would break the semantics of Swift.
If we were to run isKnownUniquelyReferenced
on Storage
inside the closure, we could indeed tell this apart, iff forward
gets called with a Storage
with ref count 1. But it can only have ref count 1 here if nothing on the way retained it.
With the existing code, we will always get isKnownUniquelyReferenced
false
because the caller guarantees a ref to us (so ref count >= 1) and we add a reference (-> ref count >= 2) but if we performed the (IMHO incorrect) "optimisation" to remove that seemingly unnecessary retain/release pair it might incorrectly say true
(because the ref count is >= 1). And that would be a huge issue because isKnownUniquelyReferenced
working according to the semantics makes things like CoW types work.
So I'd say the Swift compiler is correct and here is no semantics preserving way to just delete the retain/release pair in this function.
Now, said all that, what the Swift compiler can is to just inline all of the code (forward
the call to the closure and the closure itself) into the main
function (or whatever the actual caller is). If it does that, then it would gain even more knowledge.
But that's besides the point: The function forward
we looked at was the actual function, i.e. the one that gets invoked from callsites that didn't inline it. And naturally we also cannot inline the callback into the concrete forward
function because it's a parameter that we cannot know at compile time.
I'd love if @Michael_Gottesman could confirm (or deny) this :).