为什么 T* 可以在寄存器中传递,而 unique_ptr<T> 不能?

Why can a T* be passed in register, but a unique_ptr<T> cannot?

我正在观看 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 需要按值参数(一些但不是全部;也许 - 非原始类型?非平凡可构造的类型? ) 在内存中传递,而不是在寄存器中传递。

我的问题:

  1. 这实际上是某些平台上的 ABI 要求吗? (which?) 又或者只是某些场景下的一些悲观情绪?
  2. 为什么 ABI 是这样的?也就是说,如果 struct/class 的字段适合寄存器,甚至是单个寄存器 - 为什么我们不能在该寄存器内传递它?
  3. 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));
}
  1. Is this actually an ABI requirement, or maybe it's just some pessimization in certain scenarios?

一个例子是System V Application Binary Interface AMD64 Architecture Processor Supplement。此 ABI 适用于 64 位 x86 兼容 CPUs(Linux x86_64 架构)。在 Solaris、Linux、FreeBSD、macOS、Windows Linux 的子系统上紧随其后:

If a C++ object has either a non-trivial copy constructor or a non-trivial destructor, it is passed by invisible reference (the object is replaced in the parameter list by a pointer that has class INTEGER).

An object with either a non-trivial copy constructor or a non-trivial destructor cannot be passed by value because such objects must have well defined addresses. Similar issues apply when returning an object from a function.

注意,只有 2 个通用寄存器可以用于传递 1 个具有普通复制构造函数和普通析构函数的对象,即只有 sizeof 不大于 16 的对象的值可以在寄存器中传递.有关调用约定的详细处理,请参阅 Calling conventions by Agner Fog,特别是 §7.1 传递和返回对象。在寄存器中传递 SIMD 类型有单独的调用约定。

其他 CPU 架构有不同的 ABI。


还有Itanium C++ ABI which most compilers comply with (apart from MSVC), which requires:

If the parameter type is non-trivial for the purposes of calls, the caller must allocate space for a temporary and pass that temporary by reference.

A type is considered non-trivial for the purposes of calls if:

  • it has a non-trivial copy constructor, move constructor, or destructor, or
  • all of its copy and move constructors are deleted.

This definition, as applied to class types, is intended to be the complement of the definition in [class.temporary]p3 of types for which an extra temporary is allowed when passing or returning a type. A type which is trivial for the purposes of the ABI will be passed and returned according to the rules of the base C ABI, e.g. in registers; often this has the effect of performing a trivial copy of the type.


  1. Why is the ABI like that? That is, if the fields of a struct/class fit within registers, or even a single register - why should we not be able to pass it within that register?

这是一个实现细节,但是当处理异常时,在堆栈展开期间,被销毁的具有自动存储持续时间的对象必须相对于函数堆栈帧是可寻址的,因为此时寄存器已被破坏。堆栈展开代码需要对象的地址来调用它们的析构函数,但寄存器中的对象没有地址。

迂腐地,destructors operate on objects

An object occupies a region of storage in its period of construction ([class.cdtor]), throughout its lifetime, and in its period of destruction.

并且如果没有为对象分配 可寻址 存储,则对象不能存在于 C++ 中,因为 object's identity is its address.

当需要在寄存器中保存一个具有普通复制构造函数的对象的地址时,编译器可以将对象存储到内存中并获取地址。另一方面,如果复制构造函数是非平凡的,则编译器不能将其存储到内存中,而是需要调用采用引用的复制构造函数,因此需要寄存器中对象的地址。调用约定可能不能依赖于复制构造函数是否内联在被调用者中。

另一种思考方式是,对于普通可复制类型,编译器将对象的 传输到寄存器中,从中可以通过普通内存存储恢复对象如有必要。例如:

void f(long*);
void g(long a) { f(&a); }

on x86_64 with System V 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 在发人深省的演讲中 mentions 可能需要(除其他事项外)进行重大的 ABI 更改,以实施可以改善情况的破坏性举措。 IMO,如果使用新 ABI 的函数明确选择加入新的不同 linkage,则 ABI 更改可能不会中断,例如在 extern "C++20" {} 块中声明它们(可能在用于迁移现有 API 的新内联命名空间中)。因此只有针对具有新 linkage 的新函数声明编译的代码才能使用新的 ABI。

请注意,当调用的函数被内联时,ABI 不适用。与 link 时间代码生成一样,编译器可以内联在其他翻译单元中定义的函数或使用自定义调用约定。

对于常见的 ABI,非平凡的析构函数 -> 无法传入寄存器

(在评论中使用@harold 的示例说明@MaximEgorushkin 的回答中的一点;根据@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

无用的加载和存储。

Is this actually an ABI requirement on some platforms? (which?) Or maybe it's just some pessimization in certain scenarios?

如果某些东西在编译单元边界可见,那么无论它是隐式定义还是显式定义,它都会成为 ABI 的一部分。

Why is the ABI like that?

根本问题是,当您在调用堆栈中上下移动时,寄存器会一直保存和恢复。因此,拥有指向它们的引用或指针是不切实际的。

内联和由此产生的优化在发生时很好,但 ABI 设计人员不能依赖它发生。他们必须假设最坏的情况来设计 ABI。我认为程序员不会对 ABI 根据优化级别而改变的编译器感到非常满意。

可以在寄存器中传递普通可复制类型,因为逻辑复制操作可以分为两部分。参数由调用者复制到用于传递参数的寄存器中,然后由被调用者复制到局部变量中。因此局部变量是否有内存位置只是被调用者关心的。

另一方面,必须使用复制或移动构造函数的类型不能以这种方式拆分其复制操作,因此必须在内存中传递。

Has the C++ standards committee discussed this point in recent years, or ever?

我不知道标准机构是否考虑过这一点。

对我来说最明显的解决方案是在语言中添加适当的破坏性动作(而不是 "valid but otherwise unspecified state" 当前的中途之家),然后引入一种方法来标记类型允许 "trivial destructive moves" 即使它不允许简单的副本。

但是这样的解决方案需要打破现有代码的 ABI 才能实现现有类型,这可能会带来相当大的阻力(尽管 ABI 由于新的 C++ 标准版本而中断并不是前所未有的,例如std::string C++11 中的更改导致 ABI 中断..

首先我们需要回到按值传递和按引用传递的含义。

对于像 Java 和 SML 这样的语言,按值传递很简单(并且没有按引用传递),就像复制变量值一样,因为所有变量都只是标量并且具有内置复制语义: 它们要么是 C++ 中的算术类型,要么是 "references"(具有不同名称和语法的指针)。

在 C 中,我们有标量和用户定义类型:

  • 标量有一个被复制的数字或抽象值(指针不是数字,它们有一个抽象值)。
  • 聚合类型复制了所有可能初始化的成员:
    • 对于产品类型(数组和结构):递归地,复制结构的所有成员和数组的元素(C 函数语法不能直接按值传递数组,只能复制结构的数组成员,但这是一个细节)。
    • 对于求和类型(联合):"active member"的值被保留;显然,一个成员一个成员的副本是不按顺序排列的,因为并非所有成员都可以初始化。

在 C++ 中,用户定义的类型可以具有用户定义的复制语义,这使得能够真正地 "object oriented" 使用具有资源所有权和 "deep copy" 操作的对象进行编程。在这种情况下,复制操作实际上是对几乎可以执行任意操作的函数的调用。

对于编译为 C++ 的 C 结构,"copying" 仍然定义为调用用户定义的复制操作(构造函数或赋值运算符),这些操作由编译器隐式生成。这意味着 C/C++ 公共子集程序的语义在 C 和 C++ 中是不同的:在 C 中复制整个聚合类型,在 C++ 中调用隐式生成的复制函数来复制每个成员;最终结果是,无论哪种情况,每个成员都会被复制。

(我认为有一个例外,当复制联合内部的结构时。)

因此对于 class 类型,创建新实例的唯一方法(外部联合副本)是通过构造函数(即使对于那些具有普通编译器生成的构造函数的实例)。

您不能通过一元运算符获取右值的地址 & 但这并不意味着没有右值对象; 一个对象,根据定义,有一个地址;并且该地址甚至由语法构造表示:class 类型的对象只能由构造函数创建,并且它有一个 this 指针;但是对于普通类型,没有用户编写的构造函数,因此在构造副本并命名之前没有放置 this 的地方。

对于标量类型,对象的值是对象的右值,存储到对象中的纯数学值。

对于 class 类型,对象值的唯一概念是对象的另一个副本,它只能由复制构造函数创建,一个真正的函数(尽管对于函数的平凡类型非常简单,有时可以在不调用构造函数的情况下创建这些)。这意味着object 的值是通过执行 改变全局程序状态的结果。它不以数学方式访问。

所以按值传递真的不是一回事:它是通过复制构造函数调用传递的,这不太漂亮。复制构造函数应根据对象类型的正确语义执行合理的 "copy" 操作,尊重其内部不变量(抽象用户属性,而不是固有的 C++ 属性)。

按 class 对象的值传递意味着:

  • 创建另一个实例
  • 然后让调用的函数作用于那个实例。

请注意,这个问题与副本本身是否是具有地址的对象无关:所有函数参数都是对象并且具有地址(在语言语义级别).

问题是:

  • 副本是一个新对象,用原始对象的纯数学值(真正的纯右值)初始化,与标量一样;
  • 副本是原始对象的值,与classes一样。

对于平凡的class类型,您仍然可以定义原始成员副本的成员,因此由于复制操作的平凡性,您可以定义原始的纯右值(复制构造函数和赋值)。任意特殊用户函数并非如此:原始值必须是构造副本。

Class对象必须由调用者构造;构造函数形式上有一个 this 指针,但形式主义在这里不相关:所有对象形式上都有一个地址,但只有那些实际获得其地址的对象以非纯本地方式使用(不像 *&i = 1; 是纯本地的使用地址)需要有一个明确定义的地址。

如果一个对象必须在这两个单独编译的函数中都具有地址,则它必须绝对通过地址传递:

void callee(int &i) {
  something(&i);
}

void caller() {
  int i;
  callee(i);
  something(&i);
}

这里即使something(address)是一个纯函数或宏或任何不能存储地址或与另一个实体通信的东西(如printf("%p",arg)),我们也有通过地址传递的要求因为必须为具有唯一标识的唯一对象 int 明确定义地址。

我们不知道外部函数在传递给它的地址方面是否会 "pure"。

这里 潜在 在非平凡的构造函数或析构函数中真正使用地址 在调用方 可能是采取安全、简单的路线并在调用者中为对象赋予身份并传递其地址的原因,因为它确保在构造函数中、构造函数之后和析构函数中对其地址的任何重要使用都是consistentthis 必须在对象存在时看起来相同。

像任何其他函数一样,非平凡的构造函数或析构函数可以使用 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->),但对象标识无关紧要:编译器可以很好地使用按位复制对象来移动它并执行 "copy elision"。这是基于"purity"在特殊成员函数中使用this的水平(地址不转义)。

但是 纯度不是标准声明级别可用的属性(存在在非内联函数声明上添加纯度描述的编译器扩展),所以 你无法根据可能不可用的代码纯度定义 ABI(代码可能内联也可能不内联并可用于分析)。

纯度测量为 "certainly pure" 或 "impure or unknown"。共同点或语义上限(实际上是最大值)或 LCM(最小公倍数)是 "unknown"。所以 ABI 选择未知。

总结:

  • 一些构造要求编译器定义对象标识。
  • ABI 是根据 class 程序定义的,而不是可以优化的特定情况。

未来可能的工作:

纯度注释是否足够有用以进行通用化和标准化?