Part 3 · Reading compiler output
Verifying "zero-cost"
Reading Rust told you iterator chains compile to the same loop. This is where you check, and the real answer is more interesting than yes or no.
What this lesson is trying to break (3)
- "Zero-cost" is a slogan you have to take on trust.
- Identical performance means byte-identical output.
- High-level abstractions must cost something.
If you worked through Reading Rust, you were told repeatedly that iterator chains are “zero-cost” and that they compile to the same loop you would have written. You were asked to take it on trust.
Here is the check. The numbers below were measured with rustc 1.96 at
-C opt-level=2 — the same version you have installed locally — so you should
reproduce them exactly.
The two versions
#[no_mangle]
pub extern "C" fn f(p: *const i32, n: usize) -> i32 {
let v = unsafe { std::slice::from_raw_parts(p, n) };
v.iter().filter(|&&x| x > 0).map(|&x| x * 2).sum()
}
#[no_mangle]
pub extern "C" fn f(p: *const i32, n: usize) -> i32 {
let v = unsafe { std::slice::from_raw_parts(p, n) };
let mut s = 0i32;
let mut i = 0;
while i < v.len() {
if v[i] > 0 { s += v[i] * 2; }
i += 1;
}
s
}
One is three combinators. The other is an index loop with a bounds-checked subscript on every access.
The #[no_mangle] pub extern "C" wrapper is there for a practical reason worth
knowing: a plain pub fn in a library crate does not reliably produce a
standalone function in Compiler Explorer’s output — it can be optimised away or
have its symbol filtered. Forcing external C linkage guarantees a real,
ABI-conforming body to read. This is 3.1’s
warning in practice.
Commit numbers for both before compiling. Also predict whether the bounds checks in the second version survive.
iterator version : 46 instructions, 21 of them vector
hand-written loop: 46 instructions, 21 of them vector
byte-identical? No — they differ on exactly 2 lines.And here is the entirety of the difference:
iterator version: xor edx, edx
hand-written loop: xor eax, eaxTwo instructions zero a register, and the two versions picked different registers to zero. That is the whole divergence.
Three things were established at once:
The abstraction cost nothing. Same instruction count, same vectorisation, same work. Not “close enough” — the same.
The bounds checks vanished. The loop version indexes with v[i], which in
Rust is a bounds-checked operation that can panic. The compiler proved
i < v.len() from the loop condition, so every check was eliminated. There is no
comparison-and-panic anywhere in the output.
Both vectorised. 21 vector instructions each — xmm registers processing four
i32 lanes at a time, with the horizontal-sum cascade at the exit you first met in
0.2.
The interesting part is “not identical”
A weaker course would have wanted byte-identical output and called this a partial result. It is the opposite: the difference proves you now know how to read.
Register allocation is arbitrary among equivalent choices. Nothing distinguishes
zeroing eax from zeroing edx here — the register is a scratch accumulator and
either works. The compiler reached the same destination by two paths that happen
to label things differently.
This is precisely the distinction the course has been drilling since 0.1: incidental versus essential. Register names, operand order, and instruction scheduling are incidental. Instruction count, memory traffic, dependency structure and vectorisation are essential.
A reader who cannot tell those apart concludes “the outputs are different, so the abstraction costs something” — and is wrong. A reader who can, concludes “identical work, different arbitrary labels” — and is right. That judgement is what fifteen lessons bought you.
Why Rust wins the aliasing argument
There is a second, less-celebrated result here. Write the C equivalent:
int f(const int *p, size_t n) {
int s = 0;
for (size_t i = 0; i < n; i++) if (p[i] > 0) s += p[i] * 2;
return s;
}
This one reads through a single pointer so it vectorises fine. But the moment a C function has an output pointer as well as an input pointer, the compiler must emit a runtime alias check and a scalar fallback (3.4), because nothing in C forbids the caller passing overlapping ranges.
Rust’s &mut is an exclusivity guarantee. rustc emits LLVM’s noalias
attribute on those pointers, so the compiler knows there is no overlap without
checking. No alias check, no fallback path, smaller and faster code.
So the borrow checker is not only a safety mechanism — it is an optimisation
input that C can only obtain by hand-annotating restrict and hoping the
annotation is true. That is the concrete cash value of everything Part 2 of the
Rust course put you through, and you can now see it in the instruction stream
rather than believe it.
Where zero-cost stops being true
Being able to name the exceptions is what makes the claim credible rather than promotional:
dyn Trait— a real indirect call through a vtable, which cannot be inlined. That is not zero-cost and was never claimed to be.Rc/Arcclones — real refcount traffic;Arcuses atomics, which are genuinely expensive under contention.- Bounds checks that cannot be proven away — indexing by a value the compiler cannot bound leaves a real check. Iterators avoid this by construction, which is an argument for the abstraction rather than against it.
RefCell— a runtime flag check on every borrow.- Iterators over non-trivial closures that fail to inline, which is rare but happens with very large bodies.
The honest formulation, which is the one to use in a discussion: Rust’s iterator and generic abstractions are zero-cost; its runtime-polymorphism and shared-ownership abstractions are not, and are not sold as such.
Pick one abstraction you currently take on trust — in Rust, C++, or anywhere with a compiler — and design the experiment that would check it.
Be specific: what two versions would you compile, what flags, and what would you count in the output to decide? Then run it.
If your two versions differ in a way that is not the thing you are testing, the experiment is invalid. Say how you would know.
Part 3 checkpoint
- Name three ways Compiler Explorer differs from your build. (3.1)
- Why is
-O0readable and misleading at once? (3.2) - Why is inlining the most valuable optimisation? (3.3)
- A multiply by a large odd constant plus shifts — what was it? (3.3)
- Name three reasons a loop refuses to vectorise. (3.4)
- Why does field order change
sizeof? (3.5) - What did the iterator-versus-loop comparison actually establish? (3.6)
You can now read optimised output and check a claim about it. Part 4 breaks all four fictions, and it is the hardest part of the course.