211 lines
14 KiB
Markdown
211 lines
14 KiB
Markdown
# What the hand-written x86-64 backend costs
|
||
|
||
`survey.sh` has said for three handoffs that this backend agrees with LLVM on all 97 corpus programs it can build.
|
||
Item 7 of `docs/handoffs/HANDOFF-x86-rt.md` is the other half of that sentence — nobody had a number for what the agreement
|
||
costs — and it came with a list of suspects: a guard after every call, three frame temporaries per bounds check,
|
||
every intermediate in memory, `rep movsb` block copies, and an extra load per call site in a dev build. This is
|
||
the measurement. It does not change anything; two of the five suspects turn out not to matter, and the one that
|
||
matters most is not on the list.
|
||
|
||
Produced by `spike/x86/cost.sh` (the corpus, for size) and `spike/x86/bench.sh` (four purpose-written programs,
|
||
for speed). Both are documented in their own headers. Machine: 16-core x86-64, Fedora, clang as the assembler and
|
||
linker on both sides, and other work running on it throughout — which is why every time below is the *minimum* of
|
||
five or seven runs and why nothing here rests on a difference of a few percent.
|
||
|
||
The rows behind the tables are committed beside this file as `cost-corpus.tsv` and `cost-bench.tsv`, so a later
|
||
lane can recompute a ratio rather than believe one.
|
||
|
||
## What is being compared, and against what
|
||
|
||
The interesting column is not the size of the executable. A Flan binary is mostly `flan_rt.o` and libc glue, the
|
||
same object on both sides, and it drowns the signal: over the corpus the whole file is only **1.09×** bigger
|
||
through this backend and `.text` only **1.18×**, which would be a reassuring number and a meaningless one.
|
||
|
||
So the measurement is the sum of the sizes of the defined symbols the compiler *named itself* — everything called
|
||
`flan.<something>`. The runtime's C is `flan_<something>` with an underscore, so the two never collide, and a
|
||
runtime symbol has the same size on both sides (`flan_map_clone`, `0x4b3` either way), which is the check that
|
||
says the difference really is codegen and not a differently-linked runtime.
|
||
|
||
There is a third column, and it is what makes the second readable: **LLVM with `--debug`, which forces `-O0`**.
|
||
This backend has no optimiser at all, so measuring it against LLVM at `-O2` charges it for the whole of mem2reg,
|
||
inlining and constant folding. `-O0` is LLVM's instruction selection with none of that, which is the comparison
|
||
that says something about *this* backend rather than about the absence of a middle end. (`--x86 --debug` is
|
||
refused — the backend emits no DWARF — so the column exists on one side only. DWARF lands in `.debug_*` sections
|
||
and not in `.text`, checked, so it does not contaminate the symbol sums.)
|
||
|
||
## The corpus: size
|
||
|
||
Ninety-seven programs — the same set `survey.sh` matches on, minus the two that run forever. Summed over all of
|
||
them:
|
||
|
||
| | LLVM -O2 | LLVM -O0 | x86 |
|
||
|---|---|---|---|
|
||
| own code, all 97 programs | 221,608 | 444,504 | 851,638 |
|
||
| against LLVM -O2 | 1.00× | 2.01× | **3.84×** |
|
||
| against LLVM -O0 | | 1.00× | **1.92×** |
|
||
|
||
So the headline is two numbers, not one. **This backend emits 3.8× the code LLVM does at `-O2`, and half of that
|
||
factor is the optimiser Flan ships with rather than anything about the backend; against LLVM with the optimiser
|
||
off it is 1.9×.** Per program the second ratio is tight — median 2.21, quartiles 1.94 and 2.98, the whole range
|
||
1.32 to 5.44 — which is itself a finding: the cost is not a few bad nodes, it is a constant tax on everything.
|
||
|
||
The ten largest programs, which are where the bytes actually are:
|
||
|
||
| program | LLVM -O2 | LLVM -O0 | x86 | x86 / -O2 | x86 / -O0 |
|
||
|---|---|---|---|---|---|
|
||
| `strings` | 13,395 | 22,681 | 33,265 | 2.48× | 1.47× |
|
||
| `maps` | 12,833 | 19,850 | 40,445 | 3.15× | 2.04× |
|
||
| `edn` | 12,057 | 34,556 | 48,532 | 4.03× | 1.40× |
|
||
| `generics` | 10,137 | 20,427 | 39,241 | 3.87× | 1.92× |
|
||
| `slurp` | 9,744 | 14,813 | 24,656 | 2.53× | 1.66× |
|
||
| `into` | 8,277 | 12,345 | 21,320 | 2.58× | 1.73× |
|
||
| `vec` | 8,039 | 12,089 | 22,770 | 2.83× | 1.88× |
|
||
| `map-iter` | 7,211 | 10,374 | 21,715 | 3.01× | 2.09× |
|
||
| `algorithms` | 6,776 | 16,616 | 30,875 | 4.56× | 1.86× |
|
||
| `slices` | 2,393 | 9,057 | 17,753 | 7.42× | 1.96× |
|
||
|
||
And the two ends of the distribution, both of which are more interesting than the middle:
|
||
|
||
| program | LLVM -O2 | LLVM -O0 | x86 | x86 / -O2 | x86 / -O0 | why |
|
||
|---|---|---|---|---|---|---|
|
||
| `bounds` | 363 | 1,951 | 4,039 | **11.13×** | 2.07× | LLVM at `-O2` proves the indices and deletes the checks |
|
||
| `array-ctor` | 357 | 1,840 | 3,880 | 10.87× | 2.11× | the same, over a constructor's worth of stores |
|
||
| `p2-loop-print` | 283 | 106 | 577 | 2.04× | **5.44×** | a `-O0` build *smaller* than `-O2`: LLVM unrolls the five-iteration loop and `-O0` does not |
|
||
| `pkg-return` | 7,024 | 23,635 | 31,716 | 4.52× | 1.34× | mostly prelude, where `-O0` is already fat |
|
||
|
||
`bounds` is the clearest case in the table of why the `-O0` column had to exist. Eleven times is a shocking
|
||
number and it is not about this backend at all: the program's whole point is indexing, LLVM at `-O2` can see the
|
||
indices are in range and removes the check, and neither LLVM at `-O0` nor this backend can. Against the compiler
|
||
that also keeps every check, `bounds` is 2.07× — a completely ordinary row.
|
||
|
||
## Where the size goes
|
||
|
||
Every one of these is from the disassembly of the benchmark programs, which are small enough to read whole.
|
||
|
||
**Every intermediate goes through the frame, and so does every constant.** This is the big one and it is not one
|
||
feature, it is the shape of the whole backend. `(step acc 1)` in `b1-calls` compiles to:
|
||
|
||
movabs $0x1,%rax ; a 10-byte immediate ...
|
||
mov %rax,-0x30(%rbp) ; ... stored to a frame slot ...
|
||
mov -0x30(%rbp),%rsi ; ... and loaded back into the argument register
|
||
|
||
Three instructions and 24 bytes where LLVM writes `mov $1,%esi`, five. The loop bound gets the same treatment
|
||
*every iteration* — `movabs $0x1312d00` into a slot, sign-extended out of it, compared — because nothing is
|
||
hoisted. So does the loop condition: `cmp`/`setl`/`movzbq`/store a byte to the frame/reload it/`test`/`jne`,
|
||
seven instructions for what is `cmp`/`jge` anywhere else. This is most of the 2× against `-O0` and essentially
|
||
all of the difference on the programs at the bottom of the table, which have no calls, no bounds checks and no
|
||
aggregates in them at all.
|
||
|
||
**The guard after every call is four instructions and one dependent load.**
|
||
|
||
call 4009f8 <flan.step>
|
||
mov -0x18(%rbp),%r11 ; the condition frame, from its own slot
|
||
mov 0x0(%r11),%r11 ; ... dereferenced
|
||
test %r11,%r11
|
||
jne <unwind>
|
||
|
||
Roughly 25 bytes per call site. Real, cheap, and third in size behind the two above it — on a program that is
|
||
nothing *but* calls (`b1`) the whole backend is 2.8× LLVM `-O0`, and the guard is a minority of that.
|
||
|
||
**The bounds check is three frame temporaries, as suspected, and it costs code and not time.** The index is
|
||
widened, stored, reloaded, stored again, the limit goes to a third slot, and then `cmp`/`jb` — with the failure
|
||
path, its `.rodata` location string and its length, inline at the branch target. On `b2-bounds`, `flan.main` is
|
||
`0x4b1` with checks and `0x3c0` without: **241 bytes, a quarter of the function.** In time it is 112.6ms against
|
||
105.0ms over 20.5 million checked loads — **about 0.4ns, a cycle or two a check** — because the branch predicts
|
||
perfectly and the loads were going to memory anyway. That contradicts the way the handoff's list reads. The three
|
||
temporaries are a code-size item. They are not a speed item.
|
||
|
||
**`rep movsb` is real and it is the most expensive single instruction here.** A 64-byte struct copy lowers to
|
||
`lea`/`lea`/`movabs $0x40,%rcx`/`rep movsb`, and `b4-copy` runs 2 million of them in 20.8ms against LLVM `-O0`'s
|
||
7.2ms: **about 6.8ns of the difference per copy, some twenty cycles**, which is `rep movsb`'s startup cost and
|
||
almost none of it the 64 bytes. It is also the one place where the backend loses to `-O0` by a factor (2.9×) it
|
||
does not lose by on straight-line code, and the one item on the suspect list where a targeted fix — inline
|
||
16-byte moves under some size threshold — would pay for itself.
|
||
|
||
## The corpus: speed, and why there is barely any
|
||
|
||
Almost nothing. **Every program in `test/programs` runs in about 2.5 milliseconds, nearly all of it `execve` and
|
||
the dynamic loader**, and both backends produce the same 2.5 milliseconds. Best-of-five does not rescue a signal
|
||
that is not there. There is exactly one corpus program whose own code is a measurable part of its runtime, and it
|
||
is the right one:
|
||
|
||
| program | LLVM -O2 | LLVM -O0 | x86 | what it is |
|
||
|---|---|---|---|---|
|
||
| `recur` | ~1ms | 10ms | 60ms | a ten-million-iteration counting loop, written to prove `recur` is a jump |
|
||
|
||
Read that carefully, because the 25× against `-O2` is not a fact about this backend: LLVM folds the loop to its
|
||
answer and runs nothing. Against `-O0`, which also runs ten million iterations, it is **6×** — about 6ns an
|
||
iteration against 1ns, or roughly eighteen cycles for `i+1` and a compare. That is the frame-slot round trip
|
||
above, four or five times over, and it is the honest number.
|
||
|
||
## The benchmarks
|
||
|
||
Four programs in `spike/x86/bench/`, each written so that one suspected cost is most of what the program does.
|
||
They are in a subdirectory on purpose: `survey.sh` globs `spike/x86/*.flan` and a benchmark is not a case.
|
||
Times are best-of-seven, in milliseconds.
|
||
|
||
| bench | what it is | LLVM -O2 | LLVM -O0 | x86 | x86 / -O0 | own code, -O0 → x86 |
|
||
|---|---|---|---|---|---|---|
|
||
| `b1-calls` | 20M calls of a one-instruction function | 2.1 | 36.8 | 104.3 | 2.8× | 200 → 807 |
|
||
| `b2-bounds` | 20.5M bounds-checked array loads | 4.7 | 27.0 | 112.6 | 4.2× | 407 → 1390 |
|
||
| `b3-spill` | 5M iterations of a six-deep arithmetic tree | 22.2 | 29.0 | 137.3 | 4.7× | 248 → 1117 |
|
||
| `b4-copy` | 2M copies of a 64-byte struct | 2.0 | 7.2 | 20.8 | 2.9× | 348 → 877 |
|
||
|
||
`b2` with `--no-bounds-checks` on both sides: LLVM 4.5ms, x86 105.0ms — the 7.6ms the check costs over 20.5
|
||
million of them, and the 241 bytes it costs in `flan.main`, are the whole of it.
|
||
|
||
`b3`'s ratio is the one to distrust slightly: its expression ends in a `%`, which is an `idiv`, and an `idiv` is
|
||
twenty-odd cycles on every side. That is most of LLVM's own 22.2ms and a good part of its 29.0ms, so the
|
||
denominator is largely a hardware latency this backend cannot do anything about. The absolute gap — 108ms over
|
||
5 million iterations, about 21ns of extra work each — is the honest reading of that row.
|
||
|
||
|
||
|
||
`b1-calls` and `b3-spill` are the pair to read together, with the `idiv` caveat above in mind. `b1` is 20 million
|
||
calls of a one-instruction function and lands at 2.8× `-O0`; `b3` has no calls at all and pays 21ns an iteration
|
||
for six dependent arithmetic temporaries. **The backend is worse at arithmetic than it is at calling**, which is
|
||
the opposite of what the suspect list implies, and it is because a call already costs enough that four extra
|
||
instructions beside it disappear, while an add that should be one instruction costs five. `recur`, which is a
|
||
counting loop and nothing else, says the same thing on a corpus program: 6× LLVM `-O0`.
|
||
|
||
The `-O2` column in `b1` and `b4` is 2ms — the loop is gone. That is a true fact about the toolchain Flan ships
|
||
and a useless one about code generation, which is the whole reason the `-O0` column exists.
|
||
|
||
## `--dev`, which is the one axis both backends pay
|
||
|
||
`SURVEY_FLAGS=--dev` reported 97 MATCH for the lane before this one, so the comparison is available. The suspected cost was the extra
|
||
load per call site — every cross-function call going through its indirection cell. **It is not measurable.** On
|
||
`b1-calls`, 20 million calls, x86 release is 104.3ms and x86 `--dev` is 100.9ms: the same number, and the dev
|
||
build is nominally the *faster* of the two, which is what a difference below the noise floor looks like. The load
|
||
is from a `.data` cell that is in L1 after the first call and the machine was already waiting on the frame.
|
||
|
||
What a dev build actually costs is something else entirely, and both backends pay it. `b1-calls` is a program
|
||
with two functions in it:
|
||
|
||
| build | own code |
|
||
|---|---|
|
||
| LLVM, release | 82 bytes |
|
||
| x86, release | 807 bytes |
|
||
| LLVM, `--dev` | 32,714 bytes |
|
||
| x86, `--dev` | 83,018 bytes |
|
||
|
||
**A dev build emits the entire prelude**, because anything might be redefined and so nothing may be dropped. That
|
||
is four hundred times the code for this program, and it dwarfs every item on the suspect list put together. It is
|
||
also not a backend cost — LLVM pays a 400× of its own — so it is `Reach`'s business and not `x86.ml`'s. The
|
||
backend's share of it is the same ~2.5× it charges everywhere else.
|
||
|
||
## What this says to the lane rewriting `lib/x86.ml`
|
||
|
||
Ranked by what the numbers actually support, and not by the order of the list in the handoff:
|
||
|
||
1. **Keep values in registers across a single expression.** Not a register allocator — just not routing every
|
||
constant and every subexpression through a frame slot, and not re-materialising a loop bound every iteration.
|
||
This is most of the 2× against `-O0` and most of `recur`'s 6×, and it is what `b3`'s 21ns an iteration buys.
|
||
2. **Inline small aggregate copies** instead of `rep movsb`. One instruction, twenty cycles, on a copy that is
|
||
four `movdqu` pairs.
|
||
3. **`flan_dev_reg_note` in a release build** (item 2 of the old handoff's list) is worth doing and is small.
|
||
4. **The call guard is fine.** Four instructions and 25 bytes, invisible in time. Leave it.
|
||
5. **The bounds check is fine on time and fat on code.** If it is ever worth touching, it is worth touching for
|
||
the 241 bytes — hoisting the failure path out of line would get most of that back without changing a cycle.
|
||
6. **The dev call cell is free.** Whatever the redefinition emitter costs, it does not cost this.
|