Digging into the CoreCLRExceptional costs, Part II
Note, this post was written by Federico.
In my last post we talked about the cost of throwing and catching exceptions at the caller site. Some are straightforward and easily seen like the code complexity, but others are a bit deeper, like for instance how the code ends up like that (we will talk about that, but not just yet). Today we will focus on what to do when we control the call-site and are in a performance sensitive hot-spot.
There are 2 important assumptions here:
- You own the code
- You are in a hot-spot
If either one of these two is not true, then this is of zero importance or you are screwed.
So let's modify our code a bit (check in yesterday's post if you don’t remember the details). In order to achieve the same result we will resort to a very well-known pattern that I like to call TryXXX. Many instance of such optimizations are visible in the .Net Framework like the famous int.TryParse method. Apparently someone during the course of using v1.0 (or v1.1) of the Framework figured out that the cost of exception handling for certain scenarios was a bit too much. We probably won’t know who was, but we can all be glad they have fixed it; even though we have to live with an exception based implementation (borrowed from Java style?) as obsolete code since then.
So let's see how the code would look.
Pretty straightforward I might say. Now the interesting thing is what happens at the assembler level:
Even under shallow review, we can conclude that this code is definitely faster than the alternative. Now what did we win against the try-catch version? Essentially, we don't have a prolog and an epilog in case of the choosing the exceptional path, that’s faster than having to execute such code. The exception case also does not have to deal with non-local effects caused by unwinding the stack; but we are forced to have a hierarchy of TryXXX methods if that goes deep (the alternative of using exceptions for readability is not great either).
Now in this code we have the first glimpse of evidence of a few JIT design choices (and some current restrictions too) that are important performance wise and we will discuss them in future posts.
More posts in "Digging into the CoreCLR" series:
- (25 Nov 2016) Some bashing on the cost of hashing
- (12 Aug 2016) Exceptional costs, Part II
- (11 Aug 2016) Exceptional costs, Part I
- (10 Aug 2016) JIT Introduction
I have seen many cases now where the mere presence of a try in a method causes machine code in that method to become much worse in ways that are unintuitive. This is not an area that the JIT team has spent much time on so far (which is OK).
Regarding the TryParse pattern: It is a pity that nullables did not exist at that time. out parameters do not compose well because you can't use such calls as pure expressions. They always require an accompanying statement (a var declaration). Exceptions have the same problem. As an example this matters in LINQ queries.
I feel C# should try to make more statements work as expressions. In Ruby, statements such as if and try are expressions that can return a value. I see no reason C# could not do that. Unfortunately, declaration expressions have not made it into the language yet. And we are stuck with TryParse(out) cruft in the framework forever. I maintain a set of extensions for parsing which are super convenient.
The pattern is called the Try-Parse pattern, and it's in the best practices discussed in the .NET documentation. I'd say it's a better name than "TryTripleX" :P. And of course exceptions aren't obsolete, it's still the preferred mechanism to report errors. But they are avoided in performance critical scenarios, like you mentionned in this article. Anyway, it was nice to see the assembler code generated for both cases.
@tobi I haven't investigated the impact of nullables in performance; probably will do so to round the topic on the different alternatives. I presume it should not cause any allocation, but I am not that sure either of the actual implementation of them.
@dbg Actually Try-Parse is just one example, grep the RavenDB code and you will find many cases of Try-Action (which probably is a better name than TryXXX :D) which is the non specialized version of the pattern.
I'm not convinced that the non-tryXXX version is useless: if you know already that the string should be a valid representation of a string, the exception-throwing version could be more efficient as it bypasses a useless test in all but exceptional cases, no?
Yeah, never mind that. Super dubious, now that I've looked at the code. Not sure what I was expecting. Oh well.
Bertrand, The problem is that even in the happy path, there is stuff needed to be done to support the exceptions.
Yup, you're right; figured as much from the source.