https://gcc.gnu.org/bugzilla/show_bug.cgi?id=126408
Bug ID: 126408
Summary: false -Warray-bounds when speculative devirtualization
inlines the wrong target of a 2-target polymorphic
call
Product: gcc
Version: 16.1.0
Status: UNCONFIRMED
Severity: normal
Priority: P3
Component: c++
Assignee: unassigned at gcc dot gnu.org
Reporter: liweifriends at gmail dot com
Target Milestone: ---
GCC 16 emits a bogus -Warray-bounds on a code path that is unreachable at run
time. The path is created by ipa-devirt itself: a polymorphic call with exactly
two candidate targets is turned into a speculative 2-way dispatch, both
candidate
bodies are inlined under vtable guards, and the array-bounds pass then reports
member accesses in the branch belonging to the *other* (larger) type as being
out of bounds of the (smaller) object that the caller actually allocated.
GCC 15.3.1 accepts the same code silently. This is a 15 -> 16 regression.
Reduced from a real type-erasure ("polymorphic wrapper") library, where it
breaks
a -Werror build.
--------------------------------------------------------------------------------
REPRODUCER (self-contained, no headers beyond libstdc++)
--------------------------------------------------------------------------------
Save as bugreport.cpp. The line numbers quoted below assume the file starts at
the #include, with no leading comment.
#include <memory>
#include <utility>
#include <exception>
struct AbsImp {
virtual ~AbsImp() = default;
virtual bool is_eof() = 0;
virtual int bos() = 0;
virtual void to_get() = 0;
};
struct Small {
char m_buf[64]{};
bool m_eof = false;
long m_pos = 0;
bool is_eof() { return m_eof; }
int bos() { return 1; }
void to_get() { m_buf[2]++; }
};
template <typename K>
struct Wrap : K {
char m_zbuf[256]{};
bool m_ended = false;
bool is_eof() { return m_ended || K::is_eof(); }
int bos() { m_ended = false; return K::bos(); }
void to_get() { m_zbuf[5]++; K::to_get(); }
};
template <typename K>
struct Imp : AbsImp {
int m_state = 0;
K m_kernel;
explicit Imp(K k) : m_kernel(std::move(k)) {}
bool is_eof() override { return m_kernel.is_eof(); }
int bos() override { m_state = m_kernel.bos(); return m_state; }
void to_get() override { m_state = 2; m_kernel.to_get(); }
};
struct Erased {
std::unique_ptr<AbsImp> m_ptr;
template <typename K>
explicit Erased(K k) : m_ptr(new Imp<K>(std::move(k))) {}
bool is_eof() { if (!m_ptr) std::rethrow_exception(std::exception_ptr{});
return m_ptr->is_eof(); }
int bos() { if (!m_ptr) std::rethrow_exception(std::exception_ptr{});
return m_ptr->bos(); }
void to_get() { if (!m_ptr) std::rethrow_exception(std::exception_ptr{});
m_ptr->to_get(); }
};
extern void sink(long);
void small_ops() {
Erased e{Small{}};
e.bos();
sink(e.is_eof());
e.to_get();
}
void big_ops() {
Erased e{Wrap<Small>{}};
e.bos();
e.to_get();
}
--------------------------------------------------------------------------------
COMMAND
--------------------------------------------------------------------------------
$ g++ -O2 -Wall -std=c++17 -c bugreport.cpp
--------------------------------------------------------------------------------
ACTUAL OUTPUT (GCC 16.1.1)
--------------------------------------------------------------------------------
In member function 'void Wrap<K>::to_get() [with K = Small]',
inlined from 'void Imp<K>::to_get() [with K = Wrap<Small>]' at
bugreport.cpp:37:58,
inlined from 'void Erased::to_get()' at bugreport.cpp:47:92,
inlined from 'void small_ops()' at bugreport.cpp:56:13:
bugreport.cpp:27:29: warning: array subscript 'Wrap<Small>[0]' is partly
outside array bounds of 'unsigned char [96]' [-Warray-bounds=]
27 | void to_get() { m_zbuf[5]++; K::to_get(); }
| ~~~~~~~~^
In constructor 'Erased::Erased(K) [with K = Small]',
inlined from 'void small_ops()' at bugreport.cpp:53:21:
bugreport.cpp:43:34: note: at offset 16 into object of size 96 allocated by
'operator new'
43 | explicit Erased(K k) : m_ptr(new Imp<K>(std::move(k))) {}
| ^~~~~~~~~~~~~~~~~~~~~~~~
(a second, near-identical warning follows for column 21 of the same line)
--------------------------------------------------------------------------------
EXPECTED OUTPUT
--------------------------------------------------------------------------------
No diagnostic. GCC 15.3.1 compiles this silently.
--------------------------------------------------------------------------------
WHY THIS IS A FALSE POSITIVE
--------------------------------------------------------------------------------
Note the inlining chain in the diagnostic. It pairs
- an allocation of Imp<Small> (96 bytes, from small_ops), with
- the body of Imp<Wrap<Small>>::to_get (a *different*, larger type).
Those two can never occur together at run time. In small_ops() the object is
constructed one line earlier by 'new Imp<Small>', so its dynamic type is
statically evident; Imp<Wrap<Small>> is only ever created in big_ops().
The offending path is introduced by ipa-devirt. -fdump-ipa-devirt shows the
call
site being given two speculative targets:
bugreport.cpp:47:92: optimized: speculatively devirtualizing call in
void Erased::to_get()/300 to void Imp<K>::to_get() [with K = Small]/465
bugreport.cpp:47:92: optimized: speculatively devirtualizing call in
void Erased::to_get()/300 to void Imp<K>::to_get() [with K =
Wrap<Small>]/462
bugreport.cpp:47:92: optimized: devirtualized call in
void Erased::to_get()/300 to 2 targets
Erased is deliberately *not* templated on the kernel type, so a single
Erased::to_get() body is shared by both instantiations. ipa-devirt sees exactly
two candidates there and emits a speculative 2-way dispatch: both bodies
inlined,
each guarded by a vtable check. That combined body is subsequently inlined into
small_ops(), where the allocation size is known to be 96. Inside the
Wrap<Small> arm -- which the vtable guard can never select for an Imp<Small>
object -- m_zbuf sits past offset 96, and -Warray-bounds flags it.
So the access is unreachable, not out of bounds. The array-bounds pass does not
appear to account for the vtable guard that ipa-devirt placed around the
speculative branch.
Linking the TU against a driver that calls small_ops()/big_ops() and defines
sink() produces a program that runs correctly; the flagged access never
executes.
--------------------------------------------------------------------------------
CONDITIONS
--------------------------------------------------------------------------------
* Regression: GCC 15.3.1 clean, GCC 16.1.1 warns.
* -O0 and -O1 are clean; -O2 and -O3 warn.
* Independent of -std: c++11/14/17/20/23 all reproduce.
* Requires *exactly two* candidate targets at the call site. With one, GCC
devirtualizes exactly and there is no warning.
* main() must live in a different TU. Adding main() to this file lets GCC see
the concrete types program-wide, devirtualize exactly, and the warning goes
away -- which is itself further evidence that the speculation is the cause.
* On the original (unreduced) code, -O3 produced noticeably more of these
warnings than -O2, so the problem scales with how aggressively the
speculative
dispatch is applied.
--------------------------------------------------------------------------------
WORKAROUND
--------------------------------------------------------------------------------
-fno-devirtualize-speculatively suppresses it (as does -fno-devirtualize),
which
confirms the pass responsible:
$ g++ -O2 -Wall -std=c++17 -c bugreport.cpp -fno-devirtualize-speculatively
(no output)
--------------------------------------------------------------------------------
VERSION
--------------------------------------------------------------------------------
$ g++ --version
g++ (GCC) 16.1.1 20260515 (Red Hat 16.1.1-2)
$ g++-15 --version
g++-15 (GCC) 15.3.1 20260722 (Red Hat 15.3.1-1)
Host and target: x86_64-redhat-linux (Fedora)