Blog post: Rust vs Julia in scientific computing

I have to admit that I got a feeling it could be much more difficult then Julia and the adventure could be indeed time consuming, however, frustrating …? This is surprising.

I started learning C++ recently, with no prior experience with static languages, and I’m so far finding it surprisingly straightforward. Julia experience is tremendously helpful.

How hard is Rust compared to C++?

2 Likes

Learning Rust is very frustrating because until you understand how Rust manages memory and the borrow checker, you will be fighting the compiler.

For instance, this piece of code doesn’t compile.

let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1);
1 Like

I’ve heard the opposite, but it’s very dependent on where people come from, I suppose. A couple other blogs from dual-citizen Julian-Rustaceans love Rust, they acknowledge that its different model of variables and memory (closer to C++'s) would be difficult for Julians (more like Python’s) to learn, and even C++ people griped about the borrow-checking, but they say it’s not worse than any other unfamiliar thing, and it only gets that reputation because it’s new to so many people. I haven’t jumped into Rust myself so I can’t speak from personal experience.

The borrow checker isn’t that hard to grasp, at least on a broad level imo. We need a GC for mutable instances because we can’t guarantee we didn’t leave some references around. The analogy breaks down a bit because mutability is more a property of variables and references, but Rust’s ownership guarantees that references don’t outlive their origin and that you can’t have >1 mutable reference to the same thing at a time (which is the reason why mutable types exist in Julia). So it makes sense why they don’t need a GC and don’t run into data races.

3 Likes

I tried to jump into Rust. I didn’t get stuck on the Borrow Checker so much as the mechanics of unwrapping Option Types. The concept is simple enough, but know what options there are, the syntax for catching them, and what do in chains of methods with many different options really threw me. It wasn’t any harder than any new thing, but Rust has many compounding new things that can make it hard to focus on learning any one of them. I didn’t even make it to impl blocks and the like because it was just too much for me at the time.

I have nothing but respect for Rust, to be clear. But its learning curve is definitely steeper than just a memory management model.

6 Likes

Thanks to a consistent focus on ergonomics, Rust has become considerably easier to use over the last few years. Companies building large teams of Rust users report that the typical onboarding time for a Rust engineer is around 3-6 months. Once folks learn Rust, they typically love it. Even so, many people report a sense of high “cognitive overhead” in using it, and “learning curve” remains the most common reason not to use Rust. The fact is that, even after you learn how the Rust borrow checker works, there remain a lot of “small details” that you have to get just right to get your Rust program to compile.

3 Likes

Learning Julia is easier than learning Rust for sure. Learning to write highly optimized Julia code teaches you a lot of things that are very helpful while learning Rust.

Having to use composition instead of inheritance in Julia does make getting into Rust also easier.

I would recommend learning Julia first if you are in the scientific computing field and then starting with THE Rust book. Not only because it makes learning Rust easier, but because knowing Julia is also a very good skill in scientific computing.

It takes some weeks to about 2 months to be confident in writing Rust code. You could compare it a bit to the results from Google.

There where two talks about quantum computing in Rust in the conference. You can check them out on YouTube: https://www.youtube.com/@ScientificComputinginRust

I don’t have experience in machine learning in Rust, but this website could be a good resource: https://www.arewelearningyet.com/

For me personally, it was not frustrating, but you have to unlearn some patterns from other languages. Maybe it was not that frustrating to me because I did not directly start to code in Rust and read the first chapters of the book first. I would definitely recommend against starting to code before going through the first chapters.

Compared to C++, Rust is in my opinion easier. Most importantly, there is no SEGFAULT which is very hard to debug in C++. The tooling in Rust is also much better than in C++.

8 Likes

Interesting benchmark, but given your analysis, I have this suggested edit to benchmark the steps separately:

function pushtest(v, m)
  # the m push!es should take almost all the time
  sizehint!(v, m) # does nothing if already m capacity
  for i in 1:m  push!(v, i)  end
  empty!(v)  # prevent memory leak, should keep capacity
end

function settest(v, m)
  for i in 1:m  v[i] = i     end
  v
end

function benchmarkalloc2()
  m = 2^14 # not repeated n times
  v = @btime sizehint!(Int[], $m)
  @btime pushtest($v, $m)
  v = @btime Vector{Int64}(undef, $m)
  @btime settest($v, $m)
  nothing
end

And the result:

julia> benchmarkalloc2() # Julia v1.8.3, if it matters
  382.187 ns (2 allocations: 128.12 KiB)
  80.779 μs (0 allocations: 0 bytes)
  394.334 ns (2 allocations: 128.05 KiB)
  1.422 μs (0 allocations: 0 bytes)

julia> benchmark_alloc() # original benchmark on my machine
  23.232 ms (512 allocations: 32.03 MiB)
  2.890 ms (512 allocations: 32.01 MiB)

So the array allocation steps are comparable (I don’t think the dozen ns is a real advantage, probably just OS jitter), and the push!ing to a whole vector is really ~57x slower than setindex!ing the whole vector. The original benchmark mixing them together kind of averaged that difference to only ~8x. Of course replicate this on your own version and machine for consistent numbers.

This seems like a good opportunity to optimize the push! implementation, and it seems a much larger magnitude than the 30% speedup the ccall issue could address, though that was on a much older version so who knows what it’ll be now.

I do have a question about the Rust benchmark, is there any way you could display allocations over the course of the main call as well? Is it at all possible that the Rust compiler is smart enough to lift that allocation out of the loop and just sets the length to 0 each iteration?

1 Like

Just some remarks while reading through your blog post:

  1. Fearless concurrency: Yes, that is indeed a neat showcase of Rust’s ownership model. Originally invented to handle memory, but also helpful in other cases.
    In case of concurrency, there are other abstractions which prevent data races though. Imho, channels support many reusable and composable concurrency patterns. Fortunately, Julia has channels out of the box even though they appear a bit under-documented as compared to Clojure or Go.

  2. Static analysis: A well-designed static type system certainly helps in refactoring, i.e., giving that nice feeling that everything still works. So does tooling and testing though. Interestingly, many refactoring techniques have been explored and developed in Smalltalk which – compared to today’s languages – still has superior tooling, e.g., for automatically adding member variables or changing call signatures, despite being dynamically typed. Admittedly, tooling is still somewhat lacking in Julia.

  3. Error handling: Here, static languages seem to lean towards tagged unions, i.e., sum types, nowadays whereas many dynamically typed languages prefer untagged unions and nil-punning. Both have their merits and trade-offs, but in the end writing safe code requires to design for and think of errors one-way or the other: On the one hand, sum types force you to do so. On the other hand, they can be annoying to chain through long computations and have you fall into the habit to just pass None along. Thus, in any case, writing safe programs requires discipline in any language. Also keep in mind, that Julia brings a runtime and errors will not necessarily end the program, but might be fixed interactively – see here for a Lisp legend.

  4. Interfaces: Here, I mostly agree. Rust as well as other statically typed languages distinguish between parametric, i.e., functions with one implementation and possibly parameterized, trait-bound arguments, and ad-hoc polymorphism, i.e., methods of traits which can be implemented differently for different types, though and only the latter functionalities can be extended. Julia is more flexible in this respect in that all functions are generic and support multiple implementations. Guess that LinearAlgebra or ChainRules might be a good place to look for examples where this enables easier or more performant implementations by defining methods for handling specific type combinations.

  5. Performance: Would you consider Box<dyn SomeTrait> as a performance footgun in Rust? That type seems similar to a non-concrete type in Julia, e.g., Ref{Real}.

Thanks for writing this up. It’s always nice to contrast and discuss programming languages: Flame wars for life :wink:

5 Likes

I did use Valgrind’s DHAT without the outer loop with n:

# With an empty main function
$ valgrind --tool=dhat ./target/release/benchmark_alloc
…
==360== Total:     2,157 bytes in 10 blocks
…

# With m = 2^14
$ valgrind --tool=dhat ./target/release/benchmark_alloc
…
==365== Total:     133,229 bytes in 11 blocks
…

# Abstract the allocations of the empty main function
$ math 133229 - 2157
131072

# The expected size for m = 2^14 and 8 bytes for usize
$ math 8 x 2^14
131072

The vector is allocated.

But I did improve measuring the time for Rust in the blog post.

To clarify, I wanted to know if a fresh vector is allocated per iteration, for a total of n times over the program, or if it allocates it once and reuses it per iteration as an optimization. If you mean you’re using DHAT on versions of your benchmark code without the 0..n loop, you would not have captured that detail.

Oh, I thought you wanted to check if the compiler does not optimize out the whole vector because it is not read from.

I did run DHAT on the code currently in the blog post (with outer loop having n = 2^8) and I get the following:

==115== Total:     33,557,613 bytes in 267 blocks

Which is more than 2^8 * 2^14 * 8 = 33,554,432.

I think the advantage of Rust is that it keeps track of the capacity internally:

Where Julia just outsources dealing with the capacity into C:

1 Like

It seems certain that a new vector is allocated each iteration in Rust as well.

Yes, Arrays and some other Core types are implemented in C, and many things are not exposed to the Julia side. For example, fieldnames(Vector{Int}) gives (), but we know that there must be metadata. I too had tracked the difference between push! and setindex! to jl_array_grow_end and jl_array_grow_at_end, the code being in array.c. As for the performance difference, I’m guessing it’s not ccall overhead but actually the algorithm.

The following is probably not relevant enough to scientific computing to be in your blog, and nobody would alter internal code on their own. But IIRC Rust bootstrapped its way to being written almost entirely in Rust down to the lowest level. I don’t know how different internal and probably unsafe Rust is, but when Rustaceans dig deep they don’t run into a different language. That’s a point in Rust’s favor, if the comparison was for core language developers.

There is a discussion on how to pull Arrays to the Julia side to wrap a buffer type on the C side, but I can’t find the link right now. In any case, I don’t think there’s a plan to put this into action soon, not much need for an overhaul instead of small fixes.

Thanks for this intro and summary @Mo8it! Just to clarify, I did not say frustrating. Actually, I’ve always been finding it, I mean coding, as quite a lot of fun. Will take a look at Rust, hope to learn at least a bit about it in practice.

1 Like

No. Although it is known to have an overhead by using the heap and performing dynamic dispatch, using it is explicit and therefore will not be an accident or a surprise. Something being a “footgun” means that it happens without an intention; “unintentionally behaves self-destructive”, says wiktionary.

In contrast to Julia silently propagating an abstract type like Any, Rust will not put an instance inside a Box unless you explicitly tell it to.

1 Like

On top of this, there actually is a Box footgun in Julia:

julia> function foo()
         x = 1
         x = 2
         bar(y) = x,y # closure captures x as a field
       end
foo (generic function with 1 method)

julia> foo().x
Core.Box(2)

Box acts like a featureless Ref{Any}, and it’s there because the captured variable is reassigned, so the closure’s field type is made mutable. For now, the compiler can only infer Core.Box for a mutable field, and since it represents the same captured variable x, x is also type-unstable, even though it would be stable without the closure. This issue can’t be improved in all cases, say if bar(y) = x=y, then foo() could never infer bar.x to be anything specific because y could be anything. However, there are many cases like this that surprise people because the compiler is otherwise much better than them at type inference.

1 Like

9 posts were split to a new topic: Is accessing an undef array undefined behavior?

Note that the conditions-restarts kind of error handling from Common Lisp is not available in Julia.

Errors are caught, or not, and then they end the program. But you can’t go back to the inside of the stack to fix them.

2 Likes

As I recall the last word on this was the proposal linked in Buffer types for array backend by Tokazama · Pull Request #48728 · JuliaLang/julia · GitHub. At the time it felt like there was a heightened sense of urgency given how many issues this would unblock, but perhaps someone in the know can provide an update since there’s been no news since?

Another thing not mentioned here but germaine to the performance discussion is that dynamic dispatch in Julia will box all arguments to a method, whereas in Rust I believe it’s closer to vtable call semantics in C++. Tying this back to the discussion about array stuff, it seems to be mostly an artifact of how the C side of the Julia runtime and its calling conventions are designed, so in theory there’s more performance to be found for dynamic dispatch in Julia. That said, I only know of highly experimental compiler proposals which have tried to tackle this angle thus far.

Fair enough. In the end it’s probably just preference between, Julia, dynamic with optional static dispatch or, Rust, with static dispatch and explicit dynamic … Guess I made my choice long ago :wink: