Непоследовательное поведение оптимизации компилятора неиспользуемой строки - программирование

Непоследовательное поведение оптимизации компилятора неиспользуемой строки

Мне любопытно, почему следующий кусок кода:

#include <string>
int main()
{
    std::string a = "ABCDEFGHIJKLMNO";
}

при компиляции с -O3 выдает следующий код:

main:                                   # @main
    xor     eax, eax
    ret

(Я прекрасно понимаю, что нет необходимости в неиспользованном a поэтому компилятор может полностью исключить его из сгенерированного кода)

Однако следующая программа:

#include <string>
int main()
{
    std::string a = "ABCDEFGHIJKLMNOP"; // <-- !!! One Extra P 
}

выходы:

main:                                   # @main
        push    rbx
        sub     rsp, 48
        lea     rbx, [rsp + 32]
        mov     qword ptr [rsp + 16], rbx
        mov     qword ptr [rsp + 8], 16
        lea     rdi, [rsp + 16]
        lea     rsi, [rsp + 8]
        xor     edx, edx
        call    std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::_M_create(unsigned long&, unsigned long)
        mov     qword ptr [rsp + 16], rax
        mov     rcx, qword ptr [rsp + 8]
        mov     qword ptr [rsp + 32], rcx
        movups  xmm0, xmmword ptr [rip + .L.str]
        movups  xmmword ptr [rax], xmm0
        mov     qword ptr [rsp + 24], rcx
        mov     rax, qword ptr [rsp + 16]
        mov     byte ptr [rax + rcx], 0
        mov     rdi, qword ptr [rsp + 16]
        cmp     rdi, rbx
        je      .LBB0_3
        call    operator delete(void*)
.LBB0_3:
        xor     eax, eax
        add     rsp, 48
        pop     rbx
        ret
        mov     rdi, rax
        call    _Unwind_Resume
.L.str:
        .asciz  "ABCDEFGHIJKLMNOP"

при компиляции с тем же -O3. Я не понимаю, почему он не распознает, что a все еще не используется, несмотря на то, что строка на один байт длиннее.

Этот вопрос относится к gcc 9.1 и clang 8.0 (онлайн: https://gcc.godbolt.org/z/p1Z8Ns), потому что другие компиляторы в моем наблюдении либо полностью отбрасывают неиспользуемую переменную (ellcc), либо генерируют для нее код независимо от длина строки

4b9b3361

Ответ 1

Это связано с небольшой оптимизацией строки. Когда строковые данные меньше или равны 16 символам, включая нулевой терминатор, они сохраняются в буфере, локальном для самого объекта std::string. В противном случае он выделяет память в куче и сохраняет там данные.

Первая строка "ABCDEFGHIJKLMNO" плюс нулевой терминатор имеет точный размер 16. Добавление "P" делает его превышающим буфер, следовательно, new вызывается внутренне, что неизбежно приводит к системному вызову. Компилятор может что-то оптимизировать, если это возможно, чтобы гарантировать отсутствие побочных эффектов. Системный вызов, вероятно, делает это невозможным - из-за этого изменение локального буфера для строящегося объекта позволяет проводить такой анализ побочных эффектов.

Трассировка локального буфера в libstdc++, версия 9.1, выявляет эти части bits/basic_string.h:

template<typename _CharT, typename _Traits, typename _Alloc>
class basic_string
{
   // ...

  enum { _S_local_capacity = 15 / sizeof(_CharT) };

  union
    {
      _CharT           _M_local_buf[_S_local_capacity + 1];
      size_type        _M_allocated_capacity;
    };
   // ...
 };

который позволяет определить размер локального буфера _S_local_capacity и сам локальный буфер (_M_local_buf). Когда конструктор запускает basic_string::_M_construct, вы получаете в bits/basic_string.tcc:

void _M_construct(_InIterator __beg, _InIterator __end, ...)
{
  size_type __len = 0;
  size_type __capacity = size_type(_S_local_capacity);

  while (__beg != __end && __len < __capacity)
  {
    _M_data()[__len++] = *__beg;
    ++__beg;
  }

где локальный буфер заполнен его содержимым. Сразу после этой части мы попадаем в ветку, где исчерпана локальная емкость - выделяется новое хранилище (через M_create в M_create), локальный буфер копируется в новое хранилище и заполняется оставшейся частью инициализирующего аргумента:

  while (__beg != __end)
  {
    if (__len == __capacity)
      {
        // Allocate more space.
        __capacity = __len + 1;
        pointer __another = _M_create(__capacity, __len);
        this->_S_copy(__another, _M_data(), __len);
        _M_dispose();
        _M_data(__another);
        _M_capacity(__capacity);
      }
    _M_data()[__len++] = *__beg;
    ++__beg;
  }

Как примечание, небольшая оптимизация строк сама по себе является темой. Чтобы понять, как настройка отдельных битов может иметь большое значение, я бы рекомендовал этот доклад. В нем также упоминается, как работает реализация std::string которая поставляется с gcc (libstdc++), и изменялась в прошлом, чтобы соответствовать более новым версиям стандарта.

Ответ 2

Я был удивлен, что компилятор просматривал пару std::string конструктор/деструктор, пока не увидел ваш второй пример. Это не так. Здесь вы видите небольшую строковую оптимизацию и соответствующие оптимизации от компилятора.

Оптимизация небольших строк происходит, когда сам объект std::string достаточно велик, чтобы содержать содержимое строки, размер и, возможно, различающий бит, используемый для указания того, работает ли строка в режиме маленькой или большой строки. В таком случае динамическое распределение не происходит, и строка сохраняется в самом объекте std::string.

Компиляторы действительно плохо удаляют ненужные выделения и освобождения, к ним относятся почти так, как будто они имеют побочные эффекты и, следовательно, их невозможно исключить. Когда вы пересекаете порог оптимизации небольшой строки, происходит динамическое распределение, и в результате вы видите то, что видите.

В качестве примера

void foo() {
    delete new int;
}

является самой простой, самой тупой парой размещения/освобождения, хотя gcc испускает эту сборку даже под O3

sub     rsp, 8
mov     edi, 4
call    operator new(unsigned long)
mov     esi, 4
add     rsp, 8
mov     rdi, rax
jmp     operator delete(void*, unsigned long)

Ответ 3

Как уже упоминалось, это связано с небольшой оптимизацией строки. Но это еще не все. Компилятору разрешается удалять new/delete в таких случаях (см. Это обсуждение SO для более подробной информации). GCC и clang не удалили пару new/delete из-за способа реализации gcc std::allocator.

Если тот же самый лязг используется с другой библиотекой C++, такой как libC++, тогда пара new/delete будет оптимизирована. Так же, как эта оптимизация имеет место для длинной std::string, так и для std::vector libC++.

Следующий пример Godbolt:

int main()
{
    std::string a = "ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZ";
    std::vector<int> foo{1,2};
}

Оптимизировано ни к чему:

main:                                   # @main
# %bb.0:
        #DEBUG_VALUE: main:foo <- [DW_OP_deref] undef
        xorl    %eax, %eax
        retq

Что не происходит с библиотекой GCC C++: libstdC++.

Я подозреваю, что это связано со следующим различием в определении allocator::allocate.

lib C++ (лязг):

_LIBCPP_INLINE_VISIBILITY pointer allocate(size_type __n, 
 allocator<void>::const_pointer = 0)
{return static_cast<pointer>(::operator new(__n * sizeof(_Tp)));}

Принимая во внимание, что gcc std::allocator наследуется от new_allocator<T>:

pointer
allocate(size_type __n, const void* = 0)
{ 
   if (__n > this->max_size())
     std::__throw_bad_alloc();

   return static_cast<_Tp*>(::operator new(__n * sizeof(_Tp)));
}

А затем size_type max_size() const _GLIBCXX_USE_NOEXCEPT {return size_t (-1)/sizeof (_Tp); } }

Я подозреваю, что условие if, которое вызывает max_size() является частью, которая исключает исключение new/delete. Одной из вещей, которые могут помочь, является тот факт, что max_size() не является константой времени компиляции.