编译器在添加字符时停止优化未使用的字符串

我很好奇为什么下面这段代码:

#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) ,要么不管字符串的长度为其生成代码。

5750 次浏览

这是由于小字符串优化。当字符串数据小于或等于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中的分配) ,本地缓冲区被复制到新的存储器中,并用初始化参数的其余部分填充:

  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;
}

顺便说一句,小字符串优化本身就是一个相当大的话题。为了了解如何调整单个比特可以在大范围内产生不同的效果,我推荐 这次谈话。它还提到了随 gcc(libstdc + +)附带的 std::string实现是如何工作的,并在过去进行了更改以匹配该标准的新版本。

我很惊讶编译器看穿了 std::string构造函数/析构函数对,直到我看到了第二个示例。没有。您在这里看到的是小型字符串优化,以及编译器围绕它进行的相应优化。

小字符串优化是指当 std::string对象本身足够大以容纳字符串的内容、大小以及可能用于指示字符串是在小字符串模式还是在大字符串模式下运行的判别位。在这种情况下,不会出现动态分配,字符串存储在 std::string对象本身中。

编译器不善于省略不需要的分配和释放,它们几乎被视为具有副作用,因此不可能省略。当您检查小字符串优化阈值时,会出现动态分配,结果就是您所看到的。

举个例子

void foo() {
delete new int;
}

是可能的最简单、最愚蠢的分配/释放对,然而即使在 O3下 GCC 发射这个程序集

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)

虽然接受的答案是有效的,因为 C + + 14实际上是 newdelete调用 可以被优化掉的情况。看看这个神秘的措辞,关于消除对妇女一切形式偏好:

允许新表达式删除... 通过可替换分配函数进行的分配。在省略的情况下,编译器可以在不调用分配函数的情况下提供存储(这也允许优化未使用的新表达式)。

...

注意,只有当新表达式为 而不是调用可替换分配函数的任何其他方法: delete[] new int[10];可以优化出来,但操作员 delete(operator new(10));不能。

这实际上允许编译器完全删除本地 std::string,即使它很长。事实上—— clang + + with libc + + 已经这么做了(GodBolt) ,因为 libc + + 在实现 std::string时使用了内置的 __new__delete——这是“编译器提供的存储”。因此,我们得到:

main():
xor eax, eax
ret

基本上是任意长度的未使用字符串。

GCC 不这样做,但是我最近打开了关于这方面的 bug 报告; 请参阅 这么回答获取链接。