1 points par GN⁺ 2024-05-16 | 1 commentaires | Partager sur WhatsApp
  • La PR llama.cpp #7154 réécrit le calcul de SiLU et SoftMax de GGML sur CPU à partir de l’implémentation vectorisée de expf() de llamafile, et a été fusionnée dans master le 17 mai 2024
  • L’ancienne implémentation de GGML utilisait une table de correspondance short[65536] pour gagner en vitesse, mais la nouvelle vise un calcul plus précis tout en maintenant une erreur d’arrondi maximale de 2 ULP sur aarch64 et SSE2+
  • Dans les tests de performances CPU de SOFT_MAX, SSE2+FMA est 1,5 fois plus rapide, AVX2+FMA 1,9 fois, et AVX512 2,1 fois ; sur AMD Ryzen 9 5950X et M2 Ultra, des résultats d’environ 1,5 fois plus rapides que master ont aussi été confirmés
  • Les changements incluent l’ajout de ggml_v_expf() et ggml_v_silu(), l’extraction de code dupliqué vers ggml_vec_soft_max_f32(), la suppression des fonctions liées à GGML_SILU_FP16, ainsi qu’un ajustement du chemin SiLU conditionnel pour SSE2 ou ARM NEON
  • Après la fusion, des résultats non déterministes ont été reproduits lors de l’exécution du serveur avec >1 slots ; la cause a ensuite été réduite à -ffinite-math-only, menant à une contrainte de build imposant -fno-finite-math-only

Objectif des changements de la PR et état de fusion

  • La PR #7154, intitulée ggml : rewrite silu and softmax for cpu, réécrit le calcul de SiLU et SoftMax dans le chemin CPU de GGML de llama.cpp
  • Le changement a commencé comme une remontée upstream de la fonction vectorisée expf() de llamafile
  • La PR a été fusionnée dans ggml-org:master le 17 mai 2024, avec le commit de fusion 934266c
  • L’auteur indique que cette nouvelle approche permet de calculer SoftMax et SiLU avec plus de précision que l’ancienne table de correspondance short[65536] utilisée par GGML pour aller plus vite

Précision et périmètre de support

  • Le nouveau chemin basé sur expf() prend en charge aarch64 et SSE2+, avec une erreur d’arrondi maximale annoncée de 2 ULP
  • Dans l’explication initiale, des implémentations AVX2 et AVX512 avaient aussi été écrites, mais n’avaient pas été incluses car leur gain n’était pas jugé suffisant face à la complexité du code par rapport à SSE2+FMA
  • Après examen des résultats de benchmark, le code AVX2 et AVX512 a finalement été inclus
  • Une sortie de test séparée indique 4294967296 numbers tested successfully, avec une comparaison des résultats entre exp et l’implémentation llamafile sur plusieurs valeurs d’entrée

Étendue des modifications du code

  • Le reviewer a résumé les principaux changements comme suit
    • suppression du #define commenté
    • extraction de 5 lignes dupliquées vers ggml_vec_soft_max_f32()
    • suppression de plusieurs fonctions liées à GGML_SILU_FP16
    • ajout de ggml_v_expf()
    • ajout de ggml_v_silu()
    • ajustement des directives de préprocesseur afin que ggml_vec_silu_f32() utilise des fonctions différentes selon le drapeau SSE2 ou __ARM_NEON
  • Le nombre de fichiers modifiés est affiché à 1 dans les métadonnées GitHub
  • La PR porte les labels refactoring et Review Complexity : High, ce dernier indiquant qu’une connaissance approfondie des LLM ou des GPU peut être nécessaire

Benchmarks et résultats de performance

  • ggerganov a confirmé que SOFT_MAX était environ 1,5 fois plus rapide que master sur AMD Ryzen 9 5950X et M2 Ultra
  • La commande de test utilisée est la suivante
make -j tests && ./tests/test-backend-ops -o SOFT_MAX -b CPU perf
  • L’auteur a ensuite indiqué que, avec cette même commande, le gain montait à
    • SSE2+FMA : 1,5x
    • AVX2+FMA : 1,9x
    • AVX512 : 2,1x
  • Dans un script de développement séparé, les valeurs suivantes ont été données
    • 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
  • Le benchmark GitHub Actions de llama.cpp server a enregistré 543 iterations sur Standard_NC4as_T4_v3 avec la configuration phi-2 q4_0
    • utilisateurs simultanés : 8
    • duration : 10 minutes
    • moyenne des requêtes HTTP : 8626.19ms
    • p95 : 21696.44ms
    • moyenne du traitement du prompt : 94.59 tk/s
    • moyenne de génération de tokens : 33.43 tk/s

Discussion autour de l’optimisation AVX512

  • chriselrod a proposé l’utilisation de vscalefps en AVX512
    • vscalefps calcule zmm0 = zmm1 * 2^{zmm2}
    • cela permettrait de gérer correctement overflow et underflow, et donc de supprimer des checks et des blends
  • Un exemple d’implémentation Julia ainsi qu’une boucle assembleur ont été partagés ; si les tests sont corrects, l’erreur maximale à x=47.483456f était inférieure à 1 ULP
  • L’approche vscalefps n’utilise pas de lookup table ; pour l’implémentation Float64/double, il est expliqué qu’une lookup table de 16 éléments est utilisée via vpermi2pd
  • Un lien vers une implémentation C++ a ensuite aussi été partagé
    • ExpAVX512
    • les sources se trouvent dans include/ExpAVX512.hpp
    • le README inclut des benchmarks, mais sans comparaison avec d’autres implémentations

Problème de non-déterminisme après fusion

  • Après la fusion, un cas reproductible de résultats non déterministes a été signalé sur le serveur lors de l’utilisation de >1 slots
  • La procédure minimale de reproduction est la suivante
make clean && make server
./server -m models/opt/llama_2-7b-q4_0.gguf --parallel 2 --threads 1
  • La requête exécutée depuis un autre shell est la suivante
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
  • Les probabilités de token du dernier token alternaient entre deux valeurs à chaque appel curl, et avec 4 slots, elles tournaient entre quatre valeurs possibles

-ffinite-math-only et contraintes de build

  • Des commits liés par la suite font référence au fait que -ffinite-math-only a été identifié comme cause du problème
  • Il est noté que le problème venait vraisemblablement du fait que SiLU renvoyait NaN ou d’autres valeurs erronées au lieu de flusher les petites valeurs à 0
  • Le correctif vérifie que -fno-finite-math-only est bien défini et impose un contrôle garantissant que le mode de compilation n’utilise pas finite math
  • Le message d’erreur indique que certaines routines de GGML nécessitent une arithmétique mathématique non finie, et demande de passer -fno-finite-math-only au compilateur
  • Des utilisateurs ont ensuite partagé que -Ofast ou -ffast-math pouvaient inclure -ffinite-math-only et casser le build
    • jusqu’à GCC 13.2, -Ofast pouvait être utilisé, mais à partir de GCC 14 les résultats devenaient erronés selon un signalement
    • dans certains tests, -fmath-errno était aussi nécessaire en plus de -fno-finite-math-only
    • plusieurs commits de suivi dans d’autres dépôts mentionnent la suppression de -ffast-math ou l’ajout explicite de -fno-finite-math-only pour corriger l’erreur de compilation de ggml

1 commentaires

 
GN⁺ 2024-05-16
Commentaires sur Hacker News
  • Il y a une vingtaine d’années, quand je programmais pour le processeur de signal radar Hughes, je devais calculer e^x pour 0 < x < 1
    Ce processeur disposait d’une multiplication, donc j’ai créé 4 tables de e^x de 256 valeurs possibles chacune pour chacun des 4 blocs de 8 bits d’un mot de 32 bits, puis je les multipliais pour obtenir la valeur finale
    C’était environ 5 fois plus rapide que la meilleure routine e^x précédente et, même si elle est aujourd’hui obsolète, c’était une machine amusante qui, pendant un temps, traitait les signaux radar plus vite que des processeurs nominalement beaucoup plus rapides

    • Si c’était difficile à suivre, l’idée ressemble en gros à e^x = e^(a+b+c+d), où a/b/c/d sont les différents octets de x, que l’on transforme en e^a * e^b * e^c * e^d, puis on crée une lookup table e^a, e^b pour chacun
      Strictement parlant, comme a est sous une forme du type high byte << 24, la table e^a fait correspondre a à e^(a<<24), et les autres octets sont traités de façon similaire
  • Je me demande dans quelle mesure ces améliorations de silu et softmax affectent la vitesse globale de l’inférence LLM
    Corrigez-moi si je me trompe, mais comme la majeure partie du temps est consacrée aux multiplications de matrices, j’imagine que l’effet de ce changement est faible

    • Il est vrai que la plupart des opérations en virgule flottante servent aux multiplications de matrices, mais softmax utilise une quantité disproportionnée de bande passante mémoire, donc cela prend généralement beaucoup plus de temps que ce qu’on anticiperait en regardant seulement le nombre d’opérations
  • C’est un peu hors sujet, mais en parcourant le code je me suis dit : « c’est une optimisation assez folle. C’est complexe, et le code a déjà été vu par beaucoup de monde ». Puis j’ai vu le contributeur et je me suis dit : « évidemment, c’est jart. Les solutions follement bonnes viennent toujours de jart »

    • Si ça a surtout l’air effrayant, c’est parce que la syntaxe des intrinsics C/C++ est comme ça par nature
      Comme souvent dans ce domaine, une bonne part de cette douleur est assez auto-infligée
      Je crois qu’il existe aussi des bibliothèques C++ qui permettent une syntaxe de SIMD et d’intrinsics matériels façon C#, mais elles ont l’inconvénient de rendre plus difficile la recherche directe des mnémoniques dans la documentation du jeu d’instructions
      Je ne veux pas minimiser l’importance du travail réalisé ici ; je veux dire qu’il aurait pu être plus accessible à un public plus large. Cela dit, je ne vais pas encore faire la proposition que tout le monde ici trouverait absurde : réécrire les backends d’inférence en C#
    • adapted from arm limited optimized routine, donc au final, c’est bien debout sur les épaules de géants
    • J’ai l’impression que ce n’est pas le genre de choses qu’on enseigne dans les cours d’analyse asymptotique
      Ça me rappelle un professeur qui disait, de façon célèbre, que « cette constante que tout le monde ignore peut, en ingénierie, vous dévorer la tête entière »
  • Remplacer une lookup table short[65536], ça me semble être un choix un peu pataud dès le départ
    C’est en gros une lookup table de la taille entière du cache L1 ; est-ce que ça marche étonnamment bien parce que, statistiquement, ça tombe à peu près juste ?

    • La raison pour laquelle les lookup tables fonctionnent étonnamment bien, c’est que la charge de travail elle-même est extrêmement défavorable au cache
      Vider le cache L1 n’a pas grande importance, et les données évincées pour faire de la place à la LUT n’auraient de toute façon presque jamais été réutilisées
      Les charges de machine learning sont généralement des chargements en streaming qui lisent linéairement tout le jeu de données à chaque itération
    • Cet article sur les raisons pour lesquelles il ne faut probablement pas utiliser de lookup tables https://specbranch.com/posts/lookup-tables/ traite de leur pertinence en général
      D’après mon expérience limitée, on peut faire pas mal de calcul en temps réel avant que cela devienne plus lent qu’une lookup
  • Dans llama.cpp, il s’agit de la version CPU

    • À l’origine, cela a été développé pour llamafile et inclus dans les deux dernières releases : https://github.com/Mozilla-Ocho/llamafile/releases/tag/0.8.2
      Maintenant, c’est en cours d’upstream vers le projet llama.cpp
      Il existe aussi d’autres améliorations de performances qu’on ne peut actuellement obtenir que dans llamafile, par exemple le travail de Kawrakow qui rend les K quants beaucoup plus rapides
  • C’est peut-être un peu hors sujet, mais quelqu’un sait-il comment quelque chose comme ggml se compare à des runtimes comme tensorflow lite ou onnxruntime ?

    • Je maintiens ONNX et la bibliothèque Flutter llama.cpp sur les 6 True Platforms, donc je connais assez bien le sujet
      En bref, pour les LLM, llama.cpp est le bon choix, et avec GGML comme dépendance centrale, whisper est aussi possible
      Pour le reste, utilisez ONNX
      TF est un peu l’Apple du monde du machine learning : si vous êtes complètement enfermé dans l’écosystème Google ML, c’est excellent, mais en dehors de ça, c’est pratiquement mort. Une proportion absurde des modèles HF, environ 94 %, est en PyTorch
      Une comparaison directe des performances d’inférence qui vaudrait la peine serait Whisper sur ONNX face à GGML ; quelqu’un a fait tourner ma bibliothèque llama.cpp avec Whisper et n’a pas signalé de différence de performances significative
    • Il faut préciser de quel matériel on parle exactement
  • À l’heure actuelle, pour l’inférence non batchée sur des appareils CUDA, gguf/llama.cpp est-il la solution la plus performante, ou exllamav2+flashattention garde-t-il l’avantage ?

    • Sur 2x 4090, la différence est négligeable
      Il y a des différences plus importantes, comme le cache KV en 4 bits
  • Les LUT peuvent aussi être vectorisées
    https://www.intel.com/content/www/us/en/docs/intrinsics-guid...
    J’ai aussi écrit autrefois sur ce qu’il est possible de faire avec des LUT https://darkcephas.blogspot.com/2018/10/validating-utf8-stri...

    • C’est vrai, mais même en implémentant directement exp, il suffit d’environ 10 à 20 FMA selon la précision voulue
      Il est difficile pour un gather ou une permutation de rivaliser avec du calcul pur
  • Dans le même esprit, il existe aussi un tanh plus rapide https://github.com/microsoft/onnxruntime/pull/20612

    • Beau travail
      Mais quel est l’objectif ? Rendre cette approximation de GeLU plus rapide ?
      Revenir à erff() serait probablement bien plus rapide
  • Est-ce que cela aide aussi pour les cas d’usage avec offloading partiel GPU de gguf ?
    Le côté CPU devient-il aussi plus rapide ?