https://gcc.gnu.org/bugzilla/show_bug.cgi?id=127858
Bug ID: 127858
Summary: wrong code at -O2: LIM hoists load past aliasing store
after merging a COMPONENT_REF with an equivalent
MEM_REF
Product: gcc
Version: 17.0
Status: UNCONFIRMED
Severity: normal
Priority: P3
Component: tree-optimization
Assignee: unassigned at gcc dot gnu.org
Reporter: r1ch+gcc at teamliquid dot com
Target Milestone: ---
Created attachment 65810
--> https://gcc.gnu.org/bugzilla/attachment.cgi?id=65810&action=edit
Minimal reproducer
Hello,
I compile my own nginx binaries with GCC, using -O3 and -flto with GCC 12.2.0
(Debian 12.2.0-14+deb12u1). After deploying these, I began to see rare
segfaults in production. After investigating the core dump and creating a
minimal reproducer with LLM assistance, I believe this to be a compiler bug.
The following write-up is LLM generated; I'm not going to pretend to understand
compiler internals but I was able to mostly follow along and believe it to be
accurate. The code compiles correctly under Clang and MSVC:
https://godbolt.org/z/hYo86Wj9P
Summary
-------
The attached testcase (lim-nocast.c, ISO C99) is miscompiled at -O2 and
-O3.
$ gcc -O2 lim-nocast.c && ./a.out
MISCOMPILED: 4 of 4 reads saw a stale value
It prints "OK" at -O0/-O1, with -fno-strict-aliasing, with
-fno-tree-loop-im, and with clang. -fwrapv has no effect. It is clean
under -fsanitize=undefined,address with GCC 14 and clang 19.
Reproduced with:
gcc version 17.0.0 20261009 (experimental) [trunk r17 26e33a3c47a]
gcc version 14.2.0 (Debian 14.2.0-19)
gcc version 12.4.0 (Debian 12.4.0-5)
The source has no casts, no pointer arithmetic and no type punning. All
three accesses go to one object of type struct node * (owner->list.prev),
through lvalues of that type, formed with plain '.', '->' and '&'. That is
permitted by C11 6.5p7, so -fno-strict-aliasing changing the result points
to a bug in GCC's TBAA-based disambiguation, not to an aliasing violation
in the source. The loop has no arithmetic that could overflow.
The bare MEM_REF that triggers the bug is created by GCC itself: *field
is forward-propagated into MEM[(struct node * *)owner + 8B].
-fdump-tree-lim2-details (14.2; trunk shows the same) then merges both
loads into one ref, wrongly finds it independent of the store, and hoists
both loads:
Memory reference 1: owner_8(D)->list.prev
Memory reference 2: list_alias_13(D)->prev
Querying dependency of refs 1 and 2: independent.
Moving statement
before_11 = owner_8(D)->list.prev;
Moving statement
after_15 = MEM[(struct node * *)owner_8(D) + 8B];
Executing store motion of list_alias_13(D)->prev from loop 1
The loop body vanishes and the function just returns 'iterations':
count_stale_reads:
movl %ecx, %eax
testl %ecx, %ecx
jle .L3
leal -1(%rcx), %edi
salq $4, %rdi
addq %rdi, %rdx
movq %rdx, (%rsi)
ret
Analysis
--------
gather_mem_refs_stmt hashes refs by (base pointer, constant offset, size),
so owner->list.prev and MEM[owner + 8B] land in one im_mem_ref. The first
one seen sets the ao_ref: base MEM[(struct owner *)owner], offset 64 bits.
When the second, structurally different ref arrives, the slot is
canonicalized. If the incoming ref is itself a MEM_REF/TARGET_MEM_REF, only
the tree is replaced:
if (TREE_CODE (*mem) == MEM_REF
|| TREE_CODE (*mem) == TARGET_MEM_REF)
{
(*slot)->mem.ref = *mem;
(*slot)->mem.base_alias_set = ao_ref_base_alias_set (&aor);
}
mem.base and mem.offset still describe the old COMPONENT_REF, while
mem.ref is now MEM[owner + 8B], whose own base and offset are that MEM_REF
and 0. When this ref is queried against list_alias->prev,
indirect_refs_may_alias_p calls aliasing_component_refs_p. That function
matches the type of the bare MEM_REF (struct node *) with the 'prev'
FIELD_DECL in the other access path and compares the stale offset1 (64)
with offset2 (0). The ranges don't overlap, so it returns "no alias".
This is the inconsistency described in PR111019 (745ec2135aa). That fix
updates base and offset only in the else branch, where a new MEM_REF is
built, not in the branch above that adopts an existing MEM_REF. The code is
unchanged on trunk.
The bug needs strict aliasing because the wrong answer comes from the
TBAA-gated access-path check. The non-TBAA nonoverlapping_refs_since_match_p
returns -1, because the bare MEM_REF has no component path.
Proposed fix: adopt the whole decomposition of the new ref so the recorded
base and offset stay consistent with it.
Real-world impact
-----------------
This crashed nginx's HTTP/3 code (ngx_http_v3_new_entry() drain loop over
an ngx_queue_t list) when built with -O3. In that case, lsplit splits the
loop on a semi-invariant condition, and pcom then hoists h3c->blocked.prev
out of the store-free split loop as a bare
MEM[(struct ngx_queue_t **)h3c + 272B]. The new preheader is still inside
the outer loop, so lim4 merges that MEM_REF with the original
h3c->blocked.prev and hoists the list-empty check past ngx_queue_remove().