c++ - sharp - Eigen: efecto del estilo de codificación en el rendimiento
tutoriales de programacion c (4)
Por lo que he leído sobre Eigen ( here ), parece que operator=()
actúa como una "barrera" de géneros para la evaluación perezosa, por ejemplo, hace que Eigen deje de devolver plantillas de expresión y realmente realice el cálculo (optimizado). almacenando el resultado en el lado izquierdo de =
.
Esto parece significar que el "estilo de codificación" tiene un impacto en el rendimiento, es decir, el uso de variables con nombre para almacenar el resultado de cálculos intermedios podría tener un efecto negativo en el rendimiento al hacer que algunas partes del cálculo sean evaluadas "demasiado pronto" .
Para tratar de verificar mi intuición, escribí un ejemplo y me sorprendieron los resultados ( código completo aquí ):
using ArrayXf = Eigen::Array <float, Eigen::Dynamic, Eigen::Dynamic>;
using ArrayXcf = Eigen::Array <std::complex<float>, Eigen::Dynamic, Eigen::Dynamic>;
float test1( const MatrixXcf & mat )
{
ArrayXcf arr = mat.array();
ArrayXcf conj = arr.conjugate();
ArrayXcf magc = arr * conj;
ArrayXf mag = magc.real();
return mag.sum();
}
float test2( const MatrixXcf & mat )
{
return ( mat.array() * mat.array().conjugate() ).real().sum();
}
float test3( const MatrixXcf & mat )
{
ArrayXcf magc = ( mat.array() * mat.array().conjugate() );
ArrayXf mag = magc.real();
return mag.sum();
}
Lo anterior proporciona 3 formas diferentes de calcular la suma de magnitudes a nivel de coeficiente en una matriz de valores complejos.
-
test1
tipo de toma cada parte del cálculo "paso a paso". -
test2
hace todo el cálculo en una expresión. -
test3
adopta un enfoque "combinado", con una cierta cantidad de variables intermedias.
En cierto modo esperaba que, dado que test2
incluye todo el cálculo en una expresión, Eigen podría aprovechar eso y optimizar globalmente todo el cálculo, proporcionando el mejor rendimiento.
Sin embargo, los resultados fueron sorprendentes (los números se muestran en microsegundos totales en 1000 ejecuciones de cada prueba):
test1_us: 154994
test2_us: 365231
test3_us: 36613
(Esto fue compilado con g ++ -O3 - vea la esencia para más detalles).
La versión que esperaba ser más rápida ( test2
) fue en realidad la más lenta. Además, la versión que esperaba ser la más lenta ( test1
) estaba en el medio.
Entonces, mis preguntas son:
- ¿Por qué
test3
funciona mucho mejor que las alternativas? - ¿Existe alguna técnica que se pueda usar (salvo bucear en el código ensamblador) para obtener cierta visibilidad de cómo Eigen está realmente implementando sus cálculos?
- ¿Existe un conjunto de pautas a seguir para lograr un buen equilibrio entre el rendimiento y la legibilidad (uso de variables intermedias) en su código Eigen?
En cómputos más complejos, hacer todo en una expresión puede dificultar la legibilidad, por lo que estoy interesado en encontrar la forma correcta de escribir código que sea legible y de rendimiento.
Lo que sucede es que debido al paso .real()
, Eigen no vectorizará explícitamente test2
. Llamará así al operador complejo :: operador * estándar, que, desafortunadamente, nunca se inline por gcc. Las otras versiones, por otro lado, usan la propia implementación de complejos de vectores vectorizados de Eigen.
Por el contrario, ICC integra el complejo :: operator *, lo que hace que test2
el más rápido para ICC. También puede reescribir test2
como:
return mat.array().abs2().sum();
para obtener un rendimiento aún mejor en todos los compiladores:
gcc:
test1_us: 66016
test2_us: 26654
test3_us: 34814
icpc:
test1_us: 87225
test2_us: 8274
test3_us: 44598
clang:
test1_us: 87543
test2_us: 26891
test3_us: 44617
La puntuación extremadamente buena de ICC en este caso se debe a su ingenioso motor de auto-vectorización.
Otra forma de solucionar la falla de Gcc sin modificar test2
es definir su propio operator*
para el complex<float>
. Por ejemplo, agregue lo siguiente en la parte superior de su archivo:
namespace std {
complex<float> operator*(const complex<float> &a, const complex<float> &b) {
return complex<float>(real(a)*real(b) - imag(a)*imag(b), imag(a)*real(b) + real(a)*imag(b));
}
}
y luego entiendo:
gcc:
test1_us: 69352
test2_us: 28171
test3_us: 36501
icpc:
test1_us: 93810
test2_us: 11350
test3_us: 51007
clang:
test1_us: 83138
test2_us: 26206
test3_us: 45224
Por supuesto, este truco no siempre se recomienda ya que, a diferencia de la versión glib, podría provocar problemas de desbordamiento o cancelación numérica, pero esto es lo que icpc y las otras versiones vectorizadas computan de todos modos.
Parece un problema de GCC. El compilador de Intel da el resultado esperado.
$ g++ -I ~/program/include/eigen3 -std=c++11 -O3 a.cpp -o a && ./a
test1_us: 200087
test2_us: 320033
test3_us: 44539
$ icpc -I ~/program/include/eigen3 -std=c++11 -O3 a.cpp -o a && ./a
test1_us: 214537
test2_us: 23022
test3_us: 42099
En comparación con la versión icpc
, gcc
parece tener problemas para optimizar su test2
.
Para un resultado más preciso, es posible que desee desactivar las aserciones de depuración por -DNDEBUG
como se muestra here .
EDITAR
Para la pregunta 1
@ggael da una excelente respuesta que gcc
no puede vectorizar el ciclo de suma. Mi experimento también encontró que test2
es tan rápido como el for-loop ingenuo escrito a mano, ambos con gcc
e icc
, lo que sugiere que la vectorización es la razón, y no se detecta asignación temporal de memoria en test2
por el método mencionado a continuación, sugiriendo que Eigen evaluar la expresión correctamente
Para la pregunta 2
Evitar la memoria intermedia es el objetivo principal que Eigen usa plantillas de expresión. Por lo tanto, Eigen proporciona una macro here y una función simple que le permite verificar si se asigna una memoria intermedia durante el cálculo de la expresión. Puedes encontrar un código de muestra here . Tenga en cuenta que esto solo puede funcionar en modo de depuración.
EIGEN_RUNTIME_NO_MALLOC: si se define, se introduce un nuevo interruptor que se puede activar y desactivar llamando a set_is_malloc_allowed (bool). Si malloc no está permitido y Eigen intenta asignar memoria dinámicamente de todos modos, se produce una falla de aserción. No definido por defecto.
Para la pregunta 3
Hay una manera de usar variables intermedias y obtener la mejora de rendimiento introducida por las plantillas de evaluación / expresión perezosas al mismo tiempo.
La forma es usar variables intermedias con el tipo de datos correcto. En lugar de usar Eigen::Matrix/Array
, que ordena que se evalúe la expresión, debe usar la expresión tipo Eigen::MatrixBase/ArrayBase/DenseBase
para que la expresión solo se almacene pero no se evalúe. Esto significa que debe almacenar la expresión como intermedia, en lugar del resultado de la expresión, con la condición de que este intermediario solo se use una vez en el siguiente código.
Como determinar los parámetros de plantilla en el tipo de expresión Eigen::MatrixBase/...
podría ser doloroso, podría usar auto
lugar. Puede encontrar algunos consejos sobre cuándo debería / no debería usar los tipos de auto
/ expresión en esta página . Otra página también le dice cómo pasar las expresiones como parámetros de funciones sin evaluarlas.
De acuerdo con el experimento instructivo sobre .abs2()
en la respuesta de @ggael, creo que otra directriz es evitar reinventar la rueda.
Solo quiero que noten que hicieron perfiles de forma no óptima, por lo que en realidad el problema podría ser su método de creación de perfiles.
Dado que hay muchas cosas como la ubicación del caché para tener en cuenta, debe hacer los perfiles de esa manera:
int warmUpCycles = 100;
int profileCycles = 1000;
// TEST 1
for(int i=0; i<warmUpCycles ; i++)
doTest1();
auto tick = std::chrono::steady_clock::now();
for(int i=0; i<profileCycles ; i++)
doTest1();
auto tock = std::chrono::steady_clock::now();
test1_us = (std::chrono::duration_cast<std::chrono::microseconds>(tock-tick)).count();
// TEST 2
// TEST 3
Una vez que hiciste la prueba de la manera correcta, puedes llegar a conclusiones.
Sospecho que dado que estás perfilando una operación a la vez, terminas usando la versión en caché en la tercera prueba ya que es probable que el compilador vuelva a ordenar las operaciones.
También debe probar diferentes compiladores para ver si el problema es el despliegue de plantillas (hay un límite de profundidad para optimizar plantillas: es probable que pueda golpearlo con una sola gran expresión).
Además, si la semántica del movimiento de soporte Eigen no existe ninguna razón, una versión debería ser más rápida, ya que no siempre se garantiza que las expresiones se puedan optimizar.
Por favor, intenta y avísame, eso es interesante. También asegúrese de haber habilitado las optimizaciones con indicadores como -O3
, el perfilado sin optimización no tiene sentido.
Para evitar que el compilador optimice todo, use la entrada inicial de un archivo o cin
y luego vuelva a alimentar la entrada dentro de las funciones.
Una cosa que he hecho antes es hacer mucho uso de la palabra clave auto
. Teniendo en cuenta que la mayoría de las expresiones Eigen devuelven tipos de datos de expresiones especiales (p. CwiseBinaryOp
., CwiseBinaryOp
), una asignación a una Matrix
puede forzar la evaluación de la expresión (que es lo que estás viendo). El uso de auto
permite al compilador deducir el tipo de retorno como el tipo de expresión que sea, lo que evitará la evaluación el mayor tiempo posible:
float test1( const MatrixXcf & mat )
{
auto arr = mat.array();
auto conj = arr.conjugate();
auto magc = arr * conj;
auto mag = magc.real();
return mag.sum();
}
Esto esencialmente debería estar más cerca de su segundo caso de prueba. En algunos casos, he tenido buenas mejoras de rendimiento manteniendo la legibilidad ( no quiero tener que deletrear los tipos de plantillas de expresiones). Por supuesto, su millaje puede variar, por lo tanto, comparta con cuidado :)