5 ms·
I wish that there was a useful “freeze” intrinsic exposed, even if only for primitive types and not for generic user types, where the values of the frozen regio
by vgatherps 1y ago
I wish that there was a useful “freeze” intrinsic exposed, even if only for primitive types and not for generic user types, where the values of the frozen region become unspecified instead of undefined. I believe llvm has one now?
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
- CJefferson 1y agoI agree, I'd go further and say I wonder why primitive types aren't "frozen" by default. 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.
- wrs 1y agoThat assumes the compiler reserves one continuous place for the value, which isn’t always true (hardly ever true in the case of registers). If the compiler is required to make all code paths result in the same uninitialized value, that can limit code generation options, which might reduce performance (and performance is the whole reason to use uninitialized values!). 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.
- CJefferson 1y agoBut, I wonder how much it would reduce performance, if we only have to pick a value the first time the memory is read? 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?
- ablob 1y agoif you pick a value you have to store it, and if you have to store it it might spill into memory when register allocation fails. Moving from register-only to stack/heap usage easily slows down your program by an order of magnitude or two. If this is in a hot path, which I'd argue it is since using uninitialized values seems senseless otherwise, it might have a big impact. 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.
- dathinab 1y ago> same uninitialized value, that can limit code generation options 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)
- dooglius 1y ago> If the compiler is required to make all code paths result in the same uninitialized value, that can limit code generation options 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?
- wrs 1y agoI can’t find a citation, but my recollection is the problem happened with the Briggs-Torczon sparse set algorithm, which relies on uninitialized memory not changing. For performance, they were using MMAP_UNINITIALIZED (which has to be enabled with a kernel config).
- wrs 1y agoThe most obvious general case (to me) is reading an uninitialized local variable in a loop. If uninitialized has to be the same value every time, you’d have to allocate a register or stack space to ensure the value was the same on every iteration. Instead, you’d don’t have to allocate anything, just use whatever value is in any register that’s handy. (By this logic you can also start pruning code, by picking the “most optimal” value for the uninitialized variable.)
- vgatherps 1y agoUninitialized memory being UB isn’t an insane default imo (although it makes masked simd hard), nor is most UB. But the lack of escape hatches can be frustrating
- adastra22 1y agoAnything being UB is insane to me...
- IshKebab 1y agoNah it makes some sense for portability between architectures. Or at least it did back when C was invented and there were some wild architectures out there. And it definitely does allow some optimisation. But probably nothing significant on modern out-of-order machines.
- dathinab 1y ago> there were some wild architectures out there. 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
- dathinab 1y agoonly until you get deeper into how the hardware actually work (and OS to some degree) 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)
- dathinab 1y ago> why primitive types aren't "frozen" by default. 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...
- Arnavion 1y ago>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. 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.
- lilyball 1y agoWhat is the reason for explicitly tracking the initialized-but-unfilled portion? AIUI there's no harm in treating initialized bytes as uninitialized since you're working with u8, so what do you actually gain by knowing the initialized portion? I mean, at an API level you gain the ability to return a &mut [u8] for the initialized portion, but presumably anyone actually trying to write to this buffer either wants to copy in from a &[u8] or write to a &mut [MaybeUninit<u8>].
- Arnavion 1y agoIt's for the sake of providing a &mut [u8] to the initialized region for callers that cannot work with &mut [MaybeUninit<u8>] for whatever reason, eg if they wanted to use this with a std::io::Read impl. In particlar, Read impls are discouraged but not prohibited from reading the content of the [u8] given to them, so calling them with a [MaybeUninit<u8>] transmuted into a [u8] is not generally safe.
- lilyball 1y agoAh right, std::io::Read, I didn't think about that. And so it needs to explicitly track the initialized portion because otherwise calling .ensure_init() every time you want to do a read becomes expensive, that makes sense. And presumably this is also why the Buffer trait from this article cannot ever replace BorrowedBuf, because Buffer is not compatible with the Read trait without explicitly initializing the buffer every time (though I suppose it could still be added on top of BorrowedBuf if you add a new method init_len() that returns the length of the already-known-to-be-initialized prefix of parts_mut()). 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.
- jcranmer 1y agoThis is already discussed for Rust: https://github.com/rust-lang/rfcs/pull/3605 https://github.com/rust-lang/rfcs/pull/3605. TL;DR: it's not as easy as it looks to just add "freeze."
- orlp 1y agoIt is as easy as it looks to add `freeze`. That is, value-based `freeze`, reference-based `freeze` while seemingly reasonable is broken because of MADV_FREE. 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.
- dathinab 1y agoalso to be realistic in a lot of practical situations I/O buffers are reused so at least for the I/O buffer use-case it can be very viable (perf wise, in most use cases) to just zero or rand initialize it once on alloc and then treat it as frozen in all repeated usages (through it does open the issue of bug now potentially leaking previous content of the I/O buffer). but I guess this isn't just about I/O buffers ;)
- LegionMammal978 1y ago> Currently sound Rust code does not depend on the value of uninitialized memory whatsoever. Adding `freeze` means that it can. 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.
- JoshTriplett 1y agoThis isn't just about the abstract machine. This is also about making it hard to end up using uninitialized memory, which is a security hole. Abstractions like ReadBuf allow safe code to efficiently work with uninitialized buffers without risking exposure of random memory contents.