https://gcc.gnu.org/bugzilla/show_bug.cgi?id=127641

            Bug ID: 127641
           Summary: [VRP] Range info lost after store-to-load forwarding
                    (memcpy falls back to libcall)
           Product: gcc
           Version: 16.1.0
            Status: UNCONFIRMED
          Severity: normal
          Priority: P3
         Component: tree-optimization
          Assignee: unassigned at gcc dot gnu.org
          Reporter: 3440910457 at qq dot com
  Target Milestone: ---

# Range info lost after store-to-load forwarding (memcpy falls back to libcall)

## What's wrong

When I ran a benchmark for `std::function` and `std::move_only_function` with
signature `void(std::string, std::string, std::string, std::string)` in GCC
16.1.0 (MinGW-w64, MSYS2, x86-64, `-O2`), the `std::move_only_function` version
was 50% slower than `std::function`.

In the following description I simply write `void(std::string)` instead of
`void(std::string, std::string, std::string, std::string)`.

Here is a tiny sample <https://godbolt.org/z/5jfce8sb7> (GCC 16.1).
`std::function` calls `memcpy` in .L16
`std::move_only_function` calls `memcpy` in .L67 and .L70

Similar behavior is seen on GCC trunk.

## What probably happened

This is probably because the range information for `memcpy` is lost in the
`std::move_only_function` case after the devirtualization optimization
introduced in GCC 16.

```cpp
void foo() {
    std::string s{};
    std::move_only_function<void(std::string)> f = /*...*/;
    for (/*...*/) { f(s); }
}
```

The invocation list is as follows:

`foo` -call_1-> `operator()` -call_2-> `_M_invoker` -call_3->
`target_free_function`

call_1: The signature of `operator()` is `void(std::string)`, so a COPY of
`std::string` is made here.
call_3: The signature of `target_free_function` is `void(std::string)`, so a
MOVE of `std::string` is made here.

In the copy constructor and move constructor of `std::string`, `memcpy`
(`__builtin_memcpy`) is called. However, only the `memcpy` in the move
constructor has range information:

```cpp
      _GLIBCXX20_CONSTEXPR
      basic_string(basic_string&& __str) noexcept
      : _M_dataplus(_M_local_data(), std::move(__str._M_get_allocator()))
      {
        if (__str._M_is_local()) // _M_is_local assumes "__str.length() < 16"
          {
            _M_init_local_buf();
            // The `memcpy` in `traits_type::copy` knows the range information,
            // so the compiler can avoid the libcall for `memcpy`.
            traits_type::copy(_M_local_buf, __str._M_local_buf,
                              __str.length() + 1);
          }
          /*...*/
      }
```

There is no range information in the copy constructor:

```cpp
      _GLIBCXX20_CONSTEXPR
      basic_string(const basic_string& __str)
      : _M_dataplus(_M_local_data(),
                    _Alloc_traits::_S_select_on_copy(__str._M_get_allocator()))
      {
        // No `_M_is_local` here and no `_M_is_local` in
`_M_construct<true>(...)`.
        // So there is no range information for `memcpy` in
`_M_construct<true>(...)`,
        // and the compiler must use a libcall for `memcpy`.
        _M_construct<true>(__str._M_data(), __str.length());
      }
```

So, theoretically speaking, only one `memcpy` call is expected, as in the
`std::function` case. However, there are 2, not 1, `memcpy` calls in the
`std::move_only_function` case.

That's because the range information for `memcpy` is lost after 3 steps of
optimization:

1. devirtualization
`call_2` is eliminated, so `call_1` and `call_3` end up in the same scope.
```cpp
n0 = x + 1;
__builtin_memcpy(_, _, n0); // (call_1) COPY-Ctor, no range info
s._M_length = x;            // store
/*...*/
n1 = s._M_length + 1;       // load
if (n1 > 16)
    __builtin_unreachable();// range info provided for SSA n1
__builtin_memcpy(_, _, n1); // (call_3) MOVE-Ctor, has range info
```

2. store-to-load forwarding
The compiler forwards the store to the load, so SSA name `n1` is replaced by
`n0`.
```cpp
n0 = x + 1;
__builtin_memcpy(_, _, n0); // (call_1) COPY-Ctor, no range info
/*...*/
if (n0 > 16)
    __builtin_unreachable();// guard moved from n1 to n0, but the range info
did not
__builtin_memcpy(_, _, n0); // (call_3) MOVE-Ctor, no range info
```

3. redundant guard removal
The guard is then removed as a redundant assumption, and the range information
is not available at the second `memcpy`. So it is lost. (bug?)
```cpp
n0 = x + 1;
__builtin_memcpy(_, _, n0); // (call_1) COPY-Ctor, no range info
/*...*/
__builtin_memcpy(_, _, n0); // (call_3) MOVE-Ctor, no range info
```

In short, the range information is attached to the SSA name. When `n1` is
replaced by the equivalent `n0`, the bound is not transferred to the value used
by the second `memcpy`; since `n0` is also used by call_1, it cannot simply
carry a global `<= 16` range. As a result, two `memcpy` calls without range
information are emitted.

## Why the `std::function` case is not affected

The first devirtualization optimization does not happen for `std::function`, so
`call_1` and `call_3` stay in different scopes. No store-to-load forwarding is
possible.

## How to fix it

1. The libstdc++ workaround

Change the copy constructor of `std::string` so that the SSO copy has range
information and the two copies no longer share one size SSA name: SSA `n1` (the
bounded one in MOVE-Ctor) is no longer replaced by `n0`, so its range
information is preserved.

New copy constructor for `basic_string` in bits/basic_string.h:
```cpp
      /**
       *  @brief  Construct string with copy of value of @a __str.
       *  @param  __str  Source string.
       */
      _GLIBCXX20_CONSTEXPR
      basic_string(const basic_string& __str)
      : _M_dataplus(_M_local_data(),
                    _Alloc_traits::_S_select_on_copy(__str._M_get_allocator()))
      {
        if (__str._M_is_local()) // `_M_is_local` provides range info
          {
            _M_init_local_buf();
            traits_type::copy(_M_local_buf, __str._M_local_buf,
                              __str.length() + 1);
            _M_length(__str.length());
          }
        else
          _M_construct<true>(__str._M_data(), __str.length());
      }
```

With this workaround, the `std::move_only_function` case is no longer 50%
slower than `std::function`; it is about 2% faster.

2. The compiler fix

I don't really know the implementation details of the GCC store-load
optimization, so I'm not sure whether this is a bug or just an inherent
limitation of that optimization.

---

link: <https://gcc.gnu.org/bugzilla/show_bug.cgi?id=85316> ([meta] VRP)

Reply via email to