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


85

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 gastos generales en los que incurres al usar un std::unique_ptr<int>over an 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? 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 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));
}

8
No estoy seguro de cuál es exactamente el requisito de ABI, pero no prohíbe poner estructuras en los registros
harold

66
Si tuviera que adivinar, diría que tiene que ver con funciones miembro no triviales que necesitan un thispuntero que apunta a una ubicación válida. unique_ptrtiene esos. Derramar el registro para ese propósito negaría un poco toda la optimización de "pasar en un registro".
StoryTeller - Unslander Monica

2
itanium-cxx-abi.github.io/cxx-abi/abi.html#calls . Por lo tanto, este comportamiento requerido. ¿Por qué? itanium-cxx-abi.github.io/cxx-abi/cxx-closed.html , busque el problema C-7. Hay alguna explicación allí, pero no es demasiado detallada. Pero sí, este comportamiento no me parece lógico. Estos objetos podrían pasar a través de la pila normalmente. Empujarlos para apilar, y luego pasar la referencia (solo para objetos "no triviales") parece un desperdicio.
geza

66
Parece que C ++ está violando sus propios principios aquí, lo cual es bastante triste. Estaba 140% convencido de que un unique_ptr simplemente desaparece después de la compilación. Después de todo, es solo una llamada de destructor diferido que se conoce en tiempo de compilación.
One Man Monkey Squad

77
@MaximEgorushkin: Si lo hubiera escrito a mano, habría puesto el puntero en un registro y no en la pila.
einpoklum

Respuestas:


49
  1. ¿Es esto realmente un requisito de ABI, o tal vez es solo una pesimización en ciertos escenarios?

Un ejemplo es System V Aplicación Binary Interface AMD64 Arquitectura Suplemento procesador . 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 sizeofse pueden pasar valores de objetos con no más de 16 en los registros. Consulte Convenciones de llamadas de Agner Fog para un tratamiento detallado de las convenciones de llamadas, en particular §7.1 Pasar y devolver objetos. Existen convenciones de llamadas separadas para pasar tipos SIMD en los 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 menciona 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 optan explícitamente por tener un nuevo enlace diferente, por ejemplo, declararlas en extern "C++20" {}bloque (posiblemente, en un nuevo espacio de nombres en línea para migrar las 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.


Los comentarios no son para discusión extendida; Esta conversación se ha movido al chat .
Samuel Liew

8

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 Fooobjeto se pasa a testun registro ( edi) y también se devuelve en un registro ( eax).

Cuando el destructor no es trivial (como el std::unique_ptrejemplo de los 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.


No estoy convencido por este argumento. El destructor no trivial no hace nada para prohibir la regla as-if. Si no se observa la dirección, no hay absolutamente ninguna razón por la que deba existir. Por lo tanto, un compilador conforme podría ponerlo felizmente en un registro, si hacerlo no cambia el comportamiento observable (y los compiladores actuales lo harán si se conocen los llamadores ).
ComicSansMS

1
Desafortunadamente, es al revés (estoy de acuerdo en que algo de esto ya está fuera de razón). Para ser precisos: no estoy convencido de que las razones que proporcionó necesariamente harían que cualquier ABI concebible permitiera pasar la corriente std::unique_ptren un registro no conforme.
ComicSansMS

3
"destructor trivial [CITA NECESARIA]" claramente falso; si ningún código realmente depende de la dirección, entonces como si significa que la dirección no necesita existir en la máquina real . La dirección debe existir en la máquina abstracta , pero las cosas en la máquina abstracta que no tienen impacto en la máquina real son cosas que se pueden eliminar.
Yakk - Adam Nevraumont

2
@einpoklum No hay nada en el estándar de que existan registros de estados. La palabra clave de registro solo dice "no puede tomar la dirección". Solo hay una máquina abstracta en lo que respecta al estándar. "como si" significa que cualquier implementación de máquina real solo necesita comportarse "como si" la máquina abstracta se comportara, hasta un comportamiento indefinido por el estándar. Ahora, hay problemas muy desafiantes en torno a tener un objeto en un registro, del que todos han hablado extensamente. Además, las convenciones de llamadas, que el estándar tampoco trata, tienen necesidades prácticas.
Yakk - Adam Nevraumont

1
@einpoklum No, en esa máquina abstracta, todas las cosas tienen direcciones; pero las direcciones solo son observables en ciertas circunstancias. La registerpalabra clave tenía la intención de hacer trivial que la máquina física almacenara algo en un registro al bloquear cosas que prácticamente dificultan "no tener dirección" en la máquina física.
Yakk - Adam Nevraumont

2

¿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 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.


¿Puede explicar cómo los movimientos destructivos apropiados permitirían pasar un unique_ptr en un registro? ¿Sería eso porque permitiría eliminar el requisito de almacenamiento direccionable?
einpoklum

Los movimientos destructivos adecuados permitirían introducir un concepto de movimientos destructivos triviales. Esto permitiría que dicho movimiento trivial sea dividido por el ABI de la misma manera que las copias triviales pueden ser hoy.
plugwash

Aunque también desearía agregar una regla de que un compilador podría implementar un paso de parámetro como un movimiento o copia regular seguido de un "movimiento destructivo trivial" para garantizar que siempre sea posible pasar registros sin importar de dónde provenga el parámetro.
plugwash

¿Porque el tamaño del registro puede contener un puntero, pero una estructura unique_ptr? ¿Cuál es sizeof (unique_ptr <T>)?
Mel Viso Martinez

@MelVisoMartinez Puede ser confuso unique_ptry shared_ptrsemántico: le shared_ptr<T>permite proporcionar al ctor 1) un ptr x al objeto derivado U que se eliminará con el tipo estático U con la expresión delete x;(por lo que no necesita un dtor virtual aquí) 2) o Incluso una función de limpieza personalizada. Eso significa que el estado de tiempo de ejecución se usa dentro del shared_ptrbloque de control para codificar esa información. OTOH unique_ptrno tiene dicha funcionalidad y no codifica el comportamiento de eliminación en estado; la única forma de personalizar la limpieza es crear otra instancia de plantilla (otro tipo de clase).
curioso

-1

Primero necesitamos volver a lo que significa pasar por valor y por referencia.

Para lenguajes como Java y SML, el paso por valor es sencillo (y no hay paso por referencia), al igual que copiar un valor variable, ya que todas las variables son solo escalares y tienen una copia semántica incorporada: son lo que cuentan como aritmética escriba C ++ o "referencias" (punteros con diferentes nombres y sintaxis).

En C tenemos tipos escalares y definidos por el usuario:

  • Los escalares tienen un valor numérico o abstracto (los punteros no son números, tienen un valor abstracto) que se copia.
  • Los tipos agregados tienen todos sus miembros posiblemente inicializados copiados:
    • para tipos de productos (matrices y estructuras): de forma recursiva, todos los miembros de estructuras y elementos de matrices se copian (la sintaxis de la función C no permite pasar matrices por valor directamente, solo las matrices miembros de una estructura, pero eso es un detalle )
    • para tipos de suma (uniones): se conserva el valor del "miembro activo"; obviamente, la copia miembro por miembro no está en orden, ya que no todos los miembros pueden inicializarse.

En C ++, los tipos definidos por el usuario pueden tener una semántica de copia definida por el usuario, que permite una programación verdaderamente "orientada a objetos" con objetos con propiedad de sus recursos y operaciones de "copia profunda". En tal caso, una operación de copia es realmente una llamada a una función que casi puede realizar operaciones arbitrarias.

Para estructuras C compiladas como C ++, "copiar" todavía se define como llamar a la operación de copia definida por el usuario (ya sea constructor u operador de asignación), que el compilador genera implícitamente. Significa que la semántica de un programa de subconjunto común de C / C ++ es diferente en C y C ++: en C se copia un tipo de agregado completo, en C ++ se llama a una función de copia generada implícitamente para copiar cada miembro; El resultado final es que en cualquier caso se copia cada miembro.

(Creo que hay una excepción cuando se copia una estructura dentro de una unión).

Entonces, para un tipo de clase, la única forma (fuera de las copias de la unión) para hacer una nueva instancia es a través de un constructor (incluso para aquellos con constructores triviales generados por el compilador).

No puede tomar la dirección de un valor r mediante un operador unario, &pero eso no significa que no haya un objeto rvalue; y un objeto, por definición, tiene una dirección ; y esa dirección incluso está representada por una construcción de sintaxis: un objeto de tipo de clase solo puede ser creado por un constructor, y tiene un thispuntero; pero para los tipos triviales, no hay un constructor escrito por el usuario, por lo que no hay lugar para colocar thishasta después de que se construye y se nombra la copia.

Para el tipo escalar, el valor de un objeto es el valor r del objeto, el valor matemático puro almacenado en el objeto.

Para un tipo de clase, la única noción de un valor del objeto es otra copia del objeto, que solo puede ser realizada por un constructor de copia, una función real (aunque para tipos triviales esa función es especialmente trivial, a veces puede ser creado sin llamar al constructor). Eso significa que el valor del objeto es el resultado del cambio del estado global del programa por una ejecución . No accede matemáticamente.

Por lo tanto, pasar por valor realmente no es una cosa: es pasar por llamada de constructor de copia , que es menos bonita. Se espera que el constructor de copia realice una operación de "copia" sensata de acuerdo con la semántica adecuada del tipo de objeto, respetando sus invariantes internos (que son propiedades abstractas del usuario, no propiedades intrínsecas de C ++).

Pasar por valor de un objeto de clase significa:

  • crear otra instancia
  • luego haga que la función llamada actúe en esa instancia.

Tenga en cuenta que el problema no tiene nada que ver con si la copia en sí es un objeto con una dirección: todos los parámetros de función son objetos y tienen una dirección (en el nivel semántico del lenguaje).

El problema es si:

  • la copia es un nuevo objeto inicializado con el valor matemático puro (verdadero valor puro) del objeto original, como con los escalares;
  • o la copia es el valor del objeto original , como con las clases.

En el caso de un tipo de clase trivial, aún puede definir el miembro de la copia miembro del original, por lo que puede definir el valor puro del original debido a la trivialidad de las operaciones de copia (constructor de copia y asignación). No es así con funciones de usuario especiales arbitrarias: un valor del original tiene que ser una copia construida.

Los objetos de clase deben ser construidos por la persona que llama; un constructor formalmente tiene un thispuntero, pero el formalismo no es relevante aquí: todos los objetos tienen formalmente una dirección, pero solo aquellos que realmente usan su dirección de manera no puramente local (a diferencia de lo *&i = 1;que es el uso puramente local de la dirección) deben tener un bien definido habla a.

Un objeto debe pasar absolutamente por dirección si parece tener una dirección en estas dos funciones compiladas por separado:

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

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

Aquí, incluso si se something(address)trata de una función pura o macro o lo que sea (como printf("%p",arg)) que no puede almacenar la dirección o comunicarse con otra entidad, tenemos el requisito de pasar por dirección porque la dirección debe estar bien definida para un objeto único intque tiene un único identidad.

No sabemos si una función externa será "pura" en términos de direcciones que se le pasen.

Aquí, el potencial para un uso real de la dirección en un constructor o destructor no trivial en el lado de la persona que llama es probablemente la razón para tomar la ruta segura y simplista y darle al objeto una identidad en la persona que llama y pasar su dirección, ya que hace asegúrese de que cualquier uso no trivial de su dirección en el constructor, después de la construcción y en el destructor sea consistente : thisdebe parecer ser el mismo sobre la existencia del objeto.

Un constructor o destructor no trivial como cualquier otra función puede usar el thispuntero de una manera que requiera consistencia sobre su valor a pesar de que algún objeto con cosas no triviales no:

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);
};

Tenga en cuenta que en ese caso, a pesar del uso explícito de un puntero (sintaxis explícita this->), la identidad del objeto es irrelevante: el compilador bien podría usar copiar bit a bit el objeto para moverlo y hacer "copiar elisión". Esto se basa en el nivel de "pureza" del uso de thisfunciones miembro especiales (la dirección no se escapa).

Pero la pureza no es un atributo disponible en el nivel de declaración estándar (existen extensiones del compilador que agregan una descripción de pureza en la declaración de función no en línea), por lo que no puede definir un ABI basado en la pureza del código que puede no estar disponible (el código puede o puede no estar en línea y disponible para análisis).

La pureza se mide como "ciertamente pura" o "impura o desconocida". El terreno común, o límite superior de la semántica (en realidad el máximo), o LCM (mínimo común múltiplo) es "desconocido". Entonces el ABI se decide por lo desconocido.

Resumen:

  • Algunas construcciones requieren que el compilador defina la identidad del objeto.
  • El ABI se define en términos de clases de programas y no en casos específicos que podrían optimizarse.

Posible trabajo futuro:

¿Es la anotación de pureza lo suficientemente útil como para ser generalizada y estandarizada?


1
Su primer ejemplo parece engañoso. Creo que solo estás haciendo un punto en general, pero al principio pensé que estabas haciendo una analogía con el código de la pregunta. Pero void foo(unique_ptr<int> ptr)toma el objeto de clase por valor . Ese objeto tiene un miembro puntero, pero estamos hablando de que el objeto de clase se pasa por referencia. (Debido a que no es trivialmente copiable, su constructor / destructor necesita un coherente this). Ese es el argumento real y no está conectado al primer ejemplo de pasar por referencia explícitamente ; en ese caso, el puntero se pasa en un registro.
Peter Cordes el

@PeterCordes " estabas haciendo una analogía con el código en la pregunta " . Hice exactamente eso. " el objeto de clase por valor " Sí, probablemente debería explicar que, en general, no existe el "valor" de un objeto de clase, por lo que por valor para un tipo no matemático no es "por valor". " Ese objeto tiene un miembro puntero " La naturaleza de ptr de un "ptr inteligente" es irrelevante; y también lo es el miembro ptr del "ptr inteligente". Un ptr es simplemente un escalar como un int: escribí un ejemplo de "archivo inteligente" que ilustra que "propiedad" no tiene nada que ver con "llevar un ptr".
curioso

1
El valor de un objeto de clase es su representación de objeto. Para unique_ptr<T*>, este es el mismo tamaño y diseño que T*cabe en un registro. Los objetos de clase que se pueden copiar trivialmente se pueden pasar por valor en registros en x86-64 System V, como la mayoría de las convenciones de llamada. Esto hace una copia del unique_ptrobjeto, a diferencia de su intejemplo donde la persona que llama &i es la dirección de la persona que llama iporque pasó por referencia en el nivel de C ++ , no solo como un detalle de implementación de asm.
Peter Cordes el

1
Err, corrección a mi último comentario. No es solo hacer una copia del unique_ptrobjeto; está utilizando, std::movepor lo que es seguro copiarlo porque eso no dará como resultado 2 copias de la misma unique_ptr. Pero para un tipo que se puede copiar trivialmente, sí, copia todo el objeto agregado. Si se trata de un solo miembro, las convenciones de llamadas buenas lo tratan igual que un escalar de ese tipo.
Peter Cordes el

1
Se ve mejor. Notas: Para estructuras de C compiladas como C ++ : esta no es una forma útil de introducir la diferencia entre C ++. En C ++ struct{}es una estructura C ++. Quizás deberías decir "estructuras simples" o "a diferencia de C". Porque sí, hay una diferencia. Si lo utiliza atomic_intcomo miembro de estructura, C lo copiará de forma no atómica, error de C ++ en el constructor de copia eliminado. Olvidé lo que C ++ hace en estructuras con volatilemiembros. C le permitirá hacer la struct tmp = volatile_struct;copia completa (útil para un SeqLock); C ++ no lo hará.
Peter Cordes
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.