tl;dr
I would like to add the following function to the language and the standard library:
// in `core::ptr`
pub unsafe fn read_freeze<T>(ptr: *const T) -> T;
This function works like core::ptr::read(), but if the value is not initialized, it returns an arbitrary bit pattern instead of invoking undefined behavior. The caller must ensure that the ptr is valid for reads and that an arbitrary bit pattern forms a valid value of type T.
(This requires a new intrinsic that produces the LLVM freeze instruction, so it also affects the compiler, it's not just an addition to the standard library.)
My questions are whether it makes sense, what should I do next if I want to implement it, and how long do we have to wait until the compiler can use features introduced in LLVM 23, which will make this function much more useful?
Motivation
I need to "serialize" a struct T by converting &T to a &[u8; size_of::<T>()] and copying it to a memory buffer to be shipped over the network. However, that's not possible in Rust, at least not safely: T may contain uninitialized bytes due to padding, and reading these bytes to u8 is undefined behavior, because u8 must contain only initialized (defined) values.
But I still need to read those bytes! I don't care about the values of the uninitialized bytes, it should be easy for the compiler to just read the unspecified bitpattern that happens to be there and treat it as an arbitrary but defined u8. Rust currently can't do that, but it turns out that in LLVM, undefined values can be tamed by the freeze instruction, which converts undefined and poison values to an arbitrary value and leaves defined values intact.
Approach 1: MaybeUninit<T>::freeze()
There has been prior discussion about exposing the freeze operation in Rust as follows:
impl<T> MaybeUninit<T> {
pub fn freeze(&self) -> MaybeUninit<T>;
}
This would allow me to write my "serialization" function as follows:
fn serialize_by_copying<T: Copy>(value: &T, dst: &mut [u8]) {
assert_eq!(mem::size_of::<T>(), dst.len());
let value_ptr = value as *const T as *const MaybeUninit<u8>;
for i in 0..mem::size_of::<T>() {
// this is safe, `value_ptr.add(i)` points inside the `value`, but we have to
// read a `MaybeUninit<u8>` because the byte at offset `i` may be uninitialized
// padding
let byte_maybe_uninit: MaybeUninit<u8> = unsafe { value_ptr.add(i).read() }
// now we freeze the `MaybeUninit<u8>`, so it's initialized now
let byte_frozen: MaybeUninit<u8> = byte_maybe_uninit.freeze();
// this is safe, because `byte_frozen` is initialized and every bit pattern is a valid `u8`
let byte: u8 = unsafe { byte_frozen.assume_init(); };
dst[i] = byte;
}
}
I tried to implement this as a proof of concept, but I got stuck trying to implement an intrinsic that operates on MaybeUninit<T>. I also realized that this might be unnecessary complex, because really the only place where we can encounter uninitialized values is when reading from memory.
Approach 2: ptr::read_freeze()
Instead of trying to implement freezing for arbitrary values, it is enough to expose a fused load + freeze operation:
unsafe fn read_freeze<T>(ptr: *const T) -> T;
This operation loads a value from memory and immediately freezes it, so the uninitialized/undefined value is never exposed to the language. However, the caller must ensure that the pointer is valid for reads and that the frozen bits form a valid value of type T (so read_freeze::<bool>() of an uninitialized byte is unsound, because the frozen byte can have any value, and bool must be either 0 or 1).
With this function, my serialization function becomes:
fn serialize_by_copying<T: Copy>(value: &T, dst: &mut [u8]) {
assert_eq!(mem::size_of::<T>(), dst.len());
let value_ptr = value as *const T as *const MaybeUninit<u8>;
for i in 0..mem::size_of::<T>() {
// this is safe, because `value_ptr.add(i)` is valid for reads of `u8`, and
// arbitrary bit pattern is a valid `u8`
let byte: MaybeUninit<u8> = unsafe { value_ptr.add(i).read_freeze() }
dst[i] = byte;
}
}
I have a proof of concept implementation of read_freeze(), which generates the LLVM load followed by a freeze.
Question: does this make sense? What would be the next step if I wanted to add this function to the standard library and to the compiler, does this require an RFC or should I just open a PR?
The catch
However, there is one problem with this function: if some of the loaded bits are initialized and the others are not initialized, we would like to keep the initialized bits unchanged and only set the uninitialized bits to arbitrary values. However, in my implementation, if any loaded bit is uninitialized, the whole value will be replaced by an arbitrary bit pattern! The reason is that read_freeze::<u32>() produces LLVM code that looks like this:
%loaded = load i32, ptr %ptr, align 4
%result = freeze i32 %loaded
The problem is that a value of type i32 does not track definedness for each bit: it is either a defined integer or undef. This means that a single undefined bit "taints" the whole i32 value.
It turns out that LLVM actually has a solution for exactly this problem: the "byte" type. Somewhat confusingly, a "byte type" is not 8 bits wide but it can have an arbitrary bit width, e.g. a b32 is a byte type that is 32 bits wide. The important thing about byte types is that they faithfully represent content of memory, so each bit is either 0, 1 or undefined. This means that we can load a b32, freeze it, and then convert it to an i32 type:
%loaded = load b32, ptr %ptr, align 4
%frozen = freeze b32, %loaded
%result = bitcast b32 %frozen to i32
This sequence of operations preserves all initialized bits, and replaces only undefined bits with arbitrary values.
(The byte type is one part of the effort to remove undef values from LLVM and fix some longstanding LLVM bugs.)
Unfortunately, the byte type was added relatively recently, and it's only available in LLVM 23, which hasn't even been released yet. There is already a MR that updates the compiler to LLVM 23, but it will probably take some time for it to be merged. Moreover, the rustc-dev-guide says that one or two preceding LLVM versions are typically supported.
Question: when will rustc be able to use features introduced by LLVM 23, like the byte type? Will we have to wait for the next year or two, until LLVM 24 or LLVM 25 is released?