Improving Type Name Computation Performance

Improving Type Name Computation Performance

Introduction

The recommended way to obtain a type name from a generic type parameter is:

  • String(describing: type(of:)) — returns the type name without the module prefix

  • String(reflecting: type(of:)) — returns the fully qualified type name, including the module prefix (e.g., Foundation.Data)

However, both String(describing:) and String(reflecting:) carry significant performance overhead that may be unacceptable in performance-sensitive code paths.


Deep Dive

Performance Overhead

Internally, String(reflecting: type(of: self)) performs multiple protocol conformance checks in OutputStream.swift, testing against:

  • CustomDebugStringConvertible

  • CustomStringConvertible

  • TextOutputStreamable

  • CustomReflectable (inside the Mirror initializer)

The first conformance check for any given (type, protocol) pair is expensive because the runtime must scan all protocol conformance type descriptors. The same overhead applies to String(describing:) (OutputStream.swift).

As an application grows and accumulates more protocol conformance descriptors, the cost of the first call to String(describing:) or String(reflecting:) for any given type increases proportionally.

How Type Names Are Computed Internally

Despite the overhead above, both String(reflecting:) and String(describing:) ultimately delegate to the internal _typeName(_:qualified:) function:

  • String(reflecting:) calls it with qualified: true

  • String(describing:) calls it with qualified: false

All of the protocol conformance scanning described above is therefore unnecessary overhead when the goal is simply to retrieve a type name.

Full call-stack:

  1. String(reflecting:)

  2. _debugPrint_unlocked. Because Any.Type does not conform any of checked protocols.

  3. _adHocPrint_unlocked

  4. _adHocPrint_unlocked.printTypeName


Performance Measurements

In our production app, which contains approximately 200,000 protocol conformance descriptors, the first call to String(describing:) or String(reflecting:) for a given Any.Type takes 6–9 ms on an iPhone 13.

To isolate and quantify this overhead, I wrote a benchmark running on an M2 Max with 32 GB RAM.

First Call (nearly empty binary)

Method Duration
String(describing:) / String(reflecting:) 0.6 – 1.1 ms
_typeName(_:qualified:) 0.0085 – 0.03 ms

Subsequent Calls for the Same Type

Method Standard types (Int, Double, non-generic) Generic types
String(describing:) / String(reflecting:) 600 – 690 ns 600 – 690 ns
_typeName(_:qualified: false) 10 – 12 ns 40 – 50 ns
_typeName(_:qualified: true) 10 – 12 ns 40 – 50 ns

Summary

First call (nearly empty binary):

  • String(reflecting:) is 40–100× slower than _typeName(_:qualified: true)

  • String(describing:) is 40–130× slower than _typeName(_:qualified: false)

Subsequent calls for the same type:

  • String(reflecting:) is 13–50× slower than _typeName(_:qualified: true)

  • String(describing:) is 14–60× slower than _typeName(_:qualified: false)


Current Workaround and Its Limitations

In our codebase, we rely heavily on _typeName(_:qualified:) to avoid this overhead. Unfortunately, the leading underscore indicates that this function is officially unstable and unsupported, meaning we cannot depend on it in production code long-term.

This is the motivation for proposing a proper public API solution.


Proposed Solutions

Option 1: Add Any.Type Overloads for String(reflecting:) and String(describing:)

Introduce overloads that accept an Any.Type argument and delegate directly to _typeName(_:qualified:), bypassing all unnecessary protocol conformance checks.


public init(reflecting subject: Any.Type) {

self = _typeName(subject, qualified: true)

}

public init(describing subject: Any.Type) {

self = _typeName(subject, qualified: false)

}

Precedent: Similar type-specific overloads already exist, such as DefaultStringInterpolation.appendInterpolation.

Pros:

  • :white_check_mark: No call-site changes required. Existing code using String(describing: type(of: x)) or String(reflecting: type(of: x)) will automatically resolve to the new, faster overload after upgrading to a stdlib that includes it.

  • :white_check_mark: Full performance benefit with no manual migration effort.

Cons:

  • :warning: Introduces new overloads, which could theoretically affect overload resolution in edge cases.

Option 2: Introduce a Public typeName(_:qualified:) Function

Expose _typeName(_:qualified:) as a supported public API by removing the underscore prefix.

Pros:

  • :white_check_mark: A dedicated, clearly named function is more semantically precise than repurposing String(describing:) or String(reflecting:) for type name lookup.

  • :white_check_mark: Full performance benefit.

Cons:

  • :cross_mark: Requires updating all existing call sites manually.

  • :cross_mark: Adds a new top-level function to the standard library.


Option 3: Optimize Swift Runtime Casting to Short-Circuit for Metatypes

Modify the Swift runtime so that when the argument to String(describing:) or String(reflecting:) is a metatype, the protocol conformance scanning is skipped entirely.

I am currently exploring the Swift runtime source to understand whether this is feasible, but I do not yet have a concrete implementation approach.

Pros:

  • :white_check_mark: No new public API surface.

Cons:

  • :cross_mark: Likely complex to implement correctly.

  • :cross_mark: Even if implemented, some overhead from protocol casting and Mirror initialization would remain without also addressing the String API layer.


My opinion

Option 1 is the most practical path forward. It delivers the full performance improvement with zero migration cost for existing code, and the pattern of providing type-specialized overloads is already established in the standard library.

2 Likes

Overloads degrade type checking performance and can cause source breakage due to hard-to-predict changes in how expressions resolve. If I was going to look into this, I would focus on option 3. There's quite a bit of low-hanging fruit in how we handle printing; aside from providing short circuits for commonly-printed types, we should arguably also cache the results of the conformance lookup so we know how to print the same type again without redoing the lookup every time.

It's also worth taking a step back to ask why you're rendering type names in a hot loop, and what you might do differently in your client code. In particular, people commonly complain about type printing performance because they're trying to use the type names as a Hashable key for dictionaries or sets, but printing is almost always the wrong thing to use for this; ObjectIdentifier is also Hashable and is very cheap to construct from a type reference (since it just uses the type metadata pointer value).

7 Likes

On that note, is there a reason why Metatypes aren’t Hashable? I’ve had to do the storage[ObjectIdentifier(T.self)] thing many times to implement per-type storage behind a generic interface. Why not just storage[T.self]?

1 Like

Regarding the first option, if it’s valid to implement an overload for Any.Type that skips protocol conformance checks, that approach should be valid for all types, since Any.Type is a supertype of all Metatype instances. I would expect String(describing: Foo.self) and String(describing: Foo.self as Any.Type) to behave identically. But my understanding is that the optimization of solution 1 may cause them to differ in subtle and unsurprising ways.

we should arguably also cache the results of the conformance lookup

All conformance lookups are being cached already in ProtocolConformance.cpp.

Moreover, there is caching for type-names in Casting.cpp.

But major issue with String(describing: type(of:)) is about first call for any meta-type (in our app from 6 to 9 ms). Consequent calls performance is basically optional information illustrating that not only first calls have performance struggles.

It's also worth taking a step back to ask why you're rendering type names in a hot loop, and what you might do differently in your client code. In particular, people commonly complain about type printing performance because they're trying to use the type names as a Hashable key for dictionaries or sets, but printing is almost always the wrong thing to use for this; ObjectIdentifier is also Hashable and is very cheap to construct from a type reference (since it just uses the type metadata pointer value).

You're right, using class name as key for hashing is not optimal. And there are plenty of other cases where ObjectIdentifier(Any.Type) init can help us with. But sometimes type name is needed.

For example, in generic functions:

  • Generic helper function for registering and dequeuing UICollectionViewCells and UITableViewCells
  • Generic helper function for inserting new objects to CoreData (using NSEntityDescription.insertNewObject)
  • All kinds of performance/error tracking and logging when you need some human-readable name for identifying what classes are involved

Overloads degrade type checking performance and can cause source breakage due to hard-to-predict changes in how expressions resolve

For sure, introducing many overloads can impact type checking performance. I don't think there are many usages of String(describing:) and String(reflecting:) in codebases so it can significantly degrade type checking performance. And I can't see why String(describing/reflecting:) can't have such overload while DefaultStringInterpolation has.

In Swift, metatypes do not conform to any protocols.

Option #3 has the least potential ABI-/source-breaking impact, however it won't help developers with back-deployment needs. It may make sense to try a hybrid approach where a back-deployable implementation of #1 is provided, or we make String.init(describing:) @_alwaysEmitIntoClient and add the appropriate check there (before calling into the existing ABI-stable initializer).

Backward deployment is a fair question. A faster implementation of runtime printing seems like it could be implemented largely separate from the standard library as is, since the necessary base primitives of "look up a protocol conformance" and "print a string" exist in the standard library as is. So maybe the better implementation could be shipped as a static library linked into back-deployed binaries. That might also make it easier to build that better implementation to begin with.

That probably isn't necessary from a technical perspective if we can just do:

@_alwaysEmitIntoClient
public init(describing value: Any) {
  if let value = value as? Any.Type {
    self = _typeName(value, qualified: false)
    return
  }
  self.init(describingAny: value)
}

@abi(init(describing value: Any))
@usableFromInline init(describingAny value: Any) {
  // existing codepath
}

That might also help with the overhead of doing this check, since in many cases I imagine the compiler would be able to statically know the value is not a metatype and therefore elide the call to _typeName entirely.