c++ gcc clang calling-convention stdtuple

c++ - ¿Devolver un 2-tuple es menos eficiente que std:: pair?



gcc clang (1)

Considere este código:

#include <utility> #include <tuple> std::pair<int, int> f1() { return std::make_pair(0x111, 0x222); } std::tuple<int, int> f2() { return std::make_tuple(0x111, 0x222); }

Clang 3 y 4 generan código similar para ambos en x86-64:

f1(): movabs rax,0x22200000111 ret f2(): movabs rax,0x11100000222 ; opposite packing order, not important ret

Pero Clang 5 genera un código diferente para f2() :

f2(): movabs rax,0x11100000222 mov QWORD PTR [rdi],rax mov rax,rdi ret

Al igual que GCC 4 a través de GCC 7:

f2(): movabs rdx,0x11100000222 mov rax,rdi mov QWORD PTR [rdi],rdx ; GCC 4-6 use 2 DWORD stores ret

¿Por qué el código generado es peor cuando se devuelve un std::tuple que cabe en un solo registro, vs std::pair ? Parece especialmente extraño, ya que Clang 3 y 4 parecían ser óptimos, pero 5 no lo es.

Pruébelo aquí: https://godbolt.org/g/T2Yqrj


La respuesta corta es porque la implementación de la biblioteca estándar libstc++ utilizada por gcc y clang en Linux implementa std::tuple con un constructor de movimientos no trivial (en particular, la clase base _Tuple_impl tiene un constructor de movimientos no trivial). Por otro lado, los constructores de copiar y mover para std::pair están predeterminados.

Los detalles sangrientos

Ejecutó sus pruebas en Linux, que se adhiere a la ABI SysV x86-64. Este ABI tiene reglas específicas para pasar o devolver clases o estructuras a funciones, sobre las que puede leer más here . El caso específico en el que estamos interesados ​​es si los dos campos int en estas estructuras obtendrán la clase INTEGER o la clase MEMORY .

Una versión recent de la especificación ABI tiene esto que decir:

La clasificación de agregados (estructuras y matrices) y tipos de unión funciona de la siguiente manera:

  1. Si el tamaño de un objeto es mayor que ocho bytes, o contiene campos no alineados, tiene la clase MEMORY 12.
  2. Si un objeto C ++ tiene un constructor de copia no trivial o un destructor no trivial 13, se pasa por referencia invisible (el objeto se reemplaza en la lista de parámetros por un puntero que tiene la clase INTEGER) 14.
  3. Si el tamaño del agregado excede un solo ocho bytes, cada uno se clasifica por separado. Cada ocho bytes se inicializa en la clase NO_CLASS.
  4. Cada campo de un objeto se clasifica recursivamente, de modo que siempre se consideran dos campos. La clase resultante se calcula de acuerdo con las clases de los campos en los ocho bytes.

Es la condición (2) que se aplica aquí. Tenga en cuenta que menciona solo los constructores de copia y no los constructores de movimiento , pero es bastante aparente que probablemente solo sea un defecto en la especificación dada la introducción de constructores de movimiento que generalmente deben incluirse en cualquier algoritmo de clasificación donde se incluyeron los constructores de copia antes . En particular, IA-64 cxx-abi, que se ha documentado que gcc sigue , incluye constructores de movimientos :

Si el tipo de parámetro no es trivial para los fines de las llamadas, la persona que llama debe asignar espacio para un temporal y pasar ese temporal por referencia. Específicamente:

  • La persona que llama asigna el espacio de la manera habitual para un temporal, generalmente en la pila.

y luego la definition de no trivial:

Un tipo se considera no trivial para los fines de las llamadas si:

  • tiene un constructor de copia no trivial, constructor de movimiento o destructor, o
  • Se eliminan todos sus constructores de copiar y mover.

Por lo tanto, dado que no se considera que la tuple se puede copiar de forma trivial desde una perspectiva ABI, recibe un tratamiento de MEMORY , lo que significa que su función debe poblar el objeto asignado a la pila que pasa el llamado en rdi . La función std::par puede devolver toda la estructura en rax ya que cabe en un EIGHTBYTE y tiene la clase INTEGER .

¿Importa? Sí, estrictamente hablando, una función independiente como la que ha compilado será menos eficiente para la tuple ya que este ABI diferente está "integrado".

Sin embargo, a menudo, el compilador podrá ver el cuerpo de la función y en línea o realizar un análisis entre procedimientos incluso si no está en línea. En ambos casos, el ABI ya no es importante y es probable que ambos enfoques sean igualmente eficientes, al menos con un optimizador decente. Por ejemplo , llamemos a las funciones f1() y f2() y hagamos algunos cálculos con el resultado :

int add_pair() { auto p = f1(); return p.first + p.second; } int add_tuple() { auto t = f2(); return std::get<0>(t) + std::get<1>(t); }

En principio, el método add_tuple comienza desde una desventaja, ya que tiene que llamar a f2() que es menos eficiente y también tiene que crear un objeto de tupla temporal en la pila para que pueda pasarlo a f2 como parámetro oculto. Bueno, no importa, ambas funciones están totalmente optimizadas para devolver directamente el valor correcto:

add_pair(): mov eax, 819 ret add_tuple(): mov eax, 819 ret

Entonces, en general, puede decir que el efecto de este problema de ABI con la tuple será relativamente mudo: agrega una pequeña sobrecarga fija a las funciones que deben cumplir con el ABI, pero esto solo importará en un sentido relativo para funciones muy pequeñas, pero Es probable que dichas funciones se declaren en un lugar donde puedan estar en línea (o, si no, se está dejando el rendimiento en la tabla).

libcstc ++ vs libc +++

Como se explicó anteriormente, este es un problema de ABI, no un problema de optimización, per se. Tanto clang como gcc ya están optimizando el código de la biblioteca en la medida de lo posible bajo las restricciones de ABI; si generaran código como f1() para el caso std::tuple , romperían a los llamadores compatibles con ABI.

Puede ver esto claramente si cambia a usar libc++ lugar de la opción predeterminada de libstdc++ Linux: esta implementación no tiene el constructor de movimientos explícitos (como menciona Marc Glisse en los comentarios, se quedan estancados con esta implementación por compatibilidad hacia atrás). Ahora clang (y presumiblemente gcc aunque no lo probé), genera el mismo código óptimo en ambos casos:

f1(): # @f1() movabs rax, 2345052143889 ret f2(): # @f2() movabs rax, 2345052143889 ret

Versiones anteriores de Clang

¿Por qué las versiones de clang compilan de manera diferente? Era simplemente un error en clang o un error en la especificación, dependiendo de cómo se mire. La especificación no incluía explícitamente la construcción de movimientos en los casos en que era necesario pasar un puntero oculto a un temporal. no estaba conforme con el IA-64 C ++ ABI. Por ejemplo, compilado la forma en que Clang solía hacerlo no era compatible con gcc o versiones más nuevas de clang . La especificación finalmente se actualizó y el comportamiento del clang cambió en la versión 5.0 .

Actualización: Marc Glisse mentions en los comentarios que inicialmente hubo confusión sobre la interacción de los constructores de movimientos no triviales y el C ++ ABI, y clang cambió su comportamiento en algún momento, lo que probablemente explica el cambio:

La especificación de ABI para algunos casos de aprobación de argumentos que involucraban constructores de movimientos no estaba clara, y cuando se aclararon, se modificó el sonido para seguir el ABI. Este es probablemente uno de esos casos.