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

Posted

技术标签:

【中文标题】编译器在添加字符时停止优化未使用的字符串【英文标题】:Compiler stops optimizing unused string away when adding characters 【发布时间】:2019-10-18 21:15:15 【问题描述】:

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

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

【问题讨论】:

可能链接到一些short string optimization practices ? 会不会是小字符串优化的原因?尝试将a 声明为 volatile,您会看到这两个字符串的处理方式不同。最长的似乎分配在堆上。 gcc.godbolt.org/z/WUuJIB 参见this thread 讨论是否允许编译器优化动态分配 我改用string_view,它仍然会优化更长的字符串:godbolt.org/z/AAViry 尝试附加 -stdlib=libc++ 以使用 Clang 进行编译 ;-) 【参考方案1】:

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

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

...

请注意,这种优化只在 new-expressions 是允许的 使用,而不是任何其他方法来调用可替换的分配函数: delete[] new int[10]; 可以优化出来,但是运营商 delete(operator new(10)); 不能。

这实际上允许编译器完全删除您的本地std::string,即使它很长。事实上 - clang++ 与 libc++ already does this (GodBolt),因为 libc++ 在其实现 std::string 时使用内置的 __new__delete - 那是“编译器提供的存储”。因此,我们得到:

main():
        xor eax, eax
        ret

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

GCC 不这样做,但我最近打开了关于此的错误报告;链接见this SO answer。

【讨论】:

新建和删除表达式对,当然。调用 op new 和 op delete,不完全是。【参考方案2】:

这是由于小字符串优化。当字符串数据小于或等于 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 实现如何工作,并在过去进行了更改以匹配新版本的标准。

【讨论】:

程序集输出中没有系统调用。 请注意,16 个字符的限制是实现定义的。它适用于 GCC/libstdc++ 和 MSVC 以及 x86_64 架构。 Libc++(通常与 Clang 一起使用)采用另一种方法,并且限制更高(23 个字符)。 (根据生成的程序集,Godbolt 的 Clang 貌似使用了 libstdc++。) 实际上,Clang 可以优化掉new 而无需担心底层实现。它在 C++14 中被明确允许:参见 Allocation section "delete[] new int[10]; 可以优化出来"。 ...我对编写编译器的人的敬意更加深了。 @DanielLangr:Godbolt 安装了 libc++。要让 clang 使用它,请使用 -stdlib=libc++ 。是的,这确实允许 clang8.0 优化掉更长的字符串:gcc.godbolt.org/z/gVm_6R。 Godbolt 的 clang 安装类似于普通的 GNU/Linux 安装,默认使用 libstdc++。【参考方案3】:

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

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

编译器真的不擅长忽略不需要的分配和释放,它们几乎被视为具有副作用,因此不可能被忽略。当您超过小字符串优化阈值时,就会发生动态分配,结果就是您所看到的。

举个例子

void foo() 
    delete new int;

可能是最简单、最愚蠢的分配/释放对,但 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)

【讨论】:

使用了什么编译器版本?据此:en.cppreference.com/w/cpp/language/new#Allocation,从 C++14 开始,允许优化此类分配。 @BalázsKovacsics gcc 9.1,添加到 Godbolt 的链接。 Clang 3.8 为我正确优化了它(除非它是通过 operator new() 函数调用来调用的),这似乎是一个 gcc 问题。 相关讨论:Is the compiler allowed to optimize out heap memory allocations?. 几乎被视为有副作用 这个问题的一部分可能是 C++ 的 new 可以被用户“替换”。所以它真的可能有副作用,比如记录分配。这也使得无法优化 std::vector 调整为 realloc 而不是 new/copy/delete ,除非编译器具有链接时知识 new 没有被替换,这真的很愚蠢。来自标准的 C++14 保证 delete new ... 可以被优化是有帮助的,但并不是所有的编译器都在寻找它。

以上是关于编译器在添加字符时停止优化未使用的字符串的主要内容,如果未能解决你的问题,请参考以下文章

在 ProGuard 优化期间删除未使用的字符串

在 ProGuard 优化期间删除未使用的字符串

编译器错误信息: CS1056: 意外的字符的处理办法

IDEA 编译时 未结束的字符串文字

fmt 库:如何使用 RegEx 添加编译时字符串检查?

当键被分配字符串时,是不是有优化的 Map 版本?