PathToPerformance

From Julia to Rust ๐Ÿ—บ

I've been more serious about learning Rust recently, after dragging on with passive learning for a while. My first real programming language was Julia, and I know other Julians interested in Rust. I've written this article for those people in mind, because Rust and Julia are good performance sparring partners, but Rust has a different mindset and tradeoffs that are worth considering.

I hope you enjoy it.

TLDR:

"It is important to draw wisdom from many different places. If you take it from only one place, it becomes rigid and stale."

โ€“Uncle Iroh


Why Rust? ๐Ÿคท

There are 3 talks that sold me on Rust being worth learning, the first is by Carol Nichols and the second is a lecture by Ryan Eberhardt and Armin Nanavari. The first talks about how about ~70% of all bugs from the big tech corporations are from memory safety and that trains used to not have emergency brakes. The second explains how sytems programming codebases already impose the invariants of resource ownership on the coders - but that reasoning can be horribly error prone, tedious, and automated.

That's the point of technology! To not have to worry about the previous generations problems because we figured out a way to offload that thinking to a machine.

The third talk that really sold me on Rust was Alex Gaynor's. It's bad enough that a bank or a school web site could crash because of memory bugs, but once you take into account the fact that not even the best programmers in the world (sorted by salaries, roughly) can ship safe code, you start to despair a little. Then you hear about the incredibly battle-tested libraries like sudo and, as the moral argument goes, you are likely going to put vulnerable people in harm's way if you keep shipping a broken tool. I buy the urgency of that argument more and more when journalists or human rights advocates get targeted by state actors due to a trivial (but buried) C mistake.

So that's the spiel for jumping on the Rust train when I argue with myself in the shower. What's the Rust's philosophy?


Informal introductions - tales of two languages ๐Ÿ“š

I will now give 2 hand-wavy historical rehashings of the origins of both languages.

You might know Julia's origin story - there were a gajillion DSLs for scientific computing, BLAS is a mess but implements polymorphism through namespacing for performance needs, and other libraries re-implemented a poor man's version of multiple dispatch because of the performance constraints. If you add a clever JIT to multiple dispatch capabilites, you can get ~C performance with ease if types can be inferred, and fortunately you can build a general programming language around that paradigm and those trade offs. Eventually, they baptized the language to honor the one true queen of algorithms.

Rust comes from a different place: Some years ago in Mozilla, Graydon Hoare and the team got fed up with systems programming and the C/C++ tool chain. They were working on a language that allowed for programmers to be productive in low-level systems, harness concurrency performance without the foot-bazookas, and avoid errors during run time. At first they had different systems for handling the previous problems, until the team pieced together that an ownership system, with a borrow checker at compile time, could kill 2 birds with one stone. Eventually, they named the language after the fungus.

Recap: Julians were sick of unreusable code, niche DSLs and hacky polymorphism. With multiple dispatch as the central design feature they solved those problems. Rustaceans were sick of the C/C++ minefields and trying to keep all the invariants of large, error-prone codebases in their head. The idea of ownership and a borrow checker to know those errors at compile time and be data-race free is what's got them to where they are now.

There's obviously important details missing on both stories - you can get it from proper historians if you like, this is a brief and informal introduction. I will however, mention the other big Rustian idea of affine types when I talk about how they get a version of generic code we've come to know and love in Julia land. Spoiler alert: you can get generic code if you pay the price of a Julia runtime, and that's not something Rustaceans want. If you want generics at compile time, you have to "prove" to the compiler that your types are constrained to some extent, and you relay that information by tacking on affine types to your code.

That's enough of an intro, here's the table of contents.

  1. From Julia to Rust ๐Ÿ—บ
  2. Why Rust? ๐Ÿคท
  3. Informal introductions - tales of two languages ๐Ÿ“š
  4. Handy learning materials ๐ŸŽ๐Ÿ›
  5. What does generic Rustian code look like? ๐Ÿ”
  6. Rustian projects of interest ๐Ÿฅ‡
  7. Optimization walkthroughs ๐Ÿƒ
  8. Papercuts and sharp edges โœ‚
  9. Things I wish I'd known earlier ๐Ÿ‘“
  10. Appreciation of Rust things ๐Ÿฆ€
  11. What Rust can bring to Julia โš’
  12. Acknowledgments ๐Ÿ™Œ๐Ÿป

Handy learning materials ๐ŸŽ๐Ÿ›

If for some reason you've already decided that learning Rust is a worthy endeavour, here's my list of resources to learn. I think they are a good resource to follow in approximate order, but use whatever works, and if it doesn't, skip it.


Alright, so you're set up to go on a learning journey. What's Rust look like anyway when compared to Julia?

What does generic Rustian code look like? ๐Ÿ”

We love composability and multiple dispatch, so let's look at a short example of how to get the good ol' Julia bang-for-buck, with a 1D point:

import Base: +
struct Point{T<:Real}
    val::T
end

+(x::Point{T}, y::Point{T}) where T<:Real = Point{T}(x.val + y.val)
a = Point{Int32}(1)
b = Point{Int32}(2)
a + b # works
c = Point{Float32}(1.0)
d = Point{Float32}(2.0)
c + d # Also works!

So, in Julia land, how do I get generic code?

I make sure to not use any explicit types and let the dispatch system do the rest. You use functions like zero(...), eltype(...). With the dispatches, I add them to the appropriate subtype with where T<:Foo. If I define the appropriate methods, the others get composed atop of them , so I don't need to define += once I've defined +. Duck type all the way - when something errors at runtime because I forgot a case (like the fact there's no type promotion rules above) I just write a function per call I missed and keep hacking on.

Setup a simple type hierarchy, define some functions on your types without using them explicitly, profit from not rewriting all the code, plug and chug as you run into errors or perf hits, look at docstrings in the REPL to help you out. Happy life.

Let's look at the rust example:

use std::ops::Add;

#[derive(Clone, Copy, Debug, PartialEq)]
struct Point<T> {
    val: T
}

impl<T: Add<Output = T>> Add for Point<T> {
    type Output = Self;
    
    fn add(self, b: Self) -> Self::Output {
        Self { val: self.val + b.val }
    }
}

fn main() {
    let a = Point::<i32>{val: 1};
    let b = Point::<i32>{val: 2};
    
    let c = Point::<f32>{val: 1.0};

    println!("{:?}", a + b);
    println!("{:?}", c == c);
}

In Rust Land, how do I get a similar generic code?

I worked on like half of this code and then had to look it up. You can run it in the Rust Playground here. Avid readers will notice the following:

  1. Damn, that's a lot of boilerplate. ๐Ÿ˜ฃ

  2. To get generics, you need a struct for your type, an impl<T> $TRAIT for Point<T> block where the add function is defined, and type annotations like Self::Output, Add<Output = T>.

  3. There's a sort of "name spacing" with the turbo fish operator: ::<this one!>. We don't get functions that can share names but differ in behaviour. Bummer. (We get this in Julia with some nicer outer constructors, but I think it takes from the thrust of the argument.)

  4. The println! function is different - it's a macro, and it runs at parse time, also like Julia's macros. The chars inside the {:?} signal that we want debug printing, that we got above with the #[derive(Debug)]. Rust doesn't know how to print new structs if you don't define it, which, as Lyndon White points out, is one of the problems solved by multiple dispatch .

  5. Oh, those #[things(above_the_struct)] are also macros. I still don't know how they're different, but they seem to affect how the compiler interacts with the crate too. Since some traits (like the ones for copying or printing) are so boilerplate heavy and predictable, you can get some behaviour for "free" if you add the right #[derive(...)] stuff in the declaration. That's how the c == c works actually, it's due to the PartialEq.

The main workflow feels like this:

Slap a <T> in front of your struct and the fields you want it to be generic over. Look up the functions needed for each trait in the documentation. Setup a brief test case. Doesn't compile? See what rustc says and try and tack it on some traits; maybe you missed an affine type with impl<T: Foo> or the Self::Output - the compiler guides you through patching up your code. If you're asking for some generic behaviour, the compiler will complain and you'll have to add another trait implementation so that it is damn sure you're allowed to continue.

I also chose a particularly easy example: there's no associated data (like a string) in my Point<T>, so I don't need to prove to the compiler that my data doesn't outlive its uses - those are lifetimes, and they can get hairy, fast, but you'll run into them eventually. I also don't know how easily you could handle multiple generic types and the compile time penalties associated with them.

There's more syntax up front compared to Julia, and not just because we're writing library code here. Pythonistas can pick up Julia within a few hours and be productive. Rust has a lot more surface area to cover in learning the language: references, traits, impls, enums, lifetimes, pattern matching with match, macros, cargo flags for configuration, ownership and borrowing, Send and Sync...

Whodathunkit, Garbage Collectors let you worry about other things for a small runtime price. They might not be right for every use case but they're a solid investment.


Rustian projects of interest ๐Ÿฅ‡

There's a steep wall to climb when starting out with Rust - however, they've nailed the user experience for learning tough stuff. I think it was Esteban Kuber who said something along the lines of "We weren't missing a sufficiently smart compiler, but a more empathetic one".

Alright, so what's the view from the top look like? Like Julia, Rust is an incumbent in a crowded space, so how has it punched above it's weight against the established candidates?

Here's a list of all the projects that I've found particularly of note to Julians.

NB: It is non-trivial to compose rayon and tokio codes.

There's oodles more. Check out crates.io or lib.rs if you want to explore more (this is their community based JuliaHub equivalent).

I'll make a special note of evcxr, a Rust REPL. For now, I don't think it's profitable to use Rust with a REPL-like workflow. I'm too used to that in Julia, and that works well there, but I think there's a risk of digging yourself into a "Everything must be a REPL" mentality and cutting yourself off from learning opportunities. In Rust land, I don't mind doing as the Rustaceans do and learning to do things around a command line, navigating compiler errors and configuring flags and features for certain behaviours or deployment options. Since that's the package that I wanted to learn when I bought into Rust, I don't mind adapting into that mindset. I still wish them all the best and hope they can make the best possible Rust REPL - I'd love to be wrong on this down the road.


Optimization walkthroughs ๐Ÿƒ

If you want to dive deep into nitty gritty performance fundamentals, these are the best guides I found for explaining the tradeoffs, gotchas, mental model, and engineering for those tasty, tasty flops.

  1. COST paper: Maybe doesn't fit here but this is one of my favorite papers and everyone should read it.

  2. Comparing parallel Rust and C++

  3. Cheap tricks

  4. The Rust performance Book

  5. How to write Fast Rust code

  6. Fastware Workshope


Papercuts and sharp edges โœ‚

So Rust is "worth learning", but these are roadblocks that I faced and would warn others about to save them some grief.

(NB: After posting in HackerNews, Steve Klabnik has pointed out that the term region based is technical jargon in Programming Language Theory Literature as seen in section 2 of this paper on Cyclone's memory model.) (NB2: kibwen on HN pointed out that the term garbage collection implies dynamic memory management, whereas Rust's ownership system allows for lifetimes to be determined statically. In that sense, I'm wrong except for when users opt-in to using Rcs and the like. Glad to be corrected!)

let n = 100;
use rand::distributions::Standard;
use rand::prelude::*;
thread_rng().sample_iter(&Standard).take(n).collect()

(Oh, and rand isn't part of the stdlib so that's another papercut).

NB: u/Schnatsel has kindly pointed me towards cargo-asm. The interface is not as nice as @code_XXX, but I think I'm satisfied with this. Thanks a ton!

use std::fmt;

struct Point {
    x: i32,
    y: i32,
}

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

Things I wish I'd known earlier ๐Ÿ‘“

These could have helped me settle down into a more productive workflow sooner. Get a buddy that knows Rust to see you code to figure most of these out.

  1. If you can, avoid the examples with Strings and &str. Yes, they're a great motivation for systems people for all the gnarly use-after free and double-free and memory-leak examples - stick with numerical algorithms first, to get the gist of ownership, try and do some exercisms with iterators and Strings will be much easier to get after that. I don't think it's worth worrying about at first unless your target is systems.

  2. The preferred way of "whipping up an example in the REPL"/getting a MWE is to cargo new foo, mucking about and then cargo run --release or using the Rust Playground.

  3. If you're using an expansive test suite, cargo test --test-threads 8 and cargo test --quiet are helpful flags.

  4. For loops are not idiomatic in Rust - writing Fortran-ey code instead of iterators will lead to pain and slower loops. Spending time reading the examples in the iterator docs and the community solutions in the exercisms will help a lot.

  5. Just clone everything when you are starting out to get around most borrow checker shenanigans - worry about allocations later, Rust is usually fast enough.

  6. In the following function, the types of v and w are a slice of Int32s, which are different from Vec<32>. Read the Scientific Computing link above to see a nice table of the differences. An array like [f32; 4] includes the size as part of the type, a slice like [f32] does not. Diving into linear algebra means being familiar with many to_array(), to_slice(), from_array(), and from_slice() cases.

fn dot(v: &[i32], w: &[i32]) -> i32 {...}
  1. Including docs and tests in the same file as your implementation is idiomatic - even the IDEs support clicking on the #[test] line and having that run. Julia has a nice workflow for test driven development out-of-the-box - Rust gives you some of those guarantees by... conversing with the compiler.

  2. Rust has something similar to the concept of type piracy: they're called the orphan rules, as explained by this Michael Gattozzi post:

Recently at work I managed to hit the Orphan Rules implementing some things for an internal crate. Orphan Rules you say? These are ancient rules passed down from the before times (pre 1.0) that have to do with trait coherence. Mainly, if you and I both implement a trait from another crate on the same type in another crate and we compile the code, which implementation do we use?

  1. Rust is not as centralized with online communication as Julia is around Slack/Zulip/Discourse. Their version of #appreciation channels is to go on twitter and tell @ekuber what a joy the compilers errors are. There's tons of people on their Discord, and everywhere.


Appreciation of Rust things ๐Ÿฆ€

These are things the Rust people have nailed down.

  1. Ferris the crab is too cute.

  2. Rust people take uwu-ification very, VERY seriously. The uwu project uses SIMD to uwu-ify strings for great artistic value. Julia and Rust both draw me because they make me feel more powerful when I code with them than I think I should be.

  3. Governance: The Rust foundation and strong community conduct codes. Given the blow ups that have happened with open source communities recently from short-sighted governance or hate reactionaries tanking projects, this is a welcome sight that will probably pay off for decades to come.

  4. Compiler error messages are second to none. Definitely check out clippy too and follow the hints. cargo fmt will also format all your crate so that Rust code is almost always a unified reading experience.

  5. Awesome mentors. This is a project maintained Jane Lusby and other volunteers. I've gotten world-class mentorship from kind, patient and friendly Rust folks. Shoutout to Jubilee for her great wisdom and patience and the rest of the stdsimd gang.

  6. They also poke the LLVM crowd to improve the compilation times, which is great.

  7. They're doc deployment system is unified, polished, and friendly. Inline docs and tests are also great.

  8. cargo is a joy compared to Make hell. Pkg is somewhat inspired by it, so that rocks.


What Rust can bring to Julia โš’

  1. A model of governance. The Rust community is at least 10x the size of Julia, and it's unclear that adding more hats to the same TruckFactorCritical people would help. That said, it'd be better to have those conversations sooner rather than later, and building bridges with Rust people seems wise in the long term. I don't think that Rust is the closest model to look up to given the other projects under the NumFocus umbrella that we can learn from, but I don't see what is lost from learning from them.

  2. Less vulnerable software in the world is a good thing. Oxidization is great! Sometimes. I don't think any Julia internals will oxidize in the short term, but it would be an interesting experiment to say the least.

  3. Error handling: Multiple dispatch may prove advantageous in this domain, and it hasn't been as much of a priority as it has in Rust. Perhaps that merits some careful rethinking for future Julia versions.

  4. Awesome Julia mentors, I think we need this.


Acknowledgments ๐Ÿ™Œ๐Ÿป