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:
- Si el tamaño de un objeto es mayor que ocho bytes, o contiene campos no alineados, tiene la clase MEMORY 12.
- 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.
- 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.
- 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.