[Bug c++/126408] New: false -Warray-bounds when speculative devirtualization inlines the wrong target of a 2-target polymorphic call

liweifriends at gmail dot com via Gcc-bugs <[email protected]>
Newsgroups gmane.comp.gcc.bugs
Message-ID <[email protected]/bugzilla/>
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)
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.