A by-value wide record on xtensa renders a live address
RESOLVED. All four spots landed together. Both ABIs now match the x86-64 oracle on every shape probed,
test_arm32_record_byval_wideis wired intotest-xtensa, and the 129-source differential went 100 -> 101 matching with zero programs regressed (windowed 50 -> 51). Compiler sha1f24e6cd3989.It was four spots, not three. The ticket said three and named them; a shape-varying probe found the fourth after the first three were green. What follows keeps the original three-spot analysis intact and adds the fourth below it, because the reason the count was wrong is the reusable part.
Repro — nine lines, and the three symptoms are one bug at three distances
type TPlain = record a, b: integer; end; { 8 bytes }
procedure lone(r: TPlain); begin writeln(r.a,' ',r.b); end;
procedure mixedMiddle(x: integer; r: TPlain; y: integer);
begin writeln(x,' ',r.a,' ',r.b,' ',y); end;
procedure mixedTail(x1,x2,x3,x4: integer; r: TPlain);
begin writeln(x1,' ',x2,' ',x3,' ',x4,' ',r.a,' ',r.b); end;
| call | xtensa | oracle |
|---|---|---|
lone(p) |
1 0 |
1 2 |
mixedMiddle(1,p,2) |
1 1 1 2 |
1 1 2 2 |
mixedTail(1,2,3,4,p) |
1 2 3 4 1 134730429 |
1 2 3 4 1 2 |
Data loss → neighbour corruption → a live address rendered as a decimal
number, as the slot that gets read moves further from the one that was
written. arm32 recorded exactly this escalation for the identical defect
(bug-arm32-record-byvalue-over-4-bytes-abi-gap); riscv32 hit it as
bug-riscv32-byval-record-param-one-word. riscv32 and arm32 are correct
today; xtensa is the sixth backend, skipped again — the same sentence as
ABIParamSlotHoldsValueAddr, PXXStrCmp3, and the frozen-equality guard fixed
an hour ago.
Three spots, all counting 4 bytes where the type is 8
arm32's ticket is explicit that it is three, and that fixing a subset makes it worse: "Fixing the word-count without also widening the param's own frame slot turned the data loss into active corruption."
| # | spot | file | status |
|---|---|---|---|
| 1 | IR_LOAD_SYM — a 5-8 byte record VALUE must load BOTH words |
ir_codegen_xtensa.inc |
landed |
| 2 | the by-value call-arg push loop — push both words and apply the even-word pad | ir_codegen_xtensa.inc |
landed |
| 3 | the callee param spill — store both words and advance pw by 2 |
ir_codegen.inc |
landed (bounded grant) |
| 4 | IR_LOAD_MEM — a record-RETURNING call used directly as an argument |
ir_codegen_xtensa.inc |
landed — not in the original analysis |
Where it is blocked
EmitParamSpillsForTarget's xtensa arm (ir_codegen.inc, ~line 1467) widens
only for tyInt64 / tyUInt64 / tyDouble:
if ((Syms[idx].TypeKind = tyInt64) or (Syms[idx].TypeKind = tyUInt64) or
(Syms[idx].TypeKind = tyDouble)) and
(not Syms[idx].IsRef) and (not Syms[idx].IsArray) then
A 5-8 byte tyRecord param falls to the else, stores one word and does
Inc(pw, 1). With spot 2 landed the caller pushes two, so the callee reads one
and every following parameter shifts by a word — which is why the middle and
tail rows change value rather than becoming correct.
ir_codegen.inc is Track A's and frankA is working in it right now
(perf-a-cache-the-compiled-nilpy-runtime-unit-image). One if needs the
record terms added, plus Inc(pw, 2) on that path. Nothing else.
Why this was probed rather than reasoned
After spots 1 and 2 the minimal repro was byte-identical to before, which
reads exactly like "my change did nothing". Reasoning would have gone looking
for a wrong predicate in the arms I had just written. A one-line Error probe
in the call-arg arm answered it in one build: the arm fires. The caller was
already correct; the callee was the half that had never been visited. Two
minutes of probe against an afternoon of re-reading a correct guard.
RESOLUTION
Landed as one change, compiler sha 1f24e6cd3989, converged after 1 round(s).
| measurement | before | after |
|---|---|---|
lone(p) |
1 0 |
1 2 ✓ |
mixedMiddle(1,p,2) |
1 1 1 2 |
1 1 2 2 ✓ |
mixedTail(1,2,3,4,p) |
1 2 3 4 1 134730429 |
1 2 3 4 1 2 ✓ |
test_arm32_record_byval_wide |
DIFF | MATCH, both ABIs |
| 129-source differential, Call0 | 100 match / 7 diff | 101 / 6, zero lost |
| 129-source differential, windowed | 50 match / 55 diff | 51 / 54, zero lost |
The match-set delta was computed as a set difference in both directions, not from the totals — a +1 that is really a +2/-1 looks identical in a count.
Two things this ticket got wrong, and both were caught by measuring
1. The blocked spot 3 was written as dead code first. The obvious guard —
sz > 4 where sz was already in scope as ParamSize(idx) — compiles,
self-hosts, and never fires, because ParamSize answers the SLOT-LAYOUT
question via ABIParamSlotIsPointer, which holds tyRecord unconditionally.
So sz is TARGET_PTR_SIZE (4 here) for every record regardless of width.
I wrote a comment asserting sz "asks the record's real width rather than
assuming it"; that sentence was reasoning, and it was false. The repro came back
byte-identical and the false comment was the only reason the arm looked right.
arm32 and riscv32 spell it RecSize(Syms[idx].RecName) a few dozen lines up in
the same procedure — the fix was to copy the two backends that already had the
rule, not to invent a third spelling. ParamSize and AllocParam genuinely
disagree here; filed separately as
[[bug-a-paramsize-and-allocparam-disagree-about-a-5-8-byte-byvalue-record]].
2. It was four spots, not three, and only a shape-varying probe found it.
With spots 1-3 green, lone and tail were correct and mixedMiddle was wrong
in a new way (1 2 2 1 for 1 1 2 2) — the caller had packed the record at
words 1-2 while the callee read 2-3 and took word 4 for y, i.e. the callee
applied the even-word pad and the caller did not. That was spot 2 missing
XtensaPadTo64Xtensa. Then a deliberately widened repro — 4-byte record, 5-byte
record, two records in a row, 5th and 6th parameter positions, forwarding a
record parameter onward, and a record-RETURNING function called directly as an
argument — turned up exactly one surviving failure: inner(mk) printed 7 0 on
Call0 and 7 6 (a stale register) on windowed. The producer node there is
IR_LOAD_MEM, not IR_LOAD_SYM, so widening IR_LOAD_SYM could not cover it.
riscv32 carries that arm with the identical comment; xtensa had never had it.
The generalisation, and it is the same one this repo keeps re-deriving: the
repro in a ticket describes ONE shape of the defect, and a fix measured only
against that repro is a fix sized to the repro. Varying the shape cost about ten
minutes and moved the count from three spots to four. Had I stopped when the
ticket's own three rows went green — which is what "the ticket said three" makes
tempting — inner(mk) would have shipped broken, and it is precisely the shape
(a record-returning function used inline) that a real program reaches for.
See devdocs/dev/root-cause-over-microfix.md.
Sixth-backend note
All four spots are rows arm32 and riscv32 already carried. That is now the
fifth xtensa defect tonight with the same signature — a rule the other
backends have and xtensa was skipped for — after ABIParamSlotHoldsValueAddr,
PXXStrCmp3, the frozen-string equality guard, and SPECIAL_IN. The standing
explanation is in scratchpad/why.md and holds here too: the target with no
working oracle is the target that keeps the bug. xtensa has an oracle now, and
this is what it is for.
Gate as run
make compiler/pascal26 to fixedpoint — converged after 1 round(s), sha
1f24e6cd3989, confirmed different from pinned — the repro above, the widened
shape probe, test_arm32_record_byval_wide against the x86-64 oracle in both
ABIs, the 129-source differential in both ABIs for regressions, and
tools/gate.sh quick (this touched a shared Track A file, so the optional
quick gate was run rather than skipped). test_arm32_record_byval_wide is wired
into test-xtensa, compared against the x86-64 oracle rather than a literal
transcript, since the point of the row is agreement with the other backends.
Log
- 2026-08-30 — resolved, commit a5f5bd42f.