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

Compiler stops optimizing unused string away when adding characters

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

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

这是由于小字符串优化。当字符串数据小于或等于 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;
  }

附带说明一下,小字符串优化本身就是一个话题。为了感受调整单个位如何在大规模上产生影响,我建议 this talk。它还提到 gcc (libstdc++) 附带的 std::string 实现在过去如何工作和更改以匹配标准的新版本。

令我惊讶的是,在我看到您的第二个示例之前,编译器能够看穿 std::string constructor/destructor 对。它没有。你在这里看到的是小字符串优化和编译器围绕它的相应优化。

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

编译器确实不擅长消除不必要的分配和解除分配,它们几乎被视为具有副作用,因此无法消除。当您超过小字符串优化阈值时,会发生动态分配,结果如您所见。

举个例子

void foo() {
    delete new int;
}

是最简单、最笨的 allocation/deallocation 对,但 gcc emits 即使在 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)

虽然接受的答案是有效的,但从 C++14 开始,实际上 newdelete 调用 可以 被优化掉。在 cppreference 上看到这个神秘的措辞:

New-expressions are allowed to elide ... allocations made through replaceable allocation functions. In case of elision, the storage may be provided by the compiler without making the call to an allocation function (this also permits optimizing out unused new-expression).

...

Note that this optimization is only permitted when new-expressions are used, not any other methods to call a replaceable allocation function: delete[] new int[10]; can be optimized out, but operator delete(operator new(10)); cannot.

这实际上允许编译器完全删除您的本地 std::string,即使它很长。事实上 - clang++ 与 libc++ already does this (GodBolt),因为 libc++ 在其 std::string 的实现中使用内置 __new__delete - 即 "storage provided by the compiler"。因此,我们得到:

main():
        xor eax, eax
        ret

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

GCC 没有,但我最近打开了有关此的错误报告;请参阅 this SO answer 以获取链接。