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

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

提问人:einpoklum 提问时间:10/11/2019 最后编辑:Peter Cordeseinpoklum 更新时间:6/20/2020 访问量:7047

问:

我正在观看 Chandler Carruth 在 CppCon 2019 上的演讲:

没有零成本抽象

在其中,他举了一个例子,说明他对使用一个 over an 会产生多少开销感到惊讶;该段大约在 17:25 时间点开始。std::unique_ptr<int>int*

你可以看看他的示例片段对(godbolt.org)的编译结果 - 以证明,事实上,编译器似乎不愿意将unique_ptr值(实际上在底线只是一个地址)传递到寄存器中,只在直接内存中传递。

Carruth 先生在 27:00 左右提出的一个观点是,C++ ABI 要求在内存中而不是在寄存器内传递按值参数(一些但不是全部;也许是非原始类型?非平凡的可构造类型?)。

我的问题:

  1. 这实际上是某些平台上的 ABI 要求吗?(哪个?或者也许只是某些情况下的一些悲观?
  2. 为什么 ABI 会这样?也就是说,如果结构/类的字段适合寄存器,甚至是单个寄存器,为什么我们不能在该寄存器内传递它呢?
  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));
}
C++ 程序集 unique-ptr 调用约定 abi

评论

8赞 harold 10/11/2019
我不确定 ABI 要求到底是什么,但它并不禁止将结构放入寄存器中
6赞 StoryTeller - Unslander Monica 10/11/2019
如果非要我猜的话,我会说这与非平凡的成员函数有关,需要指向有效位置的指针。 有那些。为此目的溢出寄存器会否定整个“传入寄存器”优化。thisunique_ptr
2赞 geza 10/11/2019
itanium-cxx-abi.github.io/cxx-abi/abi.html#calls。所以这种行为是必需的。为什么?itanium-cxx-abi.github.io/cxx-abi/cxx-closed.html,搜索问题 C-7。那里有一些解释,但不是太详细。但是,是的,这种行为对我来说似乎不合逻辑。这些对象可以正常通过堆栈传递。将它们推到堆栈中,然后传递引用(仅用于“非平凡”对象)似乎是一种浪费。
7赞 One Man Monkey Squad 10/11/2019
C++似乎在这里违反了自己的原则,这是相当可悲的。我 140% 确信任何unique_ptr在编译后都会消失。毕竟,它只是一个延迟的析构函数调用,在编译时是已知的。
7赞 einpoklum 10/11/2019
@MaximEgorushkin:如果你是手写的,你会把指针放在寄存器中,而不是堆栈上。

答:

66赞 Maxim Egorushkin 10/11/2019 #1
  1. 这实际上是 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 的规则传递和返回,例如在寄存器中;通常,这会执行该类型的琐碎副本。


  1. 为什么 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 不适用。除了链接时代码生成外,编译器还可以内联在其他翻译单元中定义的函数或使用自定义调用约定。

评论

0赞 Samuel Liew 10/15/2019
评论不用于扩展讨论;此对话已移至 Chat
0赞 user541686 5/23/2021
真正需要改变的不是链接,而是调用约定或类类型。像在课堂上这样的东西应该就足够了。__declspec(register)
0赞 Aykhan Hagverdili 8/17/2022
如果我编译了怎么办?-fno-exceptions
0赞 Maxim Egorushkin 10/17/2023
@AykhanHagverdili ABI 规则适用于具有非平凡构造函数/析构函数的对象。 不会更改/破坏 ABI,也不会使这些特殊成员函数变得微不足道。-fno-exceptions
8赞 einpoklum 10/12/2019 #2

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

(在评论中使用@harold的例子来说明@MaximEgorushkin回答中的一个观点;根据@Yakk的评论进行了更正。

如果编译:

struct Foo { int bar; };
Foo test(Foo byval) { return byval; }

您将获得:

test(Foo):
        mov     eax, edi
        ret

即对象在寄存器()中传递,并在寄存器()中返回。Footestedieax

当析构函数不是微不足道的(如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

无用的加载和存储。

评论

1赞 ComicSansMS 10/12/2019
不幸的是,这是另一种方式(我同意其中一些已经超出了理性)。准确地说:我不相信您提供的理由一定会使任何允许在寄存器中通过电流的可想象的 ABI 不符合要求。std::unique_ptr
4赞 Yakk - Adam Nevraumont 10/14/2019
“trivial destructor [CITATION NEEDED]”明显是错误的;如果没有代码实际上依赖于地址,那么 as-if 意味着该地址不需要存在于实际机器上。地址必须存在于抽象机器中,但抽象机器中对实际机器没有影响的东西,就好像是被允许消除的东西。
4赞 Yakk - Adam Nevraumont 10/14/2019
@einpoklum 标准中没有任何规定存在寄存器。register 关键字仅声明“您不能获取地址”。就标准而言,只有一台抽象的机器。“仿佛”意味着任何真实的机器实现只需要表现得“仿佛”抽象机器的行为,直到标准未定义的行为。现在,在寄存器中有一个对象存在非常具有挑战性的问题,每个人都广泛讨论了这个问题。此外,标准也没有讨论的调用约定也有实际需求。
1赞 Yakk - Adam Nevraumont 10/14/2019
@einpoklum 不,在那台抽象的机器里,万物都有地址;但地址仅在特定情况下是可观察的。该关键字旨在通过阻止实际上使物理机器更难在物理机器中“没有地址”的东西,使物理机器在寄存器中存储某些内容变得微不足道。register
2赞 einpoklum 11/17/2019
@curiousguy:我们谈论的是 abstact 机器的任意实现。您可以决定为寄存器中的内容保留一部分地址空间,并且内存从某个非零地址开始。
2赞 plugwash 10/12/2019 #3

这实际上是某些平台上的 ABI 要求吗?(哪个?或者也许只是某些情况下的一些悲观?

如果某物在编译单元边界处可见,则无论它是隐式定义的还是显式定义的,它都将成为 ABI 的一部分。

为什么 ABI 会这样?

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

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

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

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

C++标准委员会近年来是否讨论过这一点?

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

对我来说,显而易见的解决方案是将适当的破坏性动作(而不是当前“有效但未指定状态”的中途之家)添加到语言中,然后引入一种方法来将类型标记为允许“微不足道的破坏性动作”,即使它不允许微不足道的副本。

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

评论

0赞 einpoklum 10/12/2019
您能否详细说明适当的破坏性动作如何允许在寄存器中传递unique_ptr?那是因为它允许放弃对可寻址存储的要求吗?
0赞 plugwash 10/12/2019
适当的破坏性举动将使琐碎的破坏性举动的概念得以引入。这将允许 ABI 以与今天琐碎副本相同的方式拆分上述微不足道的举动。
0赞 plugwash 10/12/2019
尽管您还希望添加一条规则,即编译器可以将参数传递作为常规移动或复制,然后进行“微不足道的破坏性移动”,以确保无论参数来自何处,始终可以传入寄存器。
0赞 Mel Viso Martinez 10/16/2019
因为寄存器大小可以容纳指针,但unique_ptr结构?sizeof(unique_ptr<T>)是多少?
0赞 curiousguy 11/16/2019
@MelVisoMartinez 您可能会感到困惑和语义:允许您向 ctor 提供 1) ptr x 到派生对象,您要用静态类型 U 和表达式删除(因此您在这里不需要虚拟 dtor) 2) 甚至自定义清理函数。这意味着在控制块内部使用运行时状态来对该信息进行编码。OTOH 没有此类功能,并且不会对状态中的删除行为进行编码;自定义清理的唯一方法是创建另一个模板实例化(另一种类类型)。unique_ptrshared_ptrshared_ptr<T>delete x;shared_ptrunique_ptr
-1赞 curiousguy 11/30/2019 #4

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

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

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

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

在 C++ 中,用户定义的类型可以具有用户定义的复制语义,从而实现真正的“面向对象”编程,对象具有其资源所有权和“深度复制”操作。在这种情况下,复制操作实际上是对几乎可以执行任意操作的函数的调用。

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

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

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

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

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

对于类类型,对象值的唯一概念是对象的另一个副本,它只能由复制构造函数(一个实函数)创建(尽管对于琐碎的类型,该函数非常琐碎,有时可以在不调用构造函数的情况下创建这些函数)。这意味着 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 是根据程序类别定义的,而不是根据可能优化的特定情况定义的。

未来可能开展的工作:

纯度注释是否足够有用,可以推广和标准化?

评论

1赞 Peter Cordes 11/30/2019
您的第一个示例似乎具有误导性。我认为你只是在笼统地提出一个观点,但起初我以为你是在类问题中的代码。但按值获取类对象。该对象有一个指针成员,但我们谈论的是类对象本身是通过引用传递的。(因为它不是可简单复制的,所以它的构造函数/析构函数需要一致的 .这是真正的论点,与显式通过引用传递的第一个示例无关;在这种情况下,指针在寄存器中传递。void foo(unique_ptr<int> ptr)this
0赞 curiousguy 11/30/2019
@PeterCordes“你是在类比问题中的代码。我正是这样做的。“按值划分的类对象” 是的,我可能应该解释一下,一般来说,类对象没有“值”这样的东西,所以非数学类型的按值不是“按值”。“该对象有一个指针成员” “智能 ptr”的类似 ptr 的性质是无关紧要的;“Smart PTR”的PTR成员也是如此。ptr 只是一个标量,就像 : 我写了一个“smart fileno”的例子,它说明了“所有权”与“携带 ptr”无关。int
1赞 Peter Cordes 11/30/2019
类对象的值是它的对象表示形式。对于 ,这与寄存器的大小和布局相同,并且适合寄存器。与大多数调用约定一样,在 x86-64 System V 中,可以按寄存器中的传递简单可复制的类对象。这将创建对象的副本,这与示例中的被调用方调用方的地址不同,因为您在 C++ 级别通过引用传递,而不仅仅是作为 asm 实现细节传递。unique_ptr<T*>T*unique_ptrint&ii
1赞 Peter Cordes 11/30/2019
错误,更正我上次的评论。这不仅仅是复制对象;它正在使用,因此复制它是安全的,因为这不会导致相同的 2 个副本。但是对于一个简单可复制的类型,是的,它确实复制了整个聚合对象。如果这是单个成员,则良好的调用约定会将其视为与该类型的标量相同。unique_ptrstd::moveunique_ptr
1赞 Peter Cordes 12/8/2019
看起来更好。注意:对于编译为 C++ 的 C 结构 - 这不是介绍 C++ 之间差异的有用方法。在 C++ 中是一个 C++ 结构。也许你应该说“普通结构”,或者“不像 C”。因为是的,这是有区别的。如果用作结构成员,C 将非原子化复制它,删除复制构造函数时出现 C++ 错误。我忘记了 C++ 在带有成员的结构上做了什么。C 会让你复制整个东西(对 SeqLock 有用);C++ 不会。struct{}atomic_intvolatilestruct tmp = volatile_struct;