Comparison of Rust to Julia for scientific computing?

This is really my hope too. Julia still has a number of unresolved tradeoffs, but they are not inherent any more than the performance/productivity tradeoff that Julia has already shattered are. They may be fixable but they are not easy to fix.

The first tradeoff that Julia really needs to tackle is the JIT latency one. Lots of work has been done here but I think what will really “fix” it will be having a tiered JIT where an interpreted version of code starts executing immediately until a fast compiled version of that code is available. Work on that is already starting to happen (lazy LLVM JIT being the first step). It will be accomplished incrementally in the sense that even when it’s mostly solved it probably still won’t include some of the fancier tiered JIT features like on-stack replacement of code running in the interpreter with the same code running optimized. In the fullness of time, if it seems beneficial, all the bells and whistles can be added.

The next tradeoff is the one here: providing a way to move gradually from loose, interactively developed code that may not be fully correct in all corner cases to code that can be shown to be correct and not error in various ways. Rust includes rules to ensure that kind of correctness in its type system and compiler, but that’s not the only way to do it, as JET shows. My hope here is that JET will be extended and eventually shipped with Julia and can be applied to code to show that it is correct with respect to various desirable properties in an incremental way.

72 Likes

I think you would normally use join for this, instead of reduce. Without testing, I suspect it’s faster too.

2 Likes

Yes, join is at least 2x faster for this string length, I updated the code. What I meant was that I wrote the Julia version off the top of my head without even thinking about it and got a correct result quite fast. The workfolw of scientists/researches tends to be like this; start with a quick idea, write a bunch of code lines to test it, modify some parts, iterate … I’m curious as to how Rust might help with this. Also for the second example, look at how they apply 4 functions in a row to a thread_rng object just to mean randstring (not to mention that scary &Alphanumeric). Rust might be a good competetor to say C++, but Julia is totally on the other side for that matter.

4 Likes

There is a whole section dedicated to Math there, which may give a picture on how some scientific code may look like in Rust:

No doubt Julia syntax is cleaner, at least. For instance multiplying to matrices looks like:

use ndarray::arr2;

fn main() {
    let a = arr2(&[[1, 2, 3],
                   [4, 5, 6]]);

    let b = arr2(&[[6, 3],
                   [5, 2],
                   [4, 1]]);

    println!("{}", a.dot(&b));
}

There is heavy use of the ndrarray package for these operations, and rust developers mention that it feels like using numpy.

1 Like

Just a small addition from a heavy Julia user that has also done some amount of rust tinkering. On the topic of one-off scripts, I find the rust build system to be pretty annoying. Like, as soon as you want one dependency, you have to make your hierarchy of folders, download all the packages, and invariably add 10GiB of stuff to ~/.cargo (not always a huge exaggeration). So whenever I would want to write something to invert a 4x4 matrix now need to create a project, and a build file, and all this infrastructure. I am not familiar of any way to use raw rustc and compile a program from a single source file if it has any dependencies. That really makes prototyping more annoying, at least for me.

In comparison, even following the best practices for Julia and having a separate Project.toml file for each new project/script/whatever is pretty painless. I have alias jp="julia --project" in my ~/.bashrc file and hardly notice the burden of creating it once and then just running jp my_script.jl .... And as far as I can tell this method still gets to enjoy all of the very precise dependency management.

I’m sure there are many good reasons for rust doing things the way they do. Super cool language, all that. But at least for the kind of prototyping I do, the cargo-based workflow for that is definitely less pleasant. I in part write this because I’d love to be corrected by somebody about how an easier method for one-off scripts with deps can be built, though, so please correct me if my experience is out-of-date.

12 Likes

@cgeoga We are getting off-topic, but you can install https://github.com/killercup/cargo-edit (for cargo add) and do something like

cargo new --bin hello_world; cd hello_world
cargo add foo bar
cargo run

I do not understand your complaint over downloading stuff. Those dependencies need to come from somewhere o.O

3 Likes

I’ve been thinking about this for a while. I think where I am right now is that sometimes I would happily give up some of the dynamism of Julia, if I got some more static type checking and IDE support in exchange. And for other projects, I would not want that at all. So one option might also just be a “strict” or “typed” mode that is purely implemented as a linter and takes away certain things that are legal in standard Julia and marks them as errors. In exchange, a whole lot of advanced IDE features could light up and the linter might be able to find errors that typically a static compiler would find. But I am wondering whether all of that could actually be implemented entirely outside of Julia itself, i.e. as a linter with a special mode.

22 Likes

Haskell, a very statically typed language, has a nice REPL. Haskell’s type inference is extremely robust so type annotations can be omitted during interactive exploration and the compiler figures out which types are needed.

Rust has some existing unofficial REPLs and an RFC for an official one (no timeline).

4 Likes

This is my hope as well, but will it not require additional language features that may be breaking? Reasoning as follows:

JET has often been casted as a solution but that, IIUC, requires a concrete entrypoint, which defeats the purpose of Julia’s amazing polymorphism. An analysis on one call may not accurately represent a useful space of possible concrete programs that could materialize when composing with that entrypoint.

How do new generation static languages with type and function polymorphism guarantee performance without requiring a concretely typed entrypoint or call site? (rust, templated C++, Dex, Swift). Obviously there are restrictions on this polymorphism, but what are they and can they be opted into (if people want for their type or function) in a dynamic language like Julia without too many difficult design challenges or undesirable consequences?

The reason why this seems to me like a fundamental design tradeoff right now (unless there’s some way to opt into invariants) is that even if we could prove correctness without access to concrete call sites the per the way @ckfinite is investigating, it won’t guarantee type stable code because of the way Julia abstract types and functions are, well, maximally abstract. According to ben, that requires another type of analysis… but then what of all the other types of optimization and code transforms we want? They don’t just fall out of type stable code, but have different notions of optimizability and rewriting which don’t fit Julia’s pre- DL era semantics.

Does that make sense at all?

1 Like

I am not claiming rust is more terse than Julia, but to be fair, you are rather comparing (std)libraries than the core languages. Note that rust‘s std is minimal by design.

In rust you could also cheat like

use random_string::generate;
let alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
// or if you feel like it
let alphabet = String::from_utf8(
        (b'a'..=b'z')
            .chain(b'A'..=b'Z')
            .chain(b'0'..=b'9')
            .collect(),
    )
    .unwrap();

generate(30, alphabet)

and the second example can be implemented as

use rand;
use rand::distributions::{Alphanumeric, DistString};
Alphanumeric.sample_string(&mut rand::thread_rng(), 30)

There are probably other ways.

8 Likes

C++ is like Julia. Templates aren’t checked except for instantiations.
But having a few instantiations around your code will lead to those particular instantiations being checked.
Other possible types might not work.

Rust requires you specify traits for the arguments. If your generic function is

function foo(a::A, b::B)
    c = bar(a, b)
    d = c - 3
    d / a
end

you have to specify traits for A and B that make bar defined to return an object with a trait such that -(::typeof(c), ::Int) returns an object with a trait such that /(::typeof(d), ::A) is defined.
Otherwise, you can’t compile the function. The IDE will give you helpful warnings telling you what is missing.
Thus, foo will have to work for anything with traits A and B.
If you define a new type and want to add trait A, the compiler will throw errors (telling you what is missing) until everything that trait requires is there.

A can be a sum of many different traits, which makes it easier.

5 Likes

This was later corrected.

I strongly agree with the desire for a tiered JIT.
That is #1 on my compiler wish list.

No. This is a powerful system of dispatch that would be great to have in Julia, but it is still like Julia in terms of (not) checking.
Nothing is checked, other than the checks you add yourself.

It is awesome that you can define a concept A that says foo(::A) returns Int, but then you can proceed to write the body of your function that dispatches on concept A without any checks or guarantees that A supports any of the body’s contents.

# concept saying that `foo` must be defined on an `A`
# and return an `Int`.
# `floatmemaybe` must also be defined on `A`, too. `floatmemaybe(::A)`
# can return anything that can be converted to `Float64`
concept A = foo(A)::Int && floatmemaybe(A)::convert(Float64,..)

function bar(a::A)
    # foo must be defined, and must return an `Int`,
    # because `a` satisfies concept A
    b = foo(a)
    # but we didn't say anything about buz!!!
    # Still, calling `buz` is okay in C++. Not in Rust. 
    buz(a, b)
end

In C++ you can dispatch templates on what methods a type supports or basically any other property you can come up with, but that’s dispatch. It is also cool that this works with any number of arguments, i.e. you can dispatch on combinations of satisfying different concepts for each argument – it just has to happen at compile time (while Julia’s multiple dispatch semantically gets resolved at runtime).

But the template body itself is not checked.

3 Likes

I am not sure, I understand your argument correctly. Maybe this article helps: No, dynamic type systems are not inherently more open ?

tl;dr Statically typed languages with a proper™ type system can be as vague or concrete as necessary. In any case, you need to know something about a functions argument’s structure/properties to do something useful with it. Dynamic languages just happen to be implicit in their assumptions and tend to push checks into the runtime, whereas statically typed languages are explicit.

My summary probably butchered the original article, so I urge you to read it instead.

1 Like

See my excerpts from Jeff’s thesis here @yonjuni : State of machine learning in Julia - #28 by Akatz

Are you referring to something akin to Python‘s type hints that together with static linters like mypy introduce a gradual type system into python?

1 Like

Maybe? I haven’t really thought it through, but it could be something like that. In a very vague sense, it just seems that Julia has ample syntax for type annotations/hints already, and I think if we just disallowed a few things, one could more or less treat it as a pretty strongly typed language already?

2 Likes

(Sorry a bit off-topic below) I have just tried searching the net, and possibly this kind of things might help…? (I have no experience with it so not very sure it fits the purpose, though)

The above thing seems similar to “dub” + single file in D:

1 Like

Yes, that is one approach, but it does not provide general compile-time bounds checking. It restricts what you can do so that the kinds of array access operations that cannot be checked simply cannot be used.

You could certainly argue that this is fine, even desirable, since it can be handled by including appropriate checks in your code. But then, as Jeff points out, that user code amounts to a runtime check. And it has performance implications along very similar lines to Rust’s runtime bounds checking. If you’re in a situation where you’re willing to write unsafe code because you’re sure or pretty sure nothing will go wrong (maybe you’re relying on a sophisticated math theorem which is not readily communicated to the compiler), and the performance differences matter to you, then C++ lets you do that. That would not be possible in a safe language.

In other words, there are some aspects of the safety-performance tradeoff which are fundamental, even though in many cases safety can actually improve performance, depending on the details of the problem (and the irreducible costs are in any case not very large).

1 Like

Is this because the proofs are compile time constants?