You have an intermediate representation and you want machine code. That is the whole problem statement, and it is worth noticing how much it does not say. It does not say which instruction. It does not say which register. It does not say in what order. Those three questions are what a backend is, and a backend is not a formatter — it is three passes that each take a sequence of operations and each answer one of them.
Before the first number, the thing that governs what a number is allowed to be. This host has three absences and one presence, and the artifact prints all four before it prints anything else:
$ python3 compback.py --run | sed -n '/^ compiler:/,/^$/p'
compiler: Ubuntu clang version 21.1.8 (6ubuntu1)
disassembler: Ubuntu LLVM version 21.1.8
MEASURED for x86-64 code, because it ran. MEASURED-ON-BYTES for an AArch64 or RISC-V instruction, because a word was emitted and read back and nothing more. QUOTED for anything about what a machine would do, with a document named. Three labels and no fourth, because the provenance table on the last page prints a count that only adds up if the spellings are consistent.
And a word about where the tool is. LLVM is the oracle and never the dependency. Read what clang emits; compare it against an allocator and a scheduler you wrote. Section 2 of the artifact prints what replaces each piece of LLVM by file name, and the reason it prints it as a table is that a learner who copies clang’s architecture will not be able to build one — the ten files that do the work are short enough to read in an afternoon, and a finished backend tells you what finished looks like, not how to get there.
The positive half of the argument for an IR is a ratio: how many IR instructions clang emitted, how many machine instructions the second reader printed, and the quotient. One source, five optimisation levels, x86-64.
$ python3 compback.py --run | sed -n '/level IR insns/,/IS A STORY/p'
level IR insns machine insns IR/machine
O0 260 235 1.106
O1 99 117 0.846
O2 207 253 0.818
O3 207 253 0.818
Os 99 103 0.961
the ratio spans 0.818 to 1.106 across five levels, and it is NOT
MONOTONIC: -O1 gives a SMALLER ratio than -O0 because -O0 emits
MORE machine instructions than IR and -O1 emits fewer. §KEEP§A NUMBER
THAT MOVES IN BOTH DIRECTIONS IS A MEASUREMENT; A NUMBER
THAT ONLY FALLS AS THE COMPILER GETS BETTER IS A STORY.
Above 1.0 the backend deleted instructions the IR had. Below 1.0 the backend inserted them — an address computation, a zero extension, a spill reload. Both directions are decisions, and -O0 sits above because it spills every argument while -O2 sits below because it inserts vector setup. That is the whole reason the table has five rows and not one average: a ratio read in one direction is a score, and a score cannot move up.
And -O1 gives a smaller ratio than -O0. Nobody writes that sentence in a tutorial. It is here because it is what the machine did.
The same source, compiled for three targets at the same level. How much of the IR is common and how much is target-specific:
$ python3 compback.py --run | grep -A 5 'AT -Os THE THREE TARGETS SHARE'
AT -Os THE THREE TARGETS SHARE 17 OF 19 OPCODES, AND THE 2 THAT
ARE TARGET-SPECIFIC ARE:
insertelement riscv64
shufflevector riscv64
At -O2 and -O3 and -O1 all three targets share every opcode. At -Os RISC-V emits two the others do not, from the same source at the same optimisation level — and -Os emits more IR for RISC-V than -O2 does.
So the finding is neither “an IR is target-independent” nor “an IR is target-dependent”. It is this: at -O2 all three targets agree on every opcode, and the machine codes still differ.
$ python3 compback.py --run | grep -A 4 'THE MACHINE CODES AT -O2'
target IR insns machine insns IR/machine
x86_64 207 253 0.818
aarch64 207 190 1.089
riscv64 203 207 0.981
Three targets, one opcode set, three different instruction counts: 253, 190 and 207. The IR is the same and the code is not, and every instruction of the difference is a decision the backend made. That is the sentence an IR earns its keep on, and it is the one an “IR is portable” slide never prints.
The vectoriser is the most capability-sensitive pass in the compiler, and this corpus shows it entering at one level and not another. Measured over the whole corpus, once per level, for each target:
$ python3 compback.py --run | sed -n '/level target IR vector ops/,/IR vector operations across/p'
level target IR vector ops machine vector insns
O0 x86_64 0 0
O0 aarch64 0 0
O0 riscv64 0 0
O1 x86_64 0 0
O1 aarch64 0 0
O1 riscv64 0 0
O2 x86_64 2 42
O2 aarch64 2 14
O2 riscv64 2 20
O3 x86_64 2 42
O3 aarch64 2 14
O3 riscv64 2 20
Os x86_64 0 0
Os aarch64 0 0
Os riscv64 2 26
IR vector operations across all three targets: -O1 0, -O2 6, -O3 6, -Os 2.
-O1 vectorises nothing. -O2 and -O3 emit six IR vector operations across the three targets. -Os emits two — on RISC-V only, and it is a level whose whole purpose is code size.
So: a level of -O is a set of objectives, not a ranking. A level is a request, and the compiler decides which of your requests it can honour given what the target makes cheap. -Os asked for small code and the RISC-V target’s vector extension made the vector form smaller, so it took it.
The function chosen for the disassembly is walk, and the choice is deliberate: it is the only one in the corpus with a reduction the vectoriser can take and a non-reduction it cannot, so the level at which it fires shows up as a change rather than as an absence.
$ python3 compback.py --run | sed -n '/ -O0 27 instructions/,/ -Os 16/p'
-O0 27 instructions, 112 bytes, 0 SSE/AVX operands
-O1 18 instructions, 64 bytes, 0 SSE/AVX operands
-O2 59 instructions, 222 bytes, 49 SSE/AVX operands
-O3 59 instructions, 222 bytes, 49 SSE/AVX operands
-Os 16 instructions, 42 bytes, 0 SSE/AVX operands
-O0 emits 27 instructions and -O2 emits 59 — more, not fewer. And the instructions that appeared are not random: they are the ones that existed only to give the stack a shape.
$ python3 compback.py --run | grep -A 5 'THE DIFFERENCE IN ONE LINE'
THE DIFFERENCE IN ONE LINE: -O0 emits 27 instructions and -O2 emits
59 -- MORE, NOT FEWER -- and §KEEP§THE INSTRUCTIONS THAT APPEARED
ARE NOT RANDOM EITHER.
§KEEP§THEY ARE THE ONES THAT EXISTED ONLY TO GIVE THE STACK A
SHAPE: -O0 SPILLS EVERY ARGUMENT AND -O2 SPILLS NOTHING IN A LEAF.
Read the -O0 body and the extra instructions are pushq %rbp, movq %rsp, %rbp, and a run of movq <slot>(%rbp), <reg> that loads each argument back out of memory immediately after storing it in. Read the -O2 body and there is no frame at all — the function is a leaf, and a leaf needs no frame.
So the honest reading of “-O0 has more instructions” is not “-O0 is worse”. It is: -O0 spent twenty-seven instructions keeping its arguments in memory, and -O2 kept them in registers and spent some of the difference on arithmetic that eight scalar instructions could not express. The count went up and the reason the count went up is that there is less memory traffic.
This is the table that makes the mission’s claim concrete rather than aspirational. The mission says a learner finishing the chain should be able to emit machine code for x86-64, AArch64 and RISC-V without depending on LLVM or any other backend. Here is the file that does each job:
$ python3 compback.py --run | sed -n '/AND WHAT IS STILL MISSING/,/^ \* Exception/p'
* SSA construction and PHI placement. Our IR is straight-line and
the reason is in cbir.py's own docstring: instruction selection,
allocation and scheduling are three questions about a SEQUENCE of
operations, and a branch would put a fourth question in the
middle of them.
* A real CFG. No basic blocks, no dominators, no loop structure.
* Peepholes, and the x86-64 and RISC-V compressed encodings.
* Exception frames and debug information.
Read that missing list as a design decision rather than an omission. The three passes are three questions about a sequence. Add a branch and you get a fourth question — which of the two paths does this value reach, and is it live on both? — and it sits in the middle of the other three, because liveness through a control-flow graph is what register allocation actually is. The straight-line IR is a simplification of the subject, declared as one, and the declarations are the three files in the table above.
It is also worth being blunt about the two listings the table does not have. There is no floating point, no vectors and no atomics in this IR. Twelve integer operations, and the corpus has no <2 x i32> anywhere. The vector register file — which is where the interesting register pressure lives on a real target — is absent from every measurement in this course. The pages that use SSE operand counts are reading clang’s output, not this course’s allocator’s.
Three labels, and the arithmetic a reader can do with a pencil. The field-width arithmetic is not in the missing list because it is not missing from the subject — it is in concept 2: four five-bit register fields in a 32-bit AArch64 word is twenty bits of thirty-two, and the remaining twelve hold sf, a ten-bit group selector, the MADD/MSUB bit and the shift amount. Twenty plus twelve is thirty-two, and both halves are checked against words a real assembler emitted. That kind of arithmetic is the part of backend work that survives every toolchain that ever existed.
-O0, -O1, -O2 and -Os. Count the IR instructions and the machine instructions and take the quotient. Now write down what each of the two numbers means separately before you write down the ratio: at -O0 you should find the quotient above 1.0 and the reason is a prologue and a run of reloads; at -O2 it should be below 1.0 and the reason is inserted work. If you cannot say which direction you are in for your own function, you have a ratio and not a measurement.)clang -S -emit-llvm -O2 and -Os, for x86-64 and for RISC-V. At -O2 expect the opcode sets to be identical and the machine counts to differ. At -Os expect the RISC-V IR to carry opcodes the others do not. That is the two-named-opcodes finding reproduced on your own corpus, and it is the cheapest way to see that the IR hides the target’s operations but not its shapes.)-O0 and at -O2. At -O0, find the push %rbp / mov %rsp, %rbp pair and every movq <slot>(%rbp), <reg> that reloads an argument. At -O2 confirm there is no frame at all. The count going up with optimisation is not a paradox and it is not the compiler regressing — it is the stack traffic disappearing. That is the sentence walk exists to demonstrate.)cbir.py and put a conditional jump in the middle. Now you need a live range per path, and the allocator’s question changes from “is this interval live from a to b” to “is this interval live on both paths”, and a value live on one path only can be assigned to a register that the other path overwrites. That is not a small change and it is not a bug in your code — it is the fourth question the straight-line IR was chosen to postpone. The docstring in cbir.py says exactly this, and this exercise is how you find out whether you believed it.)Backwards, exe-latency is where you met the idea that an instruction is not free and that its cost depends on what it waits for. This concept deliberately does not repeat any of it: the question here is not what an instruction costs but how many of them a backend decided to emit, and a count does not know whether the instruction is fast. isa-opcodes has the fourteen 0F xx opcodes this page never names, and the encodings of all three targets are in x86-integers, a64-encoding and rv-encoding — this course’s encoder is checked against those courses’ decoders rather than forked from them, and concept 2 prints the result.
Forwards, the three questions. Instruction selection answers which machine form, and its central finding is a selector that never fails once and still computes a different number. Register allocation answers which register, and its central finding is that being wrong in one direction makes an allocator look better. Scheduling answers which order, and its central finding is that the worst schedule it found was the fastest one.
Outward, this concept is the first half of a course whose whole subject is how you check your own work. Every ratio here is a count, and a count is only evidence because something else counted it independently — the IR reader is ours and the machine counter is llvm-objdump-21’s. The last concept turns that around and asks what you can recover from bytes you did not write, and the answer is three counts and one inference, which is not the same thing.
And the honesty at the end of this page is not a disclaimer, it is the finding. Every ratio in it has a denominator you can argue with. The IR reader counts one line per result-producing instruction and nothing else: it does not count inside a define, it does not count a declare, and it does not count metadata. The counts are 260 and 207 against 235 and 253, and the counting rule is stated rather than assumed, and the last concept numbers that as limit 13 among fifteen.