12 ms·
Rust vs. C++: Fine-grained Performance
- jasode 11y agoThe author compares gcc vs rustc.[1] I'm curious if the author considered comparing clang vs rustc since both use the LLVM backend. (I'm guessing the more mature C++ code generation would still win the benchmark but one could study the intermediate code emitted by clang/rustc instead of the final machine code emitted by gcc.) [1]https://github.com/ncm/nytm-spelling-bee/blob/master/Makefile https://github.com/ncm/nytm-spelling-bee/blob/master/Makefil...
- nkurz 11y agoCuriously, most variations of the C++ version run only half as fast as they should on Intel Haswell chips, probably because of branch prediction failures I haven't looked at the code yet (time for bed) but this note from Agner seems like it might be relevant: 3.8 Branch prediction in Intel Haswell, Broadwell and Skylake The branch predictor appears to have been redesigned in the Haswell, but very little is known about its construction. The measured throughput for jumps and branches varies between one branch per clock cycle and one branch per two clock cycles for jumps and predicted taken branches. Predicted not taken branches have an even higher throughput of up to two branches per clock cycle. The high throughput for taken branches of one per clock was observed for up to 128 branches with no more than one branch per 16 bytes of code. If there is more than one branch per 16 bytes of code then the throughput is reduced to one jump per two clock cycles. If there are more than 128 branches in the critical part of the code, and if they are spaced by at least 16 bytes, then apparently the first 128 branches have the high throughput and the remaining have the low throughput. These observations may indicate that there are two branch prediction methods: a fast method tied to the μop cache and the instruction cache, and a slower method using a branch target buffer. http://www.agner.org/optimize/microarchitecture.pdf http://www.agner.org/optimize/microarchitecture.pdf At a glance, the symptoms seem like this might match. Separately, I think there is still a limit of 3 branches per 16B before the branch prediction starts to fail. It's rare to hit this in normal code, but something this optimized might.
- wscott 11y agoI found the branch prediction claim to be a bit weak without perfmon data to support it. The references point out that the Intel 'popcnt %r1, %r2' instruction has a false input dependency on the output variable and neither gcc nor clang seem to know about that and adjust register scheduling. This is usually caused because the chip uses the same scheduling bucket used for instructions like 'add'. So putting 'popcnt %rax, %rax' inside a tight loop will prevent that loop from being unrolled in the processor. As a result the code is highly sensitive to random changes because the compiler occasionally gets lucky and breaks the chain.
- nly 11y agoI've recently experienced what I believe was likely branch predictor failure. I rewrote essentially the same algorithm, with the same complexity, in a completely new way, to reduce constant factors. The new code was beautiful, and I was expecting perhaps 15-20% performance improvement. (40% of the runtime in the old version was in one function, the new version should, and did, call that function with less data to grind on average). Memory access patterns should be identical. Grand. So what happened with my standard benchmark on Haswell (GCC 5.3, -march=native -O2)? Old version, without PGO: ~8 minutes (baseline) Old version, with PGO on: ~9 minutes (yes, PGO made it slower) New version, without PGO: 11 minutes (60% slower) New version, with PGO on: 7 minutes (~15% faster) All of these benchmarks were replicated with a common set of functions tagged noinline, and I inspected the assembly to see if there was a difference with regard to loop unrolling. Nope, no unrolling. Afaict it appeared to be entirely to do with the way GCC had laid out code with respect to branches. The differences were minor in key areas and seemed like arbitrary compiler choices. I eventually narrowed this down with callgrinds branch-predictor simulator to a few hot spots. I added a redundant if-statement to check for and short-circuit a common case, even though it shouldn't have mattered, and a few __builtin_expects, and suddenly I was getting the 15% speedup without PGO.
- melted 11y agoDon't know about Clang/LLVM, but in GCC you can give the compiler a hint if a branch is likely to be taken. As far as I can tell from disassembly, most of the time it just puts that branch first in the instruction stream, but I have measured significant benefit in very hot code paths.
- threeseed 11y agoThis is really fantastic to get an understanding of how Rust works in a real world environment. As a non-systems developer I am finding the Rust language to be a little cryptic though in particular the many new syntax elements that don't exist in other languages. I would be curious to know if there are plans like Swift to simplify the language syntax in the future or if it is just something to get used to.
- steveklabnik 11y agoIt depends on what you mean by "simplify". We've just accepted an RFC for ?, for example, which should make certain code more concise, but it is an additional feature, so is that more simple?
- quickben 11y agoGeneral notes: - Semicolons inconsistent usage makes Rust harder to process. Is that semicolon extra? Is it not? Will that be a compilation error? Saving single keystrokes will be paid in cognitive load. Programming is already a demanding task, this isn't helping. - Chaining multiple statements on a single line is bad for various reasons. Dependencies / headers / modules: - C++ is better precisely because it's more grained on the initialization side. The paragraph starting with 'Rust wins, here', is just a general turnoff to read the rest of the article. When I read the title 'Rust vs. C++: Fine-grained performance' I was honestly expecting either a table with performance times across various algorithms, or a savvy optimization guide. I wasn't expecting a LoC duke-it-out. Input file processing - Both are conceptually boilerplate code. There is more for Rust apparently. - There is an interesting &⊛env:: in there that makes the language looks like it needs a bit more work. Three operators just to do something with the first object? - Are they intentionally trying to copy C++ with the :: operator but removing visually arbitrary semicolons? Just replace the :: with something else. Data structure and input setup - Remove the 'let' from the language, and it does look cleaner than the C++. Input state machine - Why isn't "(0..7).rev()" "(7..0)" - On the bottom part, lamba-like usage will makes in less comprehensible (and probably undebuggable the way it's written) General lack of performance numbers - "I found that iterating over an array with (e.g.) “array.iter()” was much faster than with “&array”, although it should be the same..." "Curiously, changing scores to an array of 16-bit values slowed down (earlier versions of) the C++ program by quite a large amount – almost 10% in some tests – as the compiler yields to temptation and forces scores into an XMM register. The Rust program was also affected, but less so." Edit: Unicode
- anfroid555 11y agoRust = c syntax with Erlang conventions
- biokoda 11y agoEhm no not even close.
- pdpi 11y agoYou would've hit closer to home if you'd said "ML" or "Haskell" instead of Erlang, though you'd still be shooting wide. Rust's borrow checker is its single most distinctive feature, and that is inspired by the work done in the Cyclone language.
- sitkack 11y agohttp://doc.rust-lang.org/reference.html#appendix-influences http://doc.rust-lang.org/reference.html#appendix-influences
- Ace17 11y agoIf you want to compare the verbosity of both languages, why not use 'using namespace std' ? Why write: > std::vector<unsigned> counts; counts.resize(sevens.size()); When you can write: > auto counts = vector<unsigned>(sevens.size()); Doesn't seem like a fair comparison to me.
- svalorzen 11y agoI don't agree on the general usage of "using namespace std", so that part does not bother me. For an even shorter init: > std::vector<unsigned> counts(sevens.size());
- Ace17 11y ago> I don't agree on the general usage of "using namespace std", so that part does not bother me. It should: we're comparing to Rust, whose namespace system seems very similar to a "using namespace std".
- arielb1 11y agoRust only provides short reexports for a stable set of a few dozen items, rather than the "entire universe" of `using namespace std`. The only types that have a default reexport are `Option`, `Result`, `Box`, `String` and `Vec`.
- steveklabnik 11y agoTo be even more specific, http://doc.rust-lang.org/std/prelude/ http://doc.rust-lang.org/std/prelude/
- pcwalton 11y ago> we're comparing to Rust, whose namespace system seems very similar to a "using namespace std". Not at all; only if you use globs ("use foo::*"). D's namespace system is like that, but not Rust's.
- rosshemsley 11y ago
- vkjv 11y agoI think one thing of note here is that the author did not want to make the program parallel. One of the benefits touted by Rust is "fearless concurrency." If this feature was introduced this is where # lines or performance may have diverged.
- sitkack 11y agoParallel is of course very important. But if serial speed isn't in the same range, go parallel will be the same perf crutch as the Python folks who say drop to native. Rust programs shouldn't be parallel to beat a single threaded C++ program.
- toolz 11y ago>Rust programs shouldn't be parallel to beat a single threaded C++ program. I don't write code in either language, but as an outsider my impression is that rust was created to make programming for parallel execution, easier. If that's the case, why are we comparing a use case c++ is optimized for against a use case rust isn't optimized for?
- autoreleasepool 11y agoRust wasn't created to make programming for parallel execution "easier". What Rust does is make writing implicitly unsafe parrellel code impossible to write. This is a good thing, but is orthogonal to single threaded performance (which from what I've read, the Rust team definitely cares about).
- kzrdude 11y agoIt's absolutely intended to make it easier. Easier in the sense for example, that you can create a multithreaded program that uses stack-allocated but shared data, and you don't have to debug complex synchronization yourself. The rayon library is an exciting example of some of the possibilities http://smallcultfollowing.com/babysteps/blog/2015/12/18/rayon-data-parallelism-in-rust/ http://smallcultfollowing.com/babysteps/blog/2015/12/18/rayo... Rayon, being a multithreading library, is of course itself not so trivial to write. What it lives up to is that users of rayon can use the regular rust type system rules to ensure that their use of rayon is thread safe if it compiles.
- zvrba 11y agoMost insightful part for me was this one: > Many variations [he's talking about Rust] that seemed like they ought to run the same speed or faster turned out slower, often much slower. By contrast, in C++ it was surprisingly difficult to discover a way to express the same operations differently and get a different run time. This is an insidious pitfall that may earn Rust the "slow" label, akin to what happened to Common Lisp.
- infinity0 11y agoYeah, I wish he had gone through this in more detail, too.
- humanrebar 11y ago> This is an insidious pitfall that may earn Rust the "slow" label, akin to what happened to Common Lisp. Could it just be that the language is still young and all the edge cases in the optimizer haven't been implemented yet?
- steveklabnik 11y agoNot just that, but there's a lot of optimizations that would be nice, but we haven't implemented yet. (Though some of these will end up being more to help with compile times than runtime performance, though)
- imh 11y agoI thought Rust left the optimization to LLVM. Is that not the case?
- dikaiosune 11y agoAs I understand it, there are a number of potential optimizations based on Rust's implicit understanding of the lifetime of a memory region. LLVM doesn't know about those because they can't be represented in the IR. Last I heard, any kind of optimization work based on the extra information rustc has is waiting for the compiler refactor that's currently underway.
- twic 11y agoI'm surprised by the small warts in the Rust code. In this line: let fname = &*env::args().nth(1).unwrap_or("/usr/share/dict/words".into()); Neither the &* not the .into() add much to the meaning for me as a reader. Why are they necessary for the compiler? Why is there an .unwrap() on writeln! ? To force a panic if the write failed?
- steveklabnik 11y agoThey used `into()` because it's the shortest. The more idiomatic options are all of the other ones: String::from, .to_string(), and .to_owned() (Strings implement a lot of generic interfaces.) The &* converts a String to a &str. I would have done it in the match rather than here, personally. > Why are they necessary for the compiler? Rust does not heap allocate things automatically nor convert types automatically. This is the side effect: you can see exactly how much allocation is going on, and know exactly how stuff is being converted. (We do have a very small and very specific set of conversions that will happen, they don't here, though.) > Why is there an .unwrap() on writeln! ? To force a panic if the write failed? Yes. The compiler will warn you that you're ignoring the errors on the write, and panicking is one way of handling it.
- twic 11y agoSo the string literal is a str, but it gets turned into a String with the .into(), and then back into a str (well, &str) on the same line. That seems like a lot of travelling to go a short distance. Is this because args() ultimately gives you a String, and you need to get that and the string literal out of a single expression? Why does args() give you String rather than str? Couldn't command-line arguments be static more-or-less constants? Would there be any way to turn the output of args straight into a &str, so you didn't need to .into() the literal? Why are String and str separate types, rather than different uses of a single type? Are there any other examples of pairs of types with the same relationship? Arrays and slices? Any others? Is there something like this for maps/dictionaries/hashes? I realise i've asked a lot of basic questions about strings here, and that you (and any other knowledgeable people reading this) are probably pretty tired of explaining this over and over again. I would be equally grateful for a link to some relevant documentation or whatever as for a detailed answer!
- hellofunk 11y ago>but I’m amazed that Intel released Haswell that way. I don’t know yet if it Intel fixed it in Broadwell or Skylake. I would love to know more about this; how curious indeed.
- ufo 11y agoIncidentally, I don’t know why I can write let (mut word, mut len, mut ones) = (0u32, 0, 0); but not (word, len, ones) = (0, 0, 0); Marking variable declarations with let is a good thing and makes it clear if something is an assignment or a variable declaration. It also leads to cleaner code when you use nested functions (I always hated the "nonlocal" keyword in Python)
- losvedir 11y agoI think the author was indicating that while you can use that syntax to declare and initialize variables, you can't use it later to assign new values to them. I don't think they had any problems with the concept of `let`. And it does strike me as a little weird as well to only be able to take advantage of the pattern match destructuring on initialization, but I'm sure there's a good technical reason.
- deng 11y agoI read it the same way. There's an open issue for it: https://github.com/rust-lang/rfcs/issues/372 https://github.com/rust-lang/rfcs/issues/372 I think one of the main reasons this isn't done is because of the grammar issue. I dimly remember that one of the goals of Rust was to be parseable with a LL(1) grammar.
- steveklabnik 11y agoThere are grammar issues, yes. We unfortunately have one tiny case that's context-sensitive though, so we didn't quite get there :/ that vast majority of the grammar is very straightforward though. There's a lot of benefit to it.
- masklinn 11y agoThe main reason this isn't done is that `let (a, b) = (…)` is the pattern matching of two tuples, but assignment doesn't do pattern-matching.
- sremani 11y ago
- cppishellacool 11y agoDo you want C++ compiler to also prevent data races? Then use const functions by default, seriously. http://youtu.be/Y1KOuFYtTF4 http://youtu.be/Y1KOuFYtTF4 (starts at 29:00) C++ template meta programming language is such a powerful and expressive tool for writing libraries and safe abstraction. Imagine if all the effort of 8 years of Rust team went into a modern C++ safety library. Such a library would have been more useful contribution and more people would have benefited.
- steveklabnik 11y agoUsing const does not prevent data races. We made Rust because you can't actually retrofit it's guarantees on C++ without breaking backwards compatibility. The CPP Core Guidelines are an example of this: Herb said that data race prevention is a non-goal, and in general, how it interacts with concurrency is not yet understood.
- cppishellacool 11y agoDid you watch the video? Curious why you think using const functions doesn't prevent data races after watching at 29:00 he says the C++ standard guarantees const member functions are data race free.
- steveklabnik 11y agoI mean, data races need mutability, so something that's const can't have a race, sure. What I meant was something like "const itself is not a panacea against data races generally." It is a useful tool.
- cppishellacool 11y agoDon't get me wrong I respect Rust core team as theorists and think rust is an important research project. But in C++ if you have a const member function F() then F() can only change member variables that use the "mutable" key word. If you default to all methods are const you have much much safer C++. As safe as rust? not probably but combined with liftimes proposal and static analysis rules we have a language good engineers can sufficiently work with. struct Foo { mutable auto a = 4; auto increment() const -> void { this->++a; } }; If we train engineers to use const member functions, then the programmer is forced to think about state and mutation. As C++98 PTSD begins to fade and C++17 becomes the cultural mental image of the language, the argument for switching to rust will become less and less compelling. Eventually, the only "advantage" will be ML influenced syntax. The reason is there are only three languages that can easily call into C++ code. Objective-C (swift by proxy), D, and of course C++. Why rust didn't prioritize compatibility with C++ like C++ did with C boggles my mind. Why do you think C++ became so popular? Near perfect C compatibility (until recently) is literally the only reason. Instead of focusing on pragmatisim Rust focused on language purity. At what cost?
- zerr 11y agoIt seems Rust sacrifice too much for the sake of safety. If Swift will evolve to truly cross-platform language, I think it is in the golden middle of safety vs usability trade-off (without requiring non-deterministic GC like D).
- autoreleasepool 11y agoI think after about Swift 4.0, if you're writing a native application in [systems language] and you're using Arc/Rc/shared_ptr all over the place you need to seriously take a step back and ask yourself why you're not using Swift. If Swift had structural references (for all intents and purposes in Swift, all structs are value types and all classes are automatically reference counted pointers) and move semantics Swift would absolutely kill it. It's an exciting time to be a PL nerd.
- kibwen 11y agoI'm a Rust fan, but I'll still be excited if Swift manages to add some sort of borrow checker to the language in a future revision (C++, D, and Nim have all also at least alluded towards such a direction, but only C++ has made any progress so far). From what I've seen though, I'm still not sure that Chris Lattner has given serious thought to such an addition. I'm waiting for a concrete proposal before getting my hopes up, because Rust had to make put significant effort into tailoring the base language to borrow checking and implementing such an analysis after the fact runs the risk of serious divergence (or worse, breakage) in the ecosystem. (There's also the fact that, as of the hypothetical Swift 4k, none of the library ecosystem will be leveraging the borrow checker and so Rust will still have a substantial head start for people who want thoroughly static ownership.) Other than that I'm a bit dismayed that Swift just punts concurrency to GCD, given how many other new languages make concurrency such a major focus (especially Rust).
- gnuvince 11y ago> It seems Rust sacrifice too much for the sake of safety. Like what? Given the constraints that Rust has, I don't see much that could radically be changed. Little things like the ? operator (equivalent to try!()) can improve the common tasks, but how do you ensure memory safety in the absence of a garbage collector without the programming being explicit about what he wants to do?
- shin_lao 11y agoTo my knowledge std::ifstream is pretty slow, I think the author should load the whole file in memory with a cache friendly layout and try the benchmark again.
- Animats 11y agoI need to learn Rust again. I haven't used it in a year, and the language has changed a lot. Everything now seems to require a closure or some ".into()" idiom. Like this: let file: Box<Read> = match fname { "-" => Box::new(stdin.lock()), _ => Box::new(fs::File::open(fname).unwrap_or_else(|err| { writeln!(io::stderr(), "{}: \"{}\"", err, fname).unwrap(); process::exit(1); })) }; Type Result has its very own set of control flow primitives.[1] So does type Option.[2] Any type can have its very own flow control primitives. Are we going to see date.if_weekday(|day| { ... }) and similar cruft for every type? I hope not. Hopefully the setup of closures that probably won't be executed isnt't too expensive. Do those require a heap allocation and release when not used, or is this all on the stack? The run time variation for small changes indicates that some constructs are more expensive than others, but it's hard to know which ones are bad. I understand the rationale behind the Rust approach to error handling, but it's just painful to look at. After seeing the gyrations people go through in Go and Rust to deal with the lack of exceptions, it looks like leaving exceptions does not make a language simpler. From a compiler perspective, the compiler can generally assume that exceptions are the rare case, and can optimize accordingly. Rust has no idea which closure a function will call, if any. [1] https://doc.rust-lang.org/std/result/enum.Result.html https://doc.rust-lang.org/std/result/enum.Result.html [2] https://doc.rust-lang.org/std/option/enum.Option.html https://doc.rust-lang.org/std/option/enum.Option.html
- heinrich5991 11y agoClosures are just as expensive as normal control flow, if they're not passed as a trait object (which is unusual). To expand: No closures require heap allocation. If you pass the closure as trait object, calling it will require a virtual function call, which is more expensive than a normal one.
- steveklabnik 11y agoClosures aren't more expensive than what you use; they're not heap-allocated by default, and a closure that doesn't close over everything should end up being a regular old function. We just merged an RFC for ? syntax, which should make error handling less verbose.