- El PR #7154 de llama.cpp reescribió los cálculos de SiLU y SoftMax para CPU en GGML usando una implementación basada en
expf() vectorizada de llamafile, y se fusionó en master el 17 de mayo de 2024
- GGML antes usaba una tabla de consulta
short[65536] para ganar velocidad, pero la nueva implementación busca cálculos más precisos, manteniendo un error de redondeo máximo de 2 ULP en aarch64 y SSE2+
- En las pruebas de rendimiento de
SOFT_MAX en CPU, SSE2+FMA fue 1.5 veces más rápido, AVX2+FMA 1.9 veces y AVX512 2.1 veces; además, en AMD Ryzen 9 5950X y M2 Ultra se confirmó un resultado de alrededor de 1.5 veces más rápido frente a master
- Los cambios incluyen la adición de
ggml_v_expf() y ggml_v_silu(), la extracción de código duplicado hacia ggml_vec_soft_max_f32(), la eliminación de funciones relacionadas con GGML_SILU_FP16 y el ajuste de la ruta condicional de SiLU para SSE2 o ARM NEON
- Tras la fusión, se reprodujeron resultados no deterministas al ejecutar el servidor con
>1 slots; después se acotó la causa a -ffinite-math-only, lo que terminó imponiendo la restricción de compilación de que se necesita -fno-finite-math-only
Objetivo de los cambios del PR y estado de la fusión
- El PR #7154, titulado
ggml : rewrite silu and softmax for cpu, reescribe los cálculos de SiLU y SoftMax en la ruta de CPU de GGML dentro de llama.cpp
- El cambio comenzó como una incorporación upstream de la función
expf() vectorizada de llamafile
- El PR se fusionó en
ggml-org:master el 17 de mayo de 2024, y el commit de fusión aparece como 934266c
- El autor indicó que el nuevo método puede calcular SoftMax y SiLU con mayor precisión que la tabla de consulta
short[65536] que GGML usaba antes para priorizar velocidad
Precisión y alcance de soporte
- La nueva ruta basada en
expf() soporta aarch64 y SSE2+, y se reporta un error de redondeo máximo de 2 ULP
- En la explicación inicial se mencionó que también se habían escrito implementaciones AVX2 y AVX512, pero que no se incluyeron porque la ventaja no justificaba la complejidad de código frente a SSE2+FMA
- Más adelante, con base en los resultados de benchmark, también se incluyeron código AVX2 y AVX512
- En una salida de prueba separada se mostró
4294967296 numbers tested successfully, junto con comparaciones de resultados entre exp y la implementación de llamafile para varios valores de entrada
Alcance de los cambios en el código
- Los principales cambios resumidos por un revisor fueron los siguientes
- Eliminación de
#define comentados
- Extracción de 5 líneas duplicadas hacia
ggml_vec_soft_max_f32()
- Eliminación de varias funciones relacionadas con
GGML_SILU_FP16
- Adición de
ggml_v_expf()
- Adición de
ggml_v_silu()
- Ajuste de las directivas de preprocesador para que
ggml_vec_silu_f32() use funciones distintas según la bandera SSE2 o __ARM_NEON
- En los metadatos de GitHub, el número de archivos modificados aparece como 1
- El PR recibió las etiquetas
refactoring y Review Complexity : High; esta última incluye la explicación de que puede requerir conocimiento profundo sobre LLM o GPU
Benchmarks y resultados de rendimiento
ggerganov confirmó que SOFT_MAX era alrededor de 1.5 veces más rápido que master tanto en AMD Ryzen 9 5950X como en M2 Ultra
- El comando de prueba utilizado fue el siguiente
make -j tests && ./tests/test-backend-ops -o SOFT_MAX -b CPU perf
- Después, el autor indicó que con el mismo comando la mejora de rendimiento crecía así
- SSE2+FMA: 1.5 veces
- AVX2+FMA: 1.9 veces
- AVX512: 2.1 veces
- En un script de desarrollo aparte se reportaron las siguientes cifras
run_expf(): 2.98601 ns
run_llamafile_expf_sse2(): 1.35154 ns
run_llamafile_expf_avx2(): 1.16659 ns
run_llamafile_expf_avx512(): 1.18844 ns
- El benchmark de
llama.cpp server en GitHub Actions registró 543 iterations en Standard_NC4as_T4_v3 con la configuración phi-2 q4_0
- Usuarios concurrentes: 8
- duration: 10 minutos
- Promedio de solicitudes HTTP: 8626.19ms
- p95: 21696.44ms
- Promedio de procesamiento de prompt: 94.59 tk/s
- Promedio de generación de tokens: 33.43 tk/s
Discusión sobre optimización AVX512
chriselrod propuso usar vscalefps en AVX512
vscalefps calcula zmm0 = zmm1 * 2^{zmm2}
- Indicó que puede manejar correctamente overflow y underflow, eliminando checks y blends
- Se compartieron un ejemplo de implementación en Julia y un loop en ensamblador, y se comentó que, si las pruebas eran correctas, el error máximo en
x=47.483456f era de menos de 1 ULP
- Se explicó que el enfoque con
vscalefps no usa lookup table, mientras que la implementación para Float64/double usa una lookup table de 16 elementos mediante vpermi2pd
- Más adelante también se compartió un enlace a una implementación en C++
- ExpAVX512
- El código fuente está en
include/ExpAVX512.hpp
- El README incluye benchmarks, aunque se señaló que no se hicieron comparativas contra otras implementaciones
Problema de no determinismo después de la fusión
- Después de la fusión, se reportó un caso reproducible de resultados no deterministas al usar
>1 slots en el servidor
- El procedimiento mínimo de reproducción fue el siguiente
make clean && make server
./server -m models/opt/llama_2-7b-q4_0.gguf --parallel 2 --threads 1
- La solicitud ejecutada desde otra shell fue la siguiente
curl --request POST --url http://localhost:8080/completion --header "Content-Type: application/json" --data '{"prompt": "", "n_predict":10, "n_probs": 2, "temperature": -1}' | python3 -m json.tool
- Se indicó que las probabilidades de token del último token alternaban entre dos valores en cada llamada de
curl, y que con 4 slots alternaban entre cuatro valores posibles
-ffinite-math-only y restricciones de compilación
- Más tarde, commits relacionados hicieron referencia a que la causa del problema se había acotado a
-ffinite-math-only
- Se dejó registrado que se suponía que el problema ocurría porque SiLU devolvía NaN u otros valores basura en lugar de hacer flush a 0 para valores pequeños
- El fix forzó una verificación de que estuviera configurado
-fno-finite-math-only, y exigió que el modo de compilación no fuera finite math mode
- El mensaje de error indicaba que algunas rutinas de GGML requieren non-finite math arithmetic y pedía pasar
-fno-finite-math-only al compilador
- Más adelante, usuarios compartieron experiencias de que
-Ofast o -ffast-math pueden incluir -ffinite-math-only y romper la compilación
- Hay reportes de que hasta GCC 13.2 se podía usar
-Ofast, pero desde GCC 14 los resultados se volvían basura
- En algunas pruebas, además de
-fno-finite-math-only, también fue necesario -fmath-errno
- Varios commits posteriores en distintos repositorios mencionaron resolver errores de compilación de ggml quitando
-ffast-math o especificando -fno-finite-math-only
1 comentarios
Opiniones de Hacker News
Hace unos 20 años, cuando programaba para un procesador de señales de radar Hughes, necesitaba calcular e^x en el rango 0 < x < 1.
Ese procesador tenía multiplicación, así que armé 4 tablas de e^x con los 256 valores posibles para cada uno de los 4 bloques de 8 bits de una palabra de 32 bits, y luego los multiplicaba para obtener el valor final.
Era unas 5 veces más rápido que la mejor rutina de e^x anterior y, aunque hoy ya es obsoleto, fue una máquina curiosa que durante un tiempo procesó señales de radar más rápido que procesadores nominalmente mucho más veloces.
En sentido estricto, como a es algo del estilo
high byte << 24, la tabla de e^a termina siendo un mapeo a => e^(a<<24), y los demás bytes se manejan de forma similar.Me da curiosidad cuánto impactan estas mejoras de silu y softmax en la velocidad total de inferencia de los LLM.
Corríjanme si me equivoco, pero como la mayor parte del tiempo se va en multiplicación de matrices, me parece que el efecto de este cambio debería ser pequeño.
Un poco fuera de tema, pero al pasar la vista pensé: “Esto parece una optimización bastante loca. Es código complejo y que ya vio mucha gente”, y cuando vi al contribuidor pensé: “Claro, es jart. Las soluciones locamente buenas siempre son de jart”.
Como muchas cosas de ese lado, este dolor tiene bastante de autoinfligido.
Tengo entendido que también hay bibliotecas de C++ que permiten una sintaxis de SIMD e intrinsics de hardware al estilo C#, aunque tienen la desventaja de que no es fácil buscar directamente los mnemónicos en la documentación del conjunto de instrucciones.
No quiero restarle mérito a la importancia del trabajo hecho aquí; solo digo que podría haber sido más accesible para una audiencia más amplia. Aunque todavía no voy a proponer algo que aquí todos considerarían descabellado: reescribir los backends de inferencia en C#.
adapted from arm limited optimized routinesignifica que, al final, está parado sobre hombros de gigantes.Me recuerda a un profesor que dijo algo famoso como: “esa constante que todos ignoran puede, en ingeniería, comerse la cabeza entera”.
Dicen que reemplaza una lookup table
short[65536], pero me parece una elección un poco torpe desde el inicio.Es como tener una lookup table del tamaño completo de la caché L1; ¿será que por probabilidades más o menos cuadra y funciona sorprendentemente bien?
No importa mucho vaciar la caché L1, y los datos desplazados para meter la LUT casi nunca iban a reutilizarse de todos modos.
Las cargas de machine learning suelen ser cargas en streaming que leen linealmente todo el dataset en cada iteración.
En mi experiencia limitada, se puede hacer bastante cálculo en tiempo real antes de que una lookup table resulte más rápida.
En llama.cpp, esto es para CPU.
Ahora están haciendo upstream de esto al proyecto llama.cpp.
También hay otras mejoras de rendimiento que por ahora solo se consiguen en llamafile; por ejemplo, el trabajo de Kawrakow que hizo mucho más rápidos los K quants.
Quizá sea un poco tangencial, pero ¿alguien sabe cómo se compara algo como ggml con runtimes como tensorflow lite u onnxruntime?
En resumen: para LLM corresponde llama.cpp, y con GGML, que es su dependencia central, también se puede hacer whisper.
Para lo demás, usa ONNX.
TF es como el Apple del mundo del machine learning: si estás completamente atado al ecosistema de Google ML, es excelente, pero fuera de eso está prácticamente muerto. Un porcentaje ridículo de los modelos de HF, alrededor del 94%, es PyTorch.
Una comparación directa de rendimiento de inferencia que valdría la pena sería entre Whisper en ONNX y en GGML, pero alguien ejecutó mi biblioteca de llama.cpp con Whisper y no reportó ninguna diferencia de rendimiento significativa.
En este momento, para inferencia sin batching en dispositivos CUDA, ¿gguf/llama.cpp es la solución de mejor rendimiento, o exllamav2+flashattention sigue por delante?
Hay diferencias más importantes, como la caché KV de 4 bits.
Las LUT también se pueden vectorizar.
https://www.intel.com/content/www/us/en/docs/intrinsics-guid...
Hace tiempo también escribí sobre lo que se puede hacer con LUT: https://darkcephas.blogspot.com/2018/10/validating-utf8-stri...
expdirectamente, dependiendo de la precisión que quieras, bastan unas 10 a 20 FMA.Es difícil que gather o permutation compitan con cálculo puro.
En una línea parecida, también hay un tanh más rápido: https://github.com/microsoft/onnxruntime/pull/20612
Pero ¿cuál es el objetivo? ¿Hacer más rápida esa aproximación de GeLU?
Si se vuelve a
erff(), probablemente sería mucho más rápido.¿Esto también ayuda en el caso de uso de offloading parcial a GPU de gguf?
¿El lado de CPU también se vuelve más rápido?