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

74

我很好奇下面这段代码:

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

17
可能与一些“短字符串优化实践”相关联? - UmNyobe
4
可能是因为小字符串优化吗?试着将 a 声明为 volatile,你会发现这两个字符串被不同地处理。其中最长的似乎被分配在堆上。https://gcc.godbolt.org/z/WUuJIB - Davide Spataro
6
请参考此帖子讨论编译器是否允许优化动态分配的内存。 - M.M
1
如果你使用 string_view,它仍然可以优化掉更长的字符串: https://godbolt.org/z/AAViry - Ted Lyngmo
1
尝试在使用Clang进行编译时添加-stdlib=libc++参数;-) - Daniel Langr
@DanielLangr:请查看我的答案以获取解释。 - einpoklum
3个回答

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

4
汉语翻译:汇编代码输出中没有系统调用。 - Maxim Egorushkin
8
请注意,16个字符的限制是由具体实现定义的。这适用于GCC/libstdc++、MSVC和x86_64架构。Libc++(通常与Clang一起使用)采用另一种方法,因此限制更高(23个字符)。 (根据生成的汇编代码,Godbolt的Clang似乎使用libstdc++。) - Daniel Langr
11
实际上,Clang可以优化掉new而不用担心其底层实现。在C++14中,这是明确允许的:请参阅Allocation section "delete[] new int[10]; 可以被优化掉"。 - Matthieu M.
6
我的尊重之情对编写编译器的人更加增加了。 - kedarps
4
Godbolt已安装libc++。 为了让clang使用它,请使用“-stdlib = libc ++”参数。 是的,这确实允许clang8.0优化较长的字符串:https://gcc.godbolt.org/z/gVm_6R。 Godbolt的clang安装与普通的GNU / Linux安装类似,它默认使用libstdc ++。 - Peter Cordes
显示剩余3条评论

19

当我看到你的第二个例子时,我惊讶于编译器能够识别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)

3
使用了哪个编译器版本? 根据此链接:https://en.cppreference.com/w/cpp/language/new#Allocation ,自C++14起,可以优化掉这种分配。 - Balázs Kovacsics
@BalázsKovacsics gcc 9.1,添加了到godbolt的链接。 - Passer By
5
Clang 3.8 在我的情况下正确地将其优化掉了(除非使用 operator new() 函数调用),看起来这是一个 gcc 的问题。 - Balázs Kovacsics
7
相关讨论:编译器能否优化掉堆内存分配? - Daniel Langr
3
这个问题的一部分可能是由于C++中的new可以被用户“替换”,因此它实际上可能会产生副作用,比如记录分配情况。这也使得将std::vector的调整大小优化为realloc而不是new/copy/delete变得不可能,除非编译器在链接时知道new没有被替换,这真的很糟糕。C++14标准保证delete new ...可以被优化掉,但并不是所有编译器都能做到。 - Peter Cordes

0

虽然被接受的答案是有效的,但自从C++14以来,实际上newdelete调用可以被优化掉。请参阅cppreference上的这个晦涩的措辞:

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

...

请注意,只有在使用新表达式时才允许进行此优化,而不是任何其他调用可替换分配函数的方法: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不支持,但我最近已经开了关于此问题的错误报告;请参见this SO answer获取链接。


新的和删除表达式对,没问题。调用op new和op delete,不太一样。 - Deduplicator

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接