[ANN] Automatic borrow checking at the compiler level, with BorrowChecker.Auto

image

Presenting… BorrowChecker.Auto!

Happy to share BorrowChecker.jl’s Auto layer, which comes with a macro BorrowChecker.@auto, that forms an automatic best-effort Rust-like borrow checker that runs directly on Julia’s SSA-form IR without the need for extra syntax.

For example:

julia> import BorrowChecker

julia> BorrowChecker.@auto function f()
           x = [1, 2, 3]
           y = x
           push!(x, 4)
           return y
       end
f (generic function with 1 method)

julia> f()  # errors
ERROR: BorrowCheckError for specialization Tuple{typeof(f)}

  method: f() @ Main REPL[7]:1

  [1] stmt#7: cannot perform write: value is aliased by another live binding      at REPL[7]:4
        2         x = [1, 2, 3]
        3         y = x
      > 4         push!(x, 4)
        5         return y
        6     end

      stmt: Main.push!(%5, 4)

The goal of this macro is a drop-in tripwire for existing Julia code: flag surprising aliasing / escape patterns during development, without changing program behavior when valid.

@auto is designed to catch two big classes of hard-to-debug issues, loosely inspired by Rust’s ownership and lifetime model:

  • Aliasing violations :prohibited:: : mutating a value while another live binding may observe that mutation.
  • Escapes / “moves” :prohibited:: storing a mutable value somewhere that outlives the current scope (e.g. a global cache / a field / a container), then continuing to reference it locally.

Avoiding these issues sometimes requires refactoring or redesign, but this typically leads to safer, more robust code.

One nice difference here is that in Rust, you would need to explicitly indicate mut to declare something is mutable, whereas here, mutability is inferred! This is done by analysing the SSA-form IR operations (for example, a Core.setfield call in the SSA-form IR would trigger this). The library of operations it need to register is surprisingly small because of working at this abstraction level (this level of analysis was inspired by Mooncake which does something similar for AD).

When BorrowChecker.Auto cannot determine what a call does, it will be conservative (and may throw false positives) and might assume mutation/escapes.

How it works

When you write:

BorrowChecker.@auto function f(args...)
    # ...
end

the macro rewrites the function so that:

  1. On entry, it runs a borrow check for the current method specialization and caches the result.
  2. The checker asks Julia for the function’s typed compiler IR (the lowered form the compiler, after initial cleanup but before optimization).
  3. It walks that IR and tracks two key things:
    • Which bindings may refer to the same mutable object (aliasing).
    • Which operations write to a tracked object and/or cause it to escape (which gets treated like a move).
  4. When it sees an operation that would be illegal under Rust-like rules (e.g. “write while aliased”, or “use after escape”), it throws a BorrowCheckError with a source-level-ish diagnostic.

Aliasing Detection

BorrowChecker.jl’s @auto macro can detect when values are modified through aliased bindings, and throw an error:

julia> import BorrowChecker

julia> BorrowChecker.@auto function f()
           x = [1, 2, 3]
           y = x
           push!(x, 4)
           return y
       end
f (generic function with 1 method)

julia> f()  # errors

This will generate a helpful error pointing out the location of the borrow check violation, and the statement that violated the rule:

ERROR: BorrowCheckError for specialization Tuple{typeof(f)}

  method: f() @ Main REPL[7]:1

  [1] stmt#7: cannot perform write: value is aliased by another live binding      at REPL[7]:4
        2         x = [1, 2, 3]
        3         y = x
      > 4         push!(x, 4)
        5         return y
        6     end

      stmt: Main.push!(%5, 4)

To fix it, simply copy the value, which will avoid the error:

julia> BorrowChecker.@auto function f()
           x = [1, 2, 3]
           y = copy(x)
           push!(x, 4)
           return y
       end
f (generic function with 1 method)

julia> f()
3-element Vector{Int64}:
 1
 2
 3

You could equivalently just return the x value, which would end the lifetime of y early, and so permit x to be mutated again:

julia> BorrowChecker.@auto function f()
           x = [1, 2, 3]
           y = x            # no copy, but its ok, since we dont use y again!
           push!(x, 4)
           return x
       end
f (generic function with 1 method)

julia> f()
3-element Vector{Int64}:
 1
 2
 3

Escape Detection

Much like Rust’s ownership model, BorrowChecker.jl’s @auto macro attempts to infer when values escape their scope (moved/consumed) and throw an error if they are used afterwards.

julia> const CACHE = Dict()
Dict{Any, Any}()

julia> cache(x) = (CACHE[x] = 1; nothing)
foo (generic function with 1 method)

julia> BorrowChecker.@auto function bar()
           x = [1, 2]
           cache(x)
           return x
       end
bar (generic function with 1 method)

julia> bar()  # errors

This generates the following error:

ERROR: BorrowCheckError for specialization Tuple{typeof(bar)}

  method: bar() @ Main REPL[13]:1

  [1] stmt#6: value escapes/consumed by unknown call; it (or an alias) is used later      at REPL[13]:3
        1     BorrowChecker.@auto function bar()
        2         x = [1, 2]
      > 3         cache(x)
        4         return x
        5     end

      stmt: Main.cache(%5)

Why is this an error? Because x was stored as a key in the cache, but is mutable externally. Furthermore, it is returned by bar! This is a violation of borrowing rules. Once the value gets stored in the cache, its ownership is transferred to the cache, and is no longer accessible by bar. So this code would illegal.

How can we fix it? We have two options. The first is we can copy the value before storing it:

julia> BorrowChecker.@auto function bar()
           x = [1, 2]
           cache(copy(x))
           return x
       end
bar (generic function with 1 method)

julia> bar()  # ok

We no longer have access to the object created by copy(x), so the borrow check passes.
Alternatively, we can use immutable objects, which are safe to pass around:

julia> BorrowChecker.@auto function bar()
           x = (1, 2)
           cache(x)
           return x
       end
bar (generic function with 1 method)

julia> bar()  # ok

No copies needed for immutables.


This macro is still highly experimental. There will be false positives and false negatives. Please share those when you find them! The more examples in the test suite, the better.

Interested to hear what people think.

9 Likes

Very cool! Impressive that you can hack the compiler to do this kind of stuff (both impressive that you have those skills, and that the compiler enables it).

Could you go full Rust mode and put @auto on an entire module?

1 Like

That is the best logo ever.

3 Likes

One option would be to do something like DispatchDoctor.jl and propagate the macro through all functions in the library. Or (ii) we could do something like AllocCheck.jl and recursively analyze all functions in the stack and assert no borrow violations anywhere.

I did try (ii) but it seemed slow when I did it. I think I just need to figure out how to cache things better though.

Also if anybody has any ideas for how to make the borrow check evaporate after it passes, that would be useful. I’m not sure if there’s a way to actually incorporate the check into the compiler passes or not (GPUCompiler.jl?).

1 Like