Re: ABI of throwing

From: Joe Groff via swift-evolution <swift-evolution@xxxxxxxxx>
To: Félix Cloutier <felixcca@xxxxxxxx>
CC: swift-evolution <swift-evolution@xxxxxxxxx>
Date: Tue, 09 Aug 2016 10:25:38 -0700
Why ads?

On Aug 6, 2016, at 7:25 PM, Félix Cloutier via swift-evolution <swift-evolution@xxxxxxxxx> wrote:

Currently, Swift adds a hidden byref error parameter to propagate thrown errors:

public func foo() throws {
        throw FooError.error
}

define void @_TF4test3fooFzT_T_(%swift.refcounted* nocapture readnone, %swift.error** nocapture) #0 {
entry:
  %2 = tail call { %swift.error*, %swift.opaque* } @swift_allocError(/* snip */)
  %3 = extractvalue { %swift.error*, %swift.opaque* } %2, 0
  store %swift.error* %3, %swift.error** %1, align 8
  ret void
}

This means that call sites for throwing functions must always check if an exception occurred. This makes it essentially equivalent to returning an error code in addition to the function's actual return type.

Note that we don't currently implement the error handling ABI as we eventually envision it. The plan is for LLVM to eventually lower that %swift.error** parameter to a normally callee-preserved register, which is set to zero by the caller before the call. That way, nonthrowing and 'rethrows' functions can cheaply be used where throwing functions are expected, since a nonthrowing callee will just preserve the zero the caller put in the register. And since ARM64 has a handy 'branch if nonzero' instruction, this means that a throwing call would only cost two instructions on the success path:

        movz wError, #0
        bl _function_that_may_throw
        cbnz wError, catch
        ; happy path continues

Non-taken branches are practically free with modern predictors, and (as Chris noted in his reply) there's no need for the compiler to emit massive unwind metadata or the runtime to interpret that metadata, so the impact on the success path is small and the error path is still only a branch away. As Chris also noted, we only want people using 'throw' in places where errors are expected as part of normal operation, such as file IO or network failures, so we don't *want* to overly pessimize failure branches.

-Joe


On the other hand, there are exception handling mechanisms where the execution time cost in the success case is zero, and the error case is expensive. When you throw, the runtime walks through the return addresses on the stack to find out if there's an associated catch block that can handle the current exception. Apple uses this mechanism (with the Itanium C++ ABI) for C++ and Objective-C exceptions, at least on x86_64.

Other compiler engineers, like Microsoft's Joe Duffy, have determined that there actually is a non-trivial cost associated to branching for error codes. In exchange for faster error cases, you get slower success cases. This is mildly unfortunate for throwing functions that overwhelmingly succeed.

As Fernando Rodríguez reports in another thread, you have many options to signal errors right now (I took the liberty to add mechanisms that he didn't cover):

        • trapping
        • returning nil
        • returning an enum that contains a success case and a bunch of error cases (which is really just a generalization of "returning nil")
        • throwing

With the current implementation, it seems to me that the main difference between throwing and returning an enum is that catch works even when you don't know what you're catching (but I really hope that we can get typed throws for Swift 4, because unless you actually don't know what you're catching, this feels like an anti-feature). However, if throwing and returning an enum had different-enough performance characteristics, the guidance could become:

        • return an enum value if you expect that the function will fail often or if recovery is expected to be cheap;
        • throw if you expect that the function will rarely fail or if recovery is expected to be expensive for at least one failure reason (for example, if you'd have to re-establish a connection after some network error, or if you'd have to start over some UI process because the user picked a file that was deleted before it could be opened).

Additionally, using the native ABI to throw means that you can throw across language boundaries, which might be useful in the possible but distant future in which Swift interops with C++. Even though catching from the other language will probably be tedious, that would already be useful in language sandwiches to unwind correctly (like in Swift that calls C++ that calls Swift, where the topmost Swift code throws).

I don't really know what to expect in terms of discussion, especially since it may boil down to "we're experts in this fields and you're just peasants" or "the cost of changing this isn't worth the benefit". Still, I'd like some more insight into why Swift exceptions don't use the same mechanism as C++ exceptions and Objective-C exceptions. The error handling rationale document is very terse on the implementation design, especially given the length of the rest of the document:

Error propagation for the kinds of explicit, typed errors that I've been focusing on should be handled by implicit manual propagation. It would be good to bias the implementation somewhat towards the non-error path, perhaps by moving error paths to the ends of functions and so on, and perhaps even by processing cleanups with an interpretive approach instead of directly inlining that code, but we should not bias so heavily as to seriously compromise performance. In other words, we should not use table-based unwinding.

I find the rationale somewhat lacking. I can't pretend that I've measured the impact or frequency of retuning a error objects in Objective-C or Swift, and given my access to source code, I probably couldn't do a comprehensive study. However, as linked above, someone did for Microsoft platforms (for Microsoft-platform-style errors) and found that there is an impact. The way it's phrased here, it feels like this was chosen as a rule of thumb.

Throwing and unwind tables are all over the place in a lot of languages that have exceptions (C++, Java, C#). They throw for a lot of the same reasons that Objective-C frameworks returns errors, and people usually seem content with the performance. Since Swift is co-opting the exception terminology, I think that developers reasonably expect that exceptions will have about the same performance cost as in these other languages.

For binary size concerns, since Swift functions have to annotate whether they throw or not, unless I'm mistaken, there only needs to be exception handler lookup tables for functions that call functions that throw. Java and C# compilers can't really decide that because it's assumed that any call could throw. (C++ has `noexcept` and could do this for the subset of functions that only call `noexcept` functions, but the design requires you to be conscious of what can't throw instead of what can.)

Finally, that error handling rationale doesn't really give any strong reason to use one of the two more verbose error handling solutions (throwing vs returning a complex enum value) over the other.

_______________________________________________
swift-evolution mailing list
swift-evolution@xxxxxxxxx
https://lists.swift.org/mailman/listinfo/swift-evolution
Why ads?