Topic 6 · Lecture notes · Week 15

Measuring, Documenting, and Knowing the Limits of What You Built

No new syntax. What separates a project from a piece of engineering: honest measurement, an accurate account of the limits, and knowing what a production compiler does that yours does not.

Read alongside this

0Where this topic sits

1. Lexicalchars → tokensscanner.l2. Syntaxtokens → ASTparser.y3. SemanticAST → checked ASTsemantic.c4. IRAST → TACtac.c5. OptimizeTAC → better TACtac.c6. CodeGenTAC → MIPScodegen.c

The language is complete. Nothing in this topic adds syntax. What it adds is the three things that separate a working project from a piece of engineering: numbers you can defend, documentation a stranger can use, and an honest account of the limits.

Dragon §1.4 calls this the science of building a compiler, and the phrase is not decoration. The discipline is: build an abstraction, choose a mathematical model that fits it, and then be honest about where the model stops applying. You have done the first two five times this semester. This topic is the third.

1What you actually built

Six phases, roughly three thousand lines, and — the part worth being proud of — about eighty-five per cent of it does not know what machine it targets. That is not an accident of how the files were divided; it follows from placing an intermediate representation between the front and back ends in week one, and it is the single most consequential structural decision in the whole design.

It is worth restating what each phase contributed, because the Benchmark asks you to explain the whole thing and the account is easy to give badly:

PhaseThe abstraction it introducedThe model behind it
1 · Lexicalthe tokenregular expressions and finite automata
2 · Syntaxthe syntax treecontext-free grammars and LR parsing
3 · Semanticthe symbol table and scopesyntax-directed definitions; attribute evaluation
4 · Intermediatethree-address codethe machine-independent instruction set
5 · Optimizationthe transformation, applied to a fixed pointiteration to convergence (data-flow, in the general case)
6 · Code generationthe activation record and the register cachethe calling convention; storage allocation

Every one of those is an abstraction plus a model. That pairing is the method of the field, and it is why compiler construction is one of the few areas of software engineering with genuine theory behind it (Dragon §1.4.1).

2Two performance questions, and not confusing them

1 — how fast is the COMPILER?subject: your program, minicompilermetric: wall-clock time per phasetool: the driver's own timing tablevaries with: input sizematters when: builds are slowAsk: which phase grows fastest?2 — how good is its OUTPUT?subject: the .s file it producedmetric: instructions executedtool: SPIM's run statisticsvaries with: the benchmark programmatters when: the program is slowAsk: optimized versus unoptimized?Project 6 asks for both. Conflating them is the most common error in the write-up.
Two entirely different performance questions. A compiler that runs fast and emits terrible code and one that takes an hour to produce excellent code are both possible, and are measured with different tools. Say which you measured, every time.

The Benchmark project asks for compilation time and execution time. They are different questions with different subjects, different tools and different units, and the most common failure in the write-up is answering one and labeling it the other.

3Measuring the compiler

Your driver already reports per-phase timings. One number tells you very little; the shape as input grows tells you a great deal.

1–2 scan + parse0.07 mslinear in tokens3 semantic0.10 mslinear in AST nodes4 TAC generation0.65 mslinear in AST nodes5 optimization0.33 mspasses × n × cost of liveness test6 code generation0.36 mslinear in TAC instructionsTimes are from one small program — the SHAPE of the growth matters, not these numbers.
A phase-time profile. Most phases are linear in the size of their input. The optimizer is not: it runs several passes, and its dead-code test scans forward from each instruction — so it is quadratic in the worst case. Compile a 10-line, a 100-line and a 1000-line program and plot it; finding that curve in your own compiler is a far better write-up than repeating that optimization is expensive.

Most phases here are linear in the size of their input, which is what you would hope: scanning is linear by construction (Topic 1 §6), LR parsing is linear in the number of tokens, and a tree walk is linear in the nodes. The optimizer is the exception. It runs several passes, and its dead-code test scans forward from each instruction, so in the worst case it is quadratic in the length of the TAC.

This is a finding, not a defect

Noticing that your own optimizer is super-linear, measuring it, and saying what would fix it — a liveness analysis, which computes the same information once for the whole function instead of rescanning per instruction — is exactly the kind of observation Project 6 is asking for. It is worth far more than a paragraph restating that optimization takes time.

Amdahl's law

original30%everything else 70%total 100%phase 5 made 2× faster15%70%total 85%phase 5 made infinitely fast70%total 70%
Amdahl's law, applied to your own compiler. If a phase is 30% of the run time, making it infinitely fast buys at most a 30% improvement. Before optimizing anything — your compiler or the code it emits — measure where the time actually goes. The instinct to optimize the part you find most interesting is nearly always wrong.

Before optimizing anything, measure where the time goes. The instinct to improve the part you find most interesting is almost always wrong, and this applies as much to your compiler as to the programs it compiles.

4Measuring the generated code

Here the subject is the .s file, and the honest metric is instructions executed.

SPIM is not a timing-accurate simulator

It models the instruction set, not the machine: no pipeline, no caches, no memory latency. Wall-clock seconds from SPIM tell you about your laptop and the simulator, not about your compiler. Instructions executed is deterministic, exactly reproducible, and directly attributable to the code you generated — which is what makes it the right thing to report.

the same optimization, two different programsstraight-line setup code17 instructions removed17 executions savedinside a loop running 300×1 instruction removed300 executions saved
Why code size and work done disagree. The bar is instructions saved at run time. Removing seventeen instructions of setup and removing one instruction from a hot loop look identical in a diff and differ by a factor of eighteen at run time. When your two percentages disagree, this is why — and saying so is a better result than either number alone.

What makes a measurement defensible

  1. A stated baseline. 'Thirty per cent faster' invites the question than what? Say: optimized versus unoptimized output of the same compiler, same source, same simulator.
  2. A stated benchmark. A result on a program with a hot loop is a result about hot loops. Include the source in the report.
  3. Repetition and a spread. Three runs minimum. If the run-to-run spread is eight per cent, a five per cent improvement is not a result — it is noise, and reporting it as a result is the thing a reviewer will notice first.
  4. The metric named. Code size and work done are different questions. Report both and explain the gap; the explanation is worth more than either number.

These four rules are not specific to compilers. They are what makes any performance claim checkable, and the Benchmark is partly assessing whether you can make one.

5Knowing the limits

Every compiler has limits. The difference between a student project and a piece of engineering is whether the author can list them, say why each exists, and say what lifting it would cost. Here are this design's, and each is a decision rather than an oversight:

LimitWhy it existsWhat lifting it would take
At most four arguments per callarguments are passed only in $a0–$a3pass the rest on the stack; decide who pops them (Dragon §7.2.3)
No array bounds checkingno length information survives to run timecarry the length with the array, or pass it; then a compare and a branch per subscript
int is the only typethe type is recorded at declaration and never used againa real type field, and type rules in phase 3 (Dragon §6.5)
Registers flushed at every branchthe allocator has no liveness informationbuild the flow graph, compute liveness (Dragon §9.2)
The optimizer forgets everything at a labelno analysis across basic blocksthe same flow graph, then reaching definitions
No recursion into expressions with side effectseager evaluation of && and ||flow-of-control translation of booleans (Dragon §6.6)
t0, L0 … are reserved identifiersthey would collide with generated namesprefix generated names with a character the scanner cannot produce
This table is the answer to Lab Question 29

'Three things you would do differently' is a design review, not a confession. Pick one from the compiler's architecture, one from the language's feature set, and one from your implementation. For each, say what the alternative was, what it would have cost, and why you decided as you did. A confident, accurate account of a limitation reads far better than a claim that there are none.

6What a production compiler does that yours does not

what we left outDragonwhat it iswhat it buysSSA form§6.2.4one definition per variable; φ at mergesmakes most optimizations simpler and strongerFlow graph + data-flow§8.4, §9.2basic blocks, IN/OUT equationsoptimization across blocks instead of within oneGraph-coloring allocation§8.8.4interference graph, color itfar fewer spills than LRU cachingInstruction selection by tree rewriting§8.9match tree patterns with costspicks cheaper instruction sequencesInstruction schedulingch. 10reorder to fill pipeline slotslarge gains on real hardwareLoop transformations for localitych. 11tiling, interchange, fusioncache behavior, often the dominant costA type system§6.5type expressions, checking, coercioncatches whole categories of bugGarbage collection§7.5reachability, collection strategiesmakes a heap safe to use
The map of what a production compiler has that yours does not. Every row is a deliberate omission, not an oversight — each has a chapter or section behind it, and each is a legitimate next step. Being able to name three of these and say what they would cost is the difference between 'my compiler is finished' and 'I know what finished would mean'.

Three of those are worth a paragraph each, because they are the natural next steps and because knowing what they are makes your own design legible as a set of choices.

Static single assignment

SSA (Dragon §6.2.4) requires every variable to be assigned exactly once. Where control paths merge, a φ-function selects among the versions. The payoff is that the definition reaching any use is unique by construction, so optimizations that would otherwise need a reaching-definitions analysis become almost trivial. Every serious modern compiler — GCC, LLVM — is built on it. Placing the φ-functions needs the flow graph, which is why it belongs after Topic 4's machinery, not before.

Register allocation by graph coloring

Dragon §8.8.4 builds an interference graph: one node per value, an edge between values that are simultaneously live. Assigning k registers without two live values sharing one is exactly k-coloring that graph. The problem is NP-complete, so real allocators use Chaitin's heuristic — repeatedly remove a node with fewer than k neighbors, then color on the way back — and spill what cannot be colored. Ours has no liveness information at all, which is why it caches and evicts instead.

Instruction scheduling

On a pipelined processor the order of instructions matters as much as their number: a load followed immediately by a use of its result stalls. Reordering to fill those slots is instruction scheduling, and on real hardware it is often worth more than any of the transformations in Dragon §9.1. SPIM does not model a pipeline, so the effect is invisible here — which is itself worth knowing when you read a benchmark result.

7Errors, across all six phases

It is worth taking one last look at error handling as a whole, because Project 6 asks you to demonstrate it and because the six phases catch genuinely different things:

PhaseCatchesExampleWhy no earlier phase could
1 · Lexicalcharacters the language does not admitx = y @ z;there is no earlier phase
2 · Syntaxtoken sequences no production matchesint int int;the scanner sees one lexeme at a time and no context
3 · Semanticcontext-sensitive rulesundeclared variable; wrong argument count; break outside a loopno context-free grammar can express these (Topic 2 §8)
4–5 · IRinternal inconsistenciesa temporary with no definitionthese are compiler bugs, not program errors
6 · Codegentarget limitationsmore than four argumentsthe limit is a property of the machine and the convention

That fourth row is worth dwelling on. Errors detected in phases 4 and 5 are almost always your bugs rather than the programmer's, and should be reported differently — an internal-error message naming the compiler, not a diagnostic blaming the source. Compilers that confuse the two are infuriating to use.

8Documentation as an engineering artefact

A README is not a courtesy. Its test is operational, and it is a hard test: can somebody who has never seen your compiler build it and run it from your README alone, with no conversation?

Almost every README fails on the same four things:

The cold-start test in this week's activity is the only reliable way to find these. Reading your own README does not work, because you already know what it left out.

9The retrospective

Lab Questions 29 and 30 ask what you would do differently and what you are proudest of. Both are better answered structurally than emotionally. Some prompts that produce good answers:

LayerQuestion worth asking
ArchitectureWhich phase boundary earned its keep? Which one would you draw differently, and what would that have cost elsewhere?
Language designWhich feature was hardest to compile, and was it worth having? Which omission did you most regret?
ImplementationWhich data structure would you change? Where did a shortcut in week 3 cost you a day in week 12?
ProcessHow did you divide the work? What would you divide differently, and why?

A specific, technical answer — 'the AST's statement list should have been a vector, because every visitor needed a special case for a nested list, and I got that wrong twice' — is worth ten general ones.

10Where this goes next

Dragon §1.5 lists what compiler technology is actually used for beyond compilers, and it is a longer list than most people expect:

The last of those is the most immediately useful. A linter is a front end plus a data-flow analysis. An IDE's 'rename symbol' is a parser plus a symbol table with scopes. You have built the machinery; the applications differ mainly in what they do with the tree.

If you want to keep going, in order of return on effort

11Self-check

Your optimizer reports 35% fewer TAC instructions but SPIM reports 4% fewer instructions executed. What do you write in the report?

Which of these is the best entry in a limitations section?

A phase takes 30% of your compiler's run time. You make it twice as fast. What is the overall improvement?

Why should errors detected in phase 4 or 5 be reported differently from errors in phases 1–3?

What is the strongest single claim you can make about your compiler's architecture?

12References