Lesson 15 / 15
Optimization Levels
Uses the model of an optimizer deleting an unread write to cover the as-if rule, the guarantee volatile provides, and a closing tally of the course's fifteen lessons.
Published Updated
In this lesson
- Shows, through a model, when the as-if rule allows a write to be deleted
- Explains why a write marked volatile is never deleted
- Collects the course's fifteen changes into a table by class
Contents
The previous lesson closed by noting that the value a debugger sees doesn’t always match what the source wrote: a variable may live in a register, or may have been removed entirely. This lesson takes up where that difference comes from. An optimizer can remove a write that doesn’t change observable behavior — a write it removes never reaches any reader. The course has measured this with a single question throughout: who sees this change? For optimization, the answer reaches unobserved by a path different from the other fourteen changes: there is no copy here and no intermediary. The write happens, but before any read reaches it, another write lands on top of it.
Write Overwritten Before Any Read
Think of a flow in three steps: a location is written with 1, the same location is
written with 2, and then that location is read somewhere. If the first write’s effect was
never read before the second write arrived, then by the time the second write lands, every
trace of the first write in that location is gone. No party observing the program’s
behavior — not output written to a file, not a value sent to another process, not shared
state protected by a lock — can tell whether the first write ever happened at all. The
program could have skipped the first write entirely and the observed result would not
change.
PI10. An optimizer model can remove the first of two consecutive writes to the same location once it detects that no read occurred between them; whether it removes it or not, the observable result is the same.
The measurement uses the same record format as the course’s core:
// observe.h — a record that classifies whom a change reaches
#ifndef OBSERVE_H
#define OBSERVE_H
enum Class { COPY, SHARED, HANDOFF, UNOBSERVED };
/* Records one change: seen is whether the side that did not make the
change can read it (1 or 0). */
void observe(const char *change, enum Class cls, int seen);
/* Prints the count per class and the reached / not reached totals. */
void observe_summary(void);
#endif
// observe.c — the counts live only in this unit
#include <stdio.h>
#include "observe.h"
static const char *const names[] = { "copy", "shared", "handoff", "unobserved" };
static int counts[4];
static int reached = 0;
static int not_reached = 0;
void observe(const char *change, enum Class cls, int seen) {
/* shared and handoff reach, copy and unobserved do not */
int expected = (cls == SHARED || cls == HANDOFF);
counts[cls]++;
if (seen) {
reached++;
} else {
not_reached++;
}
printf("%-34s %-8s %s%s\n", change, seen ? "saw" : "missed",
names[cls], seen == expected ? "" : " MISMATCH");
}
void observe_summary(void) {
printf("classes: copy %d, shared %d, handoff %d, unobserved %d\n",
counts[COPY], counts[SHARED], counts[HANDOFF], counts[UNOBSERVED]);
printf("reached %d, not reached %d\n", reached, not_reached);
}
The excerpt below is taken from the course’s core, and it’s this lesson’s own excerpt — its names and body do not change across the course:
// optimization_measurement.c — the optimization excerpt from who_sees.c, unchanged
#include <stdio.h>
#include "observe.h"
struct Step {
char op;
int value;
};
static void optimization(void) {
struct Step flow[] = { { 'w', 1 }, { 'w', 2 }, { 'r', 0 } };
int readers = 0;
int last_write = -1;
for (int i = 0; i < 3; i++) {
if (flow[i].op == 'w') {
last_write = i;
} else if (last_write == 0) {
readers++;
}
}
observe("write overwritten before any read", UNOBSERVED, readers > 0);
}
int main(void) {
optimization();
observe_summary();
return 0;
}
write overwritten before any read missed unobserved classes: copy 0, shared 0, handoff 0, unobserved 1 reached 0, not reached 1
The flow array carries three steps: 'w' is a write, 'r' is a read. The loop tracks,
at each read step, which write was most recently in effect (last_write). When the read
arrives, if last_write still points at the zeroth write (last_write == 0), that read
counts as having seen the first write’s value, and readers increases. In this model
flow holds, in order, w=1, w=2, r: the second step overwrites the first by moving
last_write to 1; by the time the read at the third step arrives, last_write is no
longer 0, it’s 1. No read ever sees the first write — readers stays at zero. The call
to observe records this with class UNOBSERVED and passes readers > 0 (that is, 0,
false) as the seen field.
This is the model for why an optimizer can delete an unread write: even if the first
write is never made at all, the last_write sequence — and therefore the readers count —
stays the same. If the program contained a single write instead of two — writing 2
directly — the observable result (what the third step reads) would not change. The model
places write overwritten before any read in the unobserved class for exactly this
reason: a change was made, but it never reached any reader.
The As-if Rule
An optimizer’s right to delete a write isn’t arbitrary; it rests on a single condition: the
as-if rule. This rule defines a program’s run-time behavior in terms of a set of
observable behavior, and as long as the optimizer preserves that set, it may change how
the program executes step by step in any way it likes — it only has to look as if it ran in
source order, not actually run that way. Observable behavior is bounded by three things: the
order and content of input/output operations, accesses to objects marked volatile, and the
program’s termination state. In the model above, the first write falls under none of these:
it isn’t written to a file, it isn’t volatile, and it doesn’t affect how the program
terminates. So removing it does not violate the as-if rule.
PI11. A write can only be overwritten before being read if it falls into none of the parts of observable behavior; the moment it falls into one, that right disappears.
The as-if rule doesn’t conflict with the core’s other fourteen measurements — it completes
them. The variable the child changes after fork (copy class) isn’t subject to the as-if
rule at all — it’s about the existence of two separate memories, not about write order
within a single process. The flag the handler raises (handoff class) enters the as-if rule
from the opposite direction: because it’s marked volatile sig_atomic_t, the optimizer can
never touch that write — the next section works through this in detail.
The Guarantee Volatile Provides
When the volatile keyword is applied to a variable, it tells the compiler that every read
and write to that variable counts as part of observable behavior. The consequence is direct:
a volatile-marked write cannot be deleted even if no read follows it; a volatile-marked
read cannot be skipped even if its result is used nowhere. The flag variable in the
signals lesson was declared volatile sig_atomic_t for exactly this reason: if the
optimizer could delete the write the handler makes to that flag on the grounds that “nobody
is reading it yet,” before the main flow reads it, the handoff would never happen at all.
In the model’s own terms: if the position in the flow array were volatile, the first
write would still be overwritten before being read, but it could not be deleted, because the
write itself would count as part of observable behavior. This doesn’t change the record’s
class — by the time the second write arrives, no read has still seen the first write’s
value, and the record is still unobserved. What changes is only the optimizer’s right: the
write stays in the program; it just never reaches a reader. This lesson doesn’t measure
that with separate code; volatile‘s effect shows up in the code an optimizer produces, and
a model in source text cannot print that code. This lesson’s main function calls
optimization and then observe_summary; the output shows the one record marked missed
falling into the unobserved class.
The guarantee volatile provides is narrow, and it should not be stretched further. On its
own it does not provide ordering between threads, does not provide atomicity, and does not
establish a memory barrier — the lock and join from the POSIX Threads lesson are still
required. All volatile gives is the guarantee that accesses to that object will not be
deleted by the optimizer and that their order will be preserved; this is exactly why the
only form of sharing between a signal handler and the main flow is volatile sig_atomic_t.
The Bounding Measurement
The model decides whether a write can be deleted by looking at a single rule — whether observable behavior is preserved. A real optimizer makes this decision with much broader information: whether a variable’s address has been taken, whether it’s visible from another translation unit, whether it could be aliased through a pointer. This lesson does not write an optimization level as a flag, and does not print compiler output, generated code, or a measurement’s duration — because none of these can be shown by a model in source text; they are a real tool’s real decision. The model shows only the rule itself: which writes can be deleted, and which cannot. If a write’s address is held by another function, that function can read the location when called back; in that case the write is now part of observable behavior and cannot be silently deleted the way the model shows. The model not showing this is not a gap, it’s a boundary: the excerpt only handles consecutive writes within a single flow, not cross-function visibility.
Summary
write overwritten before any read(model): if two consecutive writes land on one location and the first is never read before the second arrives, no trace of the first remains.- The as-if rule says an optimizer may delete a write only if doing so will not change
observable behavior; observable behavior is bounded by input/output,
volatileaccesses, and termination state. volatilecounts every access to an object as part of observable behavior; that’s why avolatile-marked write is never deleted, even if it’s never read.- The
volatile sig_atomic_tfrom the signals lesson relied on exactly this guarantee: the flag a handler raises cannot be deleted by the optimizer even while the main flow has not yet read it. - The class this lesson measures is unobserved: a write happens but never reaches any reader — it doesn’t stay on the writer’s own side (copy), and it doesn’t pass through an intermediary and get seen once (handoff); it gets overwritten and disappears.
- The model shows not a real optimizer’s decision but the rule that decision rests on; address-taking and cross-function visibility are outside the model’s scope.
Course Wrap-Up
Across fifteen lessons, this course asked a single question: who sees this change? This lesson answers that question for the last time, and the answer reaches unobserved by a path different from the other fourteen — there’s a write, no reader, because the next write arrived before any read ever did. The table below collects all fifteen changes:
| Lesson | Measured change | Who sees it | Bounding measurement |
|---|---|---|---|
| File Descriptors | The offset shared by a descriptor duplicated with dup |
shared | The descriptor number does not say what is shared; the sharing is measured only through offset behavior |
| Process Management | The variable the child changes after fork |
copy | The measurement does not depend on timing; it does not measure a race, it measures that the two copies are independent |
| Signals | The flag the handler raises | handoff | The same signal arriving again while blocked is delivered only once when unblocked |
| Inter-Process Communication | The value written to the pipe | handoff | A pipe is a stream, it carries no message boundary; a boundary is established with a separate length prefix |
| POSIX Threads | The counter the thread writes under the lock | shared | Only the final value is verified; intermediate states and write order are not measured |
| Linked Structures | The node changed through an alias | shared | A pointer’s sharing is not visible at the source; whether two heads point at the same node is measured by equality |
| Ring Buffers and Queues | The item put in the ring | handoff | The read function’s signature doesn’t say whether the value stays in the structure; this is measured with two reads in a row |
| Hash Tables | The key changed after insertion | copy | The insert function’s signature doesn’t say whether the key is copied; if the key were held by pointer, the class would be shared |
| Object-Like Design | A deposit made by one user | shared | The shape of the interface (opaque type, function pointers, plain functions) does not determine sharing; only pointer equality does |
| Resource Cleanup Idioms | The buffer exited without being flushed | unobserved | Unobserved means reaching nowhere; this is verified with == NULL checks, without printing the value |
| Build Systems | The header with a dependency that was never written | copy | The model only sees the dependency that was written; it has no way of knowing on its own which header is actually read |
| Building Libraries | An update to a statically linked library | copy | The model only measures which value is reached; if the interface changes, the pointer-based guarantee is lost, and that case is not run |
| Testing Frameworks | The change made by an isolated test | copy | Process isolation cuts off memory change, not filesystem change |
| Using a Debugger | The intermediate value between two stops | unobserved | A breakpoint and a watchpoint observe a running process; a core dump reads one that has already stopped; no dump is produced |
| Optimization Levels | A write overwritten before any read | unobserved | The model only handles consecutive writes within a single flow; address-taking is out of scope |
5 copy · 4 shared · 3 handoff · 3 unobserved.
This distribution matches the distribution of the core’s fifteen observations exactly — not an unexpected coincidence, because the core carries exactly one observation per lesson, and each lesson takes its own excerpt from that observation. Seven changes reached the other side, eight did not; the ones that reached passed through an intermediary (handoff) or a shared object (shared); the ones that didn’t either stayed on the writer’s own side (copy) or never landed anywhere at all (unobserved). This distinction held from the start of the course: a change having been made does not guarantee that it was seen.
The same question was asked again at different levels throughout the course — between two processes’ memory, between a handler and the main flow, in the memory two threads share, in the trace one build unit leaves for another, and finally in a single write an optimizer deletes. At every level, the answer fell into one of the same four classes; the number of classes never changed, only which mechanism fell into which class changed.
The next course (C++ Fundamentals and Object Design) covers classes and resource management; it will establish its own measurement axis in its own first lesson.
To keep your progress and take notes, Log in
My notes
Log in to take notes.