- Incluso en un bucle pequeño en C, la salida del compilador no siempre es la mejor; al ajustar manualmente el ensamblador x86_64, una versión con eliminación de ramas condicionales terminó siendo 6.73 veces más rápida que la salida de clang
- La función objetivo trata en una cadena
's'como +1,'p'como -1 y'\0'como fin; la salida de clang 16 divide este flujo en 3 ramas condicionales - Tras cambiar el orden de las ramas, reubicar bloques básicos y sustituir saltos por aritmética, el tiempo de ejecución bajó de 3.23 s a 2.87 s, alcanzando en ese punto la misma velocidad que GCC 12
- La versión más rápida usa
cmovepara elegir entre 0, 1 y -1 como valor a sumar por carácter, y luego siempre ejecutaadd, logrando 0.48 s y un rendimiento de 1.94 GiB/s - El benchmark se hizo en un AMD Ryzen 5 5625U con Linux 6.1.33, procesando 1000 veces una lista aleatoria de 1 millón de caracteres
'p'/'s', y usando el mejor resultado entre varias ejecuciones
La función evaluada y la salida del compilador
- La función objetivo incrementa un puntero de cadena de uno en uno y actualiza el entero
ressegún el carácter's':res += 1'p':res -= 1'\0': devuelveres- Cualquier otro carácter: sin cambios
- Como la función es pequeña, se partió de la expectativa de que gcc o clang podrían optimizarla bastante bien, quizá incluso de forma óptima
- El ensamblador inicial generado por clang separa los cuatro casos en tres ramas condicionales (
je,je,jne)- Empieza con
res = 0 - Lee un carácter y primero comprueba si es
'\0' - Después compara con
'p'y's'
- Empieza con
- Resultado inicial con clang
- Tiempo de ejecución: 3.23 s
- Rendimiento: 295.26 MiB/s
- GCC generó un poco más de código, pero fue ligeramente más rápido
Comprobar primero los caracteres frecuentes en lugar de la condición rara de fin
- El bucle solo termina al encontrar el carácter nulo terminador
'\0', y en esta función ese carácter aparece como máximo una vez - La salida de clang comprueba
'\0'primero, así que con cada carácter'p'y's'revisa antes la condición de salida - El primer cambio manual fue invertir el orden de las comparaciones para comprobar primero
'p'y's' - Resultado
- Tiempo de ejecución: 3.10 s
- Mejora de velocidad: 1.04 veces
- Rendimiento: 307.64 MiB/s
Reubicar bloques básicos y reducir saltos
- Como los dos casos frecuentes,
'p'y's', vuelven a saltar al inicio del bucle, se puede reducir una rama colocando uno de esos bloques encima del bucle - Si el bloque de
's'se coloca justo antes del bucle, tras procesar's'el flujo cae de vuelta al bucle sin un salto extra - A cambio, al inicio de la función hay que hacer un salto al bucle para saltarse una vez el bloque de
's'- Ese salto del inicio de la función ocurre una sola vez
- Como el carácter
's'puede aparecer muchas veces, se consideró una compensación aceptable
- Resultado
- Tiempo de ejecución: 2.98 s
- Mejora total de velocidad: 1.08 veces
- Rendimiento: 320.02 MiB/s
Eliminar un salto incondicional usando aritmética
- Para quitar el
jmpincondicional que vuelve al bucle desde el bloquep:, se usó aritmética - Como una resta de 1 puede lograrse con
sub eax, 2seguido deinc eax, se hizo que tras procesar'p'el flujo continuara hacia el bloque de's' - Con esto se eliminó otra instrucción de rama
- Resultado
- Tiempo de ejecución: 2.87 s
- Mejora total de velocidad: 1.12 veces
- Rendimiento: 332.29 MiB/s
- En este punto, el rendimiento quedó igual al del código generado por GCC 12
- El código de GCC 12 también corre en 2.87 s
- La versión escrita a mano tiene 13 instrucciones
- La salida de GCC tiene 19 instrucciones
- El código de GCC parece desenrollar el bucle y reutilizar parcialmente los bloques de caso
Sustituir ramas condicionales por cmove
- Si las ramas condicionales son el cuello de botella, se pueden eliminar sin depender del predictor de ramas
- La versión más rápida usa
cmove, es decir, movimiento condicional si es igual - La regla de funcionamiento es simple
- El valor por defecto es 0
- Si el carácter actual es
's', usa 1 - Si el carácter actual es
'p', usa -1 - En cada iteración, siempre suma el valor elegido a
res
- Este enfoque elimina muchas flechas del grafo de flujo de control
- Resultado
- Tiempo de ejecución: 0.48 s
- Mejora total de velocidad: 6.73 veces
- Rendimiento: 1.94 GiB/s
- En el ensamblador de un bucle compacto en C escrito a mano, fue posible lograr una mejora de más de 6 veces con optimizaciones que el compilador no automatizó
Intentos de ahorrar registros y otros experimentos fallidos
- También se probó una versión que usa
setede x86_64 para fijar condicionalmente un registro de 1 byte en 0 o 1 - Esa versión elimina el uso de
r8d, pero fue más lenta que la versión que solo usacmov - Resultado
- Tiempo de ejecución: 0.51 s
- Mejora total de velocidad: 6.33 veces
- Rendimiento: 1.83 GiB/s
- Usar menos registros o hacer operaciones de 8 bits en lugar de 32 bits no la hizo más rápida
- Otros intentos adicionales también empeoraron el rendimiento
- Desenrollar el bucle en la mejor versión: más lento
- Alinear el inicio del bucle a un límite de 16 bytes: más lento
- En GNU assembler, poner
.align <bytes>antes de una etiqueta puede insertarnop
Entorno del benchmark y código
- El listado del código está en GitHub
- Entorno del benchmark
- OS: Linux 6.1.33
- CPU: AMD Ryzen 5 5625U with Radeon Graphics
- Familia de CPU 25, 6 núcleos, 2 hilos por núcleo, 1 socket
- clang: 16.0.1
- gcc: 12.2.0
- La versión en C se compiló con
-march=nativepara permitir generar código ajustado a esa CPU específica - El benchmark usa una lista de 1 millón de caracteres aleatorios
'p'y's'- Cada versión de la función procesa esa lista 1000 veces
- Cada versión se ejecuta varias veces y se toma el mejor resultado
- Como texto de seguimiento, enlaza a part two
1 comentarios
Opiniones en Hacker News
La conclusión correcta se acerca más a el ensamblador escrito a mano es 6 veces más rápido que C no, sino a los saltos pueden ser mucho más lentos que la aritmética condicional
Incluso en C se puede lograr fácilmente el mismo efecto sin usar
switch, manejándolo con uno o dosif. Al cambiar la función en C para que si essincremente, si espdisminuya y si es\0termine, se volvió 5.5 veces más rápida, y en la ejecución de ejemplo bajó de 3.58 segundos a 0.65 segundosComo dijeron otros, también se podría vectorizar el algoritmo después de ajustar la entrada. Yo lo vi como un ejercicio educativo y sinceramente espero que no termine llevando a nadie a bajar a ensamblador sin una razón suficiente
Linus también escribió hace tiempo un texto largo sobre que
cmovno es útil en ramas predecibles: https://yarchive.net/comp/linux/cmov.htmlgcc (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0, tantolonecomoltwotardaron unos 3.58 segundosswitchpor variosifsiempre será más rápido. También me intriga a partir de cuántos casosswitchpasa a ser más rápido, y si es consistente, parecería algo que debería entrar en optimización del compiladorCreo que el código original no estaba escrito de una forma muy amigable para el compilador. Si se escribe como
result += *s == 's'; result -= *s == 'p';, el compilador genera el código sin ramas consete/cmovadecuado y sale casi igual de rápido que el ensamblador optimizado del artículoEso sí, no hace unrolling del bucle ni vectorización. Si se pasa el tamaño de la cadena por separado y se itera conociendo
size, el compilador puede conocer el tamaño del bucle, desenrollarlo y, si puede, usar instrucciones AVX-512. En entradas grandes es mucho más rápido, pero me da flojera hacer el benchmark yo mismo. Si eres de los programadores en C que no rastrean la longitud de la cadena, haz lo que quieras, pero de verdad no deberías: https://godbolt.org/z/rde51zMd8Esa versión alcanza 3.88GiB/s. A propósito no llegué hasta vectorizar; quería mantener acotado el alcance del problema y mostrar los tips y trucos de ensamblador del artículo. Más adelante podría escribir otro texto sobre rellenar la cadena de entrada y vectorizar el algoritmo
/* DON’T REFACTOR THIS FOR READABILITY IT WILL SLOW DOWN */{.overflowChecks:off.}habilitado y recorriendoinput, incrementando cuando's' == cy decrementando cuando'p' == cEn un Apple M1 dio una mejora de velocidad de unas 5 veces, y con las verificaciones de overflow activadas solo fue unas 2 veces más rápido que la versión base en C. Siempre es bueno conocer patrones que inducen optimización SIMD
Desde una postura más cercana a la de un experto en optimización, yo resolvería este problema de una forma totalmente distinta. En mi máquina la versión inicial en C iba a 389MB por segundo, y si el ensamblador del artículo da la misma mejora de 6.2 veces, eso da alrededor de 2.4GB por segundo
En buffers largos esta versión en C++ supera 24GB por segundo en mi máquina: https://gist.github.com/Const-me/3ade77faad47f0fbb0538965ae7...
Sin ensamblador y usando intrinsics AVX2, es 61 veces más rápida que la versión original
ymm, parece que se podría vectorizar el prólogo usandomovemaskypopcntTodavía no es código probado, así que haría falta benchmarkearlo, pero parece viable construir máscaras para
s,py\0, y usartzcntybzhipara contar los bits hasta el final de la cadenastd::experimental::simd: https://en.cppreference.com/w/cpp/experimental/simdEste código parece encajar realmente bien con SIMD. Si se pudiera cambiar el prototipo para recibir una longitud explícita, sería fácil leer y procesar 16 bytes a la vez
Bastaría con sumar y restar directamente los resultados de las comparaciones, y solo con llamar a
strlen()al inicio de la función para obtener una longitud explícita probablemente ya valdría la penaHice rápidamente una implementación vectorizada para RISC-V. La idea es leer la cadena con
rvv, encontrar la posición de\0y contar convcpopcuántassyphayEn una Mangopi MQ Pro (C906,
rv64gc+rvv 0.7.1, longitud de vector de 128 bits),switchdio 0.19 Bytes/Cycle, la implementación en C con tabla dio 0.17 Bytes/Cycle yrvvdio 1.57 Bytes/Cycle, aunque después de unos 30KiB baja a 1.35. Si se alinea el puntero a página yvlno supera el tamaño de página, se puede llegar a 2/1.7 Bytes/Cyclervvtiene esa función; de lo contrario puede fallar si el byte nulo está justo antes del final de la memoria asignadaEsto parece ser una propiedad particular de la arquitectura x86. Como el costo de no ramificar es muy bajo, en comparación la ramificación parece cara: https://wordsandbuttons.online/challenge_your_performance_in...
Pero en otros procesadores puede no ser así: https://wordsandbuttons.online/using_logical_operators_for_l...
La pregunta más grande es por qué se necesita C en general. Si vas a ajustarlo manualmente para que corra lo mejor posible en hardware específico, C es la herramienta equivocada, y lo que hace falta es ensamblador y un buen sistema de macros. El objetivo original de C era facilitar mover código a nivel de sistema de una plataforma a otra, y en ese proceso se esperaba cierta pérdida de eficiencia. Es como escribir poesía hindi en esperanto y luego traducirla automáticamente al idioma que quieras, en vez de traducirla al urdu. No obtienes dos grandes poemas, pero sí dos traducciones mediocres rápidamente, y ese es el papel de C
Si compilas con FDO/PGO, definitivamente puede haber reordenamiento de ramas y bloques. Sin FDO, el compilador no puede saber con qué frecuencia se tomará cada rama. En algunos casos, FDO también puede habilitar
cmovPero que
cmovsea más efectivo que untest/jumpnormal depende mucho de qué tan predecible sea la rama, y por lo generalcmovfunciona mejor cuando la rama es muy impredecible. Sicmovlo hizo 6 veces más rápido, supongo que la entrada de prueba era una cadena aleatoria compuesta casi por completo desyp. No está mal, pero aprovecha una propiedad no mencionada de los datos al especializar el benchmark, así que el artículo puede prestarse un poco a confusiónSe elige aleatoriamente
's'o'p', y no pueden aparecer otros caracteres aparte de's','p'y el nulo terminador. Si conoces esta característica de la entrada, incluso es posible una optimización demasiado ingeniosa comoresult += (1 | *s++) - 'r';. Es un código excesivamente listo, pero muestra perfectamente el punto de aprovechar las propiedades de los datos'\0'puede encontrarse como máximo una vez porque la función retorna, mientras que los otros caracteres pueden aparecer muchas veces. Parece el tipo de información al que el compilador podría acceder incluso sin PGOClaro que PGO ayuda, y en mi computadora dio 2.80 segundos, mejor que el código al final de la sección
Rearranging blocks. La entrada está descrita enBenchmarking setupy también está en el repositorio: https://github.com/414owen/blog-code/blob/master/01-six-time...En la segunda parte enlazada al final del artículo, el código en C se hace lo más rápido posible y supera a todo el ensamblador de este post. Nunca dije que usar ensamblador fuera necesariamente una buena idea; solo me parece un desafío interesante y una buena oportunidad de aprendizaje optimizar y descifrar la salida del compilador
Creo que lo hice más rápido que el artículo y su continuación. Eso sí, con el costo de estar especializado para el caso en que la cadena solo contiene
's'y'p'Como el benchmark también prueba únicamente cadenas formadas por
's'y'p', me parece justo. La clave es que cuando el siguiente carácter ess, quieres incrementarresen 1, perores += c - 'r'falla porque ensda 1, mientras que enpda -2. Sin embargo, si interpretas'p' - 'r'como entero sin signo, ocurre un underflow y se activa el carry flag, yadcen x64 suma dos registros junto con el carry flag. Así que puedes reemplazar doscmp, cmovpor un solosub, adc. Esta versión fue 1.08 veces más rápida que la versión en C del artículo de seguimiento, y 1.66 veces más rápida que la x64-7 anterior. Claro, todavía se puede mejorar con SWAR/SIMD02-the-same-speed-as-c/loop-5.x64.sera la versión más rápida que yo teníaEn mi computadora,
loop-5.x64.starda 0.244 segundos y la implementación de arriba 0.422 segundos. No sé exactamente por qué hay tanta diferencia; visualmente, la implementación de arriba parece más rápida. Por eso siempre hay que hacer benchmark en el hardware real donde se va a ejecutar'p' * len, luego dividir entre('s' - 'p')para obtener la cantidad des. La cantidad depeslen - s_countLa suma inicial también se vectoriza fácilmente. Si no me equivoco, debería funcionar, y el único problema real es la posible saturación de la suma acumulada. No tengo ganas de benchmarkearlo yo mismo. Edit: pasé por alto la parte que disminuye al ver
s, así que el resultado final esp_count - s_countstrlen()probablemente ya esté implementado de forma bastante rápida, y si conoces el tamaño del búfer, el compilador puede vectorizar automáticamente el bucle internoDe hecho, el código que hace
len = strlen(buf)y luego en el bucleforsuma(buf[i] == 's') - (buf[i] == 'p')se vectoriza automáticamente: https://gcc.godbolt.org/z/qYfadPYoqHace tiempo escribí un decodificador UTF-8 en Common Lisp para SBCL. Ya había un decodificador integrado, así que era solo por práctica
Dejando de lado las optimizaciones obviamente fáciles, casi toda la mejora de rendimiento vino de estructurar el código para que el compilador generara instrucciones
cmov*en vez de ramasUn decodificador UTF-8 normalmente se ejecuta mucho sobre entrada completamente ASCII. Me pregunto con qué tipo de entrada hiciste el benchmark