- 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
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
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 similaireJe 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
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 »
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Ç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épartC’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 ?
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
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
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 ?
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
À 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 ?
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...
exp, il suffit d’environ 10 à 20 FMA selon la précision voulueIl 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
Mais quel est l’objectif ? Rendre cette approximation de GeLU plus rapide ?
Revenir à
erff()serait probablement bien plus rapideEst-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 ?