c++ assembly unique-ptr calling-convention abi

c++ - ¿Por qué se puede pasar una T*en el registro, pero no se puede un unique_ptr<T>?



assembly unique-ptr (3)

Estoy viendo la charla de Chandler Carruth en CppCon 2019:

No hay abstracciones de costo cero

en él, da el ejemplo de cómo se sorprendió por la cantidad de sobrecarga en la que incurre al usar un std::unique_ptr<int> sobre un int* ; ese segmento comienza aproximadamente en el punto de tiempo 17:25.

Puede echar un vistazo a los resultados de la compilación de su par de fragmentos de ejemplo (godbolt.org), para observar que, de hecho, parece que el compilador no está dispuesto a pasar el valor unique_ptr, que en realidad es el resultado final. solo una dirección, dentro de un registro, solo en memoria directa.

Uno de los puntos que el Sr. Carruth hace alrededor de las 27:00 es que el ABI de C ++ requiere que los parámetros por valor (algunos, pero no todos; tal vez: ¿tipos no primitivos? ¿Tipos no construibles trivialmente?) Se pasen en la memoria en lugar de dentro de un registro.

Mis preguntas:

  1. ¿Es esto realmente un requisito de ABI en algunas plataformas? (¿Cuál?) ¿O tal vez es solo un poco de pesimismo en ciertos escenarios?
  2. ¿Por qué es así el ABI? Es decir, si los campos de una estructura / clase se ajustan a los registros, o incluso a un único registro, ¿por qué no deberíamos poder pasarlo dentro de ese registro?
  3. ¿El comité de estándares de C ++ ha discutido este punto en los últimos años, o alguna vez?

PD: para no dejar esta pregunta sin código:

Puntero liso:

void bar(int* ptr) noexcept; void baz(int* ptr) noexcept; void foo(int* ptr) noexcept { if (*ptr > 42) { bar(ptr); *ptr = 42; } baz(ptr); }

Puntero único:

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. ¿Es esto realmente un requisito de ABI, o tal vez es solo una pesimización en ciertos escenarios?

Un ejemplo es el Suplemento del procesador de arquitectura AMD64 de la interfaz binaria de la aplicación System V. Este ABI es para CPU de 64 bits compatibles con x86 (Linux x86_64 architecure). Se sigue en Solaris, Linux, FreeBSD, macOS, Windows Subsystem para Linux:

Si un objeto C ++ tiene un constructor de copia no trivial o un destructor no trivial, se pasa por referencia invisible (el objeto se reemplaza en la lista de parámetros por un puntero que tiene la clase INTEGER).

Un objeto con un constructor de copia no trivial o un destructor no trivial no se puede pasar por valor porque dichos objetos deben tener direcciones bien definidas. Se aplican problemas similares al devolver un objeto desde una función.

Tenga en cuenta que solo se pueden usar 2 registros de propósito general para pasar 1 objeto con un constructor de copia trivial y un destructor trivial, es decir, solo se pueden pasar valores de objetos con un sizeof no mayor de 16 en los registros. Consulte Convenciones de llamadas de Agner Fog para obtener un tratamiento detallado de las convenciones de llamadas, en particular §7.1 Pasar y devolver objetos. Existen convenciones de llamada separadas para pasar tipos SIMD en registros.

Existen diferentes ABI para otras arquitecturas de CPU.

  1. ¿Por qué es así el ABI? Es decir, si los campos de una estructura / clase se ajustan a los registros, o incluso a un único registro, ¿por qué no deberíamos poder pasarlo dentro de ese registro?

Es un detalle de implementación, pero cuando se maneja una excepción, durante el desbobinado de la pila, los objetos con la duración del almacenamiento automático que se destruye deben ser direccionables en relación con el marco de la pila de funciones porque los registros se han bloqueado en ese momento. El código de desenrollado de pila necesita las direcciones de los objetos para invocar sus destructores, pero los objetos en los registros no tienen una dirección.

Pendientemente, los destructores operan en objetos :

Un objeto ocupa una región de almacenamiento en su período de construcción ([class.cdtor]), a lo largo de su vida útil y en su período de destrucción.

y un objeto no puede existir en C ++ si no se le asigna un almacenamiento direccionable porque la identidad del objeto es su dirección .

Cuando se necesita una dirección de un objeto con un constructor de copia trivial guardado en registros, el compilador puede almacenar el objeto en la memoria y obtener la dirección. Si el constructor de la copia no es trivial, por otro lado, el compilador no puede simplemente almacenarlo en la memoria, sino que necesita llamar al constructor de la copia que toma una referencia y, por lo tanto, requiere la dirección del objeto en los registros. La convención de llamada probablemente no puede depender de si el constructor de la copia fue incorporado en la llamada o no.

Otra forma de pensar en esto es que, para los tipos que se pueden copiar trivialmente, el compilador transfiere el valor de un objeto en registros, desde el cual un objeto puede recuperarse mediante almacenes de memoria simple si es necesario. P.ej:

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

en x86_64 con System V ABI compila en:

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.

En su charla estimulante, Chandler Carruth mentions que puede ser necesario un cambio abrupto de ABI (entre otras cosas) para implementar el movimiento destructivo que podría mejorar las cosas. En mi opinión, el cambio de ABI podría no interrumpirse si las funciones que utilizan el nuevo ABI se suscriben explícitamente para tener un nuevo enlace diferente, por ejemplo, declararlos en el bloque extern "C++20" {} (posiblemente, en un nuevo espacio de nombres en línea para migrar API existentes). Para que solo el código compilado contra las nuevas declaraciones de función con el nuevo enlace pueda usar el nuevo ABI.

Tenga en cuenta que ABI no se aplica cuando la función llamada ha sido incorporada. Además de la generación de código de tiempo de enlace, el compilador puede incorporar funciones definidas en otras unidades de traducción o utilizar convenciones de llamada personalizadas.


¿Es esto realmente un requisito de ABI en algunas plataformas? (¿Cuál?) ¿O tal vez es solo un poco de pesimismo en ciertos escenarios?

Si algo es visible en el límite de la unidad de cumplimiento, entonces si se define implícita o explícitamente se convierte en parte de la ABI.

¿Por qué es así el ABI?

El problema fundamental es que los registros se guardan y restauran todo el tiempo a medida que avanza hacia abajo y hacia arriba en la pila de llamadas. Por lo tanto, no es práctico tener una referencia o puntero a ellos.

La alineación y las optimizaciones que resultan de ella es agradable cuando sucede, pero un diseñador de ABI no puede confiar en que suceda. Tienen que diseñar el ABI asumiendo el peor de los casos. No creo que los programadores estén muy contentos con un compilador donde el ABI cambió dependiendo del nivel de optimización.

Se puede pasar un tipo trivialmente copiable en los registros porque la operación de copia lógica se puede dividir en dos partes. Los parámetros se copian en los registros utilizados para pasar los parámetros por la persona que llama y luego se copia a la variable local por la persona que llama. Si la variable local tiene una ubicación de memoria o no es, por lo tanto, solo la preocupación de la persona que llama.

Por otro lado, un tipo en el que se debe usar un constructor de copia o movimiento no puede dividir su operación de copia de esta manera, por lo que debe pasarse en la memoria.

¿El comité de estándares de C ++ ha discutido este punto en los últimos años, o alguna vez?

No tengo idea si los organismos de normalización han considerado esto.

La solución obvia para mí sería agregar movimientos destructivos adecuados (en lugar de la casa a mitad de camino actual de un "estado válido pero no especificado") al lenguaje, luego introducir una forma de marcar un tipo que permita "movimientos destructivos triviales "incluso si no permite copias triviales.

pero tal solución DEBERÍA romper el ABI del código existente para implementar los tipos existentes, lo que puede traer una buena resistencia (aunque el ABI se rompe como resultado de las nuevas versiones estándar de C ++ no tienen precedentes, por ejemplo, los cambios std :: string en C ++ 11 resultó en una ruptura de ABI.


Con ABI comunes, el destructor no trivial -> no puede pasar registros

(Una ilustración de un punto en la respuesta de @ MaximEgorushkin usando el ejemplo de @ harold en un comentario; corregido según el comentario de @ Yakk).

Si compilas:

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

usted obtiene:

test(Foo): mov eax, edi ret

es decir, el objeto Foo se pasa a test en un registro ( edi ) y también se devuelve en un registro ( eax ).

Cuando el destructor no es trivial (como el ejemplo std::unique_ptr de OP): los ABI comunes requieren la colocación en la pila. Esto es cierto incluso si el destructor no utiliza la dirección del objeto en absoluto.

Por lo tanto, incluso en el caso extremo de un destructor de no hacer nada, si compila:

struct Foo2 { int bar; ~Foo2() { } }; Foo2 test(Foo2 byval) { return byval; }

usted obtiene:

test(Foo2): mov edx, DWORD PTR [rsi] mov rax, rdi mov DWORD PTR [rdi], edx ret

con carga y almacenamiento inútiles.