Why would this take 410ms to type-check?

In an attempt to diagnose chronically slow compilation times, I enabled the -Xfrontend options to warn when expressions or function bodies take longer than a certain threshold to evaluate. What I did not expect is the compiler to complain about this (>400ms to evaluate!):

extension BinaryInteger {
    @inlinable
    var isPowerOfTwo: Bool {
        self > 0 && (self & (~self + 1)) == self
    }
}

Does anyone know what’s so evil about the body of this computed variable?

Operator overload resolution is known to be expensive.:

You can find more examples in the forums.

You can workaround this by splitting up the expression into multiple expression e.g. by using temporary variables.

2 Likes

One technique to improve type-checking time is to be explicit about the types of literals in long expressions. Since literals are untyped and have their type inferred from the surrounding expression, being explicit removes work from the type checker:

extension BinaryInteger {
    @inlinable
    var isPowerOfTwo: Bool {
        self > Self(0) && (self & (~self + Self(1))) == self
    }
}
4 Likes

FWIW in Xcode 15 beta, it type-checks in 55ms for me. This is also a case that will be improved by [CSSolver] Implementation of disjunction choice favoring algorithm by xedin · Pull Request #63585 · apple/swift · GitHub, which brings it down to 12ms for me.

13 Likes

Thank you, I'll get in the habit of doing this. It is unclear why – on repeated clean + rebuild tests – the compiler would always report >400ms for that one function. I assume the complexity is "inherited" by the callsite?

Thank you, any improvement to the compiler gets my vote... heck half of Apple should just work on improving usability of the Swift toolchain :+1:

When I keep an eye on the compilation status, the behavior reminds me of the type of performance problems you get when too many inter-dependencies exist between theoretically parallel tasks whose dataset is averse to being parallelized. There must be patterns in my (newbie) Swift code that are causing this. If I take that same function body and I move it to a different, much simpler project, there is no penalty to compilation speed. But within the context of my project, that one line of code causes a bubble. The compiler gives you no idea what that context is or how to fix it... we are dealing with a 10+ years old language that requires significant cognitive resources to guessing invisible compiler behaviors in our code just to make it possible to work with it. Lesson #1 tells you "Type inference makes beautiful, easy to read code", Lesson #10 tells you "Now that your project has hit 100 source files, hint all you sources with static types to make sure you can compile before your time on earth is up!"

2 Likes

Is that function the first one in source order in a particular file? The first time the standard library needs to be loaded, the type checking time will include that, and then it's cached for later type checking requests. So you usually end up with one outlier because of that.

3 Likes

I would advise against reaching for this flag because it can be misleading. As others have mentioned you might be measuring one-time deserialization overhead if this happens to be the first thing that gets type checked. Also more often than not, expression type checking is not the dominant factor in compilation time.

Other things that can make the compiler slow:

  • declaration checking
  • SIL optimizations
  • LLVM optimizations
  • weird ClangImporter stuff
  • general overhead from duplicated work across frontend jobs in non-WMO mode
  • dark matter evenly spread through the compilation pipeline with no single apparent hotspot
  • incremental builds rebuilding too many source files because of overly-conservative dependencies getting recorded somewhere
9 Likes

Writing explicit type annotations isn't really a good rule of thumb unfortunately because the type checker still has to convince itself your annotation is correct. I might be completely wrong but my understanding is that it won't make much of a difference except in a few isolated cases involving complex polymorphic literals and such (where admittedly it's dramatic).

3 Likes

Thank you for the extra insight! Indeed I tried a couple more changes to test some of the recommendations made here...

  • Assuming the >400ms evaluation cost comes because isPowerOfTwo() is the first function to cause some other component to be loaded (hence incurring that cost) I moved it below other functions in the same BinaryInteger protocol extension, but it did not make any difference.
  • Assuming the problem comes from how the function is used in a concrete use case, I simply eliminated (commented out) all such uses of the isPowerOfTwo() function, leaving the compiler to truly just look at an unused function with a single line of code. Again, it did not make a difference.
  • Assuming that whatever "externality" is responsible for the high evaluation cost would find a different victim if I removed the isPowerOfTwo() function entirely, I removed it from the codebase. Alas no other function has suddenly become the new "victim" to this hypothetical externality. On repeated builds no additional function/expression was flagged for high evaluation cost.
  • Wrapping integer literals in Self(...) cuts time roughly by half. This is just about the only positive change I could make to the code, reinforcing the hypothesis that type inference has a huge cost even in seemingly trivial code.

Xcode 15 Beta 3 was short lived, and I'm now revisiting this problem under Beta 4. Early results suggest nothing has changed. Without type annotation, i.e. using literals 0 and 1 rather than wrapping them in Self(...), the function still takes >400ms on a clean build. Wrapping both integers in Self(...) reduces that time to <400ms (298ms in last test, setting -Xfrontend -warn-long-function-bodies=200 -Xfrontend -warn-long-expression-type-checking=200). This is with no source files actually calling this function anywhere :man_shrugging:

3 Likes

are there any better ways to find compilation bottlenecks?

4 Likes

The number of call-sites doesn't play a role in how long a function takes to type-check, even with no callers we still need to type-check to ensure we diagnose any invalid code.

Have you defined any operator overloads in your project by any chance (specifically for ==, &, &&, >, and ~)? That could explain the difference in type-checking time, particularly for any generic overloads.

1 Like

I have implemented custom == and > functions three times each, which I assume doesn't qualify as "wild overuse" of these operators. Pasted below:

/// Call into our own C-based comparator to give CGColorSpace conformance to Swift Comparable
extension CGColorSpace: Equatable {
    public static func == (lhs: CGColorSpace, rhs: CGColorSpace) -> Bool { CGColorSpaceEqualToColorSpace(lhs, rhs) }
}

// This is gives Swift Equatable conformance to classes that conform to an internal
// FxCoreEquatable objc protocol 
extension Equatable where Self: FxCoreEquatable {
    static func == (left: Self, right: Self) -> Bool {
        left.isEqual(right)
    }        
}
// This is the ugliest of the three: allow comparison of String with Optional<String> 
public extension String {
    @inlinable
    static func == (left: Self, right: Self?) -> Bool {
        right != nil && left == right!
    }
}

On the > side, the situation isn't much worse:

// Give Swift Comparable conformance to Obj-C classes that implement 
// the internal FxCoreComparable protocol
extension Comparable where Self: FxCoreComparable {
    public static func > (left: Self, right: Self) -> Bool {
        left.compare(right) == .orderedDescending
    }
}

// Give Swift Comparable conformance to NSNumber
extension NSNumber: Comparable {    
    public static func > (lhs: NSNumber, rhs: NSNumber) -> Bool {
        lhs.compare(rhs) == .orderedDescending
    }
}

// And the third > override simply provides Comparable conformance to an internal struct to 
// provide predictable ordering in a sorted collection... the fact that comparing hashes doesn't
// have a meaning useful to humans doesn't matter.
static func > (lhs: Parameters, rhs: Parameters) -> Bool {
    lhs.hash > rhs.hash || lhs.count > rhs.count
}

I assume none of the above one liners represent an obscene disregard for Swift coding conventions or compiler expectations.

FWIW I don’t think this initialiser is necessary for real-world code. And in the worst case it’s actively harmful. You can compare Thing with Optional<Thing> already without manual overloads. But the way this one is written, it will produce a different result for optionalThing == thing vs. thing == optionalThing, which sounds like a day-long headache of debugging waiting to happen.

I have had some issues with expression type checking in the past but always managed to improve the situation by use of 123 as MyNumberType (not MyNumberType(123)) and splitting one-liners into multiple subexpressions. You don’t need to worry about the latter causing performance overhead: the optimiser will remove the intermediate variables. In my case the MyNumberType(123) syntax wasn’t any faster because initialisers are also heavily overloaded.

2 Likes

Since SE-0213, these two should be equivalent.

3 Likes

Great to know! Old (Swift 2) habits die hard :slight_smile:

1 Like

Semantically, iff MyNumberType conforms to ExpressibleByIntegerLiteral, but I wouldn't be so confident about type checker performance also being equivalent.

2 Likes

It bears mentioning, although they may or may not affect type checking performance, that there are in fact issues here which are outside of best practice. It has been warned against "retroactive" conformance of a type you do not own to a protocol you do not own:

extension CGColorSpace: Equatable { ... }
extension NSNumber: Comparable { ... }

This can lead to unspecified behavior where more than one conformance is available. Additionally, as to the particular extensions above, Swift has special support for bridging concrete numeric types to and from NSNumber, so I would have especial concern about what that does to type checking performance.

2 Likes

Thank you for the additional context! I assume that if such a second conformance would suddenly land in the SDK, the compiler would be nice enough to let me know?

It won't until the implementation PR of SE-0364 is merged and included in a release of the Swift compiler you're using. In the meantime either of the conformances can be picked at up at run time and you can't guarantee which one.

2 Likes