我正在观看 Chandler Carruth 在 CppCon 2019 上的演讲:
There are no Zero-Cost Abstractions
在其中,他举了一个例子,说明他对使用 std::unique_ptr<int>
而不是 int*
所产生的开销感到惊讶;该段大约在时间点 17:25 开始。
您可以查看他的示例片段对 (godbolt.org) 的 compilation results - 以证明编译器确实似乎不愿意传递 unique_ptr 值 - 实际上这是底线只是一个地址-在寄存器中,仅在直接内存中。
Carruth 先生在 27:00 左右提出的观点之一是 C++ ABI 需要按值参数(一些但不是全部;也许 - 非原始类型?非平凡可构造类型?)在内存中传递而不是在寄存器中。
我的问题:
这实际上是某些平台上的 ABI 要求吗? (哪个?)或者这只是某些情况下的悲观情绪?为什么ABI是这样的?也就是说,如果结构/类的字段适合寄存器,甚至是单个寄存器 - 为什么我们不能在该寄存器中传递它? C++ 标准委员会在最近几年或曾经讨论过这一点吗?
PS - 为了不让这个问题没有代码:
普通指针:
void bar(int* ptr) noexcept;
void baz(int* ptr) noexcept;
void foo(int* ptr) noexcept {
if (*ptr > 42) {
bar(ptr);
*ptr = 42;
}
baz(ptr);
}
唯一指针:
using std::unique_ptr;
void bar(int* ptr) noexcept;
void baz(unique_ptr<int> ptr) noexcept;
void foo(unique_ptr<int> ptr) noexcept {
if (*ptr > 42) {
bar(ptr.get());
*ptr = 42;
}
baz(std::move(ptr));
}
this
指针的非平凡成员函数有关。 unique_ptr
有这些。为此目的溢出寄存器会有点否定整个“传入寄存器”优化。
这实际上是 ABI 要求,还是在某些情况下只是一些悲观?
一个例子是 System V Application Binary Interface AMD64 Architecture Processor Supplement。此 ABI 适用于 64 位 x86 兼容 CPU(Linux x86_64 架构)。在 Solaris、Linux、FreeBSD、macOS、Linux 的 Windows 子系统上紧随其后:
如果 C++ 对象具有非平凡的复制构造函数或非平凡的析构函数,则它通过不可见的引用传递(该对象在参数列表中被具有类 INTEGER 的指针替换)。具有非平凡复制构造函数或非平凡析构函数的对象不能按值传递,因为此类对象必须具有明确定义的地址。从函数返回对象时也会出现类似的问题。
请注意,只有 2 个通用寄存器可用于传递具有普通复制构造函数和普通析构函数的 1 个对象,即只有 sizeof
不大于 16 的对象的值可以在寄存器中传递。有关调用约定的详细处理,请参见 Calling conventions by Agner Fog,特别是 §7.1 传递和返回对象。在寄存器中传递 SIMD 类型有单独的调用约定。
其他 CPU 架构有不同的 ABI。
还有大多数编译器都遵守的 Itanium C++ ABI(MSVC 除外),其中 requires:
如果参数类型对于调用而言是非平凡的,则调用者必须为临时分配空间并通过引用传递该临时。在以下情况下,为了调用的目的,一个类型被认为是非平凡的:它有一个非平凡的复制构造函数、移动构造函数或析构函数,或者它的所有复制和移动构造函数都被删除。此定义应用于类类型,旨在补充 [class.temporary]p3 中在传递或返回类型时允许额外临时的类型的定义。对于 ABI 而言微不足道的类型将根据基本 C ABI 的规则(例如在寄存器中)传递和返回;这通常具有执行类型的简单副本的效果。
为什么ABI是这样的?也就是说,如果结构/类的字段适合寄存器,甚至是单个寄存器 - 为什么我们不能在该寄存器中传递它?
这是一个实现细节,但是当处理异常时,在堆栈展开期间,具有自动存储持续时间被销毁的对象必须相对于函数堆栈帧是可寻址的,因为此时寄存器已被破坏。堆栈展开代码需要对象的地址来调用它们的析构函数,但寄存器中的对象没有地址。
迂腐地,destructors operate on objects:
一个对象在其构建期间([class.cdtor])、在其整个生命周期以及在其销毁期间占用一个存储区域。
如果因为 object's identity is its address 而没有为其分配 可寻址 存储,则该对象不能存在于 C++ 中。
当需要在寄存器中保存一个简单的复制构造函数的对象的地址时,编译器可以将对象存储到内存中并获取地址。另一方面,如果复制构造函数不平凡,编译器不能只将其存储到内存中,而是需要调用复制构造函数,该构造函数需要引用,因此需要寄存器中对象的地址。调用约定可能不能取决于复制构造函数是否内联在被调用者中。
另一种思考方式是,对于普通可复制类型,编译器将对象的值传输到寄存器中,如果需要,可以通过普通内存存储从寄存器中恢复对象。例如:
void f(long*);
void g(long a) { f(&a); }
在带有 System V ABI 的 x86_64 上编译为:
g(long): // Argument a is in rdi.
push rax // Align stack, faster sub rsp, 8.
mov qword ptr [rsp], rdi // Store the value of a in rdi into the stack to create an object.
mov rdi, rsp // Load the address of the object on the stack into rdi.
call f(long*) // Call f with the address in rdi.
pop rax // Faster add rsp, 8.
ret // The destructor of the stack object is trivial, no code to emit.
在他发人深省的演讲中,Chandler Carruth mentions 认为,可能需要(除其他外)进行破坏性 ABI 更改,以实施可以改善情况的破坏性举措。 IMO,如果使用新 ABI 的函数明确选择加入新的不同链接,则 ABI 更改可能不会中断,例如在 extern "C++20" {}
块中声明它们(可能在用于迁移现有 API 的新内联命名空间中)。这样只有针对具有新链接的新函数声明编译的代码才能使用新的 ABI。
请注意,当调用的函数被内联时,ABI 不适用。与链接时代码生成一样,编译器可以内联在其他翻译单元中定义的函数或使用自定义调用约定。
使用常见的 ABI,非平凡的析构函数 -> 不能传入寄存器
(@MaximEgorushkin 在评论中使用@harold 的示例说明了一个点;根据@Yakk 的评论进行了更正。)
如果你编译:
struct Foo { int bar; };
Foo test(Foo byval) { return byval; }
你得到:
test(Foo):
mov eax, edi
ret
即 Foo
对象在寄存器 (edi
) 中传递给 test
,并在寄存器 (eax
) 中返回。
当析构函数不重要时(如 OP 的 std::unique_ptr
示例)- 常见 ABI 需要放置在堆栈上。即使析构函数根本不使用对象的地址也是如此。
因此,即使在无操作析构函数的极端情况下,如果您编译:
struct Foo2 {
int bar;
~Foo2() { }
};
Foo2 test(Foo2 byval) { return byval; }
你得到:
test(Foo2):
mov edx, DWORD PTR [rsi]
mov rax, rdi
mov DWORD PTR [rdi], edx
ret
无用的装载和存储。
std::unique_ptr
不合格。
register
关键字旨在通过阻止实际上使物理机中“没有地址”变得更加困难的事物,使物理机在寄存器中存储某些内容变得微不足道。
这实际上是某些平台上的 ABI 要求吗? (哪个?)或者这只是某些情况下的悲观情绪?
如果某些东西在编译单元边界可见,那么无论它是隐式定义还是显式定义,它都会成为 ABI 的一部分。
为什么ABI是这样的?
根本的问题是,当您在调用堆栈中上下移动时,寄存器会一直被保存和恢复。因此,对它们进行引用或指针是不切实际的。
内联和由此产生的优化在发生时很好,但 ABI 设计人员不能依赖它发生。他们必须在最坏的情况下设计 ABI。我认为程序员不会对 ABI 根据优化级别而改变的编译器感到非常满意。
可以在寄存器中传递普通可复制类型,因为逻辑复制操作可以分为两部分。参数由调用者复制到用于传递参数的寄存器中,然后由被调用者复制到局部变量中。因此,局部变量是否具有内存位置只是被调用者关心的问题。
另一方面,必须使用复制或移动构造函数的类型不能以这种方式拆分其复制操作,因此必须在内存中传递它。
C++ 标准委员会在最近几年或曾经讨论过这一点吗?
我不知道标准机构是否考虑过这一点。
对我来说显而易见的解决方案是在语言中添加适当的破坏性动作(而不是当前“有效但未指定状态”的中途房子),然后引入一种方法将类型标记为允许“微不足道的破坏性动作” “即使它不允许琐碎的副本。
但是这样的解决方案将需要打破现有代码的 ABI 来实现现有类型,这可能会带来相当大的阻力(尽管由于新的 C++ 标准版本导致 ABI 中断并非史无前例,例如 std::string 更改在 C++11 中导致 ABI 中断..
unique_ptr
和 shared_ptr
语义:shared_ptr<T>
允许您向 ctor 提供 1)一个指向派生对象 U 的 ptr x 以使用带有表达式 delete x;
的静态类型 U 删除(所以您这里不需要虚拟 dtor)2)甚至自定义清理功能。这意味着在 shared_ptr
控制块内使用运行时状态来编码该信息。 OTOH unique_ptr
没有这样的功能,并且不编码状态中的删除行为;自定义清理的唯一方法是创建另一个模板实例化(另一个类类型)。
首先,我们需要回到按值传递和按引用传递的含义。
对于像 Java 和 SML 这样的语言,按值传递很简单(并且没有按引用传递),就像复制变量值一样,因为所有变量都只是标量并且具有内置的复制语义:它们要么是算术的东西键入 C++ 或“引用”(具有不同名称和语法的指针)。
在 C 中,我们有标量和用户定义的类型:
标量具有可复制的数字或抽象值(指针不是数字,它们具有抽象值)。
聚合类型复制了所有可能初始化的成员: 对于产品类型(数组和结构):递归地复制所有结构成员和数组元素(C 函数语法不能直接按值传递数组,仅结构的数组成员,但这是一个细节)。对于总和类型(联合):保留“活动成员”的值;显然,逐个成员的副本不是按顺序排列的,因为并非所有成员都可以初始化。
对于产品类型(数组和结构):递归地复制结构的所有成员和数组的元素(C 函数语法不能直接按值传递数组,只有结构的数组成员,但这是一个细节)。
对于总和类型(联合):保留“活动成员”的值;显然,逐个成员的副本不是按顺序排列的,因为并非所有成员都可以初始化。
在 C++ 中,用户定义的类型可以具有用户定义的复制语义,这使得真正的“面向对象”编程能够使用具有资源所有权的对象和“深拷贝”操作。在这种情况下,复制操作实际上是对几乎可以执行任意操作的函数的调用。
对于编译为 C++ 的 C 结构,“复制”仍定义为调用用户定义的复制操作(构造函数或赋值运算符),这些操作由编译器隐式生成。这意味着 C/C++ 公共子集程序的语义在 C 和 C++ 中是不同的:在 C 中复制整个聚合类型,在 C++ 中调用隐式生成的复制函数来复制每个成员;最终结果是在任何一种情况下每个成员都被复制。
(我认为,当复制联合中的结构时,会有一个例外。)
因此,对于类类型,创建新实例的唯一方法(外部联合副本)是通过构造函数(即使对于那些编译器生成的构造函数也很简单)。
您不能通过一元运算符 &
获取右值的地址,但这并不意味着没有右值对象; 根据定义,一个对象有一个地址;并且该地址甚至由语法结构表示:类类型的对象只能由构造函数创建,并且它有一个 this
指针;但是对于普通类型,没有用户编写的构造函数,因此在构造副本并命名之前没有地方放置 this
。
对于标量类型,对象的值是对象的右值,即存储到对象中的纯数学值。
对于类类型,对象值的唯一概念是对象的另一个副本,它只能由复制构造函数、真正的函数来创建(尽管对于普通类型,该函数是如此特别普通,但有时可以在不调用构造函数的情况下创建)。这意味着 object 的值是执行更改全局程序状态的结果。它不能以数学方式访问。
所以按值传递真的不是一件事:它是通过复制构造函数调用传递,这不太漂亮。复制构造函数应根据对象类型的正确语义执行合理的“复制”操作,尊重其内部不变量(这是抽象的用户属性,而不是内在的 C++ 属性)。
按类对象的值传递意味着:
创建另一个实例
然后使被调用的函数作用于该实例。
请注意,该问题与副本本身是否是具有地址的对象无关:所有函数参数都是对象并且具有地址(在语言语义级别)。
问题是:
副本是用原始对象的纯数学值(真正的纯右值)初始化的新对象,与标量一样;
或者副本是原始对象的值,就像类一样。
在普通类类型的情况下,您仍然可以定义原始成员副本的成员,因此由于复制操作(复制构造函数和赋值)的琐碎性,您可以定义原始的纯右值。任意特殊用户函数并非如此:原始值必须是构造的副本。
类对象必须由调用者构造;构造函数形式上具有 this
指针,但形式主义在这里不相关:所有对象形式上都有地址,但只有那些实际上以非纯本地方式使用其地址的对象(不像 *&i = 1;
,它是纯本地使用地址)需要有一个明确的地址。
如果一个对象必须在这两个单独编译的函数中看起来都具有地址,则它必须绝对通过地址传递:
void callee(int &i) {
something(&i);
}
void caller() {
int i;
callee(i);
something(&i);
}
在这里,即使 something(address)
是纯函数或宏或不能存储地址或与另一个实体通信的任何东西(如 printf("%p",arg)
),我们也需要通过地址传递,因为地址必须为具有唯一标识的唯一对象 int
。
我们不知道外部函数在传递给它的地址方面是否是“纯”的。
这里潜在在调用方的非平凡构造函数或析构函数中真正使用地址可能是采取安全、简单路线并给出在调用者中对象一个身份并传递其地址,因为 它确保在构造函数中、在构造之后和在析构函数中对其地址的任何重要使用都是一致的:this
必须看起来是相同的对象存在。
像任何其他函数一样的非平凡构造函数或析构函数可以以需要其值一致的方式使用 this
指针,即使某些具有非平凡事物的对象可能不会:
struct file_handler { // don't use that class!
file_handler () { this->fileno = -1; }
file_handler (int f) { this->fileno = f; }
file_handler (const file_handler& rhs) {
if (this->fileno != -1)
this->fileno = dup(rhs.fileno);
else
this->fileno = -1;
}
~file_handler () {
if (this->fileno != -1)
close(this->fileno);
}
file_handler &operator= (const file_handler& rhs);
};
请注意,在这种情况下,尽管显式使用了指针(显式语法 this->
),但对象标识是无关紧要的:编译器可以很好地使用按位复制对象来移动它并执行“复制省略”。这是基于在特殊成员函数中使用 this
的“纯度”级别(地址不会转义)。
但是纯度不是标准声明级别可用的属性(存在编译器扩展,在非内联函数声明上添加纯度描述),因此您不能基于可能不可用的代码纯度定义 ABI(代码可能或可能不是内联的并且可用于分析)。
纯度被测量为“肯定纯”或“不纯或未知”。语义的共同点或上限(实际上是最大值)或 LCM(最小公倍数)是“未知的”。所以 ABI 选择未知。
概括:
一些构造要求编译器定义对象标识。
ABI 是根据程序类别定义的,而不是可以优化的特定情况。
未来可能的工作:
纯度注释是否足够有用以进行概括和标准化?
void foo(unique_ptr<int> ptr)
采用 按值 的类对象。该对象有一个指针成员,但我们谈论的是通过引用传递的类对象本身。 (因为它不是简单可复制的,所以它的构造函数/析构函数需要一致的 this
。)这是真正的论点,与 显式 引用传递的第一个示例无关;在这种情况下,指针在寄存器中传递。
int
一样的标量:我写了一个“智能文件号”示例,说明“所有权”与“携带 ptr”无关。
unique_ptr<T*>
,它的大小和布局与 T*
相同,并且适合寄存器。像大多数调用约定一样,可以在 x86-64 System V 的寄存器中按值传递可平凡复制的类对象。这会生成 unique_ptr
对象的 副本,这与您的 int
示例不同,在该示例中,被调用者的 &i
是调用者的 i
的地址,因为您传递了通过在 C++ 级别的引用,而不仅仅是作为 asm 实现细节。
unique_ptr
对象的副本;它正在使用 std::move
,因此复制它是安全的,因为这不会导致相同 unique_ptr
的 2 个副本。但是对于一个可简单复制的类型,是的,它确实复制了整个聚合对象。如果那是单个成员,则良好的调用约定将其视为该类型的标量。
struct{}
是一个 C++ 结构。也许您应该说“普通结构”或“不像 C”。因为是的,有区别。如果您使用 atomic_int
作为结构成员,C 将非原子地复制它,删除的复制构造函数上的 C++ 错误。我忘记了 C++ 对具有 volatile
成员的结构做了什么。 C 将让您执行 struct tmp = volatile_struct;
来复制整个内容(对 SeqLock 很有用); C++ 不会。
不定期副业成功案例分享
__declspec(register)
之类的内容就足够了。