You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
nomicon/src/aliasing.md

5.2 KiB

Aliasing

First off, let's get some important caveats out of the way:

  • We will be using the broadest possible definition of aliasing for the sake of discussion. Rust's definition will probably be more restricted to factor in mutations and liveness.

  • We will be assuming a single-threaded, interrupt-free, execution. We will also be ignoring things like memory-mapped hardware. Rust assumes these things don't happen unless you tell it otherwise. For more details, see the Concurrency Chapter.

With that said, here's our working definition: variables and pointers alias if they refer to overlapping regions of memory.

Why Aliasing Matters

So why should we care about aliasing?

Consider this simple function:

fn compute(input: &u32, output: &mut u32) {
    if *input > 10 {
        *output = 1;
    }
    if *input > 5 {
        *output *= 2;
    }
    // remember that `output` will be `2` if `input > 10`
}

We would like to be able to optimize it to the following function:

fn compute(input: &u32, output: &mut u32) {
    let cached_input = *input; // keep `*input` in a register
    if cached_input > 10 {
        // If the input is greater than 10, the previous code would set the output to 1 and then double it,
        // resulting in an output of 2 (because `>10` implies `>5`).
        // Here, we avoid the double assignment and just set it directly to 2.
        *output = 2;
    } else if cached_input > 5 {
        *output *= 2;
    }
}

In Rust, this optimization should be sound. For almost any other language, it wouldn't be (barring global analysis). This is because the optimization relies on knowing that aliasing doesn't occur, which most languages are fairly liberal with. Specifically, we need to worry about function arguments that make input and output overlap, such as compute(&x, &mut x).

With that input, we could get this execution:

                    //  input ==  output == 0xabad1dea
                    // *input == *output == 20
if *input > 10 {    // true  (*input == 20)
    *output = 1;    // also overwrites *input, because they are the same
}
if *input > 5 {     // false (*input == 1)
    *output *= 2;
}
                    // *input == *output == 1

Our optimized function would produce *output == 2 for this input, so the correctness of our optimization relies on this input being impossible.

In Rust we know this input should be impossible because &mut isn't allowed to be aliased. So we can safely reject its possibility and perform this optimization. In most other languages, this input would be entirely possible, and must be considered.

This is why alias analysis is important: it lets the compiler perform useful optimizations! Some examples:

  • keeping values in registers by proving no pointers access the value's memory
  • eliminating reads by proving some memory hasn't been written to since last we read it
  • eliminating writes by proving some memory is never read before the next write to it
  • moving or reordering reads and writes by proving they don't depend on each other

These optimizations also tend to prove the soundness of bigger optimizations such as loop vectorization, constant propagation, and dead code elimination.

In the previous example, we used the fact that &mut u32 can't be aliased to prove that writes to *output can't possibly affect *input. This lets us cache *input in a register, eliminating a read.

By caching this read, we knew that the write in the > 10 branch couldn't affect whether we take the > 5 branch, allowing us to also eliminate a read-modify-write (doubling *output) when *input > 10.

The key thing to remember about alias analysis is that writes are the primary hazard for optimizations. That is, the only thing that prevents us from moving a read to any other part of the program is the possibility of us re-ordering it with a write to the same location.

For instance, we have no concern for aliasing in the following modified version of our function, because we've moved the only write to *output to the very end of our function. This allows us to freely reorder the reads of *input that occur before it:

fn compute(input: &u32, output: &mut u32) {
    let mut temp = *output;
    if *input > 10 {
        temp = 1;
    }
    if *input > 5 {
        temp *= 2;
    }
    *output = temp;
}

We're still relying on alias analysis to assume that input doesn't alias temp, but the proof is much simpler: the value of a local variable can't be aliased by things that existed before it was declared. This is an assumption every language freely makes, and so this version of the function could be optimized the way we want in any language.

This is why the definition of "alias" that Rust will use likely involves some notion of liveness and mutation: we don't actually care if aliasing occurs if there aren't any actual writes to memory happening.

Of course, a full aliasing model for Rust must also take into consideration things like function calls (which may mutate things we don't see), raw pointers (which have no aliasing requirements on their own), and UnsafeCell (which lets the referent of an & be mutated).