Legalityspec

Observable Behavior

The list the whole domain depends on: input and output, volatile accesses, whether the program terminates, and the order the language sequences those in. Elapsed time, memory used, chosen registers and instruction counts are not on it — which is exactly why the compiler may change them.

The question

Which effects of my program is the compiler obliged to preserve, and which is it free to change?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

The abstract machine: an idealised interpreter the language specification describes, which executes the source program one step at a time and produces a sequence of observable events. The real program is a different artifact entirely — machine instructions, registers, caches — and the only requirement connecting the two is that the sequence of events match. This representation exists to answer "what did the program do", in a form that says nothing whatever about how it did it.

What this phase may assume or do

Every later phase may assume that anything not in the observable set is free. Concretely: an optimizer may assume it owns the number of instructions executed, the registers and stack slots used, the amount of memory allocated, the wall-clock time taken, the order of any two operations the language does not sequence, and the values in any storage no observer can reach. It may assume none of those things about I/O, about a volatile access, about program termination where the language defines it, or about the order in which the language says those happen relative to one another.

Key points

  • Observable behavior is a short, specified list: I/O, volatile accesses, prompt-before-input, and termination — plus the order the language sequences them in.
  • Everything else — time, memory, registers, instruction count, allocation count — is explicitly not observable, which is what makes optimization legal at all.
  • Sequencing is half the definition. A language buys optimization freedom by leaving evaluation order loose, and C leaves more of it loose than most.
  • The single-threaded observable set does not model a second thread, which is the gap a memory model exists to close.
  • volatile makes an access itself observable; it says nothing about other threads and is not a substitute for atomics in C or C++.
  • When you need an unobservable effect to survive, you must create an observer: an opaque call, an asm barrier, a specified library function, or an atomic.
  • Elapsed time is not observable under any mainstream language specification, so no source-level construct can guarantee that a computation takes time.

The list, and the deliberate shortness of it

The set of observable behaviors is small, and its smallness is the point. C++ names four things: reads and writes of volatile objects, data written to files at program termination, prompting output written before a program waits for input, and — under the forward-progress rules — whether the program terminates at all. C says essentially the same with different wording. Everything else your program appears to do is, from the specification's point of view, an implementation detail of how those things came about.

That is a startling amount of freedom, and it is deliberate. If instruction count were observable, no optimization would be legal. If memory usage were observable, register allocation would be illegal. If elapsed time were observable, every compiler would be frozen at -O0. The specification defines an abstract machine precisely so that it can then say: match the *events*, and do whatever you like about the mechanism.

The practical skill is being able to answer, for any effect you care about, whether it is on the list. Almost every surprise an engineer has with an optimizer comes from an effect they assumed was observable and the language did not: the store that zeroed a buffer, the loop that took time, the allocation that showed up in a memory graph, the ordering two threads relied on.

On the list and off itspec
EffectObservable?What follows from that
Writing to a file or a socketYesThe write must happen, and its contents and relative order with other writes are fixed.
Reading or writing a volatile objectspecYes — this is what volatile meansEach access must happen exactly once, in order, and may not be merged, duplicated or removed.
Prompt output before waiting for inputspecYesA prompt may not be moved after the read that it prompts for, which is the reason this clause exists at all.
Whether the program terminatesspecYes, with a large asteriskC++ permits an implementation to assume a loop without side effects finishes; C11 says the same for loops with non-constant conditions.
Which values a printf printsYes — it is I/OThe computation producing them may be reorganised freely; the resulting bytes may not.
Elapsed wall-clock timeNoThe compiler may make any part of the program arbitrarily faster or slower, including removing timing loops entirely.
Peak memory or number of allocationsspecNoStack slots may be reused, objects may be promoted to registers, and C++ explicitly permits eliding allocations.
Which registers are usedNoRegister allocation is entirely the compiler's to decide — see [[register-allocation]].
How many instructions executeNoUnrolling, inlining and vectorization all change it in both directions on purpose.
The order of two unsequenced operationsspecNoIf the language leaves two evaluations unsequenced, either order is a conforming implementation.
Values in memory nothing can reachNoA store to a dead local is removable, which is why memset-before-free needs explicit_bzero.

Sequencing is half of it, and the half people forget

specEvaluation order rules are language-specific and have changed. C++17 made function arguments indeterminately sequenced (still unspecified order, but no interleaving) and fixed the order of << chains; C leaves function arguments unsequenced to this day. Java and C# specify strict left-to-right evaluation of operands throughout, so the entire category of surprise does not exist there. Any reasoning about evaluation order must name the language and the standard revision.

Naming the observable events is only half of the specification. The other half is the *order* they must occur in, and a language gets a great deal of optimization freedom by leaving that order loose in places where an engineer would assume it is fixed.

C and C++ call this sequencing. Within a single expression, most subexpressions are unsequenced relative to each other: in f(g(), h()), g and h may run in either order, and every call in the program may choose differently. That is not a gap in the standard; it is a licence, and it exists so an implementation can schedule the two calls around each other. C++17 tightened some of these — the operands of <<, of ->*, and the arguments of a call became indeterminately sequenced rather than unsequenced — precisely because the freedom was buying less than the confusion cost.

Between statements, sequencing is stronger, but the compiler is still permitted to reorder anything whose reordering nobody can observe. Two stores to distinct non-volatile locations may be swapped. A computation may be sunk past a branch it is not needed on. A load may be hoisted above a store it cannot alias. Each of those is a reordering that no single-threaded observer can detect — and that qualifier is where concurrency comes in, because a second thread is an observer the single-threaded rules never modelled. That gap is exactly what a memory model exists to close, and Concurrency is where it is closed.

Three expressions and what the language will and will not fix about them
1int i = 0;
2
3// UNSEQUENCED: two modifications of i with no sequence point between them.
4// This is undefined behavior, not "implementation-defined order".
5i = i++ + 1;
6
7// UNSEQUENCED (C) / INDETERMINATELY SEQUENCED (C++17): g and h may run in
8// either order, and the choice may differ between two calls in one program.
9f(g(), h());
10
11// SEQUENCED: && and || sequence their operands and short-circuit.
12// The compiler may not evaluate deref(p) before testing p.
13if (p != NULL && deref(p) > 0) { ... }

The third line is the one people rely on without knowing they are relying on a guarantee. && is a sequence point in C and a sequenced-before relation in C++, so the null test genuinely happens first — which is why the null-check idiom is safe and why the *reverse* order, dereferencing before testing, is the disaster that [[ub-and-optimization]] is about.

What to do when the thing you care about is not on the list

The recurring practical problem is that you have an effect the language does not consider observable and you need it to happen anyway. There are only a few tools, and picking the wrong one produces code that works today and stops working when a compiler improves.

volatile tells the compiler that an access is itself observable: each read and write must occur, exactly once, in program order relative to other volatile accesses. That is the right tool for a memory-mapped device register and the wrong tool for inter-thread communication, which is what atomics are for — volatile says nothing about other threads, about caches, or about the reordering of *non*-volatile accesses around it.

An opaque call — a function in another translation unit the compiler cannot see through, or an empty inline-assembly block with a memory clobber — creates an observer the compiler must assume exists. This is what benchmark harnesses use: Google Benchmark's DoNotOptimize and ClobberMemory are exactly this, and they exist because "the loop was removed" is the single most common microbenchmarking failure.

A purpose-built library function is the answer where a standard exists: explicit_bzero and memset_s are specified to actually write, so the compiler is forbidden the removal it would otherwise be entitled to. And an atomic operation with a specified memory order is the tool when the observer is another thread, because that is the only mechanism the language gives you for constraining what a second thread may see — atomics and happens-before in Concurrency are the vocabulary for it.

  • Device register, single threadvolatile. Each access happens, in order, never merged or duplicated.
  • Another thread is the observer → an atomic with an explicit memory order. volatile is not a synchronization primitive in C or C++, whatever it means in Java.
  • Benchmark that must not be optimized away → an opaque sink such as benchmark::DoNotOptimize, or an asm barrier with a memory clobber.
  • Erasing a secretexplicit_bzero, memset_s, or SecureZeroMemory. A plain memset before the storage dies is removable and is routinely removed, which is why secrets management in Security treats it as a known hazard.
  • Wanting a computation to take time → nothing in the language offers this. Time is not observable, and any loop you write for it may be deleted or reordered.

How it works

The steps, in the order the compiler takes them.

  • The language specification defines an abstract machine and the sequence of events its execution produces.
  • A conforming implementation must produce the same sequence of observable events; nothing constrains how.
  • The optimizer therefore treats any state no observer can reach as free, and any ordering the language leaves unsequenced as its own to choose.
  • Effect analysis classifies each instruction: does it perform I/O, touch volatile storage, synchronize, or possibly not terminate.
  • Alias and escape analysis decide which memory an observer could reach, which is what turns "a store" into "an unobservable store".
  • Transformations consult those classifications before removing, reordering, merging or duplicating anything.
  • Accesses marked volatile or atomic are excluded from the whole apparatus by construction, since their occurrence is itself the observable event.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A memset that clears a password buffer is absent from the disassembly, and the secret remains readable in a core dump taken minutes later.
  • A benchmark loop reports nanoseconds per iteration for work that no longer exists, and a team ships a "40x speedup" that is a measurement of an empty loop.
  • A driver polling a hardware status register hangs forever, because the load was hoisted out of the loop — the variable needed to be volatile.
  • Two threads communicate through a volatile flag and the handshake works on x86 and fails on AArch64, because volatile never constrained the reordering of the *data* writes around it.
  • A delay loop compiled at -O2 finishes instantly, and a hardware initialisation sequence violates its timing requirement in a way that reproduces on one board in twenty.
  • An allocation counter in a test asserts a number that changes with the optimization level, because C++ permits the implementation to elide allocations.

When it helps

  • Deciding whether a surprising removal is a compiler bug or expected behavior — nine times in ten the effect was simply not on the list.
  • Writing microbenchmarks that measure something, which requires deliberately creating an observer for the result you want kept.
  • Reviewing security-sensitive code, where "this store will happen" is an assumption that has to be justified rather than assumed.
  • Reading a language specification productively: the observable-behavior clause is the shortest path to understanding what its compilers are permitted to do.

When it hurts

  • Reasoning about concurrency from the single-threaded observable set. Another thread is an observer that set was never written to describe, and every conclusion drawn without the memory model is unsound.
  • Assuming the list is the same across languages. Java specifies a memory model and finalization; Rust defines overflow; JavaScript engines are constrained by a spec that fixes evaluation order. The C++ list is the C++ list.

What it costs

Every one of these is paid by something.

  • A short observable list buys enormous optimization freedom and pays with a category of engineer-visible surprise — every effect people care about that is not on the list becomes a bug report that is not a bug.
  • Adding an item to the list, as C++ did by constraining evaluation order in C++17, buys predictability and pays the reorderings the previous freedom permitted; the committee accepted that trade only where the freedom was demonstrably buying little.
  • volatile buys a guaranteed access and pays every optimization on that object: no folding, no reuse, no register promotion, one memory access per source access, forever. Marking a hot structure volatile to "be safe" can cost an order of magnitude.
  • Creating an observer for a benchmark buys a measurement of real work and pays some of the overhead you were trying to measure, which is why microbenchmark numbers include the barrier, and why a microbenchmark and a system measurement answer different questions.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Specify more, as Java does: a memory model with defined semantics for racy programs, defined integer overflow, and defined evaluation order. Fewer surprises, and a set of reorderings the JIT must not perform.
  • Specify effects in the type system, as Haskell and Koka do, so what is observable is visible in the signature and the compiler can prove purity rather than assume its absence — [[effect-systems]].
  • Make everything observable, which is what a debugger build approximates at -O0: every store happens, every variable has a location, nothing moves. Correct, predictable, and several times slower — [[debug-vs-release]].
  • Provide explicit escape hatches instead of a broad rule: Rust's read_volatile/write_volatile and black_box are separate functions rather than a type qualifier, which keeps the optimization barrier local to the operation instead of attached to the object.

See it for yourself

The flag, dump or tool that shows you this directly.

  • C and C++: compile a function that writes a local buffer and returns, at -O2, and look for the store. Repeat with volatile on the buffer and diff — the difference is the observable set, in assembly.
  • clang -O2 -S on a memset-before-return and then on explicit_bzero: one of the two survives, and the reason is a specification clause rather than a compiler setting.
  • Google Benchmark: read the implementation of DoNotOptimize — it is an inline-assembly constraint that makes the value observable to a compiler that cannot see through it.
  • Compiler Explorer with -O0 beside -O2 on a timing loop: watch the loop disappear, and note that no diagnostic is emitted, because nothing wrong happened.
  • Our pass manager at /compilers/passes runs the program before and after optimization and compares the printed output — print is the only observable effect AtlasLang has, which makes the comparison exact.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "If I wrote it, it has to happen." Only if what it does is on the list. A store nobody can read, a computation nobody uses and a loop with no visible effect are all removable by design.
  • "volatile makes it thread-safe." It makes each access to that object happen. It orders nothing else, publishes nothing to another core, and provides no atomicity — the Java meaning of the keyword is a different guarantee with the same spelling.
  • "The compiler cannot change my program's memory usage." It reuses stack slots, promotes objects into registers, and in C++ is explicitly permitted to elide allocations. Memory usage is not observable.
  • "Evaluation order is left to right." In C it frequently is not, and the same compiler may choose differently in two places in one file. Relying on it is how code becomes portable to exactly one compiler version.

Misconceptions

The claim, and what is actually true.

Observable behavior means anything the program does that you can see happening.
It means a specific short list in the language specification. You can see elapsed time and memory usage with a profiler; neither is observable in the technical sense, and both are the compiler's to change.
A write to memory is always performed.
A write to storage no observer can reach is removable, and dead-store elimination removes it routinely. Making the write survive requires telling the language that someone is watching.
Two independent statements execute in the order I wrote them.
They execute in an order no observer can distinguish from the one you wrote, which is not the same claim. Add a second thread and the difference becomes visible immediately.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

The language spells out which effects of a program count as its behavior: the input and output it performs, accesses to storage marked volatile, and whether it finishes. Those must survive compilation exactly. Everything else — how long it takes, how much memory it uses, which machine instructions run — is not part of the program's meaning, so the compiler can change all of it freely.

practical

Before assuming something will survive optimization, check whether it is on the list. Stores to memory nobody reads, loops that only burn time, and computations whose results are discarded are all fair game. When you need one of them anyway, create an observer: volatile for a device register, an atomic when another thread is watching, explicit_bzero for a secret, and an opaque sink in a benchmark. And never use volatile to talk between threads in C or C++ — it was never that, even where it happened to work.

advanced

The interesting structural point is that the observable set is defined over a *single-threaded* abstract machine, and the concurrency extensions had to be bolted onto it afterwards. C++11 did this by defining a memory model in which the observable behavior of a multi-threaded program is defined only for race-free executions, and by giving atomics an ordering vocabulary that constrains the compiler and the hardware together. That is why data races are undefined rather than merely unspecified: making them defined would require the abstract machine to model every interleaving, which would forbid nearly every reordering both the compiler and the processor rely on. The cost of that decision lands on the programmer, and it is the reason data races sit in a different category from ordinary bugs.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

specThe four-item list is C++'s [intro.abstract] and C's equivalent §5.1.2.3, and the termination clause differs between them: C++ permits assuming that a loop without side effects terminates, while C11 restricts that assumption to loops whose controlling expression is not a constant. Java specifies no such licence at all and additionally defines a memory model constraining what other threads may observe. Nothing in this list transfers between languages without checking.
typicalThat mainstream compilers actually exploit every part of the freedom described is not uniform. GCC and Clang aggressively remove unobserved stores and non-volatile timing loops; MSVC has historically given volatile acquire/release semantics on x86 under /volatile:ms, which is stronger than the standard requires and is exactly the kind of implementation-specific behavior that stops working when a project is ported or the flag changes.
simplifiedAtlasLang's observable set has exactly one member: print. There is no I/O beyond it, no volatile qualifier, no threads and no way to observe time, which is why hasEffect fits in four lines. Every complication in a real language's observable set is a complication in its effect analysis, and the ratio is roughly linear.

If you were asked this in an interview

  • Name the things a C++ compiler is obliged to preserve. Now name three things it is not.
  • Why does zeroing a password buffer need a special function?
  • A colleague uses volatile for a flag shared between two threads. What is wrong with that, and what would you use?

Connections

OS & Networkingsignals
Domains that do not exist yet
  • Programming Languages & Runtime Internals — What a runtime observes that the language specification does not
    A garbage collector, a profiler and a debugger all observe things the abstract machine says are unobservable — allocation counts, stack contents, variable locations. That is why the compiler must emit extra metadata for them rather than relying on the semantics, and the metadata is our half of the story.