cc @RalfJung, who asked me to write up my thoughts on a new aliasing model
I've spent a lot of time recently working on a new aliasing model, which I call Permissive Borrows. This post is meant to introduce the model. It's written vaguely in the style of an RFC, as I consider it to be a good style for conveying new ideas, but isn't a proper RFC or pre-RFC because it isn't suggesting any particular course of action (in particular, it does not discuss alternatives or prior art, things that should definitely be considered before making any decisions), so it just abruptly ends after explaining the idea and a couple of variations on it.
It is quite possible that this document contains mistakes, either due to things that I haven't considered, or due to a failure to correctly translate my thoughts into writing. It's also probable that I haven't picked the best way in which to explain my ideas, and it may be that a different way of stating things would be clearer.
Motivation
Rust's existing aliasing models, Stacked Borrows and Tree Borrows,
effectively separate pointers and references into two groups:
references &'a T / &'a mut T that borrow the memory they target
for the entire lifetime 'a (which starts and ends in the same stack
frame), and everything else. For references in the first group, they
provide a good level of optimization. But for references in the
second group, they do not provide much optimization help: such
references allow operations that would be undefined behavior in
Stacked/Tree Borrows if they were performed on shared or mutable
references, and thus need to be given exceptions or opt-outs (which
typically leave code that uses such references almost unoptimized).
Rust has a lot of types of references that don't quite fit into the
first group, but nonetheless have aliasing guarantees that should in
theory allow for a high level of optimization. In particular, many
types of reference act like &T or &mut T but do not have a known
lifetime, or have a lifetime that does not necessarily start and end
in the same stack frame:
-
Box<T>(lifetime ends when dropped, not at a specific point in time); -
Borrow types of
RefCell(lifetime can be ended early by dropping it, potentially in a different stack from from where it started); -
Anything that gets borrowed across an await point inside an
asyncfuture or other coroutine (you can poll the coroutine in one function, then when it halts at anawait, call another function that polls the coroutine, causing the borrowed value's lifetime to end in a different stack frame from where it started).
At present, these situations are handled either by not optimizing them
(e.g. the current compiler does not make use of aliasing assumptions
between the local variables of an async block), or using hacks to
allow the compiler to pretend a type is in the first group after all
(e.g. Box is optimized by pretending that an old and new allocation
point to different memory even if they have the same address, but this
model only works if memory allocations come from entirely outside the
Rust virtual machine, so it is incompatible with inlining the
allocator and is incompatible with custom allocators written in Rust).
Additionally, a coroutine can borrow from itself, without invalidating
an outside reference to the coroutine as a whole (meaning that a
coroutine's local variable can be simultaneously borrowed by two
unrelated references), and this situation was not envisaged by Stacked
Borrows or Tree Borrows and thus needs a special case to deal with it.
The currently planned solution is to use UnsafePinned as the special
case, but this has the side effect of removing almost all ability to
optimize futures (because UnsafePinned is permissive enough that the
compiler can no longer prove that the future is not stored inside its
own local variables, meaning that it has to be reloaded from memory
whenever the vairables are written).
Permissive Borrows is designed as an aliasing model that is capable of optimizing situations like this: it does not assume that lifetimes start and end in the same stack frame (except for types that are impossible to move or drop and thus necessarily survive throughout an entire function), and is even capable of justifying many optimizations on self-referential coroutines.
Guide-level explanation
Permissive Borrows is an aliasing model: set of rules that allow the compiler to assume that a program will not access memory in certain ways, so that it can optimize code based on that assumption. Aliasing models work by defining certain operations to be undefined behavior, so that optimizations that would be broken by those operations are valid (on the basis that if those operations do not occur, the optimization is valid, and if they do, the code has undefined behavior and so any optimization is valid).
In Permissive Borrows, the main requirement is that if you do something that contradicts the aliasing requirements of a reference, you do not use that reference again:
-
If you write to memory that was used by a shared reference (except inside
UnsafeCell), you must not use that shared reference again (because the write indicates that the shared reference's lifetime has ended); -
If you access memory that was used by an exclusive reference (except inside
UnsafePinned), using a reference that is not based on (e.g. reborrowed from) that exclusive reference, you must not use that exclusive reference again (because the conflicting access indicates that the exclusive reference's lifetime has ended); -
If you access memory through an exclusive reference, you must not use any reference or pointer based on that exclusive reference again. (This is because using an exclusive reference ends any reborrow of that exclusive reference.)
Permissive Borrows considers Box and &own to be types of exclusive
reference in addition to &mut.
What counts as a "use" of a reference depends on the exact type of the
reference. References that are expected to point to valid memory
(such as &T and Box<T>) are considered to use that memory from
when they are created to when their lifetime ends. For types like
&T and &mut T that cannot be moved or dropped and are provided as
a function/method argument, they are considered to still be in use
when the function ends, as there is no way to drop them early. For
types like Box<T> which can be dropped, their lifetime is considered
to end at their last use, because they might be dropped early to
repurpose the memory.
Some references are not necessarily expected to point to valid memory:
for types like MaybeDangling<&mut T> which might not be valid, the
rules are only enforced for references that are actually accessed
through. For references to coroutines (that might be borrowing values
from themselves), the reference is only considered to use any given
memory address between its first and last access to that address
(before the first access, the address might potentially have been
borrowed by the coroutine, but an access directly through the
reference proves that any such borrow has ended).
Reference-level explanation
(In this explanation, "provenance" refers specifically to the component of provenance used to track aliasing violations. This is not meant to preclude the possibilty that other types of provenance exist.)
As with most aliasing systems, Permissive Borrows tracks pointers and references using their provenance. Permissive Borrows assigns a new provenance to the result of almost any attempt to derive one pointer/reference from another (via reborrowing, reference-to-pointer conversion, or borrowing a dereference of a pointer); the exceptions are reborrowing a shared reference as another shared reference, and operations that convert a raw pointer directly (or via a place) into another raw pointer, which retain the same provenance. Each provenance has a parent (which is the provenance that it was created from), making it possible to determine a provenance's ancestors and descendants.
For the purpose of Permissive Borrows, reference/pointer-like types
that assert ownership (e.g. &own and Box) are considered to be
types of exclusive references, but different types from &mut;
"exclusive reference" refers to these types collectively, but &mut
only to mutable types in particular.
For each provenance, define that provenance's range as the set of
memory addresses that have ever been accessed through that provenance
and its descendants. The basic idea of Permissive Borrows is to
consider a pointer/reference R to be valid at a particular type
T if, since a specific time (specified below), nothing has happened
that would disable the ability of a pointer/reference with that type
and provenance to access any memory address in the provenance's range.
However, a provenance also becomes invalid when any of its ancestors
are used as an exclusive reference, and a pointer/reference with an
invalid provenance (or no provenance at all) is considered invalid at
every type.
The events that disable accesses are as follows:
-
Any write to a memory address disables the ability of shared references to access that address, unless the type of the shared reference considers the address to be inside
UnsafeCell; -
Any memory access through a pointer/access P disables the ability of exclusive references to access the same address, unless the exclusive reference has the same provenance as P or a provenance that is an ancestor of P's, or the type of the exclusive reference considers the address to be inside
UnsafePinned.
Depending on the type T of a reference R, there are two possible
time periods that might be checked for disabling accesses that might
invalidate R at type T:
-
If
Thas a target that is a coroutine that borrows or reborrows from itself,Ris only disabled by a disabling access to an addressAifR's provenance first accessedAprior to the time at which the disabling access occurred. -
In other cases,
Ris disabled by a disabling access to an addressAif the disabling access happened sinceR's provenance was created.
It is undefined behavior to:
-
Perform a memory access through a reference/pointer
Rat typeT, unlessRwould be valid at typeTimmediately after the access (this implies thatRmust also be valid at typeTimmediately before the access); -
Typed-move, typed-copy or reborrow a reference/pointer
Rat typeT, unless eitherRis valid at typeTand all memory it could access (based on its address and type) can be accessed without faulting, orRis contained in a wrapper likeMaybeDanglingorMaybeUninitthat is intended to allow the storage of invalid references; -
For references
Rthat are function/method arguments whose type inherently prevents them being moved or dropped (&Tor&mut T, either directly as the type of the argument or as a field of aCopystructorCopyenum), do anything during the execution of that function/method (and functions/methods it calls into) that would make a reborrow ofRat that type undefined behaviour under rule 4.
For example, suppose a &T (where T has no special aliasing
properties) is reborrowed from a &mut T, with the &T used to read
the value at an address, then the &mut T is later used to read the
value at the same address. Based on rule 2, the &mut T is valid as
long as all accesses to that address since it was created have been
through that reference and its descendants. Although the address was
added to the range of the &mut T due to a read through a &T, the
type of the original read does not matter when determining whether the
&mut T is valid (e.g. it would be possible to write to the address
through the &mut T between the two reads, and the reference would
still be valid).
The rules above are not quite enough to allow the desired optimizations due to the possibility that weird aliasing situations could be created via transmutes or untyped copies. There is an additional rule to rule out these situations:
- Whenever an access is made via an exclusive reference, anything else with the same provenance as that exclusive reference loses its provenance (i.e. its provenance is removed, or set to a null provenance).
Optimizations this permits
Rules 1, 3 and 6 are sufficient on their own to permit two main types of optimizations:
-
As long as they stay in the same relative order, reads or writes through a reference and its descendants (other than inside
UnsafeCell/UnsafePinned) can be moved to later points in the code, as long as they do not move beyond the last read or write through that reference that can be guaranteed to happen and reads and writes through the same reference; -
As long as they stay in the same relative order, reads or writes through a reference and its descendants (other than inside
UnsafeCell/UnsafePinned) can be moved to earlier points in the code, as long as they do not move before the first read or write through that reference to the same address.
These two basic optimizations can be used as components to build up larger optimizations. The most notable are autovectorization (which can be done by postponing writes to perform them in batches, causing the reads to also be performable in batches because any writes between them were moved out of the way), and loop-invariant code motion (which can be done via moving all the reads of a single address back to the point of the first read of that address). I consider these to be the most important optimizations (because they give much larger savings than most and can be difficult to replicate by rearranging the code by hand), and it is a big advantage of Permissive Borrows that they are possible even in the case of a coroutine that borrows from itself.
Most references are not to coroutines that borrow or reborrow from themselves, so can use rules 2, 3 and 6 instead. This gives a little more optimization power:
- As long as reads or writes through a reference and its descendants
(other than inside
UnsafeCell/UnsafePinned) can be proven to happen, and as long as they stay in the same relative order, they can be moved to any point in the code between the point at which the reference was created and the last read or write through that reference that can be guaranteed to happen.
The main optimization advantage gained here is the ability to optimize
a memmove into a memcpy: in memmove situations (like reading a
large array from one reference and writing it into another), the rule
1+3 optimizations do not help because all the reads are bunched
together already, and likewise all the writes are bunched together, so
they cannot be moved to interleave them; whereas the rule 2+3
optimizations allow moving some of the writes earlier in order to
interleave the reads and writes.
The optimization gains from rules 4 and 5 are much smaller, primarily
allowing speculative reads in cases where the program's control flow
might not guarantee that the read happens, or postponing reads beyond
functions that might potentially free or reuse memory. (Rules 2+3+4 justify
LLVM's dereferenceable; 2+3+4+5 justify dereferenceable, noalias
and nofree (and readonly for shared references). But most of the
optimisations noalias permits are already permitted by rules 1+3 or
2+3, just for a different reason, so the gain from noalias is
small.)
Permissive Borrows is expected to give a much larger scope for
optimization than Stacked/Tree Borrows do. This is because Stacked
Borrows and Tree Borrows are unable to soundly optimize certain types
(e.g. Box with a custom or inlined allocator, &own, borrows from
RefCell, closures that reborrow from themselves), and thus compile
these types with no optimization (in particular, LLVM's noalias is
not valid for these types); but most of the optimizations allowed by
Permissive Borrows still work even on types like these (meaning that
they can be heavily optimized even in the absence of noalias).
Variations
Blocking the reborrow of a mutable reference from a shared reference
As written, Permissive Borrows makes it possible to reborrow a mutable reference from a shared reference, as long as the shared reference is never used again (and is not a function/method argument). This seems to naturally fall out of the definitions (and some similar operations are intentionally allowed, e.g. you can convert an exclusive reference to a raw pointer, then mix that raw pointer freely with raw pointers that were not based on the exclusive reference, as long as you never use the exclusive reference again; and doing so is useful and intended because many implementations of deallocation work like this). It also provides a simple way to implement two-phase borrows (reborrow as shared, then upgrade by reborrowing the shared reference as mutable).
Oddly, blocking the ability to reborrow a mutable reference from a
shared reference does not seem to allow any additional optimizations.
On the other hand, being able to do that is extremely weird and
counterintuitive for most Rust programmers, so it might make sense to
ban it regardless. There seems to be no benefit to making it
undefined behavior (other than allowing Miri to shout at people who
try it); perhaps it could/should be considered erroneous rather than
undefined behavior.
(EDIT: I found a reason to make this undefined behavior: it allows the caller of a function to prove that the callee does not change a piece of memory on the basis that it hasn't been given any references that are capable of writing to it, in cases where it moves a shared reference into the function it's calling (via a wrapper) rather than reborrowing it. This situation is a little hard to formalize (due to the possibility of reborrows via raw pointers) and probably fairly rare, but a rule to cover it might nonetheless be useful.)
Changing the rules for when rule 5 applies
Rule 5 is the equivalent of a Shared Borrows "protector" (i.e. a special case that requires a reference that's a function argument to be alive and valid for the whole function).
In Stacked Borrows, protectors are used to justify LLVM's "noalias" attribute. Right now, there aren't many easy ways to communicate aliasing information to LLVM, so being able to apply "noalias" to a function parameter is important in the short term (to avoid a situation in which the aliasing model is able to justify a lot of optimizations but the compiler is unable to communicate this fact to LLVM).
There are two possible ways to vary this. One possibility is to make
rule 5 apply to more types, e.g. also applying it to references that
can be dropped. My experience is that putting protectors on such
types is extremely unintuitive: I consider it to be natural to assume
"I have dropped this reference and thus I can reuse the memory it
pointed to without causing aliasing variations". I have in the past
accidentally written code that is unsound under Stacked and Tree
Borrows due to making this assumption, and I'm not the only such
person (such code existed in the Rust standard library for many years,
and could theoretically have caused miscompiles). I'm assuming that
in the long term, we will find a way to allow LLVM to optimize such
references even in the absence of noalias (especially as Permissive
Borrows gives precise rules for how you would do so), in which case
noalias would give only a very small gain.
Another alternative is to (once LLVM is able to optimize on the basis of rules 1/2/3: it can already translate rule 4) remove rule 5 altogether, on the basis that the gain is fairly marginal compared to that of the other rules, and it adds a significant amount of complexity. There are a few optimizations that would be lost in theory, and LLVM does attempt these on occasion. In my experience, these optimizations are not very successful in practice with current LLVM: they are often missed in cases when they would help, applied in cases where they don't help, or disabled for performance reasons. As such, after implementing the other rules, there may be a case for removing this one (or perhaps locking it to a high optimization level). That said, there is potential that rule 5 could become more useful in future.