Understanding code that leads to swift::_checkGenericRequirements calls

A performance assessment has led me to look into code paths that lead to swift_conformsToProtocol. One of the common callers is swift::_checkGenericRequirements. From a previous forum post ([SE-0143] Dynamic casting for conditional conformances), @Douglas_Gregor describes how conditional conformance leads to a runtime check of generic requirements.

In our code, a primary source of calls to swift::_checkGenericRequirements is coming from RxSwift. However these calls don't seem to be due to conditional conformance. I've reduced some RxSwift code into the below code. This code results in a call to _checkGenericRequirements when the Sink is constructed, and it's not clear to me why a runtime check conformance is needed.

I'd like to learn which patterns of code lead to runtime conformance checks, and ideally how to write code that avoids it. Any directions for further reading/viewing on this subject are much appreciated.

In this code, the Sink<T: ObserverType> class by itself does not trigger _checkGenericRequirements, it's also the presence of let disposable: Disposable. Removing either the protocol constraint, or removing the existential property, will prevent/bypass the generic requirements check at runtime.

protocol ObserverType {}
protocol Disposable {}

class Sink<T: ObserverType> {
    let disposable: Disposable

    init(disposable: Disposable) {
        self.disposable = disposable
    }
}

class Observer: ObserverType {}
class Disposer: Disposable {}
_ = Sink<Observer>(disposable: Disposer())

The stack trace that leads to the swift::_checkGenericRequirements call is:

frame #0:  swift_conformsToProtocol
frame #1:  swift::_conformsToProtocol(swift::OpaqueValue const*, swift::TargetMetadata<swift::InProcess> const*, swift::TargetProtocolDescriptorRef<swift::InProcess>, swift::TargetWitnessTable<swift::InProcess> const**)
frame #2:  swift::_checkGenericRequirements(llvm::ArrayRef<swift::TargetGenericRequirementDescriptor<swift::InProcess> >, llvm::SmallVectorImpl<void const*>&, std::__1::function<swift::TargetMetadata<swift::InProcess> const* (unsigned int, unsigned int)>, std::__1::function<swift::TargetWitnessTable<swift::InProcess> const* (swift::TargetMetadata<swift::InProcess> const*, unsigned int)>)
frame #3:  _gatherGenericParameters(swift::TargetContextDescriptor<swift::InProcess> const*, llvm::ArrayRef<swift::TargetMetadata<swift::InProcess> const*>, swift::TargetMetadata<swift::InProcess> const*, llvm::SmallVectorImpl<unsigned int>&, llvm::SmallVectorImpl<void const*>&, swift::Demangle::Demangler&)
frame #4:  (anonymous namespace)::DecodedMetadataBuilder::createBoundGenericType(swift::TargetContextDescriptor<swift::InProcess> const*, llvm::ArrayRef<swift::TargetMetadata<swift::InProcess> const*>, swift::TargetMetadata<swift::InProcess> const*) const
frame #5:  swift::Demangle::TypeDecoder<(anonymous namespace)::DecodedMetadataBuilder>::decodeMangledType(swift::Demangle::Node*)
frame #6:  swift_getTypeByMangledNodeImpl(swift::MetadataRequest, swift::Demangle::Demangler&, swift::Demangle::Node*, void const* const*, std::__1::function<swift::TargetMetadata<swift::InProcess> const* (unsigned int, unsigned int)>, std::__1::function<swift::TargetWitnessTable<swift::InProcess> const* (swift::TargetMetadata<swift::InProcess> const*, unsigned int)>)
frame #7:  swift::swift_getTypeByMangledNode(swift::MetadataRequest, swift::Demangle::Demangler&, swift::Demangle::Node*, void const* const*, std::__1::function<swift::TargetMetadata<swift::InProcess> const* (unsigned int, unsigned int)>, std::__1::function<swift::TargetWitnessTable<swift::InProcess> const* (swift::TargetMetadata<swift::InProcess> const*, unsigned int)>)
frame #8:  swift_getTypeByMangledNameImpl(swift::MetadataRequest, llvm::StringRef, void const* const*, std::__1::function<swift::TargetMetadata<swift::InProcess> const* (unsigned int, unsigned int)>, std::__1::function<swift::TargetWitnessTable<swift::InProcess> const* (swift::TargetMetadata<swift::InProcess> const*, unsigned int)>)
frame #9:  swift::swift_getTypeByMangledName(swift::MetadataRequest, llvm::StringRef, void const* const*, std::__1::function<swift::TargetMetadata<swift::InProcess> const* (unsigned int, unsigned int)>, std::__1::function<swift::TargetWitnessTable<swift::InProcess> const* (swift::TargetMetadata<swift::InProcess> const*, unsigned int)>)
frame #10: swift_getTypeByMangledNameInContext
frame #11: ConformsToProtocol`__swift_instantiateConcreteTypeFromMangledName
frame #12: ConformsToProtocol`main [inlined] generic specialization <ConformsToProtocol.Observer> of ConformsToProtocol.Sink.__allocating_init(disposable: ConformsToProtocol.Disposable) -> ConformsToProtocol.Sink<A> at main.swift:0 [opt]
2 Likes

The compiler generates a mangled name from which to generate the metadata for Sink<Observer>, and the runtime demangler calls _checkGenericRequirements to fill in the protocol requirements for Sink when it creates the metadata. This happens only the first time Sink<Observer> is referenced.

Thanks Joe. In some cases _checkGenericRequirements isn't called, and I'm hoping to learn how to reason about when the call happens, and when it doesn't.

For example, by changing the disposable property from type Disposable (a protocol) to Disposer (a class), results in no call to _checkGenericRequirements. In this sample code, Sink needs a constrained generic type and an existential property to trigger a call to _checkGenericRequirements. If either of those are removed, then the call does not happen. What triggers the call, and what doesn't?

First time per Observer, including the product of its associated types? In other words, if there's an Observer whose Element is Int, and another whose Element is String, will these each result in a call to _checkGenericRequirements?

It's possible that there's some optimization that eliminates the class instance entirely that fires when the property is of class type but not when it's an existential for some reason, since your constructor call could get inlined, and the object is never used after it's constructed. If we never construct the object, then we never need the class's metadata. A more reliable way to force the class metadata to be instantiated might be to print it:

print("\(Sink<Observer>.self)")

We'll generate a mangled name for every fully concrete compound type whose metadata we need to instantiate, so Sink<Int> and Sink<String> would both be instantiated once separately. In unspecialized generics, we would not go through the mangler.

Why are you concerned about _checkGenericRequirements calls?

A performance analysis has shown the aggregate of swift_conformsToProtocol are one cause of slow app launching. From Instruments, a majority of these calls are coming from _checkGenericRequirements – which is being triggered by RxSwift.

thanks for this tip

I'm working on making it so that mangled names used for type references directly encode the conformances used by their protocol requirements, which should eliminate the overhead you're seeing. @nate_chandler has also been working on metadata prespecialization for generic types we know are needed at compile time, which could get the runtime out of the business of creating these metadata records entirely. In the meantime, the top-of-tree toolchain also has a frontend flag -Xfrontend -disable-concrete-type-metadata-mangled-name-accessors which will disable the use of the demangler for type metadata accessors and bring you back to the Swift 5.1 behavior. This could have a significant code size impact, though.

1 Like

I'm having the same performance problems also related to RxSwift.
@Joe_Groff or @nate_chandler any updates here?
I think we feel the problem more acutely since we update our app weekly which (correct me if I'm wrong) invalidates the conformance cache.

Is there anything we can do to mitigate this short of using fewer generic types? Or fewer concrete versions of generic types?

The runtime cost of swift_conformsToProtocol, should be optimized after iOS 16 introduced dyld_shared_cache_for_protocol_conformance.

Only the first launch of App (without dyld cache in the sandbox container) will pay the O(n^2) symbol search algorithm

You can test on iOS 16+,
using real iPhone not simulator, for this performance test

For iOS 15 and earlier, we (...) have a plan to use the similar way to cache the protocol conformance check result to disk, more information will be available when we're ready to ship it to production environment

1 Like

Does dyld_shared_cache_for_protocol_conformance cache survive an app update though?
We update the app weekly, so a higher percent of sessions are first time users for a particular version.

Is there any way to observe the cache hit rate?

On first launch, dyld will build what's called a "launch closure", which will accelerate (among other things) subsequent conformance lookups for protocols in your app. SDK protocols can always use the cache, since it's not part of your app.

Newly updated apps count as first launch for this purpose.

*insider ex-Apple knowledge* I vaguely recall that on iOS specifically this was done at install time rather than actual first launch, since the user is already waiting during an install. This is obviously not contractual, but neither is any dyld cache, so is that still a thing? Or can you neither confirm nor deny—

I double checked with Mike before responding but it's always possible I misinterpreted :slight_smile: hopefully someone else can confirm

1 Like