提问人:einpoklum 提问时间:10/11/2019 最后编辑:Peter Cordeseinpoklum 更新时间:6/20/2020 访问量:7047
为什么 T* 可以传入寄存器,而 unique_ptr<T> 不能?
Why can a T* be passed in register, but a unique_ptr<T> cannot?
问:
我正在观看 Chandler Carruth 在 CppCon 2019 上的演讲:
在其中,他举了一个例子,说明他对使用一个 over an 会产生多少开销感到惊讶;该段大约在 17:25 时间点开始。std::unique_ptr<int>
int*
你可以看看他的示例片段对(godbolt.org)的编译结果 - 以证明,事实上,编译器似乎不愿意将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));
}
答:
- 这实际上是 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 个对象,其中包含一个普通复制构造函数和一个普通析构函数,即只有不超过 16 的对象的值才能在寄存器中传递。有关调用约定的详细处理,请参阅 Agner Fog 的调用约定,特别是 §7.1 传递和返回对象。在寄存器中传递 SIMD 类型有单独的调用约定。sizeof
其他 CPU 架构有不同的 ABI。
还有大多数编译器都遵循的 Itanium C++ ABI(MSVC 除外),它要求:
如果参数类型对于调用而言是非平凡的,则调用方必须为临时分配空间,并通过引用传递该临时。
在以下情况下,出于调用目的,类型被视为非平凡的:
- 它有一个重要的复制构造函数、移动构造函数或析构函数,或者
- 删除其所有复制和移动构造函数。
此定义应用于类类型时,旨在作为 [class.temporary]p3 中定义的补充,这些类型在传递或返回类型时允许额外的临时性。对于 ABI 而言微不足道的类型将根据基本 C ABI 的规则传递和返回,例如在寄存器中;通常,这会执行该类型的琐碎副本。
- 为什么 ABI 会这样?也就是说,如果结构/类的字段适合寄存器,甚至是单个寄存器,为什么我们不能在该寄存器内传递它呢?
这是一个实现细节,但是当处理异常时,在堆栈展开期间,自动存储持续时间被销毁的对象必须相对于函数堆栈帧是可寻址的,因为此时寄存器已被破坏。堆栈展开代码需要对象的地址来调用其析构函数,但寄存器中的对象没有地址。
迂腐地,析构函数对对象进行操作:
对象在其构造期间 ([class.cdtor])、整个生命周期和销毁期间都占据一个存储区域。
如果没有为对象分配可寻址存储,则对象不能存在于 C++ 中,因为对象的标识是其地址。
当需要在寄存器中保存一个带有简单复制构造函数的对象的地址时,编译器可以将对象存储到内存中并获取地址。另一方面,如果复制构造函数是非平凡的,编译器不能只是将其存储到内存中,而是需要调用复制构造函数,该构造函数接受引用,因此需要寄存器中对象的地址。调用约定可能无法确定复制构造函数是否内联在被调用方中。
另一种思考方式是,对于简单可复制的类型,编译器在寄存器中传输对象的值,如有必要,可以通过普通内存存储从中恢复对象。例如:
void f(long*);
void g(long a) { f(&a); }
在 System V 的 x86_64上,ABI 编译为:
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)提到,为了实施可以改善事情的破坏性举措,可能需要进行突破性的ABI更改(除其他外)。IMO 中,如果使用新 ABI 的函数明确选择加入新的不同链接,例如在块中声明它们(可能在用于迁移现有 API 的新内联命名空间中),则 ABI 更改可能是非中断的。因此,只有针对具有新链接的新函数声明编译的代码才能使用新 ABI。extern "C++20" {}
请注意,当调用的函数已内联时,ABI 不适用。除了链接时代码生成外,编译器还可以内联在其他翻译单元中定义的函数或使用自定义调用约定。
评论
__declspec(register)
-fno-exceptions
-fno-exceptions
对于常见的 ABI,非平凡的析构函数 -> 无法传入寄存器
(在评论中使用@harold的例子来说明@MaximEgorushkin回答中的一个观点;根据@Yakk的评论进行了更正。
如果编译:
struct Foo { int bar; };
Foo test(Foo byval) { return byval; }
您将获得:
test(Foo):
mov eax, edi
ret
即对象在寄存器()中传递,并在寄存器()中返回。Foo
test
edi
eax
当析构函数不是微不足道的(如OP的例子)时,常见的ABI需要放置在堆栈上。即使析构函数根本不使用对象的地址,也是如此。std::unique_ptr
因此,即使在无操作析构函数的极端情况下,如果您编译:
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 中断并非史无前例,例如 C++11 中的 std::string 更改导致 ABI 中断。
评论
unique_ptr
shared_ptr
shared_ptr<T>
delete x;
shared_ptr
unique_ptr
首先,我们需要回到按值和引用传递的含义。
对于像 Java 和 SML 这样的语言,按值传递很简单(并且没有通过引用传递),就像复制变量值一样,因为所有变量都只是标量并且具有内置的复制语义:它们要么是 C++ 中的算术类型,要么是“引用”(具有不同名称和语法的指针)。
在 C 语言中,我们有标量和用户定义的类型:
- 标量具有被复制的数值或抽象值(指针不是数字,它们具有抽象值)。
- 聚合类型复制了其所有可能初始化的成员:
- 对于产品类型(数组和结构):递归地复制结构的所有成员和数组的元素(C 函数语法无法直接按值传递数组,只能按结构体的数组成员传递数组,但这是一个细节)。
- 对于总和类型(联合):保留“活动成员”的值;显然,逐个成员的副本是不合顺序的,因为并非所有成员都可以初始化。
在 C++ 中,用户定义的类型可以具有用户定义的复制语义,从而实现真正的“面向对象”编程,对象具有其资源所有权和“深度复制”操作。在这种情况下,复制操作实际上是对几乎可以执行任意操作的函数的调用。
对于编译为 C++ 的 C 结构,“复制”仍定义为调用用户定义的复制操作(构造函数或赋值运算符),这些操作由编译器隐式生成。这意味着 C/C++ 公共子集程序的语义在 C 和 C++ 中是不同的:在 C 中复制整个聚合类型,在 C++ 中调用隐式生成的复制函数来复制每个成员;最终结果是,无论哪种情况,都会复制每个成员。
(我认为,当联合内部的结构被复制时,有一个例外。
因此,对于类类型,创建新实例的唯一方法(联合副本之外)是通过构造函数(即使对于那些使用简单编译器生成的构造函数的构造函数)。
您不能通过一元运算符获取右值的地址,但这并不意味着没有右值对象;根据定义,对象具有地址;该地址甚至由语法结构表示:类类型的对象只能由构造函数创建,并且它有一个指针;但是对于琐碎的类型,没有用户编写的构造函数,因此在构造和命名副本之前没有地方可以放置。&
this
this
对于标量类型,对象的值是对象的右值,即存储在对象中的纯数学值。
对于类类型,对象值的唯一概念是对象的另一个副本,它只能由复制构造函数(一个实函数)创建(尽管对于琐碎的类型,该函数非常琐碎,有时可以在不调用构造函数的情况下创建这些函数)。这意味着 object 的值是执行更改全局程序状态的结果。它不能以数学方式访问。
因此,按值传递实际上不是一回事:它是通过复制构造函数调用传递的,这不太漂亮。复制构造函数应根据对象类型的正确语义执行合理的“复制”操作,并尊重其内部不变量(抽象用户属性,而不是内部 C++ 属性)。
按类对象的值传递意味着:
- 创建另一个实例
- 然后使被调用的函数作用于该实例。
请注意,该问题与副本本身是否是具有地址的对象无关:所有函数参数都是对象,并且具有地址(在语言语义级别)。
问题在于:
- 副本是用原始对象的纯数学值(true pure rvalue)初始化的新对象,就像标量一样;
- 或者 copy 是原始对象的值,就像类一样。
在普通类类型的情况下,您仍然可以定义原始类副本的成员,因此由于复制操作(复制构造函数和赋值)的琐碎性,您可以定义原始类的纯右值。对于任意的特殊用户函数,情况并非如此:原始函数的值必须是构造的副本。
类对象必须由调用方构造;构造函数在形式上有一个指针,但形式主义在这里无关紧要:所有对象在形式上都有一个地址,但只有那些真正以非纯本地方式使用其地址的对象(与地址的纯粹本地使用不同)需要有一个明确定义的地址。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
unique_ptr<T*>
T*
unique_ptr
int
&i
i
unique_ptr
std::move
unique_ptr
struct{}
atomic_int
volatile
struct tmp = volatile_struct;
评论
this
unique_ptr