[go: up one dir, main page]

  1. 38
    Roadmap for improving the Swift type checker plt swift forums.swift.org
    1. 9

      I’ve been learning swift recently and the “compiler is unable to type check this expression in reasonable time” really makes an otherwise great language frustrating to program in. It really feels like one of those “computer says no” kind of errors, so I’m really glad this is being handled.

      I wonder how other languages handle this? I know languages like Haskell also have insane type inference, but I don’t know of any that have the same hard limit as swift does. Do they just accept the time complexity, or does the language allow for some better optimizations?

      1. 9

        I can't remember the name of the actual type inference algorithm used by swift, but my recollection is that its approach to bidirectional inference is much more, we'll say, powerful. It does allow some things that most other languages cannot do.

        The Damas-Hindley-Milner type system (sometimes Hindley-Milner, I'm not sure to this day why Damas is included by some, and not by others, and honestly at this point I'm not really interested in finding out :D my education has had Damas so that's my default) is the basis of more or less all functional languages, and is linear time. But that is largely possible due a function named 'f' only has one declaration, and one type (that type might be generic, but under these type systems that is a single type)

        Now consider an expression: f x (f y (f z 1))

        In DHM, f has a type for all a->b->c, all of which are fixed - they may be explicit types or they might be type parameters, so in the above we know that the type of x must be a'0, the type of (f y (f z 1)) must be b'0. In f y (f z 1), y must be a'1, the type of (f z 1) must be b'2, but also in this case we know the type of c'1 must be the same as b'0, so we've unified those types, hurrah!. The same then happens in f z 1, z must be of type a'2, and 1 is Int, so b'2 is unified with Int, e.g. b'2 == Int, and similarly to earlier we know that the return type c'2 must be the same as b'1.

        From that you can probably see the general gist of how this inference operates in linear time.

        The first complexity swift has comes from the ability to have overloads of a function, so the type of f might be a->b->c, or c->b->a, or a->b->c->d, or even a->b->c, a->b->a, a->b->d. We can discard the function with a mismatching number of parameters[1], but we have multiple possible types to select from, so in f x (f y (f z 1)) (ignore the non-swift syntax) swift cannot immediately assign a type to x, it also cannot immediately assign a type to f y (f z 1), it cannot immediately assign a type to y, it cannot immediately assign a type to f z 1, or z. We'll leave 1 until later - for now just assume it's an Int. Swift's type system is much closer to a generalized constraint solver: instead of a fairly direct linear path for assigning types, it has to try to find the a combination of the correct types and the functions those that correspond to those types, ensuring that the return types of each function works in the bounds of the caller. For example you could have (excuse the syntax)

        f(Int, Int) -> Int // f_0
        f(String, Int) -> String // f_1
        f(Float, String) -> String // f_2
        f(Int, String) -> Int // f_3
        

        Now consider what happens in the above nested calls: f z 1 this could be either f_0 or f_1, now consider f y (f z 1), currently we have two options for f z 1, one returns an Int and one returns a string, but that means we could be calling f_1, f_2, or f_3, so the return type can still be String or Int. Which means f x (f y (f z 1)) can now be f_0, f_1, f_2, or f_3. If you wrote let v : String = f x (f y (f z 1)) (ignore the syntax nonsense I'm just trying to be consistent with the expression), then you've provided a concrete type so it can reduce the possible options of the outer expression to just f_1 or f_2.

        Basically to get to a complete solution it has to determine the specific types of x, y, and z for a call to f, while at the same time the type of f determines the types of x, y, and z.

        You can probably see how it's possible to construct expressions where this can start taking a, uh... long time.

        Despite this complexity, most code decays to linear time, but "most" is not "all", and it is possible to write reasonable looking, and probably objectively reasonable, code where the inference becomes exponential over the set of types and overloads.

        So that's the basic model of the type system which leads to this, but there are ways in which this is exacerbated. Let's go back to 1, earlier we said it is an Int, but it actually isn't, it is an integer literal, and types can implement a protocol (this is exactly equivalent to trait in rust, type class in Haskell - they're all literally the same type theoretic construct) - something like ConvertibleFromIntegerLiteral though definitely don't quote me on this :D. While that may not sound super useful, but it allows somethings someDouble + 1 to work entirely from within the logic of the type system, with no special casing of "I am adding this numeric type to an integer literal".

        However I believe in the original version of swift, that meant

        let x = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1....;
        

        Would take a lot of time very quickly because without being careful about how you prioritize search and similar, every call to + (in type theory and reason arithmetic operators are functions) might be to Double + Double or Int + Int, and that therefore mean each of the +s around them could be Int or Double, so that's fun. But that's also too simple because other types implement convertible from Int: Int8, Int16, Int32, UInt, UInt8, ..., Float, etc so suddenly there are many options.

        In my experience the easiest way to cause this to happen in code that isn't written with the intent of demonstrating edge cases (e.g. the original 1+1+1... example above isn't what you would write in practice) is actually closures, I assume because absent an explicit type signature the type system simply has a single gigantic expression so essentially the entire body of the closure becomes exciting. Given the above complexity of the type inference system, at a base level it seems surprising to me that it generally handles most even relatively complex code, and that's because the actual algorithm involved is quite a bit more clever than the naive explanation above, however at a fundamental level their are expressions where inference becomes an exponential operation, but I think in some cases they become undecidable (which would assume is considered an error, as saying "we can't tell if your code is correct" seems somehow worse than "this takes to long, please provide us with some type information" :D).

        I think a lot of the type system and language for swift is really nice, I also think a lot of rust is really nice, they've just taken different approaches and tradeoffs (ignoring the very different decisions around object lifetime as that isn't really involved in how the type systems and interfaces feel) that result in complexity in different areas. Swift does also have other considerations that I feel are less of an actual choice (it's possible it would have done this anyway): ignoring the different inference algorithms most of the type system of each is the same - they both inherit from the "true" functional languages, they both model type polymorphism in the same manner. But swift also has classes in the traditional polymorphic inheritance model, because it has to interact pretty much seamlessly with objective-c (to the extent that the vtable pointer itself is compatible with objective-c's isa pointer).

        Again there are tradeoffs everywhere - the class portion of swift has a bunch of costs, but at the same time means that it is much easier for the type system to understand the C++ object model, which is much harder in rust (for instance, it is very difficult for rust to model the behavior of C++ types that have things like copy constructors). Noting of course that easier does not mean easy, because, you know, C++ :D

        [1] In general most functions written in languages like Haskell are literally a->b->c: e.g if you write a function that takes two arguments and returns a value, it is the same type as a function that takes one argument and returns a function that takes another argument. At a core level in fact all functions only take one argument, and everything else is syntactic sugar. The equivalent of a two argument function in languages like C, rust, Swift, etc is (a, b)->c.

        1. 5

          Hindley-Milner is usually efficient but it can be exponential, for example in code that looks roughly like a billion laughs attack.

          1. 3

            I can't remember the name of the actual type inference algorithm used by swift

            I would guess that it's based on Pierce's "Local Type Inference" because that's what most languages with subtyping seem to start with, but that's just a guess.

            I agree with all of your analysis. I think the crux of the issue is around overloading + implicit conversions = exponential.

            But there must be something more subtle going on because C# and C++ also have overloading and implicit conversions but as far as I know don't get wedged as badly as Swift. I would guess that those languages have some restrictions in how implicit conversions or overloads can be selected specifically to spare the type-checker from needing to explore the entire constraint solution space.

            For what it's worth, Dart avoids this problem. Type checking is (I believe!) linear or at worst maybe quadratic in some odd corners. We've avoided overloading and user-defined implicit conversions in large part to maintain the good performance of type inference and type checking.

            1. 3

              As far as I can tell It’s the addition of overloading on the return type.

              Ie Int f() Float f()

              Is invalid in most languages, even into() in rust which is still to a single type - albeit a generic one.

              1. 1

                I don't believe return type overloading is necessary to cause problems. It's enough to have:

                1. Overloading by parameter types.
                2. Generic methods or implicit conversions. (I suspect either is enough but Swift has both.)
                3. Type inference that pushes a surrounding context type into subexpressions and also uses subexpressions to infer surrounding expressions.

                Once you've got those then given a call like a(b()):

                1. You don't know which overload of a() to call until you know the return type of b().
                2. But if b() is generic or returns a type that has an implicit conversion, then you don't know the return type of b() until you've selected an override for a() that provides a context type to infer the generic type argument or implicit conversion.

                This sets up a complex nest of constraints that has to be solved. Now imagine that expression have multiple arguments and nested calls.

                1. 2

                  The point is that without return type overloading in your above example a(b()) you by definition know the return type of b(). The only way you do not know that return type is if b() has multiple definitions with different return types.

                  b() being generic isn't particularly relevant because there is only ever one option - inferring the return type is either easy via unification with the parameter type of a, or it is undecidable if there are multiple versions of a which is where you get ambiguous overloads.

                  It's very important to recognize the a function that is returning a dependent parameter is not overloaded on the return type - there is only a single return type, and that is the type parameter.

                  But if b() is generic or returns a type that has an implicit conversion, then you don't know the return type of b() until you've selected an override for a() that provides a context type to infer the generic type argument or implicit conversion.

                  This is what you're misunderstanding: if you are not able to resolve the a single declaration for the function b based solely on the parameter types provided, then resolution has failed and you'll get a warning along the lines of "ambiguous overload". By definition overload resolution does not care about the return type, only the parameters to b. e.g. there is no difference between

                  a(b())
                  

                  and

                  auto v = b();
                  a(v)
                  

                  If you can resolve b in b() you know the return type, if you can't or there are multiple choices the initial resolution of the function b will fail.

                  C++'s inference doesn't even try to handle inference over a generic return type but other languages like rust do, so consider the common trait function into(), it has a declaration along the lines of:

                  fn into<T>() -> T;
                  

                  Now if you call something like f(someValue.into()) there's no return type overloading: there is only a single candidate function into to choose, which means that in the above expression we can unify T with whatever (potentially also generic) parameter type of the argument to f.

                  1. 2

                    Oh, sorry, I was in a hurry and didn't come up with a good example. You're right that my example doesn't work.

                    1. 2

                      No worries, it's easy to conflate generic return types with overloaded return types :D

            2. 1

              Thank you for this write-up, that’s been really insightful! I guess the overloading really screws up the time complexity!

              1. 3

                I think it’s specifically the return type overloading - for other systems once you find a candidate function you can immediately use the concrete return type of that function because all functions with the same identity must have the same return type

            3. 3

              Haskell (and other languages like it) doesn't support overloading, which helps a ton AFAICT.

              1. 7

                Haskell has overloading, that's what typeclasses are for.

                1. 7

                  Yes Haskell has "overloading", but this is not a thread about Haskell, so what @robinheghan wrote was

                  Haskell (and other languages like it) doesn't support <overloading as defined in swift, java, c++, c#, etc>, which helps a ton AFAICT

                  Which is correct, and what you replied with is

                  Haskell has <overloading as defined in swift, java, c++, c#, etc>, that's what type classes are for

                  Which is incorrect.

                  Your comment here is like someone going to a baseball game, and calling a "ball", but using the cricket definition of that term. It's same word, in a similar context, but similar is not "the same as", and so even if the definition of "ball" that they used is correct in cricket, it's still incorrect because that's not the game being played.

                  Given the thread and article being discussed, Haskell calls "overloading" is what these languages simply call "generic functions", and you have to accept that, because that's what the definitions being used are.

                  If Haskell did support <overloading as defined in swift, java, c++, c#, etc>, you would be able to write

                  f = 1
                  f = "foo"
                  

                  This is not mutating a value, nor is it replacing one. There are now two functions, both named f, one of type Int and one of type [Char]. That is not something Haskell allows, and type classes don't change that. + is not an overloaded function, there is just one function named +, and it has the type forall a. (Num a) => a -> a -> a, that is the type it has in every place it is called, there are not different versions for different types.

                  1. 3

                    Both languages allow overloading functions, they obviously differ in how they do it. It doesn't matter what names you Swift-style overloading vs. Haskell-style overloading, it's the same feature serving the same purpose, implemented in different ways.

                    1. 1

                      Right, and the definition of overloading is not the same, so claiming that they're equivalent is incorrect.

                      Haskell "overloading" exists in these other languages, and that does not cause any of the problems discussed.

                      Moreover the overloading that they support has literally no equivalence in Haskell, and is not possible in any way. There is literally no equivalence. Please stop trying to claim that they are, or that your comment is correct.

                      Your comment is like "both languages have identifiers, and Haskell can distinguish type variables and type names trivially, because Haskell type variables start with lower case letters".

                      They are not the same, stop trying to claim your comment was correct.

                      I explained why it is not.

                      You could simply accept that you were wrong through and easily made error, or you can double down. Please stop trying to double down, I do know what I'm talking about, I have literally implemented a Haskell compiler.

                  2. 2

                    That’s the first time I’ve seen type classes referred to as overloading.

                    I was specifically referring to function overloading, which Swift supports in addition to ad hoc polymorphism.

                    1. 2

                      That’s the first time I’ve seen type classes referred to as overloading.

                      It's called overloading since at least 1989:

                      This paper presents type classes, which extend Hindley/Milner type system to include certain kinds of overloading, ...

                      From https://dl.acm.org/doi/pdf/10.1145/75277.75283.

                      1. 3

                        Oh, I didn’t mean to imply it wasn’t correct. Just expressing surprise :)

                        1. 2

                          Haskell just has a different definition of overloading, so in this thread your statement was correct :)

                          1. 1

                            It's the same feature, obviously with differences.

                            What you're saying is like: Haskell and Java have different ways to do polymorphism, therefore one is not polymorphism.

                            1. 2

                              No.

                              The entire point of your comment is "Haskell has overloading, therefore it is not a problem".

                              Haskell's overloading is not overloading in other languages, claiming they're the same is simply false.

                    2. [Comment removed by author]

                2. 11

                  Wow, that 42 second type checking for a simple expression is just unbelievable.. Swift is a relatively new language so I'm assuming they had the flexibility to design a proper type system, but instead they designed a cursed one full of super-linear (even exponential?) behaviors. Doesn't give me confidence in the language team's language design expertise tbh.

                  1. 19

                    My impression on the contrary is that the Swift people are generally very good at what they do, including pretty good at language design. (Look at their work on ABI stability, it is pretty impressive.)

                    I do agree that it's interesting that they decided that a naive approach to overloading using backtracking was okay, and now it comes back to bite them massively. This does sound like a design mistake. I would hazard to guess that it comes from engineering experience, rather than lack of expertise: my impression is that while academics tend to be fairly rigid in their design principle ("no exponential worse cases!", "let-polymorphism will be saved at all costs!", "principality forever!", etc.), experienced engineers have a more pragmatic approach where they know that many constraints can typically be relaxed in practice, and it's okay. I can sort of guess how the discussion went:

                    • "So we've been trying to come with an efficient overloading-resolution algorithm for a few months now, it does not cover all use-cases, we need to extend the overload declaration mechanism to let users express X and Y and Z so that we can check this efficiently."

                    • "Enough is enough! Let's do the dumb thing of just backtracking on every possible choice, and picking the choice that works."

                    • "But... this is exponential!"

                    • "Sure, but exponential is fine because user expressions are short in practice! Today's machine are fast, they can deal with exponentials on small inputs just fine. Users will much prefer a simple system that is easy to reason about, and fast enough in practice, to the complexity monster that you have been designing and refining for months. Let's cut down on our complexity budget for this corner of the language."

                    • "But..."

                    • "Let's run the dumb algorithm on our testsuite, you will see that it is fast enough in practice."

                    ... and that's (maybe?) how it went. Of course, hindsight is 20/20, and in retrospect it is strange that they did not consider the fact that people do in fact write reasonably-complex nested trees of overloadable infix operators, and try to put some of those in their testsuite before deciding to go for this approach.

                    1. 7

                      That's my impression as well. Compiler are full of little super-linear behaviors, from backtracking in parsing to register selection in optimization, it's just that usually n stays small and It's Fine. They took a reasonable-ish gamble and got burned.

                      1. 6

                        Compiler back ends are full of super-linear behaviors, but the front end should all be simple algorithms that both terminate and have known algorithmic complexity, IMO

                        That should be trivial for parsing (though some people have gotten it wrong -- e.g. parsing bash, make, perl, and C++ is undecidable )

                        Type checking is of course the hard part

                        I say this is because the front end is what you can't change -- it determines what the language is! It is the API that users depend on

                        It feels like they failed to distinguish between:

                        • a "one way door" -- a decision you can't back out of, because it will break programs and libraries
                        • a "two way door" -- a decision you can, by say writing a different compiler!

                        So they are certainly very skilled in compilers, but I agree that there is a language design problem here.


                        i.e. If you think that bash/make/perl/C++ have language design problems, I would also apply that same logic to Swift :-) (and yes type checkers are harder, but the practical effects of the problem are even worse in this case)

                        1. 2

                          You're correct, I just feel like it's worth pointing out a few exceptions: bad parsing complexity sometimes doesn't matter 'cause people tend not to write degenerate programs, HM type checking can be exponential as pointed out in a sibling comment, stuff like that. You can get away with quadratic or exponential stuff sometimes, even with "one way door" decisions like C's syntax and OCaml's type inference, as long as it's rare or small in real code.

                          So why is Swift getting burned now? 'Cause a confluence of several design decisions (type inference with subtyping, type-based function overloading, very common usage of overloaded functions) that makes the O(n^2) behavior common with large n.

                        2. 4

                          I don't necessarily support your choice of examples :-) People make a big deal of the complexity guarantee of LR parsers (for example), and there has been spades of "attacks" on regexp implementations with exponential backtracking behavior -- it is an issue that has caused a significant amount of pain to solve later on. Register selection is also very carefully monitored for performance, and if I understand correctly production compilers often use linear-scan plus clever heuristics to avoid compilation time blowups.

                        3. 6

                          I think the point is more that Swift was publicly released over a decade ago and has gone through 6 major versions, and at no point did they decide "this is too broken, we need to make some sort of design change to fix this". Even this roadmap is just about finding ways to optimize without changing behavior. If Swift was in its first year or two of development and still in the midst of "growing pains", that would be one thing. But it's not.

                          The forum post has replies from people with other very trivial examples that result in very, very long type-checking times or outright timeouts, so it's not just one kind of expression either (an example from a post linked below by @osa1 even has a type annotation, but still takes over 40 seconds! Another reply a few below that one has a simple SwiftUI example with a single-character typo that times out!)

                          I haven't studied the problem in detail but the roadmap post suggests that the only two design changes that would resolve this problem systematically have unacceptable tradeoffs that remove expressive power. At least for me personally, whatever "expressive power" such features may provide, as a programmer I am not really interested in such an expensive time-expressiveness tradeoff, and I am somewhat surprised so many others seem to be.

                          1. 4

                            They spent their major syntax design change credits on the transition from Swift 2 to 3, which made all function calls everywhere a lot less Objective-C shaped. That original shape, like all of the Objective-C compatibility, was a necessary mess to bring their existing development community along. Source stability was a Swift 4+ guarantee, and ABI stability arrived in 5, each of which was critical to a widening audience. Compile times, as much as I lament them, are also way better than they used to be in large projects. It's not for lack of trying but for lack of room to maneuver.

                        4. 7

                          Well. Would you consider ML, Haskell, and Java to have proper type systems? Because ML and Haskell require exponential time to type check, and type checking Java is undecidable (so there are short Java programs for which type checking runs forever, and this is not a bug). Fortunately none of these behaviors comes up much in practice.

                          ML and Haskell are hard to type check because is polymorphism, and Java because of subtyping. The difficulty in Swift is about operator overloading. Not obvious to me how much this is a poor implementation vs. provably hard.

                          https://static.aminer.org/pdf/20170130/pdfs/popl/o8rbwxmj6h2cxf7qdezlupsbgn5u40wn.pdf

                          https://arxiv.org/pdf/1605.05274

                          1. 4

                            Literally no real Haskell, ML, or Java program suffer from exponential type checking times, whereas in Swift normal everyday programs take forever to type check apparently. (examples in the original post + links in the original post)

                          2. 4

                            Swift is a relatively new language…

                            It did have to inherit a lot of objective-c compatibility support though. I wonder how much that hamstrung the overall design of the language.

                            1. 2

                              It took 42 seconds to type check "a project that makes heavy use of overloading and generics," per the post, not "a simple expression." Compilers do occasionally get into pathological holes in typechecking, and Swift moreso than compilers who don't implement type checking with a SAT solver, but the claims in the post that the time spent in type checking a whole project are improved are not unreasonable here, and certainly not so unreasonable that they would make me question the expertise of the dev team.

                              1. 4

                                There's nothing special about <expr1> + <expr2> + ..., anyone could've written that. It's simply unacceptable for a binary expression chain to take 42 seconds to type check.

                                There are also more examples given by others in the comments, e.g. https://forums.swift.org/t/roadmap-for-improving-the-type-checker/82952/6. It's not like there's just one kind of expression that takes forever to type check and everything else is fine.

                            2. 5

                              For me, the average Swift program tends to compile pretty quick without fuss, though sometimes it gets bogged down with broken syntax from sloppy pasting around for refactors. The real horror show is ViewBuilders and SwiftUI, where seemingly trivial errors in trivial programs will result in the compiler crying and giving up.

                              1. 3

                                Fantastic. Compile times are far and away the improvement I most want in Swift. In particular I've been sidestepping this complex expression type solving issue for years. I had thought there was nothing to be done about it.

                                1. 2

                                  Non-goals worth mentioning: Removing overloading from the language.

                                  It's always struck me as very strange that despite Swift having a perfectly good alternative to overloading on type, they overload on type anyway. Having parameter names be part of the function name already permits you to have overloading convenience by function name, and having extension interface implementations already permits you to have overloading convenience by alternate parameter type. Rust only has one of these and already gets by without overloading, but strong parameter names are such a perfect solution to overloading - they should never have needed to add type-based overloading!

                                  1. 3

                                    I also sometimes wonder about the impossibility of going back on decisions, and not even making a cultural effort. So we can't remove overloading and ExpressibleByX, but couldn't Apple at least start suggesting that they're not a good idea in new code? Like, in WWDC sessions? It's hard for Apple to admit mistakes, but still. We've known for years that certain kinds of things are extremely slow to compile and while some improvements are possible, they're never going to become fast.

                                    1. 3

                                      But, operators are functions too, and they don't get argument labels. You'd have a hard time getting people to give up the ability to use + between a variety of same-typed operand pairs. The promise of source stability locked that in many years ago.

                                      That is: One of the reasons they did add type-based overloading is to get the definition of the basic number types out of the language and into the standard library, which entails operator overloading. That is, static func + (lhs: Int, rhs: Int) -> Int and static func + (lhs: Double, rhs: Double) -> Double must be defined in the standard library, because Int and Double are. Their call sites look like 2 + 2 and 3.0 + 3.0. So these addition functions can only be distinguished by operator types, unless we had a different name than + for each known type. Some languages use +. for floats but we'd also have to distinguish Int64, UInt8… or give up on those and use named functions.