Skip to content

compiler: register allocation is non-deterministic — one binary compiles the same source two different ways #50

Description

@mparrett

Compiling the same source with the same binary does not always produce the same bytecode. The instruction sequence is stable; one register assignment moves.

Reproduce

go build -o /tmp/pas ./cmd/paserati/
cd tests
for i in $(seq 1 40); do /tmp/pas -bytecode scripts/bench_setindex.ts | shasum -a 256; done | sort | uniq -c

On a182fa2 that gives two distinct outputs, 6 of 40 runs taking the minority form. One binary, one input, no flags varied.

The entire difference

 0314      OpIncPost        R27, R24
-0324      OpUnsignedShiftRight R27, R21, R1
-0328      OpReturn         R27
+0324      OpUnsignedShiftRight R26, R21, R1
+0328      OpReturn         R26

(the OpIncPost line differs in the same register). Three lines, one register, everything else identical.

Of the five benchmark fixtures under tests/scripts/, only bench_setindex.ts shows it in 15 repeats each — bench_add.ts, bench_arith.ts, factorial.ts and matrix_mult.ts are stable. So it needs some particular shape of register pressure to surface.

Why I care about it beyond tidiness

I found this while building null controls for perf measurement, and it has a consequence there that is easy to miss: compilation happens per launch, so two runs of the same commit can execute different bytecode. That means byte-identical binaries do not imply identical executed code, and a control built on comparing build artifacts cannot see it.

BenchmarkSetIndex is the tightest benchmark in our suite (0.30% MAD/median), so whatever this costs is small — but it is an uncontrolled variable sitting underneath every measurement of that benchmark, and nothing was accounting for it.

Beyond benchmarking it also means -bytecode output is not reproducible, which makes it awkward as a debugging or golden-test artifact.

Guess at the cause

Most likely a map iteration order feeding register allocation — a for … range over a Go map somewhere in the allocator or in whatever tracks free/live registers. Go randomises that order per run, which fits both the per-run variation and the fact that only one function in one fixture is affected.

Happy to dig if useful, but you will find it much faster than I will.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions