Skip to content

Conversation

@chorman0773
Copy link

@chorman0773 chorman0773 commented Apr 2, 2024

@chorman0773

This comment was marked as resolved.

@saethlin saethlin added T-lang Relevant to the language team, which will review and decide on the RFC. T-libs-api Relevant to the library API team, which will review and decide on the RFC. T-opsem Relevant to the operational semantics team, which will review and decide on the RFC. labels Apr 2, 2024
@RustyYato
Copy link

RustyYato commented Apr 2, 2024

MaybeUninit::freeze should be unsafe.
Otherwise you could get MaybeUninit::<AnyType>::uninit().freeze() which is trivially unsound.

Thinking about this so late was a bad idea, sorry for the noise. I misread Self as T

@kennytm
Copy link
Member

kennytm commented Apr 2, 2024

MaybeUninit<T>::freeze() returns a MaybeUninit<T> not a T, why would that be unsound

@chorman0773
Copy link
Author

I also do explicitly address an unsafe T returning MaybeUninit::freeze in the rationale and alternatives section and explain why I went for the safe version instead.

@clarfonthey
Copy link

clarfonthey commented Apr 2, 2024

Small thing to point out: although it's not exposed publicly, the compiler uses the term "freeze" to refer to things without interior mutability, and that makes the naming of this potentially confusing for those familiar with the term. That said, the existing "freeze" term could always be renamed to something else since it's not stable, but it's worth mentioning anyway.

One thing worth asking here is how this specifically differs from volatile reads, since I can see a lot of similarities between them, and while I understand the differences, a passing viewer might not, and that's worth elaborating a bit more.

Comment on lines 30 to 45
```rust
// in module `core::ptr`
pub unsafe fn read_freeze<T>(ptr: *const T) -> T;

impl<T> *const T{
pub unsafe fn read_freeze(self) -> T;
}
impl<T> *mut T{
pub unsafe fn read_freeze(self) -> T;
}

// in module `core::mem`
impl<T> MaybeUninit<T>{
pub fn freeze(self) -> Self;
}
```
Copy link

@clarfonthey clarfonthey Apr 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Poking around the library, the obvious parallel to this functionality is zeroed, and these methods don't really parallel that at all.

Namely, zeroed says: give me a value that is initialized with zero bytes. You still have to assume_init to assert that zeroing is valid for your type, but you do know what the bytes are.

To me, freeze here says: initialize a memory location by "freezing" its bytes. Again, you should still have to assume_init to assert that an arbitrary value is valid for your type, but you know that the bytes are intialized.

Another maybe-controversial decision is that I think that these methods should require *mut T, since we're conceptually writing over uninitialized memory and replacing it with an arbitrary value, even though no actual writing is occurring (and thus, no data races can occur). It also allows for things like miri to actually randomize the data in the buffers to help with testing, without worrying about making things unsound.

Maybe there could be some sort of "atomic freeze" that accepts an Ordering, but in general, it seems like you're probably going to want a mutable pointer here.

So, to that end, it feels more like the API should look like this instead:

// in core::ptr
pub fn freeze<T>(ptr: *mut T);

pub unsafe fn read_freeze<T>(ptr: *mut T) -> T {
    freeze(ptr);
    read(ptr)
}

impl<T> *mut T {
    pub fn freeze<T>(self);

    pub unsafe fn read_freeze<T>(self) -> T {
        self.freeze();
        self.read()
    }
}

impl<T> MaybeUninit<T> {
    pub fn freeze(&mut self);

    // and maybe:
    pub fn frozen() -> MaybeUninit<T> {
        let mut uninit = MaybeUninit::uninit();
        uninit.freeze();
        uninit
    }
}

And maybe there could also be helpers for arrays, similar to how copy will also accept a number of elements. That API might mean that these freeze methods for pointers now accept a count indicating an array size.

Copy link
Contributor

@digama0 digama0 Apr 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me, freeze here says: initialize a memory location by "freezing" its bytes. Again, you should still have to assume_init to assert that an arbitrary value is valid for your type, but you know that the bytes are intialized.

I was confused about this as well, but reading the rest of the RFC I came to the conclusion that this is supposed to be a read-only operation which does nothing to the original memory location. It returns a "freezed version" of the read bytes but the original memory is left untouched.

The API you are talking about is summarized by MaybeUninit::freeze(&mut self) and is considered in the Alternatives section. (There are reasons not to want to go that route, at least in the first iteration of this API.)

Copy link
Contributor

@digama0 digama0 Apr 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another maybe-controversial decision is that I think that these methods should require *mut T, since we're conceptually writing over uninitialized memory and replacing it with an arbitrary value, even though no actual writing is occurring (and thus, no data races can occur). It also allows for things like miri to actually randomize the data in the buffers to help with testing, without worrying about making things unsound.

No, this is actually literally a write to the target memory, with all of the consequences that follow from that. (No "conceptual" about it, there are cases where you need to actually issue a write instruction, fault on read-only pages, acquire exclusive access to the cache line, the whole nine yards.) The RFC proposes to use *ptr = ptr.read_freeze() as the way to express this operation.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, although I guess the main controversy here is that it kind of forgets the reason why MaybeUninit exists: in general, it's very bad to pass around values when you're dealing with potentially uninitialized memory.

For example, take this simple use case: you want to read out a struct in full, including padding bytes, which are normally uninitialized. So, let's say we freeze-read that struct, copy it over a buffer of bytes, then write that out.

This is unsound. When you read the struct as a value, the padding is still uninitialized. What you actually want to do is cast the struct to bytes first, then freeze-read that into your buffer.

But again, this is still wrong. You need to cast to a slice of maybe-uninit bytes, since the padding is uninitialized, freeze-read that into a slice of init bytes, and write that.

This is why fn zeroed<T>() -> T is mostly discouraged; if you're working with anything other than integers, it's generally wrong to use. This is why I think that fn read_freeze<T>(ptr: *mut T) -> T is the wrong API; in most cases, the T input and the T output should be different, but here, they're the same, and you have to do a bunch of weird transmutation before you actually get to the thing you want to do.

If we treat freeze as actually working on the memory location, then the freezing and reading can be done separately, with all the necessary transmutes in between. Or, we have a freeze_transmute_copy type thing that freezes and does a transmute_copy afterward.

But ultimately, I feel like freeze returning a value is the wrong choice.

Copy link
Contributor

@digama0 digama0 Apr 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But again, this is still wrong. You need to cast to a slice of maybe-uninit bytes, since the padding is uninitialized, freeze-read that into a slice of init bytes, and write that.

I agree with the first part: if you read a struct T using read_freeze::<T>(_) -> T then you will still not have any padding bytes. But read_freeze::<[u8; size_of::<T>()]>() should work and produce initialized bytes, including for the padding, because (according to the docs in the RFC) the freezing happens before the typed-copy, so it should be safe to do as long as the target type is valid for every initialized bit pattern.

Using by-value instead of by-mut-ref freeze avoids a whole bunch of issues related to "what if we just elided the read?" on weird memory. (This has been discussed at length on #t-opsem.) It also makes it unusable on read-only memory. The API footgun concern is sound, but the by-value MaybeUninit<T> method should alleviate that concern, no?

Copy link
Contributor

@newpavlov newpavlov Apr 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You seem to be thinking of this is terms of permitted optimizations, but that's not a good way to specify a language

Yes, I know. But since I am more of a practitioner, it's easier for me to discuss concrete examples based on potential usecases I have in mind for freeze. Hopefully, then people like you who specialize in language semantics will be able to find a way to bridge what people want and how to specify it robustly.

Does not matter how robust your model is, if it does something surprising or unexpected for people who practice the language (be it it terms of performance or generated insructions), arguably, it's a bad model. You either need to better communicate what the model does, or change the model itself to better satisfy user expectations.

After this discussion, I personally find that the proposed freeze model is hardly useful and that RFC did not do a good job of communicating capabilities of the model. freeze is usually viewed as an optimization technique and the proposed approach may result in very surprising (for low-level programmers) code generation. The RFC needs at the very least to clearly discuss several examples of what can be and can not be done with the model and provide expected code generation for each example. In the current form it does not even mention interaction with MADV_FREE like at all!

One last example from me:

pub fn f() {
    unsafe {
        let mut buf: [u8; 1024] = MaybeUninit::<[u8; 1024]>::uninit().freeze().assume_init();
        extern_read(&mut buf);
    }
}

How much stack space this function would use when compiled with enabled optimizations? ~1024 bytes? ~2048 bytes? More? What exact writes would be generated?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How much stack space this function would use when compiled with enabled optimizations? ~1024 bytes? ~2048 bytes? More? What exact writes would be generated?

That example should be trivially optimizable to use 1kb of stack. No writes should be necessary since 1kb is less than a page size. If it was more than a page size then it's my understanding that stack probes would already be injected to probe the additional stack pages, irrespective of the use of freeze here.

Copy link
Contributor

@newpavlov newpavlov Apr 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't be so sure. Such buffer can easily cross page boundaries. And even if it does not, you don't know if any writes were generated for this page. What if buf was allocated right at the page edge? You may argue that for stack pages MADV_FREE is not used, but: 1) I can easily imagine a threading library reusing stack frames with MADV_FREE 2) changing freeze behavior depending on whether it's used on stack or heap memory would be... not ideal, to say the least.

So instead of a trivial no-op expected by programmers for this code snippet, we get quite non-trivial behavior.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So instead of trivial no-op expected by programmers for this code snippet, we get quite non-trivial behavior.

Yeah that's what happens when programmers are wrong. They expect things to be simple that aren't simple (see pointer provenance as another example of this). I don't blame them; optimizing compilers and system tricks like MADV_FREE make things complicated in subtle hard-to-predict ways. freeze needs careful documentation to make sure programmers are aware of the caveats.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if buf was allocated right at the page edge?

Then you may need additional probes at the page boundary if the contract between the compiler and the platform does not require that stack reads give deterministic results already. I don't know if such a contract is already defined, but a reasonable option is to simply say that such a threading library is just wrong (in the same way the thread stacks are required to be properly aligned.)

changing freeze behavior depending on whether it's used on stack or heap memory would be... not ideal, to say the least.

The behaviour would be exactly the same, because behaviour is a property of the AM. What instructions are emitted by the compiler for the intrinsic could be completely different in different circumstances though... Obviously? That's what compiler optimizations do!

So instead of a trivial no-op expected by programmers for this code snippet, we get quite non-trivial behavior.

The behaviour is trivial, the emitted code is not. Freeze is not a no-op in the AM - whether it's a no-op in practice depends on optimizations and contracts, in the same way that whether a copy is elided depends on optimisations. If the contract says that stack memory reads are deterministic then it can indeed compile to a no-op. If not then of course the compiler needs to emit a write to that memory to ensure that it becomes initialized.

`read_volatile` performs an observable side effect (that compilers aren't allowed to remove), but will otherwise act (mostly) the same as `read`. `read_volatile` does not freeze bytes read.
In contrast, `read_freeze` is not a side effect (thus can be freely optimized by the compiler to anything equivalent).

It is possible in the future that `read_volatile` may carry a guarantee of freezing (non-padding) bytes, but this RFC does not provide that guarantee.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps it could be mentioned that adding "freezing" semantics to read_volatile is not necessary to semantically perform a "freezing volatile read"; one can read_volatile as MaybeUninit and then by-value freeze that.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sentence alone made me realise just how much I had been misunderstanding the RFC, so, I think it's worth including here at least.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, @chorman0773 could you add that?

* Either one of the two functions could be provided on their own
* Both functions are provided for maximum flexibility, and can be defined in terms of each other. The author does not believe there is significant drawback to providing both functions instead of just one
* An in-place, mutable `freeze` could be offered, e.g. `MaybeUninit::freeze(&mut self)`
* While this function would seem to be a simple body that llvm could replace with a runtime no-op, in reality it is possible for [virtual memory](https://man7.org/linux/man-pages/man2/madvise.2.html#MADV_FREE) that has been freshly allocated and has not been written to exhibit properties of uninitialized memory (rather than simply being an abstract phenomenon the compiler tracks that disappears at runtime). Thus, such an operation would require a potentially expensive in-place copy. Until such time as an optimized version is available, we should avoid defining the in-place version, and require users to spell it explicitly as `*self = core::mem::replace(&mut self, uninit()).freeze()`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we add this, the in-place version also can be implemented in a library crate, that uses inline asm to touch/write to each page at least once ensuring that it is not MADV_FREE.

Without the primitive freeze, there's no corresponding sequence of AM operations that this is equivalent to, but with it, the asm is equivalent to freezing each byte by-hand.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you elaborate that a bit @thomcc? Like, what inline assembly are you thinking of? Because from your comment it feels like you're proposing both the use of ASM and the new operation.

Copy link
Contributor

@zachs18 zachs18 Apr 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@clarfonthey Inline assembly that does this can be written today, but because the freeze operation does not exist in the abstract machine, that inline asm has... maybe not quite UB, but it performs something the AM does not recognize as a thing that can be done. However, if the freeze operation is added to the AM, that inline asm can be shown to have fully defined behavior in the AM in terms of the freeze operation.

In other words, yes, the ASM and the new stdlib functions would probably both be used (for different use-cases (freezing a large chunk of memory in-place vs freeze-loading a single value, for simple examples)), but semantically both would be defined in terms of the new AM operation.

x86_64 ASM example (Not the most optimized, but gets the point across. Provided AS-IS with no warranty, etc, etc.)
#[inline(never)]
pub fn freeze_in_place(mem: &mut [MaybeUninit<u8>]) -> &mut [u8] {
    let len: usize = mem.len();
    if len > 0 {
        let ptr: *mut MaybeUninit<u8> = mem.as_mut_ptr();
        // touch the first byte of the slice, then
        // touch the first byte in each subsequent page that 
        // contains a byte of the slice.
        unsafe {
            core::arch::asm!(
                "2:",
                "mov {tmp:l}, BYTE PTR [{ptr}]",
                "mov BYTE PTR [{ptr}], {tmp:l}",
                "mov {tmpptr}, {ptr}",
                "and {tmpptr}, -4096", // addr of first byte of this page
                "add {tmpptr}, 4096", // addr of first byte of next page
                "mov {tmplen}, {tmpptr}",
                "sub {tmplen}, {ptr}",
                // tmplen is the number of bytes we semantically froze 
                // with the above `mov BYTE PTR ...`.
                // if this is >= the remaining length of the slice, we're done
                "cmp {tmplen}, {len}",
                "jae 3f",
                // otherwise, keep going
                "sub {len}, {tmplen}",
                "add {ptr}, {tmplen}", 
                "jmp 2b",
                "3:",
                ptr = inout(reg) ptr => _,
                len = inout(reg) len => _,
                tmpptr = out(reg) _,
                tmplen = out(reg) _,
                tmp = out(reg) _,
            );
        }
    }
    // Safety: either the slice is empty, or we touched every page containing the slice
    unsafe {
        &mut *(mem as *mut [_] as *mut [u8])
    }
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this mean for the RFC?

I think the wording of "requires a potentially expensive in-place copy" could be refined since it doesn't seem to be quite accurate.

@RalfJung
Copy link
Member

RalfJung commented Apr 4, 2024

Cc @rust-lang/opsem

Comment on lines 30 to 45
```rust
// in module `core::ptr`
pub unsafe fn read_freeze<T>(ptr: *const T) -> T;

impl<T> *const T{
pub unsafe fn read_freeze(self) -> T;
}
impl<T> *mut T{
pub unsafe fn read_freeze(self) -> T;
}

// in module `core::mem`
impl<T> MaybeUninit<T>{
pub fn freeze(self) -> Self;
}
```
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure there's a "nice" API here that isn't project-safe-transmute

Can you say a bit more about this? I'm the head of PST, and would love gather thoughts of how freeze might impact ST APIs.

Would it make any sense for the intrinsic to have this signature:

pub fn freeze<T>(v: &T) -> [u8; size_of::<T>()];

I think this would avoid some of the safety footguns of the signature defined by the RFC, but I'm not sure if this limits its utility in any obvious (or non obvious) ways.


```rust
// in module `core::ptr`
pub unsafe fn read_freeze<T>(ptr: *const T) -> T;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the safety contract of this?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I write it later in the guide-level explanation: Same as core::ptr::read with the caveat of freezing uninit bytes (thus having a weaker initialization invariant on *ptr).

Copy link

@clarfonthey clarfonthey Apr 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, to me it felt obvious the unsafe here was just because it's a pointer deref, but since the ultimate implementation will have document that anyway, probably worth just writing it here explicitly.

* Either one of the two functions could be provided on their own
* Both functions are provided for maximum flexibility, and can be defined in terms of each other. The author does not believe there is significant drawback to providing both functions instead of just one
* An in-place, mutable `freeze` could be offered, e.g. `MaybeUninit::freeze(&mut self)`
* While this function would seem to be a simple body that llvm could replace with a runtime no-op, in reality it is possible for [virtual memory](https://man7.org/linux/man-pages/man2/madvise.2.html#MADV_FREE) that has been freshly allocated and has not been written to exhibit properties of uninitialized memory (rather than simply being an abstract phenomenon the compiler tracks that disappears at runtime). Thus, such an operation would require a potentially expensive in-place copy. Until such time as an optimized version is available, we should avoid defining the in-place version, and require users to spell it explicitly as `*self = core::mem::replace(&mut self, uninit()).freeze()`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think I totally understand the performance considerations here. What is an "in place" copy? Why is it more expensive than a move?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not more expensive than a move (except as to llvm having more freedom to elide it), but I'm guessing a move looks more like it's "Doing something", whereas a user might assume that MaybeUninit::freeze(&mut self) is really a runtime no-op, and complain it's O(n) on their massive structure.

Copy link

@clarfonthey clarfonthey Apr 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just rewording how I interpreted this, to verify I'm understanding correctly: since virtual memory may be uninitialised but still copy-on-write (for example, from a forked process), there are cases where a copy would still happen even if it's not explicit in the in-place signature, and so it's probably easier to just treat as a read anyway?

This does feel like a case of, optimising for the common case even if it's not always the best, and it might be worth elaborating more.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, I personally think that "user isn't capable of reading documentation" is a compelling case for advocating for a particular API. slice::reverse is also an in-place mut method but users don't have issues understanding that it takes linear time. I know there's a better justification, so, I'd personally avoid that kind of argument.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Putting on my Project Safe Transmute hat, I'd like to advocate for alternatively or additionally providing an in-place intrinsic. Libraries like zerocopy and bytemuck almost exclusively work with references to values, and those values are potentially DST. The proposed by-value intrinsic does not lend itself to working on DSTs. At the very least, I'd like this RFC to propose a snippet that used the by-value intrinsic to implement a by-ref routine that works on slice DSTs.

That said, the safety considerations of implementing a general, in-place freeze are quite subtle. Zerocopy, for instance, requires that every unsafe block is convincingly proven to be sound with citations to authoritative documentation. I think we're very far from having an abstract machine for Rust that is sufficiently well specified that zerocopy could robustly justify the operation described in #3605 (comment). I would far prefer this fundamental operation to be provided by the standard library, than for it to be re-invented ad hoc by crates.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The in-place intrinsic doesn't exist as an intrinsic in LLVM, so there's quite the gap there in terms of how well-supported the operation is throughout the stack. So I think it's better to not block by-val freeze on resolving all those questions.

Co-authored-by: Jack Wrenn <[email protected]>
impl<T> *const T{
pub unsafe fn read_freeze(self) -> T;
}
impl<T> *mut T{

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should have a method for NonNull too.

* Undefined behaviour does not prevent malicious code from accessing any memory it physically can.


# Rationale and alternatives
Copy link
Member

@jswrenn jswrenn Apr 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An alternative that is being considered by PST (see discussion here) is something like:

/// Initializes possibly-uninitialized bytes in `v` to `0`.
///
/// # Safety
///
/// Unsafe code may depend on uninitialized bytes in `v` being
/// initialized to `0`. Note, however, that the initialization
/// of padding bytes is not preserved on move.
// TODO: Restrict `v` to non-dyn pointers.
fn initialize<T: ?Sized>(v: &mut T) {}

This alternative mitigates some of the listed Drawbacks of this RFC's proposal: Rust would retain the property that sound code does not read uninitialized memory.

The performance cost of this is linear with respect to the number of padding bytes in a type. Since this is less than or equal to the total number of bytes in a type, it may be more performant than a move. For types with no padding, it's free.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To implement initialize as-written (i.e. specifically initializing padding bytes to zero, not just some arbitrary value, and not modifying non-padding bytes), this would require compiler support to know where padding bytes are in an arbitrary type, and as the comment mentions it definitely wouldn't work for dyn Trait in general.

If would also that require compiler support to guarantee "better-than-O(size_of_val)" performance, even for arbitrary initialization instead of zero-initialization.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't work with dyn Trait (with the ?Sized bound there - though that is mentioned), nor on types that allow uninit bytes that aren't in padding bytes. This does not function for the MaybeUinit<Scalar> case.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zachs18:

To implement initialize as-written (i.e. specifically initializing padding bytes to zero, not just some arbitrary value, and not modifying non-padding bytes), this would require compiler support to know where padding bytes are in an arbitrary type, and as the comment mentions it definitely wouldn't work for dyn Trait in general.

The compiler does know where padding bytes are for arbitrary types. That's the entire shtick of Project Safe Transmute, and it's also part of how miri is able to validate transmutations at runtime.

If would also that require compiler support to guarantee "better-than-O(size_of_val)" performance, even for arbitrary initialization instead of zero-initialization.

I don't follow why that would be the case. Both initialize and freeze are O(size_of_val).


@chorman0773:

This doesn't work with dyn Trait (with the ?Sized bound there - though that is mentioned)

As I understand it, neither initialize nor freeze would work on such types. At any rate, I haven't seen any demand from folks doing zero-copy parsing to support dyn Trait. (If anyone reading this has a use-case for that, please let us know!)

nor on types that allow uninit bytes that aren't in padding bytes.

As documented, it would overwrite them. It would probably be useful to provide initialize_padding that only overwrote padding bytes, too.

This does not function for the MaybeUinit<Scalar> case.

Can you say more about this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The compiler does know where padding bytes are for arbitrary types. That's the entire shtick of Project Safe Transmute, and it's also part of how miri is able to validate transmutations at runtime.

There's a catch here: for enums, we need to read the discriminant to find out where the padding bytes are. So this can only be done for data where the discriminant is valid.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/// Unsafe code may depend on uninitialized bytes in `v` being
/// initialized to `0`.

I think that's unimplementable because sometimes LLVM doesn't know if bytes are uninitialized (e.g. calling an external function), so it can't know which bytes to zero. freeze without zeroing works because when LLVM doesn't know if a byte is uninitialized, it just uses the current machine-level value of that byte, which is always implementable.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As documented, it would overwrite them. It would probably be useful to provide initialize_padding that only overwrote padding bytes, too.

In order for this to work, either the compiler would have to know and preserve which bytes are uninit (violating the refinement that an uninitialized byte to any initialized value which is not implementable), or it would have to set every byte to 0.

Copy link
Member

@jswrenn jswrenn Apr 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@programmerjake, @chorman0773 The semantics of initialize and freeze are different. The compiler does, in fact, know whether bytes in a type are possibly uninit, because the compiler knows where it has placed padding and union fields. However, the compiler cannot know, statically, whether union-introduced uninit bytes have been overwritten at runtime, and so initialize would either need to unconditionally overwrite union-introduced uninit bytes (initialize), or leave them untouched (initialize_padding).

freeze has the advantage that it preserves the runtime values of union-introduced uninit bytes. However, for the sort of zero-copy (de)serialization done by consumers of bytemuck and zerocopy, this property isn't necessarily important (it's unusual that you'd cast initialized bytes into a MaybeUninit). freeze also has the advantage that it can operate on invalid data.

I don't claim that initialize covers all use-cases of freeze, but I do claim that it satisfies many of the use-cases relevant to Project Safe Transmute, and it does so without some of the drawbacks of freeze:

It's a sufficiently viable and compelling alternative that it deserves some consideration in the Alternatives section of this RFC.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initialize isn't really an alternative IMO so much as it is just a different operation. As far as I can tell, initalize is for when you have &mut T (with already valid T), want &mut [u8] preserving the validity of T, are okay with branching code for initializing through enum, and are okay overwriting any and all union bytes.

Without an additional bound that union isn't used, I don't see this being particularly useful, even for safe transmute, although PST is also likely the most likely to be able to enforce that bound. With the bound it's useful for getting to &[u8] which can then be cast further, but in the abstract I can't see any usage that wants this operation not exclusively as a way to bypass using difficult to express "uninit at the same (or more) byte offsets" bounds.

There's no need for initialize to accept ?Sized, either. Just have initialize for sized types and a separate initialize_slice for slices. I suppose that doesn't work for custom slice tail DST, but that's also status quo for most API.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'll agree that initialize is a completely different operation. It's probably worth at least tangentially mentioning in the Alternatives section, though. Initialize doesn't do a whole lot of good for use cases 2 and 3, though.


Examples of uses:
1. The major use for freeze is to read padding bytes of structs. This can be used for a [generic wrapper around standard atomic types](https://docs.rs/atomic/latest/atomic/struct.Atomic.html).
2. SIMD Code using masks can load a large value by freezing the bytes, doing lanewise arithmetic operations, then doing a masked store of the initialized elements. With additional primitives not specified here, this can allow for efficient partial load operations which prevent logical operations from going out of bounds (such a primitive could be defined to yield uninit for the lane, which could then be frozen).
Copy link
Member

@RalfJung RalfJung Apr 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to rely on us being able to map LLVM poison to Rust "uninit", which is currently not possible.

Same for the next bullet point.

Only the bytes of the return value are frozen. The bytes behind the pointer argument are unmodified.
**The `read_freeze` operation does not freeze any padding bytes of `T` (if any are present), and those are set to uninit after the read as usual.**

`MaybeUninit::freeze` is a safe, by-value version of `read_freeze`. It takes in a `MaybeUninit` and yields an initialized value, which is either exactly `self` if it is already initialized, or some arbitrary value if it is not.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be worth noting that the term "initialized" here refers to the notion of an Abstract Byte in memory being initialized. This is not to be confused with the notion of MaybeUninit being "initialized" in the sense that assume_init() is safe to call. The text says that MaybeUninit::<bool>::uninit().freeze() is initialized but it is obviously not safe to call assume_init on this.

# Future possibilities
[future-possibilities]: #future-possibilities

* With the project-portable-simd, the `Simd` type could support `Simd::load_partial<T, const N: usize>(x: *const T) -> Simd<[MaybeUninit<T>;N]>` (signature to be bikeshed) which could then be frozen lanewise into `Simd<[T;N]>`. With proper design (which is not the subject of this RFC), this could allow optimized loads at allocation boundaries by allowing operations that may physically perform an out-of-bounds read, but instead logically returns uninit for the out-of-bounds portion. This can be used to write an optimized implementation of `memchr`, `strchr`, or `strlen`, or even optimize `UTF-8` encoding and processing.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This relies on a hypothetical new LLVM operation that does a load where out-of-bounds memory is undef, right? That should be mentioned.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or an appropriate masked/partial load that doesn't logically perform the problematic reads at the llvm level.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The text does not indicate a way for portable-simd to have the information to produce such a mask.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: use an expository signature of

Simd::read_select(
    source: *const [T],
    enable: Mask<isize, N>,
) -> Simd<MaybeUninit<T>, N>

This is deliberately imitating the signature of gather_select and gather_select_ptr. While likely not the "correct" signature for a partial read, it can clearly do what the example usage wants to do.

Copy link
Member

@RalfJung RalfJung Apr 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

load_select is likely closer to the intended primitive.

# Future possibilities
[future-possibilities]: #future-possibilities

* With the project-portable-simd, the `Simd` type could support `Simd::load_partial<T, const N: usize>(x: *const T) -> Simd<[MaybeUninit<T>;N]>` (signature to be bikeshed) which could then be frozen lanewise into `Simd<[T;N]>`. With proper design (which is not the subject of this RFC), this could allow optimized loads at allocation boundaries by allowing operations that may physically perform an out-of-bounds read, but instead logically returns uninit for the out-of-bounds portion. This can be used to write an optimized implementation of `memchr`, `strchr`, or `strlen`, or even optimize `UTF-8` encoding and processing.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: use an expository signature of

Simd::read_select(
    source: *const [T],
    enable: Mask<isize, N>,
) -> Simd<MaybeUninit<T>, N>

This is deliberately imitating the signature of gather_select and gather_select_ptr. While likely not the "correct" signature for a partial read, it can clearly do what the example usage wants to do.

@JarredAllen
Copy link

JarredAllen commented Apr 6, 2024

An extra function related to this that I've often found myself wishing existed (name unimportant and can be changed):

impl<const N: usize> [u8; N] {
    pub fn new_freeze() -> Self {
        unsafe { MaybeUninit::<Self>::new().freeze().assume_init() }
    }
}

I mostly find this useful for functions on std::io::Read which require an initialized &mut [u8], for which there's no reason to write a value just for it to be overwritten.

It's not that important, since it's easy to implement on top of the functions provided here, but I think a safe function in the standard library for this case would be nice.

@RalfJung
Copy link
Member

RalfJung commented Apr 19, 2024 via email

@Fishrock123

This comment was marked as resolved.

@CAD97

This comment was marked as resolved.

@chorman0773
Copy link
Author

I think whether or not doing this in inline asm can somehow be justified is largely off-topic for this RFC. It is clearly outside the guidance we give for sound asm/FFI (the interaction with AM state, both how it is read and how it is changed, is not expressible with Rust code).

FTR, I would consider it to be in-scope in so far as the RFC would definitively endorse the operation via the proposed language primitive.

@RalfJung
Copy link
Member

Yes, with this RFC it becomes possible to do it in inline asm. The discussion was about whether it is already possible to do that without this RFC, and IMO that is off-topic here.

@arielb1
Copy link
Contributor

arielb1 commented May 21, 2025

Yes, with this RFC it becomes possible to do it in inline asm. The discussion was about whether it is already possible to do that without this RFC, and IMO that is off-topic here.

Without the RFC you can have "empty" inline asm to be defined as filling a buffer with nondeterministic bytes, which in practice are very likely to contain secrets. What you can't do is be sure that in all cases, these nondeterministic bytes will end up being the same as the previously-valid bytes that were there.

I would rather like the compiler to promise that inline-asm "frozen" bytes, if initialized, are the right ones.

I think it might make sense to have freeze be a perma-unstable abstract machine op and expose it only via inline assembly, or, if we expose freeze via an intrinsic, to make the intrinsic a program error so that sanitizers can error on it.

@RalfJung
Copy link
Member

RalfJung commented May 21, 2025

We have sufficiently many requests for freeze that I don't think declaring it EB is useful. We should explicitly establish an expectation that safe functions, unless documented otherwise, will not leak the contents of uninit memory -- pragmatically, that's probably the best we can do.

The main thing that remains to be resolved here is figuring out the API, IMO. Once we have an API that sufficiently many people agree with, we should ensure the RFC discusses possible alternative APIs and their trade-offs, and then we nominate for the lang team (potentially suggesting a design meeting if there's too many API trade-offs we can't agree on).

@arielb1
Copy link
Contributor

arielb1 commented May 21, 2025

I don't think declaring it EB is useful

Of course, it's not freeze that is EB, it's freezing an undef value. I think it makes sense for it to be EB since it's very likely to be leaking secret information. Of course, sanitizers can be smarter and only error if there is actually a risk of "actual secrets" being leaked.

As far as I can tell, "ordinary Rust libraries" don't use freeze. It is used by applications and kernels for fairly specific things, and having some form of linting to keep it under control to only the expected places sounds smart. EB is just a vehicle for that.

I think the API does not matter at all - I expect that many freezing operations will happen via FFI / inline asm anyway. Even if not, in the standard case where you are talking with an untrusted / buggy thread, the case where you freeze actually-undef data is generally some sort of actual program error, so having it be EB makes sense.

If the cause of the freeze is a debugging tool, I don't think the debugging tool being EB is that bad of a problem. I definitely think that it makes sense for sanitizers to co-operate with debugging tools.

@ijackson
Copy link

We have sufficiently many requests for freeze

It may indeed be that holding back the tide here will be politically impossible. However, I think we should try a lot harder to discourage its use, and in particular to discourage correct-seeming patterns that are capable of leaking secrets to safe code.

If we add this feature, we should say explicitly something along the lines of "This operation is extremely dangerous. It is very hard to use correctly, and very frequently misunderstood by programmers. It is much more hazardous than std::mem::transmute."

Is it even sound to use any non-constant-time operation on the output of freeze? I don't see how code handling crypto keys can reliably prevent them ending up in the output of freeze in a distant library.

Can we have tooling that will let us forbid use of freeze in an entire program? That will allow projects (eg, Tor which I'm working on) to detect if our dependencies start to use freeze and to push back (or choose different dependencies).

We should explicitly establish an expectation that safe functions, unless documented otherwise, will not leak the contents of uninit memory -- pragmatically, that's probably the best we can do.

I think we can do much better than this. And, indeed, we must. I think the severity and persistence of misuinderstanding shown in this RFC discussion demonstrates that many programmers are very likely to write broken code, because they don't really believe what our opsem experts are saying.

We should give several examples, in the documentation, of correct-seeming patterns which are in fact unsound.

And, before we adopt this RFC, the motivation section should give several correct patterns, including all necessary precautions, in a lot more detail than currently shown.

Also, would someone please expand "EB"? I found a paper by Julien Vanegue which defines it as "Exploitable Bug(s)" but that doesn't seem to make sense in the current context.

@arielb1
Copy link
Contributor

arielb1 commented May 21, 2025

Also, would someone please expand "EB"? I found a paper by Julien Vanegue which defines it as "Exploitable Bug(s)" but that doesn't seem to make sense in the current context.

EB = erroneous behavior = "program error".

It's a way of saying that the behavior is defined, but in some sense "considered a program error" so that linters and sanitizers are allowed to trap it.

Since freeze is inherently dangerous (since it's very likely to leak secrets), I think having it as EB makes sense.

I think that doing the freeze via inline assembly normally makes more sense than doing it in Rust, since it allows tighter control over the non-determinism.

@ijackson
Copy link

EB = erroneous behavior = "program error".

It's a way of saying that the behavior is defined, but in some sense "considered a program error" so that linters and sanitizers are allowed to trap

Ah. Thanks. This makes sense to me.

That would go well with my suggestion to be able to exclude it statically for a whole program.

@ojeda
Copy link

ojeda commented May 21, 2025

so that linters and sanitizers are allowed to trap it.

At the end of the day, in practice, certain tooling may end up doing anything, even for correct, fully defined things, which is good! Making it explicitly wrong was the key idea behind EB, in my view (in C there were arguments that UB was needed for that, due to the "allow sanitizers" part, which I always pushed against).

@arielb1
Copy link
Contributor

arielb1 commented May 21, 2025

in C there were arguments that UB was needed for that, due to the "allow sanitizers" part, which I always pushed against

yea, the point of EB is to allow for sanitizers to do well-defined implementation-defined operations like printing an error message and aborting the program while not allowing arbitrary things, while making it clear that "good" programs should not have "false positives".

UB is very bad from a programmer's perspective since it allows for all sorts of bad behavior

@RalfJung
Copy link
Member

RalfJung commented May 21, 2025

The default expectation with EB is that it's a bug to trigger it -- e.g., an integer arithmetic overflow. It makes no sense to me to have an operation that is defined to always be a bug to call, then we might as well not add it. So EB is the wrong hammer here.

@ijackson

It is much more hazardous than std::mem::transmute."

In which sense do you think this is the case? I would not agree to that statement. freeze seems much less likely to eat your laundry than transmute.

Is it even sound to use any non-constant-time operation on the output of freeze? I don't see how code handling crypto keys can reliably prevent them ending up in the output of freeze in a distant library.

Of course it is sound. Rust soundness is not concerned with execution time at all. If we had a notion of "constant time" (which sadly we don't, partially because nobody around us in the ecosystem does), it would have to have some good way to deal with non-determinism, of which we have a lot more than just freeze. (The fact that non-determinism and information flow control / non-interference don't go well together is well-known.) Anyway, given that we do not have a notion of "constant time", I'd rather not derail this discussion by also painting that bikeshed. Inline assembly and sufficiently careless code can already leak secret keys today, the state-of-the-art tools to prevent that (e.g. zeroing out memory) are unsatisfying, but adding freeze does not make it any worse from a practical standpoint -- the "sound code cannot leak secrets" property relies on code being sound in the first place, after all, which is rarely formally established. If anything, by establishing a clear expectation that functions leaking the contents of uninit memory should be documented as such, this RFC can help improve the situation here.

Can we have tooling that will let us forbid use of freeze in an entire program? That will allow projects (eg, Tor which I'm working on) to detect if our dependencies start to use freeze and to push back (or choose different dependencies).

It would be easy to add a flag in Miri that forbids freeze, and I'd accept such a PR. Also for the regular compiler we could in principle have a flag that compiles freeze to a trap, but that would be an extremely unusual thing to do. After all, there are entirely correct ways of using freeze that will not actually leak anything, so freeze is not inherently more dangerous than UB is -- whatever process Tor uses to audit the unsafe code they pull in for potential UB could also involve a freeze audit.

It would also be easy to have a clippy lint against freeze; I can't speak for the clippy team about whether they'd accept such a lint.

@arielb1
Copy link
Contributor

arielb1 commented May 21, 2025

The default expectation with EB is that it's a bug to trigger it -- e.g., an integer arithmetic overflow. It makes no sense to me to have an operation that is defined to always be a bug to call, then we might as well not add it. So EB is the wrong hammer here.

It's not always a bug to call, it's only "either a bug or a smell" to call if the input is undef. You can call it EB, you can call it "suspect behavior that is not EB but ought to be lintable against", but I think that's the same concept.

@RalfJung
Copy link
Member

There's nothing smelly about data structures like this one.

@arielb1
Copy link
Contributor

arielb1 commented May 21, 2025

I definitely see why the Tor etc. people would not be happy about such a data structure.

I also think that the main use case for this data structure is fine with not using undef data but rather maintaining the "undefinedness" within a context.

@RalfJung
Copy link
Member

I also think that the main use case for this data structure is fine with not using undef data but rather maintaining the "undefinedness" within a context.

I have no idea what you mean by this. What this data structure needs is a primitive to "read this memory and just give me some arbitrary but fixed integer if it is not initialized". The data structure also ensures that this arbitrary integer never leaves this code.

@zachs18
Copy link
Contributor

zachs18 commented May 21, 2025

I also think that the main use case for this data structure is fine with not using undef data but rather maintaining the "undefinedness" within a context.

If you are using that sparse set data structure, but your backing data is never actually undef and is always initialized (but just possibly "junk"), then you do not need to use freeze at all, you can just read the initialized (but possibly "junk") values normally without any UB. But note that that somewhat reduces the benefits of the data structure, since you now have to initialize the whole sparse array when you create the data structure to make sure it does not contain any undef, or in some other way ensure that the backing data is at worst "junk" and never undef-uninitialized.

@chorman0773
Copy link
Author

The main thing that remains to be resolved here is figuring out the API, IMO. Once we have an API that sufficiently many people agree with, we should ensure the RFC discusses possible alternative APIs and their trade-offs, and then we nominate for the lang team (potentially suggesting a design meeting if there's too many API trade-offs we can't agree on).

I'm not sure what much there is to determine for API. The only major question is whether or not to support MaybeUninit::freeze_in_place(&mut self) (and maybe a core::ptr version of that), which I do already address why that isn't in the current version of the RFC (it is a potential future possibility, though). freeze_by_ref is ruled out by both AM-fiat, and LLVM-fiat.


(See also [XKCD 221](https://xkcd.com/221/))

Note that the value `4` was chosen for expository purposes only, and the same optimization could be validly replace by any other constant, or not at all.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it can also be replaced with variables from functions it's inlined into.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can be replaced by basically anything, the RFC does not and should not try to have an exhaustive list.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that note seems like an exhaustive list to me, so maybe:

Suggested change
Note that the value `4` was chosen for expository purposes only, and the same optimization could be validly replace by any other constant, or not at all.
Note that the value `4` was chosen for expository purposes only, and the same optimization could validly replace the result with any other constant, though the result can also easily be a non-constant.

Comment on lines +22 to +24
3. Low level libraries, such as software floating-point implementations, used to provide operations for compilers where uninit is considered a valid value for the provided operations.
* Along the same lines, a possible fast floating-point operation set that yields uninit on invalid (such as NaN or Infinite) results, stored as `MaybeUninit`, then frozen upon return as `f32`/`f64`.
* Note that such operations require compiler support, and these operations are *not* defined by this RFC.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this is not a great motivation since it requires a bunch more work throughout the stack -- currently LLVM isn't very happy with poison stored in memory, so this kind of API is still pretty far off.

OTOH I also feel like we've seen enough people ask for freeze that surely we have more use-cases...?

@RalfJung
Copy link
Member

@chorman0773 there are a whole bunch of threads up there that have not been resolved nor answered (many over a year old) -- will you have time to update that to push the RFC forward?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-lang Relevant to the language team, which will review and decide on the RFC. T-libs-api Relevant to the library API team, which will review and decide on the RFC. T-opsem Relevant to the operational semantics team, which will review and decide on the RFC.

Projects

None yet

Development

Successfully merging this pull request may close these issues.