I am trying to convert a program from C code to Rust. A lot of the program consists of creating and reading buffers, which are mostly little endian integers.
I am a little bit sensitive regarding performance, so I did the following experiment: Compiler Explorer
The unsafe version obviously is efficient, but it really looks horrible in code and I want to avoid unsafe as much as possible. Why does the Rust compiler create 4 checks in the safe version and how can I avoid it?
The code in the compiler explorer:
pub fn read_u32(data: &[u8], pos: usize) -> u32 {
return (data[pos] as u32) + ((data[pos + 1] as u32) << 8) + ((data[pos + 2] as u32) << 16) + ((data[pos + 3] as u32) << 24);
}
pub fn read_u32_unsafe(data: &[u8], pos: usize) -> u32 {
if pos + 4 > data.len() {
panic!();
}
unsafe {
let dptr = data.as_ptr();
return (*dptr.offset(pos as isize) as u32) + ((*dptr.offset(pos as isize + 1) as u32) << 8) + ((*dptr.offset(pos as isize + 2) as u32) << 16) + ((*dptr.offset(pos as isize + 3) as u32) << 24);
}
}
That code is unsound, because it allows me to trigger UB from safe code. Don't do that.
The safe version is the right choice; both checks are fundamental.
That said, one can add preconditions that subsume some of the checks, which can sometimes help them optimize out when used. For example, you could do this:
pub fn read_u32(data: &[u8], pos: usize) -> u32 {
assert!(pos <= isize::max_value() as usize, "an index that large is never valid");
let mut arr = [0; 4];
arr.copy_from_slice(&data[pos..pos+4]);
return u32::from_le_bytes(arr);
}
That way there's still only two checks, because LLVM knows that adding 4 to something that passed the assert never overflows, so it removes the second slice_index_len_fail possibility. And it might be more obvious, once inlined, that the pos passed in is trivially smaller than the limit, letting it remove that check too.
That code is also missing safety conditions. Specifically,
#[inline]
///Get reference to the value from slice
///
///This function is UB if `slice.len() < mem::size_of::<T>()`
pub unsafe fn as_type_unchecked<T: Sized>(slice: &[u8]) -> &T {
&*(slice.as_ptr() as *const T)
}
That's not checking alignment, so should document that it's UB to call if mem::align_if::<T>() > 1 and the slice.as_ptr() is not aligned to that larger amount.
Demonstration that it performs UB when when I meet the slice-long-enough condition mentioned in the comment:
Practically any UB is bad, because the mere existence of UB in your code gives the compiler backend, LLVM, free license to arbitrarily scramble your code in the quest for optimization, and to do so differently any time either your code, an included library, or the compiler itself has changed. If you don't want your code to work reliably now or in the future, go for UB; otherwise avoid it like the plague.
The problem with Rust is that it is bad at defining what is UB.
In particular I do know that strict aliasing rule violation might be a problem to Cang, but not in case of LLVM IR
Therefore in this particular case I doubtful of actual code being unsafe.
While it is true that some areas of Rust UB need clarification, this particular case is pretty clear-cut, as it is prominently featured at the top of the UB list of the Rust reference: Behavior considered undefined - The Rust Reference .
Also, note that Rust's aliasing rules are quite different from the strict aliasing rules of C. In particular, it is not and will never be UB to use a T* and U* pointing to the same memory region in Rust, which is a huge relief for bit hacking fans What Rust cares about (while C doesn't) is that you never have an &mut T and another reference live at the same time, and Stacked Borrows is a pretty well-advanced attempt at defining what that means exactly.
No, this is very definitely UB. If you want to read from an unaligned pointer, use ptr::read_unaligned instead.
The whole danger of UB is that is sometimes appears to work; that's the whole reason that unsafe giving you a subset of code to audit is valuable. (I agree that this is one of the things that people often think is fine. Historically I've seen them disabused of this notion most often when they need to compile for ARM as well, though I don't know whether that still hardware faults the way it once did. Just do it right; it's not any slower.)
Which leaves me wondering why this is not the default behavior. Anyone know why this even is a thing in 2020? It's not like the majority of users are programming hardware from 1987, right?
Because it's platform dependent. The implied part of that statement was if the UB appears to work. LLVM knows that if you call ptr::read_unaligned to read an i32 when compiling for x86, it can just emit a normal load instruction, because x86 allows that. But on other platforms, or for different types, it can't, and needs to emit different code.
I'm not willing to have every pointer read -- including, say, array indexing -- use the slower instruction just because someone might want to read unaligned sometimes.
True, but irrelevant.
Alignment is still required by fairly-modern ARM hardware, because it's way simpler in silicon, and thus can be done for lower power. These days my stuff is all x64, but as of about 2015 I had firsthand experience of code that needed fixing for this problem to run on new ARM processors.
yeah but why are the rust functions not called read and read_aligned? The read function would of course emit a simple read on systems that do not require alignment