Непоследовательное поведение оптимизации компилятора неиспользуемой строки
Мне любопытно, почему следующий кусок кода:
#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), либо генерируют для нее код независимо от длина строки
Ответы
Ответ 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()
не является константой времени компиляции.