Blog post: Rust vs Julia in scientific computing

Well, for a beehive-poking topic, I find that people are behaving relatively nicely :slight_smile:

14 Likes

There is another side here. With good static analysis, it is simple and safe to make big changes to a codebase. Without good static analysis, it becomes uncertain, and big changes may be too complex or risky to attempt.

11 Likes

That’s completely true. Indeed, there is probably some complexity crossover point where Rust becomes easier to maintain than Julia. That’s why we need better static analysis in Julia.

17 Likes

When I think of scientific computing, as a scientist, first image that appears in my mind is a scientist -not a computer scientist but any other- using a computer. Scientists aim to solve a specific problem in their domain and they sometimes need to analyze data or do calculations to reach their goal. Writing code to do these things is a necessity, which a lot of them would avoid if it was possible. Therefore, as the second answer from @jlperla points out, people use Julia beacuse “it looks clean and like math”. Meaning that quite a few scientists do not even want to bother to learn programming -or do not have the time or resources-, they just want to get their results.

I remember the time that first time I had to deal with a code in another language than MATLAB -it was Java-, I just did not get why I needed to create a class and than an object to just multiply two matrices. I did not even know or care about what they meant, they just stood on my way.

I believe that everything that you point out are correct. However, I also read that Rust has quite a learning curve, even for the experienced programmers. For the scientists who need to run their code a few hundred times and just get their results, it might be a little too much of a time investment.

16 Likes

And the code! Show the rust code with all of the safety to someone used to solving ODEs in julia/matlab/python to see which matches the math better.

3 Likes

This is mostly tangential to the discussion, but I sell Julia to my colleagues with a different kind of “Two Language Problem.”

I am a data scientist/engineer which means the bulk of my work is in Python. But it isn’t actually in Python, it’s in Pandas. Or if compute demands grow too much, it’s in Dask. And the number of times I’ve written code, and then a reviewer says “This would be faster with (insert DataFrame method)” is wild. And there is a different approach you should take if you need Dask. Also, you’re gonna need to learn some numpy syntax. Oh, and .astype() doesn’t handle nan in this case and there’s not much you can do about it. Or whatever.

In Julia, I write the process using the same basic programming primitives that I think in (loops, control flow, etc.)

Most data scientists/analysts/etc will never write a line of C/C++/Rust/Zig. They can either get speed with an R/Python library or they deal with slow code. So most of us are facing the “half dozen library mini-languages” problem.

Julia solves this for me. There might be a package that has a clever fast approach to the thing I’m doing, but if I don’t know about it, I can still write a plenty fast version. And with multiple dispatch, most mini-languages (ie DataFrames) use the same function names I’d use myself.

So for us normies who are just trying to do some math from a CSV, Julia solves many two language problems.

30 Likes

That and how easy is to write unit tests. In Julia it is very easy, and almost a natural consequence of implementing every new function. I don’t know (really) how easy that is with Rust.

3 Likes

Ctrl-Fing “vtable” in that article only leads me to the section Understanding Dispatch > Issues > Efficiency, which says vtables are only usable for single dispatch, which has always been my understanding. The one sentence on runtime multiple dispatch there says it’s still not as simple or fast as runtime single dispatch. Which is why multiple dispatch is so often compile-time in Julia.

Julia has a middle ground of Union types that can employ some optimizations. Struct fields or array elements with a Union type can be stored inline if the component types are isbits (no pointers), I think up to 256 types. If code deals with a small Unions of an argument’s types, the runtime dispatches are done with cheap conditional branching (up to 4) instead of the usual expensive way. However, this blogpost explains in great detail that Union types and sum types are fundamentally different, the short answer being that a sum type is 1 “wrapper” type with its instances while a Union Type describes multiple types with their separate instances. Which is probably why there are a couple sum types packages: SumTypes.jl, Unityper.jl.

I am not familiar with Rust beyond language design articles, so I do wonder, how are functions “dispatched” (if that’s even the right word) on sum types, and is it made efficient somehow? Using Julia pseudocode in an example use case, say that I have a sum type Sif = Sum{Int, Float64} and I want to add their instances Sif(1) + Sif(2.3) with the same methods as a 1 + 2.3 call.

While Rust’s lazy element type is cool, always failing to push the 1 afterward is considered undesirable in Julia, especially when the array ends up in the global scope for interactivity. Even in cases where the array constructor promotes given Number inputs to 1 concrete type, pushing other Numbers afterward attempts a convert step, which doesn’t always fail. It’s also easy to generically specify the eltype, so the debate on how the automatic eltype determination should work is not very useful.

3 Likes

Thank you @Olivier_Merchiers, this means a lot to me :heart:

Thanks for the welcoming, but I am not new to the Julia forum. I did join about 1.5 years ago :slight_smile:

I did warn about my bias towards Rust in the blog post and I did expect obvious bias towards Julia here. But I did not expect some people in this forum to act offended. I also did not expect some replies indirectly assuming that I don’t have enough knowledge in both languages to be allowed to compare them.

A reply tells me indirectly that I am not qualified for talks at scientific conferences.
How dare I think that Julia is planning a version 2.0 after seeing that 2.0 milestone?
It is like having a public blueprint for a wall, then blaming people that think that a wall is planned and saying “No one has any intention of building a wall, [at least not anytime soon]” (Allusion to Walter Ulbricht on the Berlin wall) :joy:
Anyway, the concrete phrasing about 2.0 is corrected in the blog post now.

I am awaiting editing proposals now to neutralize my Rust bias :smiley: (but I do not promise to approve all. At the end, it is my blog post and I did mark the conclusion as “My personal conclusion”)


@jakobnissen Thanks for the lovely feedback and sharing the post on Hacker News :smiling_face_with_three_hearts:

I think that we agree on many points, especially the ones that you did mention in the first part of your reply. Thanks a lot for that detailed reply :smiley:

I agree. At least my productivity in Rust got much better the longer I work with it, but it does not match the productivity that Julia provides for small projects.
But when a project gets bigger, then exactly this is the case:

The “complexity crossover” :star:


If we restrict the two-language problem to only performance, then yes, Julia does solve that problem because I would not rewrite Julia code for performance since the difference would not be worth it.
If I know before starting the project that I would need maximum performance, I would still start in Rust and do some minor prototyping in Julia (for example generating matrices, validating a formula, etc.). But if the code is already written in Julia, then it is a waste of time to rewrite it only for performance.

But since we both agree on the “complexity crossover” that you mentioned, I can justify rewriting Julia code for better “project scalability”.

My understanding of the two-language problem is not restricted to performance and therefore under my broader definition, any reason that would justify rewriting Julia code into another language in scientific computing declares the two-language problem unsolved.

Would you like me to add this distinction to my conclusion in the blog post? Would that be more fair for Julia? :slight_smile:

Data races are a subset of race conditions. And I do warn about dead locks in the blog post. I do not claim that Rust prevents all race conditions. I said that it makes data races impossible as @sijo explained. It sounds “sexy” and is accurate. It is even proven in this paper:

I did use @report_opt but since it only generates warnings, I did not want to mention them. There were also warnings about println. Should I not trust println then?

I will not check each unclear warning each time I extend my program. last.pop() (from the blog post) should be an error, not one uncertain warning under many distracting ones.

Yes, I 100% agree.
I don’t even know why this point is under your major points where we don’t agree. I did say that REPLs in Rust will not reach the level of the Julia REPL and I use Julia often in the REPL because of this point :wink:

6 Likes

I don’t think you should take any reply personally, but you can figure out why people tend to answer emotionally sometimes, particularly related to specific claims that indirectly to imply they’re doing a ill informed choice by using Julia. Specifically:

  1. there are huge packages and integrated ecosystems in Julia, very successful ones. And they are fully written in Julia, from interfaces to high performance codes. Thus Julia can be used effectively for very large code bases, solving the two language problem in every of it’s interpretations. Rust is great for large code bases, but claiming that Julia is not suited for the task is false.

  2. saying that you can’t get maximum performance from Julia is way too generic. In what exactly? Projects like Octavian.jl match the performance of the most optimized numerical code that arguably exists. Microbechmarks are mostly BS, we agree.

In both cases people use Julia because some qualities of the language make it practical and scalable to write code with it. And we want of course the language to attract more users, so simplistic statements that appear to imply that the language is only good as a prototyping (scripting :roll_eyes:) framework, particularly in forums of high visibility, tend to be refuted vehemently.

14 Likes

Thanks for responding.

A fair comparison for Base.Threads functionality would be using std::thread directly. I think there’s a big difference between an external package and a built in standard library in terms of how quickly the interfaces can be iterated and how experimental they can be.

You technically do not need rayon to use threads in Rust. Here’s a not very good parallel version using only the standard library in Rust. Additional work would involve partition the work among n threads.

use std::thread;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::sync::atomic::AtomicU64;

fn main() {
    let counter = Arc::new(AtomicU64::new(0));

    let handles: Vec<_> = (0..10_000)
        .map(|_| {
            let counter = Arc::clone(&counter);
            thread::spawn(move || {
                counter.fetch_add(1, Ordering::SeqCst);
            })
        })
        .collect();

    for handle in handles {
        handle.join().expect("Thread panic!");
    }

    println!("The value of i: {}", counter.load(Ordering::SeqCst));
}

Julia has several parallel libraries. A blog post on this topic was recently made highlighting many of those.

The impression from your blog post is that Rust has cool packages to hide away the complexity in multithreading but Julia does not. The equivalent of rayon in Julia seems to be something more like ThreadsX.jl or Transducers.jl.

5 Likes

That was worded harshly, but I do agree with the sentiment that solid background research is a responsibility for public presentations, and confusions are not acceptable there, no matter how understandable you feel those were. It would be an especially bad look to take questions and assert publicly, like you did here earlier, that Julia’s multiple dispatch “is done dynamically using a vtable” based on a misreading of a wikipedia article; that error is easily spotted with very little knowledge, either on what vtables are or that Julia’s compiler dispatches function calls. It’s good you’re engaging with communities and correcting yourself, including in your existing writings for your audience.

I don’t think you should, there’s nothing unusual or wrong with favoring Rust and presenting its merits. It’s only fair to present the merits of Julia accurately in a good-faith comparison with Rust. For example, you asserted that you need to write a for loop for maximum performance in Julia, whereas Rust’s iterators have zero-cost abstractions and optimize with SIMD and boundschecking elision. It’s not that simple, Julia’s map/broadcasting mechanism uses @inbounds @simd for, and there are many packages for optimizing for-loops or iterators. You also presented undef preallocation as a trap for undefined behavior, but you don’t mention the more usual array comprehension, fill, zeros, ones, etc. for initializing arrays, or how undef preallocation is useful for eliding the extra work of default initialization right before you initialize in code that won’t fit in an array comprehension. Inaccurate information, whether on Julia or anything else, is unnecessary and even detrimental if your goal is to promote Rust for scientific computing.

11 Likes

std::thread in Rust is low level and not comparable with @threads in Julia because you don’t have to manually spawn and join threads, maintain a thread pool etc.

Since the standard library is “thin” by design, it is very common in Rust to use crates for rather simple things like generating random number with rand and work-stealing multi-threading with a thread pool using rayon.

Such “core crates” are actually maintained by people very close to the standard library and compiler development. The coauthor of rayon for example is Niko Matsakis, the team leader in the language team.

What you suggest would imply that I have to implement a random number generator for a fair comparison of generating random numbers in Rust and Julia.


@Benny As you mentioned, fill, zeros and ones are not the same as preallocation with undef. The three functions have an overhead caused by overwriting the memory with the respecting value first. Plus, using undef is an official recommendation in a performance tip that I link to in the blog post. I don’t see how criticizing an official recommendation is inaccurate.

I would happily edit the blog post if the official recommendation is changed :slight_smile:

Even if it was not an official recommendation. Just having this “feature” in the language is a problem if we want to prevent undefined behavior in Julia. I would suggest marking it as deprecated to save the Julia community hours of horrible debugging of undefined behavior.

Criticizing an official recommendation is not inherently inaccurate, but you did so inaccurately. As your link shows, the recommendation is for cases where the values are fully initialized before any indexing, but you presented indexing an uninitialized array as commonplace. You then make the comparison to Rust’s vectors with 0 length but >0 allocated capacity, which is a completely different context from which the Julia recommendation addressed. In Julia, you would simply make a vector with 0 length, there’s no need to preallocate if you don’t have values yet. If you want to increase its allocated capacity without changing its semantic length, so that planned push!es don’t need to allocate more than once overall, you can use sizehint!. As I said before, solid background research is important if your goal is to compare how two tools address the same problems. You could instead promote Rust without comparison, there’s no need to make unhelpful basic errors.

7 Likes

I would make a similar argument about Julia Base. It’s frustratingly bare in some areas and quite difficult to add new functionality to it. With respect to Base.Threads.@threads, I have very low expectations of it.

This argument also applies to ThreadsX.jl and Tranducers.jl. The author, Takafumi Arakaki, @tkf, is a Postdoctoral Associate of the Julia Lab at MIT: Current members .

My suggestion is that if you are going to show “core” crates in Rust that you should compare to them to “core” packages in Julia. Admittedly, the concept of a “core” crate or package is a bit fuzzy.

There an important nuance here. Allocating uninitialized memory is not undefined behavior.

Here’s an example straight out of the Rust documentation. Note that it says this is not UB.

use std::mem::MaybeUninit;

// Create an explicitly uninitialized reference. The compiler knows that data inside
// a `MaybeUninit<T>` may be invalid, and hence this is not UB:
let mut x = MaybeUninit::<&i32>::uninit();

What is undefined behavior is attempting to read that memory before it has been initialized.

Zero initializing memory may not incur overhead in all circumstances, although Julia’s Base.zeros always does. One of my critiques of Julia is that zeros in Julia Base is implemented with an explicit memset rather than calloc. Depending on the operating system, libc implementation, and perhaps the amount of memory being allocated, calloc may not incur additional overhead because the OS or libc may already know that already zero initialized memory exists. New memory pages allocated to a process must be zeroed out as to not leak data from one process to another. I’ve elaborated on this in the discussion of ArrayAllocators.jl.

The history of the ArrayType{T}(undef, sz...) syntax may be of interest here. Basically, we wanted some indicator that something undefined was happening here. Some advocated for a shorter syntax such as ArrayType{T}(sz...), but it was felt that this did not sufficiently alert that user about what they were doing. The term undef was chosen over uninit or uninitialized. Essentially, the use of undef is meant to be regarded as similar to unsafe.

The performance recommendation you cite correctly uses this as it does initialize the memory before using it.

6 Likes

Would you like me to add this distinction to my conclusion in the blog post? Would that be more fair for Julia?

Yeah, that would be most reasonable, I think. I also hope that Julia will close the performance gap between it and Rust in the future, and I’m somewhat optimistic that that will happen. In my experience, the performance gap is not due to language design, but simply that functions implemented in Julia is full of small inefficiencies, whereas Rust prioritizes performance higher.

And I do warn about dead locks in the blog post. I do not claim that Rust prevents all race conditions. I said that it makes data races impossible as @sijo explained. It sounds “sexy” and is accurate.

Yes you are right. I see you even have a disclaimer attached to “fearless concurrency”, stating you can still have deadlocks. I was not reading carefully enough, I apologize.

I did use @report_opt but since it only generates warnings, I did not want to mention them. There were also warnings about println. Should I not trust println then?

You should not trust any code you haven’t tested, and that includes Rust code. Nonetheless, I agree that dynamic dispatch is currently too hard to avoid because IO operations and thread fetching is inherently type unstable. That is most unfortunate.
In my experience though, at least for medium-sized Julia programs (a few thousand LOC), these false positives tend to be few enough that one can manually review all type instabilities.
Also IMO, static checking in Julia still needs to be better in several ways:

  • There are too many type instabilities in Base
  • There are too many type instabilities in packages throughout the ecosystem
  • There is no good way of statically checking an entire package. Maybe that’s not possible in Julia.

Luckily, JET has improved a fair bit since it appeared about 2.5 years ago. I’m fairly optimistic it will continue to improve.

One more thing I want to comment on: In your blog post, you write:

On the other hand, if you want the highest performance in Julia, you have to write a for loop.

As I’m sure other people in the thread has told you already, that’s not really the case :wink: Iterators in Julia, just like in Rust, can automatically elide bounds checks, SIMD, and are zero-cost.

It sounds like we mostly agree, broadly. Perhaps the difference comes down to the kinds of programs we write. In my job, I tend to write packages that are used fairly generically (so code reuse matters), and where the requirements constantly shift. I also write a ton of data analysis one-time-off code. I don’t write a lot of application-like code.

I feel like at least 50% of the Rust vs Julia discussion comes down to the old dynamic vs static language discussion. Rust and C++ provide more guarantees, but Julia and Python is easier to write and iterate on. Rust doesn’t fundamentally change this calculus, although it does nudge the balance a little by providing more guarantees than C++ while (alledgedly, I have no C++ experience) being nicer to write. On the other hand, Julia also nudges the balance by being fully dynamic, while also statically analyzable.
The proponents of static languages - whether C++ or Rust - tend to place correctness as the top priority and be relatively less worried about programmer productivity. Some people, not you, but the most ardent proponents of static languages, even claim that one should always prefer static languages because the time saved by static checks outweighs any gain dynamicness brings. I think it’s safe to say that scientists and programmers historically have overwhelmingly “voted with their feet” and ignored this advice. There is a reason dynamic languages like Python and Javascript are the most popular languages, and I don’t really see Rust changing this picture.

12 Likes

It’s time to end this sidebar discussion on background credentials/research. We don’t gatekeep the ability to criticize the language here. The author is here actively engaging with the feedback and has been responsive to critiques. There’s no need to harp on subtle inaccuracies.

Thanks for your post @Mo8it; this is a great discussion.

42 Likes

Yeah I’d just like to echo with a thank you @Mo8it, this is a nice blogpost. I value some different things from what you value and I prefer some different things from what you prefer, so I come to different conclusions about some of this material but that’s okay.

There’s some technical quibbles I have but it looks like people are mostly addressing those in the thread so I’ll avoid dog-piling onto those issues.

All in all, Rust has a lot going for it and I’m glad people are using it. I’m pretty happy using Julia and for me it definitely solves the two-language problem in a very satisfying way, but this post is also a nice demonstration of some strong points for Rust.

25 Likes

Thanks a lot for your post initiating a nice discussion!

My experience is different. Trixi.jl, our Julia framework for adaptive high-order numerical simulations of hyperbolic PDEs is definitely not limited by the GC - but it’s something people fear quite often. Thus, we have included the time spent by the GC in our standard analysis output. You can check it for a 3D compressible fluid simulation of a turbulent Taylor-Green vortex yourself:

julia> using Pkg; Pkg.activate(temp = true); Pkg.add(["Trixi", "OrdinaryDiffEq"])

julia> using Trixi, OrdinaryDiffEq

julia> include(joinpath(examples_dir(), "tree_3d_dgsem", "elixir_euler_taylor_green_vortex.jl"))
...
────────────────────────────────────────────────────────────────────────────────────────────────────
 Simulation running 'CompressibleEulerEquations3D' with DGSEM(polydeg=3)
────────────────────────────────────────────────────────────────────────────────────────────────────
 #timesteps:               1198                run time:       2.58845727e+01 s
 Δt:             3.33868838e-03                └── GC time:    3.28885310e-02 s (0.127%)
 sim. time:      1.00000000e+01                time/DOF/rhs!:  1.25447603e-07 s
                                               PID:            1.32271348e-07 s
 #DOF:                    32768                alloc'd memory:        388.717 MiB
 #elements:                 512
...

This output tells you that the GC time is significantly less than one percent of the total runtime - including the amount of GC time spent collecting garbage from compilation after the simulation started.

17 Likes

If you require all Julia code to pass JET’s type checking, with no type instabilities or errors, then you’re essentially using Julia as a statically typed language, and you’ll be fighting JET, which is no more fun than fighting a built-in type checker in a statically typed language. I suspect that such an “almost statically typed” dialect of Julia will be adopted by only a minority of the community, and we won’t see a large part of the ecosystem being rewritten to be JET-compliant.

For now, it would be helpful if JET allowed users to turn off warnings / errors for individual functions like println rather than an entire module like Base.

4 Likes