Repository navigation
Stacked Borrows: How precise should UnsafeCell be tracked? #236
Description
Currently, when looking for UnsafeCell behind shared references, Miri descends through arrays, structs and the like, but does not descend through enums. Instead, when it sees an enum, it checks if T: Freeze, and if not, treats the entire enum as an UnsafeCell.
The benefit of this is that finding UnsafeCell does not require reading from memory (rust-lang/miri#931), which makes formal reasoning about Stacked Borrows a lot simpler. Accessing memory during UnsafeCell search opens all sorts of nasty questions, such as whether those accesses are themselves subject to Stacked Borrows rules or not (and if yes, which tag they use). Not reading enum discriminants also avoids potential confusion from Stacked Borrows partially checking the validity invariant of the referenced data.
On the other hand, being more precise with UnsafeCell search could help optimizations. When a function works on &Result<Cell<i32>, i32>, and the compiler somehow can know that the Err variant is active, we would be able to rely on this shared reference being read-only -- currently, that is not an assumption that the compiler can make. (But note that once a shared reference gets created to the i32 in the Err variant, that is already guaranteed to be read-only.)
This is somewhat related to #204.
Some posts with useful datapoints (not exhaustive):
- Stacked Borrows: How precise should UnsafeCell be tracked? #236 (comment)
- Stacked Borrows: How precise should UnsafeCell be tracked? #236 (comment)
Other parts of this question:
- If we don't have strict subobject provenance, then what is the mutability of bytes outside the range of
T? - Does
UnsafeCellinsidePhantomDatahave any effect?
Activity
RalfJung commentedon Jun 13, 2020
To be precise, here is currently how we compute the permissions associated with a shared reference
&T: we do recursive descend overT.UnsafeCell, the memory it covers is writable.Usuch thatU: Freeze, we stop searching forUnsafeCellhere. This is purely an optimization, we could equivalently continue our descend, but we know thatU: Freezeguarantees the absence ofUnsafeCell.variantsisVariants::Multiple, and since we already checkedFreeze, we treat memory this type covers as writable.Variants::Single), we descend recursively through its fields.Every location not marked as writable in the above procedure is marked as read-only.
chorman0773 commentedon Nov 2, 2020
For reasons similar to those brought up in #77, #84, and #253, I think the multiple variants case should consider the active variant, when determining runtime writability.
Consider the type
Result<Cell<i32>,i32>, brought up in the original example. Under the model presented by lccc, in an object of that type, reguardless of the variant, there is a subobject of typei32. However, that subobject has the mutable characteristic (used to implementUnsafeCell) only when the result isOk. Behind a shared reference, it should be reasonable to conclude that the value of this subobject does not change if you have theErrvariant. This allows the movement/removal of reads behind a shared reference of this type when the variant is known to beErr(without loss of generality).I cannot think of a particular example where this distinction would have an effect (one would probably use an external
UnsafeCell, which does fun things anyways, and I've pretty much given up trying to optimize things inside anUnsafeCell, and focus my efforts on things outside of one).RalfJung commentedon Jun 26, 2022
FWIW, making
UnsafeCell"maximally imprecise" would also help with issues like rust-lang/rust#98498, where currently the obvious fix is not sound because one of the fields ofScopeis not anUnsafeCell.RalfJung commentedon Jun 26, 2022
In fact, even just making each field be in an
UnsafeCellis not always sufficient to allow deallocating that reference, since the padding between the fields is not in anUnsafeCell.So making use of the exception that is being added in rust-lang/rust#98017 is rather fragile currently, but would fairly very well if we don't attempt to track where inside a type the
UnsafeCellare.CAD97 commentedon Jun 26, 2022
There is still a possible axiomatic alternative: treat padding even more weakly. All fields being
UnsafeCellcan be sufficient to say the whole struct is inUnsafeCellif you explicitly don't care about padding. Treating the answer to “is this padding byte in an UnsafeCell” as undefined isn't strong enough; that could resolve to always say no and be the same as today; I think you'd need to somehow say the answer is always yes but still somehow justifyFreezefrom lack of non-padding UnsafeCell.I don't know exactly what this would look like, and it would probably break some important properties of Stacked Borrows — the simplest interpretation is probably to have a “padding” permission tag on the borrow stack for a byte reborrowed as padding, and then reading through a padding permission both returns an uninitialized byte and stores uninit. (Making a read perform a write is the certainly broken bit.)
Ultimately I'm also in favor of the weakening applied by UnsafeCell (and by analogy, PhantomPinned) applying to the entire pointee if the pointee type contains any UnsafeCell. As chorman has pointed out, this greatly limits the reasoning the compiler1 can make about generic code, but giving that up this seems justified2 by the operational specification simplifications gained.
What might be beneficial under such a model is splitting the
UnsafeCellweakening into parts, so that you can remove thedereferencable_always(n)andnoaliasqualities individually.Footnotes
But I want to point out that code authors can still make these assumptions in a generic environment due to privacy, encapsulation, and generally the concept of soundness of an API not considering outside uses of
unsafethat break such. ↩Especially since for better or for worse Rust already relies heavily on monomorphizations for performance. ↩
RalfJung commentedon Jun 26, 2022
We could do something operational where if all fields of a struct are entirely
UnsafeCell, then its padding is consideredUnsafeCell, too.Basically, separating its effect on protectors and permissions? Sure, that would be easy to represent.
chorman0773 commentedon Jun 28, 2022
I actually think there might be a more insidious problem, if this includes the fact that you can get a
&mut Tto those fields (while the exterior can still be freely moved as a shared reference). If it's just mutability, then the loss in generic contexts actually affects non-generic contexts as well, since non-trivial validity attributes are salient parts of pointer types in XIR (if they weren't the same problem I mentioned before is trivial to recreate, since it basically involves smuggling auniquepointer into a function as a trivially-valid pointer type, which doesn't cause implicit derives, and thus allows you to alias it to anotheruniquepointer passed into the same function, since the raw pointer in the second parameter is its direct parent).I'd be even fine with a more expansive restriction: Any contiguous extent of padding bytes that preceeds or follows an
UnsafeCellgets SRW as well. Unlike actual fields, XIRs model doesn't let you just point to padding bytes the same way as a subobject (I mean, you can have a pointer to it, but not a pointer to it), which is where the problem would arrise.RalfJung commentedon Jun 28, 2022
I don't see why that has to be the case, or why it would be the case in MIR+LLVM.
Even precisely defining the notion of "padding byte" is super hard (and subtle), on top of the already subtle rules for how far we traverse looking for
UnsafeCell, on top of the already super subtle aliasing rules... I think this is way overspending our complexity budget.chorman0773 commentedon Jun 28, 2022
RalfJung commentedon Jun 28, 2022
Can you explain this without going into the specifics of XIR? As in, is there actually something fundamental here, or just a design quirk of XIR?
There are no implicit Pad fields. Padding is the absence of a field at a given offset into a struct. (And something more complicated for enums.) At least that's how I prefer to think of it.
chorman0773 commentedon Jun 28, 2022
It's fundamental, part of the type system. Especially for non-trivial validity, it changes how the pointers interact with certain operations, like being moved (particularily into a function, local, or a static, the same way that
&mut Tand&Tdo. And there are no implicit conversions in XIR, onlyweakones with the convert instruction, especially when the conversion is as non-trivial as an implicit derive operation (which can best be described as being equivalent to a generalization of the reborrow operation - although as an instruction,deriveis "Change pointer attributes and produce new pointer value").Yeah, that's how I'd think of it as well. Parts of the object-representation that don't participate in determining the value.
RalfJung commentedon Jun 28, 2022
I don't mean fundamental to XIR. I mean fundamental in a way that does not depend on the details of the language this is formalized in.
RalfJung commentedon Jun 28, 2022
Context: I am mostly interested in XIR insofar as it teaches us general lessons that we might miss by taking a MIR/LLVM view, due to the quirks of those languages. I am much less interested in XIR itself. So if there is a lesson here that transcends XIR, I would like to learn it.
47 remaining items
chorman0773 commentedon Aug 6, 2023
TBH, this might have issues with the question presented here.
chorman0773 commentedon Aug 6, 2023
Or hmm... Miri at least seems to think that mutating a promoted constant, even an
impl !Freezeone, is UB.RalfJung commentedon Aug 6, 2023
Promoted constants are put in read-only memory, so yeah their mutation is definitely UB. That's a different "source of UB" that
&T-readonly-ness.Yeah there are definitely some interesting potential interactions.
RalfJung commentedon Aug 14, 2023
There are at least 2 creates that rely on
PhantomDatabeing alwaysFreeze, at least for the purpose of creating references in const-eval.This doesn't necessarily impact the aliasing rules, I think, but it is still certainly useful to know.
Auto merge of rust-lang#128543 - RalfJung:const-interior-mut, r=fee1-…
Auto merge of #128543 - RalfJung:const-interior-mut, r=fee1-dead
Jules-Bertholet commentedon Mar 20, 2025
I wonder if we could get most of the optimization benefits of precise
UnsafeCelltracking without relying on runtime reads by making use of the information provided by some sort of pattern types mechanism to track the active variant statically.RalfJung commentedon Mar 20, 2025
Potentially we could interpret
Enum @ Freeze(u8)as a type withoutUnsafeCell, yeah.danielhenrymantilla commentedon Mar 20, 2025
I don't know whether what I am going to point out is necessarily 100% aligned with the topic of this issue or whether it is a bit orthogonal, but I was wondering about the interaction between
staticpromotion of anenuminstantiated with a "Freezevariant" despite there being other variant(s) which may be non-Freeze/containingUnsafeCells and whatnot.This is:
'static-annotate the aforementioned ref);but then I wondered about the interaction between this, and:
#[repr(Rust)] enumsuch asOptionfeels too dirty, we could easily imagine replacing these things with a#[repr(C and/or int)] enums, all the way down tounions (which do not getstatic-promoted even when instantiated withFreezefields exclusively)<Is
write_to_some_payload():Noneis fed to it?static-locatedNone?Running it with
mirias of now seems to accept it when given a non-static-locatedNone, whereas astatic-promoted one does cause UB:CAD97 commentedon Mar 20, 2025
I can't find the source at the moment, but I do vaguely recall there being a decision that it's fine to forbid writes to values just based on that safe code can't write to it. To break the soundness of your example, consider that I could alias
&None::<Cell<i32>>with&[MaybeUninit<u8>; 8]even for non-static places in order to retag the memory as immutably shared.So I'd say somewhere between your blatant and mildly unsound: something untypical needs to be done, but those things are more useful and less surprising to allow.
But that only really covers
const { &None }. In the case of static promoted&None, the promotion does change semantics1, which promotion isn't supposed to do. For that, I recall discussing potentially deprecating!Freezeand requiring such to useconstblocks, although I think with even just informal justification of that the type of a literal is more precise than the full type is sufficient to leave it as a default allow restriction style lint.Footnotes
Assuming
Noneis a literal of typeOption<Cell<i32>>and not of refined typeOption<Cell<i32>> is None, which as noted can potentially be treated as beingFreeze. ↩RalfJung commentedon Mar 20, 2025
@danielhenrymantilla I think you might be talking about #493?
Auto merge of #128543 - RalfJung:const-interior-mut, r=fee1-dead