Should we stop emitting public method descriptors for non-open methods?

Swift's ABI for resilient classes centers around two per-method entities:

  • The dispatch function is a function symbol that performs normal class dispatch on the method. This is called when invoking the method from outside the defining module.

  • The method descriptor is a data symbol that identifies the method. This is currently used in two ways:

    • A pointer to the method descriptor is used in override tables to identify the overridden method. This is therefore interpreted directly by the Swift runtime.
    • A pointer to the method descriptor is passed to the class's method lookup function to identify the method being looked up. Right now, the method lookup function is used exclusively in super dispatch; in principle it could be used in other ways. A separate method lookup function is emitted for any given resilient class; it typically defers to an implementation in the Swift runtime, although it could certainly substitute its own logic.

When a method is open, the implementation of all of these things needs to be "honest" to support external overrides. But when a method is not open, and the method is not overridden within its defining module, we can potentially take some shortcuts to minimize the abstraction overheads of using a class. The most important of these is that the dispatch function can just directly call (or even be an alias for) the unique implementation. But we should also be able to eliminate the v-table slot for the method entirely, since it's always known to be the same function. (There are several reasons why this is sometimes difficult in practice, arising from the implementation, but let's focus on just the ABI concerns.) And it's interesting to ask what we should do with the method descriptor symbol.

When a method is not open, we know that no code outside the module will attempt to override it. That eliminates one of the two uses of the method-descriptor symbol. The other use, super dispatch, is extremely uncommon to use in the absence of an override. That doesn't mean we can refuse to support it, but it does mean that it might be the right trade-off to make such calls a little more expensive if it gives us substantial wins elsewhere.

Method descriptors are normally emitted in an array that's 1-1 with entries in the v-table. This isn't guaranteed by the ABI; the class opts in to allowing this interpretation by using various bits of runtime support which in principle the class could just open-code. Still, it's conceptually useful to distinguish vtable method descriptors from non-vtable method descriptors. And it's important to understand that the existing runtime doesn't directly support non-vtable method descriptors, which affects the feasibility of back-deploying attempts to rely on them.

So, what are our options for optimizing these non-open but public methods?

  • We could drop the v-table entry but continue to export the method descriptor symbol as a non-vtable descriptor, just in case it's used. This has two disadvantages. First, the method descriptor adds some direct code size: the descriptor itself is fairly small, but its symbol name alone can be fairly long. But also, the current runtime does not support non-vtable method descriptors, which means we'd have to filter these out before the runtime sees them, at least when compiling code that has to deploy to existing runtimes. That's possible for the method lookup function, but it would add a lot of code size costs to that function. It would not work easily for overrides, but fortunately, you can't override a non-open method – unless it becomes open later and you want to back-deploy the override. (We'll come back to that point later.)

  • We could drop the v-table entry and decline to export a method descriptor symbol. This gets us our code-size wins, but we have to have some answer for what happens with super dispatch and back-deployed overrides. For super dispatch, we can handle this by checking whether the symbol exists and then either calling the lookup function or the normal dispatch function, depending. (This would not have the right semantics for a super dispatch in an extension method added to a public subclass defined in the defining module, but that should not be legal, as it's an encapsulation violation.) This adds overhead to super dispatches to methods that might not be overridable, but that's probably the right trade-off.

So what's about back-deployed overrides of methods that are now open but were once public? The most reasonable semantics is that the override simply has no effect when running on a library where the method is non-open. And while the current Swift runtime doesn't support overrides that resolve to a non-vtable method descriptor, it's actually forgiving about overrides with a null method descriptor, which can happen when overriding a method that simply doesn't exist in older versions of the class. That's the behavior we want! Having class emission decline to export the method descriptor when it's non-open actually enables the right behavior for this; I'd go so far as to call it a bug fix.

So in the abstract, I think we should stop exporting method descriptors non-open methods entirely. If we need method descriptors for non-open methods for intra-module implementation purposes (e.g. because there's overriding within the module), we can still emit them, just not as publicly-exported symbols. When the compiler emits a resilient super call to a non-open method, we check whether the method descriptor exists to decide whether to use the method lookup function or just the dispatch function.

The main problem I see with this is that it's technically an ABI break. Existing code that performs a resilient super dispatch to a non-open method will unconditionally assume that the symbol exists, which means it may fail to load when linked with a program compiled this way. I think doing that is probably uncommon enough that we can get away with this break, but it's something we'll have to explore.

The other problem that I foresee is that this does make it impossible to perform a correct super dispatch in extensions of subclasses defined in the defining module. Like I said, this is probably not something that should be legal; super dispatch should generally be restricted to code written in your own classes.

5 Likes

The super thing is such a bummer, but I think it really does block this optimization in the general case. Consider:

// Version 1
public class Base {
  public func f() {
    print(Base.self)
  }
}

public class Main: Base {
  public override func f() {
    print(Main.self)
  }

  @inlinable public func callSuperF() {
    super.f()
  }
}
// Version 1
public class Base {
  public func f() {
    print(Base.self)
  }
}

public class Middle: Base {
  public override func f() {
    print(Middle.self)
  }
}

public class Main: Middle {
  public override func f() {
    print(Main.self)
  }

  @inlinable public func callSuperF() {
    super.f()
  }
}

I'm not saying this is good, but I also have a hard time explaining why it's invalid, given that we do allow inserting superclasses into a class hierarchy. It'd be one thing if Swift only allowed super to access the current method/property outside of initializers, but we don't have that restriction.

EDIT: sorry, I guess I shouldn't be so negative. Let's call this another possible use of super we'd be breaking beyond extensions.

5 Likes

Oh, @inlinable code. Yes, that's a very interesting point; I'll think about it.