10 ms·
Generics and Compile-Time in Rust
- the_duke 6y agoThere are plenty of Rust features I'd love to see finalized, like GAT ( HKT, sort of), generators, async trait methods, custom test frameworks, ... But there is an area that could have a big impact on certain (mostly higher level) domains, yet doesn't seem to get much attention: better trait objects. They are severely limited in a few aspects: * only a single trait/vtable * casting is only available with Any and you can't cast between different traits, requiring really awkward super-traits with manual conversion methods or hacks like mopa [1] * object safety rules are cumbersome and prevent certain important traits like Clone to be available, leading to clone_boxed, clone_arc everywhere, or proc macro solutions like dyn-clone [2] * ... Doing anything more fancy with them usually feels annoying. Therefore the standard library and entire ecosystem strongly favor generics and monomorphization. This is generally fine and has worked out well for the language, but there are plenty of use cases where more advanced trait objects could reduce code size and compile times with very small impact on performance, while also enabling some interesting new patterns. I realize there are plenty of implementation challenges that make work in this area far from trivial in the current language, but it's frustrating to miss out on part of a toolbox. I think Swift is an interesting comparsion. The languages are similar in quite a few aspects, but Swift often prioritizes small code size and dynamic dispatch over monomorphization. Compile times aren't that great either, though... ps: it is briefly mentioned in the post, but switching to LLD has provided noticeable build time improvements on most of the binary crates I am working on. [1] https://github.com/chris-morgan/mopa https://github.com/chris-morgan/mopa [2] https://github.com/dtolnay/dyn-clone https://github.com/dtolnay/dyn-clone
- whb07 6y agoHypothetically couldn’t you implement this as a macro extension of sorts?
- m-hilgendorf 6y agoYou can, it's how com-rs [0] works and we're co-opting it in vst3-sys [1] for non-Windows targets. It's painful, unsurprisingly. There are some people working on better abstractions, but I hand-coded a VST3 plugin using those macros just for the FFI-safe COM bindings and it is verbose and particularly unsafe [2] I'm in disagreement with the parent, dynamic dispatch through vtables is only a zero-cost abstraction across FFI boundaries. Personally I'd ballpark about 33% of the value-add of traits are shared interfaces on different types. The real money is in associated types and trait bounds. The latter is still a serious type-checking requirement at compile time, and the former requires RTTI to support dynamically in some form - both of which have costs at compile and runtime. All that said, there may be an argument that the features GP is talking about are covered currently by enum variants (many of the use cases one would have for base classes with associated member variables are done that way, for example), and if you could show how performance and ergonomics improved by increasing the semantic complexity of dyn trait objects - you'd have a strong argument to add it to the language. Just my two cents. [0] https://github.com/microsoft/com-rs/ https://github.com/microsoft/com-rs/ [1] https://github.com/RustAudio/vst3-sys https://github.com/RustAudio/vst3-sys [2] https://github.com/RustAudio/vst3-sys/blob/master/examples/passthru.rs https://github.com/RustAudio/vst3-sys/blob/master/examples/p...
- deleted 6y ago[deleted]
- saghm 6y ago> This is generally fine and has worked out well for the language, but there are plenty of use cases where more advanced trait objects could reduce code size and compile times with very small impact on performance, while also enabling some interesting new patterns To elaborate a bit on the part about new patterns, I've encountered issues where trait objects allow me to define APIs that otherwise wouldn't be possible. One example is when developing something like a database driver, you might define a trait EventHandler with the methods `handle_start_event` and `handle_completion_event`, where users can pass in values of types that implement EventHandler, and you call their handling methods whenever you start or complete an operation. The most straightforward way to specify this would be to have your client type have an internal vector of EventHandlers that you can iterate over and call the corresponding event handling methods whenever needed. If you use generics for this, then your client type will need to be generic over the type of EventHandler it can contain, which means you can't specify EventHandlers with different concrete types. The best way to get around this is to use something like `Vec<Box<dyn EventHandler>>`. I understand the sentiment behind favoring generics over trait objects in the Rust ecosystem, as strongly preferring compile time costs to runtime costs when there's a choice between them is one of the more fundamental guiding principles to the Rust ecosystem (and is one of the things I really like about Rust), but there are patterns that trait objects allow that just can't be expressed with generics, and when you need to use one of them, it can be frustrated to hit some of those sharp edges you mentioned.
- dllthomas 6y agoThis sounds like the Existential Antipattern, from Haskell land. I don't know to what degree it translates to this setting. https://lukepalmer.wordpress.com/2010/01/24/haskell-antipattern-existential-typeclass/ https://lukepalmer.wordpress.com/2010/01/24/haskell-antipatt...
- zenhack 6y agoThe difference is that the author's suggestion as to what to do instead doesn't work in Rust. In Haskell there's no need to use a type class/trait here at all; you can just use a record. We could try to do the same thing in Rust with a struct: struct EventHandler { handle_start_event: fn(), handle_completion_event: fn(), } ...but this differs in a key way from the haskell example: Rust's `fn` is not a closure, it's just a pointer to some code, and you can't construct them dynamically. If you want to pass around a _closure_, you need ...a trait object. Something like: struct EventHandler { handle_start_event: Box<dyn impl Fn()>, handle_start_event: Box<dyn impl Fn()>, } ...but that's exactly the pattern the author of that post is suggesting getting away from. I agree with the author's point, but it doesn't really apply to Rust because there isn't actually a simpler way to do it. And I think that's part of what the OP was getting at -- Trait objects are the closest thing Rust has to proper closures, and they're a bit awkward. Note that I'm still something of a newbie at Rust, and don't feel like I can really say much about the day to day experience of using (or avoiding) trait objects due to limited experience.
- saagarjha 6y agoCan we have the title changed to "Generics and Compile Time in Rust"? The way it's written know, I thought for sure it would be about compile-time programming using generics.
- marcianx 6y ago> first, modern CPUs have invested a lot of silicon into branch prediction, so if a function pointer has been called recently it will likely be predicted correctly the next time and called quickly Huh, TIL. Branch prediction is normally about predicting which branch an `if` would take. But apparently this applies to indirect jumps as well: https://stackoverflow.com/a/26240197/1082652 https://stackoverflow.com/a/26240197/1082652
- xenadu02 6y agoThis also applies to predicting the return address. You can assume any form of control flow has branch prediction. A surprising amount of silicon ends up being worth the cost if it can improve prediction just a little bit because a pipeline stall is so astronomically expensive.
- secondcoming 6y agoThere's a famous SO question: https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster-than-processing-an-unsorted-array https://stackoverflow.com/questions/11227809/why-is-processi...
- gpderetta 6y agofor what is worth, indirect branch prediction has been added relatively recently (as in the last 20 years) in mainstream x86 cpus.
- riquito 6y agoI love when authors add how to read aloud something to help newcomers, as in fn print<T: ToString>(v: T) { > We say that “print is generic over type T, where T implements Stringify”
- aspaceman 6y agoI also greatly appreciate this. Thanks for pointing it out for me!
- seaish 6y agoDoes anyone actually call it "Stringify"? I would call it "to string".
- smabie 6y agoProbably not. If they wanted people to call it Stringify then just name it stringify. Though personally, I like how instead of toString, Haskell calls it 'show'. Clear, to the point, and one word.
- carlmr 6y agoHow is "show" clear for string conversion? You could just as well expect a GUI popup just going from that word.
- smabie 6y agoHow is toString, you just might expect your object to turn into a ball of yarn.
- carlmr 6y agoString is a very common but specific word in programming for a string of characters. Show is a very common but generic word in programming, for showing plots, showing message boxes, showing anything really. Not knowing Haskell I would not have expected it to convert an object to a string.
- platz 6y ago> In general, for programming languages, there are two ways to translate a generic function: > 1. translate the generic function for each set of instantiated type parameters > 2. translate the generic function just once, calling each trait method through a function pointer (via a “vtable”). The approach in haskell might be considered a variation of 2, since it involves indirection, but a little different from other languages normally using vtables, since it's not selecting different implementations at run time, but just looking up the pre-determined implementation through a new parameter. In particular, the function is transformed into a higher-order function accepting a new parameter representing the dynamic to_string functinoality, then at the call site, the appropriate concrete implementation to_string parameter is inserted for the new transformed function, and similarly this new higher-order function 'print' only needs to be compiled once.
- whateveracct 6y agoOne thing to note is Haskell is exceptionally good at inlining that explicit dictionary away. Haskell isn't 0-cost-abstraction like Rust ofc, but it is definitely a minimize-the-cost-of-abstraction. And the control over said minimization is getting better with each release.
- zenhack 6y agoAlso worth noting that Haskell's type system is too powerful for monomorphization to work as an implementation strategy for generics in all cases. In particular, polyorphic recursion[1] means that attempting to just monomorphize anything wouldn't necessarily terminate. Higher rank types also break this, as you can define things like: newtype GenericThing = GenericThing (forall a. [a] -> a) doManyGenericThings :: [GenericThing] -> [a] -> [a] doManyGenericThings things list = map (\GenericThing f -> f list) things Here, `doManyGenericThings` takes a list of generic functions, and applies each of them to its argument. You can't just monomorphize it, because you'd have to somehow also monomorphize every argument it is ever passed, which is a dynamic property that you can't know in advance (and again, may not be a finite set). [1]: https://en.wikipedia.org/wiki/Polymorphic_recursion https://en.wikipedia.org/wiki/Polymorphic_recursion
- amw-zero 6y agoOnce all of this compile-time metaprogramming and code execution starts to happen, it always makes me ask: doesn’t this just conclude with dynamic typing? I’m currently a static typing lover. But it’s almost as if we’re just looking for the full power of a programming language. Why not just use the language itself instead of a weird, stratified compile time language?
- hedora 6y agoThe weird stratified compile time language lets you create DSLs that generate and typecheck the program you’re trying to write. I’ve written a lot of C++ (and no Rust), but if the compile time language is turing complete, then you can push all sorts of correctness and type checks to compile time. This is really, really powerful. Even with unsafe C++, I’m used to writing very high level, high performance concurrent code. If it crashes after it’s passed CI testing for a week or so, it’s probably a hardware bug. (Of course, there are exceptions, but those fall outside of the parts I have the compiler check for me.)
- amw-zero 6y agoBut what you can prove at compile time is rarely useful. You can use phantom types, smart constructors, and the like, but after a while you need a runtime to do anything interesting. An example: this function is only valid on positive, odd integers in the range 3-91. With any static type system, this has to be a runtime check anyway. I’m not an expert on dependent types, but that sounds like what those try and solve. But at that point, you just want compile time to be a full program, which has all of the same pitfalls as the runtime of a program.
- _bxg1 6y agoAny static type system prevents you from doing some subset of things that you could do with a dynamic type system. But there are obviously lots of things you get in exchange for that power. Type system evolution, usually, is about trying to colonize a larger and larger portion of that possibility space and put it under the domain of "statically checkable". It will never cover the whole thing, but it's still really valuable work and great strides are still being made.
- baby 6y agoRust compile times are just really really bad.
- ed25519FUUU 6y ago> * Note that in these examples we have to use inline(never) to defeat the optimizer. Without this it would turn these simple examples into the exact same machine code. I'll explore this phenomenon further in a future episode of this series.* I’m really eager to use more rust, but three optimizations really turn me off. Optimizing the compiler feels like meta programming.
- prerok 6y agoWhat do you mean? I think that these statements are there only so that the author can provide a glimpse into the difference of code generation between the two examples. You would not want to disable it when writing code for an actual project.
- rob74 6y agoA true gem from the "comments on the last episode": > The compile times we see for TiKV aren't so terrible, and are comparable to C++ So if you're already used to the terrible compile times of C++, the compile times of Rust won't seem that bad in comparison. And Mozilla, where Rust started, mostly relies on C++. That does explain a lot...
- Ygg2 6y agoThat, and the more you optimize for runtime performance, the less compile-time performance you have.
- Gibbon1 6y agoI always keep coming back to code gets compiled a lot more than it gets modified. In C land it's typical to run analysis tools on a codebase as a separate process. Seems doing the sorts of checks Rust does on every compile seriously wastes programmers time. Also a problem that for 95% of code written speed doesn't matter at all. Combine those two and programmers a paying a lot for something that isn't needed. AKA 99% of the time you're compiling a module that hasn't changed. And 95% of the code in those modules speed doesn't matter at all.
- lmm 6y agoRust is a language specifically for the other 5% though. If you don't have extreme performance requirements wouldn't you just use OCaml (which compiles very quickly)?
- Gibbon1 6y agoOf late I feel like I care less about perfect languages and more about ABI compatibility. There is something to be said about most of the time you should be using a safe mildly performant glue language with a fast write/compile/test cycle. The big monkey wrench of course is ABI incompatibility. Which has always left you with C and now Swift as the base language.
- deleted 6y ago[deleted]
- blackrock 6y agoNim or Rust? Some Rust syntax seems overly confusing. But Nim doesn’t really have strong corporate support backing it. But, both are still new, and missing a lot of libraries. Rust is annoying, where there aren’t standardized libraries for common functions. Just some guy’s tweet to use some random cargo crate from someone.
- twoodfin 6y agoThis is a good article, but rather misses the point on performance of monomorphization vs. dynamic dispatch. Yes, CPU indirect branch predictors are getting better, and compilers are getting smarter about identifying opportunities to turn dynamic into static dispatch. But inlining remains the optimizer’s silver bullet, enabling a host of dependent optimizations. It’s those further optimizations that make the primary performance difference for static calls.
- AndrewBissell 6y agoI did some investigation into the performance impacts of using the _Generic keyword in C11. It was a toy example so not necessarily broadly applicable to real world code, but a good 70 or 80 percent of the speedup from generics seemed to come from inlining. https://abissell.com/2014/01/16/c11s-_generic-keyword-macro-applications-and-performance-impacts/ https://abissell.com/2014/01/16/c11s-_generic-keyword-macro-...
- twoodfin 6y agoInlining is also what’s behind the classic “C++ is faster than C” example of “sort()”.
- vlovich123 6y agoI think an area ripe for improvement is compilers to think about which monomorphized variant is valuable & can benefit from that inlining vs where the dynamic dispatch is sufficient. Start with PGO & I'm sure smarter people will investigate how that can be done for 80% of the benfit at compile time without PGO.
- estebank 6y agoI am convinced that `rustc` should have an "optimizer lint" phase with all the same checks other languages perform to change the behavior of the code and instead suggest changes that affect running code and compile time, like `Box`ing fields or variants that disproportionately affect the size of an `enum`[1] or to suggest changing generic params to trait objects or vice versa[2] when it makes sense. The advantage of not doing it automatically is that the behavior of the code once compiled can be always inferred from looking at the code, no magic and sudden changes in behavior because some threshold has been passed in some optimizer. [1]: https://github.com/rust-lang/rust-clippy/pull/5466 https://github.com/rust-lang/rust-clippy/pull/5466 [2]: https://github.com/rust-lang/rust-clippy/issues/14 https://github.com/rust-lang/rust-clippy/issues/14
- deleted 6y ago[deleted]