Valgrind does track usage of uninitialized memory. You can try it with this program, compiled with --release:
use std::hint::black_box;
use std::mem::MaybeUninit;
fn main() {
//let uninit = black_box(Box::new(MaybeUninit::<u32>::uninit()));
let uninit = black_box(MaybeUninit::<u32>::uninit());
let value = unsafe { uninit.as_ptr().read() };
println!("{}", value > 0);
}
Valgrind correctly reports that the value > 0 branch depends on uninitialized value, for both variants of let uninit = (ie, both on stack and on the heap).
The problem with unsafe fn freeze<T>(src: &mut MaybeUninit<T>) -> &mut T is that it would have to overwrite the memory that src points to with defined bytes, which would defeat the purpose, you could as well just initialize src properly with safe code.
For writing to memory, yes that can be problematic. But reading should always be permitted though.
From the article:
The first reflex might be to say that this is obviously UB: that stack memory might be subject to noalias constraints (due to a mutable reference pointing to the stack); you can’t just read from memory that you don’t have permission to read. However, that presupposes that the story for this asm block involves reading memory. An alternative story is to say that the asm block just returns some arbitrary, non-deterministically chosen value. The upside of this story is that, as long as the read doesn’t trap, the story is always correct according to our rules: whatever the assembly code actually does, it surely refines returning an arbitrary value. However, the downside of this story is that when reasoning about our code, we cannot make any assumptions about the value we read!
It's totally fine if the semantics on the Rust side says the bytes read are arbitrary; that's what we wanted with read_freeze in the first place, and that's the story we would get with the asm version.
Now, the asm itself has a semantic (that is independent from Rust and can't be affected by optimizations done in the Rust side) and analyzing that we would conclude the bytes read aren't merely arbitrary. But the Rust side wouldn't know that.
My understanding is that if the "story" for the asm block that reads a possibly uninitialized byte was "this returns an arbitrary byte", then the Rust code would have to assume that the returned value is arbitrary even if the byte is actually initialized. The important thing about the freeze operation is that it returns arbitrary byte only if the byte is uninitialized in memory, and the safe Rust "story" for the asm block can't express that, because there's no way how safe Rust code could find out whether a byte in memory is initialized or not.
Note that in the conclusion of the article, Ralf Jung writes:
This is why I am proposing to take the conservative approach: only allow inline asm blocks that are obviously compatible with all universal properties of actual Rust code, because their story can be expressed as actual Rust code. If there is an operation we want to allow that currently has no valid story, we should just add a new language operation, which corresponds to officially blessing that operation as one the compiler will keep respecting.
where "new language operation" links to the freeze RFC. So it seems that Ralf Jung is saying that until we have the freeze intrinsic, an assembly block that emulates freeze would not, in fact, have a valid story.
I have a question: why all proposals about freeze I see have T in some form as output? It looks like the following function should be sound, safe and serve the quoted purpose well (ideally without commenting out size_of::<T>()):
// core::mem
/// Return the in-memory representation of the `value` which has all
/// uninitialized bytes replaced with arbitrary bytes.
///
/// Returned slice can be converted back into `T` by
/// [core::ptr::read_unaligned] under the following conditions:
///
/// - If `T: static` and has no pointers and reading is performed in the
/// same application instance.
/// - If `T: 'static` and has no references or pointers and has stable ABI
/// (for example, `#[repr(C)]`) and application which performs read
/// was compiled for the same target.
///
/// # Examples
///
/// ```
/// let data = (12u8, 3456u16);
/// let slice = core::mem::freeze(&data);
/// let data_clone: (u8, u16) = unsafe {
/// core::ptr::read_unaligned(slice.as_ptr().cast())
/// };
/// assert_eq!(data, data_clone);
/// ```
pub const fn freeze<T: Sized>(value: &T) -> &[u8 /* ; size_of::<T>() */] {
…
}
Though it may make sense to reject freeze calls if T is not constructible at the call site, but this part is not a memory safety issue.
This should be safe because if freeze operation is valid in the first place then this function will either yield valid bytes found in T or frozen uninitialized bytes: u8 itself has no invalid bit patterns and something like &2u8 being passed as &bool means UB has happened earlier and not here.
The problem with fn freeze<T>(value: &T) -> &[u8] is that to freeze the uninitialized bytes, the function would have to write into the memory pointed to by value, which can't be done through a &T, so the function would have to be fn freeze(value: &mut T) -> &mut [u8], and its implementation would have to read all bytes from *value, freeze them, and write them back into memory.
Another issue is that &[u8; size_of::<T>()] can't afaik be done in stable Rust.
If the semantics of freeze require &mut T to write back (I thought one can just use it to declare memory initialized), then accepting &mut T sounds OK. But return type must be &[u8], not &mut [u8] otherwise it enables safe code to do something like
let mut b = true;
core::mem::freeze(&mut b)[0] = 2;
// b now is 2 which is invalid for bool.
Yes, this is why I had this part commented out in signature I have written. I would have preferred function returning array as size is known at compile time, but returning slice instead should not even make code which requires array impossible to write, just inconvenient. And for some uses only slices are needed.
The plan is for Rust to use the byte type as the underlying LLVM type for MaybeUninit (and for unions in general).
That's not the same as the proposed operation though. You cannot write a story that behaves like freeze. The best story you can write is "unconditionally overwrite everything with arbitrary initialized bits", which will destroy any prior contents of this memory even if it was already initialized.
LLVM's undef is not very relevant here. Nothing like LLVM's undef exists in the Rust semantics, and LLVM is in the (slow) process of getting rid of undef.
Rust's uninitialized memory behaves basically like LLVM's poison, except we have more UB on the Rust side (it's UB to even have an uninitialized integer, let alone call + on it, unlike LLVM poison where that produces poison).
At the same time we do take care to map our Rust semantics to LLVM IR that makes sense with the LLVM semantics, and I don't think we need the byte type for this to be correct. We currently represent MaybeUninit with i32 which is fine because we ensure that there's never an LLVM poison value in memory. Therefore the existing freeze support is fine, at least for freezing individual scalar values.
The return type &[u8 /* ; size_of::<T>() */] is problematic since u8 cannot hold provenance, so if you want to preserve the original contents of T insofar as they are initialized, this is a bad idea.
Taking a step back, I am convinced at this point that we want something like freeze. I am just not sure what the best API for it is. As you noted, freezing only makes sense if we also involve the byte type, and that means MaybeUninit should be involved as that's the surface Rust type corresponding to the LLVM byte type. We could have the read_freeze you propose as a convenient wrapper, but the underlying primitive should probably be something like
But even then we have the issue that LLVM freeze only works on LLVM SSA values, but T here may be a non-scalar type that does not have a corresponding SSA value. What we really need is a freezing version of memcpy, and that either needs to be in LLVM or we'll have to implement it ourselves.
So I suspect a good version of this will need some work on the LLVM side, though we could start with a less-than-optimal hand-written memcpy loop that has freeze in it.
Can't we just use inline assembly? If we continue the storytelling approach to LLVM itself, we could pick a story that says the asm reads all bytes, freezes them, and writes them back. Since LLVM does have freezing this works, unlike in surface Rust.
That won't actually freeze the value if the memory we are writing to is MADV_FREE memory. (Uninitialized memory exists for real, in the sense of memory that can have different values if you read it multiple times even if nobody wrote to it.) The entire point of having a "by-value" freeze like read_freeze is to avoid having to worry about that. A freezing memcpy would also avoid that.
In-place freeze is more complicated due to MADV_FREE and should not be part of the first MVP in my opinion. (This was already extensively discussed in the RFC but unfortunately we're now repeating all the same points again. This is why new proposals should summarize the state of prior discussions. Otherwise we are wasting a lot of time re-treading known ground.)
Indeed, we must write one byte per page (using volatile writes or inside the asm). But this still leaves most bytes unwritten, so it still has a perf benefit.
Yes, the problem can probably be solved, but there are a bunch of extra problems. Trying to solve too many problems at once will make the entire thing take way longer than if we take one step after the other.
As we discussed on Github, I plan to write an RFC for freeze, summarize all the discussions in the RFC and various other places, and propose an approach for going forward. At the moment, it seems to me that the most straightforward approach is to add a function that freezes a MaybeUnint<T> by value, and not by reference:
There are two reasons why I think that taking the input by value and not by reference is preferable:
At least for scalar types, this function will compile to a single freeze LLVM instruction and thus to no machine code at all. If the input was a reference, it would have to compile to a load followed by a freeze; even though the compiler should be able to optimize the load away in some cases, it seems preferable to expose the most primitive operation, and then perhaps build more operations on top of it.
MaybeUninit<T> is Copy and Clone only when T: Copy, which I understand to be a guardrail against accidental duplication of non-Copy values. If we exposed fn frozen(&self) -> MaybeUninit<T>, taking its input by reference, it would in effect be a copy operation for all T, including T: !Copy, which would bypass this guardrail.
(btw, I like the name frozen, because it makes it more clear that only the returned value is frozen, and the original bits in memory continue to be possibly uninit.)
The reason why I mentioned the undef value in LLVM is that afaik we can't correctly implement the freeze operation today in LLVM 22 even for scalar types, because my understanding is that if we load an integer type from memory, and just a single bit in memory is undefined, LLVM will treat the whole integer as undefined, and a freeze operation on it may replace the whole integer with an arbitrary bit pattern, not just the uninitialized bits.
That's not correct. LLVM has different kinds of "not fully defined" values: undef and poison. For uniniitalized memory, it currently uses undef, and undef is meant to be properly preserved on a per-bit basis. undef is also fundamentally broken and internally inconsistent so we are slowly moving away from it. But meanwhile, it is fine to continue to assume that i32 can hold partially initialized values. We already rely on that for MaybeUninit<i32> to behave the way we document. We might as well continue to rely on it for freeze.
Thanks for the clarification! My understanding was that undef values tend to keep their defined bits in practice, but the "Undefined Values" section in the LLVM language reference does not make this promise; it seems to imply that a value is either defined or undef, with no mention of values that are partly defined and partly undefined.
However, if we can rely on LLVM 22 to preserve defined bits in undef values, that's only good news, because the freeze operation in Rust won't have to wait for LLVM 23 and the byte type
LLVM tracks definedness per bit, not per byte (at least in the "byte type", which the compiler should eventually use for MaybeUninit<T>). There is currently no way how to get a byte with a mix of initialized and uninitialized bits in Rust, but for example in C it's possible with bitfields.
I think that Rust's semantics in this regard are still fluid enough that it's an open question whether we want to treat memory as defined per bit or per byte, but if we decide to track definedness per byte, we will lose the ability to read C bitfields.
Are there some benefits of tracking definedness per byte, or something that prevents us from tracking it per bit, that I missed?
I'm not sure why you would talk about what LLVM does, since this thread is about adding a new operation to Rust. In that case, what actually matters is Rust's semantics.
LLVM is only relevant insofar as we must be able to map the Rust semantics onto the LLVM semantics when using LLVM as the backend.
I am talking about what LLVM does, because it forms the upper bound of how we can reasonably define the Rust semantics. LLVM can track definedness by bit, so in Rust we can also track uninitialized values by bit, if we decide to.
Of course, we can also decide to track uninitialized values by byte, but what would be the advantages of this decision?