iphone - C vs ensamblador vs rendimiento NEON
image-processing assembly (4)
Estoy trabajando en una aplicación para iPhone que realiza el procesamiento de imágenes en tiempo real. Uno de los primeros pasos en su canalización es convertir una imagen BGRA a escala de grises. Probé varios métodos diferentes y la diferencia en el tiempo de los resultados es mucho mayor de lo que había imaginado. Primero intenté usar C. Aproximé la conversión a la luminosidad agregando B + 2 * G + R / 4
void BGRA_To_Byte(Image<BGRA> &imBGRA, Image<byte> &imByte)
{
uchar *pIn = (uchar*) imBGRA.data;
uchar *pLimit = pIn + imBGRA.MemSize();
uchar *pOut = imByte.data;
for(; pIn < pLimit; pIn+=16) // Does four pixels at a time
{
unsigned int sumA = pIn[0] + 2 * pIn[1] + pIn[2];
pOut[0] = sumA / 4;
unsigned int sumB = pIn[4] + 2 * pIn[5] + pIn[6];
pOut[1] = sumB / 4;
unsigned int sumC = pIn[8] + 2 * pIn[9] + pIn[10];
pOut[2] = sumC / 4;
unsigned int sumD = pIn[12] + 2 * pIn[13] + pIn[14];
pOut[3] = sumD / 4;
pOut +=4;
}
}
Este código tarda 55 ms en convertir una imagen 352x288. Luego encontré un código ensamblador que hace esencialmente lo mismo
void BGRA_To_Byte(Image<BGRA> &imBGRA, Image<byte> &imByte)
{
uchar *pIn = (uchar*) imBGRA.data;
uchar *pLimit = pIn + imBGRA.MemSize();
unsigned int *pOut = (unsigned int*) imByte.data;
for(; pIn < pLimit; pIn+=16) // Does four pixels at a time
{
register unsigned int nBGRA1 asm("r4");
register unsigned int nBGRA2 asm("r5");
unsigned int nZero=0;
unsigned int nSum1;
unsigned int nSum2;
unsigned int nPacked1;
asm volatile(
"ldrd %[nBGRA1], %[nBGRA2], [ %[pIn], #0] /n" // Load in two BGRA words
"usad8 %[nSum1], %[nBGRA1], %[nZero] /n" // Add R+G+B+A
"usad8 %[nSum2], %[nBGRA2], %[nZero] /n" // Add R+G+B+A
"uxtab %[nSum1], %[nSum1], %[nBGRA1], ROR #8 /n" // Add G again
"uxtab %[nSum2], %[nSum2], %[nBGRA2], ROR #8 /n" // Add G again
"mov %[nPacked1], %[nSum1], LSR #2 /n" // Init packed word
"mov %[nSum2], %[nSum2], LSR #2 /n" // Div by four
"add %[nPacked1], %[nPacked1], %[nSum2], LSL #8 /n" // Add to packed word
"ldrd %[nBGRA1], %[nBGRA2], [ %[pIn], #8] /n" // Load in two more BGRA words
"usad8 %[nSum1], %[nBGRA1], %[nZero] /n" // Add R+G+B+A
"usad8 %[nSum2], %[nBGRA2], %[nZero] /n" // Add R+G+B+A
"uxtab %[nSum1], %[nSum1], %[nBGRA1], ROR #8 /n" // Add G again
"uxtab %[nSum2], %[nSum2], %[nBGRA2], ROR #8 /n" // Add G again
"mov %[nSum1], %[nSum1], LSR #2 /n" // Div by four
"add %[nPacked1], %[nPacked1], %[nSum1], LSL #16 /n" // Add to packed word
"mov %[nSum2], %[nSum2], LSR #2 /n" // Div by four
"add %[nPacked1], %[nPacked1], %[nSum2], LSL #24 /n" // Add to packed word
///////////
////////////
: [pIn]"+r" (pIn),
[nBGRA1]"+r"(nBGRA1),
[nBGRA2]"+r"(nBGRA2),
[nZero]"+r"(nZero),
[nSum1]"+r"(nSum1),
[nSum2]"+r"(nSum2),
[nPacked1]"+r"(nPacked1)
:
: "cc" );
*pOut = nPacked1;
pOut++;
}
}
Esta función convierte la misma imagen en 12 ms, ¡casi 5 veces más rápido! No he programado en ensamblador antes, pero asumí que no sería tan rápido como C para una operación tan simple. Inspirado por este éxito, seguí buscando y descubrí un ejemplo de conversión NEON here .
void greyScaleNEON(uchar* output_data, uchar* input_data, int tot_pixels)
{
__asm__ volatile("lsr %2, %2, #3 /n"
"# build the three constants: /n"
"mov r4, #28 /n" // Blue channel multiplier
"mov r5, #151 /n" // Green channel multiplier
"mov r6, #77 /n" // Red channel multiplier
"vdup.8 d4, r4 /n"
"vdup.8 d5, r5 /n"
"vdup.8 d6, r6 /n"
"0: /n"
"# load 8 pixels: /n"
"vld4.8 {d0-d3}, [%1]! /n"
"# do the weight average: /n"
"vmull.u8 q7, d0, d4 /n"
"vmlal.u8 q7, d1, d5 /n"
"vmlal.u8 q7, d2, d6 /n"
"# shift and store: /n"
"vshrn.u16 d7, q7, #8 /n" // Divide q3 by 256 and store in the d7
"vst1.8 {d7}, [%0]! /n"
"subs %2, %2, #1 /n" // Decrement iteration count
"bne 0b /n" // Repeat unil iteration count is not zero
:
: "r"(output_data),
"r"(input_data),
"r"(tot_pixels)
: "r4", "r5", "r6"
);
}
Los resultados de los tiempos fueron difíciles de creer. Convierte la misma imagen en 1 ms. 12 veces más rápido que el ensamblador y un asombroso 55 veces más rápido que C. No tenía idea de que tales ganancias de rendimiento fueran posibles. A la luz de esto tengo algunas preguntas. En primer lugar, ¿estoy haciendo algo terriblemente mal en el código C? Todavía me cuesta creer que sea tan lento. Segundo, si estos resultados son del todo precisos, ¿en qué tipo de situaciones puedo esperar ver estas ganancias? Probablemente pueda imaginar lo emocionado que estoy ante la perspectiva de hacer que otras partes de mi tubería funcionen 55 veces más rápido. ¿Debería estar aprendiendo ensamblador / NEON y usarlos dentro de cualquier bucle que tome una cantidad apreciable de tiempo?
Actualización 1: He publicado el resultado del ensamblador de mi función C en un archivo de texto en http://temp-share.com/show/f3Yg87jQn Era demasiado grande para incluirlo directamente aquí.
El tiempo se realiza utilizando las funciones de OpenCV.
double duration = static_cast<double>(cv::getTickCount());
//function call
duration = static_cast<double>(cv::getTickCount())-duration;
duration /= cv::getTickFrequency();
//duration should now be elapsed time in ms
Resultados
He probado varias mejoras sugeridas. Primero, según lo recomendado por Viktor, reordené el bucle interno para poner primero todas las búsquedas. El bucle interno entonces parecía.
for(; pIn < pLimit; pIn+=16) // Does four pixels at a time
{
//Jul 16, 2012 MR: Read and writes collected
sumA = pIn[0] + 2 * pIn[1] + pIn[2];
sumB = pIn[4] + 2 * pIn[5] + pIn[6];
sumC = pIn[8] + 2 * pIn[9] + pIn[10];
sumD = pIn[12] + 2 * pIn[13] + pIn[14];
pOut +=4;
pOut[0] = sumA / 4;
pOut[1] = sumB / 4;
pOut[2] = sumC / 4;
pOut[3] = sumD / 4;
}
Este cambio redujo el tiempo de procesamiento a 53 ms una mejora de 2 ms. A continuación, según lo recomendado por Victor, cambié mi función para obtener como uint. El bucle interno entonces parecía
unsigned int* in_int = (unsigned int*) original.data;
unsigned int* end = (unsigned int*) in_int + out_length;
uchar* out = temp.data;
for(; in_int < end; in_int+=4) // Does four pixels at a time
{
unsigned int pixelA = in_int[0];
unsigned int pixelB = in_int[1];
unsigned int pixelC = in_int[2];
unsigned int pixelD = in_int[3];
uchar* byteA = (uchar*)&pixelA;
uchar* byteB = (uchar*)&pixelB;
uchar* byteC = (uchar*)&pixelC;
uchar* byteD = (uchar*)&pixelD;
unsigned int sumA = byteA[0] + 2 * byteA[1] + byteA[2];
unsigned int sumB = byteB[0] + 2 * byteB[1] + byteB[2];
unsigned int sumC = byteC[0] + 2 * byteC[1] + byteC[2];
unsigned int sumD = byteD[0] + 2 * byteD[1] + byteD[2];
out[0] = sumA / 4;
out[1] = sumB / 4;
out[2] = sumC / 4;
out[3] = sumD / 4;
out +=4;
}
Esta modificación tuvo un efecto dramático, reduciendo el tiempo de procesamiento a 14 ms, una caída de 39 ms (75%). Este último resultado está muy cerca del rendimiento del ensamblador de 11ms. La optimización final recomendada por rob fue incluir la palabra clave __restrict. Lo agregué delante de cada declaración de puntero cambiando las siguientes líneas
__restrict unsigned int* in_int = (unsigned int*) original.data;
unsigned int* end = (unsigned int*) in_int + out_length;
__restrict uchar* out = temp.data;
...
__restrict uchar* byteA = (uchar*)&pixelA;
__restrict uchar* byteB = (uchar*)&pixelB;
__restrict uchar* byteC = (uchar*)&pixelC;
__restrict uchar* byteD = (uchar*)&pixelD;
...
Estos cambios no tuvieron un efecto medible en el tiempo de procesamiento. Gracias por toda su ayuda, en el futuro prestaré mucha más atención a la administración de la memoria.
Aquí hay una explicación sobre algunos de los motivos del "éxito" de NEON: http://hilbert-space.de/?p=22
Intente compilar su código C con los conmutadores "-S -O3" para ver la salida optimizada del compilador GCC.
En mi humilde opinión, la clave del éxito es el patrón de lectura / escritura optimizado empleado por ambas versiones de ensamblaje. Y los motores vectoriales NEON / MMX / otros también admiten la saturación (los resultados de sujeción a 0..255 sin tener que usar los ''ints sin signo'').
Vea estas líneas en el bucle:
unsigned int sumA = pIn[0] + 2 * pIn[1] + pIn[2];
pOut[0] = sumA / 4;
unsigned int sumB = pIn[4] + 2 * pIn[5] + pIn[6];
pOut[1] = sumB / 4;
unsigned int sumC = pIn[8] + 2 * pIn[9] + pIn[10];
pOut[2] = sumC / 4;
unsigned int sumD = pIn[12] + 2 * pIn[13] + pIn[14];
pOut[3] = sumD / 4;
pOut +=4;
Las lecturas y escrituras son realmente mixtas. Una versión ligeramente mejor del ciclo del bucle sería
// and the pIn reads can be combined into a single 4-byte fetch
sumA = pIn[0] + 2 * pIn[1] + pIn[2];
sumB = pIn[4] + 2 * pIn[5] + pIn[6];
sumC = pIn[8] + 2 * pIn[9] + pIn[10];
sumD = pIn[12] + 2 * pIn[13] + pIn[14];
pOut +=4;
pOut[0] = sumA / 4;
pOut[1] = sumB / 4;
pOut[2] = sumC / 4;
pOut[3] = sumD / 4;
Tenga en cuenta que la línea "sin signo en sumA" aquí puede significar realmente la llamada aloca () (asignación en la pila), por lo que está perdiendo muchos ciclos en las asignaciones de var temporales (la función llama 4 veces).
Además, la indexación pIn [i] solo realiza una recuperación de un solo byte de la memoria. La mejor manera de hacer esto es leer el int y luego extraer los bytes individuales. Para hacer las cosas más rápidas, use el "int no digerido" para leer 4 bytes (pIn [i * 4 + 0], pIn [i * 4 + 1], pIn [i * 4 + 2], pIn [i * 4 + 3]).
La versión NEON es claramente superior: las líneas.
"# load 8 pixels: /n"
"vld4.8 {d0-d3}, [%1]! /n"
y
"#save everything in one shot /n"
"vst1.8 {d7}, [%0]! /n"
Guarde la mayor parte del tiempo para el acceso a la memoria.
Esto es una especie de cambio entre el rendimiento y la capacidad de mantenimiento. Por lo general, tener una aplicación que se cargue y funcione rápidamente es muy agradable para el usuario, pero existe una compensación. Ahora su aplicación es bastante difícil de mantener y las ganancias de velocidad pueden ser injustificadas. Si los usuarios de su aplicación se quejaron de que se sintió lento, entonces estas optimizaciones merecen el esfuerzo y la falta de mantenimiento, pero si surgió la necesidad de acelerar su aplicación, entonces no debe ir tan lejos en la optimización. Si está realizando la conversión de estas imágenes al inicio de la aplicación, entonces la velocidad no es esencial, pero si las está haciendo constantemente (y muchas de ellas) mientras la aplicación se está ejecutando, entonces tienen más sentido. Solo optimice las partes de la aplicación donde el usuario pasa tiempo y en realidad experimenta la ralentización.
También observando el ensamblaje, no usan división sino más bien solo multiplicaciones, así que analice eso para su código C. Otra instancia es que optimiza tu multiplicación por 2 a dos adiciones. Esto también puede ser otro truco, ya que la multiplicación puede ser más lenta en una aplicación de iPhone que una adición.
La respuesta de Viktor Latypov tiene mucha información buena, pero quiero señalar una cosa más: en su función C original, el compilador no puede decir que pIn
y pOut
apuntan a regiones de memoria no superpuestas. Ahora mira estas líneas:
pOut[0] = sumA / 4;
unsigned int sumB = pIn[4] + 2 * pIn[5] + pIn[6];
El compilador debe asumir que pOut[0]
podría ser el mismo que pIn[4]
o pIn[5]
o pIn[6]
(o cualquier otro pIn[x]
). Así que, básicamente, no se puede reordenar ninguno de los códigos en su bucle.
Puede decirle al compilador que pIn
y pOut
no se superponen declarándolos __restrict
:
__restrict uchar *pIn = (uchar*) imBGRA.data;
__restrict uchar *pOut = imByte.data;
Esto podría acelerar un poco tu versión C original.
Si el rendimiento es sumamente importante (como ocurre generalmente con el procesamiento de imágenes en tiempo real), es necesario prestar atención al código de la máquina. Como ha descubierto, puede ser especialmente importante usar las instrucciones vectoriales (que están diseñadas para cosas como el procesamiento de imágenes en tiempo real), y es difícil para los compiladores usar las instrucciones vectoriales de manera automática y efectiva.
Lo que debe intentar, antes de comprometerse con el ensamblaje, es usar los intrínsecos del compilador . Los intrínsecos del compilador no son más portátiles que el ensamblaje, pero deberían ser más fáciles de leer y escribir, y más fáciles para que funcione el compilador. Aparte de los problemas de mantenimiento, el problema de rendimiento con el ensamblaje es que efectivamente apaga el optimizador (usted usó el indicador de compilador apropiado para activarlo, ¿verdad?). Es decir: con el ensamblaje en línea, el compilador no puede ajustar la asignación de registros, etc., por lo que si no escribe todo su bucle interno en el ensamblaje, es posible que no sea tan eficiente como podría ser.
Sin embargo, aún podrá utilizar su nueva experiencia en ensamblajes con buenos resultados, ya que ahora puede inspeccionar el ensamblaje producido por su compilador y averiguar si está siendo estúpido. Si es así, puede modificar el código C (tal vez haciendo una pipelining a mano si el compilador no logra hacerlo), vuelva a compilarlo, mire la salida del ensamblaje para ver si el compilador está haciendo lo que usted quiere y luego haga una pipelining comparativa para ver si en realidad se está ejecutando más rápido ...
Si ha intentado lo anterior y aún no puede hacer que el compilador haga lo correcto, siga adelante y escriba su bucle interno en el ensamblaje (y, nuevamente, verifique si el resultado es realmente más rápido). Por los motivos descritos anteriormente, asegúrese de obtener todo el bucle interno, incluida la rama del bucle.
Finalmente, como han mencionado otros, tómese un tiempo para intentar averiguar qué es "lo correcto". Otro beneficio de aprender la arquitectura de su máquina es que le brinda un modelo mental de cómo funcionan las cosas, por lo que tendrá una mejor oportunidad de entender cómo armar un código eficiente.