How is isolation info modeled on reabstraction thunks?

In looking into the issues outlined here, it got me thinking – how is function isolation modeled for reabstraction thunks? It seems like they don't have the information generally present on SILFunctions that represents actor isolation (specifically the actorIsolation property). Should they?

Here's a derivation from the motivating example, where we convert a nonisolated(nonsending) function from a non-throwing to a throwing representation.

func toThrowsReabstractionThunk(
    _ src: nonisolated(nonsending) @escaping () async -> Void
) -> nonisolated(nonsending) () async throws -> Void {
    src
}

The thunk generated for this function conversion looks like (canonical SIL):

// thunk for @escaping @callee_guaranteed @async (@guaranteed Builtin.ImplicitActor) -> (), loc * "<compiler-generated>":0:0, scope 3
sil shared [transparent] [reabstraction_thunk] @$sBAIegHgIL_BAs5Error_pIegHgILzo_TR : $@convention(thin) @async (@sil_isolated @sil_implicit_leading_param @guaranteed Builtin.ImplicitActor, @guaranteed @async @callee_guaranteed (@sil_isolated @sil_implicit_leading_param @guaranteed Builtin.ImplicitActor) -> ()) -> @error any Error {
// %0                                             // user: %2
// %1                                             // user: %2
bb0(%0 : $Builtin.ImplicitActor, %1 : $@async @callee_guaranteed (@sil_isolated @sil_implicit_leading_param @guaranteed Builtin.ImplicitActor) -> ()):
  %2 = apply %1(%0) : $@async @callee_guaranteed (@sil_isolated @sil_implicit_leading_param @guaranteed Builtin.ImplicitActor) -> (), loc * "<compiler-generated>":0:0, scope 3
  %3 = tuple (), loc * "<compiler-generated>":0:0, scope 3 // user: %4
  return %3, loc * "<compiler-generated>":0:0, scope 3 // id: %4
} // end sil function '$sBAIegHgIL_BAs5Error_pIegHgILzo_TR'

However, if we manually implement the thunk:

func toThrowsManualThunk(
    _ src: nonisolated(nonsending) @escaping () async -> Void
) -> nonisolated(nonsending) () async throws -> Void {
    let thunk: nonisolated(nonsending) () async throws -> Void = {
        await src()
    }
    return thunk
}

The codegen we get is:

// closure #1 in toThrowsManualThunk(_:), loc "/app/example.swift":237:66, scope 7
// Isolation: caller_isolation_inheriting <-- 👈👇
sil private [isolation "caller_isolation_inheriting"] @$s6output19toThrowsManualThunkyyyYaKYCcyyYaYCcFyyYaYCcfU_ : $@convention(thin) @async (@sil_isolated @sil_implicit_leading_param @guaranteed Builtin.ImplicitActor, @guaranteed @async @callee_guaranteed (@sil_isolated @sil_implicit_leading_param @guaranteed Builtin.ImplicitActor) -> ()) -> @error any Error {
// %0                                             // users: %7, %5
// %1 "src"                                       // users: %6, %5, %4, %3
bb0(%0 : $Builtin.ImplicitActor, %1 : @closureCapture $@async @callee_guaranteed (@sil_isolated @sil_implicit_leading_param @guaranteed Builtin.ImplicitActor) -> ()):
  debug_value undef : $any Error, var, name "$error", argno 1, loc "/app/example.swift":237:66, scope 7 // id: %2
  debug_value %1, let, name "src", argno 2, loc "/app/example.swift":235:7, scope 8 // id: %3
  strong_retain %1, loc "/app/example.swift":238:15, scope 7 // id: %4
  %5 = apply %1(%0) : $@async @callee_guaranteed (@sil_isolated @sil_implicit_leading_param @guaranteed Builtin.ImplicitActor) -> (), loc "/app/example.swift":238:15, scope 7
  strong_release %1, loc * "/app/example.swift":238:15, scope 7 // id: %6
  hop_to_executor %0, loc * "/app/example.swift":238:15, scope 7 // id: %7
  %8 = tuple (), loc "/app/example.swift":239:5, scope 7 // user: %9
  return %8, loc "/app/example.swift":239:5, scope 7 // id: %9
} // end sil function '$s6output19toThrowsManualThunkyyyYaKYCcyyYaYCcFyyYaYCcfU_'

I built this with the isolation printing option enabled[1], and you can see that the manually-implemented closure has explicit actor isolation set, while the synthesized reabstraction thunk does not.

I assumed these generated thunks still have a notion of function isolation, but it's just not modeled in the same way as it is for explicit functions. Is that assumption correct?

In this specific case, is it accurate to say the reabstraction thunk actually has "CallerIsolationInheriting" isolation, but it's just not represented via the actorIsolation property? If so, is it reasonable to infer that particular isolation value for the function by checking for the presence of the implicit isolated parameter?


  1. -Xllvm -sil-print-function-isolation-info â†Šī¸Ž

3 Likes

I know little about this, but I think it's better to use raw SIL in experiments like this. The thunk and the closure have the same pattern in their use of hop_to_executor in raw SIL . Both hops to implicit actor on entry and on exit. So the difference in canonical SIL comes from optimization.

Thunk (raw SIL):

sil shared [transparent] [serialized] [reabstraction_thunk] [ossa] @$sBAIegHgIL_BAs5Error_pIegHgILzo_TR : $@convention(thin) @async (@sil_isolated @sil_implicit_leading_param @guaranteed Builtin.ImplicitActor, @guaranteed @async @callee_guaranteed (@sil_isolated @sil_implicit_leading_param @guaranteed Builtin.ImplicitActor) -> ()) -> @error any Error {
bb0(%0 : @guaranteed $Builtin.ImplicitActor, %1 : @guaranteed $@async @callee_guaranteed (@sil_isolated @sil_implicit_leading_param @guaranteed Builtin.ImplicitActor) -> ()):
  hop_to_executor %0, loc * "<compiler-generated>":0:0, scope 3 // id: %2
  %3 = apply %1(%0) : $@async @callee_guaranteed (@sil_isolated @sil_implicit_leading_param @guaranteed Builtin.ImplicitActor) -> (), loc * "<compiler-generated>":0:0, scope 3
  %4 = tuple (), loc * "<compiler-generated>":0:0, scope 3 // user: %6
  hop_to_executor %0, loc * "<compiler-generated>":0:0, scope 3 // id: %5
  return %4, loc * "<compiler-generated>":0:0, scope 3 // id: %6
} // end sil function '$sBAIegHgIL_BAs5Error_pIegHgILzo_TR'

Closure (raw SIL):

sil private [ossa] @$s6output19toThrowsManualThunkyyyYaKYCcyyYaYCcFyyYaYCcfU_ : $@convention(thin) @async (@sil_isolated @sil_implicit_leading_param @guaranteed Builtin.ImplicitActor, @guaranteed @async @callee_guaranteed (@sil_isolated @sil_implicit_leading_param @guaranteed Builtin.ImplicitActor) -> ()) -> @error any Error {
bb0(%0 : @guaranteed $Builtin.ImplicitActor, %1 : @closureCapture @guaranteed $@async @callee_guaranteed (@sil_isolated @sil_implicit_leading_param @guaranteed Builtin.ImplicitActor) -> ()):
  debug_value undef : $any Error, var, name "$error", argno 1, loc "/app/example.swift":30:66, scope 5 // id: %2
  debug_value %1, let, name "src", argno 2, loc "/app/example.swift":28:7, scope 6 // id: %3
  hop_to_executor %0, loc * "/app/example.swift":30:66, scope 6 // id: %4
  %5 = copy_value %1, loc "/app/example.swift":31:15, scope 5 // users: %10, %6
  %6 = begin_borrow %5, loc "/app/example.swift":31:15, scope 5 // users: %8, %7
  %7 = apply %6(%0) : $@async @callee_guaranteed (@sil_isolated @sil_implicit_leading_param @guaranteed Builtin.ImplicitActor) -> (), loc "/app/example.swift":31:15, scope 5
  end_borrow %6, loc "/app/example.swift":31:19, scope 5 // id: %8
  hop_to_executor %0, loc * "/app/example.swift":31:15, scope 5 // id: %9
  destroy_value %5, loc "/app/example.swift":31:19, scope 5 // id: %10
  %11 = tuple (), loc "/app/example.swift":32:5, scope 5 // user: %12
  return %11, loc "/app/example.swift":32:5, scope 5 // id: %12
} //

EDIT: Ah, after reading your post more carefully, your question is about different thing :flushed:

Yeah, the question here is more about how the underlying isolation modeling for the thunks works (it seems they don't have isolation info represented in the same way other functions do). In this particular case, since the motivating problem seems to stem from the OptimizeHopToExecutor pass, canonical SIL is relevant since I wanted the output after at least that pass ran. But I suppose just for the purposes of this question it might not matter which type of SIL is used.

I didn't bring this up initially, but as you can see from the outputs, something seems to be a bit off since the implicit thunk gets both hops eliminated and the manual one doesn't despite the fact that they seem like they should ultimately generate the same code (at least, eventually after optimizations are applied).