Comparing Parallel Rust and C++
parallel-rust-cpp.github.io
parallel-rust-cpp.github.io
The one glaring issue that is ignored is the floating point model used. I understand Rust still doesn't have a usable equivalent to -ffast-math, so I assume it wasn't used for that reason. Some discussion of whether it's permissible in this algorithm (I believe so?) and how much advantage that might give to C++ seems crucial when performance is a priority.
Ironically, reading this has further convinced me that, for all of its disadvantages, I much prefer C++ to Rust for my needs. I'm sure others will draw the opposite conclusion and that's great. Rust is a language that clearly knows what it wants and if your priorities are aligned with that, the performance gap is shrinking and implementation-related reasons to avoid it are rapidly decreasing in number.
See my benchmarks [1] with fast-math in the middle and the generated assembly comparison at the bottom.
The base language is Nim but pure C code should generate the same assembly.
[1] https://github.com/numforge/laser/blob/e660eeeb723426e80a7b1....
I actually wanted to know exactly what ffast-math did, down to the codegen (so you have assembly at the bottom).
What are your needs, if I may ask? I'm not asking this to start a thread "rust/C++ is better", I'm just curious regarding situations in which someone decides that one language matches their needs more than another, it's always interesting to see what boundaries people consider for those decisions.
From this rather narrow point of view, Rust does very little to help and quite a lot to hinder me. Rust is very much about memory safety - an issue extremely far down my list of concerns - and to me the borrow checker is an anti-feature I'd love to turn off. None of this is in any way an indictment of Rust - it looks like a very well designed language that knows what it wants to accomplish. It just happens to want the exact opposite things from what I want, and that's fine. I know I'm in a weird minority (most of the people who think like I do seem to be game engine developers).
For the remaining few cases - yes, manually inserting the minimal necessary amount of synchronization (on x86-64 that's often a single instruction). On GPUs you obviously need memory barriers fairly frequently, but that's much less of an issue than on CPUs. Anyway, I don't have any faith in "smart" compilers or language features (which have large costs elsewhere) that try to free me from the simple necessity to carefully think through what my algorithm actually does before I implement it. If you care about getting close to optimal performance, you'll have to do the thinking anyway.
Of course, no language can protect you from all bugs, but some languages can protect you from at least some bugs. Rust can, and does, protect you from all data race-related bugs, and few other classes of issues.
My reasoning is: If rust addresses correctness issues that C++ does not, then it is more likely that rust programs produce correct computing results than C++ programs on average. But this is just an assumption and I might be wrong.
> This does not sound nice at all.
Ah that's true, sorry for that, I should have expressed it in a different (nicer) way.
> Rust is very deliberately designed to address safety and correctness issues that exist in a style of programming that's just completely orthogonal to what I do.
this doesn't in any way imply that Rust addresses correctness issues that all C++ code is subject to. Rather, Rust addresses correctness issues that are endemic in particular kinds of C++ code, which is qualitatively different from the code I find myself writing (probably much more due to domain specifics than any personal skill). Therefore, in that context, I don't see much reason to believe Rust will on average produce better results than C++ in terms of correctness, even though it might very well do so in a different context (like the context it was actually designed for).
That being said, I obviously do care about correctness. If I had reason to believe that Rust will on average lead to satisfactorily correct code with less effort than it would take me to achieve the same level of correctness in C++, absent any other major contraindications I would switch languages. Personally, I would be rather happy to switch away from C++ - unfortunately, most alternatives so far look considerably worse given the specific context I operate in.
options and results instead of null pointer or using bit flags to indicate invalid states (a recent sudo exploit would not have happened in a language with option types)
everything is an expression so you do not have to create uninitialized variables and then set them later inside a switch or if statement.
much less (no?) undefined behavior
for someone working in a particular C++ niche who has developed strategies to avoid all of these problems already, then switching to Rust certainly may not be worth the cost involved in learning something new, but if you were to start from scratch and pick one of the two languages, there might be good reasons to pick Rust for the same task.
Anyway, I agree that some aspects of Rust unrelated to memory safety are good for correctness. Unfortunately, I can't pick languages in a vacuum, so I have to weigh that against things like GPGPU support (first rate vs. non-existent), tooling quality (particularly profilers), library support (Eigen alone is worth quite a lot) and other factors. If I could ignore all of those real world issues and just choose the better language, I don't know if I would choose Rust, but it would certainly have a decent shot.
It's not really practical because C++ has no true sum types. You can emulate them with a Java-style visitor pattern but that carries an immense code overhead.
Which isn't a true sum type because it doesn't nest properly.
> Or you can use a library: https://github.com/mpark/patterns
Interesting; proper pattern-matching is nice, but the lack of type safety is still a major issue.
First of all, the "Bad Things" aren't actually uniformly bad. In its zeal, Rust won't let you modify different elements of the same array from different threads (an issue that comes up almost immediately in the OP) even though you know with perfect mathematical certainty that to do so is perfectly safe. In the context of simulation code that's not nitpicking, it's a usecase that comes up all the time, I can't even think of a codebase I've worked on in the last 5 years that didn't have this pattern in it somewhere. I have never, ever encountered a data race bug in any of those cases (even though there are usually tests to cover it just in case), but switching to Rust would immediately force me to waste time (and code quality, in my view) on working around a language restriction. The same goes for read-after-free and most other memory safety features, because most of the reasoning there simply doesn't apply when you're dealing with statically allocated memory that's never freed, with static guarantees on sizes, etc.
Rust is very deliberately designed to address safety and correctness issues that exist in a style of programming that's just completely orthogonal to what I do. There is some inevitably some overlap and it's always possible to construct a contrived case where Rust could potentially prevent a bug I could conceivably make, but the point is that everything I've seen leads me to conclude that any such benefit would be vastly outweighed by the time unnecessarily spent on working around non-issues, dealing with syntax verbosity and semantic baggage (I distinctly do not want the notion of "lifetimes" at the language level at all), all of which means more code and, in all likelihood, more bugs.
All of this sounds very negative, but I really don't mean to be. If you buy into the dynamic allocation heavy style of programming Rust is designed to improve, Rust looks great. It also seems to have an educated and thoughtful user community for the most part, something I actually envy a bit. I just don't think people should fall into the narrow view of thinking that Rust is a universal good - it takes a particular approach, with very stringent limitations and the limitations aren't there because Rust is bad, they are there to make it great... just not for everyone.
Yeah, I think at this point we're just agreeing very loudly :)
Rust absolutely has some objective advantages over C++, as does C++ over Rust. The choice of which set of (dis)advantages works best is very much something that can only be determined on a case-by-case basis.
To be clear, Rust will let you do this, as long as you're communicating that you know this to the compiler. split_at_mut and scoped threads are two ways that this is possible without requiring you to write your own unsafe code.
The rust equivalent is doing the exact same thing but requires you to formalise that invariant in your code by using one of the methods defined above.
I can see how initially this looks like an extra hoop to jump through but after using it for a while I’ve found the opposite is true: because those invariants are checked by the compiler it’s one less thing I have to keep in my head and I can concentrate on the real problem.
[1] https://github.com/duneroadrunner/SaferCPlusPlus/blob/master...
[2] https://github.com/duneroadrunner/SaferCPlusPlus/blob/master...
Incidentally, I rewrote that piece of code using a self-made `lattice_iterator` in C++ that "linearised" the code just to simplify it and make debugging easier and everything started to work. We found the off-by-one error afterwards by comparing the iteration behaviour of a single run.
This kind of problem can be caught by tests (though it's difficult, you can't tell me that you are really testing each individual iteration behaviour of your code), the advantage of Rust's approach is that this wouldn't even have passed the compiler.
Not that I'm bitter. (I'm bitter)
Academic code being shitty can't be fixed by inventing a better language.
How do you know it's the case that you don't have memory safety defects in your implementation?
It's almost certainly not the case that you don't care about safety. Out of bounds accesses and writes would make the simulation defective. Defective simulations are useless. However it might make sense if you said that you don't find yourself fixing defects like these often.
I also find if you write modern, idiomatic C++ code you rarely, if ever, have to worry about memory safety issues.
This is empirically not true. Tons of memory safety issues are found (and exploited) all the time in modern idiomatic C++ codebases.
For example my latest game server is up more than 2 month already and the only reason it gets restarted is that I am updating it with the new version.
https://twitter.com/lazyfishbarrel is very much worth reading.
In my own cases I use proprietary protocols for client-server communications that more or less ensure that memory bounds are not broken.
Of course attackers might be able to punch holes in lower layers ( UDP for example ) over which I have no direct control but in this case Rust would use the same UDP stack and offer no advantage.
> This is empirically not true. Tons of memory safety issues are found (and exploited) all the time in modern idiomatic C++ codebases.
I smell sarcasm here. I do not claim my code to be unbreakable. I do believe it is REASONABLY safe by design. pcwalton's claim is generic claim about generic code that may have no relevance to particular situations. Mine for example
- raw mallocs : https://github.com/mozilla/gecko/blob/central/dom/plugins/ip...
- new / delete : https://github.com/mozilla/gecko/blob/central/dom/plugins/ip...
- "whatever.Allocate<T>" : https://github.com/mozilla/gecko/blob/central/dom/plugins/ip...
and that's not limited to a single file... look at this :
https://github.com/mozilla/gecko/blob/3e6d6e013400af38f85ceb... - some malloc and new, again
- you also get some unique_ptr (because "modern" m'see) : https://github.com/mozilla/gecko/blob/3e6d6e013400af38f85ceb...
- moz_xmalloc because why not ? https://github.com/mozilla/gecko/blob/3e6d6e013400af38f85ceb...
- oh and did you know about our own custom reference counting pointer ? https://github.com/mozilla/gecko/blob/3e6d6e013400af38f85ceb...
etc etc... when you've got 35 different ways to allocate objects used willy-nilly of course things go wrong. Most modern codebases only ever use automatic storage, and unique / shared_ptr.
Mozilla could have achieved 90% of what it wanted from a rust rewrite with a modern C++11 rewrite at a quarter of the cost. Linter rules that say "no new or delete", "either unique_ptr<T> or shared_ptr<const T>", and "only construct unique or shared ptr via make_unique and make_shared" get them like three quarters of the way there.
The thing that makes rust great is that the static analyzer is built into the compiler and has strict defaults. C++ is the same language, but clang-analyze and clang-tidy are shipped as separate packages and have more permissive defaults.
There is a reasonable argument that modern C++ is less safe than old C++, because features like lambdas are very prone to use-after-free.
But that's not really an issue in modern C++. It's only really a problem when you want to implement your own data structures with raw pointers, in which case, yes, you have to be careful and write tests and use sanitizers, Valgrind, etc.
For years, we've been telling newbies not to use the expression "C/C++", which is incorrect. Now the Rust community disingenuously keeps pushing this outdated meme; I say it's disingenuous because they are informed enough to know that C and C++ are distinct languages, yet they use the well-known flaws of C to attack C++ when it can advance their moral crusade for memory safety.
These issues are every bit as much of problems in C++. In fact, there is a reasonable argument that modern C++ is less safe than old C++, because of features like lambdas that practically invite use-after-free.
It reports a total of 37 issues in:
- freetype2 (C lib, 20+ years old)
- usrsctp (C lib, age unknown)
- libexif (C lib, age unknown)
- libxslt (C lib, 20+ years old)
- imagemagick (C lib, 20+ years old)
- mruby (C)
- php (C)
- openSSL (C, 20+ years old)
- curl (C lib, 20+ years old)
- ffmpeg (C lib, 18 years old)
- ghostscript (C lib, 30 years old)
- irssi (C, 20 years old)
In that list were also Skia and libsass, two projects actually written in C++.In Sass, the issue is a nullptr issue: https://github.com/sass/libsass/issues/3001
In Skia the bug was in intrinsics code: https://skia.googlesource.com/skia/+/0f55db539032a23b52897ae...
Of course that's a single data point, but it shows what I think is a reasonable argument: most of the issues indeed happen in (old) C code, for well-known reasons (no standard string, array or collection support, no RAII), but because C++ supports those things by default it largely avoids those issues.
Thats the whole point. You need to do these things in C++, not in rust. In rust you get it for free and dont need to be an expert and use runtime detection tools or even static analyzers besides your compiler (w.r.t to memory safety and some classes of data races. These things can be useful in other domains)
People make the same assertions about dynamically typed languages at scale and how you "only" need to write tests that assert the types or "i wrote the function and know which type is passed duh" or "i write unit tests that would catch this" when a statically typed language tells you at compile time whether or not it will work. No intelligence required.
Hopefully you're not only relying on those - valgrind, address sanitizer, fuzzing tools, static analysis, etc. are a must for network-facing C++ (or unsafe Rust) as far as I'm concerned. You're not just looking for leaks, but use after free bugs, single byte overflows, bad casts triggered by bad data, and a whole slew of other potential problems.
I don't understand the desperate need to paint all modern C++ code bases as dangerously unsafe. It is demonstrably not true and doesn't reflect well on the motivations of those that would blindly assert it. Modern C++ has many issues and, like all programming languages, is the scene of many bugs. Just not memory safety issues. Furiously asserting that memory safety is an issue does not manufacture fact.
Because the idea that security vulnerabilities can be fixed by just "modernizing" C++ codebases is actively harming security, by discouraging investment in memory-safe languages.
> It is demonstrably not true and doesn't reflect well on the motivations of those that would blindly assert it.
It is demonstrably true, as http://twitter.com/lazyfishbarrel shows. Perhaps consider that those of us who work on browsers, which are some of the largest most-attacked pieces of software in the world, would know what we are talking about.
There is a long tail of exceptions to my statement, hard to find things that escape notice for years.
There are a lot of security issues that are not really memory safety as we are talking about here. (many are memory safety in a way that has nothing to do with getting your allocate/free wrong - using uninitialized memory for example). Some of them are subtle new attacks that were just discovered and now need to be mitigated.
[1] Yes, there are scratch buffers for things like log output and other details of convenience. None of them impact the actual simulation and all are easily handled by a trivial bump allocator over a static buffer. Bugs are unlikely and rare, but if they do happen they don't really affect anything of consequence.
[2] The borrow checker is just a random example, I also tend to avoid most of "modern" C++ for the same reasons and write in a fairly orthodox style.
As an amateur game engine developer, I'm probably in a weird minority for abhorring C/C++/C#/Canything. :)
I love Swift, and Rust or Dart would be my next choices if Swift wasn't available.
With first-class support for native APIs which do most of the work anyway, performance has not been a concern for me on Swift. I see only benefits, like making it hard for some types of bugs to creep in and code that is pleasant to read and a joy to write (though you can write beautiful/ugly code in any language, of course.)
There are plenty of games whose performance demands are not so high, that a GC language can work fine. There are also many examples of realtime 3d games, that used garbage collection, and were extremely successful (minecraft, subnautica). These were high quality games, though both do suffer from performance downsides, related at least indirectly to using GC languages.
But Nim doesn't have a single GC, it has multiple and the default one can be compiled with real-time latency constraints so that you can use to make sure that you don't stop the world for less than 1/200 of a second for example.
Plus the GC is per type, you can mix raw pointers for manually managed memory and references for GC-managed objects. The GC can only be triggerred in code paths with references.
I can't say how far you could get in pure Swift for 3D engines, but for 2D games Swift is more than good enough, and you can always interop with C/C++ when you really need to.
However, a lot of game engines use GC languages to operate game logic. Lots of them use Lua, unity uses c#. The ubiquity of Lua in gamedev has resulted in luajit being shockingly good if you're pushing around a bunch of floats.
This is a common misconception — safety and security are different things. If your simulations make extensive use of parallelism (and I can only assume they do), memory safety also helps you ensure correctness.
I can absolutely appreciate that you might lose other things to achieve that, and that this is not a trivial tradeoff to make (and I'll take you on your word that it is the wrong tradeoff for you!), but it's not as clear cut as "I don't need safety features because I have no security exposure".
If the security requirements are permissive, what's the point of investing so much effort in preventing only one category of bug? There's no misconception.
Use-after-free, concurrent read/writes, forgetting to acquire a lock are all problems that can crash an application or cause correctness issues. Preventing all of those falls under the "safety" heading, and is still useful in a security-permissive environment.
Now, what do you mean by "investing so much effort"? At the language level, it's valuable effort that lets us achieve security in many security-conscious applications, and we get the safety for free in less security-critical environments as a knock-on. Score! From an application developer's point of view, though, you're right. It might genuinely not matter, especially if you're not otherwise motivated to learn the language (a lot of that effort is a one-off and is amortised over your lifetime as a developer, if you choose to learn it). Not all applications benefit that much from the safety guarantees, and you might just be better off not even putting in the effort of using the language if something else is a better fit for you.
I'd have to disagree in this specific context of high-performance parallel numeric simulation. It is terribly easy for memory management errors to produce incorrect simulations that still "work" perfectly, because incorrect handling of floating point numbers will never crash the program, merely corrupt the results in arbitrary, but not necessarily easy-to-detect, ways. Bugs that cause visibly obvious incorrect answers or crash the program are far preferable to bugs that cause subtle errors. It's too easy to "correct" for a bug in a simulation by simply poking it until the buggy simulation seems to return to the behavior you expect, but two bugs don't make correct code.
If you don't really care about accuracy, it may not be a big deal, though... the "shape" of these errors is very difficult to wrap one's head around and I'd still question someone working for or with me fairly hard even so if they think they don't care about accuracy and so it's OK to write code that may or may not be thread-safe.
Something glaring would be if we were getting values that were zeroed out or something like that, but when the only difference is that a slope is -1.4 instead of -1.6 or something like that, you don't immediately realize something's up.
Once we realized there was a bug, it took a long time to track down, and after that I was so frustrated I started writing a new library from scratch in Rust for our research and it worked out pretty well.
Anecdotal evidence for sure but it was really nice not having to triple check for memory race issues.
Or did it not happen in that case?
I'm not even gonna comment on the rewrite, that reads like an ad. :-)
I don't think OP said anything about writing code that's not thread-safe. They specifically said they're carefully designing such code. What they said is that due to the way they handle memory, most related errors don't happen in practice - which is perfectly believable. Floating point errors are separate issue, they could happen in almost any language.
This is a personal quirk, but I consider C++ a different language from C++ + a lot of static analysis tools, on the grounds that most of my major beefs with C++ (and C) can largely be addressed by using powerful static analysis tools from day one. So from my personal, idiosyncratic perspective, I see two very different situations if the original poster is "using C++" or "using C++ and a suite of quality static analysis tools".
I prefer languages like Rust that take advantage of our decades of experience with C/C++ and write the most important stuff directly into the language, but in 2019 I still concede that "C(++) + good static analysis" is still a very economically compelling language for a lot of companies with existing C/C++ codebases. "Upgrading" to C+++SA is not cheap, but it's cheaper than any of the other options, as much fun as "rewrite in Rust" may be.
As for their belief that their code is thread safe, if they are using C++ and not C+++SA, I echo mike_hock's comment. It may be threadsafe, but from this distance and without a lot more evidence, my Bayesian priors say I don't believe it.
I guess I'm somewhere in between on that spectrum. I use clang-tidy and valgrind constantly, along with an everchanging set of profiling tools that occasionally find a potential bug as well. I've used cppcheck in the past, but didn't find it particularly useful, and I use whatever VS2019's static analysis tool is called now if I'm on Windows. I'm also extremely pedantic about compiler warnings, in that I either have a specific reason for disabling a warning entirely for that codebase or it will get fixed. Exhaustive (often mathematically exhaustive) tests go on top of this.
That being said, I don't use any of the sanitizers in my usual workflow. The reason is simple - most of them don't offer anything I need. The huge set of bugs around excessive dynamic memory allocation simply doesn't arise when you don't do dynamic memory allocation, so ASan/LSan/MSan don't give me much. UBSan might theoretically help, but only with issues that have so far been very trivial to debug (accidentally misaligned pointer in explicit SIMD code and the like, results in instant crash and an immediate fix). The one exception is the ThreadSanitizer and when I happen to finally get a non-trivial deadlock, that'll certainly be my first stop. As it is, most of the multithreading I've done recently has been fairly trivial from a compsci point of view (and for good reason, overly fancy algorithms aren't often particularly performant on current architectures).
Regardless, I'm entirely open to the possibility that I've neglected a tool that might help me further. Strong tooling is one of the major advantages of C++ and I've tried most of the major tools at one point or another. I do require demonstrable benefit in the context I work in to continue using them, however, and I've often found it worthwhile to simplify my workflow by cutting something out that turned out to be mostly superfluous.
Many simulation algorithms involve manipulating large arrays of data. Rust can check that your indexes are within bounds, but it can't check that your indexes are correct. If you access X+1 to get the element to the left of X, but you should have accessed X-1, then you have introduced the same sort of error as you'd get from memory corruption (and, frankly, one that is much harder to debug. Standard library assertions, Valgrind and ASan are no help at all if you're accessing valid data.)
Nevertheless, I found it helped a lot to compile with `-D_FORTIFY_SOURCE=2 -D_GLIBCXX_ASSERTIONS -D_GLIBCXX_DEBUG` during my normal development. Among other things, that adds bounds checks to all container accesses. I leave them off in release mode, as my full-scale simulations already take multiple hours and I need the performance.
IMO this could either be read as: - "The misconception that safety and security are different things is common", i.e. they are not different, but the misconception is common. - "The misconception [above, that safety and security are not different things] is common - safety and security /are/ two very different things."
I hope you mean the latter (safety != security). I consult in security (hardware and software), and I came to security by way of high-availability, high-consequence systems.
I've seen many safe, but insecure systems. I don't know if it's possible to have an unsafe, secure system (I doubt it?) but absolutely, safety != security. There is an overlap of sorts, but to say that these 2 "are not different things" is why I have lots of work.
Wow, sorry, I'll get off my soapbox. I'm still not sure if perhaps we're in "violent agreement" as the saying goes.
One of the things I spent the most time on with Fortran / C++ codes was debugging wrong-result bugs. About 90% of the time, the wrong result came from some edge-case where an array was wrongly freed too early, an array was accessed out of scope, or a race condition caused an array member to be updated in a non-deterministic manner. Each of these bugs required hours of debugging and was a huge time sink. Once I started working with Rust, I never encountered any of these bugs. After about a year of fighting the borrow-checker, I feel my overall efficiency has greatly improved.
Now, when I go back and write or read C++ code, patterns that the Rust compiler would yell about jump out at me (multiple unprotected mutable references, cloning unique pointers), and I find these are generally a source of the bug I'm hunting. Like sibling comments point out, a lot (but not all) of the things Rust stops you from doing are just bad practice anyway.
Of course, for GPGPU stuff I have to write CUDA or OpenCL, but those are generally small, compact kernels that are easy to reason about end to end.
I'm not suggesting that you are doing this, but for me, I initially resisted Rust for a long time. Rust seemed extremely complex, and whenever I'd try to use it I would run into a wall. The loud Rust community talking about how great Rust was and how easy it was to use once you "got it" made me feel stupid. Instead of being humble, I became arrogant, and I'd say things like "Rust is too restrictive for the high performance applications I care about" or "I write code that Rust would find unsafe but is actually super well-tuned for this architecture." For me, these were mental excuses I made because I was unable to accept that I was having such a hard time with Rust, and I considered myself a "high performance computing software engineer!"
It took me way longer than most to "get" Rust -- over a year of repeatedly forcing myself to learn and stumble through compiler errors before things started to click. A year after that, and I'm still frequently surprised by certain aspects of the language ("really? I need a & in that match statement?" and "oh god, what does this lifetime and trait bound mean..." are two of the most common). But the parts of Rust that have clicked for me (the borrow checker and associated lifetime mechanics) make Rust very enjoyable to write.
Again, I'm not suggesting that you are falling into the same trap I did, I just wanted to post this to encourage anyone else in the "banging their head against the Rust compiler" stage to power through!
On the broader point, I suspect a large part of the reason we've had such contrasting experiences is just a radically different mindset behind the C++ codebases we've dealt with. Wrongly freed or out of scope arrays scream of exactly the kind of C++ code Rust was designed to address, and as far as I can tell it is indeed great at doing that. On the opposite extreme, when you have statically determined sizes and bounds, all allocations happen at startup and nothing ever gets freed, that entire class of issues simply doesn't arise in the first place. The reason why the overwhelming majority of the bugs I debug are either silly typos or plain logic errors isn't because I'm particularly good at this, it's just a different approach to programming that's easy to pull off in simulation code (or embedded systems, or game engines), but probably rather more difficult in other kinds of applications.
Anyway, I'm glad you're enjoying Rust and I hope it'll have more of a scientific / numerics / GPGPU ecosystem in the future. More viable languages can only be a good thing for us computational scientists.
If you take the code `v /= v.length();` and given -ffast-math the compiler can easily compile that as a mul, two fmas, an rsqrt, and three muls.
But if you take the code: (can't format right on mobile) (edit: fixed on desktop)
__m256 len = _mm256_mul_ps(v.x, v.x);
len = _mm256_add_ps(len, _mm256_mul_ps(v.y,v.y));
len = _mm256_add_ps(len, _mm256_mul(v.z,v.z));
len = _mm256_sqrt_ps(len);
v.x = _mm256_div_ps(v.x, len);
v.y = _mm256_div_ps(v.y, len);
v.z = _mm256_div_ps(v.z, len);
You've told the compiler exactly which instructions to execute. And it will do so faithfully even though it's like two orders of magnitude slower.I would be very interested to see -Ofast used before SIMD intrinsics start getting thrown around, but it has no value afterwards. The code in the linked article is eventually almost all SIMD intrinsics (v4?) so it won't make a difference.
I think all compilers will fuse separate add and mul instrinsics to fmadd.
Both GCC and Clang will convert the _mm256_div_ps to a vmulps. GCC calculates the scaling factor by combining vsqrtps and vrcpps. Clang will emit a vrsqrtps instruction, but as that has only 12 bits of guaranteed accuracy, it fixes the result up before using it to scale x, y and z.
[C++ code]: https://github.com/parallel-rust-cpp/shortcut-comparison/blo...
[Rust code]: https://github.com/parallel-rust-cpp/shortcut-comparison/blo...
The Rust code is... typical Rust. The algorithm almost gets lost in the specific peculiarities.
An interesting question in this context would be, is there anything preventing adding an OpenMP extension to Rust?
Regarding choice of implementation, there are more C++ compilers that support OpenMP than rust compilers.
OpenMP is designed around C-style for loops, which Rust discourages.
That example isn't too bad once you're used to the syntax and idioms but the lack of white space to space out logical blocks is what's making it hard to read.
Well, that and only what I can say equates to writing C++ in rust, much the same way you can write php in JavaScript if you squint hard enough.
Probably doesn't help it's trying to be a comparison which doesn't really work. A lot of that could be refactored out into idiomatic rust and it wouldn't look so bizarre.
This is not true. We agonized over syntax decisions. "fn" was actually preferred by most of the Rust community.
The Swift team probably also agonized over syntax decisions and they came up with something very different which has won over many people and attracted little criticism.
Swift also doesn't have pervasive use of lifetimes like Rust does. Lifetimes, by their nature, always add some level of "noise".
At the end of the day though, these tiny thing like fn, or the lifetime annotations and the many others add up to an unpleasant experience and less readable code.
If it becomes a mainstream language (like Java, C++, etc), I'd be happy to concede I was wrong. It could be that memory safety will trump ergonomics and it wouldn't be the first time that somewhat painful to use tools become very popular - see Linux, git, etc.
"For new features, people insist on LOUD explicit syntax. For established features, people want terse notation."
fn is fine.
IMHO Rust may have tried to roll a little too much into the language. It has the air of the second system effect to it, where every good idea from every language is added together to get something that is less than the sum of its parts and you get code that is hard to decipher until you've learned a full college course worth of syntax.
Can you name a specific feature you want removed from Rust?
An example:
fn largest<T>(list: &[T]) -> T {
let mut largest = list[0];
for &item in list.iter() {
if item > largest {
largest = item;
}
}
I'm sure experienced Rust devs look at that and say it's fine, but that's definitely a hurdle for new developers. fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
https://tomtongue.com/docs/rust-wk-20.html#trait-bounds int size();
Am I declaring a function returning an int? Or defining an object of type int and invoking the default constructor?But if you spend enough time with the language it gets easy enough for these to not be a concern. I personally think it's very much worth learning.
By contrast the Rust example uses parallel iterators from the Rayon crate. If the C++ example had something similar using a C++ library, it would probably be worse.
It's not Rust vs. C++, it's OpenMP directives vs. explicitly writing multithreaded code.
The C++ code looks like straightforward "C code" because it's using the OpenMP parallel for. If it dealt with threads explicitly it would not look as pretty.
I do agree that the Rust code has some verbosity, for example there are a lot of type annotations and using named constants instead of magic values.
For example (C++):
int na = (n + 8 - 1) / 8;
Versus (you could write this like the above too): let vecs_per_col = (n + simd::f32x8_LENGTH - 1) / simd::f32x8_LENGTH;
An example of the type annotations (are they really necessary?): let pack_simd_row = |(i, (vd_stripe, vt_stripe)): (usize, (&mut [f32x8], &mut [f32x8]))| {
Comparing the actual internals of the algorithm is quite straightforward, e.g. look at the middle loop (lines 70-82 in Rust vs. 58-97 in C++). Do note that the C++ code is loop unrolled by hand which makes it look simpler.In the Rust code, there's a pretty cool feature where multithreading can be toggled on/off with a compile time flag, see parts with: #[cfg(not(feature = "no-multi-thread"))]
If the C++ example wouldn't use OpenMP, this would be pretty impossible to achieve without being noisy too.
Also note that even with OpenMP, it would be quite easy to create a memory unsafety issue in the C++ code by for example accessing an array out of bounds inside the loop body.
Apart from these, I have no problems reading and understanding the C++ or Rust code (and I'm a noob with Rust).
OpenMP is suitable for the most simplest kind of parallelism in the first place.
If you look at the example code here, there's like 5 lines of pretty simple Rayon code that's almost a drop-in replacement for single threaded iterator code. It performs almost as good as the OpenMP C++ code, and it's guaranteed to be safe.
OpenMP is not C++ specific, it is also supported in fortran and C (although the newer OpenMP standards have first class support for C++ iterator semantics). In principle it could also be supported in rust although I assume that making it work with the borrow checker might not be easy.
In this snippet the author went through some insane optimization, and opted to use quite a few attributes, those definitely make it look noisy in this case. FFI bindings are verbose and not so clean either.
Example:
struct HReq<'a> {
}
struct HReq<a: LifeTime> {
}
Isn't that better?It’s not a language level constraint to name lifetimes with a single letter. But folks tend to not use longer names for good reason.
Rust is in a weird spot because we have two different kinds of generic parameters: types and lifetimes. They need to be distinguished from one another somehow. Nobody loves the lifetime syntax, but nobody has ever proposed something that would end up significantly better.
I’m not proposing you should use ‘a_lifetime, I’m saying you could. In the end, it ends up obscuring more than helping.
let pack_simd_row = |(i, (vd_stripe, vt_stripe)): (usize, (&mut [f32x8], &mut [f32x8]))| {
for (jv, (vx, vy)) in vd_stripe.iter_mut().zip(vt_stripe.iter_mut()).enumerate() {
let mut vx_tmp = [std::f32::INFINITY; simd::f32x8_LENGTH];
let mut vy_tmp = [std::f32::INFINITY; simd::f32x8_LENGTH];
for (b, (x, y)) in vx_tmp.iter_mut().zip(vy_tmp.iter_mut()).enumerate() {
And I tend to write stuff like this in Rust too. The loops especially. It's just so easy to throw together all those iterator combinators and get some ugly blob that's going to make people's eyes glaze over.`pack_simd_row` is lambda: `|arguments: types| { body }` Arguments are: * `i` of type `usize` (unisgned size_t), * tuple (anonymous struct) with two fields: vd_stripe and vt_stripe, which are modified inside of lambda. They are references to fixed size array of 8 floats.
Inside function we have loop over result of iterator, which produces tuple with two fields: `jv` and tuple with two fields: `vx` and `vy`. `vx` and `vy` are elements from `vd_stripe` and `vt_stripe`. `jv` is their index.
Inside loop we create two temporary mutable variables: `vx_tmp` and `vy_tmp`, which are fixed size array of 8 floats, which are initialized with infinity.
Then we have next loop, which goes to modify these temporary arrays in place.
And so on.
Being able to fit part of a program clearly into 5 lines with fewer characters makes it easier to ensure correctness than an algorithm spread over 10 lines that is full of extra "noise". It's why there's so much syntactic sugar in so many languages.
It’s almost like Rust starts from a functional programmer’s perspective and adds the necessary complexity to have a “low level” language, whereas C is a simplified assembler and C++ tries to add “high level” amenities.
Most language comparison benchmark are completely useless other than for bragging points, but those, like this one, that go into details of why one specific implementation is faster or slower than another are much more interesting and allow making an idea of what makes a language slower or faster.
Also, it interesting that the hand optimized program is about 100 times faster than the unoptimized one, showing that even today there is room for manual optimizations and you cannot trust the compiler blindly, but you have to iteratively work with it to get to an optimal solution. I can't figure out from either this article or the original one whether -fast-math was being used. Would be nice to know if that would help the compiler vectorize and unroll the loop with multiple accumulators.
- UlmBLAS [1], for a HPC course in 14 steps
- BlisLab [2], make sure to checkout the tutorial.pdf. It gives you exercises to solve in C and each one build on the previous one
In Rust matrixmultiply crate [3] implements those techniques to reach 80% of OpenBLAS speed, see blog post GEMM: a rabbit hole [4].
This is a generic approach that can be followed by any low-level languages.
I reach 100% of OpenBLAS and MKL-DNN speed in Nim on large matrices as well [5] without any assembly and a generic code that can also generates integer matrix multiply [6].
Regarding fast-math, that's what you do manually, you interleave the fused-multiply adds as they have a latency of about 6 cycles (Broadwell, I don't remember on Skylake)
[1]: http://apfel.mathematik.uni-ulm.de/~lehn/sghpc/gemm/
[2]: https://github.com/flame/blislab
[3]: https://github.com/bluss/matrixmultiply
[4]: https://bluss.github.io/rust/2016/03/28/a-gemmed-rabbit-hole...
[5]: https://github.com/numforge/laser/blob/e660eeeb723426e80a7b1...
[6]: https://github.com/numforge/laser/blob/e660eeeb723426e80a7b1...
re fast-math, I was interested on how much additional parallelism the compile can extract from the naive code. Fast-math should,at least in principle, allow the compiler to unroll the loop and add the additional accumulators, although that violates strict IEEE semantics.
Usually you can extract 2x to 4x instruction level parallelism on simple add instructions [1] vs [2]
For fused-multiply-add even though the latency is higher the instruction is also slower so its the same. BLIS, OpenBLAS and my code extract 2x parallelism (2 accumulators) at the lowest level because we are restricted by the number of registers and the fact that x86 can only issue 2 FMAs per cycle [3].
Details, we divided the work until we have a small micro matrix C or size MR x NR (input matrix of size MxK and KxN so output of MxN), and then here is are the constraints you have to deal with:
Registers constraints and micro-kernel tuning
- To issue 2xFMAs in parallel we need to use 2x SIMD registers
- We want to hold C of size MR * NR completely in SIMD registers as well
as each value is reused k times during accumulation C[i, j] += A[i, k] * B[k, j]
- We should have enough SIMD registers left to hold
the corresponding sections of A and B (at least 4, 2xA and 2xB for FMAs)
On x86-64 X SIMD registers that can issue 2xFMAs per cycle: - NbVecs is 2 minimum
- RegsPerVec = 2 * NbVecs => 4 minimum (for A and for B)
- NR = NbVecs * NbScalarsPerSIMD
- C: MR*NR and uses MR*NbVecs SIMD registers
- MR*NbVecs + RegsPerVec <= X
-> MR*NbVecs + 2 * NbVecs <= X
-> (MR+2) * NbVecs <= X
Some solutions: - AVX with 16 registers:
- MR = 6, NbVecs = 2
FP32: 8xFP32 per SIMD --> NR = 2x8
ukernel = 6x16
FP64, ukernel = 6x8
- MR = 2, NbVecs = 4
FP32: 8xFP32 per SIMD --> NR = 4x8
ukernel = 2x32
FP64, ukernel = 2x16
- AVX512 with 32 registers
- MR = 6, NbVecs = 4
FP32 ukernel = 6x64
FP64 ukernel = 6x32
- MR = 2, NbVecs = 8
FP32 ukernel = 2x128
FP64 ukernel = 2x64
- MR = 14, NbVecs = 2
FP32 ukernel = 14x32
FP64 ukernel = 14x16
And in-depth overview of the lowest level details is available in the paper Automating the last mile for High Performance Dense Linear Algebra[5].In short, the compiler is completely unable to deal with this, and a high performance computing compiler should give an escape hatch to allow hand optimization like Halide[6] or Tiramisu do[7].
[1]: https://github.com/numforge/laser/blob/e660eeeb723426e80a7b1...
[2]: https://github.com/numforge/laser/blob/e660eeeb723426e80a7b1...
[3]: https://github.com/numforge/laser/blob/e660eeeb723426e80a7b1...
[4]: https://github.com/numforge/laser/blob/e660eeeb723426e80a7b1...
I have seen much more programs that look exactly like this (at least for the C++ part) than programs with clean separation of concerns in my life.
> No realistic coder would expect their team to be satisfied working with code like this.
assuming you've got a team (and that they are trained and not a bunch of interns or barely-graduated with 100% year-over-year turnover), assuming you are a professional developer and not someone who codes in the context of another profession (researcher, artist... heck, I've seen a music teacher making small python apps for the lobby of their music school once, etc).
Were you even looking at the same article? :-)
I thought the same as you, until I looked at the results for the "Mid-range laptop CPU with 4 physical cores and 8 hardware threads.”
There, clang does best in multi-core for v4 through v7. That made me think that, possibly, the default set of LLVM optimization transformation passes in clang is (better) optimised for Apple’s main audience: mobile.
Also: why are the fastest run on the “4.3 GHz Mid-range desktop CPU” slower than the equivalent ones on the “3.4 GHz Mid-range laptop CPU”?
Still a really useful set of comparisons. I am impressed Rust is able to compete with all the magic OpenMP is doing in the background.