Zero Cost Abstractions
ruudvanasseldonk.com
ruudvanasseldonk.com
>abstractions is to inspect the generated machine code.
To me, that's a big problem with a lot of these Heldencompilers. They may generate really optimal machine code. Then again, they may not, and the difference between optimizations working well and not working well in runtime efficiency is so great (I've measured 1000x for Swift) that they might as well be completely different languages.
For reference, 1000x means that 1 second turns into 16 minutes, and having that type of difference in something that's completely opaque is not a useful performance tool for me, because predictability is at least half the game in performance. So something like Knuth's transformation systems that turn optimization into a dialogue between programmer, compiler and instrumentation seems like a better idea[1].
[1] https://www.cs.sjsu.edu/~mak/CS185C/KnuthStructuredProgrammi...
Just kidding, but German and Dutch share a close, common "ancestor" language, closer than any other Germanic languages. I used to have a professor at Uni who would always repeat, that a language is a dialect with an army, which kind of hits the spot.
(Also Swift's pervasive atomic refcounting means that typical idiomatic code often has pretty atrocious performance.)
Refcounting can also be dropped to non-atomic by the optimizer when it can prove that an object hasn't escaped.
For example, count the number of atomic refcounts on each iteration of the following loop:
// given
struct S {
var x: [Int]
var y: [String]
var z: NSView
}
var array: [S]
for element in array {
// maybe do something with the NSView
}
That's three atomic increments and three decrements per loop iteration, as the array entry is copied into element because it can't prove the NSView operation wouldn't cause the array to zero somehow.Given atomic memory operations are some of the most expensive things on a modern CPU, this is brutal, and a place where Rust-style memory ownership would be a big improvement.
I haven't seen the optimizer convert atomic refcounts to non-atomic refcounts - do you have an example situation where that happens?
(By the way, I think Swift is a delightful language, but the pervasive reference counting threw some cold water on my passion as it means today we're not really close to replacing C/C++/Rust for machine-level performance.)
Even with pure Assembly, changing between CPU releases can have a big impact due to microcode changes or cache sizes.
Also note that Swift doesn't have the same performance goals that Rust does, preferring to trade some off in favor of ergonomics.
Rust is a lot more mature! RIP libgreen.
Yes, this is the current trend. And the results don't really justify the cost:
http://www.complang.tuwien.ac.at/kps2015/proceedings/KPS_201...
http://proebsting.cs.arizona.edu/law.html
Which is why I (roughly) agree with Daniel Bernstein's polemic "The Death of Optimizing Compilers" https://cr.yp.to/talks/2015.04.16/slides-djb-20150416-a4.pdf
(I frequently make code 10x to 1000x or more faster, and compilers' contributions to that total tend to be fairly minimal, though larger than zero and usually worthwhile. But not worth the current shenanigans and not worth having no idea how the code is going to turn out)
Moreover, this explains the NASA coding rule that all loops shall have an explicit upper bound.
The deeper issue seems to be: If you write code in a fashion that you can reason easily about its performance, then you might not get the optimal performance in all cases, but you establish an minimum level of performance.
So performance stability is more important than reaching optimal performance, because the latter may be easily destroyed accidentally in future versions of the software.
The only exception are well-defined tasks with stable input/output definitions (e.g. numeric primitives like matrix multiplication, fourier transformation, etc.) where the whole point of newer versions is performance improvements and nothing else.
It seems clear to me that at the present state of tech that Most developers are better at picking algorithms while compilers are better at picking which low level instructions. Clearly there are some exceptions, but things like gotoBLAS are clearly exceptions and not the rule.
At least people claiming something will be optimized away can readily be empirically tested in a way that most will listen to. I have no problem testing the / operator, but I have never had holders of such beliefs accept my results, but people claiming something like "virtual function calls can be optimized away" can handle it when I can demonstrated that inside a single binary they can be but at places like library boundaries they cannot.
This is one area I dislike in C and C++ culture, there is this tendency to micro-optimize code like that, without even profiling it, or it causing an actual impact on the application being built.
So one ends up with endless bike shedding discussions about how to write code, instead of writing it.
A few years some Ruby dev said that declaring methods without parenthesis would let the Ruby parser parse method declarations faster. The dev never even measured, he just thought the parens were ugly and a ton of gem devs removed parens claiming a speed up. It wasn't until a few years later that someone benchmarked it and determined that there either was no difference or that it cost just the tiniest amount to remove them.
When I asked people at CppCon about it I just got some shrugs and was told "just go look at the assembly".
Another solution is profiling - but that's got a slow turn around, and it can be hard to narrow down problem areas.
; note: forced to do GENERIC-+ (cost 10)
; unable to do inline fixnum arithmetic (cost 2) because:
; The first argument is a NUMBER, not a FIXNUM.
; The second argument is a (INTEGER -19807040623954398379958599680
; 19807040623954398375663632385), not a FIXNUM.
; The result is a (VALUES NUMBER &OPTIONAL), not a (VALUES FIXNUM &REST T).
; unable to do inline (signed-byte 64) arithmetic (cost 5) because:
; The first argument is a NUMBER, not a (SIGNED-BYTE 64).
; The second argument is a (INTEGER -19807040623954398379958599680
; 19807040623954398375663632385), not a (SIGNED-BYTE
; 64).
; The result is a (VALUES NUMBER &OPTIONAL), not a (VALUES (SIGNED-BYTE 64)
; &REST T).
; etc.
Note also that the approach is different there: integers are not modular, but you can still perform modular arithmetics if the range of values is adequate (http://www.sbcl.org/manual/#Modular-arithmetic).Also switching between source view and Assembly alongside original source view is a key away (F12) for all Microsoft languages.
That said, I'd expect something similar to happen with a well-written C program. Would equivalent abstractions in C++1{1,4,7} be "costly"?
With C the lack of generics means that writing composable iterators is hard, though.
However, Boost offers iterator adapters, which although weaker than true iterator adapters (which C++ calls ranges), those will suffice in this case.
Good implementations can cheat and use knowledge my code shouldn't have about parts of the underlying system and bad ones do extra useless crap.
Benchmarking and profiling seem to be the only real way to gain confidence in your results reliably.
The source code for his website is free software [0], so feel free to have a look or adopt it for yourself.
Ruud uses a small static site generator he's written in Haskell, and in his own words it “includes a tiny templating engine, an html and css minifier, and an aggressive font subsetter.”
Arguably missing a link the homepage/post overview at the top, perhaps I just expect this too much from convention.
Does the same code implemented in C manage to vectorize? If so isn't that an actual "cost" in comparison?
for window in buffer.sliding_window(coefficients.len()) {
let prediction = coefficients.iter()
.zip(window)
.map(|(&c, &s)| c * s as i64)
.sum::<i64>() >> qlp_shift;
let delta = buffer[i];
buffer[i] = prediction as i32 + delta;
}
with sliding_window returning an iterator over slices of the buffer?Your sliding window is just a series of windows, so there is no reason why it couldn't be compiled in the "most" efficient way. In fact it probably does, just try it out by implementing the sliding_window as an iterable.
However there's always this problem with cleverness: It gets harder and harder to read and maintain. Also http://wiki.c2.com/?SufficientlySmartCompiler
https://doc.rust-lang.org/std/primitive.slice.html#method.wi...
However, your code would not work as-is, since the window borrows the buffer, so `buffer[i]` cannot be written to. Further, there is no `windows_mut` to let you write to the end of the window, because that would let you get multiple mutable references to a given element.
At least in principle, escape analysis would be used to allocate them on the stack. In HotSpot, simple iterators are commonly allocated on the stack (and then optimized further). When you have a JIT (which means you can afford one), general abstractions become zero-cost based on their use-site. The upside is that you get a few, general and powerful abstractions that are then completely optimized based on how they're used. The downside is that it is not guaranteed, and a JIT requires more RAM and power (and in practice usually a warmup period, although that can be solved).
Could someone show me the most straight forward equivalent in vanilla C? I assume there is no direct equivalent as temporary storage will be needed. But that's fine and would further serve the purpose of explaining why Rust is cool.
Thanks.
For an imperative translation see https://www.reddit.com/r/programming/comments/5fpghn/zerocos...
Doing an exact translation to C is hard. The C that does the same thing, or C that demonstrates these abstractions that are boiling away?
uint32_t *buffer = ...;
uint64_t coefficients[12] = {...};
uint16_t qlp_shift = ...;
uint32_t *bufp = &buffer[...];
uint64_t sum;
for (size_t i = 0; i < 12; i++)
sum += coefficients[i] * bufp[i-12];
uint64_t prediction = sum >> qlp_shift;
*bufp += (uint32_t)prediction;
Edit: this is incorrectPun intended? If so, nicely done.
My point is not that the snippet is bad, just that Rust feels, looks, and probably is, quite complex. As opposed to, for example, Haskell, which also introduced a bunch of nice, new, ideas.
> Rust feels, looks, and probably is, quite complex. As opposed to, for example, Haskell
Do you really think that Haskell is less complex than Rust, or am I just reading your comment wrong?Really? I've never used rust but the code was pretty clear to me except for the >> shift part.
> But I think I would be able to understand the snippet in most imperative and functional languages.
In Python (ignoring the shift thingy) I'd write
prediction = sum(map(lambda c, s: c*s, zip(coefficients, buffer[i-12: i])))
To me, both are equally clearI know, I know, the C code you write never has buffer overruns, just like the C code I write never does, well, except that one time, but then I caught it in the debugger during testing and it was easy to fix. Oh, and then that other time, but I caught that one too, and then.....
Nah, I did that on purpose because I remember the time I accidentally swapped the 'value' and 'size' parameters of memset, trashing the stack, and so now I go the 'safer' option to just leave things uninitialised. Not to mention the performance increase I get from avoiding a memset that isn't necessary, so long as I'm really, really careful and never make a mistake, which I almost never do.
And I cannot tell if you are serious, trolling or sarcastic, because I know people who think this way, think this way is a joke and think this way is the bane of their professional lives.
No it's not. In this line:
.zip(&buffer[i - 12..i])
you can just put any indices and it will compile. for (size_t i = 0; i < 12; i++) {
sum = 0;
for (size_t j = 0; j < 12; j++)
sum += coefficients[j] * bufp[i + j];
buffer[i + 12] += sum >> qlp_shift;
} for (size_t i = 0; i < 12; i++) {
int64_t sum = 0;
int32_t *bufp = buffer + i;
for (size_t j = 0; j < 12; j++)
sum += coefficients[j] * bufp[j];
*bufp += (int32_t)(sum >> qlp_shift);
}
I think this will put it in a form where the compiler should vectorize it[1], probably making the loop about twice as fast as the version shown: 3 vector loads, 3 vector multiplications, 3 vector additions and a horizontal sum versus 16 scalar loads[2], 12 scalar multiplications and 12 scalar additions.[1] I presume the "various reasons the above snippet is not as obvious to vectorise as it might seem at first sight" is because the coefficients[] are 64-bit and buffer[] is 32-bit. But a smart compiler should be able to use PMOVSXDQ (packed move sign extend double-word to quad-word) here. Unless there is some other issue, I think the author may be underestimating the benefit of vectorizing here.
[2] While the author is right that most of coefficients stay in registers, there isn't actually room for all 12, so 4 of them are loaded from the stack every iteration. Since only 3 128-bit vector registers are required, they should remain there. I'm not sure if the excess loads are actually a bottleneck, though.
Memory safety without a hit in performance seems like a great thing, and this is a fine example that it's becoming possible. The point is not that C is always fine. In this case, I think the actual conclusion is something like: C and Rust perform equally here, both have about 50% of the theoretical assembly performance on modern processors, thus better vectorization would help both of them. But if all else is equal, choose the safe one.
I played around with you snippet using the compiler explorer https://godbolt.org/ . I tried a couple versions of clang, gcc, and icc and none of those were able to auto-vectorize it. Not sure why. Kind of a shame, since there would definitely be a speedup.
In addition, I discovered a couple other issues. I was embarrassed to see that I'd messed up the loop bounds in my example. But the bigger one is that AVX2 and earlier do not have a 64-bit x 64-bit -> 64-bit vector multiplication instruction. VPMULLQ was only added with AVX512, possibly because it requires a 512-bit intermediate.
This means that with 64-bit coefficients, the vector approach has to do the multiplications twice, and shift, and add. This is likely going to be slower than the scalar approach. But if you were able to switch to 32-bit (or floating point) coefficients, I think the vectorized solution looks pretty good. ICC estimates a 1.4x speedup, but I think that gets better with longer buffers.
I put the version with int32_t coefficients up here: https://godbolt.org/g/GXNVe2
For example: https://is.gd/hrUf7q
Isn't it possible to give a "coefficients" vector with large enough values so that computations with 64 bits would overflow too? I don't doubt the code works in practice, because the range of values are reasonable. However this is not explicit, just looking at the code. I'd prefer if the actual domains were being made obvious. Right now, 12, 32 and 64 feel like magic numbers in the code.
[1]: https://github.com/ruuda/claxon/blob/91b6af9/src/subframe.rs... [2]: https://github.com/ruuda/claxon/blob/91b6af9/src/subframe.rs...
The snippet presented is completely unreadable to me though, and I think that in general, Rust is too hard to understand (and has some syntax which seems quite arbitrary).
I suspect this is just a matter of being used to things. For me, the corresponding imperative code is harder to disentangle. Whereas, knowing what zip/map/sum do, both the intent and the behavior of the code is abundantly clear to me.
I've seen side-by-side imperative and functional comparisons, written in Rust. Reading through the functional version of the code always gave me a very rough idea of what was happening, and reading the imperative version was always immediately clear to me. I think that it's almost certainly a function of familiarity.
Its very similar when learning the functional style of programming. Even today having most of this functional style be intuitive, when I see a complex iterator chain (usually only when constructing a `Map`), I need to mentally unfold it into a loop.
That seems like a good analogy. I underwent that process a few years ago while teaching myself to read assembly, and in the past, other spoken languages.