1 puntos por GN⁺ 2024-05-16 | 1 comentarios | Compartir por WhatsApp
  • 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

 
GN⁺ 2024-05-16
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.

    • Si fue difícil de seguir, la idea parece ser más o menos que e^x = e^(a+b+c+d), donde a/b/c/d son cada byte de x; eso se transforma en e^a * e^b * e^c * e^d, y luego se crea una lookup table de e^a, e^b para cada uno.
      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.

    • Es cierto que la mayoría de las operaciones de punto flotante se usan en multiplicación de matrices, pero softmax consume una cantidad desproporcionada de ancho de banda de memoria, así que normalmente tarda bastante más de lo que uno esperaría mirando solo el conteo de operaciones.
  • 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”.

    • La razón por la que se ve intimidante es sobre todo que la sintaxis de intrinsics de C/C++ es así de por sí.
      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#.
    • Eso de adapted from arm limited optimized routine significa que, al final, está parado sobre hombros de gigantes.
    • Sospecho que esto no es precisamente lo que enseñan en las clases de análisis asintótico.
      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?

    • La razón por la que la lookup table funciona sorprendentemente bien es que la carga de trabajo en sí es extremadamente poco amigable con la caché.
      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.
    • Este artículo sobre por qué probablemente no deberías usar lookup tables https://specbranch.com/posts/lookup-tables/ trata en general cuándo son apropiadas.
      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.

    • Originalmente se desarrolló para llamafile y se incluyó en las dos versiones más recientes: https://github.com/Mozilla-Ocho/llamafile/releases/tag/0.8.2
      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?

    • Mantengo bibliotecas de ONNX y llama.cpp para Flutter en las 6 True Platforms, así que lo conozco bastante bien.
      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.
    • Importa exactamente de qué hardware estamos hablando.
  • 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?

    • En 2x 4090 la diferencia es despreciable.
      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...

    • Es cierto, pero si implementas exp directamente, 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

    • Excelente trabajo.
      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?