The strict aliasing situation is pretty bad (2016)
blog.regehr.org
blog.regehr.org
Here is why I don’t blame the developers: writing fast, efficient systems code that satisfies the requirements of strict aliasing as defined by C/C++ is surprisingly difficult. It has taken me years to figure out the technically correct incantations for every weird edge case such that they always satisfy the requirements of strict aliasing. The code gymnastics in some cases are entirely unreasonable. In fairness, recent versions of C++ have been adding ways to express each of these cases directly, eliminating the need to use obtuse incantations. But we still have huge old code bases that assume compiler behavior, as was the practice for decades.
I am not here to attribute blame, I think it the causes are pretty diffuse honestly. This is just a part of the systems world we failed to do well, and it impacts the code we write every day. I see strict aliasing violations in almost every code base I look at.
In particular, C++20 gave us std::bit_cast (https://en.cppreference.com/w/cpp/numeric/bit_cast) for type punning and C++23 added std::start_life_time_as (https://en.cppreference.com/w/cpp/memory/start_lifetime_as) for interpreting raw bytes as an object.
That paper also highlights that checking is crucial, their initial Euclid compiler just required that there's no aliasing, but never checked. So of course programmers will make mistakes and without the checks those mistakes leak into running code. The finished compiler checked, which means the mistake won't even compile.
Shifting left in this way is huge, WUFFS shifts bounds misses left - when you write code which can have a bounds miss in C of course it just does have a bounds miss at runtime, there's a stray read or overwrite and chaos results maybe it's Remote Code Execution, in Rust the miss panics at runtime - maybe a Denial of Service or at least a major inconvenience. But in WUFFS it won't compile - you find out about your bug likely before it gets sent out for code review.
Most software can't be written in WUFFS, but "most" is doing a lot of work there, plenty of code which should be in WUFFS or an analogous language is not, meaning mistakes are not shifted left.
However, it is generally too hard (in C and C++) for compilers to tell whether you were wanting to do the thing at any one particular place.
So compilers have two options: Assume that you never do the thing, or always assume that you don't do the thing.
The former is often better for performance in practice, and it's true most of the time, so here we are.
As has been pointed out elsewhere, one of the strengths of Rust is that it shifts how pointers (references) work and allows the compiler to more often know for certain that you don't do the thing, without making assumptions.
I blame this on how people like to teach C and present C.
It's very important that the second anyone conceives of the idea of learning C that they first off informed that trying things and seeing what happens is a highly unreliable method of learning how C programs behave and that C is not a high level assembly language.
If you teach C in relation to the abstract machine instead of any real world machine you will understandably scare off most people. Which is good, since most people shouldn't be learning or writing C. It's a language which can barely be written correctly even by people with the necessary self discipline to only write code they're 100% certain is well defined.
> It is difficult to determine if I’ve been successful in this endeavor.
Why is your program so full of casts between pointer types that you have difficulty determining if you've avoided strict aliasing?
Yes, if you treat C as a high level assembly language (like the linux kernel likes to do) then it becomes difficult to reason about the behaviour of your programs where 50% of them are in the grey area of uncertainty of whether they're well defined or not.
If you are forced to write C in a non-learning context, don't write any line of code unless you're certain you could tell someone which parts of the standard describe its behaviour.
> Here is why I don’t blame the developers: writing fast, efficient systems code that satisfies the requirements of strict aliasing as defined by C/C++ is surprisingly difficult.
C/C++ isn't a language. So I will stick to C because I don't know nor care about C++.
That being said, it's not hard to write efficient C which satisfies the requirements of strict aliasing except when you're dealing with idiotic APIs like bind or connect. Most code by default, assuming you use appropriate algorithms and data structures, is performant. The only time it becomes difficult with regards to strict aliasing is if you're micro optimizing.
While non-trivial, the case of converting between unsigned long and float shown in the article is entirely possible to do with completely safe C constructs. Likewise serialization/deserialization of binary data never requires coming close to aliasing unless you're dealing with a "native" endian protocol. In the case of general serialisation and deserialisation, compilers will reliably optimise such operations into one or two instructions (depending on whether you're decoding same-endianness or not).
I write database storage engines. Most of the runtime address space is being dynamically paged to storage directly by user space. You can't use mmap() for this. Consequently, objects don't have a fixed address over their lifetime and what a pointer actually points to is not always knowable at compile-time. These are all things that have to be dynamically resolved at runtime with zero copies in every context the memory might be touched. Fairly standard high-performance database stuff. The intrinsic ambiguity about the contents of a memory address create many opportunities to inadvertently create strict aliasing violations.
I've been doing it a long time, so I know the correct incantation for virtually every difficult strict aliasing edge case. Most developers are ignorant of at least some of these incantations because they are surprisingly difficult to lookup, it took me years to figure out some of them. When developers don't know they tend to YOLO it and hope the compiler does the desired thing. Which mostly works in practice, until it doesn't.
Recent versions of C++ have added explicit helper functions, which is a big improvement. Most developers don't know the code incantation required to reliably achieve the same effect as std::start_lifetime_as and they shouldn't have to.
So how would do this in Rust, if at all? (That's the context of this subthread and the admonition not to play type punning games.)
Rust assumes noalias even for objects of the same type, and that's because the entire language is built on the foundational assumption that you cannot have both a mutable and immutable reference (or two mutable references) to the same object alive at the same time.
(whats 100% not-OK in Rust is casting away constness. If you have a '&T' you must not touch, on pain of a thousand bugs)
These scenarios cause other problems for Rust e.g. DMA hardware tacitly holds an invisible mutable reference to objects, but most developers never have to deal with cases like this. C++ provides some tools to annotate the code so that the compiler understands it cannot see all references to an object or that the lifetime is ambiguous.
This type of code is not common but high-performance storage engines are kind of a perfect storm of architectural requirements that break the core Rust invariants.
For example, the equivalent[0] of the article's Offset Overlap example is perfectly valid according to Rust's abstract machine. What makes it hard is avoiding the creation of references. If I create a reference, then there's a good chance that the lifetimes don't get correctly linked up, and I accidentally have shared mutation/use after free/other UB.
[0] https://play.rust-lang.org/?version=stable&mode=debug&editio...
Oh, wait, I just saw your name, I know who you are. But you are one of very few people on this planet writing C or C++ who get a pass on this kind of thing.
Almost nobody is using C or C++ to write super duper large data high performance databases. And even people who do work on databases don't need these breakneck levels of performance that you've dealt with.
In most cases people are breaking aliasing rules for no real performance advantage. These people should just stop, a large majority of code doesn't need to worry about aliasing rules because the vast majority of code written in C doesn't have these crazy performance requirements.
> The intrinsic ambiguity about the contents of a memory address create many opportunities to inadvertently create strict aliasing violations.
I don't get what you mean, at least the way you've explained it. Your memory might be volatile in the sense that it gets reused but if code is still operating on that memory then you don't have aliasing issues, you just have issues.
You can operate in terms of char * when it comes to your userspace paging implementation and your code which requested this paging (do you use use a segfault handler to implement this?) just operates in terms of whatever type it originally cast the void * value returned by your userspace mmap reimplementation. Am I misunderstanding something here?
> std::start_lifetime_as
I got into reading, since I don't know C++, I only know C, and this sounds like a relevant whitepaper:
https://www.open-std.org/JTC1/SC22/WG21/docs/papers/2022/p25...
This lead me to:
https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2020/p05...
By the sounds of it, this is a problem in C++ only, so it explains why I wasn't aware of such an issue. So you're telling me that in C++ you can't reliably implement a userspace mmap (or even use normal mmap) implementation before C++23 because without std::start_lifetime_as the C++ abstract machine doesn't provide a way of specifying when an object's lifetime starts?
This makes me wonder, what even is the incantation you're referring to?
std::start_lifetime_as is just a nice wrapper around an older incantation: do a no-op memmove and cast followed by a constant-folding barrier. C allows type punning with unions but I would assume the constant-folding issue would still exist. Compilers finally became clever enough about constant-folding to cause problems when you reinterpret the type at runtime.
First of all compilers disagree on many interpretations and consequences of abstract machine rules. Also compilers have bugs.
So a proficient C/C++ programmer does have to learn what compilers actually do in practice and what they guarantee beyond the standard (or how they differ from it).
> C/C++ isn't a language.
It isn't, but it is a family of languages that share a lot of syntax and semantics.
List them. I am not aware of any well defined parts of the C standard where GCC and Clang disagree in implementation. Only in areas where things are too vague (and are effectively either unspecified or undefined), or understandably in areas where they're "implementation defined".
If there are behaviours where a compiler deviates from the standard it is either something you can configure (e.g. -ftrapv or -fwrapv) or it's a bug.
> Also compilers have bugs.
Nothing you do can defend against compiler bugs outside of extensively testing your results. If you determine that a compiler has a bug then the correct course of action is definitely not: "note it down and incorporate the understanding into your future programs"
> So a proficient C/C++ programmer does have to learn what compilers actually do in practice and what they guarantee beyond the standard (or how they differ from it).
There are situations where it's important to know what the compiler is doing. But these situations are limited to performance optimisation, the knowledge gained through these situations should only be applied to the single version of the compiler you observed it in, and you should not use the knowledge to feed back to your understanding of C or the implementation.
It's almost impossible to decipher how modern C compilers work exactly and trying to determine what an implementation does based on the results of compilation is therefore extremely unreliable. If you need to rely on implementation defined behaviour (unavoidable in any real program) then you should be relying solely on documentation, and if the observed behaviour deviates from the documentation then that is, again, a bug bug.
> It isn't, but it is a family of languages that share a lot of syntax and semantics.
I am not a C/C++/C#/ObjectiveC/JavaScript/Java programmer.
C++ and C might share a lot of syntax but that's basically where the similarities end in any modern implementation. People who know C thinking they know enough C to write reliable and conformant C++ and people who know C++ thinking they know enough C++ to write reliable and conformant C are one of the groups of people who produce the most subtle mistakes in these languages.
I think you could get away with these kinds of things in the 80s but that has definitely not been the case for quite a while.
Perhaps it's not "well defined" enough for you, but one example I've been stamping out recently is whether compilers will combine subexpressions across expression boundaries. For example, if you have z = x + y; a = b * z; will the compiler optimize across the semicolon to produce an fma? GCC does it aggressively, while Clang broadly will not (though it can happen in the LLVM backend).
I'm aware of some efforts to bring deterministic floating point operations into the C++ standard, but AFAIK there are no publicly available papers yet.
Subexpression combining has more general implications that are usually worked around with gratuitous volatile abuse or magical incantations to construct compiler optimization barriers. Floating point is simply the most straightforward example where it leads to an observable change in behavior.
[0] https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2024/p33...
If the end result of the calculation differ (and remember that implementations may not always use ieee floats) then you can call it a bug in whatever compiler has that difference.
If this transformation as you describe changes the observable behaviour had it not been applied, then that's just a compiler bug.
This usually means that an operation such as:
double a = x / n;
double b = y / n;
double c = z / n;
printf("%f, %f, %f\n", a, b, c);
Cannot be implemented by a compiler as: double tmp = 1 / n;
double a = x * tmp;
double b = y * tmp;
double c = z * tmp;
printf("%f, %f, %f\n", a, b, c);
Unless in both cases the same exact value is guaranteed to be printed for all a, b, c, and n.This is why people enable -ffast-math.
Anyone who regularly works with floating point in C and expects precision guarantees should therefore read that relevant portion of the standard.
and I am still in shock that many game projects still ship with fast math enabled.
well, the part of the standard that are vague and/or underspecified is a very large "Here be dragons" territory.
Time-traveling UB, pointer provenance, aliasing of aggregated types, partially overlapping lifetimes. When writing low level codes, it makes sense to know how exactly the compilers implement these rules.
In particular, regarding aliasing, GCC has a very specific conservative definition (stores can always change the underlying type, reads must read the last written type) that doesn't necessarily match what other compilers do.
>> It isn't, but it is a family of languages that share a lot of syntax and semantics. > I am not a C/C++/C#/ObjectiveC/JavaScript/Java programmer.
C#, Java, JS share a bit of syntax, but certainly not semantics. ObjectiveC/C++ definitely belong. There is a trivial mapping from most C++ constructs to the corresponding C ones.
Sure, but the answer as I said earlier is: don't touch those parts of C.
The subset which _is_ well defined is still perfectly powerful enough to write highly performant software.
It's not like I'm advocating for you to use the brainfuck subset of C.
> When writing low level codes, it makes sense to know how exactly the compilers implement these rules.
Almost nobody is writing C low level enough for this and I've written embedded code which didn't need to worry about strict aliasing.
This is again just a misconception, almost no real programs need to delve this deeply into the details.
> In particular, regarding aliasing, GCC has a very specific conservative definition (stores can always change the underlying type, reads must read the last written type) that doesn't necessarily match what other compilers do.
It doesn't matter what other compilers do as long as in terms of the abstract machine these differences do not break the rules set out in the standard. Again, you do not need to know these details for 99.99% of program code.
> C#, Java, JS share a bit of syntax, but certainly not semantics. ObjectiveC/C++ definitely belong. There is a trivial mapping from most C++ constructs to the corresponding C ones.
There's a mapping from any of these languages to any other one, in some cases also quite trivial, the amount of overlap is immense, but C and C++ have heavily deviated.
I am a C expert, I do not claim to be a C++ expert, every time I look at C++ I am increasingly surprised at just how it redefines something core about C. Something I just learned in this very thread is https://en.cppreference.com/w/cpp/memory/start_lifetime_as which doesn't exist in C because apparently C and C++ define object lifetimes completely differently.
It's dangerous to keep pushing this notion that C and C++ are very similar because it leads to constantly leads to expert C++ programmers confidently writing subtly broken C code and vice versa.
That being said, my intuition matches what little anecdotal data I’ve seen from real perf-sensitive systems, and I’d ballpark 10-15% where it matters.
But no-one cares about real-world performance, people pick C and pick a C compiler because they want the thing that's fastest on artificial microbenchmarks.
And when it became an issue c. late 90's, it was actually "NO strict aliasing" that was the point of contention. Optimizers were suddenly able to do all sorts of magic, and compiler authors realized they were getting tripped up by the inability (c.f. the halting problem) to know for sure that this arbitrary pointer wasn't scribbling over the memory contents they were trying to optimize. You'd get better (often much better) code with -fno-strict-aliasing, which was tempting enough to turn it on and hope for better analysis tools to come along and save us from the resulting bugs.
We're still waiting, alas.
Basically, C code compiled to assembly in the Amiga era looked much more straightforward than the output produced by modern C compilers (with optimizations enabled at least), you could put both side by side and see a near 1:1 relationship between the C code and the assembly code (maybe also because the Motorola 68000 seems to have taken a lot of inspiration from the PDP instruction set).
Even small changes often require years and many revisions to be accepted - burnout is common. You would need to build a consensus that this change is desirable - that's highly unlikely at best. Strict aliasing has been widely implemented since the 1990s and many compilers benefit from the rules; many compiler vendors are on the committee. You'd have to convince them that they should make their customer's code slower.
What might be achievable, however, is some kind of technical report on undefined or implementation defined behavior. Many compilers have options that allow programs with some undefined behavior to behave as the user would expect. Microsoft's C and C++ compilers, for example, don't enforce strict aliasing and allow some forms of integer overflow in loop conditionals. There would be substantial value in defining a common profile for these options. It would still be an uphill battle to get it through the committee, though.
If we can't even get that, I doubt strict aliasing will ever be voted out.
> If I were writing correctness-oriented C that relied on these casts I wouldn’t even consider building it without -fno-strict-aliasing.
int foo(int *x) {
*x = 0;
// wait until another thread writes to *x
return *x;
}
Can the C compiler really optimize foo to always return 0? That seems extremely unintuitive to me.Yes
> That seems extremely unintuitive to me.
C compilers are extremely unintuitive. This is a relatively sane case, they do things that are much more surprising than this.
It's very common for beginner embedded programmers to forget to do this and spend hours debugging why the register doesn't change when it should.
[1] https://en.m.wikipedia.org/wiki/Volatile_(computer_programmi...
At lower levels, you might have something like an IPC primitive there, which would be protected by a spinlock or similar abstraction, the inline assembly for which will include a memory barrier.
And even farther down still, the memory pointed to by "x" might be shared with another async context entirely and the "wait for" operation might be a delay loop waiting on external hardware to complete. In that case this code would be buggy and you should have declared the data volatile.
This is a wrong, a memory barrier would not salvage this code from UB. The read from `x` must at the very least be synchronized, and there might be other UB lurking as well.
You're right that if you try to write async code with only compiler instrumentation, you're very likely to be introducing race conditions (to be clear: not necessarily on architectures with sufficiently clear memory ordering behavior -- you can play tricks like this in pure C with x86 for instance). But that wasn't the question at hand.
int volatile *x
as the parameter to get the changes from a different thread.The Strict Aliasing Situation Is Pretty Bad - https://news.ycombinator.com/item?id=11288665 - March 2016 (67 comments)