Surprising compilation performance of nested .init() vs Constructable()

Given the following data structs:

struct Base {
    let nested1: Nested1
    let nested2: Nested2
}

struct Nested1 {
    let grand1: GrandChild
    let grand2: GrandChild
}

struct Nested2 {
    let grand: GrandChild
}

struct GrandChild { }

I wrote two files to test compiler performance using hyperfine (thanks @Jon_Shier ):

hyperfine 'xcrun swiftc -typecheck FILENAMEGOESHERE' --show-output --warmup 1

One file contained the following, using typed init:

let base0 = Base(nested1: Nested1(grand1: GrandChild(), grand2: GrandChild()), nested2: Nested2(grand: GrandChild()))
...
let base999 = Base(nested1: Nested1(grand1: GrandChild(), grand2: GrandChild()), nested2: Nested2(grand: GrandChild()))

The other one contained the same structure, initialized using bare inits:

let base0: Base = .init(nested1: .init(grand1: .init(), grand2: .init()), nested2: .init(grand: .init()))
...
let base999 = .init(nested1: .init(grand1: .init(), grand2: .init()), nested2: .init(grand: .init()))

However the .init version was faster, consistently. Tried it across different versions of Xcode, but it was slower for every version, more than double in 15.2.

Test 13.0 14.0.1 14.2 15.1 15.2
Bare Init 616.7 675.4 715.9 1219.0 1204.0
Typed Init 801.1 823.8 872.8 2836.0 2772.0

I don't really understand why .init would be faster, as the type has to be deduced. In other tests, for example complex nested dictionaries typing the dict types at its declaration would make it much faster.

I had situations where nested .init was really, really slow for some reason (tripping up the 1s limit) where it had to be fixed by typing everything. I was trying to recreate that, but I'm seeing the inverse here.

3 Likes

Ooof, what a regression in Swift 5.9.

It's likely contextual, with other structure around the code affecting the overall performance.

1 Like

to be pedantic, you’re not actually using typed inits in the first file; you’re calling a token named Base, and the compiler needs to deduce that Base resolves to a type named Base and expand it to Base.init. in the second file, the compiler already knows that Base is a type.

3 Likes

Yeah, a good third benchmark would be let base<X>: Base = Base(nested1:... ) to see if having both improves performance.

3 Likes

I tried that! Worse! Changed NestedExplicitInit.swift to:
let base0: Base = Base(nested1: Nested1(grand1: GrandChild(), grand2: GrandChild()), nested2: Nested2(grand: GrandChild()))

Benchmark 1: xcrun swiftc -typecheck NestedBareInit.swift
  Time (mean ± σ):      1.208 s ±  0.032 s    [User: 1.007 s, System: 0.186 s]
  Range (min … max):    1.175 s …  1.278 s    10 runs
 
Benchmark 1: xcrun swiftc -typecheck NestedExplicitInit.swift
  Time (mean ± σ):      3.167 s ±  0.112 s    [User: 2.985 s, System: 0.166 s]
  Range (min … max):    3.043 s …  3.406 s    10 runs
1 Like

perhaps the problem is with name lookup and not type checking. the difference between base0:Base = Base and base0:Base = .init is that with the leading dot syntax the compiler knows to only search for members declared on Base (or extensions to protocols Base conforms to.)

2 Likes

Oh, one thing to do for benchmarks is to disable the new swift driver, it's very slow for things like this. Use the -disallow-use-new-driver flag on swiftc.

1 Like

Also, I did a bunch of similar performance testing a couple years ago, with similar weird results. Regarding Swift type inference compile-time performance

Reading that thread, it looks like these types of benchmarks have been regressing for years now.

2 Likes

Yes, that thread was the rabbit hole that got me here!

Running tests on Xcode Build Tools version: 15.2
Benchmark 1: xcrun swiftc  -disallow-use-new-driver -typecheck NestedBareInit.swift
<unknown>:0: warning: legacy driver is now deprecated; consider avoiding specifying '-disallow-use-new-driver'
<unknown>:0: warning: legacy driver is now deprecated; consider avoiding specifying '-disallow-use-new-driver'
<unknown>:0: warning: legacy driver is now deprecated; consider avoiding specifying '-disallow-use-new-driver'
<unknown>:0: warning: legacy driver is now deprecated; consider avoiding specifying '-disallow-use-new-driver'
<unknown>:0: warning: legacy driver is now deprecated; consider avoiding specifying '-disallow-use-new-driver'
<unknown>:0: warning: legacy driver is now deprecated; consider avoiding specifying '-disallow-use-new-driver'
<unknown>:0: warning: legacy driver is now deprecated; consider avoiding specifying '-disallow-use-new-driver'
<unknown>:0: warning: legacy driver is now deprecated; consider avoiding specifying '-disallow-use-new-driver'
<unknown>:0: warning: legacy driver is now deprecated; consider avoiding specifying '-disallow-use-new-driver'
<unknown>:0: warning: legacy driver is now deprecated; consider avoiding specifying '-disallow-use-new-driver'
<unknown>:0: warning: legacy driver is now deprecated; consider avoiding specifying '-disallow-use-new-driver'
  Time (mean ± σ):      1.051 s ±  0.032 s    [User: 0.949 s, System: 0.092 s]
  Range (min … max):    1.017 s …  1.112 s    10 runs
 
Benchmark 1: xcrun swiftc  -disallow-use-new-driver -typecheck NestedExplicitInit.swift
<unknown>:0: warning: legacy driver is now deprecated; consider avoiding specifying '-disallow-use-new-driver'
<unknown>:0: warning: legacy driver is now deprecated; consider avoiding specifying '-disallow-use-new-driver'
<unknown>:0: warning: legacy driver is now deprecated; consider avoiding specifying '-disallow-use-new-driver'
<unknown>:0: warning: legacy driver is now deprecated; consider avoiding specifying '-disallow-use-new-driver'
<unknown>:0: warning: legacy driver is now deprecated; consider avoiding specifying '-disallow-use-new-driver'
<unknown>:0: warning: legacy driver is now deprecated; consider avoiding specifying '-disallow-use-new-driver'
<unknown>:0: warning: legacy driver is now deprecated; consider avoiding specifying '-disallow-use-new-driver'
<unknown>:0: warning: legacy driver is now deprecated; consider avoiding specifying '-disallow-use-new-driver'
<unknown>:0: warning: legacy driver is now deprecated; consider avoiding specifying '-disallow-use-new-driver'
<unknown>:0: warning: legacy driver is now deprecated; consider avoiding specifying '-disallow-use-new-driver'
<unknown>:0: warning: legacy driver is now deprecated; consider avoiding specifying '-disallow-use-new-driver'
  Time (mean ± σ):      2.921 s ±  0.044 s    [User: 2.847 s, System: 0.066 s]
  Range (min … max):    2.881 s …  3.008 s    10 runs

Worse

Looks a little bit better than the previous version. Really it's just a way to get the benchmarks to show the true performance difference rather than the regression from the driver. But since that overhead is constant it probably doesn't make a difference.

Sounds like you could be on to something! But confusing, in some cases adding the type on the left hand makes it faster while with literals it makes it slower"

let some: String = "compiles slow", while let some = "compiles fast"

If you read through the previous thread I linked, it was pointed out that literals are different here in that they have a whole inference process to go through to find the resulting type, which can be short circuited by providing a type. But I'd hope that let s = "" and let s: String = "" would be nearly identical since String is the default type for string literals.

3 Likes

Yeah I did. It's just hard to explain to people what would be the right approach if it's all over the place really:

  • Literals should never be typed, neither left- nor right hand, unless you assign something to a different type than the default type for the literal, for example let float: Float = 1.0
  • Arrays should never be typed, even when they're heavily mixed in terms of types. The compiler might demand you to declare it as : [Any] though when you mix too many types.
  • Dictionaries should be typed like let dict: [String: [String: [String: Any]]] = ..., at least the more complex ones
  • Constructables should be typed at the left hand declaration, like let some: Class = .init()

Of course our lives should not be dedicated to satisfying the whims of compiler, but I have been literally telling people to stop using .init (also because I think it's ugly and makes code less readable) because the way it behaves on literals and some - I guess - edge cases where it seemed to make the compiler slow.

2 Likes

Yeah, there's really no clear guidance aside from "watch out for slow builds, instrument, try different solutions to fix the slow bits" but even that's not complete. Certain workarounds may be more valuable between Swift versions, and the timing instrumentation doesn't actually tell you the real impact, given the compiler has transitioned to be more internally lazy, so often those timings are the "first access" time, not the amount of time that structure takes every time you see it in code.

Barring a statement of goals by the compiler team about which construct "should" be fastest, I'm not sure we can generalize this at all.

2 Likes

I ran the tests several times and I didn't get the same results anymore. Typed inits seemed to be your best bet in any case:

I wrote a blog post about it (draft), it seems that .init is especially bad when the "left hand side" (is there a proper term for this?) is not clearly defined:

https://lucasvandongen.dev/compiler_performance.php

Let me know if I'm completely missing the point somewhere, if you get different results on your machine or you're missing some interesting tests. Running all of the tests using the "a" option only sometimes gave warnings or errors on Xcode 13. Never figured out what that was?

2 Likes