"Tootsie Pop" model for unsafe code

One problem I have with this model is that it is formulated in terms of breaking and restoring invariants.

It is certainly more interesting case of unsafe, but is it common case of unsafe? I think common case is not breaking invariants at all. Examples are FFI and unchecked indexing.

Consider unsafe used to optimize DFA inner loop in regex. (I haven’t analysed it in detail. The following is a generality.) It is true that safety of this unsafe ultimately resides on privacy of DFA and invariant that no ill-formed DFA can be constructed. It is also true the programmer must be careful not only when changing DFA execution code which includes unsafe, but also when changing DFA construction code which does not include unsafe.

On the other hand, it is strange to me that the compiler is careful in entire regex module for broken invariants, for example, that aliasing &mut may be created. This is probably covered by “opt back in”. My guess is that this is the wrong default, because most unsafe will not break any invariants and would specify all optbacks. In my opinion, correct default is like ?Sized, making the common case unmarked.

3 Likes

The most important example is probably refcell-ref. Ignoring some current LLVM bugs involving unwinding, we could potentially mark function arguments of both mutable and immutable reference type as noalias. (LLVM’s alias analysis allows calling identical pointers noalias as long as it’s impossible to modify the pointee; this is used, for example, to enhance alias analysis on global constants.) The problem is, that fatally breaks Ref… and the relevant noalias annotation is in the user’s safe code (e.g. in fn f(x: Ref<i32>) { ... }, the Ref will get split into two references, both marked noalias).

Good luck coming up with a model to make that work. :slight_smile:

Right. Almost all the uses I have for unsafe are to call a C or assembly-language function that is faster than what I can get rustc can produce. The idea that calling my optimized assembly language function is going to, by default, de-optimize my Rust code makes zero sense in that scenerio.

Most of the other uses I have for unsafe are all "I have to do something unsafe because that's faster than the safe way" (e.g. using core::ptr::copy_nonoverlapping until clone_from_slice performance is fixed.) The final set of uses is "I'm actually trying to make things safer but the language won't let me without unsafe," e.g. coercing a slice to an array reference. In neither case am I really breaking any of the safety invariants and in all these cases improving performance is a key consideration.

5 Likes

Except that it doesn't. The optimization that Niko presented is valid in the single-threaded case and prevented by the memory barrier involved in releasing the lock in the multi-threaded case.

2 Likes

@gereeter The testcase from the blog is incomplete; consider:

fn f(x: Ref<i32>, y: &RefCell<i32>) {
  let t = *x.value; // noalias allows moving the load from here
  drop(x);
  *y.borrow_mut() = 5;
  t // to here; oops, the value changed
}

Couldn’t you just never mark private fields defined in unsafe modules as noalias?

I guess that works. It’s not very nice in the sense that it makes the unsafe boundary apply to types in addition to code.

@eefriedman Keep in mind that since RefCell contains an UnsafeCell, &RefCell is not marked as noalias in LLVM IR.

EDIT: Hmm nevermind, I see your point. Note that this currently doesn’t cause a problem in LLVM because the reference inside the Ref is not marked as noalias. The noalias annotations currently only apply to function parameters in LLVM.

What about this kind of a model:

  • Safe guarantees (like restrictions on aliasing) are expected to be followed strictly except inside literal unsafe blocks. The compiler optimizations are conservative inside the unsafe blocks, but the conservativeness doesn’t need to affect the full module or even full function. (I think this is an easy rule to grasp)
  • Unsafe fields and variables are introduced, to force the use of unsafe blocks when accessing values that uphold invariants. (This has the effect that compiler can’t simply reorder the access of such variables.) Note that in this model, the unsafe fields are not only for “caution markers”, but they are needed for suppressing “rogue” optimizations.
  • Compiler is allowed and encouraged to introduce debug_asserts to check for aliasing. This is to ensure that people won’t write code that works “accidentally”.

This has the upside that the unsafe boundary is smaller – and the state that holds up invariants is explicitly marked. However, it’s kind of an opposite of the view that “most behaviour should be defined”.

We have plans to pass small structs as the combination of their fields, which means we will pass the noalias attribute. Ref is Just a Plain Bug that should be fixed.

Per-module unsafety rules just feel kind of totally random, especially because modules do not matter to semantics in any other way.

Unsafe code is often used in places where no invariants can possibly be broken (unchecked_get and friends, FFI with raw pointers), and random de-optimization there would be very annoying.

This is made worse by the problems we are trying to bypass being basically local. Even if we take our consume_from_usize example:

pub fn entry_point() {
    let x: i32 = 2;
    let p: usize = escape_as_usize(&x);
    println!("{}", unsafe { consume_from_usize(p) });
}

It is very similar to:

pub fn entry_point() {
    let mut x: i32 = 2;
    let x_ref = &mut x;
    let p: usize = escape_as_usize(&*x_ref);
    println!("{}", unsafe { consume_from_usize(p) });
}

Here, the problem is obviously that we read x while there is a mutable reference to it, and the question is whether the mutable reference is active or not. I think we can treat the Var(x) lvalue basically as a scope-long mutable borrow, which would make these examples completely equivalent.

2 Likes

That sounds like you do want my version where the unsafe block ends only after the slices have been adjusted to not overlap. Even from the unwritten-aliased-pointers-are-OK perspective, the indexing is a function call and thus it would take inter-procedural analysis to demonstrate that neither self nor copy are written to before they are returned.

To put it in other words:

The interesting invariant is that, in safe code, values are only accessed while there is a valid, escaping loan to them.

Borrowck relies on that invariant in order to work, so any function that ignores it is prima facia unsafe (e.g. a safe consume_from_usize) - it has no way of knowing the caller did not invalidate its reference!

This means that the only way this invariant could get violated is if the function that references the value contains unsafe code. The loan does not need to escape to unsafe code (consider escape_as_usize/consume_from_usize, or even “leaking” the pointer through some remote server), but the unsafe code needs to be there.

Out of curiosity, are there any other 'action-at-a-distance' (ish) examples like this in rust where code can affect the performance of completely unrelated code just by existing?

I wouldn't be surprised if inlining thresholds were subject to such action-at-a-distance.

Also rust is supposed to be a high performance language and unsafe blocks are supposed to be for people who know what they are doing. I wouldn't want to stop optimizing whole modules to add a slight amount of hand holding to unsafe blocks.

I disagree with this sentiment. People who know what they're doing ought to know enough to know that unsafe blocks are a measure of last resort and the importance of abstractions in cordoning off the unsafe blocks from outside influence. Meanwhile, the people who don't know what they're doing may very well be lured into believing that if they don't modify code that is directly in an unsafe block then they can't cause any unsafety, but this is provably untrue if they're modifying a module that contains an unsafe block elsewhere.

The only alternative would be to make it so that privacy is not required for enforcing unsafe boundaries (as per the unsafe fields RFC, though I don't know if this would be fully sufficient), but in today's Rust, modules are very much the boundaries of unsafe code, and have been since long before this blog post.

1 Like

But the idea that I can't add a single unsafe block to my whole file without deoptimizing the entire file? Ouch.

I don't see why this would be the case, a submodule should be enough to cordon off the unsafe block behind a confidence boundary.

Not only do proposals like "unsafe fields" correctly put the emphasis back on the type with fewer inhabitants, but they also conveniently make functions like copy_drop unsafe even in the module where those fields are in scope.

I agree that if we don't want it to remain the case that modules represent the boundary of unsafe code then we'll need to reconsider the unsafe fields proposal. However, 1) I don't know if unsafe fields alone are necessary to revoke the importance of the module boundary wrt unsafety, 2) until all unsafe Rust code is rewritten to use unsafe fields, we would need to be exactly as conservative as Niko's proposal here when optimizing modules that contain unsafe code.

So the rule would at least be "a mod which defines a struct with private fields and contains unsafe anywhere inside of it is an unsafe abstraction boundary".

@glaebhoerl I'm definitely intrigued by the idea of finding a more precise formulation of the conditions that necessitate a module-level unsafety boundary. However, how does this rule deal with Niko's "usize-transfer" example in the OP?

One thing I'll note is that consume_from_usize() is not only undefined behavior according to C, but is actually disallowed by hardware that has been built. This is noted in http://www.cis.upenn.edu/~stevez/papers/KHM+15.pdf :

While the language definition provides an integer type uintptr_t that may be legally cast to and from pointer types, it does not require anything of the resulting values [5, 7.20.1.4p1].

C standard, 7.20.1.4p1:

The following type designates a signed integer type with the property that any valid pointer to void can be converted to this type, then converted back to pointer to void, and the result will compare equal to the original pointer:

intptr_t

It does not require the result be dereferenceable in any way - it's certainly permissible to set a flag bit that makes dereferencing it trap, or optimize away comparisons but turn dereferences into an unconditional error at compile time.

As for hardware, CHERI (A MIPS based on the BERI implementation) is a capability-enhanced ISA designed according to the rules of C, on which conversion of an integer to a pointer would result in a capability to memory of zero length - one which can be compared to other capabilities, but not dereferenced to any memory.

As a result, it seems to me that any unsafe boundary broader than the module would have the sole benefit of permitting behaviors that are very likely to be broken by lower levels of the compiler and high-security hardware anyway.

1 Like

One problem I have with this model is that it is formulated in terms of breaking and restoring invariants.

It is certainly more interesting case of unsafe, but is it common case of unsafe? I think common case is not breaking invariants at all. Examples are FFI and unchecked indexing.

I consider "indexes must be within bounds" to be an invariant of Rust, though that just so happens to be an invariant that Rust can only enforce at runtime. As for FFI, it seems to me like we must assume that C code running in-process can do literally anything it wants, including breaking invariants, yes?