I totally understand not wanting to promise things get zeroed, but I don't really understand why full UB, instead of just "they have whatever value is initially in memory / the register / the compiler chose" is so much better.
Has anyone ever done a performance comparison between UB and freezing I wonder? I can't find one.
Also, an uninitialized value might be in a memory page that gets reclaimed and then mapped in again, in which case (because it hasn’t been written to) the OS doesn’t guarantee it will have the same value the second time. There was recently a bug discovered in one of the few algorithms that uses uninitialized values, because of this effect.
I would imagine there isn't that many cases where we are reading uninitalised memory and counting on that reading not saving a value. It would happen when reading in 8-byte blocks for alignment, but does it happen that much elsewhere?
The only way to really know is to test this. Compilers and their optimizations depend on a lot of things. Even the order and layout of instructions can matter due to the instruction cache. You can always go and make the guarantee later on, but undoing it would be impossible.
it pretty much requires the compiler to initialize all values when they first "appear"
except that this is impossible and outright hazardous if pointers are involved
But doable for a small subset like e.g.
- stack values (but would inhibit optimizations, potentially pretty badly)
- some allocations e.g. I/O buffers, (except C alloc has no idea that you are allocating an I/O buffer)
Can you provide (on say x86_64) an example of this, other than the case where the compiler prunes cases based on characterizing certain paths as UB? In other words, a case where "an uninitialized value is well-defined but can be different on each read" allows more performance optimization than "the value will be the same on each read".
> Also, an uninitialized value might be in a memory page that gets reclaimed and then mapped in again, in which case (because it hasn’t been written to) the OS doesn’t guarantee it will have the same value the second time. There was recently a bug discovered in one of the few algorithms that uses uninitialized values, because of this effect.
This does not sound correct to me, at least for Linux (assuming one isn't directly requesting such behavior with madvise or something). Do you have more information?
And it definitely does allow some optimisation. But probably nothing significant on modern out-of-order machines.
what is out there is still pretty wield
just slightly less
> probably nothing significant on modern out-of-order machines.
having no UB at all will kill a lot of optimizations still relevant today (and won't match anymore to hardware as some UB is on hardware level)
out of order machines aren't magically fixing that, just makes some less optimized code work better, but not all
and a lot of low energy/cheap hardware does have no or very very limited out of order capabilities so it's still very relevant and likely will stay very relevant for a very long time
and realize sometimes the UB is even in the hardware registers
and that the same logical memory address might have 5 different values in hardware at the same time without you having a bug
and other fun like that
so the insanity is reality not the compiler
(through IMHO in C and especially C++ the insanity is how easily you might accidentally run into UB without doing any fancy trickery but just dumb not hot every day code)
This seems very sensitive to specific definitions that others might not share. DRAM is provided with a spec sheet that defines its behavior (if you write to an address, you’ll read back the same value from the same address in the future) under certain conditions. If you violate those conditions, the behavior is… undefined. If you operate DRAM with the wrong refresh timing, or temperature, or voltage, or ionizing radiation level, you may see strange behavior. Even non-local behavior, where the value read from one cell depends on other cells (RowHammer). How is this not UB?
I'm not exaggerating by a legalistic interpretation, and I'm only slightly exaggerating in practice. UB can do some really weird, unintuitive stuff in practice on real hardware:
https://mohitmv.github.io/blog/Shocking-Undefined-Behaviour-...
The point is that this extreme UB should never happen. It was a choice of the compiler implementors, and rather than fix this, they allowed the escape hatch of UB in the spec. It would be more sensible for the compiler to say that, e.g., accessing uninitialized memory results in a nonspecified value, or even possibly multiple different nonspecified values if accessed from different threads. That captures what we expect to happen, but would be (according to C language spec lawyers) defined behavior.
In practice, it would mean that compliant compilers will ensure that any situation in which uninitialized memory could be accessed would not result in weird edge case page faults on certain architectures or whatever that could in fact lead to wacky UB situations.
This is not an unreasonable ask.
But isn't this exactly parallel to the Rowhammer case in DRAM? When operating at the edge of the spec, the behavior of DRAM becomes undefined. (And, of course, one challenge with Rowhammer was about /which/ edge of the spec this happened on.) In this case, writing one physical address altered the contents of other physical addresses. This is "really weird, unintuitive stuff … on real hardware." And of course we can (and do) ask DRAM vendors to not take advantage of this undefined behavior; but they do so as an optimization, allowing slightly smaller and more closely spaced DRAM cells, and thus higher density DRAM dice for the same price. Just like it's possible to work with a language with fully-defined semantics at the cost of performance, it's possible to buy DRAM with much wider specifications and more clearly defined behavior up to the edges of those specifications… at the cost of performance.
Extreme UB in both hardware and software is a choice of priorities. You may favor giving up performance capabilities to achieve a more understandable system (so do I! I do most of my work on in-order lockstep processors with on-die ECC SRAM to maximize understandability of failure modes), but the market as a whole clearly does not, in both hardware and software.
I'm not saying that a computer architecture should be UB-free. That would be awesome if it could be done, but in practice probably a bridge too far. But a compiler should map high-level directives into low-level implementations on a specific architecture using constructs that do not result in UB. This is not too much to ask.
A compiler can't reasonably protect you from rowhammer attacks. But it should guarantee that, barring hardware errors, accessing uninitialized memory has no effect other than something sensible like returning unspecified contents, or causing a memory access exception, or whatever. It should be defined up front what the behavior is, even if some of the runtime values are unpredictable.
As a more concrete example, most languages these days clearly define what happens in signed integer overflow: the thing that you expect to happen in any two's complement machine (char)127 + (char)1 == -128. C treats this as undefined behavior, and as mentioned in my link above that can cause what should be a finite loop (with or without overflow) to compile as an infinite loop. This "optimization" step by the compiler should never have happened. C resists changing this because C compilers exist for non-twos-complement architectures where the behavior would be different. IMHO the correct approach would be to require THOSE weird esoteric architectures to compile in extra overflow checks (possibly disabled with opt-in compiler flags that explicitly violate the standard), rather than burden every C developer everywhere with the mess that is signed arithmetic overflow UB.
It's a matter of expectations. Any but the most junior programmers expect that signed integer overflow will not be portable. That's fine. But they expect a sensible result (e.g. wrap around on two's complement machines), even if it is non-portable. They don't expect the compiler to silently and sneakily change the logic of their program to something fundamentally different, because UB means the compiler can do whatever tf it wants.
But that's exactly the point. The "bug" of RowHammer was that it occurred slightly on the "allowed" side of the envelope, at acceptably-low refresh rates. The "UB" of RowHammer and a hundred other observable effects is that, on the "disallowed" side of the envelope, the behavior is undefined. The system designer gets to choose at what probability they are on each side of the envelope, and the trade-offs are very much optimization opportunities.
Writing software in C that may exhibit undefined behavior is exactly this -- it's choosing, as a software engineer, to be on the far side of the specification envelope. In exchange, you get access to several powerful optimizations, some at the compiler level, and some at the career level (if you think that not needing to learn to use understand your language properly is at time optimization, at least).
There most definitely is.
In the ARM documentation this is referred to as “UNPREDICTABLE”. The outcome is not defined. It may work. It may not. It may put garbage data in a register.
https://mohitmv.github.io/blog/Shocking-Undefined-Behaviour-...
UB is about declaring programs invalid with a snide "don't do that", not about incorrect execution due to an incorrect specification. E.g. speculative execution running in privileged mode due to a prior syscall is just a plain hardware bug. It's not undefined behavior. In fact, the bug in question is extremely well defined.
The closest thing to reading undefined behavior is reading a "don't care" or VHDL's 'U' value from a std_ulogic and even those are well defined in simulation, just not in physical hardware, but even there they can only ever be as bad as reading a garbage value. Since a lot of the hardware design is non-programmable, there is also usually no way to exploit it.
In short, the moment you enable integer-to-pointer conversions (assuming your target has a flat address space), you create pointer provenance problems whose only resolution is that some things have to be UB.
It may seem nitpicky, but the downside of relying on implementation defined or unspecified behavior is largely boxed and contained. E.g you might get a memory access error. UB is, in principle, completely unlimited in downside. And because of that, it often interacts badly with optimization passes, resulting in very strange bugs.
> It may seem nitpicky, but the downside of relying on implementation defined or unspecified behavior is largely boxed and contained.
... that is incoherent precisely because the interactions of provenance with optimizations isn't boxed and contained.
It's really not until people started putting the model into formal semantics that they realized "hey, wait a second, this means either most of our optimizations are wrong or our semantics are wrong" and the consensus is that it was the semantics that were broken.
it kills _a lot_ of optimizations leading to problematic perf. degredation
TL;DR: always freezing I/O buffers => yes no issues (in general); freezing all primitives => perf problem
(at lest in practice in theory many might still be possible but with a way higher analysis compute cost (like exponential higher) and potentially needing more high level information (so bad luck C)).
still for I/O buffers of primitive enough types `frozen` is basically always just fine (I also vaguely remember some discussion about some people more involved into rust core development to probably wanting to add some functionality like that, so it might still happen).
To illustrate why frozen I/O buffers are just fin: Some systems do already anyway always (zero or rand) initialize all their I/O buffers. And a lot of systems reuse I/O buffers, they init them once on startup and then just continuously re-use them. And some OS setups do (zero or rand) initialize all OS memory allocations (through that is for the OS granting more memory to your in process memory allocator, not for every lang specific alloc call, and it doesn't remove UB for stack or register values at all (nor for various stations related to heap values either)).
So doing much more "costly" things then just freezing them is pretty much normal for I/O buffers.
Through as mentioned, sometimes things are not frozen undefined on a hardware level (things like every read might return different values). It's a bit of a niche issue you probably won't run into wrt. I/O buffers and I'm not sure how common it is on modern hardware, but still a thing.
But freezing primitives which majorly affect control flows is both making some optimizations impossible and other much harder to compute/check/find, potentially to a point where it's not viable anymore.
This can involve (as in freezing can prevent) some forms of dead code elimination, some forms of inlining+unrolling+const propagation etc.. This is mostly (but not exclusively) for micro optimizations but micro optimizations which sum up and accumulate leading to (potentially but not always) major performance regressions. Frozen also has some subtle interactions with floats and their different NaN values (can be a problem especially wrt. signaling NaNs).
Through I'm wondering if a different C/C++ where arrays of primitives are always treated as frozen (and no signaling NaNs) would have worked just fine without any noticeable perf. drawback. And if so, if rust should adopt this...
I've implemented what TFA calls the "double cursor" design for buffers at $dayjob, ie an underlying (ref-counted) [MaybeUninit<u8>] with two indices to track the filled, initialized and unfilled regions, plus API to split the buffer into two non-overlapping handles, etc. It certainly required wrangling with UnsafeCell in non-trivial ways to make miri happy, but it doesn't have any less performance than the equivalent C code that just dealt with uint8_t* would've had.
EDIT: adding init_len() isn't good enough, we'd need an ensure_init() method too that returns a &mut [u8], and with those we could impl Buffer for BorrowedCursor, but if Read::read_buf() took this modified Buffer trait the default impl would be very inefficient when using a &mut [MaybeUninit<u8>] and that becomes a performance footgun.
Some people simply aren't comfortable with it.
Currently sound Rust code does not depend on the value of uninitialized memory whatsoever. Adding `freeze` means that it can. A vulnerability similar to heartbleed to expose secrets from free'd memory is impossible in sound Rust code without `freeze`, but theoretically possible with `freeze`.
Whether you consider this a realistic issue or not likely determines your stance on `freeze`. I personally don't think it's a big deal and have several algorithms which are fundamentally being slowed down by the lack of `freeze`, so I'd love it if we added it.
but I guess this isn't just about I/O buffers ;)
Arguably, the existence of "asm!() freeze" has already broken this idea. Of course, you nominally don't get any guarantees about the stability of data that asm!() code reads from uninitialized bytes, yet you can do it nonetheless.
And it's not like it's practical to say "asm!() code is always unsound if it uses uninitialized bytes like they're numbers!", since lots of it does useful stuff like communicating with the kernel with structs that get serialized, and it can also open up interfaces like mmap() which translate possibly-uninitialized virtual-memory bytes into definite kernel bytes.
Not to mention /proc/self/mem and similar kernel-provided debugging utilities that can peek into memory as serialized data.
Some people--especially when those people are closest to the workings of the operational semantics--not being comfortable is a sign that it is in fact harder than it looks.
The problems with "freeze" are in the same vein as integer-to-pointer semantics: it's a change which turns out to have implications for things not closely related to the operation itself, giving it a spooky-action-at-a-distance effect that is hard to tame.
The deeper issue is that, while there is clearly a use for some sort of "garbage value" semantics in a high-level language (that supports things like uninitialized I/O buffers, excessive reads for vectorization, padding bytes within structures), it's not clear which of the subtly different variants of garbage value works the best for all of the use cases.
Abstractions like ReadBuf allow safe code to efficiently work with uninitialized buffers without risking exposure of random memory contents.
Iirc the work on safe transmute also involves a sort of “any bit pattern” trait?
I’ve also dealt with pain implementing similar interfaces in Rust, and it really feels like you end up jumping through a ton of hoops (and in some of my cases, hurting performance) all to satisfy the abstract machine, at no benefit to programmer or application. It’s really a case where the abstract machine cart is leading the horse