← board

-O3 drops the first of two chained qword * 31 xor statements

Repro — eight lines, no units, no records, no functions

program min3;
{$mode objfpc}{$Q-}{$R-}
var a, b, c: longint; h: qword;
begin
  a := 661; b := 0; c := 7;
  h := qword(a) * 31 xor qword(b);
  h := h * 31 xor qword(c);
  writeln(int64(h));
end.
pxx -O0 = 635218    pxx -O1 = 635218    pxx -O2 = 635218    pxx -O3 = 222
fpc -O0 = 635218    fpc -O2 = 635218

At 5dd25e789, compiler binary sha256 41e452a55913, self-host fixedpoint verified at HEAD before measuring.

What -O3 actually does

222 is not noise, it is arithmetic. Working backwards: X * 31 xor 7 = 222 gives X = 7 — which is c. So the value entering the second statement is c, not the first statement's result (20491). The first statement is discarded and h is loaded with the wrong symbol.

Confirmed by making the middle operand non-zero: with b := 5, -O2 and FPC move to 635317 and -O3 still prints 222. The -O3 answer does not depend on b at all, which is what distinguishes "the first statement is dropped" from "the first statement is miscomputed".

Boundary — four axes, each measured

variation result
single statement, no chain clean — the chain is required
second statement without * 31 (h := h xor qword(c)) clean — the multiply in stmt 2 is required
all int64, no qword casts clean — the unsigned type is required
b := 5 instead of 0 still 222 — the result is independent of the dropped statement's operands
record fields / nested record / cross-unit record / enum operand / inside a function all still reproduce — none of these are involved

The last row matters for how this was found: it came out of the pasmith --units 2 rung as pxx-self_unitrec, and units, records and nesting were all irrelevant. The discovery vehicle was not the subject; the signature name is the statement kind at the first checkpoint that diverged, which is a discovery coordinate rather than a cause.

Suspect, stated as a suspect

ir_codegen.inc:6402 is gated (OptLevel >= 3) and (op = tkStar) and (IRKind[left] = IR_LOAD_SYM) — a multiply whose left operand is a symbol load, which is exactly the shape of h * 31 in the second statement, and the neighbouring -O3 fusions at 6258/6431 are the same family. This is where I would look, not a conclusion. I did not bisect the OptLevel >= 3 sites because frankA holds ir_codegen.inc/emit.inc under a live Track A lock, and a temporary diagnostic edit is still a concurrent edit.

Relationship to bug-a-o3-alone-computes-a-different-result-for-a-nested-case-statement

Possibly the same root, not established either way. Both are -O3-only, four-oracles-to-one, silent, and both diverging programs are dense in qword arithmetic. But that ticket's repro is a 77-line nested case and this one is eight lines with no case at all, so they are not the same shape. If they do share a root, this repro is by far the better handle and fixing it should be tried first — re-run seed 91162 afterwards to find out. They are filed separately because merging two findings on a suspected common cause is how one of them stops being tracked when the other is closed.

Why prio 50

Higher than the case ticket's 45 for one reason: the repro is eight lines, so the cost of acting on it is small and it may close both. Still bounded by -O3 being the free tier — nothing ships built at it, -O2 is the proven default — but this is silently wrong integer arithmetic from a chain any hash or checksum loop would produce, and it blocks promoting the pass at fault to -O2.

Acceptance

The eight-line program prints 635218 at -O3, and seed 91162 from the sibling ticket is re-checked to record whether it was the same defect.

Resolution (2026-08-30, frank-optimize-b4)

The pass at fault is -O3 store→reload elimination (feature-opt-store-reload-elimination), and the defect is not in the pass's idea but in the way it decides where to apply it.

The pass marks an IR_LOAD_SYM as redundant when the immediately preceding top-level statement stored that very symbol and nothing at all was emitted in between, so rax still holds the value. It decides that with IRFirstEvaluated, a hand-maintained mirror of IREmitNode's operand-order guard chain — and the mirror is missing an arm. -O3's W1 slice 9 (ir_codegen.inc, "both subtrees proven pure → park the RIGHT value in the scratch register and evaluate it first") reverses evaluation order; the mirror walks the LEFT subtree unconditionally and answers "the leaf load of h".

So for h := h * 31 xor qword(c) the load of h was marked redundant, the emitter evaluated qword(c) into rax first, and the load emitted nothingEmitReExtendRax on a qword is a no-op. The result is (c * 31) xor c = 222.

The four axes T measured are each explained by that mechanism, which is the check that this is the right cause and not a plausible one:

axis why the mechanism needs it
two-statement chain the mark requires the preceding statement to store h
* 31 in statement two the -O1 imm-fold arm is what makes IRFirstEvaluated walk down to the leaf load instead of stopping at "cannot say"
qword casts a cast lowers to a binop, so the right operand is not a leaf; the leaf-right arms above slice 9 do not take it. All-int64 never reaches slice 9 — which is exactly why it was clean
answer independent of the middle operand statement one's result is never read: rax holds qword(c), so nothing of statement one survives

The fix — verify the premise where it is used, do not predict it better

The obvious repair is to add the missing arm to IRFirstEvaluated. That fixes this shape and leaves the design defect in place: two models of one decision, kept in sync by hand, where the emitter is the authority and the mirror is a copy. The mirror's own comment says "MUST MOVE TOGETHER WITH IREmitMachineCode's arms" — the instruction was there, and it was not followed, which is what a hand-sync requirement gets you.

So the mark is still a prediction, but it is now checked at the point of use:

That expression is the pass's stated premise ("nothing at all was emitted in between"), evaluated against what the emitter actually did rather than against a model of what it would do. A mirror that drifts again now costs a missed optimisation, which is what the mirror's comment already claimed was the worst case.

Measured

check result
eight-line repro, -O0/-O1/-O2/-O3 635218 (fpc -O2: 635218)
same with b := 5, all levels 635317
seed 91162 (the case ticket), -O0/-O2/-O3 16452949249337348755
self-host fixedpoint converged after 1 round(s), sha256 46dbc0e5f751

Cost of the check, on compiler.pas at -O3: 19,941 marks, 1 declined. That ratio is the whole story of why this survived: the mirror was wrong once in twenty thousand, and the one time it was wrong it produced a plausible number. The single decline in the compiler's own -O3 build (sym imm8, 24 bytes already emitted) was a live wrong-code site.

And it closes the sibling

Seed 91162 now agrees with all four oracles, so bug-a-o3-alone-computes-a-different-result-for-a-nested-case-statement was the same defect. That is a measurement, not the assumption the two tickets were deliberately kept apart to avoid — the acceptance criterion written into this ticket is what turned the question into one command.

Regression

test/test_opt_store_reload.pas gains section 6 (both repro shapes) and the Makefile rows assert the values 635218 / 635317, never "the two -O levels agree" — they agreed at 222 as well. A row also asserts the emit-time refusal actually fires (PXXDBG=a.reload:* now prints DECLINED lines), because a guard that never declines cannot be shown to work.

Not done here

The pass stays on -O3. Promotion to -O2 is a separate decision and wants the full gate plus a measured win, not a fixed bug.

Log