Additional neatness would be being able to request a guarantee that all allocations - malloc, stack, constants - have at least, say, 64 bytes of non-faulting addresses after them, though that is significantly more complex, requiring cooperation between a bunch of parts.
Annoying thing is that this is trivial with a custom allocator (as long as the compiler isn't told to consider the custom sub-allocations as separate), but then you're stuck not being able to use your SIMD stuff on anything outside your custom heap due to the very tiny chance of segfaulting.
Sanitizers/valgrind don't necessarily become pointless with this even - the past-the-end values are still undefined, can be tracked as such, and error on use.
Not an expert, but to me this sounds like you want an alternative where behaviour for a read beyond the end of an allocation is merely implementation-defined, not undefined. That means the implementation (e.g. LLVM) has to document what they do — which may be platform-dependent — and the choice of whether it becomes undefined is up to the implementation.
The natural thing to do here for the implementation is of course to say "I'm just going to emit the load instruction, it may crash your program, better be prepared".
Alternatively, a different API would be returning an optional of the loaded data, having the stdlib/language/backend convert that to the appropriate boundary check (or always returning a None if impossible).
Ideally there'd be languages that can be at least configured into providing more "unsafe" useful things, even if at the expense of not having the code be compilable targeting funky hardware that noone would run the software in question on anyway.
That said, clang's MemorySanitizer, and, similarly, valgrind, could still produce errors via tracking which bytes are undefined within registers; might be somewhat delayed between load and error, but still shouldn't allow such out-of-bound values to be used for much.
And, anyway, as this load would be a separate instruction/builtin (if so decided), UB of regular operations is unaffected. If the sanitizer in question doesn't track (partial) register definedness, it could just accept all of these explicitly-potentially-OoB loads; indeed not ideal, but the alternative is not being to write such performant code at all.
And there are already people doing this, just limited to doing so with data within a custom allocator. It would just be nice to have a mechanism to not be fully ruled out of using standard tooling at least for testing.
Not using sanitizers is, of course, an option, but, as should be obvious, is very much not optimal. Having a single "questionable" operation in one place does not mean that the programmer intends for the entire function or program to be all "programmed against the machine"; the rest of the code, and, to an extent, even the fancy operation in question, could still very much benefit from regular language tooling.
Such operations needn't even be machine-specific.
"try loading N bytes, accepting garbage out-of-bounds; return None if cannot be done without potentially faulting" says and requires nothing about the architecture, and is implementable everywhere (even if as always returning None).
Aligning pointers is another option, but is fundamentally in no way different from the memory-protection-boundary-based version in how much it relies on hardware specifics, and compiler/language builtins could still be made that allow for sanitizer-friendly usage. It might be more or less efficient depending on use-case, both are useful to have.
Of course, the best option would be that malloc, the linker, etc work together to guarantee at least N bytes of addressable memory past all user-accessible pointers, at which point architecture specifics completely stop mattering. This needn't change any behavior around sanitizers or regular loads; all it'd mean is that the "load N bytes with trailing garbage" operation can always succeed. Sanitizers could error on said op reading outside of the guaranteed readability size, and regular loads of course continue erroring on any out-of-bounds read. Compilers could even use this guarantee themselves to emit unmasked loads for loop tails.
The other option that I've seen discussed is adding a freezing load to LLVM that turns the undef bits into some unspecified but valid bit patterns.
As soon as the thing is packaged to run on an raspberry or something else that doesn't like it, it will start to generate CVEs and be a major pain.
Masked loads kinda suck, they are a tiny bit slower and you now need a mask and you need to compute the mask..
The one case it can be annoying is passing pointers to constant data to custom-heap-assuming functions - e.g. to get a pointer to [n,n-1,n-2,...,2,1,0] for, say, any n≤64, make a global of [64,63,...,2,1,0] and offset its pointer; but you end up needing to add padding to the global, and this materializes as avoidable binary size increase as the "padding" could just be other constants from anywhere else. Copying the constant to the custom heap would be extra startup time and more memory usage (not sharable between processes).
A trick to avoid reading beyond the end of the buffer is to make sure the end of the buffer lies on the same page. Typically, the OS will allocate memory in pages of 4KB, thus we can make a function that checks whether it is okay to read beyond or if we should fallback to the copy version.
-- https://ogxd.github.io/articles/unsafe-read-beyond-of-death/
But it sounds like the masking feature mentioned in a sibling comment takes care of it anyway.
It might not be the nicest thing to assume to be the case on all hardware, but it shouldn't be too unreasonable to put it under an "if (arch_has_a_minimum_page_size)". So many things already assume at least 4KB pages, Intel/AMD aren't gonna break like half the world. If anything, they'd want to make larger pages to make larger L1 caches more feasible.
I definitely see the conundrum since the dangerous code is such a huge performance gain.
No. First, undefined behavior is a term of art in the C standard, so the idea of generalizing it is nonsensical. Second, ANSI C explicitly does not allow this assumption, and ISO C—while more open ended—doesn't specifically justify this assumption. The entire "UB = assume it cannot happen" thing is grossly dishonest FUD.
"It's always been this way so it's impossible to address." Forgive me if I'm not convinced.
From https://highassurance.rs/chp3/undef.html:
> In other words, should a developer inadvertently trigger UB, the program can do absolutely anything.
Well, no. It is "behavior . . . for which this International Standard imposes no requirements." There are restraints and constraints beyond the ISO standard.
realloc(p, 0) is now undefined in C23. However, every mainstream OS and compiler specifies the correct behavior for that environment. It is simply not. true. that the program can do anything. What is true is that the range of behavior is not restricted by the ISO standard.
A very significant amount of their effort, time, focus went into understanding precisely what was, and was not "Undefined Behavior" - the instant you did anything that was "undefined" anything that happened after that was fair game.
They also did zero dynamic memory allocation after starting an application. All memory was allocated on startup based on initial config settings.
My sense in watching that extraordinarily skilled team was that the logic and features they were building (on a very complex FHSS MAC) were secondary to convincing the compiler and hardware to do something that the specifications and definitions should happen. The great firmware developers were also pretty solid language lawyers.
What I am saying is that a compiler that sees
int8_t x;
float x;
and does anything other than "terminating a translation or execution (with the issuance of a diagnostic measure)" is doing the wrong thing.I am also saying that a compiler that offers -fwrapv and formats your hard drive on int x = INT_MAX; x++; rather than "behaving during translation or program execution in a documented manner characteristic of the environment" is pathological, violates the spirit of the ANSI and ISO standards, and violates the letter of the ANSI standard.
Note that one of the differences between C and Rust is that integer overflow is not UB in Rust (it panics in debug mode and wraps in release mode: https://doc.rust-lang.org/book/ch03-02-data-types.html#integ...). But there are other sources of UB in unsafe Rust, such reads through a pointer not allowed by the memory model.
No, they really don't. https://godbolt.org/z/M6hcTx3aY
Clang will not blow your computer up just because you use #pragma STDC FENV_ROUND <direction> in accordance with its documentation.
I don't know why it became popular to make UB into a bogeyman and act like it's an intractable problem, but it isn't. Compilers handle most UB just fine.* It's better for everyone if we can focus on the actual problems rather than blowing it out of proportion with sweeping generalizations.
* All undefined behavior is undefined, but some undefined behavior is more undefined than others.
It sounds like you agree that programmers should avoid relying on luck for UB safety. If you have a way to prove that this UB is actually safe and not just lucky, feel free to present it. Until then, I stand by everything I’ve said.
It isn't true to say that "any undefined behavior" can result in a parade of horribles. That is a sweeping generalization. Huge amounts of undefined behavior are in fact well-defined by the implementation and/or environment.
Indeed, the formal definition of "implementation-defined" is so narrow that most of what you'd think should be "implementation-defined" is actually "undefined." The example I gave of realloc(ptr, 0) is one such case. "Classifying a call to realloc with a size of 0 as undefined behavior would allow POSIX to define the otherwise undefined behavior however they please." WG14 n2464, available at https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2464.pdf
There’s nothing wrong or misleading in what I wrote. If, hypothetically, there were a second specification that constrained the Rust compiler’s translation of programs that over-read memory, then it would have been wrong to write that over-reading memory in Rust is undefined behavior, and misleading to suggest that a statement about “any undefined behavior” is applicable to it. But there is no such second specification (as confirmed by comments on the issue I linked from RalfJung, whose formal specification hat is much pointier than either of ours), and you aren’t even disputing that. Instead, you have deliberately misapplied a statement about “any undefined behavior” to other unrelated behavior that is in fact defined, in order to construct a pretense for calling someone else dishonest. Find better hobbies.
There's no such thing as "insofar as both specifications apply to it, that is defined behavior, not undefined behavior." Undefined behavior in C that is standardized by POSIX is still undefined behavior. Indeed, that is why "Possible undefined behavior [includes] behaving during translation or program execution in a documented manner characteristic of the environment." Behaving according to the POSIX specification on a POSIX system (or according to the specification of any system on that system) is explicitly accounted for in the definition of "undefined behavior."
These things are objectively, inarguably true, and recorded in black and white. I do not understand why you are so relentless in trying to gaslight HN about them. Just stop.
When the term “undefined behavior” appears in different context, we use our writing and reading comprehension skills to agree on what it means. If we’re programming in standard C, it refers to one class of behavior. If we’re programming in POSIX C, it refers to a smaller class of behavior (even according to the C standards committee: see the usage of “otherwise undefined behavior” in N2464). If we’re programming in Rust, it refers to a completely different class of behavior.
From my very first message, I did my part by providing the context explicitly: “undefined behavior in the Rust and LLVM memory model”. You complained that it’s nonsense to talk about “undefined behavior” in any context other than the C standard, but I provided references showing that it’s not. Yet you persist in intentionally misinterpreting what I wrote in order to accuse me of gaslighting. Does that make you feel superior?