If a sentence about C++ starts with "On x86,", stop listening.

C++

When someone starts a sentence with "On x86," whatever follows is almost always a justification for writing code that the C++ abstract machine does not permit.

The intent is usually innocent. The speaker knows that x86 has a strong memory model, that aligned stores up to a certain size are atomic in hardware, that signed integers use two's complement, and so on. They are appealing to real facts about a real CPU. Their compiler's output for their suggestion may very well be correct. The trouble is that we're not writing x86.

I recently caught myself doing a version of this. I had written some SIMD code that loads a full 16-element array, masks off the unused slots, and only uses the results from the valid ones. The unused lanes are read, masked out, and discarded. Valgrind doesn't complain, since it tracks "initializedness" the way you'd expect. The compiler was generating correct assembly for it. But reading uninitialized memory is undefined behavior in C++ even if the value is never used (issue #68). I had been reasoning from "what the chip does" and "what valgrind tracks" instead of from the standard. The assembly was correct, but I do want to upgrade my compiler someday.

🔗 The abstract machine is the only machine that matters for correctness

C++ gives you a precise set of rules for what a well-formed program means. Objects have lifetimes. Reads and writes to the same object have ordering constraints. Some operations are atomic; others are not. Signed integer overflow is undefined. The compiler is allowed to assume your program follows these rules.

x86 is one possible target for that compiler. It is a good target, with many helpful properties, but none of those properties are part of the C++ contract unless the standard explicitly says so, or unless you opt into an implementation-specific extension that documents them.

This matters because compilers optimize in terms of the abstract machine, not the physical one. If your program has undefined behavior, the compiler can do whatever it wants1, and "whatever it wants" often includes producing code that does the opposite of what you intend.

🔗 Examples

The following snippets were compiled with clang++ --target=x86_64-linux-gnu -fno-pic -O2 -S (Ubuntu clang 21.1.8). The generated assembly is exactly what you get from a real compiler, and it is precisely the kind of output that "On x86" reasoning fails to predict.

🔗 "On x86, signed overflow wraps around"

x86 arithmetic instructions use two's complement, so an overflowing signed addition leaves a wrapped value in the register. C++ does not care. Signed integer overflow is undefined behavior, and the compiler is entitled to assume it cannot happen.

bool incr_is_greater(int x) {
    return x + 1 > x;
}
incr_is_greater(int):
    movb    $1, %al
    retq

You might expect this to translate to an add somewhere in the output — x86 has an add instruction, and the source says x + 1. It doesn't appear. The compiler assumes the overflow never happens, so x + 1 > x is always true and the whole thing collapses to movb $1. If you were reasoning "On x86, INT_MAX + 1 wraps to INT_MIN, and INT_MIN > INT_MAX is false," you're picturing an add that executes and wraps in the register — but, as established, we're not writing x86.

If you actually want wraparound, use an unsigned type (which is defined to wrap in C++) or a compiler extension such as -fwrapv.

🔗 "On x86, reads and writes don't get reordered"

The x86 memory model is Total Store Order, which is famously strong. At the chip level, ordinary aligned stores really do appear to happen in program order to each observer. That does not help you at the C++ level. The compiler is allowed to reorder ordinary reads and writes, keep them in registers, or eliminate them, as long as it does not change the observable behavior of the abstract machine. Since it can assume the absence of data races it may also assume no other thread writes to a non-atomic object being accessed in the current thread.

Once your program contains a data race, the program is undefined and none of these guarantees constrain the compiler.

extern int data;
extern bool ready;

void writer() {
    data = 42;
    ready = true;
}

int reader() {
    while (!ready) {}
    return data;
}
writer():
    movl    $42, data(%rip)
    movb    $1, ready(%rip)
    retq

reader():
    movl    data(%rip), %eax
    retq

reader() contains no synchronization. Its loop body performs no observable operation — no volatile access, no I/O, nothing — so under the forward-progress rule2 the compiler may assume the thread will eventually do something observable. Since nothing in the abstract machine can ever change ready, the compiler concludes that ready must be true already: it drops the spin loop, reads data once, and returns. The while (!ready) loop is gone. The x86 chip will not reorder the stores that survive, but by then the program has already been transformed into one that does not mean what you think it means.

Use atomics and the memory-ordering tools the standard gives you:

#include <atomic>

extern int data;
extern std::atomic<bool> ready;

void writer() {
    data = 42;
    ready.store(true, std::memory_order_release);
}

int reader() {
    while (!ready.load(std::memory_order_acquire)) {}
    return data;
}

Only ready needs to be atomic. The release store on ready and the acquire load on it create a happens-before edge: once the reader observes ready == true, the write to data is complete. data itself can stay a plain int since it's not being accessed concurrently.

🔗 "On x86, an unaligned access is just a slower access"

The x86 ISA will handle many unaligned accesses in hardware; they are slow, not fatal. But that is only true of scalar loads. The aligned move movdqa requires 16-byte alignment and the hardware faults on a misaligned address. C++ allows the compiler to assume a pointer is aligned for the type it points to, and that assumption can lead it to emit one of the faulting instructions. __m128i is a 128-bit, 16-byte-aligned type, so loading it lets the compiler emit an aligned move:

#include <immintrin.h>
#include <cstdint>
uint64_t sumv(const __m128i* p) {
    __m128i v = *p;
    return (uint64_t)_mm_cvtsi128_si64(v) +
           (uint64_t)_mm_cvtsi128_si64(_mm_srli_si128(v, 8));
}
sumv(__m128i const*):
    movdqa  (%rdi), %xmm0
    pshufd  $238, %xmm0, %xmm1
    paddq   %xmm0, %xmm1
    movq    %xmm1, %rax
    retq

Both GCC and Clang emit movdqa here. If you pass a pointer that is only, say, 4-byte aligned, that movdqa faults — the x86 hardware requires the aligned move's operand to be 16-byte aligned and refuses to perform it on a misaligned address. A misaligned load is undefined behavior in C++, and the compiler is entitled to emit the alignment-requiring instruction on the strength of that assumption.3

🔗 Is "On x86," ever OK?

Yes. The sentence is fine when it is about performance, not correctness.

These statements do not make any claim about what the program means. They are engineering details about how a particular machine executes a particular binary. You can use them to make code faster; you cannot use them to make undefined behavior defined.

🔗 What to do instead

For correctness, reason from the C++ abstract machine:

If you want x86 semantics, you can also just write x86. Put it in its own .s file, obey the calling convention, and call it as an ordinary function. Then "On x86" is not a claim about how the compiler might compile your C++; it is a statement about the assembly you wrote. You can also try intrinsics, but be aware that the compiler is not obligated to generate the corresponding instructions. You can also try inline assembly, but it's more difficult to get right than a standalone .s file.

🔗 Conclusion

If a sentence about C++ starts with "On x86," it is almost certainly about to confuse hardware behavior with program semantics. That is a mistake for correctness discussions. The only legitimate uses are performance tuning, measurements on a specific target, or literally writing x86.

So in practice: if the topic is correctness, stop listening.

  1. People seem to anthropomorphize the compiler as an insufferable pedant who hates their code. The truth is that an optimizing compiler is a complex piece of software that performs a pipeline of hundreds of semantically-valid-according-to-the-abstract-machine transformations on your code, and upsetting codegen is an emergent property of interactions among these transformations. There is no line of code anywhere that says "Your code is bad, and you must suffer."

  2. The forward-progress rule ([intro.progress]) lets the implementation assume a thread will eventually perform an observable action or terminate. A spin loop whose body has no observable side effects may therefore be assumed to exit; here that means while (!ready) {} gets dropped entirely. (Other standard editions have phrased or scoped this rule differently.)

  3. Constructing this example took some trial and error. This is also a known phenomenon in the wild: a checksum routine that summed uint32_t words crashed with SIGSEGV when GCC assumed four-byte alignment and used an aligned SSE load — see Peter Zemskov, "A bug story: data alignment on x86".

  4. The compiler is not required to generate the particular codegen you are picturing. The point is that, when you are only discussing performance, a missed-optimization bug report won't be dismissed on the grounds that your code has undefined behavior.