c++ 编译器可以优化静态局部变量的初始化吗?

gwo2fgha  于 2023-10-21  发布在  其他
关注(0)|答案(4)|浏览(92)

在以下情况下应采取什么行为:

class C {
    boost::mutex mutex_;
    std::map<...> data_;
};

C& get() {
    static C c;
    return c;
}

int main() {
    get(); // is compiler free to optimize out the call? 
    ....
}

编译器是否允许优化对get()的调用?
其思想是在多线程操作需要静态变量之前对其进行初始化
这是更好的选择吗?:

C& get() {
    static C *c = new C();
    return *c;
}
yvgpqqbh

yvgpqqbh1#

更新(2023)答案:

在C23(N4950)中,初始化静态局部变量的任何副作用都可以在进入其包含块时观察到。因此,除非编译器可以确定初始化变量没有可见的副作用,否则它将不得不生成代码以在适当的时候调用get()(或根据情况执行get()的内联版本)。
与早期的标准相反,C
23不再允许“早期”完成静态局部变量的动态初始化(如下所述)。
[stmt.dcl]/3:
具有静态存储持续时间(6.7.5.2)或线程存储持续时间(6.7.5.3)的块变量的动态初始化在控制第一次通过其声明时执行;这样的变量在其初始化完成时被认为是初始化的。
02 The Dog(2010)
C和C++标准在一个相当简单的原则下运行,通常被称为“as-if规则”-基本上,编译器可以自由地做几乎任何事情,只要没有符合标准的代码可以区分它所做的和官方要求的之间的差异。
我看不出有什么方法可以让符合规范的代码辨别在这种情况下是否真的调用了get,所以在我看来,优化它是免费的。
至少在最近的N4296中,该标准包含了对静态局部变量进行早期初始化的显式权限:
具有静态存储持续时间的块作用域实体的常量初始化(3.6.2)(如果适用)在其块首次进入之前执行。在允许实现静态初始化命名空间作用域中具有静态或线程存储持续时间的变量的相同条件下,允许实现执行具有静态或线程存储持续时间的其他块作用域变量的早期初始化(3.6.2)。否则,在控制第一次通过其声明时初始化此类变量;这样的变量在其初始化完成时被认为是初始化的。
因此,在这个规则下,局部变量的初始化可以在执行的早期任意发生,所以即使它有可见的副作用,它们也允许在任何试图观察它们的代码之前发生。因此,您不能保证看到它们,因此允许对其进行优化。

y3bcpkx1

y3bcpkx12#

根据您的编辑,这里有一个改进的版本,具有相同的结果。
输入:

struct C { 
    int myfrob;
    int frob();
    C(int f);
 };
C::C(int f) : myfrob(f) {}
int C::frob() { return myfrob; }

C& get() {
    static C *c = new C(5);
    return *c;
}

int main() {
    return get().frob(); // is compiler free to optimize out the call? 

}

输出量:

; ModuleID = '/tmp/webcompile/_28088_0.bc'
target datalayout = "e-p:64:64:64-i1:8:8-i8:8:8-i16:16:16-i32:32:32-i64:64:64-f32:32:32-f64:64:64-v64:64:64-v128:128:128-a0:0:64-s0:64:64-f80:128:128-n8:16:32:64"
target triple = "x86_64-linux-gnu"

%struct.C = type { i32 }

@guard variable for get()::c = internal global i64 0            ; <i64*> [#uses=4]

declare i32 @__cxa_guard_acquire(i64*) nounwind

declare i8* @operator new(unsigned long)(i64)

declare void @__cxa_guard_release(i64*) nounwind

declare i8* @llvm.eh.exception() nounwind readonly

declare i32 @llvm.eh.selector(i8*, i8*, ...) nounwind

declare void @__cxa_guard_abort(i64*) nounwind

declare i32 @__gxx_personality_v0(...)

declare void @_Unwind_Resume_or_Rethrow(i8*)

define i32 @main() {
entry:
  %0 = load i8* bitcast (i64* @guard variable for get()::c to i8*), align 8 ; <i8> [#uses=1]
  %1 = icmp eq i8 %0, 0                           ; <i1> [#uses=1]
  br i1 %1, label %bb.i, label %_Z3getv.exit

bb.i:                                             ; preds = %entry
  %2 = tail call i32 @__cxa_guard_acquire(i64* @guard variable for get()::c) nounwind ; <i32> [#uses=1]
  %3 = icmp eq i32 %2, 0                          ; <i1> [#uses=1]
  br i1 %3, label %_Z3getv.exit, label %bb1.i

bb1.i:                                            ; preds = %bb.i
  %4 = invoke i8* @operator new(unsigned long)(i64 4)
          to label %invcont.i unwind label %lpad.i ; <i8*> [#uses=2]

invcont.i:                                        ; preds = %bb1.i
  %5 = bitcast i8* %4 to %struct.C*               ; <%struct.C*> [#uses=1]
  %6 = bitcast i8* %4 to i32*                     ; <i32*> [#uses=1]
  store i32 5, i32* %6, align 4
  tail call void @__cxa_guard_release(i64* @guard variable for get()::c) nounwind
  br label %_Z3getv.exit

lpad.i:                                           ; preds = %bb1.i
  %eh_ptr.i = tail call i8* @llvm.eh.exception()  ; <i8*> [#uses=2]
  %eh_select12.i = tail call i32 (i8*, i8*, ...)* @llvm.eh.selector(i8* %eh_ptr.i, i8* bitcast (i32 (...)* @__gxx_personality_v0 to i8*), i8* null) ; <i32> [#uses=0]
  tail call void @__cxa_guard_abort(i64* @guard variable for get()::c) nounwind
  tail call void @_Unwind_Resume_or_Rethrow(i8* %eh_ptr.i)
  unreachable

_Z3getv.exit:                                     ; preds = %invcont.i, %bb.i, %entry
  %_ZZ3getvE1c.0 = phi %struct.C* [ null, %bb.i ], [ %5, %invcont.i ], [ null, %entry ] ; <%struct.C*> [#uses=1]
  %7 = getelementptr inbounds %struct.C* %_ZZ3getvE1c.0, i64 0, i32 0 ; <i32*> [#uses=1]
  %8 = load i32* %7, align 4                      ; <i32> [#uses=1]
  ret i32 %8
}

值得注意的是,没有为::get发出任何代码,但main仍然根据需要(在%2以及invasion.i和lpad.i的末尾)分配带有保护变量的::get::c(在%4)。llvm正在内联所有这些东西。
tl;dr:不用担心,优化器通常会正确处理这些东西。你看到一个错误?

wr98u20j

wr98u20j3#

**您的原始代码是安全的。**不要引入额外的间接级别(在std::map的地址可用之前必须加载的指针变量)。

正如Jerry Coffin所说,你的代码必须运行 * 就像 * 它是按照源代码顺序运行的一样。这包括在main中的后续内容(如启动线程)之前运行,就好像它已经构建了你的boost或std::mutexstd::map
在C11之前,语言标准和内存模型并不是正式的线程感知的,但像这样的东西(线程安全的static-本地初始化)无论如何都能工作,因为编译器作者希望他们的编译器是有用的。例如,2006年的GCC 4.1(https://godbolt.org/z/P3sjo4Tjd)仍然使用guard变量with来确保在同时发生多个对get()的调用时,单个线程进行构造。
现在,在C
11和更高版本中,ISO标准确实包括线程,并且官方要求它是安全的。
由于您的程序无法观察到这种差异,因此假设编译器可以选择跳过构造,让它在第一个线程中以未优化的方式实际调用get()这很好,static局部变量的构造是线程安全的,像GCC和Clang这样的编译器使用一个“保护变量”,它们在函数开始时检查(acquire加载时为只读)。
文件作用域的static变量将避免每次调用都会发生的guard变量的load+test/分支快速路径开销,并且只要在main()开始之前没有任何东西调用get(),它就是安全的。保护变量非常便宜,特别是在x86,AArch 64和32位ARMv 8等ISA上,这些ISA具有廉价的获取加载,但在ARMv7上更昂贵,例如获取加载使用dmb ish全屏障。
如果某个假设的编译器实际上做了您所担心的优化,那么差异可能是在页面的NUMA放置中包含static C c,如果该页面中没有其他内容被首先触及。如果在第二个线程也调用get()时,构造还没有完成,那么可能会在其他线程第一次调用get()时短暂地停止。

当前的GCC和clang * don 't * 在实践中做了这种优化

Clang 17和libc为x86-64使用-O3创建了以下asm。(由Godbolt拆除)。get()的asm也内联到main中。GCC与libstdc非常相似,实际上只是在std::map内部有所不同。

get():
        movzx   eax, byte ptr [rip + guard variable for get()::c]  # all x86 loads are acquire loads
        test    al, al                       # check the guard variable
        je      .LBB0_1
        lea     rax, [rip + get()::c]        # retval = address of the static variable
   # end of the fast path through the function.
   # after the first call, all callers go through this path.
        ret

 # slow path, only reached if the guard variable is zero
.LBB0_1:
        push    rax
        lea     rdi, [rip + guard variable for get()::c]
        call    __cxa_guard_acquire@PLT
        test    eax, eax   # check if we won the race to construct c,
        je      .LBB0_3    # or if we waited until another thread finished doing it.

        xorps   xmm0, xmm0
        movups  xmmword ptr [rip + get()::c+16], xmm0     # first 16 bytes of std::map<int,int> = NULL pointers
        movups  xmmword ptr [rip + get()::c], xmm0        # std::mutex = 16 bytes of zeros
        mov     qword ptr [rip + get()::c+32], 0          # another NULL
        lea     rsi, [rip + get()::c]                     # arg for __cxa_atexit
        movups  xmmword ptr [rip + get()::c+48], xmm0     # more zeros, maybe a root node?
        lea     rax, [rip + get()::c+48]                  
        mov     qword ptr [rip + get()::c+40], rax        # pointer to another part of the map object

        lea     rdi, [rip + C::~C() [base object destructor]]  # more args for atexit
        lea     rdx, [rip + __dso_handle]
        call    __cxa_atexit@PLT                 # register the destructor function-pointer with a "this" pointer

        lea     rdi, [rip + guard variable for get()::c]
        call    __cxa_guard_release@PLT          # "unlock" the guard variable, setting it to 1 for future calls
             # and letting any other threads return from __cxa_guard_acquire and see a fully-constructed object

.LBB0_3:                                     # epilogue
        add     rsp, 8
        lea     rax, [rip + get()::c]        # return value, same as in the fast path.
        ret

即使std::map未使用,构造它也需要调用__cxa_atexitatexit的C内部版本)来注册析构函数,以便在程序退出时释放红黑树。我怀疑这是优化器不透明的部分,也是它不能像static int x = 123;static void *foo = &bar;那样优化到.data中的预初始化空间而没有运行时构造(也没有保护变量)的主要原因。
恒定传播避免了任何运行时初始化的需要 * 是 * 如果struct C只包括std::mutex会发生的情况,在GNU/Linux中,std::mutex至少没有析构函数,实际上是零初始化的。(C 23之前的C允许早期初始化,即使这包括 * 可见 * 的副作用。这个不一样编译器仍然可以在没有运行时调用的情况下将static int local_foo = an_inline_function(123);恒定地传播到.data中的一些字节中。
GCC和Clang也不会优化guard变量(如果有任何运行时工作要做的话),即使main根本不会启动任何线程,更不用说在调用get()之前了。其他编译单元(包括共享库)中的构造函数可能在main调用的同时启动了另一个调用get()的线程。(这可以说是gcc -fwhole-program错过的优化。
如果构造函数有任何(潜在的)* 可见 * 的副作用,可能包括对new的调用,因为new是可替换的,编译器不能推迟它,因为 C
规则规定了构造函数何时在抽象机中被调用。(不过,允许用户对new做一些假设,例如:clang with libc++可以优化掉new/delete以得到未使用的std::vector
std::unordered_map这样的类(一个哈希表而不是红黑树)在它们的构造函数中使用了new
我用std::map<int,int>进行测试,所以单个对象没有具有可见副作用的析构函数。一个std::map<Foo,Bar>,其中Foo::~Foo打印了一些东西,这将使它在静态本地初始化器运行时变得重要,因为那是我们调用__cxa_atexit的时候。假设析构顺序与构造顺序相反,那么等到以后再调用__cxa_atexit可能会导致它被更快地析构,从而导致Foo::~Foo()调用发生得太快,可能会在其他一些可见的副作用之前而不是之后。

**或者其他全局数据结构可能会引用std::map<int,int>中的int对象,并在其析构函数中使用这些对象。**如果我们过早地析构std::map,那将是不安全的。

(我不确定ISO C或GNU C是否为析构函数的排序提供了这样的顺序保证。但如果是这样的话,这就是编译器在涉及到注册析构函数时通常不能推迟构造的原因。在琐碎的程序中寻找这种优化是不值得花费编译时间的。)

使用file-scope static来避免保护变量

请注意,缺少了一个保护变量,使得快速路径更快,特别是对于像ARMv7这样的ISA,它们没有一个好的方法来实现获取屏障。https://godbolt.org/z/4bGx3Tasj -

static C global_c;     // It's not actually global, just file-scoped static

C& get2() {
    return global_c;
}
# clang -O3 for x86-64
get2():
      # note the lack of a load + branch on a guard variable
        lea     rax, [rip + global_c]
        ret

main:
      # construction already happened before main started, and we don't do anything with the address
        xor     eax, eax
        ret
# GCC -O3 -mcpu=cortex-a15     // a random ARMv7 CPU
get2():
        ldr     r0, .L81          @ PC-relative load
        bx      lr

@ somewhere nearby, between functions
.L81:
        .word   .LANCHOR0+52      @ pointer to struct C global_c

main:
        mov     r0, #0
        bx      lr

执行存储和调用__cxa_atexit的构造函数代码仍然存在,它只是在一个名为_GLOBAL__sub_I_example.cpp:(clang)或_GLOBAL__sub_I_get():(GCC)的单独函数中,编译器将其添加到main之前调用的init函数列表中。

函数作用域的本地变量通常很好,开销非常小,特别是在x86-64和ARMv 8上。但是由于您担心构建std::map时的微优化,因此我认为值得一提。并展示编译器用来使这些东西在引擎盖下工作的机制。

3j86kqsm

3j86kqsm4#

编译器是否优化函数调用基本上是未指定的行为。一个未指定的行为基本上是从一组有限的可能性中选择的行为,但这种选择可能并不总是一致的。在这种情况下,选择是“优化”或“不”,标准没有规定,实现也不应该记录,因为它是一个给定实现可能不一致的选择。
如果这个想法只是“触摸”,如果我们只是添加一个虚拟的volatile变量并在每次调用中虚拟递增它,它会有帮助吗
例如

C& getC(){
   volatile int dummy;
   dummy++;
   // rest of the code
}

相关问题