为什么T *可以在寄存器中传递,但unique_ptr <T>无法传递?


85

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

没有零成本抽象

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

您可以看一下他的示例代码对(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));
}

8
我不知道ABI的需求到底是什么,但它并没有禁止把结构在寄存器
哈罗德

6
如果我不得不猜测,那可能与非平凡的成员函数有关this,后者需要一个指向有效位置的指针。unique_ptr有那些。为此目的溢出寄存器会否定整个“传递寄存器”的优化。
StoryTeller-Unslander Monica,

2
itanium-cxx-abi.github.io/cxx-abi/abi.html#calls。因此,此行为是必需的。为什么?itanium-cxx-abi.github.io/cxx-abi/cxx-closed.html,搜索问题C-7。那里有一些解释,但并不太详细。但是,对我来说,这种行为似乎不合逻辑。这些对象可以正常通过堆栈。将它们推入堆栈,然后传递引用(仅适用于“非平凡的”对象)似乎是浪费。
geza

6
看来C ++在这里违反了自己的原则,这是非常可悲的。我有140%的人相信任何unique_ptr在编译后都会消失。毕竟,这只是在编译时就知道的延迟析构函数调用。
一个人猴小队

7
@MaximEgorushkin:如果您是手工编写的,则将指针放在寄存器中而不是堆栈中。
einpoklum

Answers:


49
  1. 这实际上是ABI要求,还是在某些情况下只是一些悲观?

一个示例是System V应用程序二进制接口AMD64体系结构处理器补充。该ABI适用于64位x86兼容CPU(Linux x86_64体系结构)。在Linux的Solaris,Linux,FreeBSD,macOS和Windows子系统上紧随其后:

如果C ++对象具有非平凡的复制构造函数或非平凡的析构函数,则它将通过不可见的引用传递(该对象在参数列表中被具有INTEGER类的指针替换)。

具有平凡的复制构造函数或非平凡的析构函数的对象无法按值传递,因为此类对象必须具有定义明确的地址。从函数返回对象时,也会遇到类似的问题。

请注意,只有2个通用寄存器可用于通过平凡的复制构造函数和平凡的析构函数传递1个对象,即,只能将sizeof不大于16个对象的值传递到寄存器中。有关调用约定的详细信息,请参阅Agner Fog的调用约定,尤其是第7.1节“传递和返回对象”。对于在寄存器中传递SIMD类型,有单独的调用约定。

其他CPU架构有不同的ABI。


  1. 为什么ABI这样?也就是说,如果结构/类的字段适合寄存器,甚至单个寄存器,为什么我们不能在该寄存器中传递它呢?

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

徒劳地,析构函数对对象进行操作

一个对象在其构造期间([class.cdtor]),其整个生命周期以及其破坏期间都占据一个存储区域。

如果没有为对象分配可寻址的存储,则该对象在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)在他的发人深省的讲话中提到,必须实施一项重大的ABI变更(除其他事项外),以实施可以改善事情的破坏性举动。IMO,如果使用新ABI的功能明确选择具有新的不同链接(例如,在extern "C++20" {}块中声明它们(可能在用于迁移现有API的新的内联命名空间中)声明),则ABI更改可能不会中断。这样,只有针对具有新链接的新函数声明编译的代码才能使用新的ABI。

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


评论不作进一步讨论;此对话已转移至聊天
塞缪尔·柳

8

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

(在@MaximEgorushkin的答案中使用@harold的示例说明了这一点;已根据@Yakk的注释进行了更正。)

如果编译:

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

你得到:

test(Foo):
        mov     eax, edi
        ret

也就是说,将Foo对象传递到test寄存器(edi)中,并返回到寄存器(eax)中。

当析构函数并非无关紧要时(std::unique_ptr例如OP的示例),常见的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

与无用的加载和存储。


我不相信这种说法。非平凡的析构函数不会禁止任何仿制规则。如果未遵守该地址,则绝对没有理由需要该地址。因此,如果这样做不会改变可观察到的行为,那么合格的编译器可以很乐意将其放入寄存器中(并且如果调用者是已知的,当前的编译器实际上也会这样做)。
ComicSansMS

1
不幸的是,这是另一回事(我同意其中有些已经超出了理智)。确切地说:我不相信您提供的原因必然会导致任何可能的ABI允许电流std::unique_ptr在寄存器中传递不合格。
ComicSansMS

3
“琐碎的破坏者[CITATION NEEDED]”显然是错误的;如果没有代码实际取决于该地址,则as-if表示该地址不需要在实际计算机上存在。该地址必须存在于抽象机中,但是抽象机中对实际计算机没有影响的事物似乎是允许消除的事物。
Yakk-Adam Nevraumont

2
@einpoklum在标准中,没有任何状态寄存器存在。register关键字仅指出“您不能使用该地址”。就标准而言,只有一台抽象机。“好像”意味着任何实际的机器实现都只需要“像”抽象机器一样地运行,直到标准未定义的行为为止。现在,关于在寄存器中放置一个对象存在着非常具有挑战性的问题,每个人都对此进行了广泛讨论。此外,该标准也未讨论的调用约定具有实际需求。
Yakk-Adam Nevraumont '19

1
@einpoklum不,在抽象机器中,所有东西都有地址;但是地址仅在某些情况下可见。该register关键字的目的是通过阻断东西,实际上使其更难“无地址”,在物理机,使其平凡的物理机到店的东西在寄存器中。
Yakk-Adam Nevraumont '19

2

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

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

为什么ABI这样?

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

内联以及由此产生的优化很好,但是ABI设计人员不能依靠它的发生。他们必须在假设最坏情况的情况下设计ABI。我认为程序员不会对ABI根据优化级别进行更改的编译器感到满意。

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

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

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

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

对我来说,显而易见的解决方案是在语言中添加适当的破坏性移动(而不是当前的“有效但未指定状态”的中途房屋),然后引入一种标记类型的方式,以允许“琐碎的破坏性移动” ”,即使它不允许进行琐碎的复制。

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


您能否详细说明如何进行适当的破坏性移动才能将unique_ptr传递到寄存器中?那是因为它将允许删除对可寻址存储的需求吗?
einpoklum

适当的破坏性动作将可以引入微不足道的破坏性动作的概念。这将使上述琐碎的举动可以由ABI拆分,就像今天的琐碎副本一样。
–plugwash

尽管您还希望添加一条规则,使编译器可以将参数传递作为常规移动或复制,然后执行“琐碎的破坏性移动”,以确保无论参数来自何处,始终可以传递寄存器。
–plugwash

因为寄存器的大小可以容纳一个指针,但是unique_ptr结构呢?sizeof(unique_ptr <T>)是多少?
梅尔·维索·马丁内斯

@MelVisoMartinez您可能会感到困惑unique_ptrshared_ptr语义:shared_ptr<T>让您向ctor提供1)一个ptr x到派生对象U,该对象要使用静态类型U w /表达式删除delete x;(因此这里不需要虚拟dtor)2)或甚至自定义清理功能。这意味着在shared_ptr控制块内部使用运行时状态对该信息进行编码。OTOH unique_ptr没有这种功能,并且不对状态下的删除行为进行编码;定制清除的唯一方法是创建另一个模板实例化(另一个类类型)。
curiousguy19年

-1

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

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

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

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

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

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

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

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

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

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

对于类类型,对象值的唯一概念是该对象的另一个副本,该副本只能由副本构造函数(一个实函数)创建(尽管对于琐碎类型,该函数是如此特别琐碎,有时它们可​​以是创建而无需调用构造函数)。这意味着对象的值是通过执行更改全局程序状态的结果。它在数学上无法访问。

因此,按值传递确实不是一回事:它是通过拷贝构造函数call传递的,它不太漂亮。复制构造函数应根据对象类型的正确语义执行合理的“复制”操作,同时要尊重其内部不变式(即抽象用户属性,而不是固有的C ++属性)。

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

  • 创建另一个实例
  • 然后使被调用的函数对该实例起作用。

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

问题是:

  • 副本是一个对象,使用标量标量初始化了原始对象的纯数学值(真正的纯rvalue);
  • 副本是原始对象(与类一样)的值

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

类对象必须由调用方构造。构造函数形式上有一个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
您的第一个示例似乎具有误导性。我认为您只是在提出一个要点,但起初我认为您是在对问题代码进行类比。但是按值void foo(unique_ptr<int> ptr)获取类对象。该对象具有一个指针成员,但是我们正在谈论的是类对象本身是通过引用传递的。(因为它不是平凡的可复制的,所以它的构造函数/析构函数需要一个一致的。)这是真实的参数,与显式传递引用的第一个示例无关;在这种情况下,指针将传递到寄存器中。this
彼得·科德斯

@PeterCordes“ 您正在对问题中的代码进行类比。 ”我正是这样做的。“ 按值分类的对象 ”是的,我可能应该解释一下,通常不存在类对象的“值”之类的东西,因此非数学类型的按值不是“按值”。“ 该对象具有一个指针成员 ”“智能ptr”的ptr类性质无关紧要;“智能ptr”的ptr成员也是如此。一个PTR就像一个标量int:我写了一个“ smart fileno”示例,它说明“所有权”与“携带ptr”无关。
curiousguy19年

1
类对象的值是其对象表示形式。对于unique_ptr<T*>,它的大小和布局T*与寄存器相同。像大多数调用约定一样,可以通过x86-64 System V中的寄存器中的值传递琐碎可复制的类对象。这将创建对象的副本,这unique_ptr与您的int示例中被调用方&i 调用方的地址不同,i因为您在C ++级别通过引用传递了该对象,而不仅仅是asm实现细节。
彼得·科德斯

1
错误,请更正我的最后评论。这不只是做一个副本中的unique_ptr对象; 它正在使用,std::move因此可以安全地复制它,因为这不会导致2个副本相同unique_ptr。但是对于普通复制类型,是的,它确实复制了整个聚合对象。如果是单个成员,则良好的调用约定将其与该类型的标量相同。
彼得·科德斯

1
看起来更好。注意:对于编译为C ++的C结构 -这不是介绍C ++之间差异的有用方法。在C ++中,struct{}是C ++结构。也许您应该说“普通结构”或“不像C”。因为是的,所以有所不同。如果atomic_int用作结构成员,C将以非原子方式复制它,在已删除的复制构造函数上C ++错误。我忘记了C ++对具有volatile成员的结构的作用。C将使您struct tmp = volatile_struct;能够复制整个内容(对于SeqLock有用);C ++不会。
彼得·科德斯
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.