Highway est une bibliothèque C++ qui fournit des intrinsèques SIMD/vecteur portables.
Documentation
Auparavant sous licence Apache 2, désormais sous licence double Apache 2 / BSD-3.
Nous sommes passionnés par les logiciels performants. Nous voyons un potentiel majeur inexploité dans les processeurs (serveurs, mobiles, ordinateurs de bureau). Highway s'adresse aux ingénieurs qui souhaitent repousser de manière fiable et économique les limites de ce qui est possible en matière de logiciels.
Les processeurs fournissent des instructions SIMD/vecteur qui appliquent la même opération à plusieurs éléments de données. Cela peut réduire la consommation d'énergie, par exemple, par cinq, car moins d'instructions sont exécutées. Nous constatons également souvent des accélérations 5 à 10x .
Highway rend la programmation SIMD/vecteur pratique et réalisable selon ces principes directeurs :
Fait ce que vous attendez : Highway est une bibliothèque C++ avec des fonctions soigneusement choisies qui correspondent bien aux instructions du processeur sans transformations approfondies du compilateur. Le code résultant est plus prévisible et plus robuste aux modifications de code/mises à jour du compilateur que l'autovectorisation.
Fonctionne sur des plates-formes largement utilisées : Highway prend en charge cinq architectures ; le même code d'application peut cibler différents jeux d'instructions, y compris ceux avec des vecteurs « évolutifs » (taille inconnue au moment de la compilation). Highway ne nécessite que C++11 et prend en charge quatre familles de compilateurs. Si vous souhaitez utiliser Highway sur d'autres plates-formes, veuillez soulever un problème.
Flexible à déployer : les applications utilisant Highway peuvent s'exécuter sur des cloud ou des appareils clients hétérogènes, en choisissant le meilleur jeu d'instructions disponible au moment de l'exécution. Alternativement, les développeurs peuvent choisir de cibler un seul jeu d’instructions sans aucune surcharge d’exécution. Dans les deux cas, le code de l'application est le même, à l'exception de l'échange HWY_STATIC_DISPATCH
avec HWY_DYNAMIC_DISPATCH
plus une ligne de code. Voir aussi l'introduction de @kfjahnke à la répartition.
Adapté à une variété de domaines : Highway fournit un ensemble complet d'opérations, utilisées pour le traitement d'images (virgule flottante), la compression, l'analyse vidéo, l'algèbre linéaire, la cryptographie, le tri et la génération aléatoire. Nous reconnaissons que les nouveaux cas d'utilisation peuvent nécessiter des opérations supplémentaires et sommes heureux de les ajouter là où cela est logique (par exemple, pas de chute de performances sur certaines architectures). Si vous souhaitez discuter, veuillez déposer un problème.
Récompense la conception parallèle aux données : Highway fournit des outils tels que Gather, MaskedLoad et FixedTag pour permettre d'accélérer les structures de données existantes. Cependant, les gains les plus importants sont obtenus en concevant des algorithmes et des structures de données pour des vecteurs évolutifs. Les techniques utiles incluent le traitement par lots, les dispositions de structure de tableau et les allocations alignées/rembourrées.
Nous recommandons ces ressources pour commencer :
Démonstrations en ligne utilisant Compiler Explorer :
Nous observons que Highway est référencé dans les projets open source suivants, trouvés via sourcegraph.com. La plupart sont des référentiels GitHub. Si vous souhaitez ajouter votre projet ou créer un lien vers celui-ci directement, n'hésitez pas à soulever un problème ou à nous contacter via l'e-mail ci-dessous.
Autre
Si vous souhaitez obtenir Highway, en plus de le cloner à partir de ce référentiel GitHub ou de l'utiliser comme sous-module Git, vous pouvez également le trouver dans les gestionnaires de packages ou référentiels suivants :
Voir également la liste sur https://repology.org/project/highway-simd-library/versions .
Highway prend en charge 24 cibles, classées par ordre alphabétique de plate-forme :
EMU128
, SCALAR
;NEON_WITHOUT_AES
, NEON
, NEON_BF16
, SVE
, SVE2
, SVE_256
, SVE2_128
;Z14
, Z15
;PPC8
(v2.07), PPC9
(v3.0), PPC10
(v3.1B, pas encore pris en charge en raison de bugs du compilateur, voir #1207 ; nécessite également QEMU 7.2) ;RVV
(1,0 );WASM
, WASM_EMU256
(une version 2x déroulée de wasm128, activée si HWY_WANT_WASM2
est défini. Cela restera pris en charge jusqu'à ce qu'il soit potentiellement remplacé par une future version de WASM.) ;SSE2
SSSE3
(~Intel Core)SSE4
(~Nehalem, comprend également AES + CLMUL).AVX2
(~Haswell, comprend également BMI2 + F16 + FMA)AVX3
(~Skylake, AVX-512F/BW/CD/DQ/VL)AVX3_DL
(~Icelake, inclut BitAlg + CLMUL + GFNI + VAES + VBMI + VBMI2 + VNNI + VPOPCNT ; nécessite l'adhésion en définissant HWY_WANT_AVX3_DL
sauf en cas de compilation pour une répartition statique),AVX3_ZEN4
(comme AVX3_DL mais optimisé pour AMD Zen4 ; nécessite une inscription en définissant HWY_WANT_AVX3_ZEN4
en cas de compilation pour la répartition statique, mais activé par défaut pour la répartition à l'exécution),AVX3_SPR
(~Sapphire Rapids, inclut AVX-512FP16)Notre politique est que, sauf indication contraire, les cibles resteront prises en charge tant qu'elles pourront être (cross-)compilées avec Clang ou GCC actuellement pris en charge et testées à l'aide de QEMU. Si la cible peut être compilée avec le tronc LLVM et testée à l'aide de notre version de QEMU sans indicateurs supplémentaires, elle est alors éligible pour être incluse dans notre infrastructure de tests continus. Sinon, la cible sera testée manuellement avant les versions avec les versions/configurations sélectionnées de Clang et GCC.
SVE a été initialement testé en utilisant farm_sve (voir remerciements).
Les versions Highway visent à suivre le système semver.org (MAJOR.MINOR.PATCH), en incrémentant MINOR après des ajouts rétrocompatibles et PATCH après des correctifs rétrocompatibles. Nous vous recommandons d'utiliser les versions (plutôt que l'astuce Git) car elles sont testées de manière plus approfondie, voir ci-dessous.
La version actuelle 1.0 signale une attention accrue portée à la rétrocompatibilité. Les applications utilisant des fonctionnalités documentées resteront compatibles avec les futures mises à jour portant le même numéro de version majeure.
Tests d'intégration continue construits avec une version récente de Clang (fonctionnant sur x86 natif, ou QEMU pour RISC-V et Arm) et MSVC 2019 (v19.28, fonctionnant sur x86 natif).
Avant les versions, nous testons également sur x86 avec Clang et GCC, et Armv7/8 via la compilation croisée GCC. Voir le processus de test pour plus de détails.
Le répertoire contrib
contient des utilitaires liés à SIMD : une classe d'images avec des lignes alignées, une bibliothèque mathématique (16 fonctions déjà implémentées, principalement de la trigonométrie) et des fonctions de calcul de produits scalaires et de tri.
Si vous n'avez besoin que du support x86, vous pouvez également utiliser la bibliothèque de classes vectorielles VCL d'Agner Fog. Il comprend de nombreuses fonctions dont une bibliothèque mathématique complète.
Si vous disposez d'un code existant utilisant les intrinsèques x86/NEON, vous pourriez être intéressé par SIMDe, qui émule ces intrinsèques en utilisant les intrinsèques ou l'autovectorisation d'autres plates-formes.
Ce projet utilise CMake pour générer et construire. Dans un système basé sur Debian, vous pouvez l'installer via :
sudo apt install cmake
Les tests unitaires de Highway utilisent googletest. Par défaut, CMake de Highway télécharge cette dépendance au moment de la configuration. Vous pouvez éviter cela en définissant la variable CMake HWY_SYSTEM_GTEST
sur ON et en installant gtest séparément :
sudo apt install libgtest-dev
Alternativement, vous pouvez définir HWY_TEST_STANDALONE=1
et supprimer toutes les occurrences de gtest_main
dans chaque fichier BUILD, puis les tests évitent la dépendance à GUnit.
L'exécution de tests compilés de manière croisée nécessite la prise en charge du système d'exploitation, qui sur Debian est fournie par le paquet qemu-user-binfmt
.
Pour créer Highway en tant que bibliothèque partagée ou statique (en fonction de BUILD_SHARED_LIBS), le workflow CMake standard peut être utilisé :
mkdir -p build && cd build
cmake ..
make -j && make test
Ou vous pouvez exécuter run_tests.sh
( run_tests.bat
sous Windows).
Bazel est également pris en charge pour la construction, mais il n'est pas aussi largement utilisé/testé.
Lors de la construction pour Armv7, une limitation des compilateurs actuels vous oblige à ajouter -DHWY_CMAKE_ARM7:BOOL=ON
à la ligne de commande CMake ; voir #834 et #1032. Nous comprenons que des travaux sont en cours pour supprimer cette limitation.
La construction sur x86 32 bits n'est pas officiellement prise en charge et AVX2/3 y est désactivé par défaut. Notez que johnplatts a construit et exécuté avec succès les tests Highway sur x86 32 bits, y compris AVX2/3, sur GCC 7/8 et Clang 8/11/12. Sur Ubuntu 22.04, Clang 11 et 12, mais pas les versions ultérieures, nécessitent des indicateurs de compilateur supplémentaires -m32 -isystem /usr/i686-linux-gnu/include
. Clang 10 et versions antérieures nécessitent ce qui précède plus -isystem /usr/i686-linux-gnu/include/c++/12/i686-linux-gnu
. Voir #1279.
Highway est maintenant disponible en vcpkg
vcpkg install highway
Le port routier dans vcpkg est tenu à jour par les membres de l'équipe Microsoft et les contributeurs de la communauté. Si la version est obsolète, veuillez créer un problème ou une pull request sur le référentiel vcpkg.
Vous pouvez utiliser le benchmark
dans examples/ comme point de départ.
Une page de référence rapide répertorie brièvement toutes les opérations et leurs paramètres, et instruction_matrix indique le nombre d'instructions par opération.
La FAQ répond aux questions sur la portabilité, la conception des API et où trouver plus d'informations.
Nous vous recommandons d'utiliser des vecteurs SIMD complets autant que possible pour une portabilité maximale des performances. Pour les obtenir, transmettez une balise ScalableTag
(ou de manière équivalente HWY_FULL(float)
) à des fonctions telles que Zero/Set/Load
. Il existe deux alternatives pour les cas d'utilisation nécessitant une limite supérieure sur les voies :
Pour un maximum de N
voies, spécifiez CappedTag
ou l'équivalent HWY_CAPPED(T, N)
. Le nombre réel de voies sera N
arrondi à la puissance de deux la plus proche, par exemple 4 si N
est 5, ou 8 si N
est 8. Ceci est utile pour les structures de données telles qu'une matrice étroite. Une boucle est toujours nécessaire car les vecteurs peuvent en réalité avoir moins de N
voies.
Pour exactement une puissance de deux N
voies, spécifiez FixedTag
. Le plus grand N
pris en charge dépend de la cible, mais il est garanti qu'il est d'au moins 16/sizeof(T)
.
En raison des restrictions ADL, le code utilisateur appelant les opérations routières doit :
namespace hwy { namespace HWY_NAMESPACE {
; ounamespace hn = hwy::HWY_NAMESPACE; hn::Add()
; ouusing hwy::HWY_NAMESPACE::Add;
. De plus, chaque fonction qui appelle des opérations Highway (telles que Load
) doit soit être préfixée par HWY_ATTR
, OU résider entre HWY_BEFORE_NAMESPACE()
et HWY_AFTER_NAMESPACE()
. Les fonctions Lambda nécessitent actuellement HWY_ATTR
avant leur accolade ouvrante.
N'utilisez pas d'initialiseurs de portée d'espace de noms ni static
pour les vecteurs SIMD, car cela peut provoquer SIGILL lors de l'utilisation de la répartition d'exécution et le compilateur choisit un initialiseur compilé pour une cible non prise en charge par le processeur actuel. Au lieu de cela, les constantes initialisées via Set
doivent généralement être des variables locales (const).
Les points d'entrée dans le code utilisant Highway diffèrent légèrement selon qu'ils utilisent la répartition statique ou dynamique. Dans les deux cas, nous recommandons que la fonction de niveau supérieur reçoive un ou plusieurs pointeurs vers des tableaux, plutôt que des types de vecteurs spécifiques à la cible.
Pour une répartition statique, HWY_TARGET
sera la meilleure cible disponible parmi HWY_BASELINE_TARGETS
, c'est-à-dire celles autorisées à être utilisées par le compilateur (voir référence rapide). Les fonctions dans HWY_NAMESPACE
peuvent être appelées en utilisant HWY_STATIC_DISPATCH(func)(args)
dans le même module dans lequel elles sont définies. Vous pouvez appeler la fonction à partir d'autres modules en l'enveloppant dans une fonction régulière et en déclarant la fonction régulière dans un en-tête.
Pour la répartition dynamique, une table de pointeurs de fonction est générée via la macro HWY_EXPORT
qui est utilisée par HWY_DYNAMIC_DISPATCH(func)(args)
pour appeler le meilleur pointeur de fonction pour les cibles prises en charge par le processeur actuel. Un module est automatiquement compilé pour chaque cible dans HWY_TARGETS
(voir référence rapide) si HWY_TARGET_INCLUDE
est défini et foreach_target.h
est inclus. Notez que le premier appel de HWY_DYNAMIC_DISPATCH
, ou chaque appel au pointeur renvoyé par le premier appel de HWY_DYNAMIC_POINTER
, implique une certaine surcharge de détection du processeur. Vous pouvez empêcher cela en appelant ce qui suit avant toute invocation de HWY_DYNAMIC_*
: hwy::GetChosenTarget().Update(hwy::SupportedTargets());
.
Voir également une introduction distincte à la répartition dynamique par @kfjahnke.
Lors de l'utilisation de la répartition dynamique, foreach_target.h
est inclus dans les unités de traduction (fichiers .cc), et non dans les en-têtes. Les en-têtes contenant du code vectoriel partagé entre plusieurs unités de traduction nécessitent une garde d'inclusion spéciale, par exemple celle-ci tirée de examples/skeleton-inl.h
:
#if defined(HIGHWAY_HWY_EXAMPLES_SKELETON_INL_H_) == defined(HWY_TARGET_TOGGLE)
#ifdef HIGHWAY_HWY_EXAMPLES_SKELETON_INL_H_
#undef HIGHWAY_HWY_EXAMPLES_SKELETON_INL_H_
#else
#define HIGHWAY_HWY_EXAMPLES_SKELETON_INL_H_
#endif
#include "hwy/highway.h"
// Your vector code
#endif
Par convention, nous nommons ces en-têtes -inl.h
car leur contenu (souvent des modèles de fonctions) est généralement intégré.
Les applications doivent être compilées avec les optimisations activées. Sans inline, le code SIMD peut ralentir par des facteurs de 10 à 100. Pour clang et GCC, -O2
est généralement suffisant.
Pour MSVC, nous recommandons de compiler avec /Gv
pour permettre aux fonctions non intégrées de transmettre des arguments vectoriels dans les registres. Si vous avez l'intention d'utiliser la cible AVX2 avec des vecteurs demi-largeur (par exemple pour PromoteTo
), il est également important de compiler avec /arch:AVX2
. Cela semble être le seul moyen de générer de manière fiable des instructions SSE codées en VEX sur MSVC. Parfois, MSVC génère des instructions SSE codées en VEX, si elles sont mélangées avec AVX, mais pas toujours, voir DevCom-10618264. Sinon, le mélange d'instructions AVX2 codées en VEX et de SSE non VEX peut entraîner une grave dégradation des performances. Malheureusement, avec l'option /arch:AVX2
, le binaire résultant nécessitera alors AVX2. Notez qu'aucun indicateur de ce type n'est nécessaire pour clang et GCC car ils prennent en charge les attributs spécifiques à la cible, que nous utilisons pour garantir une génération correcte de code VEX pour les cibles AVX2.
Lors de la vectorisation d'une boucle, une question importante est de savoir si et comment gérer un nombre d'itérations (« nombre de trajets », noté count
) qui ne divise pas uniformément la taille du vecteur N = Lanes(d)
. Par exemple, il peut être nécessaire d’éviter d’écrire au-delà de la fin d’un tableau.
Dans cette section, laissez T
désigner le type d'élément et d = ScalableTag
. Supposons que le corps de la boucle soit donné sous forme de fonction template
.
Le « strip-mining » est une technique permettant de vectoriser une boucle en la transformant en boucle externe et en boucle interne, de telle sorte que le nombre d'itérations dans la boucle interne corresponde à la largeur du vecteur. Ensuite, la boucle interne est remplacée par des opérations vectorielles.
Highway propose plusieurs stratégies de vectorisation de boucle :
Assurez-vous que toutes les entrées/sorties sont remplies. Alors la boucle (externe) est simplement
for (size_t i = 0; i < count; i += N) LoopBody(d, i, 0);
Ici, le paramètre de modèle et le deuxième argument de fonction ne sont pas nécessaires.
Il s’agit de l’option privilégiée, à moins que N
ne se compte par milliers et que les opérations vectorielles soient acheminées avec de longues latences. C'était le cas pour les supercalculateurs dans les années 90, mais de nos jours, les ALU sont bon marché et nous voyons la plupart des implémentations diviser les vecteurs en 1, 2 ou 4 parties, donc le traitement de vecteurs entiers coûte peu, même si nous n'avons pas besoin de toutes leurs voies. En effet, cela évite le coût (potentiellement important) de prédication ou de chargements/stockages partiels sur des cibles plus anciennes, et ne duplique pas le code.
Traitez des vecteurs entiers et incluez les éléments précédemment traités dans le dernier vecteur :
for (size_t i = 0; i < count; i += N) LoopBody(d, HWY_MIN(i, count - N), 0);
Il s’agit de la deuxième option privilégiée à condition que count >= N
et LoopBody
soit idempotent. Certains éléments peuvent être traités deux fois, mais un seul chemin de code et une vectorisation complète en valent généralement la peine. Même si count < N
, il est généralement logique de compléter les entrées/sorties jusqu'à N
.
Utilisez les fonctions Transform*
dans hwy/contrib/algo/transform-inl.h. Cela prend en charge la gestion des boucles et des restes et vous définissez simplement une fonction lambda générique (C++14) ou un foncteur qui reçoit le vecteur actuel du tableau d'entrée/sortie, plus éventuellement des vecteurs de jusqu'à deux tableaux d'entrée supplémentaires, et renvoie la valeur à écrire dans le tableau d’entrée/sortie.
Voici un exemple implémentant la fonction BLAS SAXPY ( alpha * x + y
) :
Transform1(d, x, n, y, [](auto d, const auto v, const auto v1) HWY_ATTR {
return MulAdd(Set(d, alpha), v, v1);
});
Traitez des vecteurs entiers comme ci-dessus, suivis d'une boucle scalaire :
size_t i = 0;
for (; i + N <= count; i += N) LoopBody(d, i, 0);
for (; i < count; ++i) LoopBody(CappedTag(), i, 0);
Le paramètre de modèle et les arguments de la deuxième fonction ne sont pas non plus nécessaires.
Cela évite la duplication de code et est raisonnable si count
est important. Si count
est petit, la deuxième boucle peut être plus lente que l'option suivante.
Traitez des vecteurs entiers comme ci-dessus, suivis d'un seul appel à un LoopBody
modifié avec masquage :
size_t i = 0;
for (; i + N <= count; i += N) {
LoopBody(d, i, 0);
}
if (i < count) {
LoopBody(d, i, count - i);
}
Désormais, le paramètre de modèle et le troisième argument de fonction peuvent être utilisés dans LoopBody
pour « mélanger » de manière non atomique les premières lignes num_remaining
de v
avec le contenu précédent de la mémoire aux emplacements suivants : BlendedStore(v, FirstN(d, num_remaining), d, pointer);
. De même, MaskedLoad(FirstN(d, num_remaining), d, pointer)
charge les premiers éléments num_remaining
et renvoie zéro dans les autres voies.
C'est une bonne valeur par défaut lorsqu'il est impossible de garantir que les vecteurs sont remplis, mais cela n'est sûr que #if !HWY_MEM_OPS_MIGHT_FAULT
! Contrairement à la boucle scalaire, une seule itération finale est nécessaire. L'augmentation de la taille du code à partir de deux corps de boucle devrait être intéressante car elle évite le coût du masquage dans toutes les itérations sauf la dernière.
Nous avons utilisé farm-sve de Berenger Bramas ; cela s'est avéré utile pour vérifier le port SVE sur une machine de développement x86.
Il ne s'agit pas d'un produit Google officiellement pris en charge. Contact : [email protected]