Package for Rust-like borrow checker in Julia?

Rust’s semantics were designed specifically to make lifetimes and aliasing easier and always possible to figure out. But there can be parallels to Julia’s different semantics, even if not one-to-one or always. Rust’s sense of mutation encompasses both variable reassignments and mutations of our objects. If a mutable object in Julia is never mutated in a statically known lifetime, that’s like an immutable Rust variable; if a variable or argument is assigned to such an object and never reassigned, that’s like an immutable reference. Whether it can be an immutable reference on the level of Rust source isn’t the point, it’s that there’s the same information that can be statically analyzed and exploited for optimizations.

Oh I see what you mean. Yeah that would definitely be nice if it’s somehow possible! But doing that within Julia’s more flexible model seems an order of magnitude harder than if we just provide explicit annotations on what is expected to be mutable/immutable and borrowed/moved.

I mean, even in Rust, lifetime relationships can be very complex, which is why lifetime annotations are necessary. This would likely be even harder in Julia, given the lack of ownership rules, so figuring it all out automatically would be a huge challenge.

At the same time though I guess such escape analysis doesn’t have to capture all potential errors. Doing any part of it automatically would be awesome.

Just a side remark since you mentioned that “performance is really a secondary consideration”: Another, imho more high-level approach for avoiding race-conditions/mutations errors, is not using mutation in the first place. Rob Pike has put it nicely as

Don’t communicate by sharing memory; share memory by communicating.

With Channels and Accessors, Julia is already well suited for concurrent programming without mutable data. E.g., it’s my impression that I’m about 10% faster programming in Clojure than Python as one never even has to think about ownership/safety of mutation etc.

3 Likes

The good(?) news is we don’t need all of it for working code. Rust’s rules aren’t just for preventing race conditions (probably too late to mention they correctly call it data races but that’s what we mean here, some Rust examples showing the critical differences). They have other benefits like automatic memory management without a garbage collector to recheck unknown lifetimes; one of the goals of Julia’s escape analysis is to possibly exploit a statically knowable lifetime of a mutable object to save the GC some work. If data races are the problem, we don’t need to be so strict about ownership everywhere, definitely not in a single thread.

I would if I could! I’ve actually tried this in the past. Some of the tree and graph structures I manipulate are mutated so frequently and in such complex ways, that it becomes be prohibitively expensive to recreate every time I make a modification, even despite using stack memory. It is much faster (and the implementation is much simpler) to modify in-place. But when I can I make things immutable.

If it wasn’t clear (or perhaps I said something naive) – I actually use rust quite a bit already, e.g., GitHub - MilesCranmer/rip2: A safe and ergonomic alternative to rm is one of the packages I work on. My experience using rust is why I want to get similar checks in Julia. Of course I can always try to stick to the same rules, but the borrow checker knows best.

(Haven’t read every thread response)
Perhaps you could already move towards your goal by simply using a sort of linter. Assignments can just be marked through a comment, e.g.

x = rand(5)  # bcheck: owned

and then a linter will check whether this “compiles”?

1 Like

Alright I hacked together an example – BorrowChecker.jl:

It is very manual; it basically requires the user to use the macros to do anything. Maybe in the future that code_escape could do parts of this automatically though.

Note that you can always unwrap owned values with @take (which trips moved to true and thus the owned value can’t be used anymore). Instead, you should use @ref inside a @lifetime block – those create immutable references to the object (by “immutable” I just mean that setproperty! will error - not truly immutable).

And there are very few methods implemented for Owned* and Borrowed* apart from a few array methods, so don’t expect it to work apart from some simple example code:

Internally it’s calling this function request_value(var, Val{mode}) for mode in :read and :write. Basically you need to specify whether a method is mutating or not for the auto-unpacking to work.


BorrowChecker.jl

:warning: Warning: This is a highly experimental demonstration of Rust-like ownership semantics in Julia. It is not intended for production use and should not be depended upon in any project. The borrow checking is performed at runtime, not compile time.

This package demonstrates Rust-like ownership and borrowing semantics in Julia through a macro-based system that performs runtime checks.

Available Macros

Ownership

  • @own x = value: Create a new owned immutable value
  • @own_mut x = value: Create a new owned mutable value
  • @move new = old: Transfer ownership from one variable to another, invalidating the old variable
  • @take var: Unwrap an owned value to pass ownership to an external function

References and Lifetimes

  • @lifetime lt begin ... end: Create a scope for references whose lifetimes lt are the duration of the block
  • @ref var = value in lt: Create an immutable reference to owned value value and assign it to var within the given lifetime scope lt
  • @ref_mut var = value in lt: Create a mutable reference to owned mutable value value and assign it to var within the given lifetime scope lt

Assignment

  • @set x = value: Assign a new value to an existing owned mutable variable

Property Access

For owned values and references, property access follows these rules:

  • Use @take x to extract the wrapped value of x, exiting the BorrowChecker.jl system and allowing direct access to the value. x loses ownership and can’t be used after this.
  • You can use getproperty and setproperty! normally on owned values and references. Ownership will be transferred when necessary, and errors will be thrown when determined by ownership rules.

Examples

Ownership

First, let’s look at basic ownership.

julia> using BorrowChecker

julia> @own x = 1
Owned{Int64}(1)

This is meant to emulate let x = 42 in Rust.
We can compare it to objects, and the borrow checker will
confirm that we can read it:

julia> x == 1
true

We could also do this by unpacking the value, which moves
ownership:

julia> (@take x) == 1
true

julia> x
[moved]

julia> x == 2
ERROR: Cannot use value: value has been moved

Now, let’s look at a mutable value:

julia> @own_mut y = 1
OwnedMut{Int64}(1)

We change the contents of this variable using @set:

julia> @set y = 2
OwnedMut{Int64}(2)

Note that we can’t do this with immutable values:

julia> @own x = 1;

julia> @set x = 2
ERROR: Cannot assign to immutable

This also works with arrays:

julia> @own array = [1, 2, 3]
Owned{Vector{Int64}}([1, 2, 3])

julia> push!(array, 4)
ERROR: Cannot write to immutable

julia> @own_mut array = [1, 2, 3]
OwnedMut{Vector{Int64}}([1, 2, 3])

julia> push!(array, 4)
OwnedMut{Vector{Int64}}([1, 2, 3, 4])

Just like with immutable values, we can move ownership:

julia> @move array2 = array
Owned{Vector{Int64}}([1, 2, 3, 4])

julia> array
[moved]

julia> array[1] = 5
ERROR: Cannot use value: value has been moved

julia> array2[1] = 5; # works!

Borrowing

References must be created within a @lifetime block. Let’s look at
immutable references first:

julia> @own_mut data = [1, 2, 3];

julia> @lifetime lt begin
           @ref ref = data in lt
           ref
       end
Borrowed{Vector{Int64},OwnedMut{Vector{Int64}}}([1, 2, 3])

Once we have created the reference ref, we are no longer allowed to modify
data until the lifetime lt ends. This helps prevent data races.
After the lifetime ends, we can edit data again:

julia> data[1] = 4; data
OwnedMut{Vector{Int64}}([4, 2, 3])

Note that we can have multiple immutable references at once:

julia> @lifetime lt begin
           @ref ref1 = data in lt
           @ref ref2 = data in lt
           ref1 == ref2
       end
true

For mutable references, we can only have one at a time:

julia> @lifetime lt begin
           @ref_mut mut_ref = data in lt
           @ref_mut mut_ref2 = data in lt
       end
ERROR: Cannot create mutable reference: value is already mutably borrowed

And we can’t mix mutable and immutable references:

julia> @lifetime lt begin
           @ref ref = data in lt
           @ref_mut mut_ref = data in lt
       end
ERROR: Cannot create mutable reference: value is immutably borrowed

We can also use references to temporarily borrow values in functions:

julia> function borrow_vector(v::Borrowed)  # Signature confirms we only need immutable references
           @assert v == [1, 2, 3]
       end;

julia> @own vec = [1, 2, 3]
Owned{Vector{Int64}}([1, 2, 3])

julia> @lifetime lt begin
           borrow_vector(@ref d = vec in lt)  # Immutable borrow
       end

julia> vec
Owned{Vector{Int64}}([1, 2, 3])
17 Likes

This is a very disingenuous statement for the actual discussion you’re having here. Obviously everyone would want this. But the point that is being made though is that Julia’s immutable semantics already guarantee this! Because the semantics of a Julia immutable does not have references, any instance can be acted upon independently and you are guaranteed they are not sharing a reference on the heap, because there are no references to them!

So the discussion of “don’t you want to add this language feature so you can make sure Julia immutable ‘safe’, do you not care about this safety?”, that’s very insincere. Instead, does Rust not care about safety? If it really wanted safer memory semantics, it could just adopt Julia’s semantics, where if you have immutables with no references and no GC then the user doesn’t need to do any borrow checker stuff because you are guaranteed by the design of the language to never be able to borrow an immutable! How is that not safer? In fact, Rust’s semantics then allows a user to opt out of the borrow checker on an immutable, isn’t that less safe than semantics that don’t allow an opt out?

So while I agree that there could be nice ownership semantics on mutable variables that could improve the ease of proving multithreaded safety for code, throwing immutable under the same bus and saying “do you not care about safety?” makes no sense. Mutables could use such a feature, but the Julia semantics for immutables are so different that they simply cannot have this problem.

But this does highlight something else.

If anything, the proposal on Julia immutables could go the other direction. Julia immutables you can easily argue are too safe, i.e. the user does not have any way to make a reference to them and any memory semantics on them are not exposed to the user. This makes it not possible to do some manual memory optimizations you can do in a language like Rust or C++. For almost all instances this doesn’t really come up, but I have seen this come up in three instances:

  1. The implementation static arrays
  2. The implementation of BLAS kernels
  3. The implementation of kernels for non-graph symbolic types (i.e. SymEngine internals)

The reason is because in these instances you do want to be able to create static vectors and reuse references to them while making sure that no copies are made. In these cases, having the ability to make a reference to an immutable would be a good feature, so that you can semantically guarantee that you have no copies. In Julia’s current design, you cannot make that guarantee because the non-copy semantics are only achieved through compiler optimizations, and while the copies are removed in many instances, there are a decent set of cases in which the compiler cannot prove and remove the copy.

But if you were to add references to immutables in Julia, you’d have to make some substantial changes to the language. The semantics of whether objects are on the heap vs stack would then need to be more exposed to the user, and then you’d open yourself to all of these issues, where you’d then need to have ways for the user to tell the system about ownership etc. etc., and then I agree that the right semantics for handling that would be close to Rust.

However, I’m not 100% convinced that this added complexity makes sense on immutables for the set of use cases that can allow for this larger set of manual optimizations: similar work in escape analysis seems to be getting pretty good at removing the copies in almost all instances I’d want to use it for, so it’s not very clear that the complexity is actual helpful in this instance.

So, let’s split the discussion of immutable semantics away from this. Is just different. I agree that it would be nice on mutable objects, but the discussion on the immutables seems to confusing a few very separate topics and is not grounded in the Julia semantics of immutables.

8 Likes

I think you might be mixing up “immutable reference” and “reference to an immutable”? I am speaking about the former, not the latter. I am not proposing anything regarding Julia immutables.

That would be insincere, but it’s not actually what I’m talking about at all… Immutable references to mutable objects are a completely different thing

1 Like

The general concept of allowing mutability with a more fine-grained control is nice! (although I agree with immutability being preferable whenever possible)

I wonder if you’ve seen earlier attempts from a somewhat different angle, GitHub - tkf/Mutabilities.jl and GitHub - andyferris/Freeze.jl: Experimental API for mutable/immutable collections? They are arguably easier to understand for those without a rust background, but not sure how your package compares to them in terms of capabilities in the Julia context.

There’s also Base.Experimental.Const btw.

1 Like

I didn’t see these, thanks. Nice finds. Mutabilities in particular looks quite nice.

Comment on Mutabilities.jl semantics:

Just to note one subtle difference — the Rust behavior here would be to throw an error when you modify x, because there is an active immutable reference (z). This is why we actually need to create these separate Owned/OwnedMut types.

For example

let mut x = 5;
let y = &x;     // Immutable reference
x += 1;         // Error: Cannot modify `x` because it is borrowed as immutable

I think analogous behavior should happen in the BorrowChecker.jl experiment above (hopefully, if I did it right). Basically an OwnedMut object counts how many references there are to it, and refuses :write operations like setindex! whenever immutable_references > 0.

So what we’d expect (on mobile so not sure if this actually works yet!) is:

> @own_mut x = [1, 2, 3]

> @lifetime l begin
      @ref r = x in l
      x[1] = 5  # ERROR
  end

Which happens because the @ref macro would increment .immutable_references in x. And once the lifetime block finishes, all the references would be reset and you could modify x again. Basically it’s a hacky way to get a similar concept to lifetimes and immutable references in Julia.

Looking at your package, I think I kinda understand the semantics (have no rust knowledge whatsoever), but don’t really get when exactly one wants to use it in practice. I imagine you have some examples that are highly error-prone without borrow checking (and cannot easily use immutables) but become safe applying borrowchecker?

1 Like

Yeah exactly. Rust’s borrow checker makes it logically impossible to have a standard thread race. Here’s what Rust guarantees:

  1. At any given time, you can have either:
    • Any number of immutable references (&T), or
    • Exactly one mutable reference (&mut T)
  2. References cannot outlive the data they refer to (lifetime rules)

And note that you can create immutable references to a mutable object. Just can’t modify that object for the lifetime of those references.

So in my case, my data structure is too large to use immutables. But current Julia semantics = “a mutable anywhere is a mutable everywhere”. Which for multithreaded code can lead to footguns, if I accidentally mutate where I’m not supposed to.

just to say, I really appreciate your approach here and in DispatchDoctor @MilesCranmer to improve the developer experience with iteratively designed library-level tools! I think sometimes folks here forget that perfect is the enemy of good, and that approaches like this widen the design space and the set of developers who can contribute, and can provide information for future even-better solutions.

15 Likes

Can’t really separate that in a language where references don’t have a mutability property. Where Rust would idiomatically use only immutable variables and references for a type, Julia likely uses an immutable type. The different static-variable/runtime-instance models just don’t make for neat parallels in how similar information is conveyed to LLVM, which makes it difficult for anyone to implement Rust semantics for Julia.

For example, whether a(n immutable) value is passed “by value or reference” to a method isn’t a concept in Julia like in Rust; the analogous effect on loading and writing data is decided on by the compiler, which simplifies method calls at the cost of less control over storage in containers and composite types. There is no immutable version of Ref{T}, and statically unknown lifetimes means there’s little benefit in having one instead of using Ref; Julia’s mutable objects are more about sharing data than mutation, and immutable references do just that.

This at least I did gather, so I don’t think you were trying to ignore or downplay how immutables are used in Julia. Speaking of lacking neat parallels, there is the reverse way to look at this. Earlier I described Rust’s types as “potentially mutable” in the Julia sense, but it is potentially immutable as well. While it may seem simpler to build things on a mutable (like Owned{T}) and decide on the mutability in the wrappers, we could hypothetically use immutables and use Ref to make them references. Mutability in the Rust sense would be unrelated to Ref’s mutability and could be accomplished by optimizing Accessors-like transformation of reassignments (incrementing a counter in Julia can use immutable types x += 1, but in Rust that’s a mutable variable let mut x).

Yeah! This is how OwnedMut{T} works

1 Like

Also interesting to see that at one point @Keno drafted a design for freeze and melt in Julia itself: Julep/Very WIP - Heap allocated immutable arrays and compiler support by Keno · Pull Request #31630 · JuliaLang/julia · GitHub which might offer a notion of “immutable reference.”

Although I think it’s important for immutable references to transfer immutability (of the reference) recursively to all properties or elements - so not just the array, but all elements should be frozen as well. I try to do this for Borrowed{T,O} in that it will return another Borrowed with the same owner. Likewise if you getproperty on an Owned object, if the result is mutable (i.e., not a simple concrete Float64), it automatically moves ownership, effectively preventing use of the original variable (which again is the same as how rust does it). But there’s probably a bunch of sharp edges since the language itself doesn’t have such features yet.

1 Like

Pure speculation here, but I wonder if there is some way to design an immutable version of your data structure and the associated update methods such that they compile to efficient code.

It would be nice, but every time I try, it’s slower. Stack memory allocation is faster, but it’s not zero. So if you are needing to recreate a 1,000-node binary tree in entirety, versus mutating a single node, even a 500x speed improvement from stack memory wouldn’t be enough.

Stack memory is great for smaller objects though.

Immutable types don’t mean stack allocation, and mutable types don’t mean heap allocation, though that’s the general implementation. We don’t have any more semantic control over whether things get allocated on a stack or heap than whether immutables are passed by value or reference to methods. Escape analysis is in part working towards inference of static lifetimes of fixed-sized mutables when possible so they can be stack allocated, and an excessively large immutable can be put on the heap, though I have no idea if or what the cutoff is.

I mentioned this earlier but it’s possible for what is semantically instantiation and reassignment of a fixed-size container or composite type in Julia to be compiled to modifying a specific field in-place. That usually happens after the composite type is picked apart and loaded in parts anyway. To do this more often like reassignments x += 1 though, something like Accessors would have to be used to skip constructors, and special syntax or intrinsics could help the compiler recognize the intention. For now, getting the field, reassigning that in whatever loop, then reinstantiating the composite type after is preferable.

Things without fixed sizes like binary trees shouldn’t go on the stack anyway, and the performance of doing the same things wouldn’t be different whereever you do it in working memory.