Understanding Swift's optimization to reduce executor hoppings

Hi, I've been recently carefully studying SE-0431: @isolated(any) Function Types. Here is the relevant text I stumbled on :

Swift reserves the right to optimize the execution of tasks to avoid "unnecessary" isolation changes, such as when an isolated async function starts by calling a function with different isolation. In general, this includes optimizing where the task initially starts executing:

@MainActor class MyViewController: UIViewController {
  @IBAction func buttonTapped(_ sender : UIButton) {
    Task {
      // This closure is implicitly isolated to the main actor, but Swift
      // is free to recognize that it doesn't actually need to start there.
      let image = await downloadImage()
      display.showImage(image)
    }
  }
}

However, in practice, I can't observe such optimizations really happening. Even for the following simple example:

nonisolated func bar() async {
    // <- Set breakpoint 3
}

@MainActor
func foo() async {

    Task { @MainActor in
          // <- Set breakpoint 2
    }

    Task {
        await bar()
    }

    sleep(2)
    // <- Set breakpoint 1
}

In my tests, the above code always execute in the order 1 -> 2 -> 3, no matter what method I use to observe the execution - using breakpoints, using print, etc.

In my understanding, if the optimization kicks in, the execution order is expected to be 3 -> 1 -> 2.

I understand the proposal simply said Swift "reserved" the right to do such optimizations, but I'd really like to know if they are actually implemented nowadays.

2 Likes

my interpretation is that the right to perform this optimization remains 'reserved', and hasn't yet been implemented[1]. the Task initializer currently extracts the isolation of its operation parameter to determine on which executor the Task should begin.

in the SIL for this example, the calls to Task.init look like this:

  %9 = init_existential_ref %8 : $MainActor : $MainActor, $any Actor, loc "/app/example.swift":11:10, scope 21 // user: %10
  %10 = enum $Optional<any Actor>, #Optional.some!enumelt, %9 : $any Actor, loc "/app/example.swift":11:10, scope 21 // user: %11
  %11 = partial_apply [callee_guaranteed] [isolated_any] %7(%10) : $@convention(thin) @Sendable @async @substituted <΄_0_0> (@guaranteed Optional<any Actor>) -> @out ΄_0_0 for <()>, loc "/app/example.swift":11:10, scope 21 // user: %12
  %12 = convert_function %11 : $@isolated(any) @Sendable @async @callee_guaranteed @substituted <΄_0_0> () -> @out ΄_0_0 for <()> to $@isolated(any) @async @callee_guaranteed @substituted <΄_0_0> () -> @out ΄_0_0 for <()>, loc "/app/example.swift":11:10, scope 21 // user: %14
  %13 = function_ref @$sScTss5NeverORs_rlE8priority9operationScTyxABGScPSg_xyYaYAcntcfCyt_Tt1g5 : $@convention(thin) (@in Optional<TaskPriority>, @sil_sending @owned @isolated(any) @async @callee_guaranteed @substituted <΄_0_0> () -> @out ΄_0_0 for <()>) -> @owned Task<(), Never>, loc "/app/example.swift":11:5, scope 21 // users: %25, %14
  %14 = apply %13(%5, %12) : $@convention(thin) (@in Optional<TaskPriority>, @sil_sending @owned @isolated(any) @async @callee_guaranteed @substituted <΄_0_0> () -> @out ΄_0_0 for <()>) -> @owned Task<(), Never>, loc "/app/example.swift":11:5, scope 21 // user: %15
  release_value %14 : $Task<(), Never>, loc * "<compiler-generated>":0:0, scope 21 // id: %15
  dealloc_stack %5 : $*Optional<TaskPriority>, loc "/app/example.swift":14:5, scope 21 // id: %16
  %17 = alloc_stack $Optional<TaskPriority>, loc "/app/example.swift":16:10, scope 21 // users: %25, %27, %18
  inject_enum_addr %17 : $*Optional<TaskPriority>, #Optional.none!enumelt, loc "/app/example.swift":16:10, scope 21 // id: %18
  %19 = function_ref @$s6output3fooyyYaFyyYacfU0_ : $@convention(thin) @Sendable @async @substituted <΄_0_0> (@guaranteed Optional<any Actor>) -> @out ΄_0_0 for <()>, loc "/app/example.swift":16:10, scope 21 // user: %23
  %20 = apply %1(%0) : $@convention(method) (@thick MainActor.Type) -> @owned MainActor, loc "/app/example.swift":16:10, scope 21 // user: %21
  %21 = init_existential_ref %20 : $MainActor : $MainActor, $any Actor, loc "/app/example.swift":16:10, scope 21 // user: %22
  %22 = enum $Optional<any Actor>, #Optional.some!enumelt, %21 : $any Actor, loc "/app/example.swift":16:10, scope 21 // user: %23
  %23 = partial_apply [callee_guaranteed] [isolated_any] %19(%22) : $@convention(thin) @Sendable @async @substituted <΄_0_0> (@guaranteed Optional<any Actor>) -> @out ΄_0_0 for <()>, loc "/app/example.swift":16:10, scope 21 // user: %24
  %24 = convert_function %23 : $@isolated(any) @Sendable @async @callee_guaranteed @substituted <΄_0_0> () -> @out ΄_0_0 for <()> to $@isolated(any) @async @callee_guaranteed @substituted <΄_0_0> () -> @out ΄_0_0 for <()>, loc "/app/example.swift":16:10, scope 21 // user: %25
  %25 = apply %13(%17, %24) : $@convention(thin) (@in Optional<TaskPriority>, @sil_sending @owned @isolated(any) @async @callee_guaranteed @substituted <΄_0_0> () -> @out ΄_0_0 for <()>) -> @owned Task<(), Never>, loc "/app/example.swift":16:5, scope 21 // user: %26

the initializer method is called by instructions %14 and %25, and in both cases if you backtrack slightly, you can see that the main actor is passed through as the function's isolation (instructions %10 and %22 i think).

personally i wonder if such an optimization wouldn't lead to some potentially confusing behaviors... the current implementation may be less efficient than it theoretically could be, but it is perhaps more straightforward to reason about.

at any rate, this is my best guess based on inspecting the source and current compiler output – @John_McCall or @ktoso would presumably have more definitive answers.


  1. there is however an optimization pass to eliminate unnecessary executor hopping â†Šī¸Ž

3 Likes